diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 5a0a976..6aa81ac 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -857,7 +857,8 @@ export const createReservedTurnHandler = async (options: { fallbackDefinition: GeneralActionDefinition, command: ReservedTurnEntry, applyNextTurnAt: boolean, - alternativeDepth = 0 + alternativeDepth = 0, + sharedActionRng?: RandUtil ): { nextTurnAt?: Date; actionKey: string; usedFallback: boolean; blockedReason?: string } => { const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition); const rawArgs = extractArgsRecord(command.args); @@ -954,11 +955,12 @@ export const createReservedTurnHandler = async (options: { uniqueConfig, getAdditionalOccupiedUniqueItemKeys: options.getAdditionalOccupiedUniqueItemKeys, }); + let actionRng = sharedActionRng ?? buildRng(actionKey); let baseContext: ActionContextBase = { general: currentGeneral, city: currentCity, nation: currentNation, - rng: buildRng(actionKey), + rng: actionRng, uniqueLottery, }; let specificContext = buildActionContext( @@ -985,11 +987,12 @@ export const createReservedTurnHandler = async (options: { usedFallback = true; blockedReason = '예약된 명령을 실행하지 못했습니다.'; logs.push(createActionLog('예약된 명령을 실행하지 못했습니다.')); + actionRng = sharedActionRng ?? buildRng(actionKey); baseContext = { general: currentGeneral, city: currentCity, nation: currentNation, - rng: buildRng(actionKey), + rng: actionRng, }; specificContext = baseContext; } @@ -1287,7 +1290,8 @@ export const createReservedTurnHandler = async (options: { args: extractArgsRecord(resolution.alternative.args), }, applyNextTurnAt, - alternativeDepth + 1 + alternativeDepth + 1, + actionRng ); } diff --git a/packages/logic/src/actions/turn/actionContextHelpers.ts b/packages/logic/src/actions/turn/actionContextHelpers.ts index 8f5da0e..2ae8186 100644 --- a/packages/logic/src/actions/turn/actionContextHelpers.ts +++ b/packages/logic/src/actions/turn/actionContextHelpers.ts @@ -110,6 +110,31 @@ export const resolveStartYear = (world: ActionContextWorldState, scenarioMeta?: return world.currentYear; }; +const readFiniteInteger = (value: unknown, fallback: number): number => { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.floor(value); + } + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + } + return fallback; +}; + +export const resolveInitYearMonth = ( + world: ActionContextWorldState, + scenarioMeta?: ScenarioMeta +): { year: number; month: number } => { + const meta = asRecord(world.meta); + const startYear = resolveStartYear(world, scenarioMeta); + return { + year: readFiniteInteger(meta.initYear, startYear), + month: readFiniteInteger(meta.initMonth, 1), + }; +}; + export const resolveTurnTermMinutes = (world: ActionContextWorldState): number => Math.max(1, Math.round(world.tickSeconds / 60)); diff --git a/packages/logic/src/actions/turn/general/che_무작위건국.ts b/packages/logic/src/actions/turn/general/che_무작위건국.ts index 6d0eedb..542aa80 100644 --- a/packages/logic/src/actions/turn/general/che_무작위건국.ts +++ b/packages/logic/src/actions/turn/general/che_무작위건국.ts @@ -25,6 +25,7 @@ import { z } from 'zod'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; import type { GeneralTurnCommandSpec } from './index.js'; import { parseArgsWithSchema } from '../parseArgs.js'; import { JosaUtil } from '@sammo-ts/common'; @@ -223,34 +224,18 @@ export class ActionDefinition< } } -const resolveStartYear = (currentYear: number, raw: unknown): number => { - if (typeof raw === 'number' && Number.isFinite(raw)) { - return Math.floor(raw); - } - if (typeof raw === 'string') { - const parsed = Number(raw); - if (Number.isFinite(parsed)) { - return Math.floor(parsed); - } - } - return currentYear; -}; - export const actionContextBuilder: ActionContextBuilder = (base, options) => { const nationId = base.nation?.id ?? base.general.nationId; const currentYear = options.world.currentYear; const currentMonth = options.world.currentMonth; - - const startYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear); - const initYear = startYear; - const initMonth = 1; + const init = resolveInitYearMonth(options.world, options.scenarioMeta); return { ...base, allCities: options.worldRef?.listCities() ?? [], nationGenerals: options.worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [], currentYearMonth: currentYear * 12 + currentMonth - 1, - initYearMonth: initYear * 12 + initMonth - 1, + initYearMonth: init.year * 12 + init.month - 1, }; }; diff --git a/packages/logic/src/actions/turn/general/che_해산.ts b/packages/logic/src/actions/turn/general/che_해산.ts index fe80b0e..d0cce43 100644 --- a/packages/logic/src/actions/turn/general/che_해산.ts +++ b/packages/logic/src/actions/turn/general/che_해산.ts @@ -15,6 +15,7 @@ import { import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic/logging/types.js'; import { JosaUtil } from '@sammo-ts/common'; import type { ActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; +import { resolveInitYearMonth } from '@sammo-ts/logic/actions/turn/actionContextHelpers.js'; import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'; import type { GeneralTurnCommandSpec } from './index.js'; @@ -145,33 +146,19 @@ export class ActionDefinition< } } -const resolveStartYear = (currentYear: number, raw: unknown): number => { - if (typeof raw === 'number' && Number.isFinite(raw)) { - return Math.floor(raw); - } - if (typeof raw === 'string') { - const parsed = Number(raw); - if (Number.isFinite(parsed)) { - return Math.floor(parsed); - } - } - return currentYear; -}; - export const actionContextBuilder: ActionContextBuilder = (base, options) => { const nationId = base.nation?.id ?? base.general.nationId; const worldRef = options.worldRef; const currentYear = options.world.currentYear; const currentMonth = options.world.currentMonth; - const initYear = resolveStartYear(currentYear, options.scenarioMeta?.startYear); - const initMonth = 1; + const init = resolveInitYearMonth(options.world, options.scenarioMeta); return { ...base, nationGenerals: worldRef?.listGenerals().filter((general) => general.nationId === nationId) ?? [], nationCities: worldRef?.listCities().filter((city) => city.nationId === nationId) ?? [], currentYearMonth: currentYear * 12 + currentMonth - 1, - initYearMonth: initYear * 12 + initMonth - 1, + initYearMonth: init.year * 12 + init.month - 1, }; }; diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 1bc38a4..bf9dfa9 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -160,8 +160,11 @@ const createCommandProfile = (request: TurnCommandFixtureRequest): TurnCommandPr if (!GENERAL_TURN_COMMAND_KEYS.includes(request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) { throw new Error(`Unknown general command: ${request.action}`); } + const generalActions = [request.action, '휴식', 'che_인재탐색', 'che_해산', 'che_이동'] as Array< + (typeof GENERAL_TURN_COMMAND_KEYS)[number] + >; return { - general: [request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number], '휴식'], + general: [...new Set(generalActions)], nation: ['휴식'], }; } diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 387c47d..52780e2 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -717,6 +717,117 @@ integration('general sabotage injury boundary matrix', () => { ); }); +integration('general command alternative matrix', () => { + const expectAlternativeParity = async ( + request: TurnCommandFixtureRequest, + expectedActionKey: string, + expectedLogText: string + ): Promise => { + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed: false }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: request.action, + actionKey: expectedActionKey, + usedFallback: false, + }); + expect( + reference.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes(expectedLogText)) + ).toBe(true); + expect( + core.after.logs.some((entry) => typeof entry.text === 'string' && entry.text.includes(expectedLogText)) + ).toBe(true); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }; + + it('che_해산 continues into che_인재탐색 with the legacy RNG and state delta', async () => { + const request = buildRequest( + 'che_해산', + undefined, + {}, + { + world: { initYear: 190, initMonth: 1 }, + nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } }, + } + ); + request.setup!.world!.hiddenSeed = 'general-alternative-disband-search'; + await expectAlternativeParity(request, 'che_인재탐색', '다음 턴부터 해산할 수 있습니다.'); + }, 120_000); + + it('che_랜덤임관 continues into che_인재탐색 when no eligible nation exists', async () => { + const request = buildRequest( + 'che_랜덤임관', + undefined, + { nationId: 0, officerLevel: 0 }, + { + generals: { + 2: { npcState: 5 }, + 3: { npcState: 5 }, + }, + } + ); + request.setup!.world!.hiddenSeed = 'general-alternative-random-appointment-search'; + await expectAlternativeParity(request, 'che_인재탐색', '임관 가능한 국가가 없습니다.'); + }, 120_000); + + it('che_무작위건국 continues into che_인재탐색 during the initial month', async () => { + const request = buildRequest( + 'che_무작위건국', + { nationName: '신국', nationType: 'che_도적', colorType: 1 }, + {}, + { + world: { startYear: 190, initYear: 190, initMonth: 1 }, + nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } }, + } + ); + request.setup!.world!.hiddenSeed = 'general-alternative-random-founding-search'; + await expectAlternativeParity(request, 'che_인재탐색', '다음 턴부터 건국할 수 있습니다.'); + }, 120_000); + + it('che_무작위건국 continues into che_해산 when no founding city exists', async () => { + const request = buildRequest( + 'che_무작위건국', + { nationName: '신국', nationType: 'che_도적', colorType: 1 }, + {}, + { + world: { initYear: 180, initMonth: 1, year: 181 }, + nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } }, + cities: { + 3: { nationId: 1, level: 4 }, + 70: { nationId: 0, level: 4 }, + }, + randomFoundingCandidateCityIds: [3], + } + ); + request.setup!.world!.hiddenSeed = 'general-alternative-random-founding-disband'; + await expectAlternativeParity(request, 'che_해산', '건국할 수 있는 도시가 없습니다.'); + }, 120_000); + + it('che_출병 continues into che_이동 when the destination belongs to the actor nation', async () => { + const request = buildRequest( + 'che_출병', + { destCityID: 70 }, + {}, + { + world: { startYear: 180, year: 185 }, + cities: { 70: { nationId: 1, supplyState: 1, frontState: 0 } }, + generals: { 2: { nationId: 1 } }, + } + ); + request.setup!.world!.hiddenSeed = 'general-alternative-sortie-move'; + await expectAlternativeParity(request, 'che_이동', '본국입니다.'); + }, 120_000); +}); + type GeneralConstraintCase = { name: string; action: string;