diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index 6803ed2f..051b7337 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -7,3 +7,10 @@ export * from './scenario/scenarioLoader.js'; export * from './scenario/databaseUrl.js'; export * from './scenario/mapLoader.js'; export * from './scenario/scenarioSeeder.js'; +export * from './turn/types.js'; +export * from './turn/worldLoader.js'; +export * from './turn/inMemoryWorld.js'; +export * from './turn/inMemoryStateStore.js'; +export * from './turn/inMemoryTurnProcessor.js'; +export * from './turn/databaseHooks.js'; +export * from './turn/turnDaemon.js'; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts new file mode 100644 index 00000000..a3048623 --- /dev/null +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -0,0 +1,142 @@ +import type { Prisma } from '@prisma/client'; + +import { createPostgresConnector } from '@sammo-ts/infra'; + +import type { TurnDaemonHooks } from '../lifecycle/types.js'; +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; + +export interface DatabaseTurnHooks { + hooks: TurnDaemonHooks; + close(): Promise; +} + +const asJson = (value: unknown): Prisma.InputJsonValue => + value as Prisma.InputJsonValue; + +const toCode = (value: string | null | undefined): string => + value && value !== 'None' ? value : 'None'; + +const buildGeneralUpdate = ( + general: ReturnType['generals'][number] +): Prisma.GeneralUpdateInput => ({ + name: general.name, + nationId: general.nationId, + cityId: general.cityId, + troopId: general.troopId, + leadership: general.stats.leadership, + strength: general.stats.strength, + intel: general.stats.intelligence, + experience: general.experience, + dedication: general.dedication, + officerLevel: general.officerLevel, + injury: general.injury, + gold: general.gold, + rice: general.rice, + crew: general.crew, + crewTypeId: general.crewTypeId, + train: general.train, + age: general.age, + npcState: general.npcState, + horseCode: toCode(general.role.items.horse), + weaponCode: toCode(general.role.items.weapon), + bookCode: toCode(general.role.items.book), + itemCode: toCode(general.role.items.item), + personalCode: toCode(general.role.personality), + specialCode: toCode(general.role.specialDomestic), + special2Code: toCode(general.role.specialWar), + meta: asJson(general.meta), + turnTime: general.turnTime, + recentWarTime: general.recentWarTime ?? null, +}); + +const buildCityUpdate = ( + city: ReturnType['cities'][number] +): Prisma.CityUpdateInput => ({ + name: city.name, + nationId: city.nationId, + level: city.level, + population: city.population, + populationMax: city.populationMax, + agriculture: city.agriculture, + agricultureMax: city.agricultureMax, + commerce: city.commerce, + commerceMax: city.commerceMax, + security: city.security, + securityMax: city.securityMax, + supplyState: city.supplyState, + frontState: city.frontState, + defence: city.defence, + defenceMax: city.defenceMax, + wall: city.wall, + wallMax: city.wallMax, + meta: asJson(city.meta), +}); + +const buildNationUpdate = ( + nation: ReturnType['nations'][number] +): Prisma.NationUpdateInput => ({ + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId, + gold: nation.gold, + rice: nation.rice, + level: nation.level, + typeCode: nation.typeCode, + meta: asJson(nation.meta), +}); + +export const createDatabaseTurnHooks = async ( + databaseUrl: string, + world: InMemoryTurnWorld +): Promise => { + // 턴 처리 결과를 DB에 반영하는 훅을 만든다. + const connector = createPostgresConnector({ url: databaseUrl }); + await connector.connect(); + + const hooks: TurnDaemonHooks = { + flushChanges: async () => { + const state = world.getState(); + const { generals, cities, nations, logs } = world.consumeDirtyState(); + + await connector.prisma.worldState.update({ + where: { id: state.id }, + data: { + currentYear: state.currentYear, + currentMonth: state.currentMonth, + tickSeconds: state.tickSeconds, + meta: asJson(state.meta), + }, + }); + + await Promise.all([ + ...generals.map((general) => + connector.prisma.general.update({ + where: { id: general.id }, + data: buildGeneralUpdate(general), + }) + ), + ...cities.map((city) => + connector.prisma.city.update({ + where: { id: city.id }, + data: buildCityUpdate(city), + }) + ), + ...nations.map((nation) => + connector.prisma.nation.update({ + where: { id: nation.id }, + data: buildNationUpdate(nation), + }) + ), + ]); + + if (logs.length > 0) { + // TODO: API 서버 연동 전까지는 로그를 별도 처리하지 않는다. + } + }, + }; + + return { + hooks, + close: () => connector.disconnect(), + }; +}; diff --git a/app/game-engine/src/turn/inMemoryStateStore.ts b/app/game-engine/src/turn/inMemoryStateStore.ts new file mode 100644 index 00000000..ff44bc83 --- /dev/null +++ b/app/game-engine/src/turn/inMemoryStateStore.ts @@ -0,0 +1,33 @@ +import type { TurnCheckpoint, TurnStateStore } from '../lifecycle/types.js'; +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; + +export class InMemoryTurnStateStore implements TurnStateStore { + // 인메모리 월드의 턴 상태를 TurnDaemonLifecycle에 제공한다. + private readonly world: InMemoryTurnWorld; + private checkpoint?: TurnCheckpoint; + + constructor(world: InMemoryTurnWorld) { + this.world = world; + } + + async loadLastTurnTime(): Promise { + return this.world.getState().lastTurnTime; + } + + async loadNextGeneralTurnTime(): Promise { + return this.world.getNextGeneralTurnTime(this.checkpoint); + } + + async saveLastTurnTime(turnTime: Date): Promise { + this.world.setLastTurnTime(turnTime); + } + + async loadCheckpoint(): Promise { + return this.checkpoint; + } + + async saveCheckpoint(checkpoint?: TurnCheckpoint): Promise { + this.checkpoint = checkpoint; + this.world.setCheckpoint(checkpoint); + } +} diff --git a/app/game-engine/src/turn/inMemoryTurnProcessor.ts b/app/game-engine/src/turn/inMemoryTurnProcessor.ts new file mode 100644 index 00000000..d6255779 --- /dev/null +++ b/app/game-engine/src/turn/inMemoryTurnProcessor.ts @@ -0,0 +1,104 @@ +import type { + TurnCheckpoint, + TurnProcessor, + TurnRunBudget, + TurnRunResult, +} from '../lifecycle/types.js'; +import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; + +export interface InMemoryTurnProcessorOptions { + tickMinutes?: number; +} + +const resolveTickMinutes = ( + world: InMemoryTurnWorld, + override?: number +): number => { + if (override !== undefined) { + return Math.max(1, override); + } + const tickSeconds = world.getState().tickSeconds; + return Math.max(1, Math.round(tickSeconds / 60)); +}; + +export class InMemoryTurnProcessor implements TurnProcessor { + // 인메모리 월드로 턴을 실행하고 월/연 갱신까지 처리한다. + private readonly world: InMemoryTurnWorld; + private readonly tickMinutes: number; + + constructor(world: InMemoryTurnWorld, options: InMemoryTurnProcessorOptions = {}) { + this.world = world; + this.tickMinutes = resolveTickMinutes(world, options.tickMinutes); + } + + async run( + targetTime: Date, + budget: TurnRunBudget, + checkpoint?: TurnCheckpoint + ): Promise { + const startMs = Date.now(); + const deadlineMs = startMs + Math.max(0, budget.budgetMs); + const isBudgetExpired = () => Date.now() >= deadlineMs; + + this.world.setCheckpoint(checkpoint); + + let processedGenerals = 0; + let processedTurns = 0; + let partial = false; + let generalPartial = false; + let nextCheckpoint: TurnCheckpoint | undefined = undefined; + + const dueGenerals = this.world.listDueGenerals(targetTime, checkpoint); + for (const general of dueGenerals) { + if (processedGenerals >= budget.maxGenerals || isBudgetExpired()) { + partial = true; + generalPartial = true; + break; + } + const executedAt = new Date(general.turnTime.getTime()); + this.world.executeGeneralTurn(general); + processedGenerals += 1; + nextCheckpoint = { + turnTime: executedAt.toISOString(), + generalId: general.id, + year: this.world.getState().currentYear, + month: this.world.getState().currentMonth, + }; + } + + if (!partial) { + let nextTickTime = getNextTickTime( + this.world.getState().lastTurnTime, + this.tickMinutes + ); + while (nextTickTime.getTime() <= targetTime.getTime()) { + if (processedTurns >= budget.catchUpCap || isBudgetExpired()) { + partial = true; + break; + } + this.world.advanceMonth(nextTickTime); + processedTurns += 1; + nextTickTime = getNextTickTime( + this.world.getState().lastTurnTime, + this.tickMinutes + ); + } + } + + if (!generalPartial) { + nextCheckpoint = undefined; + } + + const lastTurnTime = this.world.getState().lastTurnTime.toISOString(); + + return { + lastTurnTime, + processedGenerals, + processedTurns, + durationMs: Math.max(0, Date.now() - startMs), + partial, + checkpoint: nextCheckpoint, + }; + } +} diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts new file mode 100644 index 00000000..9c49e4f5 --- /dev/null +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -0,0 +1,264 @@ +import type { City, Nation, TurnSchedule } from '@sammo-ts/logic'; +import { getNextTurnAt } from '@sammo-ts/logic'; + +import type { TurnCheckpoint } from '../lifecycle/types.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js'; + +export interface GeneralTurnContext { + general: TurnGeneral; + city?: City; + nation?: Nation | null; + world: TurnWorldState; + schedule: TurnSchedule; +} + +export interface GeneralTurnResult { + general?: TurnGeneral; + city?: City; + nation?: Nation | null; + nextTurnAt?: Date; + logs?: string[]; +} + +export interface GeneralTurnHandler { + // 장수 턴 처리 결과를 반영하기 위한 확장 포인트. + execute(context: GeneralTurnContext): GeneralTurnResult; +} + +export interface TurnCalendarContext { + previousYear: number; + previousMonth: number; + currentYear: number; + currentMonth: number; + turnTime: Date; +} + +export interface TurnCalendarHandler { + // 월/연 변경에 따른 후처리를 끼워 넣기 위한 확장 포인트. + onMonthChanged?(context: TurnCalendarContext): void; + onYearChanged?(context: TurnCalendarContext): void; +} + +export interface InMemoryTurnWorldOptions { + schedule: TurnSchedule; + generalTurnHandler?: GeneralTurnHandler; + calendarHandler?: TurnCalendarHandler; +} + +const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => { + const timeDiff = left.turnTime.getTime() - right.turnTime.getTime(); + if (timeDiff !== 0) { + return timeDiff; + } + return left.id - right.id; +}; + +const shouldProcessByCheckpoint = ( + general: TurnGeneral, + checkpoint?: TurnCheckpoint +): boolean => { + if (!checkpoint) { + return true; + } + const generalTime = general.turnTime.getTime(); + const checkpointTime = new Date(checkpoint.turnTime).getTime(); + if (generalTime < checkpointTime) { + return false; + } + if (generalTime > checkpointTime) { + return true; + } + if (checkpoint.generalId === undefined) { + return false; + } + return general.id > checkpoint.generalId; +}; + +export class InMemoryTurnWorld { + // DB에서 읽어온 월드 상태를 메모리에 고정해 턴 처리를 담당한다. + private readonly schedule: TurnSchedule; + private readonly generalTurnHandler: GeneralTurnHandler; + private readonly calendarHandler?: TurnCalendarHandler; + private readonly generals = new Map(); + private readonly cities = new Map(); + private readonly nations = new Map(); + private readonly dirtyGeneralIds = new Set(); + private readonly dirtyCityIds = new Set(); + private readonly dirtyNationIds = new Set(); + private readonly logs: string[] = []; + private checkpoint?: TurnCheckpoint; + private state: TurnWorldState; + + constructor( + state: TurnWorldState, + snapshot: TurnWorldSnapshot, + options: InMemoryTurnWorldOptions + ) { + this.state = { ...state }; + this.schedule = options.schedule; + this.generalTurnHandler = + options.generalTurnHandler ?? + ({ + execute: () => ({}), + } satisfies GeneralTurnHandler); + this.calendarHandler = options.calendarHandler; + + for (const general of snapshot.generals) { + this.generals.set(general.id, { ...general }); + } + for (const city of snapshot.cities) { + this.cities.set(city.id, { ...city }); + } + for (const nation of snapshot.nations) { + this.nations.set(nation.id, { ...nation }); + } + } + + getState(): TurnWorldState { + return { ...this.state }; + } + + setLastTurnTime(turnTime: Date): void { + const meta = { + ...this.state.meta, + lastTurnTime: turnTime.toISOString(), + }; + this.state = { + ...this.state, + lastTurnTime: new Date(turnTime.getTime()), + meta, + }; + } + + setCheckpoint(checkpoint?: TurnCheckpoint): void { + this.checkpoint = checkpoint; + } + + getCheckpoint(): TurnCheckpoint | undefined { + return this.checkpoint; + } + + getNextGeneralTurnTime(checkpoint?: TurnCheckpoint): Date | null { + let next: TurnGeneral | null = null; + for (const general of this.generals.values()) { + if (!shouldProcessByCheckpoint(general, checkpoint)) { + continue; + } + if (!next || compareTurnOrder(general, next) < 0) { + next = general; + } + } + return next ? new Date(next.turnTime.getTime()) : null; + } + + listDueGenerals( + targetTime: Date, + checkpoint?: TurnCheckpoint + ): TurnGeneral[] { + const targetMs = targetTime.getTime(); + const due = Array.from(this.generals.values()).filter((general) => { + if (!shouldProcessByCheckpoint(general, checkpoint)) { + return false; + } + return general.turnTime.getTime() <= targetMs; + }); + due.sort(compareTurnOrder); + return due; + } + + executeGeneralTurn(general: TurnGeneral): Date { + const city = this.cities.get(general.cityId); + const nation = + general.nationId > 0 ? this.nations.get(general.nationId) ?? null : null; + + const result = this.generalTurnHandler.execute({ + general, + city, + nation, + world: this.state, + schedule: this.schedule, + }); + + const nextTurnAt = + result.nextTurnAt ?? getNextTurnAt(general.turnTime, this.schedule); + const nextGeneral = { + ...(result.general ?? general), + turnTime: nextTurnAt, + }; + this.generals.set(nextGeneral.id, nextGeneral); + this.dirtyGeneralIds.add(nextGeneral.id); + + if (result.city) { + this.cities.set(result.city.id, result.city); + this.dirtyCityIds.add(result.city.id); + } + if (result.nation) { + this.nations.set(result.nation.id, result.nation); + this.dirtyNationIds.add(result.nation.id); + } + if (result.logs && result.logs.length > 0) { + this.logs.push(...result.logs); + } + + return nextTurnAt; + } + + advanceMonth(turnTime: Date): void { + const previousYear = this.state.currentYear; + const previousMonth = this.state.currentMonth; + let nextYear = previousYear; + let nextMonth = previousMonth + 1; + if (nextMonth > 12) { + nextMonth = 1; + nextYear = previousYear + 1; + } + + const meta = { + ...this.state.meta, + lastTurnTime: turnTime.toISOString(), + }; + this.state = { + ...this.state, + currentYear: nextYear, + currentMonth: nextMonth, + lastTurnTime: new Date(turnTime.getTime()), + meta, + }; + + const context: TurnCalendarContext = { + previousYear, + previousMonth, + currentYear: nextYear, + currentMonth: nextMonth, + turnTime, + }; + this.calendarHandler?.onMonthChanged?.(context); + if (nextYear !== previousYear) { + this.calendarHandler?.onYearChanged?.(context); + } + } + + consumeDirtyState(): { + generals: TurnGeneral[]; + cities: City[]; + nations: Nation[]; + logs: string[]; + } { + const generals = Array.from(this.dirtyGeneralIds) + .map((id) => this.generals.get(id)) + .filter((general): general is TurnGeneral => Boolean(general)); + const cities = Array.from(this.dirtyCityIds) + .map((id) => this.cities.get(id)) + .filter((city): city is City => Boolean(city)); + const nations = Array.from(this.dirtyNationIds) + .map((id) => this.nations.get(id)) + .filter((nation): nation is Nation => Boolean(nation)); + const logs = this.logs.splice(0, this.logs.length); + + this.dirtyGeneralIds.clear(); + this.dirtyCityIds.clear(); + this.dirtyNationIds.clear(); + + return { generals, cities, nations, logs }; + } +} diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts new file mode 100644 index 00000000..d2901dca --- /dev/null +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -0,0 +1,119 @@ +import type { TurnSchedule } from '@sammo-ts/logic'; + +import { SystemClock } from '../lifecycle/clock.js'; +import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; +import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js'; +import type { + Clock, + TurnDaemonControlQueue, + TurnDaemonHooks, + TurnRunBudget, +} from '../lifecycle/types.js'; +import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js'; +import type { MapLoaderOptions } from '../scenario/mapLoader.js'; +import { createDatabaseTurnHooks } from './databaseHooks.js'; +import type { + GeneralTurnHandler, + InMemoryTurnWorldOptions, + TurnCalendarHandler, +} from './inMemoryWorld.js'; +import { InMemoryTurnWorld } from './inMemoryWorld.js'; +import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js'; +import { InMemoryTurnStateStore } from './inMemoryStateStore.js'; +import { loadTurnWorldFromDatabase } from './worldLoader.js'; + +export interface TurnDaemonRuntimeOptions { + profile: string; + databaseUrl: string; + defaultBudget?: TurnRunBudget; + clock?: Clock; + controlQueue?: TurnDaemonControlQueue; + schedule?: TurnSchedule; + tickMinutes?: number; + mapOptions?: MapLoaderOptions; + generalTurnHandler?: GeneralTurnHandler; + calendarHandler?: TurnCalendarHandler; + enableDatabaseFlush?: boolean; +} + +export interface TurnDaemonRuntime { + lifecycle: TurnDaemonLifecycle; + world: InMemoryTurnWorld; + controlQueue: TurnDaemonControlQueue; + stateStore: InMemoryTurnStateStore; + processor: InMemoryTurnProcessor; + hooks?: TurnDaemonHooks; + close(): Promise; +} + +const resolveTickMinutes = (tickSeconds: number, override?: number): number => { + if (override !== undefined) { + return Math.max(1, override); + } + return Math.max(1, Math.round(tickSeconds / 60)); +}; + +const buildFixedSchedule = (tickMinutes: number): TurnSchedule => ({ + entries: [{ startMinute: 0, tickMinutes }], +}); + +export const createTurnDaemonRuntime = async ( + options: TurnDaemonRuntimeOptions +): Promise => { + // DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다. + const { state, snapshot } = await loadTurnWorldFromDatabase({ + databaseUrl: options.databaseUrl, + mapOptions: options.mapOptions, + }); + + const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes); + const schedule = options.schedule ?? buildFixedSchedule(tickMinutes); + const worldOptions: InMemoryTurnWorldOptions = { + schedule, + generalTurnHandler: options.generalTurnHandler, + calendarHandler: options.calendarHandler, + }; + const world = new InMemoryTurnWorld(state, snapshot, worldOptions); + + const stateStore = new InMemoryTurnStateStore(world); + const processor = new InMemoryTurnProcessor(world, { tickMinutes }); + const controlQueue = options.controlQueue ?? new InMemoryControlQueue(); + const clock = options.clock ?? new SystemClock(); + + let hooks: TurnDaemonHooks | undefined; + let close = async () => {}; + if (options.enableDatabaseFlush ?? true) { + const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world); + hooks = dbHooks.hooks; + close = dbHooks.close; + } + + const defaultBudget: TurnRunBudget = options.defaultBudget ?? { + budgetMs: 5000, + maxGenerals: 200, + catchUpCap: 1, + }; + + const lifecycle = new TurnDaemonLifecycle( + { + clock, + controlQueue, + getNextTickTime: (lastTurnTime) => + getNextTickTime(lastTurnTime, tickMinutes), + stateStore, + processor, + hooks, + }, + { profile: options.profile, defaultBudget } + ); + + return { + lifecycle, + world, + controlQueue, + stateStore, + processor, + hooks, + close, + }; +}; diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts new file mode 100644 index 00000000..cd4103bc --- /dev/null +++ b/app/game-engine/src/turn/types.ts @@ -0,0 +1,44 @@ +import type { + City, + General, + MapDefinition, + Nation, + ScenarioConfig, + ScenarioDiplomacy, + ScenarioMeta, + UnitSetDefinition, + WorldSnapshot, +} from '@sammo-ts/logic'; + +export interface TurnWorldState { + id: number; + currentYear: number; + currentMonth: number; + tickSeconds: number; + lastTurnTime: Date; + meta: Record; +} + +export interface TurnGeneral extends General { + turnTime: Date; + recentWarTime?: Date | null; +} + +export interface TurnWorldSnapshot + extends Omit { + scenarioConfig: ScenarioConfig; + scenarioMeta?: ScenarioMeta; + map: MapDefinition; + unitSet?: UnitSetDefinition; + diplomacy: ScenarioDiplomacy[]; + events: unknown[]; + initialEvents: unknown[]; + generals: TurnGeneral[]; + cities: City[]; + nations: Nation[]; +} + +export interface TurnWorldLoadResult { + state: TurnWorldState; + snapshot: TurnWorldSnapshot; +} diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts new file mode 100644 index 00000000..418485f1 --- /dev/null +++ b/app/game-engine/src/turn/worldLoader.ts @@ -0,0 +1,273 @@ +import type { Prisma } from '@prisma/client'; + +import { createPostgresConnector } from '@sammo-ts/infra'; +import type { + City, + Nation, + ScenarioConfig, + ScenarioDiplomacy, + ScenarioMeta, + TriggerValue, +} from '@sammo-ts/logic'; + +import { getNextTickTime } from '../lifecycle/getNextTickTime.js'; +import type { MapLoaderOptions } from '../scenario/mapLoader.js'; +import { loadMapDefinitionByName } from '../scenario/mapLoader.js'; +import type { TurnGeneral, TurnWorldLoadResult } from './types.js'; + +interface TurnWorldLoaderOptions { + databaseUrl: string; + mapOptions?: MapLoaderOptions; +} + +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 parseScenarioMeta = (meta: JsonRecord): ScenarioMeta | undefined => { + const raw = meta.scenarioMeta; + if (isRecord(raw)) { + return raw as ScenarioMeta; + } + return 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 => + raw as ScenarioConfig; + +const mapGeneralRow = (row: Prisma.General): 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, + 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: Prisma.City): 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), +}); + +const mapNationRow = (row: Prisma.Nation): 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), +}); + +const mapDiplomacyRow = (row: Prisma.Diplomacy): ScenarioDiplomacy => ({ + fromNationId: row.srcNationId, + toNationId: row.destNationId, + state: row.stateCode, + durationMonths: row.term, +}); + +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, + eventRows, + ] = await Promise.all([ + prisma.general.findMany(), + prisma.city.findMany(), + prisma.nation.findMany(), + prisma.diplomacy.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 scenarioConfig = mapScenarioConfig(worldState.config); + const mapName = scenarioConfig.environment?.mapName ?? 'che'; + const map = await loadMapDefinitionByName(mapName, options.mapOptions); + + 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, + nations, + cities, + generals, + diplomacy, + events, + initialEvents, + }, + }; + } finally { + await connector.disconnect(); + } +};