feat: 턴 처리 및 데이터베이스 연동을 위한 새로운 클래스 및 인터페이스 추가

This commit is contained in:
2025-12-29 07:41:43 +00:00
parent b11a546ffa
commit 86c5d5f111
8 changed files with 986 additions and 0 deletions
+7
View File
@@ -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';
+142
View File
@@ -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<void>;
}
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<InMemoryTurnWorld['consumeDirtyState']>['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<InMemoryTurnWorld['consumeDirtyState']>['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<InMemoryTurnWorld['consumeDirtyState']>['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<DatabaseTurnHooks> => {
// 턴 처리 결과를 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(),
};
};
@@ -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<Date> {
return this.world.getState().lastTurnTime;
}
async loadNextGeneralTurnTime(): Promise<Date | null> {
return this.world.getNextGeneralTurnTime(this.checkpoint);
}
async saveLastTurnTime(turnTime: Date): Promise<void> {
this.world.setLastTurnTime(turnTime);
}
async loadCheckpoint(): Promise<TurnCheckpoint | undefined> {
return this.checkpoint;
}
async saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void> {
this.checkpoint = checkpoint;
this.world.setCheckpoint(checkpoint);
}
}
@@ -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<TurnRunResult> {
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,
};
}
}
+264
View File
@@ -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<number, TurnGeneral>();
private readonly cities = new Map<number, City>();
private readonly nations = new Map<number, Nation>();
private readonly dirtyGeneralIds = new Set<number>();
private readonly dirtyCityIds = new Set<number>();
private readonly dirtyNationIds = new Set<number>();
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 };
}
}
+119
View File
@@ -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<void>;
}
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<TurnDaemonRuntime> => {
// 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,
};
};
+44
View File
@@ -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<string, unknown>;
}
export interface TurnGeneral extends General {
turnTime: Date;
recentWarTime?: Date | null;
}
export interface TurnWorldSnapshot
extends Omit<WorldSnapshot, 'generals' | 'cities' | 'nations'> {
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;
}
+273
View File
@@ -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<string, unknown>;
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<string, TriggerValue> =>
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
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<TurnWorldLoadResult> => {
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();
}
};