Files
core2026/app/game-engine/src/turn/unificationPersistence.ts
T
Hide_D 383d173790 feat: 오픈 게임 취소와 유산 정산 경로 추가
별도 최고위험 권한으로 실행되는 원자적 취소 작업을 추가한다. 버려진 게임과 장수 기록의 보존·삭제 옵션, 오픈 원금 전액 환급 및 획득 포인트 보전율, 취소 상태와 관리자·과거기록 UI를 함께 반영한다.
2026-08-18 13:31:49 +00:00

667 lines
28 KiB
TypeScript

import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import { acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
import type { GamePrisma, InputJsonValue } from '@sammo-ts/infra';
import { LogCategory, LogScope, sendMessage, type MessageDraft, type MessageRecordDraft } from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from './inheritancePointCalculation.js';
import { buildOldNationArchiveData } from './oldNationArchive.js';
import type { PendingUnificationAuctionCancellation, TurnGeneral } from './types.js';
const UNIFIER_POINT = 2000;
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
export interface UnificationFinalizationInput {
readonly generationKey: string;
readonly serverId: string;
readonly profileName: string;
readonly winnerNationId: number;
readonly year: number;
readonly month: number;
readonly completedAt: Date;
readonly auctionCancellations: readonly PendingUnificationAuctionCancellation[];
}
export interface UnificationFinalizationResult {
status: 'APPLIED' | 'ALREADY_APPLIED';
generationKey: string;
messageMailboxes: number[];
}
const readNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
const readInteger = (value: unknown, fallback = 0): number => {
const parsed = typeof value === 'string' ? Number(value) : value;
return typeof parsed === 'number' && Number.isFinite(parsed) ? Math.floor(parsed) : fallback;
};
const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0);
const ownerDisplayName = (meta: Record<string, unknown>): string | null => {
for (const key of ['ownerDisplayName', 'owner_name', 'ownerName']) {
const value = meta[key];
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
}
return null;
};
export const resolveStoredInheritancePoint = (
currentPoints: ReadonlyMap<string, number>,
general: Pick<TurnGeneral, 'inheritancePoints'>,
key: (typeof ALL_MERGED_INHERITANCE_KEYS)[number],
unifierAward: number
): number =>
currentPoints.get(key) ??
(key === 'unifier'
? Math.max(0, (general.inheritancePoints?.[key] ?? 0) - unifierAward)
: (general.inheritancePoints?.[key] ?? 0));
const formatHistogram = (value: unknown): string =>
Object.entries(asRecord(value))
.filter((entry): entry is [string, number] => typeof entry[1] === 'number' && Number.isFinite(entry[1]))
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, count]) => `${key}(${count})`)
.join(', ');
const claimGeneration = async (
transaction: GamePrisma.TransactionClient,
input: UnificationFinalizationInput
): Promise<'CLAIMED' | 'ALREADY_APPLIED'> => {
await acquireGameSchemaAdvisoryXactLock(transaction, `unification-finalization:${input.generationKey}`);
const existing = await transaction.unificationFinalization.findUnique({
where: { generationKey: input.generationKey },
});
if (existing) {
const matches =
existing.serverId === input.serverId &&
existing.profileName === input.profileName &&
existing.winnerNation === input.winnerNationId &&
existing.year === input.year &&
existing.month === input.month &&
existing.completedAt.getTime() === input.completedAt.getTime();
if (!matches) {
throw new Error(`Unification generation payload mismatch: ${input.generationKey}.`);
}
return 'ALREADY_APPLIED';
}
await transaction.unificationFinalization.create({
data: {
generationKey: input.generationKey,
serverId: input.serverId,
profileName: input.profileName,
winnerNation: input.winnerNationId,
year: input.year,
month: input.month,
completedAt: input.completedAt,
},
});
return 'CLAIMED';
};
interface LockedUnificationAuctionRow {
auctionId: number;
status: 'OPEN' | 'FINALIZING';
closeAt: Date;
detail: unknown;
}
interface HighestUnificationBidRow {
bidId: number;
bidderGeneralId: number;
amount: number;
meta: unknown;
}
const insertMessage = async (transaction: GamePrisma.TransactionClient, draft: MessageRecordDraft): Promise<number> => {
const rows = await transaction.$queryRaw<Array<{ id: number }>>`
INSERT INTO message (mailbox, type, src, dest, time, valid_until, message)
VALUES (
${draft.mailbox},
${draft.msgType},
${draft.srcId},
${draft.destId},
${draft.time},
${draft.validUntil},
CAST(${JSON.stringify(draft.payload)} AS jsonb)
)
RETURNING id
`;
const id = rows[0]?.id;
if (!id) throw new Error('Failed to persist unification auction cancellation message.');
return id;
};
const cancelPendingUniqueAuctions = async (
transaction: GamePrisma.TransactionClient,
input: UnificationFinalizationInput,
world: InMemoryTurnWorld
): Promise<number[]> => {
const messageMailboxes: number[] = [];
const lockedRows = await transaction.$queryRaw<LockedUnificationAuctionRow[]>`
SELECT
auction.id AS "auctionId",
auction.status,
auction.close_at AS "closeAt",
auction.detail
FROM auction
WHERE auction.type = 'UNIQUE_ITEM'
AND auction.status IN ('OPEN', 'FINALIZING')
ORDER BY auction.close_at ASC, auction.id ASC
FOR UPDATE OF auction
`;
if (lockedRows.length !== input.auctionCancellations.length) {
throw new Error(
`Unification auction set changed: planned=${input.auctionCancellations.length}, actual=${lockedRows.length}.`
);
}
for (const [index, row] of lockedRows.entries()) {
const planned = input.auctionCancellations[index]!;
const title = asRecord(row.detail).title;
if (
row.auctionId !== planned.auctionId ||
row.status !== planned.status ||
row.closeAt.getTime() !== planned.closeAt.getTime() ||
title !== planned.title
) {
throw new Error(`Unification auction plan mismatch: ${planned.auctionId}.`);
}
const highestRows = await transaction.$queryRaw<HighestUnificationBidRow[]>`
SELECT id AS "bidId", general_id AS "bidderGeneralId", amount, meta
FROM auction_bid
WHERE auction_id = ${row.auctionId}
ORDER BY amount DESC, id ASC
LIMIT 1
`;
const highest = highestRows[0] ?? null;
const highestRankTrackedAmount = Math.max(
0,
readNumber(highest ? asRecord(highest.meta).inheritSpentTrackedAmount : 0)
);
if (
(highest?.bidId ?? null) !== planned.highestBidId ||
(highest?.bidderGeneralId ?? null) !== planned.bidderGeneralId ||
(highest?.amount ?? null) !== planned.amount ||
highestRankTrackedAmount !== planned.rankTrackedAmount
) {
throw new Error(`Unification auction highest bid changed: ${planned.auctionId}.`);
}
if (highest) {
const bidder = world.getGeneralById(highest.bidderGeneralId);
const dbBidder = await transaction.general.findUnique({
where: { id: highest.bidderGeneralId },
select: { userId: true },
});
if (!bidder?.userId || bidder.userId !== dbBidder?.userId) {
throw new Error(`Unification auction refund owner is unavailable: ${planned.auctionId}.`);
}
await transaction.inheritancePoint.upsert({
where: { userId_key: { userId: bidder.userId, key: 'previous' } },
update: { value: { increment: highest.amount } },
create: { userId: bidder.userId, key: 'previous', value: highest.amount },
});
await transaction.rankData.upsert({
where: {
generalId_type: { generalId: bidder.id, type: 'inherit_spent_dyn' },
},
update: {
nationId: bidder.nationId,
value: readNumber(bidder.meta.inherit_spent_dyn),
},
create: {
generalId: bidder.id,
nationId: bidder.nationId,
type: 'inherit_spent_dyn',
value: readNumber(bidder.meta.inherit_spent_dyn),
},
});
const nation = world.getNationById(bidder.nationId);
const message: MessageDraft = {
msgType: 'private',
src: {
generalId: 0,
generalName: '',
nationId: 0,
nationName: 'System',
color: '#000000',
icon: '',
},
dest: {
generalId: bidder.id,
generalName: bidder.name,
nationId: bidder.nationId,
nationName: nation?.name ?? '재야',
color: nation?.color ?? '#000000',
icon: bidder.picture ?? '',
},
text: `${planned.auctionId}${planned.title}가 취소되었습니다.`,
time: input.completedAt,
validUntil: new Date('9999-12-31T00:00:00.000Z'),
};
await sendMessage(
{
insertMessage: async (draft) => {
const messageId = await insertMessage(transaction, draft);
messageMailboxes.push(draft.mailbox);
return messageId;
},
},
message,
{ sendDestOnly: true }
);
}
await transaction.auction.update({
where: { id: row.auctionId },
data: { status: 'CANCELED', finishedAt: input.completedAt },
});
}
return messageMailboxes;
};
export const persistUnificationFinalization = async (
transaction: GamePrisma.TransactionClient,
input: UnificationFinalizationInput,
world: InMemoryTurnWorld
): Promise<UnificationFinalizationResult> => {
if (!input.generationKey.trim()) {
throw new Error('Unification finalization requires a non-empty generationKey.');
}
const claim = await claimGeneration(transaction, input);
if (claim === 'ALREADY_APPLIED') {
return { status: 'ALREADY_APPLIED', generationKey: input.generationKey, messageMailboxes: [] };
}
const state = world.getState();
if (state.currentYear !== input.year || state.currentMonth !== input.month) {
throw new Error(
`Unification snapshot date mismatch: input=${input.year}-${input.month}, world=${state.currentYear}-${state.currentMonth}.`
);
}
const winner = world.getNationById(input.winnerNationId);
if (!winner) {
throw new Error(`Unification winner nation does not exist: ${input.winnerNationId}.`);
}
const meta = asRecord(state.meta);
const snapshotServerId =
typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId.trim() : input.profileName;
if (snapshotServerId !== input.serverId) {
throw new Error(`Unification snapshot server mismatch: input=${input.serverId}, world=${snapshotServerId}.`);
}
const serverId = input.serverId;
const serverName =
typeof meta.serverName === 'string' && meta.serverName.trim() ? meta.serverName.trim() : input.profileName;
const generals = world.listGenerals();
const cities = world.listCities();
const nations = world.listNations();
const eligibleGenerals = generals.filter((general) => general.userId && general.npcState < 2);
// Ref cancels and refunds every unfinished unique auction before it merges
// inheritance. Keep that order inside the generation transaction.
const messageMailboxes = await cancelPendingUniqueAuctions(transaction, input, world);
const pointRows = eligibleGenerals.length
? await transaction.inheritancePoint.findMany({
where: { userId: { in: eligibleGenerals.map((general) => general.userId!) } },
select: { userId: true, key: true, value: true },
})
: [];
const pointsByUser = new Map<string, Map<string, number>>();
for (const row of pointRows) {
const points = pointsByUser.get(row.userId) ?? new Map<string, number>();
points.set(row.key, row.value);
pointsByUser.set(row.userId, points);
}
for (const general of eligibleGenerals) {
const userId = general.userId!;
const currentPoints = pointsByUser.get(userId) ?? new Map<string, number>();
const previous = currentPoints.get('previous') ?? 0;
const unifier = currentPoints.get('unifier') ?? 0;
const unifierAward = general.nationId === input.winnerNationId && general.officerLevel > 4 ? UNIFIER_POINT : 0;
const mergedPoints = Object.fromEntries(
ALL_MERGED_INHERITANCE_KEYS.map((key) => {
const stored = resolveStoredInheritancePoint(currentPoints, general, key, unifierAward);
const effectiveStored = key === 'unifier' ? stored + unifierAward : stored;
return [key, computeActiveInheritancePoint(general, key, effectiveStored)];
})
) as Record<(typeof ALL_MERGED_INHERITANCE_KEYS)[number], number>;
const total = Math.floor(previous + Object.values(mergedPoints).reduce((sum, value) => sum + value, 0));
await transaction.inheritancePoint.upsert({
where: { userId_key: { userId, key: 'previous' } },
update: { value: total },
create: { userId, key: 'previous', value: total },
});
await transaction.inheritancePoint.deleteMany({ where: { userId, key: { not: 'previous' } } });
await transaction.inheritanceResult.create({
data: {
serverId,
owner: userId,
generalId: general.id,
year: input.year,
month: input.month,
value: {
previous,
...mergedPoints,
unifierBeforeAward: unifier,
unifierAward,
total,
generationKey: input.generationKey,
},
},
});
await transaction.inheritanceLog.create({
data: {
userId,
serverId,
year: input.year,
month: input.month,
text: `천하 통일 정산: ${total.toLocaleString('ko-KR')} 포인트`,
},
});
}
const rankRows = generals.length
? await transaction.rankData.findMany({
where: { generalId: { in: generals.map((general) => general.id) } },
select: { generalId: true, type: true, value: true },
})
: [];
const ranksByGeneral = new Map<number, Record<string, number>>();
for (const row of rankRows) {
const ranks = ranksByGeneral.get(row.generalId) ?? {};
ranks[row.type] = row.value;
ranksByGeneral.set(row.generalId, ranks);
}
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const season = readInteger(meta.season, 1);
const scenario = readInteger(meta.scenarioId);
const scenarioName = String(asRecord(meta.scenarioMeta).title ?? '');
const startTime = typeof meta.starttime === 'string' ? meta.starttime : null;
const unitedTime = input.completedAt.toISOString();
const serverCount = await transaction.gameHistory.count();
const minHallAge = readInteger(asRecord(world.getScenarioConfig().const).minPushHallAge, 30);
const hallTypes: Array<[HallOfFameType, 'natural' | 'rank' | 'calc']> = HALL_OF_FAME_TYPES.map((type) => {
if (type === 'experience' || type === 'dedication' || type.startsWith('dex')) {
return [type, 'natural'];
}
return [type, type.endsWith('rate') ? 'calc' : 'rank'];
});
for (const general of eligibleGenerals.filter((entry) => entry.age >= minHallAge)) {
const ranks = ranksByGeneral.get(general.id) ?? {};
const totals = {
tt: (ranks.ttw ?? 0) + (ranks.ttd ?? 0) + (ranks.ttl ?? 0),
tl: (ranks.tlw ?? 0) + (ranks.tld ?? 0) + (ranks.tll ?? 0),
ts: (ranks.tsw ?? 0) + (ranks.tsd ?? 0) + (ranks.tsl ?? 0),
ti: (ranks.tiw ?? 0) + (ranks.tid ?? 0) + (ranks.til ?? 0),
};
const calc: Record<string, number> = {
winrate: computeRate(ranks.killnum ?? 0, ranks.warnum ?? 0),
killrate: computeRate(ranks.killcrew ?? 0, Math.max(1, ranks.deathcrew ?? 0)),
killrate_person: computeRate(ranks.killcrew_person ?? 0, Math.max(1, ranks.deathcrew_person ?? 0)),
ttrate: computeRate(ranks.ttw ?? 0, totals.tt),
tlrate: computeRate(ranks.tlw ?? 0, totals.tl),
tsrate: computeRate(ranks.tsw ?? 0, totals.ts),
tirate: computeRate(ranks.tiw ?? 0, totals.ti),
betrate: computeRate(ranks.betwingold ?? 0, Math.max(1, ranks.betgold ?? 0)),
};
const generalMeta = asRecord(general.meta);
const nation = nationMap.get(general.nationId);
const background = nation?.color ?? '#000000';
const aux = {
name: general.name,
nationName: nation?.name ?? '재야',
bgColor: background,
fgColor: resolveLegacyTextColor(background),
picture: general.picture ?? null,
imgsvr: general.imageServer ?? 0,
startTime,
unitedTime,
ownerDisplayName: ownerDisplayName(generalMeta),
serverID: serverId,
serverIdx: serverCount,
serverName,
scenarioName,
generationKey: input.generationKey,
};
for (const [type, valueType] of hallTypes) {
const value =
valueType === 'calc'
? (calc[type] ?? 0)
: valueType === 'rank'
? (ranks[type] ?? 0)
: type === 'experience'
? general.experience
: type === 'dedication'
? general.dedication
: readNumber(generalMeta[type]);
if ((type === 'winrate' || type === 'killrate') && (ranks.warnum ?? 0) < 10) continue;
if (type === 'ttrate' && totals.tt < 50) continue;
if (type === 'tlrate' && totals.tl < 50) continue;
if (type === 'tsrate' && totals.ts < 50) continue;
if (type === 'tirate' && totals.ti < 50) continue;
if (type === 'betrate' && (ranks.betgold ?? 0) < 1000) continue;
if (value <= 0) continue;
const existing = await transaction.hallOfFame.findFirst({
where: {
OR: [
{ serverId, type, generalNo: general.id },
{ serverId, type, owner: general.userId },
],
},
});
if (!existing) {
await transaction.hallOfFame.create({
data: {
serverId,
season,
scenario,
generalNo: general.id,
type,
value,
owner: general.userId ?? null,
aux,
},
});
} else if (value > existing.value) {
await transaction.hallOfFame.update({ where: { id: existing.id }, data: { value, aux } });
}
}
}
await transaction.gameHistory.update({
where: { serverId },
data: { winnerNation: input.winnerNationId, date: input.completedAt, status: 'COMPLETED' },
});
const nationHistoryRows = await transaction.logEntry.findMany({
where: { nationId: input.winnerNationId, scope: LogScope.NATION, category: LogCategory.HISTORY },
orderBy: { id: 'asc' },
select: { text: true },
});
const nationHistory = nationHistoryRows.map((row) => row.text);
const winnerGenerals = generals.filter((general) => general.nationId === input.winnerNationId);
const neutralGenerals = generals.filter((general) => general.nationId === 0);
const cityCount = cities.filter((city) => city.nationId === input.winnerNationId).length;
const totalPop = cities.reduce((sum, city) => sum + city.population, 0);
const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0);
const winnerMeta = asRecord(winner.meta);
const winnerData = {
...buildOldNationArchiveData({
nation: winner,
generalIds: winnerGenerals.map((general) => general.id),
history: nationHistory,
}),
msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''),
scout_msg: String(winnerMeta.scout_msg ?? ''),
generationKey: input.generationKey,
};
await transaction.oldNation.upsert({
where: { serverId_nation_sourceId: { serverId, nation: input.winnerNationId, sourceId: 0 } },
update: { data: asJson(winnerData), date: input.completedAt },
create: {
serverId,
nation: input.winnerNationId,
sourceId: 0,
data: asJson(winnerData),
date: input.completedAt,
},
});
const neutralData = {
nation: 0,
name: '재야',
generals: neutralGenerals.map((general) => general.id),
generationKey: input.generationKey,
};
await transaction.oldNation.upsert({
where: { serverId_nation_sourceId: { serverId, nation: 0, sourceId: 0 } },
update: { data: neutralData, date: input.completedAt },
create: { serverId, nation: 0, sourceId: 0, data: neutralData, date: input.completedAt },
});
const archiveGenerals = [...neutralGenerals, ...winnerGenerals];
const generalHistoryRows = archiveGenerals.length
? await transaction.logEntry.findMany({
where: {
generalId: { in: archiveGenerals.map((general) => general.id) },
scope: LogScope.GENERAL,
category: LogCategory.HISTORY,
},
orderBy: { id: 'asc' },
select: { generalId: true, text: true },
})
: [];
const historyByGeneral = new Map<number, string[]>();
for (const row of generalHistoryRows) {
if (row.generalId === null) continue;
const history = historyByGeneral.get(row.generalId) ?? [];
history.push(row.text);
historyByGeneral.set(row.generalId, history);
}
for (const general of archiveGenerals) {
const data = {
...general,
turnTime: general.turnTime.toISOString(),
history: historyByGeneral.get(general.id) ?? [],
generationKey: input.generationKey,
};
await transaction.oldGeneral.upsert({
where: { by_no: { serverId, generalNo: general.id } },
update: {
owner: general.userId ?? null,
name: general.name,
lastYearMonth: input.year * 100 + input.month,
turnTime: general.turnTime,
data: asJson(data),
},
create: {
serverId,
generalNo: general.id,
owner: general.userId ?? null,
name: general.name,
lastYearMonth: input.year * 100 + input.month,
turnTime: general.turnTime,
data: asJson(data),
},
});
}
const officerMap = new Map(
winnerGenerals
.filter((general) => general.officerLevel >= 5)
.map((general) => [general.officerLevel, general] as const)
);
const topList = (type: 'killnum' | 'firenum', limit: number): string =>
rankRows
.filter((row) => row.type === type && row.value > 0 && winnerGenerals.some((g) => g.id === row.generalId))
.sort((left, right) => right.value - left.value)
.slice(0, limit)
.map(
(row) =>
`${generals.find((general) => general.id === row.generalId)?.name ?? '무명'}${row.value.toLocaleString('ko-KR')}】`
)
.join(', ');
const previousNationArchives = await transaction.oldNation.findMany({
where: { serverId },
select: { data: true },
});
const archivedNationNames = previousNationArchives
.map((row) => asRecord(row.data).name)
.filter((name): name is string => typeof name === 'string' && Boolean(name));
if (!archivedNationNames.includes(winner.name)) archivedNationNames.push(winner.name);
const statistics = asRecord(meta.dynastyStatistics);
const nationCount = `1 / ${Math.max(1, readInteger(statistics.maxNationCount, 1))}`;
const genCount = `${generals.length} / ${Math.max(generals.length, readInteger(statistics.maxGeneralCount))}`;
const statisticNationNames = String(statistics.maxNationName ?? '').trim();
const personalHist = formatHistogram(statistics.personalHist);
const specialHist = [formatHistogram(statistics.specialHist), formatHistogram(statistics.special2Hist)]
.filter(Boolean)
.join(' // ');
const population = `${totalPop} / ${totalMaxPop}`;
const popRate = totalMaxPop > 0 ? `${Math.round((totalPop / totalMaxPop) * 10000) / 100} %` : '0 %';
const officer = (level: number) => officerMap.get(level);
await transaction.emperor.create({
data: {
serverId,
phase: `${serverName}${serverCount}기`,
nationCount,
nationName: statisticNationNames || archivedNationNames.join(', '),
nationHist: formatHistogram(statistics.maxNationHist),
genCount,
personalHist,
specialHist,
name: winner.name,
type: winner.typeCode,
color: winner.color,
year: input.year,
month: input.month,
power: winner.power,
gennum: winnerGenerals.length,
citynum: cityCount,
pop: population,
poprate: popRate,
gold: winner.gold,
rice: winner.rice,
l12name: officer(12)?.name ?? '',
l12pic: officer(12)?.picture ?? '',
l11name: officer(11)?.name ?? '',
l11pic: officer(11)?.picture ?? '',
l10name: officer(10)?.name ?? '',
l10pic: officer(10)?.picture ?? '',
l9name: officer(9)?.name ?? '',
l9pic: officer(9)?.picture ?? '',
l8name: officer(8)?.name ?? '',
l8pic: officer(8)?.picture ?? '',
l7name: officer(7)?.name ?? '',
l7pic: officer(7)?.picture ?? '',
l6name: officer(6)?.name ?? '',
l6pic: officer(6)?.picture ?? '',
l5name: officer(5)?.name ?? '',
l5pic: officer(5)?.picture ?? '',
tiger: topList('killnum', 5),
eagle: topList('firenum', 7),
gen: winnerGenerals
.slice()
.sort((left, right) => right.dedication - left.dedication)
.map((general) => general.name)
.join(', '),
history: nationHistory,
aux: { winnerNationId: input.winnerNationId, generationKey: input.generationKey },
},
});
return { status: 'APPLIED', generationKey: input.generationKey, messageMailboxes };
};