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
@@ -4,6 +4,15 @@ import { NATION_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,
@@ -35,23 +44,7 @@ const timestampMillis = (value: unknown): number => {
return new Date(normalized).getTime();
};
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
.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 semanticLogSignatures = (logs: Array<Record<string, unknown>>): string[] => orderedSemanticLogStreams(logs);
const nationCommandLogs = (logs: Array<Record<string, unknown>>): Array<Record<string, unknown>> =>
logs.filter((entry) => normalizeStoredLogText(entry.text) !== '아무것도 실행하지 않았습니다.');
@@ -62,7 +55,9 @@ const ignoredLifecyclePaths = [
/^logs/,
/^messages/,
/^world\.turnTime$/,
/^world\.gameNow$/,
/^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|killTurn|mySet)(?:\.|$)/,
/^generals\[1\]\.(?:turnTick|turnSecond|turnFraction)$/,
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
/^nations\[[^\]]+\]\.meta\.(?:turn_last_\d+|next_execute_.+|capset|tech|gennum|war|surlimit|strategic_cmd_limit)(?:\.|$)/,
];
@@ -211,6 +206,7 @@ const buildRequest = (
year: 190,
month: 1,
hiddenSeed: 'turn-command-nation-matrix-v1',
freezeClock: true,
...fixturePatches.world,
},
nations: [
@@ -364,6 +360,10 @@ const buildRequest = (
],
},
observe: {
allGenerals: true,
allCities: true,
allNations: true,
allTroops: true,
generalIds: [
1,
2,
@@ -430,6 +430,8 @@ const buildRequest = (
}
: {}),
logAfterId: 0,
includeNationHistoryLogs: true,
includeGlobalHistoryLogs: true,
messageAfterId: 0,
},
});
@@ -647,7 +649,10 @@ 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));
const matrixActions = cases.map(([action]) => action);
expect(new Set(matrixActions).size).toBe(matrixActions.length);
expect([...matrixActions].sort()).toEqual([...NATION_TURN_COMMAND_KEYS].sort());
});
});
@@ -732,6 +737,30 @@ integration('nation command success matrix', () => {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
// Ref persists logs in two independent ID streams and messages in
// per-mailbox rows, so they are excluded from the state comparator.
// Compare those observable graphs for every registered command.
expect(semanticLogSignatures(nationCommandLogs(addedReferenceLogs(core.before, core.after.logs)))).toEqual(
semanticLogSignatures(nationCommandLogs(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);
const researchConfig = researchConfigs[action];
if (researchConfig) {
for (const snapshot of [reference, core]) {
@@ -1291,6 +1320,24 @@ integration('nation diplomacy proposal boundary and message parity', () => {
})
).toEqual([]);
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);
const referenceMessages = reference.after.messages.slice(reference.before.messages.length);
if (!completed) {
expect(referenceMessages).toEqual([]);
@@ -1319,10 +1366,12 @@ integration('nation diplomacy proposal boundary and message parity', () => {
option: expected.option,
},
});
expect(core.after.messages).toHaveLength(1);
expect(core.after.messages[0]).toMatchObject({
expect(core.after.messages).toHaveLength(2);
expect(core.after.messages.find((entry) => entry.mailbox === 9002)).toMatchObject({
type: expected.type,
sourceId: 9001,
destinationId: 9002,
payload: {
msgType: expected.type,
src: { nationId: 1, nationName: sourceNation?.name },
dest: { nationId: 2, nationName: destinationNation?.name },
text: expected.text,
@@ -2819,7 +2868,14 @@ integration('nation seizure NPC public message parity', () => {
{ isGold: true, amount: 100, destGeneralID: 3 },
{
world: { hiddenSeed: 'seizure-message-37' },
generals: { 3: { name: '몰수NPC', npcState: 2 } },
generals: {
3: {
name: '몰수NPC',
npcState: 2,
picture: 'npc/custom.png',
imageServer: 0,
},
},
}
);
const reference = runReferenceTurnCommandTraceRequest(
@@ -2835,22 +2891,62 @@ integration('nation seizure NPC public message parity', () => {
const referenceMessages = reference.after.messages.slice(reference.before.messages.length);
expect(referenceMessages).toHaveLength(1);
expect(core.after.messages).toHaveLength(1);
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);
expect(referenceMessages[0]).toMatchObject({
mailbox: 9999,
type: 'public',
sourceId: 3,
destinationId: 9999,
payload: {
src: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' },
dest: { id: 3, name: '몰수NPC', nation_id: 1, nation: '아국' },
src: {
id: 3,
name: '몰수NPC',
nation_id: 1,
nation: '아국',
icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png',
},
dest: {
id: 3,
name: '몰수NPC',
nation_id: 1,
nation: '아국',
icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png',
},
text: NPC_SEIZURE_MESSAGE_TEXT,
},
});
expect(core.after.messages[0]).toMatchObject({
mailbox: 9999,
type: 'public',
sourceId: 3,
destinationId: 9999,
payload: {
msgType: 'public',
src: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' },
dest: { generalId: 3, generalName: '몰수NPC', nationId: 1, nationName: '아국' },
src: {
generalId: 3,
generalName: '몰수NPC',
nationId: 1,
nationName: '아국',
icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png',
},
dest: {
generalId: 3,
generalName: '몰수NPC',
nationId: 1,
nationName: '아국',
icon: 'https://dev-sam-ref.hided.net/image/icons/npc/custom.png',
},
text: NPC_SEIZURE_MESSAGE_TEXT,
},
});
@@ -2919,13 +3015,27 @@ integration('nation seizure zero target balance parity', () => {
expect(core.rng).toEqual(reference.rng);
expect(referenceMessages).toHaveLength(1);
expect(core.after.messages).toHaveLength(1);
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);
expect(referenceMessages[0]).toMatchObject({
type: 'public',
sourceId: 3,
payload: { text: NPC_SEIZURE_MESSAGE_TEXT },
});
expect(core.after.messages[0]).toMatchObject({
payload: { msgType: 'public', text: NPC_SEIZURE_MESSAGE_TEXT },
type: 'public',
payload: { text: NPC_SEIZURE_MESSAGE_TEXT },
});
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {