import type { City as PrismaCity, Diplomacy as PrismaDiplomacy, General as PrismaGeneral, Nation as PrismaNation, Prisma, Troop as PrismaTroop, } from '@prisma/client'; import { createPostgresConnector } from '@sammo-ts/infra'; import type { City, Nation, ScenarioConfig, ScenarioDiplomacy, ScenarioMeta, Troop, TriggerValue, } from '@sammo-ts/logic'; import { z } from 'zod'; 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 { TurnGeneral, TurnWorldLoadResult } from './types.js'; interface TurnWorldLoaderOptions { databaseUrl: string; mapOptions?: MapLoaderOptions; unitSetOptions?: UnitSetLoaderOptions; } type JsonRecord = Record; const isRecord = (value: unknown): value is JsonRecord => value !== null && typeof value === 'object' && !Array.isArray(value); const asRecord = (value: unknown): JsonRecord => isRecord(value) ? value : {}; const asTriggerRecord = (value: unknown): Record => isRecord(value) ? (value as Record) : {}; const normalizeCode = (value: string | null | undefined): string | null => { if (!value || value === 'None') { return null; } return value; }; 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: Prisma.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 mapGeneralRow = (row: PrismaGeneral): TurnGeneral => ({ id: row.id, 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: { horse: normalizeCode(row.horseCode), weapon: normalizeCode(row.weaponCode), book: normalizeCode(row.bookCode), item: normalizeCode(row.itemCode), }, }, injury: row.injury, gold: row.gold, rice: row.rice, crew: row.crew, crewTypeId: row.crewTypeId, train: row.train, atmos: row.atmos, age: row.age, npcState: row.npcState, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {}, }, meta: asTriggerRecord(row.meta), turnTime: row.turnTime, recentWarTime: row.recentWarTime ?? null, }); const mapCityRow = (row: PrismaCity): City => ({ id: row.id, name: row.name, nationId: row.nationId, level: row.level, 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, meta: { ...asTriggerRecord(row.meta), trust: row.trust, trade: row.trade, region: row.region, }, }); const mapNationRow = (row: PrismaNation): Nation => ({ id: row.id, name: row.name, color: row.color, capitalCityId: row.capitalCityId, chiefGeneralId: null, gold: row.gold, rice: row.rice, power: 0, level: row.level, typeCode: row.typeCode, meta: { ...asTriggerRecord(row.meta), tech: row.tech, }, }); const mapDiplomacyRow = (row: PrismaDiplomacy): ScenarioDiplomacy => ({ fromNationId: row.srcNationId, toNationId: row.destNationId, state: row.stateCode, durationMonths: row.term, }); const mapTroopRow = (row: PrismaTroop): Troop => ({ id: row.troopLeaderId, nationId: row.nationId, name: row.name, }); export const loadTurnWorldFromDatabase = async ( options: TurnWorldLoaderOptions ): Promise => { const connector = createPostgresConnector({ url: options.databaseUrl }); await connector.connect(); try { const prisma = connector.prisma; const worldState = await prisma.worldState.findFirst(); if (!worldState) { throw new Error('world_state row is required to start turn daemon.'); } const [ generalRows, cityRows, nationRows, diplomacyRows, troopRows, eventRows, ] = await Promise.all([ prisma.general.findMany(), prisma.city.findMany(), prisma.nation.findMany(), prisma.diplomacy.findMany(), prisma.troop.findMany(), prisma.event.findMany({ orderBy: [{ priority: 'desc' }, { id: 'asc' }], }), ]); const generals = generalRows.map(mapGeneralRow); const cities = cityRows.map(mapCityRow); const nations = nationRows.map(mapNationRow); const diplomacy = diplomacyRows.map(mapDiplomacyRow); const troops = troopRows.map(mapTroopRow); 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((row) => ({ id: row.id, targetCode: row.targetCode, priority: row.priority, condition: row.condition, action: row.action, meta: row.meta, })); const initialEvents = eventRows .filter((row) => row.targetCode === 'initial') .map((row) => ({ id: row.id, targetCode: row.targetCode, priority: row.priority, condition: row.condition, action: row.action, meta: row.meta, })); return { state: { id: worldState.id, currentYear: worldState.currentYear, currentMonth: worldState.currentMonth, tickSeconds: worldState.tickSeconds, lastTurnTime, meta, }, snapshot: { scenarioConfig, ...(scenarioMeta ? { scenarioMeta } : {}), map, unitSet, nations, cities, generals, troops, diplomacy, events, initialEvents, }, }; } finally { await connector.disconnect(); } };