feat: 오픈 게임 취소와 유산 정산 경로 추가

별도 최고위험 권한으로 실행되는 원자적 취소 작업을 추가한다. 버려진 게임과 장수 기록의 보존·삭제 옵션, 오픈 원금 전액 환급 및 획득 포인트 보전율, 취소 상태와 관리자·과거기록 UI를 함께 반영한다.
This commit is contained in:
2026-08-18 13:31:49 +00:00
parent a7b11811de
commit 383d173790
36 changed files with 1967 additions and 83 deletions
+4
View File
@@ -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"
+1
View File
@@ -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');
});
});
+1
View File
@@ -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',