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