fix: 추방 보호 대상을 서버에서 차단한다

인증된 실행자와 현재 world의 대상을 기준으로 본인, 군주, 수뇌, 외교권자를 mutation 전에 거부한다. 인사부 후보와 엔진 무변경 회귀, 실제 Chromium 후보 검증을 함께 추가한다.
This commit is contained in:
2026-08-24 08:33:57 +00:00
parent 69e34e3c95
commit c6459a64dd
4 changed files with 56 additions and 3 deletions
@@ -1997,7 +1997,7 @@ async function handleKick(
}
const target = world.getGeneralById(command.destGeneralId);
if (!target || target.id === general.id || target.nationId !== general.nationId) {
if (!target || target.nationId !== general.nationId) {
return {
type: 'kick',
ok: false,
@@ -2005,7 +2005,18 @@ async function handleKick(
reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.',
};
}
if (resolveMaxSecretPermission(target) === 4 && resolvePermissionKind(target) === 'ambassador') {
if (target.id === general.id) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '본인은 추방할 수 없습니다.' };
}
// Ref 화면은 군주와 본인을 후보에서 제외하지만 서버는 조작 요청을 막지 못했다.
// 국가 소유권을 깨뜨리는 대상은 UI와 무관하게 durable command 경계에서 거부한다.
if (target.id === nation.chiefGeneralId || target.officerLevel === 12) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '군주는 추방할 수 없습니다.' };
}
if (target.officerLevel >= 5) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '수뇌는 추방할 수 없습니다.' };
}
if (resolvePermissionKind(target) === 'ambassador') {
return {
type: 'kick',
ok: false,
@@ -312,6 +312,39 @@ describe('nation personnel world commands', () => {
expect(fixture.world.peekDirtyState().logs).toHaveLength(2);
});
it('rejects self, ruler, head officer, and ambassador targets without partial mutation', async () => {
const cases = [
{ label: 'self', targetId: 2, reason: '본인은 추방할 수 없습니다.' },
{ label: 'ruler', targetId: 1, reason: '군주는 추방할 수 없습니다.' },
{ label: 'head officer', targetId: 3, reason: '수뇌는 추방할 수 없습니다.' },
{ label: 'ambassador', targetId: 4, reason: '외교권자는 추방할 수 없습니다.' },
] as const;
for (const testCase of cases) {
const fixture = buildWorld({
generals: [
buildGeneral(1, { officerLevel: 12 }),
buildGeneral(2, { officerLevel: 5 }),
buildGeneral(3, { officerLevel: 7 }),
buildGeneral(4, {
meta: { killturn: 12, belong: 5, permission: 'ambassador' },
penalty: { noAmbassador: true },
}),
buildGeneral(5),
],
});
const originalTarget = fixture.world.getGeneralById(testCase.targetId);
await expect(
fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: testCase.targetId })
).resolves.toMatchObject({ ok: false, reason: testCase.reason });
expect(fixture.world.getGeneralById(testCase.targetId), testCase.label).toEqual(originalTarget);
expect(fixture.world.getGeneralById(2)?.meta.killturn, testCase.label).toBe(12);
expect(fixture.world.peekDirtyState().logs, testCase.label).toEqual([]);
expect(fixture.world.peekDirtyState().nations, testCase.label).toEqual([]);
}
});
it('preserves the legacy kick year boundaries and deterministic NPC public message', async () => {
const early = buildWorld({
currentYear: 181,
@@ -412,6 +412,13 @@ test('personnel reflows row-level appointments at 500px and 390px without gradie
expect(rowGeometry.gradientCount).toBe(0);
await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0);
await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible();
await expect(page.getByRole('combobox', { name: '추방 대상 장수' }).locator('option')).toHaveText([
'장수 선택',
'하후돈 (70/70/70)',
'곽가 (70/70/70)',
'정욱 (70/70/70)',
'장료 (70/70/70)',
]);
await page.getByRole('button', { name: '허창 태수 변경하기', exact: true }).click();
const picker = page.getByTestId('personnel-selection-dialog');
@@ -99,7 +99,9 @@ const cityCandidates = (level: OfficerLevel): GeneralEntry[] => {
return candidates;
};
const kickCandidates = computed(() =>
(data.value?.generals ?? []).filter((general) => general.id !== data.value?.me.id)
(data.value?.generals ?? []).filter(
(general) => general.id !== data.value?.me.id && general.officerLevel < 5 && general.permission !== 'ambassador'
)
);
const awardText = (entries: PersonnelResponse['awards']['tigers']): string =>
entries.map((entry) => `${entry.name}${entry.value.toLocaleString('ko-KR')}`).join(', ');