diff --git a/app/game-api/src/router/archive/index.ts b/app/game-api/src/router/archive/index.ts index eeb7909b..fe8c14cd 100644 --- a/app/game-api/src/router/archive/index.ts +++ b/app/game-api/src/router/archive/index.ts @@ -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(); + 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(); @@ -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); diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index f4e5917a..d4f94630 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -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' }], }); diff --git a/app/game-api/test/archiveRouter.test.ts b/app/game-api/test/archiveRouter.test.ts index 572cbed1..13a1261c 100644 --- a/app/game-api/test/archiveRouter.test.ts +++ b/app/game-api/test/archiveRouter.test.ts @@ -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(); diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index 7efe4179..00464f1f 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -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({ diff --git a/app/game-engine/package.json b/app/game-engine/package.json index 525cb580..873263a6 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -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" diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index 5276b11a..ad8c7b4b 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -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'; diff --git a/app/game-engine/src/scenario/gameCancellation.ts b/app/game-engine/src/scenario/gameCancellation.ts new file mode 100644 index 00000000..b767a14f --- /dev/null +++ b/app/game-engine/src/scenario/gameCancellation.ts @@ -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; +} + +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, +}); + +const cancelGameInTransaction = async ( + prisma: GamePrisma.TransactionClient, + request: Omit & { cancelledAt: Date } +): Promise => { + 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>(); + for (const row of pointRows) { + const points = pointsByUser.get(row.userId) ?? new Map(); + 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>(); + 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(); + 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(); + 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(); + for (const result of resultRows) { + const rows = resultsByUser.get(result.owner) ?? []; + rows.push(result); + resultsByUser.set(result.owner, rows); + } + const inheritanceLogsByUser = new Map(); + 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(); + const trackedByGeneral = new Map(); + 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(); + for (const general of activeGenerals) { + if (!general.userId || general.npcState >= 2) continue; + const points = pointsByUser.get(general.userId) ?? new Map(); + 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 = {}; + 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 => { + 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(); + } +}; diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 908878b4..a1d1c96f 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -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(); + 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) { diff --git a/app/game-engine/src/turn/inheritancePointCalculation.ts b/app/game-engine/src/turn/inheritancePointCalculation.ts index 48cad4bc..ed18c3b6 100644 --- a/app/game-engine/src/turn/inheritancePointCalculation.ts +++ b/app/game-engine/src/turn/inheritancePointCalculation.ts @@ -1,7 +1,10 @@ -import type { TurnGeneral } from './types.js'; - const DEX_LIMIT = 1_275_975; +interface InheritancePointGeneral { + meta: Record; + inheritancePoints?: Record; +} + const STORED_INHERITANCE_KEYS = [ 'lived_month', 'max_domestic_critical', @@ -31,7 +34,7 @@ const readNumber = (source: Record, 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 => { diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 107fb382..3936cd2d 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -245,6 +245,23 @@ const setInheritancePoint = async (db: DatabaseClient, userId: string, value: nu }); }; +const ensureGameInheritanceBaseline = async ( + db: DatabaseClient, + worldMeta: Record, + userId: string, + openingPoint: number +): Promise => { + 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))) diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 98c9865a..5a845dd3 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -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({ diff --git a/app/game-engine/test/gameCancellation.integration.test.ts b/app/game-engine/test/gameCancellation.integration.test.ts new file mode 100644 index 00000000..af47422f --- /dev/null +++ b/app/game-engine/test/gameCancellation.integration.test.ts @@ -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) | undefined; + + const cleanup = async (): Promise => { + 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 => { + 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', + }); + }); +}); diff --git a/app/game-engine/test/gameCancellation.test.ts b/app/game-engine/test/gameCancellation.test.ts new file mode 100644 index 00000000..ae67daa3 --- /dev/null +++ b/app/game-engine/test/gameCancellation.test.ts @@ -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'); + }); +}); diff --git a/app/game-engine/tsdown.config.ts b/app/game-engine/tsdown.config.ts index 6e0af083..3713de76 100644 --- a/app/game-engine/tsdown.config.ts +++ b/app/game-engine/tsdown.config.ts @@ -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', diff --git a/app/game-frontend/e2e/pastPlays.spec.ts b/app/game-frontend/e2e/pastPlays.spec.ts index e1c0dab2..561f71a2 100644 --- a/app/game-frontend/e2e/pastPlays.spec.ts +++ b/app/game-frontend/e2e/pastPlays.spec.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,45 @@ test('보존되지 않은 과거 전투 집계는 0으로 꾸미지 않고 가 await expect(page.locator('[data-general-battle-summary]')).not.toContainText('승률'); }); +test('취소 게임은 정식 기수 번호와 왕조 링크 없이 별도 기록으로 표시된다', async ({ page }, testInfo) => { + await installArchive(page, { abandoned: true }); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('past-plays'); + + const seasonCard = page.locator('.season-card'); + await expect(seasonCard.getByText('취소 게임', { exact: true })).toBeVisible(); + await expect(seasonCard.getByText('취소 ID 12345678')).toBeVisible(); + await expect(seasonCard).toContainText('천하쟁패'); + await expect(seasonCard).not.toContainText('51기'); + await expect(seasonCard.getByRole('link', { name: '이 기수 국가 정보' })).toHaveCount(0); + const desktop = await seasonCard.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const heading = element.querySelector('.season-heading')!.getBoundingClientRect(); + return { x: rect.x, width: rect.width, headingHeight: heading.height }; + }); + expect(desktop).toMatchObject({ x: 100, width: 1000 }); + await page.screenshot({ path: testInfo.outputPath('abandoned-past-play-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + const mobile = await seasonCard.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + x: rect.x, + width: rect.width, + viewportWidth: document.documentElement.clientWidth, + documentScrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(mobile.x).toBeGreaterThanOrEqual(0); + expect(mobile.x + mobile.width).toBeLessThanOrEqual(mobile.viewportWidth); + expect(mobile.documentScrollWidth).toBeLessThanOrEqual(mobile.viewportWidth); + await writeFile( + testInfo.outputPath('abandoned-past-play-metrics.json'), + JSON.stringify({ desktop, mobile }, null, 2) + ); + await page.screenshot({ path: testInfo.outputPath('abandoned-past-play-mobile.png'), fullPage: true }); +}); + test('past plays is available without a current general and preserves desktop interaction geometry', async ({ page, }) => { diff --git a/app/game-frontend/src/views/PastPlaysView.vue b/app/game-frontend/src/views/PastPlaysView.vue index dc7b5b04..17ed68ec 100644 --- a/app/game-frontend/src/views/PastPlaysView.vue +++ b/app/game-frontend/src/views/PastPlaysView.vue @@ -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 => { const key = detailKey(season, generalNo); @@ -197,7 +206,7 @@ onMounted(() => { -

이전 서버에서 종료된 기수에 보관된 내 장수 기록입니다.

+

종료된 기수와 관리자가 보존한 취소 게임의 내 장수 기록입니다.

{{ error }}

불러오는 중...

보관된 지난 플레이가 없습니다.

@@ -205,15 +214,19 @@ onMounted(() => {
- 이전 서버 기록 + + {{ archiveLabel(season) }} + {{ seasonSourceProfile(season) }} - {{ season.serverId }} + {{ archiveIdentifier(season) }}
{{ formatOpenedAt(season) }} {{ season.scenarioName ?? '시나리오 미상' }} - +
@@ -425,6 +438,11 @@ onMounted(() => { background: #00582c; } +.archive-label.abandoned { + border-color: #956f38; + background: #66471f; +} + .season-meta { justify-content: flex-end; } diff --git a/app/gateway-api/src/adminCapabilities.ts b/app/gateway-api/src/adminCapabilities.ts index 154d2293..85d7bdb8 100644 --- a/app/gateway-api/src/adminCapabilities.ts +++ b/app/gateway-api/src/adminCapabilities.ts @@ -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')) { diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 9c1aaec4..c1f1fe0c 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -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); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index fef7a1d1..9de1a2e5 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -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; + 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; + 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 + ): Promise { + 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 => { + 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, diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index 994106e7..1fa900a6 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -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'; diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 82395628..1e429481 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -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({ diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index 6d961d16..bd84fd50 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -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 + startGate?: Promise, + 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> = []; + const cancellationProfile = { ...profile, status: 'RUNNING' as const }; + const cancelGame: NonNullable = async (request) => { + settlementRequests.push(request as unknown as Record); + 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')); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 4da85c3c..e0d3bbf1 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -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', }); }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index e2e4b004..7a98f76d 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -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 }, diff --git a/app/gateway-frontend/src/components/ServerProfileTabs.vue b/app/gateway-frontend/src/components/ServerProfileTabs.vue index 6851e282..8f9d9bd5 100644 --- a/app/gateway-frontend/src/components/ServerProfileTabs.vue +++ b/app/gateway-frontend/src/components/ServerProfileTabs.vue @@ -1,13 +1,14 @@ diff --git a/app/gateway-frontend/src/router/index.ts b/app/gateway-frontend/src/router/index.ts index 16d7b940..dffd700a 100644 --- a/app/gateway-frontend/src/router/index.ts +++ b/app/gateway-frontend/src/router/index.ts @@ -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', diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index f42dc142..28bb3f68 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -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)" />
diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 78dcc675..8475bea0 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -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(() => {
@@ -740,7 +793,105 @@ onBeforeUnmount(() => { {{ message }}
-
+
+
+
+ 이 작업은 게임을 즉시 닫고 profile을 CANCELLED 상태로 만듭니다. 버전 + rollback이나 단순 서버 정지가 아니며, 다시 열려면 새 시나리오 초기화가 필요합니다. +
+
+ + +
+ + + + +
+
+ +
{ {{ operation.status }} -
{{ operation.sourceRef }}
+
+ {{ operation.sourceRef }} +
{{ shortSha(operation.resolvedCommitSha) }} @@ -1206,7 +1359,11 @@ onBeforeUnmount(() => { data-testid="gateway-release-error-toggle" @click="toggleGatewayReleaseError(operation.id)" > - {{ expandedGatewayErrorOperationId === operation.id ? '오류 닫기' : '오류 보기' }} + {{ + expandedGatewayErrorOperationId === operation.id + ? '오류 닫기' + : '오류 보기' + }}
@@ -1227,7 +1384,7 @@ onBeforeUnmount(() => {
오류 상세
{{ operation.error }}
+ >{{ operation.error }} diff --git a/packages/common/src/gateway/profileStatus.ts b/packages/common/src/gateway/profileStatus.ts index 5b8d32f0..92c75c34 100644 --- a/packages/common/src/gateway/profileStatus.ts +++ b/packages/common/src/gateway/profileStatus.ts @@ -4,6 +4,7 @@ export const GATEWAY_PROFILE_STATUSES = [ 'RUNNING', 'PAUSED', 'COMPLETED', + 'CANCELLED', 'STOPPED', 'DISABLED', ] as const; @@ -52,6 +53,12 @@ const CAPABILITIES: Record = { turnsRunning: false, operatorResumable: false, }, + CANCELLED: { + runtimeExpected: false, + userAccessible: false, + turnsRunning: false, + operatorResumable: false, + }, STOPPED: { runtimeExpected: false, userAccessible: false, diff --git a/packages/common/test/gateway-profile-status.test.ts b/packages/common/test/gateway-profile-status.test.ts index e1e72e73..fb1ce9f3 100644 --- a/packages/common/test/gateway-profile-status.test.ts +++ b/packages/common/test/gateway-profile-status.test.ts @@ -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); }); }); diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index df8598f8..ca1d8b12 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -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") diff --git a/packages/infra/prisma/gateway-migrations/20260818001000_add_game_cancellation_operation/migration.sql b/packages/infra/prisma/gateway-migrations/20260818001000_add_game_cancellation_operation/migration.sql new file mode 100644 index 00000000..466a8a92 --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20260818001000_add_game_cancellation_operation/migration.sql @@ -0,0 +1,2 @@ +ALTER TYPE "GatewayProfileStatus" ADD VALUE IF NOT EXISTS 'CANCELLED'; +ALTER TYPE "GatewayOperationType" ADD VALUE IF NOT EXISTS 'CANCEL_GAME'; diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index b818720e..67f3fe82 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -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 } diff --git a/packages/infra/prisma/migrations/20260818001000_add_game_cancellation/migration.sql b/packages/infra/prisma/migrations/20260818001000_add_game_cancellation/migration.sql new file mode 100644 index 00000000..9a690d0e --- /dev/null +++ b/packages/infra/prisma/migrations/20260818001000_add_game_cancellation/migration.sql @@ -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); diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index 2d6a89e7..6a0a5d18 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -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; diff --git a/release-manifest.json b/release-manifest.json index 7b831722..1bccec58 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -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"] }