import { createGamePostgresConnector, type JsonValue, type TurnEngineCityRow, type TurnEngineDatabaseClient, type TurnEngineDiplomacyRow, type TurnEngineGeneralRow, type TurnEngineGeneralAccessLogRow, type TurnEngineInheritancePointRow, type TurnEngineRankDataRow, type TurnEngineNationRow, type TurnEngineTroopRow, } from '@sammo-ts/infra'; import type { City, GeneralItemSlots, GeneralLastTurn, Nation, ScenarioConfig, ScenarioMeta, Troop, TriggerValue, } from '@sammo-ts/logic'; import { projectItemSlots, readItemInventoryFromMeta } from '@sammo-ts/logic/items/index.js'; import { z } from 'zod'; import { asRecord, isRecord } from '@sammo-ts/common'; import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; import type { MapLoaderOptions } from '../scenario/mapLoader.js'; import { loadMapDefinitionByName } from '../scenario/mapLoader.js'; import type { UnitSetLoaderOptions } from '../scenario/unitSetLoader.js'; import { loadUnitSetDefinitionByName } from '../scenario/unitSetLoader.js'; import type { TurnDiplomacy, TurnEvent, TurnGeneral, TurnWorldLoadResult } from './types.js'; import { readDiplomacyMeta } from '@sammo-ts/logic'; interface TurnWorldLoaderOptions { databaseUrl: string; mapOptions?: MapLoaderOptions; unitSetOptions?: UnitSetLoaderOptions; } type JsonRecord = Record; const asTriggerRecord = (value: unknown): Record => isRecord(value) ? (value as Record) : {}; const normalizeGeneralLastTurn = (value: unknown): GeneralLastTurn => { const raw = asRecord(value); const arg = asRecord(raw.arg); return { command: typeof raw.command === 'string' ? raw.command : '휴식', ...(Object.keys(arg).length > 0 ? { arg } : {}), ...(typeof raw.term === 'number' && Number.isFinite(raw.term) ? { term: Math.floor(raw.term) } : {}), ...(typeof raw.seq === 'number' && Number.isFinite(raw.seq) ? { seq: Math.floor(raw.seq) } : {}), }; }; const normalizeCode = (value: string | null | undefined): string | null => { if (!value || value === 'None') { return null; } return value; }; const readMetaNumber = (meta: Record, key: string): number | null => { const value = meta[key]; if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string') { const parsed = Number(value); if (Number.isFinite(parsed)) { return parsed; } } return null; }; const zScenarioStatBlock = z.object({ total: z.number(), min: z.number(), max: z.number(), npcTotal: z.number(), npcMax: z.number(), npcMin: z.number(), chiefMin: z.number(), }); const zScenarioEnvironment = z.object({ mapName: z.string(), unitSet: z.string(), scenarioEffect: z.union([z.string(), z.null()]).optional(), }); const zScenarioConfig = z.object({ stat: zScenarioStatBlock, iconPath: z.string(), map: z.record(z.string(), z.unknown()), const: z.record(z.string(), z.unknown()), environment: zScenarioEnvironment, }); const zScenarioMeta = z.object({ title: z.string(), startYear: z.number().nullable(), life: z.number().nullable(), fiction: z.number().nullable(), history: z.array(z.string()), ignoreDefaultEvents: z.boolean(), }); const parseScenarioMeta = (meta: JsonRecord): ScenarioMeta | undefined => { const raw = meta.scenarioMeta; const parsed = zScenarioMeta.safeParse(raw); return parsed.success ? parsed.data : undefined; }; const parseLastTurnTime = (meta: JsonRecord): Date | null => { const raw = meta.lastTurnTime; if (typeof raw !== 'string') { return null; } const parsed = new Date(raw); if (Number.isNaN(parsed.getTime())) { return null; } return parsed; }; const resolveFallbackTurnTimeBase = (generals: TurnGeneral[], updatedAt: Date | null): Date => { let earliest: Date | null = null; for (const general of generals) { const turnTime = general.turnTime; if (!earliest || turnTime.getTime() < earliest.getTime()) { earliest = turnTime; } } if (earliest) { return earliest; } if (updatedAt) { return updatedAt; } return new Date(); }; const alignToPreviousTick = (base: Date, tickMinutes: number): Date => { const nextTick = getNextTickTime(base, tickMinutes); return new Date(nextTick.getTime() - tickMinutes * 60_000); }; const mapScenarioConfig = (raw: JsonValue): ScenarioConfig => { const parsed = zScenarioConfig.safeParse(raw); if (!parsed.success) { throw new Error(`world_state.config is invalid: ${parsed.error.message}`); } return parsed.data; }; const GENERAL_RANK_META_PREFIX_TYPES = new Set([ 'warnum', 'killnum', 'deathnum', 'occupied', 'killcrew', 'deathcrew', 'killcrew_person', 'deathcrew_person', ]); const mapGeneralRow = ( row: TurnEngineGeneralRow, rankRows: readonly TurnEngineRankDataRow[], inheritanceRows: readonly TurnEngineInheritancePointRow[], accessRow?: TurnEngineGeneralAccessLogRow ): TurnGeneral => { const legacySlots: GeneralItemSlots = { horse: normalizeCode(row.horseCode), weapon: normalizeCode(row.weaponCode), book: normalizeCode(row.bookCode), item: normalizeCode(row.itemCode), }; const rawMeta = { ...(asTriggerRecord(row.meta) as Record) }; for (const rank of rankRows) { if (rank.type === 'experience' || rank.type === 'dedication') { continue; } rawMeta[GENERAL_RANK_META_PREFIX_TYPES.has(rank.type) ? `rank_${rank.type}` : rank.type] = rank.value; } const inheritancePoints = Object.fromEntries(inheritanceRows.map((entry) => [entry.key, entry.value])); const itemInventory = readItemInventoryFromMeta(rawMeta, legacySlots); return { ...((): { meta: TurnGeneral['meta'] } => { const meta = rawMeta; const killturn = readMetaNumber(meta, 'killturn'); if (killturn === null) { throw new Error(`general.meta.killturn is required (generalId=${row.id}).`); } return { meta: { ...meta, killturn } as TurnGeneral['meta'] }; })(), id: row.id, userId: row.userId, name: row.name, nationId: row.nationId, cityId: row.cityId, troopId: row.troopId, stats: { leadership: row.leadership, strength: row.strength, intelligence: row.intel, }, experience: row.experience, dedication: row.dedication, officerLevel: row.officerLevel, role: { personality: normalizeCode(row.personalCode), specialDomestic: normalizeCode(row.specialCode), specialWar: normalizeCode(row.special2Code), items: projectItemSlots(itemInventory), }, injury: row.injury, gold: row.gold, rice: row.rice, crew: row.crew, crewTypeId: row.crewTypeId, train: row.train, atmos: row.atmos, age: row.age, startAge: row.startAge, npcState: row.npcState, bornYear: row.bornYear, deadYear: row.deadYear, affinity: row.affinity, picture: row.picture, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {}, }, itemInventory, lastTurn: normalizeGeneralLastTurn(row.lastTurn), penalty: row.penalty, // meta는 상단에서 보장 처리됨. turnTime: row.turnTime, recentWarTime: row.recentWarTime ?? null, inheritancePoints, ...(accessRow ? { refreshScoreTotal: accessRow.refreshScoreTotal } : {}), }; }; const mapCityRow = (row: TurnEngineCityRow): City => { const meta = asTriggerRecord(row.meta); const state = typeof meta.state === 'number' && Number.isFinite(meta.state) ? Math.floor(meta.state) : 0; return { id: row.id, name: row.name, nationId: row.nationId, level: row.level, state, population: row.population, populationMax: row.populationMax, agriculture: row.agriculture, agricultureMax: row.agricultureMax, commerce: row.commerce, commerceMax: row.commerceMax, security: row.security, securityMax: row.securityMax, supplyState: row.supplyState, frontState: row.frontState, defence: row.defence, defenceMax: row.defenceMax, wall: row.wall, wallMax: row.wallMax, conflict: asTriggerRecord(row.conflict), meta: { ...meta, trust: row.trust, ...(row.trade === null ? {} : { trade: row.trade }), region: row.region, }, }; }; const mapNationRow = (row: TurnEngineNationRow): Nation => ({ id: row.id, name: row.name, color: row.color, capitalCityId: row.capitalCityId, chiefGeneralId: row.chiefGeneralId, gold: row.gold, rice: row.rice, power: 0, level: row.level, typeCode: row.typeCode, meta: { ...asTriggerRecord(row.meta), tech: row.tech, }, }); const mapDiplomacyRow = (row: TurnEngineDiplomacyRow): TurnDiplomacy => { const { meta, dead } = readDiplomacyMeta(asRecord(row.meta)); return { fromNationId: row.srcNationId, toNationId: row.destNationId, state: row.stateCode, term: row.term, dead, meta, }; }; const mapEventRow = (row: { id: number; targetCode: string; priority: number; condition: JsonValue; action: JsonValue; meta: JsonValue; }): TurnEvent => ({ id: row.id, targetCode: row.targetCode, priority: row.priority, condition: row.condition, action: row.action, meta: asRecord(row.meta), }); const mapTroopRow = (row: TurnEngineTroopRow): Troop => ({ id: row.troopLeaderId, nationId: row.nationId, name: row.name, }); export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions): Promise => { const connector = createGamePostgresConnector({ url: options.databaseUrl }); await connector.connect(); try { const prisma: TurnEngineDatabaseClient = connector.prisma; const worldState = await prisma.worldState.findFirst(); if (!worldState) { throw new Error('world_state row is required to start turn daemon.'); } const [ generalRows, rankRows, inheritanceRows, accessRows, cityRows, nationRows, diplomacyRows, troopRows, eventRows, ] = await Promise.all([ prisma.general.findMany(), prisma.rankData.findMany(), prisma.inheritancePoint.findMany(), prisma.generalAccessLog.findMany(), prisma.city.findMany(), prisma.nation.findMany(), prisma.diplomacy.findMany(), prisma.troop.findMany(), prisma.event.findMany({ orderBy: [{ priority: 'desc' }, { id: 'asc' }], }), ]); const ranksByGeneral = new Map(); for (const row of rankRows) { const bucket = ranksByGeneral.get(row.generalId) ?? []; bucket.push(row); ranksByGeneral.set(row.generalId, bucket); } const inheritanceByUser = new Map(); for (const row of inheritanceRows) { const bucket = inheritanceByUser.get(row.userId) ?? []; bucket.push(row); inheritanceByUser.set(row.userId, bucket); } const accessByGeneral = new Map(accessRows.map((row) => [row.generalId, row])); const generals = generalRows.map((row) => mapGeneralRow( row, ranksByGeneral.get(row.id) ?? [], row.userId ? (inheritanceByUser.get(row.userId) ?? []) : [], accessByGeneral.get(row.id) ) ); const cities = cityRows.map(mapCityRow); const nations = nationRows.map(mapNationRow); const diplomacy = diplomacyRows.map(mapDiplomacyRow); const troops = troopRows.map(mapTroopRow); const worldConfig = asRecord(worldState.config); const scenarioConfig = mapScenarioConfig(worldState.config); const mapName = scenarioConfig.environment?.mapName ?? 'che'; const map = await loadMapDefinitionByName(mapName, options.mapOptions); const unitSetName = scenarioConfig.environment?.unitSet ?? 'che'; const unitSet = await loadUnitSetDefinitionByName(unitSetName, options.unitSetOptions); const meta = asRecord(worldState.meta); const scenarioMeta = parseScenarioMeta(meta); const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60)); const fallbackBase = resolveFallbackTurnTimeBase(generals, worldState.updatedAt ?? null); const lastTurnTime = parseLastTurnTime(meta) ?? alignToPreviousTick(fallbackBase, tickMinutes); const events = eventRows.filter((row) => row.targetCode !== 'initial').map(mapEventRow); const initialEvents = eventRows.filter((row) => row.targetCode === 'initial').map(mapEventRow); return { state: { id: worldState.id, currentYear: worldState.currentYear, currentMonth: worldState.currentMonth, tickSeconds: worldState.tickSeconds, lastTurnTime, meta, }, snapshot: { scenarioConfig, ...(scenarioMeta ? { scenarioMeta } : {}), worldConfig, map, unitSet, nations, cities, generals, troops, diplomacy, events, initialEvents, }, }; } finally { await connector.disconnect(); } };