feat: 턴 처리 및 데이터베이스 연동을 위한 새로운 클래스 및 인터페이스 추가
This commit is contained in:
@@ -7,3 +7,10 @@ export * from './scenario/scenarioLoader.js';
|
|||||||
export * from './scenario/databaseUrl.js';
|
export * from './scenario/databaseUrl.js';
|
||||||
export * from './scenario/mapLoader.js';
|
export * from './scenario/mapLoader.js';
|
||||||
export * from './scenario/scenarioSeeder.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