diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 15ce9ce8..3958b1da 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -135,7 +135,16 @@ describe('buildTurnCommandTable', () => { 'che_정착장려', 'che_주민선정', ], - 군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'], + 군사: [ + 'che_징병', + 'che_모병', + 'che_훈련', + 'che_사기진작', + 'che_출병', + 'che_집합', + 'che_소집해제', + 'che_첩보', + ], 인사: ['che_이동', 'che_강행', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'], 계략: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'], 국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'], @@ -221,6 +230,37 @@ describe('buildTurnCommandTable', () => { }); }); + it('exposes the user-only spy command with a city target when the actor can pay the Ref cost', async () => { + const general = { ...buildGeneral(), gold: 300, rice: 300 } as GeneralRow; + const table = await buildTurnCommandTable({ + worldState: buildWorldState(), + general, + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + + const spy = table.general + .find(({ category }) => category === '군사') + ?.values.find(({ key }) => key === 'che_첩보'); + + expect(spy).toMatchObject({ + name: '첩보', + reqArg: true, + possible: true, + status: 'available', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }); + }); + it('uses min-condition constraints for availability', async () => { const table = await buildTurnCommandTable({ worldState: buildWorldState(), diff --git a/app/game-engine/test/nationTurnCompatibility.test.ts b/app/game-engine/test/nationTurnCompatibility.test.ts index 9cdb1163..e085f38e 100644 --- a/app/game-engine/test/nationTurnCompatibility.test.ts +++ b/app/game-engine/test/nationTurnCompatibility.test.ts @@ -224,4 +224,31 @@ describe('레거시 사령부 턴 실행 호환성', () => { }, ]); }); + + it('첩보 도시는 실행 월부터 세 달 보이고 각 월 시작에 감소한 뒤 만료된다', async () => { + const nation = { + id: 1, + meta: { + rate: 20, + spy: { 2: 3 }, + }, + }; + const handler = createNationTurnMonthlyHandler({ + getWorld: () => + ({ + listNations: () => [nation], + updateNation: (_id: number, patch: { meta?: typeof nation.meta }) => { + if (patch.meta) nation.meta = patch.meta; + }, + }) as never, + }); + + expect(nation.meta.spy).toEqual({ 2: 3 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({ 2: 2 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({ 2: 1 }); + await handler.beforeMonthChanged?.({} as never); + expect(nation.meta.spy).toEqual({}); + }); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 22248b40..c1d8afa2 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -416,6 +416,22 @@ const commandTable = { }, ], }, + { + key: 'che_첩보', + name: '첩보', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }, ], }, ], @@ -921,6 +937,48 @@ test('renders and accepts every Ref strategy command at mobile width', async ({ await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); }); +test('shows and reserves the Ref spy command for a user on desktop and mobile', async ({ page }) => { + const requests = await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/'); + const editor = page.locator('[data-command-scope="general"]'); + await editor.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + + let picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '군사', exact: true }).click(); + const spy = picker.getByRole('button', { name: '첩보', exact: true }); + await expect(spy).toBeVisible(); + await spy.hover(); + await spy.focus(); + await expect(spy).toBeFocused(); + await spy.click(); + const form = picker.getByTestId('command-argument-form'); + await expect(form.getByTestId('command-argument-guidance')).toContainText( + '선택한 도시에 첩보를 실행합니다.' + ); + await expect(form.getByTestId('command-argument-guidance')).toContainText( + '인접 도시에서는 더 많은 정보를 얻습니다.' + ); + await form.locator('select').selectOption('2'); + await picker.screenshot({ path: test.info().outputPath('spy-command-desktop-1200.png') }); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect(editor.locator('.action-column > div').first()).toHaveText('【허창】에 첩보 실행'); + expect(JSON.stringify(requests)).toContain('"action":"che_첩보","args":{"destCityId":2}'); + + await page.setViewportSize({ width: 500, height: 900 }); + await editor.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); + picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '군사', exact: true }).click(); + await expect(picker.getByRole('button', { name: '첩보', exact: true })).toBeVisible(); + const geometry = await picker.evaluate((element) => ({ + width: element.getBoundingClientRect().width, + horizontalOverflow: element.scrollWidth - element.clientWidth, + })); + expect(geometry.width).toBeLessThanOrEqual(500); + expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0); + await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') }); +}); + test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page, }) => { diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index 29a6a649..6553d1f8 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -33,6 +33,7 @@ "che_징병", "che_모병", "che_소집해제", + "che_첩보", "che_군량매매", "che_물자조달", "che_증여",