From 875dc610fba078cbdcb00c4e38ea8293a743fa06 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 06:03:21 +0000 Subject: [PATCH] fix: preserve best-general rank data parity --- app/game-api/src/router/ranking/index.ts | 70 +++++++++++---- app/game-api/test/rankingRouter.test.ts | 31 ++++++- app/game-engine/src/turn/databaseHooks.ts | 83 +----------------- app/game-engine/src/turn/rankData.ts | 87 +++++++++++++++++++ .../src/turn/reservedTurnHandler.ts | 44 ++-------- app/game-engine/src/turn/worldLoader.ts | 19 +--- .../test/generalTurnLifecycle.test.ts | 11 ++- app/game-engine/test/rankData.test.ts | 65 ++++++++++++++ .../general-command-differential-testing.md | 9 ++ packages/common/src/ranking/types.ts | 65 ++++++++++++++ .../src/actions/turn/general/che_은퇴.ts | 45 +--------- .../src/turn-differential/canonical.ts | 12 +++ .../src/turn-differential/compare.ts | 6 ++ .../src/turn-differential/coreCommandTrace.ts | 21 +++++ .../src/turn-differential/databaseSnapshot.ts | 7 +- ...rnCommandGeneralMatrix.integration.test.ts | 64 ++++++++++++++ .../test/turnSnapshotComparator.test.ts | 18 ++++ 17 files changed, 460 insertions(+), 197 deletions(-) create mode 100644 app/game-engine/src/turn/rankData.ts create mode 100644 app/game-engine/test/rankData.test.ts diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index b170a83..b23dccf 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -8,8 +8,29 @@ import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import { authedProcedure, procedure, router } from '../../trpc.js'; -const DEFAULT_BG_COLOR = '#2b2b2b'; +const DEFAULT_BG_COLOR = '#330000'; 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 => { if (typeof value === 'number' && Number.isFinite(value)) { @@ -24,7 +45,17 @@ const readMetaNumber = (value: unknown): number => { 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 meta = asRecord(value); @@ -72,6 +103,7 @@ export const rankingRouter = router({ ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }), ctx.db.general.findMany({ where: { npcState: npcFilter }, + orderBy: { id: 'asc' }, select: { id: true, name: true, @@ -133,11 +165,11 @@ export const rankingRouter = router({ } return (r.killcrew_person ?? 0) / Math.max(1, r.deathcrew_person ?? 0); }], - ['보 병 숙 련 도', 'int', (_g, r) => r.dex1 ?? 0], - ['궁 병 숙 련 도', 'int', (_g, r) => r.dex2 ?? 0], - ['기 병 숙 련 도', 'int', (_g, r) => r.dex3 ?? 0], - ['귀 병 숙 련 도', 'int', (_g, r) => r.dex4 ?? 0], - ['차 병 숙 련 도', 'int', (_g, r) => r.dex5 ?? 0], + ['보 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex1)], + ['궁 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex2)], + ['기 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex3)], + ['귀 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex4)], + ['차 병 숙 련 도', 'int', (g) => readMetaNumber(asRecord(g.meta).dex5)], ['전 력 전 승 률', 'percent', (_g, r) => { const total = (r.ttw ?? 0) + (r.ttd ?? 0) + (r.ttl ?? 0); if (total < 50) { @@ -186,17 +218,20 @@ export const rankingRouter = router({ const ranks = rankMap.get(general.id) ?? {}; const value = valueFn(general, ranks); const nation = nationMap.get(general.nationId) ?? null; + const bgColor = + nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR); let display = { id: general.id, name: general.name, ownerName: isUnited ? readOwnerDisplayName(general.meta) : null, nationName: nation?.name ?? '재야', - bgColor: nation?.color ?? DEFAULT_BG_COLOR, - fgColor: DEFAULT_FG_COLOR, + bgColor, + fgColor: resolveLegacyTextColor(bgColor), picture: general.picture ?? null, imageServer: general.imageServer ?? 0, value, - printValue: valueType === 'percent' ? percentText(value) : Math.floor(value).toLocaleString('ko-KR'), + printValue: + valueType === 'percent' ? percentText(value) : formatLegacyRankingNumber(value), }; if (!isUnited && (title === '계 략 성 공' || title === '유 산 소 모 량' || title === '유 산 획 득 량')) { @@ -206,7 +241,7 @@ export const rankingRouter = router({ ownerName: null, nationName: '???', bgColor: DEFAULT_BG_COLOR, - fgColor: DEFAULT_FG_COLOR, + fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR), picture: null, imageServer: 0, }; @@ -268,12 +303,14 @@ export const rankingRouter = router({ }) .map((general) => { const nation = nationMap.get(general.nationId) ?? null; + const bgColor = + nation?.color ?? (general.nationId === 0 ? NEUTRAL_BG_COLOR : DEFAULT_BG_COLOR); return { id: general.id, name: general.name, nationName: nation?.name ?? '재야', - bgColor: nation?.color ?? DEFAULT_BG_COLOR, - fgColor: DEFAULT_FG_COLOR, + bgColor, + fgColor: resolveLegacyTextColor(bgColor), picture: general.picture ?? null, imageServer: general.imageServer ?? 0, }; @@ -299,7 +336,7 @@ export const rankingRouter = router({ name: '미발견', nationName: '-', bgColor: DEFAULT_BG_COLOR, - fgColor: DEFAULT_FG_COLOR, + fgColor: resolveLegacyTextColor(DEFAULT_BG_COLOR), picture: null, imageServer: 0, }, @@ -398,7 +435,10 @@ export const rankingRouter = router({ picture: typeof aux.picture === 'string' ? aux.picture : null, imageServer: readMetaNumber(aux.imgsvr), 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 ?? ''), serverIdx: readMetaNumber(aux.serverIdx), scenarioName: String(aux.scenarioName ?? ''), diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts index 7f897a6..84f4f50 100644 --- a/app/game-api/test/rankingRouter.test.ts +++ b/app/game-api/test/rankingRouter.test.ts @@ -9,6 +9,7 @@ import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.j import type { DatabaseClient, GameApiContext, GameProfile } from '../src/context.js'; import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; import { appRouter } from '../src/router.js'; +import { formatLegacyRankingNumber, resolveLegacyTextColor } from '../src/router/ranking/index.js'; const profile: GameProfile = { id: 'che', @@ -40,7 +41,7 @@ const generalRows = [ npcState: 0, picture: '1.jpg', imageServer: 0, - meta: { ownerName: '공개소유자' }, + meta: { ownerName: '공개소유자', dex1: 120 }, experience: 1200, dedication: 900, horseCode: 'che_명마_15_적토마', @@ -56,7 +57,7 @@ const generalRows = [ npcState: 1, picture: null, imageServer: 0, - meta: { owner_name: '빙의소유자' }, + meta: { owner_name: '빙의소유자', dex1: 80 }, experience: 1100, dedication: 800, horseCode: 'None', @@ -72,7 +73,7 @@ const generalRows = [ npcState: 2, picture: null, imageServer: 0, - meta: {}, + meta: { dex1: 200 }, experience: 1300, dedication: 1000, horseCode: 'None', @@ -122,6 +123,9 @@ const buildContext = (options?: { { generalId: 1, type: 'firenum', value: 10 }, { generalId: 2, type: 'firenum', value: 20 }, { 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: { @@ -218,6 +222,27 @@ describe('ranking.getBestGeneral', () => { const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' }); 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', () => { diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 996b78e..0462695 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -22,7 +22,7 @@ import { type LogEntryDraft, type MessageRecordDraft, } 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 { InMemoryTurnWorld } from './inMemoryWorld.js'; @@ -33,6 +33,7 @@ import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js'; import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; +import { buildPersistedRankRows } from './rankData.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -270,20 +271,6 @@ const toLegacyDatabaseInt = (value: number): number => { return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5); }; -const readRankMetaNumber = (meta: Record, 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 = [ 'leadership_exp', 'strength_exp', @@ -312,72 +299,10 @@ const buildPersistedGeneralMeta = ( return asJson(meta); }; -const buildRankRows = ( - general: ReturnType['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 = ( general: ReturnType['generals'][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 = ( general: ReturnType['generals'][number] @@ -953,7 +878,7 @@ export const createDatabaseTurnHooks = async ( if (createdGenerals.length > 0 || rankTargets.length > 0) { const rankRows = [ ...createdGenerals.flatMap(buildInitialRankRows), - ...rankTargets.flatMap(buildRankRows), + ...rankTargets.flatMap(buildPersistedRankRows), ]; await Promise.all( rankRows.map((row) => diff --git a/app/game-engine/src/turn/rankData.ts b/app/game-engine/src/turn/rankData.ts new file mode 100644 index 0000000..1ddeaf1 --- /dev/null +++ b/app/game-engine/src/turn/rankData.ts @@ -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 => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? { ...(value as Record) } + : {}; +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, 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 => { + const legacyTypes = new Set(LEGACY_RANK_DATA_TYPES); + return buildPersistedRankRows(general).filter( + (row): row is PersistedRankRow & { type: LegacyRankDataType } => legacyTypes.has(row.type) + ); +}; + +export const applyPersistedRankRowsToMeta = ( + rawMeta: Record, + rows: ReadonlyArray<{ type: string; value: number }> +): void => { + const supportedTypes = new Set(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; + } +}; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 6aa81ac..f9de9b0 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -37,7 +37,7 @@ import { } from '@sammo-ts/logic'; import { buildLegacyDefaultUniqueItemPool } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; 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'; @@ -57,6 +57,7 @@ import { buildFrontStatePatches } from './frontStateHandler.js'; import { buildActionContext } from './reservedTurnActionContext.js'; import { GeneralAI, shouldUseAi } from './ai/generalAi.js'; import type { AiReservedTurnProvider } from './ai/types.js'; +import { rankMetaKey } from './rankData.js'; const DEFAULT_ACTION = '휴식'; @@ -227,44 +228,11 @@ const cloneTurnGeneral = (general: TurnGeneral): TurnGeneral => ({ const resetRetiredGeneral = (general: TurnGeneral): TurnGeneral => { const meta = { ...general.meta }; - for (const key of [ - 'firenum', - '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; + for (const type of LEGACY_RANK_DATA_TYPES) { + meta[rankMetaKey(type)] = 0; } + meta.specage = 0; + meta.specage2 = 0; for (let dex = 1; dex <= 5; dex += 1) { const key = `dex${dex}`; meta[key] = Math.round(readMetaNumber(meta, key, 0) * 0.5); diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 90fc556..c0948f8 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -32,6 +32,7 @@ import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js'; import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js'; import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js'; import { readDiplomacyMeta } from '@sammo-ts/logic'; +import { applyPersistedRankRowsToMeta } from './rankData.js'; interface TurnWorldLoaderOptions { databaseUrl: string; @@ -157,17 +158,6 @@ const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => { return parsed.data; }; -const GENERAL_RANK_META_PREFIX_TYPES = new Set([ - 'warnum', - 'killnum', - 'deathnum', - 'occupied', - 'killcrew', - 'deathcrew', - 'killcrew_person', - 'deathcrew_person', -]); - const mapGeneralRow = ( row: TurnEngineGeneralRow, rankRows: readonly TurnEngineRankDataRow[], @@ -181,12 +171,7 @@ const mapGeneralRow = ( item: normalizeCode(row.itemCode), }; const rawMeta = { ...(asTriggerRecord(row.meta) as Record) }; - for (const rank of 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; - } + applyPersistedRankRowsToMeta(rawMeta, rankRows); const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value])); const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots); return { diff --git a/app/game-engine/test/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index 9097161..107e121 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; +import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; 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 { 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 () => { + const legacyRankMeta = Object.fromEntries( + LEGACY_RANK_DATA_TYPES.map((type, index) => [rankMetaKey(type), index + 1]) + ); const harness = await createTurnTestHarness({ snapshot: makeSnapshot([ makeGeneral({ @@ -302,11 +307,11 @@ describe('legacy general turn lifecycle', () => { experience: 101, dedication: 81, meta: { + ...legacyRankMeta, killturn: 24, dex1: 101, inherit_lived_month: 10, 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.inherit_lived_month).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'); }); }); diff --git a/app/game-engine/test/rankData.test.ts b/app/game-engine/test/rankData.test.ts new file mode 100644 index 0000000..bd0c589 --- /dev/null +++ b/app/game-engine/test/rankData.test.ts @@ -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 = {}; + 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'); + }); +}); diff --git a/docs/architecture/general-command-differential-testing.md b/docs/architecture/general-command-differential-testing.md index 17f02a8..06fb157 100644 --- a/docs/architecture/general-command-differential-testing.md +++ b/docs/architecture/general-command-differential-testing.md @@ -33,6 +33,15 @@ ref `next_execute` KV와 core general meta의 공통 projection으로 비교한 존재하지 않는 대상 경계 4개는 증여·등용의 장수 ID와 첩보·이동의 도시 ID를 고정해 원 명령 미완료, 휴식 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단위 반올림과 100..max clamp 9개, 헌납의 보유량보다 큰 요청·최소 쌀 미달 2개, 증여의 최소 쌀 보존·자기 자신 거부 2개를 비교한다. diff --git a/packages/common/src/ranking/types.ts b/packages/common/src/ranking/types.ts index 6350075..714f823 100644 --- a/packages/common/src/ranking/types.ts +++ b/packages/common/src/ranking/types.ts @@ -47,6 +47,71 @@ export const RANK_DATA_TYPES = [ 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([ + '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 = [ 'experience', 'dedication', diff --git a/packages/logic/src/actions/turn/general/che_은퇴.ts b/packages/logic/src/actions/turn/general/che_은퇴.ts index 400bc3d..557e217 100644 --- a/packages/logic/src/actions/turn/general/che_은퇴.ts +++ b/packages/logic/src/actions/turn/general/che_은퇴.ts @@ -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 { tryApplyUniqueLottery } from '@sammo-ts/logic/rewards/uniqueLottery.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 {} @@ -22,7 +22,6 @@ const ACTION_NAME = '은퇴'; const ACTION_KEY = 'che_은퇴'; const REQ_AGE = 60; - const reqGeneralValue = (): Constraint => ({ name: 'reqGeneralValue', requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], @@ -51,46 +50,8 @@ export class ActionResolver< } nextMeta.specAge = 0; nextMeta.specAge2 = 0; - nextMeta.firenum = 0; - for (const key of [ - '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; + for (const type of LEGACY_RANK_DATA_TYPES) { + nextMeta[rankDataMetaKey(type)] = 0; } const josaYi = JosaUtil.pick(general.name, '이'); diff --git a/tools/integration-tests/src/turn-differential/canonical.ts b/tools/integration-tests/src/turn-differential/canonical.ts index d7d118f..b130574 100644 --- a/tools/integration-tests/src/turn-differential/canonical.ts +++ b/tools/integration-tests/src/turn-differential/canonical.ts @@ -13,6 +13,7 @@ export interface CanonicalTurnSnapshot { engine: CanonicalEngine; world: Record; generals: Array>; + rankData: Array>; cities: Array>; nations: Array>; diplomacy: Array>; @@ -91,6 +92,7 @@ export const projectCoreDatabaseSnapshot = (rows: { meta: unknown; }; generals: Array>; + rankData: Array>; cities: Array>; nations: Array>; diplomacy: Array>; @@ -99,6 +101,7 @@ export const projectCoreDatabaseSnapshot = (rows: { logs: Array>; }): CanonicalTurnSnapshot => { const worldMeta = asRecord(rows.world.meta); + const legacyRankTypes = new Set(LEGACY_RANK_DATA_TYPES); const generals = rows.generals.map((row) => { const meta = asRecord(row.meta); return { @@ -235,6 +238,14 @@ export const projectCoreDatabaseSnapshot = (rows: { isUnited: readNumber(worldMeta, 'isUnited', readNumber(worldMeta, 'isunited')), }, 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, nations, diplomacy, @@ -248,3 +259,4 @@ export const projectCoreDatabaseSnapshot = (rows: { }, }; }; +import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; diff --git a/tools/integration-tests/src/turn-differential/compare.ts b/tools/integration-tests/src/turn-differential/compare.ts index 7d3b518..2d60c37 100644 --- a/tools/integration-tests/src/turn-differential/compare.ts +++ b/tools/integration-tests/src/turn-differential/compare.ts @@ -14,6 +14,12 @@ export interface SnapshotComparisonOptions { type FlatSnapshot = Map; const entityKey = (value: Record, 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']) { const candidate = value[key]; if (typeof candidate === 'number' || typeof candidate === 'string') { diff --git a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts index 8035dce..d01eac5 100644 --- a/tools/integration-tests/src/turn-differential/coreCommandTrace.ts +++ b/tools/integration-tests/src/turn-differential/coreCommandTrace.ts @@ -18,6 +18,10 @@ import type { TurnWorldSnapshot, TurnWorldState, } from '@sammo-ts/game-engine/turn/types.js'; +import { + applyPersistedRankRowsToMeta, + buildLegacyComparableRankRows, +} from '@sammo-ts/game-engine/turn/rankData.js'; import { canonicalizeTurnCommandArgs, @@ -47,6 +51,7 @@ export interface TurnCommandFixtureRequest { }; isolateWorld?: boolean; generals?: Array>; + rankData?: Array<{ generalId: number; type: string; value: number }>; nations?: Array>; cities?: Array>; troops?: Array>; @@ -295,6 +300,17 @@ const buildWorldInput = ( 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 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) ? referenceBefore.world.generalCooldowns : []; @@ -523,6 +539,11 @@ const projectWorld = ( }), }, generals, + rankData: world + .listGenerals() + .filter((general) => selector.generalIds.has(general.id)) + .flatMap(buildLegacyComparableRankRows) + .map((row) => ({ ...row })), cities: world .listCities() .filter((city) => selector.cityIds.has(city.id)) diff --git a/tools/integration-tests/src/turn-differential/databaseSnapshot.ts b/tools/integration-tests/src/turn-differential/databaseSnapshot.ts index 589d10c..9d34281 100644 --- a/tools/integration-tests/src/turn-differential/databaseSnapshot.ts +++ b/tools/integration-tests/src/turn-differential/databaseSnapshot.ts @@ -11,11 +11,15 @@ export const readCoreDatabaseSnapshot = async ( try { const db = connector.prisma; 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({ 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 } }, orderBy: { id: 'asc' }, @@ -54,6 +58,7 @@ export const readCoreDatabaseSnapshot = async ( return projectCoreDatabaseSnapshot({ world, generals, + rankData, cities, nations, diplomacy, diff --git a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts index 3ebcdf6..416a43d 100644 --- a/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts +++ b/tools/integration-tests/test/turnCommandGeneralMatrix.integration.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { LEGACY_RANK_DATA_TYPES } from '@sammo-ts/common'; import { compareTurnSnapshotDeltas } from '../src/turn-differential/compare.js'; import { runCoreTurnCommandTrace, type TurnCommandFixtureRequest } from '../src/turn-differential/coreCommandTrace.js'; @@ -72,6 +73,7 @@ interface FixturePatches { troops?: Array>; diplomacy?: Record>; randomFoundingCandidateCityIds?: number[]; + rankData?: Array<{ generalId: number; type: string; value: number }>; } const buildRequest = ( @@ -166,6 +168,7 @@ const buildRequest = ( { ...general(2, 2, 70, 12), ...fixturePatches.generals?.[2] }, { ...general(3, 1, 3, 1), ...fixturePatches.generals?.[3] }, ], + ...(fixturePatches.rankData ? { rankData: fixturePatches.rankData } : {}), ...(fixturePatches.troops ? { troops: fixturePatches.troops } : {}), ...(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 + ); + 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 + ); + 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 = { action: string; args?: Record; diff --git a/tools/integration-tests/test/turnSnapshotComparator.test.ts b/tools/integration-tests/test/turnSnapshotComparator.test.ts index 6579135..60973ba 100644 --- a/tools/integration-tests/test/turnSnapshotComparator.test.ts +++ b/tools/integration-tests/test/turnSnapshotComparator.test.ts @@ -15,6 +15,7 @@ const snapshot = ( engine, 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 }], + rankData: [], cities: [{ id: 1, nationId: 1, agriculture: 1000, defence: 500 }], nations: [{ id: 1, gold: 0, rice: 0 }], diplomacy: [], @@ -44,6 +45,23 @@ describe('turn snapshot differential comparator', () => { 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', () => { expect( canonicalizeTurnCommandArgs({