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
+64 -25
View File
@@ -240,30 +240,51 @@ export const archiveRouter = router({
const currentServerIds = Array.from(
new Set(entries.filter((entry) => entry.source === 'current').map((entry) => entry.serverId))
);
const [legacyGames, legacyNationRows, legacyEmperors, currentGames, currentNationRows, currentEmperors] =
await Promise.all([
findLegacyGames(ctx.db, legacyKeys),
findLegacyNations(ctx.db, legacyKeys),
findLegacyEmperors(ctx.db, legacyKeys),
currentServerIds.length
? ctx.db.gameHistory.findMany({ where: { serverId: { in: currentServerIds } } })
: [],
currentServerIds.length
? ctx.db.oldNation.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
})
: [],
currentServerIds.length
? ctx.db.emperor.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: { id: 'desc' },
select: { id: true, serverId: true },
})
: [],
]);
const [
legacyGames,
legacyNationRows,
legacyEmperors,
currentGames,
currentCancellations,
currentNationRows,
currentEmperors,
] = await Promise.all([
findLegacyGames(ctx.db, legacyKeys),
findLegacyNations(ctx.db, legacyKeys),
findLegacyEmperors(ctx.db, legacyKeys),
currentServerIds.length
? ctx.db.gameHistory.findMany({ where: { serverId: { in: currentServerIds } } })
: [],
currentServerIds.length
? ctx.db.gameCancellation.findMany({ where: { serverId: { in: currentServerIds } } })
: [],
currentServerIds.length
? ctx.db.oldNation.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
})
: [],
currentServerIds.length
? ctx.db.emperor.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: { id: 'desc' },
select: { id: true, serverId: true },
})
: [],
]);
const games = new Map<string, { openedAt: Date; season: number; scenario: number; scenarioName: string }>();
const games = new Map<
string,
{
openedAt: Date;
season: number;
scenario: number;
scenarioName: string;
status?: 'OPEN' | 'COMPLETED' | 'ABANDONED';
cancellationId?: string;
cancelledAt?: Date;
}
>();
for (const row of legacyGames) {
games.set(key('legacy', row.sourceProfile, row.serverId), row);
}
@@ -273,6 +294,18 @@ export const archiveRouter = router({
season: row.season,
scenario: row.scenario,
scenarioName: row.scenarioName,
status: row.status,
});
}
for (const row of currentCancellations) {
games.set(key('current', ctx.profile.id, row.serverId), {
openedAt: row.openedAt,
season: row.originalSeason,
scenario: row.scenario,
scenarioName: row.scenarioName,
status: 'ABANDONED',
cancellationId: row.id,
cancelledAt: row.cancelledAt,
});
}
const nations = new Map<string, ArchiveNationEntry>();
@@ -326,6 +359,9 @@ export const archiveRouter = router({
season: number | null;
scenario: number | null;
scenarioName: string | null;
status: 'OPEN' | 'COMPLETED' | 'ABANDONED' | 'LEGACY';
cancellationId: string | null;
cancelledAt: string | null;
dynastyId: number | null;
generals: Array<{
generalNo: number;
@@ -360,10 +396,13 @@ export const archiveRouter = router({
serverId: entry.serverId,
openedAt,
date: openedAt,
season: game?.season ?? null,
season: game?.status === 'ABANDONED' ? null : (game?.season ?? null),
scenario: game?.scenario ?? null,
scenarioName: game?.scenarioName ?? null,
dynastyId: dynastyIds.get(entryKey) ?? null,
status: entry.source === 'legacy' ? 'LEGACY' : (game?.status ?? 'COMPLETED'),
cancellationId: game?.cancellationId ?? null,
cancelledAt: game?.cancelledAt?.toISOString() ?? null,
dynastyId: game?.status === 'ABANDONED' ? null : (dynastyIds.get(entryKey) ?? null),
generals: [],
};
seasons.set(entryKey, season);
+1
View File
@@ -414,6 +414,7 @@ export const rankingRouter = router({
return Array.from(optionMap.values());
}
const rows = await ctx.db.gameHistory.findMany({
where: { status: 'COMPLETED' },
select: { season: true, scenario: true, scenarioName: true },
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
});
+37 -1
View File
@@ -25,7 +25,11 @@ const auth: GameSessionTokenPayload = {
sanctions: {},
};
const context = (session: GameSessionTokenPayload | null, includeLegacy = false): GameApiContext => {
const context = (
session: GameSessionTokenPayload | null,
includeLegacy = false,
includeCancellation = false
): GameApiContext => {
const db = {
$queryRaw: async (query: { strings?: readonly string[] }) => {
if (!includeLegacy) return [];
@@ -236,10 +240,27 @@ const context = (session: GameSessionTokenPayload | null, includeLegacy = false)
season: 1,
scenario: 100,
scenarioName: '테스트',
status: 'COMPLETED',
env: {},
},
],
},
gameCancellation: {
findMany: async () =>
includeCancellation
? [
{
id: 'cancel-fixture',
serverId: 'che_legacy_1',
originalSeason: 1,
scenario: 100,
scenarioName: '테스트',
openedAt: new Date('2025-01-02T00:00:00.000Z'),
cancelledAt: new Date('2025-01-03T00:00:00.000Z'),
},
]
: [],
},
oldNation: {
findMany: async () => [
{
@@ -359,6 +380,21 @@ describe('archive.myPastPlays', () => {
});
});
it('labels a retained cancellation as an unnumbered abandoned game without a dynasty link', async () => {
const result = await appRouter.createCaller(context(auth, false, true)).archive.myPastPlays();
expect(result.seasons).toEqual([
expect.objectContaining({
serverId: 'che_legacy_1',
season: null,
status: 'ABANDONED',
cancellationId: 'cancel-fixture',
cancelledAt: '2025-01-03T00:00:00.000Z',
dynastyId: null,
}),
]);
});
it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => {
const caller = appRouter.createCaller(context(auth, true));
const list = await caller.archive.myPastPlays();
@@ -327,6 +327,11 @@ integration('generic general creation through the durable turn daemon', () => {
})
).toMatchObject({ value: 7351 });
expect(await db.inheritancePoint.count({ where: { userId } })).toBe(1);
await expect(
db.gameInheritanceBaseline.findUniqueOrThrow({
where: { serverId_userId: { serverId: profile, userId } },
})
).resolves.toMatchObject({ openingPoint: 10_351, source: 'FIRST_ACTIVITY' });
expect(await db.inheritanceLog.count({ where: { userId } })).toBe(9);
expect(
await db.inheritanceLog.findFirst({
+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',
+46 -4
View File
@@ -8,7 +8,7 @@ const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route) =>
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
const installArchive = async (page: Page, options: { battleAvailable?: boolean } = {}) => {
const installArchive = async (page: Page, options: { battleAvailable?: boolean; abandoned?: boolean } = {}) => {
await page.addInitScript((profile) => {
localStorage.setItem('sammo-game-token', 'ga_archive');
localStorage.setItem('sammo-game-profile', profile);
@@ -22,14 +22,17 @@ const installArchive = async (page: Page, options: { battleAvailable?: boolean }
seasons: [
{
sourceProfile: 'che',
source: 'legacy',
source: options.abandoned ? 'current' : 'legacy',
serverId: 'che_2024_01',
openedAt: '2024-01-31T00:00:00.000Z',
date: '2024-01-31T00:00:00.000Z',
season: 51,
season: options.abandoned ? null : 51,
scenario: 2,
scenarioName: '천하쟁패',
dynastyId: 7,
status: options.abandoned ? 'ABANDONED' : 'LEGACY',
cancellationId: options.abandoned ? '12345678-full-id' : null,
cancelledAt: options.abandoned ? '2024-02-01T00:00:00.000Z' : null,
dynastyId: options.abandoned ? null : 7,
generals: [
{
generalNo: 17,
@@ -153,6 +156,45 @@ test('보존되지 않은 과거 전투 집계는 0으로 꾸미지 않고 가
await expect(page.locator('[data-general-battle-summary]')).not.toContainText('승률');
});
test('취소 게임은 정식 기수 번호와 왕조 링크 없이 별도 기록으로 표시된다', async ({ page }, testInfo) => {
await installArchive(page, { abandoned: true });
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('past-plays');
const seasonCard = page.locator('.season-card');
await expect(seasonCard.getByText('취소 게임', { exact: true })).toBeVisible();
await expect(seasonCard.getByText('취소 ID 12345678')).toBeVisible();
await expect(seasonCard).toContainText('천하쟁패');
await expect(seasonCard).not.toContainText('51기');
await expect(seasonCard.getByRole('link', { name: '이 기수 국가 정보' })).toHaveCount(0);
const desktop = await seasonCard.evaluate((element) => {
const rect = element.getBoundingClientRect();
const heading = element.querySelector('.season-heading')!.getBoundingClientRect();
return { x: rect.x, width: rect.width, headingHeight: heading.height };
});
expect(desktop).toMatchObject({ x: 100, width: 1000 });
await page.screenshot({ path: testInfo.outputPath('abandoned-past-play-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
const mobile = await seasonCard.evaluate((element) => {
const rect = element.getBoundingClientRect();
return {
x: rect.x,
width: rect.width,
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobile.x).toBeGreaterThanOrEqual(0);
expect(mobile.x + mobile.width).toBeLessThanOrEqual(mobile.viewportWidth);
expect(mobile.documentScrollWidth).toBeLessThanOrEqual(mobile.viewportWidth);
await writeFile(
testInfo.outputPath('abandoned-past-play-metrics.json'),
JSON.stringify({ desktop, mobile }, null, 2)
);
await page.screenshot({ path: testInfo.outputPath('abandoned-past-play-mobile.png'), fullPage: true });
});
test('past plays is available without a current general and preserves desktop interaction geometry', async ({
page,
}) => {
+22 -4
View File
@@ -20,6 +20,9 @@ type ArchiveSeason = Archive['seasons'][number] & {
source?: string;
openedAt?: string | null;
date?: string | null;
status?: 'OPEN' | 'COMPLETED' | 'ABANDONED' | 'LEGACY';
cancellationId?: string | null;
cancelledAt?: string | null;
};
type ArchiveGeneral = {
@@ -129,6 +132,12 @@ const formatOpenedAt = (season: ArchiveSeason): string => {
if (Number.isNaN(date.getTime())) return '개장일 미상';
return `${new Intl.DateTimeFormat('ko-KR', { dateStyle: 'medium', timeZone: 'Asia/Seoul' }).format(date)} 개장`;
};
const archiveLabel = (season: ArchiveSeason): string =>
season.status === 'ABANDONED' ? '취소 게임' : '이전 서버 기록';
const archiveIdentifier = (season: ArchiveSeason): string =>
season.status === 'ABANDONED' && season.cancellationId
? `취소 ID ${season.cancellationId.slice(0, 8)}`
: season.serverId;
const selectGeneral = async (season: ArchiveSeason, generalNo: number): Promise<void> => {
const key = detailKey(season, generalNo);
@@ -197,7 +206,7 @@ onMounted(() => {
</nav>
</header>
<p class="page-note">이전 서버에서 종료된 기수 보관된 장수 기록입니다.</p>
<p class="page-note">종료된 기수 관리자가 보존한 취소 게임의 장수 기록입니다.</p>
<p v-if="error" class="error-row">{{ error }}</p>
<p v-else-if="loading && !archive" class="empty-row">불러오는 중...</p>
<p v-else-if="archive?.seasons.length === 0" class="empty-row">보관된 지난 플레이가 없습니다.</p>
@@ -205,15 +214,19 @@ onMounted(() => {
<section v-for="season in archive?.seasons ?? []" :key="detailKey(season, 0)" class="season-card">
<div class="season-heading legacy-bg2">
<div class="season-identity">
<strong class="archive-label">이전 서버 기록</strong>
<strong class="archive-label" :class="{ abandoned: season.status === 'ABANDONED' }">
{{ archiveLabel(season) }}
</strong>
<strong>{{ seasonSourceProfile(season) }}</strong>
<span>{{ season.serverId }}</span>
<span>{{ archiveIdentifier(season) }}</span>
</div>
<div class="season-meta">
<span>{{ formatOpenedAt(season) }}</span>
<span>
{{ season.scenarioName ?? '시나리오 미상' }}
<template v-if="season.season !== null"> · {{ season.season }}</template>
<template v-if="season.status !== 'ABANDONED' && season.season !== null">
· {{ season.season }}
</template>
</span>
</div>
</div>
@@ -425,6 +438,11 @@ onMounted(() => {
background: #00582c;
}
.archive-label.abandoned {
border-color: #956f38;
background: #66471f;
}
.season-meta {
justify-content: flex-end;
}
+8
View File
@@ -66,6 +66,13 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [
risk: 'CRITICAL',
scope: 'PROFILE',
},
{
permission: 'admin.games.cancel',
label: '진행 게임 취소',
description: '지정 profile의 진행 중 게임을 취소하고 기록과 유산 포인트를 정산합니다.',
risk: 'CRITICAL',
scope: 'PROFILE',
},
{
permission: 'admin.reset.schedule',
label: 'Profile 초기화 예약',
@@ -123,6 +130,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown):
}
if (path.endsWith('.operations.requestDeploy')) return 'admin.profiles.deploy';
if (path.endsWith('.operations.requestReset')) return 'admin.scenarios.reset';
if (path.endsWith('.operations.requestGameCancellation')) return 'admin.games.cancel';
if (path.endsWith('.operations.requestRuntime')) return 'admin.profiles.runtime';
if (path.endsWith('.profiles.upsert') || path.endsWith('.profiles.updateMeta')) return 'admin.profiles.settings';
if (path.endsWith('.profiles.setStatus') || path.endsWith('.profiles.reconcileNow')) {
+82 -6
View File
@@ -60,6 +60,7 @@ const ROLE_ADMIN_PROFILE_RUNTIME = 'admin.profiles.runtime';
const ROLE_ADMIN_PROFILE_SETTINGS = 'admin.profiles.settings';
const ROLE_ADMIN_PROFILE_DEPLOY = 'admin.profiles.deploy';
const ROLE_ADMIN_SCENARIO_RESET = 'admin.scenarios.reset';
const ROLE_ADMIN_GAME_CANCEL = 'admin.games.cancel';
const ROLE_ADMIN_RELEASES = 'admin.releases.manage';
const ROLE_ADMIN_NOTICE = 'admin.notice.manage';
const ROLE_ADMIN_AUDIT = 'admin.audit.read';
@@ -1268,6 +1269,65 @@ export const adminRouter = router({
});
}
}),
requestGameCancellation: adminProcedure
.input(
z.object({
profileName: z.string().min(1),
historyMode: z.enum(['RETAIN_ABANDONED', 'DELETE']),
generalMode: z.enum(['RETAIN', 'DELETE']),
earnedPointRetentionPercent: z.number().int().min(0).max(100),
reason: z.string().trim().min(5).max(500),
})
)
.mutation(async ({ ctx, input }) => {
const adminAuth = requireAdminAuth(ctx);
assertPermission(adminAuth, ROLE_ADMIN_GAME_CANCEL, input.profileName);
const profile = await ctx.profiles.getProfile(input.profileName);
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
}
if (!['PREOPEN', 'RUNNING', 'PAUSED'].includes(profile.status)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only a PREOPEN, RUNNING, or PAUSED game can be cancelled.',
});
}
const releaseState = await ctx.releases.getState();
const sourceRef = releaseState.activeCommitSha?.trim();
if (!sourceRef) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'The Gateway has no active release commit for the cancellation migration boundary.',
});
}
try {
const resolvedCommitSha = await resolveGitCommitSha(sourceRef);
return await ctx.profiles.createOperation({
profileName: input.profileName,
type: 'CANCEL_GAME',
sourceMode: 'COMMIT',
sourceRef: resolvedCommitSha,
payload: {
historyMode: input.historyMode,
generalMode: input.generalMode,
earnedPointRetentionPercent: input.earnedPointRetentionPercent,
} as GatewayPrisma.JsonObject,
reason: input.reason,
requestedBy: adminAuth.user.id,
});
} catch (error) {
if (!isUniqueConstraintError(error)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'The active Gateway release commit cannot be resolved for game cancellation.',
});
}
throw new TRPCError({
code: 'CONFLICT',
message: 'This profile already has a queued or running operation.',
});
}
}),
requestDeploy: adminProcedure
.input(
z.object({
@@ -1340,6 +1400,18 @@ export const adminRouter = router({
if (!profile) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
}
if (input.action === 'START' && !gatewayProfileCapabilities(profile.status).operatorResumable) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This profile must be reset before it can be started.',
});
}
if (input.action === 'STOP' && !gatewayProfileCapabilities(profile.status).runtimeExpected) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only a running profile can be stopped.',
});
}
try {
const operation = await ctx.profiles.createOperation({
profileName: input.profileName,
@@ -1367,9 +1439,11 @@ export const adminRouter = router({
const permission =
previous.type === 'RESET'
? ROLE_ADMIN_SCENARIO_RESET
: previous.type === 'DEPLOY'
? ROLE_ADMIN_PROFILE_DEPLOY
: ROLE_ADMIN_PROFILE_RUNTIME;
: previous.type === 'CANCEL_GAME'
? ROLE_ADMIN_GAME_CANCEL
: previous.type === 'DEPLOY'
? ROLE_ADMIN_PROFILE_DEPLOY
: ROLE_ADMIN_PROFILE_RUNTIME;
assertPermission(adminAuth, permission, previous.profileName);
const cancelled = await ctx.profiles.cancelOperation(input.id);
if (!cancelled) {
@@ -1389,9 +1463,11 @@ export const adminRouter = router({
const permission =
previous.type === 'RESET'
? ROLE_ADMIN_SCENARIO_RESET
: previous.type === 'DEPLOY'
? ROLE_ADMIN_PROFILE_DEPLOY
: ROLE_ADMIN_PROFILE_RUNTIME;
: previous.type === 'CANCEL_GAME'
? ROLE_ADMIN_GAME_CANCEL
: previous.type === 'DEPLOY'
? ROLE_ADMIN_PROFILE_DEPLOY
: ROLE_ADMIN_PROFILE_RUNTIME;
assertPermission(adminAuth, permission, previous.profileName);
if (previous.type === 'RESET') {
const payload = readMetaObject(previous.payload);
@@ -5,6 +5,14 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { stripVTControlCharacters } from 'node:util';
import type { ScenarioInstallOptions } from '@sammo-ts/game-engine/scenario/scenarioSeeder.js';
import {
cancelGame as defaultCancelGame,
GAME_CANCELLATION_GENERAL_MODES,
GAME_CANCELLATION_HISTORY_MODES,
type GameCancellationGeneralMode,
type GameCancellationHistoryMode,
type GameCancellationResult,
} from '@sammo-ts/game-engine/scenario/gameCancellation.js';
import { gatewayProfileCapabilities } from '@sammo-ts/common';
import {
createGamePostgresConnector,
@@ -56,6 +64,7 @@ export interface GatewayOrchestratorOptions {
now?: () => Date;
fetchImpl?: typeof fetch;
clearTournamentRuntimeState?: (profileName: string) => Promise<void>;
cancelGame?: typeof defaultCancelGame;
}
export interface ProfileRuntimeState {
@@ -596,6 +605,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
private readonly now: () => Date;
private readonly fetchImpl: typeof fetch;
private readonly clearTournamentRuntimeState: (profileName: string) => Promise<void>;
private readonly cancelGame: typeof defaultCancelGame;
private reconcileTimer?: NodeJS.Timeout;
private scheduleTimer?: NodeJS.Timeout;
private buildTimer?: NodeJS.Timeout;
@@ -627,6 +637,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.clearTournamentRuntimeState =
options.clearTournamentRuntimeState ??
((profileName) => this.clearTournamentRuntimeStateFromRedis(profileName));
this.cancelGame = options.cancelGame ?? defaultCancelGame;
}
private sanitizeOperationLogMessage(message: string): string {
@@ -1029,6 +1040,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
let resolvedCommitSha: string | undefined;
try {
if (operation.type === 'START') {
if (!gatewayProfileCapabilities(profile.status).operatorResumable) {
throw new Error(`Profile status ${profile.status} cannot be started by an operator.`);
}
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 시작합니다.');
const updated = await updateOperationProfile(
{
@@ -1066,6 +1080,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
return;
}
if (operation.type === 'STOP') {
if (!gatewayProfileCapabilities(profile.status).runtimeExpected && profile.status !== 'STOPPED') {
throw new Error(`Profile status ${profile.status} cannot be stopped by an operator.`);
}
await this.appendOperationLog(operation.id, 'runtime', '프로필 process를 정지합니다.');
await updateOperationProfile({ status: 'STOPPED' }, () =>
this.repository.updateStatus(profile.profileName, 'STOPPED')
@@ -1082,7 +1099,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
if (!operation.sourceMode || !operation.sourceRef) {
throw new Error('Reset source mode and ref are required.');
throw new Error('Operation source mode and ref are required.');
}
await this.appendOperationLog(
operation.id,
@@ -1105,6 +1122,48 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
await this.appendOperationLog(operation.id, 'resolve', `대상 커밋을 ${commitSha}로 고정했습니다.`);
await assertLease();
if (operation.type === 'CANCEL_GAME') {
const payload = normalizeMeta(operation.payload);
const historyMode = payload.historyMode;
const generalMode = payload.generalMode;
const retention = payload.earnedPointRetentionPercent;
if (
typeof historyMode !== 'string' ||
!GAME_CANCELLATION_HISTORY_MODES.includes(historyMode as GameCancellationHistoryMode) ||
typeof generalMode !== 'string' ||
!GAME_CANCELLATION_GENERAL_MODES.includes(generalMode as GameCancellationGeneralMode) ||
typeof retention !== 'number' ||
!Number.isInteger(retention) ||
retention < 0 ||
retention > 100 ||
!operation.reason
) {
throw new Error('Game cancellation payload is invalid.');
}
const result = await this.handleGameCancellation(
profile,
operation,
commitSha,
{
historyMode: historyMode as GameCancellationHistoryMode,
generalMode: generalMode as GameCancellationGeneralMode,
earnedPointRetentionPercent: retention,
},
assertLease
);
await this.appendOperationLog(
operation.id,
'complete',
`게임 취소가 완료되었습니다. 참여자 ${result.participantCount}명, 보존 장수 ${result.preservedGeneralCount}명.`
);
await this.repository.completeOperation(
operation.id,
'SUCCEEDED',
{ resolvedCommitSha: commitSha, error: null },
this.operationLeaseOwner
);
return;
}
if (operation.type === 'DEPLOY') {
const result = await this.handleProfileDeploy(profile, commitSha, assertLease, operation.id);
if (!result.ok) {
@@ -1186,6 +1245,115 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
}
}
private async handleGameCancellation(
profile: GatewayProfileRecord,
operation: GatewayOperationRecord,
commitSha: string,
options: {
historyMode: GameCancellationHistoryMode;
generalMode: GameCancellationGeneralMode;
earnedPointRetentionPercent: number;
},
assertLease: () => Promise<void>
): Promise<GameCancellationResult> {
if (!['PREOPEN', 'RUNNING', 'PAUSED', 'STOPPED'].includes(profile.status)) {
throw new Error(`Profile status ${profile.status} cannot be cancelled.`);
}
if (this.buildInFlight) throw new Error('build already in progress');
this.buildInFlight = true;
let runtimeStopped = profile.status === 'STOPPED';
let cancellationCommitted = false;
const updateClaimedProfile = async (patch: GatewayClaimedProfileUpdate): Promise<GatewayProfileRecord> => {
if (!this.repository.updateProfileForOperation) {
throw new Error('Game cancellation requires lease-fenced profile updates.');
}
const updated = await this.repository.updateProfileForOperation(
operation.id,
this.operationLeaseOwner,
profile.profileName,
patch
);
if (!updated) {
throw new OperationLeaseLostError(`Operation lease lost while cancelling game: ${operation.id}`);
}
return updated;
};
try {
await this.appendOperationLog(
operation.id,
'build',
'현재 profile 커밋의 취소 도구와 migration을 준비합니다.'
);
const { result: buildResult, workspace } = await this.runBuildCommands(commitSha, profile, operation.id);
await assertLease();
if (!buildResult.ok) throw new Error(buildResult.output.slice(-4000) || 'profile build failed');
const databaseUrl = this.resolveProfileDatabaseUrl(profile);
await this.appendOperationLog(operation.id, 'migration', '게임 취소 schema migration을 적용합니다.');
const migration = await this.runProfileMigration(
workspace.root,
databaseUrl,
this.buildProgress(operation.id, 'migration')
);
await assertLease();
if (!migration.ok) throw new Error(migration.output.slice(-4000) || 'profile database migration failed');
if (!runtimeStopped) {
await updateClaimedProfile({ status: 'STOPPED' });
await this.appendOperationLog(
operation.id,
'runtime',
'쓰기 차단을 위해 profile process를 정지합니다.'
);
await this.stopProfile(profile, assertLease);
runtimeStopped = true;
}
await assertLease();
await this.appendOperationLog(
operation.id,
'settlement',
'기수·장수 기록과 유산 포인트를 원자적으로 정산합니다.'
);
const result = await this.cancelGame({
cancellationId: operation.id,
databaseUrl,
cancelledBy: operation.requestedBy,
reason: operation.reason ?? '',
...options,
cancelledAt: this.now(),
});
cancellationCommitted = true;
await assertLease();
await updateClaimedProfile({
status: 'CANCELLED',
preopenAt: null,
openAt: null,
scheduledStartAt: null,
lastError: null,
});
await this.appendOperationLog(
operation.id,
'publish',
`profile을 재개할 수 없는 CANCELLED 상태로 전환했습니다. 취소 ID: ${result.cancellationId}`
);
return result;
} catch (error) {
if (error instanceof OperationLeaseLostError) throw error;
if (runtimeStopped && !cancellationCommitted && profile.status !== 'STOPPED') {
try {
await updateClaimedProfile({ status: profile.status, lastError: null });
await this.startProfile(profile, assertLease);
runtimeStopped = false;
} catch {
// The original cancellation failure remains authoritative.
}
}
throw error;
} finally {
this.buildInFlight = false;
}
}
private async handleProfileDeploy(
profile: GatewayProfileRecord,
commitSha: string,
@@ -8,7 +8,7 @@ export { GATEWAY_PROFILE_STATUSES, type GatewayProfileStatus };
export const GATEWAY_BUILD_STATUSES = ['IDLE', 'QUEUED', 'RUNNING', 'FAILED', 'SUCCEEDED'] as const;
export type GatewayBuildStatus = (typeof GATEWAY_BUILD_STATUSES)[number];
export type GatewayOperationType = 'RESET' | 'DEPLOY' | 'START' | 'STOP';
export type GatewayOperationType = 'RESET' | 'DEPLOY' | 'CANCEL_GAME' | 'START' | 'STOP';
export type GatewayOperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
+101 -4
View File
@@ -31,6 +31,7 @@ const buildCaller = async (
initialProfileStatus?: GatewayProfileRecord['status'];
profileScenario?: string | null;
profileMeta?: GatewayProfileRecord['meta'];
releaseCommitSha?: string;
initialOperation?: GatewayOperationRecord;
profileLogVisibilityAfterPolls?: number;
releaseLogVisibilityAfterPolls?: number;
@@ -158,7 +159,7 @@ const buildCaller = async (
const releases: GatewayReleaseRepository = {
getState: async () => ({
id: 'gateway',
activeCommitSha: '1111111111111111111111111111111111111111',
activeCommitSha: options.releaseCommitSha ?? '1111111111111111111111111111111111111111',
activeWorkspace: '/srv/sammo/current',
previousCommitSha: '2222222222222222222222222222222222222222',
previousWorkspace: '/srv/sammo/previous',
@@ -476,6 +477,99 @@ describe('gateway notice API', () => {
});
describe('admin operation API', () => {
it('queues game cancellation only with its dedicated scoped capability', async () => {
const harness = await buildCaller(
async (input) => ({
id: '77777777-7777-4777-8777-777777777777',
profileName: input.profileName,
type: input.type,
status: 'QUEUED',
sourceMode: input.sourceMode,
sourceRef: input.sourceRef,
payload: input.payload ?? {},
reason: input.reason,
requestedBy: input.requestedBy,
createdAt: '2026-08-18T00:00:00.000Z',
updatedAt: '2026-08-18T00:00:00.000Z',
}),
{
adminRoles: ['admin.games.cancel:che:2'],
firstUserIsAdmin: false,
initialProfileStatus: 'RUNNING',
releaseCommitSha: 'HEAD',
}
);
await harness.caller.admin.operations.requestGameCancellation({
profileName: 'che:2',
historyMode: 'RETAIN_ABANDONED',
generalMode: 'DELETE',
earnedPointRetentionPercent: 35,
reason: '잘못 연 게임 취소',
});
expect(harness.createdInputs[0]).toMatchObject({
profileName: 'che:2',
type: 'CANCEL_GAME',
sourceMode: 'COMMIT',
sourceRef: expect.stringMatching(/^[0-9a-f]{40}$/u),
payload: {
historyMode: 'RETAIN_ABANDONED',
generalMode: 'DELETE',
earnedPointRetentionPercent: 35,
},
reason: '잘못 연 게임 취소',
});
});
it('does not treat scenario reset permission as game cancellation permission', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{
adminRoles: ['admin.scenarios.reset:che:2'],
firstUserIsAdmin: false,
initialProfileStatus: 'RUNNING',
releaseCommitSha: 'HEAD',
}
);
await expect(
harness.caller.admin.operations.requestGameCancellation({
profileName: 'che:2',
historyMode: 'DELETE',
generalMode: 'DELETE',
earnedPointRetentionPercent: 0,
reason: '잘못 연 게임 취소',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects cancellation after the profile is already terminal', async () => {
const harness = await buildCaller(
async () => {
throw new Error('not used');
},
{
adminRoles: ['admin.games.cancel:che:2'],
firstUserIsAdmin: false,
initialProfileStatus: 'COMPLETED',
releaseCommitSha: 'HEAD',
}
);
await expect(
harness.caller.admin.operations.requestGameCancellation({
profileName: 'che:2',
historyMode: 'RETAIN_ABANDONED',
generalMode: 'RETAIN',
earnedPointRetentionPercent: 0,
reason: '완료 게임 취소 시도',
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('queues a start operation with the authenticated requester', async () => {
const operation = {
id: '11111111-1111-4111-8111-111111111111',
@@ -504,9 +598,12 @@ describe('admin operation API', () => {
});
it('reports an active-operation uniqueness conflict', async () => {
const harness = await buildCaller(async () => {
throw { code: 'P2002' };
});
const harness = await buildCaller(
async () => {
throw { code: 'P2002' };
},
{ initialProfileStatus: 'RUNNING' }
);
await expect(
harness.caller.admin.operations.requestRuntime({
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { GatewayOrchestrator } from '../src/orchestrator/gatewayOrchestrator.js';
import { GatewayOrchestrator, type GatewayOrchestratorOptions } from '../src/orchestrator/gatewayOrchestrator.js';
import type { ProcessDefinition, ProcessManager } from '../src/orchestrator/processManager.js';
import type {
GatewayOperationRecord,
@@ -45,8 +45,13 @@ const createHarness = (
processesPresent = true,
missingOnDelete = false,
workspaceManager?: GitWorkspaceManager,
startGate?: Promise<void>
startGate?: Promise<void>,
options: {
profile?: GatewayProfileRecord;
cancelGame?: GatewayOrchestratorOptions['cancelGame'];
} = {}
) => {
const harnessProfile = options.profile ?? profile;
let nextOperation: GatewayOperationRecord | null = operation;
const statuses: string[] = [];
const completions: GatewayOperationStatus[] = [];
@@ -57,23 +62,23 @@ const createHarness = (
const logs: Array<{ phase: string; message: string; level: string }> = [];
const repository: GatewayProfileRepository = {
listProfiles: async () => [profile],
getProfile: async () => profile,
upsertProfile: async () => profile,
updateCurrentScenario: async () => profile,
listProfiles: async () => [harnessProfile],
getProfile: async () => harnessProfile,
upsertProfile: async () => harnessProfile,
updateCurrentScenario: async () => harnessProfile,
updateStatus: async (_profileName, status) => {
statuses.push(status);
return { ...profile, status };
return { ...harnessProfile, status };
},
updateBuildStatus: async () => profile,
updateMeta: async () => profile,
updateBuildStatus: async () => harnessProfile,
updateMeta: async () => harnessProfile,
listReservedToStart: async () => [],
findQueuedBuild: async () => null,
updateLastError: async () => {},
updateWorkspaceUsage: async () => {},
clearWorkspaceUsage: async () => {},
listOperations: async () => [],
listActiveOperationProfileNames: async () => [profile.profileName],
listActiveOperationProfileNames: async () => [harnessProfile.profileName],
getOperation: async () => operation,
listOperationLogs: async () => [],
appendOperationLog: async (operationId, input) => {
@@ -99,6 +104,10 @@ const createHarness = (
requeueOperation: async () => ({ ...operation, status: 'QUEUED' }),
cancelOperation: async () => false,
retryOperation: async () => null,
updateProfileForOperation: async (_id, _ownerId, _profileName, patch) => {
if (patch.status) statuses.push(patch.status);
return { ...harnessProfile, status: patch.status ?? harnessProfile.status };
},
};
const processManager: ProcessManager = {
list: async () =>
@@ -152,17 +161,102 @@ const createHarness = (
redisKeyPrefix: 'sammo:test',
gameTokenSecret: 'test-secret',
gatewayInternalApiUrl: 'http://127.0.0.1:13000',
baseEnv: { DATABASE_URL: 'postgresql://test:test@127.0.0.1:15432/test' },
},
reconcileIntervalMs: 60_000,
scheduleIntervalMs: 60_000,
buildIntervalMs: 60_000,
adminActionIntervalMs: 60_000,
cancelGame: options.cancelGame,
});
return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs };
};
describe('GatewayOrchestrator first-class operations', () => {
it('stops runtime, settles once, and seals a cancelled profile', async () => {
const operation: GatewayOperationRecord = {
id: '88888888-8888-4888-8888-888888888888',
profileName: profile.profileName,
type: 'CANCEL_GAME',
status: 'RUNNING',
sourceMode: 'COMMIT',
sourceRef: profile.buildCommitSha,
resolvedCommitSha: profile.buildCommitSha,
payload: {
historyMode: 'RETAIN_ABANDONED',
generalMode: 'RETAIN',
earnedPointRetentionPercent: 40,
},
reason: '잘못 연 게임 취소',
requestedBy: 'admin',
createdAt: '2026-08-18T00:00:00.000Z',
startedAt: '2026-08-18T00:00:00.000Z',
updatedAt: '2026-08-18T00:00:00.000Z',
};
const workspaceManager = {
resolveCommit: async () => profile.buildCommitSha!,
prepare: async () => ({ root: process.cwd(), needsInstall: false }),
remove: async () => {},
} as unknown as GitWorkspaceManager;
const settlementRequests: Array<Record<string, unknown>> = [];
const cancellationProfile = { ...profile, status: 'RUNNING' as const };
const cancelGame: NonNullable<GatewayOrchestratorOptions['cancelGame']> = async (request) => {
settlementRequests.push(request as unknown as Record<string, unknown>);
return {
cancellationId: operation.id,
serverId: 'che_260818_fixture',
originalSeason: 7,
participantCount: 2,
preservedGeneralCount: 2,
historyMode: 'RETAIN_ABANDONED',
generalMode: 'RETAIN',
earnedPointRetentionPercent: 40,
alreadyApplied: false,
settlements: {},
};
};
const harness = createHarness(operation, false, false, true, false, workspaceManager, undefined, {
profile: cancellationProfile,
cancelGame,
});
await harness.orchestrator.runOperationsNow();
expect(settlementRequests).toHaveLength(1);
expect(settlementRequests[0]).toMatchObject({
cancellationId: operation.id,
cancelledBy: 'admin',
reason: '잘못 연 게임 취소',
historyMode: 'RETAIN_ABANDONED',
generalMode: 'RETAIN',
earnedPointRetentionPercent: 40,
});
expect(harness.statuses).toEqual(['STOPPED', 'CANCELLED']);
expect(harness.stopped).toHaveLength(6);
expect(harness.completions).toEqual(['SUCCEEDED']);
expect(harness.logs).toEqual(
expect.arrayContaining([
expect.objectContaining({ phase: 'settlement' }),
expect.objectContaining({ phase: 'publish', message: expect.stringContaining('CANCELLED') }),
])
);
});
it('does not let a stale START operation reopen a cancelled profile', async () => {
const cancelledProfile = { ...profile, status: 'CANCELLED' as const };
const operation = buildOperation('START');
const harness = createHarness(operation, false, false, true, false, undefined, undefined, {
profile: cancelledProfile,
});
await harness.orchestrator.runOperationsNow();
expect(harness.started).toEqual([]);
expect(harness.completions).toEqual(['FAILED']);
expect(harness.completionFields[0]?.error).toContain('CANCELLED');
});
it('does not reconcile a profile while a durable operation is active', async () => {
const harness = createHarness(buildOperation('START'));
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260818000000_add_legacy_import_checkpoints',
gatewaySchemaHead: '20260818001000_add_game_cancellation_operation',
gameSchemaHead: '20260818010000_add_legacy_battle_result_logs',
});
});
@@ -5,7 +5,7 @@ type OperationStatus = 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLE
type Operation = {
id: string;
profileName: string;
type: 'RESET' | 'DEPLOY' | 'START' | 'STOP';
type: 'RESET' | 'DEPLOY' | 'START' | 'STOP' | 'CANCEL_GAME';
status: OperationStatus;
sourceMode?: 'BRANCH' | 'COMMIT';
sourceRef?: string;
@@ -389,6 +389,22 @@ const installFixture = async (page: Page, state: FixtureState) => {
state.operations = [operation];
return response(operation);
}
if (name === 'admin.operations.requestGameCancellation') {
const operation: Operation = {
id: '88888888-8888-4888-8888-888888888888',
profileName: 'che:default',
type: 'CANCEL_GAME',
status: 'QUEUED',
sourceMode: 'COMMIT',
sourceRef: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
payload: {},
requestedBy: 'admin',
createdAt: '2026-08-18T01:00:00.000Z',
updatedAt: '2026-08-18T01:00:00.000Z',
};
state.operations = [operation];
return response(operation);
}
if (name === 'admin.releases.requestGatewayDeploy' || name === 'admin.releases.requestGatewayRollback') {
const releaseOperation = {
id: '77777777-7777-4777-8777-777777777777',
@@ -700,6 +716,93 @@ test('separates DB-preserving profile deployment from DB reset', async ({ page }
expect(state.requestBodies.some((entry) => entry.operation === 'admin.operations.requestReset')).toBe(false);
});
test('submits a separately authorized destructive game cancellation on desktop and mobile', async ({
page,
}, testInfo) => {
const state: FixtureState = {
operations: [],
gatewayOperations: [],
runtimeRunning: true,
requestBodies: [],
profileLogProgress: true,
capabilities: [{ permission: 'admin.games.cancel', scope: 'PROFILE', scopes: ['che:default'] }],
};
await installFixture(page, state);
const confirmations: string[] = [];
page.on('dialog', async (dialog) => {
confirmations.push(dialog.message());
await dialog.accept();
});
await page.goto('admin/servers/che%3Adefault/cancel');
await expect(page).toHaveURL(/\/gateway\/admin\/servers\/che%3Adefault\/cancel$/);
await expect(page.getByRole('heading', { name: 'che:default 게임 취소' })).toBeVisible();
await expect(page.getByRole('link', { name: '게임 취소', exact: true })).toHaveAttribute('aria-current', 'page');
await expect(page.getByRole('link', { name: '시나리오 초기화', exact: true })).toHaveCount(0);
await expect(page.getByTestId('request-game-cancellation')).toBeDisabled();
await page.getByTestId('cancellation-history-mode').selectOption('DELETE');
await page.getByTestId('cancellation-general-mode').selectOption('RETAIN');
await page.getByTestId('cancellation-retention-percent').fill('35');
await page.getByTestId('cancellation-reason').fill('잘못된 시나리오로 개장함');
await page.getByTestId('cancellation-confirmation').fill('che:default');
const cancelButton = page.getByTestId('request-game-cancellation');
await expect(cancelButton).toBeEnabled();
await cancelButton.hover();
const desktopMetrics = await page.getByTestId('game-cancellation-form').evaluate((form) => {
const rect = form.getBoundingClientRect();
const style = getComputedStyle(form);
const controls = Array.from(form.querySelectorAll('select, input:not([type="range"]), textarea, button')).map(
(control) => {
const controlRect = control.getBoundingClientRect();
return { width: controlRect.width, height: controlRect.height };
}
);
return {
x: rect.x,
width: rect.width,
borderColor: style.borderColor,
backgroundColor: style.backgroundColor,
minimumControlHeight: Math.min(...controls.map((control) => control.height)),
};
});
expect(desktopMetrics.width).toBeGreaterThan(800);
expect(desktopMetrics.minimumControlHeight).toBeGreaterThanOrEqual(21);
await page.screenshot({ path: testInfo.outputPath('game-cancellation-desktop.png'), fullPage: true });
await cancelButton.click();
await expect(page.getByText('게임 취소 작업을 등록했습니다.').first()).toBeVisible();
await expect(page.getByTestId('operations-table')).toContainText('CANCEL_GAME');
expect(confirmations).toHaveLength(1);
expect(confirmations[0]).toContain('기수 행 물리 삭제');
expect(confirmations[0]).toContain('장수 기록 보존');
expect(confirmations[0]).toContain('유산 획득분 35% 보전');
const request = state.requestBodies.find((entry) => entry.operation === 'admin.operations.requestGameCancellation');
expect(JSON.stringify(request?.body)).toContain('"historyMode":"DELETE"');
expect(JSON.stringify(request?.body)).toContain('"generalMode":"RETAIN"');
expect(JSON.stringify(request?.body)).toContain('"earnedPointRetentionPercent":35');
expect(JSON.stringify(request?.body)).toContain('잘못된 시나리오로 개장함');
await page.setViewportSize({ width: 390, height: 844 });
const mobileMetrics = await page.getByTestId('game-cancellation-form').evaluate((form) => {
const rect = form.getBoundingClientRect();
return {
x: rect.x,
width: rect.width,
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobileMetrics.x).toBeGreaterThanOrEqual(0);
expect(mobileMetrics.x + mobileMetrics.width).toBeLessThanOrEqual(mobileMetrics.viewportWidth);
expect(mobileMetrics.documentScrollWidth).toBeLessThanOrEqual(mobileMetrics.viewportWidth);
await writeFile(
testInfo.outputPath('game-cancellation-metrics.json'),
JSON.stringify({ desktopMetrics, mobileMetrics }, null, 2)
);
await page.screenshot({ path: testInfo.outputPath('game-cancellation-mobile.png'), fullPage: true });
});
for (const viewportSize of [
{ name: 'desktop', width: 1280, height: 720 },
{ name: 'mobile', width: 390, height: 844 },
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
type ServerProfileTab = 'status' | 'version' | 'scenario';
type ServerProfileTab = 'status' | 'version' | 'scenario' | 'cancel';
const props = defineProps<{
profileName: string;
activeTab: ServerProfileTab;
canDeploy: boolean;
canReset: boolean;
canCancel: boolean;
}>();
const tabs = computed(() =>
@@ -30,6 +31,12 @@ const tabs = computed(() =>
to: `/admin/servers/${encodeURIComponent(props.profileName)}/scenario`,
visible: props.canReset,
},
{
id: 'cancel' as const,
label: '게임 취소',
to: `/admin/servers/${encodeURIComponent(props.profileName)}/cancel`,
visible: props.canCancel,
},
].filter((tab) => tab.visible)
);
</script>
+6
View File
@@ -61,6 +61,12 @@ const router = createRouter({
component: ServerOperationsView,
props: (route) => ({ mode: 'scenario', profileName: route.params.profileName }),
},
{
path: '/admin/servers/:profileName/cancel',
name: 'admin-server-cancel',
component: ServerOperationsView,
props: (route) => ({ mode: 'cancel', profileName: route.params.profileName }),
},
{
path: '/admin/system',
name: 'admin-system',
@@ -457,6 +457,7 @@ const profileLifecycleText = (profile: AdminProfile): string => {
if (profile.status === 'RUNNING') return '서버 운영 및 턴 진행 중';
if (profile.status === 'PREOPEN') return '서버 접근 가능 · 개장 전 턴 정지';
if (profile.status === 'COMPLETED') return '종료 기수 조회 가능 · 턴 정지';
if (profile.status === 'CANCELLED') return '취소 게임 · 접근 및 재개 불가 · 새 시나리오 초기화 필요';
if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가';
return '준비 중 · 게임 접근 불가';
};
@@ -2162,6 +2163,7 @@ onMounted(() => {
active-tab="status"
:can-deploy="hasCapability('admin.profiles.deploy', profile.profileName)"
:can-reset="hasCapability('admin.scenarios.reset', profile.profileName)"
:can-cancel="hasCapability('admin.games.cancel', profile.profileName)"
/>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
@@ -12,7 +12,7 @@ import {
} from '../utils/resetDefaults';
import { directTrpc, trpc } from '../utils/trpc';
type OperationPageMode = 'version' | 'scenario' | 'gateway';
type OperationPageMode = 'version' | 'scenario' | 'cancel' | 'gateway';
const props = defineProps<{
mode: OperationPageMode;
@@ -33,7 +33,7 @@ type Scenario = {
type Operation = {
id: string;
profileName: string;
type: 'RESET' | 'DEPLOY' | 'START' | 'STOP';
type: 'RESET' | 'DEPLOY' | 'CANCEL_GAME' | 'START' | 'STOP';
status: 'QUEUED' | 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
sourceMode?: 'BRANCH' | 'COMMIT';
sourceRef?: string;
@@ -152,6 +152,13 @@ const gatewayForm = reactive({
sourceRef: 'main',
reason: '',
});
const cancellationForm = reactive({
historyMode: 'RETAIN_ABANDONED' as 'RETAIN_ABANDONED' | 'DELETE',
generalMode: 'RETAIN' as 'RETAIN' | 'DELETE',
earnedPointRetentionPercent: 0,
reason: '',
confirmation: '',
});
const selectedGatewayOperation = computed(
() => gatewayReleaseOperations.value.find((operation) => operation.id === selectedGatewayOperationId.value) ?? null
@@ -190,12 +197,16 @@ const hasCapability = (permission: string): boolean =>
const pageTitle = computed(() => {
if (props.mode === 'gateway') return 'Gateway 릴리스';
if (props.mode === 'cancel') return `${props.profileName ?? ''} 게임 취소`;
if (props.mode === 'scenario') return `${props.profileName ?? ''} 시나리오 초기화`;
return `${props.profileName ?? ''} 버전 업데이트`;
});
const pageDescription = computed(() => {
if (props.mode === 'gateway') return 'Gateway control plane 배포와 rollback을 별도 권한으로 관리합니다.';
if (props.mode === 'cancel') {
return '진행 중 게임을 닫고 정식 기수에서 제외하며 장수 기록과 유산 포인트 보전 범위를 선택합니다.';
}
if (props.mode === 'scenario') {
return '현재 배포 버전으로 시나리오만 초기화하거나, 배포 권한이 있을 때 새 버전과 함께 초기화합니다.';
}
@@ -445,8 +456,7 @@ const selectGatewayReleaseOperation = (operationId: string) => {
};
const toggleGatewayReleaseError = (operationId: string) => {
expandedGatewayErrorOperationId.value =
expandedGatewayErrorOperationId.value === operationId ? '' : operationId;
expandedGatewayErrorOperationId.value = expandedGatewayErrorOperationId.value === operationId ? '' : operationId;
};
const requestDeploy = async () => {
@@ -627,6 +637,48 @@ const requestReset = async () => {
}
};
const requestGameCancellation = async () => {
clearStatus();
const profileName = selectedProfileName.value;
if (!profileName || activeOperation.value) return;
if (cancellationForm.reason.trim().length < 5) {
errorMessage.value = '취소 사유를 5자 이상 입력해주세요.';
return;
}
if (cancellationForm.confirmation.trim() !== profileName) {
errorMessage.value = `확인란에 ${profileName}을 정확히 입력해주세요.`;
return;
}
const historyText =
cancellationForm.historyMode === 'RETAIN_ABANDONED' ? '취소 게임으로 보존' : '기수 행 물리 삭제';
const generalText = cancellationForm.generalMode === 'RETAIN' ? '장수 기록 보존' : '장수 기록 삭제';
if (
!window.confirm(
`${profileName}의 진행 중 게임을 취소합니다.\n${historyText}\n${generalText}\n유산 획득분 ${cancellationForm.earnedPointRetentionPercent}% 보전\n취소 후 시나리오 초기화 전에는 재개할 수 없습니다.`
)
) {
return;
}
submitting.value = true;
try {
const operation = await adminClient.operations.requestGameCancellation.mutate({
profileName,
historyMode: cancellationForm.historyMode,
generalMode: cancellationForm.generalMode,
earnedPointRetentionPercent: cancellationForm.earnedPointRetentionPercent,
reason: cancellationForm.reason.trim(),
});
selectedProfileOperationId.value = operation.id;
cancellationForm.confirmation = '';
message.value = '게임 취소 작업을 등록했습니다.';
await loadState(true);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '게임 취소 요청에 실패했습니다.';
} finally {
submitting.value = false;
}
};
const cancelOperation = async (operation: Operation) => {
clearStatus();
if (!window.confirm('대기 중인 작업을 취소하시겠습니까?')) {
@@ -725,9 +777,10 @@ onBeforeUnmount(() => {
<ServerProfileTabs
v-if="mode !== 'gateway' && profileName"
:profile-name="profileName"
:active-tab="mode === 'scenario' ? 'scenario' : 'version'"
:active-tab="mode === 'scenario' ? 'scenario' : mode === 'cancel' ? 'cancel' : 'version'"
:can-deploy="hasCapability('admin.profiles.deploy')"
:can-reset="hasCapability('admin.scenarios.reset')"
:can-cancel="hasCapability('admin.games.cancel')"
/>
<div v-if="errorMessage" class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-200">
@@ -740,7 +793,105 @@ onBeforeUnmount(() => {
{{ message }}
</div>
<section v-if="mode !== 'gateway'">
<section v-if="mode === 'cancel'">
<form
class="space-y-5 rounded-lg border border-red-900/80 bg-zinc-900 p-5"
data-testid="game-cancellation-form"
@submit.prevent="requestGameCancellation"
>
<div class="rounded border border-red-800 bg-red-950/50 px-4 py-3 text-sm text-red-100">
작업은 게임을 즉시 닫고 profile을 <strong>CANCELLED</strong> 상태로 만듭니다. 버전
rollback이나 단순 서버 정지가 아니며, 다시 열려면 시나리오 초기화가 필요합니다.
</div>
<fieldset class="grid gap-4 md:grid-cols-2">
<label class="text-sm text-zinc-300">
기수 데이터
<select
v-model="cancellationForm.historyMode"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2"
data-testid="cancellation-history-mode"
>
<option value="RETAIN_ABANDONED">취소 게임으로 DB에 보존</option>
<option value="DELETE">기수 물리 삭제</option>
</select>
</label>
<label class="text-sm text-zinc-300">
플레이 장수 기록
<select
v-model="cancellationForm.generalMode"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2"
data-testid="cancellation-general-mode"
>
<option value="RETAIN"> 지난 플레이에 취소 게임 기록 보존</option>
<option value="DELETE">장수 과거 기록 삭제</option>
</select>
</label>
</fieldset>
<label class="block text-sm text-zinc-300">
당기 획득 유산 포인트 보전율
<div class="mt-1 flex items-center gap-3">
<input
v-model.number="cancellationForm.earnedPointRetentionPercent"
type="range"
min="0"
max="100"
step="1"
class="w-full"
data-testid="cancellation-retention-range"
/>
<input
v-model.number="cancellationForm.earnedPointRetentionPercent"
type="number"
min="0"
max="100"
step="1"
class="w-24 rounded border border-zinc-700 bg-zinc-950 px-3 py-2 text-right"
data-testid="cancellation-retention-percent"
/>
<span>%</span>
</div>
<span class="mt-1 block text-xs text-zinc-500">
개장 보유 원금은 사용 여부와 관계없이 전액 복구되고, 비율은 이번 게임에서 획득한
몫에만 적용됩니다.
</span>
</label>
<label class="block text-sm text-zinc-300">
취소 사유
<textarea
v-model="cancellationForm.reason"
rows="3"
maxlength="500"
class="mt-1 w-full rounded border border-zinc-700 bg-zinc-950 px-3 py-2"
placeholder="잘못된 기수/시나리오 설정 등 감사 기록에 남길 사유"
data-testid="cancellation-reason"
></textarea>
</label>
<label class="block text-sm text-zinc-300">
확인을 위해 <strong>{{ selectedProfileName }}</strong> 입력
<input
v-model="cancellationForm.confirmation"
class="mt-1 w-full rounded border border-red-800 bg-zinc-950 px-3 py-2 font-mono"
:placeholder="selectedProfileName"
data-testid="cancellation-confirmation"
/>
</label>
<button
type="submit"
class="w-full rounded bg-red-700 px-4 py-3 font-bold text-white hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="
submitting ||
Boolean(activeOperation) ||
cancellationForm.reason.trim().length < 5 ||
cancellationForm.confirmation.trim() !== selectedProfileName
"
data-testid="request-game-cancellation"
>
진행 게임 취소
</button>
</form>
</section>
<section v-if="mode !== 'gateway' && mode !== 'cancel'">
<form
class="rounded-lg border border-zinc-800 bg-zinc-900 p-5 space-y-5"
@submit.prevent="mode === 'scenario' ? requestReset() : requestDeploy()"
@@ -1178,7 +1329,9 @@ onBeforeUnmount(() => {
</td>
<td class="p-2 font-semibold">{{ operation.status }}</td>
<td class="hidden p-2 font-mono sm:table-cell">
<div class="truncate" :title="operation.sourceRef">{{ operation.sourceRef }}</div>
<div class="truncate" :title="operation.sourceRef">
{{ operation.sourceRef }}
</div>
</td>
<td class="hidden p-2 font-mono sm:table-cell">
{{ shortSha(operation.resolvedCommitSha) }}
@@ -1206,7 +1359,11 @@ onBeforeUnmount(() => {
data-testid="gateway-release-error-toggle"
@click="toggleGatewayReleaseError(operation.id)"
>
{{ expandedGatewayErrorOperationId === operation.id ? '오류 닫기' : '오류 보기' }}
{{
expandedGatewayErrorOperationId === operation.id
? '오류 닫기'
: '오류 보기'
}}
</button>
</div>
</td>
@@ -1227,7 +1384,7 @@ onBeforeUnmount(() => {
<div class="mb-2 text-xs font-semibold text-red-300">오류 상세</div>
<pre
class="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-red-200"
>{{ operation.error }}</pre>
>{{ operation.error }}</pre>
</div>
</td>
</tr>
@@ -4,6 +4,7 @@ export const GATEWAY_PROFILE_STATUSES = [
'RUNNING',
'PAUSED',
'COMPLETED',
'CANCELLED',
'STOPPED',
'DISABLED',
] as const;
@@ -52,6 +53,12 @@ const CAPABILITIES: Record<GatewayProfileStatus, GatewayProfileCapabilities> = {
turnsRunning: false,
operatorResumable: false,
},
CANCELLED: {
runtimeExpected: false,
userAccessible: false,
turnsRunning: false,
operatorResumable: false,
},
STOPPED: {
runtimeExpected: false,
userAccessible: false,
@@ -21,6 +21,6 @@ describe('gateway profile status capabilities', () => {
});
it('defines capabilities for every persisted status', () => {
expect(GATEWAY_PROFILE_STATUSES.map((status) => gatewayProfileCapabilities(status))).toHaveLength(7);
expect(GATEWAY_PROFILE_STATUSES.map((status) => gatewayProfileCapabilities(status))).toHaveLength(8);
});
});
+58 -6
View File
@@ -375,22 +375,74 @@ model HallOfFame {
@@map("hall")
}
enum GameHistoryStatus {
OPEN
COMPLETED
ABANDONED
}
enum GameCancellationHistoryMode {
RETAIN_ABANDONED
DELETE
}
enum GameCancellationGeneralMode {
RETAIN
DELETE
}
model GameHistory {
id Int @id @default(autoincrement())
serverId String @map("server_id")
id Int @id @default(autoincrement())
serverId String @map("server_id")
date DateTime
winnerNation Int? @map("winner_nation")
map String? @map("map")
winnerNation Int? @map("winner_nation")
map String? @map("map")
season Int
scenario Int
scenarioName String @map("scenario_name")
env Json @default(dbgenerated("'{}'::jsonb"))
scenarioName String @map("scenario_name")
status GameHistoryStatus @default(OPEN)
env Json @default(dbgenerated("'{}'::jsonb"))
@@unique([serverId])
@@index([date])
@@map("ng_games")
}
model GameInheritanceBaseline {
serverId String @map("server_id")
userId String @map("user_id")
openingPoint Float @map("opening_point")
source String @default("OPENING")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@id([serverId, userId])
@@index([userId, createdAt])
@@map("game_inheritance_baseline")
}
model GameCancellation {
id String @id
serverId String @unique @map("server_id")
originalSeason Int @map("original_season")
scenario Int
scenarioName String @map("scenario_name")
openedAt DateTime @map("opened_at")
cancelledAt DateTime @map("cancelled_at")
cancelledBy String @map("cancelled_by")
reason String
historyMode GameCancellationHistoryMode @map("history_mode")
generalMode GameCancellationGeneralMode @map("general_mode")
earnedPointRetentionPercent Int @map("earned_point_retention_percent")
participantCount Int @map("participant_count")
preservedGeneralCount Int @map("preserved_general_count")
settlement Json @default(dbgenerated("'{}'::jsonb"))
createdAt DateTime @default(now()) @map("created_at")
@@index([cancelledAt])
@@map("game_cancellation")
}
model OldNation {
id Int @id @default(autoincrement())
serverId String @map("server_id")
@@ -0,0 +1,2 @@
ALTER TYPE "GatewayProfileStatus" ADD VALUE IF NOT EXISTS 'CANCELLED';
ALTER TYPE "GatewayOperationType" ADD VALUE IF NOT EXISTS 'CANCEL_GAME';
+2
View File
@@ -19,6 +19,7 @@ enum GatewayProfileStatus {
RUNNING
PAUSED
COMPLETED
CANCELLED
STOPPED
DISABLED
}
@@ -34,6 +35,7 @@ enum GatewayBuildStatus {
enum GatewayOperationType {
RESET
DEPLOY
CANCEL_GAME
START
STOP
}
@@ -0,0 +1,50 @@
CREATE TYPE "GameHistoryStatus" AS ENUM ('OPEN', 'COMPLETED', 'ABANDONED');
CREATE TYPE "GameCancellationHistoryMode" AS ENUM ('RETAIN_ABANDONED', 'DELETE');
CREATE TYPE "GameCancellationGeneralMode" AS ENUM ('RETAIN', 'DELETE');
ALTER TABLE "ng_games"
ADD COLUMN "status" "GameHistoryStatus" NOT NULL DEFAULT 'OPEN';
UPDATE "ng_games"
SET "status" = 'COMPLETED'
WHERE "winner_nation" IS NOT NULL;
CREATE TABLE "game_inheritance_baseline" (
"server_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"opening_point" DOUBLE PRECISION NOT NULL,
"source" TEXT NOT NULL DEFAULT 'OPENING',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "game_inheritance_baseline_pkey" PRIMARY KEY ("server_id", "user_id")
);
CREATE INDEX "game_inheritance_baseline_user_id_created_at_idx"
ON "game_inheritance_baseline"("user_id", "created_at");
CREATE TABLE "game_cancellation" (
"id" TEXT NOT NULL,
"server_id" TEXT NOT NULL,
"original_season" INTEGER NOT NULL,
"scenario" INTEGER NOT NULL,
"scenario_name" TEXT NOT NULL,
"opened_at" TIMESTAMP(3) NOT NULL,
"cancelled_at" TIMESTAMP(3) NOT NULL,
"cancelled_by" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"history_mode" "GameCancellationHistoryMode" NOT NULL,
"general_mode" "GameCancellationGeneralMode" NOT NULL,
"earned_point_retention_percent" INTEGER NOT NULL,
"participant_count" INTEGER NOT NULL,
"preserved_general_count" INTEGER NOT NULL,
"settlement" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "game_cancellation_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "game_cancellation_server_id_key" ON "game_cancellation"("server_id");
CREATE INDEX "game_cancellation_cancelled_at_idx" ON "game_cancellation"("cancelled_at");
ALTER TABLE "game_cancellation"
ADD CONSTRAINT "game_cancellation_retention_percent_check"
CHECK ("earned_point_retention_percent" BETWEEN 0 AND 100);
+2
View File
@@ -21,6 +21,8 @@ export interface DatabaseClient {
rankData: GamePrisma.RankDataDelegate;
hallOfFame: GamePrisma.HallOfFameDelegate;
gameHistory: GamePrisma.GameHistoryDelegate;
gameInheritanceBaseline: GamePrisma.GameInheritanceBaselineDelegate;
gameCancellation: GamePrisma.GameCancellationDelegate;
oldNation: GamePrisma.OldNationDelegate;
oldGeneral: GamePrisma.OldGeneralDelegate;
emperor: GamePrisma.EmperorDelegate;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 2,
"gatewaySchemaHead": "20260818000000_add_legacy_import_checkpoints",
"gatewaySchemaHead": "20260818001000_add_game_cancellation_operation",
"gameSchemaHead": "20260818010000_add_legacy_battle_result_logs",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}