import { describe, expect, it } from 'vitest'; import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.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 ignoredLifecyclePaths = [ /^generalTurns/, /^nationTurns/, /^logs/, /^messages/, /^world\.turnTime$/, /^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/, /^generals\[[^\]]+\]\.meta(?:\.|$)/, /^nations\[[^\]]+\]\.meta(?:\.|$)/, ]; 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, 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[]; } 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, hiddenSeed: 'turn-command-general-matrix-v1', ...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], }, ], 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.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: { generalIds: [1, 2, 3], cityIds: [3, 70], nationIds: [1, 2], logAfterId: 0, 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_하야', 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); 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: ignoredLifecyclePaths } ).filter((entry) => entry.path.startsWith('generals')), coreGeneralDelta: compareTurnSnapshotDeltas( core.before, core.after, core.before, core.before, { ignoredPathPatterns: ignoredLifecyclePaths } ).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); 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); expect( compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { ignoredPathPatterns: ignoredLifecyclePaths, }) ).toEqual([]); }, 120_000 ); }); type SabotageProbabilityClampCase = { name: string; action: 'che_화계' | 'che_선동' | 'che_파괴' | 'che_탈취'; stat: 'leadership' | 'strength' | 'intelligence'; boundary: 'zero' | 'max'; }; 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); expect( compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { ignoredPathPatterns: ignoredLifecyclePaths, }) ).toEqual([]); }, 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); expect( compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { ignoredPathPatterns: ignoredLifecyclePaths, }) ).toEqual([]); }, 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 ); }); 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 }, }, ]; 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([]); }, 120_000 ); });