diff --git a/packages/logic/src/actions/turn/general/che_견문.ts b/packages/logic/src/actions/turn/general/che_견문.ts index bb542d9..b82b7de 100644 --- a/packages/logic/src/actions/turn/general/che_견문.ts +++ b/packages/logic/src/actions/turn/general/che_견문.ts @@ -30,9 +30,7 @@ type InclusiveRandomGenerator = GeneralActionResolveContext['rng'] & { const legacyChoiceIndex = (rng: GeneralActionResolveContext['rng'], length: number): number => { const inclusive = rng as InclusiveRandomGenerator; - return inclusive.nextIntInclusive - ? inclusive.nextIntInclusive(length - 1) - : rng.nextInt(0, length); + return inclusive.nextIntInclusive ? inclusive.nextIntInclusive(length - 1) : rng.nextInt(0, length); }; const SIGHTSEEING_MESSAGES: Array<{ @@ -71,22 +69,25 @@ const SIGHTSEEING_MESSAGES: Array<{ }, { flags: IncExp | IncGold, - texts: ['지나가는 행인에게서 금을 :goldAmount: 받았습니다.'], + texts: ['지나가는 행인에게서 금을 :goldAmount: 받았습니다.'], weight: 1, }, { flags: IncExp | IncRice, - texts: ['지나가는 행인에게서 쌀을 :riceAmount: 받았습니다.'], + texts: ['지나가는 행인에게서 쌀을 :riceAmount: 받았습니다.'], weight: 1, }, { flags: IncExp | DecGold, - texts: ['산적을 만나 금 :goldAmount:을 빼앗겼습니다.', '돈을 :goldAmount: 빌려주었다가 떼어먹혔습니다.'], + texts: [ + '산적을 만나 금 :goldAmount:을 빼앗겼습니다.', + '돈을 :goldAmount: 빌려주었다가 떼어먹혔습니다.', + ], weight: 1, }, { flags: IncExp | DecRice, - texts: ['쌀을 :riceAmount: 빌려주었다가 떼어먹혔습니다.'], + texts: ['쌀을 :riceAmount: 빌려주었다가 떼어먹혔습니다.'], weight: 1, }, { @@ -111,22 +112,25 @@ const SIGHTSEEING_MESSAGES: Array<{ }, { flags: IncHeavyExp | IncStrength | IncGold, - texts: ['산적과 싸워 금 :goldAmount:을 빼앗았습니다.'], + texts: ['산적과 싸워 금 :goldAmount:을 빼앗았습니다.'], weight: 1, }, { flags: IncHeavyExp | IncStrength | IncRice, - texts: ['호랑이를 잡아 고기 :riceAmount:을 얻었습니다.', '곰을 잡아 고기 :riceAmount:을 얻었습니다.'], + texts: [ + '호랑이를 잡아 고기 :riceAmount:을 얻었습니다.', + '곰을 잡아 고기 :riceAmount:을 얻었습니다.', + ], weight: 1, }, { flags: IncHeavyExp | IncIntel | IncGold, - texts: ['돈을 빌려주었다가 이자 :goldAmount:을 받았습니다.'], + texts: ['돈을 빌려주었다가 이자 :goldAmount:을 받았습니다.'], weight: 1, }, { flags: IncHeavyExp | IncIntel | IncRice, - texts: ['쌀을 빌려주었다가 이자 :riceAmount:을 받았습니다.'], + texts: ['쌀을 빌려주었다가 이자 :riceAmount:을 받았습니다.'], weight: 1, }, ]; diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 416a43d..812f3d6 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -12,6 +12,39 @@ 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 normalizeStoredLogText = (value: unknown): string => + String(value) + .replace(/^(?:●<\/>|◆<\/>|★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '') + .replace(/ ?<1>\d{2}:\d{2}<\/>$/, ''); + +const semanticLogSignatures = (logs: Array>): string[] => + logs + .filter((entry) => normalizeStoredLogText(entry.text) !== '아무것도 실행하지 않았습니다.') + .map((entry) => + JSON.stringify({ + scope: String(entry.scope).toLowerCase(), + category: String(entry.category).toLowerCase(), + generalId: Number(entry.generalId) || null, + nationId: Number(entry.nationId) || null, + text: normalizeStoredLogText(entry.text), + }) + ) + .sort(); + +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/, @@ -385,6 +418,225 @@ integration('general command success matrix', () => { ); }); +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 + ); +}); + integration('명장일람 rank_data command parity', () => { it('화계 increments firenum from the same seeded value as legacy', async () => { const request = buildRequest(