import { randomUUID } from 'node:crypto'; import { expect, test, type Page } from '@playwright/test'; import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js'; import { createTurnDaemonRuntime, seedScenarioToDatabase } from '../../game-engine/dist/index.js'; import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js'; const databaseUrl = process.env.DIE_ON_PRESTART_LIVE_DATABASE_URL; const redisUrl = process.env.DIE_ON_PRESTART_LIVE_REDIS_URL; const gameTokenSecret = process.env.DIE_ON_PRESTART_LIVE_GAME_SECRET; const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'die_on_prestart_live_integration:2'; const archiveServerId = 'die_prestart:2'; const scenarioId = Number(process.env.PLAYWRIGHT_SCENARIO ?? '2'); const userId = 'die-prestart-live-user'; const generalId = 991_741; const memberId = 991_742; const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret); const installSession = async (page: Page): Promise => { const now = new Date(); const gameToken = encryptGameSessionToken( { version: 1, profile, issuedAt: now.toISOString(), expiresAt: new Date(now.getTime() + 3_600_000).toISOString(), sessionId: `die-prestart-live-${randomUUID()}`, user: { id: userId, username: 'die-prestart-live', displayName: '실삭제사용자', roles: ['user'], legacyMemberNo: 7_741, canUseGeneralPicture: false, }, sanctions: {}, identity: { kakaoVerified: true, canCreateGeneral: true, requiresKakaoVerification: false, graceEndsAt: null, }, }, gameTokenSecret! ); await page.addInitScript( ({ token, gameProfile }) => { if (location.pathname.startsWith('/che/')) { window.localStorage.setItem('sammo-game-token', token); window.localStorage.setItem('sammo-game-profile', gameProfile); } }, { token: gameToken, gameProfile: profile } ); }; test.describe('pre-start deletion through live PostgreSQL, Redis, API, daemon, and Chromium', () => { test.skip(!hasLiveFixture, 'live pre-start deletion token, Redis, and database are required'); let db: ReturnType['prisma']; let closeDb: (() => Promise) | undefined; let runtime: Awaited> | undefined; let daemonLoop: Promise | undefined; test.beforeAll(async () => { const schema = new URL(databaseUrl!).searchParams.get('schema'); if (!schema?.endsWith('die_on_prestart_live_integration')) { throw new Error(`Refusing non-dedicated schema: ${schema ?? '(missing)'}`); } const previousSeed = process.env.INTEGRATION_WORLD_SEED; process.env.INTEGRATION_WORLD_SEED = 'die-on-prestart-live-seed'; try { await seedScenarioToDatabase({ scenarioId, databaseUrl: databaseUrl!, now: new Date('2099-07-31T12:00:00.000Z'), installOptions: { turnTermMinutes: 5, npcMode: 0, showImgLevel: 3, serverId: profile, season: 1, }, }); } finally { if (previousSeed === undefined) { delete process.env.INTEGRATION_WORLD_SEED; } else { process.env.INTEGRATION_WORLD_SEED = previousSeed; } } const connector = createGamePostgresConnector({ url: databaseUrl! }); await connector.connect(); db = connector.prisma; closeDb = () => connector.disconnect(); await db.inputEvent.deleteMany(); await db.logEntry.deleteMany(); const worldState = await db.worldState.findFirstOrThrow(); await db.worldState.update({ where: { id: worldState.id }, data: { meta: { ...(worldState.meta as Record), serverId: archiveServerId, }, }, }); await db.oldGeneral.deleteMany({ where: { serverId: archiveServerId, generalNo: generalId } }); await db.inheritanceResult.deleteMany({ where: { serverId: archiveServerId, owner: userId } }); await db.inheritanceLog.deleteMany({ where: { userId } }); await db.inheritancePoint.deleteMany({ where: { userId } }); await db.selectPoolEntry.deleteMany({ where: { uniqueName: '실삭제풀' } }); await db.general.deleteMany({ where: { id: { in: [generalId, memberId] } } }); const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); const availableAt = new Date(Date.now() - 60_000); await db.general.createMany({ data: [ { id: generalId, userId, name: '실삭제장수', nationId: 0, cityId: city.id, troopId: generalId, npcState: 0, leadership: 70, strength: 60, intel: 50, turnTime: new Date('2099-07-31T12:05:00.000Z'), meta: { killturn: 6, prestart_delete_after: availableAt.toISOString(), inheritRandomUnique: true, inheritSpecificSpecialWar: true, }, penalty: {}, }, { id: memberId, userId: null, name: '실삭제부대원', nationId: 0, cityId: city.id, troopId: generalId, npcState: 2, leadership: 50, strength: 50, intel: 50, turnTime: new Date('2099-07-31T12:05:00.000Z'), meta: { killturn: 6 }, penalty: {}, }, ], }); await db.troop.create({ data: { troopLeaderId: generalId, nationId: 0, name: '실삭제부대' }, }); await db.generalAccessLog.create({ data: { generalId, userId, lastRefresh: new Date(availableAt.getTime() - 10 * 60_000) }, }); await db.generalTurn.create({ data: { generalId, turnIdx: 0, actionCode: '휴식', arg: {} }, }); await db.generalTurnRevision.create({ data: { generalId, revision: 1 }, }); await db.rankData.create({ data: { generalId, nationId: 0, type: 'warnum', value: 1 }, }); await db.selectPoolEntry.create({ data: { uniqueName: '실삭제풀', ownerUserId: userId, generalId, reservedUntil: new Date(Date.now() + 3_600_000), info: {}, }, }); await db.inheritancePoint.create({ data: { userId, key: 'previous', value: 100 }, }); runtime = await createTurnDaemonRuntime({ profile, databaseUrl: databaseUrl!, enableDatabaseFlush: true, enableLeaseHeartbeat: false, leaseOwnerId: 'die-on-prestart-live-daemon', }); daemonLoop = runtime.lifecycle.start(); }); test.afterAll(async () => { if (runtime) { await runtime.lifecycle.stop('pre-start deletion live complete'); await daemonLoop; await runtime.close(); } await closeDb?.(); }); test('deletes the owned general and its lifecycle state from the actual UI', async ({ page }, testInfo) => { const dialogs: string[] = []; await installSession(page); await page.route('**/image/game/**', (route) => route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }) ); await page.route('**/gateway/**', (route) => route.fulfill({ status: 200, contentType: 'text/html', body: 'gateway', }) ); page.on('dialog', async (dialog) => { dialogs.push(`${dialog.type()}:${dialog.message()}`); await dialog.accept(); }); await page.setViewportSize({ width: 1000, height: 900 }); await page.goto('my-page'); const actionLine = page.locator('.action-line').filter({ hasText: '가오픈 기간 내 장수 삭제' }); const deleteButton = actionLine.getByRole('button', { name: '장수 삭제' }); await expect(actionLine).toContainText('가오픈 기간 내 장수 삭제'); await expect(deleteButton).toBeVisible(); await expect(deleteButton).toHaveCSS('width', '160px'); await expect(deleteButton).toHaveCSS('height', '30px'); await expect(deleteButton).toHaveCSS('background-color', 'rgb(34, 85, 0)'); await deleteButton.click(); await page.waitForURL(/\/gateway\/$/u); await expect(page.locator('body')).toHaveText('gateway'); expect(dialogs).toEqual(['confirm:정말로 삭제하시겠습니까?']); const event = await db.inputEvent.findFirstOrThrow({ where: { actorUserId: userId, eventType: 'dieOnPrestart' }, orderBy: { createdAt: 'desc' }, }); const requestPrefix = `general:dieOnPrestart:${userId}:`; expect(event.requestId.startsWith(requestPrefix)).toBe(true); const clientRequestId = event.requestId.slice(requestPrefix.length); expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/iu); await expect(db.general.findUnique({ where: { id: generalId } })).resolves.toBeNull(); await expect(db.general.findUniqueOrThrow({ where: { id: memberId } })).resolves.toMatchObject({ troopId: 0, }); await expect(db.troop.findUnique({ where: { troopLeaderId: generalId } })).resolves.toBeNull(); await expect(db.generalAccessLog.findUnique({ where: { generalId } })).resolves.toBeNull(); await expect(db.generalTurn.count({ where: { generalId } })).resolves.toBe(0); await expect(db.generalTurnRevision.findUnique({ where: { generalId } })).resolves.toBeNull(); await expect(db.rankData.count({ where: { generalId } })).resolves.toBe(0); await expect( db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: '실삭제풀' } }) ).resolves.toMatchObject({ ownerUserId: null, generalId: null, reservedUntil: null, }); const archived = await db.oldGeneral.findUniqueOrThrow({ where: { by_no: { serverId: archiveServerId, generalNo: generalId } }, }); const archivedData = archived.data as { troopId?: number; meta?: Record }; expect(archivedData.troopId).toBe(0); expect(archivedData.meta).not.toHaveProperty('inheritRandomUnique'); expect(archivedData.meta).not.toHaveProperty('inheritSpecificSpecialWar'); await expect( db.inheritancePoint.findUniqueOrThrow({ where: { userId_key: { userId, key: 'previous' } } }) ).resolves.toMatchObject({ value: 7_105 }); const inheritanceResult = await db.inheritanceResult.findFirstOrThrow({ where: { serverId: archiveServerId, owner: userId }, }); expect(inheritanceResult).toMatchObject({ generalId }); expect(inheritanceResult.value).toMatchObject({ previous: 100, refund: 7_000, combat: 5, }); expect( ( await db.inheritanceLog.findMany({ where: { userId }, orderBy: { id: 'asc' }, select: { text: true }, }) ).map((entry: { text: string }) => entry.text) ).toEqual([ '사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망으로 전투 특기 지정 4000 포인트 반환', '사망 정산: 7,105 포인트', ]); await expect( db.logEntry.findFirstOrThrow({ where: { scope: 'SYSTEM', category: 'SUMMARY', text: { contains: '실삭제장수가 홀연히 모습을 감추었습니다' }, }, }) ).resolves.toBeDefined(); expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1, actorUserId: userId, }); expect( await page.evaluate(() => ({ gameToken: localStorage.getItem('sammo-game-token'), gameProfile: localStorage.getItem('sammo-game-profile'), })) ).toEqual({ gameToken: null, gameProfile: profile, }); await page.screenshot({ path: testInfo.outputPath('die-on-prestart-live-success.png'), fullPage: true, }); }); });