feat: 턴 처리 및 데이터베이스 연동을 위한 새로운 클래스 및 인터페이스 추가
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user