feat: 오픈 게임 취소와 유산 정산 경로 추가
별도 최고위험 권한으로 실행되는 원자적 취소 작업을 추가한다. 버려진 게임과 장수 기록의 보존·삭제 옵션, 오픈 원금 전액 환급 및 획득 포인트 보전율, 취소 상태와 관리자·과거기록 UI를 함께 반영한다.
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { cancelGame } from '../src/scenario/gameCancellation.js';
|
||||
|
||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const serverId = 'che_game_cancellation_fixture';
|
||||
const userId = 'game-cancellation-user';
|
||||
const generalId = 9_851;
|
||||
const openedAt = new Date('2026-08-18T00:00:00.000Z');
|
||||
const cancelledAt = new Date('2026-08-18T01:00:00.000Z');
|
||||
|
||||
integration('game cancellation transaction', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
|
||||
const cleanup = async (): Promise<void> => {
|
||||
await db.gameCancellation.deleteMany({ where: { serverId } });
|
||||
await db.gameInheritanceBaseline.deleteMany({ where: { serverId } });
|
||||
await db.unificationFinalization.deleteMany({ where: { serverId } });
|
||||
await db.yearbookHistory.deleteMany({ where: { profileName: serverId } });
|
||||
await db.emperor.deleteMany({ where: { serverId } });
|
||||
await db.oldGeneral.deleteMany({ where: { serverId } });
|
||||
await db.oldNation.deleteMany({ where: { serverId } });
|
||||
await db.hallOfFame.deleteMany({ where: { serverId } });
|
||||
await db.inheritanceResult.deleteMany({ where: { serverId } });
|
||||
await db.inheritanceLog.deleteMany({ where: { userId } });
|
||||
await db.inheritancePoint.deleteMany({ where: { userId } });
|
||||
await db.gameHistory.deleteMany({ where: { serverId } });
|
||||
await db.logEntry.deleteMany({ where: { generalId } });
|
||||
await db.rankData.deleteMany({ where: { generalId } });
|
||||
await db.general.deleteMany({ where: { id: generalId } });
|
||||
await db.worldState.deleteMany({ where: { scenarioCode: 'game-cancellation-fixture' } });
|
||||
};
|
||||
|
||||
const seed = async (): Promise<void> => {
|
||||
await db.worldState.create({
|
||||
data: {
|
||||
scenarioCode: 'game-cancellation-fixture',
|
||||
currentYear: 190,
|
||||
currentMonth: 7,
|
||||
tickSeconds: 600,
|
||||
meta: { serverId, season: 7, isUnited: 0 },
|
||||
},
|
||||
});
|
||||
await db.gameHistory.create({
|
||||
data: {
|
||||
serverId,
|
||||
date: openedAt,
|
||||
season: 7,
|
||||
scenario: 1010,
|
||||
scenarioName: '취소 테스트',
|
||||
status: 'OPEN',
|
||||
env: { meta: { serverId, season: 7 } },
|
||||
},
|
||||
});
|
||||
await db.general.create({
|
||||
data: {
|
||||
id: generalId,
|
||||
userId,
|
||||
name: '취소장수',
|
||||
turnTime: openedAt,
|
||||
meta: { inherit_spent_dyn: 4_500 },
|
||||
},
|
||||
});
|
||||
await db.rankData.createMany({
|
||||
data: [
|
||||
{ generalId, nationId: 0, type: 'inherit_spent_dyn', value: 4_500 },
|
||||
{ generalId, nationId: 0, type: 'warnum', value: 10 },
|
||||
],
|
||||
});
|
||||
await db.inheritancePoint.createMany({
|
||||
data: [
|
||||
{ userId, key: 'previous', value: 7_000 },
|
||||
{ userId, key: 'max_domestic_critical', value: 200 },
|
||||
],
|
||||
});
|
||||
await db.gameInheritanceBaseline.create({
|
||||
data: { serverId, userId, openingPoint: 10_000, source: 'OPENING' },
|
||||
});
|
||||
await db.inheritanceLog.create({
|
||||
data: {
|
||||
userId,
|
||||
serverId,
|
||||
year: 190,
|
||||
month: 7,
|
||||
text: '신규/복귀 생성으로 포인트 1500 지급',
|
||||
createdAt: new Date('2026-08-18T00:30:00.000Z'),
|
||||
},
|
||||
});
|
||||
await db.oldGeneral.create({
|
||||
data: {
|
||||
serverId,
|
||||
generalNo: generalId - 1,
|
||||
owner: userId,
|
||||
name: '사망장수',
|
||||
lastYearMonth: 19006,
|
||||
turnTime: openedAt,
|
||||
data: { meta: { inherit_spent_dyn: 0 } },
|
||||
},
|
||||
});
|
||||
await db.hallOfFame.create({
|
||||
data: { serverId, season: 7, scenario: 1010, generalNo: generalId, type: 'warnum', value: 10 },
|
||||
});
|
||||
await db.oldNation.create({ data: { serverId, nation: 1, sourceId: 1 } });
|
||||
await db.emperor.create({ data: { serverId, name: '취소 황제' } });
|
||||
await db.yearbookHistory.create({
|
||||
data: { profileName: serverId, year: 190, month: 7, map: {}, nations: {} },
|
||||
});
|
||||
await db.unificationFinalization.create({
|
||||
data: {
|
||||
generationKey: `${serverId}:fixture`,
|
||||
serverId,
|
||||
profileName: 'che',
|
||||
winnerNation: 1,
|
||||
year: 190,
|
||||
month: 7,
|
||||
completedAt: cancelledAt,
|
||||
},
|
||||
});
|
||||
await db.inheritanceResult.create({
|
||||
data: {
|
||||
serverId,
|
||||
owner: userId,
|
||||
generalId,
|
||||
year: 190,
|
||||
month: 7,
|
||||
value: { refund: 0 },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanup();
|
||||
await seed();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('rolls back a late failure, then refunds spending and retains selected earnings exactly once', async () => {
|
||||
const request = {
|
||||
cancellationId: 'game-cancellation-retain-fixture',
|
||||
databaseUrl: databaseUrl!,
|
||||
cancelledBy: 'admin',
|
||||
reason: '잘못 연 게임 취소',
|
||||
historyMode: 'RETAIN_ABANDONED' as const,
|
||||
generalMode: 'RETAIN' as const,
|
||||
earnedPointRetentionPercent: 40,
|
||||
cancelledAt,
|
||||
};
|
||||
|
||||
await expect(cancelGame({ ...request, cancelledBy: 'admin\0invalid' })).rejects.toThrow();
|
||||
await expect(db.gameHistory.findUniqueOrThrow({ where: { serverId } })).resolves.toMatchObject({
|
||||
status: 'OPEN',
|
||||
});
|
||||
await expect(
|
||||
db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } })
|
||||
).resolves.toMatchObject({ value: 7_000 });
|
||||
await expect(db.hallOfFame.count({ where: { serverId } })).resolves.toBe(1);
|
||||
await expect(db.gameCancellation.count({ where: { serverId } })).resolves.toBe(0);
|
||||
|
||||
const result = await cancelGame(request);
|
||||
expect(result).toMatchObject({
|
||||
participantCount: 1,
|
||||
preservedGeneralCount: 2,
|
||||
alreadyApplied: false,
|
||||
settlements: {
|
||||
[userId]: {
|
||||
openingPoint: 10_000,
|
||||
currentPoint: 7_000,
|
||||
earnedPoint: 1_750,
|
||||
retainedEarnedPoint: 700,
|
||||
finalPoint: 10_700,
|
||||
baselineSource: 'OPENING',
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
db.inheritancePoint.findMany({ where: { userId }, orderBy: { key: 'asc' } })
|
||||
).resolves.toMatchObject([{ key: 'previous', value: 10_700 }]);
|
||||
await expect(db.gameHistory.findUniqueOrThrow({ where: { serverId } })).resolves.toMatchObject({
|
||||
status: 'ABANDONED',
|
||||
winnerNation: null,
|
||||
});
|
||||
const archived = await db.oldGeneral.findMany({ where: { serverId }, orderBy: { generalNo: 'asc' } });
|
||||
expect(archived).toHaveLength(2);
|
||||
expect(archived.every((row) => JSON.stringify(row.data).includes(request.cancellationId))).toBe(true);
|
||||
await expect(db.hallOfFame.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.oldNation.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.emperor.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.yearbookHistory.count({ where: { profileName: serverId } })).resolves.toBe(0);
|
||||
await expect(db.unificationFinalization.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.inheritanceResult.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.worldState.findFirstOrThrow()).resolves.toMatchObject({
|
||||
meta: expect.objectContaining({ isCancelled: 1, cancellationId: request.cancellationId }),
|
||||
});
|
||||
|
||||
await expect(cancelGame(request)).resolves.toMatchObject({ alreadyApplied: true });
|
||||
await expect(
|
||||
db.inheritanceLog.count({ where: { userId, text: { startsWith: '취소 게임 정산:' } } })
|
||||
).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('physically deletes the numbered history row and past-play general archive on request', async () => {
|
||||
const result = await cancelGame({
|
||||
cancellationId: 'game-cancellation-delete-fixture',
|
||||
databaseUrl: databaseUrl!,
|
||||
cancelledBy: 'admin',
|
||||
reason: '기수와 장수 기록 삭제',
|
||||
historyMode: 'DELETE',
|
||||
generalMode: 'DELETE',
|
||||
earnedPointRetentionPercent: 0,
|
||||
cancelledAt,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ participantCount: 1, preservedGeneralCount: 0, alreadyApplied: false });
|
||||
await expect(db.gameHistory.findUnique({ where: { serverId } })).resolves.toBeNull();
|
||||
await expect(db.oldGeneral.count({ where: { serverId } })).resolves.toBe(0);
|
||||
await expect(db.gameCancellation.findUnique({ where: { serverId } })).resolves.toMatchObject({
|
||||
originalSeason: 7,
|
||||
historyMode: 'DELETE',
|
||||
generalMode: 'DELETE',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { calculateCancelledInheritancePoint } from '../src/scenario/gameCancellation.js';
|
||||
|
||||
describe('cancelled game inheritance settlement', () => {
|
||||
it('refunds every spent point and discards earned points at zero percent', () => {
|
||||
expect(
|
||||
calculateCancelledInheritancePoint({
|
||||
openingPoint: 10_000,
|
||||
earnedPoint: 2_345.75,
|
||||
earnedPointRetentionPercent: 0,
|
||||
})
|
||||
).toEqual({ retainedEarnedPoint: 0, finalPoint: 10_000 });
|
||||
});
|
||||
|
||||
it('retains the selected integer percentage without rounding upward', () => {
|
||||
expect(
|
||||
calculateCancelledInheritancePoint({
|
||||
openingPoint: 10_000,
|
||||
earnedPoint: 333,
|
||||
earnedPointRetentionPercent: 50,
|
||||
})
|
||||
).toEqual({ retainedEarnedPoint: 166, finalPoint: 10_166 });
|
||||
});
|
||||
|
||||
it('retains all earned points at one hundred percent', () => {
|
||||
expect(
|
||||
calculateCancelledInheritancePoint({
|
||||
openingPoint: 10_000,
|
||||
earnedPoint: 333,
|
||||
earnedPointRetentionPercent: 100,
|
||||
})
|
||||
).toEqual({ retainedEarnedPoint: 333, finalPoint: 10_333 });
|
||||
});
|
||||
|
||||
it.each([-1, 1.5, 101])('rejects invalid retention percentage %s', (earnedPointRetentionPercent) => {
|
||||
expect(() =>
|
||||
calculateCancelledInheritancePoint({
|
||||
openingPoint: 0,
|
||||
earnedPoint: 0,
|
||||
earnedPointRetentionPercent,
|
||||
})
|
||||
).toThrow('integer from 0 to 100');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user