429 lines
14 KiB
TypeScript
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();
|
|
}
|
|
};
|