fix: 커맨드 차등 생명주기와 로그 그래프를 보강

장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다.

실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
2026-08-23 21:48:29 +00:00
parent 85591c68ad
commit c63a49bd07
128 changed files with 8615 additions and 848 deletions
@@ -1,8 +1,18 @@
import { describe, expect, it } from 'vitest';
import { asRecord, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
import { asRecord, GAME_TICKS_PER_TURN, LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
import { GENERAL_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,
@@ -12,24 +22,8 @@ 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(/^(?:<C>●<\/>|<S>◆<\/>|<R>★<\/>)(?:(?:\d+년 )?\d+월:|\d+년:)?/, '')
.replace(/ ?<1>\d{2}:\d{2}<\/>$/, '');
const semanticLogSignatures = (logs: Array<Record<string, unknown>>): 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();
orderedSemanticLogStreams(logs, { omitRest: true });
const addedReferenceLogs = (
before: { watermarks: { logId: number; historyLogId: number } },
@@ -51,6 +45,20 @@ const ignoredLifecyclePaths = [
/^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(?:\.|$)/,
@@ -129,6 +137,7 @@ const buildRequest = (
year: 190,
month: 1,
hiddenSeed: 'turn-command-general-matrix-v1',
freezeClock: true,
...fixturePatches.world,
},
nations: [
@@ -230,6 +239,10 @@ const buildRequest = (
],
},
observe: {
allGenerals: true,
allCities: true,
allNations: true,
allTroops: true,
generalIds: [1, 2, 3],
cityIds: [
3,
@@ -238,6 +251,8 @@ const buildRequest = (
],
nationIds: [1, 2],
logAfterId: 0,
includeNationHistoryLogs: true,
includeGlobalHistoryLogs: true,
messageAfterId: 0,
},
});
@@ -286,6 +301,58 @@ const cases: Array<
],
['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 } }],
[
@@ -372,6 +439,7 @@ integration('general command success matrix', () => {
'%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<string, unknown>
@@ -390,14 +458,14 @@ integration('general command success matrix', () => {
reference.after,
reference.before,
reference.before,
{ ignoredPathPatterns: ignoredLifecyclePaths }
{ ignoredPathPatterns: successfulLifecycleIgnoredPaths }
).filter((entry) => entry.path.startsWith('generals')),
coreGeneralDelta: compareTurnSnapshotDeltas(
core.before,
core.after,
core.before,
core.before,
{ ignoredPathPatterns: ignoredLifecyclePaths }
{ ignoredPathPatterns: successfulLifecycleIgnoredPaths }
).filter((entry) => entry.path.startsWith('generals')),
referenceGenerals: reference.after.generals,
coreGenerals: core.after.generals,
@@ -416,31 +484,93 @@ integration('general command success matrix', () => {
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<Record<string, unknown>>, 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();
}
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
ignoredPathPatterns: successfulLifecycleIgnoredPaths,
})
).toEqual([]);
// 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(
semanticLogSignatures(
core.after.logs.filter((entry) => String(entry.text).includes(actionLogSuffix))
)
).toEqual(
semanticLogSignatures(
addedReferenceLogs(reference.before, reference.after.logs).filter((entry) =>
String(entry.text).includes(actionLogSuffix)
)
)
);
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<string, unknown>)
).toThrow(/setup\.world\.freezeClock must be a boolean/);
});
});
type GeneralActiveActionInheritanceCase = {
name: string;
action: string;
@@ -4230,3 +4360,12 @@ integration('general command full-constraint fallback matrix', () => {
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());
});
});