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
@@ -0,0 +1,252 @@
import { describe, expect, it } from 'vitest';
import type { MapDefinition, UnitSetDefinition } from '@sammo-ts/logic';
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
import type { CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js';
import {
buildCoreTurnCommandWorldInput,
runCoreTurnCommandTrace,
type TurnCommandFixtureRequest,
} from '../src/turn-differential/coreCommandTrace.js';
const cityStats = {
population: 1_000,
agriculture: 100,
commerce: 100,
security: 100,
defence: 100,
wall: 100,
};
const map: MapDefinition = {
id: 'clock-projection-test',
name: 'clock projection test',
cities: [
{
id: 1,
name: '테스트시',
level: 5,
region: 1,
position: { x: 0, y: 0 },
connections: [],
initial: cityStats,
max: cityStats,
},
],
};
const unitSet: UnitSetDefinition = {
id: 'clock-projection-test',
name: 'clock projection test',
defaultCrewTypeId: 1100,
crewTypes: [],
};
const referenceBefore: CanonicalTurnSnapshot = {
schemaVersion: 1,
engine: 'ref',
world: {
year: 185,
month: 1,
tickMinutes: 60,
lastTurnTick: 24_229_750_000,
// Ref snapshots use MySQL's timezone-less microsecond representation.
turnTime: '2026-08-22 14:02:55.000000',
gameNow: '2026-08-22 14:02:55.000000',
},
generals: [
{
id: 1,
name: '장수',
nationId: 0,
cityId: 1,
troopId: 0,
officerLevel: 0,
turnTick: 24_245_250_000,
turnTime: '2026-08-22 14:28:45.000000',
},
],
rankData: [],
cities: [{ id: 1, name: '테스트시', nationId: 0, level: 5 }],
nations: [],
troops: [],
diplomacy: [],
generalTurns: [],
nationTurns: [],
logs: [],
messages: [],
watermarks: { logId: 0, historyLogId: 0, messageId: 0 },
};
describe('turn command fixture GameClock projection', () => {
it('preserves Ref absolute dates when materializing persisted ticks', () => {
const request: TurnCommandFixtureRequest = {
kind: 'general',
actorGeneralId: 1,
action: '휴식',
};
const input = buildCoreTurnCommandWorldInput(request, referenceBefore, unitSet, map);
const world = new InMemoryTurnWorld(input.state, input.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 60 }] },
});
expect(world.getState().lastTurnTime.toISOString()).toBe('2026-08-22T14:02:55.000Z');
expect(world.getGeneralById(1)?.turnTime.toISOString()).toBe('2026-08-22T14:28:45.000Z');
});
it('injects Ref gameNow instead of the later actor turn time into command messages', async () => {
const commandSnapshot: CanonicalTurnSnapshot = {
...referenceBefore,
world: {
...referenceBefore.world,
initYear: 180,
initMonth: 1,
develCost: 100,
gameNow: '2026-08-22 14:02:55.123456',
},
generals: [
{
...referenceBefore.generals[0],
nationId: 1,
cityId: 3,
officerLevel: 12,
gold: 100_000,
rice: 100_000,
},
{
...referenceBefore.generals[0],
id: 2,
name: '수신자',
nationId: 2,
cityId: 70,
officerLevel: 1,
},
],
cities: [
{
id: 3,
name: '아국도시',
nationId: 1,
level: 5,
population: 100_000,
populationMax: 200_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 0,
state: 0,
trust: 80,
trade: 100,
},
{
id: 70,
name: '타국도시',
nationId: 2,
level: 5,
population: 100_000,
populationMax: 200_000,
agriculture: 1_000,
commerce: 1_000,
security: 1_000,
defence: 1_000,
wall: 1_000,
supplyState: 1,
frontState: 0,
state: 0,
trust: 80,
trade: 100,
},
],
nations: [
{
id: 1,
name: '아국',
color: '#111111',
capitalCityId: 3,
gold: 1_000_000,
rice: 1_000_000,
power: 1_000,
level: 1,
typeCode: 'che_중립',
},
{
id: 2,
name: '타국',
color: '#222222',
capitalCityId: 70,
gold: 1_000_000,
rice: 1_000_000,
power: 1_000,
level: 1,
typeCode: 'che_중립',
},
],
};
const request: TurnCommandFixtureRequest = {
kind: 'general',
actorGeneralId: 1,
action: 'che_등용',
args: { destGeneralID: 2 },
setup: {
isolateWorld: true,
world: { startYear: 180, year: 185, month: 1, freezeClock: true },
},
observe: { generalIds: [1, 2], cityIds: [3, 70], nationIds: [1, 2], messageAfterId: 0 },
};
const trace = await runCoreTurnCommandTrace(request, commandSnapshot);
expect(trace.after.world.gameNow).toBe('2026-08-22 14:02:55.123456');
expect(trace.after.messages.map((message) => message.createdAt)).toEqual(['2026-08-22T14:02:55.123Z']);
});
it('keeps a stale nation gennum visible instead of masking an update omission with live membership', async () => {
const snapshot: CanonicalTurnSnapshot = {
...referenceBefore,
generals: Array.from({ length: 4 }, (_, index) => ({
...referenceBefore.generals[0],
id: index + 1,
name: `장수${index + 1}`,
nationId: 1,
cityId: 1,
officerLevel: index === 0 ? 12 : 1,
gold: 1_000,
rice: 1_000,
})),
cities: [{ ...referenceBefore.cities[0], nationId: 1 }],
nations: [
{
id: 1,
name: '위',
color: '#111111',
capitalCityId: 1,
gold: 1_000,
rice: 1_000,
power: 1_000,
level: 1,
typeCode: 'che_중립',
// Simulate a command that created three members but omitted
// the denormalized nation counter update.
generalCount: 1,
},
],
};
const trace = await runCoreTurnCommandTrace(
{
kind: 'general',
actorGeneralId: 1,
action: '휴식',
observe: { allGenerals: true, allNations: true },
},
snapshot
);
expect(trace.before.generals.filter((general) => general.nationId === 1)).toHaveLength(4);
expect(trace.before.nations).toEqual([expect.objectContaining({ id: 1, generalCount: 1 })]);
expect(trace.after.nations).toEqual([expect.objectContaining({ id: 1, generalCount: 1 })]);
});
});
@@ -0,0 +1,559 @@
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { ChangeJournal } from '@sammo-ts/common';
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
import type { GameApiContext, GeneralRow } from '../../../app/game-api/src/context.js';
import { appRouter } from '../../../app/game-api/src/router.js';
import {
findTurnDifferentialWorkspaceRoot,
runReferenceTurnCommandTraceRequest,
} from '../src/turn-differential/referenceSnapshot.js';
type DiplomacyAction = 'noAggression' | 'cancelNA' | 'stopWar';
interface ReferenceExecution {
entryPoint: string;
action: DiplomacyAction;
outcome: { result: boolean; reason: string };
proposalMessageId: number;
proposalBefore: { validUntilTick: number; payload: unknown };
proposalAfter: { validUntilTick: number; payload: unknown };
}
interface ReferenceTrace {
execution: ReferenceExecution;
before: {
watermarks: { logId: number; messageId: number };
};
after: {
diplomacy: Array<{ fromNationId: number; toNationId: number; state: number; term: number }>;
cities: Array<{ id: number; frontState: number }>;
nations: Array<{ id: number; meta: unknown }>;
logs: Array<{
id: number;
generalId: number | null;
scope: string;
category: string;
text: string;
}>;
messages: Array<{
id: number;
mailbox: number;
type: string;
sourceId: number;
destinationId: number;
payload: unknown;
}>;
};
}
interface CoreMessageRow {
id: number;
mailbox: number;
type: 'national' | 'diplomacy';
src: number;
dest: number;
time: Date;
valid_until: Date;
message: Record<string, unknown>;
}
interface CoreLogRow {
scope: string;
category: string;
generalId?: number | null;
nationId?: number | null;
text: string;
}
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 actionCases = [
{ action: 'noAggression' as const, state: 2, reverseState: 2, term: 0 },
{ action: 'cancelNA' as const, state: 7, reverseState: 7, term: 12 },
{ action: 'stopWar' as const, state: 0, reverseState: 1, term: 6 },
];
const target = (id: number, name: string, nationId: number, nationName: string) => ({
generalId: id,
generalName: name,
nationId,
nationName,
color: '#777777',
icon: '/image/icons/default.jpg',
});
const referenceSetup = (testCase: (typeof actionCases)[number]) => ({
isolateWorld: true,
world: { year: 190, month: 3 },
nations: [
{ id: 1, name: '수락국', capitalCityId: 1 },
{
id: 2,
name: '제안국',
capitalCityId: 2,
...(testCase.action === 'noAggression' ? { nationEnv: { recv_assist: { n1: [1, 37] } } } : {}),
},
],
cities: [
{ id: 1, nationId: 1, supplyState: 1, frontState: 1 },
{ id: 2, nationId: 2, supplyState: 1, frontState: 1 },
],
generals: [
{
id: 1,
name: '수락장수',
nationId: 1,
cityId: 1,
officerLevel: 12,
permission: 'normal',
penalty: {},
},
{
id: 2,
name: '제안장수',
nationId: 2,
cityId: 2,
officerLevel: 12,
permission: 'normal',
penalty: {},
},
],
diplomacy: [
{ fromNationId: 1, toNationId: 2, state: testCase.state, term: testCase.term },
{ fromNationId: 2, toNationId: 1, state: testCase.reverseState, term: testCase.term },
],
});
const runReference = (testCase: (typeof actionCases)[number]): ReferenceTrace => {
const sourceRoot = process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot!, 'ref/sam');
const runner = path.join(sourceRoot, 'hwe/compare/instant_diplomacy_response_trace.php');
const previousRunner = process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT;
process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT = runner;
try {
return runReferenceTurnCommandTraceRequest(workspaceRoot!, {
actorGeneralId: 1,
proposerGeneralId: 2,
action: testCase.action,
response: true,
...(testCase.action === 'noAggression' ? { year: 191, month: 2 } : {}),
setup: referenceSetup(testCase),
observe: {
generalIds: [1, 2],
nationIds: [1, 2],
cityIds: [1, 2],
},
}) as unknown as ReferenceTrace;
} finally {
if (previousRunner === undefined) {
delete process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT;
} else {
process.env.TURN_DIFFERENTIAL_RUNNER_SCRIPT = previousRunner;
}
}
};
const buildCoreCaller = (testCase: (typeof actionCases)[number]) => {
const actor = {
id: 1,
userId: 'user-1',
name: '수락장수',
nationId: 1,
cityId: 1,
officerLevel: 12,
npcState: 0,
meta: {},
penalty: {},
} as GeneralRow;
const proposer = {
...actor,
id: 2,
userId: 'user-2',
name: '제안장수',
nationId: 2,
cityId: 2,
} as GeneralRow;
const nations = [
{
id: 1,
name: '수락국',
color: '#777777',
capitalCityId: 1,
chiefGeneralId: 1,
gold: 0,
rice: 0,
level: 1,
typeCode: 'che_중립',
meta: {},
},
{
id: 2,
name: '제안국',
color: '#777777',
capitalCityId: 2,
chiefGeneralId: 2,
gold: 0,
rice: 0,
level: 1,
typeCode: 'che_중립',
meta: testCase.action === 'noAggression' ? { recv_assist: { n1: [1, 37] } } : {},
},
];
const cities = [
{ id: 1, nationId: 1, supplyState: 1, frontState: 1 },
{ id: 2, nationId: 2, supplyState: 1, frontState: 1 },
// Ref isolateWorld keeps the remaining map cities as neutral. These two
// adjacent neutral cities are sufficient to exercise SetNationFront's
// peace-time `front = 2` branch for cities 1 and 2.
{ id: 9, nationId: 0, supplyState: 0, frontState: 0 },
{ id: 10, nationId: 0, supplyState: 0, frontState: 0 },
];
const diplomacy = [
{
id: 1,
srcNationId: 1,
destNationId: 2,
stateCode: testCase.state,
term: testCase.term,
},
{
id: 2,
srcNationId: 2,
destNationId: 1,
stateCode: testCase.reverseState,
term: testCase.term,
},
];
const proposalPayload = {
src: target(2, '제안장수', 2, '제안국'),
dest: target(1, '수락장수', 1, '수락국'),
text: '외교 제안',
option: {
action: testCase.action,
...(testCase.action === 'noAggression' ? { year: 191, month: 2 } : {}),
},
};
const messages: CoreMessageRow[] = [
{
id: 1,
mailbox: 9001,
type: 'diplomacy',
src: 9002,
dest: 9001,
time: new Date('2026-08-23T00:00:00Z'),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: proposalPayload,
},
{
id: 2,
mailbox: 9002,
type: 'diplomacy',
src: 9002,
dest: 9001,
time: new Date('2026-08-23T00:00:00Z'),
valid_until: new Date('9999-12-31T00:00:00Z'),
message: {
...proposalPayload,
option: null,
},
},
];
const logs: CoreLogRow[] = [];
const proposalBefore = structuredClone(messages[0]!);
const findGeneral = (id: number) => (id === actor.id ? actor : id === proposer.id ? proposer : null);
const queryRaw = vi.fn(async (strings: TemplateStringsArray, ...values: unknown[]) => {
const sql = strings.join('?');
if (sql.includes('FROM message') && sql.includes('WHERE id =')) {
const id = Number(values[0]);
const row = messages.find((message) => message.id === id);
return row && row.valid_until.getTime() > Date.now() ? [row] : [];
}
if (sql.includes('INSERT INTO message')) {
const payload = JSON.parse(String(values[8])) as Record<string, unknown>;
const row: CoreMessageRow = {
id: messages.at(-1)!.id + 1,
mailbox: Number(values[0]),
type: values[1] as CoreMessageRow['type'],
src: Number(values[2]),
dest: Number(values[3]),
time: values[4] as Date,
valid_until: values[6] as Date,
message: payload,
};
messages.push(row);
return [{ id: row.id }];
}
return [];
});
const db = {
general: {
findUnique: vi.fn(async ({ where }: { where: { id: number } }) => findGeneral(where.id)),
findMany: vi.fn(async () => []),
},
nation: {
findUnique: vi.fn(
async ({ where }: { where: { id: number } }) => nations.find((nation) => nation.id === where.id) ?? null
),
findMany: vi.fn(async () => nations),
update: vi.fn(async ({ where, data }: { where: { id: number }; data: { meta?: unknown } }) => {
const nation = nations.find((entry) => entry.id === where.id);
if (nation && data.meta !== undefined) nation.meta = data.meta as typeof nation.meta;
return nation;
}),
},
city: {
findUnique: vi.fn(
async ({ where }: { where: { id: number } }) => cities.find((city) => city.id === where.id) ?? null
),
findMany: vi.fn(async () => cities),
update: vi.fn(async ({ where, data }: { where: { id: number }; data: { frontState: number } }) => {
const city = cities.find((entry) => entry.id === where.id);
if (city) city.frontState = data.frontState;
return city;
}),
},
diplomacy: {
findUnique: vi.fn(
async ({
where,
}: {
where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } };
}) =>
diplomacy.find(
(entry) =>
entry.srcNationId === where.srcNationId_destNationId.srcNationId &&
entry.destNationId === where.srcNationId_destNationId.destNationId
) ?? null
),
findMany: vi.fn(async () => diplomacy),
update: vi.fn(
async ({
where,
data,
}: {
where: { srcNationId_destNationId: { srcNationId: number; destNationId: number } };
data: { stateCode?: number; term?: number };
}) => {
const entry = diplomacy.find(
(row) =>
row.srcNationId === where.srcNationId_destNationId.srcNationId &&
row.destNationId === where.srcNationId_destNationId.destNationId
);
if (entry) {
if (data.stateCode !== undefined) entry.stateCode = data.stateCode;
if (data.term !== undefined) entry.term = data.term;
}
return entry;
}
),
},
worldState: {
findFirst: vi.fn(async () => ({
currentYear: 190,
currentMonth: 3,
config: { environment: { mapName: 'che' } },
clockBaseTime: null,
clockTick: null,
clockMode: null,
clockWallAnchor: null,
tickSeconds: 60,
})),
},
logEntry: {
createMany: vi.fn(async ({ data }: { data: CoreLogRow[] }) => {
logs.push(...data);
return { count: data.length };
}),
},
message: {
updateMany: vi.fn(
async ({ where, data }: { where: { id: { in: number[] } }; data: { validUntil: Date } }) => {
for (const row of messages) {
if (where.id.in.includes(row.id)) row.valid_until = data.validUntil;
}
return { count: where.id.in.length };
}
),
},
$queryRaw: queryRaw,
};
const auth: GameSessionTokenPayload = {
version: 1,
profile: 'che:default',
issuedAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2027-01-01T00:00:00.000Z',
sessionId: 'session-1',
user: {
id: actor.userId!,
username: 'tester',
displayName: 'Tester',
roles: ['user'],
},
sanctions: {},
};
const context = {
db,
auth,
profile: { id: 'che', scenario: 'default', name: 'che:default' },
redis: {},
turnDaemon: {},
battleSim: {},
uploadDir: 'uploads',
uploadPath: '/uploads',
uploadPublicUrl: null,
accessTokenStore: {},
flushStore: {},
gameTokenSecret: 'test-secret',
changeJournal: new ChangeJournal(),
} as unknown as GameApiContext;
return {
caller: appRouter.createCaller(context),
actor,
nations,
cities,
diplomacy,
logs,
messages,
proposalBefore,
};
};
const normalizeTarget = (value: unknown) => {
const targetValue = (value ?? {}) as Record<string, unknown>;
return {
generalId: Number(targetValue.generalId ?? targetValue.id),
generalName: String(targetValue.generalName ?? targetValue.name),
nationId: Number(targetValue.nationId ?? targetValue.nation_id),
nationName: String(targetValue.nationName ?? targetValue.nation),
};
};
const normalizeOption = (value: unknown, proposalId: number) => {
const option =
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
return {
...(option.delete === undefined ? {} : { delete: Number(option.delete) === proposalId ? 'proposal' : 'other' }),
...(option.silence === undefined ? {} : { silence: option.silence }),
...(option.deletable === undefined ? {} : { deletable: option.deletable }),
...(option.receiverMessageID === undefined ? {} : { receiverMessageID: 'receiver-copy' }),
};
};
const normalizeMessages = (
rows: Array<{
id: number;
mailbox: number;
type: string;
src?: number;
dest?: number;
sourceId?: number;
destinationId?: number;
message?: unknown;
payload?: unknown;
}>,
proposalId: number
) =>
rows.map((row) => {
const payload = (row.payload ?? row.message) as Record<string, unknown>;
return {
mailbox: row.mailbox,
type: row.type,
sourceId: Number(row.sourceId ?? row.src),
destinationId: Number(row.destinationId ?? row.dest),
src: normalizeTarget(payload.src),
dest: normalizeTarget(payload.dest),
text: payload.text,
option: normalizeOption(payload.option, proposalId),
};
});
const normalizeLogs = (rows: CoreLogRow[]) =>
rows
.filter((row) => row.scope.toLowerCase() === 'general' || row.category.toLowerCase() === 'summary')
.map((row) => ({
generalId: row.generalId ?? 0,
category: row.category.toLowerCase(),
text: row.text,
}));
integration('Core tRPC messages.respond and Ref DecideMessageResponse dynamic differential', () => {
it.each(actionCases)('matches the accepted $action response state, logs, and messages', async (testCase) => {
const reference = runReference(testCase);
const core = buildCoreCaller(testCase);
const result = await core.caller.messages.respond({
generalId: core.actor.id,
messageId: 1,
response: true,
});
expect(reference.execution).toMatchObject({
entryPoint: 'sammo\\API\\Message\\DecideMessageResponse',
action: testCase.action,
outcome: { result: true },
});
expect(result).toEqual({ result: true, reason: 'success' });
expect(
core.diplomacy.map(({ srcNationId, destNationId, stateCode, term }) => ({
fromNationId: srcNationId,
toNationId: destNationId,
state: stateCode,
term,
}))
).toEqual(
reference.after.diplomacy.map(({ fromNationId, toNationId, state, term }) => ({
fromNationId,
toNationId,
state,
term,
}))
);
expect(core.cities.filter((city) => city.id <= 2).map(({ id, frontState }) => ({ id, frontState }))).toEqual(
reference.after.cities.map(({ id, frontState }) => ({ id, frontState }))
);
if (testCase.action === 'noAggression') {
expect(core.nations[1]?.meta).toEqual(reference.after.nations.find((nation) => nation.id === 2)?.meta);
}
const referenceLogs = reference.after.logs
.filter((log) => log.id > reference.before.watermarks.logId)
.filter((log) => log.scope === 'general' || log.category === 'summary')
.map((log) => ({
generalId: log.generalId ?? 0,
category: log.category,
text: log.text,
}));
expect(normalizeLogs(core.logs)).toEqual(referenceLogs);
const referenceResults = reference.after.messages.filter(
(message) => message.id > reference.before.watermarks.messageId
);
const coreResults = core.messages.filter((message) => message.id > 2);
expect(normalizeMessages(coreResults, 1)).toEqual(
normalizeMessages(referenceResults, reference.execution.proposalMessageId)
);
const referenceProposalOption = (
reference.execution.proposalAfter.payload as {
option?: Record<string, unknown>;
}
).option;
expect(reference.execution.proposalAfter.validUntilTick).toBeLessThan(
reference.execution.proposalBefore.validUntilTick
);
expect(referenceProposalOption).toMatchObject({ used: true, invalid: true });
// Ref also annotates the hidden JSON payload. Core's store represents
// the same invalidation by expiring validUntil, which is the predicate
// used by every product message read path.
expect(core.messages[0]?.valid_until.getTime()).toBeLessThan(core.proposalBefore.valid_until.getTime());
});
});
@@ -41,7 +41,10 @@ const observe = {
const addedLogs = (trace: ReturnType<typeof runReferenceTurnCommandTraceRequest>) =>
trace.after.logs.filter((log) => Number(log.id) > trace.before.watermarks.logId);
integration('legacy instant diplomacy responses', () => {
// This suite intentionally records the Ref command behavior only. The dynamic
// Core-vs-Ref product-path comparison lives in
// instantDiplomacyCoreReference.integration.test.ts.
integration('legacy instant diplomacy command behavior (Ref-only)', () => {
it('accepts non-aggression without RNG and copies received assistance', () => {
const setup = baseSetup(2, 0);
setup.nations[1] = {
@@ -3,7 +3,6 @@ import path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { asRecord } from '@sammo-ts/common';
import { buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js';
import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
import { createReservedTurnHandler } from '@sammo-ts/game-engine/turn/reservedTurnHandler.js';
@@ -23,6 +22,10 @@ import {
runCoreTurnCommandTrace,
type TurnCommandFixtureRequest,
} from '../src/turn-differential/coreCommandTrace.js';
import {
clearCoreTurnCommandPersistenceFixture,
seedCoreTurnCommandPersistenceFixture,
} from '../src/turn-differential/coreCommandPersistenceFixture.js';
import {
findTurnDifferentialWorkspaceRoot,
runReferenceTurnCommandTraceRequest,
@@ -41,7 +44,6 @@ const turnRunResult = {
} as const;
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
const nullableCode = (value: string | null | undefined): string => value ?? 'None';
const assertDedicatedDatabase = (rawUrl: string): void => {
const schema = new URL(rawUrl).searchParams.get('schema');
@@ -81,21 +83,7 @@ const readFixture = (fixtureName: string, scenarioEffect?: string): TurnCommandF
};
};
const cleanup = async (db: GamePrismaClient): Promise<void> => {
await db.logEntry.deleteMany();
await db.oldNation.deleteMany();
await db.rankData.deleteMany();
await db.generalTurn.deleteMany();
await db.generalTurnRevision.deleteMany();
await db.nationTurn.deleteMany();
await db.nationTurnRevision.deleteMany();
await db.diplomacy.deleteMany();
await db.general.deleteMany();
await db.troop.deleteMany();
await db.city.deleteMany();
await db.nation.deleteMany();
await db.worldState.deleteMany();
};
const cleanup = clearCoreTurnCommandPersistenceFixture;
integration('live sortie PostgreSQL persistence retry', () => {
let db: GamePrismaClient;
@@ -189,138 +177,15 @@ integration('live sortie PostgreSQL persistence retry', () => {
const map = await loadMapDefinitionByName('che');
const { state, snapshot } = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map);
await db.worldState.create({
data: {
id: state.id,
scenarioCode: 'live-sortie-persistence',
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
config: asJson(snapshot.scenarioConfig),
meta: asJson(state.meta),
},
});
await db.nation.createMany({
data: snapshot.nations.map((nation) => ({
id: nation.id,
name: nation.name,
color: nation.color,
capitalCityId: nation.capitalCityId,
chiefGeneralId: nation.chiefGeneralId,
gold: nation.gold,
rice: nation.rice,
tech: Number(nation.meta.tech ?? 0),
level: nation.level,
typeCode: nation.typeCode,
meta: asJson(nation.meta),
})),
});
await db.city.createMany({
data: snapshot.cities.map((city) => {
const definition = map.cities.find((entry) => entry.id === city.id);
return {
id: city.id,
name: city.name,
level: city.level,
nationId: city.nationId,
supplyState: city.supplyState,
frontState: city.frontState,
population: Math.round(city.population),
populationMax: city.populationMax,
agriculture: Math.round(city.agriculture),
agricultureMax: city.agricultureMax,
commerce: Math.round(city.commerce),
commerceMax: city.commerceMax,
security: Math.round(city.security),
securityMax: city.securityMax,
trust: Number(city.meta.trust ?? 0),
trade: Number(city.meta.trade ?? 100),
defence: Math.round(city.defence),
defenceMax: city.defenceMax,
wall: Math.round(city.wall),
wallMax: city.wallMax,
region: definition?.region ?? 0,
conflict: asJson(city.conflict ?? {}),
meta: asJson({ ...city.meta, state: city.state }),
};
}),
});
await db.troop.createMany({
data: snapshot.troops.map((troop) => ({
troopLeaderId: troop.id,
nationId: troop.nationId,
name: troop.name,
})),
});
await db.general.createMany({
data: snapshot.generals.map((general) => ({
id: general.id,
userId: general.userId,
name: general.name,
nationId: general.nationId,
cityId: general.cityId,
troopId: general.troopId,
npcState: general.npcState,
affinity: general.affinity,
bornYear: general.bornYear,
deadYear: general.deadYear,
picture: general.picture,
leadership: Math.round(general.stats.leadership),
strength: Math.round(general.stats.strength),
intel: Math.round(general.stats.intelligence),
injury: Math.round(general.injury),
experience: Math.round(general.experience),
dedication: Math.round(general.dedication),
officerLevel: general.officerLevel,
gold: Math.round(general.gold),
rice: Math.round(general.rice),
crew: Math.round(general.crew),
crewTypeId: general.crewTypeId,
train: Math.round(general.train),
atmos: Math.round(general.atmos),
age: general.age,
startAge: general.startAge,
personalCode: nullableCode(general.role.personality),
specialCode: nullableCode(general.role.specialDomestic),
special2Code: nullableCode(general.role.specialWar),
horseCode: nullableCode(general.role.items.horse),
weaponCode: nullableCode(general.role.items.weapon),
bookCode: nullableCode(general.role.items.book),
itemCode: nullableCode(general.role.items.item),
turnTime: general.turnTime,
recentWarTime: general.recentWarTime,
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
meta: asJson(general.meta),
penalty: asJson(general.penalty ?? {}),
})),
});
await db.rankData.createMany({
data: snapshot.generals.flatMap((general) =>
buildLegacyComparableRankRows(general).map((row) => ({
generalId: row.generalId,
nationId: row.nationId,
type: row.type,
value: row.value,
}))
),
});
await db.diplomacy.createMany({
data: snapshot.diplomacy.map((entry) => ({
srcNationId: entry.fromNationId,
destNationId: entry.toNationId,
stateCode: entry.state,
term: entry.term,
isDead: entry.dead !== 0,
meta: asJson(entry.meta),
})),
});
await db.generalTurn.createMany({
data: snapshot.generals.flatMap((general) =>
Array.from({ length: 30 }, (_, turnIdx) => ({
await seedCoreTurnCommandPersistenceFixture(db, {
worldInput: { state, snapshot, map },
scenarioCode: 'live-sortie-persistence',
generalTurns: snapshot.generals.flatMap((general) =>
Array.from({ length: 30 }, (_, turnIndex) => ({
generalId: general.id,
turnIdx,
actionCode: general.id === request.actorGeneralId && turnIdx === 0 ? request.action : '휴식',
arg: asJson(general.id === request.actorGeneralId && turnIdx === 0 ? coreArgs : {}),
turnIndex,
action: general.id === request.actorGeneralId && turnIndex === 0 ? request.action : '휴식',
args: general.id === request.actorGeneralId && turnIndex === 0 ? coreArgs : {},
}))
),
});
@@ -5,6 +5,12 @@ import { describe, expect, it } from 'vitest';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
import { orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js';
import {
projectSemanticTurnMessages,
projectSemanticUnreadMessageDeltas,
projectStrictTurnMessageTimeline,
} from '../src/turn-differential/messageProjection.js';
import {
findTurnDifferentialWorkspaceRoot,
runReferenceTurnCommandTraceRequest,
@@ -20,7 +26,9 @@ const ignoredLifecyclePaths = [
/^logs/,
/^messages/,
/^world\.turnTime$/,
/^world\.gameNow$/,
/^generals\[[^\]]+\]\.(?:turnTime|recentWarTime|lastTurn|mySet)(?:\.|$)/,
/^generals\[1\]\.(?:turnTick|turnSecond|turnFraction)$/,
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
/^nations\[[^\]]+\]\.meta(?:\.|$)/,
];
@@ -30,6 +38,7 @@ const comparedLifecycleIgnoredPaths = [
/^logs/,
/^messages/,
/^world\.turnTime$/,
/^world\.gameNow$/,
/^generalTurns\[[^\]]+\]\.args(?:\.|$)/,
/^generals\[[^\]]+\]\.(?:lastTurn|recentWarTime|turnTime)(?:\.|$)/,
/^generals\[[^\]]+\]\.meta(?:\.|$)/,
@@ -42,24 +51,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(/<span class='hidden_but_copyable'>(.*?)<\/span>/g, '$1')
.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 readFixture = (relativePath: string): TurnCommandFixtureRequest => {
const stackRoot = path.join(workspaceRoot!, 'docker_compose_files/reference');
@@ -74,6 +66,7 @@ const readFixture = (relativePath: string): TurnCommandFixtureRequest => {
world: {
...fixture.setup?.world,
hiddenSeed: 'turn-command-differential-seed',
freezeClock: true,
},
generals: fixture.setup?.generals?.map((general) => ({
...general,
@@ -257,6 +250,25 @@ integration('core ↔ legacy command-boundary differential', () => {
});
expect(reference.execution.outcome).toMatchObject({ completed: true });
expect(core.rng).toEqual(reference.rng);
const messageAfterId = reference.before.watermarks.messageId;
const coreMessages = projectSemanticTurnMessages(core.after.messages, messageAfterId);
const referenceMessages = projectSemanticTurnMessages(reference.after.messages, messageAfterId);
const coreMessageTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId);
const referenceMessageTimeline = projectStrictTurnMessageTimeline(
reference.before,
reference.after,
messageAfterId
);
expect({
messages: coreMessages,
unreadDeltas: projectSemanticUnreadMessageDeltas(core.before, core.after),
timeline: coreMessageTimeline,
}).toEqual({
messages: referenceMessages,
unreadDeltas: projectSemanticUnreadMessageDeltas(reference.before, reference.after),
timeline: referenceMessageTimeline,
});
expect(referenceMessageTimeline.usesSingleTick).toBe(true);
if (request.action === 'che_출병') {
const generalLogWatermark = reference.before.watermarks.logId;
const historyLogWatermark = reference.before.watermarks.historyLogId;
@@ -282,4 +294,61 @@ integration('core ↔ legacy command-boundary differential', () => {
},
120_000
);
it('matches the Ref receiver-only scout message on a positive collapse draw', async () => {
const request = readFixture('fixtures/turn-differential/live-sortie-conquest.json');
request.setup!.world!.hiddenSeed = 'collapse-scout-positive-4';
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
const messageAfterId = reference.before.watermarks.messageId;
const coreMessages = projectSemanticTurnMessages(core.after.messages, messageAfterId);
const referenceMessages = projectSemanticTurnMessages(reference.after.messages, messageAfterId);
const coreTimeline = projectStrictTurnMessageTimeline(core.before, core.after, messageAfterId);
const referenceTimeline = projectStrictTurnMessageTimeline(reference.before, reference.after, messageAfterId);
const coreUnread = projectSemanticUnreadMessageDeltas(core.before, core.after);
const referenceUnread = projectSemanticUnreadMessageDeltas(reference.before, reference.after);
expect(core.rng).toEqual(reference.rng);
expect(coreMessages).toEqual(referenceMessages);
expect(coreTimeline).toEqual(referenceTimeline);
expect(coreUnread).toEqual(referenceUnread);
expect(referenceTimeline.usesSingleTick).toBe(true);
expect(referenceMessages).toHaveLength(1);
expect(referenceMessages[0]).toMatchObject({
mailbox: 2,
type: 'private',
sourceId: 1,
destinationId: 2,
createdAt: referenceTimeline.beforeGameNow,
validUntil: { kind: 'infinite' },
source: {
generalId: 1,
nationId: 1,
nationName: '공격국',
},
destination: {
generalId: 2,
nationId: 0,
nationName: '재야',
color: '#000000',
},
text: '공격국으로 망명 권유 서신',
option: { action: 'scout' },
});
expect(referenceMessages[0]!.source.icon).toBe(referenceMessages[0]!.destination.icon);
expect(referenceMessages[0]!.source.icon).toMatch(/\/default\.jpg$/u);
expect(referenceUnread.find((entry) => entry.generalId === 2)).toMatchObject({
unreadPrivateDelta: 1,
hasUnreadMessage: true,
});
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
});
@@ -0,0 +1,102 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createCoreTurnCommandProfile, runCoreTurnCommandTrace } from '../src/turn-differential/coreCommandTrace.js';
import { runReferenceFullLifecycleTrace } from '../src/turn-differential/fullLifecycleTrace.js';
import {
addedFullLifecycleReferenceLogs,
fullLifecycleTurnCommandRequest as request,
projectFullLifecycleSnapshotGraph,
} from '../src/turn-differential/fullLifecycleFixture.js';
import { normalizeStoredTurnLogText, orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js';
import { findTurnDifferentialWorkspaceRoot } from '../src/turn-differential/referenceSnapshot.js';
const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const referenceSourceRoot = workspaceRoot
? path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot, 'ref/sam'))
: null;
const hasFullLifecycleRunner =
referenceSourceRoot !== null &&
fs.existsSync(path.join(referenceSourceRoot, 'hwe/compare/turn_full_lifecycle_trace.php'));
const integration = describe.skipIf(
!workspaceRoot || !hasFullLifecycleRunner || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1'
);
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
describe('full lifecycle fixture profile closure', () => {
it('keeps both the queued nation and general command executable', () => {
const profile = createCoreTurnCommandProfile(request);
expect(profile.general).toContain('che_훈련');
expect(profile.nation).toContain('che_국호변경');
});
});
integration('Ref/Core full reserved-turn lifecycle topology', () => {
it('runs nation then general and persists both queue shifts in the same actor turn', async () => {
const reference = runReferenceFullLifecycleTrace(workspaceRoot!, {
...request,
generalAction: request.action,
generalArgs: request.args,
nationAction: 'che_국호변경',
nationArgs: { nationName: '수명주기국' },
} as unknown as Record<string, unknown>);
const core = await runCoreTurnCommandTrace(request, reference.before);
const referencePhases = asRecord(reference.execution.outcome).phases as Array<Record<string, unknown>>;
expect(referencePhases.map((entry) => entry.phase)).toEqual([
'preprocess',
'block',
'nation_command',
'nation_command_resolved',
'general_command',
'general_command_resolved',
'queues_shifted',
'turn_state_advanced',
'persisted',
]);
expect(
referencePhases
.filter((entry) => entry.phase === 'nation_command' || entry.phase === 'general_command')
.map((entry) => [entry.phase, entry.action])
).toEqual([
['nation_command', 'che_국호변경'],
['general_command', 'che_훈련'],
]);
expect(referencePhases.find((entry) => entry.phase === 'queues_shifted')).toMatchObject({
generalAction: '휴식',
nationAction: '휴식',
});
const coreLifecycleActions = asRecord(core.execution.outcome).lifecycleActions as Array<
Record<string, unknown>
>;
expect(coreLifecycleActions.map((entry) => [entry.kind, entry.requestedAction, entry.usedFallback])).toEqual([
['nation', 'che_국호변경', false],
['general', 'che_훈련', false],
]);
expect(projectFullLifecycleSnapshotGraph(core.after)).toEqual(
projectFullLifecycleSnapshotGraph(reference.after)
);
const referenceLogs = addedFullLifecycleReferenceLogs(reference.before, reference.after);
expect(orderedSemanticLogStreams(core.after.logs)).toEqual(orderedSemanticLogStreams(referenceLogs));
const generalActionTexts = referenceLogs
.filter((entry) => String(entry.category).toLowerCase() === 'action')
.map((entry) => normalizeStoredTurnLogText(entry.text));
expect(generalActionTexts.findIndex((text) => text.includes('국호를'))).toBeLessThan(
generalActionTexts.findIndex((text) => text.includes('훈련치가'))
);
const persisted = referencePhases.find((entry) => entry.phase === 'persisted');
const referenceActor = reference.after.generals.find((entry) => entry.id === 1);
expect(persisted).toMatchObject({
killTurn: referenceActor?.killTurn,
mySet: referenceActor?.mySet,
});
}, 180_000);
});
@@ -0,0 +1,550 @@
import fs from 'node:fs';
import path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { asRecord } from '@sammo-ts/common';
import { createDatabaseTurnHooks } from '@sammo-ts/game-engine/turn/databaseHooks.js';
import { InMemoryTurnWorld } from '@sammo-ts/game-engine/turn/inMemoryWorld.js';
import { createReservedTurnHandler } from '@sammo-ts/game-engine/turn/reservedTurnHandler.js';
import { InMemoryReservedTurnStore } from '@sammo-ts/game-engine/turn/reservedTurnStore.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '@sammo-ts/game-engine/turn/types.js';
import { loadMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js';
import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js';
import { loadTurnWorldFromDatabase } from '@sammo-ts/game-engine/turn/worldLoader.js';
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
import {
clearCoreTurnCommandPersistenceFixture,
seedCoreTurnCommandPersistenceFixture,
} from '../src/turn-differential/coreCommandPersistenceFixture.js';
import {
buildCoreTurnCommandWorldInput,
createCoreTurnCommandProfile,
runCoreTurnCommandTrace,
} from '../src/turn-differential/coreCommandTrace.js';
import { readCoreDatabaseSnapshot } from '../src/turn-differential/databaseSnapshot.js';
import {
addedFullLifecycleReferenceLogs,
fullLifecycleSnapshotSelector,
fullLifecycleTurnCommandRequest as request,
projectFullLifecycleSnapshotGraph,
} from '../src/turn-differential/fullLifecycleFixture.js';
import { runReferenceFullLifecycleTrace } from '../src/turn-differential/fullLifecycleTrace.js';
import { normalizeStoredTurnLogText, orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js';
import { findTurnDifferentialWorkspaceRoot } from '../src/turn-differential/referenceSnapshot.js';
const databaseUrl = process.env.TURN_FULL_LIFECYCLE_PERSISTENCE_DATABASE_URL;
const workspaceRoot = process.env.TURN_DIFFERENTIAL_WORKSPACE_ROOT ?? findTurnDifferentialWorkspaceRoot(process.cwd());
const referenceSourceRoot = workspaceRoot
? path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot, 'ref/sam'))
: null;
const hasFullLifecycleRunner =
referenceSourceRoot !== null &&
fs.existsSync(path.join(referenceSourceRoot, 'hwe/compare/turn_full_lifecycle_trace.php'));
const databaseIntegration = describe.skipIf(!databaseUrl);
const leaseOwner = 'turn-full-lifecycle-persistence-daemon';
const dedicatedSuffix = 'turn_full_lifecycle_persistence';
export const assertDedicatedTurnFullLifecycleDatabase = (rawUrl: string): void => {
const url = new URL(rawUrl);
const schema = url.searchParams.get('schema');
const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/, ''));
if (!schema?.endsWith(dedicatedSuffix) && !databaseName.endsWith(dedicatedSuffix)) {
throw new Error(
`Refusing to mutate non-dedicated turn full-lifecycle database: schema=${schema ?? '(missing)'}, database=${databaseName || '(missing)'}`
);
}
};
describe('turn full-lifecycle persistence database guard', () => {
it('rejects a shared database and schema before connecting', () => {
expect(() =>
assertDedicatedTurnFullLifecycleDatabase('postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=public')
).toThrow('Refusing to mutate non-dedicated turn full-lifecycle database');
});
it('accepts only an explicitly dedicated schema or database name', () => {
expect(() =>
assertDedicatedTurnFullLifecycleDatabase(
'postgresql://fixture:fixture@127.0.0.1:5432/sammo?schema=ci_turn_full_lifecycle_persistence'
)
).not.toThrow();
expect(() =>
assertDedicatedTurnFullLifecycleDatabase(
'postgresql://fixture:fixture@127.0.0.1:5432/ci_turn_full_lifecycle_persistence'
)
).not.toThrow();
});
});
databaseIntegration('Core PostgreSQL full reserved-turn lifecycle persistence', () => {
let db: GamePrismaClient | undefined;
let disconnect: (() => Promise<void>) | undefined;
beforeAll(async () => {
assertDedicatedTurnFullLifecycleDatabase(databaseUrl!);
const connector = createGamePostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
disconnect = () => connector.disconnect();
await clearCoreTurnCommandPersistenceFixture(db);
});
beforeEach(async () => {
if (db) {
await clearCoreTurnCommandPersistenceFixture(db);
}
});
afterAll(async () => {
try {
if (db) {
await clearCoreTurnCommandPersistenceFixture(db);
}
} finally {
await disconnect?.();
}
});
it.skipIf(!workspaceRoot || !hasFullLifecycleRunner || process.env.TURN_DIFFERENTIAL_REFERENCE !== '1')(
'commits nation then general, both queue shifts, ordered logs, and reloadable state in one flush',
async () => {
if (!db) {
throw new Error('fixture database is not connected');
}
const reference = runReferenceFullLifecycleTrace(workspaceRoot!, {
...request,
generalAction: request.action,
generalArgs: request.args,
nationAction: 'che_국호변경',
nationArgs: { nationName: '수명주기국' },
} as unknown as Record<string, unknown>);
const expected = await runCoreTurnCommandTrace(request, reference.before);
const unitSet = await loadUnitSetDefinitionByName('che');
const map = await loadMapDefinitionByName('che');
const worldInput = buildCoreTurnCommandWorldInput(request, reference.before, unitSet, map);
await seedCoreTurnCommandPersistenceFixture(db, {
worldInput,
scenarioCode: 'turn-full-lifecycle-persistence',
generalTurns: reference.before.generalTurns,
nationTurns: reference.before.nationTurns,
});
const before = await readCoreDatabaseSnapshot(databaseUrl!, fullLifecycleSnapshotSelector);
expect(projectFullLifecycleSnapshotGraph(before)).toEqual(
projectFullLifecycleSnapshotGraph(reference.before)
);
expect(projectFullLifecycleSnapshotGraph(before)).toEqual(
projectFullLifecycleSnapshotGraph(expected.before)
);
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(loaded.snapshot.scenarioConfig).toEqual(worldInput.snapshot.scenarioConfig);
expect(loaded.snapshot.scenarioMeta).toEqual(worldInput.snapshot.scenarioMeta);
const reservedTurns = new InMemoryReservedTurnStore(db, {
maxGeneralTurns: 30,
maxNationTurns: 12,
leaseOwner,
leaseDurationMs: 60_000,
});
await reservedTurns.loadAll();
const loadedActor = loaded.snapshot.generals.find((general) => general.id === request.actorGeneralId);
if (!loadedActor) {
throw new Error('fixture actor is missing after database load');
}
await reservedTurns.prepareTurnsForExecution(loadedActor.id, {
nationId: loadedActor.nationId,
officerLevel: loadedActor.officerLevel,
});
const lifecycleActions: Array<{ kind: string; requestedAction: string; usedFallback: boolean }> = [];
let world: InMemoryTurnWorld | null = null;
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig: loaded.snapshot.scenarioConfig,
scenarioMeta: loaded.snapshot.scenarioMeta,
map: loaded.snapshot.map,
unitSet: loaded.snapshot.unitSet,
getWorld: () => world,
now: () => new Date(loaded.state.lastTurnTime),
commandProfile: createCoreTurnCommandProfile(request),
onActionResolved: (entry) => {
lifecycleActions.push({
kind: entry.kind,
requestedAction: entry.requestedAction,
usedFallback: entry.usedFallback,
});
},
});
world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: {
entries: [
{
startMinute: 0,
tickMinutes: Math.max(1, Math.round(loaded.state.tickSeconds / 60)),
},
],
},
generalTurnHandler: handler,
});
const actor = world.getGeneralById(request.actorGeneralId);
if (!actor) {
throw new Error('fixture actor is missing from executable world');
}
world.executeGeneralTurn(actor);
expect(lifecycleActions).toEqual([
{ kind: 'nation', requestedAction: 'che_국호변경', usedFallback: false },
{ kind: 'general', requestedAction: 'che_훈련', usedFallback: false },
]);
expect(reservedTurns.getGeneralTurn(actor.id, 0).action).toBe('휴식');
expect(reservedTurns.getNationTurn(actor.nationId, actor.officerLevel, 0).action).toBe('휴식');
const dirtyBeforeFlush = world.peekDirtyState();
expect(dirtyBeforeFlush.generals.map((entry) => entry.id)).toContain(actor.id);
expect(dirtyBeforeFlush.nations.map((entry) => entry.id)).toContain(actor.nationId);
expect(dirtyBeforeFlush.logs.length).toBeGreaterThan(0);
const databaseBeforeFlush = await readCoreDatabaseSnapshot(databaseUrl!, fullLifecycleSnapshotSelector);
expect(projectFullLifecycleSnapshotGraph(databaseBeforeFlush)).toEqual(
projectFullLifecycleSnapshotGraph(before)
);
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
try {
if (!hooks.hooks.flushChanges) {
throw new Error('database turn hooks do not expose flushChanges');
}
await hooks.hooks.flushChanges({
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
processedGenerals: 1,
processedTurns: 1,
durationMs: 0,
partial: false,
});
} finally {
await hooks.close();
}
expect(world.peekDirtyState().logs).toEqual([]);
expect(reservedTurns.peekDirtyState()).toMatchObject({
generalIds: [],
nationKeys: [],
});
const after = await readCoreDatabaseSnapshot(databaseUrl!, fullLifecycleSnapshotSelector);
expect(projectFullLifecycleSnapshotGraph(after)).toEqual(
projectFullLifecycleSnapshotGraph(reference.after)
);
expect(projectFullLifecycleSnapshotGraph(after)).toEqual(projectFullLifecycleSnapshotGraph(expected.after));
expect(orderedSemanticLogStreams(after.logs)).toEqual(
orderedSemanticLogStreams(addedFullLifecycleReferenceLogs(reference.before, reference.after))
);
expect(orderedSemanticLogStreams(after.logs)).toEqual(orderedSemanticLogStreams(expected.after.logs));
const persistedActionTexts = after.logs
.filter((entry) => String(entry.category).toLowerCase() === 'action')
.map((entry) => normalizeStoredTurnLogText(entry.text));
expect(persistedActionTexts.findIndex((text) => text.includes('국호를'))).toBeLessThan(
persistedActionTexts.findIndex((text) => text.includes('훈련치가'))
);
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const reloadedActor = reloaded.snapshot.generals.find((general) => general.id === request.actorGeneralId);
const expectedActor = expected.after.generals.find((general) => general.id === request.actorGeneralId);
expect(reloadedActor).toMatchObject({
train: expectedActor?.train,
atmos: expectedActor?.atmos,
experience: expectedActor?.experience,
dedication: expectedActor?.dedication,
});
expect(asRecord(reloadedActor?.meta)).toMatchObject({
killturn: expectedActor?.killTurn,
myset: expectedActor?.mySet,
});
expect(reloaded.snapshot.nations.find((nation) => nation.id === actor.nationId)?.name).toBe('수명주기국');
const reloadedReservedTurns = new InMemoryReservedTurnStore(db, {
maxGeneralTurns: 30,
maxNationTurns: 12,
});
await reloadedReservedTurns.loadAll();
expect(reloadedReservedTurns.getGeneralTurn(actor.id, 0).action).toBe('휴식');
expect(reloadedReservedTurns.getNationTurn(actor.nationId, actor.officerLevel, 0).action).toBe('휴식');
},
180_000
);
it('persists thirty resting turns for every command-created volunteer and reloads them', async () => {
if (!db) {
throw new Error('fixture database is not connected');
}
const map = await loadMapDefinitionByName('che');
const unitSet = await loadUnitSetDefinitionByName('che');
const cityDefinition = map.cities.find((city) => city.id === 3) ?? map.cities[0];
if (!cityDefinition) {
throw new Error('fixture map has no city');
}
const actorId = 101;
const nationId = 11;
const actionTime = new Date('0190-01-01T00:00:00.000Z');
const actor: TurnGeneral = {
id: actorId,
userId: null,
name: '영속화의병장',
nationId,
cityId: cityDefinition.id,
troopId: 0,
stats: { leadership: 90, strength: 80, intelligence: 70 },
experience: 1_000,
dedication: 1_000,
officerLevel: 12,
role: {
personality: null,
specialDomestic: null,
specialWar: null,
items: { horse: null, weapon: null, book: null, item: null },
},
injury: 0,
gold: 100_000,
rice: 100_000,
crew: 1_000,
crewTypeId: 1_100,
train: 50,
atmos: 50,
age: 30,
npcState: 0,
bornYear: 160,
deadYear: 260,
affinity: 50,
picture: 'default.jpg',
imageServer: 0,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
killturn: 24,
officer_city: cityDefinition.id,
belong: 10,
permission: 'normal',
},
turnTime: actionTime,
};
const state: TurnWorldState = {
id: 1,
currentYear: 190,
currentMonth: 1,
tickSeconds: 600,
lastTurnTime: actionTime,
clockBaseTime: actionTime,
clockTick: 0,
clockMode: 'manual',
clockWallAnchor: actionTime,
lastTurnTick: 0,
meta: {
hiddenSeed: 'turn-command-volunteer-persistence',
killturn: 24,
lastTurnTime: actionTime.toISOString(),
},
};
const snapshot: TurnWorldSnapshot = {
scenarioConfig: {
stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 70 },
iconPath: '',
map: {},
const: {
develCost: 100,
openingPartYear: 3,
defaultMaxGeneral: 500,
initialNationGenLimit: 10,
defaultNpcGold: 1_000,
defaultNpcRice: 1_000,
defaultCrewTypeId: 1_100,
retirementYear: 80,
randGenFirstName: ['가'],
randGenMiddleName: [''],
randGenLastName: ['가'],
availablePersonality: ['che_안전'],
},
environment: { mapName: map.id, unitSet: unitSet.id },
},
scenarioMeta: {
title: '명령 생성 장수 예약 턴 영속화',
startYear: 180,
life: null,
fiction: 0,
history: [],
ignoreDefaultEvents: false,
},
map,
unitSet,
nations: [
{
id: nationId,
name: '의병국',
color: '#777777',
capitalCityId: cityDefinition.id,
chiefGeneralId: actorId,
gold: 1_000_000,
rice: 1_000_000,
power: 0,
level: 1,
typeCode: 'che_명가',
meta: {
gennum: 1,
tech: 1_000,
strategic_cmd_limit: 0,
turn_last_12: { command: '의병모집', arg: {}, term: 2 },
},
},
],
cities: [
{
id: cityDefinition.id,
name: cityDefinition.name,
nationId,
level: cityDefinition.level,
state: 0,
population: cityDefinition.initial.population,
populationMax: cityDefinition.max.population,
agriculture: cityDefinition.initial.agriculture,
agricultureMax: cityDefinition.max.agriculture,
commerce: cityDefinition.initial.commerce,
commerceMax: cityDefinition.max.commerce,
security: cityDefinition.initial.security,
securityMax: cityDefinition.max.security,
supplyState: map.defaults?.supplyState ?? 1,
frontState: map.defaults?.frontState ?? 0,
defence: cityDefinition.initial.defence,
defenceMax: cityDefinition.max.defence,
wall: cityDefinition.initial.wall,
wallMax: cityDefinition.max.wall,
conflict: {},
meta: {
trust: map.defaults?.trust ?? 50,
trade: map.defaults?.trade ?? 100,
region: cityDefinition.region,
},
},
],
generals: [actor],
troops: [],
diplomacy: [],
events: [],
initialEvents: [],
};
await seedCoreTurnCommandPersistenceFixture(db, {
worldInput: { state, snapshot, map },
scenarioCode: 'turn-command-volunteer-persistence',
generalTurns: Array.from({ length: 30 }, (_, turnIndex) => ({
generalId: actorId,
turnIndex,
action: '휴식',
args: {},
})),
nationTurns: Array.from({ length: 12 }, (_, turnIndex) => ({
nationId,
officerLevel: 12,
turnIndex,
action: turnIndex === 0 ? 'che_의병모집' : '휴식',
args: {},
})),
});
const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
const reservedTurns = new InMemoryReservedTurnStore(db, {
maxGeneralTurns: 30,
maxNationTurns: 12,
leaseOwner: `${leaseOwner}-volunteer`,
leaseDurationMs: 60_000,
});
await reservedTurns.loadAll();
const loadedActor = loaded.snapshot.generals.find((general) => general.id === actorId);
if (!loadedActor) {
throw new Error('volunteer fixture actor is missing after database load');
}
await reservedTurns.prepareTurnsForExecution(actorId, { nationId, officerLevel: 12 });
let world: InMemoryTurnWorld | null = null;
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig: loaded.snapshot.scenarioConfig,
scenarioMeta: loaded.snapshot.scenarioMeta,
map: loaded.snapshot.map,
unitSet: loaded.snapshot.unitSet,
getWorld: () => world,
now: () => new Date(loaded.state.lastTurnTime),
commandProfile: {
general: ['휴식'],
nation: ['che_의병모집', '휴식'],
},
});
world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, {
schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] },
generalTurnHandler: handler,
});
const executableActor = world.getGeneralById(actorId);
if (!executableActor) {
throw new Error('volunteer fixture actor is missing from executable world');
}
world.executeGeneralTurn(executableActor);
const createdIds = world
.peekDirtyState()
.createdGenerals.map((general) => general.id)
.sort((left, right) => left - right);
expect(createdIds).toEqual([102, 103, 104]);
expect(reservedTurns.peekDirtyState().generalInitializationIds.sort((left, right) => left - right)).toEqual(
createdIds
);
const restingTurns = Array.from({ length: 30 }, () => ({ action: '휴식', args: {} }));
for (const generalId of createdIds) {
expect(reservedTurns.getGeneralTurns(generalId)).toEqual(restingTurns);
}
const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { reservedTurns });
try {
if (!hooks.hooks.flushChanges) {
throw new Error('database turn hooks do not expose flushChanges');
}
await hooks.hooks.flushChanges({
lastTurnTime: loaded.state.lastTurnTime.toISOString(),
processedGenerals: 1,
processedTurns: 1,
durationMs: 0,
partial: false,
});
} finally {
await hooks.close();
}
const persistedTurns = await db.generalTurn.findMany({
where: { generalId: { in: createdIds } },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
});
expect(persistedTurns).toHaveLength(createdIds.length * 30);
for (const generalId of createdIds) {
expect(
persistedTurns
.filter((turn) => turn.generalId === generalId)
.map((turn) => ({ turnIdx: turn.turnIdx, action: turn.actionCode, args: turn.arg }))
).toEqual(Array.from({ length: 30 }, (_, turnIdx) => ({ turnIdx, action: '휴식', args: {} })));
}
const persistedNation = await db.nation.findUnique({ where: { id: nationId }, select: { meta: true } });
expect(persistedNation?.meta).toMatchObject({ gennum: 4 });
const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! });
expect(
reloaded.snapshot.generals
.filter((general) => createdIds.includes(general.id))
.map((general) => general.id)
.sort((left, right) => left - right)
).toEqual(createdIds);
expect(reloaded.snapshot.nations.find((nation) => nation.id === nationId)?.meta).toMatchObject({ gennum: 4 });
const reloadedReservedTurns = new InMemoryReservedTurnStore(db, {
maxGeneralTurns: 30,
maxNationTurns: 12,
});
await reloadedReservedTurns.loadAll();
for (const generalId of createdIds) {
expect(reloadedReservedTurns.getGeneralTurns(generalId)).toEqual(restingTurns);
}
}, 180_000);
});
@@ -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());
});
});
@@ -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, {
@@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest';
import { orderedSemanticLogStreams } from '../src/turn-differential/logProjection.js';
const actionLog = (id: number, text: string, overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
id,
scope: 'general',
category: 'action',
generalId: 1,
nationId: null,
year: 190,
month: 1,
format: 4,
text,
...overrides,
});
const historyLog = (id: number, text: string, overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
id,
scope: 'nation',
category: 'history',
generalId: null,
nationId: 1,
year: 190,
month: 1,
format: 2,
text,
...overrides,
});
const generalHistoryLog = (
id: number,
text: string,
overrides: Record<string, unknown> = {}
): Record<string, unknown> => ({
id,
scope: 'general',
category: 'history',
generalId: 1,
nationId: null,
year: 190,
month: 1,
format: 2,
text,
...overrides,
});
describe('orderedSemanticLogStreams', () => {
it('preserves observable ordering inside general_record', () => {
const expected = orderedSemanticLogStreams([actionLog(1, '명령'), actionLog(2, '능력 상승')]);
const reversed = orderedSemanticLogStreams([actionLog(2, '명령'), actionLog(1, '능력 상승')]);
expect(reversed).not.toEqual(expected);
});
it('keeps general history in general_record so an action/history order mutant fails', () => {
const expected = orderedSemanticLogStreams([generalHistoryLog(1, '장수 역사'), actionLog(2, '장수 행동')]);
const reversed = orderedSemanticLogStreams([actionLog(1, '장수 행동'), generalHistoryLog(2, '장수 역사')]);
expect(reversed).not.toEqual(expected);
});
it('orders each Ref table by its own id and does not invent a cross-table order', () => {
const left = orderedSemanticLogStreams([
historyLog(4, '국가 기록 둘'),
historyLog(3, '국가 기록 하나'),
actionLog(8, '장수 기록 둘'),
generalHistoryLog(6, '장수 역사'),
actionLog(7, '장수 기록 하나'),
]);
const right = orderedSemanticLogStreams([
generalHistoryLog(6, '장수 역사'),
actionLog(7, '장수 기록 하나'),
actionLog(8, '장수 기록 둘'),
historyLog(3, '국가 기록 하나'),
historyLog(4, '국가 기록 둘'),
]);
expect(left).toEqual(right);
});
it('can omit the lifecycle rest log without omitting other ordered entries', () => {
expect(
orderedSemanticLogStreams([actionLog(1, '아무것도 실행하지 않았습니다.'), actionLog(2, '명령')], {
omitRest: true,
})
).toEqual(orderedSemanticLogStreams([actionLog(2, '명령')]));
});
it('maps a Core draft format to the same semantic persisted prefix as Ref', () => {
const core = actionLog(1, '명령');
const reference = actionLog(1, '<C>●</>1월:명령');
delete reference.format;
expect(orderedSemanticLogStreams([core])).toEqual(orderedSemanticLogStreams([reference]));
});
it('normalizes the hidden battle seed span with either HTML quote style', () => {
const singleQuoted = actionLog(1, `진격<span class='hidden_but_copyable'>(전투시드: abc)</span>`);
const doubleQuoted = actionLog(1, `진격<span class="hidden_but_copyable">(전투시드: abc)</span>`);
expect(orderedSemanticLogStreams([doubleQuoted])).toEqual(orderedSemanticLogStreams([singleQuoted]));
expect(
orderedSemanticLogStreams([actionLog(1, `진격<span class="visible">(전투시드: abc)</span>`)])
).not.toEqual(orderedSemanticLogStreams([singleQuoted]));
});
it.each([
['year', { year: 191 }],
['month', { month: 2 }],
['format', { format: 1 }],
])('keeps a %s mutation visible', (_field, overrides) => {
expect(orderedSemanticLogStreams([actionLog(1, '명령', overrides)])).not.toEqual(
orderedSemanticLogStreams([actionLog(1, '명령')])
);
});
it('rejects a persisted prefix whose calendar disagrees with its row', () => {
const malformed = actionLog(1, '<C>●</>2월:명령');
delete malformed.format;
expect(() => orderedSemanticLogStreams([malformed])).toThrow('stored log month 2 does not match row month 1');
});
});
@@ -0,0 +1,254 @@
import { describe, expect, it } from 'vitest';
import type { CanonicalTurnSnapshot } from '../src/turn-differential/canonical.js';
import {
projectSemanticTurnMessages,
projectSemanticUnreadMessageDeltas,
projectStrictTurnMessageTimeline,
} from '../src/turn-differential/messageProjection.js';
const referenceMessage = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
id: 41,
mailbox: 2,
type: 'private',
sourceId: 1,
destinationId: 2,
createdAt: '2026-08-23 01:02:03.123456',
validUntil: '2026-08-24 01:02:03.123456',
payload: {
src: { id: 1, name: '보낸이', nation_id: 1, nation: '아국', color: '#112233', icon: '/ref.png' },
dest: { id: 2, name: '받는이', nation_id: 2, nation: '타국', color: '#445566', icon: '/ref2.png' },
text: '아국으로 망명 권유 서신',
option: { action: 'scout' },
},
...overrides,
});
const coreMessage = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
id: 41,
mailbox: 2,
type: 'private',
sourceId: 1,
destinationId: 2,
createdAt: '2026-08-23T01:02:03.123Z',
validUntil: '2026-08-24T01:02:03.123Z',
payload: {
src: {
generalId: 1,
generalName: '보낸이',
nationId: 1,
nationName: '아국',
color: '#112233',
icon: '/ref.png',
},
dest: {
generalId: 2,
generalName: '받는이',
nationId: 2,
nationName: '타국',
color: '#445566',
icon: '/ref2.png',
},
text: '아국으로 망명 권유 서신',
option: { action: 'scout' },
},
...overrides,
});
const snapshot = (
generals: Array<Record<string, unknown>>,
messages: Array<Record<string, unknown>> = [],
gameNow = '2026-08-23 01:02:03.123456'
): CanonicalTurnSnapshot =>
({
world: { gameNow },
generals,
messages,
}) as unknown as CanonicalTurnSnapshot;
const general = (id: number, unreadPrivateCount: number, unreadDiplomacyCount: number): Record<string, unknown> => ({
id,
messageReadState: {
unreadPrivateCount,
unreadDiplomacyCount,
hasUnreadMessage: unreadPrivateCount + unreadDiplomacyCount > 0,
},
});
describe('turn message semantic projection', () => {
it('maps Ref and Core target schemas and timestamp precision to the same message', () => {
expect(projectSemanticTurnMessages([coreMessage()], 40)).toEqual(
projectSemanticTurnMessages([referenceMessage()], 40)
);
});
it('treats Ref empty-array and Core empty-object options as the same absence of fields', () => {
const referencePayload = referenceMessage().payload as Record<string, unknown>;
const corePayload = coreMessage().payload as Record<string, unknown>;
expect(projectSemanticTurnMessages([coreMessage({ payload: { ...corePayload, option: {} } })], 40)).toEqual(
projectSemanticTurnMessages([referenceMessage({ payload: { ...referencePayload, option: [] } })], 40)
);
});
it.each([
['mailbox', { mailbox: 3 }],
['type', { type: 'diplomacy' }],
['source', { sourceId: 9 }],
['destination', { destinationId: 9 }],
['createdAt', { createdAt: '2026-08-23T01:02:04.123Z' }],
['validUntil', { validUntil: '2026-08-24T01:02:04.123Z' }],
[
'source icon',
{
payload: {
...(coreMessage().payload as Record<string, unknown>),
src: {
...((coreMessage().payload as Record<string, unknown>).src as Record<string, unknown>),
icon: '/mutant.png',
},
},
},
],
[
'destination icon',
{
payload: {
...(coreMessage().payload as Record<string, unknown>),
dest: {
...((coreMessage().payload as Record<string, unknown>).dest as Record<string, unknown>),
icon: '/mutant.png',
},
},
},
],
[
'text',
{
payload: {
...(coreMessage().payload as Record<string, unknown>),
text: '다른 본문',
},
},
],
[
'option',
{
payload: {
...(coreMessage().payload as Record<string, unknown>),
option: { action: 'scout', used: true },
},
},
],
])('keeps a %s mutation visible', (_field, overrides) => {
expect(projectSemanticTurnMessages([coreMessage(overrides)], 40)).not.toEqual(
projectSemanticTurnMessages([referenceMessage()], 40)
);
});
it('uses explicit finite/infinite lifetimes and rejects missing or null lifetime', () => {
expect(projectSemanticTurnMessages([referenceMessage({ validUntil: 'infinite' })], 40)[0]?.validUntil).toEqual({
kind: 'infinite',
});
expect(projectSemanticTurnMessages([referenceMessage()], 40)[0]?.validUntil).toEqual({
kind: 'finite',
at: '2026-08-24T01:02:03.123Z',
});
expect(() => projectSemanticTurnMessages([referenceMessage({ validUntil: null })], 40)).toThrow(
/message\.validUntil must be a finite timestamp or the infinite sentinel/
);
const missing = referenceMessage();
delete missing.validUntil;
expect(() => projectSemanticTurnMessages([missing], 40)).toThrow(/message\.validUntil is missing/);
});
it('does not normalize away a sender option difference', () => {
const referenceSender = referenceMessage({
mailbox: 9001,
type: 'diplomacy',
sourceId: 9001,
destinationId: 9002,
payload: {
...(referenceMessage().payload as Record<string, unknown>),
option: null,
},
});
const coreSender = coreMessage({
mailbox: 9001,
type: 'diplomacy',
sourceId: 9001,
destinationId: 9002,
payload: {
...(coreMessage().payload as Record<string, unknown>),
option: { receiverMessageID: 41 },
},
});
expect(projectSemanticTurnMessages([coreSender], 40)).not.toEqual(
projectSemanticTurnMessages([referenceSender], 40)
);
expect(projectSemanticTurnMessages([referenceSender], 40)[0]?.option).toEqual({
kind: 'actionable-diplomacy-sender-redacted',
});
for (const option of [undefined, [], {}]) {
const mutantPayload = {
...(referenceSender.payload as Record<string, unknown>),
option,
};
expect(projectSemanticTurnMessages([{ ...referenceSender, payload: mutantPayload }], 40)).not.toEqual(
projectSemanticTurnMessages([referenceSender], 40)
);
}
});
it('keeps absolute before, after, and message timestamps in the strict single-tick timeline', () => {
const before = snapshot([], []);
const frozenAfter = snapshot([], [referenceMessage()]);
const advancedAfter = snapshot([], [referenceMessage()], '2026-08-23 01:02:03.124456');
expect(projectStrictTurnMessageTimeline(before, frozenAfter, 40)).toEqual({
beforeGameNow: '2026-08-23T01:02:03.123Z',
afterGameNow: '2026-08-23T01:02:03.123Z',
messageCreatedAts: ['2026-08-23T01:02:03.123Z'],
usesSingleTick: true,
});
expect(projectStrictTurnMessageTimeline(before, advancedAfter, 40)).toEqual({
beforeGameNow: '2026-08-23T01:02:03.123Z',
afterGameNow: '2026-08-23T01:02:03.124Z',
messageCreatedAts: ['2026-08-23T01:02:03.123Z'],
usesSingleTick: false,
});
});
it('projects explicit private and diplomacy unread deltas', () => {
expect(
projectSemanticUnreadMessageDeltas(
snapshot([general(1, 0, 2), general(2, 0, 0)]),
snapshot([general(1, 1, 2), general(2, 0, 1)])
)
).toEqual([
{
generalId: 1,
unreadPrivateBefore: 0,
unreadPrivateAfter: 1,
unreadPrivateDelta: 1,
unreadDiplomacyBefore: 2,
unreadDiplomacyAfter: 2,
unreadDiplomacyDelta: 0,
hadUnreadMessage: true,
hasUnreadMessage: true,
},
{
generalId: 2,
unreadPrivateBefore: 0,
unreadPrivateAfter: 0,
unreadPrivateDelta: 0,
unreadDiplomacyBefore: 0,
unreadDiplomacyAfter: 1,
unreadDiplomacyDelta: 1,
hadUnreadMessage: false,
hasUnreadMessage: true,
},
]);
});
});
@@ -0,0 +1,509 @@
import { GameClock, MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
import { describe, expect, it } from 'vitest';
import {
closeTurnSnapshotSelectorOverCreatedEntities,
projectCoreDatabaseSnapshot,
type CanonicalTurnSnapshot,
} from '../src/turn-differential/canonical.js';
import { compareTurnSnapshotDeltas, compareTurnSnapshots } from '../src/turn-differential/compare.js';
import { projectCoreMessageDrafts, projectCoreMessageReadState } from '../src/turn-differential/coreCommandTrace.js';
import { projectEffectiveCoreMessageValidUntil } from '../src/turn-differential/databaseSnapshot.js';
import { projectFullLifecycleSnapshotGraph } from '../src/turn-differential/fullLifecycleFixture.js';
interface CommandStateFixture {
generalMeta: Record<string, unknown>;
generalFields?: Record<string, unknown>;
nationMeta: Record<string, unknown>;
nationFields?: Record<string, unknown>;
}
const databaseSnapshot = (
latestReadPrivateMessage = 0,
commandStateFixture?: CommandStateFixture
): CanonicalTurnSnapshot =>
projectCoreDatabaseSnapshot({
world: {
currentYear: 183,
currentMonth: 1,
tickSeconds: 600,
meta: { lastTurnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
gameNow: new Date('0183-01-01T00:10:00.000Z'),
lastTurnTick: 0,
},
generals: [
{
id: 1,
name: '조조',
nationId: 1,
cityId: 1,
troopId: 1,
userId: 'owner-a',
meta: commandStateFixture?.generalMeta ?? {},
penalty: {},
...commandStateFixture?.generalFields,
},
],
rankData: [],
cities: [],
nations: commandStateFixture
? [
{
id: 1,
name: '위',
color: '#111111',
capitalCityId: 1,
gold: 1_000,
rice: 1_000,
tech: 100,
level: 1,
typeCode: 'che_명가',
meta: commandStateFixture.nationMeta,
...commandStateFixture.nationFields,
},
]
: [],
troops: [{ troopLeaderId: 1, nationId: 1, name: '조조군' }],
diplomacy: [],
generalTurns: [],
nationTurns: [],
logs: [],
messages: [
{
id: 71,
mailbox: 1,
type: 'private',
src: 2,
dest: 1,
time: new Date('0183-01-01T00:10:00.000Z'),
validUntil: new Date('0183-04-01T00:10:00.000Z'),
message: { text: '등용 서신' },
},
],
messageReadStates: [
{
generalId: 1,
latestPrivateMessage: latestReadPrivateMessage,
latestDiplomacyMessage: 0,
},
],
messageInboxRows: [{ id: 71, mailbox: 1, type: 'private', src: 2 }],
messageWatermark: 71,
});
describe('turn snapshot canonical blind-spot coverage', () => {
it('projects troop rows and detects a troop mutant', () => {
const reference = databaseSnapshot();
const core = {
...databaseSnapshot(),
troops: [{ id: 1, nationId: 1, name: '변조된 부대' }],
};
expect(reference.troops).toEqual([{ id: 1, nationId: 1, name: '조조군' }]);
expect(reference.world.gameNow).toBe('0183-01-01T00:10:00.000Z');
expect(compareTurnSnapshots(reference, core)).toContainEqual({
path: 'troops[1].name',
reference: '조조군',
core: '변조된 부대',
});
});
it('projects persisted message rows and detects a message mutant', () => {
const reference = databaseSnapshot();
const core = {
...databaseSnapshot(),
messages: [{ ...databaseSnapshot().messages[0], sourceId: 9 }],
};
expect(reference.messages).toEqual([
{
id: 71,
mailbox: 1,
type: 'private',
sourceId: 2,
destinationId: 1,
createdAt: '0183-01-01T00:10:00.000Z',
validUntil: '0183-04-01T00:10:00.000Z',
payload: { text: '등용 서신' },
},
]);
expect(reference.watermarks.messageId).toBe(71);
expect(compareTurnSnapshots(reference, core)).toContainEqual({
path: 'messages[0].sourceId',
reference: 2,
core: 9,
});
});
it('expands a Core message draft into the persisted receiver and sender rows', async () => {
const messages = await projectCoreMessageDrafts(
[
{
msgType: 'private',
src: {
generalId: 1,
generalName: '조조',
nationId: 1,
nationName: '위',
color: '#111111',
icon: '1.webp',
},
dest: {
generalId: 2,
generalName: '유비',
nationId: 2,
nationName: '촉',
color: '#222222',
icon: '2.webp',
},
text: '등용 서신',
time: new Date('0183-01-01T00:10:00.000Z'),
validUntil: new Date('0183-04-01T00:10:00.000Z'),
},
],
70
);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
id: 71,
mailbox: 2,
type: 'private',
sourceId: 1,
destinationId: 2,
validUntil: '0183-04-01T00:10:00.000Z',
payload: { text: '등용 서신' },
});
expect(messages[1]).toMatchObject({
id: 72,
mailbox: 1,
validUntil: '0183-04-01T00:10:00.000Z',
payload: { option: { receiverMessageID: 71 } },
});
expect(projectCoreMessageReadState(2, 2, messages)).toEqual({
unreadPrivateCount: 1,
unreadDiplomacyCount: 0,
hasUnreadMessage: true,
});
});
it('uses a persisted validity tick ahead of the Date fallback and keeps infinity explicit', () => {
const clock = new GameClock({
baseTime: new Date('0183-01-01T00:00:00.000Z'),
tick: 0,
mode: 'manual',
wallAnchor: new Date('0183-01-01T00:00:00.000Z'),
turnSeconds: 600,
});
const oneMinuteTick = clock.dateToTick(new Date('0183-01-01T00:01:00.000Z'));
expect(
projectEffectiveCoreMessageValidUntil(
{
validUntil: new Date('0183-01-02T00:00:00.000Z'),
validUntilTick: BigInt(oneMinuteTick),
},
clock
)
).toBe('0183-01-01T00:01:00.000Z');
expect(
projectEffectiveCoreMessageValidUntil(
{
validUntil: new Date('0183-01-02T00:00:00.000Z'),
validUntilTick: BigInt(MAX_SAFE_GAME_TICK),
},
clock
)
).toBe('infinite');
expect(
projectEffectiveCoreMessageValidUntil(
{ validUntil: new Date('0183-01-02T00:00:00.000Z'), validUntilTick: null },
clock
)
).toBe('0183-01-02T00:00:00.000Z');
});
it('compares stable owner identity instead of only owner presence', () => {
const reference = databaseSnapshot();
const core = {
...databaseSnapshot(),
generals: [{ ...databaseSnapshot().generals[0], ownerIdentity: 'owner-b' }],
};
expect(reference.generals[0]).toMatchObject({ hasOwner: true, ownerIdentity: 'owner-a' });
expect(compareTurnSnapshots(reference, core)).toContainEqual({
path: 'generals[1].ownerIdentity',
reference: 'owner-a',
core: 'owner-b',
});
});
it('detects a mutant that marks a generated incoming message as already read', () => {
const reference = databaseSnapshot();
const core = databaseSnapshot(71);
expect(reference.generals[0]?.messageReadState).toEqual({
unreadPrivateCount: 1,
unreadDiplomacyCount: 0,
hasUnreadMessage: true,
});
expect(compareTurnSnapshots(reference, core)).toEqual(
expect.arrayContaining([
{
path: 'generals[1].messageReadState.hasUnreadMessage',
reference: true,
core: false,
},
{
path: 'generals[1].messageReadState.unreadPrivateCount',
reference: 1,
core: 0,
},
])
);
});
it('closes the after selector over created entities and exposes an omission mutant', () => {
const selector = closeTurnSnapshotSelectorOverCreatedEntities(
{ generalIds: [1], cityIds: [1], nationIds: [1], troopIds: [] },
{ generalIds: [1], cityIds: [1], nationIds: [1], troopIds: [] },
{ generalIds: [1, 2], cityIds: [1], nationIds: [1, 2], troopIds: [2] }
);
expect(selector).toMatchObject({ generalIds: [1, 2], nationIds: [1, 2], troopIds: [2] });
const beforeReference = databaseSnapshot();
const afterReference = {
...databaseSnapshot(),
generals: [...databaseSnapshot().generals, { id: 2, ownerIdentity: null }],
};
const beforeCore = databaseSnapshot();
const afterCore = databaseSnapshot();
expect(compareTurnSnapshotDeltas(beforeReference, afterReference, beforeCore, afterCore)).not.toEqual([]);
});
it('keeps persisted actor rank rows in the full-lifecycle graph and catches an upsert omission', () => {
const snapshot = {
...databaseSnapshot(),
rankData: [
{ generalId: 1, nationId: 1, type: 'dedication', value: 1_015 },
{ generalId: 1, nationId: 1, type: 'experience', value: 1_015 },
],
};
const omitted = {
...snapshot,
rankData: snapshot.rankData.filter((row) => row.type !== 'experience'),
};
expect(projectFullLifecycleSnapshotGraph(snapshot).actorRankData).toEqual([
{ nationId: 1, type: 'dedication', value: 1_015 },
{ nationId: 1, type: 'experience', value: 1_015 },
]);
expect(projectFullLifecycleSnapshotGraph(omitted)).not.toEqual(projectFullLifecycleSnapshotGraph(snapshot));
});
it('projects command-semantic meta outside the ignored raw meta graph and catches omission mutants', () => {
const generalMeta = {
armType: 3,
explevel: 4,
dedlevel: 2,
npc_org: 4,
text: '의병 소개',
};
const generalFields = {
affinity: 37,
bornYear: 170,
deadYear: 210,
npcState: 4,
turnTick: 1_027_407n,
};
const nationMeta = {
can_국기변경: 1,
can_무작위수도이전: 1,
spy: { 7: 3 },
collapsed: true,
rate: 20,
bill: 100,
secretlimit: 3,
};
const expectedFixture = { generalMeta, generalFields, nationMeta };
const before = databaseSnapshot(0, { generalMeta: {}, nationMeta: {} });
const expectedAfter = databaseSnapshot(0, expectedFixture);
expect(expectedAfter.generals[0]).toMatchObject({
expLevel: 4,
dedLevel: 2,
affinity: 37,
bornYear: 170,
deadYear: 210,
npcState: 4,
npcOriginalState: 4,
npcMessage: '의병 소개',
turnTick: 1_027_407,
turnSecond: 17,
turnFraction: 123_450,
});
expect(expectedAfter.generals[0]?.commandState).toEqual({ recruitmentArmType: 3 });
expect(expectedAfter.nations[0]?.commandState).toEqual({
flagChangesRemaining: 1,
randomCapitalMovesRemaining: 1,
spy: [{ cityId: 7, remainingTurns: 3 }],
collapsed: true,
rate: 20,
bill: 100,
secretLimit: 3,
});
const ignoredRawMeta = [/^generals\[[^\]]+\]\.meta(?:\.|$)/, /^nations\[[^\]]+\]\.meta(?:\.|$)/];
const mutants: Array<{ path: string; snapshot: CanonicalTurnSnapshot }> = [
{
path: 'generals[1].commandState.recruitmentArmType',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalMeta: { ...generalMeta, armType: undefined },
}),
},
{
path: 'generals[1].expLevel',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalMeta: { ...generalMeta, explevel: 0 },
}),
},
{
path: 'generals[1].dedLevel',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalMeta: { ...generalMeta, dedlevel: 0 },
}),
},
{
path: 'generals[1].affinity',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, affinity: null },
}),
},
{
path: 'generals[1].bornYear',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, bornYear: 171 },
}),
},
{
path: 'generals[1].deadYear',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, deadYear: 211 },
}),
},
{
path: 'generals[1].npcState',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, npcState: 3 },
}),
},
{
path: 'generals[1].npcOriginalState',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalMeta: { ...generalMeta, npc_org: undefined },
}),
},
{
path: 'generals[1].npcMessage',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalMeta: { ...generalMeta, text: null },
}),
},
{
path: 'generals[1].turnSecond',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, turnTick: 1_087_407n },
}),
},
{
path: 'generals[1].turnFraction',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, turnTick: 1_027_408n },
}),
},
{
path: 'generals[1].turnTick',
snapshot: databaseSnapshot(0, {
...expectedFixture,
generalFields: { ...generalFields, turnTick: 1_027_408n },
}),
},
{
path: 'nations[1].commandState.flagChangesRemaining',
snapshot: databaseSnapshot(0, {
generalMeta,
generalFields,
nationMeta: { ...nationMeta, can_국기변경: 0 },
}),
},
{
path: 'nations[1].commandState.randomCapitalMovesRemaining',
snapshot: databaseSnapshot(0, {
generalMeta,
generalFields,
nationMeta: { ...nationMeta, can_무작위수도이전: 0 },
}),
},
{
path: 'nations[1].commandState.spy',
snapshot: databaseSnapshot(0, {
generalMeta,
generalFields,
nationMeta: { ...nationMeta, spy: {} },
}),
},
{
path: 'nations[1].commandState.collapsed',
snapshot: databaseSnapshot(0, {
generalMeta,
generalFields,
nationMeta: { ...nationMeta, collapsed: false },
}),
},
{
path: 'nations[1].commandState.rate',
snapshot: databaseSnapshot(0, {
...expectedFixture,
nationMeta: { ...nationMeta, rate: 0 },
}),
},
{
path: 'nations[1].commandState.bill',
snapshot: databaseSnapshot(0, {
...expectedFixture,
nationMeta: { ...nationMeta, bill: 0 },
}),
},
{
path: 'nations[1].commandState.secretLimit',
snapshot: databaseSnapshot(0, {
...expectedFixture,
nationMeta: { ...nationMeta, secretlimit: 2 },
}),
},
];
for (const mutant of mutants) {
const differences = compareTurnSnapshotDeltas(before, expectedAfter, before, mutant.snapshot, {
ignoredPathPatterns: ignoredRawMeta,
});
expect(
differences.some(
(difference) => difference.path === mutant.path || difference.path.startsWith(`${mutant.path}[`)
),
mutant.path
).toBe(true);
}
});
});
@@ -18,6 +18,7 @@ const snapshot = (
rankData: [],
cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }],
nations: [{ id: 1, gold: 0, rice: 0 }],
troops: [],
diplomacy: [],
generalTurns: [{ generalId: 1, turnIndex: 0, action: 'che_농지개간', args: null }],
nationTurns: [],
@@ -89,6 +90,114 @@ describe('turn snapshot differential comparator', () => {
).toEqual([]);
});
it('distinguishes a present empty collection from a missing property', () => {
const reference = snapshot('ref', {
world: {
year: 183,
month: 1,
tickMinutes: 10,
turnTime: '0183-01-01T00:00:00.000Z',
isUnited: 0,
nationCooldowns: [],
generalFlags: {},
},
});
const core = snapshot('core2026');
const differences = compareTurnSnapshots(reference, core);
expect(differences).toContainEqual({
path: 'world.nationCooldowns',
reference: { $snapshotState: 'array' },
core: { $snapshotState: 'missing' },
});
expect(differences).toContainEqual({
path: 'world.generalFlags',
reference: { $snapshotState: 'object' },
core: { $snapshotState: 'missing' },
});
expect(
compareTurnSnapshots(reference, core, {
ignoredPathPatterns: [/^world\.(?:nationCooldowns|generalFlags)(?:\.|$)/],
})
).toEqual([]);
});
it('distinguishes an empty JSON object from an empty JSON array', () => {
const reference = snapshot('ref', {
generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1, meta: {} }],
});
const core = snapshot('core2026', {
generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1, meta: [] }],
});
expect(compareTurnSnapshots(reference, core)).toContainEqual({
path: 'generals[1].meta',
reference: { $snapshotState: 'object' },
core: { $snapshotState: 'array' },
});
});
it('distinguishes collection deletion from replacement with an empty collection in deltas', () => {
const beforeRef = snapshot('ref', {
world: {
year: 183,
month: 1,
tickMinutes: 10,
turnTime: '0183-01-01T00:00:00.000Z',
isUnited: 0,
nationCooldowns: [{ nationId: 1, remaining: 2 }],
},
});
const afterRef = snapshot('ref');
const beforeCore = snapshot('core2026', {
world: {
year: 183,
month: 1,
tickMinutes: 10,
turnTime: '0183-01-01T00:00:00.000Z',
isUnited: 0,
nationCooldowns: [{ nationId: 1, remaining: 2 }],
},
});
const afterCore = snapshot('core2026', {
world: {
year: 183,
month: 1,
tickMinutes: 10,
turnTime: '0183-01-01T00:00:00.000Z',
isUnited: 0,
nationCooldowns: [],
},
});
expect(compareTurnSnapshotDeltas(beforeRef, afterRef, beforeCore, afterCore)).toContainEqual({
path: 'world.nationCooldowns',
reference: {
before: { $snapshotState: 'array' },
after: { $snapshotState: 'missing' },
},
core: { $snapshotState: 'missing' },
});
});
it('fails closed when an entity array repeats a semantic key', () => {
const reference = snapshot('ref', {
cities: [
{ id: 1, nationId: 1, agriculture: 900 },
{ id: 1, nationId: 1, agriculture: 1000 },
],
});
const core = snapshot('core2026', {
cities: [{ id: 1, nationId: 1, agriculture: 1000 }],
});
const expectedError = 'Duplicate semantic entity key "1" at "cities": indexes 0 and 1';
expect(() => compareTurnSnapshots(reference, core)).toThrowError(expectedError);
expect(() => compareTurnSnapshotDeltas(snapshot('ref'), reference, snapshot('core2026'), core)).toThrowError(
expectedError
);
});
it('reports exact changed paths for general and nation command state', () => {
const reference = snapshot('ref', {
diplomacy: [{ fromNationId: 1, toNationId: 2, state: 1, term: 24 }],
@@ -154,8 +263,8 @@ describe('turn snapshot differential comparator', () => {
expect(compareTurnSnapshotDeltas(beforeRef, afterRef, beforeCore, afterCore)).toContainEqual({
path: 'nations[2].gold',
reference: { before: 0, after: undefined },
core: undefined,
reference: { before: 0, after: { $snapshotState: 'missing' } },
core: { $snapshotState: 'missing' },
});
});
});
@@ -12,11 +12,13 @@ const ids = {
city: 2_147_000_102,
nation: 2_147_000_103,
};
const ownerIdentity = 'turn-differential-database-owner';
integration('core2026 turn state database snapshot adapter', () => {
let db: GamePrismaClient;
let disconnect: (() => Promise<void>) | undefined;
let createdWorldId: number | null = null;
let createdMessageId: number | null = null;
beforeAll(async () => {
const connector = createGamePostgresConnector({ url: databaseUrl! });
@@ -80,6 +82,7 @@ integration('core2026 turn state database snapshot adapter', () => {
await db.general.create({
data: {
id: ids.general,
userId: ownerIdentity,
name: '비교장수',
nationId: ids.nation,
cityId: ids.city,
@@ -97,9 +100,33 @@ integration('core2026 turn state database snapshot adapter', () => {
meta: { killturn: 24, myset: 6, intel_exp: 3 },
},
});
await db.troop.create({
data: {
troopLeaderId: ids.general,
nationId: ids.nation,
name: '비교부대',
},
});
createdMessageId = (
await db.message.create({
data: {
mailbox: ids.general,
type: 'private',
src: ids.general,
dest: ids.general,
time: new Date('0183-01-01T00:01:00.000Z'),
validUntil: new Date('9999-12-31T00:00:00.000Z'),
message: { text: '비교 메시지' },
},
})
).id;
});
afterAll(async () => {
if (createdMessageId !== null) {
await db.message.deleteMany({ where: { id: createdMessageId } });
}
await db.troop.deleteMany({ where: { troopLeaderId: ids.general } });
await db.general.deleteMany({ where: { id: ids.general } });
await db.city.deleteMany({ where: { id: ids.city } });
await db.nation.deleteMany({ where: { id: ids.nation } });
@@ -114,6 +141,8 @@ integration('core2026 turn state database snapshot adapter', () => {
generalIds: [ids.general],
cityIds: [ids.city],
nationIds: [ids.nation],
troopIds: [ids.general],
messageAfterId: (createdMessageId ?? 1) - 1,
});
expect(result.engine).toBe('core2026');
@@ -125,6 +154,7 @@ integration('core2026 turn state database snapshot adapter', () => {
intelligence: 80,
killTurn: 24,
mySet: 6,
ownerIdentity,
})
);
expect(result.cities).toContainEqual(
@@ -143,6 +173,18 @@ integration('core2026 turn state database snapshot adapter', () => {
power: 300,
})
);
expect(result.troops).toContainEqual({ id: ids.general, nationId: ids.nation, name: '비교부대' });
expect(result.messages).toContainEqual(
expect.objectContaining({
id: createdMessageId,
mailbox: ids.general,
type: 'private',
sourceId: ids.general,
destinationId: ids.general,
validUntil: 'infinite',
payload: { text: '비교 메시지' },
})
);
});
it('captures before/after state around a real database execution boundary', async () => {