feat: eslint 적용 및 관련 코드 일괄 수정

This commit is contained in:
2026-01-05 15:46:47 +00:00
parent cb312f02b3
commit c965b1120f
387 changed files with 39808 additions and 38800 deletions
@@ -10,9 +10,7 @@ export const getNextTickTime = (lastTurnTime: Date, turnTermMinutes: number): Da
// 월 기준 턴 그리드에 맞춰 다음 틱 경계를 계산한다.
const base = getCutTurnBase(lastTurnTime);
const elapsedMinutes = Math.floor(
(lastTurnTime.getTime() - base.getTime()) / MINUTES_TO_MS
);
const elapsedMinutes = Math.floor((lastTurnTime.getTime() - base.getTime()) / MINUTES_TO_MS);
const alignedMinutes = elapsedMinutes - (elapsedMinutes % turnTermMinutes);
return new Date(base.getTime() + (alignedMinutes + turnTermMinutes) * MINUTES_TO_MS);
};
@@ -11,19 +11,14 @@ export interface TurnDaemonStreamKeys {
eventStream: string;
}
export const buildTurnDaemonStreamKeys = (
profileName: string
): TurnDaemonStreamKeys => ({
export const buildTurnDaemonStreamKeys = (profileName: string): TurnDaemonStreamKeys => ({
commandStream: `sammo:${profileName}:turn-daemon:commands`,
eventStream: `sammo:${profileName}:turn-daemon:events`,
});
interface RedisStreamClient {
xAdd(stream: string, id: string, message: Record<string, string>): Promise<string>;
xRead(
streams: { key: string; id: string },
options?: { BLOCK?: number; COUNT?: number }
): Promise<unknown>;
xRead(streams: { key: string; id: string }, options?: { BLOCK?: number; COUNT?: number }): Promise<unknown>;
}
type RedisStreamReadResponse = Array<{
@@ -68,18 +63,13 @@ const parseCommandEnvelope = (raw: string): TurnDaemonCommandEnvelope | null =>
}
};
const normalizeCommand = (
envelope: TurnDaemonCommandEnvelope
): TurnDaemonCommand | null => {
const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonCommand | null => {
const command = envelope.command as TurnDaemonCommand & {
requestId?: string;
};
switch (command.type) {
case 'troopJoin': {
if (
typeof command.generalId !== 'number' ||
typeof command.troopId !== 'number'
) {
if (typeof command.generalId !== 'number' || typeof command.troopId !== 'number') {
return null;
}
return {
@@ -100,10 +90,7 @@ const normalizeCommand = (
};
}
case 'getStatus': {
const requestId =
typeof command.requestId === 'string'
? command.requestId
: envelope.requestId;
const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId;
return { type: 'getStatus', requestId };
}
case 'run':
@@ -116,9 +103,7 @@ const normalizeCommand = (
}
};
export class RedisTurnDaemonCommandStream
implements TurnDaemonControlQueue, TurnDaemonCommandResponder
{
export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
private readonly client: RedisStreamClient;
private readonly keys: TurnDaemonStreamKeys;
private readonly localQueue: TurnDaemonCommand[] = [];
@@ -151,10 +136,7 @@ export class RedisTurnDaemonCommandStream
return this.localQueue.shift() ?? null;
}
const blockMs =
deadlineMs === null
? 0
: Math.max(0, deadlineMs - Date.now());
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now());
if (deadlineMs !== null && blockMs === 0) {
return null;
}
@@ -174,24 +156,15 @@ export class RedisTurnDaemonCommandStream
return this.localQueue.length;
}
async publishStatus(
requestId: string,
status: TurnDaemonStatus
): Promise<void> {
async publishStatus(requestId: string, status: TurnDaemonStatus): Promise<void> {
await this.publishEvent({ type: 'status', requestId, status }, requestId);
}
async publishCommandResult(
requestId: string,
result: TurnDaemonCommandResult
): Promise<void> {
async publishCommandResult(requestId: string, result: TurnDaemonCommandResult): Promise<void> {
await this.publishEvent({ type: 'commandResult', result }, requestId);
}
private async publishEvent(
event: TurnDaemonEvent,
requestId?: string
): Promise<void> {
private async publishEvent(event: TurnDaemonEvent, requestId?: string): Promise<void> {
const envelope: TurnDaemonEventEnvelope = {
requestId,
sentAt: new Date().toISOString(),
@@ -185,9 +185,10 @@ export class TurnDaemonLifecycle {
const nextGeneralTurnTime = await this.stateStore.loadNextGeneralTurnTime();
const nextTickTime = this.getNextTickTime(lastTurnTime);
// 가장 빠른 장수 턴과 현재 틱 경계 중 먼저 오는 시각을 선택한다.
const nextTurnTime = nextGeneralTurnTime && nextGeneralTurnTime.getTime() <= nextTickTime.getTime()
? nextGeneralTurnTime
: nextTickTime;
const nextTurnTime =
nextGeneralTurnTime && nextGeneralTurnTime.getTime() <= nextTickTime.getTime()
? nextGeneralTurnTime
: nextTickTime;
this.status.nextTurnTime = nextTurnTime.toISOString();
return nextTurnTime;
@@ -228,10 +229,7 @@ export class TurnDaemonLifecycle {
return;
case 'getStatus': {
if (command.requestId) {
await this.commandResponder?.publishStatus(
command.requestId,
this.getStatus()
);
await this.commandResponder?.publishStatus(command.requestId, this.getStatus());
}
return;
}
@@ -277,9 +275,7 @@ export class TurnDaemonLifecycle {
): Promise<void> {
let result: TurnDaemonCommandResult | null = null;
try {
result = this.commandHandler
? await this.commandHandler.handle(command)
: null;
result = this.commandHandler ? await this.commandHandler.handle(command) : null;
if (!result) {
result = {
type: command.type,
@@ -290,8 +286,7 @@ export class TurnDaemonLifecycle {
} as TurnDaemonCommandResult;
}
} catch (error) {
const reason =
error instanceof Error ? error.message : 'Unknown command error.';
const reason = error instanceof Error ? error.message : 'Unknown command error.';
result = {
type: command.type,
ok: false,
@@ -302,10 +297,7 @@ export class TurnDaemonLifecycle {
}
if (this.commandResponder && command.requestId) {
await this.commandResponder.publishCommandResult(
command.requestId,
result
);
await this.commandResponder.publishCommandResult(command.requestId, result);
}
}
@@ -327,8 +319,7 @@ export class TurnDaemonLifecycle {
this.status.state = 'paused';
this.status.paused = true;
this.errorPaused = true;
this.status.lastError =
error instanceof Error ? error.message : 'Unknown turn daemon error.';
this.status.lastError = error instanceof Error ? error.message : 'Unknown turn daemon error.';
await this.hooks?.onRunError?.(error);
return;
} finally {
+1 -4
View File
@@ -24,10 +24,7 @@ export interface TurnDaemonCommandHandler {
export interface TurnDaemonCommandResponder {
publishStatus(requestId: string, status: TurnDaemonStatus): Promise<void>;
publishCommandResult(
requestId: string,
result: TurnDaemonCommandResult
): Promise<void>;
publishCommandResult(requestId: string, result: TurnDaemonCommandResult): Promise<void>;
}
export type { Clock } from '@sammo-ts/common';
+5 -19
View File
@@ -28,10 +28,7 @@ const parseEnvFile = (rawText: string): EnvMap => {
}
const key = trimmed.slice(0, index).trim();
let value = trimmed.slice(index + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
env[key] = value;
@@ -48,10 +45,7 @@ const loadEnvFile = async (envFile: string): Promise<EnvMap> => {
}
};
const applySchemaToDatabaseUrl = (
url: string,
schema: string | undefined
): string => {
const applySchemaToDatabaseUrl = (url: string, schema: string | undefined): string => {
if (!schema) {
return url;
}
@@ -64,25 +58,17 @@ const applySchemaToDatabaseUrl = (
}
};
export const resolveDatabaseUrl = async (
options?: DatabaseUrlOptions
): Promise<string> => {
export const resolveDatabaseUrl = async (options?: DatabaseUrlOptions): Promise<string> => {
const env = options?.env ?? process.env;
if (env.DATABASE_URL) {
const schema =
options?.schema ??
env.POSTGRES_SCHEMA ??
env.DATABASE_SCHEMA;
const schema = options?.schema ?? env.POSTGRES_SCHEMA ?? env.DATABASE_SCHEMA;
return applySchemaToDatabaseUrl(env.DATABASE_URL, schema);
}
const envFile = options?.envFile ?? DEFAULT_ENV_FILE;
const fileEnv = await loadEnvFile(envFile);
if (fileEnv.DATABASE_URL) {
const schema =
options?.schema ??
fileEnv.POSTGRES_SCHEMA ??
fileEnv.DATABASE_SCHEMA;
const schema = options?.schema ?? fileEnv.POSTGRES_SCHEMA ?? fileEnv.DATABASE_SCHEMA;
return applySchemaToDatabaseUrl(fileEnv.DATABASE_URL, schema);
}
+5 -20
View File
@@ -6,13 +6,7 @@ import type { MapDefinition } from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_MAP_ROOT = path.resolve(
__dirname,
'..',
'..',
'resources',
'map'
);
const DEFAULT_MAP_ROOT = path.resolve(__dirname, '..', '..', 'resources', 'map');
export interface MapLoaderOptions {
mapRoot?: string;
@@ -24,28 +18,19 @@ const readJsonFile = async (filePath: string): Promise<unknown> => {
return JSON.parse(raw) as unknown;
};
const resolveMapRoot = (options?: MapLoaderOptions): string =>
options?.mapRoot ?? DEFAULT_MAP_ROOT;
const resolveMapRoot = (options?: MapLoaderOptions): string => options?.mapRoot ?? DEFAULT_MAP_ROOT;
export const resolveMapDefinitionPath = (
mapName: string,
options?: MapLoaderOptions
): string => {
export const resolveMapDefinitionPath = (mapName: string, options?: MapLoaderOptions): string => {
const prefix = options?.filePrefix ?? 'map_';
return path.resolve(resolveMapRoot(options), `${prefix}${mapName}.json`);
};
export const loadMapDefinition = async (
mapPath: string
): Promise<MapDefinition> => {
export const loadMapDefinition = async (mapPath: string): Promise<MapDefinition> => {
const raw = await readJsonFile(mapPath);
return raw as MapDefinition;
};
export const loadMapDefinitionByName = async (
mapName: string,
options?: MapLoaderOptions
): Promise<MapDefinition> => {
export const loadMapDefinitionByName = async (mapName: string, options?: MapLoaderOptions): Promise<MapDefinition> => {
const mapPath = resolveMapDefinitionPath(mapName, options);
return loadMapDefinition(mapPath);
};
+7 -27
View File
@@ -11,13 +11,7 @@ import {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_SCENARIO_ROOT = path.resolve(
__dirname,
'..',
'..',
'resources',
'scenario'
);
const DEFAULT_SCENARIO_ROOT = path.resolve(__dirname, '..', '..', 'resources', 'scenario');
export interface ScenarioLoaderOptions {
scenarioRoot?: string;
@@ -29,29 +23,15 @@ const readJsonFile = async (filePath: string): Promise<unknown> => {
return JSON.parse(raw) as unknown;
};
const resolveScenarioRoot = (options?: ScenarioLoaderOptions): string =>
options?.scenarioRoot ?? DEFAULT_SCENARIO_ROOT;
const resolveScenarioRoot = (options?: ScenarioLoaderOptions): string => options?.scenarioRoot ?? DEFAULT_SCENARIO_ROOT;
export const resolveScenarioDefaultsPath = (
options?: ScenarioLoaderOptions
): string =>
path.resolve(
resolveScenarioRoot(options),
options?.defaultsFileName ?? 'default.json'
);
export const resolveScenarioDefaultsPath = (options?: ScenarioLoaderOptions): string =>
path.resolve(resolveScenarioRoot(options), options?.defaultsFileName ?? 'default.json');
export const resolveScenarioPath = (
options: ScenarioLoaderOptions | undefined,
scenarioId: number
): string =>
path.resolve(
resolveScenarioRoot(options),
`scenario_${scenarioId}.json`
);
export const resolveScenarioPath = (options: ScenarioLoaderOptions | undefined, scenarioId: number): string =>
path.resolve(resolveScenarioRoot(options), `scenario_${scenarioId}.json`);
export const loadScenarioDefaults = async (
defaultsPath: string
): Promise<ScenarioDefaults> => {
export const loadScenarioDefaults = async (defaultsPath: string): Promise<ScenarioDefaults> => {
// 기본 시나리오 파일을 읽고 정규화한다.
const raw = await readJsonFile(defaultsPath);
return parseScenarioDefaults(raw);
+12 -47
View File
@@ -1,13 +1,5 @@
import {
createGamePostgresConnector,
type InputJsonValue,
type TurnEngineEventCreateManyInput,
} from '@sammo-ts/infra';
import {
buildScenarioBootstrap,
type ScenarioBootstrapWarning,
type WorldSeedPayload,
} from '@sammo-ts/logic';
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
import { buildScenarioBootstrap, type ScenarioBootstrapWarning, type WorldSeedPayload } from '@sammo-ts/logic';
import type { MapLoaderOptions } from './mapLoader.js';
import { loadMapDefinitionByName } from './mapLoader.js';
@@ -41,20 +33,14 @@ export interface ScenarioSeedResult {
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
const resolveGeneralAge = (
startYear: number | null,
birthYear: number
): number => {
const resolveGeneralAge = (startYear: number | null, birthYear: number): number => {
if (startYear === null || birthYear <= 0) {
return 20;
}
return Math.max(startYear - birthYear, 0);
};
const buildEventRows = (
rows: unknown[],
targetOverride?: string
): TurnEngineEventCreateManyInput[] => {
const buildEventRows = (rows: unknown[], targetOverride?: string): TurnEngineEventCreateManyInput[] => {
const result: TurnEngineEventCreateManyInput[] = [];
for (const row of rows) {
@@ -90,29 +76,17 @@ const buildEventRows = (
};
// 시나리오 초기 데이터를 로드해 DB에 저장한다.
export const seedScenarioToDatabase = async (
options: ScenarioSeedOptions
): Promise<ScenarioSeedResult> => {
const scenario = await loadScenarioDefinitionById(
options.scenarioId,
options.scenarioOptions
);
const map = await loadMapDefinitionByName(
scenario.config.environment.mapName,
options.mapOptions
);
const unitSet = await loadUnitSetDefinitionByName(
scenario.config.environment.unitSet,
options.unitSetOptions
);
export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Promise<ScenarioSeedResult> => {
const scenario = await loadScenarioDefinitionById(options.scenarioId, options.scenarioOptions);
const map = await loadMapDefinitionByName(scenario.config.environment.mapName, options.mapOptions);
const unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet, options.unitSetOptions);
const { seed, warnings } = buildScenarioBootstrap({
scenario,
map,
unitSet,
options: {
includeNeutralNationInSeed:
options.includeNeutralNationInSeed ?? true,
includeNeutralNationInSeed: options.includeNeutralNationInSeed ?? true,
},
});
@@ -216,10 +190,7 @@ export const seedScenarioToDatabase = async (
affinity: general.affinity,
bornYear: general.birthYear,
deadYear: general.deathYear,
picture:
general.picture === null
? null
: String(general.picture),
picture: general.picture === null ? null : String(general.picture),
leadership: general.stats.leadership,
strength: general.stats.strength,
intel: general.stats.intelligence,
@@ -232,10 +203,7 @@ export const seedScenarioToDatabase = async (
bookCode: general.book ?? 'None',
itemCode: general.item ?? 'None',
turnTime: now,
age: resolveGeneralAge(
scenario.startYear ?? null,
general.birthYear
),
age: resolveGeneralAge(scenario.startYear ?? null, general.birthYear),
personalCode: general.personality ?? 'None',
specialCode: general.special ?? 'None',
special2Code: general.specialWar ?? 'None',
@@ -305,10 +273,7 @@ export const seedScenarioToDatabase = async (
});
}
const eventRows = [
...buildEventRows(seed.events),
...buildEventRows(seed.initialEvents, 'initial'),
];
const eventRows = [...buildEventRows(seed.events), ...buildEventRows(seed.initialEvents, 'initial')];
if (eventRows.length > 0) {
await prisma.event.createMany({
data: eventRows,
+4 -17
View File
@@ -6,13 +6,7 @@ import { parseUnitSetDefinition, type UnitSetDefinition } from '@sammo-ts/logic'
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_UNIT_SET_ROOT = path.resolve(
__dirname,
'..',
'..',
'resources',
'unitset'
);
const DEFAULT_UNIT_SET_ROOT = path.resolve(__dirname, '..', '..', 'resources', 'unitset');
export interface UnitSetLoaderOptions {
unitSetRoot?: string;
@@ -24,20 +18,14 @@ const readJsonFile = async (filePath: string): Promise<unknown> => {
return JSON.parse(raw) as unknown;
};
const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string =>
options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT;
const resolveUnitSetRoot = (options?: UnitSetLoaderOptions): string => options?.unitSetRoot ?? DEFAULT_UNIT_SET_ROOT;
export const resolveUnitSetDefinitionPath = (
unitSetName: string,
options?: UnitSetLoaderOptions
): string => {
export const resolveUnitSetDefinitionPath = (unitSetName: string, options?: UnitSetLoaderOptions): string => {
const prefix = options?.filePrefix ?? 'unitset_';
return path.resolve(resolveUnitSetRoot(options), `${prefix}${unitSetName}.json`);
};
export const loadUnitSetDefinition = async (
unitSetPath: string
): Promise<UnitSetDefinition> => {
export const loadUnitSetDefinition = async (unitSetPath: string): Promise<UnitSetDefinition> => {
const raw = await readJsonFile(unitSetPath);
return parseUnitSetDefinition(raw);
};
@@ -49,4 +37,3 @@ export const loadUnitSetDefinitionByName = async (
const unitSetPath = resolveUnitSetDefinitionPath(unitSetName, options);
return loadUnitSetDefinition(unitSetPath);
};
+11 -33
View File
@@ -49,10 +49,7 @@ const parseBoolean = (value: string | undefined): boolean | undefined => {
return undefined;
};
const buildBudgetOverride = (
env: NodeJS.ProcessEnv,
override?: Partial<TurnRunBudget>
): TurnRunBudget | undefined => {
const buildBudgetOverride = (env: NodeJS.ProcessEnv, override?: Partial<TurnRunBudget>): TurnRunBudget | undefined => {
const budgetOverride: Partial<TurnRunBudget> = {
budgetMs: parseNumber(env.TURN_BUDGET_MS),
maxGenerals: parseNumber(env.TURN_MAX_GENERALS),
@@ -60,29 +57,19 @@ const buildBudgetOverride = (
...override,
};
const hasOverride = Object.values(budgetOverride).some(
(value) => value !== undefined
);
const hasOverride = Object.values(budgetOverride).some((value) => value !== undefined);
if (!hasOverride) {
return undefined;
}
return { ...DEFAULT_BUDGET, ...budgetOverride };
};
export const runTurnDaemonCli = async (
options: TurnDaemonCliOptions = {}
): Promise<void> => {
export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Promise<void> => {
const env = options.env ?? process.env;
const profile =
options.profile ?? env.TURN_PROFILE ?? env.PROFILE ?? 'hwe';
const profile = options.profile ?? env.TURN_PROFILE ?? env.PROFILE ?? 'hwe';
const scenario = options.scenario ?? env.TURN_SCENARIO ?? env.SCENARIO;
const profileName =
options.profileName ??
env.TURN_PROFILE_NAME ??
(scenario ? `${profile}:${scenario}` : profile);
const databaseUrl =
options.databaseUrl ??
(await resolveDatabaseUrl({ env, schema: profile }));
const profileName = options.profileName ?? env.TURN_PROFILE_NAME ?? (scenario ? `${profile}:${scenario}` : profile);
const databaseUrl = options.databaseUrl ?? (await resolveDatabaseUrl({ env, schema: profile }));
const gatewayDatabaseUrl =
options.gatewayDatabaseUrl ??
env.GATEWAY_DATABASE_URL ??
@@ -91,15 +78,10 @@ export const runTurnDaemonCli = async (
schema: env.GATEWAY_DB_SCHEMA ?? 'public',
}));
const budget = buildBudgetOverride(env, options.budget);
const tickMinutes =
options.tickMinutes ?? parseNumber(env.TURN_TICK_MINUTES);
const enableDatabaseFlush =
options.enableDatabaseFlush ??
parseBoolean(env.TURN_FLUSH_DB) ??
true;
const tickMinutes = options.tickMinutes ?? parseNumber(env.TURN_TICK_MINUTES);
const enableDatabaseFlush = options.enableDatabaseFlush ?? parseBoolean(env.TURN_FLUSH_DB) ?? true;
const pauseGateIntervalMs = parseNumber(env.TURN_PAUSE_GATE_MS);
const adminActionIntervalMs =
options.adminActionIntervalMs ?? parseNumber(env.TURN_ADMIN_ACTION_MS);
const adminActionIntervalMs = options.adminActionIntervalMs ?? parseNumber(env.TURN_ADMIN_ACTION_MS);
const runtime = await createTurnDaemonRuntime({
profile,
@@ -137,12 +119,8 @@ export const runTurnDaemonCli = async (
process.on('SIGINT', () => void stop('SIGINT'));
process.on('SIGTERM', () => void stop('SIGTERM'));
const activeTickMinutes =
tickMinutes ??
Math.max(1, Math.round(runtime.world.getState().tickSeconds / 60));
console.info(
`[turn-daemon] started profile=${profile} tickMinutes=${activeTickMinutes}`
);
const activeTickMinutes = tickMinutes ?? Math.max(1, Math.round(runtime.world.getState().tickSeconds / 60));
console.info(`[turn-daemon] started profile=${profile} tickMinutes=${activeTickMinutes}`);
try {
await runtime.lifecycle.start();
+9 -33
View File
@@ -26,13 +26,9 @@ export interface DatabaseTurnHooks {
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
const toCode = (value: string | null | undefined): string =>
value && value !== 'None' ? value : 'None';
const toCode = (value: string | null | undefined): string => (value && value !== 'None' ? value : 'None');
const readMetaNumber = (
meta: Record<string, unknown>,
key: string
): number | null => {
const readMetaNumber = (meta: Record<string, unknown>, key: string): number | null => {
const value = meta[key];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
};
@@ -180,9 +176,7 @@ const buildTroopCreate = (
});
const buildDiplomacyCreate = (
entry: ReturnType<
InMemoryTurnWorld['consumeDirtyState']
>['diplomacy'][number]
entry: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['diplomacy'][number]
): TurnEngineDiplomacyCreateManyInput => ({
srcNationId: entry.fromNationId,
destNationId: entry.toNationId,
@@ -192,9 +186,7 @@ const buildDiplomacyCreate = (
});
const buildDiplomacyUpdate = (
entry: ReturnType<
InMemoryTurnWorld['consumeDirtyState']
>['diplomacy'][number]
entry: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['diplomacy'][number]
): TurnEngineDiplomacyUpdateInput => ({
stateCode: entry.state,
term: entry.term,
@@ -267,16 +259,10 @@ export const createDatabaseTurnHooks = async (
data: worldStateUpdate,
});
const createdIds = new Set(
createdGenerals.map((general) => general.id)
);
const createdTroopIds = new Set(
createdTroops.map((troop) => troop.id)
);
const createdIds = new Set(createdGenerals.map((general) => general.id));
const createdTroopIds = new Set(createdTroops.map((troop) => troop.id));
const createdDiplomacyKeys = new Set(
createdDiplomacy.map(
(entry) => `${entry.fromNationId}:${entry.toNationId}`
)
createdDiplomacy.map((entry) => `${entry.fromNationId}:${entry.toNationId}`)
);
if (createdGenerals.length > 0) {
@@ -336,12 +322,7 @@ export const createDatabaseTurnHooks = async (
})
),
...diplomacy
.filter(
(entry) =>
!createdDiplomacyKeys.has(
`${entry.fromNationId}:${entry.toNationId}`
)
)
.filter((entry) => !createdDiplomacyKeys.has(`${entry.fromNationId}:${entry.toNationId}`))
.map((entry) =>
prisma.diplomacy.update({
where: {
@@ -363,12 +344,7 @@ export const createDatabaseTurnHooks = async (
};
const payload = logs
.map((entry) => buildLogCreateData(entry, logContext))
.filter(
(
entry
): entry is TurnEngineLogEntryCreateManyInput =>
Boolean(entry)
);
.filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry));
if (payload.length > 0) {
await prisma.logEntry.createMany({
data: payload,
@@ -25,10 +25,7 @@ export interface GatewayAdminActionConsumerOptions {
profileName: string;
pollIntervalMs?: number;
handler: (action: GatewayAdminActionRecord) => Promise<GatewayAdminActionResult>;
onActionApplied?: (
action: GatewayAdminActionRecord,
result: GatewayAdminActionResult
) => Promise<void>;
onActionApplied?: (action: GatewayAdminActionRecord, result: GatewayAdminActionResult) => Promise<void>;
}
export interface GatewayAdminActionConsumer {
@@ -41,8 +38,7 @@ const DEFAULT_POLL_MS = 5000;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const normalizeMeta = (value: unknown): Record<string, unknown> =>
isRecord(value) ? value : {};
const normalizeMeta = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const normalizeStatus = (value: unknown): GatewayAdminActionStatus | null => {
if (typeof value === 'string') {
@@ -52,12 +48,7 @@ const normalizeStatus = (value: unknown): GatewayAdminActionStatus | null => {
};
const buildActionKey = (action: GatewayAdminActionRecord): string =>
[
action.action ?? '',
action.requestedAt ?? '',
action.scheduledAt ?? '',
action.reason ?? '',
].join('|');
[action.action ?? '', action.requestedAt ?? '', action.scheduledAt ?? '', action.reason ?? ''].join('|');
export const createGatewayAdminActionConsumer = async (
options: GatewayAdminActionConsumerOptions
@@ -84,9 +75,7 @@ export const createGatewayAdminActionConsumer = async (
return;
}
const meta = normalizeMeta(profile.meta);
const rawActions = Array.isArray(meta.adminActions)
? meta.adminActions
: [];
const rawActions = Array.isArray(meta.adminActions) ? meta.adminActions : [];
if (!rawActions.length) {
return;
}
@@ -106,10 +95,7 @@ export const createGatewayAdminActionConsumer = async (
return;
}
const updates = new Map<
string,
{ status: GatewayAdminActionStatus; detail?: string; handledAt: string }
>();
const updates = new Map<string, { status: GatewayAdminActionStatus; detail?: string; handledAt: string }>();
const appliedActions: Array<{
action: GatewayAdminActionRecord;
result: GatewayAdminActionResult;
@@ -137,8 +123,7 @@ export const createGatewayAdminActionConsumer = async (
action,
result: {
status: 'FAILED',
detail:
error instanceof Error ? error.message : String(error),
detail: error instanceof Error ? error.message : String(error),
},
});
}
@@ -192,10 +177,7 @@ export const createGatewayAdminActionConsumer = async (
if (timer) {
return;
}
timer = setInterval(
() => void pollOnce(),
options.pollIntervalMs ?? DEFAULT_POLL_MS
);
timer = setInterval(() => void pollOnce(), options.pollIntervalMs ?? DEFAULT_POLL_MS);
void pollOnce();
};
+3 -11
View File
@@ -15,12 +15,9 @@ export interface GatewayProfileGate {
const DEFAULT_CACHE_MS = 2000;
const isRunningStatus = (status: string | null | undefined): boolean =>
status === 'RUNNING';
const isRunningStatus = (status: string | null | undefined): boolean => status === 'RUNNING';
export const createGatewayProfileGate = async (
options: GatewayProfileGateOptions
): Promise<GatewayProfileGate> => {
export const createGatewayProfileGate = async (options: GatewayProfileGateOptions): Promise<GatewayProfileGate> => {
const connector = createGatewayPostgresConnector({
url: options.gatewayDatabaseUrl ?? options.databaseUrl,
});
@@ -55,12 +52,7 @@ export const createGatewayProfileGate = async (
return cachedPause;
},
async markPaused(error?: unknown): Promise<void> {
const message =
error instanceof Error
? error.message
: error
? String(error)
: null;
const message = error instanceof Error ? error.message : error ? String(error) : null;
try {
await prisma.gatewayProfile.update({
where: { profileName: options.profileName },
@@ -1,9 +1,4 @@
import type {
TurnCheckpoint,
TurnProcessor,
TurnRunBudget,
TurnRunResult,
} from '../lifecycle/types.js';
import type { TurnCheckpoint, TurnProcessor, TurnRunBudget, TurnRunResult } from '../lifecycle/types.js';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import type { TurnGeneral } from './types.js';
@@ -13,10 +8,7 @@ export interface InMemoryTurnProcessorOptions {
beforeExecuteGeneral?: (general: TurnGeneral) => Promise<void>;
}
const resolveTickMinutes = (
world: InMemoryTurnWorld,
override?: number
): number => {
const resolveTickMinutes = (world: InMemoryTurnWorld, override?: number): number => {
if (override !== undefined) {
return Math.max(1, override);
}
@@ -28,9 +20,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
// 인메모리 월드로 턴을 실행하고 월/연 갱신까지 처리한다.
private readonly world: InMemoryTurnWorld;
private readonly tickMinutes: number;
private readonly beforeExecuteGeneral?: (
general: TurnGeneral
) => Promise<void>;
private readonly beforeExecuteGeneral?: (general: TurnGeneral) => Promise<void>;
constructor(world: InMemoryTurnWorld, options: InMemoryTurnProcessorOptions = {}) {
this.world = world;
@@ -38,11 +28,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
this.beforeExecuteGeneral = options.beforeExecuteGeneral;
}
async run(
targetTime: Date,
budget: TurnRunBudget,
checkpoint?: TurnCheckpoint
): Promise<TurnRunResult> {
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;
@@ -77,10 +63,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
}
if (!partial) {
let nextTickTime = getNextTickTime(
this.world.getState().lastTurnTime,
this.tickMinutes
);
let nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes);
while (nextTickTime.getTime() <= targetTime.getTime()) {
if (processedTurns >= budget.catchUpCap || isBudgetExpired()) {
partial = true;
@@ -88,10 +71,7 @@ export class InMemoryTurnProcessor implements TurnProcessor {
}
this.world.advanceMonth(nextTickTime);
processedTurns += 1;
nextTickTime = getNextTickTime(
this.world.getState().lastTurnTime,
this.tickMinutes
);
nextTickTime = getNextTickTime(this.world.getState().lastTurnTime, this.tickMinutes);
}
}
+22 -91
View File
@@ -1,20 +1,8 @@
import type {
City,
LogEntryDraft,
Nation,
ScenarioConfig,
Troop,
TurnSchedule,
} from '@sammo-ts/logic';
import type { City, LogEntryDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic';
import { getNextTurnAt } from '@sammo-ts/logic';
import type { TurnCheckpoint } from '../lifecycle/types.js';
import type {
TurnDiplomacy,
TurnGeneral,
TurnWorldSnapshot,
TurnWorldState,
} from './types.js';
import type { TurnDiplomacy, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from './types.js';
import {
applyDiplomacyPatch as applyDiplomacyPatchToEntry,
buildDefaultDiplomacy,
@@ -87,10 +75,7 @@ const compareTurnOrder = (left: TurnGeneral, right: TurnGeneral): number => {
return left.id - right.id;
};
const shouldProcessByCheckpoint = (
general: TurnGeneral,
checkpoint?: TurnCheckpoint
): boolean => {
const shouldProcessByCheckpoint = (general: TurnGeneral, checkpoint?: TurnCheckpoint): boolean => {
if (!checkpoint) {
return true;
}
@@ -108,19 +93,13 @@ const shouldProcessByCheckpoint = (
return general.id > checkpoint.generalId;
};
const mergeStats = (
base: TurnGeneral['stats'],
patch: Partial<TurnGeneral['stats']>
): TurnGeneral['stats'] => ({
const mergeStats = (base: TurnGeneral['stats'], patch: Partial<TurnGeneral['stats']>): TurnGeneral['stats'] => ({
leadership: patch.leadership ?? base.leadership,
strength: patch.strength ?? base.strength,
intelligence: patch.intelligence ?? base.intelligence,
});
const mergeRole = (
base: TurnGeneral['role'],
patch: Partial<TurnGeneral['role']>
): TurnGeneral['role'] => ({
const mergeRole = (base: TurnGeneral['role'], patch: Partial<TurnGeneral['role']>): TurnGeneral['role'] => ({
...base,
...patch,
items: {
@@ -141,17 +120,12 @@ const mergeTriggerState = (
meta: { ...base.meta, ...(patch.meta ?? {}) },
});
const applyGeneralPatch = (
base: TurnGeneral,
patch: Partial<TurnGeneral>
): TurnGeneral => ({
const applyGeneralPatch = (base: TurnGeneral, patch: Partial<TurnGeneral>): TurnGeneral => ({
...base,
...patch,
stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats,
role: patch.role ? mergeRole(base.role, patch.role) : base.role,
triggerState: patch.triggerState
? mergeTriggerState(base.triggerState, patch.triggerState)
: base.triggerState,
triggerState: patch.triggerState ? mergeTriggerState(base.triggerState, patch.triggerState) : base.triggerState,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
});
@@ -197,11 +171,7 @@ export class InMemoryTurnWorld {
private checkpoint?: TurnCheckpoint;
private state: TurnWorldState;
constructor(
state: TurnWorldState,
snapshot: TurnWorldSnapshot,
options: InMemoryTurnWorldOptions
) {
constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) {
this.state = { ...state };
this.scenarioConfig = snapshot.scenarioConfig;
this.schedule = options.schedule;
@@ -225,10 +195,7 @@ export class InMemoryTurnWorld {
this.troops.set(troop.id, { ...troop });
}
for (const entry of snapshot.diplomacy) {
const key = buildDiplomacyKey(
entry.fromNationId,
entry.toNationId
);
const key = buildDiplomacyKey(entry.fromNationId, entry.toNationId);
this.diplomacy.set(key, {
...entry,
meta: { ...entry.meta },
@@ -283,13 +250,8 @@ export class InMemoryTurnWorld {
}));
}
getDiplomacyEntry(
srcNationId: number,
destNationId: number
): TurnDiplomacy | null {
const entry = this.diplomacy.get(
buildDiplomacyKey(srcNationId, destNationId)
);
getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null {
const entry = this.diplomacy.get(buildDiplomacyKey(srcNationId, destNationId));
if (!entry) {
return null;
}
@@ -306,10 +268,7 @@ export class InMemoryTurnWorld {
}));
}
updateGeneral(
id: number,
patch: Partial<TurnGeneral>
): TurnGeneral | null {
updateGeneral(id: number, patch: Partial<TurnGeneral>): TurnGeneral | null {
const target = this.generals.get(id);
if (!target) {
return null;
@@ -375,16 +334,10 @@ export class InMemoryTurnWorld {
return true;
}
applyDiplomacyPatch(input: {
srcNationId: number;
destNationId: number;
patch: DiplomacyPatch;
}): void {
applyDiplomacyPatch(input: { srcNationId: number; destNationId: number; patch: DiplomacyPatch }): void {
const key = buildDiplomacyKey(input.srcNationId, input.destNationId);
const existed = this.diplomacy.has(key);
const base =
this.diplomacy.get(key) ??
buildDefaultDiplomacy(input.srcNationId, input.destNationId);
const base = this.diplomacy.get(key) ?? buildDefaultDiplomacy(input.srcNationId, input.destNationId);
const next = applyDiplomacyPatchToEntry(base, input.patch);
this.diplomacy.set(key, next);
this.dirtyDiplomacyKeys.add(key);
@@ -426,10 +379,7 @@ export class InMemoryTurnWorld {
return next ? new Date(next.turnTime.getTime()) : null;
}
listDueGenerals(
targetTime: Date,
checkpoint?: TurnCheckpoint
): TurnGeneral[] {
listDueGenerals(targetTime: Date, checkpoint?: TurnCheckpoint): TurnGeneral[] {
const targetMs = targetTime.getTime();
const due = Array.from(this.generals.values()).filter((general) => {
if (!shouldProcessByCheckpoint(general, checkpoint)) {
@@ -443,8 +393,7 @@ export class InMemoryTurnWorld {
executeGeneralTurn(general: TurnGeneral): Date {
const city = this.cities.get(general.cityId);
const nation =
general.nationId > 0 ? this.nations.get(general.nationId) ?? null : null;
const nation = general.nationId > 0 ? (this.nations.get(general.nationId) ?? null) : null;
const result = this.generalTurnHandler.execute({
general,
@@ -454,8 +403,7 @@ export class InMemoryTurnWorld {
schedule: this.schedule,
});
const nextTurnAt =
result.nextTurnAt ?? getNextTurnAt(general.turnTime, this.schedule);
const nextTurnAt = result.nextTurnAt ?? getNextTurnAt(general.turnTime, this.schedule);
const nextGeneral = {
...(result.general ?? general),
turnTime: nextTurnAt,
@@ -480,10 +428,7 @@ export class InMemoryTurnWorld {
if (!target) {
continue;
}
this.generals.set(
patch.id,
applyGeneralPatch(target, patch.patch)
);
this.generals.set(patch.id, applyGeneralPatch(target, patch.patch));
this.dirtyGeneralIds.add(patch.id);
}
for (const patch of result.patches.cities) {
@@ -499,10 +444,7 @@ export class InMemoryTurnWorld {
if (!target) {
continue;
}
this.nations.set(
patch.id,
applyNationPatch(target, patch.patch)
);
this.nations.set(patch.id, applyNationPatch(target, patch.patch));
this.dirtyNationIds.add(patch.id);
}
for (const patch of result.patches.troops) {
@@ -682,22 +624,11 @@ export class InMemoryTurnWorld {
generalCounts.set(nationId, (generalCounts.get(nationId) ?? 0) + 1);
}
const updated = processDiplomacyMonth(
this.listDiplomacy(),
generalCounts
);
const updated = processDiplomacyMonth(this.listDiplomacy(), generalCounts);
for (const entry of updated) {
const key = buildDiplomacyKey(
entry.fromNationId,
entry.toNationId
);
const key = buildDiplomacyKey(entry.fromNationId, entry.toNationId);
const prev = this.diplomacy.get(key);
if (
!prev ||
prev.state !== entry.state ||
prev.term !== entry.term ||
prev.dead !== entry.dead
) {
if (!prev || prev.state !== entry.state || prev.term !== entry.term || prev.dead !== entry.dead) {
this.diplomacy.set(key, entry);
this.dirtyDiplomacyKeys.add(key);
if (!prev) {
@@ -23,8 +23,7 @@ const DEFAULT_CREW_TYPE_ID = 1100;
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> =>
isRecord(value) ? value : {};
const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const normalizeCode = (value: string | null | undefined): string | null => {
if (!value || value === 'None') {
@@ -33,11 +32,7 @@ const normalizeCode = (value: string | null | undefined): string | null => {
return value;
};
const resolveNumber = (
source: Record<string, unknown>,
keys: string[],
fallback: number
): number => {
const resolveNumber = (source: Record<string, unknown>, keys: string[], fallback: number): number => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -47,10 +42,7 @@ const resolveNumber = (
return fallback;
};
const resolveOptionalString = (
source: Record<string, unknown>,
keys: string[]
): string | null => {
const resolveOptionalString = (source: Record<string, unknown>, keys: string[]): string | null => {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string') {
@@ -60,82 +52,36 @@ const resolveOptionalString = (
return null;
};
export const buildCommandEnv = (
config: ScenarioConfig,
unitSet?: UnitSetDefinition
): TurnCommandEnv => {
export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinition): TurnCommandEnv => {
const constValues = asRecord(config.const);
return {
develCost: resolveNumber(
constValues,
['develCost', 'develcost', 'develrate'],
0
),
develCost: resolveNumber(constValues, ['develCost', 'develcost', 'develrate'], 0),
trainDelta: resolveNumber(constValues, ['trainDelta'], 0),
atmosDelta: resolveNumber(constValues, ['atmosDelta'], 0),
maxTrainByCommand: resolveNumber(constValues, ['maxTrainByCommand'], 0),
maxAtmosByCommand: resolveNumber(
constValues,
['maxAtmosByCommand'],
0
),
sabotageDefaultProb: resolveNumber(
constValues,
['sabotageDefaultProb'],
0
),
sabotageProbCoefByStat: resolveNumber(
constValues,
['sabotageProbCoefByStat'],
0
),
sabotageDefenceCoefByGeneralCount: resolveNumber(
constValues,
['sabotageDefenceCoefByGeneralCount'],
0
),
maxAtmosByCommand: resolveNumber(constValues, ['maxAtmosByCommand'], 0),
sabotageDefaultProb: resolveNumber(constValues, ['sabotageDefaultProb'], 0),
sabotageProbCoefByStat: resolveNumber(constValues, ['sabotageProbCoefByStat'], 0),
sabotageDefenceCoefByGeneralCount: resolveNumber(constValues, ['sabotageDefenceCoefByGeneralCount'], 0),
sabotageDamageMin: resolveNumber(constValues, ['sabotageDamageMin'], 0),
sabotageDamageMax: resolveNumber(constValues, ['sabotageDamageMax'], 0),
openingPartYear: resolveNumber(constValues, ['openingPartYear'], 0),
maxGeneral: resolveNumber(
constValues,
['defaultMaxGeneral', 'maxGeneral'],
0
),
defaultNpcGold: resolveNumber(
constValues,
['defaultNpcGold', 'defaultGold'],
DEFAULT_GENERAL_GOLD
),
defaultNpcRice: resolveNumber(
constValues,
['defaultNpcRice', 'defaultRice'],
DEFAULT_GENERAL_RICE
),
maxGeneral: resolveNumber(constValues, ['defaultMaxGeneral', 'maxGeneral'], 0),
defaultNpcGold: resolveNumber(constValues, ['defaultNpcGold', 'defaultGold'], DEFAULT_GENERAL_GOLD),
defaultNpcRice: resolveNumber(constValues, ['defaultNpcRice', 'defaultRice'], DEFAULT_GENERAL_RICE),
defaultCrewTypeId: resolveNumber(
constValues,
['defaultCrewTypeId'],
unitSet?.defaultCrewTypeId ?? DEFAULT_CREW_TYPE_ID
),
defaultSpecialDomestic: resolveOptionalString(
constValues,
['defaultSpecialDomestic']
),
defaultSpecialDomestic: resolveOptionalString(constValues, ['defaultSpecialDomestic']),
defaultSpecialWar: resolveOptionalString(constValues, ['defaultSpecialWar']),
initialNationGenLimit: resolveNumber(
constValues,
['initialNationGenLimit'],
0
),
initialNationGenLimit: resolveNumber(constValues, ['initialNationGenLimit'], 0),
maxTechLevel: resolveNumber(constValues, ['maxTechLevel'], 0),
baseGold: resolveNumber(constValues, ['baseGold', 'basegold'], 0),
baseRice: resolveNumber(constValues, ['baseRice', 'baserice'], 0),
maxResourceActionAmount: resolveNumber(
constValues,
['maxResourceActionAmount'],
0
),
maxResourceActionAmount: resolveNumber(constValues, ['maxResourceActionAmount'], 0),
};
};
@@ -165,34 +111,18 @@ export const buildReservedTurnDefinitions = async (options: {
const itemModules = await loadItemModules([...ITEM_KEYS]);
const itemRegistry = createItemModuleRegistry(itemModules);
const itemActionModules = createItemActionModules(itemRegistry);
options.env.generalActionModules = [
...(options.env.generalActionModules ?? []),
...itemActionModules.general,
];
options.env.warActionModules = [
...(options.env.warActionModules ?? []),
...itemActionModules.war,
];
options.env.generalActionModules = [...(options.env.generalActionModules ?? []), ...itemActionModules.general];
options.env.warActionModules = [...(options.env.warActionModules ?? []), ...itemActionModules.war];
const generalSpecs = await loadGeneralTurnCommandSpecs(
options.commandProfile.general
);
const nationSpecs = await loadNationTurnCommandSpecs(
options.commandProfile.nation
);
const generalSpecs = await loadGeneralTurnCommandSpecs(options.commandProfile.general);
const nationSpecs = await loadNationTurnCommandSpecs(options.commandProfile.nation);
const general = new Map(
generalSpecs.map((spec) => [spec.key, spec.createDefinition(options.env)])
);
const nation = new Map(
nationSpecs.map((spec) => [spec.key, spec.createDefinition(options.env)])
);
const general = new Map(generalSpecs.map((spec) => [spec.key, spec.createDefinition(options.env)]));
const nation = new Map(nationSpecs.map((spec) => [spec.key, spec.createDefinition(options.env)]));
await ensureGeneralFallback(general, options.defaultActionKey, options.env);
if (!nation.has(options.defaultActionKey)) {
const [spec] = await loadNationTurnCommandSpecs([
options.defaultActionKey,
]);
const [spec] = await loadNationTurnCommandSpecs([options.defaultActionKey]);
if (spec) {
nation.set(spec.key, spec.createDefinition(options.env));
}
+62 -158
View File
@@ -36,10 +36,7 @@ import {
buildDiplomacyKey,
type DiplomacyPatch,
} from '@sammo-ts/logic';
import {
buildCommandEnv,
buildReservedTurnDefinitions,
} from './reservedTurnCommands.js';
import { buildCommandEnv, buildReservedTurnDefinitions } from './reservedTurnCommands.js';
import { buildActionContext } from './reservedTurnActionContext.js';
const DEFAULT_ACTION = '휴식';
@@ -47,22 +44,15 @@ const DEFAULT_ACTION = '휴식';
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const asRecord = (value: unknown): Record<string, unknown> =>
isRecord(value) ? value : {};
const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const resolveConstraintEnv = (
world: TurnWorldState,
scenarioMeta: ScenarioMeta | undefined,
openingPartYear: number
): Record<string, unknown> => {
const startYear =
typeof scenarioMeta?.startYear === 'number'
? scenarioMeta.startYear
: undefined;
const relYear =
typeof startYear === 'number'
? world.currentYear - startYear
: undefined;
const startYear = typeof scenarioMeta?.startYear === 'number' ? scenarioMeta.startYear : undefined;
const relYear = typeof startYear === 'number' ? world.currentYear - startYear : undefined;
return {
currentYear: world.currentYear,
@@ -83,11 +73,7 @@ const buildSeedBase = (world: TurnWorldState): string => {
const serializeSeed = (...values: Array<string | number>): string =>
values
.map((value) =>
typeof value === 'string'
? `str(${value.length},${value})`
: `int(${Math.floor(value)})`
)
.map((value) => (typeof value === 'string' ? `str(${value.length},${value})` : `int(${Math.floor(value)})`))
.join('|');
class DeterministicRandom {
@@ -121,10 +107,7 @@ type WorldView = {
getCityById(id: number): City | null;
getNationById(id: number): Nation | null;
getTroopById(id: number): Troop | null;
getDiplomacyEntry(
srcNationId: number,
destNationId: number
): TurnDiplomacy | null;
getDiplomacyEntry(srcNationId: number, destNationId: number): TurnDiplomacy | null;
listGenerals(): TurnGeneral[];
listCities(): City[];
listNations(): Nation[];
@@ -132,19 +115,13 @@ type WorldView = {
listDiplomacy(): TurnDiplomacy[];
};
const mergeStats = (
base: TurnGeneral['stats'],
patch: Partial<TurnGeneral['stats']>
): TurnGeneral['stats'] => ({
const mergeStats = (base: TurnGeneral['stats'], patch: Partial<TurnGeneral['stats']>): TurnGeneral['stats'] => ({
leadership: patch.leadership ?? base.leadership,
strength: patch.strength ?? base.strength,
intelligence: patch.intelligence ?? base.intelligence,
});
const mergeRole = (
base: TurnGeneral['role'],
patch: Partial<TurnGeneral['role']>
): TurnGeneral['role'] => ({
const mergeRole = (base: TurnGeneral['role'], patch: Partial<TurnGeneral['role']>): TurnGeneral['role'] => ({
...base,
...patch,
items: {
@@ -165,17 +142,12 @@ const mergeTriggerState = (
meta: { ...base.meta, ...(patch.meta ?? {}) },
});
const applyGeneralPatch = (
base: TurnGeneral,
patch: Partial<TurnGeneral>
): TurnGeneral => ({
const applyGeneralPatch = (base: TurnGeneral, patch: Partial<TurnGeneral>): TurnGeneral => ({
...base,
...patch,
stats: patch.stats ? mergeStats(base.stats, patch.stats) : base.stats,
role: patch.role ? mergeRole(base.role, patch.role) : base.role,
triggerState: patch.triggerState
? mergeTriggerState(base.triggerState, patch.triggerState)
: base.triggerState,
triggerState: patch.triggerState ? mergeTriggerState(base.triggerState, patch.triggerState) : base.triggerState,
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
});
@@ -191,10 +163,7 @@ const applyNationPatch = (base: Nation, patch: Partial<Nation>): Nation => ({
meta: patch.meta ? { ...base.meta, ...patch.meta } : base.meta,
});
const mergeDiplomacyList = (
base: TurnDiplomacy[],
overrides: Map<string, TurnDiplomacy>
): TurnDiplomacy[] => {
const mergeDiplomacyList = (base: TurnDiplomacy[], overrides: Map<string, TurnDiplomacy>): TurnDiplomacy[] => {
const merged = new Map<string, TurnDiplomacy>();
for (const entry of base) {
merged.set(buildDiplomacyKey(entry.fromNationId, entry.toNationId), entry);
@@ -212,10 +181,7 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => {
const nationOverrides = new Map<number, Nation>();
const diplomacyOverrides = new Map<string, TurnDiplomacy>();
const mergeList = <T extends { id: number }>(
base: T[],
overrides: Map<number, T>
): T[] => {
const mergeList = <T extends { id: number }>(base: T[], overrides: Map<number, T>): T[] => {
const merged = new Map<number, T>();
for (const entry of base) {
merged.set(entry.id, entry);
@@ -227,16 +193,13 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => {
};
const view: WorldView = {
getGeneralById: (id) =>
generalOverrides.get(id) ?? world.getGeneralById(id),
getGeneralById: (id) => generalOverrides.get(id) ?? world.getGeneralById(id),
getCityById: (id) => cityOverrides.get(id) ?? world.getCityById(id),
getNationById: (id) =>
nationOverrides.get(id) ?? world.getNationById(id),
getNationById: (id) => nationOverrides.get(id) ?? world.getNationById(id),
getTroopById: (id) => world.getTroopById(id),
getDiplomacyEntry: (srcNationId, destNationId) =>
diplomacyOverrides.get(
buildDiplomacyKey(srcNationId, destNationId)
) ?? world.getDiplomacyEntry(srcNationId, destNationId),
diplomacyOverrides.get(buildDiplomacyKey(srcNationId, destNationId)) ??
world.getDiplomacyEntry(srcNationId, destNationId),
listGenerals: () =>
mergeList(world.listGenerals(), generalOverrides).map((general) => ({
...general,
@@ -251,10 +214,7 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => {
})),
listTroops: () => world.listTroops().map((troop) => ({ ...troop })),
listDiplomacy: () =>
mergeDiplomacyList(
world.listDiplomacy(),
diplomacyOverrides
).map((entry) => ({
mergeDiplomacyList(world.listDiplomacy(), diplomacyOverrides).map((entry) => ({
...entry,
meta: { ...entry.meta },
})),
@@ -272,8 +232,7 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => {
nationOverrides.set(nation.id, nation);
},
applyGeneralPatch: (id: number, patch: Partial<TurnGeneral>) => {
const base =
generalOverrides.get(id) ?? world.getGeneralById(id);
const base = generalOverrides.get(id) ?? world.getGeneralById(id);
if (!base) {
return;
}
@@ -293,11 +252,7 @@ const createWorldOverlay = (world: InMemoryTurnWorld) => {
}
nationOverrides.set(id, applyNationPatch(base, patch));
},
applyDiplomacyPatch: (
srcNationId: number,
destNationId: number,
patch: DiplomacyPatch
) => {
applyDiplomacyPatch: (srcNationId: number, destNationId: number, patch: DiplomacyPatch) => {
const key = buildDiplomacyKey(srcNationId, destNationId);
const base =
diplomacyOverrides.get(key) ??
@@ -353,10 +308,7 @@ class WorldStateView implements StateView {
case 'destNation':
return this.world.getNationById(req.id);
case 'diplomacy':
return this.world.getDiplomacyEntry(
req.srcNationId,
req.destNationId
);
return this.world.getDiplomacyEntry(req.srcNationId, req.destNationId);
case 'diplomacyList':
return this.world.listDiplomacy();
case 'arg':
@@ -369,12 +321,7 @@ class WorldStateView implements StateView {
}
}
const extractArgsRecord = (value: unknown): Record<string, unknown> =>
isRecord(value) ? value : {};
const extractArgsRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});
const buildConstraintContext = (
general: TurnGeneral,
@@ -414,14 +361,12 @@ export const createReservedTurnHandler = async (options: {
commandProfile?: TurnCommandProfile;
}): Promise<GeneralTurnHandler> => {
const env = buildCommandEnv(options.scenarioConfig, options.unitSet);
const commandProfile =
options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE;
const { general: generalDefinitions, nation: nationDefinitions } =
await buildReservedTurnDefinitions({
env,
commandProfile,
defaultActionKey: DEFAULT_ACTION,
});
const commandProfile = options.commandProfile ?? DEFAULT_TURN_COMMAND_PROFILE;
const { general: generalDefinitions, nation: nationDefinitions } = await buildReservedTurnDefinitions({
env,
commandProfile,
defaultActionKey: DEFAULT_ACTION,
});
const generalFallback = generalDefinitions.get(DEFAULT_ACTION)!;
const nationFallback = nationDefinitions.get(DEFAULT_ACTION)!;
@@ -431,10 +376,7 @@ export const createReservedTurnHandler = async (options: {
commandSpec: { key: string };
actionContextBuilder?: ActionContextBuilder;
}): void => {
actionContextBuilders.set(
module.commandSpec.key,
module.actionContextBuilder ?? defaultActionContextBuilder
);
actionContextBuilders.set(module.commandSpec.key, module.actionContextBuilder ?? defaultActionContextBuilder);
};
const generalModuleLoader = new GeneralTurnCommandLoader();
const nationModuleLoader = new NationTurnCommandLoader();
@@ -455,9 +397,7 @@ export const createReservedTurnHandler = async (options: {
applyActionContextBuilder(module);
}
if (!actionContextBuilders.has(DEFAULT_ACTION)) {
applyActionContextBuilder(
await generalModuleLoader.load(DEFAULT_ACTION)
);
applyActionContextBuilder(await generalModuleLoader.load(DEFAULT_ACTION));
}
let nextGeneralId: number | null = null;
@@ -478,11 +418,7 @@ export const createReservedTurnHandler = async (options: {
const worldOverlay = worldRef ? createWorldOverlay(worldRef) : null;
const worldView = worldOverlay?.view ?? worldRef;
const baseConstraintEnv = {
...resolveConstraintEnv(
context.world,
options.scenarioMeta,
env.openingPartYear
),
...resolveConstraintEnv(context.world, options.scenarioMeta, env.openingPartYear),
...(options.map ? { map: options.map } : {}),
...(options.unitSet ? { unitSet: options.unitSet } : {}),
};
@@ -510,13 +446,9 @@ export const createReservedTurnHandler = async (options: {
command: ReservedTurnEntry,
applyNextTurnAt: boolean
): Date | undefined => {
const resolvedDefinition = resolveDefinition(
command.action,
definitionMap,
fallbackDefinition
);
const resolvedDefinition = resolveDefinition(command.action, definitionMap, fallbackDefinition);
const rawArgs = extractArgsRecord(command.args);
let parsedArgs = resolvedDefinition.parseArgs(rawArgs);
const parsedArgs = resolvedDefinition.parseArgs(rawArgs);
let definition = resolvedDefinition;
let actionArgs = parsedArgs ?? {};
let actionKey = definition.key;
@@ -540,29 +472,18 @@ export const createReservedTurnHandler = async (options: {
actionArgs as Record<string, unknown>,
actionConstraintEnv
);
const view = new WorldStateView(
worldView,
actionConstraintEnv,
actionArgs as Record<string, unknown>,
{
general: currentGeneral,
city: currentCity,
nation: currentNation,
}
);
const constraints = definition.buildConstraints(
constraintCtx,
actionArgs
);
const view = new WorldStateView(worldView, actionConstraintEnv, actionArgs as Record<string, unknown>, {
general: currentGeneral,
city: currentCity,
nation: currentNation,
});
const constraints = definition.buildConstraints(constraintCtx, actionArgs);
const result = evaluateConstraints(constraints, constraintCtx, view);
if (result.kind !== 'allow') {
definition = fallbackDefinition;
actionArgs = definition.parseArgs({}) ?? {};
actionKey = definition.key;
const reason =
result.kind === 'deny'
? result.reason
: '조건을 확인할 수 없습니다.';
const reason = result.kind === 'deny' ? result.reason : '조건을 확인할 수 없습니다.';
logs.push(createActionLog(reason));
}
@@ -585,17 +506,22 @@ export const createReservedTurnHandler = async (options: {
nation: currentNation,
rng: buildRng(actionKey),
};
let specificContext = buildActionContext(actionKey, baseContext, {
world: context.world,
scenarioConfig: options.scenarioConfig,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
worldRef: worldView,
actionArgs: actionArgsRecord,
createGeneralId,
seedBase,
}, actionContextBuilders);
let specificContext = buildActionContext(
actionKey,
baseContext,
{
world: context.world,
scenarioConfig: options.scenarioConfig,
scenarioMeta: options.scenarioMeta,
map: options.map,
unitSet: options.unitSet,
worldRef: worldView,
actionArgs: actionArgsRecord,
createGeneralId,
seedBase,
},
actionContextBuilders
);
if (!specificContext && actionKey !== fallbackDefinition.key) {
definition = fallbackDefinition;
actionArgs = definition.parseArgs({}) ?? {};
@@ -645,11 +571,7 @@ export const createReservedTurnHandler = async (options: {
destNationId: effect.destNationId,
patch: effect.patch,
});
worldOverlay?.applyDiplomacyPatch(
effect.srcNationId,
effect.destNationId,
effect.patch
);
worldOverlay?.applyDiplomacyPatch(effect.srcNationId, effect.destNationId, effect.patch);
}
}
@@ -674,10 +596,7 @@ export const createReservedTurnHandler = async (options: {
);
if (worldOverlay) {
for (const patch of resolution.patches.generals) {
worldOverlay.applyGeneralPatch(
patch.id,
patch.patch as Partial<TurnGeneral>
);
worldOverlay.applyGeneralPatch(patch.id, patch.patch as Partial<TurnGeneral>);
}
for (const patch of resolution.patches.cities) {
worldOverlay.applyCityPatch(patch.id, patch.patch);
@@ -689,8 +608,7 @@ export const createReservedTurnHandler = async (options: {
}
if (resolution.created?.generals) {
const createdGenerals =
resolution.created.generals as TurnGeneral[];
const createdGenerals = resolution.created.generals as TurnGeneral[];
created.push(...createdGenerals);
if (worldOverlay) {
for (const general of createdGenerals) {
@@ -709,23 +627,11 @@ export const createReservedTurnHandler = async (options: {
0
);
runAction(nationDefinitions, nationFallback, nationCommand, false);
options.reservedTurns.shiftNationTurns(
currentNation.id,
currentGeneral.officerLevel,
-1
);
options.reservedTurns.shiftNationTurns(currentNation.id, currentGeneral.officerLevel, -1);
}
const generalCommand = options.reservedTurns.getGeneralTurn(
currentGeneral.id,
0
);
const nextTurnAt = runAction(
generalDefinitions,
generalFallback,
generalCommand,
true
);
const generalCommand = options.reservedTurns.getGeneralTurn(currentGeneral.id, 0);
const nextTurnAt = runAction(generalDefinitions, generalFallback, generalCommand, true);
options.reservedTurns.shiftGeneralTurns(currentGeneral.id, -1);
const result: GeneralTurnResult = {
@@ -735,9 +641,7 @@ export const createReservedTurnHandler = async (options: {
nextTurnAt,
logs,
patches,
...(diplomacyPatches.length > 0
? { diplomacyPatches }
: undefined),
...(diplomacyPatches.length > 0 ? { diplomacyPatches } : undefined),
created: created.length > 0 ? { generals: created } : undefined,
};
+18 -58
View File
@@ -1,8 +1,4 @@
import {
createGamePostgresConnector,
type InputJsonValue,
type TurnEngineDatabaseClient,
} from '@sammo-ts/infra';
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineDatabaseClient } from '@sammo-ts/infra';
export interface ReservedTurnEntry {
action: string;
@@ -32,21 +28,16 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
const normalizeAction = (action: string | null | undefined): string =>
action && action.length > 0 ? action : DEFAULT_TURN_ACTION;
const normalizeArgs = (args: unknown): Record<string, unknown> =>
isRecord(args) ? args : {};
const normalizeArgs = (args: unknown): Record<string, unknown> => (isRecord(args) ? args : {});
const createDefaultEntry = (): ReservedTurnEntry => ({
action: DEFAULT_TURN_ACTION,
args: {},
});
const buildDefaultTurns = (length: number): ReservedTurnEntry[] =>
Array.from({ length }, () => createDefaultEntry());
const buildDefaultTurns = (length: number): ReservedTurnEntry[] => Array.from({ length }, () => createDefaultEntry());
const applyShift = (
turns: ReservedTurnEntry[],
amount: number
): ReservedTurnEntry[] => {
const applyShift = (turns: ReservedTurnEntry[], amount: number): ReservedTurnEntry[] => {
if (amount === 0) {
return turns.slice();
}
@@ -79,13 +70,9 @@ const buildTurnListFromRows = (
return result;
};
const buildNationKey = (nationId: number, officerLevel: number): string =>
`${nationId}:${officerLevel}`;
const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`;
type ReservedTurnDatabaseClient = Pick<
TurnEngineDatabaseClient,
'generalTurn' | 'nationTurn'
>;
type ReservedTurnDatabaseClient = Pick<TurnEngineDatabaseClient, 'generalTurn' | 'nationTurn'>;
export class InMemoryReservedTurnStore {
private readonly generalTurns = new Map<number, ReservedTurnEntry[]>();
@@ -119,10 +106,7 @@ export class InMemoryReservedTurnStore {
}
}
for (const [generalId, rows] of generalGroups.entries()) {
this.generalTurns.set(
generalId,
buildTurnListFromRows(rows, this.maxGeneralTurns)
);
this.generalTurns.set(generalId, buildTurnListFromRows(rows, this.maxGeneralTurns));
}
const nationGroups = new Map<string, typeof nationRows>();
@@ -136,10 +120,7 @@ export class InMemoryReservedTurnStore {
}
}
for (const [key, rows] of nationGroups.entries()) {
this.nationTurns.set(
key,
buildTurnListFromRows(rows, this.maxNationTurns)
);
this.nationTurns.set(key, buildTurnListFromRows(rows, this.maxNationTurns));
}
}
@@ -151,16 +132,10 @@ export class InMemoryReservedTurnStore {
where: { generalId },
orderBy: [{ turnIdx: 'asc' }],
});
this.generalTurns.set(
generalId,
buildTurnListFromRows(rows, this.maxGeneralTurns)
);
this.generalTurns.set(generalId, buildTurnListFromRows(rows, this.maxGeneralTurns));
}
async refreshNationTurns(
nationId: number,
officerLevel: number
): Promise<void> {
async refreshNationTurns(nationId: number, officerLevel: number): Promise<void> {
const key = buildNationKey(nationId, officerLevel);
if (this.dirtyNationKeys.has(key)) {
return;
@@ -169,10 +144,7 @@ export class InMemoryReservedTurnStore {
where: { nationId, officerLevel },
orderBy: [{ turnIdx: 'asc' }],
});
this.nationTurns.set(
key,
buildTurnListFromRows(rows, this.maxNationTurns)
);
this.nationTurns.set(key, buildTurnListFromRows(rows, this.maxNationTurns));
}
getGeneralTurns(generalId: number): ReservedTurnEntry[] {
@@ -185,10 +157,7 @@ export class InMemoryReservedTurnStore {
return created;
}
getNationTurns(
nationId: number,
officerLevel: number
): ReservedTurnEntry[] {
getNationTurns(nationId: number, officerLevel: number): ReservedTurnEntry[] {
const key = buildNationKey(nationId, officerLevel);
const current = this.nationTurns.get(key);
if (current) {
@@ -204,11 +173,7 @@ export class InMemoryReservedTurnStore {
return list[turnIdx] ?? createDefaultEntry();
}
getNationTurn(
nationId: number,
officerLevel: number,
turnIdx: number
): ReservedTurnEntry {
getNationTurn(nationId: number, officerLevel: number, turnIdx: number): ReservedTurnEntry {
const list = this.getNationTurns(nationId, officerLevel);
return list[turnIdx] ?? createDefaultEntry();
}
@@ -266,18 +231,13 @@ export class InMemoryReservedTurnStore {
}
}
export const createReservedTurnStore = async (
options: ReservedTurnStoreOptions
): Promise<ReservedTurnStoreHandle> => {
export const createReservedTurnStore = async (options: ReservedTurnStoreOptions): Promise<ReservedTurnStoreHandle> => {
const connector = createGamePostgresConnector({ url: options.databaseUrl });
await connector.connect();
const store = new InMemoryReservedTurnStore(
connector.prisma,
{
maxGeneralTurns: options.maxGeneralTurns ?? DEFAULT_GENERAL_TURNS,
maxNationTurns: options.maxNationTurns ?? DEFAULT_NATION_TURNS,
}
);
const store = new InMemoryReservedTurnStore(connector.prisma, {
maxGeneralTurns: options.maxGeneralTurns ?? DEFAULT_GENERAL_TURNS,
maxNationTurns: options.maxNationTurns ?? DEFAULT_NATION_TURNS,
});
await store.loadAll();
return {
store,
+4 -16
View File
@@ -2,21 +2,12 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
DEFAULT_TURN_COMMAND_PROFILE,
parseTurnCommandProfile,
type TurnCommandProfile,
} from '@sammo-ts/logic';
import { DEFAULT_TURN_COMMAND_PROFILE, parseTurnCommandProfile, type TurnCommandProfile } from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
const DEFAULT_PROFILE_PATH = path.resolve(
REPO_ROOT,
'resources',
'turn-commands',
'default.json'
);
const DEFAULT_PROFILE_PATH = path.resolve(REPO_ROOT, 'resources', 'turn-commands', 'default.json');
export interface TurnCommandProfileOptions {
filePath?: string;
@@ -27,11 +18,8 @@ const readCommandProfile = async (filePath: string): Promise<TurnCommandProfile>
return parseTurnCommandProfile(JSON.parse(raw) as unknown);
};
export const loadTurnCommandProfile = async (
options?: TurnCommandProfileOptions
): Promise<TurnCommandProfile> => {
const filePath =
options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH;
export const loadTurnCommandProfile = async (options?: TurnCommandProfileOptions): Promise<TurnCommandProfile> => {
const filePath = options?.filePath ?? process.env.TURN_COMMANDS_PATH ?? DEFAULT_PROFILE_PATH;
try {
return await readCommandProfile(filePath);
} catch (error) {
+22 -53
View File
@@ -4,21 +4,12 @@ import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra
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 type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } from '../lifecycle/types.js';
import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js';
import { buildTurnDaemonStreamKeys, RedisTurnDaemonCommandStream } from '../lifecycle/redisCommandStream.js';
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { createDatabaseTurnHooks } from './databaseHooks.js';
import type {
GeneralTurnHandler,
InMemoryTurnWorldOptions,
TurnCalendarHandler,
} from './inMemoryWorld.js';
import type { GeneralTurnHandler, InMemoryTurnWorldOptions, TurnCalendarHandler } from './inMemoryWorld.js';
import { InMemoryTurnWorld } from './inMemoryWorld.js';
import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js';
import { InMemoryTurnStateStore } from './inMemoryStateStore.js';
@@ -73,10 +64,7 @@ const buildFixedSchedule = (tickMinutes: number): TurnSchedule => ({
entries: [{ startMinute: 0, tickMinutes }],
});
const resolveRedisConfig = (
redisUrl?: string,
env: NodeJS.ProcessEnv = process.env
) => {
const resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process.env) => {
if (redisUrl) {
return { url: redisUrl };
}
@@ -86,9 +74,7 @@ const resolveRedisConfig = (
return resolveRedisConfigFromEnv(env);
};
export const createTurnDaemonRuntime = async (
options: TurnDaemonRuntimeOptions
): Promise<TurnDaemonRuntime> => {
export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions): Promise<TurnDaemonRuntime> => {
// DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다.
const { state, snapshot } = await loadTurnWorldFromDatabase({
databaseUrl: options.databaseUrl,
@@ -96,9 +82,7 @@ export const createTurnDaemonRuntime = async (
});
const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes);
const resolvedState = options.tickMinutes
? { ...state, tickSeconds: tickMinutes * 60 }
: state;
const resolvedState = options.tickMinutes ? { ...state, tickSeconds: tickMinutes * 60 } : state;
const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
const reservedTurnStoreHandle = options.generalTurnHandler
? null
@@ -136,14 +120,9 @@ export const createTurnDaemonRuntime = async (
tickMinutes,
beforeExecuteGeneral: reservedTurnStoreHandle
? async (general) => {
await reservedTurnStoreHandle.store.refreshGeneralTurns(
general.id
);
await reservedTurnStoreHandle.store.refreshGeneralTurns(general.id);
if (general.nationId > 0 && general.officerLevel >= 5) {
await reservedTurnStoreHandle.store.refreshNationTurns(
general.nationId,
general.officerLevel
);
await reservedTurnStoreHandle.store.refreshNationTurns(general.nationId, general.officerLevel);
}
}
: undefined,
@@ -154,22 +133,17 @@ export const createTurnDaemonRuntime = async (
let hooks: TurnDaemonHooks | undefined;
let close = async () => {};
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
let redisConnector:
| ReturnType<typeof createRedisConnector>
| null = null;
let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
let pauseGate: (() => Promise<boolean>) | undefined;
let adminActionConsumer: Awaited<
ReturnType<typeof createGatewayAdminActionConsumer>
> | null = null;
const gatewayGate =
options.profileName
? await createGatewayProfileGate({
databaseUrl: options.databaseUrl,
gatewayDatabaseUrl: options.gatewayDatabaseUrl,
profileName: options.profileName,
cacheMs: options.pauseGateIntervalMs,
})
: null;
let adminActionConsumer: Awaited<ReturnType<typeof createGatewayAdminActionConsumer>> | null = null;
const gatewayGate = options.profileName
? await createGatewayProfileGate({
databaseUrl: options.databaseUrl,
gatewayDatabaseUrl: options.gatewayDatabaseUrl,
profileName: options.profileName,
cacheMs: options.pauseGateIntervalMs,
})
: null;
if (gatewayGate) {
pauseGate = gatewayGate.shouldPause;
}
@@ -223,13 +197,10 @@ export const createTurnDaemonRuntime = async (
if (redisConfig) {
redisConnector = createRedisConnector(redisConfig);
await redisConnector.connect();
redisCommandStream = new RedisTurnDaemonCommandStream(
redisConnector.client,
{
keys: buildTurnDaemonStreamKeys(options.profileName ?? options.profile),
startId: options.commandStreamStartId,
}
);
redisCommandStream = new RedisTurnDaemonCommandStream(redisConnector.client, {
keys: buildTurnDaemonStreamKeys(options.profileName ?? options.profile),
startId: options.commandStreamStartId,
});
}
const baseClose = close;
@@ -256,8 +227,7 @@ export const createTurnDaemonRuntime = async (
{
clock,
controlQueue: resolvedControlQueue,
getNextTickTime: (lastTurnTime) =>
getNextTickTime(lastTurnTime, tickMinutes),
getNextTickTime: (lastTurnTime) => getNextTickTime(lastTurnTime, tickMinutes),
stateStore,
processor,
hooks,
@@ -268,7 +238,6 @@ export const createTurnDaemonRuntime = async (
{ profile: options.profile, defaultBudget }
);
if (options.profileName) {
adminActionConsumer = await createGatewayAdminActionConsumer({
databaseUrl: options.databaseUrl,
+4 -5
View File
@@ -33,11 +33,10 @@ export interface TurnDiplomacy {
meta: Record<string, unknown>;
}
export interface TurnWorldSnapshot
extends Omit<
WorldSnapshot,
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
> {
export interface TurnWorldSnapshot extends Omit<
WorldSnapshot,
'generals' | 'cities' | 'nations' | 'troops' | 'diplomacy'
> {
scenarioConfig: ScenarioConfig;
scenarioMeta?: ScenarioMeta;
map: MapDefinition;
+109 -37
View File
@@ -1,4 +1,10 @@
import type { TurnDaemonHooks, TurnDaemonCommandHandler, TurnDaemonCommand, TurnDaemonCommandResult, TurnRunResult } from '../lifecycle/types.js';
import type {
TurnDaemonHooks,
TurnDaemonCommandHandler,
TurnDaemonCommand,
TurnDaemonCommandResult,
TurnRunResult,
} from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
const buildFlushResult = (world: InMemoryTurnWorld): TurnRunResult => {
@@ -13,10 +19,7 @@ const buildFlushResult = (world: InMemoryTurnWorld): TurnRunResult => {
};
};
const flushWorld = async (
world: InMemoryTurnWorld,
hooks?: TurnDaemonHooks
): Promise<void> => {
const flushWorld = async (world: InMemoryTurnWorld, hooks?: TurnDaemonHooks): Promise<void> => {
if (!hooks?.flushChanges) {
return;
}
@@ -122,9 +125,7 @@ async function handleTroopExit(
}
const troopId = general.troopId;
const members = world
.listGenerals()
.filter((entry) => entry.troopId === troopId);
const members = world.listGenerals().filter((entry) => entry.troopId === troopId);
for (const member of members) {
world.updateGeneral(member.id, { troopId: 0 });
}
@@ -145,7 +146,12 @@ async function handleDieOnPrestart(
const { world, hooks } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
return {
type: 'dieOnPrestart',
ok: false,
generalId: command.generalId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined;
@@ -169,16 +175,31 @@ async function handleBuildNationCandidate(
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return { type: 'buildNationCandidate', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
return {
type: 'buildNationCandidate',
ok: false,
generalId: command.generalId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
if (general.nationId !== 0) {
return { type: 'buildNationCandidate', ok: false, generalId: command.generalId, reason: '이미 국가에 소속되어 있습니다.' };
return {
type: 'buildNationCandidate',
ok: false,
generalId: command.generalId,
reason: '이미 국가에 소속되어 있습니다.',
};
}
const worldState = world.getState();
const opentime = worldState.meta.opentime as string | undefined;
if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) {
return { type: 'buildNationCandidate', ok: false, generalId: command.generalId, reason: '가오픈 기간이 아닙니다.' };
return {
type: 'buildNationCandidate',
ok: false,
generalId: command.generalId,
reason: '가오픈 기간이 아닙니다.',
};
}
return { type: 'buildNationCandidate', ok: true, generalId: command.generalId };
@@ -191,13 +212,23 @@ async function handleInstantRetreat(
const { world } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return { type: 'instantRetreat', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
return {
type: 'instantRetreat',
ok: false,
generalId: command.generalId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
const config = world.getScenarioConfig();
const availableInstantAction = config.const.availableInstantAction as Record<string, boolean> | undefined;
if (!availableInstantAction?.instantRetreat) {
return { type: 'instantRetreat', ok: false, generalId: command.generalId, reason: '즉시 귀환이 허용되지 않는 서버입니다.' };
return {
type: 'instantRetreat',
ok: false,
generalId: command.generalId,
reason: '즉시 귀환이 허용되지 않는 서버입니다.',
};
}
return { type: 'instantRetreat', ok: true, generalId: command.generalId };
@@ -222,13 +253,18 @@ async function handleSetMySetting(
const { world, hooks } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return { type: 'setMySetting', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
return {
type: 'setMySetting',
ok: false,
generalId: command.generalId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
world.updateGeneral(command.generalId, {
meta: {
...general.meta,
...command.settings,
}
},
});
await flushWorld(world, hooks);
return { type: 'setMySetting', ok: true, generalId: command.generalId };
@@ -257,7 +293,7 @@ async function handleDropItem(
role: {
...general.role,
items,
}
},
});
await flushWorld(world, hooks);
return { type: 'dropItem', ok: true, generalId: command.generalId };
@@ -270,7 +306,12 @@ async function handleChangePermission(
const { world, hooks } = ctx;
const general = world.getGeneralById(command.generalId);
if (!general) {
return { type: 'changePermission', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' };
return {
type: 'changePermission',
ok: false,
generalId: command.generalId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
const nation = world.getNationById(general.nationId);
if (!nation || nation.chiefGeneralId !== general.id) {
@@ -284,7 +325,7 @@ async function handleChangePermission(
meta: {
...target.meta,
permission: command.isAmbassador ? 'ambassador' : 'auditor',
}
},
});
}
}
@@ -309,7 +350,12 @@ async function handleKick(
const target = world.getGeneralById(command.destGeneralId);
if (!target || target.nationId !== general.nationId) {
return { type: 'kick', ok: false, generalId: command.generalId, reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.' };
return {
type: 'kick',
ok: false,
generalId: command.generalId,
reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.',
};
}
world.updateGeneral(command.destGeneralId, {
@@ -337,7 +383,12 @@ async function handleAppoint(
const target = world.getGeneralById(command.destGeneralId);
if (command.destGeneralId !== 0 && (!target || target.nationId !== general.nationId)) {
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.' };
return {
type: 'appoint',
ok: false,
generalId: command.generalId,
reason: '대상을 찾을 수 없거나 같은 국가가 아닙니다.',
};
}
if (command.officerLevel >= 5) {
@@ -352,17 +403,26 @@ async function handleAppoint(
} else {
const city = world.getCityById(command.destCityId);
if (!city || city.nationId !== general.nationId) {
return { type: 'appoint', ok: false, generalId: command.generalId, reason: '도시를 찾을 수 없거나 아군 도시가 아닙니다.' };
return {
type: 'appoint',
ok: false,
generalId: command.generalId,
reason: '도시를 찾을 수 없거나 아군 도시가 아닙니다.',
};
}
for (const g of world.listGenerals()) {
if (g.nationId === general.nationId && g.meta.officerCity === command.destCityId && g.officerLevel === command.officerLevel) {
if (
g.nationId === general.nationId &&
g.meta.officerCity === command.destCityId &&
g.officerLevel === command.officerLevel
) {
world.updateGeneral(g.id, { officerLevel: 0, meta: { ...g.meta, officerCity: 0 } });
}
}
if (command.destGeneralId !== 0) {
world.updateGeneral(command.destGeneralId, {
world.updateGeneral(command.destGeneralId, {
officerLevel: command.officerLevel,
meta: { ...target!.meta, officerCity: command.destCityId }
meta: { ...target!.meta, officerCity: command.destCityId },
});
}
}
@@ -380,18 +440,30 @@ export const createTurnDaemonCommandHandler = (options: {
return {
handle: async (command): Promise<TurnDaemonCommandResult | null> => {
switch (command.type) {
case 'troopJoin': return handleTroopJoin(ctx, command);
case 'troopExit': return handleTroopExit(ctx, command);
case 'dieOnPrestart': return handleDieOnPrestart(ctx, command);
case 'buildNationCandidate': return handleBuildNationCandidate(ctx, command);
case 'instantRetreat': return handleInstantRetreat(ctx, command);
case 'vacation': return handleVacation(ctx, command);
case 'setMySetting': return handleSetMySetting(ctx, command);
case 'dropItem': return handleDropItem(ctx, command);
case 'changePermission': return handleChangePermission(ctx, command);
case 'kick': return handleKick(ctx, command);
case 'appoint': return handleAppoint(ctx, command);
default: return null;
case 'troopJoin':
return handleTroopJoin(ctx, command);
case 'troopExit':
return handleTroopExit(ctx, command);
case 'dieOnPrestart':
return handleDieOnPrestart(ctx, command);
case 'buildNationCandidate':
return handleBuildNationCandidate(ctx, command);
case 'instantRetreat':
return handleInstantRetreat(ctx, command);
case 'vacation':
return handleVacation(ctx, command);
case 'setMySetting':
return handleSetMySetting(ctx, command);
case 'dropItem':
return handleDropItem(ctx, command);
case 'changePermission':
return handleChangePermission(ctx, command);
case 'kick':
return handleKick(ctx, command);
case 'appoint':
return handleAppoint(ctx, command);
default:
return null;
}
},
};
+10 -44
View File
@@ -8,14 +8,7 @@ import {
type TurnEngineNationRow,
type TurnEngineTroopRow,
} from '@sammo-ts/infra';
import type {
City,
Nation,
ScenarioConfig,
ScenarioMeta,
Troop,
TriggerValue,
} from '@sammo-ts/logic';
import type { City, Nation, ScenarioConfig, ScenarioMeta, Troop, TriggerValue } from '@sammo-ts/logic';
import { z } from 'zod';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
@@ -37,8 +30,7 @@ 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 asRecord = (value: unknown): JsonRecord => (isRecord(value) ? value : {});
const asTriggerRecord = (value: unknown): Record<string, TriggerValue> =>
isRecord(value) ? (value as Record<string, TriggerValue>) : {};
@@ -101,10 +93,7 @@ const parseLastTurnTime = (meta: JsonRecord): Date | null => {
return parsed;
};
const resolveFallbackTurnTimeBase = (
generals: TurnGeneral[],
updatedAt: Date | null
): Date => {
const resolveFallbackTurnTimeBase = (generals: TurnGeneral[], updatedAt: Date | null): Date => {
let earliest: Date | null = null;
for (const general of generals) {
const turnTime = general.turnTime;
@@ -181,10 +170,7 @@ const mapGeneralRow = (row: TurnEngineGeneralRow): TurnGeneral => ({
const mapCityRow = (row: TurnEngineCityRow): City => {
const meta = asTriggerRecord(row.meta);
const state =
typeof meta.state === 'number' && Number.isFinite(meta.state)
? Math.floor(meta.state)
: 0;
const state = typeof meta.state === 'number' && Number.isFinite(meta.state) ? Math.floor(meta.state) : 0;
return {
id: row.id,
name: row.name,
@@ -249,9 +235,7 @@ const mapTroopRow = (row: TurnEngineTroopRow): Troop => ({
name: row.name,
});
export const loadTurnWorldFromDatabase = async (
options: TurnWorldLoaderOptions
): Promise<TurnWorldLoadResult> => {
export const loadTurnWorldFromDatabase = async (options: TurnWorldLoaderOptions): Promise<TurnWorldLoadResult> => {
const connector = createGamePostgresConnector({ url: options.databaseUrl });
await connector.connect();
try {
@@ -261,14 +245,7 @@ export const loadTurnWorldFromDatabase = async (
throw new Error('world_state row is required to start turn daemon.');
}
const [
generalRows,
cityRows,
nationRows,
diplomacyRows,
troopRows,
eventRows,
] = await Promise.all([
const [generalRows, cityRows, nationRows, diplomacyRows, troopRows, eventRows] = await Promise.all([
prisma.general.findMany(),
prisma.city.findMany(),
prisma.nation.findMany(),
@@ -289,25 +266,14 @@ export const loadTurnWorldFromDatabase = async (
const mapName = scenarioConfig.environment?.mapName ?? 'che';
const map = await loadMapDefinitionByName(mapName, options.mapOptions);
const unitSetName = scenarioConfig.environment?.unitSet ?? 'che';
const unitSet = await loadUnitSetDefinitionByName(
unitSetName,
options.unitSetOptions
);
const unitSet = await loadUnitSetDefinitionByName(unitSetName, options.unitSetOptions);
const meta = asRecord(worldState.meta);
const scenarioMeta = parseScenarioMeta(meta);
const tickMinutes = Math.max(
1,
Math.round(worldState.tickSeconds / 60)
);
const fallbackBase = resolveFallbackTurnTimeBase(
generals,
worldState.updatedAt ?? null
);
const lastTurnTime =
parseLastTurnTime(meta) ??
alignToPreviousTick(fallbackBase, tickMinutes);
const 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')