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

별도 최고위험 권한으로 실행되는 원자적 취소 작업을 추가한다. 버려진 게임과 장수 기록의 보존·삭제 옵션, 오픈 원금 전액 환급 및 획득 포인트 보전율, 취소 상태와 관리자·과거기록 UI를 함께 반영한다.
This commit is contained in:
2026-08-18 13:31:49 +00:00
parent a7b11811de
commit 383d173790
36 changed files with 1967 additions and 83 deletions
+64 -25
View File
@@ -240,30 +240,51 @@ export const archiveRouter = router({
const currentServerIds = Array.from(
new Set(entries.filter((entry) => entry.source === 'current').map((entry) => entry.serverId))
);
const [legacyGames, legacyNationRows, legacyEmperors, currentGames, currentNationRows, currentEmperors] =
await Promise.all([
findLegacyGames(ctx.db, legacyKeys),
findLegacyNations(ctx.db, legacyKeys),
findLegacyEmperors(ctx.db, legacyKeys),
currentServerIds.length
? ctx.db.gameHistory.findMany({ where: { serverId: { in: currentServerIds } } })
: [],
currentServerIds.length
? ctx.db.oldNation.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
})
: [],
currentServerIds.length
? ctx.db.emperor.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: { id: 'desc' },
select: { id: true, serverId: true },
})
: [],
]);
const [
legacyGames,
legacyNationRows,
legacyEmperors,
currentGames,
currentCancellations,
currentNationRows,
currentEmperors,
] = await Promise.all([
findLegacyGames(ctx.db, legacyKeys),
findLegacyNations(ctx.db, legacyKeys),
findLegacyEmperors(ctx.db, legacyKeys),
currentServerIds.length
? ctx.db.gameHistory.findMany({ where: { serverId: { in: currentServerIds } } })
: [],
currentServerIds.length
? ctx.db.gameCancellation.findMany({ where: { serverId: { in: currentServerIds } } })
: [],
currentServerIds.length
? ctx.db.oldNation.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: [{ date: 'desc' }, { id: 'desc' }],
})
: [],
currentServerIds.length
? ctx.db.emperor.findMany({
where: { serverId: { in: currentServerIds } },
orderBy: { id: 'desc' },
select: { id: true, serverId: true },
})
: [],
]);
const games = new Map<string, { openedAt: Date; season: number; scenario: number; scenarioName: string }>();
const games = new Map<
string,
{
openedAt: Date;
season: number;
scenario: number;
scenarioName: string;
status?: 'OPEN' | 'COMPLETED' | 'ABANDONED';
cancellationId?: string;
cancelledAt?: Date;
}
>();
for (const row of legacyGames) {
games.set(key('legacy', row.sourceProfile, row.serverId), row);
}
@@ -273,6 +294,18 @@ export const archiveRouter = router({
season: row.season,
scenario: row.scenario,
scenarioName: row.scenarioName,
status: row.status,
});
}
for (const row of currentCancellations) {
games.set(key('current', ctx.profile.id, row.serverId), {
openedAt: row.openedAt,
season: row.originalSeason,
scenario: row.scenario,
scenarioName: row.scenarioName,
status: 'ABANDONED',
cancellationId: row.id,
cancelledAt: row.cancelledAt,
});
}
const nations = new Map<string, ArchiveNationEntry>();
@@ -326,6 +359,9 @@ export const archiveRouter = router({
season: number | null;
scenario: number | null;
scenarioName: string | null;
status: 'OPEN' | 'COMPLETED' | 'ABANDONED' | 'LEGACY';
cancellationId: string | null;
cancelledAt: string | null;
dynastyId: number | null;
generals: Array<{
generalNo: number;
@@ -360,10 +396,13 @@ export const archiveRouter = router({
serverId: entry.serverId,
openedAt,
date: openedAt,
season: game?.season ?? null,
season: game?.status === 'ABANDONED' ? null : (game?.season ?? null),
scenario: game?.scenario ?? null,
scenarioName: game?.scenarioName ?? null,
dynastyId: dynastyIds.get(entryKey) ?? null,
status: entry.source === 'legacy' ? 'LEGACY' : (game?.status ?? 'COMPLETED'),
cancellationId: game?.cancellationId ?? null,
cancelledAt: game?.cancelledAt?.toISOString() ?? null,
dynastyId: game?.status === 'ABANDONED' ? null : (dynastyIds.get(entryKey) ?? null),
generals: [],
};
seasons.set(entryKey, season);
+1
View File
@@ -414,6 +414,7 @@ export const rankingRouter = router({
return Array.from(optionMap.values());
}
const rows = await ctx.db.gameHistory.findMany({
where: { status: 'COMPLETED' },
select: { season: true, scenario: true, scenarioName: true },
orderBy: [{ season: 'desc' }, { scenario: 'asc' }],
});
+37 -1
View File
@@ -25,7 +25,11 @@ const auth: GameSessionTokenPayload = {
sanctions: {},
};
const context = (session: GameSessionTokenPayload | null, includeLegacy = false): GameApiContext => {
const context = (
session: GameSessionTokenPayload | null,
includeLegacy = false,
includeCancellation = false
): GameApiContext => {
const db = {
$queryRaw: async (query: { strings?: readonly string[] }) => {
if (!includeLegacy) return [];
@@ -236,10 +240,27 @@ const context = (session: GameSessionTokenPayload | null, includeLegacy = false)
season: 1,
scenario: 100,
scenarioName: '테스트',
status: 'COMPLETED',
env: {},
},
],
},
gameCancellation: {
findMany: async () =>
includeCancellation
? [
{
id: 'cancel-fixture',
serverId: 'che_legacy_1',
originalSeason: 1,
scenario: 100,
scenarioName: '테스트',
openedAt: new Date('2025-01-02T00:00:00.000Z'),
cancelledAt: new Date('2025-01-03T00:00:00.000Z'),
},
]
: [],
},
oldNation: {
findMany: async () => [
{
@@ -359,6 +380,21 @@ describe('archive.myPastPlays', () => {
});
});
it('labels a retained cancellation as an unnumbered abandoned game without a dynasty link', async () => {
const result = await appRouter.createCaller(context(auth, false, true)).archive.myPastPlays();
expect(result.seasons).toEqual([
expect.objectContaining({
serverId: 'che_legacy_1',
season: null,
status: 'ABANDONED',
cancellationId: 'cancel-fixture',
cancelledAt: '2025-01-03T00:00:00.000Z',
dynastyId: null,
}),
]);
});
it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => {
const caller = appRouter.createCaller(context(auth, true));
const list = await caller.archive.myPastPlays();
@@ -327,6 +327,11 @@ integration('generic general creation through the durable turn daemon', () => {
})
).toMatchObject({ value: 7351 });
expect(await db.inheritancePoint.count({ where: { userId } })).toBe(1);
await expect(
db.gameInheritanceBaseline.findUniqueOrThrow({
where: { serverId_userId: { serverId: profile, userId } },
})
).resolves.toMatchObject({ openingPoint: 10_351, source: 'FIRST_ACTIVITY' });
expect(await db.inheritanceLog.count({ where: { userId } })).toBe(9);
expect(
await db.inheritanceLog.findFirst({