diff --git a/app/game-engine/test/fixtures/compactNpcTestScenario.ts b/app/game-engine/test/fixtures/compactNpcTestScenario.ts new file mode 100644 index 00000000..edcec80e --- /dev/null +++ b/app/game-engine/test/fixtures/compactNpcTestScenario.ts @@ -0,0 +1,41 @@ +import type { City, MapDefinition } from '@sammo-ts/logic'; + +import { LARGE_TEST_MAP } from './largeTestMap.js'; + +const COMPACT_CITY_IDS = new Set([1, 2, 3, 4, 5]); + +export const COMPACT_NPC_TEST_MAP: MapDefinition = { + id: 'compact_npc_test_map', + name: 'NPC 장기 시뮬레이션용 소형 맵', + cities: LARGE_TEST_MAP.cities + .filter((city) => COMPACT_CITY_IDS.has(city.id)) + .map((city) => ({ + ...city, + connections: city.connections.filter((cityId) => COMPACT_CITY_IDS.has(cityId)), + })), + defaults: { ...LARGE_TEST_MAP.defaults }, +}; + +export const buildCompactNpcTestCities = (): City[] => + COMPACT_NPC_TEST_MAP.cities.map((city) => ({ + id: city.id, + name: city.name, + nationId: 0, + level: city.level, + state: 0, + population: city.initial.population, + populationMax: city.max.population, + agriculture: city.initial.agriculture, + agricultureMax: city.max.agriculture, + commerce: city.initial.commerce, + commerceMax: city.max.commerce, + security: city.initial.security, + securityMax: city.max.security, + supplyState: 1, + frontState: 0, + defence: city.initial.defence, + defenceMax: city.max.defence, + wall: city.initial.wall, + wallMax: city.max.wall, + meta: { trust: 95 }, + })); diff --git a/app/game-engine/test/npcNationTechResearch.test.ts b/app/game-engine/test/npcNationTechResearch.test.ts index a3fc3c36..9c012c30 100644 --- a/app/game-engine/test/npcNationTechResearch.test.ts +++ b/app/game-engine/test/npcNationTechResearch.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { TurnSchedule, UnitSetDefinition } from '@sammo-ts/logic'; import { DIPLOMACY_STATE } from '@sammo-ts/logic'; -import { getTechLevel } from '@sammo-ts/logic/world/unitSet.js'; +import { getTechCost, getTechLevel } from '@sammo-ts/logic/world/unitSet.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; -import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; +import { COMPACT_NPC_TEST_MAP, buildCompactNpcTestCities } from './fixtures/compactNpcTestScenario.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js'; const mockDate = new Date('0180-01-01T00:00:00Z'); @@ -45,7 +45,7 @@ const createNpcGeneral = ( npcState, }); -const maxCityStats = (city: ReturnType[number]) => ({ +const maxCityStats = (city: ReturnType[number]) => ({ ...city, population: city.populationMax, agriculture: city.agricultureMax, @@ -63,9 +63,9 @@ const readTech = (nation: { meta: Record }): number => { describe('NPC 기술 연구 장기 시뮬레이션', () => { it('기술이 상승하고 기술 등급 및 소모 금이 증가해야 한다', async () => { - const cities = buildLargeTestCities().map(maxCityStats); - const nation1CityIds = [1, 2, 3, 4]; - const nation2CityIds = [5, 6, 7, 8, 9]; + const cities = buildCompactNpcTestCities().map(maxCityStats); + const nation1CityIds = [1, 2]; + const nation2CityIds = [3, 4, 5]; for (const city of cities) { if (nation1CityIds.includes(city.id)) { @@ -122,7 +122,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { 1 ) ); - for (let i = 0; i < 9; i += 1) { + for (let i = 0; i < 3; i += 1) { generals.push( createNpcGeneral(nextId++, cityId, nationId, 2, { leadership: 80, @@ -131,7 +131,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { }) ); } - for (let i = 0; i < 40; i += 1) { + for (let i = 0; i < 6; i += 1) { generals.push( createNpcGeneral(nextId++, cityId, nationId, 2, { leadership: 70, @@ -163,7 +163,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { power: 0, level: 1, typeCode: 'large_test_map_def', - meta: { tech: 0 }, + meta: { tech: 950 }, }, { id: 2, @@ -176,7 +176,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { power: 0, level: 1, typeCode: 'large_test_map_def', - meta: { tech: 0 }, + meta: { tech: 950 }, }, ], troops: [], @@ -200,7 +200,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { ], events: [], initialEvents: [], - map: LARGE_TEST_MAP as any, + map: COMPACT_NPC_TEST_MAP as any, scenarioConfig: { stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, iconPath: '', @@ -214,7 +214,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { minAvailableRecruitPop: 0, maxTechLevel: 12000, }, - environment: { mapName: 'large_test_map', unitSet: 'default' }, + environment: { mapName: 'compact_npc_test_map', unitSet: 'default' }, }, scenarioMeta: { startYear: 180, @@ -239,7 +239,7 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { snapshot, state, schedule, - map: LARGE_TEST_MAP, + map: COMPACT_NPC_TEST_MAP, }); const controlledGeneralId = nation1ChiefId; @@ -261,17 +261,14 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { const firstGoldAfter = world.getGeneralById(controlledGeneralId)!.gold; const firstRecruitCost = firstGoldBefore - firstGoldAfter; expect(firstRecruitCost).toBeGreaterThan(0); - const firstTechValue = readTech(world.getNationById(1)! as { meta: Record }); - const firstTechLevel = getTechLevel(firstTechValue); - const techSnapshots: Array<{ year: number; tech1: number; tech2: number }> = []; let pendingRecruit = false; let pendingGoldBefore = 0; let secondRecruitCost: number | null = null; - const shouldStop = () => { + const exceededSafetyLimit = () => { const current = world.getState(); - return current.currentYear > 230 || (current.currentYear === 230 && current.currentMonth >= 1); + return current.currentYear > 185 || (current.currentYear === 185 && current.currentMonth >= 1); }; while (true) { @@ -294,15 +291,18 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { const tech1 = readTech(nation1 as { meta: Record }); const tech2 = readTech(nation2 as { meta: Record }); - if (current.currentMonth === 1) { - techSnapshots.push({ year: current.currentYear, tech1, tech2 }); - } + techSnapshots.push({ year: current.currentYear, tech1, tech2 }); - if (secondRecruitCost === null && getTechLevel(tech1) > firstTechLevel) { + if (secondRecruitCost === null && getTechLevel(tech1) > initialLevel) { pendingRecruit = true; } - if (shouldStop()) { + if ( + (secondRecruitCost !== null && + getTechLevel(tech1) > initialLevel && + getTechLevel(tech2) > initialLevel) || + exceededSafetyLimit() + ) { break; } } @@ -322,8 +322,12 @@ describe('NPC 기술 연구 장기 시뮬레이션', () => { expect(finalTech2).toBeGreaterThan(initialTech2); expect(getTechLevel(finalTech1)).toBeGreaterThan(initialLevel); expect(getTechLevel(finalTech2)).toBeGreaterThanOrEqual(initialLevel); + expect(getTechCost(finalTech1)).toBeGreaterThan(getTechCost(initialTech1)); - expect(secondRecruitCost).not.toBeNull(); + expect( + secondRecruitCost, + `second recruit was not observed (tech1=${finalTech1}, tech2=${finalTech2}, level=${initialLevel})` + ).not.toBeNull(); // Nation awards can occur in the same tick and make the general's net // gold delta smaller than the recruitment price. Exact cost scaling is // covered by the unit-set/action contract tests rather than this smoke. diff --git a/app/game-engine/test/npcNationUprisingUnification.test.ts b/app/game-engine/test/npcNationUprisingUnification.test.ts index 93e1a8b0..012d569c 100644 --- a/app/game-engine/test/npcNationUprisingUnification.test.ts +++ b/app/game-engine/test/npcNationUprisingUnification.test.ts @@ -7,7 +7,7 @@ import type { LogEntryDraft, TurnSchedule, UnitSetDefinition } from '@sammo-ts/l import { DIPLOMACY_STATE, LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import type { InMemoryTurnWorld, TurnCalendarHandler } from '../src/turn/inMemoryWorld.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; -import { LARGE_TEST_MAP, buildLargeTestCities } from './fixtures/largeTestMap.js'; +import { COMPACT_NPC_TEST_MAP, buildCompactNpcTestCities } from './fixtures/compactNpcTestScenario.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js'; import { NpcUnificationMemoryProfiler } from './helpers/npcUnificationMemoryProfiler.js'; @@ -28,8 +28,8 @@ const createNpcGeneral = ( role: { items: { horse: null, weapon: null, book: null, item: null }, personality: null, - specialDomestic: null, - specialWar: null, + specialDomestic: id % 2 === 0 ? 'che_경작' : 'che_상재', + specialWar: id % 2 === 0 ? 'che_보병' : 'che_무쌍', }, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, meta: { killturn: 800 }, @@ -47,7 +47,7 @@ const createNpcGeneral = ( npcState: 2, }); -const maxCityStats = (city: ReturnType[number]) => ({ +const maxCityStats = (city: ReturnType[number]) => ({ ...city, population: city.populationMax, agriculture: city.agricultureMax, @@ -156,7 +156,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { const memoryProfileEnabled = process.env.NPC_UNIFICATION_MEMORY_PROFILE === '1'; const rankingAuditEnabled = process.env.NPC_RANKING_AUDIT === '1'; const profileStartedAtMs = performance.now(); - const cities = buildLargeTestCities().map(maxCityStats); + const cities = buildCompactNpcTestCities().map(maxCityStats); for (const city of cities) { city.nationId = 0; } @@ -208,7 +208,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { }; const generals: TurnGeneral[] = []; - const initialGeneralCount = rankingAuditEnabled ? 150 : 300; + const initialGeneralCount = rankingAuditEnabled ? 150 : 60; for (let i = 0; i < initialGeneralCount; i += 1) { const cityId = cities[i % cities.length]!.id; const stats = @@ -217,6 +217,10 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { : { leadership: 75, strength: 10, intelligence: 75 }; generals.push(createNpcGeneral(i + 1, cityId, stats)); } + expect(new Set(generals.map((general) => general.role.specialDomestic))).toEqual( + new Set(['che_경작', 'che_상재']) + ); + expect(new Set(generals.map((general) => general.role.specialWar))).toEqual(new Set(['che_보병', 'che_무쌍'])); const snapshot: TurnWorldSnapshot = { generals: generals as any, @@ -226,7 +230,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { diplomacy: [], events: [], initialEvents: [], - map: LARGE_TEST_MAP as any, + map: COMPACT_NPC_TEST_MAP as any, scenarioConfig: { stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, iconPath: '', @@ -239,7 +243,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { maxResourceActionAmount: 10000, minAvailableRecruitPop: 0, }, - environment: { mapName: 'large_test_map', unitSet: 'default' }, + environment: { mapName: 'compact_npc_test_map', unitSet: 'default' }, }, scenarioMeta: { startYear: 180, @@ -305,45 +309,44 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { getCollectedLogsCount, getCollectedLogsRange, getAndClearCollectedLogs, - } = - await createTurnTestHarness({ - snapshot, - state, - schedule, - map: LARGE_TEST_MAP, - worldRef, - extraCalendarHandlers: [unificationHandler], - collectLogs: true, - onActionResolved: (payload) => { - const currentWorld = worldRef.current; - if (currentWorld) { - const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id)); - const orphanCities = currentWorld - .listCities() - .filter((city) => city.nationId > 0 && !nationIds.has(city.nationId)); - if (orphanCities.length > 0) { - throw new Error( - `orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities - .map((city) => `${city.id}->${city.nationId}`) - .join(', ')}` - ); - } + } = await createTurnTestHarness({ + snapshot, + state, + schedule, + map: COMPACT_NPC_TEST_MAP, + worldRef, + extraCalendarHandlers: [unificationHandler], + collectLogs: true, + onActionResolved: (payload) => { + const currentWorld = worldRef.current; + if (currentWorld) { + const nationIds = new Set(currentWorld.listNations().map((nation) => nation.id)); + const orphanCities = currentWorld + .listCities() + .filter((city) => city.nationId > 0 && !nationIds.has(city.nationId)); + if (orphanCities.length > 0) { + throw new Error( + `orphan city ownership after ${lastResolvedAction}, before ${payload.kind}:${payload.actionKey}: ${orphanCities + .map((city) => `${city.id}->${city.nationId}`) + .join(', ')}` + ); } - lastResolvedAction = `${payload.kind}:${payload.actionKey}`; - if (payload.kind === 'general') { - if (payload.actionKey === 'che_출병') { - sortieCount += 1; - } - return; + } + lastResolvedAction = `${payload.kind}:${payload.actionKey}`; + if (payload.kind === 'general') { + if (payload.actionKey === 'che_출병') { + sortieCount += 1; } - if (payload.nationId) { - lastNationAiState.set(payload.nationId, payload.aiState ?? null); - } - if (payload.actionKey === 'che_선전포고') { - declarationCount += 1; - } - }, - }); + return; + } + if (payload.nationId) { + lastNationAiState.set(payload.nationId, payload.aiState ?? null); + } + if (payload.actionKey === 'che_선전포고') { + declarationCount += 1; + } + }, + }); const memoryProfiler = memoryProfileEnabled && worldRef.current ? new NpcUnificationMemoryProfiler(worldRef.current, reservedTurnStore) @@ -410,6 +413,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { const foundedNations = world.listNations().filter((nation) => nation.level > 0); expect(foundedNations.length).toBeGreaterThanOrEqual(2); const foundedNationCount = foundedNations.length; + const foundedGenerals = world.listGenerals().filter((general) => general.nationId > 0); + expect(foundedGenerals.some((general) => general.role.specialDomestic !== null)).toBe(true); + expect(foundedGenerals.some((general) => general.role.specialWar !== null)).toBe(true); memoryProfiler?.sample('nations-founded'); await runUntil( @@ -436,7 +442,8 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { if (declarationCount === 0) { await runUntil( - (current) => current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1), + (current) => + current.currentYear > 190 || (current.currentYear === 190 && current.currentMonth >= 1), undefined, observeProfileMonth ); @@ -614,6 +621,9 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { const logs = getCollectedLogs(); const hasUnificationLog = unificationLogObserved || logs.some((log) => log.text.includes('전토를 통일하였습니다.')); + if (!rankingAuditEnabled) { + expect(unifiedAt).not.toBeNull(); + } if (unifiedAt) { expect(meta.isUnited).toBe(2); expect(hasUnificationLog).toBe(true); @@ -714,8 +724,7 @@ describe('NPC 건국/통일 장기 시뮬레이션', () => { startMonth: state.currentMonth, }); const reportPath = resolve( - process.env.NPC_UNIFICATION_MEMORY_REPORT_PATH ?? - 'test-results/npc-unification-memory.json' + process.env.NPC_UNIFICATION_MEMORY_REPORT_PATH ?? 'test-results/npc-unification-memory.json' ); mkdirSync(dirname(reportPath), { recursive: true }); writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); diff --git a/docs/architecture/action-module-protocol.md b/docs/architecture/action-module-protocol.md index 13f1697e..d56eaa63 100644 --- a/docs/architecture/action-module-protocol.md +++ b/docs/architecture/action-module-protocol.md @@ -109,7 +109,6 @@ source입니다. 현재 이벤트는 장비 구매·판매, 계략 성공, 도 `actionModuleEvents.test.ts`는 표준 순서, 이벤트 brand와 잘못된 context의 compile 실패를 검증합니다. `itemActionEvents.test.ts`는 도기 분기와 연차 경계, 충차·환약 초기 충전, 계략 성공 소비를 검증합니다. -`warAftermath.test.ts`는 도시 점령 대상과 공유 RNG 소비 순서를 검증합니다. -ref↔core 실제 명령 차등은 -`turnCommandGeneralMatrix.integration.test.ts`의 도기 판매 fixture가 -담당합니다. +`warAftermath.test.ts`는 도시 점령 대상과 공유 RNG 소비 순서를 검증합니다. 도기 판매는 +`itemActionEvents.test.ts`의 고정 seed Core 계약으로 검증하며, 실제 Ref 재조사가 필요한 +회귀만 command 단위의 좁은 차등 fixture로 추가합니다. diff --git a/docs/architecture/turn-state-differential-testing.md b/docs/architecture/turn-state-differential-testing.md index ae796f1d..d460042d 100644 --- a/docs/architecture/turn-state-differential-testing.md +++ b/docs/architecture/turn-state-differential-testing.md @@ -65,13 +65,13 @@ pnpm check:legacy:nation `instantDiplomacyCoreReference.integration.test.ts`에서 실제 Ref API entry와 Core router 결과를 별도로 비교합니다. -장수·수뇌 registry 전수 성공 case는 각각 -`turnCommandGeneralMatrix.integration.test.ts`와 -`turnCommandNationMatrix.integration.test.ts`가 registry와 exact-set으로 닫습니다. -두 matrix의 `includeLifecycle`은 요청 scope의 queue shift와 tail lifecycle까지이며, -같은 actor의 수뇌→장수 제품 outer loop를 뜻하지 않습니다. 결합 outer lifecycle은 +장수·수뇌 명령 전체를 case마다 임시 MariaDB와 PHP Ref에 실행하던 matrix는 운영 +서비스가 안정화된 뒤 상시 회귀 비용이 각각 약 1시간에 달해 제거했습니다. 명령 key, +constraint와 log의 정적 계약은 위 `check:legacy:*` 명령이 계속 검사하고, 계산·RNG와 +오류 경계는 command별 logic/engine unit이 담당합니다. 결합 outer lifecycle은 `turnCommandFullLifecycle.integration.test.ts`, 실제 PostgreSQL flush/reload는 -`turnCommandFullLifecyclePersistence.integration.test.ts`가 대표 fixture로 검증합니다. +`turnCommandFullLifecyclePersistence.integration.test.ts`의 대표 fixture로 검증합니다. +새 호환성 의심은 전수 matrix를 복원하지 않고 해당 command의 좁은 fixture로 재현합니다. 기본 Core canonical/projection 계약: diff --git a/tools/compare-command-constraints.compat.json b/tools/compare-command-constraints.compat.json index b7540d93..1269f0df 100644 --- a/tools/compare-command-constraints.compat.json +++ b/tools/compare-command-constraints.compat.json @@ -139,6 +139,22 @@ "side": "php", "name": "alwaysFail", "note": "core preserves the legacy completed-false action in resolve-time handling" + }, + { + "id": "non-aggression invalid term becomes legacy dynamic AlwaysFail", + "command": "Nation/che_불가침제의", + "kind": "full", + "side": "php", + "name": "alwaysFail", + "note": "legacy inserts AlwaysFail for a term shorter than six months" + }, + { + "id": "non-aggression term range is explicit in core constraints", + "command": "Nation/che_불가침제의", + "kind": "full", + "side": "ts", + "name": "reqTreatyTermRange", + "note": "core exposes the equivalent six-month validation as a named constraint" } ] } diff --git a/tools/conditional-integration-file-registry.tsv b/tools/conditional-integration-file-registry.tsv index 9414b8c1..57a5ac5a 100644 --- a/tools/conditional-integration-file-registry.tsv +++ b/tools/conditional-integration-file-registry.tsv @@ -4,8 +4,6 @@ test/instantDiplomacyReference.integration.test.ts reference_command test/monthlyDisasterCoreReference.integration.test.ts reference_monthly test/turnCommandCoreReference.integration.test.ts reference_command test/turnCommandFullLifecycle.integration.test.ts reference_full_lifecycle -test/turnCommandGeneralMatrix.integration.test.ts reference_command -test/turnCommandNationMatrix.integration.test.ts reference_command test/turnCommandReference.integration.test.ts reference_command test/turnSnapshotReference.integration.test.ts reference_snapshot test/turnTraceFiles.integration.test.ts saved_trace_pair diff --git a/tools/integration-tests/test-lanes.tsv b/tools/integration-tests/test-lanes.tsv index a9cc9df6..371d47a4 100644 --- a/tools/integration-tests/test-lanes.tsv +++ b/tools/integration-tests/test-lanes.tsv @@ -17,8 +17,6 @@ test/troopStaticEvent.integration.test.ts conditional test/turnCommandCoreReference.integration.test.ts reference test/turnCommandFullLifecycle.integration.test.ts reference test/turnCommandFullLifecyclePersistence.integration.test.ts conditional -test/turnCommandGeneralMatrix.integration.test.ts reference -test/turnCommandNationMatrix.integration.test.ts reference test/turnCommandReference.integration.test.ts reference test/turnCommandRiskDurabilityMatrix.integration.test.ts conditional test/turnLogProjection.test.ts core diff --git a/tools/integration-tests/test/battleDifferential.test.ts b/tools/integration-tests/test/battleDifferential.test.ts index 919bc50d..e580a00e 100644 --- a/tools/integration-tests/test/battleDifferential.test.ts +++ b/tools/integration-tests/test/battleDifferential.test.ts @@ -869,6 +869,77 @@ const assertRngParity = (reference: ReferenceTrace, coreRng: TracingRng | null): assertCanonicalValue(coreRng?.boolCalls ?? [], reference.boolRng, 'boolRng'); }; +const FINAL_GENERAL_INTEGER_FIELDS = ['rice', 'experience', 'dedication'] as const; + +const roundLikePhp = (value: number): number => { + const corrected = value + Math.sign(value) * Number.EPSILON * Math.max(1, Math.abs(value)) * 4; + return corrected < 0 ? Math.ceil(corrected - 0.5) : Math.floor(corrected + 0.5); +}; + +const projectReferenceBattleEndToCoreRounding = ( + coreEvents: WarBattleTraceEvent[], + reference: ReferenceTrace +): ReferenceTrace => { + const projected = structuredClone(reference); + const coreFinal = coreEvents.at(-1); + const referenceFinal = projected.events.at(-1); + if (coreFinal?.event !== 'battle_end' || referenceFinal?.event !== 'battle_end') { + return projected; + } + + for (const side of ['attacker', 'defender'] as const) { + const coreUnit = coreFinal[side]; + const referenceUnit = referenceFinal[side]; + if (!coreUnit || !referenceUnit || coreUnit.kind !== 'general' || referenceUnit.kind !== 'general') { + continue; + } + const coreBeforeFinish = coreEvents + .slice(0, -1) + .reverse() + .flatMap((event) => [event.attacker, event.defender]) + .find((unit) => unit?.kind === 'general' && unit.id === coreUnit.id); + const referenceBeforeFinish = reference.events + .slice(0, -1) + .reverse() + .flatMap((event) => [event.attacker, event.defender]) + .find((unit) => unit?.kind === 'general' && unit.id === referenceUnit.id); + if (!coreBeforeFinish || !referenceBeforeFinish) { + continue; + } + + for (const field of FINAL_GENERAL_INTEGER_FIELDS) { + const coreValue = coreUnit.general?.[field]; + const referenceValue = referenceUnit.general?.[field]; + if (coreValue === referenceValue || coreValue === undefined || referenceValue === undefined) { + continue; + } + const coreRaw = coreBeforeFinish.general?.[field]; + const referenceRaw = referenceBeforeFinish.general?.[field]; + expectNearlyEqual(coreRaw, referenceRaw, `battle_end.${side}.${field}.raw`); + expect(coreValue, `battle_end.${side}.${field}: Core Math.round policy`).toBe(Math.round(coreRaw!)); + expect(referenceValue, `battle_end.${side}.${field}: Ref PHP round policy`).toBe( + roundLikePhp(referenceRaw!) + ); + referenceUnit.general![field] = coreValue; + } + } + + projected.attacker = referenceFinal.attacker; + projected.finishedDefenders = projected.finishedDefenders.map((unit) => { + if (unit.kind !== 'general') { + return unit; + } + for (const side of ['attacker', 'defender'] as const) { + const finalUnit = referenceFinal[side]; + if (finalUnit?.kind === 'general' && finalUnit.id === unit.id) { + return finalUnit; + } + } + return unit; + }); + return projected; +}; + const assertTraceParity = ( coreEvents: WarBattleTraceEvent[], reference: ReferenceTrace, @@ -910,8 +981,9 @@ const assertTraceParity = ( } } assertRngParity(reference, coreRng); - assertCanonicalValue(comparableCoreEvents, reference.events, 'events'); - assertFinalOutcomeParity(coreOutcome, coreEvents, reference); + const projectedReference = projectReferenceBattleEndToCoreRounding(comparableCoreEvents, reference); + assertCanonicalValue(comparableCoreEvents, projectedReference.events, 'events'); + assertFinalOutcomeParity(coreOutcome, coreEvents, projectedReference); }; const outcomeMetaNumber = (general: WarBattleOutcome['attacker'], key: string): number => { diff --git a/tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts b/tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts index a4d4c1ef..496eb59d 100644 --- a/tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts +++ b/tools/integration-tests/test/instantDiplomacyCoreReference.integration.test.ts @@ -267,15 +267,26 @@ const buildCoreCaller = (testCase: (typeof actionCases)[number]) => { const proposalBefore = structuredClone(messages[0]!); const findGeneral = (id: number) => (id === actor.id ? actor : id === proposer.id ? proposer : null); - const queryRaw = vi.fn(async (strings: TemplateStringsArray, ...values: unknown[]) => { + const queryRaw = vi.fn(async (query: unknown, ...taggedValues: unknown[]) => { + const queryObject = query as { strings?: readonly string[]; values?: readonly unknown[] }; + const strings = Array.isArray(query) ? query.map(String) : (queryObject.strings ?? []); + const values = Array.isArray(query) + ? taggedValues + : Array.isArray(queryObject.values) + ? [...queryObject.values] + : taggedValues; const sql = strings.join('?'); - if (sql.includes('FROM message') && sql.includes('WHERE id =')) { + if (sql.includes('FROM message') && (sql.includes('WHERE id =') || sql.includes('WHERE m.id ='))) { const id = Number(values[0]); const row = messages.find((message) => message.id === id); return row && row.valid_until.getTime() > Date.now() ? [row] : []; } if (sql.includes('INSERT INTO message')) { - const payload = JSON.parse(String(values[8])) as Record; + const payloadValue = [...values] + .reverse() + .find((value) => typeof value === 'string' && value.startsWith('{')); + if (payloadValue === undefined) throw new Error('Inserted message payload was not captured.'); + const payload = JSON.parse(payloadValue) as Record; const row: CoreMessageRow = { id: messages.at(-1)!.id + 1, mailbox: Number(values[0]), @@ -359,11 +370,14 @@ const buildCoreCaller = (testCase: (typeof actionCases)[number]) => { currentYear: 190, currentMonth: 3, config: { environment: { mapName: 'che' } }, - clockBaseTime: null, - clockTick: null, - clockMode: null, - clockWallAnchor: null, - tickSeconds: 60, + clockBaseTime: new Date('0190-03-01T00:00:00.000Z'), + clockTick: 1_000n, + clockMode: 'manual', + clockWallAnchor: new Date('2026-09-04T00:00:00.000Z'), + tickSeconds: 600, + clockPhase: 'RUNNING', + clockRevision: 1n, + deadlineGeneration: 1n, })), }, logEntry: { @@ -382,6 +396,9 @@ const buildCoreCaller = (testCase: (typeof actionCases)[number]) => { } ), }, + messageAction: { + updateMany: vi.fn(async () => ({ count: 0 })), + }, $queryRaw: queryRaw, }; @@ -404,7 +421,9 @@ const buildCoreCaller = (testCase: (typeof actionCases)[number]) => { auth, profile: { id: 'che', scenario: 'default', name: 'che:default' }, redis: {}, - turnDaemon: {}, + turnDaemon: { + requestCommand: vi.fn(async () => ({ type: 'syncDiplomaticResponse', ok: true })), + }, battleSim: {}, uploadDir: 'uploads', uploadPath: '/uploads', diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts deleted file mode 100644 index 1c77729b..00000000 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ /dev/null @@ -1,4472 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { asRecord, GAME_TICKS_PER_TURN, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; -import { GENERAL_TURN_COMMAND_KEYS } from '@sammo-ts/logic'; - -import type { CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js'; -import { compareTurnSnapshotDeltas, type SnapshotDifference } from '../src/turn-differential/compare.js'; -import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; -import { - normalizeStoredTurnLogText as normalizeStoredLogText, - orderedSemanticLogStreams, -} from '../src/turn-differential/logProjection.js'; -import { - projectSnapshotThroughRefFloatRead, - type RefFloatSnapshotProjection, -} from '../src/turn-differential/legacyNumericProjection.js'; -import { - projectSemanticTurnMessages, - projectSemanticUnreadMessageDeltas, - projectStrictTurnMessageTimeline, -} from '../src/turn-differential/messageProjection.js'; -import { - findTurnDifferentialWorkspaceRoot, - runReferenceTurnCommandTraceRequest, -} from '../src/turn-differential/referenceSnapshot.js'; - -const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; -const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd()); -const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); - -const semanticLogSignatures = (logs: Array>): string[] => - orderedSemanticLogStreams(logs, { omitRest: true }); - -const addedReferenceLogs = ( - before: { watermarks: { logId: number; historyLogId: number } }, - afterLogs: Array> -): Array> => - afterLogs.filter((entry) => { - const scope = String(entry.scope).toLowerCase(); - const category = String(entry.category).toLowerCase(); - const watermark = - scope === 'nation' || (scope === 'system' && category === 'history') - ? before.watermarks.historyLogId - : before.watermarks.logId; - return (Number(entry.id) || 0) > watermark; - }); - -const ignoredLifecyclePaths = [ - /^generalTurns/, - /^nationTurns/, - /^logs/, - /^messages/, - /^world\.turnTime$/, - /^world\.gameNow$/, - /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/, - /^generals\[1\]\.(?:turnTick|turnSecond|turnFraction)$/, - /^generals\[[^\]]+\]\.meta(?:\.|$)/, - /^nations\[[^\]]+\]\.meta(?:\.|$)/, -]; - -const successfulLifecycleIgnoredPaths = [ - /^nationTurns/, - /^logs/, - /^messages/, - /^world\.turnTime$/, - /^world\.gameNow$/, - /^generalTurns\[[^\]]+\]\.args(?:\.|$)/, - /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/, - /^generals\[[^\]]+\]\.meta(?:\.|$)/, - /^nations\[[^\]]+\]\.meta(?:\.|$)/, -]; - -const expectRefFloatProjectedDeltaParity = ( - reference: { before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot }, - core: { before: CanonicalTurnSnapshot; after: CanonicalTurnSnapshot }, - ignoredPathPatterns: RegExp[], - expectedRawDifferences: SnapshotDifference[], - projection: RefFloatSnapshotProjection -): void => { - const rawDifferences = compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns, - }); - expect(rawDifferences).toEqual(expectedRawDifferences); - expect( - compareTurnSnapshotDeltas( - reference.before, - reference.after, - projectSnapshotThroughRefFloatRead(core.before, projection), - projectSnapshotThroughRefFloatRead(core.after, projection), - { ignoredPathPatterns } - ) - ).toEqual([]); -}; - -const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record => ({ - id, - nationId, - cityId, - troopId: 0, - leadership: 90, - strength: 80, - intelligence: 70, - leadershipExp: 0, - strengthExp: 0, - intelExp: 0, - experience: 1000, - dedication: 1000, - expLevel: 0, - officerLevel, - officerCityId: officerLevel >= 5 ? cityId : 0, - injury: 0, - age: 30, - gold: 100_000, - rice: 100_000, - crew: 1_000, - crewTypeId: 1100, - train: 50, - atmos: 50, - dex1: 0, - dex2: 0, - dex3: 0, - dex4: 0, - dex5: 0, - killTurn: 24, - npcState: 0, - blockState: 0, - belong: 10, - permission: 'normal', - personality: 'None', - specialDomestic: 'None', - specialWar: 'None', - itemHorse: 'None', - itemWeapon: 'None', - itemBook: 'None', - itemExtra: 'None', - meta: {}, -}); - -interface FixturePatches { - world?: NonNullable['world']>; - generals?: Record>; - nations?: Record>; - cities?: Record>; - troops?: Array>; - diplomacy?: Record>; - randomFoundingCandidateCityIds?: number[]; - rankData?: Array<{ generalId: number; type: string; value: number }>; - additionalCities?: Array>; -} - -const buildRequest = ( - action: string, - args?: Record, - actorPatch: Record = {}, - fixturePatches: FixturePatches = {} -): TurnCommandFixtureRequest => ({ - kind: 'general', - actorGeneralId: 1, - action, - ...(args ? { args } : {}), - setup: { - isolateWorld: true, - world: { - startYear: 180, - year: 190, - month: 1, - develCost: 18, - isUnited: 0, - hiddenSeed: 'turn-command-general-matrix-v1', - freezeClock: true, - ...fixturePatches.world, - }, - nations: [ - { - id: 1, - name: '아국', - capitalCityId: 3, - gold: 1_000_000, - rice: 1_000_000, - tech: 1000, - level: 1, - typeCode: 'che_중립', - war: 0, - generalCount: 2, - meta: {}, - ...fixturePatches.nations?.[1], - }, - { - id: 2, - name: '타국', - capitalCityId: 70, - gold: 1_000_000, - rice: 1_000_000, - tech: 1000, - level: 1, - typeCode: 'che_중립', - war: 0, - generalCount: 1, - meta: {}, - ...fixturePatches.nations?.[2], - }, - ], - cities: [ - { - id: 3, - nationId: 1, - population: 100_000, - populationMax: 200_000, - agriculture: 1_000, - commerce: 1_000, - security: 1_000, - defence: 1_000, - wall: 1_000, - supplyState: 1, - frontState: 0, - state: 0, - term: 0, - trust: 80, - trade: 100, - ...fixturePatches.cities?.[3], - }, - { - id: 70, - nationId: 2, - population: 100_000, - populationMax: 200_000, - agriculture: 1_000, - commerce: 1_000, - security: 1_000, - defence: 1_000, - wall: 1_000, - supplyState: 1, - frontState: 1, - state: 0, - term: 0, - trust: 80, - trade: 100, - ...fixturePatches.cities?.[70], - }, - ...(fixturePatches.additionalCities ?? []), - ], - generals: [ - { ...general(1, 1, 3, 12), ...actorPatch, ...fixturePatches.generals?.[1] }, - { ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] }, - { ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] }, - ], - ...(fixturePatches.rankData ? { rankData: fixturePatches.rankData } : {}), - ...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}), - ...(fixturePatches.randomFoundingCandidateCityIds - ? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds } - : {}), - diplomacy: [ - { - fromNationId: 1, - toNationId: 2, - state: 0, - term: 12, - dead: 0, - ...fixturePatches.diplomacy?.['1:2'], - }, - { - fromNationId: 2, - toNationId: 1, - state: 0, - term: 12, - dead: 0, - ...fixturePatches.diplomacy?.['2:1'], - }, - ], - }, - observe: { - allGenerals: true, - allCities: true, - allNations: true, - allTroops: true, - generalIds: [1, 2, 3], - cityIds: [ - 3, - 70, - ...(fixturePatches.additionalCities?.map((city) => Number(city.id)).filter(Number.isFinite) ?? []), - ], - nationIds: [1, 2], - logAfterId: 0, - includeNationHistoryLogs: true, - includeGlobalHistoryLogs: true, - messageAfterId: 0, - }, -}); - -const cases: Array< - [string, Record | undefined, Record | undefined, FixturePatches?] -> = [ - ['휴식', undefined, undefined], - ['che_훈련', undefined, undefined], - ['cr_맹훈련', undefined, undefined], - ['che_전투태세', undefined, { lastTurn: { command: '전투태세', term: 3 } }], - ['che_단련', undefined, undefined], - ['che_사기진작', undefined, undefined], - ['che_요양', undefined, { injury: 30 }], - ['che_견문', undefined, undefined], - ['che_주민선정', undefined, undefined], - ['che_정착장려', undefined, undefined], - ['che_농지개간', undefined, undefined], - ['che_상업투자', undefined, undefined], - ['che_기술연구', undefined, undefined], - ['che_치안강화', undefined, undefined], - ['che_수비강화', undefined, undefined], - ['che_성벽보수', undefined, undefined], - ['che_인재탐색', undefined, undefined], - ['che_소집해제', undefined, undefined], - ['che_군량매매', { buyRice: true, amount: 100 }, undefined], - ['che_물자조달', undefined, undefined], - ['che_헌납', { isGold: true, amount: 100 }, undefined], - ['che_이동', { destCityID: 70 }, undefined], - ['che_강행', { destCityID: 70 }, undefined], - ['che_귀환', undefined, { cityId: 70 }], - ['che_접경귀환', undefined, { cityId: 70 }], - ['che_증여', { isGold: true, amount: 100, destGeneralID: 3 }, undefined], - ['che_첩보', { destCityID: 70 }, undefined], - ['che_화계', { destCityID: 70 }, undefined], - ['che_파괴', { destCityID: 70 }, undefined], - ['che_선동', { destCityID: 70 }, undefined], - ['che_탈취', { destCityID: 70 }, undefined], - ['che_모병', { crewType: 1100, amount: 100 }, undefined], - ['che_징병', { crewType: 1100, amount: 100 }, undefined], - ['che_숙련전환', { srcArmType: 1, destArmType: 2 }, { dex1: 100 }], - [ - 'che_내정특기초기화', - undefined, - { specialDomestic: 'che_인덕', lastTurn: { command: '내정 특기 초기화', term: 1 } }, - ], - ['che_전투특기초기화', undefined, { specialWar: 'che_귀병', lastTurn: { command: '전투 특기 초기화', term: 1 } }], - ['che_장비매매', { itemType: 'weapon', itemCode: 'che_무기_01_단도' }, undefined], - [ - 'che_출병', - { destCityID: 70 }, - { leadership: 100, strength: 100, intelligence: 100, crew: 10_000, train: 100, atmos: 100 }, - { - world: { startYear: 180, year: 185 }, - nations: { 2: { capitalCityId: 71, generalCount: 2 } }, - cities: { 70: { population: 10_000, defence: 1, wall: 1 } }, - generals: { - 2: { - cityId: 71, - officerLevel: 1, - officerCityId: 0, - rice: 10_000, - crew: 0, - train: 0, - atmos: 0, - npcState: 2, - }, - 3: { - nationId: 2, - cityId: 71, - officerLevel: 12, - officerCityId: 71, - rice: 10_000, - crew: 0, - train: 0, - atmos: 0, - npcState: 2, - }, - }, - additionalCities: [ - { - id: 71, - nationId: 2, - population: 100_000, - populationMax: 200_000, - agriculture: 1_000, - commerce: 1_000, - security: 1_000, - defence: 5_000, - wall: 5_000, - supplyState: 1, - frontState: 1, - state: 0, - term: 0, - trust: 80, - trade: 100, - }, - ], - }, - ], - ['che_하야', undefined, { officerLevel: 1 }], - ['che_은퇴', undefined, { age: 60, lastTurn: { command: '은퇴', term: 1 } }], - [ - 'che_임관', - { destNationID: 1 }, - { nationId: 0, officerLevel: 0 }, - { generals: { 3: { officerLevel: 12, officerCityId: 3 } } }, - ], - [ - 'che_랜덤임관', - undefined, - { nationId: 0, officerLevel: 0 }, - { generals: { 3: { officerLevel: 12, officerCityId: 3 } } }, - ], - ['che_장수대상임관', { destGeneralID: 2 }, { nationId: 0, officerLevel: 0 }], - ['che_등용', { destGeneralID: 2 }, undefined, { generals: { 2: { officerLevel: 1 } } }], - ['che_등용수락', { destNationID: 2, destGeneralID: 2 }, { nationId: 0, officerLevel: 0 }], - ['che_선양', { destGeneralID: 3 }, undefined], - ['che_NPC능동', { optionText: '순간이동', destCityID: 70 }, { npcState: 2 }], - ['che_방랑', undefined, undefined, { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } } }], - [ - 'che_해산', - undefined, - undefined, - { - nations: { 1: { name: '조조', level: 0, capitalCityId: 0, typeCode: 'None' } }, - cities: { 3: { nationId: 0, supplyState: 0, frontState: 0 } }, - }, - ], - [ - 'che_집합', - undefined, - { troopId: 1 }, - { - generals: { 3: { cityId: 70, troopId: 1 } }, - troops: [{ id: 1, nationId: 1, name: '조조군' }], - }, - ], - ['che_거병', undefined, { nationId: 0, officerLevel: 0 }, { world: { startYear: 180, year: 181 } }], - [ - 'che_모반시도', - undefined, - { officerLevel: 11 }, - { generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 0 } } }, - ], - [ - 'che_건국', - { nationName: '신국', nationType: 'che_도적', colorType: 1 }, - undefined, - { - world: { startYear: 180, initYear: 180, initMonth: 1, year: 181 }, - nations: { 1: { name: '조조', level: 0, capitalCityId: 0, typeCode: 'None' } }, - cities: { 3: { nationId: 0, level: 5 } }, - }, - ], - [ - 'cr_건국', - { nationName: '신국', nationType: 'che_도적', colorType: 1 }, - undefined, - { - world: { startYear: 180, initYear: 180, initMonth: 1, year: 181 }, - nations: { 1: { name: '조조', level: 0, capitalCityId: 0, typeCode: 'None' } }, - cities: { 3: { nationId: 0, level: 5 } }, - }, - ], - [ - 'che_무작위건국', - { nationName: '신국', nationType: 'che_도적', colorType: 1 }, - undefined, - { - world: { startYear: 180, initYear: 180, initMonth: 1, year: 181 }, - nations: { 1: { name: '조조', level: 0, capitalCityId: 0, typeCode: 'None' } }, - cities: { - 3: { nationId: 0, level: 5 }, - 70: { nationId: 0, level: 5 }, - }, - randomFoundingCandidateCityIds: [3, 70], - }, - ], -]; - -integration('general command success matrix', () => { - it.each(cases)( - '%s matches the legacy state delta and command RNG', - async (action, args, actorPatch, fixturePatches) => { - const request = buildRequest(action, args, actorPatch, fixturePatches); - request.includeLifecycle = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - if (process.env.TURN_DIFFERENTIAL_DEBUG === '1') { - process.stderr.write( - `${JSON.stringify( - { - action, - coreOutcome: core.execution.outcome, - referenceRng: reference.rng, - coreRng: core.rng, - referenceGeneralDelta: compareTurnSnapshotDeltas( - reference.before, - reference.after, - reference.before, - reference.before, - { ignoredPathPatterns: successfulLifecycleIgnoredPaths } - ).filter((entry) => entry.path.startsWith('generals')), - coreGeneralDelta: compareTurnSnapshotDeltas( - core.before, - core.after, - core.before, - core.before, - { ignoredPathPatterns: successfulLifecycleIgnoredPaths } - ).filter((entry) => entry.path.startsWith('generals')), - referenceGenerals: reference.after.generals, - coreGenerals: core.after.generals, - }, - null, - 2 - )}\n` - ); - } - - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).not.toHaveProperty('blockedReason'); - expect(core.rng).toEqual(reference.rng); - - const actorGeneralId = request.actorGeneralId; - const referenceBeforeActor = reference.before.generals.find((general) => general.id === actorGeneralId); - const referenceAfterActor = reference.after.generals.find((general) => general.id === actorGeneralId); - const coreBeforeActor = core.before.generals.find((general) => general.id === actorGeneralId); - const coreAfterActor = core.after.generals.find((general) => general.id === actorGeneralId); - const actorTurnAt = (turns: Array>, turnIndex: number) => - turns.find((turn) => turn.generalId === actorGeneralId && turn.turnIndex === turnIndex); - expect(actorTurnAt(reference.before.generalTurns, 0)?.action).toBe(action); - expect(actorTurnAt(core.before.generalTurns, 0)?.action).toBe(action); - if (referenceAfterActor) { - expect(actorTurnAt(reference.after.generalTurns, 0)?.action).toBe('휴식'); - expect(actorTurnAt(core.after.generalTurns, 0)?.action).toBe('휴식'); - expect(Number(referenceAfterActor.turnTick) - Number(referenceBeforeActor?.turnTick)).toBe( - GAME_TICKS_PER_TURN - ); - expect(Number(coreAfterActor?.turnTick) - Number(coreBeforeActor?.turnTick)).toBe(GAME_TICKS_PER_TURN); - } else { - expect(coreAfterActor).toBeUndefined(); - expect(actorTurnAt(reference.after.generalTurns, 0)).toBeUndefined(); - expect(actorTurnAt(core.after.generalTurns, 0)).toBeUndefined(); - } - expectRefFloatProjectedDeltaParity( - reference, - core, - successfulLifecycleIgnoredPaths, - action === 'che_출병' - ? [ - { - path: 'nations[1].tech', - reference: { $snapshotState: 'missing' }, - core: 0.004800000000045657, - }, - { - path: 'nations[2].tech', - reference: 0.009999999999990905, - core: 0.009000000000014552, - }, - ] - : [], - { nationTech: action === 'che_출병' } - ); - - // Logs and messages live outside the generic state-delta graph. - // Assert both for every registered success case so a command cannot - // stay green merely because those paths are excluded above. - expect(semanticLogSignatures(addedReferenceLogs(core.before, core.after.logs))).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - const messageAfterId = reference.before.watermarks.messageId; - const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); - const referenceTimeline = projectStrictTurnMessageTimeline( - reference.before, - reference.after, - messageAfterId - ); - expect({ - unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), - messages: projectSemanticTurnMessages(core.after.messages, messageAfterId), - timeline: coreTimeline, - }).toEqual({ - unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), - messages: projectSemanticTurnMessages(reference.after.messages, messageAfterId), - timeline: referenceTimeline, - }); - expect(referenceTimeline.usesSingleTick).toBe(true); - if (action === 'che_이동' || action === 'che_강행') { - const actionLogSuffix = action === 'che_이동' ? '이동했습니다.' : '강행했습니다.'; - expect(core.after.logs.some((entry) => String(entry.text).includes('도시('))).toBe(false); - expect( - addedReferenceLogs(reference.before, reference.after.logs).some((entry) => - String(entry.text).includes(actionLogSuffix) - ) - ).toBe(true); - } - }, - 120_000 - ); -}); - -integration('turn command fixture clock validation', () => { - it('rejects a non-boolean setup.world.freezeClock', () => { - const request = buildRequest('휴식'); - const setup = request.setup!; - const world = setup.world!; - const invalidRequest = { - ...request, - setup: { - ...setup, - world: { - ...world, - freezeClock: 'yes', - }, - }, - }; - - expect(() => - runReferenceTurnCommandTraceRequest(workspaceRoot!, invalidRequest as unknown as Record) - ).toThrow(/setup\.world\.freezeClock must be a boolean/); - }); -}); - -type GeneralActiveActionInheritanceCase = { - name: string; - action: string; - args?: Record; - initialPoint?: number; - expectedPointDelta?: number; -}; - -const generalActiveActionInheritanceCases: GeneralActiveActionInheritanceCase[] = [ - { - name: 'ordinary training does not count as a legacy active action', - action: 'che_훈련', - initialPoint: 6, - expectedPointDelta: 0, - }, - { - name: 'spy contributes the legacy half active action', - action: 'che_첩보', - args: { destCityID: 70 }, - expectedPointDelta: 1.5, - }, - { - name: 'abdication contributes one active action', - action: 'che_선양', - args: { destGeneralID: 3 }, - expectedPointDelta: 3, - }, - { - name: 'rest does not count as a legacy active action', - action: '휴식', - expectedPointDelta: 0, - }, - { - name: 'talent scouting preserves its probability-weighted active action', - action: 'che_인재탐색', - }, -]; - -const readActiveActionPoints = (snapshot: { generals: Array> }, generalId: number): number => { - const general = snapshot.generals.find((entry) => entry.id === generalId); - const value = general?.inheritActiveActionPoints; - return typeof value === 'number' && Number.isFinite(value) ? value : 0; -}; - -integration('general active-action inheritance point parity', () => { - it.each(generalActiveActionInheritanceCases)( - '$name', - async ({ name, action, args, initialPoint = 0, expectedPointDelta }) => { - const request = buildRequest(action, args, { - ownerId: 2_000_000_001, - inheritActiveActionPoints: initialPoint, - }); - request.setup!.world!.hiddenSeed = `general-active-action-${name}`; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referencePointDelta = - readActiveActionPoints(reference.after, 1) - readActiveActionPoints(reference.before, 1); - const corePointDelta = readActiveActionPoints(core.after, 1) - readActiveActionPoints(core.before, 1); - - expect(readActiveActionPoints(reference.before, 1)).toBe(initialPoint); - expect(readActiveActionPoints(core.before, 1)).toBe(initialPoint); - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - if (expectedPointDelta !== undefined) { - expect(referencePointDelta).toBe(expectedPointDelta); - } else { - expect(referencePointDelta).toBeGreaterThan(0); - } - expect(corePointDelta).toBe(referencePointDelta); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -interface NpcActiveBoundaryCase { - name: string; - args: Record; - actorPatch: Record; - completed: boolean; -} - -const npcActiveBoundaryCases: NpcActiveBoundaryCase[] = [ - { - name: 'execution does not reapply the reservation-only NPC permission', - args: { optionText: '순간이동', destCityID: 70 }, - actorPatch: { npcState: 0 }, - completed: true, - }, - { - name: 'a missing destination city is rejected before execution', - args: { optionText: '순간이동', destCityID: 999_999 }, - actorPatch: { npcState: 2 }, - completed: false, - }, - { - name: 'a numeric-string destination follows the legacy weak integer argument', - args: { optionText: '순간이동', destCityID: '70' }, - actorPatch: { npcState: 2 }, - completed: true, - }, - { - name: 'a fractional destination preserves the legacy split lookup and storage coercion', - args: { optionText: '순간이동', destCityID: 70.9 }, - actorPatch: { npcState: 2 }, - completed: true, - }, -]; - -integration('NPC active command boundary parity', () => { - it.each(npcActiveBoundaryCases)( - '$name', - async ({ name, args, actorPatch, completed }) => { - const request = buildRequest('che_NPC능동', args, actorPatch); - request.setup!.world!.hiddenSeed = `general-npc-active-${name}`; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_NPC능동', - actionKey: completed ? 'che_NPC능동' : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - if (completed) { - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - expect(core.after.logs.some((entry) => String(entry.text).includes('도시('))).toBe(false); - } - }, - 120_000 - ); -}); - -interface DisbandFactionBoundaryCase { - name: string; - actorPatch?: Record; - fixturePatches: FixturePatches; - completed: boolean; - compareLogs?: boolean; -} - -const disbandFactionBoundaryCases: DisbandFactionBoundaryCase[] = [ - { - name: 'a non-lord cannot disband the wandering nation', - actorPatch: { officerLevel: 11 }, - fixturePatches: { - nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } }, - cities: { 3: { nationId: 0, supplyState: 0, frontState: 0 } }, - }, - completed: false, - }, - { - name: 'a settled nation cannot be disbanded', - fixturePatches: { - nations: { 1: { level: 1 } }, - }, - completed: false, - }, - { - name: 'disbanding preserves the legacy member state, resources, and destruction logs', - actorPatch: { - troopId: 3, - belong: 5, - permission: 'ambassador', - gold: 2_000, - rice: 3_000, - meta: { max_belong: 2 }, - }, - fixturePatches: { - nations: { 1: { name: '방랑군', level: 0, capitalCityId: 0, typeCode: 'None' } }, - cities: { 3: { nationId: 0, supplyState: 0, frontState: 0 } }, - generals: { - 3: { - troopId: 3, - belong: 7, - permission: 'auditor', - gold: 2_500, - rice: 4_000, - meta: { max_belong: 3 }, - }, - }, - troops: [{ id: 3, nationId: 1, name: '방랑군 부대' }], - }, - completed: true, - compareLogs: true, - }, -]; - -integration('general faction disband boundary, state, and log parity', () => { - it.each(disbandFactionBoundaryCases)( - '$name', - async ({ name, actorPatch, fixturePatches, completed, compareLogs }) => { - const request = buildRequest('che_해산', undefined, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = `general-disband-${name}`; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - if (process.env.TURN_DIFFERENTIAL_DEBUG === '1') { - process.stderr.write( - `${JSON.stringify( - { - name, - referenceGenerals: reference.after.generals, - coreGenerals: core.after.generals, - referenceLogs: addedReferenceLogs(reference.before, reference.after.logs), - coreLogs: core.after.logs, - }, - null, - 2 - )}\n` - ); - } - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_해산', - actionKey: completed ? 'che_해산' : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - if (compareLogs) { - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - }, - 120_000 - ); -}); - -interface SelfStateBoundaryCase { - name: string; - action: 'che_단련' | 'che_숙련전환' | 'che_사기진작' | 'che_요양'; - args?: Record; - actorPatch?: Record; - fixturePatches?: FixturePatches; - completed: boolean; -} - -const selfStateBoundaryCases: SelfStateBoundaryCase[] = [ - { name: 'drill rejects a neutral actor', action: 'che_단련', actorPatch: { nationId: 0 }, completed: false }, - { name: 'drill rejects zero crew', action: 'che_단련', actorPatch: { crew: 0 }, completed: false }, - { - name: 'drill rejects training one below the floor', - action: 'che_단련', - actorPatch: { train: 39 }, - completed: false, - }, - { - name: 'drill rejects morale one below the floor', - action: 'che_단련', - actorPatch: { atmos: 39 }, - completed: false, - }, - { name: 'drill rejects insufficient gold', action: 'che_단련', actorPatch: { gold: 0 }, completed: false }, - { name: 'drill rejects insufficient rice', action: 'che_단련', actorPatch: { rice: 0 }, completed: false }, - { - name: 'drill accepts the exact training and morale floors', - action: 'che_단련', - actorPatch: { train: 40, atmos: 40, crew: 1001 }, - completed: true, - }, - { - name: 'dex transfer rejects numeric-string arm types', - action: 'che_숙련전환', - args: { srcArmType: '1', destArmType: 2 }, - completed: false, - }, - { - name: 'dex transfer rejects fractional arm types', - action: 'che_숙련전환', - args: { srcArmType: 1.9, destArmType: 2 }, - completed: false, - }, - { - name: 'dex transfer rejects the same source and destination', - action: 'che_숙련전환', - args: { srcArmType: 1, destArmType: 1 }, - completed: false, - }, - { - name: 'dex transfer rejects an unknown arm type', - action: 'che_숙련전환', - args: { srcArmType: 99, destArmType: 2 }, - completed: false, - }, - { - name: 'dex transfer rejects a neutral actor', - action: 'che_숙련전환', - args: { srcArmType: 1, destArmType: 2 }, - actorPatch: { nationId: 0 }, - completed: false, - }, - { - name: 'dex transfer rejects a foreign-occupied city', - action: 'che_숙련전환', - args: { srcArmType: 1, destArmType: 2 }, - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'dex transfer rejects insufficient gold', - action: 'che_숙련전환', - args: { srcArmType: 1, destArmType: 2 }, - actorPatch: { gold: 0 }, - completed: false, - }, - { - name: 'dex transfer rejects insufficient rice', - action: 'che_숙련전환', - args: { srcArmType: 1, destArmType: 2 }, - actorPatch: { rice: 0 }, - completed: false, - }, - { - name: 'dex transfer preserves integer truncation at a low source value', - action: 'che_숙련전환', - args: { srcArmType: 1, destArmType: 2 }, - actorPatch: { dex1: 3, dex2: 7 }, - completed: true, - }, - { - name: 'morale boost rejects a neutral actor', - action: 'che_사기진작', - actorPatch: { nationId: 0 }, - completed: false, - }, - { - name: 'morale boost rejects a wandering nation', - action: 'che_사기진작', - fixturePatches: { nations: { 1: { level: 0 } } }, - completed: false, - }, - { - name: 'morale boost rejects a foreign-occupied city', - action: 'che_사기진작', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { name: 'morale boost rejects zero crew', action: 'che_사기진작', actorPatch: { crew: 0 }, completed: false }, - { - name: 'morale boost rejects insufficient gold', - action: 'che_사기진작', - actorPatch: { gold: 0 }, - completed: false, - }, - { - name: 'morale boost rejects the command maximum', - action: 'che_사기진작', - actorPatch: { atmos: 100 }, - completed: false, - }, - { - name: 'morale boost clamps at the command maximum', - action: 'che_사기진작', - actorPatch: { atmos: 99, train: 51, crew: 1001 }, - completed: true, - }, - { - name: 'recovery always clears injury without resource cost', - action: 'che_요양', - actorPatch: { nationId: 0, injury: 80, gold: 0, rice: 0 }, - completed: true, - }, -]; - -integration('general self-state command boundary parity', () => { - it.each(selfStateBoundaryCases)( - '$name', - async ({ name, action, args, actorPatch, fixturePatches, completed }) => { - const request = buildRequest(action, args, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = `general-self-state-${name}`; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: completed ? action : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - if (completed) { - const referenceActor = reference.after.generals.find((entry) => entry.id === 1); - const coreActor = core.after.generals.find((entry) => entry.id === 1); - expect(coreActor).toMatchObject({ - dex1: referenceActor?.dex1, - dex2: referenceActor?.dex2, - leadershipExp: referenceActor?.leadershipExp, - experience: referenceActor?.experience, - dedication: referenceActor?.dedication, - injury: referenceActor?.injury, - gold: referenceActor?.gold, - rice: referenceActor?.rice, - train: referenceActor?.train, - atmos: referenceActor?.atmos, - }); - } - }, - 120_000 - ); -}); - -integration('general sightseeing event, RNG, value, and log parity', () => { - it.each(Array.from({ length: 20 }, (_, index) => index))( - 'matches legacy sightseeing seed %i', - async (seedIndex) => { - const request = buildRequest( - 'che_견문', - undefined, - { injury: seedIndex % 2 === 0 ? 0 : 75, gold: seedIndex % 3 === 0 ? 100 : 100_000, rice: 100 }, - { world: { hiddenSeed: `general-sightseeing-${seedIndex}` } } - ); - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_견문', - actionKey: 'che_견문', - usedFallback: false, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - }, - 120_000 - ); -}); - -interface MovementBoundaryCase { - name: string; - action: 'che_강행' | 'che_귀환' | 'che_접경귀환' | 'che_집합' | 'che_방랑'; - args?: Record; - actorPatch?: Record; - fixturePatches?: FixturePatches; - completed: boolean; - fallback?: boolean; -} - -const movementBoundaryCases: MovementBoundaryCase[] = [ - { - name: 'forced move accepts a numeric-string city ID', - action: 'che_강행', - args: { destCityID: '70' }, - completed: true, - }, - { - name: 'forced move truncates a fractional city ID', - action: 'che_강행', - args: { destCityID: 70.9 }, - completed: true, - }, - { - name: 'forced move rejects the current city', - action: 'che_강행', - args: { destCityID: 3 }, - completed: false, - }, - { - name: 'forced move rejects an unknown city', - action: 'che_강행', - args: { destCityID: 999 }, - completed: false, - }, - { - name: 'forced move rejects insufficient gold', - action: 'che_강행', - args: { destCityID: 70 }, - actorPatch: { gold: 0 }, - completed: false, - }, - { - name: 'forced move preserves the training and morale floors', - action: 'che_강행', - args: { destCityID: 70 }, - actorPatch: { train: 20, atmos: 20 }, - completed: true, - }, - { - name: 'forced move carries every wandering-nation general', - action: 'che_강행', - args: { destCityID: 70 }, - fixturePatches: { nations: { 1: { level: 0 } } }, - completed: true, - }, - { - name: 'return rejects a neutral actor', - action: 'che_귀환', - actorPatch: { nationId: 0 }, - completed: false, - }, - { - name: 'return rejects a wandering nation', - action: 'che_귀환', - fixturePatches: { nations: { 1: { level: 0 } } }, - completed: false, - }, - { - name: 'return rejects a lord already in the capital', - action: 'che_귀환', - completed: false, - }, - { - name: 'return lets a local officer leave the capital for the assigned city', - action: 'che_귀환', - actorPatch: { officerLevel: 2, officerCityId: 70 }, - fixturePatches: { cities: { 70: { nationId: 1 } } }, - completed: true, - }, - { - name: 'return rewards an officer already in the assigned non-capital city', - action: 'che_귀환', - actorPatch: { officerLevel: 4, officerCityId: 70, cityId: 70 }, - fixturePatches: { cities: { 70: { nationId: 1 } } }, - completed: true, - }, - { - name: 'return sends a normal officer to the capital', - action: 'che_귀환', - actorPatch: { officerLevel: 1, cityId: 70 }, - completed: true, - }, - { - name: 'border return rejects a neutral actor', - action: 'che_접경귀환', - actorPatch: { nationId: 0, cityId: 70 }, - completed: false, - }, - { - name: 'border return rejects a wandering nation', - action: 'che_접경귀환', - actorPatch: { cityId: 70 }, - fixturePatches: { nations: { 1: { level: 0 } } }, - completed: false, - }, - { - name: 'border return rejects an actor already in an owned city', - action: 'che_접경귀환', - completed: false, - }, - { - name: 'border return fails without a supplied owned city in range', - action: 'che_접경귀환', - actorPatch: { cityId: 70 }, - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - completed: false, - fallback: false, - }, - { - name: 'border return consumes the single-candidate choice and moves', - action: 'che_접경귀환', - actorPatch: { cityId: 70 }, - completed: true, - }, - { - name: 'assembly rejects a neutral actor', - action: 'che_집합', - actorPatch: { nationId: 0, troopId: 1 }, - fixturePatches: { troops: [{ id: 1, nationId: 1, name: '조조군' }] }, - completed: false, - }, - { - name: 'assembly rejects a foreign-occupied city', - action: 'che_집합', - actorPatch: { troopId: 1 }, - fixturePatches: { - cities: { 3: { nationId: 2 } }, - troops: [{ id: 1, nationId: 1, name: '조조군' }], - }, - completed: false, - }, - { - name: 'assembly rejects an unsupplied city', - action: 'che_집합', - actorPatch: { troopId: 1 }, - fixturePatches: { - cities: { 3: { supplyState: 0 } }, - troops: [{ id: 1, nationId: 1, name: '조조군' }], - }, - completed: false, - }, - { - name: 'assembly rejects a non-leader', - action: 'che_집합', - actorPatch: { troopId: 3 }, - fixturePatches: { - generals: { 3: { troopId: 3 } }, - troops: [{ id: 3, nationId: 1, name: '조조군' }], - }, - completed: false, - }, - { - name: 'assembly rejects a troop without another member', - action: 'che_집합', - actorPatch: { troopId: 1 }, - fixturePatches: { troops: [{ id: 1, nationId: 1, name: '조조군' }] }, - completed: false, - }, - { - name: 'assembly completes when every member is already present', - action: 'che_집합', - actorPatch: { troopId: 1 }, - fixturePatches: { - generals: { 3: { troopId: 1 } }, - troops: [{ id: 1, nationId: 1, name: '조조군' }], - }, - completed: true, - }, - { - name: 'assembly moves a remote member and records its plain log', - action: 'che_집합', - actorPatch: { troopId: 1 }, - fixturePatches: { - generals: { 3: { cityId: 70, troopId: 1 } }, - troops: [{ id: 1, nationId: 1, name: '조조군' }], - }, - completed: true, - }, - { - name: 'wander rejects a non-lord', - action: 'che_방랑', - actorPatch: { officerLevel: 11 }, - fixturePatches: { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } } }, - completed: false, - }, - { - name: 'wander rejects an already wandering nation', - action: 'che_방랑', - fixturePatches: { - nations: { 1: { level: 0 } }, - diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } }, - }, - completed: false, - }, - { - name: 'wander rejects the opening phase', - action: 'che_방랑', - fixturePatches: { - world: { year: 180 }, - diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } }, - }, - completed: false, - }, - { - name: 'wander rejects a disallowed diplomacy state', - action: 'che_방랑', - completed: false, - }, - { - name: 'wander accepts neutral diplomacy', - action: 'che_방랑', - fixturePatches: { diplomacy: { '1:2': { state: 2 }, '2:1': { state: 2 } } }, - completed: true, - }, - { - name: 'wander accepts non-aggression diplomacy', - action: 'che_방랑', - fixturePatches: { diplomacy: { '1:2': { state: 7 }, '2:1': { state: 7 } } }, - completed: true, - }, -]; - -integration('general movement command boundary and log parity', () => { - it.each(movementBoundaryCases)( - '$name', - async ({ name, action, args, actorPatch, fixturePatches, completed, fallback }) => { - const request = buildRequest(action, args, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = `general-movement-${name}`; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const usedFallback = fallback ?? !completed; - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: usedFallback ? '휴식' : action, - usedFallback, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - if (completed) { - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - }, - 120_000 - ); -}); - -interface DomesticBoundaryCase { - name: string; - action: 'che_성벽보수' | 'che_수비강화' | 'che_치안강화' | 'che_기술연구' | 'che_정착장려' | 'che_물자조달'; - actorPatch?: Record; - fixturePatches?: FixturePatches; - completed: boolean; -} - -const domesticBoundaryCases: DomesticBoundaryCase[] = [ - { - name: 'wall repair rejects a neutral actor', - action: 'che_성벽보수', - actorPatch: { nationId: 0 }, - completed: false, - }, - { - name: 'wall repair rejects a wandering nation', - action: 'che_성벽보수', - fixturePatches: { nations: { 1: { level: 0 } } }, - completed: false, - }, - { - name: 'wall repair rejects a foreign-occupied city', - action: 'che_성벽보수', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'wall repair rejects an unsupplied city', - action: 'che_성벽보수', - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - completed: false, - }, - { - name: 'wall repair rejects insufficient gold', - action: 'che_성벽보수', - actorPatch: { gold: 0 }, - completed: false, - }, - { - name: 'wall repair rejects a wall already at capacity', - action: 'che_성벽보수', - fixturePatches: { cities: { 3: { wall: 1000, wallMax: 1000 } } }, - completed: false, - }, - { - name: 'wall repair preserves the early-capital front debuff', - action: 'che_성벽보수', - fixturePatches: { cities: { 3: { wall: 0, wallMax: 10_000, frontState: 1 } } }, - completed: true, - }, - { - name: 'defence reinforcement rejects a defence already at capacity', - action: 'che_수비강화', - fixturePatches: { cities: { 3: { defence: 1000, defenceMax: 1000 } } }, - completed: false, - }, - { - name: 'defence reinforcement preserves the early-capital front debuff', - action: 'che_수비강화', - fixturePatches: { cities: { 3: { defence: 0, defenceMax: 10_000, frontState: 1 } } }, - completed: true, - }, - { - name: 'security reinforcement rejects security already at capacity', - action: 'che_치안강화', - fixturePatches: { cities: { 3: { security: 1000, securityMax: 1000 } } }, - completed: false, - }, - { - name: 'security reinforcement preserves the early-capital front rule', - action: 'che_치안강화', - fixturePatches: { cities: { 3: { security: 0, securityMax: 10_000, frontState: 1 } } }, - completed: true, - }, - { - name: 'tech research rejects a neutral actor', - action: 'che_기술연구', - actorPatch: { nationId: 0 }, - completed: false, - }, - { - name: 'tech research rejects a wandering nation', - action: 'che_기술연구', - fixturePatches: { nations: { 1: { level: 0 } } }, - completed: false, - }, - { - name: 'tech research rejects a foreign-occupied city', - action: 'che_기술연구', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'tech research rejects an unsupplied city', - action: 'che_기술연구', - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - completed: false, - }, - { - name: 'tech research rejects insufficient gold', - action: 'che_기술연구', - actorPatch: { gold: 0 }, - completed: false, - }, - { - name: 'tech research uses stored nation general count at the level cap', - action: 'che_기술연구', - fixturePatches: { nations: { 1: { tech: 3000, generalCount: 11 } } }, - completed: true, - }, - { - name: 'settlement encouragement rejects insufficient rice', - action: 'che_정착장려', - actorPatch: { rice: 0 }, - completed: false, - }, - { - name: 'settlement encouragement rejects a city at population capacity', - action: 'che_정착장려', - fixturePatches: { cities: { 3: { population: 200_000, populationMax: 200_000 } } }, - completed: false, - }, - { - name: 'settlement encouragement clamps at population capacity', - action: 'che_정착장려', - fixturePatches: { cities: { 3: { population: 199_999, populationMax: 200_000 } } }, - completed: true, - }, - { - name: 'procurement rejects a neutral actor', - action: 'che_물자조달', - actorPatch: { nationId: 0 }, - completed: false, - }, - { - name: 'procurement rejects a wandering nation', - action: 'che_물자조달', - fixturePatches: { nations: { 1: { level: 0 } } }, - completed: false, - }, - { - name: 'procurement rejects a foreign-occupied city', - action: 'che_물자조달', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'procurement rejects an unsupplied city', - action: 'che_물자조달', - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - completed: false, - }, - { - name: 'procurement succeeds without owned gold or rice', - action: 'che_물자조달', - actorPatch: { gold: 0, rice: 0 }, - completed: true, - }, -]; - -integration('general domestic command boundary, value, and log parity', () => { - it.each(domesticBoundaryCases)( - '$name', - async ({ name, action, actorPatch, fixturePatches, completed }) => { - const request = buildRequest(action, undefined, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = `general-domestic-${name}`; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: completed ? action : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - if (completed) { - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - }, - 120_000 - ); -}); - -interface CoreDomesticBoundaryCase { - name: string; - action: 'che_농지개간' | 'che_상업투자' | 'che_주민선정' | 'che_정착장려' | 'che_기술연구'; - actorPatch?: Record; - fixturePatches?: FixturePatches; - hiddenSeed: string; - completed: boolean; -} - -const coreDomesticBoundaryCases: CoreDomesticBoundaryCase[] = [ - { - name: 'agriculture rejects a city at its exact capacity', - action: 'che_농지개간', - fixturePatches: { cities: { 3: { agriculture: 1_000, agricultureMax: 1_000 } } }, - hiddenSeed: 'general-core-domestic-agriculture-capacity', - completed: false, - }, - { - name: 'agriculture preserves the early capital front adjustment', - action: 'che_농지개간', - fixturePatches: { - world: { year: 183 }, - cities: { 3: { frontState: 1, agriculture: 1_000, agricultureMax: 2_000 } }, - }, - hiddenSeed: 'turn-command-general-matrix-v1', - completed: true, - }, - { - name: 'commerce preserves the non-capital front debuff and integer city storage', - action: 'che_상업투자', - fixturePatches: { - nations: { 1: { capitalCityId: 70 } }, - cities: { 3: { frontState: 1, commerce: 1_000, commerceMax: 2_000 } }, - }, - hiddenSeed: 'turn-command-general-matrix-v1', - completed: true, - }, - { - name: 'resident selection preserves fractional FLOAT storage and accumulates critical score by half', - action: 'che_주민선정', - actorPatch: { meta: { max_domestic_critical: 10 } }, - fixturePatches: { - cities: { 3: { trust: 10 } }, - }, - hiddenSeed: 'turn-command-general-matrix-v1', - completed: true, - }, - { - name: 'settlement critical success uses the shared half-score accumulator', - action: 'che_정착장려', - actorPatch: { meta: { max_domestic_critical: 10 } }, - hiddenSeed: 'turn-command-general-matrix-v1', - completed: true, - }, - { - name: 'technology critical success uses the shared half-score accumulator', - action: 'che_기술연구', - actorPatch: { meta: { max_domestic_critical: 10 } }, - hiddenSeed: 'turn-command-general-matrix-v1', - completed: true, - }, - { - name: 'commerce failure resets the current domestic critical accumulator', - action: 'che_상업투자', - actorPatch: { meta: { max_domestic_critical: 10 } }, - hiddenSeed: 'general-failure-che_상업투자-2', - completed: true, - }, -]; - -integration('core domestic command critical, front, and storage boundary parity', () => { - it.each(coreDomesticBoundaryCases)( - '$name', - async ({ action, actorPatch, fixturePatches, hiddenSeed, completed }) => { - const request = buildRequest(action, undefined, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = hiddenSeed; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: completed ? action : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - if (completed) { - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - }, - 120_000 - ); -}); - -interface MilitaryPreparationBoundaryCase { - name: string; - action: 'che_모병' | 'che_징병' | 'che_장비매매' | 'che_소집해제' | 'cr_맹훈련'; - args?: Record; - actorPatch?: Record; - fixturePatches?: FixturePatches; - completed: boolean; -} - -const militaryPreparationBoundaryCases: MilitaryPreparationBoundaryCase[] = [ - { - name: 'recruitment rejects a numeric-string crew type', - action: 'che_징병', - args: { crewType: '1100', amount: 100 }, - completed: false, - }, - { - name: 'recruitment rejects a fractional crew type', - action: 'che_징병', - args: { crewType: 1100.9, amount: 100 }, - completed: false, - }, - { - name: 'recruitment accepts a numeric-string amount', - action: 'che_징병', - args: { crewType: 1100, amount: '100' }, - completed: true, - }, - { - name: 'recruitment rejects a non-decimal numeric-looking amount', - action: 'che_징병', - args: { crewType: 1100, amount: '0x64' }, - completed: false, - }, - { - name: 'recruitment truncates a fractional amount', - action: 'che_징병', - args: { crewType: 1100, amount: 100.9 }, - completed: true, - }, - { - name: 'recruitment raises zero amount to the minimum', - action: 'che_징병', - args: { crewType: 1100, amount: 0 }, - completed: true, - }, - { - name: 'recruitment rejects a negative amount', - action: 'che_징병', - args: { crewType: 1100, amount: -1 }, - completed: false, - }, - { - name: 'recruitment rejects an unknown crew type', - action: 'che_징병', - args: { crewType: 9999, amount: 100 }, - completed: false, - }, - { - name: 'recruitment rejects a neutral actor', - action: 'che_징병', - args: { crewType: 1100, amount: 100 }, - actorPatch: { nationId: 0 }, - completed: false, - }, - { - name: 'recruitment rejects a foreign-occupied city', - action: 'che_징병', - args: { crewType: 1100, amount: 100 }, - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'recruitment rejects population below the fixed reserve', - action: 'che_징병', - args: { crewType: 1100, amount: 100 }, - fixturePatches: { cities: { 3: { population: 30_099 } } }, - completed: false, - }, - { - name: 'recruitment accepts the exact population reserve', - action: 'che_징병', - args: { crewType: 1100, amount: 100 }, - fixturePatches: { cities: { 3: { population: 30_100 } } }, - completed: true, - }, - { - name: 'recruitment rejects trust below twenty', - action: 'che_징병', - args: { crewType: 1100, amount: 100 }, - fixturePatches: { cities: { 3: { trust: 19.99 } } }, - completed: false, - }, - { - name: 'recruitment accepts trust exactly twenty', - action: 'che_징병', - args: { crewType: 1100, amount: 100 }, - fixturePatches: { cities: { 3: { trust: 20 } } }, - completed: true, - }, - { - name: 'recruitment rejects insufficient gold', - action: 'che_징병', - args: { crewType: 1100, amount: 100 }, - actorPatch: { gold: 0 }, - completed: false, - }, - { - name: 'recruitment rejects insufficient rice', - action: 'che_징병', - args: { crewType: 1100, amount: 100 }, - actorPatch: { rice: 0 }, - completed: false, - }, - { - name: 'recruitment clamps the same crew type at leadership capacity', - action: 'che_징병', - args: { crewType: 1100, amount: 99_999 }, - fixturePatches: { cities: { 3: { population: 200_000 } } }, - completed: true, - }, - { - name: 'mercenary recruitment keeps its doubled price and higher readiness', - action: 'che_모병', - args: { crewType: 1100, amount: 100 }, - completed: true, - }, - { - name: 'mercenary recruitment rejects one gold below its doubled rounded cost', - action: 'che_모병', - args: { crewType: 1100, amount: 100 }, - actorPatch: { gold: 20 }, - completed: false, - }, - { - name: 'mercenary recruitment accepts its exact doubled rounded gold cost', - action: 'che_모병', - args: { crewType: 1100, amount: 100 }, - actorPatch: { gold: 21 }, - completed: true, - }, - { - name: 'mercenary recruitment rejects one rice below maintenance plus command cost', - action: 'che_모병', - args: { crewType: 1100, amount: 100 }, - actorPatch: { rice: 10 }, - completed: false, - }, - { - name: 'mercenary recruitment accepts exact maintenance plus command cost', - action: 'che_모병', - args: { crewType: 1100, amount: 100 }, - actorPatch: { rice: 11 }, - completed: true, - }, - { - name: 'mercenary recruitment clamps the same crew type at leadership capacity', - action: 'che_모병', - args: { crewType: 1100, amount: 99_999 }, - fixturePatches: { cities: { 3: { population: 200_000 } } }, - completed: true, - }, - { - name: 'equipment trade rejects an unknown item type', - action: 'che_장비매매', - args: { itemType: 'armor', itemCode: 'che_무기_01_단도' }, - completed: false, - }, - { - name: 'equipment trade rejects an item from a different slot', - action: 'che_장비매매', - args: { itemType: 'book', itemCode: 'che_무기_01_단도' }, - completed: false, - }, - { - name: 'equipment trade rejects an unbuyable item', - action: 'che_장비매매', - args: { itemType: 'weapon', itemCode: 'che_무기_11_고정도' }, - completed: false, - }, - { - name: 'equipment trade does not treat the city trade value as trader availability', - action: 'che_장비매매', - args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' }, - fixturePatches: { cities: { 3: { trade: 0 } } }, - completed: true, - }, - { - name: 'equipment trade rejects insufficient gold', - action: 'che_장비매매', - args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' }, - actorPatch: { gold: 999 }, - completed: false, - }, - { - name: 'equipment trade rejects buying the equipped item', - action: 'che_장비매매', - args: { itemType: 'weapon', itemCode: 'che_무기_01_단도' }, - actorPatch: { itemWeapon: 'che_무기_01_단도' }, - completed: false, - }, - { - name: 'equipment trade rejects selling an empty slot', - action: 'che_장비매매', - args: { itemType: 'weapon', itemCode: 'None' }, - completed: false, - }, - { - name: 'equipment trade sells a normal item for half price', - action: 'che_장비매매', - args: { itemType: 'weapon', itemCode: 'None' }, - actorPatch: { itemWeapon: 'che_무기_01_단도' }, - completed: true, - }, - { - name: 'equipment trade permits selling a unique item and records global logs', - action: 'che_장비매매', - args: { itemType: 'weapon', itemCode: 'None' }, - actorPatch: { itemWeapon: 'che_무기_11_고정도' }, - completed: true, - }, - { - name: 'equipment trade applies the dogi sale side effect before removing it', - action: 'che_장비매매', - args: { itemType: 'item', itemCode: 'None' }, - actorPatch: { itemExtra: 'che_보물_도기' }, - completed: true, - }, - { - name: 'disband rejects zero crew', - action: 'che_소집해제', - actorPatch: { crew: 0 }, - completed: false, - }, - { - name: 'disband allows a neutral actor and exceeds population capacity', - action: 'che_소집해제', - actorPatch: { nationId: 0, crew: 1001 }, - fixturePatches: { cities: { 3: { population: 200_000, populationMax: 200_000 } } }, - completed: true, - }, - { - name: 'fierce training rejects a neutral actor', - action: 'cr_맹훈련', - actorPatch: { nationId: 0 }, - completed: false, - }, - { - name: 'fierce training rejects a wandering nation', - action: 'cr_맹훈련', - fixturePatches: { nations: { 1: { level: 0 } } }, - completed: false, - }, - { - name: 'fierce training rejects a foreign-occupied city', - action: 'cr_맹훈련', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'fierce training rejects zero crew', - action: 'cr_맹훈련', - actorPatch: { crew: 0 }, - completed: false, - }, - { - name: 'fierce training rejects training at the command maximum', - action: 'cr_맹훈련', - actorPatch: { train: 100 }, - completed: false, - }, - { - name: 'fierce training enforces the advertised rice cost before execution', - action: 'cr_맹훈련', - actorPatch: { rice: 0 }, - completed: false, - }, - { - name: 'fierce training succeeds with morale already capped', - action: 'cr_맹훈련', - actorPatch: { train: 99, atmos: 100, crew: 1001 }, - completed: true, - }, -]; - -integration('general military preparation boundary, value, RNG, and log parity', () => { - it.each(militaryPreparationBoundaryCases)( - '$name', - async ({ name, action, args, actorPatch, fixturePatches, completed }) => { - const request = buildRequest(action, args, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = `general-military-preparation-${name}`; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: completed ? action : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - if (completed) { - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - }, - 120_000 - ); -}); - -interface TrainingScoutBoundaryCase { - name: string; - action: 'che_훈련' | 'che_전투태세' | 'che_인재탐색' | 'che_첩보'; - args?: Record; - actorPatch?: Record; - fixturePatches?: FixturePatches; - hiddenSeed?: string; - completed: boolean; - fallback?: boolean; -} - -const trainingScoutBoundaryCases: TrainingScoutBoundaryCase[] = [ - { - name: 'training rejects a neutral actor', - action: 'che_훈련', - actorPatch: { nationId: 0 }, - completed: false, - }, - { - name: 'training rejects a wandering nation', - action: 'che_훈련', - fixturePatches: { nations: { 1: { level: 0 } } }, - completed: false, - }, - { - name: 'training rejects a foreign-occupied city', - action: 'che_훈련', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'training rejects zero crew', - action: 'che_훈련', - actorPatch: { crew: 0 }, - completed: false, - }, - { - name: 'training rejects the command maximum', - action: 'che_훈련', - actorPatch: { train: 100 }, - completed: false, - }, - { - name: 'training clamps one below the maximum and applies morale side effect', - action: 'che_훈련', - actorPatch: { train: 99, atmos: 99, crew: 1001 }, - completed: true, - }, - { - name: 'training preserves zero rounded score and morale side effect', - action: 'che_훈련', - actorPatch: { train: 50, atmos: 99, crew: 9_999_999 }, - completed: true, - }, - { - name: 'battle preparation rejects a neutral actor at completion', - action: 'che_전투태세', - actorPatch: { nationId: 0, lastTurn: { command: '전투태세', term: 3 } }, - completed: false, - }, - { - name: 'battle preparation rejects a wandering nation at completion', - action: 'che_전투태세', - actorPatch: { lastTurn: { command: '전투태세', term: 3 } }, - fixturePatches: { nations: { 1: { level: 0 } } }, - completed: false, - }, - { - name: 'battle preparation rejects a foreign-occupied city at completion', - action: 'che_전투태세', - actorPatch: { lastTurn: { command: '전투태세', term: 3 } }, - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'battle preparation rejects zero crew at completion', - action: 'che_전투태세', - actorPatch: { crew: 0, lastTurn: { command: '전투태세', term: 3 } }, - completed: false, - }, - { - name: 'battle preparation rejects insufficient gold at completion', - action: 'che_전투태세', - actorPatch: { gold: 0, lastTurn: { command: '전투태세', term: 3 } }, - completed: false, - }, - { - name: 'battle preparation rejects training at ninety', - action: 'che_전투태세', - actorPatch: { train: 90, lastTurn: { command: '전투태세', term: 3 } }, - completed: false, - }, - { - name: 'battle preparation rejects morale at ninety', - action: 'che_전투태세', - actorPatch: { atmos: 90, lastTurn: { command: '전투태세', term: 3 } }, - completed: false, - }, - { - name: 'battle preparation accepts exact training and morale margins', - action: 'che_전투태세', - actorPatch: { - train: 89, - atmos: 89, - gold: 1_000_000, - rice: 100_000, - lastTurn: { command: '전투태세', term: 3 }, - }, - completed: true, - }, - { - name: 'talent scout rejects insufficient development gold', - action: 'che_인재탐색', - actorPatch: { gold: 17 }, - completed: false, - }, - { - name: 'talent scout accepts the exact development gold', - action: 'che_인재탐색', - actorPatch: { gold: 18 }, - hiddenSeed: 'general-talent-scout-exact-cost', - completed: true, - }, - { - name: 'talent scout preserves a second fixed probability branch', - action: 'che_인재탐색', - hiddenSeed: 'general-talent-scout-probability-1', - completed: true, - }, - { - name: 'spy accepts a numeric-string destination city ID', - action: 'che_첩보', - args: { destCityID: '70' }, - completed: true, - }, - { - name: 'spy accepts a fractional destination city ID through PHP array-key coercion', - action: 'che_첩보', - args: { destCityID: 70.9 }, - completed: true, - }, - { - name: 'spy rejects an unknown destination city', - action: 'che_첩보', - args: { destCityID: 999 }, - completed: false, - }, - { - name: 'spy rejects the occupied city', - action: 'che_첩보', - args: { destCityID: 3 }, - completed: false, - }, - { - name: 'spy rejects a neutral actor', - action: 'che_첩보', - args: { destCityID: 70 }, - actorPatch: { nationId: 0 }, - completed: true, - }, - { - name: 'spy rejects insufficient gold', - action: 'che_첩보', - args: { destCityID: 70 }, - actorPatch: { gold: 53 }, - completed: false, - }, - { - name: 'spy rejects insufficient rice', - action: 'che_첩보', - args: { destCityID: 70 }, - actorPatch: { rice: 53 }, - completed: false, - }, - { - name: 'spy reports full adjacent intelligence and tech advantage', - action: 'che_첩보', - args: { destCityID: 70 }, - fixturePatches: { nations: { 1: { tech: 1000 }, 2: { tech: 2000 } } }, - completed: true, - }, - { - name: 'spy reports medium intelligence at distance two', - action: 'che_첩보', - args: { destCityID: 1 }, - fixturePatches: { - generals: { 2: { cityId: 1 } }, - additionalCities: [ - { - id: 1, - nationId: 2, - population: 123_456, - populationMax: 620_500, - agriculture: 1111, - commerce: 2222, - security: 3333, - defence: 4444, - wall: 5555, - supplyState: 1, - frontState: 1, - state: 0, - term: 0, - trust: 66.6, - trade: 100, - }, - ], - }, - completed: true, - }, - { - name: 'spy reports only rumors beyond distance two', - action: 'che_첩보', - args: { destCityID: 4 }, - fixturePatches: { - generals: { 2: { cityId: 4 } }, - additionalCities: [ - { - id: 4, - nationId: 2, - population: 234_567, - populationMax: 655_700, - agriculture: 1111, - commerce: 2222, - security: 3333, - defence: 4444, - wall: 5555, - supplyState: 1, - frontState: 1, - state: 0, - term: 0, - trust: 55.5, - trade: 100, - }, - ], - }, - completed: true, - }, -]; - -integration('general training, talent scout, and spy boundary, value, RNG, and log parity', () => { - it.each(trainingScoutBoundaryCases)( - '$name', - async ({ name, action, args, actorPatch, fixturePatches, hiddenSeed, completed, fallback }) => { - const request = buildRequest(action, args, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = hiddenSeed ?? `general-training-scout-${name}`; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const usedFallback = fallback ?? !completed; - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: usedFallback ? '휴식' : action, - usedFallback, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - if (completed) { - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - }, - 120_000 - ); -}); - -type GeneralAppointmentBoundaryCase = { - name: string; - action: 'che_임관' | 'che_랜덤임관' | 'che_장수대상임관' | 'che_등용' | 'che_등용수락'; - args?: Record; - actorPatch?: Record; - fixturePatches?: FixturePatches; - completed: boolean; - expectedActionKey?: string; - restoresWorldKillTurn?: boolean; -}; - -const generalAppointmentBoundaryCases: GeneralAppointmentBoundaryCase[] = [ - { - name: 'direct appointment rejects a fractional nation ID', - action: 'che_임관', - args: { destNationID: 1.9 }, - actorPatch: { nationId: 0, officerLevel: 0 }, - completed: false, - }, - { - name: 'direct appointment rejects the cached opening general limit', - action: 'che_임관', - args: { destNationID: 1 }, - actorPatch: { nationId: 0, officerLevel: 0 }, - fixturePatches: { - world: { startYear: 180, year: 182 }, - nations: { 1: { generalCount: 10 } }, - }, - completed: false, - }, - { - name: 'direct appointment has no normal-era max-general constraint', - action: 'che_임관', - args: { destNationID: 1 }, - actorPatch: { nationId: 0, officerLevel: 0 }, - fixturePatches: { - world: { startYear: 180, year: 183 }, - nations: { 1: { generalCount: 500 } }, - generals: { 3: { officerLevel: 12, officerCityId: 3 } }, - }, - completed: true, - }, - { - name: 'direct appointment blocks user generals from governor nations', - action: 'che_임관', - args: { destNationID: 1 }, - actorPatch: { nationId: 0, officerLevel: 0, npcState: 0 }, - fixturePatches: { nations: { 1: { name: 'ⓤ아국' } } }, - completed: false, - }, - { - name: 'direct appointment blocks ordinary NPCs from outsider nations', - action: 'che_임관', - args: { destNationID: 1 }, - actorPatch: { nationId: 0, officerLevel: 0, npcState: 2 }, - fixturePatches: { nations: { 1: { name: 'ⓞ아국' } } }, - completed: false, - }, - { - name: 'direct appointment permits outsider NPC type nine', - action: 'che_임관', - args: { destNationID: 1 }, - actorPatch: { nationId: 0, officerLevel: 0, npcState: 9 }, - fixturePatches: { - nations: { 1: { name: 'ⓞ아국' } }, - generals: { 3: { officerLevel: 12, officerCityId: 3 } }, - }, - completed: true, - }, - { - name: 'follow appointment uses cached general count at the opening limit', - action: 'che_장수대상임관', - args: { destGeneralID: 2 }, - actorPatch: { nationId: 0, officerLevel: 0 }, - fixturePatches: { - world: { startYear: 180, year: 182 }, - nations: { 2: { generalCount: 10 } }, - }, - completed: false, - }, - { - name: 'follow appointment blocks user generals from governor nations', - action: 'che_장수대상임관', - args: { destGeneralID: 2 }, - actorPatch: { nationId: 0, officerLevel: 0, npcState: 0 }, - fixturePatches: { nations: { 2: { name: 'ⓤ타국' } } }, - completed: false, - }, - { - name: 'follow appointment blocks ordinary NPCs from outsider nations', - action: 'che_장수대상임관', - args: { destGeneralID: 2 }, - actorPatch: { nationId: 0, officerLevel: 0, npcState: 2 }, - fixturePatches: { nations: { 2: { name: 'ⓞ타국' } } }, - completed: false, - }, - { - name: 'follow appointment preserves the legacy troop field', - action: 'che_장수대상임관', - args: { destGeneralID: 2 }, - actorPatch: { nationId: 0, officerLevel: 0, troopId: 7 }, - completed: true, - }, - { - name: 'random appointment uses cached general counts during the opening', - action: 'che_랜덤임관', - actorPatch: { nationId: 0, officerLevel: 0 }, - fixturePatches: { - world: { startYear: 180, year: 182 }, - nations: { 1: { generalCount: 10 }, 2: { generalCount: 10 } }, - }, - completed: false, - expectedActionKey: 'che_인재탐색', - }, - { - name: 'random appointment preserves the legacy troop field', - action: 'che_랜덤임관', - actorPatch: { nationId: 0, officerLevel: 0, troopId: 7 }, - fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3 } } }, - completed: true, - }, - { - name: 'random appointment uses cached general counts at the normal limit', - action: 'che_랜덤임관', - actorPatch: { nationId: 0, officerLevel: 0 }, - fixturePatches: { - world: { startYear: 180, year: 183 }, - nations: { 1: { generalCount: 500 }, 2: { generalCount: 500 } }, - }, - completed: false, - expectedActionKey: 'che_인재탐색', - }, - { - name: 'employment rejects a same-nation target', - action: 'che_등용', - args: { destGeneralID: 3 }, - completed: false, - }, - { - name: 'employment rejects a target monarch', - action: 'che_등용', - args: { destGeneralID: 2 }, - completed: false, - }, - { - name: 'employment rejects a neutral actor', - action: 'che_등용', - args: { destGeneralID: 2 }, - actorPatch: { nationId: 0, officerLevel: 0 }, - fixturePatches: { generals: { 2: { officerLevel: 1 } } }, - completed: false, - }, - { - name: 'employment rejects an unsupplied actor city', - action: 'che_등용', - args: { destGeneralID: 2 }, - fixturePatches: { - generals: { 2: { officerLevel: 1 } }, - cities: { 3: { supplyState: 0 } }, - }, - completed: false, - }, - { - name: 'accepting employment applies the opening cached limit', - action: 'che_등용수락', - args: { destNationID: 2, destGeneralID: 2 }, - actorPatch: { nationId: 0, officerLevel: 0 }, - fixturePatches: { - world: { startYear: 180, year: 182 }, - nations: { 2: { generalCount: 10 } }, - }, - completed: false, - }, - { - name: 'accepting employment has no normal-era max-general constraint', - action: 'che_등용수락', - args: { destNationID: 2, destGeneralID: 2 }, - actorPatch: { nationId: 0, officerLevel: 0 }, - fixturePatches: { - world: { startYear: 180, year: 183 }, - nations: { 2: { generalCount: 500 } }, - }, - completed: true, - }, - { - name: 'accepting employment rejects the actor as recruiter', - action: 'che_등용수락', - args: { destNationID: 2, destGeneralID: 1 }, - actorPatch: { nationId: 0, officerLevel: 0 }, - completed: false, - }, - { - name: 'accepting employment rejects a wandering destination nation', - action: 'che_등용수락', - args: { destNationID: 2, destGeneralID: 2 }, - actorPatch: { nationId: 0, officerLevel: 0 }, - fixturePatches: { nations: { 2: { level: 0 } } }, - completed: false, - }, - { - name: 'accepting employment restores the user kill-turn allowance', - action: 'che_등용수락', - args: { destNationID: 2, destGeneralID: 2 }, - actorPatch: { nationId: 0, officerLevel: 0, npcState: 0, killTurn: 5 }, - completed: true, - restoresWorldKillTurn: true, - }, -]; - -integration('general appointment and employment boundary, state, RNG, and log parity', () => { - it.each(generalAppointmentBoundaryCases)( - '$name', - async ({ - name, - action, - args, - actorPatch, - fixturePatches, - completed, - expectedActionKey, - restoresWorldKillTurn, - }) => { - const request = buildRequest(action, args, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = `general-appointment-${name}`; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const actionKey = expectedActionKey ?? (completed ? action : '휴식'); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey, - usedFallback: !completed && actionKey === '휴식', - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - if (completed) { - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - if (restoresWorldKillTurn) { - const expectedKillTurn = reference.before.world.killTurn; - expect(reference.after.generals.find((entry) => entry.id === 1)?.killTurn).toBe(expectedKillTurn); - expect(core.after.generals.find((entry) => entry.id === 1)?.killTurn).toBe(expectedKillTurn); - } - }, - 120_000 - ); -}); - -integration('명장일람 rank_data command parity', () => { - it('화계 increments firenum from the same seeded value as legacy', async () => { - const request = buildRequest( - 'che_화계', - { destCityID: 70 }, - { intelligence: 100 }, - { - generals: { 2: { intelligence: 10 } }, - rankData: [{ generalId: 1, type: 'firenum', value: 17 }], - } - ); - request.setup!.world!.hiddenSeed = 'general-value-0'; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_화계', - actionKey: 'che_화계', - usedFallback: false, - }); - expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true); - expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true); - expect(core.rng).toEqual(reference.rng); - expect(reference.after.rankData).toContainEqual( - expect.objectContaining({ generalId: 1, type: 'firenum', value: 18 }) - ); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); - - it('은퇴 resets every legacy RankColumn row exactly like legacy', async () => { - const request = buildRequest( - 'che_은퇴', - undefined, - { age: 65, lastTurn: { command: '은퇴', term: 1 } }, - { - rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({ - generalId: 1, - type, - value: index + 1, - })), - } - ); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.after.rankData.filter((row) => row.generalId === 1)).toHaveLength( - LEGACY_RANK_DATA_TYPES.length - ); - expect(reference.after.rankData.filter((row) => row.generalId === 1).every((row) => row.value === 0)).toBe( - true - ); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); -}); - -type GeneralFailureCase = { - action: string; - args?: Record; - hiddenSeed: string; - failureText: string; -}; - -const failureCases: GeneralFailureCase[] = [ - { - action: 'che_주민선정', - hiddenSeed: 'general-failure-che_주민선정-0', - failureText: "주민 선정을 실패", - }, - { - action: 'che_정착장려', - hiddenSeed: 'general-failure-che_정착장려-0', - failureText: "정착 장려를 실패", - }, - { - action: 'che_상업투자', - hiddenSeed: 'general-failure-che_상업투자-2', - failureText: "상업 투자를 실패", - }, - { - action: 'che_기술연구', - hiddenSeed: 'general-failure-che_기술연구-0', - failureText: "기술 연구를 실패", - }, - { - action: 'che_물자조달', - hiddenSeed: 'general-failure-che_물자조달-1', - failureText: "조달을 실패", - }, - { - action: 'che_화계', - args: { destCityID: 70 }, - hiddenSeed: 'general-failure-che_화계-0', - failureText: '화계가 실패했습니다.', - }, - { - action: 'che_선동', - args: { destCityID: 70 }, - hiddenSeed: 'general-failure-che_선동-0', - failureText: '선동이 실패했습니다.', - }, - { - action: 'che_파괴', - args: { destCityID: 70 }, - hiddenSeed: 'general-failure-che_파괴-0', - failureText: '파괴가 실패했습니다.', - }, - { - action: 'che_탈취', - args: { destCityID: 70 }, - hiddenSeed: 'general-failure-che_탈취-0', - failureText: '탈취가 실패했습니다.', - }, -]; - -const failureLogTexts = (logs: Array>, failureText: string): string[] => - logs - .map((entry) => entry.text) - .filter((text): text is string => typeof text === 'string' && text.includes(failureText)); - -const legacyActionLogBody = (text: string): string => { - const match = /^●<\/>\d+월:(.*) <1>\d{2}:\d{2}<\/>$/.exec(text); - return match?.[1] ?? text; -}; - -integration('general command in-action failure matrix', () => { - it.each(failureCases)( - '$action matches legacy failure RNG, side effects, and failure log', - async ({ action, args, hiddenSeed, failureText }) => { - const request = buildRequest(action, args); - request.setup!.world!.hiddenSeed = hiddenSeed; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(core.execution.outcome).not.toHaveProperty('blockedReason'); - expect(failureLogTexts(reference.after.logs, failureText)).toHaveLength(1); - expect(failureLogTexts(core.after.logs, failureText)).toEqual( - failureLogTexts(reference.after.logs, failureText).map(legacyActionLogBody) - ); - expect(core.rng).toEqual(reference.rng); - expectRefFloatProjectedDeltaParity( - reference, - core, - ignoredLifecyclePaths, - action === 'che_주민선정' - ? [ - { - path: 'cities[3].trust', - reference: 2.9643999999999977, - core: 2.9644093559690674, - }, - ] - : [], - { cityTrust: action === 'che_주민선정' } - ); - }, - 120_000 - ); -}); - -type GeneralStatusTransitionBoundaryCase = { - name: string; - action: 'che_하야' | 'che_은퇴' | 'che_선양'; - args?: Record; - actorPatch?: Record; - fixturePatches?: FixturePatches; - completed: boolean; - expectedActionKey?: string; - compareLogs?: boolean; -}; - -const generalStatusTransitionBoundaryCases: GeneralStatusTransitionBoundaryCase[] = [ - { - name: 'resignation applies the betrayal penalty and returns excess resources', - action: 'che_하야', - actorPatch: { - officerLevel: 1, - experience: 1001, - dedication: 1003, - gold: 1500, - rice: 1600, - betray: 3, - }, - completed: true, - compareLogs: true, - }, - { - name: 'resigning troop leader releases every member', - action: 'che_하야', - actorPatch: { officerLevel: 1, troopId: 1 }, - fixturePatches: { - generals: { 3: { troopId: 1 } }, - troops: [{ id: 1, nationId: 1, name: '아국군' }], - }, - completed: true, - }, - { - name: 'retirement rejects age fifty nine', - action: 'che_은퇴', - actorPatch: { age: 59, lastTurn: { command: '은퇴', term: 1 } }, - completed: false, - }, - { - name: 'retirement rounds odd attributes and resets speciality ages and ranks', - action: 'che_은퇴', - actorPatch: { - age: 60, - leadership: 91, - strength: 81, - intelligence: 71, - experience: 1001, - dedication: 1003, - dex1: 101, - dex2: 103, - dex3: 105, - dex4: 107, - dex5: 109, - specAge: 65, - specAge2: 66, - lastTurn: { command: '은퇴', term: 1 }, - }, - fixturePatches: { - rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({ - generalId: 1, - type, - value: index + 1, - })), - }, - completed: true, - compareLogs: true, - }, - { - name: 'abdication rejects a fractional target ID', - action: 'che_선양', - args: { destGeneralID: 3.5 }, - completed: false, - }, - { - name: 'abdication rejects the acting monarch as target', - action: 'che_선양', - args: { destGeneralID: 1 }, - completed: false, - }, - { - name: 'abdication rejects a target with no-chief penalty in action', - action: 'che_선양', - args: { destGeneralID: 3 }, - fixturePatches: { generals: { 3: { penalty: { noChief: true } } } }, - completed: false, - expectedActionKey: 'che_선양', - compareLogs: true, - }, - { - name: 'abdication transfers the monarch office and experience penalty', - action: 'che_선양', - args: { destGeneralID: 3 }, - actorPatch: { experience: 1001 }, - completed: true, - compareLogs: true, - }, -]; - -integration('general resignation, retirement, and abdication boundary, state, and log parity', () => { - it.each(generalStatusTransitionBoundaryCases)( - '$name', - async ({ name, action, args, actorPatch, fixturePatches, completed, expectedActionKey, compareLogs }) => { - const request = buildRequest(action, args, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = `general-status-transition-${name}`; - request.observe!.includeGlobalHistoryLogs = true; - request.observe!.includeNationHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const actionKey = expectedActionKey ?? (completed ? action : '휴식'); - const coreCompleted = actionKey === '휴식' ? true : completed; - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey, - usedFallback: !completed && actionKey === '휴식', - completed: coreCompleted, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - if (compareLogs) { - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - }, - 120_000 - ); -}); - -type GeneralFoundingRebellionBoundaryCase = { - name: string; - action: 'che_거병' | 'che_모반시도' | 'che_건국' | 'cr_건국' | 'che_무작위건국'; - args?: Record; - actorPatch?: Record; - fixturePatches?: FixturePatches; - completed: boolean; - expectedActionKey?: string; - compareLogs?: boolean; -}; - -const foundingArgs = { nationName: '신국', nationType: 'che_도적', colorType: 1 }; -const wanderingFoundingFixture = (overrides: FixturePatches = {}): FixturePatches => ({ - ...overrides, - world: { startYear: 180, initYear: 180, initMonth: 1, year: 181, ...overrides.world }, - nations: { - ...overrides.nations, - 1: { level: 0, capitalCityId: 0, typeCode: 'None', generalCount: 2, ...overrides.nations?.[1] }, - }, - cities: { - ...overrides.cities, - 3: { nationId: 0, level: 5, ...overrides.cities?.[3] }, - }, -}); - -const generalFoundingRebellionBoundaryCases: GeneralFoundingRebellionBoundaryCase[] = [ - { - name: 'uprising rejects a no-founding penalty from the general column', - action: 'che_거병', - actorPatch: { nationId: 0, officerLevel: 0, penalty: { noFoundNation: true } }, - fixturePatches: { world: { startYear: 180, year: 181 } }, - completed: false, - }, - { - name: 'uprising rejects a remaining join-action cooldown', - action: 'che_거병', - actorPatch: { nationId: 0, officerLevel: 0, makeLimit: 1 }, - fixturePatches: { world: { startYear: 180, year: 181 } }, - completed: false, - }, - { - name: 'uprising trims a duplicate full-width nation name and preserves logs', - action: 'che_거병', - actorPatch: { name: '가나다라마바사아자차', nationId: 0, officerLevel: 0 }, - fixturePatches: { - world: { startYear: 180, year: 181 }, - nations: { 1: { name: '가나다라마바사아자차' } }, - }, - completed: true, - compareLogs: true, - }, - { - name: 'rebellion rejects an active monarch', - action: 'che_모반시도', - actorPatch: { officerLevel: 11 }, - fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 999_999 } } }, - completed: false, - }, - { - name: 'rebellion rejects an npc monarch', - action: 'che_모반시도', - actorPatch: { officerLevel: 11 }, - fixturePatches: { generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 0, npcState: 2 } } }, - completed: false, - }, - { - name: 'rebellion rounds the former monarch experience and transfers the chief cache', - action: 'che_모반시도', - actorPatch: { officerLevel: 11 }, - fixturePatches: { - generals: { 3: { officerLevel: 12, officerCityId: 3, killTurn: 0, experience: 1001 } }, - }, - completed: true, - compareLogs: true, - }, - ...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({ - name: `${action} rejects a fractional color index`, - action, - args: { ...foundingArgs, colorType: 1.5 }, - fixturePatches: wanderingFoundingFixture(), - completed: false, - })), - { - name: 'founding rejects a nation name wider than eighteen legacy columns', - action: 'che_건국', - args: { ...foundingArgs, nationName: '가나다라마바사아자차' }, - fixturePatches: wanderingFoundingFixture(), - completed: false, - }, - { - name: 'founding rejects an unavailable nation type', - action: 'che_건국', - args: { ...foundingArgs, nationType: 'che_없는국가형' }, - fixturePatches: wanderingFoundingFixture(), - completed: false, - }, - ...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({ - name: `${action} uses the cached nation general count`, - action, - args: foundingArgs, - fixturePatches: wanderingFoundingFixture({ nations: { 1: { generalCount: 1 } } }), - completed: false, - })), - { - name: 'founding continues into talent scout during the initial month', - action: 'che_건국', - args: foundingArgs, - fixturePatches: wanderingFoundingFixture({ - world: { startYear: 180, initYear: 181, initMonth: 1, year: 181 }, - }), - completed: false, - expectedActionKey: 'che_인재탐색', - }, - { - name: 'restricted founding continues into talent scout during the initial month', - action: 'cr_건국', - args: foundingArgs, - fixturePatches: wanderingFoundingFixture({ - world: { startYear: 180, initYear: 181, initMonth: 1, year: 181 }, - }), - completed: false, - expectedActionKey: 'che_인재탐색', - }, - ...(['che_건국', 'cr_건국', 'che_무작위건국'] as const).map((action) => ({ - name: `${action} clears the founding city conflict and preserves success logs`, - action, - args: foundingArgs, - fixturePatches: wanderingFoundingFixture({ - cities: { 3: { conflict: { 2: 7 } } }, - randomFoundingCandidateCityIds: action === 'che_무작위건국' ? [3] : undefined, - }), - completed: true, - compareLogs: true, - })), -]; - -integration('general uprising, rebellion, and founding boundary, state, RNG, and log parity', () => { - it.each(generalFoundingRebellionBoundaryCases)( - '$name', - async ({ name, action, args, actorPatch, fixturePatches, completed, expectedActionKey, compareLogs }) => { - const request = buildRequest(action, args, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = `general-founding-rebellion-${name}`; - if (action === 'che_거병') { - request.observe!.nationIds!.push(3); - } - request.observe!.includeGlobalHistoryLogs = true; - request.observe!.includeNationHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const actionKey = expectedActionKey ?? (completed ? action : '휴식'); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey, - usedFallback: !completed && actionKey === '휴식', - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - if (compareLogs) { - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - }, - 120_000 - ); -}); - -type SabotageProbabilityClampCase = { - name: string; - action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취'; - stat: 'leadership' | 'strength' | 'intelligence'; - boundary: 'zero' | 'max'; -}; - -const sabotageStatProgressionCases = [ - { action: 'che_화계', stat: 'intelligence', statExp: 'intelExp' }, - { action: 'che_선동', stat: 'leadership', statExp: 'leadershipExp' }, - { action: 'che_파괴', stat: 'strength', statExp: 'strengthExp' }, - { action: 'che_탈취', stat: 'strength', statExp: 'strengthExp' }, -] as const; - -const sabotageSuccessfulEffectCases = sabotageStatProgressionCases.map(({ action, stat }) => ({ action, stat })); - -const sabotageSuccessfulEffectExpected = { - che_화계: { city: { agriculture: 494, commerce: 859, state: 32 } }, - che_선동: { city: { security: 0, trust: 70.1066, state: 32 } }, - che_파괴: { city: { defence: 536, wall: 222, state: 32 } }, - che_탈취: { - city: { state: 32 }, - nation: { gold: 999_341, rice: 999_200 }, - actor: { gold: 100_108, rice: 100_140 }, - }, -} as const; - -integration('general sabotage successful effect matrix', () => { - it.each(sabotageSuccessfulEffectCases)( - '$action executes a real general turn at the 0.5 probability clamp', - async ({ action, stat }) => { - const request = buildRequest( - action, - { destCityID: 70 }, - { [stat]: 100 }, - { - generals: { 2: { [stat]: 10 } }, - cities: { 70: { security: 100, securityMax: 2_000, supplyState: 1 } }, - } - ); - request.setup!.world!.hiddenSeed = 'general-value-0'; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(reference.rng[0]).toMatchObject({ - operation: 'nextBits', - arguments: { bits: 1 }, - result: '01', - }); - expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true); - expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true); - expect(core.rng).toEqual(reference.rng); - const findById = (rows: Array>, id: number) => - rows.find((entry) => entry.id === id); - const expected = sabotageSuccessfulEffectExpected[action]; - expect(findById(reference.after.cities, 70)).toMatchObject(expected.city); - if ('nation' in expected) { - expect(findById(reference.after.nations, 2)).toMatchObject(expected.nation); - } - if ('actor' in expected) { - expect(findById(reference.after.generals, 1)).toMatchObject(expected.actor); - } - expectRefFloatProjectedDeltaParity( - reference, - core, - ignoredLifecyclePaths, - action === 'che_선동' - ? [ - { - path: 'cities[70].trust', - reference: -9.8934, - core: -9.893404080791214, - }, - ] - : [], - { cityTrust: action === 'che_선동' } - ); - - if (process.env.TURN_DIFFERENTIAL_SABOTAGE_EVIDENCE === '1') { - process.stderr.write( - `${JSON.stringify({ - action, - probability: 0.5, - rng: reference.rng, - reference: { - actorBefore: findById(reference.before.generals, 1), - actorAfter: findById(reference.after.generals, 1), - targetCityBefore: findById(reference.before.cities, 70), - targetCityAfter: findById(reference.after.cities, 70), - targetNationBefore: findById(reference.before.nations, 2), - targetNationAfter: findById(reference.after.nations, 2), - }, - core: { - actorAfter: findById(core.after.generals, 1), - targetCityAfter: findById(core.after.cities, 70), - targetNationAfter: findById(core.after.nations, 2), - }, - })}\n` - ); - } - }, - 120_000 - ); -}); - -integration('general sabotage stat progression matrix', () => { - it.each(sabotageStatProgressionCases)( - '$action inherits the base strategy stat progression tail', - async ({ action, stat, statExp }) => { - const request = buildRequest( - action, - { destCityID: 70 }, - { [stat]: 100, [statExp]: 29 }, - { - generals: { 2: { [stat]: 10 } }, - cities: { 70: { security: 0, securityMax: 2_000 } }, - } - ); - request.setup!.world!.hiddenSeed = 'general-value-0'; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(core.rng).toEqual(reference.rng); - expect(reference.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101); - expect(core.after.generals.find((entry) => entry.id === 1)?.[stat]).toBe(101); - expectRefFloatProjectedDeltaParity( - reference, - core, - ignoredLifecyclePaths, - action === 'che_선동' - ? [ - { - path: 'cities[70].trust', - reference: -9.8934, - core: -9.893404080791214, - }, - ] - : [], - { cityTrust: action === 'che_선동' } - ); - }, - 120_000 - ); -}); - -const sabotageProbabilityClampCases: SabotageProbabilityClampCase[] = ( - [ - ['che_화계', 'intelligence'], - ['che_선동', 'leadership'], - ['che_파괴', 'strength'], - ['che_탈취', 'strength'], - ] as const -).flatMap(([action, stat]) => [ - { name: `${action} probability zero clamp`, action, stat, boundary: 'zero' }, - { name: `${action} probability 0.5 clamp`, action, stat, boundary: 'max' }, -]); - -integration('general sabotage probability clamp matrix', () => { - it.each(sabotageProbabilityClampCases)( - '$name preserves the legacy clamp and RNG primitive', - async ({ action, stat, boundary }) => { - const isZero = boundary === 'zero'; - const request = buildRequest( - action, - { destCityID: 70 }, - { [stat]: isZero ? 10 : 100 }, - { - generals: { 2: { [stat]: isZero ? 100 : 10 } }, - cities: { - 70: { - security: isZero ? 2_000 : 0, - securityMax: 2_000, - supplyState: 1, - }, - }, - } - ); - request.setup!.world!.hiddenSeed = `general-probability-clamp-${action}-${boundary}`; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(core.execution.outcome).not.toHaveProperty('blockedReason'); - expect(reference.rng[0]).toMatchObject( - isZero - ? { operation: 'nextInt', arguments: { maxInclusive: 99 } } - : { operation: 'nextBits', arguments: { bits: 1 } } - ); - expect(core.rng).toEqual(reference.rng); - expectRefFloatProjectedDeltaParity( - reference, - core, - ignoredLifecyclePaths, - action === 'che_선동' && boundary === 'max' - ? [ - { - path: 'cities[70].trust', - reference: -11.155500000000004, - core: -11.15547143003728, - }, - ] - : [], - { cityTrust: action === 'che_선동' && boundary === 'max' } - ); - }, - 120_000 - ); -}); - -type SabotageValueBoundaryCase = { - name: string; - action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취'; - stat: 'leadership' | 'strength' | 'intelligence'; - fixturePatches: FixturePatches; -}; - -const sabotageValueBoundaryCases: SabotageValueBoundaryCase[] = [ - { - name: 'fire attack does not reduce agriculture or commerce below zero', - action: 'che_화계', - stat: 'intelligence', - fixturePatches: { cities: { 70: { agriculture: 1, commerce: 1 } } }, - }, - { - name: 'agitation does not reduce security or trust below zero', - action: 'che_선동', - stat: 'leadership', - fixturePatches: { cities: { 70: { security: 1, trust: 1 } } }, - }, - { - name: 'destruction does not reduce defence or wall below zero', - action: 'che_파괴', - stat: 'strength', - fixturePatches: { cities: { 70: { defence: 1, wall: 1 } } }, - }, - { - name: 'seizure does not take more than supplied nation resources', - action: 'che_탈취', - stat: 'strength', - fixturePatches: { nations: { 2: { gold: 1, rice: 1 } } }, - }, - { - name: 'seizure does not reduce unsupplied city resources below zero', - action: 'che_탈취', - stat: 'strength', - fixturePatches: { - cities: { 70: { agriculture: 1, commerce: 1, supplyState: 0 } }, - }, - }, -]; - -const hasSuccessfulSabotageLog = (logs: Array>): boolean => - logs.some((entry) => typeof entry.text === 'string' && entry.text.includes('성공했습니다.')); - -integration('general sabotage value boundary matrix', () => { - it.each(sabotageValueBoundaryCases)( - '$name matches the legacy clamped state delta', - async ({ action, stat, fixturePatches }) => { - const request = buildRequest( - action, - { destCityID: 70 }, - { [stat]: 100 }, - { - ...fixturePatches, - generals: { - ...fixturePatches.generals, - 2: { ...fixturePatches.generals?.[2], [stat]: 10 }, - }, - cities: { - ...fixturePatches.cities, - 70: { - ...fixturePatches.cities?.[70], - security: 0, - securityMax: 2_000, - }, - }, - } - ); - request.setup!.world!.hiddenSeed = 'general-value-0'; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(hasSuccessfulSabotageLog(reference.after.logs)).toBe(true); - expect(hasSuccessfulSabotageLog(core.after.logs)).toBe(true); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -type SabotageInjuryBoundaryCase = { - action: 'che_화계' | 'che_선동' | 'che_파괴'; - stat: 'leadership' | 'strength' | 'intelligence'; - hiddenSeed: string; -}; - -const sabotageInjuryBoundaryCases: SabotageInjuryBoundaryCase[] = [ - { action: 'che_화계', stat: 'intelligence', hiddenSeed: 'general-injury-4' }, - { action: 'che_선동', stat: 'leadership', hiddenSeed: 'general-injury-13' }, - { action: 'che_파괴', stat: 'strength', hiddenSeed: 'general-injury-4' }, -]; - -const injuryLogTexts = (logs: Array>): string[] => - logs - .map((entry) => entry.text) - .filter((text): text is string => typeof text === 'string' && text.includes('부상을 당했습니다.')); - -const legacyInjuryLogBody = (text: string): string => text.replace(/^●<\/>\d+월:/, ''); - -integration('general sabotage injury boundary matrix', () => { - it.each(sabotageInjuryBoundaryCases)( - '$action matches legacy injury cap, integer persistence, and log', - async ({ action, stat, hiddenSeed }) => { - const request = buildRequest( - action, - { destCityID: 70 }, - { [stat]: 100 }, - { - generals: { - 2: { - [stat]: 10, - injury: 79, - crew: 101, - atmos: 51, - train: 51, - }, - }, - cities: { 70: { security: 0, securityMax: 2_000 } }, - } - ); - request.setup!.world!.hiddenSeed = hiddenSeed; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(injuryLogTexts(reference.after.logs)).toHaveLength(1); - expect(injuryLogTexts(core.after.logs)).toEqual( - injuryLogTexts(reference.after.logs).map(legacyInjuryLogBody) - ); - expect(core.rng).toEqual(reference.rng); - expectRefFloatProjectedDeltaParity( - reference, - core, - ignoredLifecyclePaths, - action === 'che_선동' - ? [ - { - path: 'cities[70].trust', - reference: -4.810900000000004, - core: -4.810929741150531, - }, - ] - : [], - { cityTrust: action === 'che_선동' } - ); - }, - 120_000 - ); -}); - -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 GeneralPreReqBoundaryCase = { - name: string; - action: string; - actorPatch?: Record; - expectedCommand: string; - expectedTerm: number; - expectedProgressText: string; -}; - -const generalPreReqBoundaryCases: GeneralPreReqBoundaryCase[] = [ - { - name: 'battle preparation starts at term 1', - action: 'che_전투태세', - expectedCommand: '전투태세', - expectedTerm: 1, - expectedProgressText: '전투태세 수행중... (1/4)', - }, - { - name: 'battle preparation advances term 1 to 2', - action: 'che_전투태세', - actorPatch: { lastTurn: { command: '전투태세', term: 1 } }, - expectedCommand: '전투태세', - expectedTerm: 2, - expectedProgressText: '전투태세 수행중... (2/4)', - }, - { - name: 'battle preparation advances term 2 to 3', - action: 'che_전투태세', - actorPatch: { lastTurn: { command: '전투태세', term: 2 } }, - expectedCommand: '전투태세', - expectedTerm: 3, - expectedProgressText: '전투태세 수행중... (3/4)', - }, - { - name: 'domestic trait reset starts at term 1 after another command', - action: 'che_내정특기초기화', - actorPatch: { - specialDomestic: 'che_인덕', - lastTurn: { command: '전투태세', term: 3 }, - }, - expectedCommand: '내정 특기 초기화', - expectedTerm: 1, - expectedProgressText: '새로운 적성을 찾는 중... (1/2)', - }, - { - name: 'war trait reset starts at term 1', - action: 'che_전투특기초기화', - actorPatch: { specialWar: 'che_귀병' }, - expectedCommand: '전투 특기 초기화', - expectedTerm: 1, - expectedProgressText: '새로운 적성을 찾는 중... (1/2)', - }, - { - name: 'retirement starts at term 1 without applying retirement', - action: 'che_은퇴', - actorPatch: { age: 60 }, - expectedCommand: '은퇴', - expectedTerm: 1, - expectedProgressText: '은퇴 수행중... (1/2)', - }, -]; - -integration('general command pre-required turn boundary matrix', () => { - it.each(generalPreReqBoundaryCases)( - '$name matches legacy intermediate last-turn state without consuming command RNG', - async ({ action, actorPatch, expectedCommand, expectedTerm, expectedProgressText }) => { - const request = buildRequest(action, undefined, actorPatch); - request.setup!.world!.hiddenSeed = `general-prereq-${action}-${expectedTerm}`; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceActor = reference.after.generals.find((entry) => entry.id === 1); - const coreActor = core.after.generals.find((entry) => entry.id === 1); - - expect(reference.execution.outcome).toMatchObject({ completed: false }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(referenceActor?.lastTurn).toMatchObject({ - command: expectedCommand, - term: expectedTerm, - }); - expect(coreActor?.lastTurn).toMatchObject({ - command: expectedCommand, - term: expectedTerm, - }); - expect(reference.after.logs.some((entry) => String(entry.text).includes(expectedProgressText))).toBe(true); - expect(core.after.logs.some((entry) => String(entry.text).includes(expectedProgressText))).toBe(true); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -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 GeneralSpecialResetBoundaryCase = { - name: string; - action: 'che_내정특기초기화' | 'che_전투특기초기화'; - actorPatch: Record; - completed: boolean; - previousTypeKey: 'prev_types_special' | 'prev_types_special2'; - expectedPreviousTypes?: string[]; - expectedSpecAgeKey: 'specAge' | 'specAge2'; - expectUniqueItem?: boolean; -}; - -const generalSpecialResetBoundaryCases: GeneralSpecialResetBoundaryCase[] = [ - { - name: 'domestic reset rejects an actor without a domestic trait', - action: 'che_내정특기초기화', - actorPatch: { specialDomestic: 'None' }, - completed: false, - previousTypeKey: 'prev_types_special', - expectedSpecAgeKey: 'specAge', - }, - { - name: 'war reset rejects an actor without a war trait', - action: 'che_전투특기초기화', - actorPatch: { specialWar: 'None' }, - completed: false, - previousTypeKey: 'prev_types_special2', - expectedSpecAgeKey: 'specAge2', - }, - { - name: 'domestic reset restarts the previous-type cycle after every trait was seen', - action: 'che_내정특기초기화', - actorPatch: { - specialDomestic: 'che_인덕', - lastTurn: { command: '내정 특기 초기화', term: 1 }, - meta: { prev_types_special: Array.from({ length: 7 }, (_, index) => `domestic-${index}`) }, - }, - completed: true, - previousTypeKey: 'prev_types_special', - expectedPreviousTypes: ['che_인덕'], - expectedSpecAgeKey: 'specAge', - }, - { - name: 'war reset restarts the previous-type cycle after every trait was seen', - action: 'che_전투특기초기화', - actorPatch: { - specialWar: 'che_귀병', - lastTurn: { command: '전투 특기 초기화', term: 1 }, - meta: { prev_types_special2: Array.from({ length: 19 }, (_, index) => `war-${index}`) }, - }, - completed: true, - previousTypeKey: 'prev_types_special2', - expectedPreviousTypes: ['che_귀병'], - expectedSpecAgeKey: 'specAge2', - }, - { - name: 'domestic reset stores the old trait and runs the forced unique lottery', - action: 'che_내정특기초기화', - actorPatch: { - specialDomestic: 'che_인덕', - lastTurn: { command: '내정 특기 초기화', term: 1 }, - meta: { inheritRandomUnique: true, prev_types_special: ['che_농업'] }, - }, - completed: true, - previousTypeKey: 'prev_types_special', - expectedPreviousTypes: ['che_농업', 'che_인덕'], - expectedSpecAgeKey: 'specAge', - expectUniqueItem: true, - }, - { - name: 'war reset stores the old trait and runs the forced unique lottery', - action: 'che_전투특기초기화', - actorPatch: { - specialWar: 'che_귀병', - lastTurn: { command: '전투 특기 초기화', term: 1 }, - meta: { inheritRandomUnique: true, prev_types_special2: ['che_신산'] }, - }, - completed: true, - previousTypeKey: 'prev_types_special2', - expectedPreviousTypes: ['che_신산', 'che_귀병'], - expectedSpecAgeKey: 'specAge2', - expectUniqueItem: true, - }, -]; - -integration('general special reset constraint, state, unique lottery, and log parity', () => { - it.each(generalSpecialResetBoundaryCases)( - '$name', - async ({ - name, - action, - actorPatch, - completed, - previousTypeKey, - expectedPreviousTypes, - expectedSpecAgeKey, - expectUniqueItem, - }) => { - const request = buildRequest(action, undefined, actorPatch, { - world: { initYear: 180, initMonth: 1 }, - }); - request.setup!.world!.hiddenSeed = `general-special-reset-${name}`; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceActor = reference.after.generals.find((entry) => entry.id === 1); - const coreActor = core.after.generals.find((entry) => entry.id === 1); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: completed ? action : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - - if (completed) { - expect(asRecord(referenceActor?.meta)[previousTypeKey]).toEqual(expectedPreviousTypes); - expect(asRecord(coreActor?.meta)[previousTypeKey]).toEqual(expectedPreviousTypes); - expect(referenceActor?.[expectedSpecAgeKey]).toBe(31); - expect(coreActor?.[expectedSpecAgeKey]).toBe(31); - } - if (expectUniqueItem) { - const referenceItems = [ - referenceActor?.itemHorse, - referenceActor?.itemWeapon, - referenceActor?.itemBook, - referenceActor?.itemExtra, - ]; - const coreItems = [ - coreActor?.itemHorse, - coreActor?.itemWeapon, - coreActor?.itemBook, - coreActor?.itemExtra, - ]; - expect(referenceItems.some((item) => item && item !== 'None')).toBe(true); - expect(coreItems).toEqual(referenceItems); - } - }, - 120_000 - ); -}); - -const missingTargetCases: Array<{ name: string; action: string; args: Record }> = [ - { - name: 'gift to a missing general', - action: 'che_증여', - args: { isGold: true, amount: 100, destGeneralID: 999 }, - }, - { - name: 'spy on a missing city', - action: 'che_첩보', - args: { destCityID: 999 }, - }, - { - name: 'move to a missing city', - action: 'che_이동', - args: { destCityID: 999 }, - }, - { - name: 'employ a missing general', - action: 'che_등용', - args: { destGeneralID: 999 }, - }, -]; - -integration('general command missing-target fallback matrix', () => { - it.each(missingTargetCases)( - '$name rejects the missing target and falls back without command RNG', - async ({ action, args }) => { - const request = buildRequest(action, args); - 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: action, - actionKey: '휴식', - usedFallback: true, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -integration('general gift missing argument object parity', () => { - it('falls back with the legacy invalid-argument log and no RNG', async () => { - const request = buildRequest('che_증여'); - 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: 'che_증여', - actionKey: '휴식', - usedFallback: true, - }); - expect(reference.rng).toEqual([]); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - }, 120_000); -}); - -const resourceAmountCases: Array<{ - name: string; - action: string; - args: Record; - expectedAmount: number; -}> = [ - { - name: 'gift rounds a half unit up', - action: 'che_증여', - args: { isGold: true, amount: 150, destGeneralID: 3 }, - expectedAmount: 200, - }, - { - name: 'gift clamps below the minimum', - action: 'che_증여', - args: { isGold: true, amount: 1, destGeneralID: 3 }, - expectedAmount: 100, - }, - { - name: 'gift clamps above the maximum', - action: 'che_증여', - args: { isGold: true, amount: 10_050, destGeneralID: 3 }, - expectedAmount: 10_000, - }, - { - name: 'donation rounds a half unit up', - action: 'che_헌납', - args: { isGold: true, amount: 150 }, - expectedAmount: 200, - }, - { - name: 'donation clamps below the minimum', - action: 'che_헌납', - args: { isGold: true, amount: 1 }, - expectedAmount: 100, - }, - { - name: 'donation clamps above the maximum', - action: 'che_헌납', - args: { isGold: true, amount: 10_050 }, - expectedAmount: 10_000, - }, - { - name: 'trade rounds a half unit up', - action: 'che_군량매매', - args: { buyRice: true, amount: 150 }, - expectedAmount: 200, - }, - { - name: 'trade clamps below the minimum', - action: 'che_군량매매', - args: { buyRice: true, amount: 1 }, - expectedAmount: 100, - }, - { - name: 'trade clamps above the maximum', - action: 'che_군량매매', - args: { buyRice: true, amount: 10_050 }, - expectedAmount: 10_000, - }, -]; - -integration('general command resource amount normalization matrix', () => { - it.each(resourceAmountCases)( - '$name matches legacy rounding and clamp semantics', - async ({ action, args, expectedAmount }) => { - const request = buildRequest(action, args); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceActor = reference.after.generals.find((entry) => entry.id === 1); - const coreActor = core.after.generals.find((entry) => entry.id === 1); - const referenceLastTurn = referenceActor?.lastTurn as { arg?: Record } | null | undefined; - const coreLastTurn = coreActor?.lastTurn as { arg?: Record } | null | undefined; - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(referenceLastTurn?.arg).toMatchObject({ amount: expectedAmount }); - expect(coreLastTurn?.arg).toMatchObject({ amount: expectedAmount }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -integration('general command donation resource boundaries', () => { - it('donates the available resource when the normalized request exceeds the current amount', async () => { - const request = buildRequest('che_헌납', { isGold: true, amount: 10_000 }, { gold: 5_000 }); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_헌납', - actionKey: 'che_헌납', - usedFallback: false, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); - - it('falls back when current rice is below the legacy minimum even for a small request', async () => { - const request = buildRequest('che_헌납', { isGold: false, amount: 100 }, { rice: 499 }); - 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: 'che_헌납', - actionKey: '휴식', - usedFallback: true, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); -}); - -integration('general command gift resource and target boundaries', () => { - it('keeps the legacy minimum rice reserve while gifting the available amount', async () => { - const request = buildRequest('che_증여', { isGold: false, amount: 10_000, destGeneralID: 3 }, { rice: 600 }); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_증여', - actionKey: 'che_증여', - usedFallback: false, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); - - it('rejects gifting to the actor and falls back without command RNG', async () => { - const request = buildRequest('che_증여', { isGold: true, amount: 100, destGeneralID: 1 }); - 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: 'che_증여', - actionKey: '휴식', - usedFallback: true, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); -}); - -interface ResourceTransferBoundaryCase { - name: string; - action: 'che_증여' | 'che_헌납' | 'che_군량매매'; - args: Record; - actorPatch?: Record; - fixturePatches?: FixturePatches; - completed: boolean; -} - -const resourceTransferBoundaryCases: ResourceTransferBoundaryCase[] = [ - { - name: 'gift completes with a zero transferable gold amount at the exact minimum', - action: 'che_증여', - args: { isGold: true, amount: 100, destGeneralID: 3 }, - actorPatch: { gold: 0 }, - completed: true, - }, - { - name: 'gift rejects a general from another nation', - action: 'che_증여', - args: { isGold: true, amount: 100, destGeneralID: 2 }, - completed: false, - }, - { - name: 'donation at the exact rice minimum may spend below that minimum', - action: 'che_헌납', - args: { isGold: false, amount: 100 }, - actorPatch: { rice: 500, crew: 0 }, - completed: true, - }, - { - name: 'donation completes with zero gold at the zero minimum', - action: 'che_헌납', - args: { isGold: true, amount: 100 }, - actorPatch: { gold: 0 }, - completed: true, - }, - { - name: 'buying rice with one gold preserves the fee and integer persistence boundary', - action: 'che_군량매매', - args: { buyRice: true, amount: 100 }, - actorPatch: { gold: 1, rice: 0 }, - completed: true, - }, - { - name: 'selling one rice preserves the fee and integer persistence boundary', - action: 'che_군량매매', - args: { buyRice: false, amount: 100 }, - actorPatch: { gold: 0, rice: 1, crew: 0 }, - completed: true, - }, - { - name: 'trade rate 137 preserves resource, tax, stat experience, and logs', - action: 'che_군량매매', - args: { buyRice: false, amount: 1_000 }, - fixturePatches: { cities: { 3: { trade: 137 } } }, - completed: true, - }, - { - name: 'a neutral general can trade in a neutral trader city without nation tax', - action: 'che_군량매매', - args: { buyRice: false, amount: 1_000 }, - actorPatch: { nationId: 0, officerLevel: 0 }, - fixturePatches: { cities: { 3: { nationId: 0 } } }, - completed: true, - }, -]; - -integration('general resource transfer target, fee, state, and log parity', () => { - it.each(resourceTransferBoundaryCases)( - '$name', - async ({ action, args, actorPatch, fixturePatches, completed }) => { - const request = buildRequest(action, args, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = `general-resource-transfer-${action}-${String(args.isGold ?? args.buyRice)}`; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: completed ? action : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - }, - 120_000 - ); -}); - -type GeneralConstraintCase = { - name: string; - action: string; - args?: Record; - actorPatch?: Record; - fixturePatches?: FixturePatches; -}; - -const constraintCases: GeneralConstraintCase[] = [ - { - name: 'neutral general', - action: 'che_훈련', - actorPatch: { nationId: 0, officerLevel: 0 }, - }, - { - name: 'wandering nation', - action: 'che_농지개간', - fixturePatches: { - nations: { 1: { level: 0, capitalCityId: 0, typeCode: 'None' } }, - }, - }, - { - name: 'city not occupied by actor nation', - action: 'che_농지개간', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - }, - { - name: 'unsupplied city', - action: 'che_농지개간', - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - }, - { - name: 'insufficient gold', - action: 'che_상업투자', - actorPatch: { gold: 0 }, - }, - { - name: 'insufficient rice', - action: 'che_주민선정', - actorPatch: { rice: 0 }, - }, - { - name: 'maximum city trust', - action: 'che_주민선정', - fixturePatches: { cities: { 3: { trust: 100 } } }, - }, - { - name: 'sabotage targets the occupied city', - action: 'che_화계', - args: { destCityID: 3 }, - }, - { - name: 'sabotage targets a neutral city', - action: 'che_화계', - args: { destCityID: 70 }, - fixturePatches: { cities: { 70: { nationId: 0 } } }, - }, - { - name: 'sabotage targets a non-aggression nation', - action: 'che_화계', - args: { destCityID: 70 }, - fixturePatches: { - diplomacy: { - '1:2': { state: 7 }, - '2:1': { state: 7 }, - }, - }, - }, - { - name: 'insufficient sabotage gold', - action: 'che_화계', - args: { destCityID: 70 }, - actorPatch: { gold: 0 }, - }, - { - name: 'insufficient sabotage rice', - action: 'che_화계', - args: { destCityID: 70 }, - actorPatch: { rice: 0 }, - }, -]; - -const targetFailureLogCases: Array<{ - action: 'che_이동' | 'che_강행' | 'che_출병' | 'che_첩보' | 'che_화계'; - args: Record; - fixturePatches?: FixturePatches; -}> = [ - { action: 'che_이동', args: { destCityID: 3 } }, - { action: 'che_강행', args: { destCityID: 3 } }, - { - action: 'che_출병', - args: { destCityID: 3 }, - fixturePatches: { world: { startYear: 180, year: 185 } }, - }, - { action: 'che_첩보', args: { destCityID: 3 } }, - { action: 'che_화계', args: { destCityID: 3 } }, -]; - -integration('general command target-specific constraint failure log parity', () => { - it.each(targetFailureLogCases)( - '$action preserves the legacy destination name and particle', - async ({ action, args, fixturePatches }) => { - const request = buildRequest(action, args, {}, fixturePatches); - request.setup!.world!.hiddenSeed = `general-target-failure-log-${action}`; - 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: action, - actionKey: '휴식', - usedFallback: true, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - }, - 120_000 - ); -}); - -integration('general command full-constraint fallback matrix', () => { - it.each(constraintCases)( - '$name: $action falls back exactly like legacy', - async ({ action, args, actorPatch, fixturePatches }) => { - const request = buildRequest(action, args, actorPatch, fixturePatches); - request.setup!.world!.hiddenSeed = `general-constraint-${action}`; - 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: action, - actionKey: '휴식', - usedFallback: true, - }); - expect(core.execution.outcome).toHaveProperty('blockedReason'); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - expect(semanticLogSignatures(core.after.logs)).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - }, - 120_000 - ); -}); - -describe('general command success matrix manifest', () => { - it('covers every registered general command exactly once', () => { - const matrixActions = cases.map(([action]) => action); - - expect(new Set(matrixActions).size).toBe(matrixActions.length); - expect([...matrixActions].sort()).toEqual([...GENERAL_TURN_COMMAND_KEYS].sort()); - }); -}); diff --git a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts deleted file mode 100644 index 642004ed..00000000 --- a/tools/integration-tests/test/turnCommandNationMatrix.integration.test.ts +++ /dev/null @@ -1,6777 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { NATION_TURN_COMMAND_KEYS } from '@sammo-ts/logic'; - -import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; -import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; -import { - normalizeStoredTurnLogText as normalizeStoredLogText, - orderedSemanticLogStreams, -} from '../src/turn-differential/logProjection.js'; -import { - projectSemanticTurnMessages, - projectSemanticUnreadMessageDeltas, - projectStrictTurnMessageTimeline, -} from '../src/turn-differential/messageProjection.js'; -import { - findTurnDifferentialWorkspaceRoot, - runReferenceTurnCommandTraceRequest, -} from '../src/turn-differential/referenceSnapshot.js'; - -const configuredWorkspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT; -const workspaceRoot = configuredWorkspaceRoot ?? findTurnDifferentialWorkspaceRoot(process.cwd()); -const integration = describe.skipIf(!workspaceRoot || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'); - -const readGold = (row: { gold?: unknown } | undefined): number => (typeof row?.gold === 'number' ? row.gold : 0); -const readNationResource = (row: { gold?: unknown; rice?: unknown } | undefined, resource: 'gold' | 'rice'): number => - typeof row?.[resource] === 'number' ? row[resource] : 0; -const readCityPopulation = (row: { population?: unknown } | undefined): number => - typeof row?.population === 'number' ? row.population : 0; -const readNumericField = (row: Record | undefined, field: string): number => - typeof row?.[field] === 'number' ? row[field] : 0; -const readRecord = (value: unknown): Record => - typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; -const readNationMeta = (row: { meta?: unknown } | undefined): Record => - typeof row?.meta === 'object' && row.meta !== null && !Array.isArray(row.meta) - ? (row.meta as Record) - : {}; -const readGeneralMeta = readNationMeta; -const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...'; - -const timestampMillis = (value: unknown): number => { - const raw = String(value); - const normalized = raw.includes('T') ? raw : `${raw.replace(' ', 'T').replace(/\.(\d{3})\d*$/, '.$1')}Z`; - return new Date(normalized).getTime(); -}; - -const semanticLogSignatures = (logs: Array>): string[] => orderedSemanticLogStreams(logs); - -const nationCommandLogs = (logs: Array>): Array> => - logs.filter((entry) => normalizeStoredLogText(entry.text) !== '아무것도 실행하지 않았습니다.'); - -const ignoredLifecyclePaths = [ - /^generalTurns/, - /^nationTurns/, - /^logs/, - /^messages/, - /^world\.turnTime$/, - /^world\.gameNow$/, - /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/, - /^generals\[1\]\.(?:turnTick|turnSecond|turnFraction)$/, - /^generals\[[^\]]+\]\.meta(?:\.|$)/, - /^nations\[[^\]]+\]\.meta\.(?:turn_last_\d+|next_execute_.+|capset|tech|gennum|war|surlimit|strategic_cmd_limit)(?:\.|$)/, -]; - -const general = (id: number, nationId: number, cityId: number, officerLevel: number): Record => ({ - id, - nationId, - cityId, - troopId: 0, - leadership: 90, - strength: 80, - intelligence: 70, - leadershipExp: 0, - strengthExp: 0, - intelExp: 0, - experience: 1000, - dedication: 1000, - expLevel: 0, - officerLevel, - officerCityId: officerLevel >= 5 ? cityId : 0, - injury: 0, - age: 30, - gold: 100_000, - rice: 100_000, - crew: 1_000, - crewTypeId: 1100, - train: 50, - atmos: 50, - killTurn: 24, - npcState: 0, - blockState: 0, - personality: 'None', - specialDomestic: 'None', - specialWar: 'None', - itemHorse: 'None', - itemWeapon: 'None', - itemBook: 'None', - itemExtra: 'None', - meta: {}, -}); - -interface FixturePatches { - world?: Partial['world']>>; - generals?: Record>; - nations?: Record>; - cities?: Record>; - troops?: Array>; - diplomacy?: Record>; - randomFoundingCandidateCityIds?: number[]; -} - -type NationMatrixCase = [string, Record | undefined, FixturePatches?]; - -const researchCase = (action: string, command: string, term: number): NationMatrixCase => [ - action, - undefined, - { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command, term }, - }, - }, - }, - }, -]; - -const researchConfigs: Record< - string, - { - command: string; - auxKey: string; - preReqTurn: number; - cost: number; - } -> = { - event_원융노병연구: { - command: '원융노병 연구', - auxKey: 'can_원융노병사용', - preReqTurn: 23, - cost: 100_000, - }, - event_화시병연구: { - command: '화시병 연구', - auxKey: 'can_화시병사용', - preReqTurn: 11, - cost: 50_000, - }, - event_음귀병연구: { - command: '음귀병 연구', - auxKey: 'can_음귀병사용', - preReqTurn: 11, - cost: 50_000, - }, - event_대검병연구: { - command: '대검병 연구', - auxKey: 'can_대검병사용', - preReqTurn: 11, - cost: 50_000, - }, - event_화륜차연구: { - command: '화륜차 연구', - auxKey: 'can_화륜차사용', - preReqTurn: 23, - cost: 100_000, - }, - event_산저병연구: { - command: '산저병 연구', - auxKey: 'can_산저병사용', - preReqTurn: 11, - cost: 50_000, - }, - event_극병연구: { - command: '극병 연구', - auxKey: 'can_극병사용', - preReqTurn: 23, - cost: 100_000, - }, - event_상병연구: { - command: '상병 연구', - auxKey: 'can_상병사용', - preReqTurn: 23, - cost: 100_000, - }, - event_무희연구: { - command: '무희 연구', - auxKey: 'can_무희사용', - preReqTurn: 23, - cost: 100_000, - }, -}; - -const buildRequest = ( - action: string, - args?: Record, - fixturePatches: FixturePatches = {} -): TurnCommandFixtureRequest => ({ - kind: 'nation', - actorGeneralId: 1, - action, - ...(args ? { args } : {}), - setup: { - isolateWorld: true, - world: { - startYear: 180, - year: 190, - month: 1, - develCost: 18, - isUnited: 0, - hiddenSeed: 'turn-command-nation-matrix-v1', - freezeClock: true, - ...fixturePatches.world, - }, - nations: [ - { - id: 1, - name: '아국', - capitalCityId: 3, - gold: 1_000_000, - rice: 1_000_000, - tech: 1000, - level: 1, - typeCode: 'che_명가', - war: 0, - diplomacyLimit: 0, - strategicCommandLimit: 0, - generalCount: 2, - meta: { can_국호변경: 1, can_국기변경: 1, surlimit: 0 }, - ...fixturePatches.nations?.[1], - }, - { - id: 2, - name: '타국', - capitalCityId: 70, - gold: 1_000_000, - rice: 1_000_000, - tech: 1000, - level: 1, - typeCode: 'che_명가', - war: 0, - diplomacyLimit: 0, - strategicCommandLimit: 0, - generalCount: 1, - meta: { surlimit: 0 }, - ...fixturePatches.nations?.[2], - }, - ], - cities: [ - { - id: 3, - nationId: 1, - population: 100_000, - agriculture: 1_000, - commerce: 1_000, - security: 1_000, - defence: 1_000, - wall: 1_000, - supplyState: 1, - frontState: 0, - state: 0, - term: 0, - trust: 80, - trade: 100, - ...fixturePatches.cities?.[3], - }, - { - id: 70, - nationId: 2, - population: 100_000, - agriculture: 1_000, - commerce: 1_000, - security: 1_000, - defence: 1_000, - wall: 1_000, - supplyState: 1, - frontState: 1, - state: 0, - term: 0, - trust: 80, - trade: 100, - ...fixturePatches.cities?.[70], - }, - ...Object.entries(fixturePatches.cities ?? {}) - .filter(([id]) => !['3', '70'].includes(id)) - .map(([id, patch]) => ({ - id: Number(id), - nationId: 0, - population: 100_000, - agriculture: 1_000, - commerce: 1_000, - security: 1_000, - defence: 1_000, - wall: 1_000, - supplyState: 1, - frontState: 0, - state: 0, - term: 0, - trust: 80, - trade: 100, - ...patch, - })), - ], - generals: [ - { ...general(1, 1, 3, 12), ...fixturePatches.generals?.[1] }, - { ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] }, - { ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] }, - ...Object.entries(fixturePatches.generals ?? {}) - .filter(([id]) => !['1', '2', '3'].includes(id)) - .map(([id, patch]) => ({ - ...general( - Number(id), - typeof patch.nationId === 'number' ? patch.nationId : 0, - typeof patch.cityId === 'number' ? patch.cityId : 3, - typeof patch.officerLevel === 'number' ? patch.officerLevel : 1 - ), - ...patch, - })), - ], - ...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}), - ...(fixturePatches.randomFoundingCandidateCityIds - ? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds } - : {}), - diplomacy: [ - { - fromNationId: 1, - toNationId: 2, - state: 3, - term: 0, - dead: 0, - ...fixturePatches.diplomacy?.['1:2'], - }, - { - fromNationId: 2, - toNationId: 1, - state: 3, - term: 0, - dead: 0, - ...fixturePatches.diplomacy?.['2:1'], - }, - ...Object.entries(fixturePatches.diplomacy ?? {}) - .filter(([key]) => !['1:2', '2:1'].includes(key)) - .map(([key, patch]) => { - const [fromNationId, toNationId] = key.split(':').map(Number); - return { - fromNationId, - toNationId, - state: 3, - term: 0, - dead: 0, - ...patch, - }; - }), - ], - nationTurns: [ - { - nationId: 1, - officerLevel: 12, - turnIndex: 1, - action: 'che_국호변경', - args: { nationName: '다음국' }, - }, - ], - }, - observe: { - allGenerals: true, - allCities: true, - allNations: true, - allTroops: true, - generalIds: [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - ...Object.keys(fixturePatches.generals ?? {}) - .map(Number) - .filter((id) => ![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(id)), - ], - cityIds: [ - 3, - 70, - ...Object.keys(fixturePatches.cities ?? {}) - .map(Number) - .filter((id) => id !== 3 && id !== 70), - ...(fixturePatches.randomFoundingCandidateCityIds ?? []).filter((id) => id !== 3 && id !== 70), - ], - nationIds: [1, 2], - ...(fixturePatches.diplomacy - ? { - diplomacyPairs: Object.keys(fixturePatches.diplomacy) - .filter((key) => !['1:2', '2:1'].includes(key)) - .map((key) => { - const [fromNationId, toNationId] = key.split(':').map(Number); - return { fromNationId, toNationId }; - }), - } - : {}), - ...(action === 'che_백성동원' || - action === 'che_이호경식' || - action === 'che_급습' || - action === 'che_필사즉생' || - action === 'che_허보' || - action === 'che_의병모집' || - action === 'che_수몰' - ? { - nationCooldowns: [ - { - nationId: 1, - actionName: - action === 'che_백성동원' - ? '백성동원' - : action === 'che_이호경식' - ? '이호경식' - : action === 'che_급습' - ? '급습' - : action === 'che_필사즉생' - ? '필사즉생' - : action === 'che_허보' - ? '허보' - : action === 'che_의병모집' - ? '의병모집' - : '수몰', - }, - ], - } - : {}), - logAfterId: 0, - includeNationHistoryLogs: true, - includeGlobalHistoryLogs: true, - messageAfterId: 0, - }, -}); - -const cases: NationMatrixCase[] = [ - ['휴식', undefined], - ['che_포상', { isGold: true, amount: 100, destGeneralID: 3 }], - ['che_선전포고', { destNationID: 2 }], - ['che_국호변경', { nationName: '신아국' }], - ['che_국기변경', { colorType: 1 }], - ['che_몰수', { isGold: true, amount: 100, destGeneralID: 3 }], - ['che_물자원조', { destNationID: 2, amountList: [100, 200] }], - ['che_불가침제의', { destNationID: 2, year: 191, month: 1 }], - [ - 'che_부대탈퇴지시', - { destGeneralID: 3 }, - { - generals: { 1: { troopId: 1 }, 3: { troopId: 1 } }, - troops: [{ id: 1, nationId: 1, name: '조조군' }], - }, - ], - ['che_발령', { destGeneralID: 3, destCityID: 70 }, { cities: { 70: { nationId: 1 } } }], - ['che_종전제의', { destNationID: 2 }, { diplomacy: { '1:2': { state: 0 }, '2:1': { state: 0 } } }], - ['che_불가침파기제의', { destNationID: 2 }, { diplomacy: { '1:2': { state: 7 }, '2:1': { state: 7 } } }], - ['cr_인구이동', { destCityID: 70, amount: 1000 }, { cities: { 70: { nationId: 1, supplyState: 1 } } }], - [ - 'che_천도', - { destCityID: 70 }, - { - nations: { - 1: { - capitalRevision: 0, - turnLastByOfficerLevel: { - 12: { - command: '천도', - arg: { destCityID: 70 }, - term: 2, - seq: 0, - }, - }, - }, - }, - cities: { 70: { nationId: 1, supplyState: 1 } }, - }, - ], - [ - 'che_증축', - undefined, - { - nations: { - 1: { - capitalRevision: 0, - turnLastByOfficerLevel: { - 12: { command: '증축', arg: {}, term: 5, seq: 0 }, - }, - }, - }, - cities: { 3: { level: 7 } }, - }, - ], - [ - 'che_감축', - undefined, - { - nations: { - 1: { - capitalRevision: 0, - turnLastByOfficerLevel: { - 12: { command: '감축', arg: {}, term: 5, seq: 0 }, - }, - }, - }, - cities: { 3: { level: 9 } }, - }, - ], - [ - 'che_무작위수도이전', - undefined, - { - world: { year: 181 }, - nations: { - 1: { - meta: { - can_무작위수도이전: 1, - }, - turnLastByOfficerLevel: { - 12: { command: '무작위 수도 이전', arg: {}, term: 1 }, - }, - }, - }, - cities: { 70: { nationId: 0, supplyState: 0 } }, - randomFoundingCandidateCityIds: [70], - }, - ], - researchCase('event_원융노병연구', '원융노병 연구', 23), - researchCase('event_화시병연구', '화시병 연구', 11), - researchCase('event_음귀병연구', '음귀병 연구', 11), - researchCase('event_대검병연구', '대검병 연구', 11), - researchCase('event_화륜차연구', '화륜차 연구', 23), - researchCase('event_산저병연구', '산저병 연구', 11), - researchCase('event_극병연구', '극병 연구', 23), - researchCase('event_상병연구', '상병 연구', 23), - researchCase('event_무희연구', '무희 연구', 23), - ['che_백성동원', { destCityID: 70 }, { cities: { 70: { nationId: 1 } } }], - [ - 'che_이호경식', - { destNationID: 2 }, - { diplomacy: { '1:2': { state: 1, term: 12 }, '2:1': { state: 1, term: 12 } } }, - ], - ['che_급습', { destNationID: 2 }, { diplomacy: { '1:2': { state: 1, term: 12 }, '2:1': { state: 1, term: 12 } } }], - [ - 'che_필사즉생', - undefined, - { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '필사즉생', arg: {}, term: 2 }, - }, - }, - }, - diplomacy: { '1:2': { state: 0 }, '2:1': { state: 0 } }, - }, - ], - [ - 'che_허보', - { destCityID: 70 }, - { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '허보', arg: { destCityID: 70 }, term: 1 }, - }, - coreTurnLastByOfficerLevel: { - 12: { command: '허보', arg: { destCityId: 70 }, term: 1 }, - }, - }, - }, - diplomacy: { '1:2': { state: 0 }, '2:1': { state: 0 } }, - }, - ], - [ - 'che_초토화', - { destCityID: 70 }, - { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '초토화', arg: { destCityID: 70 }, term: 2 }, - }, - coreTurnLastByOfficerLevel: { - 12: { command: '초토화', arg: { destCityId: 70 }, term: 2 }, - }, - }, - }, - cities: { 70: { nationId: 1, supplyState: 1 } }, - }, - ], - [ - 'che_의병모집', - undefined, - { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '의병모집', term: 2 }, - }, - }, - }, - }, - ], - [ - 'che_수몰', - { destCityID: 70 }, - { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '수몰', arg: { destCityID: 70 }, term: 2 }, - }, - coreTurnLastByOfficerLevel: { - 12: { command: '수몰', arg: { destCityId: 70 }, term: 2 }, - }, - }, - }, - diplomacy: { '1:2': { state: 0 }, '2:1': { state: 0 } }, - }, - ], - [ - 'che_피장파장', - { destNationID: 2, commandType: 'che_필사즉생' }, - { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { - command: '피장파장', - arg: { destNationID: 2, commandType: 'che_필사즉생' }, - term: 1, - }, - }, - coreTurnLastByOfficerLevel: { - 12: { - command: '피장파장', - arg: { destNationId: 2, commandType: 'che_필사즉생' }, - term: 1, - }, - }, - }, - }, - diplomacy: { '1:2': { state: 0 }, '2:1': { state: 0 } }, - }, - ], -]; - -describe('nation command differential coverage manifest', () => { - it('keeps one successful Ref/Core case for every registered nation turn command', () => { - const matrixActions = cases.map(([action]) => action); - - expect(new Set(matrixActions).size).toBe(matrixActions.length); - expect([...matrixActions].sort()).toEqual([...NATION_TURN_COMMAND_KEYS].sort()); - }); -}); - -integration('nation command success matrix', () => { - it.each(cases)( - '%s matches the legacy state delta and command RNG', - async (action, args, fixturePatches) => { - const request = buildRequest(action, args, fixturePatches); - request.includeLifecycle = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - if (process.env.TURN_DIFFERENTIAL_DEBUG === '1') { - process.stderr.write( - `${JSON.stringify( - { - action, - referenceOutcome: reference.execution.outcome, - coreOutcome: core.execution.outcome, - differences: compareTurnSnapshotDeltas( - reference.before, - reference.after, - core.before, - core.after, - { ignoredPathPatterns: ignoredLifecyclePaths } - ), - }, - null, - 2 - )}\n` - ); - } - - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).not.toHaveProperty('blockedReason'); - expect(core.rng).toEqual(reference.rng); - - const actorGeneralId = request.actorGeneralId; - const actorNationId = 1; - const actorOfficerLevel = 12; - const referenceBeforeActor = reference.before.generals.find((general) => general.id === actorGeneralId); - const referenceAfterActor = reference.after.generals.find((general) => general.id === actorGeneralId); - const coreBeforeActor = core.before.generals.find((general) => general.id === actorGeneralId); - const coreAfterActor = core.after.generals.find((general) => general.id === actorGeneralId); - const expectedTurnMinutes = Number(reference.before.world.tickMinutes); - const findActorNationTurn = (turns: Array>, turnIndex: number) => - turns.find( - (turn) => - turn.nationId === actorNationId && - turn.officerLevel === actorOfficerLevel && - turn.turnIndex === turnIndex - ); - - expect(findActorNationTurn(reference.before.nationTurns, 0)?.action).toBe(action); - expect(findActorNationTurn(core.before.nationTurns, 0)?.action).toBe(action); - expect(findActorNationTurn(reference.after.nationTurns, 0)).toMatchObject({ - action: 'che_국호변경', - args: { nationName: '다음국' }, - }); - expect(findActorNationTurn(core.after.nationTurns, 0)).toMatchObject({ - action: 'che_국호변경', - args: { nationName: '다음국' }, - }); - expect( - timestampMillis(referenceAfterActor?.turnTime) - timestampMillis(referenceBeforeActor?.turnTime) - ).toBe(expectedTurnMinutes * 60_000); - expect(timestampMillis(coreAfterActor?.turnTime) - timestampMillis(coreBeforeActor?.turnTime)).toBe( - expectedTurnMinutes * 60_000 - ); - expect(timestampMillis(coreAfterActor?.turnTime)).toBe(timestampMillis(referenceAfterActor?.turnTime)); - expect(referenceAfterActor?.mySet).toBe(Math.min(9, Number(referenceBeforeActor?.mySet) + 3)); - expect(coreAfterActor?.mySet).toBe(referenceAfterActor?.mySet); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - // Ref persists logs in two independent ID streams and messages in - // per-mailbox rows, so they are excluded from the state comparator. - // Compare those observable graphs for every registered command. - expect(semanticLogSignatures(nationCommandLogs(addedReferenceLogs(core.before, core.after.logs)))).toEqual( - semanticLogSignatures(nationCommandLogs(addedReferenceLogs(reference.before, reference.after.logs))) - ); - const messageAfterId = reference.before.watermarks.messageId; - const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); - const referenceTimeline = projectStrictTurnMessageTimeline( - reference.before, - reference.after, - messageAfterId - ); - expect({ - unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), - messages: projectSemanticTurnMessages(core.after.messages, messageAfterId), - timeline: coreTimeline, - }).toEqual({ - unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), - messages: projectSemanticTurnMessages(reference.after.messages, messageAfterId), - timeline: referenceTimeline, - }); - expect(referenceTimeline.usesSingleTick).toBe(true); - const researchConfig = researchConfigs[action]; - if (researchConfig) { - for (const snapshot of [reference, core]) { - const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); - const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); - const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1); - const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1); - const expectedProgress = 5 * (researchConfig.preReqTurn + 1); - - expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe( - researchConfig.cost - ); - expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe( - researchConfig.cost - ); - expect( - readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience') - ).toBe(expectedProgress); - expect( - readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication') - ).toBe(expectedProgress); - expect(readNumericField(readNationMeta(nationAfter), researchConfig.auxKey)).toBe(1); - expect(snapshot.rng).toEqual([]); - } - } - }, - 120_000 - ); -}); - -integration('legacy nation lifecycle comparison guard', () => { - it('does not alter command effects or RNG beyond the explicit queue and turn lifecycle', () => { - const request = buildRequest('che_포상', { isGold: true, amount: 100, destGeneralID: 3 }); - const commandOnly = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const withLifecycle = runReferenceTurnCommandTraceRequest(workspaceRoot!, { - ...request, - includeLifecycle: true, - } as unknown as Record); - - expect(withLifecycle.rng).toEqual(commandOnly.rng); - expect( - compareTurnSnapshotDeltas( - commandOnly.before, - commandOnly.after, - withLifecycle.before, - withLifecycle.after, - { ignoredPathPatterns: ignoredLifecyclePaths } - ) - ).toEqual([]); - }, 180_000); -}); - -type NationActiveActionInheritanceCase = { - name: string; - request: NationMatrixCase; - initialPoint?: number; - expectedPointDelta: number; -}; - -const nationActiveActionInheritanceCases: NationActiveActionInheritanceCase[] = [ - { - name: 'ordinary award does not count as a legacy active action', - request: ['che_포상', { isGold: true, amount: 100, destGeneralID: 3 }], - initialPoint: 6, - expectedPointDelta: 0, - }, - { - name: 'nation rename contributes one active action', - request: ['che_국호변경', { nationName: '능동아국' }], - expectedPointDelta: 3, - }, - { - name: 'event research contributes one active action on completion', - request: researchCase('event_화시병연구', '화시병 연구', 11), - expectedPointDelta: 3, - }, - { - name: 'nation rest does not count as a legacy active action', - request: ['휴식', undefined], - expectedPointDelta: 0, - }, -]; - -const readNationActorActiveActionPoints = ( - snapshot: { generals: Array> }, - generalId: number -): number => { - const general = snapshot.generals.find((entry) => entry.id === generalId); - const value = general?.inheritActiveActionPoints; - return typeof value === 'number' && Number.isFinite(value) ? value : 0; -}; - -integration('nation active-action inheritance point parity', () => { - it.each(nationActiveActionInheritanceCases)( - '$name', - async ({ name, request: [action, args, fixturePatches], initialPoint = 0, expectedPointDelta }) => { - const request = buildRequest(action, args, { - ...fixturePatches, - generals: { - ...fixturePatches?.generals, - 1: { - ...fixturePatches?.generals?.[1], - ownerId: 2_000_000_001, - inheritActiveActionPoints: initialPoint, - }, - }, - }); - request.setup!.world!.hiddenSeed = `nation-active-action-${name}`; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referencePointDelta = - readNationActorActiveActionPoints(reference.after, 1) - - readNationActorActiveActionPoints(reference.before, 1); - const corePointDelta = - readNationActorActiveActionPoints(core.after, 1) - readNationActorActiveActionPoints(core.before, 1); - - expect(readNationActorActiveActionPoints(reference.before, 1)).toBe(initialPoint); - expect(readNationActorActiveActionPoints(core.before, 1)).toBe(initialPoint); - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(referencePointDelta).toBe(expectedPointDelta); - expect(corePointDelta).toBe(referencePointDelta); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -const nationNameBoundaryCases: Array<{ - name: string; - nationName: string; - completed: boolean; - duplicate?: boolean; -}> = [ - { name: 'accepts nine full-width characters at width eighteen', nationName: '가나다라마바사아자', completed: true }, - { name: 'accepts eighteen half-width characters', nationName: '123456789012345678', completed: true }, - { name: 'preserves surrounding spaces', nationName: ' 신국 ', completed: true }, - { name: 'accepts a single space', nationName: ' ', completed: true }, - { name: 'rejects ten full-width characters at width twenty', nationName: '가나다라마바사아자차', completed: false }, - { name: 'rejects nineteen half-width characters', nationName: '1234567890123456789', completed: false }, - { name: 'rejects another nation duplicate', nationName: '타국', completed: false, duplicate: true }, - { name: 'rejects the current nation duplicate', nationName: '아국', completed: false, duplicate: true }, -]; - -const nationColors = [ - '#FF0000', - '#800000', - '#A0522D', - '#FF6347', - '#FFA500', - '#FFDAB9', - '#FFD700', - '#FFFF00', - '#7CFC00', - '#00FF00', - '#808000', - '#008000', - '#2E8B57', - '#008080', - '#20B2AA', - '#6495ED', - '#7FFFD4', - '#AFEEEE', - '#87CEEB', - '#00FFFF', - '#00BFFF', - '#0000FF', - '#000080', - '#483D8B', - '#7B68EE', - '#BA55D3', - '#800080', - '#FF00FF', - '#FFC0CB', - '#F5F5DC', - '#E0FFFF', - '#FFFFFF', - '#A9A9A9', -] as const; - -const nationFlagBoundaryCases: Array<{ - name: string; - colorType: unknown; - completed: boolean; - expectedIndex?: number; -}> = [ - { name: 'accepts integer zero', colorType: 0, completed: true, expectedIndex: 0 }, - { name: 'accepts an integer numeric string', colorType: '1', completed: true, expectedIndex: 1 }, - { name: 'accepts boolean true as key one', colorType: true, completed: true, expectedIndex: 1 }, - { name: 'accepts boolean false as key zero', colorType: false, completed: true, expectedIndex: 0 }, - { name: 'truncates a positive fractional key', colorType: 1.9, completed: true, expectedIndex: 1 }, - { name: 'truncates a negative fraction to zero', colorType: -0.9, completed: true, expectedIndex: 0 }, - { name: 'accepts the upper fractional key', colorType: 32.9, completed: true, expectedIndex: 32 }, - { name: 'rejects a leading-zero numeric string', colorType: '01', completed: false }, - { name: 'rejects a fractional numeric string', colorType: '1.5', completed: false }, - { name: 'rejects the first out-of-range integer', colorType: 33, completed: false }, - { name: 'rejects a negative integer', colorType: -1, completed: false }, - { name: 'rejects null', colorType: null, completed: false }, -]; - -const addedReferenceLogs = ( - before: { watermarks: { logId: number; historyLogId: number } }, - afterLogs: Array> -): Array> => - afterLogs.filter((entry) => { - const scope = String(entry.scope).toLowerCase(); - const category = String(entry.category).toLowerCase(); - const watermark = - scope === 'nation' || (scope === 'system' && category === 'history') - ? before.watermarks.historyLogId - : before.watermarks.logId; - return (Number(entry.id) || 0) > watermark; - }); - -integration('nation name boundary parity', () => { - it.each(nationNameBoundaryCases)( - '$name', - async ({ nationName, completed, duplicate }) => { - const request = buildRequest('che_국호변경', { nationName }); - request.observe!.includeNationHistoryLogs = true; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - if (duplicate) { - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_국호변경', - actionKey: 'che_국호변경', - usedFallback: false, - completed: false, - }); - } else { - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_국호변경', - actionKey: completed ? 'che_국호변경' : '휴식', - usedFallback: !completed, - }); - } - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - if (completed) { - expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toEqual( - readRecord(reference.execution.outcome).lastTurn - ); - expect(core.after.nations.find((entry) => entry.id === 1)?.name).toBe(nationName); - expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - }, - 120_000 - ); -}); - -integration('nation flag boundary parity', () => { - it.each(nationFlagBoundaryCases)( - '$name', - async ({ colorType, completed, expectedIndex }) => { - const request = buildRequest('che_국기변경', { colorType }); - request.observe!.includeNationHistoryLogs = true; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_국기변경', - actionKey: completed ? 'che_국기변경' : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - if (completed) { - expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toEqual( - readRecord(reference.execution.outcome).lastTurn - ); - expect(core.after.nations.find((entry) => entry.id === 1)?.color).toBe(nationColors[expectedIndex!]); - expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - }, - 120_000 - ); -}); - -type DiplomacyProposalAction = 'che_선전포고' | 'che_불가침제의' | 'che_종전제의' | 'che_불가침파기제의'; - -interface DiplomacyProposalBoundaryCase { - name: string; - action: DiplomacyProposalAction; - args?: Record; - fixturePatches?: FixturePatches; - completed: boolean; -} - -const diplomacyProposalBoundaryCases: DiplomacyProposalBoundaryCase[] = [ - ...(['che_선전포고', 'che_불가침제의', 'che_종전제의', 'che_불가침파기제의'] as const).flatMap((action) => [ - { - name: `${action} rejects a numeric-string destination`, - action, - args: action === 'che_불가침제의' ? { destNationID: '2', year: 190, month: 7 } : { destNationID: '2' }, - completed: false, - }, - { - name: `${action} rejects a fractional destination instead of truncating it`, - action, - args: action === 'che_불가침제의' ? { destNationID: 2.9, year: 190, month: 7 } : { destNationID: 2.9 }, - completed: false, - }, - { - name: `${action} rejects a missing destination nation`, - action, - args: action === 'che_불가침제의' ? { destNationID: 9, year: 190, month: 7 } : { destNationID: 9 }, - completed: false, - }, - { - name: `${action} rejects a neutral actor`, - action, - args: action === 'che_불가침제의' ? { destNationID: 2, year: 190, month: 7 } : { destNationID: 2 }, - fixturePatches: { - generals: { 1: { nationId: 0 } }, - cities: { 3: { nationId: 0 } }, - }, - completed: false, - }, - { - name: `${action} rejects an actor below chief`, - action, - args: action === 'che_불가침제의' ? { destNationID: 2, year: 190, month: 7 } : { destNationID: 2 }, - fixturePatches: { generals: { 1: { officerLevel: 4 } } }, - completed: false, - }, - ]), - { - name: 'declare-war rejects the opening year', - action: 'che_선전포고', - args: { destNationID: 2 }, - fixturePatches: { world: { year: 180 } }, - completed: false, - }, - { - name: 'declare-war accepts the exact start-year plus one boundary', - action: 'che_선전포고', - args: { destNationID: 2 }, - fixturePatches: { world: { year: 181 } }, - completed: true, - }, - { - name: 'declare-war rejects an actor city occupied by another nation', - action: 'che_선전포고', - args: { destNationID: 2 }, - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'declare-war rejects an unsupplied actor city', - action: 'che_선전포고', - args: { destNationID: 2 }, - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - completed: false, - }, - { - name: 'declare-war rejects forward war state', - action: 'che_선전포고', - args: { destNationID: 2 }, - fixturePatches: { diplomacy: { '1:2': { state: 0 }, '2:1': { state: 3 } } }, - completed: false, - }, - { - name: 'declare-war ignores the reverse diplomacy state', - action: 'che_선전포고', - args: { destNationID: 2 }, - fixturePatches: { diplomacy: { '1:2': { state: 3 }, '2:1': { state: 0 } } }, - completed: true, - }, - { - name: 'non-aggression rejects a fractional year', - action: 'che_불가침제의', - args: { destNationID: 2, year: 190.9, month: 7 }, - completed: false, - }, - { - name: 'non-aggression rejects a fractional month', - action: 'che_불가침제의', - args: { destNationID: 2, year: 190, month: 7.9 }, - completed: false, - }, - { - name: 'non-aggression rejects a year before the scenario start even when six months ahead', - action: 'che_불가침제의', - args: { destNationID: 2, year: 179, month: 7 }, - fixturePatches: { world: { year: 179, month: 1 } }, - completed: false, - }, - { - name: 'non-aggression rejects five months ahead', - action: 'che_불가침제의', - args: { destNationID: 2, year: 190, month: 6 }, - completed: false, - }, - { - name: 'non-aggression accepts exactly six months ahead without city ownership or supply', - action: 'che_불가침제의', - args: { destNationID: 2, year: 190, month: 7 }, - fixturePatches: { - nations: { 1: { name: '위' }, 2: { name: '탑' } }, - cities: { 3: { nationId: 2, supplyState: 0 } }, - }, - completed: true, - }, - { - name: 'non-aggression rejects a forward war state', - action: 'che_불가침제의', - args: { destNationID: 2, year: 190, month: 7 }, - fixturePatches: { diplomacy: { '1:2': { state: 0 }, '2:1': { state: 3 } } }, - completed: false, - }, - { - name: 'non-aggression ignores the reverse war state', - action: 'che_불가침제의', - args: { destNationID: 2, year: 190, month: 7 }, - fixturePatches: { diplomacy: { '1:2': { state: 3 }, '2:1': { state: 0 } } }, - completed: true, - }, - { - name: 'stop-war accepts forward declaration state zero', - action: 'che_종전제의', - args: { destNationID: 2 }, - fixturePatches: { diplomacy: { '1:2': { state: 0 }, '2:1': { state: 3 } } }, - completed: true, - }, - { - name: 'stop-war accepts forward war state one', - action: 'che_종전제의', - args: { destNationID: 2 }, - fixturePatches: { diplomacy: { '1:2': { state: 1 }, '2:1': { state: 3 } } }, - completed: true, - }, - { - name: 'stop-war rejects an unrelated forward state', - action: 'che_종전제의', - args: { destNationID: 2 }, - completed: false, - }, - { - name: 'stop-war rejects an unsupplied actor city', - action: 'che_종전제의', - args: { destNationID: 2 }, - fixturePatches: { cities: { 3: { supplyState: 0 } }, diplomacy: { '1:2': { state: 0 } } }, - completed: false, - }, - { - name: 'non-aggression cancellation accepts forward state seven', - action: 'che_불가침파기제의', - args: { destNationID: 2 }, - fixturePatches: { diplomacy: { '1:2': { state: 7 }, '2:1': { state: 3 } } }, - completed: true, - }, - { - name: 'non-aggression cancellation rejects an unrelated forward state', - action: 'che_불가침파기제의', - args: { destNationID: 2 }, - completed: false, - }, - { - name: 'non-aggression cancellation ignores a reverse-only state seven', - action: 'che_불가침파기제의', - args: { destNationID: 2 }, - fixturePatches: { diplomacy: { '1:2': { state: 3 }, '2:1': { state: 7 } } }, - completed: false, - }, -]; - -const expectedDiplomacyMessage = ( - action: DiplomacyProposalAction, - sourceName: string, - destinationName: string, - year: number, - month: number -): { type: 'national' | 'diplomacy'; text: string; option: Record } => { - switch (action) { - case 'che_선전포고': - return { - type: 'national', - text: `【외교】${year}년 ${month}월:${sourceName}에서 ${destinationName}에 선전포고`, - option: {}, - }; - case 'che_불가침제의': - return { - type: 'diplomacy', - text: `${sourceName}${sourceName === '위' ? '와' : '과'} ${year}년 ${month + 6}월까지 불가침 제의 서신`, - option: { action: 'noAggression', year, month: month + 6 }, - }; - case 'che_종전제의': - return { - type: 'diplomacy', - text: `${sourceName}의 종전 제의 서신`, - option: { action: 'stopWar', deletable: false }, - }; - case 'che_불가침파기제의': - return { - type: 'diplomacy', - text: `${sourceName}의 불가침 파기 제의 서신`, - option: { action: 'cancelNA', deletable: false }, - }; - } -}; - -integration('nation diplomacy proposal boundary and message parity', () => { - it.each(diplomacyProposalBoundaryCases)( - '$name', - async ({ action, args, fixturePatches, completed }) => { - const request = buildRequest(action, args, fixturePatches); - request.observe!.includeNationHistoryLogs = true; - request.observe!.includeGlobalHistoryLogs = true; - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - if ( - fixturePatches?.generals?.[1]?.nationId === 0 || - (typeof fixturePatches?.generals?.[1]?.officerLevel === 'number' && - fixturePatches.generals[1].officerLevel < 5) - ) { - expect(core.execution.outcome).toBeUndefined(); - } else { - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: completed ? action : '휴식', - usedFallback: !completed, - }); - } - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - const messageAfterId = reference.before.watermarks.messageId; - const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); - const referenceTimeline = projectStrictTurnMessageTimeline( - reference.before, - reference.after, - messageAfterId - ); - expect({ - unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), - messages: projectSemanticTurnMessages(core.after.messages, messageAfterId), - timeline: coreTimeline, - }).toEqual({ - unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), - messages: projectSemanticTurnMessages(reference.after.messages, messageAfterId), - timeline: referenceTimeline, - }); - expect(referenceTimeline.usesSingleTick).toBe(true); - - const referenceMessages = reference.after.messages.slice(reference.before.messages.length); - if (!completed) { - expect(referenceMessages).toEqual([]); - expect(core.after.messages).toEqual([]); - return; - } - - const sourceNation = reference.after.nations.find((entry) => entry.id === 1); - const destinationNation = reference.after.nations.find((entry) => entry.id === 2); - const expected = expectedDiplomacyMessage( - action, - String(sourceNation?.name), - String(destinationNation?.name), - Number(reference.after.world.year), - Number(reference.after.world.month) - ); - expect(referenceMessages).toHaveLength(2); - expect(referenceMessages.find((entry) => entry.mailbox === 9002)).toMatchObject({ - type: expected.type, - sourceId: 9001, - destinationId: 9002, - payload: { - src: { nation_id: 1, nation: sourceNation?.name }, - dest: { nation_id: 2, nation: destinationNation?.name }, - text: expected.text, - option: expected.option, - }, - }); - expect(core.after.messages).toHaveLength(2); - expect(core.after.messages.find((entry) => entry.mailbox === 9002)).toMatchObject({ - type: expected.type, - sourceId: 9001, - destinationId: 9002, - payload: { - src: { nationId: 1, nationName: sourceNation?.name }, - dest: { nationId: 2, nationName: destinationNation?.name }, - text: expected.text, - option: expected.option, - }, - }); - expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - }, - 120_000 - ); -}); - -interface PersonnelCommandBoundaryCase { - name: string; - action: 'che_부대탈퇴지시' | 'che_발령'; - args: Record; - fixturePatches?: FixturePatches; - completed: boolean; - expectedTroopId?: number; - expectedCityId?: number; -} - -const personnelCommandBoundaryCases: PersonnelCommandBoundaryCase[] = [ - { - name: 'troop-kick rejects a numeric-string target', - action: 'che_부대탈퇴지시', - args: { destGeneralID: '3' }, - completed: false, - }, - { - name: 'troop-kick rejects a fractional target instead of truncating it', - action: 'che_부대탈퇴지시', - args: { destGeneralID: 3.9 }, - completed: false, - }, - { - name: 'troop-kick rejects self', - action: 'che_부대탈퇴지시', - args: { destGeneralID: 1 }, - completed: false, - }, - { - name: 'troop-kick rejects a missing target', - action: 'che_부대탈퇴지시', - args: { destGeneralID: 9 }, - completed: false, - }, - { - name: 'troop-kick rejects a foreign target', - action: 'che_부대탈퇴지시', - args: { destGeneralID: 2 }, - completed: false, - }, - { - name: 'troop-kick completes without mutation for a non-member', - action: 'che_부대탈퇴지시', - args: { destGeneralID: 3 }, - completed: true, - expectedTroopId: 0, - }, - { - name: 'troop-kick completes without mutation for a troop leader', - action: 'che_부대탈퇴지시', - args: { destGeneralID: 3 }, - fixturePatches: { generals: { 3: { troopId: 3 } } }, - completed: true, - expectedTroopId: 3, - }, - { - name: 'troop-kick removes a regular troop member', - action: 'che_부대탈퇴지시', - args: { destGeneralID: 3 }, - fixturePatches: { generals: { 3: { troopId: 1 } } }, - completed: true, - expectedTroopId: 0, - }, - { - name: 'assignment accepts numeric-string IDs through PHP weak int coercion', - action: 'che_발령', - args: { destGeneralID: '3', destCityID: '70' }, - fixturePatches: { cities: { 70: { nationId: 1 } } }, - completed: true, - expectedCityId: 70, - }, - { - name: 'assignment truncates fractional IDs through PHP weak int coercion', - action: 'che_발령', - args: { destGeneralID: 3.9, destCityID: 70.9 }, - fixturePatches: { cities: { 70: { nationId: 1 } } }, - completed: true, - expectedCityId: 70, - }, - { - name: 'assignment rejects self', - action: 'che_발령', - args: { destGeneralID: 1, destCityID: 70 }, - fixturePatches: { cities: { 70: { nationId: 1 } } }, - completed: false, - }, - { - name: 'assignment rejects a missing target general at execution time', - action: 'che_발령', - args: { destGeneralID: 9, destCityID: 70 }, - fixturePatches: { cities: { 70: { nationId: 1 } } }, - completed: false, - }, - { - name: 'assignment rejects a foreign target general', - action: 'che_발령', - args: { destGeneralID: 2, destCityID: 70 }, - fixturePatches: { cities: { 70: { nationId: 1 } } }, - completed: false, - }, - { - name: 'assignment rejects an actor city occupied by another nation', - action: 'che_발령', - args: { destGeneralID: 3, destCityID: 70 }, - fixturePatches: { cities: { 3: { nationId: 2 }, 70: { nationId: 1 } } }, - completed: false, - }, - { - name: 'assignment rejects an unsupplied actor city', - action: 'che_발령', - args: { destGeneralID: 3, destCityID: 70 }, - fixturePatches: { cities: { 3: { supplyState: 0 }, 70: { nationId: 1 } } }, - completed: false, - }, - { - name: 'assignment rejects a destination city occupied by another nation', - action: 'che_발령', - args: { destGeneralID: 3, destCityID: 70 }, - completed: false, - }, - { - name: 'assignment rejects an unsupplied destination city', - action: 'che_발령', - args: { destGeneralID: 3, destCityID: 70 }, - fixturePatches: { cities: { 70: { nationId: 1, supplyState: 0 } } }, - completed: false, - }, - { - name: 'assignment moves a friendly target and records the assignment month', - action: 'che_발령', - args: { destGeneralID: 3, destCityID: 70 }, - fixturePatches: { cities: { 70: { nationId: 1 } } }, - completed: true, - expectedCityId: 70, - }, -]; - -integration('nation personnel command boundary parity', () => { - it.each(personnelCommandBoundaryCases)( - '$name', - async ({ action, args, fixturePatches, completed, expectedTroopId, expectedCityId }) => { - const request = buildRequest(action, args, fixturePatches); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - const originalCommandFailure = args.destGeneralID === 1; - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: completed || originalCommandFailure ? action : '휴식', - usedFallback: !completed && !originalCommandFailure, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - if (!completed) { - if (originalCommandFailure || (action === 'che_발령' && args.destGeneralID !== 9)) { - expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - } - return; - } - - const referenceTarget = reference.after.generals.find((entry) => entry.id === 3); - const coreTarget = core.after.generals.find((entry) => entry.id === 3); - if (expectedTroopId !== undefined) { - expect(referenceTarget?.troopId).toBe(expectedTroopId); - expect(coreTarget?.troopId).toBe(expectedTroopId); - } - if (expectedCityId !== undefined) { - expect(referenceTarget?.cityId).toBe(expectedCityId); - expect(coreTarget?.cityId).toBe(expectedCityId); - expect(readGeneralMeta(coreTarget).last발령).toBe(readGeneralMeta(referenceTarget).last발령); - } - expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - }, - 120_000 - ); -}); - -const scenario911AssignmentCases: Array<{ - name: string; - targetTroopId: number; - expectedActorCityId: number; - expectedMemberCityId: number; -}> = [ - { - name: 'gathers remote members when the assigned general is their troop leader', - targetTroopId: 3, - expectedActorCityId: 70, - expectedMemberCityId: 70, - }, - { - name: 'does not gather members when the assigned general is not their troop leader', - targetTroopId: 1, - expectedActorCityId: 3, - expectedMemberCityId: 3, - }, -]; - -integration('scenario 911 assignment troop gather parity', () => { - it.each(scenario911AssignmentCases)( - '$name', - async ({ targetTroopId, expectedActorCityId, expectedMemberCityId }) => { - const request = buildRequest( - 'che_발령', - { destGeneralID: 3, destCityID: 70 }, - { - world: { - staticEventHandlers: { - 'sammo\\Command\\Nation\\che_발령': ['event_부대발령즉시집합'], - }, - }, - cities: { 70: { nationId: 1 } }, - generals: { - 1: { troopId: 3 }, - 3: { troopId: targetTroopId }, - 4: { nationId: 1, cityId: 3, troopId: 3 }, - 5: { nationId: 1, cityId: 70, troopId: 3 }, - 6: { nationId: 1, cityId: 3, troopId: 6 }, - }, - troops: [ - { id: 1, nationId: 1, name: '선봉대' }, - { id: 3, nationId: 1, name: '백마대' }, - { id: 6, nationId: 1, name: '별동대' }, - ], - } - ); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_발령', - actionKey: 'che_발령', - usedFallback: false, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - - for (const [generalId, expectedCityId] of [ - [1, expectedActorCityId], - [3, 70], - [4, expectedMemberCityId], - [5, 70], - [6, 3], - ] as const) { - expect(reference.after.generals.find((entry) => entry.id === generalId)?.cityId).toBe(expectedCityId); - expect(core.after.generals.find((entry) => entry.id === generalId)?.cityId).toBe(expectedCityId); - } - expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - }, - 120_000 - ); -}); - -type VolunteerRecruitOutcome = 'fallback' | 'intermediate' | 'completed'; - -const volunteerRecruitBoundaryCases: Array<{ - name: string; - fixturePatches?: FixturePatches; - outcome: VolunteerRecruitOutcome; - coreResolution?: boolean; - expectedCreateCount?: number; - expectedPostReqTurn?: number; - expectedStrategicCommandLimit?: number; - expectedAverageExperience?: number; -}> = [ - { - name: 'rejects a neutral actor', - fixturePatches: { - generals: { 1: { nationId: 0 } }, - cities: { 3: { nationId: 0 } }, - }, - outcome: 'fallback', - coreResolution: false, - }, - { - name: 'rejects an actor city occupied by another nation', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - outcome: 'fallback', - }, - { - name: 'rejects an actor below chief rank', - fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } }, - outcome: 'fallback', - coreResolution: false, - }, - { - name: 'rejects a remaining strategic-command delay', - fixturePatches: { nations: { 1: { strategicCommandLimit: 1 } } }, - outcome: 'fallback', - }, - { - name: 'rejects the turn before the three-year opening boundary', - fixturePatches: { world: { year: 182 } }, - outcome: 'fallback', - }, - { - name: 'accepts the exact three-year opening boundary', - fixturePatches: { world: { year: 183 } }, - outcome: 'intermediate', - }, - { - name: 'allows an unsupplied actor city', - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - outcome: 'intermediate', - }, - { - name: 'starts at term one', - outcome: 'intermediate', - }, - { - name: 'advances the prior first term to term two', - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '의병모집', term: 1 }, - }, - }, - }, - }, - outcome: 'intermediate', - }, - { - name: 'restarts at term one after another command interrupted the stack', - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '필사즉생', arg: {}, term: 2 }, - }, - }, - }, - }, - outcome: 'intermediate', - }, - { - name: 'creates three default volunteer generals', - outcome: 'completed', - expectedCreateCount: 3, - expectedPostReqTurn: 98, - }, - { - name: 'applies the strategist command and global-delay modifiers', - fixturePatches: { nations: { 1: { typeCode: 'che_종횡가' } } }, - outcome: 'completed', - expectedCreateCount: 3, - expectedPostReqTurn: 73, - expectedStrategicCommandLimit: 5, - }, - { - name: 'uses stored eleven-general count for the nation cooldown', - fixturePatches: { nations: { 1: { generalCount: 11 } } }, - outcome: 'completed', - expectedCreateCount: 4, - expectedPostReqTurn: 103, - }, - { - name: 'rounds the active nations stored average at the four-general creation boundary', - fixturePatches: { - nations: { - 1: { generalCount: 4 }, - 2: { generalCount: 4 }, - }, - }, - outcome: 'completed', - expectedCreateCount: 4, - expectedPostReqTurn: 98, - }, - { - name: 'excludes a level-zero nation from the stored average', - fixturePatches: { - nations: { - 1: { generalCount: 4 }, - 2: { generalCount: 100, level: 0 }, - }, - }, - outcome: 'completed', - expectedCreateCount: 4, - expectedPostReqTurn: 98, - }, - { - name: 'preserves legacy integer persistence for fractional nation averages', - fixturePatches: { - generals: { - 3: { - experience: 1001, - dedication: 1001, - meta: { dex1: 1, dex2: 1, dex3: 1, dex4: 1, dex5: 1 }, - }, - }, - }, - outcome: 'completed', - expectedCreateCount: 3, - expectedPostReqTurn: 98, - expectedAverageExperience: 1000, - }, -]; - -integration('nation volunteer-recruitment constraints, creation values, RNG, and cooldown boundaries', () => { - it.each(volunteerRecruitBoundaryCases)( - '$name matches legacy progress, created generals, cooldown, logs, RNG, and semantic delta', - async ({ - fixturePatches, - outcome, - coreResolution = true, - expectedCreateCount = 0, - expectedPostReqTurn, - expectedStrategicCommandLimit = 9, - expectedAverageExperience = 1000, - }) => { - const completed = outcome === 'completed'; - const fallback = outcome === 'fallback'; - const completionNationPatch = completed - ? { - turnLastByOfficerLevel: { - 12: { command: '의병모집', term: 2 }, - }, - } - : {}; - const request = buildRequest('che_의병모집', undefined, { - ...fixturePatches, - nations: { - ...fixturePatches?.nations, - 1: { - ...fixturePatches?.nations?.[1], - ...completionNationPatch, - }, - }, - }); - 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 nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); - const nationAfter = 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); - const beforeGeneralIds = new Set(snapshot.before.generals.map((entry) => entry.id)); - const createdGenerals = snapshot.after.generals.filter((entry) => !beforeGeneralIds.has(entry.id)); - - expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); - expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); - expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe( - completed ? 15 : 0 - ); - expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe( - completed ? 15 : 0 - ); - expect(createdGenerals).toHaveLength(completed ? expectedCreateCount : 0); - expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( - completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit') - ); - - if (completed) { - for (const created of createdGenerals) { - expect(created).toMatchObject({ - nationId: 1, - cityId: 3, - officerLevel: 1, - npcState: 4, - gold: 1000, - rice: 1000, - age: 20, - experience: expectedAverageExperience, - dedication: expectedAverageExperience, - specialDomestic: null, - specialWar: null, - specAge: 19, - specAge2: 19, - }); - expect(String(created.name)).toMatch(/^ⓖ/); - expect( - readNumericField(created, 'leadership') + - readNumericField(created, 'strength') + - readNumericField(created, 'intelligence') - ).toBe(150); - expect(readNumericField(created, 'killTurn')).toBeGreaterThanOrEqual(64); - expect(readNumericField(created, 'killTurn')).toBeLessThanOrEqual(70); - } - const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); - expect(addedLogs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining('의병모집 발동')]) - ); - expect( - addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('의병모집')) - ).toBe(true); - } - } - - if (outcome === 'intermediate') { - const priorTurnByOfficerLevel = fixturePatches?.nations?.[1]?.turnLastByOfficerLevel; - const priorTerm = - priorTurnByOfficerLevel && typeof priorTurnByOfficerLevel === 'object' - ? (priorTurnByOfficerLevel as Record)[12] - : undefined; - const expectedTerm = priorTerm?.command === '의병모집' && priorTerm.term === 1 ? 2 : 1; - expect(reference.execution.outcome).toMatchObject({ - lastTurn: { - command: '의병모집', - term: expectedTerm, - }, - }); - expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({ - command: '의병모집', - term: expectedTerm, - }); - } - if (completed) { - const currentYear = fixturePatches?.world?.year ?? 190; - const expectedNextAvailableTurn = currentYear * 12 + expectedPostReqTurn!; - expect(reference.after.world.nationCooldowns).toEqual([ - { - nationId: 1, - actionName: '의병모집', - nextAvailableTurn: expectedNextAvailableTurn, - }, - ]); - expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns); - } - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -type FloodOutcome = 'fallback' | 'intermediate' | 'completed'; - -const floodBoundaryCases: Array<{ - name: string; - destCityId: unknown; - fixturePatches?: FixturePatches; - outcome: FloodOutcome; - coreResolution?: boolean; - expectedDefence?: number; - expectedWall?: number; - expectedPostReqTurn?: number; - expectedStrategicCommandLimit?: number; - expectedDestNationHistory?: boolean; -}> = [ - { - name: 'rejects a missing destination city', - destCityId: 9999, - outcome: 'fallback', - }, - { - name: 'accepts a numeric string destination city ID', - destCityId: '70', - outcome: 'completed', - expectedPostReqTurn: 61, - }, - { - name: 'truncates a fractional destination city ID', - destCityId: 70.9, - outcome: 'completed', - expectedPostReqTurn: 61, - }, - { - name: 'rejects a neutral destination city', - destCityId: 70, - fixturePatches: { cities: { 70: { nationId: 0 } } }, - outcome: 'fallback', - }, - { - name: 'rejects a destination city occupied by the source nation', - destCityId: 70, - fixturePatches: { cities: { 70: { nationId: 1 } } }, - outcome: 'fallback', - }, - { - name: 'rejects a source city occupied by another nation', - destCityId: 70, - fixturePatches: { cities: { 3: { nationId: 2 } } }, - outcome: 'fallback', - }, - { - name: 'rejects an actor below chief rank', - destCityId: 70, - fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } }, - outcome: 'fallback', - coreResolution: false, - }, - { - name: 'rejects a remaining strategic-command delay', - destCityId: 70, - fixturePatches: { nations: { 1: { strategicCommandLimit: 1 } } }, - outcome: 'fallback', - }, - { - name: 'rejects a declaration state instead of active war', - destCityId: 70, - fixturePatches: { diplomacy: { '1:2': { state: 1 } } }, - outcome: 'fallback', - }, - { - name: 'rejects a reverse-only war', - destCityId: 70, - fixturePatches: { - diplomacy: { - '1:2': { state: 3 }, - '2:1': { state: 0 }, - }, - }, - outcome: 'fallback', - }, - { - name: 'allows unsupplied source and destination cities at term one', - destCityId: 70, - fixturePatches: { - cities: { - 3: { supplyState: 0 }, - 70: { supplyState: 0 }, - }, - }, - outcome: 'intermediate', - }, - { - name: 'advances the prior first term to term two', - destCityId: 70, - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '수몰', arg: { destCityID: 70 }, term: 1 }, - }, - coreTurnLastByOfficerLevel: { - 12: { command: '수몰', arg: { destCityId: 70 }, term: 1 }, - }, - }, - }, - }, - outcome: 'intermediate', - }, - { - name: 'restarts at term one after another command interrupted the stack', - destCityId: 70, - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '의병모집', term: 2 }, - }, - }, - }, - }, - outcome: 'intermediate', - }, - { - name: 'completes the default flood effects and notifications', - destCityId: 70, - outcome: 'completed', - expectedPostReqTurn: 61, - }, - { - name: 'omits destination nation history when it has no general', - destCityId: 70, - fixturePatches: { generals: { 2: { nationId: 0 } } }, - outcome: 'completed', - expectedPostReqTurn: 61, - expectedDestNationHistory: false, - }, - { - name: 'rounds defence and wall multiplication like MariaDB', - destCityId: 70, - fixturePatches: { - cities: { - 70: { - defence: 1003, - wall: 1002, - }, - }, - }, - outcome: 'completed', - expectedDefence: 201, - expectedWall: 200, - expectedPostReqTurn: 61, - }, - { - name: 'applies the strategist command and global-delay modifiers', - destCityId: 70, - fixturePatches: { nations: { 1: { typeCode: 'che_종횡가' } } }, - outcome: 'completed', - expectedPostReqTurn: 45, - expectedStrategicCommandLimit: 5, - }, - { - name: 'uses stored eleven-general count for the nation cooldown', - destCityId: 70, - fixturePatches: { nations: { 1: { generalCount: 11 } } }, - outcome: 'completed', - expectedPostReqTurn: 64, - }, -]; - -integration('nation flood constraints, multistep, city damage, logs, and cooldown boundaries', () => { - it.each(floodBoundaryCases)( - '$name matches legacy progress, damage, notifications, cooldown, RNG, and semantic delta', - async ({ - destCityId, - fixturePatches, - outcome, - coreResolution = true, - expectedDefence, - expectedWall, - expectedPostReqTurn, - expectedStrategicCommandLimit = 9, - expectedDestNationHistory = true, - }) => { - const completed = outcome === 'completed'; - const fallback = outcome === 'fallback'; - const normalizedDestCityId = Math.trunc(Number(destCityId)); - const completionNationPatch = completed - ? { - turnLastByOfficerLevel: { - 12: { command: '수몰', arg: { destCityID: destCityId }, term: 2 }, - }, - coreTurnLastByOfficerLevel: { - 12: { command: '수몰', arg: { destCityId: normalizedDestCityId }, term: 2 }, - }, - } - : {}; - const request = buildRequest( - 'che_수몰', - { destCityID: destCityId }, - { - ...fixturePatches, - nations: { - ...fixturePatches?.nations, - 1: { - ...fixturePatches?.nations?.[1], - ...completionNationPatch, - }, - }, - diplomacy: { - '1:2': { state: 0 }, - '2:1': { state: 3 }, - ...fixturePatches?.diplomacy, - }, - } - ); - request.observe!.includeNationHistoryLogs = true; - 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 nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); - const nationAfter = 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); - const cityBefore = snapshot.before.cities.find((entry) => entry.id === normalizedDestCityId); - const cityAfter = snapshot.after.cities.find((entry) => entry.id === normalizedDestCityId); - - expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); - expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); - expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe( - completed ? 15 : 0 - ); - expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe( - completed ? 15 : 0 - ); - expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( - completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit') - ); - expect(readNumericField(cityAfter, 'defence')).toBe( - completed - ? (expectedDefence ?? Math.round(readNumericField(cityBefore, 'defence') * 0.2)) - : readNumericField(cityBefore, 'defence') - ); - expect(readNumericField(cityAfter, 'wall')).toBe( - completed - ? (expectedWall ?? Math.round(readNumericField(cityBefore, 'wall') * 0.2)) - : readNumericField(cityBefore, 'wall') - ); - - 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); - const hasDestGeneral = snapshot.before.generals.some((entry) => entry.nationId === 2); - expect( - addedLogs.some((entry) => entry.generalId === 2 && String(entry.text).includes('수몰')) - ).toBe(hasDestGeneral); - expect( - addedLogs.some( - (entry) => String(entry.text).includes('아국의') && String(entry.text).includes('수몰') - ) - ).toBe(expectedDestNationHistory); - expect( - addedLogs.some( - (entry) => - String(entry.text).includes('수몰') && - !String(entry.text).includes('아국의') && - String(entry.text).endsWith('발동') - ) - ).toBe(true); - } - } - - if (outcome === 'intermediate') { - const priorTurnByOfficerLevel = fixturePatches?.nations?.[1]?.turnLastByOfficerLevel; - const priorTerm = - priorTurnByOfficerLevel && typeof priorTurnByOfficerLevel === 'object' - ? ( - priorTurnByOfficerLevel as Record< - number, - { command?: string; arg?: { destCityID?: unknown }; term?: number } - > - )[12] - : undefined; - const expectedTerm = - priorTerm?.command === '수몰' && - Math.trunc(Number(priorTerm.arg?.destCityID)) === normalizedDestCityId && - priorTerm.term === 1 - ? 2 - : 1; - expect(reference.execution.outcome).toMatchObject({ - lastTurn: { - command: '수몰', - term: expectedTerm, - }, - }); - expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({ - command: '수몰', - term: expectedTerm, - }); - } - if (completed) { - const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!; - expect(reference.after.world.nationCooldowns).toEqual([ - { - nationId: 1, - actionName: '수몰', - nextAvailableTurn: expectedNextAvailableTurn, - }, - ]); - 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 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_몰수'; - args: Record; - expectedAmount: number; -}> = [ - { - name: 'award rounds a half unit up', - action: 'che_포상', - args: { isGold: true, amount: 150, destGeneralID: 3 }, - expectedAmount: 200, - }, - { - name: 'award clamps below the minimum', - action: 'che_포상', - args: { isGold: true, amount: 1, destGeneralID: 3 }, - expectedAmount: 100, - }, - { - name: 'award clamps above the maximum', - action: 'che_포상', - args: { isGold: true, amount: 10_050, destGeneralID: 3 }, - expectedAmount: 10_000, - }, - { - name: 'seizure rounds a half unit up', - action: 'che_몰수', - args: { isGold: true, amount: 150, destGeneralID: 3 }, - expectedAmount: 200, - }, - { - name: 'seizure clamps below the minimum', - action: 'che_몰수', - args: { isGold: true, amount: 1, destGeneralID: 3 }, - expectedAmount: 100, - }, - { - name: 'seizure clamps above the maximum', - action: 'che_몰수', - args: { isGold: true, amount: 10_050, destGeneralID: 3 }, - expectedAmount: 10_000, - }, -]; - -integration('nation command resource amount normalization matrix', () => { - it.each(nationResourceAmountCases)( - '$name matches legacy rounding and clamp semantics', - async ({ action, args, expectedAmount }) => { - const request = buildRequest(action, args); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceTargetBefore = reference.before.generals.find((entry) => entry.id === 3); - const referenceTargetAfter = reference.after.generals.find((entry) => entry.id === 3); - const coreTargetBefore = core.before.generals.find((entry) => entry.id === 3); - const coreTargetAfter = core.after.generals.find((entry) => entry.id === 3); - const referenceAmount = - action === 'che_포상' - ? readGold(referenceTargetAfter) - readGold(referenceTargetBefore) - : readGold(referenceTargetBefore) - readGold(referenceTargetAfter); - const coreAmount = - action === 'che_포상' - ? readGold(coreTargetAfter) - readGold(coreTargetBefore) - : readGold(coreTargetBefore) - readGold(coreTargetAfter); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: action, - usedFallback: false, - }); - expect(referenceAmount).toBe(expectedAmount); - expect(coreAmount).toBe(expectedAmount); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -const nationResourceBoundaryCases: Array<{ - name: string; - action: 'che_포상' | 'che_몰수'; - args: Record; - fixturePatches?: FixturePatches; - completed: boolean; -}> = [ - { - name: 'award is limited to the available nation gold', - action: 'che_포상', - args: { isGold: true, amount: 10_000, destGeneralID: 3 }, - fixturePatches: { nations: { 1: { gold: 5_000 } } }, - completed: true, - }, - { - name: 'award keeps the legacy base rice reserve', - action: 'che_포상', - args: { isGold: false, amount: 10_000, destGeneralID: 3 }, - fixturePatches: { nations: { 1: { rice: 2_100 } } }, - completed: true, - }, - { - name: 'award rejects the actor as its target', - action: 'che_포상', - args: { isGold: true, amount: 100, destGeneralID: 1 }, - completed: false, - }, - { - name: 'seizure is limited to the target general gold', - action: 'che_몰수', - args: { isGold: true, amount: 1_000, destGeneralID: 3 }, - fixturePatches: { generals: { 3: { gold: 50 } } }, - completed: true, - }, - { - name: 'seizure rejects the actor as its target', - action: 'che_몰수', - args: { isGold: true, amount: 100, destGeneralID: 1 }, - completed: false, - }, -]; - -integration('nation command resource balance and target boundaries', () => { - it.each(nationResourceBoundaryCases)( - '$name matches legacy completion, RNG, and state delta', - async ({ action, args, fixturePatches, completed }) => { - const request = buildRequest(action, args, fixturePatches); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: completed ? action : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -integration('nation command missing argument object parity', () => { - it.each(['che_포상', 'che_몰수'] as const)( - '%s falls back with the legacy invalid-argument log and no RNG', - async (action) => { - const request = buildRequest(action); - 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: action, - actionKey: '휴식', - usedFallback: true, - }); - expect(reference.rng).toEqual([]); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - expect(semanticLogSignatures(nationCommandLogs(core.after.logs))).toEqual( - semanticLogSignatures(addedReferenceLogs(reference.before, reference.after.logs)) - ); - }, - 120_000 - ); -}); - -integration('nation seizure NPC public message parity', () => { - it('matches the legacy fixed-seed RNG and public message side effect', async () => { - const request = buildRequest( - 'che_몰수', - { isGold: true, amount: 100, destGeneralID: 3 }, - { - world: { hiddenSeed: 'seizure-message-37' }, - generals: { - 3: { - name: '몰수NPC', - npcState: 2, - picture: 'npc/custom.png', - imageServer: 0, - }, - }, - } - ); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(reference.rng).toHaveLength(2); - expect(reference.rng.map((call) => call.operation)).toEqual(['nextFloat1', 'nextInt']); - expect(core.rng).toEqual(reference.rng); - const referenceMessages = reference.after.messages.slice(reference.before.messages.length); - expect(referenceMessages).toHaveLength(1); - expect(core.after.messages).toHaveLength(1); - const messageAfterId = reference.before.watermarks.messageId; - const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); - const referenceTimeline = projectStrictTurnMessageTimeline(reference.before, reference.after, messageAfterId); - expect({ - unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), - messages: projectSemanticTurnMessages(core.after.messages, messageAfterId), - timeline: coreTimeline, - }).toEqual({ - unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), - messages: projectSemanticTurnMessages(reference.after.messages, messageAfterId), - timeline: referenceTimeline, - }); - expect(referenceTimeline.usesSingleTick).toBe(true); - expect(referenceMessages[0]).toMatchObject({ - mailbox: 9999, - type: 'public', - sourceId: 3, - destinationId: 9999, - payload: { - src: { - id: 3, - name: '몰수NPC', - nation_id: 1, - nation: '아국', - icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png', - }, - dest: { - id: 3, - name: '몰수NPC', - nation_id: 1, - nation: '아국', - icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png', - }, - text: NPC_SEIZURE_MESSAGE_TEXT, - }, - }); - expect(core.after.messages[0]).toMatchObject({ - mailbox: 9999, - type: 'public', - sourceId: 3, - destinationId: 9999, - payload: { - src: { - generalId: 3, - generalName: '몰수NPC', - nationId: 1, - nationName: '아국', - icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png', - }, - dest: { - generalId: 3, - generalName: '몰수NPC', - nationId: 1, - nationName: '아국', - icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png', - }, - text: NPC_SEIZURE_MESSAGE_TEXT, - }, - }); - }, 120_000); -}); - -integration('nation seizure zero target balance parity', () => { - it('matches the legacy zero-amount logs for a user target', async () => { - const request = buildRequest( - 'che_몰수', - { isGold: true, amount: 100, destGeneralID: 3 }, - { generals: { 3: { name: '무자원장수', gold: 0, npcState: 0 } } } - ); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceLogs = addedReferenceLogs(reference.before, reference.after.logs); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_몰수', - actionKey: 'che_몰수', - usedFallback: false, - }); - expect(reference.rng).toEqual([]); - expect(core.rng).toEqual(reference.rng); - expect(referenceLogs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([ - expect.stringContaining('금 0를 몰수 당했습니다.'), - expect.stringContaining('금 0를 몰수했습니다.'), - ]) - ); - expect(core.after.logs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([ - expect.stringContaining('금 0를 몰수 당했습니다.'), - expect.stringContaining('금 0를 몰수했습니다.'), - ]) - ); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); - - it('preserves NPC message RNG and side effects before applying a zero delta', async () => { - const request = buildRequest( - 'che_몰수', - { isGold: true, amount: 100, destGeneralID: 3 }, - { - world: { hiddenSeed: 'seizure-message-37' }, - generals: { 3: { name: '무자원NPC', gold: 0, npcState: 2 } }, - } - ); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceMessages = reference.after.messages.slice(reference.before.messages.length); - - expect(reference.execution.outcome).toMatchObject({ completed: true }); - expect(reference.rng.map((call) => call.operation)).toEqual(['nextFloat1', 'nextInt']); - expect(core.rng).toEqual(reference.rng); - expect(referenceMessages).toHaveLength(1); - expect(core.after.messages).toHaveLength(1); - const messageAfterId = reference.before.watermarks.messageId; - const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId); - const referenceTimeline = projectStrictTurnMessageTimeline(reference.before, reference.after, messageAfterId); - expect({ - unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after), - messages: projectSemanticTurnMessages(core.after.messages, messageAfterId), - timeline: coreTimeline, - }).toEqual({ - unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after), - messages: projectSemanticTurnMessages(reference.after.messages, messageAfterId), - timeline: referenceTimeline, - }); - expect(referenceTimeline.usesSingleTick).toBe(true); - expect(referenceMessages[0]).toMatchObject({ - type: 'public', - sourceId: 3, - payload: { text: NPC_SEIZURE_MESSAGE_TEXT }, - }); - expect(core.after.messages[0]).toMatchObject({ - type: 'public', - payload: { text: NPC_SEIZURE_MESSAGE_TEXT }, - }); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); -}); - -const nationPersonnelTargetCases: Array<{ - name: string; - action: 'che_포상' | 'che_몰수'; - destGeneralID: number; -}> = [ - { - name: 'award rejects a missing target general', - action: 'che_포상', - destGeneralID: 9999, - }, - { - name: 'award rejects a foreign target general', - action: 'che_포상', - destGeneralID: 2, - }, - { - name: 'seizure rejects a missing target general', - action: 'che_몰수', - destGeneralID: 9999, - }, - { - name: 'seizure rejects a foreign target general', - action: 'che_몰수', - destGeneralID: 2, - }, -]; - -integration('nation award and seizure target constraints', () => { - it.each(nationPersonnelTargetCases)( - '$name matches legacy fallback, RNG, and semantic delta', - async ({ action, destGeneralID }) => { - const request = buildRequest(action, { isGold: true, amount: 100, destGeneralID }); - 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: action, - actionKey: '휴식', - usedFallback: true, - }); - 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 materialAidResourceCases: Array<{ - name: string; - amountList: [number, number]; - fixturePatches?: FixturePatches; - completed: boolean; - expectedGold: number; - expectedRice: number; -}> = [ - { - name: 'allows a zero rice component', - amountList: [100, 0], - completed: true, - expectedGold: 100, - expectedRice: 0, - }, - { - name: 'allows a zero gold component', - amountList: [0, 100], - completed: true, - expectedGold: 0, - expectedRice: 100, - }, - { - name: 'clamps gold to the source available balance', - amountList: [100, 0], - fixturePatches: { nations: { 1: { gold: 50 } } }, - completed: true, - expectedGold: 50, - expectedRice: 0, - }, - { - name: 'clamps rice while preserving the source base reserve', - amountList: [0, 100], - fixturePatches: { nations: { 1: { rice: 2_050 } } }, - completed: true, - expectedGold: 0, - expectedRice: 50, - }, - { - name: 'allows the exact rank aid limit', - amountList: [10_000, 0], - completed: true, - expectedGold: 10_000, - expectedRice: 0, - }, - { - name: 'rejects an amount above the rank aid limit', - amountList: [10_001, 0], - completed: false, - expectedGold: 0, - expectedRice: 0, - }, - { - name: 'rejects two zero components', - amountList: [0, 0], - completed: false, - expectedGold: 0, - expectedRice: 0, - }, -]; - -integration('nation material aid resource boundaries', () => { - it.each(materialAidResourceCases)( - '$name matches legacy completion, amounts, RNG, and semantic delta', - async ({ amountList, fixturePatches, completed, expectedGold, expectedRice }) => { - const request = buildRequest('che_물자원조', { destNationID: 2, amountList }, fixturePatches); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - - const referenceSourceBefore = reference.before.nations.find((entry) => entry.id === 1); - const referenceSourceAfter = reference.after.nations.find((entry) => entry.id === 1); - const referenceDestBefore = reference.before.nations.find((entry) => entry.id === 2); - const referenceDestAfter = reference.after.nations.find((entry) => entry.id === 2); - const coreSourceBefore = core.before.nations.find((entry) => entry.id === 1); - const coreSourceAfter = core.after.nations.find((entry) => entry.id === 1); - const coreDestBefore = core.before.nations.find((entry) => entry.id === 2); - const coreDestAfter = core.after.nations.find((entry) => entry.id === 2); - - for (const [resource, expected] of [ - ['gold', expectedGold], - ['rice', expectedRice], - ] as const) { - expect( - readNationResource(referenceSourceBefore, resource) - - readNationResource(referenceSourceAfter, resource) - ).toBe(expected); - expect( - readNationResource(referenceDestAfter, resource) - readNationResource(referenceDestBefore, resource) - ).toBe(expected); - expect( - readNationResource(coreSourceBefore, resource) - readNationResource(coreSourceAfter, resource) - ).toBe(expected); - expect(readNationResource(coreDestAfter, resource) - readNationResource(coreDestBefore, resource)).toBe( - expected - ); - } - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_물자원조', - actionKey: completed ? 'che_물자원조' : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); - - it('uses the legacy object particle when the rice component is zero', async () => { - const request = buildRequest('che_물자원조', { - destNationID: 2, - amountList: [100, 0], - }); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceLogs = addedReferenceLogs(reference.before, reference.after.logs); - - expect(referenceLogs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining('쌀0를 지원')]) - ); - expect(core.after.logs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining('쌀0를 지원')]) - ); - }, 120_000); -}); - -const materialAidConstraintCases: Array<{ - name: string; - args: Record; - fixturePatches?: FixturePatches; -}> = [ - { - name: 'rejects a missing destination nation', - args: { destNationID: 9999, amountList: [100, 0] }, - }, - { - name: 'rejects the source nation as destination', - args: { destNationID: 1, amountList: [100, 0] }, - }, - { - name: 'rejects a source nation under diplomacy restriction', - args: { destNationID: 2, amountList: [100, 0] }, - fixturePatches: { nations: { 1: { diplomacyLimit: 1 } } }, - }, - { - name: 'rejects a destination nation under diplomacy restriction', - args: { destNationID: 2, amountList: [100, 0] }, - fixturePatches: { nations: { 2: { diplomacyLimit: 1 } } }, - }, - { - name: 'rejects a negative component', - args: { destNationID: 2, amountList: [-1, 100] }, - }, - { - name: 'rejects a fractional component', - args: { destNationID: 2, amountList: [100.5, 0] }, - }, - { - name: 'rejects a one-element amount list', - args: { destNationID: 2, amountList: [100] }, - }, - { - name: 'rejects a three-element amount list', - args: { destNationID: 2, amountList: [100, 0, 0] }, - }, - { - name: 'rejects a non-array amount list', - args: { destNationID: 2, amountList: '100,0' }, - }, - { - name: 'rejects a zero destination nation ID', - args: { destNationID: 0, amountList: [100, 0] }, - }, - { - name: 'rejects a fractional destination nation ID', - args: { destNationID: 2.5, amountList: [100, 0] }, - }, -]; - -integration('nation material aid target and input constraints', () => { - it.each(materialAidConstraintCases)( - '$name matches legacy fallback, RNG, and semantic delta', - async ({ args, fixturePatches }) => { - const request = buildRequest('che_물자원조', args, fixturePatches); - 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: 'che_물자원조', - actionKey: '휴식', - usedFallback: true, - }); - 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 - ); -}); - -integration('nation material aid accumulated assistance and officer logs', () => { - it.each([ - { - name: 'array entry', - priorEntry: [1, 250], - }, - { - name: 'legacy object entry', - priorEntry: { 0: 1, 1: 250 }, - }, - ])( - 'accumulates a repeated aid amount from an existing $name', - async ({ priorEntry }) => { - const request = buildRequest( - 'che_물자원조', - { - destNationID: 2, - amountList: [100, 200], - }, - { - nations: { - 2: { - nationEnv: { - recv_assist: { - n1: priorEntry, - n9: [9, 400], - }, - }, - }, - }, - } - ); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceDest = reference.after.nations.find((entry) => entry.id === 2); - const coreDest = core.after.nations.find((entry) => entry.id === 2); - - expect(readNationMeta(referenceDest).recv_assist).toEqual({ - n1: [1, 550], - n9: [9, 400], - }); - expect(readNationMeta(coreDest).recv_assist).toEqual(readNationMeta(referenceDest).recv_assist); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); - - it('notifies eligible officers in both nations with the legacy messages', async () => { - const request = buildRequest( - 'che_물자원조', - { - destNationID: 2, - amountList: [100, 200], - }, - { - generals: { - 3: { officerLevel: 5 }, - 5: { nationId: 2, cityId: 70, officerLevel: 5, officerCityId: 70 }, - }, - } - ); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceLogs = addedReferenceLogs(reference.before, reference.after.logs); - const sourceOfficerLog = { - generalId: 3, - text: expect.stringContaining('타국으로 금100 쌀200을 지원했습니다.'), - }; - const destinationOfficerLog = { - generalId: 5, - text: expect.stringContaining('아국에서 금100 쌀200을 원조했습니다.'), - }; - - expect(referenceLogs).toEqual( - expect.arrayContaining([ - expect.objectContaining(sourceOfficerLog), - expect.objectContaining(destinationOfficerLog), - ]) - ); - expect(core.after.logs).toEqual( - expect.arrayContaining([ - expect.objectContaining(sourceOfficerLog), - expect.objectContaining(destinationOfficerLog), - ]) - ); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); -}); - -const populationMoveCases: Array<{ - name: string; - amount: unknown; - fixturePatches?: FixturePatches; - completed: boolean; - expectedMoved: number; - costBasis: number; -}> = [ - { - name: 'completes a zero population move', - amount: 0, - completed: true, - expectedMoved: 0, - costBasis: 0, - }, - { - name: 'truncates a fractional amount', - amount: 100.9, - completed: true, - expectedMoved: 100, - costBasis: 100, - }, - { - name: 'caps the request and moves only the available population', - amount: 100_001, - completed: true, - expectedMoved: 70_000, - costBasis: 100_000, - }, - { - name: 'charges for the request before clamping to available population', - amount: 1_000, - fixturePatches: { cities: { 3: { population: 30_100 } } }, - completed: true, - expectedMoved: 100, - costBasis: 1_000, - }, - { - name: 'accepts a numeric string amount', - amount: '1000', - completed: true, - expectedMoved: 1_000, - costBasis: 1_000, - }, - { - name: 'rejects a negative integer amount', - amount: -1, - completed: false, - expectedMoved: 0, - costBasis: 0, - }, - { - name: 'allows the exact gold and rice cost', - amount: 1_000, - fixturePatches: { nations: { 1: { gold: 2, rice: 2_002 } } }, - completed: true, - expectedMoved: 1_000, - costBasis: 1_000, - }, - { - name: 'rejects one less than the required gold', - amount: 1_000, - fixturePatches: { nations: { 1: { gold: 1, rice: 2_002 } } }, - completed: false, - expectedMoved: 0, - costBasis: 0, - }, - { - name: 'rejects one less than the required rice reserve', - amount: 1_000, - fixturePatches: { nations: { 1: { gold: 2, rice: 2_001 } } }, - completed: false, - expectedMoved: 0, - costBasis: 0, - }, -]; - -integration('nation population move value and resource boundaries', () => { - it.each(populationMoveCases)( - '$name matches legacy completion, resources, RNG, and semantic delta', - async ({ amount, fixturePatches, completed, expectedMoved, costBasis }) => { - const request = buildRequest( - 'cr_인구이동', - { destCityID: 70, amount }, - { - ...fixturePatches, - cities: { - ...fixturePatches?.cities, - 70: { - nationId: 1, - supplyState: 1, - ...fixturePatches?.cities?.[70], - }, - }, - } - ); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceSourceBefore = reference.before.cities.find((entry) => entry.id === 3); - const referenceSourceAfter = reference.after.cities.find((entry) => entry.id === 3); - const referenceDestBefore = reference.before.cities.find((entry) => entry.id === 70); - const referenceDestAfter = reference.after.cities.find((entry) => entry.id === 70); - const coreSourceBefore = core.before.cities.find((entry) => entry.id === 3); - const coreSourceAfter = core.after.cities.find((entry) => entry.id === 3); - const coreDestBefore = core.before.cities.find((entry) => entry.id === 70); - const coreDestAfter = core.after.cities.find((entry) => entry.id === 70); - const referenceNationBefore = reference.before.nations.find((entry) => entry.id === 1); - const referenceNationAfter = reference.after.nations.find((entry) => entry.id === 1); - const coreNationBefore = core.before.nations.find((entry) => entry.id === 1); - const coreNationAfter = core.after.nations.find((entry) => entry.id === 1); - const referenceGeneralBefore = reference.before.generals.find((entry) => entry.id === 1); - const referenceGeneralAfter = reference.after.generals.find((entry) => entry.id === 1); - const coreGeneralBefore = core.before.generals.find((entry) => entry.id === 1); - const coreGeneralAfter = core.after.generals.find((entry) => entry.id === 1); - const develCost = - typeof reference.before.world.develCost === 'number' ? reference.before.world.develCost : 0; - const expectedCost = Math.round((develCost * costBasis) / 10_000); - expect(develCost).toBe(18); - - for (const [before, after, direction] of [ - [referenceSourceBefore, referenceSourceAfter, -1], - [referenceDestBefore, referenceDestAfter, 1], - [coreSourceBefore, coreSourceAfter, -1], - [coreDestBefore, coreDestAfter, 1], - ] as const) { - const expectedDelta = expectedMoved === 0 ? 0 : direction * expectedMoved; - expect(readCityPopulation(after) - readCityPopulation(before)).toBe(expectedDelta); - } - for (const [before, after] of [ - [referenceNationBefore, referenceNationAfter], - [coreNationBefore, coreNationAfter], - ] as const) { - expect(readNationResource(before, 'gold') - readNationResource(after, 'gold')).toBe(expectedCost); - expect(readNationResource(before, 'rice') - readNationResource(after, 'rice')).toBe(expectedCost); - } - for (const field of ['experience', 'dedication']) { - const expectedDelta = completed ? 5 : 0; - expect( - readNumericField(referenceGeneralAfter, field) - readNumericField(referenceGeneralBefore, field) - ).toBe(expectedDelta); - expect(readNumericField(coreGeneralAfter, field) - readNumericField(coreGeneralBefore, field)).toBe( - expectedDelta - ); - } - if (amount === 0) { - const zeroMoveText = '인구 0명을 옮겼습니다.'; - expect(addedReferenceLogs(reference.before, reference.after.logs).map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining(zeroMoveText)]) - ); - expect(core.after.logs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining(zeroMoveText)]) - ); - } - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'cr_인구이동', - actionKey: completed ? 'cr_인구이동' : '휴식', - usedFallback: !completed, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -const populationMoveConstraintCases: Array<{ - name: string; - destCityId: unknown; - fixturePatches?: FixturePatches; - completed?: boolean; - coreResolution?: boolean; -}> = [ - { - name: 'rejects a missing destination city', - destCityId: 9999, - }, - { - name: 'rejects the source city as destination', - destCityId: 3, - }, - { - name: 'accepts a numeric string destination city ID', - destCityId: '70', - completed: true, - }, - { - name: 'rejects a source city below the minimum population', - destCityId: 70, - fixturePatches: { cities: { 3: { population: 30_099 } } }, - }, - { - name: 'rejects a source city occupied by another nation', - destCityId: 70, - fixturePatches: { cities: { 3: { nationId: 2 } } }, - }, - { - name: 'rejects a destination city occupied by another nation', - destCityId: 70, - fixturePatches: { cities: { 70: { nationId: 2 } } }, - }, - { - name: 'rejects a non-adjacent destination city', - destCityId: 73, - fixturePatches: { cities: { 73: { nationId: 1, supplyState: 1 } } }, - }, - { - name: 'rejects an actor below chief rank', - destCityId: 70, - fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } }, - coreResolution: false, - }, - { - name: 'rejects an unsupplied source city', - destCityId: 70, - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - }, - { - name: 'rejects an unsupplied destination city', - destCityId: 70, - fixturePatches: { cities: { 70: { supplyState: 0 } } }, - }, -]; - -integration('nation population move target and city constraints', () => { - it.each(populationMoveConstraintCases)( - '$name matches legacy fallback, RNG, and semantic delta', - async ({ destCityId, fixturePatches, completed = false, coreResolution = true }) => { - const request = buildRequest( - 'cr_인구이동', - { destCityID: destCityId, amount: 1_000 }, - { - ...fixturePatches, - cities: { - ...fixturePatches?.cities, - 70: { - nationId: 1, - supplyState: 1, - ...fixturePatches?.cities?.[70], - }, - }, - } - ); - 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: 'cr_인구이동', - actionKey: completed ? 'cr_인구이동' : '휴식', - usedFallback: !completed, - }); - } else { - expect(core.execution.outcome).toBeUndefined(); - } - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -const capitalMoveCases: Array<{ - name: string; - destCityId: unknown; - fixturePatches?: FixturePatches; - completed: boolean; - referenceDistance: number; - coreDistance?: number; -}> = [ - { - name: 'rejects a missing destination city', - destCityId: 9999, - completed: false, - referenceDistance: 0, - }, - { - name: 'rejects the current capital', - destCityId: 3, - completed: false, - referenceDistance: 0, - }, - { - name: 'accepts a numeric string destination city ID', - destCityId: '70', - completed: true, - referenceDistance: 1, - }, - { - name: 'rejects a destination city occupied by another nation', - destCityId: 70, - fixturePatches: { cities: { 70: { nationId: 2 } } }, - completed: false, - referenceDistance: 50, - coreDistance: 0, - }, - { - name: 'rejects an unsupplied destination city', - destCityId: 70, - fixturePatches: { cities: { 70: { supplyState: 0 } } }, - completed: false, - referenceDistance: 1, - }, - { - name: 'rejects a source city occupied by another nation', - destCityId: 70, - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - referenceDistance: 1, - }, - { - name: 'rejects an unsupplied source city', - destCityId: 70, - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - completed: false, - referenceDistance: 1, - }, - { - name: 'allows the exact distance-one gold and rice requirement', - destCityId: 70, - fixturePatches: { nations: { 1: { gold: 180, rice: 2_180 } } }, - completed: true, - referenceDistance: 1, - }, - { - name: 'rejects one less than the distance-one gold requirement', - destCityId: 70, - fixturePatches: { nations: { 1: { gold: 179, rice: 2_180 } } }, - completed: false, - referenceDistance: 1, - }, - { - name: 'rejects one less than the distance-one rice requirement', - destCityId: 70, - fixturePatches: { nations: { 1: { gold: 180, rice: 2_179 } } }, - completed: false, - referenceDistance: 1, - }, - { - name: 'rejects an owned city without an owned-city route', - destCityId: 73, - fixturePatches: { cities: { 73: { nationId: 1, supplyState: 1 } } }, - completed: false, - referenceDistance: 50, - coreDistance: 0, - }, - { - name: 'completes a distance-two capital move through an owned city', - destCityId: 23, - fixturePatches: { - nations: { 1: { gold: 360, rice: 2_360 } }, - cities: { - 70: { nationId: 1, supplyState: 1 }, - 23: { nationId: 1, supplyState: 1 }, - }, - }, - completed: true, - referenceDistance: 2, - }, -]; - -integration('nation capital move target, route, and resource constraints', () => { - it.each(capitalMoveCases)( - '$name matches legacy completion, multistep effects, RNG, and semantic delta', - async ({ destCityId, fixturePatches, completed, referenceDistance, coreDistance = referenceDistance }) => { - const coreDestCityId = typeof destCityId === 'string' ? Number(destCityId) : destCityId; - const referenceLastTurn = { - command: '천도', - arg: { destCityID: destCityId }, - term: referenceDistance * 2, - seq: 0, - }; - const coreLastTurn = { - command: '천도', - arg: { destCityID: coreDestCityId }, - term: coreDistance * 2, - seq: 0, - }; - const request = buildRequest( - 'che_천도', - { destCityID: destCityId }, - { - ...fixturePatches, - nations: { - ...fixturePatches?.nations, - 1: { - ...fixturePatches?.nations?.[1], - capitalRevision: 0, - turnLastByOfficerLevel: { 12: referenceLastTurn }, - coreTurnLastByOfficerLevel: { 12: coreLastTurn }, - }, - }, - cities: { - ...fixturePatches?.cities, - 70: { - nationId: 1, - supplyState: 1, - ...fixturePatches?.cities?.[70], - }, - }, - } - ); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceNationBefore = reference.before.nations.find((entry) => entry.id === 1); - const referenceNationAfter = reference.after.nations.find((entry) => entry.id === 1); - const coreNationBefore = core.before.nations.find((entry) => entry.id === 1); - const coreNationAfter = core.after.nations.find((entry) => entry.id === 1); - const referenceGeneralBefore = reference.before.generals.find((entry) => entry.id === 1); - const referenceGeneralAfter = reference.after.generals.find((entry) => entry.id === 1); - const coreGeneralBefore = core.before.generals.find((entry) => entry.id === 1); - const coreGeneralAfter = core.after.generals.find((entry) => entry.id === 1); - const expectedExperience = completed ? 5 * (referenceDistance * 2 + 1) : 0; - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_천도', - actionKey: completed ? 'che_천도' : '휴식', - usedFallback: !completed, - }); - for (const field of ['gold', 'rice']) { - expect( - readNumericField(referenceNationBefore, field) - readNumericField(referenceNationAfter, field) - ).toBe(0); - expect(readNumericField(coreNationBefore, field) - readNumericField(coreNationAfter, field)).toBe(0); - } - for (const field of ['experience', 'dedication']) { - expect( - readNumericField(referenceGeneralAfter, field) - readNumericField(referenceGeneralBefore, field) - ).toBe(expectedExperience); - expect(readNumericField(coreGeneralAfter, field) - readNumericField(coreGeneralBefore, field)).toBe( - expectedExperience - ); - } - if (completed) { - expect(referenceNationAfter?.capitalCityId).toBe(coreDestCityId); - expect(coreNationAfter?.capitalCityId).toBe(coreDestCityId); - } - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -const expandCityCases: Array<{ - name: string; - fixturePatches?: FixturePatches; - completed: boolean; -}> = [ - { - name: 'rejects a wandering nation without a capital', - fixturePatches: { nations: { 1: { capitalCityId: 0 } } }, - completed: false, - }, - { - name: 'rejects a level-three capital', - fixturePatches: { cities: { 3: { level: 3 } } }, - completed: false, - }, - { - name: 'allows the minimum level-four capital', - fixturePatches: { cities: { 3: { level: 4 } } }, - completed: true, - }, - { - name: 'allows the maximum expandable level-seven capital', - fixturePatches: { cities: { 3: { level: 7 } } }, - completed: true, - }, - { - name: 'rejects a level-eight capital', - fixturePatches: { cities: { 3: { level: 8 } } }, - completed: false, - }, - { - name: 'allows the exact gold and rice requirement', - fixturePatches: { nations: { 1: { gold: 69_000, rice: 71_000 } } }, - completed: true, - }, - { - name: 'rejects one less than the gold requirement', - fixturePatches: { nations: { 1: { gold: 68_999, rice: 71_000 } } }, - completed: false, - }, - { - name: 'rejects one less than the rice requirement', - fixturePatches: { nations: { 1: { gold: 69_000, rice: 70_999 } } }, - completed: false, - }, - { - name: 'rejects a source city occupied by another nation', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'rejects an unsupplied source city', - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - completed: false, - }, -]; - -integration('nation expand city level, resource, and city constraints', () => { - it.each(expandCityCases)( - '$name matches legacy completion, expansion effects, RNG, and semantic delta', - async ({ fixturePatches, completed }) => { - const request = buildRequest('che_증축', undefined, { - ...fixturePatches, - nations: { - ...fixturePatches?.nations, - 1: { - ...fixturePatches?.nations?.[1], - capitalRevision: 0, - turnLastByOfficerLevel: { - 12: { command: '증축', arg: {}, term: 5, seq: 0 }, - }, - }, - }, - cities: { - ...fixturePatches?.cities, - 3: { - level: 5, - populationMax: 300_000, - agricultureMax: 5_000, - commerceMax: 5_000, - securityMax: 5_000, - defenceMax: 5_000, - wallMax: 5_000, - ...fixturePatches?.cities?.[3], - }, - }, - }); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceCityBefore = reference.before.cities.find((entry) => entry.id === 3); - const referenceCityAfter = reference.after.cities.find((entry) => entry.id === 3); - const coreCityBefore = core.before.cities.find((entry) => entry.id === 3); - const coreCityAfter = core.after.cities.find((entry) => entry.id === 3); - const referenceNationBefore = reference.before.nations.find((entry) => entry.id === 1); - const referenceNationAfter = reference.after.nations.find((entry) => entry.id === 1); - const coreNationBefore = core.before.nations.find((entry) => entry.id === 1); - const coreNationAfter = core.after.nations.find((entry) => entry.id === 1); - const referenceGeneralBefore = reference.before.generals.find((entry) => entry.id === 1); - const referenceGeneralAfter = reference.after.generals.find((entry) => entry.id === 1); - const coreGeneralBefore = core.before.generals.find((entry) => entry.id === 1); - const coreGeneralAfter = core.after.generals.find((entry) => entry.id === 1); - const expectedCost = completed ? 69_000 : 0; - - expect(reference.before.world.develCost).toBe(18); - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_증축', - actionKey: completed ? 'che_증축' : '휴식', - usedFallback: !completed, - }); - for (const [before, after] of [ - [referenceNationBefore, referenceNationAfter], - [coreNationBefore, coreNationAfter], - ] as const) { - expect(readNationResource(before, 'gold') - readNationResource(after, 'gold')).toBe(expectedCost); - expect(readNationResource(before, 'rice') - readNationResource(after, 'rice')).toBe(expectedCost); - } - for (const [before, after] of [ - [referenceGeneralBefore, referenceGeneralAfter], - [coreGeneralBefore, coreGeneralAfter], - ] as const) { - expect(readNumericField(after, 'experience') - readNumericField(before, 'experience')).toBe( - completed ? 30 : 0 - ); - expect(readNumericField(after, 'dedication') - readNumericField(before, 'dedication')).toBe( - completed ? 30 : 0 - ); - } - for (const [field, delta] of [ - ['level', 1], - ['populationMax', 100_000], - ['agricultureMax', 2_000], - ['commerceMax', 2_000], - ['securityMax', 2_000], - ['defenceMax', 2_000], - ['wallMax', 2_000], - ] as const) { - const expectedDelta = completed ? delta : 0; - expect(readNumericField(referenceCityAfter, field) - readNumericField(referenceCityBefore, field)).toBe( - expectedDelta - ); - expect(readNumericField(coreCityAfter, field) - readNumericField(coreCityBefore, field)).toBe( - expectedDelta - ); - } - expect( - readNumericField(referenceNationAfter, 'capitalRevision') - - readNumericField(referenceNationBefore, 'capitalRevision') - ).toBe(completed ? 1 : 0); - expect( - readNumericField(coreNationAfter, 'capitalRevision') - - readNumericField(coreNationBefore, 'capitalRevision') - ).toBe(completed ? 1 : 0); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); - - it('restarts at term one when capital revision changes during the stack', async () => { - const request = buildRequest('che_증축', undefined, { - nations: { - 1: { - capitalRevision: 1, - turnLastByOfficerLevel: { - 12: { command: '증축', arg: {}, term: 5, seq: 0 }, - }, - }, - }, - cities: { - 3: { - level: 5, - populationMax: 300_000, - agricultureMax: 5_000, - commerceMax: 5_000, - securityMax: 5_000, - defenceMax: 5_000, - wallMax: 5_000, - }, - }, - }); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const coreNationAfter = core.after.nations.find((entry) => entry.id === 1); - const coreLastTurn = readNationMeta(coreNationAfter).turn_last_12; - - expect(reference.execution.outcome).toMatchObject({ - // The ref runner's completion heuristic only sees the previous term=5 - // and reports true even though capset reset the persisted result to term 1. - completed: true, - lastTurn: { - command: '증축', - term: 1, - seq: 1, - }, - }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_증축', - actionKey: 'che_증축', - usedFallback: false, - }); - expect(coreLastTurn).toMatchObject({ - command: '증축', - term: 1, - seq: 1, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); -}); - -const reduceCityCases: Array<{ - name: string; - fixturePatches?: FixturePatches; - completed: boolean; -}> = [ - { - name: 'rejects a wandering nation without a capital', - fixturePatches: { nations: { 1: { capitalCityId: 0 } } }, - completed: false, - }, - { - name: 'rejects a level-four capital', - fixturePatches: { cities: { 3: { level: 4 } } }, - completed: false, - }, - { - name: 'rejects a level-five capital below its original map level', - fixturePatches: { cities: { 3: { level: 5 } } }, - completed: false, - }, - { - name: 'rejects the original level-eight capital', - fixturePatches: { cities: { 3: { level: 8 } } }, - completed: false, - }, - { - name: 'allows the minimum reducible level-nine capital', - fixturePatches: { cities: { 3: { level: 9 } } }, - completed: true, - }, - { - name: 'allows a level-ten capital', - fixturePatches: { cities: { 3: { level: 10 } } }, - completed: true, - }, - { - name: 'rejects a source city occupied by another nation', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - completed: false, - }, - { - name: 'rejects an unsupplied source city', - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - completed: false, - }, - { - name: 'clamps low population and development values', - fixturePatches: { - cities: { - 3: { - population: 50_000, - agriculture: 1_000, - commerce: 1_000, - security: 1_000, - defence: 1_000, - wall: 1_000, - }, - }, - }, - completed: true, - }, -]; - -integration('nation reduce city level, value, recovery, and city constraints', () => { - it.each(reduceCityCases)( - '$name matches legacy completion, reduction effects, RNG, and semantic delta', - async ({ fixturePatches, completed }) => { - const request = buildRequest('che_감축', undefined, { - ...fixturePatches, - nations: { - ...fixturePatches?.nations, - 1: { - ...fixturePatches?.nations?.[1], - capitalRevision: 0, - turnLastByOfficerLevel: { - 12: { command: '감축', arg: {}, term: 5, seq: 0 }, - }, - }, - }, - cities: { - ...fixturePatches?.cities, - 3: { - level: 9, - population: 250_000, - populationMax: 900_000, - agriculture: 5_000, - agricultureMax: 10_000, - commerce: 5_000, - commerceMax: 10_000, - security: 5_000, - securityMax: 10_000, - defence: 5_000, - defenceMax: 10_000, - wall: 5_000, - wallMax: 10_000, - ...fixturePatches?.cities?.[3], - }, - }, - }); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceCityBefore = reference.before.cities.find((entry) => entry.id === 3); - const referenceCityAfter = reference.after.cities.find((entry) => entry.id === 3); - const coreCityBefore = core.before.cities.find((entry) => entry.id === 3); - const coreCityAfter = core.after.cities.find((entry) => entry.id === 3); - const referenceNationBefore = reference.before.nations.find((entry) => entry.id === 1); - const referenceNationAfter = reference.after.nations.find((entry) => entry.id === 1); - const coreNationBefore = core.before.nations.find((entry) => entry.id === 1); - const coreNationAfter = core.after.nations.find((entry) => entry.id === 1); - const referenceGeneralBefore = reference.before.generals.find((entry) => entry.id === 1); - const referenceGeneralAfter = reference.after.generals.find((entry) => entry.id === 1); - const coreGeneralBefore = core.before.generals.find((entry) => entry.id === 1); - const coreGeneralAfter = core.after.generals.find((entry) => entry.id === 1); - const expectedRecovery = completed ? 39_000 : 0; - - expect(reference.before.world.develCost).toBe(18); - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_감축', - actionKey: completed ? 'che_감축' : '휴식', - usedFallback: !completed, - }); - for (const [before, after] of [ - [referenceNationBefore, referenceNationAfter], - [coreNationBefore, coreNationAfter], - ] as const) { - expect(readNationResource(after, 'gold') - readNationResource(before, 'gold')).toBe(expectedRecovery); - expect(readNationResource(after, 'rice') - readNationResource(before, 'rice')).toBe(expectedRecovery); - } - for (const [before, after] of [ - [referenceGeneralBefore, referenceGeneralAfter], - [coreGeneralBefore, coreGeneralAfter], - ] as const) { - expect(readNumericField(after, 'experience') - readNumericField(before, 'experience')).toBe( - completed ? 30 : 0 - ); - expect(readNumericField(after, 'dedication') - readNumericField(before, 'dedication')).toBe( - completed ? 30 : 0 - ); - } - for (const [field, delta] of [ - ['level', -1], - ['populationMax', -100_000], - ['agricultureMax', -2_000], - ['commerceMax', -2_000], - ['securityMax', -2_000], - ['defenceMax', -2_000], - ['wallMax', -2_000], - ] as const) { - const expectedDelta = completed ? delta : 0; - expect(readNumericField(referenceCityAfter, field) - readNumericField(referenceCityBefore, field)).toBe( - expectedDelta - ); - expect(readNumericField(coreCityAfter, field) - readNumericField(coreCityBefore, field)).toBe( - expectedDelta - ); - } - for (const [before, after] of [ - [referenceCityBefore, referenceCityAfter], - [coreCityBefore, coreCityAfter], - ] as const) { - expect(readNumericField(after, 'population')).toBe( - completed - ? Math.max(readNumericField(before, 'population') - 100_000, 30_000) - : readNumericField(before, 'population') - ); - for (const field of ['agriculture', 'commerce', 'security', 'defence', 'wall'] as const) { - expect(readNumericField(after, field)).toBe( - completed - ? Math.max(readNumericField(before, field) - 2_000, 0) - : readNumericField(before, field) - ); - } - } - expect( - readNumericField(referenceNationAfter, 'capitalRevision') - - readNumericField(referenceNationBefore, 'capitalRevision') - ).toBe(completed ? 1 : 0); - expect( - readNumericField(coreNationAfter, 'capitalRevision') - - readNumericField(coreNationBefore, 'capitalRevision') - ).toBe(completed ? 1 : 0); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); - - it('restarts at term one when capital revision changes during the stack', async () => { - const request = buildRequest('che_감축', undefined, { - nations: { - 1: { - capitalRevision: 1, - turnLastByOfficerLevel: { - 12: { command: '감축', arg: {}, term: 5, seq: 0 }, - }, - }, - }, - cities: { - 3: { - level: 9, - population: 250_000, - populationMax: 900_000, - agriculture: 5_000, - agricultureMax: 10_000, - commerce: 5_000, - commerceMax: 10_000, - security: 5_000, - securityMax: 10_000, - defence: 5_000, - defenceMax: 10_000, - wall: 5_000, - wallMax: 10_000, - }, - }, - }); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const coreNationAfter = core.after.nations.find((entry) => entry.id === 1); - const coreLastTurn = readNationMeta(coreNationAfter).turn_last_12; - - expect(reference.execution.outcome).toMatchObject({ - // The ref runner's completion heuristic only sees the previous term=5 - // and reports true even though capset reset the persisted result to term 1. - completed: true, - lastTurn: { - command: '감축', - term: 1, - seq: 1, - }, - }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_감축', - actionKey: 'che_감축', - usedFallback: false, - }); - expect(coreLastTurn).toMatchObject({ - command: '감축', - term: 1, - seq: 1, - }); - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, 120_000); -}); - -type RandomCapitalOutcome = 'fallback' | 'incomplete' | 'completed'; - -const randomCapitalCases: Array<{ - name: string; - fixturePatches?: FixturePatches; - candidateCityIds: number[]; - flag?: number | null; - officerLevel?: number; - outcome: RandomCapitalOutcome; -}> = [ - { - name: 'rejects a source city occupied by another nation', - fixturePatches: { cities: { 3: { nationId: 2 } } }, - candidateCityIds: [70], - outcome: 'fallback', - }, - { - name: 'rejects an unsupplied source city', - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - candidateCityIds: [70], - outcome: 'fallback', - }, - { - name: 'rejects a non-lord actor', - candidateCityIds: [70], - officerLevel: 11, - outcome: 'fallback', - }, - { - name: 'rejects at the opening-part year boundary', - fixturePatches: { world: { year: 182 } }, - candidateCityIds: [70], - outcome: 'fallback', - }, - { - name: 'rejects a missing remaining-use flag', - candidateCityIds: [70], - flag: null, - outcome: 'fallback', - }, - { - name: 'rejects a zero remaining-use flag', - candidateCityIds: [70], - flag: 0, - outcome: 'fallback', - }, - { - name: 'stays incomplete when no eligible city exists', - candidateCityIds: [], - outcome: 'incomplete', - }, - { - name: 'stays incomplete when the only neutral city is level four', - fixturePatches: { cities: { 70: { level: 4 } } }, - candidateCityIds: [70], - outcome: 'incomplete', - }, - { - name: 'stays incomplete when the only neutral city is level seven', - fixturePatches: { cities: { 70: { level: 7 } } }, - candidateCityIds: [70], - outcome: 'incomplete', - }, - { - name: 'allows a single level-five neutral city', - fixturePatches: { cities: { 70: { level: 5 } } }, - candidateCityIds: [70], - outcome: 'completed', - }, - { - name: 'allows a single level-six neutral city', - fixturePatches: { cities: { 70: { level: 6 } } }, - candidateCityIds: [70], - outcome: 'completed', - }, - { - name: 'matches legacy ordering and choice across two eligible cities', - fixturePatches: { - cities: { - 70: { level: 5 }, - 71: { - nationId: 0, - level: 6, - conflict: { 2: 30 }, - officerSet: 6, - }, - }, - }, - candidateCityIds: [71, 70], - outcome: 'completed', - }, -]; - -integration('nation random capital constraints, candidates, RNG, and city reset effects', () => { - it.each(randomCapitalCases)( - '$name matches legacy completion, destination, city resets, RNG, and semantic delta', - async ({ fixturePatches, candidateCityIds, flag = 1, officerLevel = 12, outcome }) => { - const request = buildRequest('che_무작위수도이전', undefined, { - ...fixturePatches, - world: { - year: 181, - ...fixturePatches?.world, - }, - generals: { - ...fixturePatches?.generals, - 1: { - ...fixturePatches?.generals?.[1], - officerLevel, - }, - }, - nations: { - ...fixturePatches?.nations, - 1: { - ...fixturePatches?.nations?.[1], - meta: { - ...(flag === null ? {} : { can_무작위수도이전: flag }), - }, - turnLastByOfficerLevel: { - [officerLevel]: { - command: '무작위 수도 이전', - arg: {}, - term: 1, - }, - }, - }, - }, - cities: { - ...fixturePatches?.cities, - 3: { - conflict: { 2: 10 }, - officerSet: 7, - ...fixturePatches?.cities?.[3], - }, - 70: { - nationId: 0, - level: candidateCityIds.includes(70) ? 5 : 4, - conflict: { 2: 20 }, - officerSet: 5, - ...fixturePatches?.cities?.[70], - }, - }, - randomFoundingCandidateCityIds: candidateCityIds, - }); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const referenceNationBefore = reference.before.nations.find((entry) => entry.id === 1); - const referenceNationAfter = reference.after.nations.find((entry) => entry.id === 1); - const coreNationBefore = core.before.nations.find((entry) => entry.id === 1); - const coreNationAfter = core.after.nations.find((entry) => entry.id === 1); - const referenceGeneralBefore = reference.before.generals.find((entry) => entry.id === 1); - const referenceGeneralAfter = reference.after.generals.find((entry) => entry.id === 1); - const coreGeneralBefore = core.before.generals.find((entry) => entry.id === 1); - const coreGeneralAfter = core.after.generals.find((entry) => entry.id === 1); - const completed = outcome === 'completed'; - const incomplete = outcome === 'incomplete'; - - expect(reference.execution.outcome).toMatchObject({ - // The ref runner infers completion from the previous term=1. - // A no-candidate run returns false without resetting that term, - // so its heuristic reports a false positive for incomplete cases. - completed: outcome !== 'fallback', - }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_무작위수도이전', - actionKey: outcome === 'fallback' ? '휴식' : 'che_무작위수도이전', - usedFallback: outcome === 'fallback', - ...(outcome === 'fallback' ? {} : { completed }), - }); - expect(readNumericField(referenceNationAfter, 'capitalCityId')).toBe( - completed ? readNumericField(coreNationAfter, 'capitalCityId') : 3 - ); - expect(readNumericField(coreNationAfter, 'capitalCityId')).toBe( - completed ? readNumericField(referenceNationAfter, 'capitalCityId') : 3 - ); - for (const [before, after] of [ - [referenceGeneralBefore, referenceGeneralAfter], - [coreGeneralBefore, coreGeneralAfter], - ] as const) { - expect(readNumericField(after, 'experience') - readNumericField(before, 'experience')).toBe( - completed ? 10 : 0 - ); - expect(readNumericField(after, 'dedication') - readNumericField(before, 'dedication')).toBe( - completed ? 10 : 0 - ); - } - - if (completed) { - const destCityId = readNumericField(referenceNationAfter, 'capitalCityId'); - expect(candidateCityIds).toContain(destCityId); - for (const [beforeSnapshot, afterSnapshot] of [ - [reference.before, reference.after], - [core.before, core.after], - ] as const) { - const oldCity = afterSnapshot.cities.find((entry) => entry.id === 3); - const destCity = afterSnapshot.cities.find((entry) => entry.id === destCityId); - const actor = afterSnapshot.generals.find((entry) => entry.id === 1); - const compatriot = afterSnapshot.generals.find((entry) => entry.id === 3); - const foreignGeneralBefore = beforeSnapshot.generals.find((entry) => entry.id === 2); - const foreignGeneralAfter = afterSnapshot.generals.find((entry) => entry.id === 2); - - expect(oldCity).toMatchObject({ - nationId: 0, - frontState: 0, - conflict: {}, - officerSet: 0, - }); - expect(destCity).toMatchObject({ - nationId: 1, - conflict: {}, - }); - expect(readNumericField(actor, 'cityId')).toBe(destCityId); - expect(readNumericField(compatriot, 'cityId')).toBe(destCityId); - expect(readNumericField(foreignGeneralAfter, 'cityId')).toBe( - readNumericField(foreignGeneralBefore, 'cityId') - ); - } - expect(readNumericField(readNationMeta(referenceNationAfter), 'can_무작위수도이전')).toBe(0); - expect(readNumericField(readNationMeta(coreNationAfter), 'can_무작위수도이전')).toBe(0); - expect(reference.rng).toHaveLength(1); - expect(reference.rng[0]).toMatchObject({ - operation: 'nextInt', - arguments: { maxInclusive: candidateCityIds.length - 1 }, - }); - } else { - expect(reference.rng).toEqual([]); - expect(readNationMeta(referenceNationAfter).can_무작위수도이전).toBe( - readNationMeta(referenceNationBefore).can_무작위수도이전 - ); - expect(readNationMeta(coreNationAfter).can_무작위수도이전).toBe( - readNationMeta(coreNationBefore).can_무작위수도이전 - ); - } - if (incomplete) { - expect(reference.execution.outcome).toMatchObject({ - completed: true, - lastTurn: { - command: '무작위 수도 이전', - term: 1, - }, - }); - expect(readNationMeta(coreNationAfter).turn_last_12).toMatchObject({ - command: '무작위 수도 이전', - term: 1, - }); - expect(readNumericField(readGeneralMeta(coreGeneralAfter), 'inherit_active_action')).toBe( - readNumericField(readGeneralMeta(coreGeneralBefore), 'inherit_active_action') - ); - const noCandidateText = '이동할 수 있는 도시가 없습니다.'; - expect(addedReferenceLogs(reference.before, reference.after.logs).map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining(noCandidateText)]) - ); - expect(core.after.logs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining(noCandidateText)]) - ); - } - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -type ResearchBoundaryOutcome = 'fallback' | 'intermediate' | 'completed'; - -const researchBoundaryCases: Array<{ - name: string; - action: 'event_화시병연구' | 'event_원융노병연구'; - term: number; - gold?: number; - rice?: number; - auxValue?: unknown; - setAuxValue?: boolean; - fixturePatches?: FixturePatches; - outcome: ResearchBoundaryOutcome; -}> = [ - { - name: 'keeps the short research at its last intermediate turn', - action: 'event_화시병연구', - term: 10, - outcome: 'intermediate', - }, - { - name: 'keeps the long research at its last intermediate turn', - action: 'event_원융노병연구', - term: 22, - outcome: 'intermediate', - }, - { - name: 'allows the exact short-research reserves without supply', - action: 'event_화시병연구', - term: 11, - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - outcome: 'completed', - }, - { - name: 'allows the exact long-research reserves', - action: 'event_원융노병연구', - term: 23, - outcome: 'completed', - }, - { - name: 'rejects one less than the short gold reserve', - action: 'event_화시병연구', - term: 11, - gold: 49_999, - outcome: 'fallback', - }, - { - name: 'rejects one less than the short rice reserve', - action: 'event_화시병연구', - term: 11, - rice: 51_999, - outcome: 'fallback', - }, - { - name: 'rejects one less than the long gold reserve', - action: 'event_원융노병연구', - term: 23, - gold: 99_999, - outcome: 'fallback', - }, - { - name: 'rejects one less than the long rice reserve', - action: 'event_원융노병연구', - term: 23, - rice: 101_999, - outcome: 'fallback', - }, - { - name: 'rejects a numeric completed flag', - action: 'event_화시병연구', - term: 11, - auxValue: 1, - setAuxValue: true, - outcome: 'fallback', - }, - { - name: 'rejects a numeric-string completed flag', - action: 'event_화시병연구', - term: 11, - auxValue: '1', - setAuxValue: true, - outcome: 'fallback', - }, - { - name: 'rejects a boolean completed flag', - action: 'event_원융노병연구', - term: 23, - auxValue: true, - setAuxValue: true, - outcome: 'fallback', - }, - { - name: 'rejects an array-shaped completed flag', - action: 'event_원융노병연구', - term: 23, - auxValue: [], - setAuxValue: true, - outcome: 'fallback', - }, - { - name: 'accepts a numeric-string incomplete flag', - action: 'event_원융노병연구', - term: 23, - auxValue: '0', - setAuxValue: true, - outcome: 'completed', - }, -]; - -integration('nation event research turn, reserve, and duplicate-state boundaries', () => { - it.each(researchBoundaryCases)( - '$name matches legacy completion, resources, flags, logs, RNG, and semantic delta', - async ({ action, term, gold, rice, auxValue, setAuxValue = false, fixturePatches, outcome }) => { - const config = researchConfigs[action]!; - const requiredGold = config.cost; - const requiredRice = config.cost + 2_000; - const request = buildRequest(action, undefined, { - ...fixturePatches, - nations: { - ...fixturePatches?.nations, - 1: { - ...fixturePatches?.nations?.[1], - gold: gold ?? requiredGold, - rice: rice ?? requiredRice, - meta: { - // The Ref CLI decodes an otherwise empty JSON object into a - // PHP array and persists it as `[]`. Keep a semantically inert - // key so this matrix observes the research mutation rather - // than an object-to-array fixture transport artifact. - matrix_fixture: 'research-boundary', - ...(setAuxValue ? { [config.auxKey]: auxValue } : {}), - }, - turnLastByOfficerLevel: { - 12: { - command: config.command, - term, - }, - }, - }, - }, - }); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const completed = outcome === 'completed'; - const intermediate = outcome === 'intermediate'; - - expect(reference.execution.outcome).toMatchObject({ completed }); - expect(core.execution.outcome).toMatchObject({ - requestedAction: action, - actionKey: outcome === 'fallback' ? '휴식' : action, - usedFallback: outcome === 'fallback', - ...(outcome === 'fallback' ? {} : { completed }), - }); - - for (const snapshot of [reference, core]) { - const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); - const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); - const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1); - const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1); - const expectedCost = completed ? config.cost : 0; - const expectedProgress = completed ? 5 * (config.preReqTurn + 1) : 0; - - expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe( - expectedCost - ); - expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe( - expectedCost - ); - expect( - readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience') - ).toBe(expectedProgress); - expect( - readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication') - ).toBe(expectedProgress); - - if (completed) { - expect(readNumericField(readNationMeta(nationAfter), config.auxKey)).toBe(1); - expect(readNationResource(nationAfter, 'gold')).toBe(0); - expect(readNationResource(nationAfter, 'rice')).toBe(2_000); - expect(snapshot.after.logs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining(`${config.command} 완료`)]) - ); - } else { - expect(readNationMeta(nationAfter)[config.auxKey]).toEqual( - readNationMeta(nationBefore)[config.auxKey] - ); - } - } - - if (intermediate) { - expect(reference.execution.outcome).toMatchObject({ - lastTurn: { - command: config.command, - term: config.preReqTurn, - }, - }); - expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({ - command: config.command, - term: config.preReqTurn, - }); - } - 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 mobilizePeopleBoundaryCases: Array<{ - name: string; - destCityId: unknown; - fixturePatches?: FixturePatches; - completed?: boolean; - coreResolution?: boolean; - expectedDefence?: number; - expectedWall?: number; - expectedStrategicCommandLimit?: number; - expectedPostReqTurn?: number; -}> = [ - { - name: 'rejects a missing destination city', - destCityId: 9999, - }, - { - name: 'accepts a numeric string destination city ID', - destCityId: '70', - completed: true, - expectedPostReqTurn: 63, - }, - { - name: 'rejects a source city occupied by another nation', - destCityId: 70, - fixturePatches: { cities: { 3: { nationId: 2 } } }, - }, - { - name: 'rejects a destination city occupied by another nation', - destCityId: 70, - fixturePatches: { cities: { 70: { nationId: 2 } } }, - }, - { - name: 'rejects an actor below chief rank', - destCityId: 70, - fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } }, - coreResolution: false, - }, - { - name: 'rejects a remaining strategic-command delay', - destCityId: 70, - fixturePatches: { nations: { 1: { strategicCommandLimit: 1 } } }, - }, - { - name: 'allows an unsupplied source city', - destCityId: 70, - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - completed: true, - expectedPostReqTurn: 63, - }, - { - name: 'allows an unsupplied destination city', - destCityId: 70, - fixturePatches: { cities: { 70: { supplyState: 0 } } }, - completed: true, - expectedPostReqTurn: 63, - }, - { - name: 'allows the actor city as destination', - destCityId: 3, - completed: true, - expectedPostReqTurn: 63, - }, - { - name: 'rounds the 80-percent defence and wall floors like MariaDB', - destCityId: 70, - fixturePatches: { - cities: { - 70: { - defence: 1_000, - defenceMax: 5_001, - wall: 1_000, - wallMax: 5_001, - }, - }, - }, - completed: true, - expectedDefence: 4_001, - expectedWall: 4_001, - expectedPostReqTurn: 63, - }, - { - name: 'preserves defence and wall already above their 80-percent floors', - destCityId: 70, - fixturePatches: { - cities: { - 70: { - defence: 4_500, - defenceMax: 5_000, - wall: 4_600, - wallMax: 5_000, - }, - }, - }, - completed: true, - expectedDefence: 4_500, - expectedWall: 4_600, - expectedPostReqTurn: 63, - }, - { - name: 'applies the strategist global-delay modifier', - destCityId: 70, - fixturePatches: { nations: { 1: { typeCode: 'che_종횡가' } } }, - completed: true, - expectedStrategicCommandLimit: 5, - expectedPostReqTurn: 47, - }, - { - name: 'uses the actual eleven-general count for the nation cooldown', - destCityId: 70, - fixturePatches: { - nations: { 1: { generalCount: 11 } }, - generals: { - 4: { nationId: 1 }, - 5: { nationId: 1 }, - 6: { nationId: 1 }, - 7: { nationId: 1 }, - 8: { nationId: 1 }, - 9: { nationId: 1 }, - 10: { nationId: 1 }, - 11: { nationId: 1 }, - 12: { nationId: 1 }, - }, - }, - completed: true, - expectedPostReqTurn: 66, - }, -]; - -integration('nation mobilize-people target, delay, and city-effect boundaries', () => { - it.each(mobilizePeopleBoundaryCases)( - '$name matches legacy fallback, cooldown, effects, logs, RNG, and semantic delta', - async ({ - destCityId, - fixturePatches, - completed = false, - coreResolution = true, - expectedDefence, - expectedWall, - expectedStrategicCommandLimit = 9, - expectedPostReqTurn, - }) => { - const request = buildRequest( - 'che_백성동원', - { destCityID: destCityId }, - { - ...fixturePatches, - cities: { - ...fixturePatches?.cities, - 70: { - nationId: 1, - ...fixturePatches?.cities?.[70], - }, - }, - } - ); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const targetCityId = destCityId === 3 ? 3 : 70; - - expect(reference.execution.outcome).toMatchObject({ completed }); - if (coreResolution) { - expect(core.execution.outcome).toMatchObject({ - requestedAction: 'che_백성동원', - actionKey: completed ? 'che_백성동원' : '휴식', - usedFallback: !completed, - }); - } else { - expect(core.execution.outcome).toBeUndefined(); - } - - for (const snapshot of [reference, core]) { - const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); - const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); - const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1); - const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1); - const cityBefore = snapshot.before.cities.find((entry) => entry.id === targetCityId); - const cityAfter = snapshot.after.cities.find((entry) => entry.id === targetCityId); - - expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); - expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); - expect( - readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience') - ).toBe(completed ? 5 : 0); - expect( - readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication') - ).toBe(completed ? 5 : 0); - - if (completed) { - expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(expectedStrategicCommandLimit); - if (expectedDefence !== undefined) { - expect(readNumericField(cityAfter, 'defence')).toBe(expectedDefence); - } - if (expectedWall !== undefined) { - expect(readNumericField(cityAfter, 'wall')).toBe(expectedWall); - } - const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); - expect(addedLogs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining('백성동원 발동')]) - ); - const broadcastFragment = '백성동원을 하였습니다.'; - expect( - addedLogs.some( - (entry) => entry.generalId === 3 && String(entry.text).includes(broadcastFragment) - ) - ).toBe(true); - expect( - addedLogs.some( - (entry) => entry.generalId === 2 && String(entry.text).includes(broadcastFragment) - ) - ).toBe(false); - expect(addedLogs.some((entry) => String(entry.text).includes('에 백성동원을 발동'))).toBe( - true - ); - } else { - expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( - readNumericField(nationBefore, 'strategicCommandLimit') - ); - expect(readNumericField(cityAfter, 'defence')).toBe(readNumericField(cityBefore, 'defence')); - expect(readNumericField(cityAfter, 'wall')).toBe(readNumericField(cityBefore, 'wall')); - } - } - - if (completed) { - const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!; - if (expectedPostReqTurn === 66) { - expect(reference.before.generals.filter((entry) => entry.nationId === 1)).toHaveLength(11); - expect(core.before.generals.filter((entry) => entry.nationId === 1)).toHaveLength(11); - } - expect(reference.after.world.nationCooldowns).toEqual([ - { - nationId: 1, - actionName: '백성동원', - nextAvailableTurn: expectedNextAvailableTurn, - }, - ]); - 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 degradeRelationsBoundaryCases: Array<{ - name: string; - destNationId: unknown; - fixturePatches?: FixturePatches; - completed?: boolean; - coreResolution?: boolean; - expectedForwardTerm?: number; - expectedReverseTerm?: number; - expectedSourceFront?: number; - expectedDestFront?: number; - expectedStrategicCommandLimit?: number; - expectedPostReqTurn?: number; -}> = [ - { - name: 'rejects a missing destination nation', - destNationId: 99, - }, - { - name: 'rejects the source nation as destination', - destNationId: 1, - }, - { - name: 'rejects a numeric string destination nation ID', - destNationId: '2', - }, - { - name: 'rejects a fractional destination nation ID', - destNationId: 2.9, - }, - { - name: 'rejects a source city occupied by another nation', - destNationId: 2, - fixturePatches: { - cities: { 3: { nationId: 2 } }, - diplomacy: { '1:2': { state: 1, term: 12 } }, - }, - }, - { - name: 'rejects an actor below chief rank', - destNationId: 2, - fixturePatches: { - generals: { 1: { officerLevel: 4, officerCityId: 0 } }, - diplomacy: { '1:2': { state: 1, term: 12 } }, - }, - coreResolution: false, - }, - { - name: 'rejects a remaining strategic-command delay', - destNationId: 2, - fixturePatches: { - nations: { 1: { strategicCommandLimit: 1 } }, - diplomacy: { '1:2': { state: 1, term: 12 } }, - }, - }, - { - name: 'allows an unsupplied source city', - destNationId: 2, - fixturePatches: { - cities: { 3: { supplyState: 0 } }, - diplomacy: { - '1:2': { state: 1, term: 12 }, - '2:1': { state: 1, term: 12 }, - }, - }, - completed: true, - expectedForwardTerm: 15, - expectedReverseTerm: 15, - expectedSourceFront: 2, - expectedDestFront: 2, - expectedPostReqTurn: 126, - }, - { - name: 'resets both war terms to three and refreshes both fronts', - destNationId: 2, - fixturePatches: { - diplomacy: { - '1:2': { state: 0, term: 12 }, - '2:1': { state: 0, term: 8 }, - }, - }, - completed: true, - expectedForwardTerm: 3, - expectedReverseTerm: 3, - expectedSourceFront: 1, - expectedDestFront: 1, - expectedPostReqTurn: 126, - }, - { - name: 'keeps declaration fronts at the exact five-term boundary', - destNationId: 2, - fixturePatches: { - diplomacy: { - '1:2': { state: 1, term: 2 }, - '2:1': { state: 1, term: 2 }, - }, - }, - completed: true, - expectedForwardTerm: 5, - expectedReverseTerm: 5, - expectedSourceFront: 1, - expectedDestFront: 1, - expectedPostReqTurn: 126, - }, - { - name: 'updates a disallowed reverse state independently', - destNationId: 2, - fixturePatches: { - diplomacy: { - '1:2': { state: 1, term: 4 }, - '2:1': { state: 2, term: 9 }, - }, - }, - completed: true, - expectedForwardTerm: 7, - expectedReverseTerm: 12, - expectedSourceFront: 2, - expectedDestFront: 2, - expectedPostReqTurn: 126, - }, - { - name: 'applies the strategist command and global-delay modifiers', - destNationId: 2, - fixturePatches: { - nations: { 1: { typeCode: 'che_종횡가' } }, - diplomacy: { - '1:2': { state: 1, term: 12 }, - '2:1': { state: 1, term: 12 }, - }, - }, - completed: true, - expectedForwardTerm: 15, - expectedReverseTerm: 15, - expectedSourceFront: 2, - expectedDestFront: 2, - expectedStrategicCommandLimit: 5, - expectedPostReqTurn: 95, - }, - { - name: 'uses the actual eleven-general count for the nation cooldown', - destNationId: 2, - fixturePatches: { - nations: { 1: { generalCount: 11 } }, - generals: { - 4: { nationId: 1 }, - 5: { nationId: 1 }, - 6: { nationId: 1 }, - 7: { nationId: 1 }, - 8: { nationId: 1 }, - 9: { nationId: 1 }, - 10: { nationId: 1 }, - 11: { nationId: 1 }, - 12: { nationId: 1 }, - }, - diplomacy: { - '1:2': { state: 1, term: 12 }, - '2:1': { state: 1, term: 12 }, - }, - }, - completed: true, - expectedForwardTerm: 15, - expectedReverseTerm: 15, - expectedSourceFront: 2, - expectedDestFront: 2, - expectedPostReqTurn: 133, - }, -]; - -integration('nation degrade-relations target, diplomacy, front, and cooldown boundaries', () => { - it.each(degradeRelationsBoundaryCases)( - '$name matches legacy fallback, diplomacy, fronts, cooldown, logs, RNG, and semantic delta', - async ({ - destNationId, - fixturePatches, - completed = false, - coreResolution = true, - expectedForwardTerm, - expectedReverseTerm, - expectedSourceFront, - expectedDestFront, - expectedStrategicCommandLimit = 9, - expectedPostReqTurn, - }) => { - const request = buildRequest('che_이호경식', { destNationID: destNationId }, fixturePatches); - 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: completed ? 'che_이호경식' : '휴식', - usedFallback: !completed, - }); - } else { - expect(core.execution.outcome).toBeUndefined(); - } - - for (const snapshot of [reference, core]) { - const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); - const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); - const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1); - const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1); - const sourceCityBefore = snapshot.before.cities.find((entry) => entry.id === 3); - const sourceCityAfter = snapshot.after.cities.find((entry) => entry.id === 3); - const destCityBefore = snapshot.before.cities.find((entry) => entry.id === 70); - const destCityAfter = snapshot.after.cities.find((entry) => entry.id === 70); - const forwardBefore = snapshot.before.diplomacy.find( - (entry) => entry.fromNationId === 1 && entry.toNationId === 2 - ); - const forwardAfter = snapshot.after.diplomacy.find( - (entry) => entry.fromNationId === 1 && entry.toNationId === 2 - ); - const reverseBefore = snapshot.before.diplomacy.find( - (entry) => entry.fromNationId === 2 && entry.toNationId === 1 - ); - const reverseAfter = snapshot.after.diplomacy.find( - (entry) => entry.fromNationId === 2 && entry.toNationId === 1 - ); - - expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); - expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); - expect( - readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience') - ).toBe(completed ? 5 : 0); - expect( - readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication') - ).toBe(completed ? 5 : 0); - - if (completed) { - expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(expectedStrategicCommandLimit); - expect(readNumericField(forwardAfter, 'state')).toBe(1); - expect(readNumericField(reverseAfter, 'state')).toBe(1); - expect(readNumericField(forwardAfter, 'term')).toBe(expectedForwardTerm); - expect(readNumericField(reverseAfter, 'term')).toBe(expectedReverseTerm); - expect(readNumericField(sourceCityAfter, 'frontState')).toBe(expectedSourceFront); - expect(readNumericField(destCityAfter, 'frontState')).toBe(expectedDestFront); - - const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); - expect(addedLogs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining('이호경식 발동')]) - ); - 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(true); - } else { - expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( - readNumericField(nationBefore, 'strategicCommandLimit') - ); - expect(forwardAfter).toEqual(forwardBefore); - expect(reverseAfter).toEqual(reverseBefore); - expect(readNumericField(sourceCityAfter, 'frontState')).toBe( - readNumericField(sourceCityBefore, 'frontState') - ); - expect(readNumericField(destCityAfter, 'frontState')).toBe( - readNumericField(destCityBefore, 'frontState') - ); - } - } - - if (completed) { - const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!; - expect(reference.after.world.nationCooldowns).toEqual([ - { - nationId: 1, - actionName: '이호경식', - nextAvailableTurn: expectedNextAvailableTurn, - }, - ]); - 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 surpriseAttackBoundaryCases: Array<{ - name: string; - destNationId: unknown; - fixturePatches?: FixturePatches; - completed?: boolean; - coreResolution?: boolean; - expectedForwardState?: number; - expectedReverseState?: number; - expectedForwardTerm?: number; - expectedReverseTerm?: number; - expectedSourceFront?: number; - expectedDestFront?: number; - expectedStrategicCommandLimit?: number; - expectedPostReqTurn?: number; - expectedDestLogGeneralId?: number; -}> = [ - { - name: 'rejects a missing destination nation', - destNationId: 99, - }, - { - name: 'rejects a numeric string destination nation ID', - destNationId: '2', - }, - { - name: 'rejects a fractional destination nation ID', - destNationId: 2.9, - }, - { - name: 'rejects a source city occupied by another nation', - destNationId: 2, - fixturePatches: { - cities: { 3: { nationId: 2 } }, - diplomacy: { '1:2': { state: 1, term: 12 } }, - }, - }, - { - name: 'rejects an actor below chief rank', - destNationId: 2, - fixturePatches: { - generals: { 1: { officerLevel: 4, officerCityId: 0 } }, - diplomacy: { '1:2': { state: 1, term: 12 } }, - }, - coreResolution: false, - }, - { - name: 'rejects a remaining strategic-command delay', - destNationId: 2, - fixturePatches: { - nations: { 1: { strategicCommandLimit: 1 } }, - diplomacy: { '1:2': { state: 1, term: 12 } }, - }, - }, - { - name: 'rejects a forward diplomacy state other than declaration', - destNationId: 2, - fixturePatches: { - diplomacy: { '1:2': { state: 0, term: 12 } }, - }, - }, - { - name: 'rejects declaration term eleven at the lower boundary', - destNationId: 2, - fixturePatches: { - diplomacy: { '1:2': { state: 1, term: 11 } }, - }, - }, - { - name: 'allows an unsupplied source city and preserves both fronts', - destNationId: 2, - fixturePatches: { - cities: { - 3: { supplyState: 0, frontState: 2 }, - 70: { frontState: 3 }, - }, - diplomacy: { - '1:2': { state: 1, term: 12 }, - '2:1': { state: 1, term: 12 }, - }, - }, - completed: true, - expectedForwardState: 1, - expectedReverseState: 1, - expectedForwardTerm: 9, - expectedReverseTerm: 9, - expectedSourceFront: 2, - expectedDestFront: 3, - expectedPostReqTurn: 126, - }, - { - name: 'subtracts three from a disallowed reverse state into a negative term', - destNationId: 2, - fixturePatches: { - diplomacy: { - '1:2': { state: 1, term: 15 }, - '2:1': { state: 2, term: 2 }, - }, - }, - completed: true, - expectedForwardState: 1, - expectedReverseState: 2, - expectedForwardTerm: 12, - expectedReverseTerm: -1, - expectedSourceFront: 0, - expectedDestFront: 1, - expectedPostReqTurn: 126, - }, - { - name: 'allows the source nation when a self-diplomacy row is present', - destNationId: 1, - fixturePatches: { - diplomacy: { - '1:1': { state: 1, term: 12 }, - }, - }, - completed: true, - expectedForwardState: 1, - expectedReverseState: 1, - expectedForwardTerm: 9, - expectedReverseTerm: 9, - expectedSourceFront: 0, - expectedDestFront: 0, - expectedPostReqTurn: 126, - expectedDestLogGeneralId: 1, - }, - { - name: 'applies the strategist command and global-delay modifiers', - destNationId: 2, - fixturePatches: { - nations: { 1: { typeCode: 'che_종횡가' } }, - diplomacy: { - '1:2': { state: 1, term: 12 }, - '2:1': { state: 1, term: 12 }, - }, - }, - completed: true, - expectedForwardState: 1, - expectedReverseState: 1, - expectedForwardTerm: 9, - expectedReverseTerm: 9, - expectedSourceFront: 0, - expectedDestFront: 1, - expectedStrategicCommandLimit: 5, - expectedPostReqTurn: 95, - }, - { - name: 'uses the actual eleven-general count for the nation cooldown', - destNationId: 2, - fixturePatches: { - nations: { 1: { generalCount: 11 } }, - generals: { - 4: { nationId: 1 }, - 5: { nationId: 1 }, - 6: { nationId: 1 }, - 7: { nationId: 1 }, - 8: { nationId: 1 }, - 9: { nationId: 1 }, - 10: { nationId: 1 }, - 11: { nationId: 1 }, - 12: { nationId: 1 }, - }, - diplomacy: { - '1:2': { state: 1, term: 12 }, - '2:1': { state: 1, term: 12 }, - }, - }, - completed: true, - expectedForwardState: 1, - expectedReverseState: 1, - expectedForwardTerm: 9, - expectedReverseTerm: 9, - expectedSourceFront: 0, - expectedDestFront: 1, - expectedPostReqTurn: 133, - }, -]; - -integration('nation surprise-attack target, diplomacy-term, front, and cooldown boundaries', () => { - it.each(surpriseAttackBoundaryCases)( - '$name matches legacy fallback, diplomacy, fronts, cooldown, logs, RNG, and semantic delta', - async ({ - destNationId, - fixturePatches, - completed = false, - coreResolution = true, - expectedForwardState, - expectedReverseState, - expectedForwardTerm, - expectedReverseTerm, - expectedSourceFront, - expectedDestFront, - expectedStrategicCommandLimit = 9, - expectedPostReqTurn, - expectedDestLogGeneralId = 2, - }) => { - const request = buildRequest('che_급습', { destNationID: destNationId }, fixturePatches); - 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: completed ? 'che_급습' : '휴식', - usedFallback: !completed, - }); - } else { - expect(core.execution.outcome).toBeUndefined(); - } - - for (const snapshot of [reference, core]) { - const actualDestNationId = destNationId === 1 ? 1 : 2; - const actualDestCityId = actualDestNationId === 1 ? 3 : 70; - const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); - const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); - const generalBefore = snapshot.before.generals.find((entry) => entry.id === 1); - const generalAfter = snapshot.after.generals.find((entry) => entry.id === 1); - const sourceCityBefore = snapshot.before.cities.find((entry) => entry.id === 3); - const sourceCityAfter = snapshot.after.cities.find((entry) => entry.id === 3); - const destCityBefore = snapshot.before.cities.find((entry) => entry.id === actualDestCityId); - const destCityAfter = snapshot.after.cities.find((entry) => entry.id === actualDestCityId); - const forwardBefore = snapshot.before.diplomacy.find( - (entry) => entry.fromNationId === 1 && entry.toNationId === actualDestNationId - ); - const forwardAfter = snapshot.after.diplomacy.find( - (entry) => entry.fromNationId === 1 && entry.toNationId === actualDestNationId - ); - const reverseBefore = snapshot.before.diplomacy.find( - (entry) => entry.fromNationId === actualDestNationId && entry.toNationId === 1 - ); - const reverseAfter = snapshot.after.diplomacy.find( - (entry) => entry.fromNationId === actualDestNationId && entry.toNationId === 1 - ); - - expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); - expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); - expect( - readNumericField(generalAfter, 'experience') - readNumericField(generalBefore, 'experience') - ).toBe(completed ? 5 : 0); - expect( - readNumericField(generalAfter, 'dedication') - readNumericField(generalBefore, 'dedication') - ).toBe(completed ? 5 : 0); - - if (completed) { - expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe(expectedStrategicCommandLimit); - expect(readNumericField(forwardAfter, 'state')).toBe(expectedForwardState); - expect(readNumericField(reverseAfter, 'state')).toBe(expectedReverseState); - expect(readNumericField(forwardAfter, 'term')).toBe(expectedForwardTerm); - expect(readNumericField(reverseAfter, 'term')).toBe(expectedReverseTerm); - expect(readNumericField(sourceCityAfter, 'frontState')).toBe(expectedSourceFront); - expect(readNumericField(destCityAfter, 'frontState')).toBe(expectedDestFront); - - const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); - expect(addedLogs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining('급습 발동')]) - ); - expect( - addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('급습')) - ).toBe(true); - expect( - addedLogs.some( - (entry) => - entry.generalId === expectedDestLogGeneralId && - String(entry.text).includes('아국에 급습이 발동되었습니다.') - ) - ).toBe(true); - } else { - expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( - readNumericField(nationBefore, 'strategicCommandLimit') - ); - expect(forwardAfter).toEqual(forwardBefore); - expect(reverseAfter).toEqual(reverseBefore); - expect(readNumericField(sourceCityAfter, 'frontState')).toBe( - readNumericField(sourceCityBefore, 'frontState') - ); - expect(readNumericField(destCityAfter, 'frontState')).toBe( - readNumericField(destCityBefore, 'frontState') - ); - } - } - - if (completed) { - const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!; - expect(reference.after.world.nationCooldowns).toEqual([ - { - nationId: 1, - actionName: '급습', - nextAvailableTurn: expectedNextAvailableTurn, - }, - ]); - 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 desperateSurvivalBoundaryCases: Array<{ - name: string; - fixturePatches?: FixturePatches; - outcome: 'fallback' | 'intermediate' | 'completed'; - coreResolution?: boolean; - expectedLastTerm?: number; - expectedStrategicCommandLimit?: number; - expectedPostReqTurn?: number; -}> = [ - { - name: 'rejects a nation without an outgoing war', - outcome: 'fallback', - }, - { - name: 'rejects a reverse-only war', - fixturePatches: { - diplomacy: { - '1:2': { state: 3 }, - '2:1': { state: 0 }, - }, - }, - outcome: 'fallback', - }, - { - name: 'rejects a source city occupied by another nation', - fixturePatches: { - cities: { 3: { nationId: 2 } }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'fallback', - }, - { - name: 'rejects an actor below chief rank', - fixturePatches: { - generals: { 1: { officerLevel: 4, officerCityId: 0 } }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'fallback', - coreResolution: false, - }, - { - name: 'rejects a remaining strategic-command delay', - fixturePatches: { - nations: { 1: { strategicCommandLimit: 1 } }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'fallback', - }, - { - name: 'allows an unsupplied city at the first intermediate turn', - fixturePatches: { - cities: { 3: { supplyState: 0 } }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'intermediate', - expectedLastTerm: 1, - }, - { - name: 'advances the second intermediate turn without side effects', - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '필사즉생', arg: {}, term: 1 }, - }, - }, - }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'intermediate', - expectedLastTerm: 2, - }, - { - name: 'completes with an outgoing war even when the reverse relation is trade', - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '필사즉생', arg: {}, term: 2 }, - }, - }, - }, - diplomacy: { - '1:2': { state: 0, term: 0 }, - '2:1': { state: 3, term: 0 }, - }, - }, - outcome: 'completed', - expectedPostReqTurn: 87, - }, - { - name: 'preserves training and morale values already above one hundred', - fixturePatches: { - generals: { - 1: { train: 120, atmos: 110 }, - 3: { train: 105, atmos: 130 }, - }, - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '필사즉생', arg: {}, term: 2 }, - }, - }, - }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'completed', - expectedPostReqTurn: 87, - }, - { - name: 'accepts an explicit self-war as the outgoing war relation', - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '필사즉생', arg: {}, term: 2 }, - }, - }, - }, - diplomacy: { - '1:1': { state: 0 }, - }, - }, - outcome: 'completed', - expectedPostReqTurn: 87, - }, - { - name: 'restarts at term one after a different command interrupted the stack', - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '급습', arg: { destNationID: 2 }, term: 2 }, - }, - }, - }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'intermediate', - expectedLastTerm: 1, - }, - { - name: 'applies the strategist command and global-delay modifiers', - fixturePatches: { - nations: { - 1: { - typeCode: 'che_종횡가', - turnLastByOfficerLevel: { - 12: { command: '필사즉생', arg: {}, term: 2 }, - }, - }, - }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'completed', - expectedStrategicCommandLimit: 5, - expectedPostReqTurn: 65, - }, - { - name: 'uses the actual eleven-general count for the nation cooldown', - fixturePatches: { - nations: { - 1: { - generalCount: 11, - turnLastByOfficerLevel: { - 12: { command: '필사즉생', arg: {}, term: 2 }, - }, - }, - }, - generals: { - 4: { nationId: 1 }, - 5: { nationId: 1 }, - 6: { nationId: 1 }, - 7: { nationId: 1 }, - 8: { nationId: 1 }, - 9: { nationId: 1 }, - 10: { nationId: 1 }, - 11: { nationId: 1 }, - 12: { nationId: 1 }, - }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'completed', - expectedPostReqTurn: 92, - }, -]; - -integration('nation desperate-survival multistep, diplomacy, effects, and cooldown boundaries', () => { - it.each(desperateSurvivalBoundaryCases)( - '$name matches legacy progress, effects, cooldown, logs, RNG, and semantic delta', - async ({ - fixturePatches, - outcome, - coreResolution = true, - expectedLastTerm, - expectedStrategicCommandLimit = 9, - expectedPostReqTurn, - }) => { - const request = buildRequest('che_필사즉생', undefined, fixturePatches); - const reference = runReferenceTurnCommandTraceRequest( - workspaceRoot!, - request as unknown as Record - ); - const core = await runCoreTurnCommandTrace(request, reference.before); - const completed = outcome === 'completed'; - const fallback = outcome === 'fallback'; - - 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 nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); - const nationAfter = 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); - const teammateBefore = snapshot.before.generals.find((entry) => entry.id === 3); - const teammateAfter = snapshot.after.generals.find((entry) => entry.id === 3); - const foreignBefore = snapshot.before.generals.find((entry) => entry.id === 2); - const foreignAfter = snapshot.after.generals.find((entry) => entry.id === 2); - - expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); - expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, 'rice')).toBe(0); - expect(readNumericField(actorAfter, 'experience') - readNumericField(actorBefore, 'experience')).toBe( - completed ? 15 : 0 - ); - expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe( - completed ? 15 : 0 - ); - expect(readNumericField(nationAfter, 'strategicCommandLimit')).toBe( - completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit') - ); - - for (const [before, after] of [ - [actorBefore, actorAfter], - [teammateBefore, teammateAfter], - ] as const) { - expect(readNumericField(after, 'train')).toBe( - completed ? Math.max(100, readNumericField(before, 'train')) : readNumericField(before, 'train') - ); - expect(readNumericField(after, 'atmos')).toBe( - completed ? Math.max(100, readNumericField(before, 'atmos')) : readNumericField(before, 'atmos') - ); - } - expect(readNumericField(foreignAfter, 'train')).toBe(readNumericField(foreignBefore, 'train')); - expect(readNumericField(foreignAfter, 'atmos')).toBe(readNumericField(foreignBefore, 'atmos')); - - if (completed) { - const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); - expect(addedLogs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining('필사즉생 발동')]) - ); - expect( - addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('필사즉생')) - ).toBe(true); - } - } - - if (outcome === 'intermediate') { - expect(reference.execution.outcome).toMatchObject({ - lastTurn: { - command: '필사즉생', - term: expectedLastTerm, - }, - }); - expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({ - command: '필사즉생', - term: expectedLastTerm, - }); - } - if (completed) { - const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!; - expect(reference.after.world.nationCooldowns).toEqual([ - { - nationId: 1, - actionName: '필사즉생', - nextAvailableTurn: expectedNextAvailableTurn, - }, - ]); - 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 deceptionBoundaryCases: Array<{ - name: string; - destCityId: unknown; - fixturePatches?: FixturePatches; - outcome: 'fallback' | 'intermediate' | 'completed'; - coreResolution?: boolean; - expectedPostReqTurn?: number; - expectedStrategicCommandLimit?: number; - expectedTargetCityId?: number; - expectedRngCalls?: number; -}> = [ - { - name: 'rejects a missing destination city', - destCityId: 99, - outcome: 'fallback', - }, - { - name: 'accepts a numeric string destination city ID', - destCityId: '70', - fixturePatches: { diplomacy: { '1:2': { state: 0 } } }, - outcome: 'completed', - expectedPostReqTurn: 62, - expectedTargetCityId: 70, - expectedRngCalls: 2, - }, - { - name: 'truncates a fractional destination city ID', - destCityId: 70.9, - fixturePatches: { diplomacy: { '1:2': { state: 0 } } }, - outcome: 'completed', - expectedPostReqTurn: 62, - expectedTargetCityId: 70, - expectedRngCalls: 2, - }, - { - name: 'rejects a neutral destination city', - destCityId: 70, - fixturePatches: { cities: { 70: { nationId: 0 } } }, - outcome: 'fallback', - }, - { - name: 'rejects a destination city occupied by the source nation', - destCityId: 70, - fixturePatches: { cities: { 70: { nationId: 1 } } }, - outcome: 'fallback', - }, - { - name: 'rejects a trade relation to the destination nation', - destCityId: 70, - fixturePatches: { diplomacy: { '1:2': { state: 3 } } }, - outcome: 'fallback', - }, - { - name: 'rejects a reverse-only war relation', - destCityId: 70, - fixturePatches: { - diplomacy: { - '1:2': { state: 3 }, - '2:1': { state: 0 }, - }, - }, - outcome: 'fallback', - }, - { - name: 'rejects a source city occupied by another nation', - destCityId: 70, - fixturePatches: { - cities: { 3: { nationId: 2 } }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'fallback', - }, - { - name: 'rejects an actor below chief rank', - destCityId: 70, - fixturePatches: { - generals: { 1: { officerLevel: 4, officerCityId: 0 } }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'fallback', - coreResolution: false, - }, - { - name: 'rejects a remaining strategic-command delay', - destCityId: 70, - fixturePatches: { - nations: { 1: { strategicCommandLimit: 1 } }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'fallback', - }, - { - name: 'allows an unsupplied source city on the intermediate turn', - destCityId: 70, - fixturePatches: { - cities: { 3: { supplyState: 0 } }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'intermediate', - }, - { - name: 'completes against a nation under declaration', - destCityId: 70, - fixturePatches: { diplomacy: { '1:2': { state: 1 } } }, - outcome: 'completed', - expectedPostReqTurn: 62, - expectedTargetCityId: 70, - expectedRngCalls: 2, - }, - { - name: 'excludes supply state two and moves the target to the only supply-one city', - destCityId: 70, - fixturePatches: { - cities: { - 70: { supplyState: 2 }, - 23: { nationId: 2, supplyState: 1 }, - }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'completed', - expectedPostReqTurn: 62, - expectedTargetCityId: 23, - expectedRngCalls: 1, - }, - { - name: 'completes without RNG when no general is in the destination city', - destCityId: 70, - fixturePatches: { - cities: { 23: { nationId: 2, supplyState: 1 } }, - generals: { 2: { cityId: 23 } }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'completed', - expectedPostReqTurn: 62, - expectedTargetCityId: 23, - expectedRngCalls: 0, - }, - { - name: 'restarts at term one after another command interrupted the stack', - destCityId: 70, - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '필사즉생', arg: {}, term: 2 }, - }, - }, - }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'intermediate', - }, - { - name: 'applies the strategist command and global-delay modifiers', - destCityId: 70, - fixturePatches: { - nations: { 1: { typeCode: 'che_종횡가' } }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'completed', - expectedPostReqTurn: 46, - expectedStrategicCommandLimit: 5, - expectedTargetCityId: 70, - expectedRngCalls: 2, - }, - { - name: 'uses the actual eleven-general count for the nation cooldown', - destCityId: 70, - fixturePatches: { - nations: { 1: { generalCount: 11 } }, - generals: { - 4: { nationId: 1 }, - 5: { nationId: 1 }, - 6: { nationId: 1 }, - 7: { nationId: 1 }, - 8: { nationId: 1 }, - 9: { nationId: 1 }, - 10: { nationId: 1 }, - 11: { nationId: 1 }, - 12: { nationId: 1 }, - }, - diplomacy: { '1:2': { state: 0 } }, - }, - outcome: 'completed', - expectedPostReqTurn: 65, - expectedTargetCityId: 70, - expectedRngCalls: 2, - }, -]; - -integration('nation deception target, multistep, movement, RNG, and cooldown boundaries', () => { - it.each(deceptionBoundaryCases)( - '$name matches legacy progress, movement, cooldown, logs, RNG, and semantic delta', - async ({ - destCityId, - fixturePatches, - outcome, - coreResolution = true, - expectedPostReqTurn, - expectedStrategicCommandLimit = 9, - expectedTargetCityId, - expectedRngCalls, - }) => { - const normalizedDestCityId = - typeof destCityId === 'string' ? Math.trunc(Number(destCityId)) : Math.trunc(Number(destCityId)); - const completed = outcome === 'completed'; - const fallback = outcome === 'fallback'; - const completionNationPatch = completed - ? { - turnLastByOfficerLevel: { - 12: { command: '허보', arg: { destCityID: destCityId }, term: 1 }, - }, - coreTurnLastByOfficerLevel: { - 12: { command: '허보', arg: { destCityId: normalizedDestCityId }, term: 1 }, - }, - } - : {}; - const request = buildRequest( - 'che_허보', - { destCityID: destCityId }, - { - ...fixturePatches, - nations: { - ...fixturePatches?.nations, - 1: { - ...fixturePatches?.nations?.[1], - ...completionNationPatch, - }, - }, - } - ); - 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 nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); - const nationAfter = 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); - const targetBefore = snapshot.before.generals.find((entry) => entry.id === 2); - const targetAfter = snapshot.after.generals.find((entry) => entry.id === 2); - - expect(readNationResource(nationBefore, 'gold') - readNationResource(nationAfter, 'gold')).toBe(0); - expect(readNationResource(nationBefore, 'rice') - readNationResource(nationAfter, '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(nationAfter, 'strategicCommandLimit')).toBe( - completed ? expectedStrategicCommandLimit : readNumericField(nationBefore, 'strategicCommandLimit') - ); - expect(readNumericField(targetAfter, 'cityId')).toBe( - completed && expectedTargetCityId !== undefined - ? expectedTargetCityId - : readNumericField(targetBefore, 'cityId') - ); - - if (completed) { - const addedLogs = addedReferenceLogs(snapshot.before, snapshot.after.logs); - expect(addedLogs.map((entry) => entry.text)).toEqual( - expect.arrayContaining([expect.stringContaining('허보 발동')]) - ); - expect( - addedLogs.some((entry) => entry.generalId === 3 && String(entry.text).includes('허보')) - ).toBe(true); - } - } - - 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) { - const expectedNextAvailableTurn = 190 * 12 + expectedPostReqTurn!; - expect(reference.after.world.nationCooldowns).toEqual([ - { - nationId: 1, - actionName: '허보', - nextAvailableTurn: expectedNextAvailableTurn, - }, - ]); - expect(core.after.world.nationCooldowns).toEqual(reference.after.world.nationCooldowns); - } - if (expectedRngCalls !== undefined) { - expect(reference.rng).toHaveLength(expectedRngCalls); - } - expect(core.rng).toEqual(reference.rng); - expect( - compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { - ignoredPathPatterns: ignoredLifecyclePaths, - }) - ).toEqual([]); - }, - 120_000 - ); -}); - -type ScorchedEarthOutcome = 'fallback' | 'intermediate' | 'completed'; - -const scorchedEarthBoundaryCases: Array<{ - name: string; - destCityId: unknown; - fixturePatches?: FixturePatches; - outcome: ScorchedEarthOutcome; - coreResolution?: boolean; -}> = [ - { - name: 'rejects a missing destination city', - destCityId: 99, - outcome: 'fallback', - }, - { - name: 'rejects a destination city occupied by another nation', - destCityId: 70, - fixturePatches: { cities: { 70: { nationId: 2 } } }, - outcome: 'fallback', - }, - { - name: 'rejects a neutral destination city', - destCityId: 70, - fixturePatches: { cities: { 70: { nationId: 0 } } }, - outcome: 'fallback', - }, - { - name: 'rejects a source city occupied by another nation', - destCityId: 70, - fixturePatches: { cities: { 3: { nationId: 2 } } }, - outcome: 'fallback', - }, - { - name: 'rejects an unsupplied source city', - destCityId: 70, - fixturePatches: { cities: { 3: { supplyState: 0 } } }, - outcome: 'fallback', - }, - { - name: 'rejects an unsupplied destination city', - destCityId: 70, - fixturePatches: { cities: { 70: { supplyState: 0 } } }, - outcome: 'fallback', - }, - { - name: 'rejects an actor below chief rank', - destCityId: 70, - fixturePatches: { generals: { 1: { officerLevel: 4, officerCityId: 0 } } }, - outcome: 'fallback', - coreResolution: false, - }, - { - name: 'rejects the national capital', - destCityId: 70, - fixturePatches: { nations: { 1: { capitalCityId: 70 } } }, - outcome: 'fallback', - }, - { - name: 'rejects a remaining diplomacy restriction', - destCityId: 70, - fixturePatches: { nations: { 1: { diplomacyLimit: 1 } } }, - outcome: 'fallback', - }, - { - name: 'rejects any outgoing war relation', - destCityId: 70, - fixturePatches: { diplomacy: { '1:2': { state: 0 } } }, - outcome: 'fallback', - }, - { - name: 'rejects an outgoing self-war relation', - destCityId: 70, - fixturePatches: { diplomacy: { '1:1': { state: 0 } } }, - outcome: 'fallback', - }, - { - name: 'starts at term one without side effects', - destCityId: 70, - outcome: 'intermediate', - }, - { - name: 'continues to term two without side effects', - destCityId: 70, - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '초토화', arg: { destCityID: 70 }, term: 1 }, - }, - coreTurnLastByOfficerLevel: { - 12: { command: '초토화', arg: { destCityId: 70 }, term: 1 }, - }, - }, - }, - }, - outcome: 'intermediate', - }, - { - name: 'restarts at term one after another command interrupted the stack', - destCityId: 70, - fixturePatches: { - nations: { - 1: { - turnLastByOfficerLevel: { - 12: { command: '필사즉생', arg: {}, term: 2 }, - }, - }, - }, - }, - outcome: 'intermediate', - }, - { - name: 'accepts a numeric string destination city ID', - destCityId: '70', - outcome: 'completed', - }, - { - name: 'truncates a fractional destination city ID', - destCityId: 70.9, - outcome: 'completed', - }, - { - name: 'accepts supply state two for both source and destination', - destCityId: 70, - fixturePatches: { - cities: { - 3: { supplyState: 2 }, - 70: { supplyState: 2 }, - }, - }, - outcome: 'completed', - }, - { - name: 'ignores a reverse-only war relation', - destCityId: 70, - fixturePatches: { diplomacy: { '2:1': { state: 0 } } }, - outcome: 'completed', - }, - { - name: 'uses ten-percent floors and raises low trust on a level-eight city', - destCityId: 70, - fixturePatches: { - cities: { - 70: { - level: 8, - population: 1_000, - populationMax: 100_000, - agriculture: 10, - agricultureMax: 10_000, - commerce: 20, - commerceMax: 20_000, - security: 30, - securityMax: 30_000, - defence: 40, - defenceMax: 40_000, - wall: 50, - wallMax: 50_000, - trust: 20, - conflict: { 2: 30 }, - }, - }, - }, - outcome: 'completed', - }, - { - name: 'applies high-value ratios, recovered resources, officer penalties, and city resets', - destCityId: 70, - fixturePatches: { - generals: { - 1: { experience: 1_235 }, - }, - cities: { - 23: { nationId: 1, frontState: 1, conflict: { 2: 10 } }, - 70: { - level: 7, - population: 80_003, - populationMax: 100_000, - agriculture: 8_003, - agricultureMax: 10_000, - commerce: 16_003, - commerceMax: 20_000, - security: 24_003, - securityMax: 30_000, - defence: 32_003, - defenceMax: 40_000, - wall: 40_001, - wallMax: 50_000, - trust: 80, - conflict: { 2: 30 }, - }, - }, - }, - outcome: 'completed', - }, -]; - -const calcScorchedEarthReturnAmount = (city: Record): number => { - let amount = readNumericField(city, 'population') / 5; - for (const [currentKey, maxKey] of [ - ['agriculture', 'agricultureMax'], - ['commerce', 'commerceMax'], - ['security', 'securityMax'], - ] as const) { - const current = readNumericField(city, currentKey); - const max = readNumericField(city, maxKey); - amount *= (current - max * 0.5) / max + 0.8; - } - return Math.trunc(amount); -}; - -integration('nation scorched-earth constraints, multistep, city values, officers, and diplomacy boundaries', () => { - it.each(scorchedEarthBoundaryCases)( - '$name matches legacy progress, destruction, resources, officers, logs, RNG, and semantic delta', - async ({ destCityId, fixturePatches, outcome, coreResolution = true }) => { - const normalizedDestCityId = Math.trunc(Number(destCityId)); - const completed = outcome === 'completed'; - const fallback = outcome === 'fallback'; - const completionNationPatch = completed - ? { - turnLastByOfficerLevel: { - 12: { command: '초토화', arg: { destCityID: destCityId }, term: 2 }, - }, - coreTurnLastByOfficerLevel: { - 12: { command: '초토화', arg: { destCityId: normalizedDestCityId }, term: 2 }, - }, - } - : {}; - const request = buildRequest( - 'che_초토화', - { destCityID: destCityId }, - { - ...fixturePatches, - nations: { - ...fixturePatches?.nations, - 1: { - ...fixturePatches?.nations?.[1], - ...completionNationPatch, - }, - }, - cities: { - ...fixturePatches?.cities, - 70: { - nationId: 1, - ...fixturePatches?.cities?.[70], - }, - }, - generals: { - 3: { betray: 1, ...fixturePatches?.generals?.[3] }, - 4: { - nationId: 1, - cityId: 3, - officerLevel: 5, - officerCityId: 3, - experience: 1_235, - betray: 2, - ...fixturePatches?.generals?.[4], - }, - 5: { - nationId: 1, - cityId: 3, - officerLevel: 4, - officerCityId: 0, - experience: 1_235, - betray: 3, - ...fixturePatches?.generals?.[5], - }, - 6: { - nationId: 2, - cityId: 70, - officerLevel: 5, - officerCityId: 70, - experience: 1_235, - betray: 4, - ...fixturePatches?.generals?.[6], - }, - ...fixturePatches?.generals, - }, - } - ); - 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(); - } - - if (outcome === 'intermediate') { - const expectedTerm = - fixturePatches?.nations?.[1]?.turnLastByOfficerLevel && - (fixturePatches.nations[1].turnLastByOfficerLevel as Record)[12] - ?.command === '초토화' - ? 2 - : 1; - expect(reference.execution.outcome).toMatchObject({ - lastTurn: { - command: '초토화', - term: expectedTerm, - }, - }); - expect(readNationMeta(core.after.nations.find((entry) => entry.id === 1)).turn_last_12).toMatchObject({ - command: '초토화', - term: expectedTerm, - }); - } - - for (const snapshot of [reference, core]) { - const nationBefore = snapshot.before.nations.find((entry) => entry.id === 1); - const nationAfter = snapshot.after.nations.find((entry) => entry.id === 1); - const cityBefore = snapshot.before.cities.find((entry) => entry.id === normalizedDestCityId); - const cityAfter = snapshot.after.cities.find((entry) => entry.id === normalizedDestCityId); - const actorBefore = snapshot.before.generals.find((entry) => entry.id === 1); - const actorAfter = snapshot.after.generals.find((entry) => entry.id === 1); - - if (!completed) { - expect(readNationResource(nationAfter, 'gold')).toBe(readNationResource(nationBefore, 'gold')); - expect(readNationResource(nationAfter, 'rice')).toBe(readNationResource(nationBefore, 'rice')); - expect(readNumericField(actorAfter, 'experience')).toBe( - readNumericField(actorBefore, 'experience') - ); - expect(readNumericField(actorAfter, 'dedication')).toBe( - readNumericField(actorBefore, 'dedication') - ); - continue; - } - - const rewardAmount = calcScorchedEarthReturnAmount(cityBefore!); - expect(readNationResource(nationAfter, 'gold') - readNationResource(nationBefore, 'gold')).toBe( - rewardAmount - ); - expect(readNationResource(nationAfter, 'rice') - readNationResource(nationBefore, 'rice')).toBe( - rewardAmount - ); - expect( - readNumericField(nationAfter, 'diplomacyLimit') - readNumericField(nationBefore, 'diplomacyLimit') - ).toBe(24); - expect(readNumericField(actorAfter, 'experience')).toBe( - Math.round(readNumericField(actorBefore, 'experience') * 0.9 + 15) - ); - expect(readNumericField(actorAfter, 'dedication') - readNumericField(actorBefore, 'dedication')).toBe( - 15 - ); - expect(readNumericField(actorAfter, 'betray') - readNumericField(actorBefore, 'betray')).toBe(1); - - for (const generalId of [3, 4, 5, 6]) { - const before = snapshot.before.generals.find((entry) => entry.id === generalId); - const after = snapshot.after.generals.find((entry) => entry.id === generalId); - const friendly = [3, 4, 5].includes(generalId); - const officer = readNumericField(before, 'officerLevel') >= 5; - expect(readNumericField(after, 'experience')).toBe( - friendly && officer - ? Math.round(readNumericField(before, 'experience') * 0.9) - : readNumericField(before, 'experience') - ); - expect(readNumericField(after, 'betray') - readNumericField(before, 'betray')).toBe( - friendly ? 1 : 0 - ); - } - - expect(cityAfter).toMatchObject({ - nationId: 0, - frontState: 0, - trust: Math.max(50, readNumericField(cityBefore, 'trust')), - population: Math.max( - Math.round(readNumericField(cityBefore, 'populationMax') * 0.1), - Math.round(readNumericField(cityBefore, 'population') * 0.2) - ), - agriculture: Math.max( - Math.round(readNumericField(cityBefore, 'agricultureMax') * 0.1), - Math.round(readNumericField(cityBefore, 'agriculture') * 0.2) - ), - commerce: Math.max( - Math.round(readNumericField(cityBefore, 'commerceMax') * 0.1), - Math.round(readNumericField(cityBefore, 'commerce') * 0.2) - ), - security: Math.max( - Math.round(readNumericField(cityBefore, 'securityMax') * 0.1), - Math.round(readNumericField(cityBefore, 'security') * 0.2) - ), - defence: Math.max( - Math.round(readNumericField(cityBefore, 'defenceMax') * 0.1), - Math.round(readNumericField(cityBefore, 'defence') * 0.2) - ), - wall: Math.max( - Math.round(readNumericField(cityBefore, 'wallMax') * 0.1), - Math.round(readNumericField(cityBefore, 'wall') * 0.5) - ), - }); - expect(Object.keys((cityAfter?.conflict ?? {}) as object)).toHaveLength(0); - const auxBefore = readNationMeta(nationBefore); - const auxAfter = readNationMeta(nationAfter); - expect( - readNumericField(auxAfter, 'did_특성초토화') - readNumericField(auxBefore, 'did_특성초토화') - ).toBe(readNumericField(cityBefore, 'level') >= 8 ? 1 : 0); - expect(addedReferenceLogs(snapshot.before, snapshot.after.logs).map((entry) => entry.text)).toEqual( - expect.arrayContaining([ - expect.stringContaining('초토화했습니다'), - expect.stringContaining('초토화 명령'), - ]) - ); - } - - 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 - ); -});