diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 70b12f13..45095881 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -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, diff --git a/app/game-engine/test/nationPersonnelManagement.test.ts b/app/game-engine/test/nationPersonnelManagement.test.ts index aa42b49c..11391432 100644 --- a/app/game-engine/test/nationPersonnelManagement.test.ts +++ b/app/game-engine/test/nationPersonnelManagement.test.ts @@ -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, diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 1c626da9..8d098d47 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -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'); diff --git a/app/game-frontend/src/views/NationPersonnelView.vue b/app/game-frontend/src/views/NationPersonnelView.vue index 74d405c8..7a3af80d 100644 --- a/app/game-frontend/src/views/NationPersonnelView.vue +++ b/app/game-frontend/src/views/NationPersonnelView.vue @@ -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(', ');