diff --git a/packages/logic/src/actions/turn/nation/che_피장파장.ts b/packages/logic/src/actions/turn/nation/che_피장파장.ts index 08b853b..7024298 100644 --- a/packages/logic/src/actions/turn/nation/che_피장파장.ts +++ b/packages/logic/src/actions/turn/nation/che_피장파장.ts @@ -64,10 +64,7 @@ const STRATEGIC_COMMANDS: Record<(typeof STRATEGIC_COMMAND_KEYS)[number], string }; const ARGS_SCHEMA = z.object({ - destNationId: z.preprocess( - (value) => (typeof value === 'number' ? Math.floor(value) : value), - z.number().int().positive() - ), + destNationId: z.number().int().positive(), commandType: z .enum(STRATEGIC_COMMAND_KEYS) .refine((value) => value !== 'che_피장파장', '같은 전략은 선택할 수 없습니다.'), @@ -133,7 +130,13 @@ export class ActionResolver< } getTargetPostReqTurn(context: CounterStrategyResolveContext): number { - const genCount = Math.max(context.friendlyGenerals.length, this.initialNationGenLimit); + const storedCount = context.nation?.meta.gennum; + const genCount = Math.max( + typeof storedCount === 'number' && Number.isFinite(storedCount) + ? storedCount + : context.friendlyGenerals.length, + this.initialNationGenLimit + ); const base = Math.round(Math.sqrt(genCount * 2) * 10); const triggered = Math.round(this.pipeline.onCalcStrategic(context, ACTION_NAME, 'delay', base)); return Math.max(triggered, Math.round(TARGET_DELAY * 1.2)); @@ -204,14 +207,6 @@ export class ActionResolver< ...(nation.meta as object), [`next_execute_${targetCommandName}`]: currentYearMonth + this.getTargetPostReqTurn(context), }; - effects.push( - createLogEffect(broadcastMessage, { - scope: LogScope.NATION, - category: LogCategory.HISTORY, - nationId: nation.id, - format: LogFormat.YEAR_MONTH, - }) - ); } const destMeta = destNation.meta; diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 7b7d492..1e48123 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -346,6 +346,19 @@ const buildWorldInput = ( generals ) ); + const referenceNationCooldowns = Array.isArray(referenceBefore.world.nationCooldowns) + ? referenceBefore.world.nationCooldowns + : []; + for (const rawCooldown of referenceNationCooldowns) { + const cooldown = asRecord(rawCooldown); + const nationId = readNumber(cooldown, 'nationId'); + const actionName = readString(cooldown, 'actionName', ''); + const nextAvailableTurn = cooldown.nextAvailableTurn; + const nation = nations.find((entry) => entry.id === nationId); + if (nation && actionName && typeof nextAvailableTurn === 'number' && Number.isFinite(nextAvailableTurn)) { + nation.meta[`next_execute_${actionName}`] = nextAvailableTurn; + } + } const observedCityRows = new Map(referenceBefore.cities.map((row) => [readNumber(row, 'id'), row] as const)); const randomFoundingCandidateCityIds = request.setup?.randomFoundingCandidateCityIds ? new Set(request.setup.randomFoundingCandidateCityIds) diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts index da6d83f..1e74f4d 100644 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts @@ -1315,6 +1315,380 @@ integration('nation flood constraints, multistep, city damage, logs, and cooldow ); }); +const counterStrategyNames = { + che_필사즉생: '필사즉생', + che_백성동원: '백성동원', + che_수몰: '수몰', + che_허보: '허보', + che_의병모집: '의병모집', + che_이호경식: '이호경식', + che_급습: '급습', +} as const; + +type CounterStrategyCommand = keyof typeof counterStrategyNames; + +interface CounterStrategyBoundaryCase { + name: string; + destNationId?: unknown; + commandType?: unknown; + omitDestNationId?: boolean; + omitCommandType?: boolean; + fixturePatches?: FixturePatches; + outcome: 'fallback' | 'intermediate' | 'completed'; + coreResolution?: boolean; + expectedSourceTargetOffset?: number; + expectedDestTargetTurn?: number; + expectedDestGeneralLog?: boolean; +} + +const counterStrategyBoundaryCases: CounterStrategyBoundaryCase[] = [ + { + name: 'rejects a missing destination nation argument', + omitDestNationId: true, + outcome: 'fallback', + }, + { + name: 'rejects a numeric string destination nation', + destNationId: '2', + outcome: 'fallback', + }, + { + name: 'rejects a fractional destination nation instead of truncating it', + destNationId: 2.9, + outcome: 'fallback', + }, + { + name: 'rejects a zero destination nation', + destNationId: 0, + outcome: 'fallback', + }, + { + name: 'rejects a missing selected strategy', + omitCommandType: true, + outcome: 'fallback', + }, + { + name: 'rejects a non-string selected strategy', + commandType: 1, + outcome: 'fallback', + }, + { + name: 'rejects an unknown selected strategy', + commandType: 'che_초토화', + outcome: 'fallback', + }, + { + name: 'rejects selecting counter strategy itself', + commandType: 'che_피장파장', + outcome: 'fallback', + }, + { + name: 'rejects a nation that no longer exists', + destNationId: 9, + outcome: 'fallback', + }, + { + name: 'rejects a source city occupied by another nation', + fixturePatches: { cities: { 3: { nationId: 2 } } }, + outcome: 'fallback', + }, + { + name: 'rejects an actor below chief rank', + fixturePatches: { generals: { 1: { officerLevel: 4 } } }, + outcome: 'fallback', + coreResolution: false, + }, + { + name: 'rejects a remaining strategic-command delay', + fixturePatches: { nations: { 1: { strategicCommandLimit: 1 } } }, + outcome: 'fallback', + }, + { + name: 'rejects a selected strategy still cooling down in the source nation', + fixturePatches: { nations: { 1: { nationEnv: { next_execute_허보: 2281 } } } }, + outcome: 'fallback', + }, + { + name: 'rejects a forward diplomacy state outside declaration or war', + fixturePatches: { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 0 } } }, + outcome: 'fallback', + }, + { + name: 'rejects a reverse-only war', + fixturePatches: { diplomacy: { '1:2': { state: 3 }, '2:1': { state: 0 } } }, + outcome: 'fallback', + }, + { + name: 'allows an unsupplied source city and starts at term one', + fixturePatches: { cities: { 3: { supplyState: 0 } } }, + outcome: 'intermediate', + }, + { + name: 'restarts at term one when the same command has different prior arguments', + fixturePatches: { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { + command: '피장파장', + arg: { destNationID: 2, commandType: 'che_백성동원' }, + term: 1, + }, + }, + }, + }, + }, + outcome: 'intermediate', + }, + { + name: 'restarts at term one after another command interrupted the stack', + fixturePatches: { + nations: { + 1: { + turnLastByOfficerLevel: { + 12: { + command: '수몰', + arg: { destCityID: 70 }, + term: 1, + }, + }, + }, + }, + }, + outcome: 'intermediate', + }, + { + name: 'completes the default source and destination cooldowns and notifications', + outcome: 'completed', + expectedSourceTargetOffset: 72, + expectedDestTargetTurn: 2340, + }, + { + name: 'allows the exact current-turn selected-strategy cooldown', + fixturePatches: { nations: { 1: { nationEnv: { next_execute_허보: 2280 } } } }, + outcome: 'completed', + expectedSourceTargetOffset: 72, + expectedDestTargetTurn: 2340, + }, + { + name: 'adds sixty turns after an existing future destination cooldown', + fixturePatches: { nations: { 2: { nationEnv: { next_execute_허보: 2300 } } } }, + outcome: 'completed', + expectedSourceTargetOffset: 72, + expectedDestTargetTurn: 2360, + }, + { + name: 'uses stored thirty-general count for the source selected-strategy cooldown', + fixturePatches: { nations: { 1: { generalCount: 30 } } }, + outcome: 'completed', + expectedSourceTargetOffset: 77, + expectedDestTargetTurn: 2340, + }, + { + name: 'applies the strategist delay modifier but preserves the seventy-two-turn floor', + fixturePatches: { nations: { 1: { typeCode: 'che_종횡가' } } }, + outcome: 'completed', + expectedSourceTargetOffset: 72, + expectedDestTargetTurn: 2340, + }, + { + name: 'keeps destination nation history when it has no general', + fixturePatches: { generals: { 2: { nationId: 0 } } }, + outcome: 'completed', + expectedSourceTargetOffset: 72, + expectedDestTargetTurn: 2340, + expectedDestGeneralLog: false, + }, + { + name: 'allows declaration state one regardless of reverse diplomacy', + fixturePatches: { diplomacy: { '1:2': { state: 1 }, '2:1': { state: 7 } } }, + outcome: 'completed', + expectedSourceTargetOffset: 72, + expectedDestTargetTurn: 2340, + }, +]; + +integration('nation counter-strategy constraints, stack, dual cooldowns, and log boundaries', () => { + it.each(counterStrategyBoundaryCases)( + '$name matches legacy progress, cooldowns, notifications, RNG, and semantic delta', + async ({ + destNationId = 2, + commandType = 'che_허보', + omitDestNationId = false, + omitCommandType = false, + fixturePatches, + outcome, + coreResolution = true, + expectedSourceTargetOffset, + expectedDestTargetTurn, + expectedDestGeneralLog = true, + }) => { + const completed = outcome === 'completed'; + const fallback = outcome === 'fallback'; + const completionPatch = completed + ? { + turnLastByOfficerLevel: { + 12: { + command: '피장파장', + arg: { destNationID: destNationId, commandType }, + term: 1, + }, + }, + coreTurnLastByOfficerLevel: { + 12: { + command: '피장파장', + arg: { destNationId, commandType }, + term: 1, + }, + }, + } + : {}; + const args = { + ...(omitDestNationId ? {} : { destNationID: destNationId }), + ...(omitCommandType ? {} : { commandType }), + }; + const request = buildRequest('che_피장파장', args, { + ...fixturePatches, + nations: { + ...fixturePatches?.nations, + 1: { + ...fixturePatches?.nations?.[1], + ...completionPatch, + }, + }, + diplomacy: { + '1:2': { state: 0 }, + '2:1': { state: 3 }, + ...fixturePatches?.diplomacy, + }, + }); + const selectedCommand = + typeof commandType === 'string' && commandType in counterStrategyNames + ? (commandType as CounterStrategyCommand) + : 'che_허보'; + const selectedCommandName = counterStrategyNames[selectedCommand]; + request.observe!.includeNationHistoryLogs = true; + request.observe!.nationCooldowns = + typeof commandType === 'string' && commandType in counterStrategyNames + ? [ + { nationId: 1, actionName: '피장파장' }, + { nationId: 1, actionName: selectedCommandName }, + { nationId: 2, actionName: selectedCommandName }, + ] + : []; + + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(reference.execution.outcome).toMatchObject({ completed }); + if (coreResolution) { + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_피장파장', + actionKey: fallback ? '휴식' : 'che_피장파장', + usedFallback: fallback, + ...(fallback ? {} : { completed }), + }); + } else { + expect(core.execution.outcome).toBeUndefined(); + } + for (const snapshot of [reference, core]) { + const sourceBefore = snapshot.before.nations.find((entry) => entry.id === 1); + const sourceAfter = snapshot.after.nations.find((entry) => entry.id === 1); + const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1); + const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1); + expect(readNationResource(sourceBefore, 'gold') - readNationResource(sourceAfter, 'gold')).toBe(0); + expect(readNationResource(sourceBefore, 'rice') - readNationResource(sourceAfter, 'rice')).toBe(0); + expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe( + completed ? 10 : 0 + ); + expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe( + completed ? 10 : 0 + ); + expect(readNumericField(sourceAfter, 'strategicCommandLimit')).toBe( + readNumericField(sourceBefore, 'strategicCommandLimit') + ); + + if (completed) { + const nationHistoryWatermark = snapshot.before.logs + .filter((entry) => entry.scope === 'nation') + .reduce((max, entry) => Math.max(max, readNumericField(entry, 'id')), 0); + const addedLogs = snapshot.after.logs.filter((entry) => + entry.scope === 'nation' + ? readNumericField(entry, 'id') > nationHistoryWatermark + : readNumericField(entry, 'id') > snapshot.before.watermarks.logId + ); + expect( + addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('피장파장')) + ).toBe(true); + expect( + addedLogs.some((entry) => entry.generalId === 2 && String(entry.text).includes('피장파장')) + ).toBe(expectedDestGeneralLog); + expect( + addedLogs.filter( + (entry) => + String(entry.scope).toLowerCase() === 'nation' && + entry.nationId === 1 && + String(entry.text).includes('피장파장') + ) + ).toHaveLength(1); + expect( + addedLogs.filter( + (entry) => + String(entry.scope).toLowerCase() === 'nation' && + entry.nationId === 2 && + String(entry.text).includes('피장파장') + ) + ).toHaveLength(1); + } + } + + if (outcome === 'intermediate') { + expect(reference.execution.outcome).toMatchObject({ + lastTurn: { + command: '피장파장', + term: 1, + }, + }); + expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({ + command: '피장파장', + term: 1, + }); + } + if (completed) { + expect(reference.after.world.nationCooldowns).toEqual([ + { + nationId: 1, + actionName: '피장파장', + nextAvailableTurn: 2287, + }, + { + nationId: 1, + actionName: selectedCommandName, + nextAvailableTurn: 2280 + expectedSourceTargetOffset!, + }, + { + nationId: 2, + actionName: selectedCommandName, + nextAvailableTurn: expectedDestTargetTurn, + }, + ]); + expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns); + } + expect(reference.rng).toEqual([]); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, + 120_000 + ); +}); + const nationResourceAmountCases: Array<{ name: string; action: 'che_포상' | 'che_몰수';