From 2753b85a26078b80357dd8874dd41cbe98dd916d Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sun, 4 Jan 2026 16:05:22 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=84=B4=20=EB=8D=B0=EB=AA=AC=20?= =?UTF-8?q?=EB=AA=85=EB=A0=B9=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=9D=91=EB=8B=B5=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/daemon/inMemoryTransport.ts | 14 ++ app/game-api/src/daemon/redisTransport.ts | 45 ++++ app/game-api/src/daemon/transport.ts | 10 +- app/game-api/src/daemon/types.ts | 37 ++- app/game-api/src/router.ts | 84 +++---- .../src/lifecycle/redisCommandStream.ts | 236 ++++++++++++++++++ .../src/lifecycle/turnDaemonLifecycle.ts | 64 +++++ app/game-engine/src/lifecycle/types.ts | 44 +++- app/game-engine/src/turn/databaseHooks.ts | 6 + app/game-engine/src/turn/inMemoryWorld.ts | 41 +++ .../src/turn/troopCommandHandler.ts | 137 ++++++++++ app/game-engine/src/turn/turnDaemon.ts | 61 ++++- 12 files changed, 716 insertions(+), 63 deletions(-) create mode 100644 app/game-engine/src/lifecycle/redisCommandStream.ts create mode 100644 app/game-engine/src/turn/troopCommandHandler.ts diff --git a/app/game-api/src/daemon/inMemoryTransport.ts b/app/game-api/src/daemon/inMemoryTransport.ts index 425ecd5..d41af63 100644 --- a/app/game-api/src/daemon/inMemoryTransport.ts +++ b/app/game-api/src/daemon/inMemoryTransport.ts @@ -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(); 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 { + const requestId = await this.sendCommand(command); + return this.results.get(requestId) ?? null; + } + async requestStatus(): Promise { 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); + } } diff --git a/app/game-api/src/daemon/redisTransport.ts b/app/game-api/src/daemon/redisTransport.ts index 22f9b18..e31ffb4 100644 --- a/app/game-api/src/daemon/redisTransport.ts +++ b/app/game-api/src/daemon/redisTransport.ts @@ -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 { + 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 { const requestId = randomUUID(); await this.sendCommand({ type: 'getStatus', requestId }); diff --git a/app/game-api/src/daemon/transport.ts b/app/game-api/src/daemon/transport.ts index d05e6fd..1715dd0 100644 --- a/app/game-api/src/daemon/transport.ts +++ b/app/game-api/src/daemon/transport.ts @@ -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; + requestCommand( + command: TurnDaemonCommand, + timeoutMs?: number + ): Promise; requestStatus(timeoutMs?: number): Promise; } diff --git a/app/game-api/src/daemon/types.ts b/app/game-api/src/daemon/types.ts index f0e44dc..4aaa83d 100644 --- a/app/game-api/src/daemon/types.ts +++ b/app/game-api/src/daemon/types.ts @@ -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; diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index c173891..143135e 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -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({ diff --git a/app/game-engine/src/lifecycle/redisCommandStream.ts b/app/game-engine/src/lifecycle/redisCommandStream.ts new file mode 100644 index 0000000..d41c2e4 --- /dev/null +++ b/app/game-engine/src/lifecycle/redisCommandStream.ts @@ -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): Promise; + xRead( + streams: { key: string; id: string }, + options?: { BLOCK?: number; COUNT?: number } + ): Promise; +} + +type RedisStreamReadResponse = Array<{ + name: string; + messages: Array<{ id: string; message: Record }>; +}>; + +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; + 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 { + 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 { + 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 { + await this.publishEvent({ type: 'status', requestId, status }, requestId); + } + + async publishCommandResult( + requestId: string, + result: TurnDaemonCommandResult + ): Promise { + await this.publishEvent({ type: 'commandResult', result }, requestId); + } + + private async publishEvent( + event: TurnDaemonEvent, + requestId?: string + ): Promise { + 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 { + 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; + } +} diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 1e47b42..6adb0c4 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -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; } @@ -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; 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 + ): Promise { + 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 + ); } } diff --git a/app/game-engine/src/lifecycle/types.ts b/app/game-engine/src/lifecycle/types.ts index 51ea5aa..6c7d164 100644 --- a/app/game-engine/src/lifecycle/types.ts +++ b/app/game-engine/src/lifecycle/types.ts @@ -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; +} + +export interface TurnDaemonCommandResponder { + publishStatus(requestId: string, status: TurnDaemonStatus): Promise; + publishCommandResult( + requestId: string, + result: TurnDaemonCommandResult + ): Promise; +} export type { Clock } from '@sammo-ts/common'; diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 8174f3c..2a63e53 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -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 diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 2005680..8f65e59 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -189,6 +189,7 @@ export class InMemoryTurnWorld { private readonly createdGeneralIds = new Set(); private readonly createdTroopIds = new Set(); private readonly createdDiplomacyKeys = new Set(); + private readonly deletedTroopIds = new Set(); private readonly logs: LogEntryDraft[] = []; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; @@ -297,6 +298,42 @@ export class InMemoryTurnWorld { })); } + updateGeneral( + id: number, + patch: Partial + ): 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 | 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, diff --git a/app/game-engine/src/turn/troopCommandHandler.ts b/app/game-engine/src/turn/troopCommandHandler.ts new file mode 100644 index 0000000..04b55fb --- /dev/null +++ b/app/game-engine/src/turn/troopCommandHandler.ts @@ -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 => { + if (!hooks?.flushChanges) { + return; + } + await hooks.flushChanges(buildFlushResult(world)); +}; + +export const createTurnDaemonCommandHandler = (options: { + world: InMemoryTurnWorld; + hooks?: TurnDaemonHooks; +}): TurnDaemonCommandHandler => { + return { + handle: async (command): Promise => { + 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; + }, + }; +}; diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index dc220f7..9807af5 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -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 => { @@ -135,6 +153,10 @@ export const createTurnDaemonRuntime = async ( let hooks: TurnDaemonHooks | undefined; let close = async () => {}; + let redisCommandStream: RedisTurnDaemonCommandStream | null = null; + let redisConnector: + | ReturnType + | null = null; let pauseGate: (() => Promise) | undefined; let adminActionConsumer: Awaited< ReturnType @@ -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,