Files
core2026/app/game-engine/src/turn/worldLoader.ts
T

429 lines
14 KiB
TypeScript

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';
import { applyPersistedRankRowsToMeta } from './rankData.js';
interface TurnWorldLoaderOptions {
databaseUrl: string;
mapOptions?: MapLoaderOptions;
unitSetOptions?: UnitSetLoaderOptions;
}
type JsonRecord = Record<string, unknown>;
const asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
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<string, unknown>, 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 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<string, unknown>) };
applyPersistedRankRowsToMeta(rawMeta, rankRows);
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 => {
const meta = asTriggerRecord(row.meta);
return {
id: row.id,
name: row.name,
color: row.color,
capitalCityId: row.capitalCityId,
chiefGeneralId: row.chiefGeneralId,
gold: row.gold,
rice: row.rice,
power: readMetaNumber(meta, 'power') ?? 0,
level: row.level,
typeCode: row.typeCode,
meta: {
...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<TurnWorldLoadResult> => {
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<number, TurnEngineRankDataRow[]>();
for (const row of rankRows) {
const bucket = ranksByGeneral.get(row.generalId) ?? [];
bucket.push(row);
ranksByGeneral.set(row.generalId, bucket);
}
const inheritanceByUser = new Map<string, TurnEngineInheritancePointRow[]>();
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();
}
};