feat: eslint 적용 및 관련 코드 일괄 수정
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user