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