diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 9c1ca284..3fb58fdb 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -650,6 +650,40 @@ const evaluateDefinition = ( return evaluateAvailability(constraints, ctx, view, reqArg); }; +const readNextAvailableTurn = (meta: Readonly>, actionName: string): number | null => { + const raw = meta[`next_execute_${actionName}`]; + if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw); + if (typeof raw === 'string') { + const parsed = Number(raw); + return Number.isFinite(parsed) ? Math.floor(parsed) : null; + } + return null; +}; + +const evaluateCooldown = ( + definition: GeneralActionDefinition, + scope: 'general' | 'nation', + ctx: ConstraintContext, + view: StateView, + currentYearMonth: number +): AvailabilityCore | null => { + const owner = + scope === 'general' + ? (view.get({ kind: 'general', id: ctx.actorId }) as General | null) + : ctx.nationId === undefined + ? null + : (view.get({ kind: 'nation', id: ctx.nationId }) as Nation | null); + if (!owner) return null; + + const nextAvailableTurn = readNextAvailableTurn(owner.meta, definition.name); + if (nextAvailableTurn === null || currentYearMonth >= nextAvailableTurn) return null; + return { + possible: false, + status: 'blocked', + reason: `${nextAvailableTurn - currentYearMonth}턴 더 기다려야 합니다`, + }; +}; + const pickAvailability = (lhs: AvailabilityCore, rhs: AvailabilityCore): AvailabilityCore => AVAILABILITY_PRIORITY[lhs.status] >= AVAILABILITY_PRIORITY[rhs.status] ? lhs : rhs; @@ -788,14 +822,20 @@ const buildGroups = ( ctx: ConstraintContext, view: StateView, includeTurnDuration = false, - env: CommandEnv + env: CommandEnv, + scope: 'general' | 'nation', + currentYearMonth: number ): TurnCommandGroup[] => { const groups = new Map(); for (const entry of entries) { - const availability = entry.evaluate + const baseAvailability = entry.evaluate ? entry.evaluate(ctx, view) : evaluateDefinition(entry.definition, ctx, view, entry.reqArg, entry.availabilityArgs); + const availability = + baseAvailability.status === 'blocked' || baseAvailability.status === 'unknown' + ? baseAvailability + : (evaluateCooldown(entry.definition, scope, ctx, view, currentYearMonth) ?? baseAvailability); const turnDurationText = includeTurnDuration ? getTurnDurationText(entry.definition) : undefined; const costText = getCommandCostText(entry, ctx, view, env); const value: TurnCommandAvailability = { @@ -900,6 +940,7 @@ export const buildTurnCommandTable = async (options: { currentYear: options.worldState.currentYear, currentMonth: options.worldState.currentMonth, }); + const currentYearMonth = options.worldState.currentYear * 12 + options.worldState.currentMonth - 1; return { general: buildGroups( @@ -907,14 +948,18 @@ export const buildTurnCommandTable = async (options: { ctx, view, false, - env + env, + 'general', + currentYearMonth ), nation: buildGroups( projectCommandGroups(nationEntries, nationGroups ?? REF_NATION_COMMAND_GROUPS), ctx, view, true, - env + env, + 'nation', + currentYearMonth ), inputOptions: options.inputOptions ?? { cities: [], diff --git a/app/game-api/test/commandTable.test.ts b/app/game-api/test/commandTable.test.ts index 45d3fe25..284c5098 100644 --- a/app/game-api/test/commandTable.test.ts +++ b/app/game-api/test/commandTable.test.ts @@ -102,6 +102,9 @@ const buildNation = (): NationRow => }) as unknown as NationRow; describe('buildTurnCommandTable', () => { + const findCommand = (table: Awaited>, key: string) => + [...table.general, ...table.nation].flatMap(({ values }) => values).find((command) => command.key === key); + it('projects the general and chief reserved-turn categories and command order from Ref', async () => { const table = await buildTurnCommandTable({ worldState: buildWorldState(), @@ -286,6 +289,80 @@ describe('buildTurnCommandTable', () => { }); }); + it('blocks both speciality resets while their cooldown remains and opens them at the boundary month', async () => { + const cooldownGeneral = { + ...buildGeneral(), + specialCode: 'che_상재', + special2Code: 'che_신산', + meta: { + killturn: 24, + 'next_execute_내정 특기 초기화': 36, + 'next_execute_전투 특기 초기화': '37', + }, + } as GeneralRow; + const table = await buildTurnCommandTable({ + worldState: buildWorldState(), + general: cooldownGeneral, + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + + expect(findCommand(table, 'che_내정특기초기화')).toMatchObject({ + possible: true, + status: 'available', + }); + expect(findCommand(table, 'che_전투특기초기화')).toMatchObject({ + possible: false, + status: 'blocked', + reason: '1턴 더 기다려야 합니다', + }); + }); + + it('keeps the missing-speciality reason ahead of a remaining reset cooldown', async () => { + const table = await buildTurnCommandTable({ + worldState: buildWorldState(), + general: { + ...buildGeneral(), + meta: { + killturn: 24, + 'next_execute_내정 특기 초기화': 37, + 'next_execute_전투 특기 초기화': 37, + }, + } as GeneralRow, + city: buildCity(), + nation: buildNation(), + nationGenerals: null, + }); + + for (const key of ['che_내정특기초기화', 'che_전투특기초기화']) { + expect(findCommand(table, key)).toMatchObject({ + possible: false, + status: 'blocked', + reason: '특기가 없습니다.', + }); + } + }); + + it('projects a multi-turn nation command cooldown before target input', async () => { + const table = await buildTurnCommandTable({ + worldState: buildWorldState(), + general: buildGeneral(), + city: buildCity(), + nation: { + ...buildNation(), + meta: { next_execute_피장파장: 37 }, + } as NationRow, + nationGenerals: null, + }); + + expect(findCommand(table, 'che_피장파장')).toMatchObject({ + possible: false, + status: 'blocked', + reason: '1턴 더 기다려야 합니다', + }); + }); + it('projects costs from the current world develcost used by command execution', async () => { const worldState = buildWorldState(); (worldState as unknown as { meta: Record }).meta.develcost = 120; diff --git a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts index 958023ba..dbf2018a 100644 --- a/app/game-engine/test/generalTurnLegacyCompatibility.test.ts +++ b/app/game-engine/test/generalTurnLegacyCompatibility.test.ts @@ -429,6 +429,38 @@ describe('legacy general-turn execution contract', () => { expect(updated.meta.prev_types_special2).toEqual(['che_격노']); }); + it('rejects a speciality reset cooldown before accumulating its first preparation turn', async () => { + const general = makeGeneral({ + role: { + personality: null, + specialDomestic: null, + specialWar: 'che_격노', + items: { horse: null, weapon: null, book: null, item: null }, + }, + meta: { killturn: 24, 'next_execute_전투 특기 초기화': 2460 }, + }); + const harness = await createTurnTestHarness({ + snapshot: makeSnapshot(general), + state: makeState(), + schedule, + map, + collectLogs: true, + }); + harness.reservedTurnStore.getGeneralTurns(1)[0] = { action: 'che_전투특기초기화', args: {} }; + + await harness.runOneTick(); + + const updated = harness.world.getGeneralById(1)!; + expect(updated.role.specialWar).toBe('che_격노'); + expect(updated.lastTurn).not.toEqual({ command: '전투 특기 초기화', term: 1 }); + expect(harness.getCollectedLogs()).toContainEqual( + expect.objectContaining({ text: expect.stringContaining('60턴 더 기다려야 합니다') }) + ); + expect(harness.getCollectedLogs()).not.toContainEqual( + expect.objectContaining({ text: expect.stringContaining('새로운 적성을 찾는 중') }) + ); + }); + it('preserves the legacy battle-readiness term reset instead of making its reward reachable', async () => { const general = makeGeneral({ crew: 1_000, train: 40, atmos: 40 }); const harness = await createTurnTestHarness({ diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 035f791d..64b9af37 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -1207,7 +1207,7 @@ test('defaults founding to a Ref-selectable nation trait and opens colored optio await expect.poll(() => JSON.stringify(requests)).toContain('"colorType":15'); }); -test('reserves force move, retirement, and resignation from the user command picker', async ({ page }) => { +test('shows speciality reset cooldowns and reserves special user commands from the picker', async ({ page }) => { const specialCommandTable = { general: [ { @@ -1222,6 +1222,24 @@ test('reserves force move, retirement, and resignation from the user command pic reason: '나이가 60세 이상이어야 합니다.', inputFields: [], }, + { + key: 'che_내정특기초기화', + name: '내정 특기 초기화', + reqArg: false, + possible: false, + status: 'blocked', + reason: '12턴 더 기다려야 합니다', + inputFields: [], + }, + { + key: 'che_전투특기초기화', + name: '전투 특기 초기화', + reqArg: false, + possible: false, + status: 'blocked', + reason: '24턴 더 기다려야 합니다', + inputFields: [], + }, ], }, { @@ -1273,6 +1291,12 @@ test('reserves force move, retirement, and resignation from the user command pic const retirement = picker.getByRole('button', { name: '은퇴', exact: true }); await expect(retirement).toHaveClass(/blocked/); await expect(retirement).toHaveAttribute('title', '나이가 60세 이상이어야 합니다.'); + const domesticReset = picker.getByRole('button', { name: '내정 특기 초기화', exact: true }); + const warReset = picker.getByRole('button', { name: '전투 특기 초기화', exact: true }); + await expect(domesticReset).toHaveClass(/blocked/); + await expect(domesticReset).toHaveAttribute('title', '12턴 더 기다려야 합니다'); + await expect(warReset).toHaveClass(/blocked/); + await expect(warReset).toHaveAttribute('title', '24턴 더 기다려야 합니다'); await retirement.hover(); await retirement.focus(); await expect(retirement).toBeFocused(); @@ -1315,6 +1339,8 @@ test('reserves force move, retirement, and resignation from the user command pic expect(mobileGeometry.categoryColumns.split(' ')).toHaveLength(3); await picker.getByRole('button', { name: '개인', exact: true }).click(); await expect(picker.getByRole('button', { name: '은퇴', exact: true })).toBeVisible(); + await expect(picker.getByRole('button', { name: '내정 특기 초기화', exact: true })).toHaveClass(/blocked/); + await expect(picker.getByRole('button', { name: '전투 특기 초기화', exact: true })).toHaveClass(/blocked/); await picker.screenshot({ path: test.info().outputPath('special-user-commands-mobile-500.png') }); });