fix: preserve best-general rank data parity

This commit is contained in:
2026-07-26 06:03:21 +00:00
parent 06179cbd4f
commit 875dc610fb
17 changed files with 460 additions and 197 deletions
+55 -15
View File
@@ -8,8 +8,29 @@ import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { authedProcedure, procedure, router } from '../../trpc.js'; import { authedProcedure, procedure, router } from '../../trpc.js';
const DEFAULT_BG_COLOR = '#2b2b2b'; const DEFAULT_BG_COLOR = '#330000';
const DEFAULT_FG_COLOR = '#ffffff'; const DEFAULT_FG_COLOR = '#ffffff';
const NEUTRAL_BG_COLOR = '#000000';
const LEGACY_WHITE_TEXT_COLORS = new Set([
'',
'#330000',
'#FF0000',
'#800000',
'#A0522D',
'#FF6347',
'#808000',
'#008000',
'#2E8B57',
'#008080',
'#6495ED',
'#0000FF',
'#000080',
'#483D8B',
'#7B68EE',
'#800080',
'#A9A9A9',
'#000000',
]);
const readMetaNumber = (value: unknown): number => { const readMetaNumber = (value: unknown): number => {
if (typeof value === 'number' && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
@@ -24,7 +45,17 @@ const readMetaNumber = (value: unknown): number => {
return 0; return 0;
}; };
const percentText = (value: number): string => `${(value * 100).toFixed(2)}%`; export const formatLegacyRankingNumber = (value: number, fractionDigits = 0): string =>
new Intl.NumberFormat('en-US', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
useGrouping: true,
}).format(value);
export const resolveLegacyTextColor = (backgroundColor: string): string =>
LEGACY_WHITE_TEXT_COLORS.has(backgroundColor.toUpperCase()) ? '#ffffff' : '#000000';
const percentText = (value: number): string => `${formatLegacyRankingNumber(value * 100, 2)}%`;
const readOwnerDisplayName = (value: unknown): string | null => { const readOwnerDisplayName = (value: unknown): string | null => {
const meta = asRecord(value); const meta = asRecord(value);
@@ -72,6 +103,7 @@ export const rankingRouter = router({
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }), ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
ctx.db.general.findMany({ ctx.db.general.findMany({
where: { npcState: npcFilter }, where: { npcState: npcFilter },
orderBy: { id: 'asc' },
select: { select: {
id: true, id: true,
name: true, name: true,
@@ -133,11 +165,11 @@ export const rankingRouter = router({
} }
return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0); return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0);
}], }],
['보 병 숙 련 도', 'int', (_g, r) => r.dex1 ?? 0], ['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)],
['궁 병 숙 련 도', 'int', (_g, r) => r.dex2 ?? 0], ['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)],
['기 병 숙 련 도', 'int', (_g, r) => r.dex3 ?? 0], ['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)],
['귀 병 숙 련 도', 'int', (_g, r) => r.dex4 ?? 0], ['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)],
['차 병 숙 련 도', 'int', (_g, r) => r.dex5 ?? 0], ['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)],
['전 력 전 승 률', 'percent', (_g, r) => { ['전 력 전 승 률', 'percent', (_g, r) => {
const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0); const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0);
if (total < 50) { if (total < 50) {
@@ -186,17 +218,20 @@ export const rankingRouter = router({
const ranks = rankMap.get(general.id) ?? {}; const ranks = rankMap.get(general.id) ?? {};
const value = valueFn(general, ranks); const value = valueFn(general, ranks);
const nation = nationMap.get(general.nationId) ?? null; const nation = nationMap.get(general.nationId) ?? null;
const bgColor =
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
let display = { let display = {
id: general.id, id: general.id,
name: general.name, name: general.name,
ownerName: isUnited ? readOwnerDisplayName(general.meta) : null, ownerName: isUnited ? readOwnerDisplayName(general.meta) : null,
nationName: nation?.name ?? '재야', nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? DEFAULT_BG_COLOR, bgColor,
fgColor: DEFAULT_FG_COLOR, fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null, picture: general.picture ?? null,
imageServer: general.imageServer ?? 0, imageServer: general.imageServer ?? 0,
value, value,
printValue: valueType === 'percent' ? percentText(value) : Math.floor(value).toLocaleString('ko-KR'), printValue:
valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value),
}; };
if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) { if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) {
@@ -206,7 +241,7 @@ export const rankingRouter = router({
ownerName: null, ownerName: null,
nationName: '???', nationName: '???',
bgColor: DEFAULT_BG_COLOR, bgColor: DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR, fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null, picture: null,
imageServer: 0, imageServer: 0,
}; };
@@ -268,12 +303,14 @@ export const rankingRouter = router({
}) })
.map((general) => { .map((general) => {
const nation = nationMap.get(general.nationId) ?? null; const nation = nationMap.get(general.nationId) ?? null;
const bgColor =
nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR);
return { return {
id: general.id, id: general.id,
name: general.name, name: general.name,
nationName: nation?.name ?? '재야', nationName: nation?.name ?? '재야',
bgColor: nation?.color ?? DEFAULT_BG_COLOR, bgColor,
fgColor: DEFAULT_FG_COLOR, fgColor: resolveLegacyTextColor(bgColor),
picture: general.picture ?? null, picture: general.picture ?? null,
imageServer: general.imageServer ?? 0, imageServer: general.imageServer ?? 0,
}; };
@@ -299,7 +336,7 @@ export const rankingRouter = router({
name: '미발견', name: '미발견',
nationName: '-', nationName: '-',
bgColor: DEFAULT_BG_COLOR, bgColor: DEFAULT_BG_COLOR,
fgColor: DEFAULT_FG_COLOR, fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR),
picture: null, picture: null,
imageServer: 0, imageServer: 0,
}, },
@@ -398,7 +435,10 @@ export const rankingRouter = router({
picture: typeof aux.picture === 'string' ? aux.picture : null, picture: typeof aux.picture === 'string' ? aux.picture : null,
imageServer: readMetaNumber(aux.imgsvr), imageServer: readMetaNumber(aux.imgsvr),
value: row.value, value: row.value,
printValue: type.type === 'percent' ? percentText(row.value) : Math.floor(row.value).toLocaleString('ko-KR'), printValue:
type.type === 'percent'
? percentText(row.value)
: formatLegacyRankingNumber(row.value),
serverName: String(aux.serverName ?? ''), serverName: String(aux.serverName ?? ''),
serverIdx: readMetaNumber(aux.serverIdx), serverIdx: readMetaNumber(aux.serverIdx),
scenarioName: String(aux.scenarioName ?? ''), scenarioName: String(aux.scenarioName ?? ''),
+28 -3
View File
@@ -9,6 +9,7 @@ import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.j
import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js';
import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js';
import { appRouter } from '../src/router.js'; import { appRouter } from '../src/router.js';
import { formatLegacyRankingNumber, resolveLegacyTextColor } from '../src/router/ranking/index.js';
const profile: GameProfile = { const profile: GameProfile = {
id: 'che', id: 'che',
@@ -40,7 +41,7 @@ const generalRows = [
npcState: 0, npcState: 0,
picture: '1.jpg', picture: '1.jpg',
imageServer: 0, imageServer: 0,
meta: { ownerName: '공개소유자' }, meta: { ownerName: '공개소유자', dex1: 120 },
experience: 1200, experience: 1200,
dedication: 900, dedication: 900,
horseCode: 'che_명마_15_적토마', horseCode: 'che_명마_15_적토마',
@@ -56,7 +57,7 @@ const generalRows = [
npcState: 1, npcState: 1,
picture: null, picture: null,
imageServer: 0, imageServer: 0,
meta: { owner_name: '빙의소유자' }, meta: { owner_name: '빙의소유자', dex1: 80 },
experience: 1100, experience: 1100,
dedication: 800, dedication: 800,
horseCode: 'None', horseCode: 'None',
@@ -72,7 +73,7 @@ const generalRows = [
npcState: 2, npcState: 2,
picture: null, picture: null,
imageServer: 0, imageServer: 0,
meta: {}, meta: { dex1: 200 },
experience: 1300, experience: 1300,
dedication: 1000, dedication: 1000,
horseCode: 'None', horseCode: 'None',
@@ -122,6 +123,9 @@ const buildContext = (options?: {
{ generalId: 1, type: 'firenum', value: 10 }, { generalId: 1, type: 'firenum', value: 10 },
{ generalId: 2, type: 'firenum', value: 20 }, { generalId: 2, type: 'firenum', value: 20 },
{ generalId: 3, type: 'firenum', value: 30 }, { generalId: 3, type: 'firenum', value: 30 },
{ generalId: 1, type: 'dex1', value: 999 },
{ generalId: 2, type: 'dex1', value: 999 },
{ generalId: 3, type: 'dex1', value: 999 },
], ],
}, },
auction: { auction: {
@@ -218,6 +222,27 @@ describe('ranking.getBestGeneral', () => {
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' }); const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]); expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
}); });
it('uses the general dex columns as the legacy source of truth instead of mirrored rank rows', async () => {
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'user' });
const dex = result.sections.find((section) => section.title === '보 병 숙 련 도');
expect(dex?.entries.map((entry) => [entry.id, entry.value, entry.printValue])).toEqual([
[1, 120, '120'],
[2, 80, '80'],
]);
expect(dex?.entries[0]).toMatchObject({
bgColor: '#006400',
fgColor: '#000000',
});
});
it('matches PHP number_format rounding and the legacy fixed color table', () => {
expect(formatLegacyRankingNumber(1.005, 2)).toBe('1.01');
expect(formatLegacyRankingNumber(12345.6, 2)).toBe('12,345.60');
expect(resolveLegacyTextColor('#006400')).toBe('#000000');
expect(resolveLegacyTextColor('#330000')).toBe('#ffffff');
});
}); });
describe('ranking hall of fame', () => { describe('ranking hall of fame', () => {
+4 -79
View File
@@ -22,7 +22,7 @@ import {
type LogEntryDraft, type LogEntryDraft,
type MessageRecordDraft, type MessageRecordDraft,
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js'; import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -33,6 +33,7 @@ import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence
import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js'; import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js';
import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js';
import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js';
import { buildPersistedRankRows } from './rankData.js';
export interface DatabaseTurnHooks { export interface DatabaseTurnHooks {
hooks: TurnDaemonHooks; hooks: TurnDaemonHooks;
@@ -270,20 +271,6 @@ const toLegacyDatabaseInt = (value: number): number => {
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5); return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
}; };
const readRankMetaNumber = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return toLegacyDatabaseInt(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return toLegacyDatabaseInt(parsed);
}
}
return 0;
};
const LEGACY_INTEGER_GENERAL_META_KEYS = [ const LEGACY_INTEGER_GENERAL_META_KEYS = [
'leadership_exp', 'leadership_exp',
'strength_exp', 'strength_exp',
@@ -312,72 +299,10 @@ const buildPersistedGeneralMeta = (
return asJson(meta); return asJson(meta);
}; };
const buildRankRows = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Array<{ generalId: number; nationId: number; type: string; value: number }> => {
const meta = asRecord(general.meta);
const readMeta = (key: string) => readRankMetaNumber(meta, key);
const readRank = (key: string) => readRankMetaNumber(meta, `rank_${key}`);
const entries: Array<[RankDataType, number]> = [
['experience', toLegacyDatabaseInt(general.experience)],
['dedication', toLegacyDatabaseInt(general.dedication)],
['firenum', readMeta('firenum')],
['warnum', readRank('warnum')],
['killnum', readRank('killnum')],
['deathnum', readRank('deathnum')],
['occupied', readRank('occupied')],
['killcrew', readRank('killcrew')],
['deathcrew', readRank('deathcrew')],
['killcrew_person', readRank('killcrew_person')],
['deathcrew_person', readRank('deathcrew_person')],
['dex1', readMeta('dex1')],
['dex2', readMeta('dex2')],
['dex3', readMeta('dex3')],
['dex4', readMeta('dex4')],
['dex5', readMeta('dex5')],
['ttw', readMeta('ttw')],
['ttd', readMeta('ttd')],
['ttl', readMeta('ttl')],
['ttg', readMeta('ttg')],
['ttp', readMeta('ttp')],
['tlw', readMeta('tlw')],
['tld', readMeta('tld')],
['tll', readMeta('tll')],
['tlg', readMeta('tlg')],
['tlp', readMeta('tlp')],
['tsw', readMeta('tsw')],
['tsd', readMeta('tsd')],
['tsl', readMeta('tsl')],
['tsg', readMeta('tsg')],
['tsp', readMeta('tsp')],
['tiw', readMeta('tiw')],
['tid', readMeta('tid')],
['til', readMeta('til')],
['tig', readMeta('tig')],
['tip', readMeta('tip')],
['betgold', readMeta('betgold')],
['betwin', readMeta('betwin')],
['betwingold', readMeta('betwingold')],
['inherit_earned', readMeta('inherit_earned')],
['inherit_spent', readMeta('inherit_spent')],
['inherit_earned_dyn', readMeta('inherit_earned_dyn')],
['inherit_earned_act', readMeta('inherit_earned_act')],
['inherit_spent_dyn', readMeta('inherit_spent_dyn')],
];
return entries.map(([type, value]) => ({
generalId: general.id,
nationId: general.nationId,
type,
value,
}));
};
const buildInitialRankRows = ( const buildInitialRankRows = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number] general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
): Array<{ generalId: number; nationId: number; type: string; value: number }> => ): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
buildRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 })); buildPersistedRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
const buildGeneralUpdate = ( const buildGeneralUpdate = (
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number] general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
@@ -953,7 +878,7 @@ export const createDatabaseTurnHooks = async (
if (createdGenerals.length > 0 || rankTargets.length > 0) { if (createdGenerals.length > 0 || rankTargets.length > 0) {
const rankRows = [ const rankRows = [
...createdGenerals.flatMap(buildInitialRankRows), ...createdGenerals.flatMap(buildInitialRankRows),
...rankTargets.flatMap(buildRankRows), ...rankTargets.flatMap(buildPersistedRankRows),
]; ];
await Promise.all( await Promise.all(
rankRows.map((row) => rankRows.map((row) =>
+87
View File
@@ -0,0 +1,87 @@
import {
LEGACY_RANK_DATA_TYPES,
RANK_DATA_TYPES,
rankDataMetaKey,
type LegacyRankDataType,
type RankDataType,
} from '@sammo-ts/common';
export interface RankedGeneralState {
id: number;
nationId: number;
experience: number;
dedication: number;
meta: unknown;
}
export interface PersistedRankRow {
generalId: number;
nationId: number;
type: RankDataType;
value: number;
}
const asRecord = (value: unknown): Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
const toLegacyDatabaseInt = (value: number): number => {
if (!Number.isFinite(value)) {
return 0;
}
return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5);
};
const readMetaNumber = (meta: Record<string, unknown>, key: string): number => {
const value = meta[key];
if (typeof value === 'number' && Number.isFinite(value)) {
return toLegacyDatabaseInt(value);
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? toLegacyDatabaseInt(parsed) : 0;
}
return 0;
};
export const rankMetaKey = rankDataMetaKey;
export const buildPersistedRankRows = (general: RankedGeneralState): PersistedRankRow[] => {
const meta = asRecord(general.meta);
return RANK_DATA_TYPES.map((type) => {
const value =
type === 'experience'
? toLegacyDatabaseInt(general.experience)
: type === 'dedication'
? toLegacyDatabaseInt(general.dedication)
: readMetaNumber(meta, rankMetaKey(type));
return {
generalId: general.id,
nationId: general.nationId,
type,
value,
};
});
};
export const buildLegacyComparableRankRows = (
general: RankedGeneralState
): Array<PersistedRankRow & { type: LegacyRankDataType }> => {
const legacyTypes = new Set<RankDataType>(LEGACY_RANK_DATA_TYPES);
return buildPersistedRankRows(general).filter(
(row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type)
);
};
export const applyPersistedRankRowsToMeta = (
rawMeta: Record<string, unknown>,
rows: ReadonlyArray<{ type: string; value: number }>
): void => {
const supportedTypes = new Set<string>(RANK_DATA_TYPES);
for (const row of rows) {
if (row.type === 'experience' || row.type === 'dedication' || !supportedTypes.has(row.type)) {
continue;
}
rawMeta[rankMetaKey(row.type as RankDataType)] = row.value;
}
};
@@ -37,7 +37,7 @@ import {
} from '@sammo-ts/logic'; } from '@sammo-ts/logic';
import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import { asRecord, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { asRecord, LEGACY_RANK_DATA_TYPES, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
import type { ConstraintContext, StateView } from '@sammo-ts/logic'; import type { ConstraintContext, StateView } from '@sammo-ts/logic';
@@ -57,6 +57,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js';
import { buildActionContext } from './reservedTurnActionContext.js'; import { buildActionContext } from './reservedTurnActionContext.js';
import { GeneralAI, shouldUseAi } from './ai/generalAi.js'; import { GeneralAI, shouldUseAi } from './ai/generalAi.js';
import type { AiReservedTurnProvider } from './ai/types.js'; import type { AiReservedTurnProvider } from './ai/types.js';
import { rankMetaKey } from './rankData.js';
const DEFAULT_ACTION = '휴식'; const DEFAULT_ACTION = '휴식';
@@ -227,44 +228,11 @@ const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({
const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => { const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => {
const meta = { ...general.meta }; const meta = { ...general.meta };
for (const key of [ for (const type of LEGACY_RANK_DATA_TYPES) {
'firenum', meta[rankMetaKey(type)] = 0;
'rank_warnum',
'rank_killnum',
'rank_deathnum',
'rank_occupied',
'rank_killcrew',
'rank_deathcrew',
'rank_killcrew_person',
'rank_deathcrew_person',
'rank_ttw',
'rank_ttd',
'rank_ttl',
'rank_ttg',
'rank_ttp',
'rank_tlw',
'rank_tld',
'rank_tll',
'rank_tlg',
'rank_tlp',
'rank_tsw',
'rank_tsd',
'rank_tsl',
'rank_tsg',
'rank_tsp',
'rank_tiw',
'rank_tid',
'rank_til',
'rank_tig',
'rank_tip',
'rank_betgold',
'rank_betwin',
'rank_betwingold',
'specage',
'specage2',
]) {
meta[key] = 0;
} }
meta.specage = 0;
meta.specage2 = 0;
for (let dex = 1; dex <= 5; dex += 1) { for (let dex = 1; dex <= 5; dex += 1) {
const key = `dex${dex}`; const key = `dex${dex}`;
meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5); meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5);
+2 -17
View File
@@ -32,6 +32,7 @@ import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js';
import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js'; import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js';
import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js'; import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js';
import { readDiplomacyMeta } from '@sammo-ts/logic'; import { readDiplomacyMeta } from '@sammo-ts/logic';
import { applyPersistedRankRowsToMeta } from './rankData.js';
interface TurnWorldLoaderOptions { interface TurnWorldLoaderOptions {
databaseUrl: string; databaseUrl: string;
@@ -157,17 +158,6 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => {
return parsed.data; return parsed.data;
}; };
const GENERAL_RANK_META_PREFIX_TYPES = new Set([
'warnum',
'killnum',
'deathnum',
'occupied',
'killcrew',
'deathcrew',
'killcrew_person',
'deathcrew_person',
]);
const mapGeneralRow = ( const mapGeneralRow = (
row: TurnEngineGeneralRow, row: TurnEngineGeneralRow,
rankRows: readonly TurnEngineRankDataRow[], rankRows: readonly TurnEngineRankDataRow[],
@@ -181,12 +171,7 @@ const mapGeneralRow = (
item: normalizeCode(row.itemCode), item: normalizeCode(row.itemCode),
}; };
const rawMeta = { ...(asTriggerRecord(row.meta) as Record<string, unknown>) }; const rawMeta = { ...(asTriggerRecord(row.meta) as Record<string, unknown>) };
for (const rank of rankRows) { applyPersistedRankRowsToMeta(rawMeta, rankRows);
if (rank.type === 'experience' || rank.type === 'dedication') {
continue;
}
rawMeta[GENERAL_RANK_META_PREFIX_TYPES.has(rank.type) ? `rank_${rank.type}` : rank.type] = rank.value;
}
const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value])); const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value]));
const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots); const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots);
return { return {
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
import type { TurnSchedule } from '@sammo-ts/logic'; import type { TurnSchedule } from '@sammo-ts/logic';
import { rankMetaKey } from '../src/turn/rankData.js';
import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js';
import { createTurnTestHarness } from './helpers/turnTestHarness.js'; import { createTurnTestHarness } from './helpers/turnTestHarness.js';
@@ -294,6 +296,9 @@ describe('legacy general turn lifecycle', () => {
}); });
it('retires a player general and resets inherited stats and rank state', async () => { it('retires a player general and resets inherited stats and rank state', async () => {
const legacyRankMeta = Object.fromEntries(
LEGACY_RANK_DATA_TYPES.map((type, index) => [rankMetaKey(type), index + 1])
);
const harness = await createTurnTestHarness({ const harness = await createTurnTestHarness({
snapshot: makeSnapshot([ snapshot: makeSnapshot([
makeGeneral({ makeGeneral({
@@ -302,11 +307,11 @@ describe('legacy general turn lifecycle', () => {
experience: 101, experience: 101,
dedication: 81, dedication: 81,
meta: { meta: {
...legacyRankMeta,
killturn: 24, killturn: 24,
dex1: 101, dex1: 101,
inherit_lived_month: 10, inherit_lived_month: 10,
inherit_active_action: 4, inherit_active_action: 4,
rank_warnum: 12,
}, },
}), }),
]), ]),
@@ -325,7 +330,9 @@ describe('legacy general turn lifecycle', () => {
expect(updated.meta.dex1).toBe(51); expect(updated.meta.dex1).toBe(51);
expect(updated.meta.inherit_lived_month).toBe(0); expect(updated.meta.inherit_lived_month).toBe(0);
expect(updated.meta.inherit_active_action).toBe(0); expect(updated.meta.inherit_active_action).toBe(0);
expect(updated.meta.rank_warnum).toBe(0); for (const type of LEGACY_RANK_DATA_TYPES) {
expect(updated.meta[rankMetaKey(type)], type).toBe(0);
}
expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired'); expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('retired');
}); });
}); });
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { LEGACY_RANK_DATA_TYPES, RANK_DATA_TYPES } from '@sammo-ts/common';
import {
applyPersistedRankRowsToMeta,
buildLegacyComparableRankRows,
buildPersistedRankRows,
rankMetaKey,
} from '../src/turn/rankData.js';
describe('rank data projection', () => {
it('projects every core row while preserving legacy key and integer semantics', () => {
const rows = buildPersistedRankRows({
id: 7,
nationId: 2,
experience: 10.5,
dedication: 20.49,
meta: {
rank_warnum: '3.5',
ttw: 4.5,
inherit_earned: '9',
dex1: 12,
},
});
expect(rows).toHaveLength(RANK_DATA_TYPES.length);
expect(rows).toEqual(
expect.arrayContaining([
{ generalId: 7, nationId: 2, type: 'experience', value: 11 },
{ generalId: 7, nationId: 2, type: 'dedication', value: 20 },
{ generalId: 7, nationId: 2, type: 'warnum', value: 4 },
{ generalId: 7, nationId: 2, type: 'ttw', value: 5 },
{ generalId: 7, nationId: 2, type: 'inherit_earned', value: 9 },
{ generalId: 7, nationId: 2, type: 'dex1', value: 12 },
])
);
expect(buildLegacyComparableRankRows({
id: 7,
nationId: 2,
experience: 10.5,
dedication: 20.49,
meta: {},
})).toHaveLength(LEGACY_RANK_DATA_TYPES.length);
});
it('loads persisted rows into the same raw and prefixed meta keys used by commands', () => {
const meta: Record<string, unknown> = {};
applyPersistedRankRowsToMeta(meta, [
{ type: 'warnum', value: 3 },
{ type: 'ttw', value: 4 },
{ type: 'inherit_spent', value: 5 },
{ type: 'dex1', value: 6 },
{ type: 'unknown', value: 99 },
]);
expect(meta).toEqual({
rank_warnum: 3,
ttw: 4,
inherit_spent: 5,
dex1: 6,
});
expect(rankMetaKey('warnum')).toBe('rank_warnum');
expect(rankMetaKey('betgold')).toBe('betgold');
});
});
@@ -33,6 +33,15 @@ ref `next_execute` KV와 core general meta의 공통 projection으로 비교한
존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를 존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를
고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를 고정해 원 명령 미완료, 휴식 fallback, RNG 무소비와 semantic delta를
비교한다. 비교한다.
2026-07-26부터 canonical snapshot은 관찰 장수의 `rank_data`도 비교한다.
ref의 `RankColumn` 37종만 의미 행으로 정규화하며, ref에서 자연 `general`
column인 경험·공헌·숙련을 위해 core가 보유한 7개 mirror row는 비교에서
제외한다. 화계 fixture는 같은 초기 `firenum`에서 성공 명령 뒤 양쪽이
동일하게 1 증가하는지 확인한다. 은퇴 fixture는 37종 전부를 서로 다른
비영 값으로 채운 뒤 양쪽이 전부 0으로 만드는지 확인한다. 이 검증으로
일반 명령 snapshot에서 누락됐던 명장일람 누적치와 은퇴 후 메모리→DB
재저장 경로를 관찰한다.
자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과 자원 인자·보유량 경계 13개는 증여·헌납·군량매매의 100단위 반올림과
100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개, 100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개,
증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다. 증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다.
+65
View File
@@ -47,6 +47,71 @@ export const RANK_DATA_TYPES = [
export type RankDataType = (typeof RANK_DATA_TYPES)[number]; export type RankDataType = (typeof RANK_DATA_TYPES)[number];
/**
* Legacy `sammo\Enums\RankColumn` values stored in `rank_data`.
*
* `experience`, `dedication`, and `dex1` through `dex5` are natural general
* columns in the reference implementation. core2026 currently keeps mirrored
* rank rows for those values as a compatibility cache, but differential
* snapshots must compare this legacy set rather than treating the mirrors as
* source-of-truth rows.
*/
export const LEGACY_RANK_DATA_TYPES = [
'firenum',
'warnum',
'killnum',
'deathnum',
'killcrew',
'deathcrew',
'ttw',
'ttd',
'ttl',
'ttg',
'ttp',
'tlw',
'tld',
'tll',
'tlg',
'tlp',
'tsw',
'tsd',
'tsl',
'tsg',
'tsp',
'tiw',
'tid',
'til',
'tig',
'tip',
'betwin',
'betgold',
'betwingold',
'killcrew_person',
'deathcrew_person',
'occupied',
'inherit_earned',
'inherit_spent',
'inherit_earned_dyn',
'inherit_earned_act',
'inherit_spent_dyn',
] as const satisfies readonly RankDataType[];
export type LegacyRankDataType = (typeof LEGACY_RANK_DATA_TYPES)[number];
const PREFIXED_RANK_DATA_TYPES = new Set<RankDataType>([
'warnum',
'killnum',
'deathnum',
'occupied',
'killcrew',
'deathcrew',
'killcrew_person',
'deathcrew_person',
]);
export const rankDataMetaKey = (type: RankDataType): string =>
PREFIXED_RANK_DATA_TYPES.has(type) ? `rank_${type}` : type;
export const HALL_OF_FAME_TYPES = [ export const HALL_OF_FAME_TYPES = [
'experience', 'experience',
'dedication', 'dedication',
@@ -14,7 +14,7 @@ import type { TurnCommandEnv } from '@sammo-ts/logic/actions/turn/commandEnv.js'
import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js'; import { defaultActionContextBuilder } from '@sammo-ts/logic/actions/turn/actionContext.js';
import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import type { GeneralTurnCommandSpec } from './index.js'; import type { GeneralTurnCommandSpec } from './index.js';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil, LEGACY_RANK_DATA_TYPES, rankDataMetaKey } from '@sammo-ts/common';
export interface RetireArgs {} export interface RetireArgs {}
@@ -22,7 +22,6 @@ const ACTION_NAME = '은퇴';
const ACTION_KEY = 'che_은퇴'; const ACTION_KEY = 'che_은퇴';
const REQ_AGE = 60; const REQ_AGE = 60;
const reqGeneralValue = (): Constraint => ({ const reqGeneralValue = (): Constraint => ({
name: 'reqGeneralValue', name: 'reqGeneralValue',
requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], requires: (ctx) => [{ kind: 'general', id: ctx.actorId }],
@@ -51,46 +50,8 @@ export class ActionResolver<
} }
nextMeta.specAge = 0; nextMeta.specAge = 0;
nextMeta.specAge2 = 0; nextMeta.specAge2 = 0;
nextMeta.firenum = 0; for (const type of LEGACY_RANK_DATA_TYPES) {
for (const key of [ nextMeta[rankDataMetaKey(type)] = 0;
'warnum',
'killnum',
'deathnum',
'killcrew',
'deathcrew',
'ttw',
'ttd',
'ttl',
'ttg',
'ttp',
'tlw',
'tld',
'tll',
'tlg',
'tlp',
'tsw',
'tsd',
'tsl',
'tsg',
'tsp',
'tiw',
'tid',
'til',
'tig',
'tip',
'betwin',
'betgold',
'betwingold',
'killcrew_person',
'deathcrew_person',
'occupied',
'inherit_earned',
'inherit_spent',
'inherit_earned_dyn',
'inherit_earned_act',
'inherit_spent_dyn',
]) {
nextMeta[`rank_${key}`] = 0;
} }
const josaYi = JosaUtil.pick(general.name, '이'); const josaYi = JosaUtil.pick(general.name, '이');
@@ -13,6 +13,7 @@ export interface CanonicalTurnSnapshot {
engine: CanonicalEngine; engine: CanonicalEngine;
world: Record<string, unknown>; world: Record<string, unknown>;
generals: Array<Record<string, unknown>>; generals: Array<Record<string, unknown>>;
rankData: Array<Record<string, unknown>>;
cities: Array<Record<string, unknown>>; cities: Array<Record<string, unknown>>;
nations: Array<Record<string, unknown>>; nations: Array<Record<string, unknown>>;
diplomacy: Array<Record<string, unknown>>; diplomacy: Array<Record<string, unknown>>;
@@ -91,6 +92,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
meta: unknown; meta: unknown;
}; };
generals: Array<Record<string, unknown>>; generals: Array<Record<string, unknown>>;
rankData: Array<Record<string, unknown>>;
cities: Array<Record<string, unknown>>; cities: Array<Record<string, unknown>>;
nations: Array<Record<string, unknown>>; nations: Array<Record<string, unknown>>;
diplomacy: Array<Record<string, unknown>>; diplomacy: Array<Record<string, unknown>>;
@@ -99,6 +101,7 @@ export const projectCoreDatabaseSnapshot = (rows: {
logs: Array<Record<string, unknown>>; logs: Array<Record<string, unknown>>;
}): CanonicalTurnSnapshot => { }): CanonicalTurnSnapshot => {
const worldMeta = asRecord(rows.world.meta); const worldMeta = asRecord(rows.world.meta);
const legacyRankTypes = new Set<string>(LEGACY_RANK_DATA_TYPES);
const generals = rows.generals.map((row) => { const generals = rows.generals.map((row) => {
const meta = asRecord(row.meta); const meta = asRecord(row.meta);
return { return {
@@ -235,6 +238,14 @@ export const projectCoreDatabaseSnapshot = (rows: {
isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')), isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')),
}, },
generals, generals,
rankData: rows.rankData
.filter((row) => typeof row.type === 'string' && legacyRankTypes.has(row.type))
.map((row) => ({
generalId: row.generalId,
nationId: row.nationId,
type: row.type,
value: row.value,
})),
cities, cities,
nations, nations,
diplomacy, diplomacy,
@@ -248,3 +259,4 @@ export const projectCoreDatabaseSnapshot = (rows: {
}, },
}; };
}; };
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
@@ -14,6 +14,12 @@ export interface SnapshotComparisonOptions {
type FlatSnapshot = Map<string, unknown>; type FlatSnapshot = Map<string, unknown>;
const entityKey = (value: Record<string, unknown>, index: number): string => { const entityKey = (value: Record<string, unknown>, index: number): string => {
if (
(typeof value.generalId === 'number' || typeof value.generalId === 'string') &&
typeof value.type === 'string'
) {
return `${String(value.generalId)}:${value.type}`;
}
for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) { for (const key of ['id', 'generalId', 'nationId', 'fromNationId']) {
const candidate = value[key]; const candidate = value[key];
if (typeof candidate === 'number' || typeof candidate === 'string') { if (typeof candidate === 'number' || typeof candidate === 'string') {
@@ -18,6 +18,10 @@ import type {
TurnWorldSnapshot, TurnWorldSnapshot,
TurnWorldState, TurnWorldState,
} from '@sammo-ts/game-engine/turn/types.js'; } from '@sammo-ts/game-engine/turn/types.js';
import {
applyPersistedRankRowsToMeta,
buildLegacyComparableRankRows,
} from '@sammo-ts/game-engine/turn/rankData.js';
import { import {
canonicalizeTurnCommandArgs, canonicalizeTurnCommandArgs,
@@ -47,6 +51,7 @@ export interface TurnCommandFixtureRequest {
}; };
isolateWorld?: boolean; isolateWorld?: boolean;
generals?: Array<Record<string, unknown>>; generals?: Array<Record<string, unknown>>;
rankData?: Array<{ generalId: number; type: string; value: number }>;
nations?: Array<Record<string, unknown>>; nations?: Array<Record<string, unknown>>;
cities?: Array<Record<string, unknown>>; cities?: Array<Record<string, unknown>>;
troops?: Array<Record<string, unknown>>; troops?: Array<Record<string, unknown>>;
@@ -295,6 +300,17 @@ const buildWorldInput = (
const month = readNumber(referenceBefore.world, 'month', request.setup?.world?.month ?? 1); 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 turnTime = new Date(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00.000Z`);
const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime)); const generals = referenceBefore.generals.map((row) => buildGeneral(row, turnTime));
for (const general of generals) {
applyPersistedRankRowsToMeta(
general.meta,
referenceBefore.rankData
.filter((row) => readNumber(row, 'generalId') === general.id)
.map((row) => ({
type: readString(row, 'type', ''),
value: readNumber(row, 'value'),
}))
);
}
const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns) const referenceGeneralCooldowns = Array.isArray(referenceBefore.world.generalCooldowns)
? referenceBefore.world.generalCooldowns ? referenceBefore.world.generalCooldowns
: []; : [];
@@ -523,6 +539,11 @@ const projectWorld = (
}), }),
}, },
generals, generals,
rankData: world
.listGenerals()
.filter((general) => selector.generalIds.has(general.id))
.flatMap(buildLegacyComparableRankRows)
.map((row) => ({ ...row })),
cities: world cities: world
.listCities() .listCities()
.filter((city) => selector.cityIds.has(city.id)) .filter((city) => selector.cityIds.has(city.id))
@@ -11,11 +11,15 @@ export const readCoreDatabaseSnapshot = async (
try { try {
const db = connector.prisma; const db = connector.prisma;
const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } }); const world = await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } });
const [generals, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([ const [generals, rankData, cities, nations, diplomacy, generalTurns, nationTurns, logs] = await Promise.all([
db.general.findMany({ db.general.findMany({
where: { id: { in: selector.generalIds } }, where: { id: { in: selector.generalIds } },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}), }),
db.rankData.findMany({
where: { generalId: { in: selector.generalIds } },
orderBy: [{ generalId: 'asc' }, { type: 'asc' }],
}),
db.city.findMany({ db.city.findMany({
where: { id: { in: selector.cityIds } }, where: { id: { in: selector.cityIds } },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
@@ -54,6 +58,7 @@ export const readCoreDatabaseSnapshot = async (
return projectCoreDatabaseSnapshot({ return projectCoreDatabaseSnapshot({
world, world,
generals, generals,
rankData,
cities, cities,
nations, nations,
diplomacy, diplomacy,
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common';
import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js';
import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js';
@@ -72,6 +73,7 @@ interface FixturePatches {
troops?: Array<Record<string, unknown>>; troops?: Array<Record<string, unknown>>;
diplomacy?: Record<string, Record<string, unknown>>; diplomacy?: Record<string, Record<string, unknown>>;
randomFoundingCandidateCityIds?: number[]; randomFoundingCandidateCityIds?: number[];
rankData?: Array<{ generalId: number; type: string; value: number }>;
} }
const buildRequest = ( const buildRequest = (
@@ -166,6 +168,7 @@ const buildRequest = (
{ ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] }, { ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] },
{ ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] }, { ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] },
], ],
...(fixturePatches.rankData ? { rankData: fixturePatches.rankData } : {}),
...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}), ...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}),
...(fixturePatches.randomFoundingCandidateCityIds ...(fixturePatches.randomFoundingCandidateCityIds
? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds } ? { randomFoundingCandidateCityIds: fixturePatches.randomFoundingCandidateCityIds }
@@ -382,6 +385,67 @@ integration('general command success matrix', () => {
); );
}); });
integration('명장일람 rank_data command parity', () => {
it('화계 increments firenum from the same seeded value as legacy', async () => {
const request = buildRequest(
'che_화계',
{ destCityID: 70 },
{ intelligence: 100 },
{
generals: { 2: { intelligence: 10 } },
rankData: [{ generalId: 1, type: 'firenum', value: 17 }],
}
);
request.setup!.world!.hiddenSeed = 'general-injury-4';
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.after.rankData).toContainEqual(
expect.objectContaining({ generalId: 1, type: 'firenum', value: 18 })
);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
it('은퇴 resets every legacy RankColumn row exactly like legacy', async () => {
const request = buildRequest(
'che_은퇴',
undefined,
{ age: 65, lastTurn: { command: '은퇴', term: 1 } },
{
rankData: LEGACY_RANK_DATA_TYPES.map((type, index) => ({
generalId: 1,
type,
value: index + 1,
})),
}
);
const reference = runReferenceTurnCommandTraceRequest(
workspaceRoot!,
request as unknown as Record<string, unknown>
);
const core = await runCoreTurnCommandTrace(request, reference.before);
expect(reference.after.rankData.filter((row) => row.generalId === 1)).toHaveLength(
LEGACY_RANK_DATA_TYPES.length
);
expect(reference.after.rankData.filter((row) => row.generalId === 1).every((row) => row.value === 0)).toBe(
true
);
expect(
compareTurnSnapshotDeltas(reference.before, reference.after, core.before, core.after, {
ignoredPathPatterns: ignoredLifecyclePaths,
})
).toEqual([]);
}, 120_000);
});
type GeneralFailureCase = { type GeneralFailureCase = {
action: string; action: string;
args?: Record<string, unknown>; args?: Record<string, unknown>;
@@ -15,6 +15,7 @@ const snapshot = (
engine, engine,
world: { year: 183, month: 1, tickMinutes: 10, turnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 }, world: { year: 183, month: 1, tickMinutes: 10, turnTime: '0183-01-01T00:00:00.000Z', isUnited: 0 },
generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }], generals: [{ id: 1, gold: 1000, rice: 1000, crew: 1000, nationId: 1, cityId: 1 }],
rankData: [],
cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }], cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }],
nations: [{ id: 1, gold: 0, rice: 0 }], nations: [{ id: 1, gold: 0, rice: 0 }],
diplomacy: [], diplomacy: [],
@@ -44,6 +45,23 @@ describe('turn snapshot differential comparator', () => {
expect(compareTurnSnapshots(reference, core)).toEqual([]); expect(compareTurnSnapshots(reference, core)).toEqual([]);
}); });
it('compares rank rows by general and type instead of array position', () => {
const reference = snapshot('ref', {
rankData: [
{ generalId: 2, nationId: 1, type: 'firenum', value: 3 },
{ generalId: 1, nationId: 1, type: 'warnum', value: 5 },
],
});
const core = snapshot('core2026', {
rankData: [
{ generalId: 1, nationId: 1, type: 'warnum', value: 5 },
{ generalId: 2, nationId: 1, type: 'firenum', value: 3 },
],
});
expect(compareTurnSnapshots(reference, core)).toEqual([]);
});
it('normalizes legacy ID argument spelling at the trace boundary', () => { it('normalizes legacy ID argument spelling at the trace boundary', () => {
expect( expect(
canonicalizeTurnCommandArgs({ canonicalizeTurnCommandArgs({