test: 국가 사령턴 예약 수명주기 차등 검증을 강화

This commit is contained in:
2026-08-16 11:34:47 +00:00
parent 979e1c7bd4
commit 438a8da710
3 changed files with 121 additions and 11 deletions
@@ -64,6 +64,8 @@ export interface TurnCommandFixtureRequest {
cities?: Array<Record<string, unknown>>; cities?: Array<Record<string, unknown>>;
troops?: Array<Record<string, unknown>>; troops?: Array<Record<string, unknown>>;
diplomacy?: Array<Record<string, unknown>>; diplomacy?: Array<Record<string, unknown>>;
generalTurns?: Array<Record<string, unknown>>;
nationTurns?: Array<Record<string, unknown>>;
randomFoundingCandidateCityIds?: number[]; randomFoundingCandidateCityIds?: number[];
generalCooldowns?: Array<GeneralCooldownSelector & { nextAvailableTurn: number }>; generalCooldowns?: Array<GeneralCooldownSelector & { nextAvailableTurn: number }>;
}; };
@@ -741,18 +743,35 @@ const projectWorld = (
}; };
}; };
const emptyDatabaseClient = { const buildSnapshotReservedTurnDatabaseClient = (snapshot: CanonicalTurnSnapshot) => ({
generalTurn: { generalTurn: {
findMany: async () => [], findMany: async () =>
snapshot.generalTurns.map((turn, index) => ({
id: index + 1,
generalId: readNumber(turn, 'generalId'),
turnIdx: readNumber(turn, 'turnIndex'),
actionCode: readString(turn, 'action', '휴식'),
arg: asRecord(turn.args),
createdAt: new Date(0),
})),
deleteMany: async () => ({ count: 0 }), deleteMany: async () => ({ count: 0 }),
createMany: async () => ({ count: 0 }), createMany: async () => ({ count: 0 }),
}, },
nationTurn: { nationTurn: {
findMany: async () => [], findMany: async () =>
snapshot.nationTurns.map((turn, index) => ({
id: index + 1,
nationId: readNumber(turn, 'nationId'),
officerLevel: readNumber(turn, 'officerLevel'),
turnIdx: readNumber(turn, 'turnIndex'),
actionCode: readString(turn, 'action', '휴식'),
arg: asRecord(turn.args),
createdAt: new Date(0),
})),
deleteMany: async () => ({ count: 0 }), deleteMany: async () => ({ count: 0 }),
createMany: async () => ({ count: 0 }), createMany: async () => ({ count: 0 }),
}, },
}; });
export const runCoreTurnCommandTrace = async ( export const runCoreTurnCommandTrace = async (
request: TurnCommandFixtureRequest, request: TurnCommandFixtureRequest,
@@ -776,10 +795,13 @@ export const runCoreTurnCommandTrace = async (
generalCooldowns: request.observe?.generalCooldowns ?? [], generalCooldowns: request.observe?.generalCooldowns ?? [],
nationCooldowns: request.observe?.nationCooldowns ?? [], nationCooldowns: request.observe?.nationCooldowns ?? [],
}; };
const reservedTurns = new InMemoryReservedTurnStore(emptyDatabaseClient as never, { const reservedTurns = new InMemoryReservedTurnStore(
maxGeneralTurns: 30, buildSnapshotReservedTurnDatabaseClient(referenceBefore) as never,
maxNationTurns: 12, {
}); maxGeneralTurns: 30,
maxNationTurns: 12,
}
);
await reservedTurns.loadAll(); await reservedTurns.loadAll();
const actor = snapshot.generals.find((general) => general.id === request.actorGeneralId); const actor = snapshot.generals.find((general) => general.id === request.actorGeneralId);
if (!actor) { if (!actor) {
@@ -851,7 +873,9 @@ export const runCoreTurnCommandTrace = async (
}, },
}); });
world = new InMemoryTurnWorld(state, snapshot, { world = new InMemoryTurnWorld(state, snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, schedule: {
entries: [{ startMinute: 0, tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)) }],
},
generalTurnHandler: handler, generalTurnHandler: handler,
}); });
const before = projectWorld(world, reservedTurns, [], [], selector); const before = projectWorld(world, reservedTurns, [], [], selector);
@@ -212,6 +212,7 @@ integration('core ↔ legacy command-boundary differential', () => {
const afterActor = reference.after.generals.find((general) => general.id === actorGeneralId); const afterActor = reference.after.generals.find((general) => general.id === actorGeneralId);
const coreBeforeActor = core.before.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 coreAfterActor = core.after.generals.find((general) => general.id === actorGeneralId);
const expectedTurnMinutes = Number(reference.before.world.tickMinutes);
expect( expect(
reference.before.generalTurns.find( reference.before.generalTurns.find(
(turn) => turn.generalId === actorGeneralId && turn.turnIndex === 0 (turn) => turn.generalId === actorGeneralId && turn.turnIndex === 0
@@ -223,10 +224,10 @@ integration('core ↔ legacy command-boundary differential', () => {
)?.action )?.action
).toBe('휴식'); ).toBe('휴식');
expect(timestampMillis(afterActor?.turnTime) - timestampMillis(beforeActor?.turnTime)).toBe( expect(timestampMillis(afterActor?.turnTime) - timestampMillis(beforeActor?.turnTime)).toBe(
10 * 60_000 expectedTurnMinutes * 60_000
); );
expect(timestampMillis(coreAfterActor?.turnTime) - timestampMillis(coreBeforeActor?.turnTime)).toBe( expect(timestampMillis(coreAfterActor?.turnTime) - timestampMillis(coreBeforeActor?.turnTime)).toBe(
10 * 60_000 expectedTurnMinutes * 60_000
); );
expect(timestampMillis(coreAfterActor?.turnTime)).toBe(timestampMillis(afterActor?.turnTime)); expect(timestampMillis(coreAfterActor?.turnTime)).toBe(timestampMillis(afterActor?.turnTime));
expect(afterActor?.mySet).toBe(Math.min(9, Number(beforeActor?.mySet) + 3)); expect(afterActor?.mySet).toBe(Math.min(9, Number(beforeActor?.mySet) + 3));
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { NATION_TURN_COMMAND_KEYS } from '@sammo-ts/logic';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import { import {
@@ -27,6 +29,12 @@ const readNationMeta = (row: { meta?: unknown } | undefined): Record<string, unk
const readGeneralMeta = readNationMeta; const readGeneralMeta = readNationMeta;
const NPC_SEIZURE_MESSAGE_TEXT = '몰수를 하다니... 이것이 윗사람이 할 짓이란 말입니까...'; 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 normalizeStoredLogText = (value: unknown): string => const normalizeStoredLogText = (value: unknown): string =>
String(value) String(value)
.replace(/^(?:<C>●<\/>|<S>◆<\/>|<R>★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '') .replace(/^(?:<C>●<\/>|<S>◆<\/>|<R>★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '')
@@ -345,6 +353,15 @@ const buildRequest = (
}; };
}), }),
], ],
nationTurns: [
{
nationId: 1,
officerLevel: 12,
turnIndex: 1,
action: 'che_국호변경',
args: { nationName: '다음국' },
},
],
}, },
observe: { observe: {
generalIds: [ generalIds: [
@@ -628,11 +645,18 @@ const cases: NationMatrixCase[] = [
], ],
]; ];
describe('nation command differential coverage manifest', () => {
it('keeps one successful Ref/Core case for every registered nation turn command', () => {
expect(new Set(cases.map(([action]) => action))).toEqual(new Set(NATION_TURN_COMMAND_KEYS));
});
});
integration('nation command success matrix', () => { integration('nation command success matrix', () => {
it.each(cases)( it.each(cases)(
'%s matches the legacy state delta and command RNG', '%s matches the legacy state delta and command RNG',
async (action, args, fixturePatches) => { async (action, args, fixturePatches) => {
const request = buildRequest(action, args, fixturePatches); const request = buildRequest(action, args, fixturePatches);
request.includeLifecycle = true;
const reference = runReferenceTurnCommandTraceRequest( const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!, workspaceRoot!,
request as unknown as Record<string, unknown> request as unknown as Record<string, unknown>
@@ -667,6 +691,42 @@ integration('nation command success matrix', () => {
expect(reference.execution.outcome).toMatchObject({ completed: true }); expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.execution.outcome).not.toHaveProperty('blockedReason'); expect(core.execution.outcome).not.toHaveProperty('blockedReason');
expect(core.rng).toEqual(reference.rng); 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<Record<string, unknown>>, 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( expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, { compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths, ignoredPathPatterns: ignoredLifecyclePaths,
@@ -702,6 +762,31 @@ integration('nation command success matrix', () => {
); );
}); });
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<string, unknown>
);
const withLifecycle = runReferenceTurnCommandTraceRequest(workspaceRoot!, {
...request,
includeLifecycle: true,
} as unknown as Record<string, unknown>);
expect(withLifecycle.rng).toEqual(commandOnly.rng);
expect(
compareTurnSnapshotDeltas(
commandOnly.before,
commandOnly.after,
withLifecycle.before,
withLifecycle.after,
{ ignoredPathPatterns: ignoredLifecyclePaths }
)
).toEqual([]);
}, 180_000);
});
type NationActiveActionInheritanceCase = { type NationActiveActionInheritanceCase = {
name: string; name: string;
request: NationMatrixCase; request: NationMatrixCase;