merge: 오픈 게임 취소와 유산 정산 통합

This commit is contained in:
2026-08-18 13:37:44 +00:00
41 changed files with 2085 additions and 94 deletions
+1 -1
View File
@@ -236,7 +236,7 @@ source map 제거 사유가 아니며, 별도의 보안 요구와 검토가 있
각 서버의 `버전 업데이트`는 profile의 game migration만 적용하고 현재
게임 DB를 seed하지 않습니다. 별도 `시나리오 초기화`는 Git 업데이트 없이
현재 게시 commit을 기본으로 사용하며, 필요할 때만 새 버전 배포와 결합합니다.
상태 설정·버전 업데이트·시나리오 초기화는 서버별 상단 탭으로 이동하며,
상태 설정·버전 업데이트·시나리오 초기화·게임 취소는 서버별 상단 탭으로 이동하며,
버전/초기화 화면은 URL에 고정된 profile을 다시 선택하거나 전체 profile 상태를
기다리지 않습니다.
초기화는 현재 시즌 테이블을 새 시나리오로 교체하지만 `hall`, `ng_games`, 연감, 과거 장수·국가와 상속 자료는
+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');
});
});
@@ -93,6 +93,41 @@ const canRun = await canConnectToDatabase(databaseUrl);
const describeDb = describe.runIf(canRun);
describeDb('scenario database seed', () => {
test('snapshots the complete opening inheritance balance before game activity', async () => {
const serverId = 'scenario-seeder-inheritance-baseline';
const userId = 'scenario-seeder-inheritance-user';
const connector = createGamePostgresConnector({ url: databaseUrl });
await connector.connect();
try {
await connector.prisma.gameInheritanceBaseline.deleteMany({ where: { serverId } });
await connector.prisma.gameHistory.deleteMany({ where: { serverId } });
await connector.prisma.inheritancePoint.deleteMany({ where: { userId } });
await connector.prisma.inheritancePoint.createMany({
data: [
{ userId, key: 'previous', value: 10_000 },
{ userId, key: 'lived_month', value: 351.9 },
],
});
await seedScenarioToDatabase({
scenarioId: 903,
databaseUrl,
installOptions: { serverId },
});
await expect(
connector.prisma.gameInheritanceBaseline.findUniqueOrThrow({
where: { serverId_userId: { serverId, userId } },
})
).resolves.toMatchObject({ openingPoint: 10_351, source: 'OPENING' });
} finally {
await connector.prisma.gameInheritanceBaseline.deleteMany({ where: { serverId } });
await connector.prisma.gameHistory.deleteMany({ where: { serverId } });
await connector.prisma.inheritancePoint.deleteMany({ where: { userId } });
await connector.disconnect();
}
});
test('writes scenario data into tables', async () => {
const { seed } = await seedScenarioToDatabase({
scenarioId,
+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',
+47 -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,46 @@ 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 });
// Ref game pages and the shared Core shell use a fixed 500px mobile document contract.
await page.setViewportSize({ width: 500, height: 900 });
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>
+22
View File
@@ -16,6 +16,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 |
| 시나리오 초기화 | `/gateway/admin/servers/:profileName/scenario` | 현재 배포 버전 또는 새 버전으로 현 시즌 DB와 시나리오 교체 |
| 게임 취소 | `/gateway/admin/servers/:profileName/cancel` | 잘못 연 게임을 닫고 기록·유산 포인트를 취소 정책에 따라 원자적으로 정산 |
| Gateway 릴리스 | `/gateway/admin/releases` | Gateway control plane 배포와 rollback |
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
| 감사 로그 | `/gateway/admin/audit` | 관리자 조치 결과, 대상과 사유 조회 |
@@ -45,6 +46,20 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
중단되어도 관리자 capability와 서버 메뉴를 함께 기다리게 하지 않습니다.
- `DEPLOY`는 현재 game DB를 유지하고 migration/build를 적용합니다. `RESET`
현재 시즌 데이터를 새 시나리오로 교체하며 장기 보존 자료를 유지합니다.
- `CANCEL_GAME``RESET`과 다른 비가역 운영 작업입니다. `PREOPEN`, `RUNNING`,
`PAUSED` 게임만 요청할 수 있고, migration 준비 후 profile process를 정지한
다음 한 DB transaction에서 기록과 유산 포인트를 정산합니다. 성공한 profile은
사용자 접근·턴 진행·운영자 재개가 모두 금지된 `CANCELLED`가 되며, 다시
사용하려면 새 시나리오 초기화가 필요합니다.
- 취소 화면에서 기수 이력은 `버려진 게임`으로 남기거나 `ng_games` 행을 실제
삭제할 수 있습니다. 플레이 장수의 `OldGeneral` 기록도 독립적으로 보존·삭제할
수 있습니다. 버려진 기록은 원래 season을 감사 자료에만 보관하고 과거 플레이
화면에서는 정식 기수 번호·왕조·승자 기록으로 표시하지 않습니다.
- 유산 포인트는 게임 오픈 시점(또는 기존 게임의 첫 활동 시점) 원금을 먼저
고정합니다. 취소 시 게임 중 지출과 현재 잔액에 의존하지 않고
`오픈 원금 + floor(게임 중 획득분 × 보전율 / 100)`으로 정산합니다. 따라서
지출분은 항상 전액 돌아오며 신규·복귀 지급과 게임 중 보상도 관리자가 고른
0~100% 보전율의 적용 대상입니다.
- 시나리오 초기화는 기본적으로 서버에 현재 게시된 commit을 사용하므로 Git
업데이트가 필요하지 않습니다. 새 branch/commit과 함께 초기화하려면 초기화
권한과 버전 배포 권한이 모두 필요합니다.
@@ -79,6 +94,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
@@ -86,6 +102,12 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
다른 metadata action이어서 제거했습니다. 초기화와 예약은 시나리오 초기화 탭의
`GatewayOperation(type=RESET)`만 사용합니다.
`admin.games.cancel``admin.profiles.runtime`, `admin.scenarios.reset`이나
기존 운영자 role에 포함되는 포괄 권한이 아닙니다. capability catalog의 별도
`CRITICAL` 권한이며 superuser 또는 명시적으로 범위를 받은 최고관리자만 사용합니다.
브라우저의 확인 입력과 confirm은 오조작 방지 UI이고, 실제 경계는 Gateway API의
capability 검사와 orchestrator의 상태·lease 검사입니다.
## Kakao 없는 특수 계정 접근
운영자 role(`superuser`, `admin`, `admin.*`)은 별도 grant 없이 모든 game
+28
View File
@@ -125,6 +125,32 @@ table 부재로 실패하지 않습니다. 현 시즌의 장수,
초기화 작업은 되돌릴 수 있는 앱 rollback과 다릅니다. 운영 DB backup과 새
scenario 설정을 확인한 뒤 실행해 주세요.
### 잘못 연 게임 취소
게임 취소는 새 시즌을 만드는 `RESET`도, 코드를 되돌리는 release rollback도
아닙니다. `/gateway/admin/servers/:profileName/cancel`에서 별도
`admin.games.cancel:<name>` 권한으로만 요청합니다. 대상은 `PREOPEN`, `RUNNING`,
`PAUSED` 상태여야 하며 이력 보존/삭제, 플레이 장수 기록 보존/삭제, 게임 중 획득한
유산 포인트의 보전율(기본 0%)과 5자 이상의 사유를 확정해야 합니다.
Orchestrator는 Gateway에 게시된 현재 commit을 고정하고 해당 commit의 build와
game migration을 준비합니다. 그 뒤 profile을 `STOPPED`로 fence하고 process를
내린 다음 게임 schema advisory lock과 단일 transaction 안에서 다음을 수행합니다.
- 오픈 원금에 관리자가 고른 획득분 보전율을 적용해 참가자별 유산 포인트 정산
- `ng_games``ABANDONED`로 남기거나 삭제하고, 원래 기수·시나리오·사유·정산표는
`game_cancellation` 감사 행에 보존
- 선택에 따라 현재/사망 장수의 과거 기록을 `OldGeneral`로 보존하거나 모두 삭제
- 명예의 전당, 과거 국가, 황제, 연감, 통일 finalization·상속 결과처럼 정식 완료를
뜻하는 파생 자료 삭제
- `world_state`를 취소 표시하고 profile을 재개 불가능한 `CANCELLED`로 전환
Transaction 전 실패하면 원래 profile 상태와 process 복구를 시도합니다. 정산
transaction이 commit된 뒤에는 실패한 작업을 재개해 게임을 다시 열지 않습니다.
동일 operation ID와 server ID는 중복 정산되지 않으며, 실패·재시도 기록은 Gateway
operation log와 `game_cancellation`에 남습니다. 취소 후에는 새 시나리오 초기화로만
profile을 다시 사용하세요.
### Profile 실패와 재시도
Build는 현재 runtime을 멈추기 전에 수행합니다. Migration 또는 새 process
@@ -297,6 +323,8 @@ schema head·component 검사는 그대로 수행합니다.
확인합니다.
- 시나리오 초기화에서는 새 시즌 상태와 명예의 전당·연감 등 장기보존 자료를
함께 확인합니다.
- 게임 취소에서는 profile이 `CANCELLED`이고 process가 정지됐는지, 취소 이력과
장수 기록 옵션, 참가자별 원금·획득·보전·최종 포인트가 요청과 같은지 확인합니다.
Local unit, 격리 DB integration과 fixture Chromium 통과는 운영 PM2, 외부
Caddy/HTTPS, 방화벽과 실제 운영 DB 전환을 증명하지 않습니다. 운영 배포에서는
@@ -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
}
@@ -1,18 +1,39 @@
ALTER TABLE "legacy_archive"."import_run"
ADD COLUMN "source_key" TEXT,
ADD COLUMN "source_fingerprint" CHAR(64),
ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'full',
ADD COLUMN "progress" JSONB NOT NULL DEFAULT '{}'::jsonb;
ADD COLUMN IF NOT EXISTS "source_key" TEXT,
ADD COLUMN IF NOT EXISTS "source_fingerprint" CHAR(64),
ADD COLUMN IF NOT EXISTS "mode" TEXT NOT NULL DEFAULT 'full',
ADD COLUMN IF NOT EXISTS "progress" JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE "legacy_archive"."import_run"
ADD CONSTRAINT "legacy_archive_import_run_mode_check" CHECK ("mode" IN ('full', 'incremental')),
ADD CONSTRAINT "legacy_archive_import_run_fingerprint_check"
CHECK ("source_fingerprint" IS NULL OR "source_fingerprint" ~ '^[a-f0-9]{64}$');
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'legacy_archive_import_run_mode_check'
AND conrelid = 'legacy_archive.import_run'::regclass
) THEN
ALTER TABLE "legacy_archive"."import_run"
ADD CONSTRAINT "legacy_archive_import_run_mode_check"
CHECK ("mode" IN ('full', 'incremental'));
END IF;
CREATE INDEX "legacy_archive_import_run_source_started"
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'legacy_archive_import_run_fingerprint_check'
AND conrelid = 'legacy_archive.import_run'::regclass
) THEN
ALTER TABLE "legacy_archive"."import_run"
ADD CONSTRAINT "legacy_archive_import_run_fingerprint_check"
CHECK ("source_fingerprint" IS NULL OR "source_fingerprint" ~ '^[a-f0-9]{64}$');
END IF;
END
$$;
CREATE INDEX IF NOT EXISTS "legacy_archive_import_run_source_started"
ON "legacy_archive"."import_run" ("source_profile", "source_key", "started_at" DESC);
CREATE TABLE "legacy_archive"."import_checkpoint" (
CREATE TABLE IF NOT EXISTS "legacy_archive"."import_checkpoint" (
"source_profile" TEXT NOT NULL,
"source_key" TEXT NOT NULL,
"source_fingerprint" CHAR(64) NOT NULL,
@@ -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"]
}