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

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

실제 시나리오 프로필과 대표 PostgreSQL 수명주기, 즉시 외교와 출병 회귀를 추가하고 발견된 Ref 로그 및 생성 장수 저장 차이를 교정합니다.
This commit is contained in:
2026-08-23 21:48:29 +00:00
parent 85591c68ad
commit c63a49bd07
128 changed files with 8615 additions and 848 deletions
@@ -1,9 +1,17 @@
import { GAME_TICKS_PER_TURN, LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common';
export type CanonicalEngine = 'ref' | 'core2026';
export interface TurnSnapshotSelector {
generalIds: number[];
cityIds: number[];
nationIds: number[];
troopIds?: number[];
allGenerals?: boolean;
allCities?: boolean;
allNations?: boolean;
allTroops?: boolean;
includeRankMirrors?: boolean;
logAfterId?: number;
messageAfterId?: number;
includeNationHistoryLogs?: boolean;
@@ -18,6 +26,7 @@ export interface CanonicalTurnSnapshot {
rankData: Array<Record<string, unknown>>;
cities: Array<Record<string, unknown>>;
nations: Array<Record<string, unknown>>;
troops: Array<Record<string, unknown>>;
diplomacy: Array<Record<string, unknown>>;
generalTurns: Array<Record<string, unknown>>;
nationTurns: Array<Record<string, unknown>>;
@@ -30,6 +39,39 @@ export interface CanonicalTurnSnapshot {
};
}
export interface TurnSnapshotEntityIds {
generalIds: number[];
cityIds: number[];
nationIds: number[];
troopIds: number[];
}
const unionEntityIds = (selected: readonly number[] | undefined, created: readonly number[]): number[] =>
[...new Set([...(selected ?? []), ...created])].sort((left, right) => left - right);
/**
* Keep the explicit observation boundary, but extend the after snapshot over
* entities created during the execution. Otherwise a successful create can be
* absent from both the selector query and the resulting differential.
*/
export const closeTurnSnapshotSelectorOverCreatedEntities = (
selector: TurnSnapshotSelector,
before: TurnSnapshotEntityIds,
after: TurnSnapshotEntityIds
): TurnSnapshotSelector => {
const created = <Key extends keyof TurnSnapshotEntityIds>(key: Key): number[] => {
const previous = new Set(before[key]);
return after[key].filter((id) => !previous.has(id));
};
return {
...selector,
generalIds: unionEntityIds(selector.generalIds, created('generalIds')),
cityIds: unionEntityIds(selector.cityIds, created('cityIds')),
nationIds: unionEntityIds(selector.nationIds, created('nationIds')),
troopIds: unionEntityIds(selector.troopIds, created('troopIds')),
};
};
export interface CanonicalTurnCommandTrace {
schemaVersion: 1;
engine: CanonicalEngine;
@@ -85,7 +127,191 @@ const readString = (record: Record<string, unknown>, key: string): string | null
return typeof value === 'string' ? value : null;
};
const readCommandInteger = (value: unknown, field: string, fallback: number | null): number | null => {
if (value === null || value === undefined) {
return fallback;
}
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
throw new Error(`${field} must be a safe integer`);
}
return value;
};
const readCommandBoolean = (value: unknown, field: string): boolean => {
if (value === null || value === undefined || value === false || value === 0) {
return false;
}
if (value === true || value === 1) {
return true;
}
throw new Error(`${field} must be a boolean flag`);
};
const readCommandOptionalString = (value: unknown, field: string): string | null => {
if (value === null || value === undefined || value === '') {
return null;
}
if (typeof value !== 'string') {
throw new Error(`${field} must be a string`);
}
return value;
};
const readCommandValue = (
fields: Record<string, unknown>,
fieldKey: string,
meta: Record<string, unknown>,
metaKey = fieldKey
): unknown => (Object.prototype.hasOwnProperty.call(fields, fieldKey) ? fields[fieldKey] : meta[metaKey]);
const readSafeTick = (value: unknown, field: string): number | null => {
if (value === null || value === undefined) {
return null;
}
const numeric = typeof value === 'bigint' ? Number(value) : value;
if (typeof numeric !== 'number' || !Number.isSafeInteger(numeric)) {
throw new Error(`${field} must be a safe integer`);
}
return numeric;
};
export const projectCanonicalTurnOffset = (
turnTickValue: unknown,
baseTurnTickValue: unknown,
turnSecondsValue: unknown
): { turnSecond: number | null; turnFraction: number | null } => {
const turnTick = readSafeTick(turnTickValue, 'general.turnTick');
const baseTurnTick = readSafeTick(baseTurnTickValue, 'world.lastTurnTick');
if (turnTick === null || baseTurnTick === null) {
return { turnSecond: null, turnFraction: null };
}
const turnSeconds = readCommandInteger(turnSecondsValue, 'world.tickSeconds', null);
if (turnSeconds === null || turnSeconds <= 0 || GAME_TICKS_PER_TURN % turnSeconds !== 0) {
throw new Error('world.tickSeconds must divide the legacy game-turn tick domain');
}
const ticksPerSecond = GAME_TICKS_PER_TURN / turnSeconds;
const offsetTicks = turnTick - baseTurnTick;
const turnSecond = Math.floor(offsetTicks / ticksPerSecond);
const remainingTicks = offsetTicks - turnSecond * ticksPerSecond;
return {
turnSecond,
turnFraction: Math.floor((remainingTicks * 1_000_000) / ticksPerSecond),
};
};
const projectCanonicalSpyState = (value: unknown): Array<{ cityId: number; remainingTurns: number }> => {
if (value === null || value === undefined) {
return [];
}
if (typeof value !== 'object') {
throw new Error('nation.commandState.spy must be an object');
}
return Object.entries(value)
.map(([cityIdText, remainingTurns]) => {
const cityId = Number(cityIdText);
if (!Number.isSafeInteger(cityId) || cityId < 1) {
throw new Error(`nation.commandState.spy has an invalid city id: ${cityIdText}`);
}
const turns = readCommandInteger(remainingTurns, `nation.commandState.spy[${cityIdText}]`, null);
if (turns === null) {
throw new Error(`nation.commandState.spy[${cityIdText}] is missing`);
}
return { cityId, remainingTurns: turns };
})
.sort((left, right) => left.cityId - right.cityId);
};
/** Command-relevant General.aux fields kept outside the intentionally ignored raw meta graph. */
export const projectCanonicalGeneralCommandState = (metaValue: unknown): Record<string, unknown> => {
const meta = asRecord(metaValue);
return {
recruitmentArmType: readCommandInteger(meta.armType, 'general.commandState.recruitmentArmType', null),
};
};
/** Persisted General columns/semantics that commands mutate or initialize. */
export const projectCanonicalGeneralStoredFields = (
metaValue: unknown,
fieldsValue: unknown = {}
): Record<string, unknown> => {
const meta = asRecord(metaValue);
const fields = asRecord(fieldsValue);
return {
expLevel: readCommandInteger(readCommandValue(fields, 'expLevel', meta, 'explevel'), 'general.expLevel', 0),
dedLevel: readCommandInteger(readCommandValue(fields, 'dedLevel', meta, 'dedlevel'), 'general.dedLevel', 0),
affinity: readCommandInteger(readCommandValue(fields, 'affinity', meta), 'general.affinity', null),
bornYear: readCommandInteger(readCommandValue(fields, 'bornYear', meta, 'birthYear'), 'general.bornYear', null),
deadYear: readCommandInteger(readCommandValue(fields, 'deadYear', meta, 'deathYear'), 'general.deadYear', null),
npcMessage: readCommandOptionalString(
readCommandValue(fields, 'npcMessage', meta, 'text'),
'general.npcMessage'
),
npcOriginalState: readCommandInteger(
readCommandValue(fields, 'npcOriginalState', meta, 'npc_org'),
'general.npcOriginalState',
0
),
turnTick: readSafeTick(readCommandValue(fields, 'turnTick', meta), 'general.turnTick'),
turnSecond: readCommandInteger(fields.turnSecond, 'general.turnSecond', null),
turnFraction: readCommandInteger(fields.turnFraction, 'general.turnFraction', null),
};
};
/** Command-relevant nation aux/spy fields kept outside the intentionally ignored raw meta graph. */
export const projectCanonicalNationCommandState = (
metaValue: unknown,
spyValue: unknown = asRecord(metaValue).spy,
fieldsValue: unknown = {}
): Record<string, unknown> => {
const meta = asRecord(metaValue);
const fields = asRecord(fieldsValue);
return {
flagChangesRemaining: readCommandInteger(meta.can_국기변경, 'nation.commandState.flagChangesRemaining', 0),
randomCapitalMovesRemaining: readCommandInteger(
meta.can_무작위수도이전,
'nation.commandState.randomCapitalMovesRemaining',
0
),
spy: projectCanonicalSpyState(spyValue),
collapsed: readCommandBoolean(meta.collapsed, 'nation.commandState.collapsed'),
rate: readCommandInteger(readCommandValue(fields, 'rate', meta), 'nation.commandState.rate', 0),
bill: readCommandInteger(readCommandValue(fields, 'bill', meta), 'nation.commandState.bill', 0),
secretLimit: readCommandInteger(
readCommandValue(fields, 'secretLimit', meta, 'secretlimit'),
'nation.commandState.secretLimit',
3
),
};
};
const serializeDate = (value: Date | null): string | null => value?.toISOString() ?? null;
const messageMailboxNationalBase = 9_000;
export const CANONICAL_MESSAGE_VALID_UNTIL_INFINITE = 'infinite' as const;
/**
* Ref persists an unbounded message lifetime as GameClock::MAX_SAFE_TICK,
* while Core's Date fallback persists the legacy year-9999 sentinel. Keep the
* semantic distinction explicit instead of conflating it with null/missing.
*/
export const projectCanonicalMessageValidUntil = (
value: unknown
): string | typeof CANONICAL_MESSAGE_VALID_UNTIL_INFINITE => {
if (value === CANONICAL_MESSAGE_VALID_UNTIL_INFINITE) {
return value;
}
if (value === null || value === undefined) {
throw new Error('message.validUntil must be a finite timestamp or the infinite sentinel');
}
const date = value instanceof Date ? value : new Date(String(value));
if (Number.isNaN(date.getTime())) {
throw new Error(`message.validUntil must be a valid timestamp: ${String(value)}`);
}
if (date.getUTCFullYear() === 9999) {
return CANONICAL_MESSAGE_VALID_UNTIL_INFINITE;
}
return date.toISOString();
};
export const projectCoreDatabaseSnapshot = (rows: {
world: {
@@ -93,20 +319,53 @@ export const projectCoreDatabaseSnapshot = (rows: {
currentMonth: number;
tickSeconds: number;
meta: unknown;
gameNow?: Date | string;
lastTurnTick?: bigint | number | null;
};
generals: Array<Record<string, unknown>>;
rankData: Array<Record<string, unknown>>;
cities: Array<Record<string, unknown>>;
nations: Array<Record<string, unknown>>;
troops: Array<Record<string, unknown>>;
diplomacy: Array<Record<string, unknown>>;
generalTurns: Array<Record<string, unknown>>;
nationTurns: Array<Record<string, unknown>>;
logs: Array<Record<string, unknown>>;
messages: Array<Record<string, unknown>>;
messageReadStates?: Array<Record<string, unknown>>;
messageInboxRows?: Array<Record<string, unknown>>;
messageWatermark?: number;
includeRankMirrors?: boolean;
}): CanonicalTurnSnapshot => {
const worldMeta = asRecord(rows.world.meta);
const legacyRankTypes = new Set<string>(LEGACY_RANK_DATA_TYPES);
const projectedRankTypes = new Set<string>(rows.includeRankMirrors ? RANK_DATA_TYPES : LEGACY_RANK_DATA_TYPES);
const messageReadStateByGeneralId = new Map(
(rows.messageReadStates ?? []).map((row) => [readNumber(row, 'generalId'), row] as const)
);
const messageInboxRows = rows.messageInboxRows ?? [];
const generals = rows.generals.map((row) => {
const meta = asRecord(row.meta);
const turnOffset = projectCanonicalTurnOffset(row.turnTick, rows.world.lastTurnTick, rows.world.tickSeconds);
const generalId = readNumber(row, 'id');
const nationId = readNumber(row, 'nationId');
const readState = messageReadStateByGeneralId.get(generalId) ?? {};
const latestReadPrivateMessageId = readNumber(readState, 'latestPrivateMessage');
const latestReadDiplomacyMessageId = readNumber(readState, 'latestDiplomacyMessage');
const diplomacyMailbox = messageMailboxNationalBase + nationId;
const unreadPrivateCount = messageInboxRows.filter(
(message) =>
message.type === 'private' &&
readNumber(message, 'mailbox') === generalId &&
readNumber(message, 'src') !== generalId &&
readNumber(message, 'id') > latestReadPrivateMessageId
).length;
const unreadDiplomacyCount = messageInboxRows.filter(
(message) =>
message.type === 'diplomacy' &&
readNumber(message, 'mailbox') === diplomacyMailbox &&
readNumber(message, 'src') !== diplomacyMailbox &&
readNumber(message, 'id') > latestReadDiplomacyMessageId
).length;
return {
id: row.id,
name: row.name,
@@ -136,6 +395,8 @@ export const projectCoreDatabaseSnapshot = (rows: {
itemWeapon: row.itemWeapon ?? null,
itemBook: row.itemBook ?? null,
itemExtra: row.itemExtra ?? null,
picture: row.picture ?? null,
imageServer: readNumber(row, 'imageServer'),
injury: row.injury,
gold: row.gold,
rice: row.rice,
@@ -146,10 +407,27 @@ export const projectCoreDatabaseSnapshot = (rows: {
age: row.age,
npcState: row.npcState,
hasOwner: typeof row.userId === 'string' && row.userId.length > 0,
ownerIdentity: typeof row.userId === 'string' && row.userId.length > 0 ? row.userId : null,
messageReadState: {
unreadPrivateCount,
unreadDiplomacyCount,
hasUnreadMessage: unreadPrivateCount + unreadDiplomacyCount > 0,
},
turnTime: row.turnTime instanceof Date ? serializeDate(row.turnTime) : row.turnTime,
recentWarTime: row.recentWarTime instanceof Date ? serializeDate(row.recentWarTime) : row.recentWarTime,
lastTurn: row.lastTurn,
meta,
...projectCanonicalGeneralStoredFields(meta, {
expLevel: meta.explevel,
dedLevel: meta.dedlevel,
affinity: row.affinity,
bornYear: row.bornYear,
deadYear: row.deadYear,
npcOriginalState: meta.npc_org,
turnTick: row.turnTick,
...turnOffset,
}),
commandState: projectCanonicalGeneralCommandState(meta),
leadershipExp: readNumber(meta, 'leadership_exp'),
strengthExp: readNumber(meta, 'strength_exp'),
intelExp: readNumber(meta, 'intel_exp'),
@@ -215,8 +493,14 @@ export const projectCoreDatabaseSnapshot = (rows: {
capitalRevision: readNumber(meta, 'capset'),
strategicCommandLimit: readNumber(meta, 'strategic_cmd_limit'),
meta,
commandState: projectCanonicalNationCommandState(meta),
};
});
const troops = rows.troops.map((row) => ({
id: row.troopLeaderId,
nationId: row.nationId,
name: row.name,
}));
const diplomacy = rows.diplomacy.map((row) => ({
fromNationId: row.srcNationId,
toNationId: row.destNationId,
@@ -247,6 +531,18 @@ export const projectCoreDatabaseSnapshot = (rows: {
month: row.month,
text: row.text,
}));
const messages = rows.messages.map((row) => ({
id: row.id,
mailbox: row.mailbox,
type: row.type,
sourceId: row.src,
destinationId: row.dest,
createdAt: row.time instanceof Date ? serializeDate(row.time) : row.time,
validUntil: projectCanonicalMessageValidUntil(
Object.prototype.hasOwnProperty.call(row, 'effectiveValidUntil') ? row.effectiveValidUntil : row.validUntil
),
payload: row.message,
}));
return {
schemaVersion: 1,
@@ -255,12 +551,19 @@ export const projectCoreDatabaseSnapshot = (rows: {
year: rows.world.currentYear,
month: rows.world.currentMonth,
tickMinutes: Math.max(1, Math.round(rows.world.tickSeconds / 60)),
lastTurnTick: readSafeTick(rows.world.lastTurnTick, 'world.lastTurnTick'),
turnTime: readString(worldMeta, 'lastTurnTime'),
...(rows.world.gameNow !== undefined
? {
gameNow:
rows.world.gameNow instanceof Date ? serializeDate(rows.world.gameNow) : rows.world.gameNow,
}
: {}),
isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')),
},
generals,
rankData: rows.rankData
.filter((row) => typeof row.type === 'string' && legacyRankTypes.has(row.type))
.filter((row) => typeof row.type === 'string' && projectedRankTypes.has(row.type))
.map((row) => ({
generalId: row.generalId,
nationId: row.nationId,
@@ -269,16 +572,16 @@ export const projectCoreDatabaseSnapshot = (rows: {
})),
cities,
nations,
troops,
diplomacy,
generalTurns,
nationTurns,
logs,
messages: [],
messages,
watermarks: {
logId: logs.reduce((max, row) => Math.max(max, Number(row.id) || 0), 0),
historyLogId: logs.reduce((max, row) => Math.max(max, Number(row.id) || 0), 0),
messageId: 0,
messageId: rows.messageWatermark ?? messages.reduce((max, row) => Math.max(max, Number(row.id) || 0), 0),
},
};
};
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
@@ -13,42 +13,74 @@ export interface SnapshotComparisonOptions {
type FlatSnapshot = Map<string, unknown>;
const entityKey = (value: Record<string, unknown>, index: number): string => {
const flatArray = Symbol('turn-snapshot-array');
const flatObject = Symbol('turn-snapshot-object');
const flatMissing = Symbol('turn-snapshot-missing');
const publicFlatStates = {
array: Object.freeze({ $snapshotState: 'array' }),
object: Object.freeze({ $snapshotState: 'object' }),
missing: Object.freeze({ $snapshotState: 'missing' }),
} as const;
interface EntityIdentity {
key: string;
semantic: boolean;
}
const entityIdentity = (value: Record<string, unknown>, index: number): EntityIdentity => {
if (
(typeof value.generalId === 'number' || typeof value.generalId === 'string') &&
typeof value.type === 'string'
) {
return `${String(value.generalId)}:${value.type}`;
return { key: `${String(value.generalId)}:${value.type}`, semantic: true };
}
for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) {
const candidate = value[key];
if (typeof candidate === 'number' || typeof candidate === 'string') {
if (key === 'fromNationId' && value.toNationId !== undefined) {
return `${String(candidate)}->${String(value.toNationId)}`;
return { key: `${String(candidate)}->${String(value.toNationId)}`, semantic: true };
}
if (value.turnIndex !== undefined) {
return `${String(candidate)}:${String(value.officerLevel ?? '')}:${String(value.turnIndex)}`;
return {
key: `${String(candidate)}:${String(value.officerLevel ?? '')}:${String(value.turnIndex)}`,
semantic: true,
};
}
return String(candidate);
return { key: String(candidate), semantic: true };
}
}
return String(index);
return { key: String(index), semantic: false };
};
const flatten = (value: unknown, path: string, output: FlatSnapshot): void => {
if (Array.isArray(value)) {
output.set(path, flatArray);
const semanticKeys = new Map<string, number>();
value.forEach((entry, index) => {
const key =
const identity =
path === 'logs' || path === 'messages'
? String(index)
? { key: String(index), semantic: false }
: typeof entry === 'object' && entry !== null && !Array.isArray(entry)
? entityKey(entry as Record<string, unknown>, index)
: String(index);
flatten(entry, `${path}[${key}]`, output);
? entityIdentity(entry as Record<string, unknown>, index)
: { key: String(index), semantic: false };
if (identity.semantic) {
const firstIndex = semanticKeys.get(identity.key);
if (firstIndex !== undefined) {
throw new Error(
`Duplicate semantic entity key ${JSON.stringify(identity.key)} at ${JSON.stringify(
path
)}: indexes ${firstIndex} and ${index}`
);
}
semanticKeys.set(identity.key, index);
}
flatten(entry, `${path}[${identity.key}]`, output);
});
return;
}
if (typeof value === 'object' && value !== null) {
output.set(path, flatObject);
const record = value as Record<string, unknown>;
for (const key of Object.keys(record).sort()) {
flatten(record[key], path ? `${path}.${key}` : key, output);
@@ -65,6 +97,22 @@ const canonicalFlatSnapshot = (snapshot: CanonicalTurnSnapshot): FlatSnapshot =>
return output;
};
const flatValueAt = (snapshot: FlatSnapshot, path: string): unknown =>
snapshot.has(path) ? snapshot.get(path) : flatMissing;
const publicFlatValue = (value: unknown): unknown => {
if (value === flatArray) {
return publicFlatStates.array;
}
if (value === flatObject) {
return publicFlatStates.object;
}
if (value === flatMissing) {
return publicFlatStates.missing;
}
return value;
};
const valuesEqual = (left: unknown, right: unknown, numericTolerance: number): boolean => {
if (typeof left === 'number' && typeof right === 'number') {
return Math.abs(left - right) <= numericTolerance;
@@ -96,11 +144,11 @@ export const compareTurnSnapshots = (
const paths = [...new Set([...referenceFlat.keys(), ...coreFlat.keys()])].sort();
return paths
.filter((path) => !ignored.some((pattern) => pattern.test(path)))
.filter((path) => !valuesEqual(referenceFlat.get(path), coreFlat.get(path), tolerance))
.filter((path) => !valuesEqual(flatValueAt(referenceFlat, path), flatValueAt(coreFlat, path), tolerance))
.map((path) => ({
path,
reference: referenceFlat.get(path),
core: coreFlat.get(path),
reference: publicFlatValue(flatValueAt(referenceFlat, path)),
core: publicFlatValue(flatValueAt(coreFlat, path)),
}));
};
@@ -113,15 +161,15 @@ export const buildTurnSnapshotDelta = (
const paths = [...new Set([...beforeFlat.keys(), ...afterFlat.keys()])].sort();
const delta = new Map<string, unknown>();
for (const path of paths) {
const previous = beforeFlat.get(path);
const next = afterFlat.get(path);
const previous = flatValueAt(beforeFlat, path);
const next = flatValueAt(afterFlat, path);
if (Object.is(previous, next)) {
continue;
}
if (typeof previous === 'number' && typeof next === 'number') {
delta.set(path, next - previous);
} else {
delta.set(path, { before: previous, after: next });
delta.set(path, { before: publicFlatValue(previous), after: publicFlatValue(next) });
}
}
return delta;
@@ -141,10 +189,10 @@ export const compareTurnSnapshotDeltas = (
const paths = [...new Set([...referenceDelta.keys(), ...coreDelta.keys()])].sort();
return paths
.filter((path) => !ignored.some((pattern) => pattern.test(path)))
.filter((path) => !valuesEqual(referenceDelta.get(path), coreDelta.get(path), tolerance))
.filter((path) => !valuesEqual(flatValueAt(referenceDelta, path), flatValueAt(coreDelta, path), tolerance))
.map((path) => ({
path,
reference: referenceDelta.get(path),
core: coreDelta.get(path),
reference: publicFlatValue(flatValueAt(referenceDelta, path)),
core: publicFlatValue(flatValueAt(coreDelta, path)),
}));
};
@@ -0,0 +1,211 @@
import { buildPersistedRankRows } from '@sammo-ts/game-engine/turn/rankData.js';
import type { GamePrismaClient, InputJsonValue } from '@sammo-ts/infra';
import type { CanonicalTurnSnapshot } from './canonical.js';
import type { buildCoreTurnCommandWorldInput } from './coreCommandTrace.js';
type CoreTurnCommandWorldInput = ReturnType<typeof buildCoreTurnCommandWorldInput>;
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
const nullableCode = (value: string | null | undefined): string => value ?? 'None';
const turnArgs = (value: unknown): InputJsonValue =>
asJson(typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {});
const createManyIfPresent = async <Row>(
rows: Row[],
createMany: (args: { data: Row[] }) => Promise<unknown>
): Promise<void> => {
if (rows.length > 0) {
await createMany({ data: rows });
}
};
export const clearCoreTurnCommandPersistenceFixture = async (db: GamePrismaClient): Promise<void> => {
await db.message.deleteMany();
await db.messageReadState.deleteMany();
await db.webPushOutbox.deleteMany();
await db.readModelOutbox.deleteMany();
await db.readModelRevision.deleteMany();
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();
};
export const seedCoreTurnCommandPersistenceFixture = async (
db: GamePrismaClient,
input: {
worldInput: CoreTurnCommandWorldInput;
generalTurns: CanonicalTurnSnapshot['generalTurns'];
nationTurns?: CanonicalTurnSnapshot['nationTurns'];
scenarioCode: string;
}
): Promise<void> => {
const { state, snapshot, map } = input.worldInput;
await db.worldState.create({
data: {
id: state.id,
scenarioCode: input.scenarioCode,
currentYear: state.currentYear,
currentMonth: state.currentMonth,
tickSeconds: state.tickSeconds,
config: asJson(snapshot.scenarioConfig),
meta: asJson({
...state.meta,
...(snapshot.scenarioMeta ? { scenarioMeta: snapshot.scenarioMeta } : {}),
}),
},
});
await createManyIfPresent(
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),
})),
(args) => db.nation.createMany(args)
);
await createManyIfPresent(
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 }),
};
}),
(args) => db.city.createMany(args)
);
await createManyIfPresent(
snapshot.troops.map((troop) => ({
troopLeaderId: troop.id,
nationId: troop.nationId,
name: troop.name,
})),
(args) => db.troop.createMany(args)
);
await createManyIfPresent(
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,
// Preserve the canonical fixture's container exactly. Ref's
// pre-command last_turn may be an empty object; synthesizing a
// 휴식 command here changes the graph before the lifecycle runs.
lastTurn: asJson(general.lastTurn ?? {}),
meta: asJson(general.meta),
penalty: asJson(general.penalty ?? {}),
})),
(args) => db.general.createMany(args)
);
await createManyIfPresent(
snapshot.generals.flatMap((general) =>
buildPersistedRankRows(general).map((row) => ({
generalId: row.generalId,
nationId: row.nationId,
type: row.type,
value: row.value,
}))
),
(args) => db.rankData.createMany(args)
);
await createManyIfPresent(
snapshot.diplomacy.map((entry) => ({
srcNationId: entry.fromNationId,
destNationId: entry.toNationId,
stateCode: entry.state,
term: entry.term,
isDead: entry.dead !== 0,
meta: asJson(entry.meta),
})),
(args) => db.diplomacy.createMany(args)
);
await createManyIfPresent(
input.generalTurns.map((turn) => ({
generalId: Number(turn.generalId),
turnIdx: Number(turn.turnIndex),
actionCode: String(turn.action),
arg: turnArgs(turn.args),
})),
(args) => db.generalTurn.createMany(args)
);
await createManyIfPresent(
(input.nationTurns ?? []).map((turn) => ({
nationId: Number(turn.nationId),
officerLevel: Number(turn.officerLevel),
turnIdx: Number(turn.turnIndex),
actionCode: String(turn.action),
arg: turnArgs(turn.args),
})),
(args) => db.nationTurn.createMany(args)
);
};
@@ -1,11 +1,15 @@
import { LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import { GameClock, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common';
import { readLegacyStoredFloat } from '@sammo-ts/logic/compat/legacyFloat.js';
import {
GENERAL_TURN_COMMAND_KEYS,
LogFormat,
NATION_TURN_COMMAND_KEYS,
normalizeScenarioEffect,
readLegacyCityTrust,
sendMessage,
type MapDefinition,
type MessageDraft,
type MessageRecordDraft,
type Nation,
type TurnCommandProfile,
type UnitSetDefinition,
@@ -21,12 +25,25 @@ import type {
TurnWorldSnapshot,
TurnWorldState,
} from '@sammo-ts/game-engine/turn/types.js';
import { applyPersistedRankRowsToMeta, buildLegacyComparableRankRows } from '@sammo-ts/game-engine/turn/rankData.js';
import {
applyPersistedRankRowsToMeta,
buildInitialRankRows,
buildLegacyComparableInitialRankRows,
buildLegacyComparableRankRows,
buildPersistedRankRows,
} from '@sammo-ts/game-engine/turn/rankData.js';
import {
canonicalizeTurnCommandArgs,
closeTurnSnapshotSelectorOverCreatedEntities,
projectCanonicalGeneralCommandState,
projectCanonicalGeneralStoredFields,
projectCanonicalMessageValidUntil,
projectCanonicalNationCommandState,
projectCanonicalTurnOffset,
type CanonicalTurnCommandTrace,
type CanonicalTurnSnapshot,
type TurnSnapshotEntityIds,
} from './canonical.js';
interface GeneralCooldownSelector {
@@ -56,6 +73,8 @@ export interface TurnCommandFixtureRequest {
hiddenSeed?: string;
scenarioEffect?: string | null;
staticEventHandlers?: Record<string, string[]>;
freezeClock?: boolean;
messageSharedIconBaseUrl?: string;
};
isolateWorld?: boolean;
generals?: Array<Record<string, unknown>>;
@@ -73,6 +92,12 @@ export interface TurnCommandFixtureRequest {
generalIds?: number[];
cityIds?: number[];
nationIds?: number[];
troopIds?: number[];
allGenerals?: boolean;
allCities?: boolean;
allNations?: boolean;
allTroops?: boolean;
includeRankMirrors?: boolean;
logAfterId?: number;
messageAfterId?: number;
includeNationHistoryLogs?: boolean;
@@ -164,6 +189,18 @@ const readNullableString = (record: Record<string, unknown>, key: string): strin
return typeof value === 'string' && value !== '' && value !== 'None' ? value : null;
};
const parseSnapshotDate = (value: unknown, fallback: Date): Date => {
if (typeof value !== 'string') {
return new Date(fallback.getTime());
}
const mysqlTimestamp = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$/.exec(value);
const normalized = mysqlTimestamp
? `${mysqlTimestamp[1]}-${mysqlTimestamp[2]}-${mysqlTimestamp[3]}T${mysqlTimestamp[4]}:${mysqlTimestamp[5]}:${mysqlTimestamp[6]}.${(mysqlTimestamp[7] ?? '').slice(0, 3).padEnd(3, '0')}Z`
: value;
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? new Date(fallback.getTime()) : parsed;
};
const toDatabaseInt = (value: number): number => Math.round(value);
const COMMANDS_WITH_LEGACY_CORE_ARG_KEYS = new Set([
@@ -186,32 +223,57 @@ export const resolveCoreTurnCommandArgs = (request: TurnCommandFixtureRequest):
};
export const createCoreTurnCommandProfile = (request: TurnCommandFixtureRequest): TurnCommandProfile => {
const configuredGeneralActions = (request.setup?.generalTurns ?? []).map((turn) => readString(turn, 'action', ''));
const configuredNationActions = (request.setup?.nationTurns ?? []).map((turn) => readString(turn, 'action', ''));
for (const action of configuredGeneralActions) {
if (!GENERAL_TURN_COMMAND_KEYS.includes(action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) {
throw new Error(`Unknown configured general command: ${action}`);
}
}
for (const action of configuredNationActions) {
if (!NATION_TURN_COMMAND_KEYS.includes(action as (typeof NATION_TURN_COMMAND_KEYS)[number])) {
throw new Error(`Unknown configured nation command: ${action}`);
}
}
if (request.kind === 'general') {
if (!GENERAL_TURN_COMMAND_KEYS.includes(request.action as (typeof GENERAL_TURN_COMMAND_KEYS)[number])) {
throw new Error(`Unknown general command: ${request.action}`);
}
const generalActions = [request.action, '휴식', 'che_인재탐색', 'che_해산', 'che_이동'] as Array<
(typeof GENERAL_TURN_COMMAND_KEYS)[number]
>;
const generalActions = [
request.action,
...configuredGeneralActions,
'휴식',
'che_인재탐색',
'che_해산',
'che_이동',
] as Array<(typeof GENERAL_TURN_COMMAND_KEYS)[number]>;
return {
general: [...new Set(generalActions)],
nation: ['휴식'],
nation: [...new Set(['휴식', ...configuredNationActions])] as Array<
(typeof NATION_TURN_COMMAND_KEYS)[number]
>,
};
}
if (!NATION_TURN_COMMAND_KEYS.includes(request.action as (typeof NATION_TURN_COMMAND_KEYS)[number])) {
throw new Error(`Unknown nation command: ${request.action}`);
}
return {
general: ['휴식'],
nation: [request.action as (typeof NATION_TURN_COMMAND_KEYS)[number], '휴식'],
general: [...new Set(['휴식', ...configuredGeneralActions])] as Array<
(typeof GENERAL_TURN_COMMAND_KEYS)[number]
>,
nation: [
...new Set([
request.action as (typeof NATION_TURN_COMMAND_KEYS)[number],
'휴식',
...configuredNationActions,
]),
] as Array<(typeof NATION_TURN_COMMAND_KEYS)[number]>,
};
};
const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): TurnGeneral => {
const meta = asRecord(row.meta);
const rawTurnTime = row.turnTime;
const parsedTurnTime = typeof rawTurnTime === 'string' ? new Date(rawTurnTime) : fallbackTurnTime;
const turnTime = Number.isNaN(parsedTurnTime.getTime()) ? fallbackTurnTime : parsedTurnTime;
const turnTime = parseSnapshotDate(row.turnTime, fallbackTurnTime);
const rawLastTurn = asRecord(row.lastTurn);
const lastTurn =
typeof rawLastTurn.command === 'string'
@@ -256,7 +318,17 @@ const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): Tur
atmos: readNumber(row, 'atmos'),
age: readNumber(row, 'age', 30),
npcState: readNumber(row, 'npcState'),
userId: row.hasOwner === true ? 'turn-differential-owner' : null,
...(typeof row.affinity === 'number' || row.affinity === null ? { affinity: row.affinity } : {}),
...(typeof row.bornYear === 'number' ? { bornYear: row.bornYear } : {}),
...(typeof row.deadYear === 'number' ? { deadYear: row.deadYear } : {}),
picture: readNullableString(row, 'picture'),
imageServer: readNumber(row, 'imageServer'),
userId:
typeof row.ownerIdentity === 'string' && row.ownerIdentity.length > 0
? row.ownerIdentity
: row.hasOwner === true
? 'turn-differential-owner'
: null,
penalty: row.penalty,
triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} },
meta: {
@@ -275,6 +347,12 @@ const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): Tur
dex4: readNumber(row, 'dex4', readNumber(meta, 'dex4')),
dex5: readNumber(row, 'dex5', readNumber(meta, 'dex5')),
explevel: readNumber(row, 'expLevel', readNumber(meta, 'explevel')),
dedlevel: readNumber(row, 'dedLevel', readNumber(meta, 'dedlevel')),
npc_org: readNumber(row, 'npcOriginalState', readNumber(meta, 'npc_org')),
affinity: readNumber(row, 'affinity', readNumber(meta, 'affinity')),
birthYear: readNumber(row, 'bornYear', readNumber(meta, 'birthYear')),
deathYear: readNumber(row, 'deadYear', readNumber(meta, 'deathYear')),
...(typeof row.npcMessage === 'string' && row.npcMessage !== '' ? { text: row.npcMessage } : {}),
betray: readNumber(row, 'betray', readNumber(meta, 'betray')),
officerCityId: readNumber(
row,
@@ -296,6 +374,7 @@ const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): Tur
block: readNumber(row, 'blockState', readNumber(meta, 'block')),
},
...(lastTurn ? { lastTurn } : {}),
...(typeof row.turnTick === 'number' ? { turnTick: row.turnTick } : {}),
turnTime,
recentWarTime: null,
};
@@ -304,6 +383,7 @@ const buildGeneral = (row: Record<string, unknown>, fallbackTurnTime: Date): Tur
const buildNation = (row: Record<string, unknown>, generals: TurnGeneral[]): Nation => {
const id = readNumber(row, 'id');
const meta = asRecord(row.meta);
const commandState = asRecord(row.commandState);
const turnLastByOfficerLevel = asRecord(row.turnLastByOfficerLevel);
return {
id,
@@ -330,6 +410,9 @@ const buildNation = (row: Record<string, unknown>, generals: TurnGeneral[]): Nat
surlimit: readNumber(row, 'diplomacyLimit', readNumber(meta, 'surlimit')),
capset: readNumber(row, 'capitalRevision', readNumber(meta, 'capset')),
strategic_cmd_limit: readNumber(row, 'strategicCommandLimit', readNumber(meta, 'strategic_cmd_limit')),
rate: readNumber(commandState, 'rate', readNumber(meta, 'rate')),
bill: readNumber(commandState, 'bill', readNumber(meta, 'bill')),
secretlimit: readNumber(commandState, 'secretLimit', readNumber(meta, 'secretlimit', 3)),
},
};
};
@@ -342,7 +425,17 @@ export const buildCoreTurnCommandWorldInput = (
): { state: TurnWorldState; snapshot: TurnWorldSnapshot; map: MapDefinition } => {
const year = readNumber(referenceBefore.world, 'year', request.setup?.world?.year ?? 185);
const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1);
const turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`);
const calendarFallback = new Date(
`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`
);
const turnTime = parseSnapshotDate(referenceBefore.world.turnTime, calendarFallback);
const tickSeconds = readNumber(referenceBefore.world, 'tickMinutes', 10) * 60;
const lastTurnTick = readNumber(referenceBefore.world, 'lastTurnTick');
// Ref's tick is absolute within GameClock's domain, while turnTime is the
// display date at that tick. Derive the clock epoch instead of treating
// the scenario year/month as the epoch and rewriting 2026 snapshots into
// year 0185 during InMemoryTurnWorld normalization.
const clockBaseTime = GameClock.baseTimeForProjection(turnTime, lastTurnTick, tickSeconds);
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
for (const general of generals) {
applyPersistedRankRowsToMeta(
@@ -437,9 +530,10 @@ export const buildCoreTurnCommandWorldInput = (
environment: {
mapName: map.id,
unitSet: unitSet.id,
...(request.setup?.world?.scenarioEffect !== undefined
? { scenarioEffect: normalizeScenarioEffect(request.setup.world.scenarioEffect) }
: {}),
// worldLoader materializes the optional empty value as null.
// Keep the in-memory fixture on that same product boundary so
// a persistence round trip cannot add a synthetic field.
scenarioEffect: normalizeScenarioEffect(request.setup?.world?.scenarioEffect),
},
},
scenarioMeta: {
@@ -525,8 +619,13 @@ export const buildCoreTurnCommandWorldInput = (
id: 1,
currentYear: year,
currentMonth: month,
tickSeconds: readNumber(referenceBefore.world, 'tickMinutes', 10) * 60,
tickSeconds,
lastTurnTick,
lastTurnTime: turnTime,
clockBaseTime,
clockTick: lastTurnTick,
clockMode: 'manual',
clockWallAnchor: turnTime,
meta: {
hiddenSeed: request.setup?.world?.hiddenSeed ?? 'turn-command-differential-seed',
killturn: readNumber(referenceBefore.world, 'killTurn', 24),
@@ -538,6 +637,11 @@ export const buildCoreTurnCommandWorldInput = (
request.setup?.world?.initYear ?? request.setup?.world?.startYear ?? year
),
initMonth: readNumber(referenceBefore.world, 'initMonth', request.setup?.world?.initMonth ?? 1),
differentialGameNow: readString(
referenceBefore.world,
'gameNow',
readString(referenceBefore.world, 'turnTime', turnTime.toISOString())
),
},
},
snapshot,
@@ -545,19 +649,127 @@ export const buildCoreTurnCommandWorldInput = (
};
};
interface InMemorySnapshotSelector {
generalIds: Set<number>;
initialGeneralIds: Set<number>;
cityIds: Set<number>;
nationIds: Set<number>;
troopIds: Set<number>;
includeRankMirrors: boolean;
messageReadStateByGeneralId: Map<number, SemanticMessageReadState>;
generalCooldowns: GeneralCooldownSelector[];
nationCooldowns: NationCooldownSelector[];
}
interface SemanticMessageReadState {
unreadPrivateCount: number;
unreadDiplomacyCount: number;
}
const readSemanticMessageState = (general: Record<string, unknown>): SemanticMessageReadState => {
const state = asRecord(general.messageReadState);
return {
unreadPrivateCount: readNumber(state, 'unreadPrivateCount'),
unreadDiplomacyCount: readNumber(state, 'unreadDiplomacyCount'),
};
};
export const projectCoreMessageReadState = (
generalId: number,
nationId: number,
messages: CanonicalTurnSnapshot['messages'],
baseline?: SemanticMessageReadState
): SemanticMessageReadState & { hasUnreadMessage: boolean } => {
const startingState = baseline ?? { unreadPrivateCount: 0, unreadDiplomacyCount: 0 };
const diplomacyMailbox = 9_000 + nationId;
const unreadPrivateCount =
startingState.unreadPrivateCount +
messages.filter(
(message) =>
message.type === 'private' &&
readNumber(message, 'mailbox') === generalId &&
readNumber(message, 'sourceId') !== generalId
).length;
const unreadDiplomacyCount =
startingState.unreadDiplomacyCount +
messages.filter(
(message) =>
message.type === 'diplomacy' &&
readNumber(message, 'mailbox') === diplomacyMailbox &&
readNumber(message, 'sourceId') !== diplomacyMailbox
).length;
return {
unreadPrivateCount,
unreadDiplomacyCount,
hasUnreadMessage: unreadPrivateCount + unreadDiplomacyCount > 0,
};
};
const readWorldEntityIds = (world: InMemoryTurnWorld): TurnSnapshotEntityIds => ({
generalIds: world.listGenerals().map((general) => general.id),
cityIds: world.listCities().map((city) => city.id),
nationIds: world.listNations().map((nation) => nation.id),
troopIds: world.listTroops().map((troop) => troop.id),
});
const extendSelectorOverCreatedEntities = (
selector: InMemorySnapshotSelector,
before: TurnSnapshotEntityIds,
after: TurnSnapshotEntityIds
): void => {
const closed = closeTurnSnapshotSelectorOverCreatedEntities(
{
generalIds: [...selector.generalIds],
cityIds: [...selector.cityIds],
nationIds: [...selector.nationIds],
troopIds: [...selector.troopIds],
},
before,
after
);
for (const id of closed.generalIds) selector.generalIds.add(id);
for (const id of closed.cityIds) selector.cityIds.add(id);
for (const id of closed.nationIds) selector.nationIds.add(id);
for (const id of closed.troopIds ?? []) selector.troopIds.add(id);
};
export const projectCoreMessageDrafts = async (
drafts: readonly MessageDraft[],
messageIdWatermark: number
): Promise<CanonicalTurnSnapshot['messages']> => {
const records: Array<MessageRecordDraft & { id: number }> = [];
let nextId = messageIdWatermark;
for (const draft of drafts) {
await sendMessage(
{
insertMessage: async (record) => {
const id = ++nextId;
records.push({ ...record, id });
return id;
},
},
draft,
{ sendDestOnly: draft.sendDestOnly }
);
}
return records.map((record) => ({
id: record.id,
mailbox: record.mailbox,
type: record.msgType,
sourceId: record.srcId,
destinationId: record.destId,
createdAt: record.time.toISOString(),
validUntil: projectCanonicalMessageValidUntil(record.validUntil),
payload: record.payload,
}));
};
const projectWorld = (
world: InMemoryTurnWorld,
reservedTurns: InMemoryReservedTurnStore,
logs: CanonicalTurnSnapshot['logs'],
messages: CanonicalTurnSnapshot['messages'],
selector: {
generalIds: Set<number>;
cityIds: Set<number>;
nationIds: Set<number>;
initialGeneralIds: Set<number>;
generalCooldowns: GeneralCooldownSelector[];
nationCooldowns: NationCooldownSelector[];
}
selector: InMemorySnapshotSelector
): CanonicalTurnSnapshot => {
const state = world.getState();
const generals = world
@@ -574,7 +786,6 @@ const projectWorld = (
intelligence: general.stats.intelligence,
experience: toDatabaseInt(general.experience),
dedication: toDatabaseInt(general.dedication),
expLevel: readNumber(general.meta, 'explevel'),
officerLevel: general.officerLevel,
officerCityId: readNumber(
general.meta,
@@ -593,6 +804,8 @@ const projectWorld = (
itemWeapon: general.role.items.weapon,
itemBook: general.role.items.book,
itemExtra: general.role.items.item,
picture: general.picture ?? null,
imageServer: general.imageServer ?? 0,
injury: general.injury,
gold: toDatabaseInt(general.gold),
rice: toDatabaseInt(general.rice),
@@ -603,10 +816,28 @@ const projectWorld = (
age: general.age,
npcState: general.npcState,
hasOwner: Boolean(general.userId),
ownerIdentity: general.userId ?? null,
messageReadState: projectCoreMessageReadState(
general.id,
general.nationId,
messages,
selector.messageReadStateByGeneralId.get(general.id)
),
turnTime: general.turnTime.toISOString(),
recentWarTime: general.recentWarTime?.toISOString() ?? null,
lastTurn: general.lastTurn ?? null,
// Core's persisted JSON column and Ref both represent the
// pre-command state as an empty object. Do not invent a null-only
// in-memory variant at the canonical boundary.
lastTurn: general.lastTurn ?? {},
meta: general.meta,
...projectCanonicalGeneralStoredFields(general.meta, {
affinity: general.affinity,
bornYear: general.bornYear,
deadYear: general.deadYear,
turnTick: general.turnTick,
...projectCanonicalTurnOffset(general.turnTick, state.lastTurnTick, state.tickSeconds),
}),
commandState: projectCanonicalGeneralCommandState(general.meta),
leadershipExp: toDatabaseInt(readNumber(general.meta, 'leadership_exp')),
strengthExp: toDatabaseInt(readNumber(general.meta, 'strength_exp')),
intelExp: toDatabaseInt(readNumber(general.meta, 'intel_exp')),
@@ -631,7 +862,9 @@ const projectWorld = (
year: state.currentYear,
month: state.currentMonth,
tickMinutes: Math.max(1, Math.round(state.tickSeconds / 60)),
lastTurnTick: state.lastTurnTick,
turnTime: state.lastTurnTime.toISOString(),
gameNow: readString(state.meta, 'differentialGameNow', state.lastTurnTime.toISOString()),
isUnited: readNumber(state.meta, 'isUnited'),
generalCooldowns: selector.generalCooldowns.map(({ generalId, actionName }) => {
const general = world.getGeneralById(generalId);
@@ -657,9 +890,13 @@ const projectWorld = (
.listGenerals()
.filter((general) => selector.generalIds.has(general.id))
.flatMap((general) =>
buildLegacyComparableRankRows(general).map((row) =>
selector.initialGeneralIds.has(general.id) ? row : { ...row, nationId: 0, value: 0 }
)
selector.includeRankMirrors
? selector.initialGeneralIds.has(general.id)
? buildPersistedRankRows(general)
: buildInitialRankRows(general)
: selector.initialGeneralIds.has(general.id)
? buildLegacyComparableRankRows(general)
: buildLegacyComparableInitialRankRows(general)
)
.map((row) => ({ ...row })),
cities: world
@@ -706,18 +943,44 @@ const projectWorld = (
tech: readLegacyStoredFloat(readNumber(nation.meta, 'tech')),
level: nation.level,
typeCode: nation.typeCode,
generalCount: world.listGenerals().filter((general) => general.nationId === nation.id).length,
generalCount: readNumber(
nation.meta,
'gennum',
world.listGenerals().filter((general) => general.nationId === nation.id).length
),
power: nation.power,
war: readNumber(nation.meta, 'war'),
diplomacyLimit: readNumber(nation.meta, 'surlimit'),
capitalRevision: readNumber(nation.meta, 'capset'),
strategicCommandLimit: readNumber(nation.meta, 'strategic_cmd_limit'),
meta: nation.meta,
commandState: projectCanonicalNationCommandState(nation.meta),
})),
troops: world
.listTroops()
.filter(
(troop) =>
selector.troopIds.has(troop.id) ||
selector.generalIds.has(troop.id) ||
world
.listGenerals()
.some((general) => selector.generalIds.has(general.id) && general.troopId === troop.id)
)
.map((troop) => ({ ...troop })),
diplomacy: world
.listDiplomacy()
.filter((entry) => selector.nationIds.has(entry.fromNationId) && selector.nationIds.has(entry.toNationId))
.map((entry) => ({ ...entry })),
// Keep the in-memory adapter on the same canonical boundary as the
// PostgreSQL and Ref adapters. Core's internal `meta` container has
// no Ref diplomacy-table counterpart and must not appear/disappear
// as a synthetic graph mutation.
.map((entry) => ({
fromNationId: entry.fromNationId,
toNationId: entry.toNationId,
state: entry.state,
term: entry.term,
dead: entry.dead,
})),
generalTurns: generals.flatMap((general) =>
reservedTurns.getGeneralTurns(Number(general.id)).map((turn, turnIndex) => ({
generalId: general.id,
@@ -739,7 +1002,11 @@ const projectWorld = (
),
logs,
messages,
watermarks: { logId: logs.length, historyLogId: logs.length, messageId: messages.length },
watermarks: {
logId: logs.reduce((max, row) => Math.max(max, readNumber(row, 'id')), 0),
historyLogId: logs.reduce((max, row) => Math.max(max, readNumber(row, 'id')), 0),
messageId: messages.reduce((max, row) => Math.max(max, readNumber(row, 'id')), 0),
},
};
};
@@ -781,17 +1048,43 @@ export const runCoreTurnCommandTrace = async (
const map = await loadMapDefinitionByName('che');
const worldInput = buildCoreTurnCommandWorldInput(request, referenceBefore, unitSet, map);
const { state, snapshot } = worldInput;
const selector = {
generalIds: new Set([
...referenceBefore.generals.map((row) => readNumber(row, 'id')),
...(request.observe?.generalIds ?? []),
]),
cityIds: new Set(referenceBefore.cities.map((row) => readNumber(row, 'id'))),
nationIds: new Set([
...referenceBefore.nations.map((row) => readNumber(row, 'id')),
...(request.observe?.nationIds ?? []),
]),
initialGeneralIds: new Set(referenceBefore.generals.map((row) => readNumber(row, 'id'))),
const selector: InMemorySnapshotSelector = {
generalIds: new Set(
request.observe?.allGenerals
? snapshot.generals.map((general) => general.id)
: [
...referenceBefore.generals.map((row) => readNumber(row, 'id')),
...(request.observe?.generalIds ?? []),
]
),
initialGeneralIds: new Set(snapshot.generals.map((general) => general.id)),
cityIds: new Set(
request.observe?.allCities
? snapshot.cities.map((city) => city.id)
: [...referenceBefore.cities.map((row) => readNumber(row, 'id')), ...(request.observe?.cityIds ?? [])]
),
nationIds: new Set(
request.observe?.allNations
? snapshot.nations.map((nation) => nation.id)
: [
...referenceBefore.nations.map((row) => readNumber(row, 'id')),
...(request.observe?.nationIds ?? []),
]
),
troopIds: new Set(
request.observe?.allTroops
? snapshot.troops.map((troop) => troop.id)
: [
...(referenceBefore.troops ?? []).map((row) => readNumber(row, 'id')),
...(request.observe?.troopIds ?? []),
]
),
includeRankMirrors: request.observe?.includeRankMirrors === true,
messageReadStateByGeneralId: new Map(
referenceBefore.generals.map(
(general) => [readNumber(general, 'id'), readSemanticMessageState(general)] as const
)
),
generalCooldowns: request.observe?.generalCooldowns ?? [],
nationCooldowns: request.observe?.nationCooldowns ?? [],
};
@@ -818,16 +1111,17 @@ export const runCoreTurnCommandTrace = async (
}
let world: InMemoryTurnWorld | null = null;
let resolution:
| {
kind: 'nation' | 'general';
actionKey: string;
requestedAction: string;
usedFallback: boolean;
blockedReason?: string;
}
| undefined;
type LifecycleResolution = {
kind: 'nation' | 'general';
actionKey: string;
requestedAction: string;
usedFallback: boolean;
blockedReason?: string;
};
let resolution: LifecycleResolution | undefined;
const lifecycleResolutions: LifecycleResolution[] = [];
const commandRngCalls: RandomCall[] = [];
const gameNow = parseSnapshotDate(referenceBefore.world.gameNow, actor.turnTime);
const handler = await createReservedTurnHandler({
reservedTurns,
scenarioConfig: snapshot.scenarioConfig,
@@ -835,6 +1129,8 @@ export const runCoreTurnCommandTrace = async (
map,
unitSet,
getWorld: () => world,
now: () => new Date(gameNow.getTime()),
messageSharedIconBaseUrl: request.setup?.world?.messageSharedIconBaseUrl,
commandProfile: createCoreTurnCommandProfile(request),
commandRngFactory: ({ kind, actionKey, seed }) => {
const tracing = new TracingRng(new LiteHashDRBG(seed));
@@ -867,6 +1163,7 @@ export const runCoreTurnCommandTrace = async (
return new RandUtil(new LiteHashDRBG(seed));
},
onActionResolved: (payload) => {
lifecycleResolutions.push(payload);
if (payload.kind === request.kind && payload.requestedAction === request.action) {
resolution = payload;
}
@@ -878,9 +1175,12 @@ export const runCoreTurnCommandTrace = async (
},
generalTurnHandler: handler,
});
const initialWorldEntityIds = readWorldEntityIds(world);
const before = projectWorld(world, reservedTurns, [], [], selector);
world.executeGeneralTurn(actor);
extendSelectorOverCreatedEntities(selector, initialWorldEntityIds, readWorldEntityIds(world));
const dirty = world.peekDirtyState();
const projectedMessages = await projectCoreMessageDrafts(dirty.messages, referenceBefore.watermarks.messageId);
const after = projectWorld(
world,
reservedTurns,
@@ -892,14 +1192,12 @@ export const runCoreTurnCommandTrace = async (
// GENERAL logs that finalizeLogEntry would reject in production.
generalId: log.generalId,
nationId: log.nationId,
year: state.currentYear,
month: state.currentMonth,
year: log.year ?? state.currentYear,
month: log.month ?? state.currentMonth,
format: log.format ?? LogFormat.RAWTEXT,
text: log.text,
})),
dirty.messages.map((message, index) => ({
id: index + 1,
payload: message,
})),
projectedMessages,
selector
);
@@ -912,7 +1210,18 @@ export const runCoreTurnCommandTrace = async (
action: request.action,
args,
seedDomain: request.kind === 'general' ? 'generalCommand' : 'nationCommand',
outcome: resolution,
outcome: resolution
? {
...resolution,
lifecycleActions: lifecycleResolutions.map((entry) => ({
kind: entry.kind,
requestedAction: entry.requestedAction,
actionKey: entry.actionKey,
usedFallback: entry.usedFallback,
...(entry.blockedReason ? { blockedReason: entry.blockedReason } : {}),
})),
}
: resolution,
},
before,
after,
@@ -1,6 +1,55 @@
import { GameClock, MAX_SAFE_GAME_TICK, type GameClockMode } from '@sammo-ts/common';
import { createGamePostgresConnector } from '@sammo-ts/infra';
import { projectCoreDatabaseSnapshot, type CanonicalTurnSnapshot, type TurnSnapshotSelector } from './canonical.js';
import {
projectCoreDatabaseSnapshot,
CANONICAL_MESSAGE_VALID_UNTIL_INFINITE,
projectCanonicalMessageValidUntil,
type CanonicalTurnSnapshot,
type TurnSnapshotEntityIds,
type TurnSnapshotSelector,
} from './canonical.js';
export const projectEffectiveCoreMessageValidUntil = (
row: { validUntil: Date | string; validUntilTick?: bigint | number | null },
clock: GameClock | null
): string | typeof CANONICAL_MESSAGE_VALID_UNTIL_INFINITE => {
if (clock && row.validUntilTick !== null && row.validUntilTick !== undefined) {
const tick = Number(row.validUntilTick);
if (!Number.isSafeInteger(tick)) {
throw new Error(
`message.valid_until_tick is outside the JavaScript safe integer range: ${String(row.validUntilTick)}`
);
}
if (tick === MAX_SAFE_GAME_TICK) {
return CANONICAL_MESSAGE_VALID_UNTIL_INFINITE;
}
return projectCanonicalMessageValidUntil(clock.tickToDate(tick));
}
return projectCanonicalMessageValidUntil(row.validUntil);
};
export const readCoreDatabaseEntityIds = async (databaseUrl: string): Promise<TurnSnapshotEntityIds> => {
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
const db = connector.prisma;
const [generals, cities, nations, troops] = await Promise.all([
db.general.findMany({ select: { id: true }, orderBy: { id: 'asc' } }),
db.city.findMany({ select: { id: true }, orderBy: { id: 'asc' } }),
db.nation.findMany({ select: { id: true }, orderBy: { id: 'asc' } }),
db.troop.findMany({ select: { troopLeaderId: true }, orderBy: { troopLeaderId: 'asc' } }),
]);
return {
generalIds: generals.map((row) => row.id),
cityIds: cities.map((row) => row.id),
nationIds: nations.map((row) => row.id),
troopIds: troops.map((row) => row.troopLeaderId),
};
} finally {
await connector.disconnect();
}
};
export const readCoreDatabaseSnapshot = async (
databaseUrl: string,
@@ -11,60 +60,152 @@ export const readCoreDatabaseSnapshot = async (
try {
const db = connector.prisma;
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
const [generals, rankData, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
const [generals, cities, nations] = await Promise.all([
db.general.findMany({
where: { id: { in: selector.generalIds } },
...(selector.allGenerals ? {} : { where: { id: { in: selector.generalIds } } }),
orderBy: { id: 'asc' },
}),
db.rankData.findMany({
where: { generalId: { in: selector.generalIds } },
orderBy: [{ generalId: 'asc' }, { type: 'asc' }],
}),
db.city.findMany({
where: { id: { in: selector.cityIds } },
...(selector.allCities ? {} : { where: { id: { in: selector.cityIds } } }),
orderBy: { id: 'asc' },
}),
db.nation.findMany({
where: { id: { in: selector.nationIds } },
...(selector.allNations ? {} : { where: { id: { in: selector.nationIds } } }),
orderBy: { id: 'asc' },
}),
]);
const generalIds = generals.map((row) => row.id);
const nationIds = nations.map((row) => row.id);
const wallNow = new Date();
let currentMessageTime = wallNow;
let currentMessageTick: bigint | null = null;
let gameClock: GameClock | null = null;
if (world.clockBaseTime && world.clockTick !== null && world.clockWallAnchor) {
const mode: GameClockMode = world.clockMode === 'manual' ? 'manual' : 'realtime';
const storedTick = Number(world.clockTick);
if (!Number.isSafeInteger(storedTick)) {
throw new Error(
`world_state.clock_tick is outside the JavaScript safe integer range: ${world.clockTick}`
);
}
gameClock = new GameClock({
baseTime: world.clockBaseTime,
tick: storedTick,
mode,
wallAnchor: world.clockWallAnchor,
turnSeconds: world.tickSeconds,
});
currentMessageTick = BigInt(gameClock.nowTick(wallNow));
currentMessageTime = gameClock.tickToDate(Number(currentMessageTick));
}
const troopIds = new Set<number>([...(selector.troopIds ?? []), ...selector.generalIds]);
for (const general of generals) {
troopIds.add(general.id);
if (general.troopId > 0) {
troopIds.add(general.troopId);
}
}
const [
rankData,
troops,
diplomacy,
generalTurns,
nationTurns,
logs,
messages,
latestMessage,
messageReadStates,
messageInboxRows,
] = await Promise.all([
db.rankData.findMany({
where: { generalId: { in: generalIds } },
orderBy: [{ generalId: 'asc' }, { type: 'asc' }],
}),
db.troop.findMany({
...(selector.allTroops
? {}
: { where: { troopLeaderId: { in: [...troopIds].sort((left, right) => left - right) } } }),
orderBy: { troopLeaderId: 'asc' },
}),
db.diplomacy.findMany({
where: {
srcNationId: { in: selector.nationIds },
destNationId: { in: selector.nationIds },
srcNationId: { in: nationIds },
destNationId: { in: nationIds },
},
orderBy: [{ srcNationId: 'asc' }, { destNationId: 'asc' }],
}),
db.generalTurn.findMany({
where: { generalId: { in: selector.generalIds } },
where: { generalId: { in: generalIds } },
orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }],
}),
db.nationTurn.findMany({
where: { nationId: { in: selector.nationIds } },
where: { nationId: { in: nationIds } },
orderBy: [{ nationId: 'asc' }, { officerLevel: 'asc' }, { turnIdx: 'asc' }],
}),
db.logEntry.findMany({
where: {
id: { gt: selector.logAfterId ?? 0 },
OR: [
{ scope: 'SYSTEM' },
{ generalId: { in: selector.generalIds } },
{ nationId: { in: selector.nationIds } },
OR: [{ scope: 'SYSTEM' }, { generalId: { in: generalIds } }, { nationId: { in: nationIds } }],
},
orderBy: { id: 'asc' },
}),
db.message.findMany({
where: { id: { gt: selector.messageAfterId ?? 0 } },
orderBy: { id: 'asc' },
}),
db.message.findFirst({
select: { id: true },
orderBy: { id: 'desc' },
}),
db.messageReadState.findMany({
where: { generalId: { in: generalIds } },
orderBy: { generalId: 'asc' },
}),
db.message.findMany({
where: {
AND: [
{
OR: [
{ type: 'private', mailbox: { in: generalIds } },
{
type: 'diplomacy',
mailbox: { in: nationIds.map((nationId) => 9_000 + nationId) },
},
],
},
{
OR: [
...(currentMessageTick === null
? []
: [{ validUntilTick: { not: null, gt: currentMessageTick } }]),
{ validUntilTick: null, validUntil: { gt: currentMessageTime } },
],
},
],
},
select: { id: true, mailbox: true, type: true, src: true },
orderBy: { id: 'asc' },
}),
]);
return projectCoreDatabaseSnapshot({
world,
world: { ...world, gameNow: currentMessageTime },
generals,
rankData,
cities,
nations,
troops,
diplomacy,
generalTurns,
nationTurns,
logs,
messages: messages.map((row) => ({
...row,
effectiveValidUntil: projectEffectiveCoreMessageValidUntil(row, gameClock),
})),
messageReadStates,
messageInboxRows,
messageWatermark: latestMessage?.id ?? 0,
includeRankMirrors: selector.includeRankMirrors,
});
} finally {
await connector.disconnect();
@@ -0,0 +1,213 @@
import type { CanonicalTurnSnapshot, TurnSnapshotSelector } from './canonical.js';
import type { TurnCommandFixtureRequest } from './coreCommandTrace.js';
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
const semanticTimestamp = (value: unknown): number => {
const raw = String(value);
const normalized = raw.includes('T') ? raw : `${raw.replace(' ', 'T').replace(/\.(\d{3})\d*$/, '.$1')}Z`;
return new Date(normalized).getTime();
};
const semanticTurnArgs = (value: unknown): unknown => (Array.isArray(value) && value.length === 0 ? {} : value);
export const fullLifecycleGeneralTurns = Array.from({ length: 30 }, (_, turnIndex) => ({
generalId: 1,
turnIndex,
action: turnIndex === 0 ? 'che_훈련' : '휴식',
args: {},
}));
export const fullLifecycleNationTurns = Array.from({ length: 12 }, (_, turnIndex) => ({
nationId: 1,
officerLevel: 12,
turnIndex,
action: turnIndex === 0 ? 'che_국호변경' : '휴식',
args: turnIndex === 0 ? { nationName: '수명주기국' } : {},
}));
export const fullLifecycleSnapshotSelector: TurnSnapshotSelector = {
generalIds: [1],
cityIds: [3],
nationIds: [1],
allGenerals: true,
allCities: true,
allNations: true,
allTroops: true,
includeRankMirrors: true,
logAfterId: 0,
messageAfterId: 0,
includeNationHistoryLogs: true,
includeGlobalHistoryLogs: true,
};
export const fullLifecycleTurnCommandRequest: TurnCommandFixtureRequest = {
kind: 'general',
actorGeneralId: 1,
action: 'che_훈련',
args: {},
includeLifecycle: true,
setup: {
isolateWorld: true,
world: {
startYear: 180,
year: 190,
month: 1,
hiddenSeed: 'turn-command-full-lifecycle-v1',
freezeClock: true,
},
nations: [
{
id: 1,
name: '아국',
color: '#777777',
capitalCityId: 3,
gold: 1_000_000,
rice: 1_000_000,
tech: 1_000,
level: 1,
typeCode: 'che_명가',
generalCount: 1,
meta: { can_국호변경: 1 },
},
],
cities: [
{
id: 3,
nationId: 1,
level: 5,
population: 100_000,
populationMax: 200_000,
agriculture: 1_000,
agricultureMax: 2_000,
commerce: 1_000,
commerceMax: 2_000,
security: 1_000,
securityMax: 2_000,
supplyState: 1,
frontState: 0,
defence: 1_000,
defenceMax: 2_000,
wall: 1_000,
wallMax: 2_000,
state: 0,
term: 0,
trust: 80,
trade: 100,
},
],
generals: [
{
id: 1,
name: '수명주기장수',
nationId: 1,
cityId: 3,
troopId: 0,
leadership: 90,
strength: 80,
intelligence: 70,
leadershipExp: 0,
strengthExp: 0,
intelExp: 0,
experience: 1_000,
dedication: 1_000,
expLevel: 0,
officerLevel: 12,
officerCityId: 3,
belong: 10,
permission: 'normal',
injury: 0,
age: 30,
gold: 100_000,
rice: 100_000,
crew: 1_000,
crewTypeId: 1_100,
train: 50,
atmos: 50,
killTurn: 24,
npcState: 0,
blockState: 0,
personality: 'None',
specialDomestic: 'None',
specialWar: 'None',
itemHorse: 'None',
itemWeapon: 'None',
itemBook: 'None',
itemExtra: 'None',
meta: {},
},
],
generalTurns: fullLifecycleGeneralTurns,
nationTurns: fullLifecycleNationTurns,
},
observe: fullLifecycleSnapshotSelector,
};
export const projectFullLifecycleSnapshotGraph = (snapshot: CanonicalTurnSnapshot): Record<string, unknown> => {
const general = snapshot.generals.find((entry) => entry.id === 1);
const nation = snapshot.nations.find((entry) => entry.id === 1);
return {
actor: general
? {
nationId: general.nationId,
cityId: general.cityId,
train: general.train,
atmos: general.atmos,
experience: general.experience,
dedication: general.dedication,
leadershipExp: general.leadershipExp,
expLevel: general.expLevel,
dedLevel: general.dedLevel,
killTurn: general.killTurn,
mySet: general.mySet,
turnTime: semanticTimestamp(general.turnTime),
lastTurn: general.lastTurn,
}
: null,
nation: nation
? {
name: nation.name,
gold: nation.gold,
rice: nation.rice,
canRename: asRecord(nation.meta).can_국호변경 ?? 0,
}
: null,
actorRankData: snapshot.rankData
.filter((row) => row.generalId === 1)
.sort((left, right) => String(left.type).localeCompare(String(right.type)))
.map((row) => ({
nationId: row.nationId,
type: row.type,
value: row.value,
})),
generalTurns: snapshot.generalTurns
.filter((turn) => turn.generalId === 1)
.sort((left, right) => Number(left.turnIndex) - Number(right.turnIndex))
.map((turn) => ({
turnIndex: turn.turnIndex,
action: turn.action,
args: semanticTurnArgs(turn.args),
})),
nationTurns: snapshot.nationTurns
.filter((turn) => turn.nationId === 1 && turn.officerLevel === 12)
.sort((left, right) => Number(left.turnIndex) - Number(right.turnIndex))
.map((turn) => ({
turnIndex: turn.turnIndex,
action: turn.action,
args: semanticTurnArgs(turn.args),
})),
};
};
export const addedFullLifecycleReferenceLogs = (
before: CanonicalTurnSnapshot,
after: CanonicalTurnSnapshot
): Array<Record<string, unknown>> =>
after.logs.filter((entry) => {
const scope = String(entry.scope).toLowerCase();
const category = String(entry.category).toLowerCase();
const usesWorldHistory = scope === 'nation' || (scope === 'system' && category === 'history');
const watermark = usesWorldHistory ? before.watermarks.historyLogId : before.watermarks.logId;
return Number(entry.id) > watermark;
});
@@ -0,0 +1,32 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import type { CanonicalTurnCommandTrace } from './canonical.js';
/**
* Execute the comparison-only wrapper around Ref's real
* TurnExecutionHelper::executeGeneralCommandUntil entry point.
*/
export const runReferenceFullLifecycleTrace = (
workspaceRoot: string,
request: Record<string, unknown>
): CanonicalTurnCommandTrace => {
const stackDirectory = path.join(workspaceRoot, 'docker_compose_files/reference');
const appDirectory = path.resolve(process.env.REF_COMPARE_SOURCE_ROOT ?? path.join(workspaceRoot, 'ref/sam'));
const runtimeDirectory = path.join(workspaceRoot, 'ref/sam');
const runner = process.env.TURN_DIFFERENTIAL_CASE_SCRIPT ?? './scripts/run-turn-differential-case.sh';
const stdout = execFileSync(runner, ['-'], {
cwd: stackDirectory,
input: JSON.stringify(request),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
TURN_DIFFERENTIAL_STACK_DIR: stackDirectory,
TURN_DIFFERENTIAL_APP_DIR: appDirectory,
TURN_DIFFERENTIAL_RUNTIME_DIR: runtimeDirectory,
TURN_DIFFERENTIAL_RUNNER_SCRIPT: path.join(appDirectory, 'hwe/compare/turn_full_lifecycle_trace.php'),
},
});
return JSON.parse(stdout) as CanonicalTurnCommandTrace;
};
@@ -0,0 +1,214 @@
export interface OrderedSemanticLogOptions {
omitRest?: boolean;
}
type SemanticLogFormat =
| 'rawtext'
| 'plain'
| 'year_month'
| 'year'
| 'month'
| 'event_plain'
| 'event_year_month'
| 'notice'
| 'notice_year_month';
interface ParsedStoredLogText {
format: SemanticLogFormat;
text: string;
renderedYear?: number;
renderedMonth?: number;
}
const normalizeLogBody = (value: unknown): string =>
String(value)
.replace(/<span class=(['"])hidden_but_copyable\1>(.*?)<\/span>/g, '$2')
.replace(/ ?<1>\d{2}:\d{2}<\/\>$/, '');
const parseStoredLogText = (value: unknown): ParsedStoredLogText => {
const text = String(value);
const yearMonth = text.match(/^<C><\/>(\d+) (\d+):/u);
if (yearMonth) {
return {
format: 'year_month',
renderedYear: Number(yearMonth[1]),
renderedMonth: Number(yearMonth[2]),
text: text.slice(yearMonth[0].length),
};
}
const year = text.match(/^<C><\/>(\d+):/u);
if (year) {
return {
format: 'year',
renderedYear: Number(year[1]),
text: text.slice(year[0].length),
};
}
const month = text.match(/^<C><\/>(\d+):/u);
if (month) {
return {
format: 'month',
renderedMonth: Number(month[1]),
text: text.slice(month[0].length),
};
}
if (text.startsWith('<C>●</>')) {
return { format: 'plain', text: text.slice('<C>●</>'.length) };
}
const eventYearMonth = text.match(/^<S><\/>(\d+) (\d+):/u);
if (eventYearMonth) {
return {
format: 'event_year_month',
renderedYear: Number(eventYearMonth[1]),
renderedMonth: Number(eventYearMonth[2]),
text: text.slice(eventYearMonth[0].length),
};
}
if (text.startsWith('<S>◆</>')) {
return { format: 'event_plain', text: text.slice('<S>◆</>'.length) };
}
const noticeYearMonth = text.match(/^<R><\/>(\d+) (\d+):/u);
if (noticeYearMonth) {
return {
format: 'notice_year_month',
renderedYear: Number(noticeYearMonth[1]),
renderedMonth: Number(noticeYearMonth[2]),
text: text.slice(noticeYearMonth[0].length),
};
}
if (text.startsWith('<R>★</>')) {
return { format: 'notice', text: text.slice('<R>★</>'.length) };
}
return { format: 'rawtext', text };
};
const readLogCalendar = (entry: Record<string, unknown>, field: 'year' | 'month'): number => {
const value = entry[field];
if (typeof value !== 'number' || !Number.isSafeInteger(value)) {
throw new Error(`log.${field} must be a safe integer`);
}
if (field === 'month' && (value < 1 || value > 12)) {
throw new Error(`log.month must be between 1 and 12: ${value}`);
}
return value;
};
const readExplicitLogFormat = (entry: Record<string, unknown>): number | null => {
if (!Object.prototype.hasOwnProperty.call(entry, 'format')) {
return null;
}
const value = entry.format;
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 8) {
throw new Error(`log.format must be an integer from 0 through 8: ${String(value)}`);
}
return value;
};
/** Independent rendering of Core's draft enum into Ref's persisted prefix contract. */
const renderExplicitLogFormat = (text: string, format: number, year: number, month: number): string => {
switch (format) {
case 0:
return text;
case 1:
return `<C>●</>${text}`;
case 2:
return `<C>●</>${year}${month}월:${text}`;
case 3:
return `<C>●</>${year}년:${text}`;
case 4:
return `<C>●</>${month}월:${text}`;
case 5:
return `<S>◆</>${text}`;
case 6:
return `<S>◆</>${year}${month}월:${text}`;
case 7:
return `<R>★</>${text}`;
case 8:
return `<R>★</>${year}${month}월:${text}`;
default:
throw new Error(`unsupported log format: ${format}`);
}
};
const projectSemanticLogEntry = (entry: Record<string, unknown>): Record<string, unknown> => {
const year = readLogCalendar(entry, 'year');
const month = readLogCalendar(entry, 'month');
const explicitFormat = readExplicitLogFormat(entry);
const renderedText =
explicitFormat === null
? String(entry.text)
: renderExplicitLogFormat(String(entry.text), explicitFormat, year, month);
const parsed = parseStoredLogText(renderedText);
if (parsed.renderedYear !== undefined && parsed.renderedYear !== year) {
throw new Error(`stored log year ${parsed.renderedYear} does not match row year ${year}`);
}
if (parsed.renderedMonth !== undefined && parsed.renderedMonth !== month) {
throw new Error(`stored log month ${parsed.renderedMonth} does not match row month ${month}`);
}
return {
scope: String(entry.scope).toLowerCase(),
category: String(entry.category).toLowerCase(),
generalId: Number(entry.generalId) || null,
nationId: Number(entry.nationId) || null,
year,
month,
format: parsed.format,
text: normalizeLogBody(parsed.text),
};
};
export const normalizeStoredTurnLogText = (value: unknown): string => normalizeLogBody(parseStoredLogText(value).text);
const logStream = (entry: Record<string, unknown>): 'general_record' | 'world_history' => {
const scope = String(entry.scope).toLowerCase();
const category = String(entry.category).toLowerCase();
// Ref keeps a general's own history rows in general_record. Only nation
// history and global history share world_history's independent ID stream.
return scope === 'nation' || (scope === 'system' && category === 'history') ? 'world_history' : 'general_record';
};
const numericLogId = (entry: Record<string, unknown>): number => {
const id = Number(entry.id);
return Number.isFinite(id) ? id : Number.MAX_SAFE_INTEGER;
};
/**
* Compare the semantic persisted log graph without erasing write order,
* calendar ownership, or Ref's rendered format prefix.
*
* Ref stores action/summary logs in `general_record` and nation/global history
* in `world_history`. Their numeric IDs are independent, so ordering across
* those tables is not observable. Ordering inside each table is observable and
* is part of the command lifecycle contract.
*/
export const orderedSemanticLogStreams = (
logs: Array<Record<string, unknown>>,
options: OrderedSemanticLogOptions = {}
): string[] => {
const streams = new Map<string, Array<{ entry: Record<string, unknown>; inputIndex: number }>>();
logs.forEach((entry, inputIndex) => {
if (options.omitRest && normalizeStoredTurnLogText(entry.text) === '아무것도 실행하지 않았습니다.') {
return;
}
const key = logStream(entry);
const values = streams.get(key) ?? [];
values.push({ entry, inputIndex });
streams.set(key, values);
});
return [...streams.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([stream, values]) =>
JSON.stringify({
stream,
entries: values
.sort(
(left, right) =>
numericLogId(left.entry) - numericLogId(right.entry) || left.inputIndex - right.inputIndex
)
.map(({ entry }) => projectSemanticLogEntry(entry)),
})
);
};
@@ -0,0 +1,266 @@
import type { CanonicalTurnSnapshot } from './canonical.js';
type JsonRecord = Record<string, unknown>;
export interface SemanticTurnMessageTarget {
generalId: number;
generalName: string;
nationId: number;
nationName: string;
color: string;
icon: string;
}
export type SemanticTurnMessageLifetime = { kind: 'finite'; at: string } | { kind: 'infinite' };
export interface SemanticTurnMessage {
mailbox: number;
type: string;
sourceId: number;
destinationId: number;
createdAt: string;
validUntil: SemanticTurnMessageLifetime;
source: SemanticTurnMessageTarget;
destination: SemanticTurnMessageTarget;
text: string;
option: unknown;
}
export interface StrictTurnMessageTimeline {
beforeGameNow: string;
afterGameNow: string;
messageCreatedAts: string[];
usesSingleTick: boolean;
}
export interface SemanticUnreadMessageDelta {
generalId: number;
unreadPrivateBefore: number;
unreadPrivateAfter: number;
unreadPrivateDelta: number;
unreadDiplomacyBefore: number;
unreadDiplomacyAfter: number;
unreadDiplomacyDelta: number;
hadUnreadMessage: boolean;
hasUnreadMessage: boolean;
}
const asRecord = (value: unknown, field: string): JsonRecord => {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`${field} must be an object`);
}
return value as JsonRecord;
};
const readAliasedValue = (record: JsonRecord, aliases: readonly string[], field: string): unknown => {
for (const alias of aliases) {
if (Object.prototype.hasOwnProperty.call(record, alias)) {
return record[alias];
}
}
throw new Error(`${field} is missing`);
};
const readNumber = (record: JsonRecord, aliases: readonly string[], field: string): number => {
const value = readAliasedValue(record, aliases, field);
const number = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(number)) {
throw new Error(`${field} must be a finite number`);
}
return number;
};
const readString = (record: JsonRecord, aliases: readonly string[], field: string): string => {
const value = readAliasedValue(record, aliases, field);
if (typeof value !== 'string') {
throw new Error(`${field} must be a string`);
}
return value;
};
const normalizeTimestamp = (value: unknown, field = 'createdAt'): string => {
const raw = value instanceof Date ? value.toISOString() : String(value);
const withTimezone = raw.includes('T') ? raw : `${raw.replace(' ', 'T')}Z`;
const millisecondPrecision = withTimezone.replace(/(\.\d{3})\d+(?=(?:Z|[+-]\d{2}:\d{2})$)/u, '$1');
const timestamp = Date.parse(millisecondPrecision);
if (!Number.isFinite(timestamp)) {
throw new Error(`${field} must be a valid timestamp: ${raw}`);
}
return new Date(timestamp).toISOString();
};
const normalizeMessageLifetime = (value: unknown): SemanticTurnMessageLifetime => {
if (value === 'infinite') {
return { kind: 'infinite' };
}
if (value === null || value === undefined) {
throw new Error('message.validUntil must be a finite timestamp or the infinite sentinel');
}
return { kind: 'finite', at: normalizeTimestamp(value, 'message.validUntil') };
};
const normalizeJsonValue = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(normalizeJsonValue);
}
if (typeof value !== 'object' || value === null) {
return value;
}
const record = value as JsonRecord;
return Object.fromEntries(
Object.keys(record)
.sort()
.map((key) => [key, normalizeJsonValue(record[key])])
);
};
const normalizeOption = (value: unknown, context: { mailbox: number; type: string; sourceId: number }): unknown => {
if (context.type === 'diplomacy' && context.mailbox === context.sourceId && value === null) {
return { kind: 'actionable-diplomacy-sender-redacted' };
}
// Ref serializes an empty PHP option array as `[]`, while Core represents
// the same absence of option fields as `{}`. Non-empty arrays and every
// option field remain exact. The actionable diplomacy sender null above is
// a distinct security contract and must not collapse into ordinary absence.
if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) {
return {};
}
return normalizeJsonValue(value);
};
const normalizeTarget = (value: unknown, field: string): SemanticTurnMessageTarget => {
const target = asRecord(value, field);
return {
generalId: readNumber(target, ['generalId', 'id'], `${field}.generalId`),
generalName: readString(target, ['generalName', 'name'], `${field}.generalName`),
nationId: readNumber(target, ['nationId', 'nation_id'], `${field}.nationId`),
nationName: readString(target, ['nationName', 'nation'], `${field}.nationName`),
color: readString(target, ['color'], `${field}.color`),
icon: readString(target, ['icon'], `${field}.icon`),
};
};
export const projectSemanticTurnMessages = (
messages: CanonicalTurnSnapshot['messages'],
messageAfterId: number
): SemanticTurnMessage[] =>
messages
.filter((message) => readNumber(message, ['id'], 'message.id') > messageAfterId)
.map((message) => {
const payload = asRecord(readAliasedValue(message, ['payload'], 'message.payload'), 'message.payload');
const mailbox = readNumber(message, ['mailbox'], 'message.mailbox');
const type = readString(message, ['type'], 'message.type');
const sourceId = readNumber(message, ['sourceId'], 'message.sourceId');
return {
mailbox,
type,
sourceId,
destinationId: readNumber(message, ['destinationId'], 'message.destinationId'),
createdAt: normalizeTimestamp(readAliasedValue(message, ['createdAt'], 'message.createdAt')),
validUntil: normalizeMessageLifetime(readAliasedValue(message, ['validUntil'], 'message.validUntil')),
source: normalizeTarget(
readAliasedValue(payload, ['src'], 'message.payload.src'),
'message.payload.src'
),
destination: normalizeTarget(
readAliasedValue(payload, ['dest'], 'message.payload.dest'),
'message.payload.dest'
),
text: readString(payload, ['text'], 'message.payload.text'),
option: normalizeOption(payload.option, { mailbox, type, sourceId }),
};
});
export const projectStrictTurnMessageTimeline = (
before: CanonicalTurnSnapshot,
after: CanonicalTurnSnapshot,
messageAfterId: number
): StrictTurnMessageTimeline => {
const beforeGameNow = normalizeTimestamp(
readAliasedValue(asRecord(before.world, 'before.world'), ['gameNow'], 'before.world.gameNow')
);
const afterGameNow = normalizeTimestamp(
readAliasedValue(asRecord(after.world, 'after.world'), ['gameNow'], 'after.world.gameNow')
);
const messageCreatedAts = projectSemanticTurnMessages(after.messages, messageAfterId).map(
(message) => message.createdAt
);
return {
beforeGameNow,
afterGameNow,
messageCreatedAts,
usesSingleTick:
afterGameNow === beforeGameNow && messageCreatedAts.every((createdAt) => createdAt === beforeGameNow),
};
};
interface SemanticUnreadState {
unreadPrivateCount: number;
unreadDiplomacyCount: number;
hasUnreadMessage: boolean;
}
const readUnreadState = (general: JsonRecord): SemanticUnreadState => {
const generalId = readNumber(general, ['id'], 'general.id');
const state = asRecord(general.messageReadState, `general[${generalId}].messageReadState`);
const hasUnreadMessage = readAliasedValue(
state,
['hasUnreadMessage'],
`general[${generalId}].messageReadState.hasUnreadMessage`
);
if (typeof hasUnreadMessage !== 'boolean') {
throw new Error(`general[${generalId}].messageReadState.hasUnreadMessage must be a boolean`);
}
return {
unreadPrivateCount: readNumber(
state,
['unreadPrivateCount'],
`general[${generalId}].messageReadState.unreadPrivateCount`
),
unreadDiplomacyCount: readNumber(
state,
['unreadDiplomacyCount'],
`general[${generalId}].messageReadState.unreadDiplomacyCount`
),
hasUnreadMessage,
};
};
export const projectSemanticUnreadMessageDeltas = (
before: CanonicalTurnSnapshot,
after: CanonicalTurnSnapshot
): SemanticUnreadMessageDelta[] => {
const beforeByGeneralId = new Map(
before.generals.map((general) => [readNumber(general, ['id'], 'general.id'), readUnreadState(general)] as const)
);
const afterByGeneralId = new Map(
after.generals.map((general) => [readNumber(general, ['id'], 'general.id'), readUnreadState(general)] as const)
);
const generalIds = [...new Set([...beforeByGeneralId.keys(), ...afterByGeneralId.keys()])].sort(
(left, right) => left - right
);
return generalIds.map((generalId) => {
const beforeState = beforeByGeneralId.get(generalId) ?? {
unreadPrivateCount: 0,
unreadDiplomacyCount: 0,
hasUnreadMessage: false,
};
const afterState = afterByGeneralId.get(generalId) ?? {
unreadPrivateCount: 0,
unreadDiplomacyCount: 0,
hasUnreadMessage: false,
};
return {
generalId,
unreadPrivateBefore: beforeState.unreadPrivateCount,
unreadPrivateAfter: afterState.unreadPrivateCount,
unreadPrivateDelta: afterState.unreadPrivateCount - beforeState.unreadPrivateCount,
unreadDiplomacyBefore: beforeState.unreadDiplomacyCount,
unreadDiplomacyAfter: afterState.unreadDiplomacyCount,
unreadDiplomacyDelta: afterState.unreadDiplomacyCount - beforeState.unreadDiplomacyCount,
hadUnreadMessage: beforeState.hasUnreadMessage,
hasUnreadMessage: afterState.hasUnreadMessage,
};
});
};
@@ -117,5 +117,24 @@ export const runReferenceTurnCommandTraceRequest = (
...referenceRunnerEnvironment(workspaceRoot, stackDirectory),
},
});
return withProjectedTraceMeta(JSON.parse(stdout) as CanonicalTurnCommandTrace);
const raw = JSON.parse(stdout) as CanonicalTurnCommandTrace & {
harness?: { messageSharedIconBaseUrl?: unknown };
};
const messageSharedIconBaseUrl = raw.harness?.messageSharedIconBaseUrl;
if (typeof messageSharedIconBaseUrl === 'string' && messageSharedIconBaseUrl !== '') {
const setup =
typeof request.setup === 'object' && request.setup !== null && !Array.isArray(request.setup)
? (request.setup as Record<string, unknown>)
: {};
const world =
typeof setup.world === 'object' && setup.world !== null && !Array.isArray(setup.world)
? (setup.world as Record<string, unknown>)
: {};
request.setup = {
...setup,
world: { ...world, messageSharedIconBaseUrl },
};
}
const { harness: _harness, ...trace } = raw;
return withProjectedTraceMeta(trace);
};
@@ -1,5 +1,9 @@
import type { CanonicalTurnCommandTrace, TurnSnapshotSelector } from './canonical.js';
import { readCoreDatabaseSnapshot } from './databaseSnapshot.js';
import {
closeTurnSnapshotSelectorOverCreatedEntities,
type CanonicalTurnCommandTrace,
type TurnSnapshotSelector,
} from './canonical.js';
import { readCoreDatabaseEntityIds, readCoreDatabaseSnapshot } from './databaseSnapshot.js';
export interface CoreTurnTraceRequest {
kind: 'general' | 'nation';
@@ -17,10 +21,19 @@ export const captureCoreDatabaseTurnTrace = async (
rng?: CanonicalTurnCommandTrace['rng'];
}>
): Promise<CanonicalTurnCommandTrace> => {
const before = await readCoreDatabaseSnapshot(databaseUrl, request.observe);
const [before, entityIdsBefore] = await Promise.all([
readCoreDatabaseSnapshot(databaseUrl, request.observe),
readCoreDatabaseEntityIds(databaseUrl),
]);
const result = await execute();
const entityIdsAfter = await readCoreDatabaseEntityIds(databaseUrl);
const afterSelector = closeTurnSnapshotSelectorOverCreatedEntities(
request.observe,
entityIdsBefore,
entityIdsAfter
);
const after = await readCoreDatabaseSnapshot(databaseUrl, {
...request.observe,
...afterSelector,
logAfterId: request.observe.logAfterId ?? before.watermarks.logId,
messageAfterId: request.observe.messageAfterId ?? before.watermarks.messageId,
});