import { randomUUID } from 'node:crypto'; import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra'; import type { TurnDaemonTransport } from './transport.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js'; import { loadCurrentGameTime } from '../services/gameClock.js'; const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); const stableJson = (value: unknown): string => { if (Array.isArray(value)) { return `[${value.map(stableJson).join(',')}]`; } if (value && typeof value === 'object') { return `{${Object.entries(value) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`) .join(',')}}`; } return JSON.stringify(value) ?? 'null'; }; const commandIdentityJson = (value: unknown): string => { if ( value && typeof value === 'object' && !Array.isArray(value) && Reflect.get(value, 'type') === 'npcPossessGeneral' ) { const { acceptedGameAt: _acceptedGameAt, ...identity } = value as Record; return stableJson(identity); } return stableJson(value); }; export class ConflictingTurnDaemonCommandError extends Error { constructor(readonly requestId: string) { super(`Engine input event ${requestId} already exists with a different payload.`); this.name = 'ConflictingTurnDaemonCommandError'; } } export class FailedTurnDaemonCommandError extends Error { constructor( readonly requestId: string, readonly storedError: string | null ) { super(storedError ?? `Engine input event ${requestId} failed.`); this.name = 'FailedTurnDaemonCommandError'; } } export class RejectedNpcPossessionCommandError extends Error { constructor( readonly code: 'PRECONDITION_FAILED', message: string ) { super(message); this.name = 'RejectedNpcPossessionCommandError'; } } export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { constructor( private readonly db: DatabaseClient, private readonly requestTimeoutMs: number ) {} async sendCommand(command: TurnDaemonCommand): Promise { const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID(); const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand; if (durableCommand.type === 'npcPossessGeneral') { delete durableCommand.acceptedGameAt; } if (command.type === 'npcPossessGeneral') { const existing = await this.db.inputEvent.findUnique({ where: { requestId }, select: { eventType: true, payload: true }, }); if (existing) { if ( existing.eventType !== command.type || commandIdentityJson(existing.payload) !== commandIdentityJson(durableCommand) ) { throw new ConflictingTurnDaemonCommandError(requestId); } return requestId; } } try { if (command.type === 'npcPossessGeneral' && this.db.$transaction) { const rejectionReason = await this.db.$transaction(async (transaction) => { await transaction.$executeRaw( GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))` ); await transaction.$executeRaw( GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${command.userId}`}, 1))` ); const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000); const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now; const token = await transaction.npcSelectionToken.findFirst({ where: { ownerUserId: command.userId, nonce: command.tokenNonce, validUntil: { gte: acceptedGameAt }, }, select: { pickResult: true }, }); if (!token) { return '유효한 장수 목록이 없습니다.'; } if ( !token.pickResult || typeof token.pickResult !== 'object' || Array.isArray(token.pickResult) || !Object.hasOwn(token.pickResult, String(command.generalId)) ) { return '선택한 장수가 목록에 없습니다.'; } const acceptedCommand: Extract = { ...(durableCommand as Extract), acceptedGameAt: acceptedGameAt.toISOString(), }; await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt); return null; }); if (rejectionReason) { throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason); } } else { await this.createInputEvent(this.db, durableCommand, requestId); } } catch (error) { if (error instanceof RejectedNpcPossessionCommandError) { throw error; } const isUniqueConflict = typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; if (!isUniqueConflict) { throw error; } const existing = await this.db.inputEvent.findUniqueOrThrow({ where: { requestId }, select: { eventType: true, payload: true }, }); if ( existing.eventType !== command.type || commandIdentityJson(existing.payload) !== commandIdentityJson(durableCommand) ) { throw new ConflictingTurnDaemonCommandError(requestId); } } return requestId; } private async createInputEvent( db: DatabaseClient, command: TurnDaemonCommand, requestId: string, createdAt?: Date ): Promise { await db.inputEvent.create({ data: { requestId, target: 'ENGINE', eventType: command.type, payload: asJson(command), actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null, ...(createdAt ? { createdAt } : {}), }, }); } async requestCommand(command: TurnDaemonCommand, timeoutMs?: number): Promise { const requestId = await this.sendCommand(command); return this.waitForResult(requestId, timeoutMs); } async requestStatus(timeoutMs?: number): Promise { const requestId = await this.sendCommand({ type: 'getStatus', requestId: randomUUID() }); const payload = await this.waitForResult<{ status: TurnDaemonStatus }>(requestId, timeoutMs); return payload?.status ?? null; } private async waitForResult(requestId: string, timeoutMs?: number): Promise { const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs); while (Date.now() < deadline) { const event = await this.db.inputEvent.findUnique({ where: { requestId }, select: { status: true, result: true, error: true }, }); if (event?.status === 'SUCCEEDED') { return event.result as T; } if (event?.status === 'FAILED') { throw new FailedTurnDaemonCommandError(requestId, event.error); } await delay(Math.min(50, Math.max(1, deadline - Date.now()))); } return null; } }