fix: 커맨드 차등 생명주기와 로그 그래프를 보강
장수 55종과 수뇌 35종의 상태, 로그, 메시지, 예약 턴 비교를 닫습니다. 실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
@@ -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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user