From 0e344e4db133fe60438aed0c952f2231c3f18bf8 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:56:00 +0000 Subject: [PATCH] test(turn): cover general cooldown boundaries --- .../general-command-differential-testing.md | 11 +- .../turn-state-differential-testing.md | 4 + .../src/turn-differential/coreCommandTrace.ts | 31 +++++ ...rnCommandGeneralMatrix.integration.test.ts | 127 ++++++++++++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index 825af69..f33f40e 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -10,7 +10,7 @@ 현재 ref MariaDB와 core memory를 잇는 공통 runner, 성공 경로 55개, 실행 중 확률 실패 9개, full constraint fallback 12개와 모략 확률 clamp 8개, 모략 결과값 경계 5개, 부상 경계 3개, alternative 5개와 pre-required -turn 중간 경계 6개가 구현됐다. +turn 중간 경계 6개, post-required cooldown 경계 3개가 구현됐다. 실패 9개는 내정 critical `주민선정/정착장려/상업투자/기술연구/물자조달`과 모략 `화계/선동/파괴/탈취`이며 RNG 전체 trace, semantic state delta와 실패 @@ -27,9 +27,12 @@ state delta를 비교한다. 결과값 5개는 화계 농업·상업, 선동 치 비교한다. alternative 5개는 해산·랜덤임관·무작위건국·출병의 모든 대체 분기에서 최초 명령 RNG의 연속 소비와 최종 상태를 비교한다. pre-required turn 6개는 전투태세 1/2/3턴, 내정·전투 특기 초기화 1턴, 은퇴 1턴의 -`last_turn`과 진행 로그, RNG 무소비를 비교한다. 나머지 명령별 제약 -실패·값 경계와 전체 core PostgreSQL 재조회가 완료 기준을 통과하기 전까지 -55개 명령 전체의 동적 호환 상태를 `확인`으로 올리지 않는다. +`last_turn`과 진행 로그, RNG 무소비를 비교한다. cooldown 3개는 특기 +초기화 완료 직후 `current + 60 - preReq`, 1턴 전 차단, 경계 월 허용을 +ref `next_execute` KV와 core general meta의 공통 projection으로 비교한다. +나머지 명령별 제약 실패·값 경계와 전체 core PostgreSQL 재조회가 완료 +기준을 통과하기 전까지 55개 명령 전체의 동적 호환 상태를 `확인`으로 +올리지 않는다. ## 결정 요약 diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index 5abd042..f29f430 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -206,6 +206,10 @@ instance and current consumption position. Six pre-required-turn cases cover battle-preparation terms 1 through 3 plus the first intermediate turn of both trait resets and retirement. They compare the exact intermediate `lastTurn`, progress log, zero command-RNG consumption and semantic state delta. +Three post-required cooldown cases project the legacy `next_execute` KV and +core general meta into the same world-level cooldown record. They cover the +stored `current + 60 - preReq` value, rejection one turn before availability, +and successful execution exactly at the boundary. This is not yet a claim that every command-specific constraint, clamp and persistence boundary has been dynamically compared. diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index bf9dfa9..0db84a0 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -25,6 +25,11 @@ import { type CanonicalTurnSnapshot, } from './canonical.js'; +interface GeneralCooldownSelector { + generalId: number; + actionName: string; +} + export interface TurnCommandFixtureRequest { kind: 'general' | 'nation'; actorGeneralId: number; @@ -47,6 +52,7 @@ export interface TurnCommandFixtureRequest { troops?: Array>; diplomacy?: Array>; randomFoundingCandidateCityIds?: number[]; + generalCooldowns?: Array; }; observe?: { generalIds?: number[]; @@ -54,6 +60,7 @@ export interface TurnCommandFixtureRequest { nationIds?: number[]; logAfterId?: number; messageAfterId?: number; + generalCooldowns?: GeneralCooldownSelector[]; }; } @@ -288,6 +295,19 @@ const buildWorldInput = ( const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1); const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`); const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime)); + const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns) + ? referenceBefore.world.generalCooldowns + : []; + for (const rawCooldown of referenceGeneralCooldowns) { + const cooldown = asRecord(rawCooldown); + const generalId = readNumber(cooldown, 'generalId'); + const actionName = readString(cooldown, 'actionName', ''); + const nextAvailableTurn = cooldown.nextAvailableTurn; + const general = generals.find((entry) => entry.id === generalId); + if (general && actionName && typeof nextAvailableTurn === 'number' && Number.isFinite(nextAvailableTurn)) { + general.meta[`next_execute_${actionName}`] = nextAvailableTurn; + } + } const fixtureNations = new Map((request.setup?.nations ?? []).map((row) => [readNumber(row, 'id'), row] as const)); const nations = referenceBefore.nations.map((row) => buildNation( @@ -429,6 +449,7 @@ const projectWorld = ( generalIds: Set; cityIds: Set; nationIds: Set; + generalCooldowns: GeneralCooldownSelector[]; } ): CanonicalTurnSnapshot => { const state = world.getState(); @@ -489,6 +510,15 @@ const projectWorld = ( tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)), turnTime: state.lastTurnTime.toISOString(), isUnited: readNumber(state.meta, 'isUnited'), + generalCooldowns: selector.generalCooldowns.map(({ generalId, actionName }) => { + const general = world.getGeneralById(generalId); + const raw = general?.meta[`next_execute_${actionName}`]; + return { + generalId, + actionName, + nextAvailableTurn: typeof raw === 'number' && Number.isFinite(raw) ? raw : null, + }; + }), }, generals, cities: world @@ -595,6 +625,7 @@ export const runCoreTurnCommandTrace = async ( ]), cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))), nationIds: new Set(referenceBefore.nations.map((row) => readNumber(row, 'id'))), + generalCooldowns: request.observe?.generalCooldowns ?? [], }; const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, { maxGeneralTurns: 10, diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 7f8865a..f6a139e 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -931,6 +931,133 @@ integration('general command pre-required turn boundary matrix', () => { ); }); +const readGeneralCooldown = ( + snapshot: { world: Record }, + generalId: number, + actionName: string +): number | null => { + const cooldowns = Array.isArray(snapshot.world.generalCooldowns) ? snapshot.world.generalCooldowns : []; + const matched = cooldowns.find( + (entry) => + typeof entry === 'object' && + entry !== null && + (entry as Record).generalId === generalId && + (entry as Record).actionName === actionName + ); + const value = + typeof matched === 'object' && matched !== null ? (matched as Record).nextAvailableTurn : null; + return typeof value === 'number' ? value : null; +}; + +integration('general command post-required cooldown boundary matrix', () => { + it('stores the same 60-turn cooldown after domestic trait reset completion', async () => { + const request = buildRequest('che_내정특기초기화', undefined, { + specialDomestic: 'che_인덕', + lastTurn: { command: '내정 특기 초기화', term: 1 }, + }); + request.observe!.generalCooldowns = [{ generalId: 1, actionName: '내정 특기 초기화' }]; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const expectedNextAvailableTurn = 190 * 12 + 1 - 1 + 60 - 1; + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_내정특기초기화', + actionKey: 'che_내정특기초기화', + usedFallback: false, + }); + expect(readGeneralCooldown(reference.after, 1, '내정 특기 초기화')).toBe(expectedNextAvailableTurn); + expect(readGeneralCooldown(core.after, 1, '내정 특기 초기화')).toBe(expectedNextAvailableTurn); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); + + it('blocks domestic trait reset one turn before the cooldown boundary', async () => { + const currentYearMonth = 190 * 12 + 1 - 1; + const request = buildRequest('che_내정특기초기화', undefined, { + specialDomestic: 'che_인덕', + lastTurn: { command: '내정 특기 초기화', term: 1 }, + }); + request.setup!.generalCooldowns = [ + { + generalId: 1, + actionName: '내정 특기 초기화', + nextAvailableTurn: currentYearMonth + 1, + }, + ]; + request.observe!.generalCooldowns = [{ generalId: 1, actionName: '내정 특기 초기화' }]; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + + expect(readGeneralCooldown(reference.before, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1); + expect(readGeneralCooldown(core.before, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1); + expect(reference.execution.outcome).toMatchObject({ completed: false }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_내정특기초기화', + actionKey: '휴식', + usedFallback: true, + blockedReason: '1턴 더 기다려야 합니다', + }); + expect(reference.after.logs.some((entry) => String(entry.text).includes('1턴 더 기다려야 합니다'))).toBe(true); + expect(core.after.logs.some((entry) => String(entry.text).includes('1턴 더 기다려야 합니다'))).toBe(true); + expect(readGeneralCooldown(reference.after, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1); + expect(readGeneralCooldown(core.after, 1, '내정 특기 초기화')).toBe(currentYearMonth + 1); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); + + it('allows war trait reset exactly at the cooldown boundary', async () => { + const currentYearMonth = 190 * 12 + 1 - 1; + const request = buildRequest('che_전투특기초기화', undefined, { + specialWar: 'che_귀병', + lastTurn: { command: '전투 특기 초기화', term: 1 }, + }); + request.setup!.generalCooldowns = [ + { + generalId: 1, + actionName: '전투 특기 초기화', + nextAvailableTurn: currentYearMonth, + }, + ]; + request.observe!.generalCooldowns = [{ generalId: 1, actionName: '전투 특기 초기화' }]; + const reference = runReferenceTurnCommandTraceRequest( + workspaceRoot!, + request as unknown as Record + ); + const core = await runCoreTurnCommandTrace(request, reference.before); + const expectedNextAvailableTurn = currentYearMonth + 60 - 1; + + expect(reference.execution.outcome).toMatchObject({ completed: true }); + expect(core.execution.outcome).toMatchObject({ + requestedAction: 'che_전투특기초기화', + actionKey: 'che_전투특기초기화', + usedFallback: false, + }); + expect(readGeneralCooldown(reference.after, 1, '전투 특기 초기화')).toBe(expectedNextAvailableTurn); + expect(readGeneralCooldown(core.after, 1, '전투 특기 초기화')).toBe(expectedNextAvailableTurn); + expect(core.rng).toEqual(reference.rng); + expect( + compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { + ignoredPathPatterns: ignoredLifecyclePaths, + }) + ).toEqual([]); + }, 120_000); +}); + type GeneralConstraintCase = { name: string; action: string;