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 isRecord = (value: unknown): value is Record => Boolean(value && typeof value === 'object' && !Array.isArray(value)); 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 'auctionFinalize': { if (typeof command.auctionId !== 'number') { return null; } return { type: 'auctionFinalize', requestId: envelope.requestId, auctionId: command.auctionId, }; } 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 'dieOnPrestart': { if (typeof command.generalId !== 'number') { return null; } return { type: 'dieOnPrestart', requestId: envelope.requestId, generalId: command.generalId, }; } case 'buildNationCandidate': { if (typeof command.generalId !== 'number') { return null; } return { type: 'buildNationCandidate', requestId: envelope.requestId, generalId: command.generalId, }; } case 'instantRetreat': { if (typeof command.generalId !== 'number') { return null; } return { type: 'instantRetreat', requestId: envelope.requestId, generalId: command.generalId, }; } case 'vacation': { if (typeof command.generalId !== 'number') { return null; } return { type: 'vacation', requestId: envelope.requestId, generalId: command.generalId, }; } case 'setMySetting': { if (typeof command.generalId !== 'number' || !command.settings || typeof command.settings !== 'object') { return null; } return { type: 'setMySetting', requestId: envelope.requestId, generalId: command.generalId, settings: command.settings, }; } case 'dropItem': { if (typeof command.generalId !== 'number' || typeof command.itemType !== 'string') { return null; } return { type: 'dropItem', requestId: envelope.requestId, generalId: command.generalId, itemType: command.itemType, }; } case 'changePermission': { if ( typeof command.generalId !== 'number' || typeof command.isAmbassador !== 'boolean' || !Array.isArray(command.targetGeneralIds) ) { return null; } const targetGeneralIds = command.targetGeneralIds.filter((id) => typeof id === 'number'); if (targetGeneralIds.length === 0) { return null; } return { type: 'changePermission', requestId: envelope.requestId, generalId: command.generalId, isAmbassador: command.isAmbassador, targetGeneralIds, }; } case 'kick': { if (typeof command.generalId !== 'number' || typeof command.destGeneralId !== 'number') { return null; } return { type: 'kick', requestId: envelope.requestId, generalId: command.generalId, destGeneralId: command.destGeneralId, }; } case 'appoint': { if ( typeof command.generalId !== 'number' || typeof command.destGeneralId !== 'number' || typeof command.destCityId !== 'number' || typeof command.officerLevel !== 'number' ) { return null; } return { type: 'appoint', requestId: envelope.requestId, generalId: command.generalId, destGeneralId: command.destGeneralId, destCityId: command.destCityId, officerLevel: command.officerLevel, }; } case 'tournamentRefund': { if (!Array.isArray(command.refunds)) { return null; } const refunds = command.refunds .filter((entry) => entry && typeof entry.generalId === 'number' && typeof entry.amount === 'number' ) .map((entry) => ({ generalId: entry.generalId, amount: entry.amount, })); if (refunds.length === 0) { return null; } return { type: 'tournamentRefund', requestId: envelope.requestId, bettingId: typeof command.bettingId === 'number' ? command.bettingId : undefined, reason: typeof command.reason === 'string' ? command.reason : undefined, refunds, }; } case 'tournamentBettingPayout': { if (!Array.isArray(command.payouts)) { return null; } const payouts = command.payouts .filter((entry) => entry && typeof entry.generalId === 'number' && typeof entry.amount === 'number' ) .map((entry) => ({ generalId: entry.generalId, amount: entry.amount, })); if (payouts.length === 0) { return null; } return { type: 'tournamentBettingPayout', requestId: envelope.requestId, bettingId: typeof command.bettingId === 'number' ? command.bettingId : undefined, reason: typeof command.reason === 'string' ? command.reason : undefined, payouts, }; } case 'tournamentReward': { if (typeof command.winnerId !== 'number' || typeof command.runnerUpId !== 'number') { return null; } if (!Array.isArray(command.top16) || !Array.isArray(command.top8) || !Array.isArray(command.top4)) { return null; } const normalizeIds = (list: unknown[]): number[] => list.filter((entry): entry is number => typeof entry === 'number' && Number.isFinite(entry)); const top16 = normalizeIds(command.top16); const top8 = normalizeIds(command.top8); const top4 = normalizeIds(command.top4); if (top16.length === 0) { return null; } return { type: 'tournamentReward', requestId: envelope.requestId, tournamentType: typeof command.tournamentType === 'number' ? command.tournamentType : 0, winnerId: command.winnerId, runnerUpId: command.runnerUpId, top16, top8, top4, }; } case 'setNationMeta': { if (typeof command.nationId !== 'number' || !command.updates || typeof command.updates !== 'object') { return null; } return { type: 'setNationMeta', requestId: envelope.requestId, nationId: command.nationId, updates: command.updates as Record, expectedUpdatedAt: typeof command.expectedUpdatedAt === 'string' ? command.expectedUpdatedAt : undefined, }; } case 'adjustGeneralResources': { if (!Array.isArray(command.adjustments)) { return null; } const adjustments = command.adjustments .filter((entry) => entry && typeof entry.generalId === 'number') .map((entry) => ({ generalId: entry.generalId, goldDelta: typeof entry.goldDelta === 'number' ? entry.goldDelta : undefined, riceDelta: typeof entry.riceDelta === 'number' ? entry.riceDelta : undefined, })) .filter((entry) => entry.goldDelta !== undefined || entry.riceDelta !== undefined); if (adjustments.length === 0) { return null; } return { type: 'adjustGeneralResources', requestId: envelope.requestId, reason: typeof command.reason === 'string' ? command.reason : undefined, adjustments, }; } case 'patchGeneral': { if (typeof command.generalId !== 'number' || !command.patch || typeof command.patch !== 'object') { return null; } return { type: 'patchGeneral', requestId: envelope.requestId, generalId: command.generalId, patch: { meta: isRecord(command.patch.meta) ? command.patch.meta : undefined, turnTime: typeof command.patch.turnTime === 'string' ? command.patch.turnTime : undefined, stats: isRecord(command.patch.stats) ? { leadership: typeof command.patch.stats.leadership === 'number' ? command.patch.stats.leadership : undefined, strength: typeof command.patch.stats.strength === 'number' ? command.patch.stats.strength : undefined, intelligence: typeof command.patch.stats.intelligence === 'number' ? command.patch.stats.intelligence : undefined, } : undefined, specialWar: typeof command.patch.specialWar === 'string' ? command.patch.specialWar : undefined, }, }; } case 'auctionBid': { if ( typeof command.auctionId !== 'number' || typeof command.generalId !== 'number' || typeof command.amount !== 'number' ) { return null; } return { type: 'auctionBid', requestId: envelope.requestId, auctionId: command.auctionId, generalId: command.generalId, amount: command.amount, tryExtendCloseDate: typeof command.tryExtendCloseDate === 'boolean' ? command.tryExtendCloseDate : undefined, }; } 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()); const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000); if (deadlineMs !== null && blockMs === 0) { return null; } const remote = await this.readRemoteCommands(cappedBlockMs); 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; } }