feat: 턴 데몬 명령 처리 및 결과 응답 기능 추가
This commit is contained in:
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type {
|
import type {
|
||||||
TurnDaemonCommand,
|
TurnDaemonCommand,
|
||||||
TurnDaemonCommandEnvelope,
|
TurnDaemonCommandEnvelope,
|
||||||
|
TurnDaemonCommandResult,
|
||||||
TurnDaemonStatus,
|
TurnDaemonStatus,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import type { TurnDaemonTransport } from './transport.js';
|
import type { TurnDaemonTransport } from './transport.js';
|
||||||
@@ -18,6 +19,7 @@ const buildDefaultStatus = (): TurnDaemonStatus => ({
|
|||||||
export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
|
export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
|
||||||
public readonly commands: TurnDaemonCommandEnvelope[] = [];
|
public readonly commands: TurnDaemonCommandEnvelope[] = [];
|
||||||
private status: TurnDaemonStatus;
|
private status: TurnDaemonStatus;
|
||||||
|
private readonly results = new Map<string, TurnDaemonCommandResult>();
|
||||||
|
|
||||||
constructor(initialStatus: TurnDaemonStatus = buildDefaultStatus()) {
|
constructor(initialStatus: TurnDaemonStatus = buildDefaultStatus()) {
|
||||||
this.status = initialStatus;
|
this.status = initialStatus;
|
||||||
@@ -34,6 +36,14 @@ export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
return requestId;
|
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> {
|
async requestStatus(): Promise<TurnDaemonStatus> {
|
||||||
return this.status;
|
return this.status;
|
||||||
}
|
}
|
||||||
@@ -41,4 +51,8 @@ export class InMemoryTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
setStatus(status: TurnDaemonStatus): void {
|
setStatus(status: TurnDaemonStatus): void {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCommandResult(requestId: string, result: TurnDaemonCommandResult): void {
|
||||||
|
this.results.set(requestId, result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { TurnDaemonTransport } from './transport.js';
|
|||||||
import type {
|
import type {
|
||||||
TurnDaemonCommand,
|
TurnDaemonCommand,
|
||||||
TurnDaemonCommandEnvelope,
|
TurnDaemonCommandEnvelope,
|
||||||
|
TurnDaemonCommandResult,
|
||||||
TurnDaemonEventEnvelope,
|
TurnDaemonEventEnvelope,
|
||||||
TurnDaemonStatus,
|
TurnDaemonStatus,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
@@ -75,6 +76,50 @@ export class RedisTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
return envelope.requestId;
|
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> {
|
async requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null> {
|
||||||
const requestId = randomUUID();
|
const requestId = randomUUID();
|
||||||
await this.sendCommand({ type: 'getStatus', requestId });
|
await this.sendCommand({ type: 'getStatus', requestId });
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { TurnDaemonCommand, TurnDaemonStatus } from './types.js';
|
import type {
|
||||||
|
TurnDaemonCommand,
|
||||||
|
TurnDaemonCommandResult,
|
||||||
|
TurnDaemonStatus,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
export interface TurnDaemonTransport {
|
export interface TurnDaemonTransport {
|
||||||
sendCommand(command: TurnDaemonCommand): Promise<string>;
|
sendCommand(command: TurnDaemonCommand): Promise<string>;
|
||||||
|
requestCommand(
|
||||||
|
command: TurnDaemonCommand,
|
||||||
|
timeoutMs?: number
|
||||||
|
): Promise<TurnDaemonCommandResult | null>;
|
||||||
requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null>;
|
requestStatus(timeoutMs?: number): Promise<TurnDaemonStatus | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,19 +38,52 @@ export interface TurnDaemonStatus {
|
|||||||
checkpoint?: TurnCheckpoint;
|
checkpoint?: TurnCheckpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TurnDaemonMutationCommand =
|
||||||
|
| { type: 'troopJoin'; generalId: number; troopId: number }
|
||||||
|
| { type: 'troopExit'; generalId: number };
|
||||||
|
|
||||||
// 턴 데몬 제어 요청은 Redis 스트림으로 전달한다.
|
// 턴 데몬 제어 요청은 Redis 스트림으로 전달한다.
|
||||||
export type TurnDaemonCommand =
|
export type TurnDaemonCommand =
|
||||||
| { type: 'run'; reason: RunReason; targetTime?: string; budget?: TurnRunBudget }
|
| { type: 'run'; reason: RunReason; targetTime?: string; budget?: TurnRunBudget }
|
||||||
| { type: 'pause'; reason?: string }
|
| { type: 'pause'; reason?: string }
|
||||||
| { type: 'resume'; 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 서버에 알려준다.
|
// 턴 데몬 이벤트는 상태/실행 결과를 API 서버에 알려준다.
|
||||||
export type TurnDaemonEvent =
|
export type TurnDaemonEvent =
|
||||||
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
| { type: 'status'; requestId?: string; status: TurnDaemonStatus }
|
||||||
| { type: 'runStarted'; at: string; reason: RunReason }
|
| { type: 'runStarted'; at: string; reason: RunReason }
|
||||||
| { type: 'runCompleted'; at: string; result: TurnRunResult }
|
| { 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 {
|
export interface TurnDaemonCommandEnvelope {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
|||||||
+30
-54
@@ -634,42 +634,29 @@ export const appRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const general = await ctx.db.general.findUnique({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
where: { id: input.generalId },
|
type: 'troopJoin',
|
||||||
|
generalId: input.generalId,
|
||||||
|
troopId: input.troopId,
|
||||||
});
|
});
|
||||||
if (!general) {
|
if (!result) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'NOT_FOUND',
|
code: 'TIMEOUT',
|
||||||
message: 'General not found.',
|
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({
|
throw new TRPCError({
|
||||||
code: 'PRECONDITION_FAILED',
|
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 };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
@@ -680,41 +667,30 @@ export const appRouter = router({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const general = await ctx.db.general.findUnique({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
where: { id: input.generalId },
|
type: 'troopExit',
|
||||||
|
generalId: input.generalId,
|
||||||
});
|
});
|
||||||
if (!general) {
|
if (!result) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'NOT_FOUND',
|
code: 'TIMEOUT',
|
||||||
message: 'General not found.',
|
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({
|
throw new TRPCError({
|
||||||
code: 'PRECONDITION_FAILED',
|
code: 'PRECONDITION_FAILED',
|
||||||
message: 'Not in a troop.',
|
message: result.reason,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (general.troopId !== general.id) {
|
return { ok: true, wasLeader: result.wasLeader };
|
||||||
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 };
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
turnDaemon: router({
|
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,
|
TurnProcessor,
|
||||||
Clock,
|
Clock,
|
||||||
TurnCheckpoint,
|
TurnCheckpoint,
|
||||||
|
TurnDaemonCommandHandler,
|
||||||
|
TurnDaemonCommandResponder,
|
||||||
|
TurnDaemonCommandResult,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
type PendingRun = {
|
type PendingRun = {
|
||||||
@@ -31,6 +34,8 @@ export interface TurnDaemonLifecycleDeps {
|
|||||||
stateStore: TurnStateStore;
|
stateStore: TurnStateStore;
|
||||||
processor: TurnProcessor;
|
processor: TurnProcessor;
|
||||||
hooks?: TurnDaemonHooks;
|
hooks?: TurnDaemonHooks;
|
||||||
|
commandHandler?: TurnDaemonCommandHandler;
|
||||||
|
commandResponder?: TurnDaemonCommandResponder;
|
||||||
pauseGate?: () => Promise<boolean>;
|
pauseGate?: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +47,8 @@ export class TurnDaemonLifecycle {
|
|||||||
private readonly stateStore: TurnStateStore;
|
private readonly stateStore: TurnStateStore;
|
||||||
private readonly processor: TurnProcessor;
|
private readonly processor: TurnProcessor;
|
||||||
private readonly hooks?: TurnDaemonHooks;
|
private readonly hooks?: TurnDaemonHooks;
|
||||||
|
private readonly commandHandler?: TurnDaemonCommandHandler;
|
||||||
|
private readonly commandResponder?: TurnDaemonCommandResponder;
|
||||||
private readonly pauseGate?: () => Promise<boolean>;
|
private readonly pauseGate?: () => Promise<boolean>;
|
||||||
private readonly options: TurnDaemonLifecycleOptions;
|
private readonly options: TurnDaemonLifecycleOptions;
|
||||||
|
|
||||||
@@ -59,6 +66,8 @@ export class TurnDaemonLifecycle {
|
|||||||
this.stateStore = deps.stateStore;
|
this.stateStore = deps.stateStore;
|
||||||
this.processor = deps.processor;
|
this.processor = deps.processor;
|
||||||
this.hooks = deps.hooks;
|
this.hooks = deps.hooks;
|
||||||
|
this.commandHandler = deps.commandHandler;
|
||||||
|
this.commandResponder = deps.commandResponder;
|
||||||
this.pauseGate = deps.pauseGate;
|
this.pauseGate = deps.pauseGate;
|
||||||
this.options = options;
|
this.options = options;
|
||||||
this.status = {
|
this.status = {
|
||||||
@@ -218,6 +227,13 @@ export class TurnDaemonLifecycle {
|
|||||||
this.status.state = 'stopping';
|
this.status.state = 'stopping';
|
||||||
this.stopping = true;
|
this.stopping = true;
|
||||||
return;
|
return;
|
||||||
|
case 'getStatus': {
|
||||||
|
await this.commandResponder?.publishStatus(
|
||||||
|
command.requestId,
|
||||||
|
this.getStatus()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'run':
|
case 'run':
|
||||||
this.pendingRun = {
|
this.pendingRun = {
|
||||||
reason: command.reason,
|
reason: command.reason,
|
||||||
@@ -226,6 +242,54 @@ export class TurnDaemonLifecycle {
|
|||||||
};
|
};
|
||||||
this.status.pendingReason = command.reason;
|
this.status.pendingReason = command.reason;
|
||||||
return;
|
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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,49 @@ export type TurnDaemonCommand =
|
|||||||
| { type: 'run'; reason: RunReason; targetTime?: string; budget?: TurnRunBudget }
|
| { type: 'run'; reason: RunReason; targetTime?: string; budget?: TurnRunBudget }
|
||||||
| { type: 'pause'; reason?: string }
|
| { type: 'pause'; reason?: string }
|
||||||
| { type: 'resume'; 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';
|
export type { Clock } from '@sammo-ts/common';
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ export const createDatabaseTurnHooks = async (
|
|||||||
cities,
|
cities,
|
||||||
nations,
|
nations,
|
||||||
troops,
|
troops,
|
||||||
|
deletedTroops,
|
||||||
diplomacy,
|
diplomacy,
|
||||||
logs,
|
logs,
|
||||||
createdGenerals,
|
createdGenerals,
|
||||||
@@ -293,6 +294,11 @@ export const createDatabaseTurnHooks = async (
|
|||||||
data: createdDiplomacy.map(buildDiplomacyCreate),
|
data: createdDiplomacy.map(buildDiplomacyCreate),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (deletedTroops.length > 0) {
|
||||||
|
await prisma.troop.deleteMany({
|
||||||
|
where: { troopLeaderId: { in: deletedTroops } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
...generals
|
...generals
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export class InMemoryTurnWorld {
|
|||||||
private readonly createdGeneralIds = new Set<number>();
|
private readonly createdGeneralIds = new Set<number>();
|
||||||
private readonly createdTroopIds = new Set<number>();
|
private readonly createdTroopIds = new Set<number>();
|
||||||
private readonly createdDiplomacyKeys = new Set<string>();
|
private readonly createdDiplomacyKeys = new Set<string>();
|
||||||
|
private readonly deletedTroopIds = new Set<number>();
|
||||||
private readonly logs: LogEntryDraft[] = [];
|
private readonly logs: LogEntryDraft[] = [];
|
||||||
private checkpoint?: TurnCheckpoint;
|
private checkpoint?: TurnCheckpoint;
|
||||||
private state: TurnWorldState;
|
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: {
|
applyDiplomacyPatch(input: {
|
||||||
srcNationId: number;
|
srcNationId: number;
|
||||||
destNationId: number;
|
destNationId: number;
|
||||||
@@ -510,6 +547,7 @@ export class InMemoryTurnWorld {
|
|||||||
cities: City[];
|
cities: City[];
|
||||||
nations: Nation[];
|
nations: Nation[];
|
||||||
troops: Troop[];
|
troops: Troop[];
|
||||||
|
deletedTroops: number[];
|
||||||
diplomacy: TurnDiplomacy[];
|
diplomacy: TurnDiplomacy[];
|
||||||
logs: LogEntryDraft[];
|
logs: LogEntryDraft[];
|
||||||
createdGenerals: TurnGeneral[];
|
createdGenerals: TurnGeneral[];
|
||||||
@@ -540,6 +578,7 @@ export class InMemoryTurnWorld {
|
|||||||
const createdDiplomacy = Array.from(this.createdDiplomacyKeys)
|
const createdDiplomacy = Array.from(this.createdDiplomacyKeys)
|
||||||
.map((key) => this.diplomacy.get(key))
|
.map((key) => this.diplomacy.get(key))
|
||||||
.filter((entry): entry is TurnDiplomacy => Boolean(entry));
|
.filter((entry): entry is TurnDiplomacy => Boolean(entry));
|
||||||
|
const deletedTroops = Array.from(this.deletedTroopIds);
|
||||||
const logs = this.logs.splice(0, this.logs.length);
|
const logs = this.logs.splice(0, this.logs.length);
|
||||||
|
|
||||||
this.dirtyGeneralIds.clear();
|
this.dirtyGeneralIds.clear();
|
||||||
@@ -550,12 +589,14 @@ export class InMemoryTurnWorld {
|
|||||||
this.createdGeneralIds.clear();
|
this.createdGeneralIds.clear();
|
||||||
this.createdTroopIds.clear();
|
this.createdTroopIds.clear();
|
||||||
this.createdDiplomacyKeys.clear();
|
this.createdDiplomacyKeys.clear();
|
||||||
|
this.deletedTroopIds.clear();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generals,
|
generals,
|
||||||
cities,
|
cities,
|
||||||
nations,
|
nations,
|
||||||
troops,
|
troops,
|
||||||
|
deletedTroops,
|
||||||
diplomacy,
|
diplomacy,
|
||||||
logs,
|
logs,
|
||||||
createdGenerals,
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
|
import type { TurnCommandProfile, TurnSchedule } from '@sammo-ts/logic';
|
||||||
|
import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { SystemClock } from '../lifecycle/clock.js';
|
import { SystemClock } from '../lifecycle/clock.js';
|
||||||
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
|
||||||
@@ -10,6 +11,7 @@ import type {
|
|||||||
TurnRunBudget,
|
TurnRunBudget,
|
||||||
} from '../lifecycle/types.js';
|
} from '../lifecycle/types.js';
|
||||||
import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js';
|
import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js';
|
||||||
|
import { buildTurnDaemonStreamKeys, RedisTurnDaemonCommandStream } from '../lifecycle/redisCommandStream.js';
|
||||||
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
|
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
|
||||||
import { createDatabaseTurnHooks } from './databaseHooks.js';
|
import { createDatabaseTurnHooks } from './databaseHooks.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -24,6 +26,7 @@ import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js';
|
|||||||
import { createGatewayProfileGate } from './gatewayProfileGate.js';
|
import { createGatewayProfileGate } from './gatewayProfileGate.js';
|
||||||
import { createReservedTurnHandler } from './reservedTurnHandler.js';
|
import { createReservedTurnHandler } from './reservedTurnHandler.js';
|
||||||
import { createReservedTurnStore } from './reservedTurnStore.js';
|
import { createReservedTurnStore } from './reservedTurnStore.js';
|
||||||
|
import { createTurnDaemonCommandHandler } from './troopCommandHandler.js';
|
||||||
import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
import { loadTurnCommandProfile } from './turnCommandProfile.js';
|
||||||
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
import { loadTurnWorldFromDatabase } from './worldLoader.js';
|
||||||
|
|
||||||
@@ -45,6 +48,8 @@ export interface TurnDaemonRuntimeOptions {
|
|||||||
commandProfile?: TurnCommandProfile;
|
commandProfile?: TurnCommandProfile;
|
||||||
commandProfilePath?: string;
|
commandProfilePath?: string;
|
||||||
adminActionIntervalMs?: number;
|
adminActionIntervalMs?: number;
|
||||||
|
redisUrl?: string;
|
||||||
|
commandStreamStartId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnDaemonRuntime {
|
export interface TurnDaemonRuntime {
|
||||||
@@ -68,6 +73,19 @@ const buildFixedSchedule = (tickMinutes: number): TurnSchedule => ({
|
|||||||
entries: [{ startMinute: 0, tickMinutes }],
|
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 (
|
export const createTurnDaemonRuntime = async (
|
||||||
options: TurnDaemonRuntimeOptions
|
options: TurnDaemonRuntimeOptions
|
||||||
): Promise<TurnDaemonRuntime> => {
|
): Promise<TurnDaemonRuntime> => {
|
||||||
@@ -135,6 +153,10 @@ export const createTurnDaemonRuntime = async (
|
|||||||
|
|
||||||
let hooks: TurnDaemonHooks | undefined;
|
let hooks: TurnDaemonHooks | undefined;
|
||||||
let close = async () => {};
|
let close = async () => {};
|
||||||
|
let redisCommandStream: RedisTurnDaemonCommandStream | null = null;
|
||||||
|
let redisConnector:
|
||||||
|
| ReturnType<typeof createRedisConnector>
|
||||||
|
| null = null;
|
||||||
let pauseGate: (() => Promise<boolean>) | undefined;
|
let pauseGate: (() => Promise<boolean>) | undefined;
|
||||||
let adminActionConsumer: Awaited<
|
let adminActionConsumer: Awaited<
|
||||||
ReturnType<typeof createGatewayAdminActionConsumer>
|
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 ?? {
|
const defaultBudget: TurnRunBudget = options.defaultBudget ?? {
|
||||||
budgetMs: 5000,
|
budgetMs: 5000,
|
||||||
maxGenerals: 200,
|
maxGenerals: 200,
|
||||||
@@ -206,13 +255,15 @@ export const createTurnDaemonRuntime = async (
|
|||||||
const lifecycle = new TurnDaemonLifecycle(
|
const lifecycle = new TurnDaemonLifecycle(
|
||||||
{
|
{
|
||||||
clock,
|
clock,
|
||||||
controlQueue,
|
controlQueue: resolvedControlQueue,
|
||||||
getNextTickTime: (lastTurnTime) =>
|
getNextTickTime: (lastTurnTime) =>
|
||||||
getNextTickTime(lastTurnTime, tickMinutes),
|
getNextTickTime(lastTurnTime, tickMinutes),
|
||||||
stateStore,
|
stateStore,
|
||||||
processor,
|
processor,
|
||||||
hooks,
|
hooks,
|
||||||
pauseGate,
|
pauseGate,
|
||||||
|
commandHandler,
|
||||||
|
commandResponder: redisCommandStream ?? undefined,
|
||||||
},
|
},
|
||||||
{ profile: options.profile, defaultBudget }
|
{ profile: options.profile, defaultBudget }
|
||||||
);
|
);
|
||||||
@@ -232,14 +283,14 @@ export const createTurnDaemonRuntime = async (
|
|||||||
}
|
}
|
||||||
switch (action.action) {
|
switch (action.action) {
|
||||||
case 'RESUME':
|
case 'RESUME':
|
||||||
controlQueue.enqueue({ type: 'resume', reason });
|
resolvedControlQueue.enqueue({ type: 'resume', reason });
|
||||||
return { status: 'APPLIED', detail: 'resume queued' };
|
return { status: 'APPLIED', detail: 'resume queued' };
|
||||||
case 'PAUSE':
|
case 'PAUSE':
|
||||||
controlQueue.enqueue({ type: 'pause', reason });
|
resolvedControlQueue.enqueue({ type: 'pause', reason });
|
||||||
return { status: 'APPLIED', detail: 'pause queued' };
|
return { status: 'APPLIED', detail: 'pause queued' };
|
||||||
case 'STOP':
|
case 'STOP':
|
||||||
case 'SHUTDOWN':
|
case 'SHUTDOWN':
|
||||||
controlQueue.enqueue({ type: 'shutdown', reason });
|
resolvedControlQueue.enqueue({ type: 'shutdown', reason });
|
||||||
return { status: 'APPLIED', detail: 'shutdown queued' };
|
return { status: 'APPLIED', detail: 'shutdown queued' };
|
||||||
default:
|
default:
|
||||||
return { status: 'IGNORED', detail: 'not implemented' };
|
return { status: 'IGNORED', detail: 'not implemented' };
|
||||||
@@ -252,7 +303,7 @@ export const createTurnDaemonRuntime = async (
|
|||||||
return {
|
return {
|
||||||
lifecycle,
|
lifecycle,
|
||||||
world,
|
world,
|
||||||
controlQueue,
|
controlQueue: resolvedControlQueue,
|
||||||
stateStore,
|
stateStore,
|
||||||
processor,
|
processor,
|
||||||
hooks,
|
hooks,
|
||||||
|
|||||||
Reference in New Issue
Block a user