import { createGamePostgresConnector, type InputJsonValue, type TurnEngineDatabaseClient } from '@sammo-ts/infra'; import { isRecord } from '@sammo-ts/common'; export interface ReservedTurnEntry { action: string; args: Record; } export interface ReservedTurnStoreOptions { databaseUrl: string; maxGeneralTurns?: number; maxNationTurns?: number; } export interface ReservedTurnStoreHandle { store: InMemoryReservedTurnStore; close(): Promise; } const DEFAULT_TURN_ACTION = '휴식'; const DEFAULT_GENERAL_TURNS = 30; const DEFAULT_NATION_TURNS = 12; 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; export interface ReservedTurnChanges { generalIds: number[]; generalInitializationIds: number[]; nationKeys: string[]; nationInitializationKeys: string[]; } 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 maxGeneralTurns: number; private readonly maxNationTurns: number; constructor( private readonly prisma: ReservedTurnDatabaseClient, options: { maxGeneralTurns: number; maxNationTurns: number } ) { this.maxGeneralTurns = options.maxGeneralTurns; this.maxNationTurns = options.maxNationTurns; } 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)); } } 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), nationKeys: Array.from(this.dirtyNationKeys), nationInitializationKeys: Array.from(this.pendingNationInitializationKeys), }; } 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 key of changes.nationKeys) { this.dirtyNationKeys.delete(key); } for (const key of changes.nationInitializationKeys) { this.pendingNationInitializationKeys.delete(key); } } async persistChanges(prisma: ReservedTurnDatabaseClient, changes: ReservedTurnChanges): Promise { for (const generalId of changes.generalIds) { const turns = this.getGeneralTurns(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)), })), }); } 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); 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)), })), }); } 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, }); } } 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, }); await store.loadAll(); return { store, close: () => connector.disconnect(), }; };