From 8ea8e6e26fa47e37cb4ff2862f081c62d6ae2343 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 14:10:11 +0000 Subject: [PATCH 1/3] fix(engine): roll back scheduled turns when flush fails --- .../src/lifecycle/turnDaemonLifecycle.ts | 33 ++- .../test/turnDaemonLifecycle.test.ts | 205 ++++++++++++++++++ 2 files changed, 218 insertions(+), 20 deletions(-) diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 6698114..d8e2e1f 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -315,9 +315,7 @@ export class TurnDaemonLifecycle { await this.commandResponder.publishCommandError(command.requestId, error); } catch (reportError) { const reportMessage = - reportError instanceof Error - ? reportError.message - : 'Unknown command failure reporting error.'; + reportError instanceof Error ? reportError.message : 'Unknown command failure reporting error.'; this.status.lastError = `${this.status.lastError} (failure report: ${reportMessage})`; } } @@ -347,36 +345,31 @@ export class TurnDaemonLifecycle { const budget = pending.budget ?? this.options.defaultBudget; const checkpoint = this.status.checkpoint; let result: TurnRunResult; + let fallbackError = 'Unknown turn daemon error.'; try { - const runProcessor = () => this.processor.run(targetTime, budget, checkpoint); - result = this.stateManager ? await this.stateManager.transaction(runProcessor) : await runProcessor(); + const runAndFlush = async (): Promise => { + const nextResult = await this.processor.run(targetTime, budget, checkpoint); + fallbackError = 'Unknown turn flush error.'; + this.status.state = 'flushing'; + await this.stateStore.saveLastTurnTime(new Date(nextResult.lastTurnTime)); + await this.stateStore.saveCheckpoint(nextResult.checkpoint); + await this.hooks?.flushChanges?.(nextResult); + return nextResult; + }; + result = this.stateManager ? await this.stateManager.transaction(runAndFlush) : await runAndFlush(); } catch (error) { this.status.running = false; this.status.state = 'paused'; this.status.paused = true; this.errorPaused = true; - this.status.lastError = error instanceof Error ? error.message : 'Unknown turn daemon error.'; + this.status.lastError = error instanceof Error ? error.message : fallbackError; await this.hooks?.onRunError?.(error); return; } finally { this.status.running = false; } - this.status.state = 'flushing'; - try { - await this.stateStore.saveLastTurnTime(new Date(result.lastTurnTime)); - await this.stateStore.saveCheckpoint(result.checkpoint); - await this.hooks?.flushChanges?.(result); - } catch (error) { - this.status.state = 'paused'; - this.status.paused = true; - this.errorPaused = true; - this.status.lastError = error instanceof Error ? error.message : 'Unknown turn flush error.'; - await this.hooks?.onRunError?.(error); - return; - } - await this.applyRunResult(result, startMs); this.status.state = 'idle'; try { diff --git a/app/game-engine/test/turnDaemonLifecycle.test.ts b/app/game-engine/test/turnDaemonLifecycle.test.ts index 1577f87..e464256 100644 --- a/app/game-engine/test/turnDaemonLifecycle.test.ts +++ b/app/game-engine/test/turnDaemonLifecycle.test.ts @@ -75,6 +75,211 @@ describe('TurnDaemonLifecycle', () => { await loop; }); + it('restores processor and state-store mutations when scheduled flush fails', async () => { + const now = new Date('2026-01-01T00:10:00.000Z'); + const previousCheckpoint = { + turnTime: '2026-01-01T00:00:00.000Z', + generalId: 7, + year: 203, + month: 1, + }; + const nextCheckpoint = { + turnTime: now.toISOString(), + generalId: 8, + year: 203, + month: 2, + }; + let engineState: { + value: string; + lastTurnTime: string; + checkpoint: TurnRunResult['checkpoint']; + } = { + value: 'before', + lastTurnTime: previousCheckpoint.turnTime, + checkpoint: previousCheckpoint, + }; + const stateManager = new EngineStateManager(); + stateManager.register('test', { + capture: () => structuredClone(engineState), + restore: (snapshot) => { + engineState = snapshot; + }, + }); + const published = vi.fn(); + let resolveError: (() => void) | undefined; + const errorObserved = new Promise((resolve) => { + resolveError = resolve; + }); + const lifecycle = new TurnDaemonLifecycle( + { + clock: new ManualClock(now.getTime()), + controlQueue: new InMemoryControlQueue(), + getNextTickTime: () => new Date('2026-01-01T00:05:00.000Z'), + stateStore: { + loadLastTurnTime: async () => new Date(engineState.lastTurnTime), + loadNextGeneralTurnTime: async () => null, + saveLastTurnTime: async (turnTime) => { + engineState.lastTurnTime = turnTime.toISOString(); + }, + loadCheckpoint: async () => engineState.checkpoint, + saveCheckpoint: async (checkpoint) => { + engineState.checkpoint = checkpoint; + }, + }, + processor: { + run: async (): Promise => { + engineState.value = 'calculated'; + return { + lastTurnTime: now.toISOString(), + processedGenerals: 1, + processedTurns: 1, + durationMs: 0, + partial: false, + checkpoint: nextCheckpoint, + }; + }, + }, + stateManager, + hooks: { + flushChanges: async () => { + expect(engineState).toMatchObject({ + value: 'calculated', + lastTurnTime: now.toISOString(), + checkpoint: nextCheckpoint, + }); + throw new Error('scheduled flush failed'); + }, + publishEvents: published, + onRunError: async () => { + resolveError?.(); + }, + }, + }, + { + profile: 'test', + defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 }, + } + ); + + const loop = lifecycle.start(); + await errorObserved; + + expect(engineState).toEqual({ + value: 'before', + lastTurnTime: previousCheckpoint.turnTime, + checkpoint: previousCheckpoint, + }); + expect(stateManager.getRevision()).toBe(0); + expect(published).not.toHaveBeenCalled(); + expect(lifecycle.getStatus()).toMatchObject({ + state: 'paused', + paused: true, + lastError: 'scheduled flush failed', + }); + + await lifecycle.stop('done'); + await loop; + }); + + it('keeps processor and state-store mutations after flush succeeds and publishes afterward', async () => { + const now = new Date('2026-01-01T00:10:00.000Z'); + const previousCheckpoint = { + turnTime: '2026-01-01T00:00:00.000Z', + generalId: 7, + year: 203, + month: 1, + }; + const nextCheckpoint = { + turnTime: now.toISOString(), + generalId: 8, + year: 203, + month: 2, + }; + let engineState: { + value: string; + lastTurnTime: string; + checkpoint: TurnRunResult['checkpoint']; + } = { + value: 'before', + lastTurnTime: previousCheckpoint.turnTime, + checkpoint: previousCheckpoint, + }; + const stateManager = new EngineStateManager(); + stateManager.register('test', { + capture: () => structuredClone(engineState), + restore: (snapshot) => { + engineState = snapshot; + }, + }); + const callOrder: string[] = []; + let resolvePublished: (() => void) | undefined; + const published = new Promise((resolve) => { + resolvePublished = resolve; + }); + const lifecycle = new TurnDaemonLifecycle( + { + clock: new ManualClock(now.getTime()), + controlQueue: new InMemoryControlQueue(), + getNextTickTime: (lastTurnTime) => addMinutes(lastTurnTime, 10), + stateStore: { + loadLastTurnTime: async () => new Date(engineState.lastTurnTime), + loadNextGeneralTurnTime: async () => null, + saveLastTurnTime: async (turnTime) => { + callOrder.push('save-last-turn'); + engineState.lastTurnTime = turnTime.toISOString(); + }, + loadCheckpoint: async () => engineState.checkpoint, + saveCheckpoint: async (checkpoint) => { + callOrder.push('save-checkpoint'); + engineState.checkpoint = checkpoint; + }, + }, + processor: { + run: async (): Promise => { + callOrder.push('processor'); + engineState.value = 'calculated'; + return { + lastTurnTime: now.toISOString(), + processedGenerals: 1, + processedTurns: 1, + durationMs: 0, + partial: false, + checkpoint: nextCheckpoint, + }; + }, + }, + stateManager, + hooks: { + flushChanges: async () => { + callOrder.push('flush'); + }, + publishEvents: async () => { + callOrder.push('publish'); + resolvePublished?.(); + }, + }, + }, + { + profile: 'test', + defaultBudget: { budgetMs: 100, maxGenerals: 1, catchUpCap: 1 }, + } + ); + + const loop = lifecycle.start(); + await published; + + expect(engineState).toEqual({ + value: 'calculated', + lastTurnTime: now.toISOString(), + checkpoint: nextCheckpoint, + }); + expect(stateManager.getRevision()).toBe(1); + expect(callOrder).toEqual(['processor', 'save-last-turn', 'save-checkpoint', 'flush', 'publish']); + + await lifecycle.stop('done'); + await loop; + }); + it('runs scheduled turn based on queue front and checkpoint context', async () => { const turnTermMinutes = 10; const lastTurnTime = new Date(2026, 0, 2, 2, 0, 0, 0); From 02515303fd90491d74b074c066e8b3a1dcd6fe9f Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 14:10:22 +0000 Subject: [PATCH 2/3] feat(engine): finalize unification archives atomically --- app/game-engine/src/turn/databaseHooks.ts | 17 + .../turn/generalTurnLifecyclePersistence.ts | 16 +- .../src/turn/inMemoryTurnProcessor.ts | 11 +- app/game-engine/src/turn/inMemoryWorld.ts | 26 + app/game-engine/src/turn/turnDaemon.ts | 7 +- app/game-engine/src/turn/types.ts | 19 + .../src/turn/unificationHandler.ts | 833 ++---------------- .../src/turn/unificationPersistence.ts | 500 +++++++++++ app/game-engine/src/turn/yearbookHandler.ts | 147 ++-- .../src/turn/yearbookPersistence.ts | 75 ++ ...BoundaryPrePersistence.integration.test.ts | 38 +- app/game-engine/test/scenarioSeeder.test.ts | 40 +- app/game-engine/test/turnOrder.test.ts | 92 ++ ...nificationFinalization.integration.test.ts | 240 +++++ .../test/unificationPersistence.test.ts | 236 +++++ packages/common/src/index.ts | 1 + packages/common/src/ranking/legacyColor.ts | 23 + packages/infra/prisma/game.prisma | 13 + .../migration.sql | 24 + packages/infra/src/turnEngineDb.ts | 1 + 20 files changed, 1516 insertions(+), 843 deletions(-) create mode 100644 app/game-engine/src/turn/unificationPersistence.ts create mode 100644 app/game-engine/src/turn/yearbookPersistence.ts create mode 100644 app/game-engine/test/unificationFinalization.integration.test.ts create mode 100644 app/game-engine/test/unificationPersistence.test.ts create mode 100644 packages/common/src/ranking/legacyColor.ts create mode 100644 packages/infra/prisma/migrations/20260731001000_add_unification_finalization/migration.sql diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 9c072ad..4145a54 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -34,6 +34,8 @@ import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLea import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; import { buildPersistedRankRows } from './rankData.js'; +import { persistUnificationFinalization } from './unificationPersistence.js'; +import { persistYearbookSnapshot } from './yearbookPersistence.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; @@ -473,6 +475,7 @@ const buildNationUpdate = ( chiefGeneralId: nation.chiefGeneralId, gold: nation.gold, rice: nation.rice, + tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) ? Math.trunc(nation.meta.tech) : 0, level: nation.level, typeCode: nation.typeCode, meta: asJson({ @@ -546,6 +549,7 @@ export const createDatabaseTurnHooks = async ( databaseUrl: string, world: InMemoryTurnWorld, options?: { + profileName?: string; reservedTurns?: InMemoryReservedTurnStore; turnDaemonLease?: DatabaseTurnDaemonLease; } @@ -584,6 +588,8 @@ export const createDatabaseTurnHooks = async ( inheritancePointAdjustments, pendingNationBettingOpens, pendingNationBettingFinishes, + pendingYearbookSnapshots, + pendingUnificationFinalizations, } = changes; const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); @@ -952,6 +958,17 @@ export const createDatabaseTurnHooks = async ( }); } } + for (const snapshot of pendingYearbookSnapshots) { + await persistYearbookSnapshot(prisma, snapshot); + } + for (const finalization of pendingUnificationFinalizations) { + if (options?.profileName && finalization.profileName !== options.profileName) { + throw new Error( + `Unification profile mismatch: pending=${finalization.profileName}, daemon=${options.profileName}.` + ); + } + await persistUnificationFinalization(prisma, finalization, world); + } for (const message of messages) { await sendMessage( { diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts index 4842d68..ca60eae 100644 --- a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -1,4 +1,4 @@ -import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; +import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/logic'; @@ -205,13 +205,23 @@ const settleHall = async ( name: event.before.name, nationName: nation?.name ?? '재야', bgColor: nation?.color ?? '#000000', - fgColor: nation?.color ?? '#000000', + fgColor: resolveLegacyTextColor(nation?.color ?? '#000000'), startTime: typeof worldMeta.starttime === 'string' ? worldMeta.starttime : null, unitedTime: new Date().toISOString(), - ownerName: event.before.userId ?? null, + ownerDisplayName: + typeof asRecord(event.before.meta).ownerDisplayName === 'string' + ? asRecord(event.before.meta).ownerDisplayName + : typeof asRecord(event.before.meta).owner_name === 'string' + ? asRecord(event.before.meta).owner_name + : typeof asRecord(event.before.meta).ownerName === 'string' + ? asRecord(event.before.meta).ownerName + : null, + picture: event.before.picture ?? null, + imgsvr: event.before.imageServer ?? 0, serverID: serverId, serverIdx: historyCount, scenarioName, + serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '', }; for (const type of HALL_OF_FAME_TYPES) { diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts index e63b268..ee473d8 100644 --- a/app/game-engine/src/turn/inMemoryTurnProcessor.ts +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -2,6 +2,7 @@ import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { TurnGeneral } from './types.js'; +import { asNumber, asRecord } from '@sammo-ts/common'; export interface InMemoryTurnProcessorOptions { tickMinutes?: number; @@ -24,6 +25,11 @@ const resolveTickMinutes = (world: InMemoryTurnWorld, override?: number): number return Math.max(1, Math.round(tickSeconds / 60)); }; +const isWorldUnited = (world: InMemoryTurnWorld): boolean => { + const meta = asRecord(world.getState().meta); + return asNumber(meta.isunited ?? meta.isUnited, 0) !== 0; +}; + export class InMemoryTurnProcessor implements TurnProcessor { // 인메모리 월드로 턴을 실행하고 월/연 갱신까지 처리한다. private readonly world: InMemoryTurnWorld; @@ -91,13 +97,16 @@ export class InMemoryTurnProcessor implements TurnProcessor { if (!partial) { let nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes); - while (nextTickTime.getTime() <= targetTime.getTime()) { + while (!isWorldUnited(this.world) && nextTickTime.getTime() <= targetTime.getTime()) { if (processedTurns >= budget.catchUpCap || isBudgetExpired()) { partial = true; break; } await this.world.advanceMonth(nextTickTime); processedTurns += 1; + if (isWorldUnited(this.world)) { + break; + } nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes); } } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index af086fa..378cfc1 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -15,6 +15,8 @@ import type { PendingNeutralAuction, PendingNationBettingFinish, PendingNationBettingOpen, + PendingUnificationFinalization, + PendingYearbookSnapshot, TurnDiplomacy, TurnEvent, TurnGeneral, @@ -126,6 +128,8 @@ export interface TurnWorldChanges { inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>; pendingNationBettingOpens: PendingNationBettingOpen[]; pendingNationBettingFinishes: PendingNationBettingFinish[]; + pendingYearbookSnapshots: PendingYearbookSnapshot[]; + pendingUnificationFinalizations: PendingUnificationFinalization[]; } export interface InMemoryTurnWorldStateSnapshot { @@ -160,6 +164,8 @@ export interface InMemoryTurnWorldStateSnapshot { inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }>; pendingNationBettingOpens: PendingNationBettingOpen[]; pendingNationBettingFinishes: PendingNationBettingFinish[]; + pendingYearbookSnapshots: PendingYearbookSnapshot[]; + pendingUnificationFinalizations: PendingUnificationFinalization[]; } export interface InMemoryTurnWorldInspection { @@ -346,6 +352,8 @@ export class InMemoryTurnWorld { private readonly inheritancePointAdjustments: Array<{ userId: string; key: string; amount: number }> = []; private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = []; private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = []; + private readonly pendingYearbookSnapshots: PendingYearbookSnapshot[] = []; + private readonly pendingUnificationFinalizations: PendingUnificationFinalization[] = []; private readonly scenarioConfig: ScenarioConfig; private readonly unitSet?: UnitSetDefinition; private checkpoint?: TurnCheckpoint; @@ -425,6 +433,8 @@ export class InMemoryTurnWorld { inheritancePointAdjustments: this.inheritancePointAdjustments, pendingNationBettingOpens: this.pendingNationBettingOpens, pendingNationBettingFinishes: this.pendingNationBettingFinishes, + pendingYearbookSnapshots: this.pendingYearbookSnapshots, + pendingUnificationFinalizations: this.pendingUnificationFinalizations, } satisfies InMemoryTurnWorldStateSnapshot); } @@ -461,6 +471,8 @@ export class InMemoryTurnWorld { this.replaceArray(this.inheritancePointAdjustments, restored.inheritancePointAdjustments); this.replaceArray(this.pendingNationBettingOpens, restored.pendingNationBettingOpens); this.replaceArray(this.pendingNationBettingFinishes, restored.pendingNationBettingFinishes); + this.replaceArray(this.pendingYearbookSnapshots, restored.pendingYearbookSnapshots); + this.replaceArray(this.pendingUnificationFinalizations, restored.pendingUnificationFinalizations); } inspectState(): InMemoryTurnWorldInspection { @@ -554,6 +566,14 @@ export class InMemoryTurnWorld { }); } + queueYearbookSnapshot(snapshot: PendingYearbookSnapshot): void { + this.pendingYearbookSnapshots.push(structuredClone(snapshot)); + } + + queueUnificationFinalization(finalization: PendingUnificationFinalization): void { + this.pendingUnificationFinalizations.push(structuredClone(finalization)); + } + getScenarioConfig(): ScenarioConfig { return this.scenarioConfig; } @@ -1192,6 +1212,8 @@ export class InMemoryTurnWorld { winnerNationIds: [...entry.winnerNationIds], turnTime: new Date(entry.turnTime.getTime()), })); + const pendingYearbookSnapshots = structuredClone(this.pendingYearbookSnapshots); + const pendingUnificationFinalizations = structuredClone(this.pendingUnificationFinalizations); return { generals, @@ -1216,6 +1238,8 @@ export class InMemoryTurnWorld { inheritancePointAdjustments, pendingNationBettingOpens, pendingNationBettingFinishes, + pendingYearbookSnapshots, + pendingUnificationFinalizations, }; } @@ -1246,6 +1270,8 @@ export class InMemoryTurnWorld { this.inheritancePointAdjustments.splice(0, changes.inheritancePointAdjustments.length); this.pendingNationBettingOpens.splice(0, changes.pendingNationBettingOpens.length); this.pendingNationBettingFinishes.splice(0, changes.pendingNationBettingFinishes.length); + this.pendingYearbookSnapshots.splice(0, changes.pendingYearbookSnapshots.length); + this.pendingUnificationFinalizations.splice(0, changes.pendingUnificationFinalizations.length); } consumeDirtyState(): TurnWorldChanges { diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index 90a5c07..fc6837e 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -227,7 +227,6 @@ const createTurnDaemonRuntimeWithLease = async ( const unification = options.calendarHandler ? null : createUnificationHandler({ - databaseUrl: options.databaseUrl, profileName: options.profileName ?? options.profile, getWorld: () => worldRef, }); @@ -459,7 +458,6 @@ const createTurnDaemonRuntimeWithLease = async ( }, }); const yearbookHandler = createYearbookHandler({ - databaseUrl: options.databaseUrl, profileName: options.profileName ?? options.profile, getWorld: () => worldRef, }); @@ -591,6 +589,7 @@ const createTurnDaemonRuntimeWithLease = async ( } if (databaseFlushEnabled) { const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, { + profileName: options.profileName ?? options.profile, reservedTurns: reservedTurnStoreHandle?.store, turnDaemonLease: turnDaemonLease ?? undefined, }); @@ -711,10 +710,6 @@ const createTurnDaemonRuntimeWithLease = async ( close = async () => { await baseClose(); await neutralAuctionRegistrar.close(); - if (unification) { - await unification.close(); - } - await yearbookHandler.close(); if (redisConnector) { await redisConnector.disconnect(); } diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 28d7904..a4d3492 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -101,6 +101,25 @@ export interface PendingNationBettingFinish { turnTime: Date; } +export interface PendingYearbookSnapshot { + serverId: string; + sourceId: number; + year: number; + month: number; + map: unknown; + nations: unknown; +} + +export interface PendingUnificationFinalization { + generationKey: string; + serverId: string; + profileName: string; + winnerNationId: number; + year: number; + month: number; + completedAt: Date; +} + export interface TurnWorldSnapshot extends Omit< WorldSnapshot, 'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy' diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts index a526ac5..9aa0951 100644 --- a/app/game-engine/src/turn/unificationHandler.ts +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -1,794 +1,83 @@ -import { createGamePostgresConnector } from '@sammo-ts/infra'; -import { asRecord, HALL_OF_FAME_TYPES, type HallOfFameType } from '@sammo-ts/common'; -import type { LogEntryDraft } from '@sammo-ts/logic'; -import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; +import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common'; +import { LogCategory, LogFormat, LogScope, type LogEntryDraft } from '@sammo-ts/logic'; -import type { TurnCalendarHandler } from './inMemoryWorld.js'; -import type { InMemoryTurnWorld } from './inMemoryWorld.js'; - -const UNIFIER_POINT = 2000; - -const readMetaNumber = (meta: Record, key: string): number => { - const value = meta[key]; - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 0; - } - return value; -}; - -const readMetaNumberOrNull = (meta: Record, key: string): number | null => { - const value = meta[key]; - if (typeof value === 'number' && Number.isFinite(value)) { - return Math.floor(value); - } - if (typeof value === 'string') { - const parsed = Number(value); - if (Number.isFinite(parsed)) { - return Math.floor(parsed); - } - } - return null; -}; - -const computeHallRate = (numerator: number, denominator: number): number => { - if (denominator <= 0) { - return 0; - } - return numerator / denominator; -}; - -const computeDexPoint = (meta: Record): number => { - let total = 0; - for (const [key, value] of Object.entries(meta)) { - if (!key.startsWith('dex')) { - continue; - } - if (typeof value === 'number' && Number.isFinite(value)) { - total += value; - } - } - return total * 0.001; -}; +import type { InMemoryTurnWorld, TurnCalendarHandler } from './inMemoryWorld.js'; +import { queueYearbookSnapshot } from './yearbookHandler.js'; const buildUnificationLog = (nationName: string): LogEntryDraft => ({ scope: LogScope.SYSTEM, category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, - text: `【통일】${nationName}이 전토를 통일하였습니다.`, + text: `【통일】${nationName}${JosaUtil.pick(nationName, '이')} 전토를 통일하였습니다.`, meta: {}, }); +const buildNationHistoryLog = (nationId: number, nationName: string): LogEntryDraft => ({ + scope: LogScope.NATION, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + nationId, + text: `${nationName}${JosaUtil.pick(nationName, '이')} 전토를 통일`, + meta: {}, +}); + +const buildGeneralActionLog = (generalId: number, nationId: number, nationName: string): LogEntryDraft => ({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.YEAR_MONTH, + generalId, + nationId, + text: `${nationName}${JosaUtil.pick(nationName, '이')} 전토를 통일하였습니다.`, + meta: {}, +}); + +const resolveServerId = (world: InMemoryTurnWorld, fallback: string): string => { + const serverId = world.getState().meta.serverId; + return typeof serverId === 'string' && serverId.trim() ? serverId.trim() : fallback; +}; + export const createUnificationHandler = (options: { - databaseUrl: string; profileName: string; getWorld: () => InMemoryTurnWorld | null; -}): { handler: TurnCalendarHandler; close: () => Promise } => { - const connector = createGamePostgresConnector({ url: options.databaseUrl }); - const ready = connector.connect(); - - const settleInheritance = async (winnerNationId: number, year: number, month: number): Promise => { - await ready; - const prisma = connector.prisma; - - const generals = await prisma.general.findMany({ - where: { - userId: { not: null }, - npcState: { lt: 2 }, - }, - select: { - id: true, - userId: true, - nationId: true, - officerLevel: true, - meta: true, - }, - }); - - const userIds = Array.from(new Set(generals.map((general) => general.userId).filter(Boolean))) as string[]; - if (userIds.length === 0) { - return; - } - - const pointRows = await prisma.inheritancePoint.findMany({ - where: { - userId: { in: userIds }, - }, - select: { - userId: true, - key: true, - value: true, - }, - }); - const pointMap = new Map>(); - for (const row of pointRows) { - const bucket = pointMap.get(row.userId) ?? new Map(); - bucket.set(row.key, row.value); - pointMap.set(row.userId, bucket); - } - - for (const general of generals) { - if (!general.userId) { - continue; - } - const meta = asRecord(general.meta); - const livedMonth = readMetaNumber(meta, 'inherit_lived_month'); - const maxDomestic = readMetaNumber(meta, 'max_domestic_critical'); - const activeAction = readMetaNumber(meta, 'inherit_active_action'); - const combat = readMetaNumber(meta, 'rank_warnum') * 5; - const sabotage = readMetaNumber(meta, 'firenum') * 20; - const dex = computeDexPoint(meta); - - const points = pointMap.get(general.userId) ?? new Map(); - const previous = points.get('previous') ?? 0; - const unifier = points.get('unifier') ?? 0; - const earned = - livedMonth + - maxDomestic + - activeAction * 3 + - combat + - sabotage + - dex + - unifier + - (general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0); - - const total = previous + earned; - - await prisma.inheritancePoint.upsert({ - where: { - userId_key: { - userId: general.userId, - key: 'previous', - }, - }, - update: { value: total }, - create: { userId: general.userId, key: 'previous', value: total }, - }); - - await prisma.inheritancePoint.deleteMany({ - where: { - userId: general.userId, - key: { not: 'previous' }, - }, - }); - - await prisma.inheritanceResult.create({ - data: { - serverId: options.profileName, - owner: general.userId, - generalId: general.id, - year, - month, - value: { - previous, - lived_month: livedMonth, - max_domestic_critical: maxDomestic, - active_action: activeAction, - combat, - sabotage, - dex, - unifier, - unifierAward: - general.nationId === winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0, - }, - }, - }); - - await prisma.inheritanceLog.create({ - data: { - userId: general.userId, - year, - month, - text: `천하 통일 정산: ${Math.floor(total).toLocaleString()} 포인트`, - }, - }); - } - }; - - const settleHallOfFame = async (winnerNationId: number): Promise => { - const world = options.getWorld(); - if (!world) { - return; - } - await ready; - const prisma = connector.prisma; - const state = world.getState(); - const meta = asRecord(state.meta); - - const serverId = - typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName; - const season = readMetaNumberOrNull(meta, 'season') ?? 1; - const scenario = readMetaNumberOrNull(meta, 'scenarioId') ?? 0; - const scenarioName = - typeof asRecord(meta.scenarioMeta).title === 'string' ? String(asRecord(meta.scenarioMeta).title) : ''; - const startTime = typeof meta.starttime === 'string' ? meta.starttime : null; - const unitedTime = new Date().toISOString(); - - const [serverCount, nationRows, generalRows, rankRows] = await Promise.all([ - prisma.gameHistory.count(), - prisma.nation.findMany({ select: { id: true, name: true, color: true } }), - prisma.general.findMany({ - where: { npcState: { lt: 2 } }, - select: { - id: true, - userId: true, - nationId: true, - name: true, - picture: true, - imageServer: true, - experience: true, - dedication: true, - }, - }), - prisma.rankData.findMany({ - where: { generalId: { gt: 0 } }, - select: { generalId: true, type: true, value: true }, - }), - ]); - - const nationMap = new Map(); - for (const nation of nationRows) { - nationMap.set(nation.id, { name: nation.name, color: nation.color }); - } - - const rankMap = new Map>(); - for (const row of rankRows) { - const entry = rankMap.get(row.generalId) ?? {}; - entry[row.type] = row.value; - rankMap.set(row.generalId, entry); - } - - const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => { - if (type === 'experience' || type === 'dedication' || type.startsWith('dex')) { - return [type, 'natural']; - } - if (type.endsWith('rate')) { - return [type, 'calc']; - } - return [type, 'rank']; - }); - - for (const general of generalRows) { - const ranks = rankMap.get(general.id) ?? {}; - const warnum = ranks.warnum ?? 0; - const killnum = ranks.killnum ?? 0; - const killcrew = ranks.killcrew ?? 0; - const deathcrew = ranks.deathcrew ?? 0; - const killcrewPerson = ranks.killcrew_person ?? 0; - const deathcrewPerson = ranks.deathcrew_person ?? 0; - const ttw = ranks.ttw ?? 0; - const ttd = ranks.ttd ?? 0; - const ttl = ranks.ttl ?? 0; - const tlw = ranks.tlw ?? 0; - const tld = ranks.tld ?? 0; - const tll = ranks.tll ?? 0; - const tsw = ranks.tsw ?? 0; - const tsd = ranks.tsd ?? 0; - const tsl = ranks.tsl ?? 0; - const tiw = ranks.tiw ?? 0; - const tid = ranks.tid ?? 0; - const til = ranks.til ?? 0; - const betGold = ranks.betgold ?? 0; - const betWinGold = ranks.betwingold ?? 0; - - const ttTotal = ttw + ttd + ttl; - const tlTotal = tlw + tld + tll; - const tsTotal = tsw + tsd + tsl; - const tiTotal = tiw + tid + til; - - const calcValues: Record = { - winrate: computeHallRate(killnum, warnum), - killrate: computeHallRate(killcrew, Math.max(1, deathcrew)), - killrate_person: computeHallRate(killcrewPerson, Math.max(1, deathcrewPerson)), - ttrate: computeHallRate(ttw, Math.max(1, ttTotal)), - tlrate: computeHallRate(tlw, Math.max(1, tlTotal)), - tsrate: computeHallRate(tsw, Math.max(1, tsTotal)), - tirate: computeHallRate(tiw, Math.max(1, tiTotal)), - betrate: computeHallRate(betWinGold, Math.max(1, betGold)), - }; - - const nation = nationMap.get(general.nationId) ?? { name: '재야', color: '#000000' }; - const aux = { - name: general.name, - nationName: nation.name, - bgColor: nation.color, - fgColor: nation.color, - picture: general.picture, - imgsvr: general.imageServer, - startTime, - unitedTime, - ownerName: general.userId ?? null, - serverID: serverId, - serverIdx: serverCount, - serverName: options.profileName, - scenarioName, - }; - - for (const [typeName, valueType] of hallTypes) { - const value = - valueType === 'natural' - ? typeName === 'experience' - ? general.experience - : typeName === 'dedication' - ? general.dedication - : (ranks[typeName] ?? 0) - : valueType === 'rank' - ? (ranks[typeName] ?? 0) - : (calcValues[typeName] ?? 0); - - if ((typeName === 'winrate' || typeName === 'killrate') && warnum < 10) { - continue; - } - if (typeName === 'ttrate' && ttTotal < 50) { - continue; - } - if (typeName === 'tlrate' && tlTotal < 50) { - continue; - } - if (typeName === 'tsrate' && tsTotal < 50) { - continue; - } - if (typeName === 'tirate' && tiTotal < 50) { - continue; - } - if (typeName === 'betrate' && betGold < 1000) { - continue; - } - if (value <= 0) { - continue; - } - - const existing = await prisma.hallOfFame.findUnique({ - where: { - serverId_type_generalNo: { - serverId, - type: typeName, - generalNo: general.id, - }, - }, - }); - if (!existing) { - await prisma.hallOfFame.create({ - data: { - serverId, - season, - scenario, - generalNo: general.id, - type: typeName, - value, - owner: general.userId ?? null, - aux, - }, - }); - continue; - } - if (value > existing.value) { - await prisma.hallOfFame.update({ - where: { id: existing.id }, - data: { value, aux }, - }); - } - } - } - - await prisma.gameHistory.update({ - where: { serverId }, - data: { - winnerNation: winnerNationId, - date: new Date(), - }, - }); - }; - - const settleDynasty = async (winnerNationId: number): Promise => { - const world = options.getWorld(); - if (!world) { - return; - } - await ready; - const prisma = connector.prisma; - const state = world.getState(); - const meta = asRecord(state.meta); - - const serverId = - typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : options.profileName; - const serverName = - typeof meta.serverName === 'string' && meta.serverName.trim() - ? meta.serverName.trim() - : options.profileName; - - const [serverCount, nationRows, cityRows, generalRows, rankRows, historyRows, oldNationRows] = - await Promise.all([ - prisma.gameHistory.count(), - prisma.nation.findMany({ - select: { - id: true, - name: true, - color: true, - level: true, - typeCode: true, - gold: true, - rice: true, - meta: true, - capitalCityId: true, - }, - }), - prisma.city.findMany({ - select: { nationId: true, population: true, populationMax: true }, - }), - prisma.general.findMany({ - select: { - id: true, - userId: true, - name: true, - nationId: true, - dedication: true, - officerLevel: true, - picture: true, - imageServer: true, - leadership: true, - strength: true, - intel: true, - experience: true, - gold: true, - rice: true, - crew: true, - crewTypeId: true, - train: true, - atmos: true, - age: true, - startAge: true, - npcState: true, - personalCode: true, - specialCode: true, - special2Code: true, - cityId: true, - troopId: true, - turnTime: true, - meta: true, - }, - }), - prisma.rankData.findMany({ - where: { - nationId: winnerNationId, - type: { in: ['killnum', 'firenum'] }, - }, - select: { generalId: true, type: true, value: true }, - }), - prisma.logEntry.findMany({ - where: { - nationId: winnerNationId, - scope: LogScope.NATION, - category: LogCategory.HISTORY, - }, - orderBy: { id: 'asc' }, - select: { text: true }, - }), - prisma.oldNation.findMany({ - where: { serverId }, - select: { data: true }, - }), - ]); - - const winnerNation = nationRows.find((nation) => nation.id === winnerNationId); - if (!winnerNation) { - return; - } - - const powerMap = new Map(); - for (const nation of world.listNations()) { - powerMap.set(nation.id, nation.power); - } - - const winnerGenerals = generalRows.filter((general) => general.nationId === winnerNationId); - const winnerGeneralIds = winnerGenerals.map((general) => general.id); - const noNationGenerals = generalRows.filter((general) => general.nationId === 0); - - const cityCount = cityRows.filter((city) => city.nationId === winnerNationId).length; - const popSum = cityRows - .filter((city) => city.nationId === winnerNationId) - .reduce((sum, city) => sum + city.population, 0); - const popMaxSum = cityRows - .filter((city) => city.nationId === winnerNationId) - .reduce((sum, city) => sum + city.populationMax, 0); - const popText = `${popSum} / ${popMaxSum}`; - const popRate = popMaxSum > 0 ? `${Math.round((popSum / popMaxSum) * 10000) / 100} %` : '0 %'; - - const officerMap = new Map(); - for (const general of winnerGenerals) { - if (general.officerLevel < 5) { - continue; - } - if (!officerMap.has(general.officerLevel)) { - officerMap.set(general.officerLevel, { name: general.name, picture: general.picture ?? null }); - } - } - - const generalNameMap = new Map(); - for (const general of generalRows) { - generalNameMap.set(general.id, general.name); - } - - const buildTopList = (type: 'killnum' | 'firenum', limit: number): string => { - const rows = rankRows - .filter((row) => row.type === type && row.value > 0) - .sort((a, b) => b.value - a.value) - .slice(0, limit); - return rows - .map((row) => `${generalNameMap.get(row.generalId) ?? '무명'}【${row.value.toLocaleString('ko-KR')}】`) - .join(', '); - }; - - const tiger = buildTopList('killnum', 5); - const eagle = buildTopList('firenum', 7); - - const gen = winnerGenerals - .slice() - .sort((a, b) => b.dedication - a.dedication) - .map((general) => general.name) - .join(', '); - - const nationNames: string[] = []; - const nationTypeCounts = new Map(); - for (const row of oldNationRows) { - const data = asRecord(row.data); - const name = typeof data.name === 'string' ? data.name : ''; - const type = typeof data.type === 'string' ? data.type : ''; - if (name) { - nationNames.push(name); - } - if (type) { - nationTypeCounts.set(type, (nationTypeCounts.get(type) ?? 0) + 1); - } - } - if (!nationNames.includes(winnerNation.name)) { - nationNames.push(winnerNation.name); - } - if (winnerNation.typeCode) { - nationTypeCounts.set(winnerNation.typeCode, (nationTypeCounts.get(winnerNation.typeCode) ?? 0) + 1); - } - const nationHist = Array.from(nationTypeCounts.entries()) - .map(([key, count]) => `${key}(${count})`) - .join(', '); - - const phase = `${serverName}${serverCount}기`; - const nationCount = `${Math.max(1, nationRows.length)} / ${Math.max(1, nationRows.length)}`; - const genCount = `${generalRows.length} / ${generalRows.length}`; - - const history = historyRows.map((row) => row.text); - - await prisma.oldNation.upsert({ - where: { - serverId_nation_sourceId: { - serverId, - nation: winnerNationId, - sourceId: 0, - }, - }, - update: { - data: { - nation: winnerNationId, - name: winnerNation.name, - color: winnerNation.color, - type: winnerNation.typeCode, - level: winnerNation.level, - gold: winnerNation.gold, - rice: winnerNation.rice, - power: powerMap.get(winnerNationId) ?? 0, - capitalCityId: winnerNation.capitalCityId, - generals: winnerGeneralIds, - history, - meta: winnerNation.meta ?? {}, - }, - date: new Date(), - }, - create: { - serverId, - nation: winnerNationId, - sourceId: 0, - data: { - nation: winnerNationId, - name: winnerNation.name, - color: winnerNation.color, - type: winnerNation.typeCode, - level: winnerNation.level, - gold: winnerNation.gold, - rice: winnerNation.rice, - power: powerMap.get(winnerNationId) ?? 0, - capitalCityId: winnerNation.capitalCityId, - generals: winnerGeneralIds, - history, - meta: winnerNation.meta ?? {}, - }, - }, - }); - - await prisma.oldNation.upsert({ - where: { - serverId_nation_sourceId: { - serverId, - nation: 0, - sourceId: 0, - }, - }, - update: { - data: { - nation: 0, - name: '재야', - color: '#000000', - type: 'neutral', - level: 0, - gold: 0, - rice: 0, - power: 0, - capitalCityId: null, - generals: noNationGenerals.map((general) => general.id), - history: [], - meta: {}, - }, - date: new Date(), - }, - create: { - serverId, - nation: 0, - sourceId: 0, - data: { - nation: 0, - name: '재야', - color: '#000000', - type: 'neutral', - level: 0, - gold: 0, - rice: 0, - power: 0, - capitalCityId: null, - generals: noNationGenerals.map((general) => general.id), - history: [], - meta: {}, - }, - }, - }); - - const oldGeneralTargets = generalRows.filter( - (general) => general.nationId === 0 || general.nationId === winnerNationId - ); - const generalHistoryRows = oldGeneralTargets.length - ? await prisma.logEntry.findMany({ - where: { - generalId: { in: oldGeneralTargets.map((general) => general.id) }, - scope: LogScope.GENERAL, - category: LogCategory.HISTORY, - }, - orderBy: { id: 'desc' }, - select: { generalId: true, text: true }, - }) - : []; - const historyByGeneral = new Map(); - for (const row of generalHistoryRows) { - if (row.generalId === null) { - continue; - } - const history = historyByGeneral.get(row.generalId) ?? []; - history.push(row.text); - historyByGeneral.set(row.generalId, history); - } - await Promise.all( - oldGeneralTargets.map((general) => - ((snapshot) => - prisma.oldGeneral.upsert({ - where: { - by_no: { - serverId, - generalNo: general.id, - }, - }, - update: { - owner: general.userId ?? null, - name: general.name, - lastYearMonth: state.currentYear * 100 + state.currentMonth, - turnTime: general.turnTime, - data: snapshot, - }, - create: { - serverId, - generalNo: general.id, - owner: general.userId ?? null, - name: general.name, - lastYearMonth: state.currentYear * 100 + state.currentMonth, - turnTime: general.turnTime, - data: snapshot, - }, - }))({ - ...general, - turnTime: general.turnTime.toISOString(), - history: historyByGeneral.get(general.id) ?? [], - }) - ) - ); - - await prisma.emperor.create({ - data: { - serverId, - phase, - nationCount, - nationName: nationNames.join(', '), - nationHist, - genCount, - personalHist: '', - specialHist: '', - name: winnerNation.name, - type: winnerNation.typeCode, - color: winnerNation.color, - year: state.currentYear, - month: state.currentMonth, - power: powerMap.get(winnerNationId) ?? 0, - gennum: winnerGenerals.length, - citynum: cityCount, - pop: popText, - poprate: popRate, - gold: winnerNation.gold, - rice: winnerNation.rice, - l12name: officerMap.get(12)?.name ?? '', - l12pic: officerMap.get(12)?.picture ?? '', - l11name: officerMap.get(11)?.name ?? '', - l11pic: officerMap.get(11)?.picture ?? '', - l10name: officerMap.get(10)?.name ?? '', - l10pic: officerMap.get(10)?.picture ?? '', - l9name: officerMap.get(9)?.name ?? '', - l9pic: officerMap.get(9)?.picture ?? '', - l8name: officerMap.get(8)?.name ?? '', - l8pic: officerMap.get(8)?.picture ?? '', - l7name: officerMap.get(7)?.name ?? '', - l7pic: officerMap.get(7)?.picture ?? '', - l6name: officerMap.get(6)?.name ?? '', - l6pic: officerMap.get(6)?.picture ?? '', - l5name: officerMap.get(5)?.name ?? '', - l5pic: officerMap.get(5)?.picture ?? '', - tiger, - eagle, - gen, - history, - aux: { - winnerNationId, - }, - }, - }); - }; - - const handler: TurnCalendarHandler = { +}): { handler: TurnCalendarHandler } => ({ + handler: { onMonthChanged: (context) => { const world = options.getWorld(); - if (!world) { - return; - } + if (!world) return; + const state = world.getState(); const meta = asRecord(state.meta); - if (typeof meta.isUnited === 'number' && meta.isUnited !== 0) { - return; - } + if (asNumber(meta.isunited ?? meta.isUnited, 0) !== 0) return; const activeNations = world.listNations().filter((nation) => nation.level > 0); - if (activeNations.length !== 1) { - return; - } - const winner = activeNations[0]; + if (activeNations.length !== 1) return; + + const winner = activeNations[0]!; const cities = world.listCities(); - const ownedCount = cities.filter((city) => city.nationId === winner.id).length; - if (ownedCount !== cities.length) { - return; + if (cities.length === 0 || cities.some((city) => city.nationId !== winner.id)) return; + + const serverId = resolveServerId(world, options.profileName); + world.updateWorldMeta({ + isUnited: 2, + isunited: 2, + refreshLimit: asNumber(meta.refreshLimit, 0) * 100, + }); + world.pushLog(buildNationHistoryLog(winner.id, winner.name)); + for (const general of world.listGenerals().filter((entry) => entry.nationId === winner.id)) { + world.pushLog(buildGeneralActionLog(general.id, winner.id, winner.name)); } - - world.updateWorldMeta({ isUnited: 2 }); world.pushLog(buildUnificationLog(winner.name)); - void settleInheritance(winner.id, context.currentYear, context.currentMonth); - void settleHallOfFame(winner.id); - void settleDynasty(winner.id); + + queueYearbookSnapshot(world, options.profileName, context.currentYear, context.currentMonth); + world.queueUnificationFinalization({ + generationKey: `unification:${serverId}`, + serverId, + profileName: options.profileName, + winnerNationId: winner.id, + year: context.currentYear, + month: context.currentMonth, + completedAt: new Date(state.lastTurnTime.getTime()), + }); }, - }; - - const close = async (): Promise => { - await ready; - await connector.disconnect(); - }; - - return { handler, close }; -}; + }, +}); diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts new file mode 100644 index 0000000..46dd33f --- /dev/null +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -0,0 +1,500 @@ +import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; +import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; + +const UNIFIER_POINT = 2000; +const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; + +export interface UnificationFinalizationInput { + readonly generationKey: string; + readonly serverId: string; + readonly profileName: string; + readonly winnerNationId: number; + readonly year: number; + readonly month: number; + readonly completedAt: Date; +} + +export interface UnificationFinalizationResult { + status: 'APPLIED' | 'ALREADY_APPLIED'; + generationKey: string; +} + +const readNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0); + +const readInteger = (value: unknown, fallback = 0): number => { + const parsed = typeof value === 'string' ? Number(value) : value; + return typeof parsed === 'number' && Number.isFinite(parsed) ? Math.floor(parsed) : fallback; +}; + +const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0); + +const ownerDisplayName = (meta: Record): string | null => { + for (const key of ['ownerDisplayName', 'owner_name', 'ownerName']) { + const value = meta[key]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + return null; +}; + +const formatHistogram = (value: unknown): string => + Object.entries(asRecord(value)) + .filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1])) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, count]) => `${key}(${count})`) + .join(', '); + +const claimGeneration = async ( + transaction: GamePrisma.TransactionClient, + input: UnificationFinalizationInput +): Promise<'CLAIMED' | 'ALREADY_APPLIED'> => { + await transaction.$executeRaw` + SELECT pg_advisory_xact_lock( + hashtext(${'unification-finalization'}), + hashtext(${input.generationKey}) + ) + `; + const existing = await transaction.unificationFinalization.findUnique({ + where: { generationKey: input.generationKey }, + }); + if (existing) { + const matches = + existing.serverId === input.serverId && + existing.profileName === input.profileName && + existing.winnerNation === input.winnerNationId && + existing.year === input.year && + existing.month === input.month && + existing.completedAt.getTime() === input.completedAt.getTime(); + if (!matches) { + throw new Error(`Unification generation payload mismatch: ${input.generationKey}.`); + } + return 'ALREADY_APPLIED'; + } + await transaction.unificationFinalization.create({ + data: { + generationKey: input.generationKey, + serverId: input.serverId, + profileName: input.profileName, + winnerNation: input.winnerNationId, + year: input.year, + month: input.month, + completedAt: input.completedAt, + }, + }); + return 'CLAIMED'; +}; + +export const persistUnificationFinalization = async ( + transaction: GamePrisma.TransactionClient, + input: UnificationFinalizationInput, + world: InMemoryTurnWorld +): Promise => { + if (!input.generationKey.trim()) { + throw new Error('Unification finalization requires a non-empty generationKey.'); + } + const claim = await claimGeneration(transaction, input); + if (claim === 'ALREADY_APPLIED') { + return { status: 'ALREADY_APPLIED', generationKey: input.generationKey }; + } + + const state = world.getState(); + if (state.currentYear !== input.year || state.currentMonth !== input.month) { + throw new Error( + `Unification snapshot date mismatch: input=${input.year}-${input.month}, world=${state.currentYear}-${state.currentMonth}.` + ); + } + + const winner = world.getNationById(input.winnerNationId); + if (!winner) { + throw new Error(`Unification winner nation does not exist: ${input.winnerNationId}.`); + } + + const meta = asRecord(state.meta); + const snapshotServerId = + typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : input.profileName; + if (snapshotServerId !== input.serverId) { + throw new Error(`Unification snapshot server mismatch: input=${input.serverId}, world=${snapshotServerId}.`); + } + const serverId = input.serverId; + const serverName = + typeof meta.serverName === 'string' && meta.serverName.trim() ? meta.serverName.trim() : input.profileName; + const generals = world.listGenerals(); + const cities = world.listCities(); + const nations = world.listNations(); + const eligibleGenerals = generals.filter((general) => general.userId && general.npcState < 2); + + const pointRows = eligibleGenerals.length + ? await transaction.inheritancePoint.findMany({ + where: { userId: { in: eligibleGenerals.map((general) => general.userId!) } }, + select: { userId: true, key: true, value: true }, + }) + : []; + const pointsByUser = new Map>(); + for (const row of pointRows) { + const points = pointsByUser.get(row.userId) ?? new Map(); + points.set(row.key, row.value); + pointsByUser.set(row.userId, points); + } + + for (const general of eligibleGenerals) { + const userId = general.userId!; + const generalMeta = asRecord(general.meta); + const currentPoints = pointsByUser.get(userId) ?? new Map(); + const previous = currentPoints.get('previous') ?? 0; + const livedMonth = readNumber(generalMeta.inherit_lived_month); + const maxDomestic = readNumber(generalMeta.max_domestic_critical); + const activeAction = readNumber(generalMeta.inherit_active_action); + const combat = readNumber(generalMeta.rank_warnum) * 5; + const sabotage = readNumber(generalMeta.firenum) * 20; + const dex = + Object.entries(generalMeta).reduce( + (sum, [key, value]) => (key.startsWith('dex') ? sum + readNumber(value) : sum), + 0 + ) * 0.001; + const unifier = currentPoints.get('unifier') ?? 0; + const unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0; + const total = Math.floor( + previous + livedMonth + maxDomestic + activeAction * 3 + combat + sabotage + dex + unifier + unifierAward + ); + + await transaction.inheritancePoint.upsert({ + where: { userId_key: { userId, key: 'previous' } }, + update: { value: total }, + create: { userId, key: 'previous', value: total }, + }); + await transaction.inheritancePoint.deleteMany({ where: { userId, key: { not: 'previous' } } }); + await transaction.inheritanceResult.create({ + data: { + serverId, + owner: userId, + generalId: general.id, + year: input.year, + month: input.month, + value: { + previous, + lived_month: livedMonth, + max_domestic_critical: maxDomestic, + active_action: activeAction, + combat, + sabotage, + dex, + unifier, + unifierAward, + total, + generationKey: input.generationKey, + }, + }, + }); + await transaction.inheritanceLog.create({ + data: { + userId, + serverId, + year: input.year, + month: input.month, + text: `천하 통일 정산: ${total.toLocaleString('ko-KR')} 포인트`, + }, + }); + } + + const rankRows = generals.length + ? await transaction.rankData.findMany({ + where: { generalId: { in: generals.map((general) => general.id) } }, + select: { generalId: true, type: true, value: true }, + }) + : []; + const ranksByGeneral = new Map>(); + for (const row of rankRows) { + const ranks = ranksByGeneral.get(row.generalId) ?? {}; + ranks[row.type] = row.value; + ranksByGeneral.set(row.generalId, ranks); + } + const nationMap = new Map(nations.map((nation) => [nation.id, nation])); + const season = readInteger(meta.season, 1); + const scenario = readInteger(meta.scenarioId); + const scenarioName = String(asRecord(meta.scenarioMeta).title ?? ''); + const startTime = typeof meta.starttime === 'string' ? meta.starttime : null; + const unitedTime = input.completedAt.toISOString(); + const serverCount = await transaction.gameHistory.count(); + const minHallAge = readInteger(asRecord(world.getScenarioConfig().const).minPushHallAge, 30); + + const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => { + if (type === 'experience' || type === 'dedication' || type.startsWith('dex')) { + return [type, 'natural']; + } + return [type, type.endsWith('rate') ? 'calc' : 'rank']; + }); + for (const general of eligibleGenerals.filter((entry) => entry.age >= minHallAge)) { + const ranks = ranksByGeneral.get(general.id) ?? {}; + const totals = { + tt: (ranks.ttw ?? 0) + (ranks.ttd ?? 0) + (ranks.ttl ?? 0), + tl: (ranks.tlw ?? 0) + (ranks.tld ?? 0) + (ranks.tll ?? 0), + ts: (ranks.tsw ?? 0) + (ranks.tsd ?? 0) + (ranks.tsl ?? 0), + ti: (ranks.tiw ?? 0) + (ranks.tid ?? 0) + (ranks.til ?? 0), + }; + const calc: Record = { + winrate: computeRate(ranks.killnum ?? 0, ranks.warnum ?? 0), + killrate: computeRate(ranks.killcrew ?? 0, Math.max(1, ranks.deathcrew ?? 0)), + killrate_person: computeRate(ranks.killcrew_person ?? 0, Math.max(1, ranks.deathcrew_person ?? 0)), + ttrate: computeRate(ranks.ttw ?? 0, totals.tt), + tlrate: computeRate(ranks.tlw ?? 0, totals.tl), + tsrate: computeRate(ranks.tsw ?? 0, totals.ts), + tirate: computeRate(ranks.tiw ?? 0, totals.ti), + betrate: computeRate(ranks.betwingold ?? 0, Math.max(1, ranks.betgold ?? 0)), + }; + const generalMeta = asRecord(general.meta); + const nation = nationMap.get(general.nationId); + const background = nation?.color ?? '#000000'; + const aux = { + name: general.name, + nationName: nation?.name ?? '재야', + bgColor: background, + fgColor: resolveLegacyTextColor(background), + picture: general.picture ?? null, + imgsvr: general.imageServer ?? 0, + startTime, + unitedTime, + ownerDisplayName: ownerDisplayName(generalMeta), + serverID: serverId, + serverIdx: serverCount, + serverName, + scenarioName, + generationKey: input.generationKey, + }; + + for (const [type, valueType] of hallTypes) { + const value = + valueType === 'calc' + ? (calc[type] ?? 0) + : valueType === 'rank' + ? (ranks[type] ?? 0) + : type === 'experience' + ? general.experience + : type === 'dedication' + ? general.dedication + : readNumber(generalMeta[type]); + if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue; + if (type === 'ttrate' && totals.tt < 50) continue; + if (type === 'tlrate' && totals.tl < 50) continue; + if (type === 'tsrate' && totals.ts < 50) continue; + if (type === 'tirate' && totals.ti < 50) continue; + if (type === 'betrate' && (ranks.betgold ?? 0) < 1000) continue; + if (value <= 0) continue; + + const existing = await transaction.hallOfFame.findFirst({ + where: { + OR: [ + { serverId, type, generalNo: general.id }, + { serverId, type, owner: general.userId }, + ], + }, + }); + if (!existing) { + await transaction.hallOfFame.create({ + data: { + serverId, + season, + scenario, + generalNo: general.id, + type, + value, + owner: general.userId ?? null, + aux, + }, + }); + } else if (value > existing.value) { + await transaction.hallOfFame.update({ where: { id: existing.id }, data: { value, aux } }); + } + } + } + + await transaction.gameHistory.update({ + where: { serverId }, + data: { winnerNation: input.winnerNationId, date: input.completedAt }, + }); + + const nationHistoryRows = await transaction.logEntry.findMany({ + where: { nationId: input.winnerNationId, scope: LogScope.NATION, category: LogCategory.HISTORY }, + orderBy: { id: 'asc' }, + select: { text: true }, + }); + const nationHistory = nationHistoryRows.map((row) => row.text); + const winnerGenerals = generals.filter((general) => general.nationId === input.winnerNationId); + const neutralGenerals = generals.filter((general) => general.nationId === 0); + const cityCount = cities.filter((city) => city.nationId === input.winnerNationId).length; + const totalPop = cities.reduce((sum, city) => sum + city.population, 0); + const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0); + const winnerMeta = asRecord(winner.meta); + const winnerData = { + ...winner, + tech: readInteger(winnerMeta.tech), + aux: { + ...asRecord(winnerMeta.aux), + ...asRecord(winnerMeta.max_power), + }, + msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''), + scout_msg: String(winnerMeta.scout_msg ?? ''), + generals: winnerGenerals.map((general) => general.id), + history: nationHistory, + generationKey: input.generationKey, + }; + await transaction.oldNation.upsert({ + where: { serverId_nation_sourceId: { serverId, nation: input.winnerNationId, sourceId: 0 } }, + update: { data: asJson(winnerData), date: input.completedAt }, + create: { + serverId, + nation: input.winnerNationId, + sourceId: 0, + data: asJson(winnerData), + date: input.completedAt, + }, + }); + const neutralData = { + nation: 0, + name: '재야', + generals: neutralGenerals.map((general) => general.id), + generationKey: input.generationKey, + }; + await transaction.oldNation.upsert({ + where: { serverId_nation_sourceId: { serverId, nation: 0, sourceId: 0 } }, + update: { data: neutralData, date: input.completedAt }, + create: { serverId, nation: 0, sourceId: 0, data: neutralData, date: input.completedAt }, + }); + + const archiveGenerals = [...neutralGenerals, ...winnerGenerals]; + const generalHistoryRows = archiveGenerals.length + ? await transaction.logEntry.findMany({ + where: { + generalId: { in: archiveGenerals.map((general) => general.id) }, + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + }, + orderBy: { id: 'asc' }, + select: { generalId: true, text: true }, + }) + : []; + const historyByGeneral = new Map(); + for (const row of generalHistoryRows) { + if (row.generalId === null) continue; + const history = historyByGeneral.get(row.generalId) ?? []; + history.push(row.text); + historyByGeneral.set(row.generalId, history); + } + for (const general of archiveGenerals) { + const data = { + ...general, + turnTime: general.turnTime.toISOString(), + history: historyByGeneral.get(general.id) ?? [], + generationKey: input.generationKey, + }; + await transaction.oldGeneral.upsert({ + where: { by_no: { serverId, generalNo: general.id } }, + update: { + owner: general.userId ?? null, + name: general.name, + lastYearMonth: input.year * 100 + input.month, + turnTime: general.turnTime, + data: asJson(data), + }, + create: { + serverId, + generalNo: general.id, + owner: general.userId ?? null, + name: general.name, + lastYearMonth: input.year * 100 + input.month, + turnTime: general.turnTime, + data: asJson(data), + }, + }); + } + + const officerMap = new Map( + winnerGenerals + .filter((general) => general.officerLevel >= 5) + .map((general) => [general.officerLevel, general] as const) + ); + const topList = (type: 'killnum' | 'firenum', limit: number): string => + rankRows + .filter((row) => row.type === type && row.value > 0 && winnerGenerals.some((g) => g.id === row.generalId)) + .sort((left, right) => right.value - left.value) + .slice(0, limit) + .map( + (row) => + `${generals.find((general) => general.id === row.generalId)?.name ?? '무명'}【${row.value.toLocaleString('ko-KR')}】` + ) + .join(', '); + const previousNationArchives = await transaction.oldNation.findMany({ + where: { serverId }, + select: { data: true }, + }); + const archivedNationNames = previousNationArchives + .map((row) => asRecord(row.data).name) + .filter((name): name is string => typeof name === 'string' && Boolean(name)); + if (!archivedNationNames.includes(winner.name)) archivedNationNames.push(winner.name); + const statistics = asRecord(meta.dynastyStatistics); + const nationCount = `1 / ${Math.max(1, readInteger(statistics.maxNationCount, 1))}`; + const genCount = `${generals.length} / ${Math.max(generals.length, readInteger(statistics.maxGeneralCount))}`; + const statisticNationNames = String(statistics.maxNationName ?? '').trim(); + const personalHist = formatHistogram(statistics.personalHist); + const specialHist = [formatHistogram(statistics.specialHist), formatHistogram(statistics.special2Hist)] + .filter(Boolean) + .join(' // '); + const population = `${totalPop} / ${totalMaxPop}`; + const popRate = totalMaxPop > 0 ? `${Math.round((totalPop / totalMaxPop) * 10000) / 100} %` : '0 %'; + const officer = (level: number) => officerMap.get(level); + + await transaction.emperor.create({ + data: { + serverId, + phase: `${serverName}${serverCount}기`, + nationCount, + nationName: statisticNationNames || archivedNationNames.join(', '), + nationHist: formatHistogram(statistics.maxNationHist), + genCount, + personalHist, + specialHist, + name: winner.name, + type: winner.typeCode, + color: winner.color, + year: input.year, + month: input.month, + power: winner.power, + gennum: winnerGenerals.length, + citynum: cityCount, + pop: population, + poprate: popRate, + gold: winner.gold, + rice: winner.rice, + l12name: officer(12)?.name ?? '', + l12pic: officer(12)?.picture ?? '', + l11name: officer(11)?.name ?? '', + l11pic: officer(11)?.picture ?? '', + l10name: officer(10)?.name ?? '', + l10pic: officer(10)?.picture ?? '', + l9name: officer(9)?.name ?? '', + l9pic: officer(9)?.picture ?? '', + l8name: officer(8)?.name ?? '', + l8pic: officer(8)?.picture ?? '', + l7name: officer(7)?.name ?? '', + l7pic: officer(7)?.picture ?? '', + l6name: officer(6)?.name ?? '', + l6pic: officer(6)?.picture ?? '', + l5name: officer(5)?.name ?? '', + l5pic: officer(5)?.picture ?? '', + tiger: topList('killnum', 5), + eagle: topList('firenum', 7), + gen: winnerGenerals + .slice() + .sort((left, right) => right.dedication - left.dedication) + .map((general) => general.name) + .join(', '), + history: nationHistory, + aux: { winnerNationId: input.winnerNationId, generationKey: input.generationKey }, + }, + }); + + return { status: 'APPLIED', generationKey: input.generationKey }; +}; diff --git a/app/game-engine/src/turn/yearbookHandler.ts b/app/game-engine/src/turn/yearbookHandler.ts index b0c303c..60ae28c 100644 --- a/app/game-engine/src/turn/yearbookHandler.ts +++ b/app/game-engine/src/turn/yearbookHandler.ts @@ -1,6 +1,4 @@ -import { createHash } from 'crypto'; import { asNumber, asRecord } from '@sammo-ts/common'; -import { createGamePostgresConnector } from '@sammo-ts/infra'; import type { TurnCalendarHandler } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; @@ -29,6 +27,19 @@ type YearbookNation = { cities: string[]; }; +type DynastyStatistics = { + maxNationCount: number; + maxNationName: string; + maxNationHist: Record; + maxGeneralCount: number; + currentGeneralCount: number; + userGeneralCount: number; + npcGeneralCount: number; + personalHist: Record; + specialHist: Record; + special2Hist: Record; +}; + const readState = (meta: Record): number => { const raw = meta.state; if (typeof raw === 'number' && Number.isFinite(raw)) { @@ -136,58 +147,92 @@ const buildNationSnapshot = (world: InMemoryTurnWorld): YearbookNation[] => { }); }; -const buildHash = (map: YearbookMap, nations: YearbookNation[]): string => - createHash('sha256').update(JSON.stringify({ map, nations })).digest('hex'); +const increment = (target: Record, key: string): void => { + target[key] = (target[key] ?? 0) + 1; +}; + +const updateDynastyStatistics = (world: InMemoryTurnWorld): void => { + const state = world.getState(); + const previous = asRecord(state.meta.dynastyStatistics); + const activeNations = world + .listNations() + .filter((nation) => nation.level > 0) + .sort((left, right) => right.power - left.power || left.id - right.id); + const generals = world.listGenerals(); + const maxNationCount = Math.max(asNumber(previous.maxNationCount, 0), activeNations.length); + const replaceNationMaximum = activeNations.length > asNumber(previous.maxNationCount, 0); + const nationHist: Record = {}; + for (const nation of activeNations) increment(nationHist, nation.typeCode || 'neutral'); + + const personalHist: Record = {}; + const specialHist: Record = {}; + const special2Hist: Record = {}; + let userGeneralCount = 0; + let npcGeneralCount = 0; + for (const general of generals) { + increment(personalHist, general.role.personality || 'None'); + increment(specialHist, general.role.specialDomestic || 'None'); + increment(special2Hist, general.role.specialWar || 'None'); + if (general.npcState < 2) userGeneralCount += 1; + else npcGeneralCount += 1; + } + + const statistics: DynastyStatistics = { + maxNationCount, + maxNationName: replaceNationMaximum + ? activeNations.map((nation) => `${nation.name}(${nation.typeCode})`).join(', ') + : typeof previous.maxNationName === 'string' + ? previous.maxNationName + : '', + maxNationHist: replaceNationMaximum + ? nationHist + : Object.fromEntries( + Object.entries(asRecord(previous.maxNationHist)).flatMap(([key, value]) => + typeof value === 'number' && Number.isFinite(value) ? [[key, value]] : [] + ) + ), + maxGeneralCount: Math.max(asNumber(previous.maxGeneralCount, 0), generals.length), + currentGeneralCount: generals.length, + userGeneralCount, + npcGeneralCount, + personalHist, + specialHist, + special2Hist, + }; + world.updateWorldMeta({ dynastyStatistics: statistics }); +}; + +const resolveServerId = (world: InMemoryTurnWorld, fallback: string): string => { + const serverId = world.getState().meta.serverId; + return typeof serverId === 'string' && serverId.trim() ? serverId.trim() : fallback; +}; + +export const queueYearbookSnapshot = ( + world: InMemoryTurnWorld, + profileName: string, + year: number, + month: number +): void => { + world.queueYearbookSnapshot({ + serverId: resolveServerId(world, profileName), + sourceId: 0, + year, + month, + map: buildMapSnapshot(world, year, month), + nations: buildNationSnapshot(world), + }); +}; export const createYearbookHandler = (options: { - databaseUrl: string; profileName: string; getWorld: () => InMemoryTurnWorld | null; -}): { handler: TurnCalendarHandler; close: () => Promise } => { - const connector = createGamePostgresConnector({ url: options.databaseUrl }); - const ready = connector.connect(); - - const handler: TurnCalendarHandler = { - beforeMonthChanged: async (context) => { +}): { handler: TurnCalendarHandler } => ({ + handler: { + beforeMonthChanged: (context) => { const world = options.getWorld(); - if (!world) { - return; - } - await ready; - const map = buildMapSnapshot(world, context.previousYear, context.previousMonth); - const nations = buildNationSnapshot(world); - const hash = buildHash(map, nations); - - await connector.prisma.yearbookHistory.upsert({ - where: { - profileName_year_month_sourceId: { - profileName: options.profileName, - year: context.previousYear, - month: context.previousMonth, - sourceId: 0, - }, - }, - update: { - map, - nations, - hash, - }, - create: { - profileName: options.profileName, - sourceId: 0, - year: context.previousYear, - month: context.previousMonth, - map, - nations, - hash, - }, - }); + if (!world) return; + updateDynastyStatistics(world); + queueYearbookSnapshot(world, options.profileName, context.previousYear, context.previousMonth); }, - }; - - const close = async () => { - await connector.disconnect(); - }; - - return { handler, close }; -}; + }, +}); diff --git a/app/game-engine/src/turn/yearbookPersistence.ts b/app/game-engine/src/turn/yearbookPersistence.ts new file mode 100644 index 0000000..e57ff8f --- /dev/null +++ b/app/game-engine/src/turn/yearbookPersistence.ts @@ -0,0 +1,75 @@ +import { createHash } from 'node:crypto'; + +import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; +import { LogCategory, LogScope } from '@sammo-ts/logic'; + +import type { PendingYearbookSnapshot } from './types.js'; + +const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; + +const computeHash = (payload: unknown): string => createHash('sha256').update(JSON.stringify(payload)).digest('hex'); + +export const persistYearbookSnapshot = async ( + transaction: GamePrisma.TransactionClient, + snapshot: PendingYearbookSnapshot +): Promise => { + const [historyRows, actionRows] = await Promise.all([ + transaction.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + year: snapshot.year, + month: snapshot.month, + }, + orderBy: { id: 'desc' }, + select: { text: true }, + }), + transaction.logEntry.findMany({ + where: { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + year: snapshot.year, + month: snapshot.month, + }, + orderBy: { id: 'desc' }, + select: { text: true }, + }), + ]); + const globalHistory = historyRows.map((row) => row.text); + const globalAction = actionRows.map((row) => row.text); + const hash = computeHash({ + map: snapshot.map, + nations: snapshot.nations, + globalHistory, + globalAction, + }); + + await transaction.yearbookHistory.upsert({ + where: { + profileName_year_month_sourceId: { + profileName: snapshot.serverId, + year: snapshot.year, + month: snapshot.month, + sourceId: snapshot.sourceId, + }, + }, + update: { + map: asJson(snapshot.map), + nations: asJson(snapshot.nations), + globalHistory: asJson(globalHistory), + globalAction: asJson(globalAction), + hash, + }, + create: { + profileName: snapshot.serverId, + sourceId: snapshot.sourceId, + year: snapshot.year, + month: snapshot.month, + map: asJson(snapshot.map), + nations: asJson(snapshot.nations), + globalHistory: asJson(globalHistory), + globalAction: asJson(globalAction), + hash, + }, + }); +}; diff --git a/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts b/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts index 6811b9e..40229c5 100644 --- a/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts +++ b/app/game-engine/test/monthlyBoundaryPrePersistence.integration.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { TurnCommandEnv } from '@sammo-ts/logic'; +import { LogCategory, LogScope, type TurnCommandEnv } from '@sammo-ts/logic'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { composeCalendarHandlers } from '../src/turn/calendarHandlers.js'; @@ -16,6 +16,8 @@ const generalIds = [991_201, 991_202]; const cityIds = [991_201, 991_202, 991_203, 991_204, 991_205, 991_206, 991_207]; const nationId = 991_201; const yearbookProfile = 'monthly-boundary-pre-persistence'; +const yearbookServerId = 'monthly-boundary-generation-20260731'; +const archivedLogTexts = ['월경계 과거 정세', '월경계 과거 행동']; integration('monthly pre-update persistence', () => { let db: GamePrismaClient; @@ -32,7 +34,8 @@ integration('monthly pre-update persistence', () => { await db.city.deleteMany({ where: { id: { in: cityIds } } }); await db.nation.deleteMany({ where: { id: nationId } }); await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-boundary-pre-persistence' } }); - await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } }); + await db.yearbookHistory.deleteMany({ where: { profileName: { in: [yearbookProfile, yearbookServerId] } } }); + await db.logEntry.deleteMany({ where: { text: { in: archivedLogTexts } } }); }); afterAll(async () => { @@ -42,7 +45,8 @@ integration('monthly pre-update persistence', () => { await db.city.deleteMany({ where: { id: { in: cityIds } } }); await db.nation.deleteMany({ where: { id: nationId } }); await db.worldState.deleteMany({ where: { scenarioCode: 'monthly-boundary-pre-persistence' } }); - await db.yearbookHistory.deleteMany({ where: { profileName: yearbookProfile } }); + await db.yearbookHistory.deleteMany({ where: { profileName: { in: [yearbookProfile, yearbookServerId] } } }); + await db.logEntry.deleteMany({ where: { text: { in: archivedLogTexts } } }); await closeDb?.(); }); @@ -63,6 +67,24 @@ integration('monthly pre-update persistence', () => { }, }, }); + await db.logEntry.createMany({ + data: [ + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + year: 200, + month: 12, + text: archivedLogTexts[0]!, + }, + { + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + year: 200, + month: 12, + text: archivedLogTexts[1]!, + }, + ], + }); await db.city.createMany({ data: cityIds.map((id, index) => ({ id, @@ -120,6 +142,7 @@ integration('monthly pre-update persistence', () => { environment: { mapName: 'che', unitSet: 'che' }, }, meta: { + serverId: yearbookServerId, develcost: 18, scenarioMeta: { title: 'pre persistence', @@ -149,7 +172,6 @@ integration('monthly pre-update persistence', () => { }); const nations = createNationTurnMonthlyHandler({ getWorld: () => world }); const yearbook = createYearbookHandler({ - databaseUrl: databaseUrl!, profileName: yearbookProfile, getWorld: () => world, }); @@ -157,7 +179,7 @@ integration('monthly pre-update persistence', () => { schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, calendarHandler: composeCalendarHandlers(yearbook.handler, boundary, nations), }); - const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName: yearbookProfile }); try { await world.advanceMonth(new Date('0201-01-01T00:00:00.000Z')); await hooks.hooks.flushChanges?.({ @@ -219,7 +241,7 @@ integration('monthly pre-update persistence', () => { const yearbookRow = await db.yearbookHistory.findUniqueOrThrow({ where: { profileName_year_month_sourceId: { - profileName: yearbookProfile, + profileName: yearbookServerId, year: 200, month: 12, sourceId: 0, @@ -233,9 +255,11 @@ integration('monthly pre-update persistence', () => { ]) ); expect(cityIds.map((id) => yearbookStates.get(id))).toEqual([31, 32, 33, 34, 41, 42, 43]); + expect(yearbookRow.globalHistory).toEqual([archivedLogTexts[0]]); + expect(yearbookRow.globalAction).toEqual([archivedLogTexts[1]]); + expect(await db.yearbookHistory.count({ where: { profileName: yearbookProfile } })).toBe(0); } finally { await hooks.close(); - await yearbook.close(); } }); }); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index bd909ab..6fa3b82 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -739,13 +739,27 @@ describeDb('scenario database seed', () => { }, }); await prisma.emperor.create({ data: { serverId, name: marker, history: { marker }, aux: { marker } } }); + await prisma.unificationFinalization.create({ + data: { + generationKey: `unification:${serverId}`, + serverId, + profileName: marker, + winnerNation: 1, + year: 999, + month: 12, + completedAt: new Date('2033-01-01T00:00:00.000Z'), + }, + }); await prisma.yearbookHistory.create({ data: { profileName: marker, year: 999, month: 12, - map: {}, - nations: {}, + map: { marker }, + nations: [{ marker }], + globalHistory: [`${marker}-history`], + globalAction: [`${marker}-action`], + hash: `${marker}-hash`, }, }); await prisma.legacyGameStorage.create({ @@ -791,11 +805,30 @@ describeDb('scenario database seed', () => { prisma.oldNation.count({ where: { serverId } }), prisma.oldGeneral.count({ where: { serverId } }), prisma.emperor.count({ where: { serverId } }), + prisma.unificationFinalization.count({ where: { serverId } }), prisma.yearbookHistory.count({ where: { profileName: marker } }), prisma.legacyGameStorage.count({ where: { namespace: marker } }), prisma.hallOfFame.count({ where: { serverId } }), ]) - ).resolves.toEqual(Array.from({ length: 12 }, () => 1)); + ).resolves.toEqual(Array.from({ length: 13 }, () => 1)); + await expect( + prisma.yearbookHistory.findUniqueOrThrow({ + where: { + profileName_year_month_sourceId: { + profileName: marker, + year: 999, + month: 12, + sourceId: 0, + }, + }, + }) + ).resolves.toMatchObject({ + map: { marker }, + nations: [{ marker }], + globalHistory: [`${marker}-history`], + globalAction: [`${marker}-action`], + hash: `${marker}-hash`, + }); } finally { await prisma.errorLog.deleteMany({ where: { category: marker } }); await prisma.inheritancePoint.deleteMany({ where: { userId: marker } }); @@ -805,6 +838,7 @@ describeDb('scenario database seed', () => { await prisma.oldNation.deleteMany({ where: { serverId } }); await prisma.oldGeneral.deleteMany({ where: { serverId } }); await prisma.emperor.deleteMany({ where: { serverId } }); + await prisma.unificationFinalization.deleteMany({ where: { serverId } }); await prisma.gameHistory.deleteMany({ where: { serverId } }); await prisma.yearbookHistory.deleteMany({ where: { profileName: marker } }); await prisma.legacyGameStorage.deleteMany({ where: { namespace: marker } }); diff --git a/app/game-engine/test/turnOrder.test.ts b/app/game-engine/test/turnOrder.test.ts index b52f7a5..aabfa61 100644 --- a/app/game-engine/test/turnOrder.test.ts +++ b/app/game-engine/test/turnOrder.test.ts @@ -161,4 +161,96 @@ describe('InMemoryTurnProcessor ordering', () => { expect(world.getNextGeneralId()).toBe(5); expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 }); }); + + it('stops catch-up immediately after a calendar handler finalizes unification', async () => { + const baseTime = new Date('0189-01-01T00:00:00Z'); + const snapshot = { + generals: [], + cities: [ + { + id: 1, + name: 'City_1', + nationId: 1, + level: 1, + population: 1, + populationMax: 1, + agriculture: 1, + agricultureMax: 1, + commerce: 1, + commerceMax: 1, + security: 1, + securityMax: 1, + defence: 1, + defenceMax: 1, + wall: 1, + wallMax: 1, + supplyState: 1, + frontState: 0, + state: 0, + meta: {}, + }, + ], + nations: [ + { + id: 1, + name: 'TestNation', + color: '#FF0000', + capitalCityId: 1, + chiefGeneralId: null, + gold: 0, + rice: 0, + power: 0, + level: 1, + typeCode: 'che_def', + meta: {}, + }, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + map: { + id: 'test_map', + name: 'TestMap', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test_map', unitSet: 'default' }, + }, + } as TurnWorldSnapshot; + const worldHolder: { current?: InMemoryTurnWorld } = {}; + const world = new InMemoryTurnWorld( + { + id: 1, + currentYear: 189, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: baseTime, + meta: {}, + }, + snapshot, + { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: { + onMonthChanged: (): void => worldHolder.current?.updateWorldMeta({ isUnited: 2 }), + }, + } + ); + worldHolder.current = world; + const processor = new InMemoryTurnProcessor(world, { tickMinutes: 10 }); + + const result = await processor.run(addMinutes(baseTime, 50), { + budgetMs: 1_000, + maxGenerals: 10, + catchUpCap: 10, + }); + + expect(result.processedTurns).toBe(1); + expect(world.getState()).toMatchObject({ currentYear: 189, currentMonth: 2, meta: { isUnited: 2 } }); + }); }); diff --git a/app/game-engine/test/unificationFinalization.integration.test.ts b/app/game-engine/test/unificationFinalization.integration.test.ts new file mode 100644 index 0000000..29b853a --- /dev/null +++ b/app/game-engine/test/unificationFinalization.integration.test.ts @@ -0,0 +1,240 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createUnificationHandler } from '../src/turn/unificationHandler.js'; +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const fixtureId = 992_001; +const serverId = 'che_unification_atomicity_fixture'; +const profileName = 'che'; +const userId = 'unification-atomicity-user'; + +integration('unification finalization transaction', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const cleanup = async (): Promise => { + await db.unificationFinalization.deleteMany({ where: { serverId } }); + await db.yearbookHistory.deleteMany({ where: { profileName: serverId } }); + await db.emperor.deleteMany({ where: { serverId } }); + await db.oldGeneral.deleteMany({ where: { serverId } }); + await db.oldNation.deleteMany({ where: { serverId } }); + await db.hallOfFame.deleteMany({ where: { serverId } }); + await db.inheritanceResult.deleteMany({ where: { serverId } }); + await db.inheritanceLog.deleteMany({ where: { userId } }); + await db.inheritancePoint.deleteMany({ where: { userId } }); + await db.gameHistory.deleteMany({ where: { serverId } }); + await db.logEntry.deleteMany({ where: { year: 190, month: 7 } }); + await db.rankData.deleteMany({ where: { generalId: fixtureId } }); + await db.general.deleteMany({ where: { id: fixtureId } }); + await db.city.deleteMany({ where: { id: fixtureId } }); + await db.nation.deleteMany({ where: { id: fixtureId } }); + await db.worldState.deleteMany({ where: { scenarioCode: 'unification-atomicity-fixture' } }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await cleanup(); + }); + + afterAll(async () => { + await cleanup(); + await closeDb?.(); + }); + + it('rolls every archive back on a late failure and applies it exactly once on retry', async () => { + await db.nation.create({ + data: { + id: fixtureId, + name: '원자통일국', + color: '#ffffff', + capitalCityId: fixtureId, + chiefGeneralId: fixtureId, + gold: 1_000, + rice: 2_000, + tech: 123, + level: 1, + typeCode: 'che_중립', + meta: { + power: 3_000, + max_power: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] }, + }, + }, + }); + await db.city.create({ + data: { + id: fixtureId, + name: '원자도시', + nationId: fixtureId, + level: 1, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + supplyState: 1, + frontState: 0, + region: 1, + meta: { state: 0 }, + }, + }); + await db.general.create({ + data: { + id: fixtureId, + userId, + name: '원자장수', + nationId: fixtureId, + cityId: fixtureId, + npcState: 0, + officerLevel: 12, + leadership: 80, + strength: 70, + intel: 60, + experience: 10, + dedication: 5, + age: 40, + crew: 400, + picture: '1.png', + turnTime: new Date('0190-07-01T00:00:00.000Z'), + meta: { + ownerName: '원자 사용자', + killturn: 24, + inherit_lived_month: 10, + max_domestic_critical: 20, + inherit_active_action: 3, + rank_warnum: 4, + firenum: 2, + dex1: 100, + }, + }, + }); + await db.inheritancePoint.createMany({ + data: [ + { userId, key: 'previous', value: 100 }, + { userId, key: 'unifier', value: 7 }, + ], + }); + const worldRow = await db.worldState.create({ + data: { + scenarioCode: 'unification-atomicity-fixture', + currentYear: 190, + currentMonth: 6, + tickSeconds: 600, + config: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '.', + map: {}, + const: { minPushHallAge: 30 }, + environment: { mapName: 'che', unitSet: 'che' }, + }, + meta: { + serverId, + serverName: '원자 서버', + season: 1, + scenarioId: 2, + refreshLimit: 2, + scenarioMeta: { + title: '원자성 시나리오', + startYear: 190, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + }, + }, + }); + + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + let world: InMemoryTurnWorld | null = null; + const unification = createUnificationHandler({ profileName, getWorld: () => world }); + world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + calendarHandler: unification.handler, + }); + const hooks = await createDatabaseTurnHooks(databaseUrl!, world, { profileName }); + const runResult = { + lastTurnTime: '0190-07-01T00:00:00.000Z', + processedGenerals: 0, + processedTurns: 1, + durationMs: 0, + partial: false, + }; + try { + await world.advanceMonth(new Date('0190-07-01T00:00:00.000Z')); + expect(world.getState().meta).toMatchObject({ isUnited: 2, isunited: 2, refreshLimit: 200 }); + expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(1); + expect(world.peekDirtyState().pendingYearbookSnapshots).toHaveLength(1); + + await expect(hooks.hooks.flushChanges?.(runResult)).rejects.toThrow(); + + expect(await db.unificationFinalization.count({ where: { serverId } })).toBe(0); + expect(await db.yearbookHistory.count({ where: { profileName: serverId } })).toBe(0); + expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(0); + expect(await db.oldGeneral.count({ where: { serverId } })).toBe(0); + expect(await db.oldNation.count({ where: { serverId } })).toBe(0); + expect(await db.emperor.count({ where: { serverId } })).toBe(0); + expect( + (await db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })) + .value + ).toBe(100); + expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(1); + + await db.gameHistory.create({ + data: { + serverId, + date: new Date('0190-01-01T00:00:00.000Z'), + season: 1, + scenario: 2, + scenarioName: '원자성 시나리오', + }, + }); + await hooks.hooks.flushChanges?.(runResult); + + expect(await db.unificationFinalization.count({ where: { serverId } })).toBe(1); + expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(1); + expect(await db.oldGeneral.count({ where: { serverId } })).toBe(1); + expect(await db.oldNation.count({ where: { serverId } })).toBe(2); + expect(await db.emperor.count({ where: { serverId } })).toBe(1); + expect((await db.gameHistory.findUniqueOrThrow({ where: { serverId } })).winnerNation).toBe(fixtureId); + const yearbook = await db.yearbookHistory.findUniqueOrThrow({ + where: { + profileName_year_month_sourceId: { + profileName: serverId, + year: 190, + month: 7, + sourceId: 0, + }, + }, + }); + expect(yearbook.globalHistory).toEqual(expect.arrayContaining([expect.stringContaining('【통일】')])); + expect(world.peekDirtyState().pendingUnificationFinalizations).toHaveLength(0); + + await hooks.hooks.flushChanges?.(runResult); + expect(await db.unificationFinalization.count({ where: { serverId } })).toBe(1); + expect(await db.inheritanceResult.count({ where: { serverId } })).toBe(1); + expect(await db.emperor.count({ where: { serverId } })).toBe(1); + expect(await db.worldState.findUniqueOrThrow({ where: { id: worldRow.id } })).toMatchObject({ + currentYear: 190, + currentMonth: 7, + }); + } finally { + await hooks.close(); + } + }); +}); diff --git a/app/game-engine/test/unificationPersistence.test.ts b/app/game-engine/test/unificationPersistence.test.ts new file mode 100644 index 0000000..ad2bcbc --- /dev/null +++ b/app/game-engine/test/unificationPersistence.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GamePrisma } from '@sammo-ts/infra'; +import type { City, Nation } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { persistUnificationFinalization } from '../src/turn/unificationPersistence.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +const buildWorld = (): InMemoryTurnWorld => { + const turnTime = new Date('0190-07-01T00:00:00.000Z'); + const general: TurnGeneral = { + id: 1, + userId: 'user-1', + name: '통일장수', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 80, strength: 70, intelligence: 60 }, + turnTime, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { + killturn: 24, + owner_name: '표시 이름', + inherit_lived_month: 10, + max_domestic_critical: 20, + inherit_active_action: 3, + rank_warnum: 4, + firenum: 2, + dex1: 100, + }, + officerLevel: 12, + experience: 10, + dedication: 5, + injury: 0, + gold: 100, + rice: 100, + crew: 100, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 40, + npcState: 0, + picture: '1.png', + imageServer: 0, + }; + const nation: Nation = { + id: 1, + name: '통일국', + color: '#ffffff', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 1000, + rice: 2000, + power: 3000, + level: 1, + typeCode: 'test', + meta: {}, + }; + const city: City = { + id: 1, + name: '통일도시', + nationId: 1, + level: 1, + state: 0, + population: 1000, + populationMax: 2000, + agriculture: 0, + agricultureMax: 0, + commerce: 0, + commerceMax: 0, + security: 0, + securityMax: 0, + supplyState: 1, + frontState: 0, + defence: 0, + defenceMax: 0, + wall: 0, + wallMax: 0, + meta: {}, + }; + const state: TurnWorldState = { + id: 1, + currentYear: 190, + currentMonth: 7, + tickSeconds: 600, + lastTurnTime: turnTime, + meta: { + killturn: 24, + serverId: 'server-1', + serverName: '테스트', + season: 1, + scenarioId: 2, + scenarioMeta: { title: '테스트 시나리오' }, + }, + }; + const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [city], + nations: [nation], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 70 }, + iconPath: '', + map: {}, + const: { minPushHallAge: 30 }, + environment: { mapName: 'test', unitSet: 'test' }, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + return new InMemoryTurnWorld(state, snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 10 }] }, + }); +}; + +const input = { + generationKey: 'unification:server-1', + serverId: 'server-1', + profileName: 'che', + winnerNationId: 1, + year: 190, + month: 7, + completedAt: new Date('0190-07-01T00:00:00.000Z'), +} as const; + +describe('persistUnificationFinalization', () => { + it('does not write when the transaction-scoped generation was already applied', async () => { + const transaction = Object.assign({} as GamePrisma.TransactionClient, { + $executeRaw: vi.fn().mockResolvedValue(1), + unificationFinalization: { + findUnique: vi.fn().mockResolvedValue({ + generationKey: input.generationKey, + serverId: input.serverId, + profileName: input.profileName, + winnerNation: input.winnerNationId, + year: input.year, + month: input.month, + completedAt: input.completedAt, + }), + create: vi.fn(), + }, + }); + + await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({ + status: 'ALREADY_APPLIED', + generationKey: input.generationKey, + }); + expect(transaction.unificationFinalization.create).not.toHaveBeenCalled(); + }); + + it('uses one supplied transaction for absolute inheritance and archive writes', async () => { + const inheritanceUpsert = vi.fn().mockResolvedValue({}); + const inheritanceResultCreate = vi.fn().mockResolvedValue({}); + const inheritanceLogCreate = vi.fn().mockResolvedValue({}); + const hallCreate = vi.fn().mockResolvedValue({}); + const gameHistoryUpdate = vi.fn().mockResolvedValue({}); + const emperorCreate = vi.fn().mockResolvedValue({}); + const transaction = Object.assign({} as GamePrisma.TransactionClient, { + $executeRaw: vi.fn().mockResolvedValue(1), + unificationFinalization: { + findUnique: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({}), + }, + inheritancePoint: { + findMany: vi.fn().mockResolvedValue([ + { userId: 'user-1', key: 'previous', value: 100 }, + { userId: 'user-1', key: 'unifier', value: 7 }, + ]), + upsert: inheritanceUpsert, + deleteMany: vi.fn().mockResolvedValue({ count: 1 }), + }, + inheritanceResult: { create: inheritanceResultCreate }, + inheritanceLog: { create: inheritanceLogCreate }, + rankData: { findMany: vi.fn().mockResolvedValue([]) }, + gameHistory: { count: vi.fn().mockResolvedValue(1), update: gameHistoryUpdate }, + hallOfFame: { + findFirst: vi.fn().mockResolvedValue(null), + create: hallCreate, + update: vi.fn().mockResolvedValue({}), + }, + logEntry: { findMany: vi.fn().mockResolvedValue([]) }, + oldNation: { + upsert: vi.fn().mockResolvedValue({}), + findMany: vi.fn().mockResolvedValue([]), + }, + oldGeneral: { upsert: vi.fn().mockResolvedValue({}) }, + emperor: { create: emperorCreate }, + }); + await expect(persistUnificationFinalization(transaction, input, buildWorld())).resolves.toEqual({ + status: 'APPLIED', + generationKey: input.generationKey, + }); + + expect(inheritanceUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: { value: 2206 }, + create: { userId: 'user-1', key: 'previous', value: 2206 }, + }) + ); + expect(inheritanceResultCreate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ serverId: 'server-1' }) }) + ); + expect(inheritanceLogCreate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ serverId: 'server-1' }) }) + ); + expect(hallCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + aux: expect.objectContaining({ ownerDisplayName: '표시 이름', fgColor: '#000000' }), + }), + }) + ); + expect(gameHistoryUpdate).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) }) + ); + expect(emperorCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ aux: { winnerNationId: 1, generationKey: input.generationKey } }), + }) + ); + }); +}); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 8e2bb4a..f5eb819 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -16,4 +16,5 @@ export * from './turnDaemon/types.js'; export * from './realtime/keys.js'; export * from './realtime/types.js'; export * from './ranking/types.js'; +export * from './ranking/legacyColor.js'; export * from './auth/accountIconProjection.js'; diff --git a/packages/common/src/ranking/legacyColor.ts b/packages/common/src/ranking/legacyColor.ts new file mode 100644 index 0000000..8b3598a --- /dev/null +++ b/packages/common/src/ranking/legacyColor.ts @@ -0,0 +1,23 @@ +const LEGACY_WHITE_TEXT_COLORS = new Set([ + '', + '#330000', + '#FF0000', + '#800000', + '#A0522D', + '#FF6347', + '#808000', + '#008000', + '#2E8B57', + '#008080', + '#6495ED', + '#0000FF', + '#000080', + '#483D8B', + '#7B68EE', + '#800080', + '#A9A9A9', + '#000000', +]); + +export const resolveLegacyTextColor = (backgroundColor: string): '#ffffff' | '#000000' => + LEGACY_WHITE_TEXT_COLORS.has(backgroundColor.toUpperCase()) ? '#ffffff' : '#000000'; diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index db4b8f0..d05fd8d 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -416,6 +416,19 @@ model Emperor { @@map("emperior") } +model UnificationFinalization { + generationKey String @id @map("generation_key") + serverId String @unique @map("server_id") + profileName String @map("profile_name") + winnerNation Int @map("winner_nation") + year Int + month Int + completedAt DateTime @map("completed_at") + createdAt DateTime @default(now()) @map("created_at") + + @@map("unification_finalization") +} + model Troop { troopLeaderId Int @id @map("troop_leader") nationId Int @map("nation") diff --git a/packages/infra/prisma/migrations/20260731001000_add_unification_finalization/migration.sql b/packages/infra/prisma/migrations/20260731001000_add_unification_finalization/migration.sql new file mode 100644 index 0000000..dd787c9 --- /dev/null +++ b/packages/infra/prisma/migrations/20260731001000_add_unification_finalization/migration.sql @@ -0,0 +1,24 @@ +ALTER TABLE "ng_old_nations" +ALTER COLUMN "server_id" TYPE TEXT; + +ALTER TABLE "ng_old_generals" +ALTER COLUMN "server_id" TYPE TEXT; + +ALTER TABLE "emperior" +ALTER COLUMN "server_id" TYPE TEXT; + +CREATE TABLE "unification_finalization" ( + "generation_key" TEXT NOT NULL, + "server_id" TEXT NOT NULL, + "profile_name" TEXT NOT NULL, + "winner_nation" INTEGER NOT NULL, + "year" INTEGER NOT NULL, + "month" INTEGER NOT NULL, + "completed_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "unification_finalization_pkey" PRIMARY KEY ("generation_key") +); + +CREATE UNIQUE INDEX "unification_finalization_server_id_key" +ON "unification_finalization" ("server_id"); diff --git a/packages/infra/src/turnEngineDb.ts b/packages/infra/src/turnEngineDb.ts index 208d99a..d8a0a17 100644 --- a/packages/infra/src/turnEngineDb.ts +++ b/packages/infra/src/turnEngineDb.ts @@ -310,6 +310,7 @@ export interface TurnEngineNationUpdateInput { chiefGeneralId: number | null; gold: number; rice: number; + tech: number; level: number; typeCode: string; meta: InputJsonValue; From 7f12b072b3d1e4d650db0febeb81b25e49e6cb61 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 14:10:28 +0000 Subject: [PATCH 3/3] fix(api): resolve yearbooks by generation server id --- app/game-api/src/router/yearbook/index.ts | 94 ++++++++++++------- .../test/yearbookArchiveRouter.test.ts | 52 +++++++++- .../visual-parity.spec.ts | 1 + 3 files changed, 110 insertions(+), 37 deletions(-) diff --git a/app/game-api/src/router/yearbook/index.ts b/app/game-api/src/router/yearbook/index.ts index 51ac6c1..7f825a3 100644 --- a/app/game-api/src/router/yearbook/index.ts +++ b/app/game-api/src/router/yearbook/index.ts @@ -63,6 +63,27 @@ const parseYearbookNations = (value: unknown): YearbookNation[] => { return output; }; +const resolveArchiveTarget = ( + worldMeta: unknown, + profileName: string, + requestedServerId?: string +): { archiveKey: string; legacyAlias: string | null; isCurrentProfile: boolean } => { + const rawServerId = asRecord(worldMeta).serverId; + const canonicalServerId = typeof rawServerId === 'string' && rawServerId.trim() ? rawServerId.trim() : profileName; + const requested = requestedServerId ?? profileName; + const isCurrentProfile = requested === profileName || requested === canonicalServerId; + return { + archiveKey: isCurrentProfile ? canonicalServerId : requested, + legacyAlias: isCurrentProfile && canonicalServerId !== profileName ? profileName : null, + isCurrentProfile, + }; +}; + +const normalizeArchivedLogs = (value: unknown, month: number): string[] => { + const logs = parseTextArray(value); + return logs.length ? logs : [`●${month}월: 기록 없음`]; +}; + const buildNationSnapshot = async (ctx: GameApiContext) => { const [nationRows, cityRows, generalRows] = await Promise.all([ ctx.db.nation.findMany({ @@ -220,22 +241,27 @@ export const yearbookRouter = router({ message: 'World state is not initialized.', }); } - const targetProfileName = input?.serverID ?? ctx.profile.name; - const isCurrentProfile = targetProfileName === ctx.profile.name; + const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input?.serverID); - const firstRow = await ctx.db.yearbookHistory.findFirst({ - where: { profileName: targetProfileName }, - select: { year: true, month: true }, - orderBy: [{ year: 'asc' }, { month: 'asc' }], - }); + const findRange = async (profileName: string) => + Promise.all([ + ctx.db.yearbookHistory.findFirst({ + where: { profileName }, + select: { year: true, month: true }, + orderBy: [{ year: 'asc' as const }, { month: 'asc' as const }], + }), + ctx.db.yearbookHistory.findFirst({ + where: { profileName }, + select: { year: true, month: true }, + orderBy: [{ year: 'desc' as const }, { month: 'desc' as const }], + }), + ]); + let [firstRow, lastRow] = await findRange(target.archiveKey); + if ((!firstRow || !lastRow) && target.legacyAlias) { + [firstRow, lastRow] = await findRange(target.legacyAlias); + } - const lastRow = await ctx.db.yearbookHistory.findFirst({ - where: { profileName: targetProfileName }, - select: { year: true, month: true }, - orderBy: [{ year: 'desc' }, { month: 'desc' }], - }); - - if (!isCurrentProfile && (!firstRow || !lastRow)) { + if (!target.isCurrentProfile && (!firstRow || !lastRow)) { throw new TRPCError({ code: 'NOT_FOUND', message: '연감 범위를 찾을 수 없습니다.' }); } @@ -243,7 +269,7 @@ export const yearbookRouter = router({ const fallbackYearMonth = currentYearMonth - 1; const firstYearMonth = firstRow ? joinYearMonth(firstRow.year, firstRow.month) : fallbackYearMonth; const lastYearMonth = lastRow ? joinYearMonth(lastRow.year, lastRow.month) : fallbackYearMonth; - const selectedYearMonth = isCurrentProfile ? currentYearMonth : lastYearMonth; + const selectedYearMonth = target.isCurrentProfile ? currentYearMonth : lastYearMonth; return { firstYearMonth, @@ -266,15 +292,16 @@ export const yearbookRouter = router({ if (!worldState) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); } - const targetProfileName = input.serverID ?? ctx.profile.name; - const isCurrentProfile = targetProfileName === ctx.profile.name; - const shouldRecordAfterHashCheck = isCurrentProfile && Boolean(input.hash); - if (isCurrentProfile && !shouldRecordAfterHashCheck) { + const target = resolveArchiveTarget(worldState.meta, ctx.profile.name, input.serverID); + const shouldRecordAfterHashCheck = target.isCurrentProfile && Boolean(input.hash); + if (target.isCurrentProfile && !shouldRecordAfterHashCheck) { await recordHistoryAccess(ctx); } const isCurrent = - isCurrentProfile && worldState.currentYear === input.year && worldState.currentMonth === input.month; + target.isCurrentProfile && + worldState.currentYear === input.year && + worldState.currentMonth === input.month; if (isCurrent) { const { globalHistory, globalAction } = await buildLogs(ctx, input.year, input.month); @@ -301,28 +328,23 @@ export const yearbookRouter = router({ return { notModified: false, hash, data }; } - const row = await ctx.db.yearbookHistory.findFirst({ - where: { - profileName: targetProfileName, - year: input.year, - month: input.month, - }, - orderBy: [{ sourceId: 'desc' }, { id: 'desc' }], - }); + const findArchivedRow = (profileName: string) => + ctx.db.yearbookHistory.findFirst({ + where: { profileName, year: input.year, month: input.month }, + orderBy: [{ sourceId: 'desc' as const }, { id: 'desc' as const }], + }); + let row = await findArchivedRow(target.archiveKey); + if (!row && target.legacyAlias) { + row = await findArchivedRow(target.legacyAlias); + } if (!row) { throw new TRPCError({ code: 'NOT_FOUND', message: '연감 데이터를 찾을 수 없습니다.' }); } const map = asRecord(row.map) as BaseMapResult; const nations = parseYearbookNations(row.nations); - const archivedLogs = - isCurrentProfile && row.sourceId === 0 - ? await buildLogs(ctx, input.year, input.month) - : { - globalHistory: parseTextArray(row.globalHistory), - globalAction: parseTextArray(row.globalAction), - }; - const { globalHistory, globalAction } = archivedLogs; + const globalHistory = normalizeArchivedLogs(row.globalHistory, input.month); + const globalAction = normalizeArchivedLogs(row.globalAction, input.month); const data = { year: input.year, month: input.month, diff --git a/app/game-api/test/yearbookArchiveRouter.test.ts b/app/game-api/test/yearbookArchiveRouter.test.ts index 9ff6359..3e19472 100644 --- a/app/game-api/test/yearbookArchiveRouter.test.ts +++ b/app/game-api/test/yearbookArchiveRouter.test.ts @@ -17,6 +17,7 @@ const profile: GameProfile = { }; const archiveServerId = 'hwe_260725_archive'; +const currentServerId = 'che_260731_runtime'; const archiveRows = [ { id: 1, @@ -44,6 +45,19 @@ const archiveRows = [ hash: 'archive-2', createdAt: new Date('2026-07-25T01:00:00.000Z'), }, + { + id: 3, + profileName: currentServerId, + sourceId: 0, + year: 219, + month: 12, + map: { year: 219, month: 12, startYear: 190, cityList: [], nationList: [] }, + nations: [{ id: 1, name: '현재기수국', color: '#00FF00', level: 7, power: 1300, cities: ['낙양'] }], + globalHistory: ['저장된 현재 기수 과거 기록'], + globalAction: ['저장된 현재 기수 과거 행동'], + hash: 'current-archive', + createdAt: new Date('2026-07-31T00:00:00.000Z'), + }, ]; const authFor = (userId: string): GameSessionTokenPayload => ({ @@ -68,7 +82,7 @@ const buildContext = (auth: GameSessionTokenPayload | null, options: { hasGenera options.hasGeneral === false ? null : { id: where.userId === 'owner-a' ? 1 : 2, userId: where.userId }, }, worldState: { - findFirst: async () => ({ currentYear: 220, currentMonth: 1 }), + findFirst: async () => ({ currentYear: 220, currentMonth: 1, meta: { serverId: currentServerId } }), }, yearbookHistory: { findFirst: async (args: { @@ -165,4 +179,40 @@ describe('historical yearbook access from dynasty', () => { message: '연감 범위를 찾을 수 없습니다.', }); }); + + it('treats the canonical server ID and profile alias as the same live generation', async () => { + const caller = appRouter.createCaller(buildContext(authFor('owner-a'))); + + const [canonical, alias, omitted] = await Promise.all([ + caller.yearbook.getRange({ serverID: currentServerId }), + caller.yearbook.getRange({ serverID: profile.name }), + caller.yearbook.getRange(), + ]); + + expect(canonical).toEqual(alias); + expect(alias).toEqual(omitted); + expect(canonical).toEqual({ + firstYearMonth: 219 * 12 + 11, + lastYearMonth: 219 * 12 + 11, + currentYearMonth: 220 * 12, + }); + }); + + it('uses stored logs for a past month of the current generation', async () => { + const caller = appRouter.createCaller(buildContext(authFor('owner-a'))); + + const result = await caller.yearbook.getHistory({ + serverID: currentServerId, + year: 219, + month: 12, + }); + + expect(result).toMatchObject({ + notModified: false, + data: { + globalHistory: ['저장된 현재 기수 과거 기록'], + globalAction: ['저장된 현재 기수 과거 행동'], + }, + }); + }); }); diff --git a/tools/frontend-legacy-parity/visual-parity.spec.ts b/tools/frontend-legacy-parity/visual-parity.spec.ts index d4bf6a9..280387e 100644 --- a/tools/frontend-legacy-parity/visual-parity.spec.ts +++ b/tools/frontend-legacy-parity/visual-parity.spec.ts @@ -259,6 +259,7 @@ const installAuthenticatedGameFixture = async (page: Page): Promise => { ); await page.route('**/che/api/trpc/**', async (route) => { await fulfillOperations(route, (operation) => { + if (operation === 'auth.status') return { userId: 'frontend-legacy-fixture-user' }; if (operation === 'lobby.info') { return { ...fixture.game.lobby, myGeneral: fixture.game.session.general }; }