import { createGamePostgresConnector, GamePrisma, type InputJsonValue, type TurnEngineCityUpdateInput, type TurnEngineDiplomacyCreateManyInput, type TurnEngineDiplomacyUpdateInput, type TurnEngineGeneralCreateManyInput, type TurnEngineGeneralUpdateInput, type TurnEngineLogEntryCreateManyInput, type TurnEngineNationUpdateInput, type TurnEngineTroopCreateManyInput, type TurnEngineTroopUpdateInput, type TurnEngineWorldStateUpdateInput, } from '@sammo-ts/infra'; import { finalizeLogEntry, LogCategory, LogFormat, LogScope, sendMessage, type LogEntryDraft, type MessageRecordDraft, } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; import type { TurnDaemonCommandResult, TurnDaemonHooks } from '../lifecycle/types.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryReservedTurnStore, ReservedTurnChanges } from './reservedTurnStore.js'; import { buildDiplomacyMeta } from '@sammo-ts/logic'; import { ensureItemInventory, withSerializedItemInventory } from '@sammo-ts/logic/items/index.js'; import { persistGeneralLifecycleEvents } from './generalTurnLifecyclePersistence.js'; import type { DatabaseTurnDaemonLease } from '../lifecycle/databaseTurnDaemonLease.js'; import { calculateNationBettingRewards } from '../betting/nationBettingSettlement.js'; import type { NationBettingCandidate, PendingNationBettingFinish, PendingNationBettingOpen } from './types.js'; import { buildPersistedRankRows } from './rankData.js'; import { persistUnificationFinalization } from './unificationPersistence.js'; import { buildOldNationArchiveData } from './oldNationArchive.js'; import { persistYearbookSnapshot } from './yearbookPersistence.js'; export interface DatabaseTurnHooks { hooks: TurnDaemonHooks; close(): Promise; } export const excludeDeletedReservedTurnQueues = ( changes: ReservedTurnChanges, deletedGeneralIds: readonly number[], deletedNationIds: readonly number[] ): ReservedTurnChanges => { const deletedGenerals = new Set(deletedGeneralIds); const deletedNations = new Set(deletedNationIds); const keepGeneral = (generalId: number): boolean => !deletedGenerals.has(generalId); const keepNation = (key: string): boolean => !deletedNations.has(Number(key.split(':', 1)[0])); return { generalIds: changes.generalIds.filter(keepGeneral), generalInitializationIds: changes.generalInitializationIds.filter(keepGeneral), generalLeaseIds: changes.generalLeaseIds.filter(keepGeneral), nationKeys: changes.nationKeys.filter(keepNation), nationInitializationKeys: changes.nationInitializationKeys.filter(keepNation), nationLeaseKeys: changes.nationLeaseKeys.filter(keepNation), }; }; const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; const formatLegacyNumber = (value: number): string => Math.round(value).toLocaleString('en-US'); const readBettingCandidates = (value: unknown): NationBettingCandidate[] => { if (!Array.isArray(value)) { return []; } return value.flatMap((candidate) => { const item = asRecord(candidate); const aux = asRecord(item.aux); if (typeof item.title !== 'string' || typeof aux.nation !== 'number' || !Number.isInteger(aux.nation)) { return []; } return [ { title: item.title, info: typeof item.info === 'string' ? item.info : '', isHtml: true as const, aux: { nation: aux.nation, name: typeof aux.name === 'string' ? aux.name : item.title, color: typeof aux.color === 'string' ? aux.color : '#000000', type: typeof aux.type === 'string' ? aux.type : '', level: typeof aux.level === 'number' ? aux.level : 0, capital: typeof aux.capital === 'number' ? aux.capital : null, gennum: typeof aux.gennum === 'number' ? aux.gennum : 0, power: typeof aux.power === 'number' ? aux.power : 0, city_cnt: typeof aux.city_cnt === 'number' ? aux.city_cnt : 0, }, }, ]; }); }; const persistNationBettingOpen = async ( prisma: GamePrisma.TransactionClient, betting: PendingNationBettingOpen ): Promise => { await prisma.nationBetting.create({ data: { id: betting.id, type: 'bettingNation', name: betting.name, finished: false, selectCount: betting.selectCount, isExclusive: betting.isExclusive, requiresInheritancePoint: betting.requiresInheritancePoint, openYearMonth: betting.openYearMonth, closeYearMonth: betting.closeYearMonth, candidates: asJson(betting.candidates), }, }); if (betting.bonusPoint > 0) { await prisma.nationBet.create({ data: { bettingId: betting.id, generalId: 0, userId: null, selection: [-1], selectionKey: '[-1]', amount: betting.bonusPoint, }, }); } }; const persistNationBettingFinish = async ( prisma: GamePrisma.TransactionClient, finish: PendingNationBettingFinish ): Promise => { await prisma.$queryRaw` SELECT id FROM nation_betting WHERE id = ${finish.id} FOR UPDATE `; const betting = await prisma.nationBetting.findUnique({ where: { id: finish.id }, include: { bets: { orderBy: { id: 'asc' } } }, }); if (!betting || betting.type !== 'bettingNation' || betting.finished) { return; } if (finish.winnerNationIds.length !== betting.selectCount) { return; } const candidates = readBettingCandidates(betting.candidates); const candidateIndexByNation = new Map(candidates.map((candidate, index) => [candidate.aux.nation, index])); let newNationOffset = 0; const winner = finish.winnerNationIds.map((nationId) => { const candidateIndex = candidateIndexByNation.get(nationId); if (candidateIndex !== undefined) { return candidateIndex; } const result = candidates.length + newNationOffset; newNationOffset += 1; return result; }); const purifiedWinner = [...new Set(winner)].sort((left, right) => left - right); if (purifiedWinner.length !== betting.selectCount) { return; } const rewards = calculateNationBettingRewards({ selectCount: betting.selectCount, isExclusive: betting.isExclusive, winner: purifiedWinner, stakes: betting.bets.map((bet) => ({ generalId: bet.generalId, userId: bet.userId, selection: Array.isArray(bet.selection) ? bet.selection.filter((value): value is number => typeof value === 'number') : [], amount: bet.amount, })), }); for (const reward of rewards) { if (!reward.userId) { continue; } const existing = await prisma.inheritancePoint.findUnique({ where: { userId_key: { userId: reward.userId, key: 'previous' } }, select: { value: true }, }); const previousPoint = existing?.value ?? 0; const nextPoint = previousPoint + reward.amount; await prisma.inheritancePoint.upsert({ where: { userId_key: { userId: reward.userId, key: 'previous' } }, update: { value: nextPoint }, create: { userId: reward.userId, key: 'previous', value: nextPoint }, }); await prisma.rankData.upsert({ where: { generalId_type: { generalId: reward.generalId, type: 'inherit_earned_act', }, }, update: { value: { increment: Math.trunc(reward.amount) } }, create: { generalId: reward.generalId, nationId: ( await prisma.general.findUnique({ where: { id: reward.generalId }, select: { nationId: true }, }) )?.nationId ?? 0, type: 'inherit_earned_act', value: Math.trunc(reward.amount), }, }); const partialText = reward.matchPoint === betting.selectCount ? '베팅 당첨' : `베팅 부분 당첨(${reward.matchPoint}/${betting.selectCount})`; await prisma.inheritanceLog.createMany({ data: [ { userId: reward.userId, year: finish.year, month: finish.month, text: `${betting.name} ${partialText} 보상으로 ${formatLegacyNumber(reward.amount)} 포인트 획득.`, }, { userId: reward.userId, year: finish.year, month: finish.month, text: `포인트 ${formatLegacyNumber(previousPoint)} => ${formatLegacyNumber(nextPoint)}`, }, ], }); } await prisma.nationBetting.update({ where: { id: finish.id }, data: { finished: true, winner: purifiedWinner, }, }); const openYear = Math.floor(betting.openYearMonth / 12); const openMonth = (betting.openYearMonth % 12) + 1; const finishLog = finalizeLogEntry( { scope: LogScope.SYSTEM, category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, text: `【내기】 ${openYear}년 ${openMonth}월에 열렸던 ${betting.name} 내기의 결과가 나왔습니다!`, }, { year: finish.year, month: finish.month, at: finish.turnTime, } ); if (finishLog) { await prisma.logEntry.create({ data: { scope: finishLog.scope, category: finishLog.category, subType: finishLog.subType ?? null, year: finishLog.year, month: finishLog.month, text: finishLog.text, generalId: finishLog.generalId ?? null, nationId: finishLog.nationId ?? null, userId: finishLog.userId ?? null, meta: asJson(finishLog.meta ?? {}), createdAt: finishLog.createdAt, }, }); } }; const toCode = (value: string | null | undefined): string => (value && value !== 'None' ? value : 'None'); const readMetaNumber = (meta: Record, key: string): number | null => { const value = meta[key]; return typeof value === 'number' && Number.isFinite(value) ? value : null; }; const toLegacyDatabaseInt = (value: number): number => { if (!Number.isFinite(value)) { return 0; } return value >= 0 ? Math.floor(value + 0.5) : Math.ceil(value - 0.5); }; const LEGACY_INTEGER_GENERAL_META_KEYS = [ 'leadership_exp', 'strength_exp', 'intel_exp', 'dex1', 'dex2', 'dex3', 'dex4', 'dex5', 'explevel', 'dedlevel', 'killturn', 'myset', ] as const; const buildPersistedGeneralMeta = ( general: ReturnType['generals'][number] ): InputJsonValue => { const meta = withSerializedItemInventory(general.meta, ensureItemInventory(general)); for (const key of LEGACY_INTEGER_GENERAL_META_KEYS) { const value = meta[key]; if (typeof value === 'number') { meta[key] = toLegacyDatabaseInt(value); } } return asJson(meta); }; const buildInitialRankRows = ( general: ReturnType['generals'][number] ): Array<{ generalId: number; nationId: number; type: string; value: number }> => buildPersistedRankRows(general).map((row) => ({ ...row, nationId: 0, // Ref Join은 전체 rank_data를 0으로 만든 직후 장수 생성에 사용한 // 유산 포인트만 inherit_spent_dyn에 반영한다. value: row.type === 'inherit_spent_dyn' ? row.value : 0, })); const RANK_DATA_UPSERT_BATCH_SIZE = 1_000; const upsertRankRows = async ( prisma: GamePrisma.TransactionClient, rows: Array<{ generalId: number; nationId: number; type: string; value: number }> ): Promise => { for (let offset = 0; offset < rows.length; offset += RANK_DATA_UPSERT_BATCH_SIZE) { const batch = rows.slice(offset, offset + RANK_DATA_UPSERT_BATCH_SIZE); await prisma.$executeRaw(GamePrisma.sql` INSERT INTO "rank_data" ("general_id", "nation_id", "type", "value") VALUES ${GamePrisma.join( batch.map((row) => GamePrisma.sql`(${row.generalId}, ${row.nationId}, ${row.type}, ${row.value})`) )} ON CONFLICT ("general_id", "type") DO UPDATE SET "nation_id" = EXCLUDED."nation_id", "value" = EXCLUDED."value" `); } }; const buildGeneralUpdate = ( general: ReturnType['generals'][number] ): TurnEngineGeneralUpdateInput => ({ userId: general.userId ?? null, name: general.name, nationId: general.nationId, cityId: general.cityId, troopId: general.troopId, leadership: toLegacyDatabaseInt(general.stats.leadership), strength: toLegacyDatabaseInt(general.stats.strength), intel: toLegacyDatabaseInt(general.stats.intelligence), experience: toLegacyDatabaseInt(general.experience), dedication: toLegacyDatabaseInt(general.dedication), officerLevel: general.officerLevel, injury: toLegacyDatabaseInt(general.injury), gold: toLegacyDatabaseInt(general.gold), rice: toLegacyDatabaseInt(general.rice), crew: toLegacyDatabaseInt(general.crew), crewTypeId: general.crewTypeId, train: toLegacyDatabaseInt(general.train), atmos: toLegacyDatabaseInt(general.atmos), age: general.age, affinity: general.affinity ?? null, bornYear: general.bornYear, deadYear: general.deadYear, picture: general.picture ?? null, imageServer: general.imageServer ?? 0, startAge: general.startAge ?? general.age, npcState: general.npcState, horseCode: toCode(general.role.items.horse), weaponCode: toCode(general.role.items.weapon), bookCode: toCode(general.role.items.book), itemCode: toCode(general.role.items.item), personalCode: toCode(general.role.personality), specialCode: toCode(general.role.specialDomestic), special2Code: toCode(general.role.specialWar), lastTurn: asJson(general.lastTurn ?? { command: '휴식' }), penalty: asJson(general.penalty ?? {}), meta: buildPersistedGeneralMeta(general), turnTime: general.turnTime, turnTick: BigInt(general.turnTick ?? 0), recentWarTime: general.recentWarTime ?? null, recentWarTick: general.recentWarTick === null || general.recentWarTick === undefined ? null : BigInt(general.recentWarTick), }); const buildGeneralCreate = ( general: ReturnType['generals'][number] ): TurnEngineGeneralCreateManyInput => ({ id: general.id, userId: general.userId ?? null, name: general.name, nationId: general.nationId, cityId: general.cityId, troopId: general.troopId, npcState: general.npcState, leadership: toLegacyDatabaseInt(general.stats.leadership), strength: toLegacyDatabaseInt(general.stats.strength), intel: toLegacyDatabaseInt(general.stats.intelligence), experience: toLegacyDatabaseInt(general.experience), dedication: toLegacyDatabaseInt(general.dedication), officerLevel: general.officerLevel, injury: toLegacyDatabaseInt(general.injury), gold: toLegacyDatabaseInt(general.gold), rice: toLegacyDatabaseInt(general.rice), crew: toLegacyDatabaseInt(general.crew), crewTypeId: general.crewTypeId, train: toLegacyDatabaseInt(general.train), atmos: toLegacyDatabaseInt(general.atmos), age: general.age, affinity: general.affinity ?? null, bornYear: general.bornYear, deadYear: general.deadYear, picture: general.picture ?? null, imageServer: general.imageServer ?? 0, startAge: general.startAge ?? general.age, horseCode: toCode(general.role.items.horse), weaponCode: toCode(general.role.items.weapon), bookCode: toCode(general.role.items.book), itemCode: toCode(general.role.items.item), personalCode: toCode(general.role.personality), specialCode: toCode(general.role.specialDomestic), special2Code: toCode(general.role.specialWar), lastTurn: asJson(general.lastTurn ?? { command: '휴식' }), penalty: asJson(general.penalty ?? {}), meta: buildPersistedGeneralMeta(general), turnTime: general.turnTime, turnTick: BigInt(general.turnTick ?? 0), recentWarTime: general.recentWarTime ?? null, recentWarTick: general.recentWarTick === null || general.recentWarTick === undefined ? null : BigInt(general.recentWarTick), }); const buildCityUpdate = ( city: ReturnType['cities'][number] ): TurnEngineCityUpdateInput => { const meta = { ...(city.meta as Record), state: city.state, }; const trust = readMetaNumber(meta, 'trust'); const trade = readMetaNumber(meta, 'trade'); const region = readMetaNumber(meta, 'region'); const data: TurnEngineCityUpdateInput = { name: city.name, nationId: city.nationId, level: city.level, population: city.population, populationMax: city.populationMax, agriculture: city.agriculture, agricultureMax: city.agricultureMax, commerce: city.commerce, commerceMax: city.commerceMax, security: city.security, securityMax: city.securityMax, supplyState: city.supplyState, frontState: city.frontState, defence: city.defence, defenceMax: city.defenceMax, wall: city.wall, wallMax: city.wallMax, ...(city.conflict ? { conflict: asJson(city.conflict) } : {}), meta: asJson(meta), }; if (trust !== null) { data.trust = trust; } // trade가 없는 도시는 레거시 DB에서 NULL이다. City meta에 trade가 // 없다는 사실도 dirty city의 전체 snapshot 일부로 영속화한다. data.trade = trade; if (region !== null) { data.region = region; } return data; }; const buildNationUpdate = ( nation: ReturnType['nations'][number] ): TurnEngineNationUpdateInput => ({ name: nation.name, color: nation.color, capitalCityId: nation.capitalCityId, chiefGeneralId: nation.chiefGeneralId, gold: toLegacyDatabaseInt(nation.gold), rice: toLegacyDatabaseInt(nation.rice), // Ref persists nation.tech as FLOAT. Domestic research commonly produces // tenths, and truncating here changes the following month's state/power. tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) ? nation.meta.tech : 0, level: nation.level, typeCode: nation.typeCode, meta: asJson({ ...nation.meta, power: nation.power, }), }); const buildTroopUpdate = ( troop: ReturnType['troops'][number] ): TurnEngineTroopUpdateInput => ({ nationId: troop.nationId, name: troop.name, }); const buildTroopCreate = ( troop: ReturnType['troops'][number] ): TurnEngineTroopCreateManyInput => ({ troopLeaderId: troop.id, nationId: troop.nationId, name: troop.name, }); const buildDiplomacyCreate = ( entry: ReturnType['diplomacy'][number] ): TurnEngineDiplomacyCreateManyInput => ({ srcNationId: entry.fromNationId, destNationId: entry.toNationId, stateCode: entry.state, term: entry.term, meta: asJson(buildDiplomacyMeta(entry)), }); const buildDiplomacyUpdate = ( entry: ReturnType['diplomacy'][number] ): TurnEngineDiplomacyUpdateInput => ({ stateCode: entry.state, term: entry.term, meta: asJson(buildDiplomacyMeta(entry)), }); const buildLogCreateData = ( entry: LogEntryDraft, context: { year: number; month: number; at: Date } ): TurnEngineLogEntryCreateManyInput | null => { const record = finalizeLogEntry(entry, { year: context.year, month: context.month, at: context.at, }); if (!record) { return null; } return { scope: record.scope, category: record.category, subType: record.subType ?? null, year: record.year, month: record.month, text: record.text, generalId: record.generalId ?? null, nationId: record.nationId ?? null, userId: record.userId ?? null, meta: asJson(record.meta ?? {}), createdAt: record.createdAt, }; }; export const createDatabaseTurnHooks = async ( databaseUrl: string, world: InMemoryTurnWorld, options?: { profileName?: string; reservedTurns?: InMemoryReservedTurnStore; turnDaemonLease?: DatabaseTurnDaemonLease; transactionTimeoutMs?: number; } ): Promise => { // 턴 처리 결과를 DB에 반영하는 훅을 만든다. const connector = createGamePostgresConnector({ url: databaseUrl }); await connector.connect(); const prisma = connector.prisma; const persistChanges = async ( transaction?: GamePrisma.TransactionClient, commandCompletion?: { requestId: string; result: TurnDaemonCommandResult } ): Promise<() => void> => { const state = world.getState(); const changes = world.peekDirtyState(); const { generals, cities, nations, troops, deletedTroops, deletedGenerals, deletedNations, deletedNationSnapshots, diplomacy, logs, messages, createdGenerals, createdNations, createdTroops, createdDiplomacy, createdEvents, deletedEvents, lifecycleEvents, pendingNeutralAuctions, inheritancePointAdjustments, pendingNationBettingOpens, pendingNationBettingFinishes, pendingYearbookSnapshots, pendingUnificationFinalizations, } = changes; const reservedTurnChanges = options?.reservedTurns?.peekDirtyState(); const persistedReservedTurnChanges = reservedTurnChanges ? excludeDeletedReservedTurnQueues(reservedTurnChanges, deletedGenerals, deletedNations) : undefined; const worldStateUpdate: TurnEngineWorldStateUpdateInput = { currentYear: state.currentYear, currentMonth: state.currentMonth, tickSeconds: state.tickSeconds, clockBaseTime: state.clockBaseTime ?? state.lastTurnTime, clockTick: BigInt(state.clockTick ?? 0), clockMode: state.clockMode ?? 'manual', clockWallAnchor: state.clockWallAnchor ?? state.lastTurnTime, lastTurnTick: BigInt(state.lastTurnTick ?? world.dateToGameTick(state.lastTurnTime)), meta: asJson(state.meta), }; const persist = async (prisma: GamePrisma.TransactionClient): Promise => { // Lock and validate the fencing row in the same transaction as every // world mutation. A stale daemon can finish calculating, but it can // never commit after another owner has advanced the epoch. await options?.turnDaemonLease?.assertActive(prisma); let neutralAuctionsToCreate = pendingNeutralAuctions; if (pendingNeutralAuctions.length > 0) { const latestRegistrationKey = pendingNeutralAuctions[pendingNeutralAuctions.length - 1]!.registrationKey; await prisma.$executeRaw` SELECT pg_advisory_xact_lock( hashtext(${'neutral-auction-registration'}), ${state.id} ) `; const persistedRows = await prisma.$queryRaw>` SELECT meta FROM world_state WHERE id = ${state.id} FOR UPDATE `; const persistedMeta = asRecord(persistedRows[0]?.meta); if (persistedMeta.neutralAuctionRegistrationKey === latestRegistrationKey) { neutralAuctionsToCreate = []; } } await prisma.worldState.update({ where: { id: state.id }, data: worldStateUpdate, }); for (const betting of pendingNationBettingOpens) { await persistNationBettingOpen(prisma, betting); } for (const finish of pendingNationBettingFinishes) { await persistNationBettingFinish(prisma, finish); } const meta = asRecord(state.meta); const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : 'default'; await persistGeneralLifecycleEvents( prisma, lifecycleEvents, meta, asRecord(world.getScenarioConfig().const), world.gameTickToDate(state.clockTick ?? state.lastTurnTick ?? 0) ); if (inheritancePointAdjustments.length > 0) { const grouped = new Map(); for (const entry of inheritancePointAdjustments) { const groupKey = `${entry.userId}\u0000${entry.key}`; const current = grouped.get(groupKey); if (current) { current.amount += entry.amount; } else { grouped.set(groupKey, { ...entry }); } } for (const entry of grouped.values()) { await prisma.inheritancePoint.upsert({ where: { userId_key: { userId: entry.userId, key: entry.key } }, update: { value: { increment: entry.amount } }, create: { userId: entry.userId, key: entry.key, value: entry.amount }, }); } } if (deletedNationSnapshots.length > 0) { const nationIds = deletedNationSnapshots.map((snapshot) => snapshot.nation.id); const historyRows = await prisma.logEntry.findMany({ where: { nationId: { in: nationIds }, scope: LogScope.NATION, category: LogCategory.HISTORY, }, orderBy: { id: 'asc' }, select: { nationId: true, text: true }, }); const historyMap = new Map(); for (const row of historyRows) { const bucket = historyMap.get(row.nationId ?? 0) ?? []; bucket.push(row.text); historyMap.set(row.nationId ?? 0, bucket); } await Promise.all( deletedNationSnapshots.map((snapshot) => prisma.oldNation.upsert({ where: { serverId_nation_sourceId: { serverId, nation: snapshot.nation.id, sourceId: 0, }, }, update: { data: buildOldNationArchiveData({ nation: snapshot.nation, generalIds: snapshot.generalIds, history: historyMap.get(snapshot.nation.id) ?? [], }) as InputJsonValue, date: snapshot.removedAt, }, create: { serverId, nation: snapshot.nation.id, sourceId: 0, data: buildOldNationArchiveData({ nation: snapshot.nation, generalIds: snapshot.generalIds, history: historyMap.get(snapshot.nation.id) ?? [], }) as InputJsonValue, date: snapshot.removedAt, }, }) ) ); } if (neutralAuctionsToCreate.length > 0) { await prisma.auction.createMany({ data: neutralAuctionsToCreate.map((auction) => ({ type: auction.type, targetCode: auction.targetCode, hostGeneralId: auction.hostGeneralId, hostName: auction.hostName, detail: asJson(auction.detail), status: 'OPEN', openTick: BigInt(state.clockTick ?? world.dateToGameTick(state.lastTurnTime)), closeTick: BigInt(world.dateToGameTick(auction.closeAt)), closeAt: auction.closeAt, })), }); } const createdIds = new Set(createdGenerals.map((general) => general.id)); const createdNationIds = new Set(createdNations.map((nation) => nation.id)); const createdTroopIds = new Set(createdTroops.map((troop) => troop.id)); const createdDiplomacyKeys = new Set( createdDiplomacy.map((entry) => `${entry.fromNationId}:${entry.toNationId}`) ); if (createdGenerals.length > 0) { await prisma.general.createMany({ data: createdGenerals.map(buildGeneralCreate), }); } if (createdNations.length > 0) { await prisma.nation.createMany({ data: createdNations.map((nation) => ({ id: nation.id, name: nation.name, color: nation.color, capitalCityId: nation.capitalCityId, chiefGeneralId: nation.chiefGeneralId, gold: toLegacyDatabaseInt(nation.gold), rice: toLegacyDatabaseInt(nation.rice), tech: typeof nation.meta.tech === 'number' && Number.isFinite(nation.meta.tech) ? nation.meta.tech : 0, level: nation.level, typeCode: nation.typeCode, meta: asJson(nation.meta), })), }); } if (createdTroops.length > 0) { await prisma.troop.createMany({ data: createdTroops.map(buildTroopCreate), }); } if (createdDiplomacy.length > 0) { await prisma.diplomacy.createMany({ data: createdDiplomacy.map(buildDiplomacyCreate), }); } if (createdEvents.length > 0) { await prisma.event.createMany({ data: createdEvents.map((event) => ({ id: event.id, targetCode: event.targetCode, priority: event.priority, condition: asJson(event.condition), action: asJson(event.action), meta: asJson(event.meta), })), }); await prisma.$queryRaw` SELECT setval( pg_get_serial_sequence('event', 'id'), GREATEST((SELECT COALESCE(MAX(id), 1) FROM event), 1) ) `; } if (deletedTroops.length > 0) { await prisma.troop.deleteMany({ where: { troopLeaderId: { in: deletedTroops } }, }); } if (deletedGenerals.length > 0) { await prisma.selectPoolEntry.updateMany({ where: { generalId: { in: deletedGenerals } }, data: { generalId: null, ownerUserId: null, reservedUntil: null, }, }); if (prisma.generalTurnRevision) { await prisma.generalTurnRevision.deleteMany({ where: { generalId: { in: deletedGenerals } }, }); } await prisma.generalTurn.deleteMany({ where: { generalId: { in: deletedGenerals } }, }); await prisma.general.deleteMany({ where: { id: { in: deletedGenerals } }, }); await prisma.rankData.deleteMany({ where: { generalId: { in: deletedGenerals } }, }); } if (deletedNations.length > 0) { await prisma.diplomacy.deleteMany({ where: { OR: [{ srcNationId: { in: deletedNations } }, { destNationId: { in: deletedNations } }], }, }); if (prisma.nationTurnRevision) { await prisma.nationTurnRevision.deleteMany({ where: { nationId: { in: deletedNations } }, }); } await prisma.nationTurn.deleteMany({ where: { nationId: { in: deletedNations } }, }); await prisma.nation.deleteMany({ where: { id: { in: deletedNations } }, }); } if (deletedEvents.length > 0) { await prisma.event.deleteMany({ where: { id: { in: deletedEvents } }, }); } await Promise.all([ ...generals .filter((general) => !createdIds.has(general.id)) .map((general) => prisma.general.update({ where: { id: general.id }, data: buildGeneralUpdate(general), }) ), ...generals .filter( (general) => typeof general.refreshScoreTotal === 'number' && Number.isFinite(general.refreshScoreTotal) ) .map((general) => prisma.generalAccessLog.upsert({ where: { generalId: general.id }, update: { refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0), }, create: { generalId: general.id, userId: general.userId ?? null, refreshScoreTotal: Math.floor(general.refreshScoreTotal ?? 0), }, }) ), ...cities.map((city) => prisma.city.update({ where: { id: city.id }, data: buildCityUpdate(city), }) ), ...nations .filter((nation) => !createdNationIds.has(nation.id)) .map((nation) => prisma.nation.upsert({ where: { id: nation.id }, update: buildNationUpdate(nation), create: { id: nation.id, ...buildNationUpdate(nation), }, }) ), ...troops .filter((troop) => !createdTroopIds.has(troop.id)) .map((troop) => prisma.troop.update({ where: { troopLeaderId: troop.id }, data: buildTroopUpdate(troop), }) ), ...diplomacy .filter((entry) => !createdDiplomacyKeys.has(`${entry.fromNationId}:${entry.toNationId}`)) .map((entry) => prisma.diplomacy.update({ where: { srcNationId_destNationId: { srcNationId: entry.fromNationId, destNationId: entry.toNationId, }, }, data: buildDiplomacyUpdate(entry), }) ), ]); const rankTargets = generals.filter((general) => !createdIds.has(general.id)); if (createdGenerals.length > 0 || rankTargets.length > 0) { const rankRows = [ ...createdGenerals.flatMap(buildInitialRankRows), ...rankTargets.flatMap(buildPersistedRankRows), ]; await upsertRankRows(prisma, rankRows); } if (logs.length > 0) { const logContext = { year: state.currentYear, month: state.currentMonth, at: state.lastTurnTime, }; const payload = logs .map((entry) => buildLogCreateData(entry, logContext)) .filter((entry): entry is TurnEngineLogEntryCreateManyInput => Boolean(entry)); if (payload.length > 0) { await prisma.logEntry.createMany({ data: payload, }); } } for (const snapshot of pendingYearbookSnapshots) { await persistYearbookSnapshot(prisma, snapshot); } for (const finalization of pendingUnificationFinalizations) { if (options?.profileName && finalization.profileName !== options.profileName) { throw new Error( `Unification profile mismatch: pending=${finalization.profileName}, daemon=${options.profileName}.` ); } await persistUnificationFinalization(prisma, finalization, world); } for (const message of messages) { await sendMessage( { insertMessage: async (draft: MessageRecordDraft) => { const toTickOrNull = (date: Date): bigint | null => { try { return BigInt(world.dateToGameTick(date)); } catch { // Legacy messages may use year 9999 as an // effectively-unbounded expiry, beyond the // safe JavaScript tick range. return null; } }; const rows = await prisma.$queryRaw>` INSERT INTO message ( mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message ) VALUES ( ${draft.mailbox}, ${draft.msgType}, ${draft.srcId}, ${draft.destId}, ${draft.time}, ${toTickOrNull(draft.time)}, ${draft.validUntil}, ${toTickOrNull(draft.validUntil)}, CAST(${JSON.stringify(draft.payload)} AS jsonb) ) RETURNING id `; const id = rows[0]?.id; if (!id) { throw new Error('Failed to persist turn message.'); } return id; }, }, message ); } if (options?.reservedTurns && persistedReservedTurnChanges) { await options.reservedTurns.persistChanges(prisma, persistedReservedTurnChanges); } if (commandCompletion) { await prisma.inputEvent.update({ where: { requestId: commandCompletion.requestId }, data: { status: 'SUCCEEDED', result: asJson(commandCompletion.result), completedAt: new Date(), error: null, lockedBy: null, leaseUntil: null, }, }); } }; if (transaction) { await persist(transaction); } else { await prisma.$transaction( persist, options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined ); } return () => { world.acknowledgeDirtyState(changes); if (options?.reservedTurns && reservedTurnChanges) { options.reservedTurns.acknowledgeDirtyState(reservedTurnChanges); } }; }; const hooks: TurnDaemonHooks = { flushChanges: async () => { const acknowledge = await persistChanges(); acknowledge(); }, commitCommand: async (requestId, result) => { const acknowledge = await persistChanges(undefined, { requestId, result }); acknowledge(); }, executeCommand: async (requestId, execute) => { const committed = await prisma.$transaction( async (transaction) => { const result = await execute({ db: transaction }); const acknowledge = await persistChanges(transaction, { requestId, result }); return { result, acknowledge }; }, options?.transactionTimeoutMs ? { timeout: options.transactionTimeoutMs } : undefined ); committed.acknowledge(); return committed.result; }, }; return { hooks, close: () => connector.disconnect(), }; };