import { createGamePostgresConnector, type InputJsonValue, type TurnEngineDatabaseClient } from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; import { randomUUID } from 'node:crypto'; export interface ReservedTurnEntry { action: string; args: Record; } export interface ReservedTurnStoreOptions { databaseUrl: string; maxGeneralTurns?: number; maxNationTurns?: number; leaseOwner?: string; leaseDurationMs?: number; } export interface ReservedTurnStoreHandle { store: InMemoryReservedTurnStore; close(): Promise; } const DEFAULT_TURN_ACTION = '휴식'; const DEFAULT_GENERAL_TURNS = 30; const DEFAULT_NATION_TURNS = 12; const DEFAULT_LEASE_DURATION_MS = 5 * 60_000; const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; const normalizeAction = (action: string | null | undefined): string => action && action.length > 0 ? action : DEFAULT_TURN_ACTION; const normalizeArgs = (args: unknown): Record => (isRecord(args) ? args : {}); const createDefaultEntry = (): ReservedTurnEntry => ({ action: DEFAULT_TURN_ACTION, args: {}, }); const buildDefaultTurns = (length: number): ReservedTurnEntry[] => Array.from({ length }, () => createDefaultEntry()); const applyShift = (turns: ReservedTurnEntry[], amount: number): ReservedTurnEntry[] => { if (amount === 0) { return turns.slice(); } if (amount > 0) { const shift = Math.min(turns.length, amount); const padding = Array.from({ length: shift }, () => createDefaultEntry()); const sliced = turns.slice(0, Math.max(0, turns.length - shift)); return padding.concat(sliced); } const shift = Math.min(turns.length, Math.abs(amount)); const padding = Array.from({ length: shift }, () => createDefaultEntry()); const sliced = turns.slice(shift); return sliced.concat(padding); }; const buildTurnListFromRows = ( rows: Array<{ turnIdx: number; actionCode: string; arg: unknown }>, maxTurns: number ): ReservedTurnEntry[] => { const result = buildDefaultTurns(maxTurns); for (const row of rows) { if (row.turnIdx < 0 || row.turnIdx >= maxTurns) { continue; } result[row.turnIdx] = { action: normalizeAction(row.actionCode), args: normalizeArgs(row.arg), }; } return result; }; const buildNationKey = (nationId: number, officerLevel: number): string => `${nationId}:${officerLevel}`; type ReservedTurnDatabaseClient = Pick & { generalTurnRevision?: Pick< NonNullable, 'findUnique' | 'createMany' | 'updateMany' >; nationTurnRevision?: Pick< NonNullable, 'findUnique' | 'createMany' | 'updateMany' >; }; export interface ReservedTurnChanges { generalIds: number[]; generalInitializationIds: number[]; generalLeaseIds: number[]; nationKeys: string[]; nationInitializationKeys: string[]; nationLeaseKeys: string[]; } export class ReservedTurnLeaseConflictError extends Error { constructor(readonly queueKey: string) { super(`Reserved turn queue lease conflict: ${queueKey}.`); this.name = 'ReservedTurnLeaseConflictError'; } } export class InMemoryReservedTurnStore { private readonly generalTurns = new Map(); private readonly nationTurns = new Map(); private readonly dirtyGeneralIds = new Set(); private readonly dirtyNationKeys = new Set(); private readonly pendingGeneralInitializationIds = new Set(); private readonly pendingNationInitializationKeys = new Set(); private readonly leasedGeneralIds = new Set(); private readonly leasedNationKeys = new Set(); private readonly maxGeneralTurns: number; private readonly maxNationTurns: number; private readonly leaseOwner: string; private readonly leaseDurationMs: number; constructor( private readonly prisma: ReservedTurnDatabaseClient, options: { maxGeneralTurns: number; maxNationTurns: number; leaseOwner?: string; leaseDurationMs?: number; } ) { this.maxGeneralTurns = options.maxGeneralTurns; this.maxNationTurns = options.maxNationTurns; this.leaseOwner = options.leaseOwner ?? randomUUID(); this.leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS; } async loadAll(): Promise { const [generalRows, nationRows] = await Promise.all([ this.prisma.generalTurn.findMany(), this.prisma.nationTurn.findMany(), ]); const generalGroups = new Map(); for (const row of generalRows) { const list = generalGroups.get(row.generalId); if (list) { list.push(row); } else { generalGroups.set(row.generalId, [row]); } } for (const [generalId, rows] of generalGroups.entries()) { this.generalTurns.set(generalId, buildTurnListFromRows(rows, this.maxGeneralTurns)); } const nationGroups = new Map(); for (const row of nationRows) { const key = buildNationKey(row.nationId, row.officerLevel); const list = nationGroups.get(key); if (list) { list.push(row); } else { nationGroups.set(key, [row]); } } for (const [key, rows] of nationGroups.entries()) { this.nationTurns.set(key, buildTurnListFromRows(rows, this.maxNationTurns)); } } private getLeaseExpiresAt(): Date { return new Date(Date.now() + this.leaseDurationMs); } private async acquireGeneralLease(generalId: number): Promise { const revisionStore = this.prisma.generalTurnRevision; if (!revisionStore) { return; } const now = new Date(); const leaseExpiresAt = this.getLeaseExpiresAt(); let claimed = await revisionStore.updateMany({ where: { generalId, OR: [ { leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }, ], }, data: { leaseOwner: this.leaseOwner, leaseExpiresAt, }, }); if (claimed.count === 0) { claimed = await revisionStore.createMany({ data: [{ generalId, revision: 0, leaseOwner: this.leaseOwner, leaseExpiresAt }], skipDuplicates: true, }); if (claimed.count === 0) { claimed = await revisionStore.updateMany({ where: { generalId, OR: [ { leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }, ], }, data: { leaseOwner: this.leaseOwner, leaseExpiresAt, }, }); } } if (claimed.count !== 1) { throw new ReservedTurnLeaseConflictError(`general:${generalId}`); } this.leasedGeneralIds.add(generalId); } private async acquireNationLease(nationId: number, officerLevel: number): Promise { const revisionStore = this.prisma.nationTurnRevision; if (!revisionStore) { return; } const now = new Date(); const leaseExpiresAt = this.getLeaseExpiresAt(); let claimed = await revisionStore.updateMany({ where: { nationId, officerLevel, OR: [ { leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }, ], }, data: { leaseOwner: this.leaseOwner, leaseExpiresAt, }, }); if (claimed.count === 0) { claimed = await revisionStore.createMany({ data: [{ nationId, officerLevel, revision: 0, leaseOwner: this.leaseOwner, leaseExpiresAt }], skipDuplicates: true, }); if (claimed.count === 0) { claimed = await revisionStore.updateMany({ where: { nationId, officerLevel, OR: [ { leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: now } }, ], }, data: { leaseOwner: this.leaseOwner, leaseExpiresAt, }, }); } } if (claimed.count !== 1) { throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`); } this.leasedNationKeys.add(buildNationKey(nationId, officerLevel)); } private async releaseGeneralLease(generalId: number): Promise { await this.prisma.generalTurnRevision?.updateMany({ where: { generalId, leaseOwner: this.leaseOwner }, data: { leaseOwner: null, leaseExpiresAt: null }, }); this.leasedGeneralIds.delete(generalId); } private async releaseNationLease(nationId: number, officerLevel: number): Promise { await this.prisma.nationTurnRevision?.updateMany({ where: { nationId, officerLevel, leaseOwner: this.leaseOwner }, data: { leaseOwner: null, leaseExpiresAt: null }, }); this.leasedNationKeys.delete(buildNationKey(nationId, officerLevel)); } async prepareTurnsForExecution( generalId: number, nation?: { nationId: number; officerLevel: number } ): Promise { const hadGeneralLease = this.leasedGeneralIds.has(generalId); const nationKey = nation ? buildNationKey(nation.nationId, nation.officerLevel) : null; const hadNationLease = nationKey ? this.leasedNationKeys.has(nationKey) : false; try { await this.acquireGeneralLease(generalId); if (nation) { await this.acquireNationLease(nation.nationId, nation.officerLevel); } await Promise.all([ this.refreshGeneralTurns(generalId), nation ? this.refreshNationTurns(nation.nationId, nation.officerLevel) : Promise.resolve(), ]); } catch (error) { if (nation && nationKey !== null && !hadNationLease && this.leasedNationKeys.has(nationKey)) { await this.releaseNationLease(nation.nationId, nation.officerLevel); } if (!hadGeneralLease && this.leasedGeneralIds.has(generalId)) { await this.releaseGeneralLease(generalId); } throw error; } } async refreshGeneralTurns(generalId: number): Promise { if (this.dirtyGeneralIds.has(generalId)) { return; } const rows = await this.prisma.generalTurn.findMany({ where: { generalId }, orderBy: [{ turnIdx: 'asc' }], }); this.generalTurns.set(generalId, buildTurnListFromRows(rows, this.maxGeneralTurns)); } async prefetchGeneralTurns(generalIds: number[]): Promise { const targetIds = Array.from(new Set(generalIds)).filter((generalId) => !this.dirtyGeneralIds.has(generalId)); if (targetIds.length === 0) { return; } const rows = await this.prisma.generalTurn.findMany({ where: { generalId: { in: targetIds } }, orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], }); const grouped = new Map(); for (const row of rows) { const list = grouped.get(row.generalId); if (list) { list.push(row); } else { grouped.set(row.generalId, [row]); } } for (const generalId of targetIds) { const list = grouped.get(generalId) ?? []; this.generalTurns.set(generalId, buildTurnListFromRows(list, this.maxGeneralTurns)); } } async refreshNationTurns(nationId: number, officerLevel: number): Promise { const key = buildNationKey(nationId, officerLevel); if (this.dirtyNationKeys.has(key) || this.pendingNationInitializationKeys.has(key)) { return; } const rows = await this.prisma.nationTurn.findMany({ where: { nationId, officerLevel }, orderBy: [{ turnIdx: 'asc' }], }); this.nationTurns.set(key, buildTurnListFromRows(rows, this.maxNationTurns)); } getGeneralTurns(generalId: number): ReservedTurnEntry[] { const current = this.generalTurns.get(generalId); if (current) { return current; } const created = buildDefaultTurns(this.maxGeneralTurns); this.generalTurns.set(generalId, created); return created; } getNationTurns(nationId: number, officerLevel: number): ReservedTurnEntry[] { const key = buildNationKey(nationId, officerLevel); const current = this.nationTurns.get(key); if (current) { return current; } const created = buildDefaultTurns(this.maxNationTurns); this.nationTurns.set(key, created); return created; } getGeneralTurn(generalId: number, turnIdx: number): ReservedTurnEntry { const list = this.getGeneralTurns(generalId); return list[turnIdx] ?? createDefaultEntry(); } getNationTurn(nationId: number, officerLevel: number, turnIdx: number): ReservedTurnEntry { const list = this.getNationTurns(nationId, officerLevel); return list[turnIdx] ?? createDefaultEntry(); } ensureGeneralTurns(generalId: number): void { this.getGeneralTurns(generalId); this.pendingGeneralInitializationIds.add(generalId); } replaceGeneralTurns(generalId: number, entry: ReservedTurnEntry): void { this.generalTurns.set( generalId, Array.from({ length: this.maxGeneralTurns }, () => ({ action: normalizeAction(entry.action), args: normalizeArgs(entry.args), })) ); this.pendingGeneralInitializationIds.delete(generalId); this.dirtyGeneralIds.add(generalId); } ensureNationTurns(nationId: number, officerLevel: number): void { const key = buildNationKey(nationId, officerLevel); this.getNationTurns(nationId, officerLevel); this.pendingNationInitializationKeys.add(key); } shiftGeneralTurns(generalId: number, amount: number): void { const list = this.getGeneralTurns(generalId); this.generalTurns.set(generalId, applyShift(list, amount)); this.dirtyGeneralIds.add(generalId); } shiftNationTurns(nationId: number, officerLevel: number, amount: number): void { const key = buildNationKey(nationId, officerLevel); const list = this.getNationTurns(nationId, officerLevel); this.nationTurns.set(key, applyShift(list, amount)); this.dirtyNationKeys.add(key); } peekDirtyState(): ReservedTurnChanges { return { generalIds: Array.from(this.dirtyGeneralIds), generalInitializationIds: Array.from(this.pendingGeneralInitializationIds), generalLeaseIds: Array.from(this.leasedGeneralIds), nationKeys: Array.from(this.dirtyNationKeys), nationInitializationKeys: Array.from(this.pendingNationInitializationKeys), nationLeaseKeys: Array.from(this.leasedNationKeys), }; } acknowledgeDirtyState(changes: ReservedTurnChanges): void { for (const generalId of changes.generalIds) { this.dirtyGeneralIds.delete(generalId); } for (const generalId of changes.generalInitializationIds) { this.pendingGeneralInitializationIds.delete(generalId); } for (const generalId of changes.generalLeaseIds) { this.leasedGeneralIds.delete(generalId); } for (const key of changes.nationKeys) { this.dirtyNationKeys.delete(key); } for (const key of changes.nationInitializationKeys) { this.pendingNationInitializationKeys.delete(key); } for (const key of changes.nationLeaseKeys) { this.leasedNationKeys.delete(key); } } private async claimGeneralFlushLease( prisma: ReservedTurnDatabaseClient, generalId: number ): Promise { const revisionStore = prisma.generalTurnRevision; if (!revisionStore) { return false; } const leaseExpiresAt = this.getLeaseExpiresAt(); const where = this.leasedGeneralIds.has(generalId) ? { generalId, leaseOwner: this.leaseOwner } : { generalId, OR: [ { leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }, ], }; let claimed = await revisionStore.updateMany({ where, data: { leaseOwner: this.leaseOwner, leaseExpiresAt, }, }); if (claimed.count === 0 && !this.leasedGeneralIds.has(generalId)) { claimed = await revisionStore.createMany({ data: [{ generalId, revision: 0, leaseOwner: this.leaseOwner, leaseExpiresAt }], skipDuplicates: true, }); if (claimed.count === 0) { claimed = await revisionStore.updateMany({ where, data: { leaseOwner: this.leaseOwner, leaseExpiresAt, }, }); } } if (claimed.count !== 1) { throw new ReservedTurnLeaseConflictError(`general:${generalId}`); } return true; } private async finalizeGeneralFlush( prisma: ReservedTurnDatabaseClient, generalId: number, claimedLease: boolean ): Promise { const revisionStore = prisma.generalTurnRevision; if (!revisionStore) { return; } if (!claimedLease) { throw new ReservedTurnLeaseConflictError(`general:${generalId}`); } const finalized = await revisionStore.updateMany({ where: { generalId, leaseOwner: this.leaseOwner }, data: { revision: { increment: 1 }, leaseOwner: null, leaseExpiresAt: null, }, }); if (finalized.count !== 1) { throw new ReservedTurnLeaseConflictError(`general:${generalId}`); } } private async claimNationFlushLease( prisma: ReservedTurnDatabaseClient, nationId: number, officerLevel: number ): Promise { const revisionStore = prisma.nationTurnRevision; const key = buildNationKey(nationId, officerLevel); if (!revisionStore) { return false; } const leaseExpiresAt = this.getLeaseExpiresAt(); const where = this.leasedNationKeys.has(key) ? { nationId, officerLevel, leaseOwner: this.leaseOwner } : { nationId, officerLevel, OR: [ { leaseOwner: this.leaseOwner }, { leaseOwner: null }, { leaseExpiresAt: { lte: new Date() } }, ], }; let claimed = await revisionStore.updateMany({ where, data: { leaseOwner: this.leaseOwner, leaseExpiresAt, }, }); if (claimed.count === 0 && !this.leasedNationKeys.has(key)) { claimed = await revisionStore.createMany({ data: [{ nationId, officerLevel, revision: 0, leaseOwner: this.leaseOwner, leaseExpiresAt }], skipDuplicates: true, }); if (claimed.count === 0) { claimed = await revisionStore.updateMany({ where, data: { leaseOwner: this.leaseOwner, leaseExpiresAt, }, }); } } if (claimed.count !== 1) { throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`); } return true; } private async finalizeNationFlush( prisma: ReservedTurnDatabaseClient, nationId: number, officerLevel: number, claimedLease: boolean ): Promise { const revisionStore = prisma.nationTurnRevision; if (!revisionStore) { return; } if (!claimedLease) { throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`); } const finalized = await revisionStore.updateMany({ where: { nationId, officerLevel, leaseOwner: this.leaseOwner }, data: { revision: { increment: 1 }, leaseOwner: null, leaseExpiresAt: null, }, }); if (finalized.count !== 1) { throw new ReservedTurnLeaseConflictError(`nation:${nationId}:${officerLevel}`); } } async persistChanges(prisma: ReservedTurnDatabaseClient, changes: ReservedTurnChanges): Promise { for (const generalId of changes.generalIds) { const turns = this.getGeneralTurns(generalId); const claimedLease = await this.claimGeneralFlushLease(prisma, generalId); await prisma.generalTurn.deleteMany({ where: { generalId } }); await prisma.generalTurn.createMany({ data: turns.map((entry, turnIdx) => ({ generalId, turnIdx, actionCode: normalizeAction(entry.action), arg: asJson(normalizeArgs(entry.args)), })), }); await this.finalizeGeneralFlush(prisma, generalId, claimedLease); } for (const generalId of changes.generalInitializationIds) { if (changes.generalIds.includes(generalId)) { continue; } const turns = this.getGeneralTurns(generalId); await prisma.generalTurn.createMany({ data: turns.map((entry, turnIdx) => ({ generalId, turnIdx, actionCode: normalizeAction(entry.action), arg: asJson(normalizeArgs(entry.args)), })), skipDuplicates: true, }); } for (const key of changes.nationKeys) { const [nationIdRaw, officerLevelRaw] = key.split(':'); const nationId = Number(nationIdRaw); const officerLevel = Number(officerLevelRaw); const turns = this.getNationTurns(nationId, officerLevel); const claimedLease = await this.claimNationFlushLease(prisma, nationId, officerLevel); await prisma.nationTurn.deleteMany({ where: { nationId, officerLevel }, }); await prisma.nationTurn.createMany({ data: turns.map((entry, turnIdx) => ({ nationId, officerLevel, turnIdx, actionCode: normalizeAction(entry.action), arg: asJson(normalizeArgs(entry.args)), })), }); await this.finalizeNationFlush(prisma, nationId, officerLevel, claimedLease); } for (const key of changes.nationInitializationKeys) { if (changes.nationKeys.includes(key)) { continue; } const [nationIdRaw, officerLevelRaw] = key.split(':'); const nationId = Number(nationIdRaw); const officerLevel = Number(officerLevelRaw); const turns = this.getNationTurns(nationId, officerLevel); await prisma.nationTurn.createMany({ data: turns.map((entry, turnIdx) => ({ nationId, officerLevel, turnIdx, actionCode: normalizeAction(entry.action), arg: asJson(normalizeArgs(entry.args)), })), skipDuplicates: true, }); } for (const generalId of changes.generalLeaseIds) { if (changes.generalIds.includes(generalId)) { continue; } await prisma.generalTurnRevision?.updateMany({ where: { generalId, leaseOwner: this.leaseOwner }, data: { leaseOwner: null, leaseExpiresAt: null }, }); } for (const key of changes.nationLeaseKeys) { if (changes.nationKeys.includes(key)) { continue; } const [nationIdRaw, officerLevelRaw] = key.split(':'); await prisma.nationTurnRevision?.updateMany({ where: { nationId: Number(nationIdRaw), officerLevel: Number(officerLevelRaw), leaseOwner: this.leaseOwner, }, data: { leaseOwner: null, leaseExpiresAt: null }, }); } } async flushChanges(): Promise { const changes = this.peekDirtyState(); await this.persistChanges(this.prisma, changes); this.acknowledgeDirtyState(changes); } } export const createReservedTurnStore = async (options: ReservedTurnStoreOptions): Promise => { const connector = createGamePostgresConnector({ url: options.databaseUrl }); await connector.connect(); const store = new InMemoryReservedTurnStore(connector.prisma, { maxGeneralTurns: options.maxGeneralTurns ?? DEFAULT_GENERAL_TURNS, maxNationTurns: options.maxNationTurns ?? DEFAULT_NATION_TURNS, leaseOwner: options.leaseOwner, leaseDurationMs: options.leaseDurationMs, }); await store.loadAll(); return { store, close: () => connector.disconnect(), }; };