test: 국가 사령턴 예약 수명주기 차등 검증을 강화
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user