feat: 턴 데몬 명령 처리 및 결과 응답 기능 추가

This commit is contained in:
2026-01-04 16:05:22 +00:00
parent b2a625d4e7
commit 2753b85a26
12 changed files with 716 additions and 63 deletions
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
import type {
TurnDaemonCommand,
TurnDaemonCommandEnvelope,
TurnDaemonCommandResult,
TurnDaemonStatus,
} from './types.js';
import type { TurnDaemonTransport } from './transport.js';
@@ -18,6 +19,7 @@ const buildDefaultStatus = (): TurnDaemonStatus => ({
export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
public readonly commands: TurnDaemonCommandEnvelope[] = [];
private status: TurnDaemonStatus;
private readonly results = new Map<string, TurnDaemonCommandResult>();
constructor(initialStatus: TurnDaemonStatus = buildDefaultStatus()) {
this.status = initialStatus;
@@ -34,6 +36,14 @@ export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
return requestId;
}
async requestCommand(
command: TurnDaemonCommand,
_timeoutMs?: number
): Promise<TurnDaemonCommandResult | null> {
const requestId = await this.sendCommand(command);
return this.results.get(requestId) ?? null;
}
async requestStatus(): Promise<TurnDaemonStatus> {
return this.status;
}
@@ -41,4 +51,8 @@ export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
setStatus(status: TurnDaemonStatus): void {
this.status = status;
}
setCommandResult(requestId: string, result: TurnDaemonCommandResult): void {
this.results.set(requestId, result);
}
}
+45
View File
@@ -5,6 +5,7 @@ import type { TurnDaemonTransport } from './transport.js';
import type {
TurnDaemonCommand,
TurnDaemonCommandEnvelope,
TurnDaemonCommandResult,
TurnDaemonEventEnvelope,
TurnDaemonStatus,
} from './types.js';
@@ -75,6 +76,50 @@ export class RedisTurnDaemonTransport implements TurnDaemonTransport {
return envelope.requestId;
}
async requestCommand(
command: TurnDaemonCommand,
timeoutMs?: number
): Promise<TurnDaemonCommandResult | null> {
const requestId = await this.sendCommand(command);
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
let lastId = '$';
while (Date.now() < deadline) {
const remaining = Math.max(1, deadline - Date.now());
const response = (await this.client.xRead(
{ key: this.keys.eventStream, id: lastId },
{ BLOCK: remaining, COUNT: 10 }
)) as RedisStreamReadResponse | null;
if (!response) {
return null;
}
for (const stream of response) {
for (const message of stream.messages) {
lastId = message.id;
const payload = message.message.payload;
if (!payload) {
continue;
}
const envelope = parseEventEnvelope(payload);
if (!envelope) {
continue;
}
if (
envelope.event.type === 'commandResult' &&
envelope.requestId === requestId
) {
return envelope.event.result;
}
}
}
}
return null;
}
async requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null> {
const requestId = randomUUID();
await this.sendCommand({ type: 'getStatus', requestId });
+9 -1
View File
@@ -1,6 +1,14 @@
import type { TurnDaemonCommand, TurnDaemonStatus } from './types.js';
import type {
TurnDaemonCommand,
TurnDaemonCommandResult,
TurnDaemonStatus,
} from './types.js';
export interface TurnDaemonTransport {
sendCommand(command: TurnDaemonCommand): Promise<string>;
requestCommand(
command: TurnDaemonCommand,
timeoutMs?: number
): Promise<TurnDaemonCommandResult | null>;
requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null>;
}
+35 -2
View File
@@ -38,19 +38,52 @@ export interface TurnDaemonStatus {
checkpoint?: TurnCheckpoint;
}
export type TurnDaemonMutationCommand =
| { type: 'troopJoin'; generalId: number; troopId: number }
| { type: 'troopExit'; generalId: number };
// 턴 데몬 제어 요청은 Redis 스트림으로 전달한다.
export type TurnDaemonCommand =
| { type: 'run'; reason: RunReason; targetTime?: string; budget?: TurnRunBudget }
| { type: 'pause'; reason?: string }
| { type: 'resume'; reason?: string }
| { type: 'getStatus'; requestId: string };
| { type: 'getStatus'; requestId: string }
| TurnDaemonMutationCommand;
export type TurnDaemonCommandResult =
| {
type: 'troopJoin';
ok: true;
generalId: number;
troopId: number;
}
| {
type: 'troopJoin';
ok: false;
generalId: number;
troopId: number;
reason: string;
}
| {
type: 'troopExit';
ok: true;
generalId: number;
wasLeader: boolean;
}
| {
type: 'troopExit';
ok: false;
generalId: number;
reason: string;
};
// 턴 데몬 이벤트는 상태/실행 결과를 API 서버에 알려준다.
export type TurnDaemonEvent =
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
| { type: 'runStarted'; at: string; reason: RunReason }
| { type: 'runCompleted'; at: string; result: TurnRunResult }
| { type: 'runFailed'; at: string; error: string };
| { type: 'runFailed'; at: string; error: string }
| { type: 'commandResult'; result: TurnDaemonCommandResult };
export interface TurnDaemonCommandEnvelope {
requestId: string;
+30 -54
View File
@@ -634,42 +634,29 @@ export const appRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
const result = await ctx.turnDaemon.requestCommand({
type: 'troopJoin',
generalId: input.generalId,
troopId: input.troopId,
});
if (!general) {
if (!result) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
code: 'TIMEOUT',
message: 'Turn daemon did not respond.',
});
}
if (general.troopId !== 0) {
if (result.type !== 'troopJoin') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Unexpected turn daemon response.',
});
}
if (!result.ok) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Already in a troop.',
message: result.reason,
});
}
if (general.nationId <= 0) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'General is not part of a nation.',
});
}
const troop = await ctx.db.troop.findUnique({
where: { troopLeaderId: input.troopId },
});
if (!troop || troop.nationId !== general.nationId) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Troop is invalid.',
});
}
await ctx.db.general.update({
where: { id: general.id },
data: { troopId: input.troopId },
});
return { ok: true };
}),
@@ -680,41 +667,30 @@ export const appRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const general = await ctx.db.general.findUnique({
where: { id: input.generalId },
const result = await ctx.turnDaemon.requestCommand({
type: 'troopExit',
generalId: input.generalId,
});
if (!general) {
if (!result) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'General not found.',
code: 'TIMEOUT',
message: 'Turn daemon did not respond.',
});
}
if (general.troopId === 0) {
if (result.type !== 'troopExit') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Unexpected turn daemon response.',
});
}
if (!result.ok) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Not in a troop.',
message: result.reason,
});
}
if (general.troopId !== general.id) {
await ctx.db.general.update({
where: { id: general.id },
data: { troopId: 0 },
});
return { ok: true, wasLeader: false };
}
await ctx.db.$transaction([
ctx.db.general.updateMany({
where: { troopId: general.troopId },
data: { troopId: 0 },
}),
ctx.db.troop.deleteMany({
where: { troopLeaderId: general.troopId },
}),
]);
return { ok: true, wasLeader: true };
return { ok: true, wasLeader: result.wasLeader };
}),
}),
turnDaemon: router({
@@ -0,0 +1,236 @@
import type {
TurnDaemonCommand,
TurnDaemonCommandResponder,
TurnDaemonControlQueue,
TurnDaemonStatus,
TurnDaemonCommandResult,
} from './types.js';
export interface TurnDaemonStreamKeys {
commandStream: string;
eventStream: string;
}
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>;
}
type RedisStreamReadResponse = Array<{
name: string;
messages: Array<{ id: string; message: Record<string, string> }>;
}>;
type TurnDaemonEvent =
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
| { type: 'commandResult'; result: TurnDaemonCommandResult };
type TurnDaemonCommandEnvelope = {
requestId: string;
sentAt: string;
command: TurnDaemonCommand;
};
type TurnDaemonEventEnvelope = {
requestId?: string;
sentAt: string;
event: TurnDaemonEvent;
};
const parseCommandEnvelope = (raw: string): TurnDaemonCommandEnvelope | null => {
try {
const parsed = JSON.parse(raw) as Partial<TurnDaemonCommandEnvelope>;
if (!parsed || typeof parsed !== 'object') {
return null;
}
if (!parsed.command || typeof parsed.command !== 'object') {
return null;
}
if (typeof parsed.requestId !== 'string') {
return null;
}
if (typeof parsed.sentAt !== 'string') {
return null;
}
return parsed as TurnDaemonCommandEnvelope;
} catch {
return 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'
) {
return null;
}
return {
type: 'troopJoin',
requestId: envelope.requestId,
generalId: command.generalId,
troopId: command.troopId,
};
}
case 'troopExit': {
if (typeof command.generalId !== 'number') {
return null;
}
return {
type: 'troopExit',
requestId: envelope.requestId,
generalId: command.generalId,
};
}
case 'getStatus': {
const requestId =
typeof command.requestId === 'string'
? command.requestId
: envelope.requestId;
return { type: 'getStatus', requestId };
}
case 'run':
case 'pause':
case 'resume':
case 'shutdown':
return command;
default:
return null;
}
};
export class RedisTurnDaemonCommandStream
implements TurnDaemonControlQueue, TurnDaemonCommandResponder
{
private readonly client: RedisStreamClient;
private readonly keys: TurnDaemonStreamKeys;
private readonly localQueue: TurnDaemonCommand[] = [];
private lastId: string;
constructor(
client: RedisStreamClient,
options: {
keys: TurnDaemonStreamKeys;
startId?: string;
}
) {
this.client = client;
this.keys = options.keys;
this.lastId = options.startId ?? '$';
}
enqueue(command: TurnDaemonCommand): void {
this.localQueue.push(command);
}
async drain(): Promise<TurnDaemonCommand[]> {
const drained = this.localQueue.splice(0, this.localQueue.length);
const remote = await this.readRemoteCommands(1);
return drained.concat(remote);
}
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
if (this.localQueue.length > 0) {
return this.localQueue.shift() ?? null;
}
const blockMs =
deadlineMs === null
? 0
: Math.max(0, deadlineMs - Date.now());
if (deadlineMs !== null && blockMs === 0) {
return null;
}
const remote = await this.readRemoteCommands(blockMs);
if (remote.length === 0) {
return null;
}
const [first, ...rest] = remote;
if (rest.length > 0) {
this.localQueue.push(...rest);
}
return first ?? null;
}
getDepth(): number {
return this.localQueue.length;
}
async publishStatus(
requestId: string,
status: TurnDaemonStatus
): Promise<void> {
await this.publishEvent({ type: 'status', requestId, status }, requestId);
}
async publishCommandResult(
requestId: string,
result: TurnDaemonCommandResult
): Promise<void> {
await this.publishEvent({ type: 'commandResult', result }, requestId);
}
private async publishEvent(
event: TurnDaemonEvent,
requestId?: string
): Promise<void> {
const envelope: TurnDaemonEventEnvelope = {
requestId,
sentAt: new Date().toISOString(),
event,
};
await this.client.xAdd(this.keys.eventStream, '*', {
payload: JSON.stringify(envelope),
});
}
private async readRemoteCommands(blockMs: number): Promise<TurnDaemonCommand[]> {
const response = (await this.client.xRead(
{ key: this.keys.commandStream, id: this.lastId },
{ BLOCK: blockMs, COUNT: 100 }
)) as RedisStreamReadResponse | null;
if (!response) {
return [];
}
const commands: TurnDaemonCommand[] = [];
for (const stream of response) {
for (const message of stream.messages) {
this.lastId = message.id;
const payload = message.message.payload;
if (!payload) {
continue;
}
const envelope = parseCommandEnvelope(payload);
if (!envelope) {
continue;
}
const command = normalizeCommand(envelope);
if (!command) {
continue;
}
commands.push(command);
}
}
return commands;
}
}
@@ -11,6 +11,9 @@ import type {
TurnProcessor,
Clock,
TurnCheckpoint,
TurnDaemonCommandHandler,
TurnDaemonCommandResponder,
TurnDaemonCommandResult,
} from './types.js';
type PendingRun = {
@@ -31,6 +34,8 @@ export interface TurnDaemonLifecycleDeps {
stateStore: TurnStateStore;
processor: TurnProcessor;
hooks?: TurnDaemonHooks;
commandHandler?: TurnDaemonCommandHandler;
commandResponder?: TurnDaemonCommandResponder;
pauseGate?: () => Promise<boolean>;
}
@@ -42,6 +47,8 @@ export class TurnDaemonLifecycle {
private readonly stateStore: TurnStateStore;
private readonly processor: TurnProcessor;
private readonly hooks?: TurnDaemonHooks;
private readonly commandHandler?: TurnDaemonCommandHandler;
private readonly commandResponder?: TurnDaemonCommandResponder;
private readonly pauseGate?: () => Promise<boolean>;
private readonly options: TurnDaemonLifecycleOptions;
@@ -59,6 +66,8 @@ export class TurnDaemonLifecycle {
this.stateStore = deps.stateStore;
this.processor = deps.processor;
this.hooks = deps.hooks;
this.commandHandler = deps.commandHandler;
this.commandResponder = deps.commandResponder;
this.pauseGate = deps.pauseGate;
this.options = options;
this.status = {
@@ -218,6 +227,13 @@ export class TurnDaemonLifecycle {
this.status.state = 'stopping';
this.stopping = true;
return;
case 'getStatus': {
await this.commandResponder?.publishStatus(
command.requestId,
this.getStatus()
);
return;
}
case 'run':
this.pendingRun = {
reason: command.reason,
@@ -226,6 +242,54 @@ export class TurnDaemonLifecycle {
};
this.status.pendingReason = command.reason;
return;
case 'troopJoin':
case 'troopExit':
await this.handleMutationCommand(command);
return;
}
}
private async handleMutationCommand(
command: Extract<TurnDaemonCommand, { type: 'troopJoin' | 'troopExit' }>
): Promise<void> {
let result: TurnDaemonCommandResult | null = null;
try {
result = this.commandHandler
? await this.commandHandler.handle(command)
: null;
if (!result) {
result = {
type: command.type,
ok: false,
generalId: command.generalId,
...(command.type === 'troopJoin'
? {
troopId: command.troopId,
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
}
: {
reason: '턴 데몬이 명령을 처리할 수 없습니다.',
}),
} as TurnDaemonCommandResult;
}
} catch (error) {
const reason =
error instanceof Error ? error.message : 'Unknown command error.';
result = {
type: command.type,
ok: false,
generalId: command.generalId,
...(command.type === 'troopJoin'
? { troopId: command.troopId, reason }
: { reason }),
} as TurnDaemonCommandResult;
}
if (this.commandResponder) {
await this.commandResponder.publishCommandResult(
command.requestId,
result
);
}
}
+43 -1
View File
@@ -42,7 +42,49 @@ export type TurnDaemonCommand =
| { type: 'run'; reason: RunReason; targetTime?: string; budget?: TurnRunBudget }
| { type: 'pause'; reason?: string }
| { type: 'resume'; reason?: string }
| { type: 'shutdown'; reason?: string };
| { type: 'shutdown'; reason?: string }
| { type: 'getStatus'; requestId: string }
| { type: 'troopJoin'; requestId: string; generalId: number; troopId: number }
| { type: 'troopExit'; requestId: string; generalId: number };
export type TurnDaemonCommandResult =
| {
type: 'troopJoin';
ok: true;
generalId: number;
troopId: number;
}
| {
type: 'troopJoin';
ok: false;
generalId: number;
troopId: number;
reason: string;
}
| {
type: 'troopExit';
ok: true;
generalId: number;
wasLeader: boolean;
}
| {
type: 'troopExit';
ok: false;
generalId: number;
reason: string;
};
export interface TurnDaemonCommandHandler {
handle(command: TurnDaemonCommand): Promise<TurnDaemonCommandResult | null>;
}
export interface TurnDaemonCommandResponder {
publishStatus(requestId: string, status: TurnDaemonStatus): Promise<void>;
publishCommandResult(
requestId: string,
result: TurnDaemonCommandResult
): Promise<void>;
}
export type { Clock } from '@sammo-ts/common';
@@ -248,6 +248,7 @@ export const createDatabaseTurnHooks = async (
cities,
nations,
troops,
deletedTroops,
diplomacy,
logs,
createdGenerals,
@@ -293,6 +294,11 @@ export const createDatabaseTurnHooks = async (
data: createdDiplomacy.map(buildDiplomacyCreate),
});
}
if (deletedTroops.length > 0) {
await prisma.troop.deleteMany({
where: { troopLeaderId: { in: deletedTroops } },
});
}
await Promise.all([
...generals
+41
View File
@@ -189,6 +189,7 @@ export class InMemoryTurnWorld {
private readonly createdGeneralIds = new Set<number>();
private readonly createdTroopIds = new Set<number>();
private readonly createdDiplomacyKeys = new Set<string>();
private readonly deletedTroopIds = new Set<number>();
private readonly logs: LogEntryDraft[] = [];
private checkpoint?: TurnCheckpoint;
private state: TurnWorldState;
@@ -297,6 +298,42 @@ export class InMemoryTurnWorld {
}));
}
updateGeneral(
id: number,
patch: Partial<TurnGeneral>
): TurnGeneral | null {
const target = this.generals.get(id);
if (!target) {
return null;
}
const next = applyGeneralPatch(target, patch);
this.generals.set(id, next);
this.dirtyGeneralIds.add(id);
return next;
}
updateTroop(id: number, patch: Partial<Troop>): Troop | null {
const target = this.troops.get(id);
if (!target) {
return null;
}
const next = applyTroopPatch(target, patch);
this.troops.set(id, next);
this.dirtyTroopIds.add(id);
return next;
}
removeTroop(id: number): boolean {
if (!this.troops.has(id)) {
return false;
}
this.troops.delete(id);
this.dirtyTroopIds.delete(id);
this.createdTroopIds.delete(id);
this.deletedTroopIds.add(id);
return true;
}
applyDiplomacyPatch(input: {
srcNationId: number;
destNationId: number;
@@ -510,6 +547,7 @@ export class InMemoryTurnWorld {
cities: City[];
nations: Nation[];
troops: Troop[];
deletedTroops: number[];
diplomacy: TurnDiplomacy[];
logs: LogEntryDraft[];
createdGenerals: TurnGeneral[];
@@ -540,6 +578,7 @@ export class InMemoryTurnWorld {
const createdDiplomacy = Array.from(this.createdDiplomacyKeys)
.map((key) => this.diplomacy.get(key))
.filter((entry): entry is TurnDiplomacy => Boolean(entry));
const deletedTroops = Array.from(this.deletedTroopIds);
const logs = this.logs.splice(0, this.logs.length);
this.dirtyGeneralIds.clear();
@@ -550,12 +589,14 @@ export class InMemoryTurnWorld {
this.createdGeneralIds.clear();
this.createdTroopIds.clear();
this.createdDiplomacyKeys.clear();
this.deletedTroopIds.clear();
return {
generals,
cities,
nations,
troops,
deletedTroops,
diplomacy,
logs,
createdGenerals,
@@ -0,0 +1,137 @@
import type { TurnDaemonHooks, TurnDaemonCommandHandler, TurnDaemonCommandResult, TurnRunResult } from '../lifecycle/types.js';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
const buildFlushResult = (world: InMemoryTurnWorld): TurnRunResult => {
const state = world.getState();
return {
lastTurnTime: state.lastTurnTime.toISOString(),
processedGenerals: 0,
processedTurns: 0,
durationMs: 0,
partial: false,
checkpoint: world.getCheckpoint(),
};
};
const flushWorld = async (
world: InMemoryTurnWorld,
hooks?: TurnDaemonHooks
): Promise<void> => {
if (!hooks?.flushChanges) {
return;
}
await hooks.flushChanges(buildFlushResult(world));
};
export const createTurnDaemonCommandHandler = (options: {
world: InMemoryTurnWorld;
hooks?: TurnDaemonHooks;
}): TurnDaemonCommandHandler => {
return {
handle: async (command): Promise<TurnDaemonCommandResult | null> => {
if (command.type === 'troopJoin') {
const general = options.world.getGeneralById(command.generalId);
if (!general) {
return {
type: 'troopJoin',
ok: false,
generalId: command.generalId,
troopId: command.troopId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
if (general.troopId !== 0) {
return {
type: 'troopJoin',
ok: false,
generalId: command.generalId,
troopId: command.troopId,
reason: '이미 부대에 소속되어 있습니다.',
};
}
if (general.nationId <= 0) {
return {
type: 'troopJoin',
ok: false,
generalId: command.generalId,
troopId: command.troopId,
reason: '국가에 소속되어 있지 않습니다.',
};
}
const troop = options.world.getTroopById(command.troopId);
if (!troop || troop.nationId !== general.nationId) {
return {
type: 'troopJoin',
ok: false,
generalId: command.generalId,
troopId: command.troopId,
reason: '부대가 올바르지 않습니다.',
};
}
options.world.updateGeneral(command.generalId, {
troopId: command.troopId,
});
await flushWorld(options.world, options.hooks);
return {
type: 'troopJoin',
ok: true,
generalId: command.generalId,
troopId: command.troopId,
};
}
if (command.type === 'troopExit') {
const general = options.world.getGeneralById(command.generalId);
if (!general) {
return {
type: 'troopExit',
ok: false,
generalId: command.generalId,
reason: '장수 정보를 찾을 수 없습니다.',
};
}
if (general.troopId === 0) {
return {
type: 'troopExit',
ok: false,
generalId: command.generalId,
reason: '부대에 소속되어 있지 않습니다.',
};
}
if (general.troopId !== general.id) {
options.world.updateGeneral(command.generalId, {
troopId: 0,
});
await flushWorld(options.world, options.hooks);
return {
type: 'troopExit',
ok: true,
generalId: command.generalId,
wasLeader: false,
};
}
const troopId = general.troopId;
const members = options.world
.listGenerals()
.filter((entry) => entry.troopId === troopId);
for (const member of members) {
options.world.updateGeneral(member.id, { troopId: 0 });
}
options.world.removeTroop(troopId);
await flushWorld(options.world, options.hooks);
return {
type: 'troopExit',
ok: true,
generalId: command.generalId,
wasLeader: true,
};
}
return null;
},
};
};
+56 -5
View File
@@ -1,4 +1,5 @@
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { SystemClock } from '../lifecycle/clock.js';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
@@ -10,6 +11,7 @@ import type {
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 {
@@ -24,6 +26,7 @@ import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { createReservedTurnHandler } from './reservedTurnHandler.js';
import { createReservedTurnStore } from './reservedTurnStore.js';
import { createTurnDaemonCommandHandler } from './troopCommandHandler.js';
import { loadTurnCommandProfile } from './turnCommandProfile.js';
import { loadTurnWorldFromDatabase } from './worldLoader.js';
@@ -45,6 +48,8 @@ export interface TurnDaemonRuntimeOptions {
commandProfile?: TurnCommandProfile;
commandProfilePath?: string;
adminActionIntervalMs?: number;
redisUrl?: string;
commandStreamStartId?: string;
}
export interface TurnDaemonRuntime {
@@ -68,6 +73,19 @@ const buildFixedSchedule = (tickMinutes: number): TurnSchedule => ({
entries: [{ startMinute: 0, tickMinutes }],
});
const resolveRedisConfig = (
redisUrl?: string,
env: NodeJS.ProcessEnv = process.env
) => {
if (redisUrl) {
return { url: redisUrl };
}
if (!env.REDIS_URL) {
return null;
}
return resolveRedisConfigFromEnv(env);
};
export const createTurnDaemonRuntime = async (
options: TurnDaemonRuntimeOptions
): Promise<TurnDaemonRuntime> => {
@@ -135,6 +153,10 @@ export const createTurnDaemonRuntime = async (
let hooks: TurnDaemonHooks | undefined;
let close = async () => {};
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
let redisConnector:
| ReturnType<typeof createRedisConnector>
| null = null;
let pauseGate: (() => Promise<boolean>) | undefined;
let adminActionConsumer: Awaited<
ReturnType<typeof createGatewayAdminActionConsumer>
@@ -197,6 +219,33 @@ export const createTurnDaemonRuntime = async (
};
}
const redisConfig = resolveRedisConfig(options.redisUrl);
if (redisConfig) {
redisConnector = createRedisConnector(redisConfig);
await redisConnector.connect();
redisCommandStream = new RedisTurnDaemonCommandStream(
redisConnector.client,
{
keys: buildTurnDaemonStreamKeys(options.profileName ?? options.profile),
startId: options.commandStreamStartId,
}
);
}
const baseClose = close;
close = async () => {
await baseClose();
if (redisConnector) {
await redisConnector.disconnect();
}
};
const resolvedControlQueue = options.controlQueue ?? redisCommandStream ?? controlQueue;
const commandHandler = createTurnDaemonCommandHandler({
world,
hooks,
});
const defaultBudget: TurnRunBudget = options.defaultBudget ?? {
budgetMs: 5000,
maxGenerals: 200,
@@ -206,13 +255,15 @@ export const createTurnDaemonRuntime = async (
const lifecycle = new TurnDaemonLifecycle(
{
clock,
controlQueue,
controlQueue: resolvedControlQueue,
getNextTickTime: (lastTurnTime) =>
getNextTickTime(lastTurnTime, tickMinutes),
stateStore,
processor,
hooks,
pauseGate,
commandHandler,
commandResponder: redisCommandStream ?? undefined,
},
{ profile: options.profile, defaultBudget }
);
@@ -232,14 +283,14 @@ export const createTurnDaemonRuntime = async (
}
switch (action.action) {
case 'RESUME':
controlQueue.enqueue({ type: 'resume', reason });
resolvedControlQueue.enqueue({ type: 'resume', reason });
return { status: 'APPLIED', detail: 'resume queued' };
case 'PAUSE':
controlQueue.enqueue({ type: 'pause', reason });
resolvedControlQueue.enqueue({ type: 'pause', reason });
return { status: 'APPLIED', detail: 'pause queued' };
case 'STOP':
case 'SHUTDOWN':
controlQueue.enqueue({ type: 'shutdown', reason });
resolvedControlQueue.enqueue({ type: 'shutdown', reason });
return { status: 'APPLIED', detail: 'shutdown queued' };
default:
return { status: 'IGNORED', detail: 'not implemented' };
@@ -252,7 +303,7 @@ export const createTurnDaemonRuntime = async (
return {
lifecycle,
world,
controlQueue,
controlQueue: resolvedControlQueue,
stateStore,
processor,
hooks,