import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { asRecord, HALL_OF_FAME_TYPES, RANK_DATA_TYPES, rankDataMetaKey, resolveLegacyTextColor, type HallOfFameType, } from '@sammo-ts/common'; import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra'; import { LogCategory, LogScope } from '@sammo-ts/logic'; import { computeInheritanceSettlementBreakdown } from '@sammo-ts/logic/inheritance/pointCalculation.js'; import { readCentennialRecordableDexterity, type CentennialDexKey, } from '@sammo-ts/logic/scenario/centennialAllStar.js'; import type { GeneralLifecycleEvent } from './inMemoryWorld.js'; import { persistHallOfFameCandidate, resolveOfficialGameIndex } from './hallOfFamePersistence.js'; import { buildInheritanceSettlementLogTexts } from './inheritanceSettlementLogs.js'; import { buildPersistedRankRows } from './rankData.js'; const asJson = (value: unknown): InputJsonValue => value as InputJsonValue; const readNumber = (record: Record, key: string): number => { const value = record[key]; if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string') { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } return 0; }; const readWorldNumber = (record: Record, key: string, fallback: number): number => { const value = readNumber(record, key); return value === 0 && record[key] === undefined ? fallback : Math.floor(value); }; type LifecycleRankValues = Map; export interface GeneralLifecycleArchiveLog { generalId: number; category: string; text: string; } const loadLifecycleRankValues = async ( prisma: GamePrisma.TransactionClient, event: GeneralLifecycleEvent ): Promise => { const persisted = await prisma.rankData.findMany({ where: { generalId: event.generalId }, select: { type: true, value: true }, }); const values = new Map(persisted.map((row) => [row.type, row.value])); const snapshotMeta = asRecord(event.before.meta); for (const row of buildPersistedRankRows(event.before)) { if ( row.type === 'experience' || row.type === 'dedication' || Object.prototype.hasOwnProperty.call(snapshotMeta, rankDataMetaKey(row.type)) ) { values.set(row.type, row.value); } } return values; }; const persistPostRetirementRankValues = async ( prisma: GamePrisma.TransactionClient, event: GeneralLifecycleEvent ): Promise => { if (!event.after) { return; } for (const row of buildPersistedRankRows(event.after)) { await prisma.rankData.upsert({ where: { generalId_type: { generalId: row.generalId, type: row.type } }, update: { nationId: row.nationId, value: row.value }, create: row, }); } }; const settleInheritance = async ( prisma: GamePrisma.TransactionClient, event: GeneralLifecycleEvent, worldMeta: Record, isRebirth: boolean, configConst: Record, rankValues: ReadonlyMap ): Promise => { const userId = event.before.userId; if (!userId || event.before.npcState >= 2 || (isRebirth && event.before.npcState === 1)) { return; } const meta = asRecord(event.before.meta); if (event.before.npcState === 1) { const pickYearMonth = readNumber(meta, 'pickYearMonth'); if (pickYearMonth === 0 && meta.pickYearMonth === undefined) { return; } const pickYear = Math.floor(pickYearMonth / 12); const scenarioMeta = asRecord(worldMeta.scenarioMeta); const startYear = readWorldNumber( worldMeta, 'startYear', readWorldNumber(worldMeta, 'startyear', readWorldNumber(scenarioMeta, 'startYear', event.year)) ); if ((event.year - pickYear) * 2 <= event.year - startYear) { return; } } const rows = await prisma.inheritancePoint.findMany({ where: { userId }, select: { key: true, value: true }, }); const points = new Map(rows.map((row) => [row.key, row.value])); const previous = points.get('previous') ?? 0; const randomUniqueRefund = !isRebirth && meta.inheritRandomUnique ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) : 0; const specificSpecialRefund = !isRebirth && meta.inheritSpecificSpecialWar ? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000) : 0; const refund = randomUniqueRefund + specificSpecialRefund; const calculationMeta = { ...Object.fromEntries(rankValues), ...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])), ...meta, }; const settlement = computeInheritanceSettlementBreakdown( { meta: calculationMeta, inheritancePoints: Object.fromEntries(points), }, isRebirth ); const total = Math.trunc(previous + refund + settlement.totalEarned); await prisma.inheritancePoint.upsert({ where: { userId_key: { userId, key: 'previous' } }, update: { value: total }, create: { userId, key: 'previous', value: total }, }); if (isRebirth) { const retainedEntries = Object.entries(settlement.retained).filter( ([key, value]) => key === 'max_belong' || points.has(key) || value !== 0 ); for (const [key, value] of retainedEntries) { await prisma.inheritancePoint.upsert({ where: { userId_key: { userId, key } }, update: { value }, create: { userId, key, value }, }); } await prisma.inheritancePoint.deleteMany({ where: { userId, key: { notIn: ['previous', ...retainedEntries.map(([key]) => key)] }, }, }); } else { await prisma.inheritancePoint.deleteMany({ where: { userId, key: { not: 'previous' } } }); } const serverId = typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; await prisma.inheritanceResult.create({ data: { serverId, owner: userId, generalId: event.generalId, year: event.year, month: event.month, value: asJson({ previous, refund, ...settlement.earned, ...(isRebirth ? { retained: settlement.retained } : {}), rebirth: isRebirth, total, }), }, }); for (const text of [ ...(randomUniqueRefund > 0 ? [`사망으로 랜덤 유니크 구입 ${randomUniqueRefund} 포인트 반환`] : []), ...(specificSpecialRefund > 0 ? [`사망으로 전투 특기 지정 ${specificSpecialRefund} 포인트 반환`] : []), ]) { await prisma.inheritanceLog.create({ data: { userId, year: event.year, month: event.month, text, }, }); } for (const text of buildInheritanceSettlementLogTexts({ previous: previous + refund, points: settlement.earned, storedKeys: new Set([...points.keys(), ...(refund > 0 ? (['previous'] as const) : [])]), total, isRebirth, })) { await prisma.inheritanceLog.create({ data: { userId, year: event.year, month: event.month, text, }, }); } }; const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0); const settleHall = async ( prisma: GamePrisma.TransactionClient, event: GeneralLifecycleEvent, worldMeta: Record, gameNow: Date, rank: ReadonlyMap ): Promise => { const isUnited = event.isUnitedAtEvent ?? readWorldNumber(worldMeta, 'isUnited', readWorldNumber(worldMeta, 'isunited', 0)); if (isUnited !== 0) { return; } const [nation, serverIdx] = await Promise.all([ event.before.nationId > 0 ? prisma.nation.findUnique({ where: { id: event.before.nationId }, select: { name: true, color: true }, }) : null, resolveOfficialGameIndex(prisma, worldMeta), ]); const value = (key: string): number => rank.get(key) ?? readNumber(asRecord(event.before.meta), key); const warnum = value('warnum'); const tt = value('ttw') + value('ttd') + value('ttl'); const tl = value('tlw') + value('tld') + value('tll'); const ts = value('tsw') + value('tsd') + value('tsl'); const ti = value('tiw') + value('tid') + value('til'); const calc: Record = { winrate: computeRate(value('killnum'), warnum), killrate: computeRate(value('killcrew'), Math.max(1, value('deathcrew'))), killrate_person: computeRate(value('killcrew_person'), Math.max(1, value('deathcrew_person'))), ttrate: computeRate(value('ttw'), Math.max(1, tt)), tlrate: computeRate(value('tlw'), Math.max(1, tl)), tsrate: computeRate(value('tsw'), Math.max(1, ts)), tirate: computeRate(value('tiw'), Math.max(1, ti)), betrate: computeRate(value('betwingold'), Math.max(1, value('betgold'))), }; const serverId = typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; const season = readWorldNumber(worldMeta, 'season', 1); const scenario = readWorldNumber(worldMeta, 'scenarioId', 0); const scenarioName = typeof asRecord(worldMeta.scenarioMeta).title === 'string' ? String(asRecord(worldMeta.scenarioMeta).title) : ''; const aux = { name: event.before.name, nationName: nation?.name ?? '재야', bgColor: nation?.color ?? '#000000', fgColor: resolveLegacyTextColor(nation?.color ?? '#000000'), startTime: typeof worldMeta.starttime === 'string' ? worldMeta.starttime : null, unitedTime: gameNow.toISOString(), ownerDisplayName: typeof asRecord(event.before.meta).ownerDisplayName === 'string' ? asRecord(event.before.meta).ownerDisplayName : typeof asRecord(event.before.meta).owner_name === 'string' ? asRecord(event.before.meta).owner_name : typeof asRecord(event.before.meta).ownerName === 'string' ? asRecord(event.before.meta).ownerName : null, picture: event.before.picture ?? null, imgsvr: event.before.imageServer ?? 0, serverID: serverId, serverIdx, scenarioName, serverName: typeof worldMeta.serverName === 'string' ? worldMeta.serverName : '', }; for (const type of HALL_OF_FAME_TYPES) { const eventMeta = asRecord(event.before.meta); let hallValue = type === 'experience' ? event.before.experience : type === 'dedication' ? event.before.dedication : type.endsWith('rate') ? (calc[type] ?? 0) : type.startsWith('dex') ? readCentennialRecordableDexterity(eventMeta, type as CentennialDexKey) : value(type); if ((type === 'winrate' || type === 'killrate') && warnum < 10) continue; if (type === 'ttrate' && tt < 50) continue; if (type === 'tlrate' && tl < 50) continue; if (type === 'tsrate' && ts < 50) continue; if (type === 'tirate' && ti < 50) continue; if (type === 'betrate' && value('betgold') < 1000) continue; if (!Number.isFinite(hallValue) || hallValue <= 0) continue; hallValue = Number(hallValue); await persistHallOfFameCandidate(prisma, { serverId, season, scenario, generalNo: event.generalId, type: type as HallOfFameType, value: hallValue, owner: event.before.userId ?? null, aux, }); } }; const archiveGeneral = async ( prisma: GamePrisma.TransactionClient, event: GeneralLifecycleEvent, worldMeta: Record, rankValues: ReadonlyMap, pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[] ): Promise => { const serverId = typeof worldMeta.serverId === 'string' && worldMeta.serverId.trim() ? worldMeta.serverId.trim() : 'default'; const recordRows = await prisma.logEntry.findMany({ where: { generalId: event.generalId, scope: LogScope.GENERAL, category: { in: [LogCategory.HISTORY, LogCategory.BATTLE_BRIEF] }, }, orderBy: { id: 'desc' }, select: { category: true, text: true }, }); const archivedMeta = { ...asRecord(event.before.meta), ...Object.fromEntries(RANK_DATA_TYPES.map((type) => [rankDataMetaKey(type), rankValues.get(type) ?? 0])), }; const pendingGeneralLogs = pendingArchiveLogs.filter((row) => row.generalId === event.generalId); const history = [ ...pendingGeneralLogs .filter((row) => row.category === LogCategory.HISTORY) .map((row) => row.text) .reverse(), ...recordRows.filter((row) => row.category === LogCategory.HISTORY).map((row) => row.text), ]; const battleResults = [ ...pendingGeneralLogs .filter((row) => row.category === LogCategory.BATTLE_BRIEF) .map((row) => row.text) .reverse(), ...recordRows.filter((row) => row.category === LogCategory.BATTLE_BRIEF).map((row) => row.text), ]; const data = { ...event.before, meta: archivedMeta, turnTime: event.before.turnTime.toISOString(), recentWarTime: event.before.recentWarTime?.toISOString() ?? null, history, records: { battleResult: battleResults }, availability: { battleResultLogs: true }, }; await prisma.oldGeneral.upsert({ where: { by_no: { serverId, generalNo: event.generalId } }, update: { owner: event.before.userId ?? null, name: event.before.name, lastYearMonth: event.year * 100 + event.month, turnTime: event.before.turnTime, data: asJson(data), }, create: { serverId, generalNo: event.generalId, owner: event.before.userId ?? null, name: event.before.name, lastYearMonth: event.year * 100 + event.month, turnTime: event.before.turnTime, data: asJson(data), }, }); }; export const persistGeneralLifecycleEvents = async ( prisma: GamePrisma.TransactionClient, events: GeneralLifecycleEvent[], worldMeta: Record, configConst: Record, gameNow = new Date(), pendingArchiveLogs: readonly GeneralLifecycleArchiveLog[] = [] ): Promise => { if (events.length === 0) { return; } await prisma.generalAccessLog.updateMany({ where: { generalId: { in: events.map((event) => event.generalId) } }, data: { refreshScore: 0 }, }); const recordsFinalized = await areSeasonRecordsFinalized(prisma, worldMeta.serverId); for (const event of events) { if (event.outcome === 'detached' || event.outcome === 'deleted') { await prisma.generalAccessLog.deleteMany({ where: { generalId: event.generalId } }); } if (recordsFinalized) { if (event.outcome === 'retired') await persistPostRetirementRankValues(prisma, event); continue; } if (event.outcome !== 'deleted' && event.outcome !== 'retired') { continue; } const rankValues = await loadLifecycleRankValues(prisma, event); if (event.outcome === 'deleted') { await settleInheritance(prisma, event, worldMeta, false, configConst, rankValues); await archiveGeneral(prisma, event, worldMeta, rankValues, pendingArchiveLogs); } if (event.outcome === 'retired') { await settleHall(prisma, event, worldMeta, gameNow, rankValues); await settleInheritance(prisma, event, worldMeta, true, configConst, rankValues); await persistPostRetirementRankValues(prisma, event); } } };