feat: 오픈 게임 취소와 유산 정산 경로 추가
별도 최고위험 권한으로 실행되는 원자적 취소 작업을 추가한다. 버려진 게임과 장수 기록의 보존·삭제 옵션, 오픈 원금 전액 환급 및 획득 포인트 보전율, 취소 상태와 관리자·과거기록 UI를 함께 반영한다.
This commit is contained in:
@@ -30,6 +30,10 @@
|
||||
"types": "./dist/scenario/scenarioSeeder.d.ts",
|
||||
"default": "./dist/scenario/scenarioSeeder.js"
|
||||
},
|
||||
"./scenario/gameCancellation.js": {
|
||||
"types": "./dist/scenario/gameCancellation.d.ts",
|
||||
"default": "./dist/scenario/gameCancellation.js"
|
||||
},
|
||||
"./scenario/unitSetLoader.js": {
|
||||
"types": "./dist/scenario/unitSetLoader.d.ts",
|
||||
"default": "./dist/scenario/unitSetLoader.js"
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from './scenario/generalPoolLoader.js';
|
||||
export * from './scenario/databaseUrl.js';
|
||||
export * from './scenario/mapLoader.js';
|
||||
export * from './scenario/scenarioSeeder.js';
|
||||
export * from './scenario/gameCancellation.js';
|
||||
export * from './turn/types.js';
|
||||
export * from './turn/worldLoader.js';
|
||||
export * from './turn/inMemoryWorld.js';
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { createGamePostgresConnector, type GamePrisma, type InputJsonValue } from '@sammo-ts/infra';
|
||||
import { LogCategory, LogScope } from '@sammo-ts/logic';
|
||||
|
||||
import { ALL_MERGED_INHERITANCE_KEYS, computeActiveInheritancePoint } from '../turn/inheritancePointCalculation.js';
|
||||
|
||||
export const GAME_CANCELLATION_HISTORY_MODES = ['RETAIN_ABANDONED', 'DELETE'] as const;
|
||||
export const GAME_CANCELLATION_GENERAL_MODES = ['RETAIN', 'DELETE'] as const;
|
||||
|
||||
export type GameCancellationHistoryMode = (typeof GAME_CANCELLATION_HISTORY_MODES)[number];
|
||||
export type GameCancellationGeneralMode = (typeof GAME_CANCELLATION_GENERAL_MODES)[number];
|
||||
|
||||
export interface GameCancellationRequest {
|
||||
cancellationId: string;
|
||||
databaseUrl: string;
|
||||
cancelledBy: string;
|
||||
reason: string;
|
||||
historyMode: GameCancellationHistoryMode;
|
||||
generalMode: GameCancellationGeneralMode;
|
||||
earnedPointRetentionPercent: number;
|
||||
cancelledAt?: Date;
|
||||
}
|
||||
|
||||
export interface GameCancellationSettlementEntry {
|
||||
openingPoint: number;
|
||||
currentPoint: number;
|
||||
earnedPoint: number;
|
||||
retainedEarnedPoint: number;
|
||||
finalPoint: number;
|
||||
baselineSource: string;
|
||||
}
|
||||
|
||||
export interface GameCancellationResult {
|
||||
cancellationId: string;
|
||||
serverId: string;
|
||||
originalSeason: number;
|
||||
participantCount: number;
|
||||
preservedGeneralCount: number;
|
||||
historyMode: GameCancellationHistoryMode;
|
||||
generalMode: GameCancellationGeneralMode;
|
||||
earnedPointRetentionPercent: number;
|
||||
alreadyApplied: boolean;
|
||||
settlements: Record<string, GameCancellationSettlementEntry>;
|
||||
}
|
||||
|
||||
const asJson = (value: unknown): InputJsonValue => value as InputJsonValue;
|
||||
|
||||
const numberValue = (value: unknown): number => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value.replaceAll(',', ''));
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const integerValue = (value: unknown, fallback = 0): number => {
|
||||
const parsed = numberValue(value);
|
||||
return parsed === 0 && value === undefined ? fallback : Math.trunc(parsed);
|
||||
};
|
||||
|
||||
const parseLoggedPoint = (text: string, pattern: RegExp): number => {
|
||||
const match = pattern.exec(text);
|
||||
return match ? numberValue(match[1]) : 0;
|
||||
};
|
||||
|
||||
const sumSettlementEarned = (value: unknown): number => {
|
||||
const record = asRecord(value);
|
||||
return [
|
||||
'lived_month',
|
||||
'max_belong',
|
||||
'max_domestic_critical',
|
||||
'active_action',
|
||||
'combat',
|
||||
'sabotage',
|
||||
'dex',
|
||||
'unifier',
|
||||
'tournament',
|
||||
'betting',
|
||||
].reduce((sum, key) => sum + numberValue(record[key]), 0);
|
||||
};
|
||||
|
||||
export const calculateCancelledInheritancePoint = (input: {
|
||||
openingPoint: number;
|
||||
earnedPoint: number;
|
||||
earnedPointRetentionPercent: number;
|
||||
}): { retainedEarnedPoint: number; finalPoint: number } => {
|
||||
if (
|
||||
!Number.isInteger(input.earnedPointRetentionPercent) ||
|
||||
input.earnedPointRetentionPercent < 0 ||
|
||||
input.earnedPointRetentionPercent > 100
|
||||
) {
|
||||
throw new Error('Earned inheritance point retention percent must be an integer from 0 to 100.');
|
||||
}
|
||||
const retainedEarnedPoint = Math.floor((input.earnedPoint * input.earnedPointRetentionPercent) / 100);
|
||||
return {
|
||||
retainedEarnedPoint,
|
||||
finalPoint: Math.floor(input.openingPoint + retainedEarnedPoint),
|
||||
};
|
||||
};
|
||||
|
||||
type ActiveGeneral = {
|
||||
id: number;
|
||||
userId: string | null;
|
||||
name: string;
|
||||
nationId: number;
|
||||
cityId: number;
|
||||
troopId: number;
|
||||
npcState: number;
|
||||
affinity: number | null;
|
||||
bornYear: number;
|
||||
deadYear: number;
|
||||
picture: string | null;
|
||||
imageServer: number;
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intel: number;
|
||||
injury: number;
|
||||
experience: number;
|
||||
dedication: number;
|
||||
officerLevel: number;
|
||||
gold: number;
|
||||
rice: number;
|
||||
crew: number;
|
||||
crewTypeId: number;
|
||||
train: number;
|
||||
atmos: number;
|
||||
weaponCode: string;
|
||||
bookCode: string;
|
||||
horseCode: string;
|
||||
itemCode: string;
|
||||
turnTime: Date;
|
||||
recentWarTime: Date | null;
|
||||
age: number;
|
||||
startAge: number;
|
||||
personalCode: string;
|
||||
specialCode: string;
|
||||
special2Code: string;
|
||||
lastTurn: unknown;
|
||||
meta: unknown;
|
||||
penalty: unknown;
|
||||
};
|
||||
|
||||
const buildActiveGeneralArchive = (
|
||||
general: ActiveGeneral,
|
||||
history: string[],
|
||||
cancellation: { id: string; at: Date; reason: string }
|
||||
): InputJsonValue =>
|
||||
asJson({
|
||||
id: general.id,
|
||||
userId: general.userId,
|
||||
name: general.name,
|
||||
nationId: general.nationId,
|
||||
cityId: general.cityId,
|
||||
troopId: general.troopId,
|
||||
npcState: general.npcState,
|
||||
affinity: general.affinity,
|
||||
bornYear: general.bornYear,
|
||||
deadYear: general.deadYear,
|
||||
picture: general.picture,
|
||||
imageServer: general.imageServer,
|
||||
stats: {
|
||||
leadership: general.leadership,
|
||||
strength: general.strength,
|
||||
intelligence: general.intel,
|
||||
},
|
||||
experience: general.experience,
|
||||
dedication: general.dedication,
|
||||
officerLevel: general.officerLevel,
|
||||
injury: general.injury,
|
||||
gold: general.gold,
|
||||
rice: general.rice,
|
||||
crew: general.crew,
|
||||
crewTypeId: general.crewTypeId,
|
||||
train: general.train,
|
||||
atmos: general.atmos,
|
||||
turnTime: general.turnTime.toISOString(),
|
||||
recentWarTime: general.recentWarTime?.toISOString() ?? null,
|
||||
age: general.age,
|
||||
startAge: general.startAge,
|
||||
role: {
|
||||
personality: general.personalCode,
|
||||
specialDomestic: general.specialCode,
|
||||
specialWar: general.special2Code,
|
||||
items: {
|
||||
weapon: general.weaponCode === 'None' ? null : general.weaponCode,
|
||||
book: general.bookCode === 'None' ? null : general.bookCode,
|
||||
horse: general.horseCode === 'None' ? null : general.horseCode,
|
||||
item: general.itemCode === 'None' ? null : general.itemCode,
|
||||
},
|
||||
},
|
||||
lastTurn: general.lastTurn,
|
||||
meta: general.meta,
|
||||
penalty: general.penalty,
|
||||
history,
|
||||
abandonedGame: {
|
||||
cancellationId: cancellation.id,
|
||||
cancelledAt: cancellation.at.toISOString(),
|
||||
reason: cancellation.reason,
|
||||
},
|
||||
});
|
||||
|
||||
const resultFromPersisted = (row: {
|
||||
id: string;
|
||||
serverId: string;
|
||||
originalSeason: number;
|
||||
participantCount: number;
|
||||
preservedGeneralCount: number;
|
||||
historyMode: GameCancellationHistoryMode;
|
||||
generalMode: GameCancellationGeneralMode;
|
||||
earnedPointRetentionPercent: number;
|
||||
settlement: unknown;
|
||||
}): GameCancellationResult => ({
|
||||
cancellationId: row.id,
|
||||
serverId: row.serverId,
|
||||
originalSeason: row.originalSeason,
|
||||
participantCount: row.participantCount,
|
||||
preservedGeneralCount: row.preservedGeneralCount,
|
||||
historyMode: row.historyMode,
|
||||
generalMode: row.generalMode,
|
||||
earnedPointRetentionPercent: row.earnedPointRetentionPercent,
|
||||
alreadyApplied: true,
|
||||
settlements: asRecord(row.settlement) as Record<string, GameCancellationSettlementEntry>,
|
||||
});
|
||||
|
||||
const cancelGameInTransaction = async (
|
||||
prisma: GamePrisma.TransactionClient,
|
||||
request: Omit<GameCancellationRequest, 'databaseUrl' | 'cancelledAt'> & { cancelledAt: Date }
|
||||
): Promise<GameCancellationResult> => {
|
||||
await prisma.$queryRawUnsafe(
|
||||
'SELECT pg_advisory_xact_lock(hashtextextended(current_schema(), 0))::text AS lock_result'
|
||||
);
|
||||
|
||||
const existingById = await prisma.gameCancellation.findUnique({ where: { id: request.cancellationId } });
|
||||
if (existingById) return resultFromPersisted(existingById);
|
||||
|
||||
const world = await prisma.worldState.findFirst();
|
||||
if (!world) {
|
||||
const latest = await prisma.gameCancellation.findFirst({ orderBy: { cancelledAt: 'desc' } });
|
||||
if (latest) return resultFromPersisted(latest);
|
||||
throw new Error('The profile has no active game to cancel.');
|
||||
}
|
||||
const worldMeta = asRecord(world.meta);
|
||||
const serverId = typeof worldMeta.serverId === 'string' ? worldMeta.serverId.trim() : '';
|
||||
if (!serverId) throw new Error('The active game has no canonical serverId.');
|
||||
|
||||
const existingByServer = await prisma.gameCancellation.findUnique({ where: { serverId } });
|
||||
if (existingByServer) return resultFromPersisted(existingByServer);
|
||||
const isUnited = integerValue(worldMeta.isUnited ?? worldMeta.isunited);
|
||||
if (isUnited !== 0) throw new Error('A completed or finalizing game cannot be cancelled.');
|
||||
|
||||
const game = await prisma.gameHistory.findUnique({ where: { serverId } });
|
||||
if (!game) throw new Error(`The active game history is missing: ${serverId}`);
|
||||
if (game.status !== 'OPEN') throw new Error(`Only an OPEN game can be cancelled: ${game.status}`);
|
||||
|
||||
const [activeGenerals, oldGenerals, pointRows, baselineRows, resultRows, inheritanceLogs] = await Promise.all([
|
||||
prisma.general.findMany({ where: { userId: { not: null } } }),
|
||||
prisma.oldGeneral.findMany({ where: { serverId } }),
|
||||
prisma.inheritancePoint.findMany(),
|
||||
prisma.gameInheritanceBaseline.findMany({ where: { serverId } }),
|
||||
prisma.inheritanceResult.findMany({ where: { serverId } }),
|
||||
prisma.inheritanceLog.findMany({
|
||||
where: { createdAt: { gte: game.date, lte: request.cancelledAt } },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const activeIds = activeGenerals.map((general) => general.id);
|
||||
const [resolvedRankRows, resolvedHistoryLogs] = await Promise.all([
|
||||
activeIds.length ? prisma.rankData.findMany({ where: { generalId: { in: activeIds } } }) : [],
|
||||
activeIds.length
|
||||
? prisma.logEntry.findMany({
|
||||
where: {
|
||||
generalId: { in: activeIds },
|
||||
scope: LogScope.GENERAL,
|
||||
category: LogCategory.HISTORY,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
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);
|
||||
}
|
||||
const baselineByUser = new Map(baselineRows.map((row) => [row.userId, row]));
|
||||
const ranksByGeneral = new Map<number, Record<string, number>>();
|
||||
for (const row of resolvedRankRows) {
|
||||
const ranks = ranksByGeneral.get(row.generalId) ?? {};
|
||||
ranks[row.type] = row.value;
|
||||
ranksByGeneral.set(row.generalId, ranks);
|
||||
}
|
||||
const logsByGeneral = new Map<number, string[]>();
|
||||
for (const row of resolvedHistoryLogs) {
|
||||
if (row.generalId === null) continue;
|
||||
const logs = logsByGeneral.get(row.generalId) ?? [];
|
||||
logs.push(row.text);
|
||||
logsByGeneral.set(row.generalId, logs);
|
||||
}
|
||||
|
||||
const participantUsers = new Set<string>();
|
||||
for (const general of activeGenerals) if (general.userId) participantUsers.add(general.userId);
|
||||
for (const general of oldGenerals) if (general.owner) participantUsers.add(general.owner);
|
||||
for (const result of resultRows) participantUsers.add(result.owner);
|
||||
for (const baseline of baselineRows) participantUsers.add(baseline.userId);
|
||||
|
||||
const resultsByUser = new Map<string, typeof resultRows>();
|
||||
for (const result of resultRows) {
|
||||
const rows = resultsByUser.get(result.owner) ?? [];
|
||||
rows.push(result);
|
||||
resultsByUser.set(result.owner, rows);
|
||||
}
|
||||
const inheritanceLogsByUser = new Map<string, typeof inheritanceLogs>();
|
||||
for (const log of inheritanceLogs) {
|
||||
if (!participantUsers.has(log.userId)) continue;
|
||||
const rows = inheritanceLogsByUser.get(log.userId) ?? [];
|
||||
rows.push(log);
|
||||
inheritanceLogsByUser.set(log.userId, rows);
|
||||
}
|
||||
|
||||
const trackedSpentByUser = new Map<string, number>();
|
||||
const trackedByGeneral = new Map<number, { userId: string; value: number }>();
|
||||
for (const general of oldGenerals) {
|
||||
if (!general.owner) continue;
|
||||
const value = numberValue(asRecord(asRecord(general.data).meta).inherit_spent_dyn);
|
||||
trackedByGeneral.set(general.generalNo, { userId: general.owner, value });
|
||||
}
|
||||
for (const general of activeGenerals) {
|
||||
if (!general.userId) continue;
|
||||
const value = Math.max(
|
||||
numberValue(asRecord(general.meta).inherit_spent_dyn),
|
||||
numberValue(ranksByGeneral.get(general.id)?.inherit_spent_dyn)
|
||||
);
|
||||
trackedByGeneral.set(general.id, { userId: general.userId, value });
|
||||
}
|
||||
for (const tracked of trackedByGeneral.values()) {
|
||||
trackedSpentByUser.set(tracked.userId, (trackedSpentByUser.get(tracked.userId) ?? 0) + tracked.value);
|
||||
}
|
||||
|
||||
const activeEarnedByUser = new Map<string, number>();
|
||||
for (const general of activeGenerals) {
|
||||
if (!general.userId || general.npcState >= 2) continue;
|
||||
const points = pointsByUser.get(general.userId) ?? new Map<string, number>();
|
||||
const inheritancePoints = Object.fromEntries(points);
|
||||
const ranks = ranksByGeneral.get(general.id) ?? {};
|
||||
const meta = {
|
||||
...asRecord(general.meta),
|
||||
...Object.fromEntries(Object.entries(ranks).map(([k, v]) => [`rank_${k}`, v])),
|
||||
};
|
||||
const earned = ALL_MERGED_INHERITANCE_KEYS.reduce(
|
||||
(sum, key) => sum + computeActiveInheritancePoint({ meta, inheritancePoints }, key),
|
||||
0
|
||||
);
|
||||
activeEarnedByUser.set(general.userId, (activeEarnedByUser.get(general.userId) ?? 0) + earned);
|
||||
}
|
||||
|
||||
const settlements: Record<string, GameCancellationSettlementEntry> = {};
|
||||
for (const userId of [...participantUsers].sort()) {
|
||||
const logs = inheritanceLogsByUser.get(userId) ?? [];
|
||||
const settledEarned = (resultsByUser.get(userId) ?? []).reduce(
|
||||
(sum, row) => sum + sumSettlementEarned(row.value),
|
||||
0
|
||||
);
|
||||
const settledRefund = (resultsByUser.get(userId) ?? []).reduce(
|
||||
(sum, row) => sum + numberValue(asRecord(row.value).refund),
|
||||
0
|
||||
);
|
||||
const actionEarned = logs.reduce(
|
||||
(sum, row) =>
|
||||
sum +
|
||||
parseLoggedPoint(row.text, /보상으로\s+([\d,.]+)\s*포인트 획득/) +
|
||||
parseLoggedPoint(row.text, /신규\/복귀 생성으로 포인트\s+([\d,.]+)\s*지급/),
|
||||
0
|
||||
);
|
||||
let baseline = baselineByUser.get(userId);
|
||||
if (!baseline) {
|
||||
const directSpent = logs.reduce((sum, row) => {
|
||||
const standard = parseLoggedPoint(row.text, /^([\d,.]+)\s+포인트로\s+/);
|
||||
const statBonus = parseLoggedPoint(row.text, /^([\d,.]+)로 .*보너스 능력치 적용/);
|
||||
return sum + standard + statBonus;
|
||||
}, 0);
|
||||
const currentPoint = pointsByUser.get(userId)?.get('previous') ?? 0;
|
||||
const openingPoint =
|
||||
currentPoint +
|
||||
(trackedSpentByUser.get(userId) ?? 0) +
|
||||
directSpent -
|
||||
settledRefund -
|
||||
settledEarned -
|
||||
actionEarned;
|
||||
if (!Number.isFinite(openingPoint) || openingPoint < 0) {
|
||||
throw new Error(`Cannot reconstruct a safe inheritance baseline for user ${userId}.`);
|
||||
}
|
||||
baseline = await prisma.gameInheritanceBaseline.create({
|
||||
data: {
|
||||
serverId,
|
||||
userId,
|
||||
openingPoint,
|
||||
source: 'RECONSTRUCTED',
|
||||
},
|
||||
});
|
||||
baselineByUser.set(userId, baseline);
|
||||
}
|
||||
const earnedPoint = settledEarned + actionEarned + (activeEarnedByUser.get(userId) ?? 0);
|
||||
const calculated = calculateCancelledInheritancePoint({
|
||||
openingPoint: baseline.openingPoint,
|
||||
earnedPoint,
|
||||
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
||||
});
|
||||
const currentPoint = pointsByUser.get(userId)?.get('previous') ?? 0;
|
||||
await prisma.inheritancePoint.upsert({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
update: { value: calculated.finalPoint },
|
||||
create: { userId, key: 'previous', value: calculated.finalPoint },
|
||||
});
|
||||
await prisma.inheritancePoint.deleteMany({ where: { userId, key: { not: 'previous' } } });
|
||||
await prisma.inheritanceLog.create({
|
||||
data: {
|
||||
userId,
|
||||
serverId,
|
||||
year: world.currentYear,
|
||||
month: world.currentMonth,
|
||||
text: `취소 게임 정산: 원금 ${Math.floor(baseline.openingPoint)}, 획득 ${Math.floor(earnedPoint)} 중 ${request.earnedPointRetentionPercent}% 보전, 최종 ${calculated.finalPoint} 포인트`,
|
||||
},
|
||||
});
|
||||
settlements[userId] = {
|
||||
openingPoint: baseline.openingPoint,
|
||||
currentPoint,
|
||||
earnedPoint,
|
||||
retainedEarnedPoint: calculated.retainedEarnedPoint,
|
||||
finalPoint: calculated.finalPoint,
|
||||
baselineSource: baseline.source,
|
||||
};
|
||||
}
|
||||
|
||||
let preservedGeneralCount = 0;
|
||||
if (request.generalMode === 'RETAIN') {
|
||||
const abandonment = { id: request.cancellationId, at: request.cancelledAt, reason: request.reason };
|
||||
for (const row of oldGenerals) {
|
||||
const data = asRecord(row.data);
|
||||
await prisma.oldGeneral.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
data: asJson({
|
||||
...data,
|
||||
abandonedGame: {
|
||||
cancellationId: abandonment.id,
|
||||
cancelledAt: abandonment.at.toISOString(),
|
||||
reason: abandonment.reason,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const general of activeGenerals) {
|
||||
if (!general.userId || general.npcState >= 2) continue;
|
||||
await prisma.oldGeneral.upsert({
|
||||
where: { by_no: { serverId, generalNo: general.id } },
|
||||
update: {
|
||||
owner: general.userId,
|
||||
name: general.name,
|
||||
lastYearMonth: world.currentYear * 100 + world.currentMonth,
|
||||
turnTime: general.turnTime,
|
||||
data: buildActiveGeneralArchive(
|
||||
general as ActiveGeneral,
|
||||
logsByGeneral.get(general.id) ?? [],
|
||||
abandonment
|
||||
),
|
||||
},
|
||||
create: {
|
||||
serverId,
|
||||
generalNo: general.id,
|
||||
owner: general.userId,
|
||||
name: general.name,
|
||||
lastYearMonth: world.currentYear * 100 + world.currentMonth,
|
||||
turnTime: general.turnTime,
|
||||
data: buildActiveGeneralArchive(
|
||||
general as ActiveGeneral,
|
||||
logsByGeneral.get(general.id) ?? [],
|
||||
abandonment
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
preservedGeneralCount = await prisma.oldGeneral.count({ where: { serverId, owner: { not: null } } });
|
||||
} else {
|
||||
await prisma.oldGeneral.deleteMany({ where: { serverId } });
|
||||
}
|
||||
|
||||
await prisma.hallOfFame.deleteMany({ where: { serverId } });
|
||||
await prisma.oldNation.deleteMany({ where: { serverId } });
|
||||
await prisma.emperor.deleteMany({ where: { serverId } });
|
||||
await prisma.yearbookHistory.deleteMany({ where: { profileName: serverId } });
|
||||
await prisma.unificationFinalization.deleteMany({ where: { serverId } });
|
||||
await prisma.inheritanceResult.deleteMany({ where: { serverId } });
|
||||
|
||||
if (request.historyMode === 'RETAIN_ABANDONED') {
|
||||
const env = asRecord(game.env);
|
||||
const envMeta = asRecord(env.meta);
|
||||
await prisma.gameHistory.update({
|
||||
where: { serverId },
|
||||
data: {
|
||||
winnerNation: null,
|
||||
status: 'ABANDONED',
|
||||
env: asJson({
|
||||
...env,
|
||||
meta: {
|
||||
...envMeta,
|
||||
cancellationId: request.cancellationId,
|
||||
cancelledAt: request.cancelledAt.toISOString(),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.gameHistory.delete({ where: { serverId } });
|
||||
}
|
||||
|
||||
await prisma.worldState.update({
|
||||
where: { id: world.id },
|
||||
data: {
|
||||
meta: asJson({
|
||||
...worldMeta,
|
||||
isCancelled: 1,
|
||||
cancellationId: request.cancellationId,
|
||||
cancelledAt: request.cancelledAt.toISOString(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const created = await prisma.gameCancellation.create({
|
||||
data: {
|
||||
id: request.cancellationId,
|
||||
serverId,
|
||||
originalSeason: game.season,
|
||||
scenario: game.scenario,
|
||||
scenarioName: game.scenarioName,
|
||||
openedAt: game.date,
|
||||
cancelledAt: request.cancelledAt,
|
||||
cancelledBy: request.cancelledBy,
|
||||
reason: request.reason,
|
||||
historyMode: request.historyMode,
|
||||
generalMode: request.generalMode,
|
||||
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
||||
participantCount: participantUsers.size,
|
||||
preservedGeneralCount,
|
||||
settlement: asJson(settlements),
|
||||
},
|
||||
});
|
||||
|
||||
return { ...resultFromPersisted(created), alreadyApplied: false };
|
||||
};
|
||||
|
||||
export const cancelGame = async (request: GameCancellationRequest): Promise<GameCancellationResult> => {
|
||||
if (!request.reason.trim()) throw new Error('Game cancellation reason is required.');
|
||||
if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.');
|
||||
if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.');
|
||||
calculateCancelledInheritancePoint({
|
||||
openingPoint: 0,
|
||||
earnedPoint: 0,
|
||||
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
||||
});
|
||||
const connector = createGamePostgresConnector({ url: request.databaseUrl });
|
||||
await connector.connect();
|
||||
try {
|
||||
return await connector.prisma.$transaction(
|
||||
(prisma) =>
|
||||
cancelGameInTransaction(prisma, {
|
||||
...request,
|
||||
reason: request.reason.trim(),
|
||||
cancelledAt: request.cancelledAt ?? new Date(),
|
||||
}),
|
||||
{ timeout: 60_000 }
|
||||
);
|
||||
} finally {
|
||||
await connector.disconnect();
|
||||
}
|
||||
};
|
||||
@@ -439,6 +439,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
: 1,
|
||||
scenario: options.scenarioId,
|
||||
scenarioName: String(seed.scenarioMeta?.title ?? ''),
|
||||
status: 'OPEN',
|
||||
env: asJson({
|
||||
config: scenarioConfig,
|
||||
meta: archivedWorldMeta,
|
||||
@@ -454,12 +455,33 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
: 1,
|
||||
scenario: options.scenarioId,
|
||||
scenarioName: String(seed.scenarioMeta?.title ?? ''),
|
||||
status: 'OPEN',
|
||||
env: asJson({
|
||||
config: scenarioConfig,
|
||||
meta: archivedWorldMeta,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const openingPointRows = await prisma.inheritancePoint.findMany({
|
||||
select: { userId: true, value: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const openingPoints = new Map<string, number>();
|
||||
for (const row of openingPointRows) {
|
||||
openingPoints.set(row.userId, (openingPoints.get(row.userId) ?? 0) + row.value);
|
||||
}
|
||||
if (openingPoints.size > 0) {
|
||||
await prisma.gameInheritanceBaseline.createMany({
|
||||
data: Array.from(openingPoints, ([userId, openingPoint]) => ({
|
||||
serverId: worldMeta.serverId as string,
|
||||
userId,
|
||||
openingPoint: Math.trunc(openingPoint),
|
||||
source: 'OPENING',
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (seed.nations.length > 0) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { TurnGeneral } from './types.js';
|
||||
|
||||
const DEX_LIMIT = 1_275_975;
|
||||
|
||||
interface InheritancePointGeneral {
|
||||
meta: Record<string, unknown>;
|
||||
inheritancePoints?: Record<string, number>;
|
||||
}
|
||||
|
||||
const STORED_INHERITANCE_KEYS = [
|
||||
'lived_month',
|
||||
'max_domestic_critical',
|
||||
@@ -31,7 +34,7 @@ const readNumber = (source: Record<string, unknown>, key: string): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const computeDexPoint = (general: TurnGeneral): number => {
|
||||
const computeDexPoint = (general: InheritancePointGeneral): number => {
|
||||
let totalDexterity = 0;
|
||||
for (let index = 1; index <= 5; index += 1) {
|
||||
let dexterity = readNumber(general.meta, `dex${index}`);
|
||||
@@ -44,7 +47,7 @@ const computeDexPoint = (general: TurnGeneral): number => {
|
||||
return totalDexterity * 0.001;
|
||||
};
|
||||
|
||||
const computeBettingPoint = (general: TurnGeneral): number => {
|
||||
const computeBettingPoint = (general: InheritancePointGeneral): number => {
|
||||
const wins = readNumber(general.meta, 'betwin');
|
||||
const gold = readNumber(general.meta, 'betgold');
|
||||
const wonGold = readNumber(general.meta, 'betwingold');
|
||||
@@ -53,7 +56,7 @@ const computeBettingPoint = (general: TurnGeneral): number => {
|
||||
};
|
||||
|
||||
export const computeActiveInheritancePoint = (
|
||||
general: TurnGeneral,
|
||||
general: InheritancePointGeneral,
|
||||
key: MergedInheritanceKey,
|
||||
storedOverride?: number
|
||||
): number => {
|
||||
|
||||
@@ -245,6 +245,23 @@ const setInheritancePoint = async (db: DatabaseClient, userId: string, value: nu
|
||||
});
|
||||
};
|
||||
|
||||
const ensureGameInheritanceBaseline = async (
|
||||
db: DatabaseClient,
|
||||
worldMeta: Record<string, unknown>,
|
||||
userId: string,
|
||||
openingPoint: number
|
||||
): Promise<void> => {
|
||||
const serverId = typeof worldMeta.serverId === 'string' ? worldMeta.serverId.trim() : '';
|
||||
if (!serverId) {
|
||||
throw new Error('현재 게임의 serverId가 없어 유산 포인트 원금을 기록할 수 없습니다.');
|
||||
}
|
||||
await db.gameInheritanceBaseline.upsert({
|
||||
where: { serverId_userId: { serverId, userId } },
|
||||
update: {},
|
||||
create: { serverId, userId, openingPoint, source: 'FIRST_ACTIVITY' },
|
||||
});
|
||||
};
|
||||
|
||||
const appendInheritanceLog = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
@@ -583,6 +600,7 @@ export const createGeneralFromJoin = async (options: {
|
||||
|
||||
const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat);
|
||||
const inheritConstants = resolveInheritConstants(worldState);
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus);
|
||||
const currentInheritancePoint = await applyInheritanceUser(
|
||||
db,
|
||||
@@ -590,6 +608,7 @@ export const createGeneralFromJoin = async (options: {
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth
|
||||
);
|
||||
await ensureGameInheritanceBaseline(db, worldMeta, input.userId, currentInheritancePoint);
|
||||
if (currentInheritancePoint < inheritRequiredPoint) {
|
||||
fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!');
|
||||
}
|
||||
@@ -613,7 +632,6 @@ export const createGeneralFromJoin = async (options: {
|
||||
buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, world.dateToGameTick(acceptedAt))
|
||||
)
|
||||
);
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
const currentGenius = Math.max(
|
||||
0,
|
||||
Math.floor(asNumber(worldMeta.genius, asNumber(configConst.defaultMaxGenius, DEFAULT_MAX_GENIUS)))
|
||||
|
||||
@@ -482,7 +482,7 @@ export const persistUnificationFinalization = async (
|
||||
|
||||
await transaction.gameHistory.update({
|
||||
where: { serverId },
|
||||
data: { winnerNation: input.winnerNationId, date: input.completedAt },
|
||||
data: { winnerNation: input.winnerNationId, date: input.completedAt, status: 'COMPLETED' },
|
||||
});
|
||||
|
||||
const nationHistoryRows = await transaction.logEntry.findMany({
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { cancelGame } from '../src/scenario/gameCancellation.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const serverId = 'che_game_cancellation_fixture';
|
||||
const userId = 'game-cancellation-user';
|
||||
const generalId = 9_851;
|
||||
const openedAt = new Date('2026-08-18T00:00:00.000Z');
|
||||
const cancelledAt = new Date('2026-08-18T01:00:00.000Z');
|
||||
|
||||
integration('game cancellation transaction', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
|
||||
const cleanup = async (): Promise<void> => {
|
||||
await db.gameCancellation.deleteMany({ where: { serverId } });
|
||||
await db.gameInheritanceBaseline.deleteMany({ where: { serverId } });
|
||||
await db.unificationFinalization.deleteMany({ where: { serverId } });
|
||||
await db.yearbookHistory.deleteMany({ where: { profileName: serverId } });
|
||||
await db.emperor.deleteMany({ where: { serverId } });
|
||||
await db.oldGeneral.deleteMany({ where: { serverId } });
|
||||
await db.oldNation.deleteMany({ where: { serverId } });
|
||||
await db.hallOfFame.deleteMany({ where: { serverId } });
|
||||
await db.inheritanceResult.deleteMany({ where: { serverId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.gameHistory.deleteMany({ where: { serverId } });
|
||||
await db.logEntry.deleteMany({ where: { generalId } });
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode: 'game-cancellation-fixture' } });
|
||||
};
|
||||
|
||||
const seed = async (): Promise<void> => {
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'game-cancellation-fixture',
|
||||
currentYear: 190,
|
||||
currentMonth: 7,
|
||||
tickSeconds: 600,
|
||||
meta: { serverId, season: 7, isUnited: 0 },
|
||||
},
|
||||
});
|
||||
await db.gameHistory.create({
|
||||
data: {
|
||||
serverId,
|
||||
date: openedAt,
|
||||
season: 7,
|
||||
scenario: 1010,
|
||||
scenarioName: '취소 테스트',
|
||||
status: 'OPEN',
|
||||
env: { meta: { serverId, season: 7 } },
|
||||
},
|
||||
});
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '취소장수',
|
||||
turnTime: openedAt,
|
||||
meta: { inherit_spent_dyn: 4_500 },
|
||||
},
|
||||
});
|
||||
await db.rankData.createMany({
|
||||
data: [
|
||||
{ generalId, nationId: 0, type: 'inherit_spent_dyn', value: 4_500 },
|
||||
{ generalId, nationId: 0, type: 'warnum', value: 10 },
|
||||
],
|
||||
});
|
||||
await db.inheritancePoint.createMany({
|
||||
data: [
|
||||
{ userId, key: 'previous', value: 7_000 },
|
||||
{ userId, key: 'max_domestic_critical', value: 200 },
|
||||
],
|
||||
});
|
||||
await db.gameInheritanceBaseline.create({
|
||||
data: { serverId, userId, openingPoint: 10_000, source: 'OPENING' },
|
||||
});
|
||||
await db.inheritanceLog.create({
|
||||
data: {
|
||||
userId,
|
||||
serverId,
|
||||
year: 190,
|
||||
month: 7,
|
||||
text: '신규/복귀 생성으로 포인트 1500 지급',
|
||||
createdAt: new Date('2026-08-18T00:30:00.000Z'),
|
||||
},
|
||||
});
|
||||
await db.oldGeneral.create({
|
||||
data: {
|
||||
serverId,
|
||||
generalNo: generalId - 1,
|
||||
owner: userId,
|
||||
name: '사망장수',
|
||||
lastYearMonth: 19006,
|
||||
turnTime: openedAt,
|
||||
data: { meta: { inherit_spent_dyn: 0 } },
|
||||
},
|
||||
});
|
||||
await db.hallOfFame.create({
|
||||
data: { serverId, season: 7, scenario: 1010, generalNo: generalId, type: 'warnum', value: 10 },
|
||||
});
|
||||
await db.oldNation.create({ data: { serverId, nation: 1, sourceId: 1 } });
|
||||
await db.emperor.create({ data: { serverId, name: '취소 황제' } });
|
||||
await db.yearbookHistory.create({
|
||||
data: { profileName: serverId, year: 190, month: 7, map: {}, nations: {} },
|
||||
});
|
||||
await db.unificationFinalization.create({
|
||||
data: {
|
||||
generationKey: `${serverId}:fixture`,
|
||||
serverId,
|
||||
profileName: 'che',
|
||||
winnerNation: 1,
|
||||
year: 190,
|
||||
month: 7,
|
||||
completedAt: cancelledAt,
|
||||
},
|
||||
});
|
||||
await db.inheritanceResult.create({
|
||||
data: {
|
||||
serverId,
|
||||
owner: userId,
|
||||
generalId,
|
||||
year: 190,
|
||||
month: 7,
|
||||
value: { refund: 0 },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanup();
|
||||
await seed();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('rolls back a late failure, then refunds spending and retains selected earnings exactly once', async () => {
|
||||
const request = {
|
||||
cancellationId: 'game-cancellation-retain-fixture',
|
||||
databaseUrl: databaseUrl!,
|
||||
cancelledBy: 'admin',
|
||||
reason: '잘못 연 게임 취소',
|
||||
historyMode: 'RETAIN_ABANDONED' as const,
|
||||
generalMode: 'RETAIN' as const,
|
||||
earnedPointRetentionPercent: 40,
|
||||
cancelledAt,
|
||||
};
|
||||
|
||||
await expect(cancelGame({ ...request, cancelledBy: 'admin\0invalid' })).rejects.toThrow();
|
||||
await expect(db.gameHistory.findUniqueOrThrow({ where: { serverId } })).resolves.toMatchObject({
|
||||
status: 'OPEN',
|
||||
});
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })
|
||||
).resolves.toMatchObject({ value: 7_000 });
|
||||
await expect(db.hallOfFame.count({ where: { serverId } })).resolves.toBe(1);
|
||||
await expect(db.gameCancellation.count({ where: { serverId } })).resolves.toBe(0);
|
||||
|
||||
const result = await cancelGame(request);
|
||||
expect(result).toMatchObject({
|
||||
participantCount: 1,
|
||||
preservedGeneralCount: 2,
|
||||
alreadyApplied: false,
|
||||
settlements: {
|
||||
[userId]: {
|
||||
openingPoint: 10_000,
|
||||
currentPoint: 7_000,
|
||||
earnedPoint: 1_750,
|
||||
retainedEarnedPoint: 700,
|
||||
finalPoint: 10_700,
|
||||
baselineSource: 'OPENING',
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
db.inheritancePoint.findMany({ where: { userId }, orderBy: { key: 'asc' } })
|
||||
).resolves.toMatchObject([{ key: 'previous', value: 10_700 }]);
|
||||
await expect(db.gameHistory.findUniqueOrThrow({ where: { serverId } })).resolves.toMatchObject({
|
||||
status: 'ABANDONED',
|
||||
winnerNation: null,
|
||||
});
|
||||
const archived = await db.oldGeneral.findMany({ where: { serverId }, orderBy: { generalNo: 'asc' } });
|
||||
expect(archived).toHaveLength(2);
|
||||
expect(archived.every((row) => JSON.stringify(row.data).includes(request.cancellationId))).toBe(true);
|
||||
await expect(db.hallOfFame.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.oldNation.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.emperor.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.yearbookHistory.count({ where: { profileName: serverId } })).resolves.toBe(0);
|
||||
await expect(db.unificationFinalization.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.inheritanceResult.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.worldState.findFirstOrThrow()).resolves.toMatchObject({
|
||||
meta: expect.objectContaining({ isCancelled: 1, cancellationId: request.cancellationId }),
|
||||
});
|
||||
|
||||
await expect(cancelGame(request)).resolves.toMatchObject({ alreadyApplied: true });
|
||||
await expect(
|
||||
db.inheritanceLog.count({ where: { userId, text: { startsWith: '취소 게임 정산:' } } })
|
||||
).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('physically deletes the numbered history row and past-play general archive on request', async () => {
|
||||
const result = await cancelGame({
|
||||
cancellationId: 'game-cancellation-delete-fixture',
|
||||
databaseUrl: databaseUrl!,
|
||||
cancelledBy: 'admin',
|
||||
reason: '기수와 장수 기록 삭제',
|
||||
historyMode: 'DELETE',
|
||||
generalMode: 'DELETE',
|
||||
earnedPointRetentionPercent: 0,
|
||||
cancelledAt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ participantCount: 1, preservedGeneralCount: 0, alreadyApplied: false });
|
||||
await expect(db.gameHistory.findUnique({ where: { serverId } })).resolves.toBeNull();
|
||||
await expect(db.oldGeneral.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.gameCancellation.findUnique({ where: { serverId } })).resolves.toMatchObject({
|
||||
originalSeason: 7,
|
||||
historyMode: 'DELETE',
|
||||
generalMode: 'DELETE',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { calculateCancelledInheritancePoint } from '../src/scenario/gameCancellation.js';
|
||||
|
||||
describe('cancelled game inheritance settlement', () => {
|
||||
it('refunds every spent point and discards earned points at zero percent', () => {
|
||||
expect(
|
||||
calculateCancelledInheritancePoint({
|
||||
openingPoint: 10_000,
|
||||
earnedPoint: 2_345.75,
|
||||
earnedPointRetentionPercent: 0,
|
||||
})
|
||||
).toEqual({ retainedEarnedPoint: 0, finalPoint: 10_000 });
|
||||
});
|
||||
|
||||
it('retains the selected integer percentage without rounding upward', () => {
|
||||
expect(
|
||||
calculateCancelledInheritancePoint({
|
||||
openingPoint: 10_000,
|
||||
earnedPoint: 333,
|
||||
earnedPointRetentionPercent: 50,
|
||||
})
|
||||
).toEqual({ retainedEarnedPoint: 166, finalPoint: 10_166 });
|
||||
});
|
||||
|
||||
it('retains all earned points at one hundred percent', () => {
|
||||
expect(
|
||||
calculateCancelledInheritancePoint({
|
||||
openingPoint: 10_000,
|
||||
earnedPoint: 333,
|
||||
earnedPointRetentionPercent: 100,
|
||||
})
|
||||
).toEqual({ retainedEarnedPoint: 333, finalPoint: 10_333 });
|
||||
});
|
||||
|
||||
it.each([-1, 1.5, 101])('rejects invalid retention percentage %s', (earnedPointRetentionPercent) => {
|
||||
expect(() =>
|
||||
calculateCancelledInheritancePoint({
|
||||
openingPoint: 0,
|
||||
earnedPoint: 0,
|
||||
earnedPointRetentionPercent,
|
||||
})
|
||||
).toThrow('integer from 0 to 100');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
'scenario/mapLoader': 'src/scenario/mapLoader.ts',
|
||||
'scenario/scenarioComposition': 'src/scenario/scenarioComposition.ts',
|
||||
'scenario/scenarioLoader': 'src/scenario/scenarioLoader.ts',
|
||||
'scenario/gameCancellation': 'src/scenario/gameCancellation.ts',
|
||||
'scenario/scenarioSeeder': 'src/scenario/scenarioSeeder.ts',
|
||||
'scenario/unitSetLoader': 'src/scenario/unitSetLoader.ts',
|
||||
'turn/databaseHooks': 'src/turn/databaseHooks.ts',
|
||||
|
||||
Reference in New Issue
Block a user