From 71ec02d0914fc5c111cb29bf9a6a966f8701be21 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 05:44:36 +0000 Subject: [PATCH] feat: port pre-start general deletion lifecycle --- app/game-api/src/router/general/index.ts | 53 ++- app/game-api/src/tournament/worker.ts | 5 +- .../test/createGeneral.integration.test.ts | 8 + .../test/inGameMenuPermissions.test.ts | 69 +++- .../test/selectPool.integration.test.ts | 8 + app/game-engine/src/turn/commandRegistry.ts | 16 + .../turn/generalTurnLifecyclePersistence.ts | 38 ++- app/game-engine/src/turn/inMemoryWorld.ts | 15 + .../src/turn/joinCreateGeneralService.ts | 3 + app/game-engine/src/turn/prestartDeletion.ts | 27 ++ app/game-engine/src/turn/selectPoolService.ts | 116 ++----- .../src/turn/worldCommandHandler.ts | 133 +++++++- app/game-engine/test/dieOnPrestart.test.ts | 260 ++++++++++++++ ...rnLifecyclePersistence.integration.test.ts | 39 +-- .../generalTurnLifecyclePersistence.test.ts | 7 +- .../dieOnPrestart.live.playwright.config.mjs | 78 +++++ .../e2e/dieOnPrestartLive.spec.ts | 321 ++++++++++++++++++ app/game-frontend/e2e/inGameMenus.spec.ts | 148 +++++++- .../e2e/playwright.live.tsconfig.json | 4 +- app/game-frontend/package.json | 1 + app/game-frontend/src/stores/session.ts | 4 + app/game-frontend/src/views/MyPageView.vue | 48 ++- packages/common/src/turnDaemon/types.ts | 10 +- .../test/auctionFlow.test.ts | 6 +- 24 files changed, 1257 insertions(+), 160 deletions(-) create mode 100644 app/game-engine/src/turn/prestartDeletion.ts create mode 100644 app/game-engine/test/dieOnPrestart.test.ts create mode 100644 app/game-frontend/e2e/dieOnPrestart.live.playwright.config.mjs create mode 100644 app/game-frontend/e2e/dieOnPrestartLive.spec.ts diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index f0780f9..9571c52 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -30,7 +30,7 @@ const resolveImmediateActionRequestId = ( contextRequestId: string | undefined, userId: string, clientRequestId: string | undefined, - action: 'buildNationCandidate' | 'instantRetreat' + action: 'buildNationCandidate' | 'dieOnPrestart' | 'instantRetreat' ): string | undefined => { if (clientRequestId) { return `general:${action}:${userId}:${clientRequestId}`; @@ -41,13 +41,19 @@ const resolveImmediateActionRequestId = ( const requestImmediateAction = async ( ctx: GameApiContext, input: { clientRequestId?: string } | undefined, - action: 'buildNationCandidate' | 'instantRetreat' + action: 'buildNationCandidate' | 'dieOnPrestart' | 'instantRetreat' ): Promise<{ ok: true }> => { const userId = ctx.auth?.user.id; if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const general = await getMyGeneral(ctx); + const general = + action === 'dieOnPrestart' + ? await ctx.db.general.findFirst({ where: { userId, npcState: 0 } }) + : await getMyGeneral(ctx); + if (!general) { + throw new TRPCError({ code: 'NOT_FOUND', message: '장수가 없습니다' }); + } const requestId = resolveImmediateActionRequestId(ctx.requestId, userId, input?.clientRequestId, action); try { const result = await ctx.turnDaemon.requestCommand({ @@ -277,20 +283,45 @@ export const generalRouter = router({ penalties, }; }), - dieOnPrestart: authedProcedure.mutation(async ({ ctx }) => { - const general = await getMyGeneral(ctx); + ensureDieOnPrestartStatus: engineAuthedProcedure.mutation(async ({ ctx }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const general = await ctx.db.general.findFirst({ + where: { userId, npcState: 0 }, + select: { id: true }, + }); + if (!general) { + return { show: false, available: false, availableAt: null }; + } const result = await ctx.turnDaemon.requestCommand({ - type: 'dieOnPrestart', + type: 'ensureDieOnPrestartStatus', + ...(ctx.requestId ? { requestId: `${ctx.requestId}:general.ensureDieOnPrestartStatus` } : {}), + userId, generalId: general.id, }); - if (!result || result.type !== 'dieOnPrestart') { - throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); + if (!result) { + throw new TRPCError({ + code: 'TIMEOUT', + message: '삭제 가능 시각을 아직 확인하지 못했습니다. 다시 시도해 주세요.', + }); } - if (!result.ok) { - throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); + if (result.type !== 'ensureDieOnPrestartStatus') { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: '턴 데몬이 올바르지 않은 삭제 상태를 반환했습니다.', + }); } - return { ok: true }; + return { + show: result.show, + available: result.available, + availableAt: result.availableAt ?? null, + }; }), + dieOnPrestart: engineAuthedProcedure + .input(zImmediateActionInput) + .mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'dieOnPrestart')), buildNationCandidate: engineAuthedProcedure .input(zImmediateActionInput) .mutation(({ ctx, input }) => requestImmediateAction(ctx, input, 'buildNationCandidate')), diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 9d65372..5b95e5f 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -475,8 +475,9 @@ export const settleTournamentOutcome = async (options: { if (result.type !== expectedType) { throw new Error(`${expectedType} 명령에 잘못된 응답(${result.type})을 받았습니다.`); } - if (!result.ok) { - throw new Error(`${expectedType} 명령이 실패했습니다: ${result.reason}`); + if (!('ok' in result) || !result.ok) { + const reason = 'reason' in result ? result.reason : '성공 여부가 없는 응답'; + throw new Error(`${expectedType} 명령이 실패했습니다: ${reason}`); } }; diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index 9b05ef0..8fde508 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -274,6 +274,14 @@ integration('generic general creation through the durable turn daemon', () => { killturn: 6, inherit_spent_dyn: 4500, }); + const createdAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: created.id } }); + if (!createdAccess.lastRefresh) { + throw new Error('created general must have an initial access timestamp'); + } + expect( + new Date((created.meta as Record).prestart_delete_after as string).getTime() - + createdAccess.lastRefresh.getTime() + ).toBe(2 * 5 * 60 * 1_000); expect(runtime!.world.getGeneralById(created.id)).toMatchObject({ id: created.id, userId, diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 729265b..abb11a1 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -74,15 +74,15 @@ const auth: GameSessionTokenPayload = { }; const createContext = (options: { - me?: GeneralRow; + me?: GeneralRow | null; targets?: GeneralRow[]; nationMeta?: Record; requestCommand?: ReturnType; }) => { - const me = options.me ?? buildGeneral(); - const targets = options.targets ?? [me]; + const me = options.me === undefined ? buildGeneral() : options.me; + const targets = options.targets ?? (me ? [me] : []); const requestCommand = - options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me.id })); + options.requestCommand ?? vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: me?.id ?? 0 })); const generalFindUnique = vi.fn( async ({ where }: { where: { id: number } }) => targets.find((general) => general.id === where.id) ?? null ); @@ -90,7 +90,7 @@ const createContext = (options: { general: { findFirst: vi.fn(async () => me), findUnique: generalFindUnique, - findMany: vi.fn(async () => targets.filter((general) => general.nationId === me.nationId)), + findMany: vi.fn(async () => targets.filter((general) => general.nationId === (me?.nationId ?? 0))), update: vi.fn(), }, city: { findUnique: vi.fn(async () => null) }, @@ -232,6 +232,7 @@ describe('in-game my information ownership', () => { }); it.each([ + ['dieOnPrestart', 'dieOnPrestart'], ['buildNationCandidate', 'buildNationCandidate'], ['instantRetreat', 'instantRetreat'], ] as const)('dispatches %s only for the session-owned general', async (procedure, commandType) => { @@ -253,6 +254,32 @@ describe('in-game my information ownership', () => { }); }); + it('gets the server-owned pre-start deletion status without accepting a general id', async () => { + const requestCommand = vi.fn(async () => ({ + type: 'ensureDieOnPrestartStatus' as const, + generalId: 7, + show: true, + available: false, + availableAt: '2026-01-01T00:20:00.000Z', + })); + const fixture = createContext({ requestCommand }); + + await expect(appRouter.createCaller(fixture.context).general.ensureDieOnPrestartStatus()).resolves.toEqual({ + show: true, + available: false, + availableAt: '2026-01-01T00:20:00.000Z', + }); + expect(requestCommand).toHaveBeenCalledWith({ + type: 'ensureDieOnPrestartStatus', + userId: 'user-7', + generalId: 7, + }); + expect(fixture.db.general.findFirst).toHaveBeenCalledWith({ + where: { userId: 'user-7', npcState: 0 }, + select: { id: true }, + }); + }); + it('returns the daemon compatibility failure without performing an API-side mutation', async () => { const requestCommand = vi.fn(async () => ({ type: 'instantRetreat' as const, @@ -287,6 +314,38 @@ describe('in-game my information ownership', () => { generalId: 7, }); }); + + it('keeps the die-on-prestart request identity when its destructive result times out', async () => { + const requestCommand = vi.fn(async () => null); + const fixture = createContext({ requestCommand }); + const caller = appRouter.createCaller(fixture.context); + const clientRequestId = '33333333-3333-4333-8333-333333333333'; + + await expect(caller.general.dieOnPrestart({ clientRequestId })).rejects.toMatchObject({ + code: 'TIMEOUT', + message: expect.stringContaining('같은 요청으로 다시 시도'), + }); + expect(requestCommand).toHaveBeenCalledWith({ + type: 'dieOnPrestart', + requestId: `general:dieOnPrestart:user-7:${clientRequestId}`, + userId: 'user-7', + generalId: 7, + }); + }); + + it('rejects deletion with the legacy no-general message before dispatching a daemon command', async () => { + const requestCommand = vi.fn(); + const fixture = createContext({ me: null, requestCommand }); + + await expect(appRouter.createCaller(fixture.context).general.dieOnPrestart()).rejects.toMatchObject({ + code: 'NOT_FOUND', + message: '장수가 없습니다', + }); + expect(fixture.db.general.findFirst).toHaveBeenCalledWith({ + where: { userId: 'user-7', npcState: 0 }, + }); + expect(requestCommand).not.toHaveBeenCalled(); + }); }); describe('battle-center general and user permissions', () => { diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index fe0a43e..8f65abe 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -209,6 +209,14 @@ integration('scenario 903 select pool through the durable turn daemon', () => { const initial = await db.general.findFirstOrThrow({ where: { userId } }); const initialRuntime = runtime!.world.getGeneralById(initial.id); + const initialAccess = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: initial.id } }); + if (!initialAccess.lastRefresh) { + throw new Error('selected general must have an initial access timestamp'); + } + expect( + new Date((initial.meta as Record).prestart_delete_after as string).getTime() - + initialAccess.lastRefresh.getTime() + ).toBe(2 * 5 * 60 * 1_000); expect(initialRuntime).toMatchObject({ id: initial.id, userId, diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 90a2df4..f86f0ad 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -88,6 +88,13 @@ const zTroopRename = z.object({ const zDieOnPrestart = z.object({ type: z.literal('dieOnPrestart'), + userId: z.string().min(1), + generalId: zFiniteNumber, +}); + +const zEnsureDieOnPrestartStatus = z.object({ + type: z.literal('ensureDieOnPrestartStatus'), + userId: z.string().min(1), generalId: zFiniteNumber, }); @@ -415,6 +422,14 @@ const normalizeDieOnPrestart: CommandNormalizer<'dieOnPrestart'> = (envelope) => return { ...command, requestId: envelope.requestId }; }; +const normalizeEnsureDieOnPrestartStatus: CommandNormalizer<'ensureDieOnPrestartStatus'> = (envelope) => { + const command = parseWith(zEnsureDieOnPrestartStatus, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeBuildNationCandidate: CommandNormalizer<'buildNationCandidate'> = (envelope) => { const command = parseWith(zBuildNationCandidate, envelope.command); if (!command) { @@ -629,6 +644,7 @@ const normalizers: CommandNormalizerMap = { troopKick: normalizeTroopKick, troopRename: normalizeTroopRename, dieOnPrestart: normalizeDieOnPrestart, + ensureDieOnPrestartStatus: normalizeEnsureDieOnPrestartStatus, buildNationCandidate: normalizeBuildNationCandidate, instantRetreat: normalizeInstantRetreat, vacation: normalizeVacation, diff --git a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts index 53cbbe6..4842d68 100644 --- a/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts +++ b/app/game-engine/src/turn/generalTurnLifecyclePersistence.ts @@ -74,13 +74,13 @@ const settleInheritance = async ( const ranks = new Map(rankRows.map((row) => [row.type, row.value])); const rank = (key: string): number => ranks.get(key) ?? readNumber(meta, `rank_${key}`); const previous = points.get('previous') ?? 0; - const refund = - (meta.inheritRandomUnique - ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) - : 0) + - (meta.inheritSpecificSpecialWar - ? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000) - : 0); + const randomUniqueRefund = meta.inheritRandomUnique + ? readWorldNumber(configConst, 'inheritItemRandomPoint', 3000) + : 0; + const specificSpecialRefund = meta.inheritSpecificSpecialWar + ? readWorldNumber(configConst, 'inheritSpecificSpecialPoint', 4000) + : 0; + const refund = randomUniqueRefund + specificSpecialRefund; const lived = readNumber(meta, 'inherit_lived_month'); const maxBelong = readNumber(meta, 'inherit_max_belong') * 10; const maxDomestic = readNumber(meta, 'max_domestic_critical'); @@ -129,6 +129,19 @@ const settleInheritance = async ( }), }, }); + for (const text of [ + ...(randomUniqueRefund > 0 ? [`사망으로 랜덤 유니크 구입 ${randomUniqueRefund} 포인트 반환`] : []), + ...(specificSpecialRefund > 0 ? [`사망으로 전투 특기 지정 ${specificSpecialRefund} 포인트 반환`] : []), + ]) { + await prisma.inheritanceLog.create({ + data: { + userId, + year: event.year, + month: event.month, + text, + }, + }); + } await prisma.inheritanceLog.create({ data: { userId, @@ -139,8 +152,7 @@ const settleInheritance = async ( }); }; -const computeRate = (numerator: number, denominator: number): number => - denominator > 0 ? numerator / denominator : 0; +const computeRate = (numerator: number, denominator: number): number => (denominator > 0 ? numerator / denominator : 0); const settleHall = async ( prisma: GamePrisma.TransactionClient, @@ -186,7 +198,9 @@ const settleHall = async ( const season = readWorldNumber(worldMeta, 'season', 1); const scenario = readWorldNumber(worldMeta, 'scenarioId', 0); const scenarioName = - typeof asRecord(worldMeta.scenarioMeta).title === 'string' ? String(asRecord(worldMeta.scenarioMeta).title) : ''; + typeof asRecord(worldMeta.scenarioMeta).title === 'string' + ? String(asRecord(worldMeta.scenarioMeta).title) + : ''; const aux = { name: event.before.name, nationName: nation?.name ?? '재야', @@ -270,8 +284,12 @@ const archiveDeletedGeneral = async ( orderBy: { id: 'desc' }, select: { text: true }, }); + const archivedMeta = { ...asRecord(event.before.meta) }; + delete archivedMeta.inheritRandomUnique; + delete archivedMeta.inheritSpecificSpecialWar; const data = { ...event.before, + meta: archivedMeta, turnTime: event.before.turnTime.toISOString(), recentWarTime: event.before.recentWarTime?.toISOString() ?? null, history: history.map((entry) => entry.text), diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index cbf7747..af086fa 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -699,6 +699,21 @@ export class InMemoryTurnWorld { return true; } + deleteGeneralWithLifecycle(id: number, year: number, month: number): boolean { + const general = this.generals.get(id); + if (!general) { + return false; + } + this.lifecycleEvents.push({ + generalId: id, + outcome: 'deleted', + before: structuredClone(general), + year, + month, + }); + return this.removeGeneral(id); + } + updateCity(id: number, patch: Partial): City | null { const target = this.cities.get(id); if (!target) { diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index 3e6183e..53dfaaf 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -20,6 +20,7 @@ import type { WarTraitKey } from '@sammo-ts/logic'; import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import { buildPrestartDeleteAfter } from './prestartDeletion.js'; import type { TurnGeneral } from './types.js'; type WorldStateRow = GamePrismaTypes.WorldStateGetPayload>; @@ -702,6 +703,7 @@ export const createGeneralFromJoin = async (options: { const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint; const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId); const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus; + const prestartDeleteAfter = buildPrestartDeleteAfter(acceptedAt, worldState.tickSeconds, config); const general: TurnGeneral = { id: generalId, userId: input.userId, @@ -778,6 +780,7 @@ export const createGeneralFromJoin = async (options: { tournament: 0, newvote: 0, inherit_spent_dyn: inheritRequiredPoint, + prestart_delete_after: prestartDeleteAfter.toISOString(), }, }; if (!world.addGeneral(general)) { diff --git a/app/game-engine/src/turn/prestartDeletion.ts b/app/game-engine/src/turn/prestartDeletion.ts new file mode 100644 index 0000000..2e1bf01 --- /dev/null +++ b/app/game-engine/src/turn/prestartDeletion.ts @@ -0,0 +1,27 @@ +import { asNumber, asRecord } from '@sammo-ts/common'; + +const DEFAULT_MIN_TURNS = 2; + +export const readPrestartDeleteAfter = (meta: Record): Date | null => { + const value = meta.prestart_delete_after; + if (typeof value !== 'string' || !value.trim()) { + return null; + } + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +export const buildPrestartDeleteAfter = (base: Date, tickSeconds: number, config: Record): Date => { + const configConst = asRecord(config.const); + const minTurns = Math.max(0, Math.floor(asNumber(configConst.minTurnDieOnPrestart, DEFAULT_MIN_TURNS))); + return new Date(base.getTime() + Math.max(1, Math.floor(tickSeconds)) * minTurns * 1_000); +}; + +export const formatPrestartDeleteAfter = (value: Date): string => { + const seoul = new Date(value.getTime() + 9 * 60 * 60 * 1_000); + const pad = (part: number): string => String(part).padStart(2, '0'); + return [ + `${seoul.getUTCFullYear()}-${pad(seoul.getUTCMonth() + 1)}-${pad(seoul.getUTCDate())}`, + `${pad(seoul.getUTCHours())}:${pad(seoul.getUTCMinutes())}:${pad(seoul.getUTCSeconds())}`, + ].join(' '); +}; diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts index a614c43..bf30fc1 100644 --- a/app/game-engine/src/turn/selectPoolService.ts +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -1,12 +1,6 @@ import { z } from 'zod'; -import { - asNumber, - asRecord, - JosaUtil, - LiteHashDRBG, - RandUtil, -} from '@sammo-ts/common'; +import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { GamePrisma, LogCategory, LogScope } from '@sammo-ts/infra'; import { EventDomesticTraitLoader, @@ -18,15 +12,12 @@ import { import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import { buildPrestartDeleteAfter } from './prestartDeletion.js'; import type { TurnGeneral } from './types.js'; type WorldStateRow = GamePrismaTypes.WorldStateGetPayload>; -export type SelectPoolErrorCode = - | 'BAD_REQUEST' - | 'PRECONDITION_FAILED' - | 'CONFLICT' - | 'INTERNAL_SERVER_ERROR'; +export type SelectPoolErrorCode = 'BAD_REQUEST' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR'; export class SelectPoolError extends Error { constructor( @@ -98,10 +89,7 @@ export interface SelectPoolReservationDto { candidates: SelectPoolCandidateDto[]; } -const fail = ( - code: SelectPoolErrorCode, - message: string -): never => { +const fail = (code: SelectPoolErrorCode, message: string): never => { throw new SelectPoolError(code, message); }; @@ -135,12 +123,7 @@ export const resolveSelectionMaxGeneral = (worldState: WorldStateRow): number => return Math.max( 0, Math.floor( - asNumber( - config.maxGeneral ?? - configConst.defaultMaxGeneral ?? - configConst.maxGeneral, - DEFAULT_MAX_GENERAL - ) + asNumber(config.maxGeneral ?? configConst.defaultMaxGeneral ?? configConst.maxGeneral, DEFAULT_MAX_GENERAL) ) ); }; @@ -174,9 +157,7 @@ const candidateWeight = (candidate: SelectPoolCandidateInfo): number => const eventDomesticTraitLoader = new EventDomesticTraitLoader(); -const toCandidateDto = async ( - candidate: SelectPoolCandidateInfo -): Promise => { +const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise => { const trait = isEventDomesticTraitKey(candidate.specialDomestic) ? await eventDomesticTraitLoader.load(candidate.specialDomestic) : null; @@ -187,8 +168,7 @@ const toCandidateDto = async ( strength: candidate.strength, intel: candidate.intel, specialDomestic: candidate.specialDomestic, - specialDomesticName: - trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''), + specialDomesticName: trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''), specialDomesticInfo: trait?.info ?? '', specialWar: candidate.specialWar ?? null, ego: candidate.ego ?? null, @@ -204,18 +184,12 @@ const toReservationDto = ( ): Promise => { const validUntil = rows[0]?.reservedUntil; if (!validUntil) { - throw new SelectPoolError( - 'INTERNAL_SERVER_ERROR', - '장수 선택 후보의 유효기간이 없습니다.' - ); + throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.'); } const expiresAt = validUntil; const sorted = rows .map((row) => ({ id: row.id, info: parseCandidate(row) })) - .sort( - (left, right) => - candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id - ); + .sort((left, right) => candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id); return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({ poolName: SUPPORTED_POOL, hasGeneral, @@ -229,16 +203,11 @@ const formatLegacySeedTime = (value: Date): string => { const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS); return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad( koreaTime.getUTCDate() - )} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad( - koreaTime.getUTCSeconds() - )}`; + )} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`; }; -export const buildSelectPoolSeed = ( - hiddenSeed: string | number, - ownerIdentity: string | number, - now: Date -): string => simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now)); +export const buildSelectPoolSeed = (hiddenSeed: string | number, ownerIdentity: string | number, now: Date): string => + simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now)); export const claimWeightedSelectionCandidates = async (options: { weighted: [T, number][]; @@ -365,13 +334,7 @@ export const reserveSelectionPool = async (options: { } const rng = new RandUtil( - new LiteHashDRBG( - buildSelectPoolSeed( - getWorldHiddenSeed(worldState), - options.seedOwnerIdentity ?? userId, - now - ) - ) + new LiteHashDRBG(buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, now)) ); const weighted = available.map((row) => [row, candidateWeight(parseCandidate(row))] as [SelectPoolRow, number]); const reservedUntil = new Date( @@ -398,10 +361,10 @@ export const reserveSelectionPool = async (options: { }, }); const reserved = selected.map((candidate) => ({ - ...candidate, - ownerUserId: userId, - reservedUntil, - })); + ...candidate, + ownerUserId: userId, + reservedUntil, + })); if (reserved.length !== RESERVATION_COUNT) { fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.'); } @@ -413,10 +376,7 @@ const lockSelectionMutationTables = async (db: DatabaseClient): Promise => await db.$executeRaw(GamePrisma.sql`LOCK TABLE "select_pool" IN SHARE ROW EXCLUSIVE MODE`); }; -const assertGeneralIdSnapshotMatches = async ( - db: DatabaseClient, - world: InMemoryTurnWorld -): Promise => { +const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemoryTurnWorld): Promise => { const persistedIds = ( await db.general.findMany({ select: { id: true }, @@ -427,13 +387,8 @@ const assertGeneralIdSnapshotMatches = async ( .listGenerals() .map(({ id }) => id) .sort((left, right) => left - right); - if ( - persistedIds.length !== runtimeIds.length || - persistedIds.some((id, index) => id !== runtimeIds[index]) - ) { - throw new Error( - 'DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.' - ); + if (persistedIds.length !== runtimeIds.length || persistedIds.some((id, index) => id !== runtimeIds[index])) { + throw new Error('DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.'); } }; @@ -468,12 +423,7 @@ const resolveRandomPersonality = ( ): string => new RandUtil( new LiteHashDRBG( - simpleSerialize( - getWorldHiddenSeed(worldState), - 'selectPickedGeneralPersonality', - ownerIdentity, - uniqueName - ) + simpleSerialize(getWorldHiddenSeed(worldState), 'selectPickedGeneralPersonality', ownerIdentity, uniqueName) ) ).choice([...PERSONALITY_TRAIT_KEYS]); @@ -495,19 +445,10 @@ const resolveSelectedPersonality = ( return requested; }; -const resolvePoolRng = ( - worldState: WorldStateRow, - ownerIdentity: string | number, - uniqueName: string -): RandUtil => +const resolvePoolRng = (worldState: WorldStateRow, ownerIdentity: string | number, uniqueName: string): RandUtil => new RandUtil( new LiteHashDRBG( - simpleSerialize( - getWorldHiddenSeed(worldState), - 'selectPickedGeneral', - ownerIdentity, - uniqueName - ) + simpleSerialize(getWorldHiddenSeed(worldState), 'selectPickedGeneral', ownerIdentity, uniqueName) ) ); @@ -612,16 +553,12 @@ export const createGeneralFromSelectionPool = async (options: { const nextChangeAt = new Date( now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000 ); + const prestartDeleteAfter = buildPrestartDeleteAfter(now, worldState.tickSeconds, config); const showImgLevel = asNumber(config.showImgLevel, 0); const picture = showImgLevel >= 3 ? info.picture : 'default.jpg'; const defaultSpecialWar = typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None'; - const personality = resolveSelectedPersonality( - worldState, - seedOwnerIdentity, - uniqueName, - options.personality - ); + const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality); // 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다. // SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저 // getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다. @@ -692,6 +629,7 @@ export const createGeneralFromSelectionPool = async (options: { dex5: info.dex[4], next_change: nextChangeAt.toISOString(), nextChangeAt: nextChangeAt.toISOString(), + prestart_delete_after: prestartDeleteAfter.toISOString(), npc_org: 0, }, }; @@ -884,6 +822,6 @@ export const getSelectionPoolStatus = async ( poolName, allowOptions: resolvePoolAllowOptions(worldState), hasGeneral: Boolean(general), - nextChangeAt: general ? readNextChangeAt(general.meta)?.toISOString() ?? null : null, + nextChangeAt: general ? (readNextChangeAt(general.meta)?.toISOString() ?? null) : null, }; }; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 8770f6e..687ae44 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -52,6 +52,7 @@ import { } from './selectPoolService.js'; import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js'; import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js'; +import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js'; let itemRegistryPromise: Promise> | null = null; @@ -141,7 +142,15 @@ const resolveCommandAcceptedAt = async ( db: DatabaseClient, command: Extract< TurnDaemonCommand, - { type: 'joinCreateGeneral' | 'npcPossessGeneral' | 'selectPoolCreate' | 'selectPoolReselect' } + { + type: + | 'dieOnPrestart' + | 'ensureDieOnPrestartStatus' + | 'joinCreateGeneral' + | 'npcPossessGeneral' + | 'selectPoolCreate' + | 'selectPoolReselect'; + } > ): Promise => { if (!command.requestId) { @@ -1029,31 +1038,136 @@ async function handleTroopRename( }; } +const ensurePrestartDeleteAfter = async ( + ctx: CommandHandlerContext, + command: Extract, + general: TurnGeneral, + acceptedAt: Date +): Promise => { + const current = readPrestartDeleteAfter(asRecord(general.meta)); + if (current) { + return current; + } + const db = requireCommandDatabase(ctx); + const access = await db.generalAccessLog.findUnique({ + where: { generalId: general.id }, + select: { lastRefresh: true }, + }); + const deleteAfter = buildPrestartDeleteAfter( + access?.lastRefresh ?? acceptedAt, + ctx.world.getState().tickSeconds, + asRecord(ctx.world.getScenarioConfig()) + ); + const updated = ctx.world.updateGeneral(general.id, { + meta: { + ...general.meta, + prestart_delete_after: deleteAfter.toISOString(), + }, + }); + if (!updated) { + throw new Error(`${command.type} general disappeared while fixing the pre-start deletion time.`); + } + return deleteAfter; +}; + +async function handleEnsureDieOnPrestartStatus( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const general = ctx.world.getGeneralById(command.generalId); + if (!general || general.npcState !== 0 || general.userId !== command.userId) { + return { + type: 'ensureDieOnPrestartStatus', + generalId: command.generalId, + show: false, + available: false, + }; + } + const db = requireCommandDatabase(ctx); + const acceptedAt = await resolveCommandAcceptedAt(db, command); + const worldState = ctx.world.getState(); + const opentime = typeof worldState.meta.opentime === 'string' ? worldState.meta.opentime : null; + if ((opentime && worldState.lastTurnTime.getTime() > new Date(opentime).getTime()) || general.nationId !== 0) { + return { + type: 'ensureDieOnPrestartStatus', + generalId: command.generalId, + show: false, + available: false, + }; + } + const availableAt = await ensurePrestartDeleteAfter(ctx, command, general, acceptedAt); + return { + type: 'ensureDieOnPrestartStatus', + generalId: command.generalId, + show: true, + available: availableAt.getTime() <= acceptedAt.getTime(), + availableAt: availableAt.toISOString(), + }; +} + async function handleDieOnPrestart( ctx: CommandHandlerContext, command: Extract ): Promise { const { world } = ctx; + const db = requireCommandDatabase(ctx); const general = world.getGeneralById(command.generalId); - if (!general) { + if (!general || general.npcState !== 0 || general.userId !== command.userId) { return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, - reason: '장수 정보를 찾을 수 없습니다.', + reason: '장수가 없습니다', }; } + const acceptedAt = await resolveCommandAcceptedAt(db, command); const worldState = world.getState(); const opentime = worldState.meta.opentime as string | undefined; if (opentime && new Date(worldState.lastTurnTime) > new Date(opentime)) { - return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '가오픈 기간이 아닙니다.' }; + return { + type: 'dieOnPrestart', + ok: false, + generalId: command.generalId, + reason: '게임이 시작되었습니다.', + }; } - if (general.npcState !== 0 || general.nationId !== 0) { - return { type: 'dieOnPrestart', ok: false, generalId: command.generalId, reason: '삭제할 수 없는 상태입니다.' }; + if (general.nationId !== 0) { + return { + type: 'dieOnPrestart', + ok: false, + generalId: command.generalId, + reason: '이미 국가에 소속되어있습니다.', + }; } - world.removeGeneral(command.generalId); + const deleteAfter = await ensurePrestartDeleteAfter(ctx, command, general, acceptedAt); + if (deleteAfter.getTime() > acceptedAt.getTime()) { + return { + type: 'dieOnPrestart', + ok: false, + generalId: command.generalId, + reason: `아직 삭제할 수 없습니다. ${formatPrestartDeleteAfter(deleteAfter)} 부터 가능합니다.`, + }; + } + + if (general.troopId === general.id) { + for (const member of world.listGenerals()) { + if (member.troopId === general.id) { + world.updateGeneral(member.id, { troopId: 0 }); + } + } + world.removeTroop(general.id); + } + const josaYi = JosaUtil.pick(general.name, '이'); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + text: `${general.name}${josaYi} 홀연히 모습을 감추었습니다`, + meta: {}, + }); + world.deleteGeneralWithLifecycle(command.generalId, worldState.currentYear, worldState.currentMonth); return { type: 'dieOnPrestart', ok: true, generalId: command.generalId }; } @@ -2135,6 +2249,11 @@ export const createTurnDaemonCommandHandler = (options: { troopKick: (command) => handleTroopKick(ctx, command as Extract), troopRename: (command) => handleTroopRename(ctx, command as Extract), + ensureDieOnPrestartStatus: (command) => + handleEnsureDieOnPrestartStatus( + ctx, + command as Extract + ), dieOnPrestart: (command) => handleDieOnPrestart(ctx, command as Extract), buildNationCandidate: (command) => diff --git a/app/game-engine/test/dieOnPrestart.test.ts b/app/game-engine/test/dieOnPrestart.test.ts new file mode 100644 index 0000000..54a5d5d --- /dev/null +++ b/app/game-engine/test/dieOnPrestart.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GamePrisma } from '@sammo-ts/infra'; +import type { TurnSchedule } from '@sammo-ts/logic'; + +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { buildPrestartDeleteAfter, formatPrestartDeleteAfter } from '../src/turn/prestartDeletion.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; +const acceptedAt = new Date('2026-07-31T00:00:00.000Z'); + +const buildGeneral = (overrides: Partial = {}): TurnGeneral => ({ + id: 7, + userId: 'owner-7', + name: '테스트장수', + nationId: 0, + cityId: 1, + troopId: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + turnTime: new Date('0185-01-01T00:00:00.000Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + penalty: {}, + officerLevel: 0, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + age: 20, + npcState: 0, + ...overrides, + meta: { killturn: 6, ...overrides.meta }, +}); + +const buildFixture = (options: { + generals?: TurnGeneral[]; + minTurns?: number; + lastTurnTime?: Date; + troops?: TurnWorldSnapshot['troops']; + lastRefresh?: Date | null; + eventActor?: string; +}) => { + const state: TurnWorldState = { + id: 1, + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: options.lastTurnTime ?? new Date('2026-07-30T00:00:00.000Z'), + meta: { opentime: '2026-08-01T00:00:00.000Z' }, + }; + const snapshot: TurnWorldSnapshot = { + generals: options.generals ?? [buildGeneral()], + cities: [], + nations: [], + troops: options.troops ?? [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 100, npcTotal: 150, npcMax: 50, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: { + ...(options.minTurns === undefined ? {} : { minTurnDieOnPrestart: options.minTurns }), + }, + environment: { mapName: 'test', unitSet: 'test' }, + }, + scenarioMeta: { + title: 'test', + startYear: 180, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, + }; + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const inputEvent = { + findUnique: vi.fn(async ({ where }: { where: { requestId: string } }) => ({ + createdAt: acceptedAt, + actorUserId: options.eventActor ?? 'owner-7', + target: 'ENGINE', + eventType: where.requestId.startsWith('status-') ? 'ensureDieOnPrestartStatus' : 'dieOnPrestart', + })), + }; + const generalAccessLog = { + findUnique: vi.fn(async () => + options.lastRefresh === null ? null : { lastRefresh: options.lastRefresh ?? acceptedAt } + ), + }; + const db = { inputEvent, generalAccessLog } as unknown as GamePrisma.TransactionClient; + return { + world, + db, + handler: createTurnDaemonCommandHandler({ world }), + inputEvent, + generalAccessLog, + }; +}; + +describe('pre-start general deletion', () => { + it('uses the default two turns, scenario override, and Ref Seoul error timestamp', () => { + expect(buildPrestartDeleteAfter(acceptedAt, 600, { const: {} }).toISOString()).toBe('2026-07-31T00:20:00.000Z'); + expect( + buildPrestartDeleteAfter(acceptedAt, 600, { + const: { minTurnDieOnPrestart: 1 }, + }).toISOString() + ).toBe('2026-07-31T00:10:00.000Z'); + expect(formatPrestartDeleteAfter(new Date('2026-07-31T00:20:00.000Z'))).toBe('2026-07-31 09:20:00'); + }); + + it('fixes a legacy missing cutoff once from lastRefresh and returns it on later status requests', async () => { + const lastRefresh = new Date('2026-07-30T23:55:00.000Z'); + const fixture = buildFixture({ lastRefresh }); + const first = await fixture.handler.handle( + { + type: 'ensureDieOnPrestartStatus', + requestId: 'status-first', + userId: 'owner-7', + generalId: 7, + }, + { db: fixture.db } + ); + expect(first).toEqual({ + type: 'ensureDieOnPrestartStatus', + generalId: 7, + show: true, + available: false, + availableAt: '2026-07-31T00:15:00.000Z', + }); + expect(fixture.world.getGeneralById(7)?.meta.prestart_delete_after).toBe('2026-07-31T00:15:00.000Z'); + + const second = await fixture.handler.handle( + { + type: 'ensureDieOnPrestartStatus', + requestId: 'status-second', + userId: 'owner-7', + generalId: 7, + }, + { db: fixture.db } + ); + expect(second).toEqual(first); + expect(fixture.generalAccessLog.findUnique).toHaveBeenCalledTimes(1); + }); + + it('preserves the Ref error order and validates the durable event actor', async () => { + const started = buildFixture({ + generals: [buildGeneral({ nationId: 1 })], + lastTurnTime: new Date('2026-08-02T00:00:00.000Z'), + }); + await expect( + started.handler.handle( + { type: 'dieOnPrestart', requestId: 'die-started', userId: 'owner-7', generalId: 7 }, + { db: started.db } + ) + ).resolves.toMatchObject({ ok: false, reason: '게임이 시작되었습니다.' }); + + const nation = buildFixture({ generals: [buildGeneral({ nationId: 1 })] }); + await expect( + nation.handler.handle( + { type: 'dieOnPrestart', requestId: 'die-nation', userId: 'owner-7', generalId: 7 }, + { db: nation.db } + ) + ).resolves.toMatchObject({ ok: false, reason: '이미 국가에 소속되어있습니다.' }); + + const actorMismatch = buildFixture({ eventActor: 'foreign-user' }); + await expect( + actorMismatch.handler.handle( + { type: 'dieOnPrestart', requestId: 'die-actor', userId: 'owner-7', generalId: 7 }, + { db: actorMismatch.db } + ) + ).rejects.toThrow('input event actor does not match dieOnPrestart user'); + + const ownerMismatch = buildFixture({}); + await expect( + ownerMismatch.handler.handle( + { type: 'dieOnPrestart', requestId: 'die-owner', userId: 'foreign-user', generalId: 7 }, + { db: ownerMismatch.db } + ) + ).resolves.toMatchObject({ ok: false, reason: '장수가 없습니다' }); + }); + + it('allows the equality boundary and queues troop cleanup, lifecycle, and exact global log together', async () => { + const leader = buildGeneral({ + troopId: 7, + meta: { killturn: 3, prestart_delete_after: acceptedAt.toISOString() }, + }); + const member = buildGeneral({ id: 8, userId: 'owner-8', name: '부대원', troopId: 7 }); + const fixture = buildFixture({ + generals: [leader, member], + troops: [{ id: 7, nationId: 0, name: '테스트부대' }], + }); + await expect( + fixture.handler.handle( + { type: 'dieOnPrestart', requestId: 'die-success', userId: 'owner-7', generalId: 7 }, + { db: fixture.db } + ) + ).resolves.toEqual({ type: 'dieOnPrestart', ok: true, generalId: 7 }); + + expect(fixture.world.getGeneralById(7)).toBeNull(); + expect(fixture.world.getGeneralById(8)?.troopId).toBe(0); + const changes = fixture.world.peekDirtyState(); + expect(changes.deletedTroops).toEqual([7]); + expect(changes.deletedGenerals).toEqual([7]); + expect(changes.lifecycleEvents).toEqual([ + expect.objectContaining({ + generalId: 7, + outcome: 'deleted', + before: expect.objectContaining({ troopId: 0 }), + year: 185, + month: 1, + }), + ]); + expect(changes.logs).toEqual([ + expect.objectContaining({ + scope: 'SYSTEM', + category: 'SUMMARY', + text: '테스트장수가 홀연히 모습을 감추었습니다', + }), + ]); + }); + + it('persists a legacy cutoff on early failure without queuing deletion or a log', async () => { + const fixture = buildFixture({ lastRefresh: acceptedAt, minTurns: 1 }); + await expect( + fixture.handler.handle( + { type: 'dieOnPrestart', requestId: 'die-early', userId: 'owner-7', generalId: 7 }, + { db: fixture.db } + ) + ).resolves.toMatchObject({ + ok: false, + reason: '아직 삭제할 수 없습니다. 2026-07-31 09:10:00 부터 가능합니다.', + }); + expect(fixture.world.getGeneralById(7)?.meta.prestart_delete_after).toBe('2026-07-31T00:10:00.000Z'); + expect(fixture.world.peekDirtyState()).toMatchObject({ + deletedGenerals: [], + lifecycleEvents: [], + logs: [], + }); + }); +}); diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts index 75d8dfb..3e0cd34 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.integration.test.ts @@ -10,12 +10,8 @@ import type { TurnGeneral } from '../src/turn/types.js'; const databaseUrl = process.env.GENERAL_LIFECYCLE_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const generalIds = [990_001, 990_002, 990_003]; -const userIds = [ - 'integration-lifecycle-dead', - 'integration-lifecycle-retired', - 'integration-lifecycle-possessed', -]; -const serverId = 'integration-lifecycle'; +const userIds = ['integration-lifecycle-dead', 'integration-lifecycle-retired', 'integration-lifecycle-possessed']; +const serverId = 'lifecycle-int'; const makeGeneral = (id: number, userId: string, patch: Partial = {}): TurnGeneral => ({ id, @@ -57,10 +53,7 @@ const makeGeneral = (id: number, userId: string, patch: Partial = { ...patch, }); -const event = ( - general: TurnGeneral, - outcome: GeneralLifecycleEvent['outcome'] -): GeneralLifecycleEvent => ({ +const event = (general: TurnGeneral, outcome: GeneralLifecycleEvent['outcome']): GeneralLifecycleEvent => ({ generalId: general.id, outcome, before: general, @@ -153,12 +146,24 @@ integration('general turn lifecycle persistence', () => { const archived = await db.oldGeneral.findUniqueOrThrow({ where: { by_no: { serverId, generalNo: general.id } }, }); - expect(asRecord(archived.data).history).toEqual(['●둘째 기록', '●첫 기록']); + const archivedData = asRecord(archived.data); + expect(archivedData.history).toEqual(['●둘째 기록', '●첫 기록']); + expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritRandomUnique'); + expect(asRecord(archivedData.meta)).not.toHaveProperty('inheritSpecificSpecialWar'); expect( await db.inheritancePoint.findUnique({ where: { userId_key: { userId: general.userId!, key: 'previous' } }, }) ).toMatchObject({ value: 3_147 }); + expect( + ( + await db.inheritanceLog.findMany({ + where: { userId: general.userId! }, + orderBy: { id: 'asc' }, + select: { text: true }, + }) + ).map(({ text }) => text) + ).toEqual(['사망으로 랜덤 유니크 구입 3000 포인트 반환', '사망 정산: 3,147 포인트']); }); it('resets access/ranks and records pre-rebirth hall and inheritance values', async () => { @@ -185,8 +190,9 @@ integration('general turn lifecycle persistence', () => { expect(await db.generalAccessLog.findUnique({ where: { generalId: general.id } })).toMatchObject({ refreshScore: 0, }); - expect(await db.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } })) - .toMatchObject({ value: 0 }); + expect( + await db.rankData.findUnique({ where: { generalId_type: { generalId: general.id, type: 'warnum' } } }) + ).toMatchObject({ value: 0 }); expect( await db.hallOfFame.findUnique({ where: { @@ -220,12 +226,7 @@ integration('general turn lifecycle persistence', () => { }); await db.$transaction((tx) => - persistGeneralLifecycleEvents( - tx, - [event(general, 'deleted')], - { serverId, startYear: 180 }, - {} - ) + persistGeneralLifecycleEvents(tx, [event(general, 'deleted')], { serverId, startYear: 180 }, {}) ); expect( diff --git a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts index a88bb46..b46f996 100644 --- a/app/game-engine/test/generalTurnLifecyclePersistence.test.ts +++ b/app/game-engine/test/generalTurnLifecyclePersistence.test.ts @@ -37,7 +37,11 @@ const archivedGeneral = (): TurnGeneral => ({ deadYear: 240, affinity: 50, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, - meta: { killturn: 0 }, + meta: { + killturn: 0, + inheritRandomUnique: true, + inheritSpecificSpecialWar: true, + }, turnTime: new Date('0200-01-01T00:00:00.000Z'), }); @@ -79,6 +83,7 @@ describe('general lifecycle archive history', () => { create: expect.objectContaining({ data: expect.objectContaining({ history: ['●둘째 기록', '●첫 기록'], + meta: { killturn: 0 }, }), }), }) diff --git a/app/game-frontend/e2e/dieOnPrestart.live.playwright.config.mjs b/app/game-frontend/e2e/dieOnPrestart.live.playwright.config.mjs new file mode 100644 index 0000000..a5b6e33 --- /dev/null +++ b/app/game-frontend/e2e/dieOnPrestart.live.playwright.config.mjs @@ -0,0 +1,78 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15144); +const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15145); +const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`; +const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'die_on_prestart_live_integration'; +const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2'; +const expectedGameProfile = `${profileId}:${scenario}`; +const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? expectedGameProfile; +const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`; +const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`; +const databaseUrl = process.env.DIE_ON_PRESTART_LIVE_DATABASE_URL ?? ''; +const redisUrl = process.env.DIE_ON_PRESTART_LIVE_REDIS_URL ?? ''; +const gameSecret = process.env.DIE_ON_PRESTART_LIVE_GAME_SECRET ?? ''; +const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; + +if (databaseUrl && databaseSchema !== profileId) { + throw new Error( + `Die-on-prestart live schema must match its profile ID: ${databaseSchema ?? '(missing)'} != ${profileId}` + ); +} +if (gameProfile !== expectedGameProfile) { + throw new Error( + `PLAYWRIGHT_GAME_PROFILE must match profile and scenario: ${gameProfile} != ${expectedGameProfile}` + ); +} + +export default defineConfig({ + testDir: '.', + testMatch: ['dieOnPrestartLive.spec.ts'], + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/die-on-prestart-live'), + use: { + baseURL, + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: [ + { + command: 'node app/game-api/dist/index.js', + cwd: repositoryRoot, + url: `http://127.0.0.1:${apiPort}/healthz`, + reuseExistingServer: false, + timeout: 120_000, + env: { + DATABASE_URL: databaseUrl, + REDIS_URL: redisUrl, + GAME_TOKEN_SECRET: gameSecret, + GAME_API_HOST: '127.0.0.1', + GAME_API_PORT: String(apiPort), + PROFILE: profileId, + SCENARIO: scenario, + GAME_PROFILE_NAME: gameProfile, + DAEMON_REQUEST_TIMEOUT_MS: '10000', + }, + }, + { + command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`, + cwd: repositoryRoot, + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + }, + ], +}); diff --git a/app/game-frontend/e2e/dieOnPrestartLive.spec.ts b/app/game-frontend/e2e/dieOnPrestartLive.spec.ts new file mode 100644 index 0000000..6fb7f85 --- /dev/null +++ b/app/game-frontend/e2e/dieOnPrestartLive.spec.ts @@ -0,0 +1,321 @@ +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, + }); + }); +}); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 3346808..cf5adbc 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -26,6 +26,10 @@ type FixtureState = { instantRetreatEnabled?: boolean; instantRetreatAttempts?: number; instantRetreatInputs?: Array>; + dieOnPrestartShow?: boolean; + dieOnPrestartAvailableAt?: string; + dieOnPrestartAttempts?: number; + dieOnPrestartInputs?: Array>; generalMeQueries?: number; generalLogQueries?: number; settingMutations: Array>; @@ -125,8 +129,10 @@ const battleCenter = (state: FixtureState) => ({ const install = async (page: Page, state: FixtureState) => { await page.addInitScript(() => { - localStorage.setItem('sammo-game-token', 'ga_menu-token'); - localStorage.setItem('sammo-game-profile', 'che:default'); + if (location.pathname.startsWith('/che/')) { + localStorage.setItem('sammo-game-token', 'ga_menu-token'); + localStorage.setItem('sammo-game-profile', 'che:default'); + } }); await page.route('**/image/game/**', async (route) => { const filename = basename(new URL(route.request().url()).pathname); @@ -158,6 +164,12 @@ const install = async (page: Page, state: FixtureState) => { state.generalMeQueries = (state.generalMeQueries ?? 0) + 1; return response(myGeneral(state)); } + if (operation === 'general.ensureDieOnPrestartStatus') + return response({ + show: state.dieOnPrestartShow ?? false, + available: false, + availableAt: state.dieOnPrestartAvailableAt ?? null, + }); if (operation === 'world.getState') return response({ currentYear: 185, @@ -209,6 +221,20 @@ const install = async (page: Page, state: FixtureState) => { } return response({ ok: true }); } + if (operation === 'general.dieOnPrestart') { + state.dieOnPrestartInputs?.push(jsonInput); + state.dieOnPrestartAttempts = (state.dieOnPrestartAttempts ?? 0) + 1; + if (state.dieOnPrestartAttempts === 1) { + return { + error: { + message: '요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.', + code: -32000, + data: { code: 'TIMEOUT', httpStatus: 408, path: operation }, + }, + }; + } + return response({ ok: true }); + } if (operation === 'general.setMySetting') { state.settingMutations.push(jsonInput); state.myset = Math.max(0, state.myset - 1); @@ -480,6 +506,124 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후 expect(state.instantRetreatInputs?.every((input) => !('generalId' in input))).toBe(true); }); +test('가오픈 장수 삭제는 레거시 표시와 확인을 보존하고 timeout을 같은 ID로 재시도한다', async ({ page }) => { + const state: FixtureState = { + permission: 'head', + myset: 3, + dieOnPrestartShow: true, + dieOnPrestartAvailableAt: '2026-01-01T00:20:00.000Z', + dieOnPrestartAttempts: 0, + dieOnPrestartInputs: [], + settingMutations: [], + accessPages: [], + }; + const dialogs: string[] = []; + let confirmCount = 0; + page.on('dialog', async (dialog) => { + dialogs.push(`${dialog.type()}:${dialog.message()}`); + if (dialog.type() === 'confirm') { + confirmCount += 1; + if (confirmCount === 1) { + await dialog.dismiss(); + return; + } + } + await dialog.accept(); + }); + await install(page, state); + await page.addInitScript(() => { + localStorage.setItem('sammo-session-token', 'gateway-session-token'); + }); + await page.route(/^http:\/\/127\.0\.0\.1:\d+\/api\/trpc\/me(?:\?|$)/u, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify( + response({ + id: 'gateway-user', + username: 'gateway-user', + displayName: '게이트웨이 사용자', + }) + ), + }); + }); + await page.route('**/gateway/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html', + body: 'gateway', + }); + }); + 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('가오픈 기간 내 장수 삭제 (2026-01-01 09:20:00 부터)'); + await expect(deleteButton).toBeVisible(); + await expect(deleteButton).toBeEnabled(); + const geometry = await deleteButton.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + width: rect.width, + height: rect.height, + fontSize: style.fontSize, + color: style.color, + backgroundColor: style.backgroundColor, + cursor: style.cursor, + }; + }); + expect(geometry).toEqual({ + width: 160, + height: 30, + fontSize: '14px', + color: 'rgb(255, 255, 255)', + backgroundColor: 'rgb(34, 85, 0)', + cursor: 'pointer', + }); + await persistParityArtifact(page, 'core-my-page-die-on-prestart', geometry); + + await deleteButton.click(); + await expect.poll(() => confirmCount).toBe(1); + expect(dialogs[0]).toBe('confirm:정말로 삭제하시겠습니까?'); + expect(state.dieOnPrestartInputs).toHaveLength(0); + + await deleteButton.click(); + await expect.poll(() => state.dieOnPrestartInputs?.length).toBe(1); + await expect + .poll(() => + dialogs.some((message) => + message.includes('alert:실패했습니다: 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다.') + ) + ) + .toBe(true); + await expect(deleteButton).toBeVisible(); + const pendingRequestId = await page.evaluate(() => sessionStorage.getItem('sam.pending.dieOnPrestart')); + expect(pendingRequestId).toEqual(expect.stringMatching(/^[0-9a-f-]{36}$/i)); + + await deleteButton.click(); + await expect.poll(() => state.dieOnPrestartInputs?.length).toBe(2); + await page.waitForURL(/\/gateway\/$/u); + await expect(page.locator('body')).toHaveText('gateway'); + + const requestIds = state.dieOnPrestartInputs?.map((input) => input.clientRequestId); + expect(requestIds).toEqual([pendingRequestId, pendingRequestId]); + expect(state.dieOnPrestartInputs?.every((input) => !('generalId' in input) && !('userId' in input))).toBe(true); + const storage = await page.evaluate(() => ({ + gameToken: localStorage.getItem('sammo-game-token'), + gameProfile: localStorage.getItem('sammo-game-profile'), + sessionToken: localStorage.getItem('sammo-session-token'), + pendingRequestId: sessionStorage.getItem('sam.pending.dieOnPrestart'), + })); + expect(storage).toEqual({ + gameToken: null, + gameProfile: 'che:default', + sessionToken: 'gateway-session-token', + pendingRequestId: null, + }); +}); + test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => { const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, head); diff --git a/app/game-frontend/e2e/playwright.live.tsconfig.json b/app/game-frontend/e2e/playwright.live.tsconfig.json index a284204..acbae34 100644 --- a/app/game-frontend/e2e/playwright.live.tsconfig.json +++ b/app/game-frontend/e2e/playwright.live.tsconfig.json @@ -14,6 +14,8 @@ "./joinGeneralLive.spec.ts", "./joinGeneral.live.playwright.config.mjs", "./npcPossessionLive.spec.ts", - "./npcPossession.live.playwright.config.mjs" + "./npcPossession.live.playwright.config.mjs", + "./dieOnPrestartLive.spec.ts", + "./dieOnPrestart.live.playwright.config.mjs" ] } diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 477a3ae..2fbdda0 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -17,6 +17,7 @@ "test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs", "test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", + "test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/stores/session.ts b/app/game-frontend/src/stores/session.ts index a98a586..86510c8 100644 --- a/app/game-frontend/src/stores/session.ts +++ b/app/game-frontend/src/stores/session.ts @@ -99,6 +99,10 @@ export const useSessionStore = defineStore('session', { this.setGameToken(null); this.status = 'public'; }, + leaveGame() { + this.setGameToken(null); + this.status = this.sessionToken ? 'authed' : 'public'; + }, async refreshGeneralStatus() { if (!this.gameToken) { this.status = this.sessionToken ? 'authed' : 'public'; diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 935dc96..1254191 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -4,14 +4,17 @@ import { trpc } from '../utils/trpc'; import { formatLog } from '../utils/formatLog'; import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic'; +import { useSessionStore } from '../stores/session'; const SCREEN_MODE_KEY = 'sam.screenMode'; const CUSTOM_CSS_KEY = 'sam_customCSS'; +const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart'; type ScreenMode = 'auto' | '500px' | '1000px'; type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction'; type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item'; type MyGeneralResponse = Awaited>; type SelectionPoolStatus = Awaited>['selectionPool']; +type DieOnPrestartStatus = Awaited>; type WorldSnapshot = { currentYear: number; @@ -31,13 +34,20 @@ type SettingForm = { const data = ref(null); const world = ref(null); const selectionPoolStatus = ref(null); +const dieOnPrestartStatus = ref(null); const loading = ref(false); const error = ref(null); const screenMode = ref('auto'); const customCss = ref(''); const cssSaving = ref(false); +const session = useSessionStore(); let cssTimer: number | null = null; +const readPendingDieOnPrestartId = (): string => { + const stored = window.sessionStorage.getItem(PENDING_DIE_ON_PRESTART_KEY); + return stored && /^[0-9a-f-]{36}$/iu.test(stored) ? stored : crypto.randomUUID(); +}; const immediateActionRequestIds = reactive({ + dieOnPrestart: readPendingDieOnPrestartId(), buildNationCandidate: crypto.randomUUID(), instantRetreat: crypto.randomUUID(), }); @@ -136,12 +146,16 @@ const actionAvailability = computed(() => { const preopen = Boolean(turnTime && openTime && turnTime.getTime() <= openTime.getTime()); const npcMode = numberValue(config.npcMode ?? config.npcmode, 0); return { - dieOnPrestart: Boolean(preopen && general?.npcState === 0 && general.nationId === 0), + dieOnPrestart: Boolean(dieOnPrestartStatus.value?.show), buildNationCandidate: Boolean(preopen && general?.nationId === 0), instantRetreat: Boolean(availableInstantAction.instantRetreat), selectOtherGeneral: Boolean(npcMode === 2 && general?.npcState === 0), }; }); +const formatDieOnPrestartAvailableAt = computed(() => { + const value = dieOnPrestartStatus.value?.availableAt; + return value ? formatSeoulDateTime(value) : ''; +}); const formatSelectionAvailableAt = computed(() => { const value = selectionPoolStatus.value?.nextChangeAt; if (!value) return ''; @@ -178,14 +192,16 @@ const loadPage = async () => { loading.value = true; error.value = null; try { - const [general, state, joinConfig] = await Promise.all([ + const [general, state, joinConfig, prestartStatus] = await Promise.all([ trpc.general.me.query(), trpc.world.getState.query() as Promise, trpc.join.getConfig.query(), + trpc.general.ensureDieOnPrestartStatus.mutate(), ]); data.value = general; world.value = state; selectionPoolStatus.value = joinConfig.selectionPool; + dieOnPrestartStatus.value = prestartStatus; if (general) { Object.assign(form, general.settings); } @@ -218,6 +234,25 @@ const confirmMutation = async (message: string, mutation: () => Promise } }; +const dieOnPrestart = async () => { + if (!confirm('정말로 삭제하시겠습니까?')) return; + const clientRequestId = immediateActionRequestIds.dieOnPrestart; + window.sessionStorage.setItem(PENDING_DIE_ON_PRESTART_KEY, clientRequestId); + try { + await trpc.general.dieOnPrestart.mutate({ clientRequestId }); + window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY); + session.leaveGame(); + window.location.replace(import.meta.env.VITE_GATEWAY_WEB_URL?.trim() || '/gateway/'); + } catch (cause) { + const code = asRecord(asRecord(cause).data).code; + if (code !== 'TIMEOUT') { + window.sessionStorage.removeItem(PENDING_DIE_ON_PRESTART_KEY); + } + alert(`실패했습니다: ${errorText(cause)}`); + window.location.reload(); + } +}; + const dropItem = (item: { key: ItemSlotKey; name: string; code: string | null }) => confirmMutation(`${item.code ?? item.name}을(를) 버리시겠습니까?`, () => trpc.general.dropItem.mutate({ itemType: item.key }) @@ -387,13 +422,8 @@ onMounted(() => {
- 가오픈 기간 내 장수 삭제
- + 가오픈 기간 내 장수 삭제 ({{ formatDieOnPrestartAvailableAt }} 부터)
+
서버 개시 이전 거병(2턴부터 건국 가능)
diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index 655c3c6..70b69fd 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -69,7 +69,8 @@ export type TurnDaemonCommand = targetGeneralId: number; } | { type: 'troopRename'; requestId?: string; generalId: number; troopId: number; troopName: string } - | { type: 'dieOnPrestart'; requestId?: string; generalId: number } + | { type: 'ensureDieOnPrestartStatus'; requestId?: string; userId: string; generalId: number } + | { type: 'dieOnPrestart'; requestId?: string; userId: string; generalId: number } | { type: 'buildNationCandidate'; requestId?: string; userId: string; generalId: number } | { type: 'instantRetreat'; requestId?: string; userId: string; generalId: number } | { type: 'vacation'; requestId?: string; generalId: number } @@ -373,6 +374,13 @@ export type TurnDaemonCommandResult = troopId: number; reason: string; } + | { + type: 'ensureDieOnPrestartStatus'; + generalId: number; + show: boolean; + available: boolean; + availableAt?: string; + } | { type: 'dieOnPrestart'; ok: boolean; generalId: number; reason?: string } | { type: 'buildNationCandidate'; ok: boolean; generalId: number; reason?: string } | { type: 'instantRetreat'; ok: boolean; generalId: number; reason?: string } diff --git a/tools/integration-tests/test/auctionFlow.test.ts b/tools/integration-tests/test/auctionFlow.test.ts index 774ec16..3c2176c 100644 --- a/tools/integration-tests/test/auctionFlow.test.ts +++ b/tools/integration-tests/test/auctionFlow.test.ts @@ -506,7 +506,7 @@ describe('auction integration flow', () => { ` ); const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); - expect(result?.ok).toBe(true); + expect(result).toMatchObject({ type: 'auctionFinalize', ok: true }); const finished = await prisma.auction.findUnique({ where: { id: auction.id }, @@ -652,7 +652,7 @@ describe('auction integration flow', () => { ` ); const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); - expect(result?.ok).toBe(false); + expect(result).toMatchObject({ type: 'auctionFinalize', ok: false }); const reopened = await prisma.auction.findUnique({ where: { id: auction.id }, @@ -794,7 +794,7 @@ describe('auction integration flow', () => { ` ); const result = await transport.requestCommand({ type: 'auctionFinalize', auctionId: auction.id }, 30_000); - expect(result?.ok).toBe(true); + expect(result).toMatchObject({ type: 'auctionFinalize', ok: true }); const winner = await prisma.general.findUnique({ where: { id: bidderB.generalId },