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,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());
});
});