From 2de8f64da49aa0d0427a335ff5fb957ea2c49f0b Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 31 Jul 2026 03:46:02 +0000 Subject: [PATCH] feat: port NPC possession through turn daemon --- app/game-api/src/daemon/databaseTransport.ts | 98 ++- app/game-api/src/router/join/index.ts | 275 ++++--- app/game-api/src/router/lobby/index.ts | 9 +- app/game-api/src/router/public/index.ts | 125 +++- .../test/npcPossession.integration.test.ts | 657 +++++++++++++++++ app/game-api/test/publicNpcList.test.ts | 119 ++- app/game-engine/src/index.ts | 1 + app/game-engine/src/turn/commandRegistry.ts | 22 + .../src/turn/joinCreateGeneralService.ts | 2 +- .../src/turn/npcPossessionService.ts | 524 +++++++++++++ .../src/turn/worldCommandHandler.ts | 54 +- .../test/npcPossessionService.test.ts | 11 + .../npcPossession.live.playwright.config.mjs | 77 ++ app/game-frontend/e2e/npcPossession.spec.ts | 359 +++++++++ .../e2e/npcPossessionLive.spec.ts | 241 ++++++ app/game-frontend/e2e/playwright.config.mjs | 1 + .../e2e/playwright.live.tsconfig.json | 7 +- app/game-frontend/package.json | 2 + app/game-frontend/src/env.d.ts | 1 + app/game-frontend/src/views/JoinView.vue | 694 ++++++++++++++++-- .../e2e/general-icon-lifecycle.spec.ts | 13 +- .../e2e/lobby-game-auth.spec.ts | 109 ++- .../e2e/playwright.config.mjs | 2 +- app/gateway-frontend/package.json | 2 +- app/gateway-frontend/src/views/LobbyView.vue | 82 ++- packages/common/src/turnDaemon/types.ts | 21 + packages/infra/prisma/game.prisma | 15 +- .../migration.sql | 51 ++ packages/infra/src/db.ts | 1 + tools/run-conditional-integration.sh | 15 +- 30 files changed, 3271 insertions(+), 319 deletions(-) create mode 100644 app/game-api/test/npcPossession.integration.test.ts create mode 100644 app/game-engine/src/turn/npcPossessionService.ts create mode 100644 app/game-engine/test/npcPossessionService.test.ts create mode 100644 app/game-frontend/e2e/npcPossession.live.playwright.config.mjs create mode 100644 app/game-frontend/e2e/npcPossession.spec.ts create mode 100644 app/game-frontend/e2e/npcPossessionLive.spec.ts create mode 100644 packages/infra/prisma/migrations/20260731000000_add_npc_selection_token/migration.sql diff --git a/app/game-api/src/daemon/databaseTransport.ts b/app/game-api/src/daemon/databaseTransport.ts index ec3f9bc..6b3b0de 100644 --- a/app/game-api/src/daemon/databaseTransport.ts +++ b/app/game-api/src/daemon/databaseTransport.ts @@ -1,8 +1,7 @@ import { randomUUID } from 'node:crypto'; -import type { GamePrisma } from '@sammo-ts/infra'; +import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra'; -import type { DatabaseClient } from '../context.js'; import type { TurnDaemonTransport } from './transport.js'; import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js'; @@ -39,6 +38,16 @@ export class FailedTurnDaemonCommandError extends Error { } } +export class RejectedNpcPossessionCommandError extends Error { + constructor( + readonly code: 'PRECONDITION_FAILED', + message: string + ) { + super(message); + this.name = 'RejectedNpcPossessionCommandError'; + } +} + export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { constructor( private readonly db: DatabaseClient, @@ -48,20 +57,63 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { async sendCommand(command: TurnDaemonCommand): Promise { const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID(); const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand; - try { - await this.db.inputEvent.create({ - data: { - requestId, - target: 'ENGINE', - eventType: command.type, - payload: asJson(durableCommand), - actorUserId: - 'userId' in command && typeof command.userId === 'string' - ? command.userId - : null, - }, + if (command.type === 'npcPossessGeneral') { + const existing = await this.db.inputEvent.findUnique({ + where: { requestId }, + select: { eventType: true, payload: true }, }); + if (existing) { + if ( + existing.eventType !== command.type || + stableJson(existing.payload) !== stableJson(durableCommand) + ) { + throw new ConflictingTurnDaemonCommandError(requestId); + } + return requestId; + } + } + try { + if (command.type === 'npcPossessGeneral' && this.db.$transaction) { + const rejectionReason = await this.db.$transaction(async (transaction) => { + await transaction.$executeRaw( + GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))` + ); + await transaction.$executeRaw( + GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${command.userId}`}, 1))` + ); + const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000); + const token = await transaction.npcSelectionToken.findFirst({ + where: { + ownerUserId: command.userId, + nonce: command.tokenNonce, + validUntil: { gte: acceptedAt }, + }, + select: { pickResult: true }, + }); + if (!token) { + return '유효한 장수 목록이 없습니다.'; + } + if ( + !token.pickResult || + typeof token.pickResult !== 'object' || + Array.isArray(token.pickResult) || + !Object.hasOwn(token.pickResult, String(command.generalId)) + ) { + return '선택한 장수가 목록에 없습니다.'; + } + await this.createInputEvent(transaction, durableCommand, requestId, acceptedAt); + return null; + }); + if (rejectionReason) { + throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason); + } + } else { + await this.createInputEvent(this.db, durableCommand, requestId); + } } catch (error) { + if (error instanceof RejectedNpcPossessionCommandError) { + throw error; + } const isUniqueConflict = typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002'; if (!isUniqueConflict) { @@ -78,6 +130,24 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { return requestId; } + private async createInputEvent( + db: DatabaseClient, + command: TurnDaemonCommand, + requestId: string, + createdAt?: Date + ): Promise { + await db.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: command.type, + payload: asJson(command), + actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null, + ...(createdAt ? { createdAt } : {}), + }, + }); + } + async requestCommand(command: TurnDaemonCommand, timeoutMs?: number): Promise { const requestId = await this.sendCommand(command); return this.waitForResult(requestId, timeoutMs); diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index b3da10b..edb82ba 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -15,7 +15,11 @@ import { } from '@sammo-ts/logic'; import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js'; import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js'; -import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js'; +import { + ConflictingTurnDaemonCommandError, + RejectedNpcPossessionCommandError, +} from '../../daemon/databaseTransport.js'; +import { NpcPossessionError, reserveNpcPossessionCandidates } from '@sammo-ts/game-engine'; const resolveSelectionCommandResult = ( result: Awaited> | null, @@ -115,6 +119,71 @@ const requestJoinCreateCommand = async ( } }; +const resolveNpcPossessionCommandResult = ( + result: Awaited> | null +): { ok: true; generalId: number } => { + if (!result) { + throw new TRPCError({ + code: 'TIMEOUT', + message: + 'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.', + }); + } + if (result.type !== 'npcPossessGeneral') { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: '턴 데몬이 올바르지 않은 NPC 빙의 결과를 반환했습니다.', + }); + } + if (!result.ok) { + throw new TRPCError({ + code: result.code, + message: result.reason, + }); + } + return { ok: true, generalId: result.generalId }; +}; + +const resolveNpcPossessionRequestId = ( + contextRequestId: string | undefined, + userId: string, + clientRequestId: string | undefined +): string | undefined => { + if (clientRequestId) { + return `npc-possess:${userId}:${clientRequestId}`; + } + return contextRequestId ? `${contextRequestId}:join.possessGeneral` : undefined; +}; + +const requestNpcPossessionCommand = async ( + ctx: GameApiContext, + command: Parameters[0] +) => { + try { + return await ctx.turnDaemon.requestCommand(command); + } catch (error) { + if ( + error instanceof RejectedNpcPossessionCommandError || + (error instanceof Error && error.name === 'RejectedNpcPossessionCommandError') + ) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: error.message, + }); + } + if ( + error instanceof ConflictingTurnDaemonCommandError || + (error instanceof Error && error.name === 'ConflictingTurnDaemonCommandError') + ) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이미 접수된 NPC 빙의 요청과 입력이 다릅니다. 새 요청 번호로 다시 시도해 주세요.', + }); + } + throw error; + } +}; + const DEFAULT_JOIN_STAT = { total: 165, min: 15, @@ -257,6 +326,7 @@ export const joinRouter = router({ user: { id: ctx.auth?.user.id ?? '', displayName: ctx.auth?.user.displayName ?? '', + canCreateGeneral: ctx.auth?.identity?.canCreateGeneral !== false, }, personalities: [{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, ...personalities], warSpecials, @@ -282,6 +352,9 @@ export const joinRouter = router({ availableSpecialWar: warSpecials, }, selectionPool, + npcPossession: { + enabled: asNumber(config.npcMode ?? config.npcmode, 0) === 1, + }, }; }), getSelectionPool: authedProcedure.mutation(async ({ ctx }) => { @@ -427,155 +500,77 @@ export const joinRouter = router({ listPossessCandidates: authedProcedure .input( z.object({ - limit: z.number().int().min(1).max(50).optional(), - offset: z.number().int().min(0).optional(), - }) - ) - .query(async ({ ctx, input }) => { - const limit = input.limit ?? 20; - const offset = input.offset ?? 0; - - const candidates = await ctx.db.general.findMany({ - where: { - userId: null, - npcState: { gte: 2 }, - }, - orderBy: { id: 'asc' }, - skip: offset, - take: limit, - select: { - id: true, - name: true, - npcState: true, - nationId: true, - cityId: true, - leadership: true, - strength: true, - intel: true, - age: true, - officerLevel: true, - personalCode: true, - specialCode: true, - special2Code: true, - picture: true, - imageServer: true, - }, - }); - - const [nationRows, cityRows] = await Promise.all([ - ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }), - ctx.db.city.findMany({ select: { id: true, name: true } }), - ]); - const nationMap = new Map(nationRows.map((nation) => [nation.id, nation])); - const cityMap = new Map(cityRows.map((city) => [city.id, city])); - - return candidates.map((candidate) => { - const nation = nationMap.get(candidate.nationId); - const city = cityMap.get(candidate.cityId); - return { - id: candidate.id, - name: candidate.name, - npcState: candidate.npcState, - nation: nation - ? { id: nation.id, name: nation.name, color: nation.color } - : { id: 0, name: '재야', color: '#666666' }, - city: city ? { id: city.id, name: city.name } : null, - stats: { - leadership: candidate.leadership, - strength: candidate.strength, - intelligence: candidate.intel, - }, - age: candidate.age, - officerLevel: candidate.officerLevel, - personality: candidate.personalCode, - special: candidate.specialCode, - specialWar: candidate.special2Code, - picture: candidate.picture, - imageServer: candidate.imageServer, - }; - }); - }), - possessGeneral: authedProcedure - .input( - z.object({ - generalId: z.number().int().positive(), + refresh: z.boolean().optional(), + keepIds: z.array(z.number().int().positive()).max(5).optional(), }) ) .mutation(async ({ ctx, input }) => { - const userId = ctx.auth?.user.id; - if (!userId) { + const auth = ctx.auth; + if (!auth) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } - const existing = await ctx.db.general.findFirst({ where: { userId } }); - if (existing) { + if (auth.identity?.canCreateGeneral === false) { throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: '이미 장수가 생성되어 있습니다.', + code: 'FORBIDDEN', + message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', }); } - - await ctx.db.$transaction!(async (db) => { - const [candidate, worldState] = await Promise.all([ - db.general.findUnique({ - where: { id: input.generalId }, - select: { npcState: true, meta: true }, - }), - db.worldState.findFirst({ - select: { currentYear: true, currentMonth: true }, - }), - ]); - if (!candidate || candidate.npcState < 2 || !worldState) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: '빙의 가능한 장수를 찾지 못했습니다.', - }); - } - - const now = new Date(); - const updated = await db.general.updateMany({ - where: { - id: input.generalId, - userId: null, - npcState: candidate.npcState, - }, - data: { - userId, - npcState: 1, - meta: { - ...asRecord(candidate.meta), - npc_org: candidate.npcState, - owner_name: ctx.auth?.user.displayName ?? '', - pickYearMonth: worldState.currentYear * 12 + worldState.currentMonth - 1, - killturn: 6, - defence_train: 80, - }, - updatedAt: now, - }, + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', }); - if (updated.count === 0) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: '빙의 가능한 장수를 찾지 못했습니다.', - }); - } - await db.generalAccessLog.upsert({ - where: { generalId: input.generalId }, - update: { - userId, - lastRefresh: now, - refresh: 0, - refreshTotal: 0, - refreshScore: 0, - refreshScoreTotal: 0, - }, - create: { - generalId: input.generalId, - userId, - lastRefresh: now, - }, + } + try { + return await reserveNpcPossessionCandidates({ + db: ctx.db, + worldState, + userId: auth.user.id, + ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id, + refresh: input.refresh, + keepIds: input.keepIds, }); + } catch (error) { + if (error instanceof NpcPossessionError) { + throw new TRPCError({ code: error.code, message: error.message }); + } + throw error; + } + }), + possessGeneral: engineAuthedProcedure + .input( + z.object({ + generalId: z.number().int().positive(), + tokenNonce: z.number().int().nonnegative(), + clientRequestId: z.string().uuid().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const auth = ctx.auth; + if (!auth) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + if (auth.identity?.canCreateGeneral === false) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', + }); + } + const userId = auth.user.id; + const commandRequestId = resolveNpcPossessionRequestId(ctx.requestId, userId, input.clientRequestId); + const result = await requestNpcPossessionCommand(ctx, { + type: 'npcPossessGeneral', + ...(commandRequestId ? { requestId: commandRequestId } : {}), + userId, + ownerDisplayName: auth.user.displayName, + profileId: ctx.profile.id, + ...(auth.sanctions.legacyPenalty !== undefined + ? { ownerLegacyPenalty: auth.sanctions.legacyPenalty } + : {}), + generalId: input.generalId, + tokenNonce: input.tokenNonce, }); - - return { ok: true }; + return resolveNpcPossessionCommandResult(result); }), }); diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index 8071541..bde6e37 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; -import { isSelectionPoolWorld } from '../../services/selectPool.js'; +import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '../../services/selectPool.js'; import { procedure, router } from '../../trpc.js'; export const lobbyRouter = router({ @@ -20,8 +20,8 @@ export const lobbyRouter = router({ meta: zWorldStateMeta.parse(rawWorldState.meta), }; - const userCnt = await ctx.db.general.count({ where: { npcState: 0 } }); - const npcCnt = await ctx.db.general.count({ where: { npcState: { gt: 0 } } }); + const userCnt = await ctx.db.general.count({ where: { npcState: { lt: 2 } } }); + const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } }); const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } }); let myGeneral = null; @@ -43,7 +43,7 @@ export const lobbyRouter = router({ year: worldState.currentYear, month: worldState.currentMonth, userCnt, - maxUserCnt: worldState.config.maxUserCnt ?? 500, + maxUserCnt: resolveSelectionMaxGeneral(rawWorldState), npcCnt, nationCnt, turnTerm: worldState.tickSeconds / 60, @@ -54,6 +54,7 @@ export const lobbyRouter = router({ otherTextInfo: worldState.meta.otherTextInfo ?? '', isUnited: worldState.meta.isUnited ?? 0, selectionPoolEnabled: isSelectionPoolWorld(rawWorldState), + npcPossessionEnabled: worldState.config.npcMode === 1, myGeneral, }; }), diff --git a/app/game-api/src/router/public/index.ts b/app/game-api/src/router/public/index.ts index 411407c..9589590 100644 --- a/app/game-api/src/router/public/index.ts +++ b/app/game-api/src/router/public/index.ts @@ -1,5 +1,5 @@ import { TRPCError } from '@trpc/server'; -import { asRecord } from '@sammo-ts/common'; +import { asNumber, asRecord } from '@sammo-ts/common'; import { LogCategory, LogScope } from '@sammo-ts/infra'; import { z } from 'zod'; @@ -173,6 +173,33 @@ const readFiniteMetaNumber = (meta: Record, key: string): numbe return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; +const resolveExperienceLevel = (experience: number, maxLevel: number): number => { + const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10)); + return Math.max(0, Math.min(level, maxLevel)); +}; + +const resolveHonorText = (experience: number): string => { + if (experience < 640) return '전무'; + if (experience < 2_560) return '무명'; + if (experience < 5_760) return '신동'; + if (experience < 10_240) return '약간'; + if (experience < 16_000) return '평범'; + if (experience < 23_040) return '지역적'; + if (experience < 31_360) return '전국적'; + if (experience < 40_960) return '세계적'; + if (experience < 45_000) return '유명'; + if (experience < 51_840) return '명사'; + if (experience < 55_000) return '호걸'; + if (experience < 64_000) return '효웅'; + if (experience < 77_440) return '영웅'; + return '구세주'; +}; + +const resolveDedicationText = (dedication: number, maxLevel: number): string => { + const level = Math.max(0, Math.min(Math.ceil(Math.sqrt(dedication) / 10), maxLevel)); + return level === 0 ? '무품관' : `${maxLevel - level + 1}품관`; +}; + const compareString = (left: string, right: string): number => { if (left === right) { return 0; @@ -180,16 +207,21 @@ const compareString = (left: string, right: string): number => { return left < right ? -1 : 1; }; -const sortNpcList = (rows: T[], sort: NpcListSort): T[] => +const sortNpcList = < + T extends { + name: string; + nationId: number; + statTotal: number; + leadership: number; + strength: number; + intelligence: number; + experience: number; + dedication: number; + }, +>( + rows: T[], + sort: NpcListSort +): T[] => rows.sort((left, right) => { switch (sort) { case 2: @@ -450,18 +482,28 @@ export const publicRouter = router({ z .object({ sort: z.number().int().min(1).max(8).catch(1).optional(), + includeAllWithToken: z.boolean().optional(), }) .optional() ) .query(async ({ ctx, input }) => { const sort = (input?.sort ?? 1) as NpcListSort; - const [generals, nations] = await Promise.all([ + const includeAllWithToken = input?.includeAllWithToken === true; + if (includeAllWithToken && !ctx.auth) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const now = new Date(Math.floor(Date.now() / 1000) * 1000); + const [generals, nations, activeTokens, worldState] = await Promise.all([ ctx.db.general.findMany({ - where: { npcState: { gt: 0 } }, + ...(includeAllWithToken ? {} : { where: { npcState: { gt: 0 } } }), select: { id: true, name: true, + picture: true, + imageServer: true, npcState: true, + age: true, + officerLevel: true, nationId: true, leadership: true, strength: true, @@ -476,8 +518,19 @@ export const publicRouter = router({ orderBy: { id: 'asc' }, }), ctx.db.nation.findMany({ - select: { id: true, name: true }, + select: { id: true, name: true, level: true }, }), + includeAllWithToken + ? ctx.db.npcSelectionToken.findMany({ + where: { validUntil: { gte: now } }, + select: { pickResult: true }, + }) + : [], + includeAllWithToken + ? ctx.db.worldState.findFirst({ + select: { config: true }, + }) + : null, ]); const personalityKeys = generals.map((general) => normalizeTraitKey(general.personalCode)); @@ -488,12 +541,21 @@ export const publicRouter = router({ loadTraitNames(domesticKeys, 'domestic'), loadTraitNames(warKeys, 'war'), ]); - const nationMap = new Map(nations.map((nation) => [nation.id, nation.name])); + const nationMap = new Map(nations.map((nation) => [nation.id, nation])); + const worldConfig = asRecord(worldState?.config); + const worldConstants = asRecord(worldConfig.const); + const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255))); + const maxDedLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxDedLevel, 30))); - // Legacy select_pool rows preceded possessed NPC rows before its stable-value sort. - const pool = generals.filter((general) => general.npcState >= 2); - const possessed = generals.filter((general) => general.npcState === 1); - const rows = [...pool, ...possessed].map((general) => { + // Legacy public NPC list put pool rows before possessed rows. The token-aware + // selection list instead consumes the raw id-ordered full list before its own comparator. + const sourceRows = includeAllWithToken + ? generals + : [ + ...generals.filter((general) => general.npcState >= 2), + ...generals.filter((general) => general.npcState === 1), + ]; + const rows = sourceRows.map((general) => { const meta = asRecord(general.meta); const personalityKey = normalizeTraitKey(general.personalCode); const domesticKey = normalizeTraitKey(general.specialCode); @@ -510,11 +572,19 @@ export const publicRouter = router({ return { id: general.id, name: general.name, + picture: general.picture, + imageServer: general.imageServer, npcState: general.npcState, ownerName, - level: readFiniteMetaNumber(meta, 'explevel'), + age: general.age, + level: includeAllWithToken + ? resolveExperienceLevel(general.experience, maxLevel) + : readFiniteMetaNumber(meta, 'explevel'), + officerLevel: general.officerLevel, + killturn: readFiniteMetaNumber(meta, 'killturn'), nationId: general.nationId, - nationName: nationMap.get(general.nationId) ?? '-', + nationName: nationMap.get(general.nationId)?.name ?? '-', + nationLevel: nationMap.get(general.nationId)?.level ?? 0, personality: personalityKey ? { key: personalityKey, @@ -541,13 +611,24 @@ export const publicRouter = router({ strength: general.strength, intelligence: general.intel, experience: general.experience, + experienceText: resolveHonorText(general.experience), dedication: general.dedication, + dedicationText: resolveDedicationText(general.dedication, maxDedLevel), }; }); + const tokenKeepCounts = Object.fromEntries( + activeTokens.flatMap((token) => + Object.entries(asRecord(token.pickResult)).flatMap(([generalId, value]) => { + const keepCount = asNumber(asRecord(value).keepCount, Number.NaN); + return Number.isFinite(keepCount) ? [[generalId, Math.max(0, Math.floor(keepCount))]] : []; + }) + ) + ); return { sort, - generals: sortNpcList(rows, sort), + generals: includeAllWithToken ? rows : sortNpcList(rows, sort), + tokenKeepCounts, }; }), }); diff --git a/app/game-api/test/npcPossession.integration.test.ts b/app/game-api/test/npcPossession.integration.test.ts new file mode 100644 index 0000000..fcf8e72 --- /dev/null +++ b/app/game-api/test/npcPossession.integration.test.ts @@ -0,0 +1,657 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine'; +import { createGamePostgresConnector, GamePrisma, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { GameApiContext } from '../src/context.js'; +import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const databaseUrl = process.env.NPC_POSSESSION_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const profile = 'hwe:2'; +const userId = 'npc-possession-integration-user'; +const otherUserId = 'npc-possession-integration-other'; +const failureUserId = 'npc-possession-integration-failure'; +const rejectedUserId = 'npc-possession-integration-rejected'; +const delayedUserId = 'npc-possession-integration-delayed'; +const cleanupUserId = 'npc-possession-integration-cleanup'; +const raceUserId = 'npc-possession-integration-race'; +const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : ''; + +const assertDedicatedDatabase = (rawUrl: string): void => { + const schema = new URL(rawUrl).searchParams.get('schema'); + if (!schema?.endsWith('npc_possession_integration')) { + throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`); + } + if (!/^[a-z0-9_]+$/.test(schema)) { + throw new Error(`Refusing unsafe schema name: ${schema}`); + } +}; + +const buildAuth = (id: string, displayName: string, legacyMemberNo: number): GameSessionTokenPayload => ({ + version: 1, + profile, + issuedAt: '2026-07-31T00:00:00.000Z', + expiresAt: '2026-08-31T00:00:00.000Z', + sessionId: `npc-possession-${id}`, + user: { + id, + username: id, + displayName, + roles: ['user'], + legacyMemberNo, + }, + sanctions: { + legacyPenalty: { + any: { + ban: { expire: 4_102_444_800, value: 1 }, + expired: { expire: 1, value: 9 }, + }, + hwe: { + ban: { expire: 4_102_444_800, value: 2 }, + chat: { expire: 4_102_444_800, value: 3 }, + }, + }, + }, + identity: { + kakaoVerified: true, + canCreateGeneral: true, + requiresKakaoVerification: false, + graceEndsAt: null, + }, +}); + +integration('mode 1 NPC possession through token reservation and the durable daemon', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + let runtime: TurnDaemonRuntime | undefined; + let daemonLoop: Promise | undefined; + let turnDaemon: TurnDaemonTransport; + + const auth = buildAuth(userId, '빙의사용자', 7_701); + const otherAuth = buildAuth(otherUserId, '다른사용자', 7_702); + const failureAuth = buildAuth(failureUserId, '재시도사용자', 7_703); + const rejectedAuth = buildAuth(rejectedUserId, '거절사용자', 7_704); + const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705); + const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706); + const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707); + + const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => { + const redisClient = { + get: async () => null, + set: async () => null, + }; + return { + requestId, + db, + redis: redisClient as unknown as RedisConnector['client'], + turnDaemon, + battleSim: new InMemoryBattleSimTransport(), + profile: { id: 'hwe', scenario: '2', name: profile }, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + auth: actorAuth, + accessTokenStore: new RedisAccessTokenStore(redisClient, profile), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'npc-possession-test-secret', + }; + }; + + const stopRuntime = async (reason: string): Promise => { + if (!runtime) return; + await runtime.lifecycle.stop(reason); + await daemonLoop; + await runtime.close(); + runtime = undefined; + daemonLoop = undefined; + }; + + const startRuntime = async (ownerId: string): Promise => { + runtime = await createTurnDaemonRuntime({ + profile, + databaseUrl: databaseUrl!, + enableDatabaseFlush: true, + enableLeaseHeartbeat: false, + leaseOwnerId: ownerId, + }); + turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000); + daemonLoop = runtime.lifecycle.start(); + await expect(turnDaemon.requestStatus(10_000)).resolves.toMatchObject({ + state: expect.any(String), + }); + }; + + beforeAll(async () => { + assertDedicatedDatabase(databaseUrl!); + const previousSeed = process.env.INTEGRATION_WORLD_SEED; + process.env.INTEGRATION_WORLD_SEED = 'npc-possession-integration-seed'; + try { + await seedScenarioToDatabase({ + scenarioId: 2, + databaseUrl: databaseUrl!, + now: new Date('2099-07-31T12:00:00.000Z'), + installOptions: { + turnTermMinutes: 5, + npcMode: 1, + 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(); + await db.npcSelectionToken.deleteMany(); + const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); + await db.general.createMany({ + data: Array.from({ length: 24 }, (_, index) => ({ + id: index + 1, + userId: null, + name: `빙의후보${index + 1}`, + nationId: 0, + cityId: city.id, + npcState: 2, + leadership: 40 + index, + strength: 50 + index, + intel: 60 + index, + turnTime: new Date('2099-07-31T12:05:00.000Z'), + personalCode: 'che_안전', + specialCode: 'che_인덕', + special2Code: 'che_무쌍', + picture: 'default.jpg', + imageServer: 0, + meta: { killturn: 6 }, + penalty: {}, + })), + }); + await startRuntime('npc-possession-integration-daemon'); + }, 60_000); + + afterAll(async () => { + await stopRuntime('npc possession integration complete'); + await closeDb?.(); + }, 30_000); + + it('reserves at most five exact type-2 NPCs and preserves Ref refresh/keep timing', async () => { + const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig(); + expect(config.npcPossession).toEqual({ enabled: true }); + + const [first, concurrentSameOwner] = await Promise.all([ + appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}), + appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}), + ]); + expect(concurrentSameOwner).toEqual(first); + expect(await db.npcSelectionToken.count({ where: { ownerUserId: userId } })).toBe(1); + expect(first.candidates.length).toBeGreaterThan(0); + expect(first.candidates.length).toBeLessThanOrEqual(5); + expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length); + expect(first.pickMoreSeconds).toBe(0); + expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true); + const rows = await db.general.findMany({ + where: { id: { in: first.candidates.map(({ id }) => id) } }, + select: { id: true, userId: true, npcState: true }, + }); + expect(rows).toHaveLength(first.candidates.length); + expect(rows.every((row) => row.userId === null && row.npcState === 2)).toBe(true); + + const reused = await appRouter + .createCaller(buildContext('npc-possession-token-b')) + .join.listPossessCandidates({}); + expect(reused).toEqual(first); + + const kept = first.candidates[0]!; + const refreshed = await appRouter + .createCaller(buildContext('npc-possession-token-refresh')) + .join.listPossessCandidates({ refresh: true, keepIds: [kept.id] }); + expect(refreshed.tokenNonce).not.toBe(first.tokenNonce); + expect(refreshed.pickMoreSeconds).toBeGreaterThan(0); + expect(refreshed.candidates.find(({ id }) => id === kept.id)?.keepCount).toBe(2); + + await expect( + appRouter + .createCaller(buildContext('npc-possession-token-too-early')) + .join.listPossessCandidates({ refresh: true, keepIds: [] }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '아직 다시 뽑을 수 없습니다', + }); + + const other = await appRouter + .createCaller(buildContext('npc-possession-token-other', otherAuth)) + .join.listPossessCandidates({}); + const firstIds = new Set(refreshed.candidates.map(({ id }) => id)); + expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false); + }, 30_000); + + it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => { + const reservation = await appRouter + .createCaller(buildContext('npc-possession-token-current')) + .join.listPossessCandidates({}); + const firstCandidate = reservation.candidates[0]!; + const secondCandidate = reservation.candidates[1]!; + const firstClientRequestId = '11111111-1111-4111-8111-111111111111'; + const secondClientRequestId = '22222222-2222-4222-8222-222222222222'; + const firstInput = { + generalId: firstCandidate.id, + tokenNonce: reservation.tokenNonce, + clientRequestId: firstClientRequestId, + }; + const secondInput = { + generalId: secondCandidate.id, + tokenNonce: reservation.tokenNonce, + clientRequestId: secondClientRequestId, + }; + const concurrent = await Promise.allSettled([ + appRouter.createCaller(buildContext('npc-possession-http-a')).join.possessGeneral(firstInput), + appRouter.createCaller(buildContext('npc-possession-http-b')).join.possessGeneral(secondInput), + ]); + expect(concurrent.filter(({ status }) => status === 'fulfilled')).toHaveLength(1); + expect(concurrent.filter(({ status }) => status === 'rejected')).toHaveLength(1); + const fulfilledIndex = concurrent.findIndex(({ status }) => status === 'fulfilled'); + const candidate = fulfilledIndex === 0 ? firstCandidate : secondCandidate; + const input = fulfilledIndex === 0 ? firstInput : secondInput; + const clientRequestId = input.clientRequestId; + const first = concurrent[fulfilledIndex]!; + if (first.status !== 'fulfilled') { + throw new Error('Exactly one NPC possession must succeed.'); + } + const retried = await appRouter + .createCaller(buildContext('npc-possession-http-retry')) + .join.possessGeneral(input); + expect(retried).toEqual(first.value); + + const persisted = await db.general.findUniqueOrThrow({ where: { id: candidate.id } }); + expect(persisted).toMatchObject({ + userId, + npcState: 1, + penalty: { + ban: 2, + chat: 3, + }, + }); + expect(persisted.meta).toMatchObject({ + npc_org: 2, + ownerName: '빙의사용자', + owner_name: '빙의사용자', + killturn: 6, + defence_train: 80, + permission: 'normal', + }); + expect(runtime!.world.getGeneralById(candidate.id)).toMatchObject({ + userId, + npcState: 1, + penalty: { + ban: 2, + chat: 3, + }, + }); + expect(await db.general.count({ where: { userId } })).toBe(1); + expect(await db.npcSelectionToken.findUnique({ where: { ownerUserId: userId } })).toBeNull(); + const access = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: candidate.id } }); + expect(access).toMatchObject({ + userId, + refresh: 0, + refreshTotal: 0, + refreshScore: 0, + refreshScoreTotal: 0, + }); + const requestId = `npc-possess:${userId}:${clientRequestId}`; + const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); + expect(event).toMatchObject({ + target: 'ENGINE', + eventType: 'npcPossessGeneral', + status: 'SUCCEEDED', + attempts: 1, + actorUserId: userId, + }); + expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime()); + const logs = await db.logEntry.findMany({ + where: { + OR: [ + { generalId: candidate.id, text: { contains: '빙의되다' } }, + { text: { contains: '빙의됩니다' } }, + ], + }, + }); + expect(logs).toHaveLength(2); + await expect( + appRouter.createCaller(buildContext('npc-possession-lobby-after')).lobby.info() + ).resolves.toMatchObject({ + userCnt: 1, + npcCnt: 23, + npcPossessionEnabled: true, + selectionPoolEnabled: false, + myGeneral: { + name: candidate.name, + }, + }); + + await expect( + appRouter.createCaller(buildContext('npc-possession-conflict')).join.possessGeneral({ + ...input, + generalId: candidate.id === firstCandidate.id ? secondCandidate.id : firstCandidate.id, + }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + + await stopRuntime('verify NPC possession reload'); + await startRuntime('npc-possession-integration-reloaded-daemon'); + expect(runtime!.world.getGeneralById(candidate.id)).toMatchObject({ + userId, + npcState: 1, + penalty: { + ban: 2, + chat: 3, + }, + }); + }, 45_000); + + it('keeps an accepted token through wall-clock expiry until the queued ENGINE event finishes', async () => { + const reservation = await appRouter + .createCaller(buildContext('npc-possession-delayed-token', delayedAuth)) + .join.listPossessCandidates({}); + const candidate = reservation.candidates[0]!; + const clientRequestId = '88888888-8888-4888-8888-888888888888'; + const requestId = `npc-possess:${delayedUserId}:${clientRequestId}`; + const input = { + generalId: candidate.id, + tokenNonce: reservation.tokenNonce, + clientRequestId, + }; + + await stopRuntime('hold accepted NPC possession past token expiry'); + turnDaemon = new DatabaseTurnDaemonTransport(db, 100); + await expect( + appRouter.createCaller(buildContext('npc-possession-delayed-http', delayedAuth)).join.possessGeneral(input) + ).rejects.toMatchObject({ code: 'TIMEOUT' }); + + const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); + const acceptedSecond = new Date(Math.floor(event.createdAt.getTime() / 1000) * 1000); + await db.npcSelectionToken.update({ + where: { ownerUserId: delayedUserId }, + data: { validUntil: acceptedSecond }, + }); + await new Promise((resolve) => setTimeout(resolve, 1_100)); + + await appRouter + .createCaller(buildContext('npc-possession-cleanup-token', cleanupAuth)) + .join.listPossessCandidates({}); + await expect( + db.npcSelectionToken.findUnique({ where: { ownerUserId: delayedUserId } }) + ).resolves.not.toBeNull(); + await expect( + appRouter + .createCaller(buildContext('npc-possession-delayed-refresh', delayedAuth)) + .join.listPossessCandidates({ refresh: true, keepIds: [] }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.', + }); + + await startRuntime('npc-possession-delayed-retry-daemon'); + await expect( + appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input) + ).resolves.toEqual({ ok: true, generalId: candidate.id }); + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ + status: 'SUCCEEDED', + attempts: 1, + }); + expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(1); + }, 45_000); + + it('serializes durable enqueue before a token refresh can replace its nonce', async () => { + const reservation = await appRouter + .createCaller(buildContext('npc-possession-race-token', raceAuth)) + .join.listPossessCandidates({}); + const candidate = reservation.candidates[0]!; + const clientRequestId = '99999999-9999-4999-8999-999999999999'; + const requestId = `npc-possess:${raceUserId}:${clientRequestId}`; + const input = { + generalId: candidate.id, + tokenNonce: reservation.tokenNonce, + clientRequestId, + }; + + await stopRuntime('hold NPC possession enqueue behind the reservation lock'); + turnDaemon = new DatabaseTurnDaemonTransport(db, 100); + + let releaseLock!: () => void; + let markLockReady!: () => void; + const lockReady = new Promise((resolve) => { + markLockReady = resolve; + }); + const lockRelease = new Promise((resolve) => { + releaseLock = resolve; + }); + const blocker = db.$transaction( + async (transaction) => { + await transaction.$executeRaw( + GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))` + ); + markLockReady(); + await lockRelease; + }, + { timeout: 5_000 } + ); + await lockReady; + + const enqueue = appRouter + .createCaller(buildContext('npc-possession-race-http', raceAuth)) + .join.possessGeneral(input); + await new Promise((resolve) => setTimeout(resolve, 150)); + await expect(db.inputEvent.findUnique({ where: { requestId } })).resolves.toBeNull(); + + releaseLock(); + await blocker; + await expect(enqueue).rejects.toMatchObject({ code: 'TIMEOUT' }); + await expect( + appRouter + .createCaller(buildContext('npc-possession-race-refresh', raceAuth)) + .join.listPossessCandidates({ refresh: true, keepIds: [] }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.', + }); + await expect(db.npcSelectionToken.findUnique({ where: { ownerUserId: raceUserId } })).resolves.toMatchObject({ + nonce: reservation.tokenNonce, + }); + + await startRuntime('npc-possession-race-retry-daemon'); + await expect( + appRouter.createCaller(buildContext('npc-possession-race-retry', raceAuth)).join.possessGeneral(input) + ).resolves.toEqual({ ok: true, generalId: candidate.id }); + }, 45_000); + + it('rolls back a late log failure and retries the same ENGINE event once', async () => { + const reservation = await appRouter + .createCaller(buildContext('npc-possession-failure-token', failureAuth)) + .join.listPossessCandidates({}); + const candidate = reservation.candidates[0]!; + const clientRequestId = '33333333-3333-4333-8333-333333333333'; + const requestId = `npc-possess:${failureUserId}:${clientRequestId}`; + const triggerName = 'npc_possession_fail_first_log'; + const functionName = 'npc_possession_fail_first_log_fn'; + + await db.$executeRawUnsafe(` + CREATE OR REPLACE FUNCTION "${schemaName}"."${functionName}"() + RETURNS trigger AS $$ + BEGIN + IF NEW.text LIKE '%재시도사용자%' + AND EXISTS ( + SELECT 1 + FROM "${schemaName}"."input_event" + WHERE "request_id" = '${requestId}' + AND "status" = 'PROCESSING' + AND "attempts" = 1 + ) + THEN + RAISE EXCEPTION 'injected first NPC possession log failure'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql + `); + await db.$executeRawUnsafe(` + CREATE TRIGGER "${triggerName}" + BEFORE INSERT ON "${schemaName}"."log_entry" + FOR EACH ROW EXECUTE FUNCTION "${schemaName}"."${functionName}"() + `); + + try { + await expect( + appRouter.createCaller(buildContext('npc-possession-failure-http', failureAuth)).join.possessGeneral({ + generalId: candidate.id, + tokenNonce: reservation.tokenNonce, + clientRequestId, + }) + ).resolves.toEqual({ ok: true, generalId: candidate.id }); + } finally { + await db.$executeRawUnsafe(`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`); + await db.$executeRawUnsafe(`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`); + } + + expect(await db.general.count({ where: { userId: failureUserId } })).toBe(1); + expect(runtime!.world.getGeneralById(candidate.id)).toMatchObject({ + userId: failureUserId, + npcState: 1, + }); + expect(await db.npcSelectionToken.findUnique({ where: { ownerUserId: failureUserId } })).toBeNull(); + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ + status: 'SUCCEEDED', + attempts: 2, + actorUserId: failureUserId, + error: null, + }); + expect( + await db.logEntry.count({ + where: { text: { contains: '재시도사용자' } }, + }) + ).toBe(2); + }, 45_000); + + it('rejects wrong mode, foreign nonce, unlisted ID, expiry and a full server without mutation', async () => { + await db.npcSelectionToken.deleteMany({ where: { ownerUserId: cleanupUserId } }); + const worldState = await db.worldState.findFirstOrThrow(); + const originalConfig = worldState.config as GamePrisma.InputJsonObject; + await db.worldState.update({ + where: { id: worldState.id }, + data: { + config: { + ...originalConfig, + npcMode: 0, + }, + }, + }); + await expect( + appRouter + .createCaller(buildContext('npc-possession-reject-mode-token', rejectedAuth)) + .join.listPossessCandidates({}) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '빙의 가능한 서버가 아닙니다', + }); + + await db.worldState.update({ + where: { id: worldState.id }, + data: { config: originalConfig }, + }); + const reservation = await appRouter + .createCaller(buildContext('npc-possession-reject-token', rejectedAuth)) + .join.listPossessCandidates({}); + const foreignToken = await db.npcSelectionToken.findUniqueOrThrow({ + where: { ownerUserId: otherUserId }, + }); + await expect( + appRouter.createCaller(buildContext('npc-possession-reject-foreign', rejectedAuth)).join.possessGeneral({ + generalId: reservation.candidates[0]!.id, + tokenNonce: foreignToken.nonce, + clientRequestId: '44444444-4444-4444-8444-444444444444', + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '유효한 장수 목록이 없습니다.', + }); + + const reservedIds = new Set(reservation.candidates.map(({ id }) => id)); + const unlisted = await db.general.findFirstOrThrow({ + where: { + userId: null, + npcState: 2, + id: { notIn: [...reservedIds] }, + }, + }); + await expect( + appRouter.createCaller(buildContext('npc-possession-reject-unlisted', rejectedAuth)).join.possessGeneral({ + generalId: unlisted.id, + tokenNonce: reservation.tokenNonce, + clientRequestId: '55555555-5555-4555-8555-555555555555', + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '선택한 장수가 목록에 없습니다.', + }); + + await db.npcSelectionToken.update({ + where: { ownerUserId: rejectedUserId }, + data: { validUntil: new Date('2000-01-01T00:00:00.000Z') }, + }); + await expect( + appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({ + generalId: reservation.candidates[0]!.id, + tokenNonce: reservation.tokenNonce, + clientRequestId: '66666666-6666-4666-8666-666666666666', + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '유효한 장수 목록이 없습니다.', + }); + + const fresh = await appRouter + .createCaller(buildContext('npc-possession-reject-fresh-token', rejectedAuth)) + .join.listPossessCandidates({}); + const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } }); + await db.worldState.update({ + where: { id: worldState.id }, + data: { + config: { + ...originalConfig, + npcMode: 1, + maxGeneral: activeCount, + }, + }, + }); + await expect( + appRouter.createCaller(buildContext('npc-possession-reject-cap', rejectedAuth)).join.possessGeneral({ + generalId: fresh.candidates[0]!.id, + tokenNonce: fresh.tokenNonce, + clientRequestId: '77777777-7777-4777-8777-777777777777', + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '더 이상 등록 할 수 없습니다.', + }); + await db.worldState.update({ + where: { id: worldState.id }, + data: { config: originalConfig }, + }); + + expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0); + expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false); + }, 45_000); +}); diff --git a/app/game-api/test/publicNpcList.test.ts b/app/game-api/test/publicNpcList.test.ts index d17400f..6f51d79 100644 --- a/app/game-api/test/publicNpcList.test.ts +++ b/app/game-api/test/publicNpcList.test.ts @@ -16,12 +16,16 @@ const profile: GameProfile = { name: 'che:default', }; -const buildContext = (): GameApiContext => { +const buildContext = (auth: GameSessionTokenPayload | null = null): GameApiContext => { const generalRows = [ { id: 10, name: '관우', + picture: '10.jpg', + imageServer: 0, npcState: 1, + age: 42, + officerLevel: 5, nationId: 1, leadership: 90, strength: 95, @@ -31,12 +35,16 @@ const buildContext = (): GameApiContext => { personalCode: 'None', specialCode: 'None', special2Code: 'None', - meta: { owner_name: '악령 관우', explevel: 4 }, + meta: { owner_name: '악령 관우', explevel: 4, killturn: 7 }, }, { id: 20, name: '조운', + picture: '20.jpg', + imageServer: 1, npcState: 2, + age: 35, + officerLevel: 0, nationId: 0, leadership: 90, strength: 95, @@ -48,16 +56,61 @@ const buildContext = (): GameApiContext => { special2Code: 'None', meta: { owner_name: '노출 금지', explevel: 5 }, }, + { + id: 30, + name: '유비', + picture: '30.jpg', + imageServer: 0, + npcState: 0, + age: 44, + officerLevel: 12, + nationId: 1, + leadership: 80, + strength: 70, + intel: 85, + experience: 16_000, + dedication: 10_000, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + meta: { owner_name: '노출 금지', explevel: 9 }, + }, ]; const db = { general: { - findMany: async (args: { where: { npcState: { gt: number } } }) => { - expect(args.where).toEqual({ npcState: { gt: 0 } }); + findMany: async (args: { where?: { npcState: { gt: number } } }) => { + if (args.where) { + expect(args.where).toEqual({ npcState: { gt: 0 } }); + return generalRows.filter((general) => general.npcState > 0); + } return generalRows; }, }, nation: { - findMany: async () => [{ id: 1, name: '촉' }], + findMany: async () => [{ id: 1, name: '촉', level: 5 }], + }, + npcSelectionToken: { + findMany: async (args: { where: { validUntil: { gte: Date } } }) => { + expect(args.where.validUntil.gte).toBeInstanceOf(Date); + return [ + { + pickResult: { + 10: { keepCount: 2 }, + invalid: { keepCount: 'bad' }, + }, + }, + { + pickResult: { + 20: { keepCount: -1 }, + }, + }, + ]; + }, + }, + worldState: { + findFirst: async () => ({ + config: { const: { maxLevel: 255, maxDedLevel: 30 } }, + }), }, }; const redis = { @@ -70,7 +123,7 @@ const buildContext = (): GameApiContext => { turnDaemon: new InMemoryTurnDaemonTransport(), battleSim: new InMemoryBattleSimTransport(), profile, - auth: null as GameSessionTokenPayload | null, + auth, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, @@ -89,11 +142,17 @@ describe('public.getNpcList', () => { { id: 10, name: '관우', + picture: '10.jpg', + imageServer: 0, npcState: 1, ownerName: '악령 관우', + age: 42, level: 4, + officerLevel: 5, + killturn: 7, nationId: 1, nationName: '촉', + nationLevel: 5, personality: null, specialDomestic: null, specialWar: null, @@ -102,16 +161,24 @@ describe('public.getNpcList', () => { strength: 95, intelligence: 75, experience: 800, + experienceText: '무명', dedication: 700, + dedicationText: '28품관', }, { id: 20, name: '조운', + picture: '20.jpg', + imageServer: 1, npcState: 2, ownerName: '', + age: 35, level: 5, + officerLevel: 0, + killturn: 0, nationId: 0, nationName: '-', + nationLevel: 0, personality: null, specialDomestic: null, specialWar: null, @@ -120,13 +187,53 @@ describe('public.getNpcList', () => { strength: 95, intelligence: 75, experience: 900, + experienceText: '무명', dedication: 600, + dedicationText: '28품관', }, ]); + expect(result.tokenKeepCounts).toEqual({}); expect(JSON.stringify(result)).not.toContain('노출 금지'); expect(JSON.stringify(result)).not.toContain('userId'); }); + it('returns the full id-ordered list and every active reservation only to an authenticated caller', async () => { + const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-07-31T00:00:00.000Z', + expiresAt: '2026-08-31T00:00:00.000Z', + sessionId: 'npc-list-owner', + user: { + id: 'owner-user', + username: 'owner-user', + displayName: '소유자', + roles: ['user'], + legacyMemberNo: 101, + }, + sanctions: {}, + }; + + const result = await appRouter + .createCaller(buildContext(auth)) + .public.getNpcList({ sort: 1, includeAllWithToken: true }); + + expect(result.tokenKeepCounts).toEqual({ 10: 2, 20: 0 }); + expect(result.generals.map((general) => general.id)).toEqual([10, 20, 30]); + expect(result.generals[2]).toMatchObject({ + npcState: 0, + level: 40, + experienceText: '지역적', + dedicationText: '21품관', + }); + }); + + it('rejects the token-aware full list without an authenticated session', async () => { + await expect( + appRouter.createCaller(buildContext()).public.getNpcList({ sort: 1, includeAllWithToken: true }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + it('keeps pool rows before possessed NPCs when the selected value is tied', async () => { const result = await appRouter.createCaller(buildContext()).public.getNpcList({ sort: 3 }); diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index 8bcb1ae..972a635 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -24,6 +24,7 @@ export * from './turn/inMemoryStateStore.js'; export * from './turn/inMemoryTurnProcessor.js'; export * from './turn/databaseHooks.js'; export * from './turn/joinCreateGeneralService.js'; +export * from './turn/npcPossessionService.js'; export * from './turn/selectPoolService.js'; export * from './turn/turnDaemon.js'; export * from './turn/cli.js'; diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index f694e89..90a2df4 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -270,6 +270,19 @@ const zJoinCreateGeneral = z }) .strict(); +const zNpcPossessGeneral = z + .object({ + type: z.literal('npcPossessGeneral'), + requestId: z.string().optional(), + userId: z.string().min(1), + ownerDisplayName: z.string(), + profileId: z.string().min(1), + ownerLegacyPenalty: zRecord.optional(), + generalId: z.number().int().positive(), + tokenNonce: z.number().int().nonnegative(), + }) + .strict(); + const zSelectPoolCreate = z .object({ type: z.literal('selectPoolCreate'), @@ -546,6 +559,14 @@ const normalizeJoinCreateGeneral: CommandNormalizer<'joinCreateGeneral'> = (enve return { ...command, requestId: envelope.requestId }; }; +const normalizeNpcPossessGeneral: CommandNormalizer<'npcPossessGeneral'> = (envelope) => { + const command = parseWith(zNpcPossessGeneral, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelope) => { const command = parseWith(zSelectPoolCreate, envelope.command); if (!command) { @@ -626,6 +647,7 @@ const normalizers: CommandNormalizerMap = { tournamentMatchResult: normalizeTournamentMatchResult, patchGeneral: normalizePatchGeneral, joinCreateGeneral: normalizeJoinCreateGeneral, + npcPossessGeneral: normalizeNpcPossessGeneral, selectPoolCreate: normalizeSelectPoolCreate, selectPoolReselect: normalizeSelectPoolReselect, getStatus: normalizeGetStatus, diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index b71cf52..3e6183e 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -87,7 +87,7 @@ const fail = (code: JoinCreateGeneralErrorCode, message: string): never => { const normalizeJoinName = (value: string): string => normalizeTroopName(value).replace(LEGACY_JOIN_REMOVED_CHARACTERS, ''); -const resolveLegacyPenalty = ( +export const resolveLegacyPenalty = ( rawPenalty: Record | undefined, profileId: string, acceptedAt: Date diff --git a/app/game-engine/src/turn/npcPossessionService.ts b/app/game-engine/src/turn/npcPossessionService.ts new file mode 100644 index 0000000..3a33a83 --- /dev/null +++ b/app/game-engine/src/turn/npcPossessionService.ts @@ -0,0 +1,524 @@ +import { randomInt } from 'node:crypto'; + +import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { GamePrisma, type DatabaseClient, type GamePrisma as GamePrismaTypes } from '@sammo-ts/infra'; +import { + ActionLogger, + DomesticTraitLoader, + isDomesticTraitKey, + isPersonalityTraitKey, + isWarTraitKey, + PersonalityTraitLoader, + simpleSerialize, + WarTraitLoader, +} from '@sammo-ts/logic'; +import { z } from 'zod'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import { resolveLegacyPenalty } from './joinCreateGeneralService.js'; + +type WorldStateRow = GamePrismaTypes.WorldStateGetPayload>; + +export type NpcPossessionErrorCode = + 'BAD_REQUEST' | 'NOT_FOUND' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR'; + +export class NpcPossessionError extends Error { + constructor( + readonly code: NpcPossessionErrorCode, + message: string + ) { + super(message); + this.name = 'NpcPossessionError'; + } +} + +const zTraitSnapshot = z.object({ + code: z.string(), + name: z.string(), + info: z.string(), +}); + +const zNpcPossessionCandidate = z.object({ + id: z.number().int().positive(), + name: z.string(), + nation: z.object({ + id: z.number().int(), + name: z.string(), + color: z.string(), + }), + stats: z.object({ + leadership: z.number().int(), + strength: z.number().int(), + intelligence: z.number().int(), + }), + picture: z.string().nullable(), + imageServer: z.number().int(), + personality: zTraitSnapshot, + specialDomestic: zTraitSnapshot, + specialWar: zTraitSnapshot, + keepCount: z.number().int().min(0), +}); + +const zNpcPossessionPickResult = z.record(z.string(), zNpcPossessionCandidate); + +export type NpcPossessionCandidate = z.infer; + +export interface NpcPossessionReservation { + tokenNonce: number; + validUntil: string; + pickMoreFrom: string; + pickMoreSeconds: number; + candidates: NpcPossessionCandidate[]; +} + +interface NpcSelectionTokenRow { + ownerUserId: string; + validUntil: Date; + pickMoreFrom: Date; + pickResult: unknown; + nonce: number; +} + +const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000; +const VALID_SECONDS = 90; +const PICK_MORE_SECONDS = 10; +const KEEP_COUNT = 3; +const MAX_PICK_COUNT = 5; +const FIRST_PICK_MORE_FROM = new Date('2000-01-01T01:00:00.000Z'); +const DEFAULT_MAX_GENERAL = 500; + +const fail = (code: NpcPossessionErrorCode, message: string): never => { + throw new NpcPossessionError(code, message); +}; + +const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTime() / 1000) * 1000); + +const formatLegacySeedTime = (value: Date): string => { + const pad = (part: number): string => String(part).padStart(2, '0'); + 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())}`; +}; + +export const buildNpcSelectionTokenSeed = ( + hiddenSeed: string | number, + ownerIdentity: string | number, + now: Date +): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, formatLegacySeedTime(now)); + +const readHiddenSeed = (worldState: WorldStateRow): string | number => { + const meta = asRecord(worldState.meta); + const value = meta.hiddenSeed ?? meta.seed; + if (typeof value === 'string' || typeof value === 'number') { + return value; + } + return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 비밀 seed가 설정되지 않았습니다.'); +}; + +const resolveTurnTermMinutes = (worldState: WorldStateRow): number => { + const config = asRecord(worldState.config); + return Math.max(1, Math.abs(Math.trunc(asNumber(config.turnTermMinutes, Math.round(worldState.tickSeconds / 60))))); +}; + +const resolveMaxGeneral = (worldState: WorldStateRow): number => { + const config = asRecord(worldState.config); + const configConst = asRecord(config.const); + return Math.max( + 0, + Math.floor( + asNumber( + config.maxGeneral ?? config.maxgeneral ?? configConst.defaultMaxGeneral ?? configConst.maxGeneral, + DEFAULT_MAX_GENERAL + ) + ) + ); +}; + +const requireNpcPossessionWorld = (worldState: WorldStateRow): void => { + const config = asRecord(worldState.config); + if (asNumber(config.npcMode ?? config.npcmode, 0) !== 1) { + fail('PRECONDITION_FAILED', '빙의 가능한 서버가 아닙니다'); + } +}; + +const lockNpcPossession = async (db: DatabaseClient, userId: string): Promise => { + // Ref의 서로 다른 owner token 중복과 동일 owner 다중 빙의 race는 데이터 손상 + // 가능성이 있어, 후보 예약과 최종 점유 모두 같은 lock 순서로 직렬화한다. + await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))`); + await db.$executeRaw( + GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${userId}`}, 1))` + ); +}; + +const parsePickResult = (value: unknown): Record => { + const parsed = zNpcPossessionPickResult.safeParse(value); + if (!parsed.success) { + return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 후보 정보가 올바르지 않습니다.'); + } + return parsed.data; +}; + +const toReservation = ( + token: Pick, + now: Date +): NpcPossessionReservation => { + const pickResult = parsePickResult(token.pickResult); + return { + tokenNonce: token.nonce, + validUntil: token.validUntil.toISOString(), + pickMoreFrom: token.pickMoreFrom.toISOString(), + pickMoreSeconds: Math.max(0, Math.ceil((token.pickMoreFrom.getTime() - now.getTime()) / 1000)), + candidates: Object.values(pickResult).sort( + (left, right) => + left.stats.leadership + + left.stats.strength + + left.stats.intelligence - + (right.stats.leadership + right.stats.strength + right.stats.intelligence) || left.id - right.id + ), + }; +}; + +const loadTraitSnapshot = async ( + code: string, + kind: 'personality' | 'domestic' | 'war' +): Promise> => { + const fallback = { code, name: code === 'None' ? '-' : code, info: code === 'None' ? '없음' : '' }; + if (kind === 'personality' && isPersonalityTraitKey(code)) { + const trait = await new PersonalityTraitLoader().load(code); + return { code, name: trait.name, info: trait.info ?? '' }; + } + if (kind === 'domestic' && isDomesticTraitKey(code)) { + const trait = await new DomesticTraitLoader().load(code); + return { code, name: trait.name, info: trait.info ?? '' }; + } + if (kind === 'war' && isWarTraitKey(code)) { + const trait = await new WarTraitLoader().load(code); + return { code, name: trait.name, info: trait.info ?? '' }; + } + return fallback; +}; + +const buildCandidateSnapshot = async ( + row: { + id: number; + name: string; + nationId: number; + leadership: number; + strength: number; + intel: number; + picture: string | null; + imageServer: number; + personalCode: string; + specialCode: string; + special2Code: string; + }, + nation: { id: number; name: string; color: string } | undefined +): Promise => { + const [personality, specialDomestic, specialWar] = await Promise.all([ + loadTraitSnapshot(row.personalCode, 'personality'), + loadTraitSnapshot(row.specialCode, 'domestic'), + loadTraitSnapshot(row.special2Code, 'war'), + ]); + return { + id: row.id, + name: row.name, + nation: nation ?? { id: 0, name: '재야', color: '#666666' }, + stats: { + leadership: row.leadership, + strength: row.strength, + intelligence: row.intel, + }, + picture: row.picture, + imageServer: row.imageServer, + personality, + specialDomestic, + specialWar, + keepCount: KEEP_COUNT, + }; +}; + +const chooseCandidates = ( + candidates: NpcPossessionCandidate[], + kept: Record, + rng: RandUtil +): Record => { + const picked = { ...kept }; + const weights = Object.fromEntries( + candidates.map((candidate) => [ + String(candidate.id), + Math.pow(candidate.stats.leadership + candidate.stats.strength + candidate.stats.intelligence, 1.5), + ]) + ); + const byId = new Map(candidates.map((candidate) => [String(candidate.id), candidate])); + const pickLimit = Math.min(candidates.length, MAX_PICK_COUNT); + while (Object.keys(picked).length < pickLimit) { + const selectedId = String(rng.choiceUsingWeight(weights)); + if (!Object.hasOwn(picked, selectedId)) { + const candidate = byId.get(selectedId); + if (!candidate) { + throw new Error(`NPC 빙의 후보 ${selectedId}를 찾을 수 없습니다.`); + } + picked[selectedId] = candidate; + } + } + return picked; +}; + +export const reserveNpcPossessionCandidates = async (options: { + db: DatabaseClient; + worldState: WorldStateRow; + userId: string; + ownerIdentity: string | number; + refresh?: boolean; + keepIds?: number[]; + now?: Date; +}): Promise => { + const { db, worldState, userId } = options; + requireNpcPossessionWorld(worldState); + const now = truncateToSeconds(options.now ?? new Date()); + await lockNpcPossession(db, userId); + + if (await db.general.findFirst({ where: { userId }, select: { id: true } })) { + fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다'); + } + + const inFlightPossession = await db.inputEvent.findFirst({ + where: { + target: 'ENGINE', + eventType: 'npcPossessGeneral', + actorUserId: userId, + status: { in: ['PENDING', 'PROCESSING'] }, + }, + select: { requestId: true }, + }); + let existing = (await db.npcSelectionToken.findUnique({ + where: { ownerUserId: userId }, + })) as NpcSelectionTokenRow | null; + if (inFlightPossession) { + const inFlightToken = existing; + if (!inFlightToken) { + return fail('CONFLICT', '처리 중인 NPC 빙의 요청이 있습니다.'); + } + if (options.refresh) { + fail('CONFLICT', 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.'); + } + return toReservation(inFlightToken, now); + } + if (existing && existing.validUntil.getTime() < now.getTime()) { + await db.npcSelectionToken.deleteMany({ + where: { + ownerUserId: userId, + nonce: existing.nonce, + validUntil: { lt: now }, + }, + }); + existing = null; + } + + const kept: Record = {}; + if (existing && options.refresh) { + if (now.getTime() < existing.pickMoreFrom.getTime()) { + fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다'); + } + const oldPick = parsePickResult(existing.pickResult); + for (const keepId of options.keepIds ?? []) { + const key = String(keepId); + const candidate = oldPick[key]; + if (candidate && candidate.keepCount > 0) { + kept[key] = { ...candidate, keepCount: candidate.keepCount - 1 }; + } + } + // Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다. + if (Object.keys(kept).length === Object.keys(oldPick).length) { + return toReservation(existing, now); + } + } else if (existing) { + return toReservation(existing, now); + } + + const reservedRows = await db.npcSelectionToken.findMany({ + where: { + ownerUserId: { not: userId }, + validUntil: { gte: now }, + }, + select: { pickResult: true }, + }); + const reservedIds = new Set( + reservedRows.flatMap((row) => Object.keys(parsePickResult(row.pickResult)).map((id) => Number(id))) + ); + const generalRows = await db.general.findMany({ + where: { + userId: null, + npcState: 2, + ...(reservedIds.size > 0 ? { id: { notIn: [...reservedIds] } } : {}), + }, + orderBy: { id: 'asc' }, + select: { + id: true, + name: true, + nationId: true, + leadership: true, + strength: true, + intel: true, + picture: true, + imageServer: true, + personalCode: true, + specialCode: true, + special2Code: true, + }, + }); + const nationRows = await db.nation.findMany({ + select: { id: true, name: true, color: true }, + }); + const nations = new Map(nationRows.map((nation) => [nation.id, nation])); + const candidates = await Promise.all( + generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId))) + ); + const rng = new RandUtil( + new LiteHashDRBG(buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, now)) + ); + const pickResult = chooseCandidates(candidates, kept, rng); + const turnTermMinutes = resolveTurnTermMinutes(worldState); + const validUntil = new Date(now.getTime() + Math.max(VALID_SECONDS, turnTermMinutes * 40) * 1000); + const refreshedPickMoreFrom = new Date( + now.getTime() + Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8)) * 1000 + ); + const nonce = randomInt(0, 0x10000000); + + if (existing) { + const updated = await db.npcSelectionToken.updateMany({ + where: { ownerUserId: userId, nonce: existing.nonce }, + data: { + validUntil, + pickMoreFrom: refreshedPickMoreFrom, + pickResult: pickResult as GamePrisma.InputJsonValue, + nonce, + }, + }); + if (updated.count === 0) { + fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요'); + } + return toReservation({ validUntil, pickMoreFrom: refreshedPickMoreFrom, pickResult, nonce }, now); + } + + try { + await db.npcSelectionToken.create({ + data: { + ownerUserId: userId, + validUntil, + pickMoreFrom: FIRST_PICK_MORE_FROM, + pickResult: pickResult as GamePrisma.InputJsonValue, + nonce, + }, + }); + } catch (error) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002') { + fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요'); + } + throw error; + } + return toReservation({ validUntil, pickMoreFrom: FIRST_PICK_MORE_FROM, pickResult, nonce }, now); +}; + +export const possessNpcGeneral = async (options: { + db: DatabaseClient; + world: InMemoryTurnWorld; + worldState: WorldStateRow; + userId: string; + ownerDisplayName: string; + profileId: string; + ownerLegacyPenalty?: Record; + generalId: number; + tokenNonce: number; + acceptedAt: Date; +}): Promise<{ ok: true; generalId: number }> => { + const { db, world, worldState, userId, generalId, acceptedAt } = options; + const tokenAcceptedAt = truncateToSeconds(acceptedAt); + requireNpcPossessionWorld(worldState); + await lockNpcPossession(db, userId); + await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`); + + if ( + world.listGenerals().some((general) => general.userId === userId) || + (await db.general.findFirst({ where: { userId }, select: { id: true } })) + ) { + fail('PRECONDITION_FAILED', '이미 장수가 생성되어 있습니다.'); + } + + const token = (await db.npcSelectionToken.findFirst({ + where: { + ownerUserId: userId, + nonce: options.tokenNonce, + validUntil: { gte: tokenAcceptedAt }, + }, + })) as NpcSelectionTokenRow | null; + if (!token) { + return fail('PRECONDITION_FAILED', '유효한 장수 목록이 없습니다.'); + } + const pickResult = parsePickResult(token.pickResult); + const picked = pickResult[String(generalId)]; + if (!picked) { + fail('PRECONDITION_FAILED', '선택한 장수가 목록에 없습니다.'); + } + + const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } }); + if (activeCount >= resolveMaxGeneral(worldState)) { + fail('PRECONDITION_FAILED', '더 이상 등록 할 수 없습니다.'); + } + + const row = await db.general.findUnique({ + where: { id: generalId }, + select: { userId: true, npcState: true }, + }); + const general = world.getGeneralById(generalId); + if (!row || row.userId !== null || row.npcState !== 2 || !general || general.userId || general.npcState !== 2) { + return fail('NOT_FOUND', '장수 등록에 실패했습니다.'); + } + + const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, acceptedAt); + world.updateGeneral(generalId, { + userId, + npcState: 1, + penalty, + meta: { + ...general.meta, + npc_org: 2, + ownerName: options.ownerDisplayName, + owner_name: options.ownerDisplayName, + pickYearMonth: worldState.currentYear * 12 + worldState.currentMonth - 1, + killturn: 6, + defence_train: 80, + permission: 'normal', + }, + }); + + await db.generalAccessLog.upsert({ + where: { generalId }, + update: { + userId, + lastRefresh: acceptedAt, + refresh: 0, + refreshTotal: 0, + refreshScore: 0, + refreshScoreTotal: 0, + }, + create: { + generalId, + userId, + lastRefresh: acceptedAt, + }, + }); + await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId } }); + + const logger = new ActionLogger({ generalId }); + const josaYi = JosaUtil.pick(options.ownerDisplayName, '이'); + logger.pushGeneralHistoryLog(`${picked.name}의 육체에 ${options.ownerDisplayName}${josaYi} 빙의되다.`); + logger.pushGlobalActionLog( + `${picked.name}의 육체에 ${options.ownerDisplayName}${josaYi} 빙의됩니다!` + ); + for (const log of logger.flush()) { + world.pushLog(log); + } + return { ok: true, generalId }; +}; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 3843b9e..8770f6e 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -51,6 +51,7 @@ import { SelectPoolError, } from './selectPoolService.js'; import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js'; +import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js'; let itemRegistryPromise: Promise> | null = null; @@ -138,14 +139,17 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => { const resolveCommandAcceptedAt = async ( db: DatabaseClient, - command: Extract + command: Extract< + TurnDaemonCommand, + { type: 'joinCreateGeneral' | 'npcPossessGeneral' | 'selectPoolCreate' | 'selectPoolReselect' } + > ): Promise => { if (!command.requestId) { throw new Error(`${command.type} requestId is required.`); } const event = await db.inputEvent.findUnique({ where: { requestId: command.requestId }, - select: { createdAt: true, actorUserId: true }, + select: { createdAt: true, actorUserId: true, target: true, eventType: true }, }); if (!event) { throw new Error(`ENGINE input event ${command.requestId} is missing.`); @@ -153,6 +157,9 @@ const resolveCommandAcceptedAt = async ( if (event.actorUserId !== command.userId) { throw new Error(`ENGINE input event actor does not match ${command.type} user.`); } + if (event.target !== 'ENGINE' || event.eventType !== command.type) { + throw new Error(`ENGINE input event type does not match ${command.type}.`); + } return event.createdAt; }; @@ -241,6 +248,47 @@ async function handleJoinCreateGeneral( } } +async function handleNpcPossessGeneral( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const db = requireCommandDatabase(ctx); + const worldState = await db.worldState.findUnique({ + where: { id: ctx.world.getState().id }, + }); + if (!worldState) { + throw new Error('NPC possession world state is missing.'); + } + const acceptedAt = await resolveCommandAcceptedAt(db, command); + try { + return { + type: 'npcPossessGeneral', + ...(await possessNpcGeneral({ + db, + world: ctx.world, + worldState, + userId: command.userId, + ownerDisplayName: command.ownerDisplayName, + profileId: command.profileId, + ...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}), + generalId: command.generalId, + tokenNonce: command.tokenNonce, + acceptedAt, + })), + }; + } catch (error) { + if (error instanceof NpcPossessionError) { + return { + type: 'npcPossessGeneral', + ok: false, + code: error.code, + reason: error.message, + }; + } + throw error; + } +} + async function handleSelectPoolCreate( ctx: CommandHandlerContext, command: Extract @@ -2131,6 +2179,8 @@ export const createTurnDaemonCommandHandler = (options: { handlePatchGeneral(ctx, command as Extract), joinCreateGeneral: (command) => handleJoinCreateGeneral(ctx, command as Extract), + npcPossessGeneral: (command) => + handleNpcPossessGeneral(ctx, command as Extract), selectPoolCreate: (command) => handleSelectPoolCreate(ctx, command as Extract), selectPoolReselect: (command) => diff --git a/app/game-engine/test/npcPossessionService.test.ts b/app/game-engine/test/npcPossessionService.test.ts new file mode 100644 index 0000000..17d9406 --- /dev/null +++ b/app/game-engine/test/npcPossessionService.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest'; + +import { buildNpcSelectionTokenSeed } from '../src/turn/npcPossessionService.js'; + +describe('NPC possession legacy token contracts', () => { + it('builds the Ref SelectNPCToken seed from the Seoul whole-second timestamp', () => { + expect(buildNpcSelectionTokenSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe( + 'str(4,seed)|str(14,SelectNPCToken)|int(42)|str(19,2026-07-31 08:59:58)' + ); + }); +}); diff --git a/app/game-frontend/e2e/npcPossession.live.playwright.config.mjs b/app/game-frontend/e2e/npcPossession.live.playwright.config.mjs new file mode 100644 index 0000000..3cf0dce --- /dev/null +++ b/app/game-frontend/e2e/npcPossession.live.playwright.config.mjs @@ -0,0 +1,77 @@ +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 ?? 15134); +const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15135); +const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`; +const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'npc_possession_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.NPC_POSSESSION_LIVE_DATABASE_URL ?? ''; +const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL ?? ''; +const gameSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET ?? ''; +const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; + +if (databaseUrl && databaseSchema !== profileId) { + throw new Error( + `NPC possession live schema must exactly match PLAYWRIGHT_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: ['npcPossessionLive.spec.ts'], + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/npc-possession-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, + }, + }, + { + 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/npcPossession.spec.ts b/app/game-frontend/e2e/npcPossession.spec.ts new file mode 100644 index 0000000..37a2fb0 --- /dev/null +++ b/app/game-frontend/e2e/npcPossession.spec.ts @@ -0,0 +1,359 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`; +const operationNames = (route: Route) => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); + +type FixtureState = { + reservationCalls: number; + reservationInputs: Array>; + rawBodies: unknown[]; + possessInputs: Array>; + hasGeneral: boolean; + injectTimeout: boolean; +}; + +const candidates = Array.from({ length: 5 }, (_, index) => ({ + id: index + 1, + name: `빙의후보${index + 1}`, + nation: { id: 0, name: '재야', color: '#aaaaaa' }, + stats: { + leadership: 40 + index, + strength: 50 + index, + intelligence: 60 + index, + }, + picture: 'default.jpg', + imageServer: index === 0 ? 1 : 0, + personality: { code: 'che_안전', name: '안전', info: '안전을 중시합니다.' }, + specialDomestic: { code: 'che_인덕', name: '인덕', info: '인덕 설명' }, + specialWar: { code: 'che_무쌍', name: '무쌍', info: '무쌍 설명' }, + keepCount: 3, +})); +const generalList = Array.from({ length: 55 }, (_, index) => { + const candidate = candidates[index % candidates.length]!; + return { + id: index + 1, + name: `전체장수${String(index + 1).padStart(2, '0')}`, + picture: candidate.picture, + imageServer: candidate.imageServer, + npcState: index === 54 ? 1 : 2, + ownerName: index === 54 ? '빙의자' : '', + age: 25 + (index % 20), + level: 3 + (index % 5), + officerLevel: index % 5, + killturn: index % 10, + nationId: 0, + nationName: '재야', + nationLevel: 0, + personality: { key: 'che_안전', name: '안전', info: '안전을 중시합니다.' }, + specialDomestic: { key: 'che_인덕', name: '인덕', info: '인덕 설명' }, + specialWar: { key: 'che_무쌍', name: '무쌍', info: '무쌍 설명' }, + statTotal: 150 + index, + leadership: 40 + (index % 30), + strength: 50 + (index % 20), + intelligence: 60 + (index % 10), + experience: 800 + index, + experienceText: '무명', + dedication: 700 + index, + dedicationText: '28품관', + }; +}); +generalList.push({ + ...generalList[0]!, + id: 56, + name: '사용자장수', + npcState: 0, + ownerName: '', + statTotal: 230, + leadership: 80, + strength: 70, + intelligence: 80, + experience: 16_000, + experienceText: '지역적', + dedication: 10_000, + dedicationText: '21품관', +}); + +const findInput = (value: unknown): Record => { + if (!value || typeof value !== 'object') return {}; + if ('json' in value && value.json && typeof value.json === 'object') { + return value.json as Record; + } + const record = value as Record; + if ( + ['refresh', 'keepIds', 'generalId', 'tokenNonce', 'clientRequestId'].some((key) => Object.hasOwn(record, key)) + ) { + return record; + } + for (const nested of Object.values(value)) { + const input = findInput(nested); + if (Object.keys(input).length > 0) return input; + } + return {}; +}; + +const installFixture = async (page: Page, state: FixtureState): Promise => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_npc_possession'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + await page.route('**/image/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }); + }); + await page.route('**/gateway/api/user-icons/default.jpg', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }); + }); + await page.route('**/events**', async (route) => route.abort()); + await page.route(`**${basePath}/api/trpc/**`, async (route) => { + const operations = operationNames(route); + const rawBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; + state.rawBodies.push(rawBody); + const body = rawBody && typeof rawBody === 'object' ? (rawBody as Record) : {}; + const results = operations.map((operation, index) => { + const input = findInput(body[String(index)] ?? body); + if (operation === 'lobby.info') { + return response({ + myGeneral: state.hasGeneral ? { id: 1, name: '빙의후보1' } : null, + year: 180, + month: 1, + turnTerm: 5, + }); + } + if (operation === 'join.getConfig') { + return response({ + rules: { + stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 }, + allowCustomName: true, + }, + user: { id: 'npc-user', displayName: '빙의사용자', canCreateGeneral: true }, + personalities: [{ key: 'Random', name: '???', info: '' }], + warSpecials: [], + nations: [], + serverInfo: { + currentYear: 180, + currentMonth: 1, + tickMinutes: 5, + maxGeneral: 500, + userGeneralCount: 0, + npcGeneralCount: 12, + }, + inherit: { + totalPoint: 0, + costs: { + inheritBornSpecialPoint: 0, + inheritBornTurntimePoint: 0, + inheritBornCityPoint: 0, + inheritBornStatPoint: 0, + }, + availableCities: [], + turnTimeZones: [], + availableSpecialWar: [], + }, + selectionPool: { enabled: false }, + npcPossession: { enabled: true }, + }); + } + if (operation === 'join.listPossessCandidates') { + state.reservationCalls += 1; + state.reservationInputs.push(input); + const refresh = input.refresh === true; + const now = Date.now(); + const keepIds = Array.isArray(input.keepIds) ? input.keepIds : []; + return response({ + tokenNonce: refresh ? 202 : 101, + validUntil: new Date(now + 90_000).toISOString(), + pickMoreFrom: new Date(refresh ? now + 1_200 : now - 1_000).toISOString(), + pickMoreSeconds: refresh ? 2 : 0, + candidates: candidates.map((candidate) => ({ + ...candidate, + keepCount: refresh && keepIds.includes(candidate.id) ? 2 : 3, + })), + }); + } + if (operation === 'public.getNpcList') { + return response({ + sort: 1, + generals: generalList, + tokenKeepCounts: { 1: 2, 2: 1 }, + }); + } + if (operation === 'join.possessGeneral') { + state.possessInputs.push(input); + if (state.injectTimeout) { + state.injectTimeout = false; + return { + error: { + message: + 'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.', + code: -32008, + data: { + code: 'TIMEOUT', + httpStatus: 408, + path: 'join.possessGeneral', + }, + }, + }; + } + state.hasGeneral = true; + return response({ ok: true, generalId: Number(input.generalId) }); + } + return response({}); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(operations.length === 1 ? results[0] : results), + }); + }); +}; + +test('renders Ref-shaped token cards, preserves keep cooldown and retries possession with one ID', async ({ + page, +}, testInfo) => { + const state: FixtureState = { + reservationCalls: 0, + reservationInputs: [], + rawBodies: [], + possessInputs: [], + hasGeneral: false, + injectTimeout: true, + }; + await installFixture(page, state); + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto('join?tab=possess'); + await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/); + + await expect(page.locator('.npc-card')).toHaveCount(5); + await expect(page.getByText(/까지 유효/)).toBeVisible(); + const geometry = await page.locator('.npc-possession-section').evaluate((section) => { + const sectionRect = section.getBoundingClientRect(); + const cards = [...section.querySelectorAll('.npc-card')]; + const image = section.querySelector('.npc-card-image'); + return { + sectionWidth: sectionRect.width, + cardWidths: cards.map((card) => card.getBoundingClientRect().width), + imageWidth: image?.getBoundingClientRect().width, + imageHeight: image?.getBoundingClientRect().height, + imageNaturalWidth: image?.naturalWidth, + imageNaturalHeight: image?.naturalHeight, + }; + }); + expect(geometry).toEqual({ + sectionWidth: 1000, + cardWidths: [125, 125, 125, 125, 125], + imageWidth: 64, + imageHeight: 64, + imageNaturalWidth: 64, + imageNaturalHeight: 64, + }); + const tooltip = page.locator('.npc-tooltip').first(); + const tooltipPopup = tooltip.getByRole('tooltip'); + await expect(tooltipPopup).toBeHidden(); + await tooltip.hover(); + await expect(tooltipPopup).toBeVisible(); + await tooltip.focus(); + await expect(tooltip).toBeFocused(); + await expect(tooltipPopup).toHaveText('안전을 중시합니다.'); + await expect(page.locator('.npc-card-image').first()).toHaveAttribute('src', '/gateway/api/user-icons/default.jpg'); + + await page.locator('#btn-load-general-list').click(); + await expect(page.locator('#tb-general-list')).toBeVisible(); + await expect(page.locator('#tb-general-list tbody tr')).toHaveCount(50); + await expect(page.locator('#tb-general-list tbody tr').first()).toHaveAttribute('data-general-id', '56'); + await expect(page.locator('#tb-general-list tbody tr').first()).toHaveAttribute('data-reservation-state', '2'); + const selectedRow = page.locator('#tb-general-list tbody tr[data-general-id="1"]'); + await expect(selectedRow).toHaveAttribute('data-reservation-state', '1'); + await expect(selectedRow.locator('.npc-general-name')).toHaveCSS('color', 'rgb(238, 130, 238)'); + await expect(selectedRow.locator('.npc-general-name')).toContainText('(2회)'); + const listGeometry = await page.locator('#tb-general-list').evaluate((table) => { + const rect = table.getBoundingClientRect(); + const icon = table.querySelector('.npc-general-icon'); + return { + width: rect.width, + iconWidth: icon?.getBoundingClientRect().width, + iconHeight: icon?.getBoundingClientRect().height, + iconNaturalWidth: icon?.naturalWidth, + iconNaturalHeight: icon?.naturalHeight, + }; + }); + expect(listGeometry).toEqual({ + width: 970, + iconWidth: 64, + iconHeight: 64, + iconNaturalWidth: 64, + iconNaturalHeight: 64, + }); + await page.locator('#btn-print-more-generals').click(); + await expect(page.locator('#tb-general-list tbody tr')).toHaveCount(56); + await expect(page.locator('#btn-print-more-generals')).toHaveCount(0); + await page.screenshot({ + path: testInfo.outputPath('npc-possession-candidates-and-list.png'), + fullPage: true, + }); + + await page.locator('.npc-keep input').first().check(); + await page.getByRole('button', { name: '다른 장수 보기' }).click(); + await expect.poll(() => state.reservationCalls).toBe(2); + expect(state.reservationInputs.at(-1), JSON.stringify(state.rawBodies.at(-1))).toMatchObject({ + refresh: true, + keepIds: [1], + }); + await expect(page.locator('.npc-keep').first()).toContainText('보관(2회)'); + const refreshButton = page.locator('#btn-pick-more'); + await expect(refreshButton).toBeDisabled(); + await expect(refreshButton).toContainText(/다른 장수 보기\([12]초\)/); + await expect(refreshButton).toBeEnabled({ timeout: 3_000 }); + + const dialogs: string[] = []; + page.on('dialog', async (dialog) => { + dialogs.push(dialog.message()); + if ( + dialog.type() === 'confirm' && + dialogs.filter((message) => message.startsWith('빙의할까요?')).length === 1 + ) { + await dialog.dismiss(); + return; + } + await dialog.accept(); + }); + const possessButton = page.locator('.npc-action').first(); + await possessButton.click(); + expect(state.possessInputs).toHaveLength(0); + + await possessButton.click(); + await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.'); + expect(state.possessInputs).toHaveLength(1); + const firstRequestId = state.possessInputs[0]?.clientRequestId; + expect(firstRequestId).toMatch(/^[0-9a-f-]{36}$/); + expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toContain( + firstRequestId as string + ); + + await page.evaluate(() => { + const expiredNow = Date.now() + 120_000; + Date.now = () => expiredNow; + }); + await expect(page.locator('.npc-token-expired')).toBeVisible(); + await expect(possessButton).toBeEnabled(); + await expect(refreshButton).toBeDisabled(); + await page.locator('#btn-retry-possession').click(); + await expect(page).toHaveURL(new RegExp(`${basePath}/$`)); + expect(state.possessInputs).toHaveLength(2); + expect(state.possessInputs[1]?.clientRequestId).toBe(firstRequestId); + expect(dialogs).toContain('빙의에 성공했습니다.'); + expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull(); + + await page.screenshot({ + path: testInfo.outputPath('npc-possession-success.png'), + fullPage: true, + }); +}); diff --git a/app/game-frontend/e2e/npcPossessionLive.spec.ts b/app/game-frontend/e2e/npcPossessionLive.spec.ts new file mode 100644 index 0000000..07d83b1 --- /dev/null +++ b/app/game-frontend/e2e/npcPossessionLive.spec.ts @@ -0,0 +1,241 @@ +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.NPC_POSSESSION_LIVE_DATABASE_URL; +const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL; +const gameTokenSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET; +const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'npc_possession_integration:2'; +const scenarioId = Number(process.env.PLAYWRIGHT_SCENARIO ?? '2'); +const userId = 'npc-possession-live-user'; +const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret); + +if (!Number.isSafeInteger(scenarioId) || scenarioId <= 0 || profile !== `${profile.split(':', 1)[0]}:${scenarioId}`) { + throw new Error(`NPC possession live scenario/profile mismatch: ${profile} / ${scenarioId}`); +} + +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: `npc-possession-live-${randomUUID()}`, + user: { + id: userId, + username: 'npc-possession-live-user', + displayName: '브라우저빙의', + roles: ['user'], + legacyMemberNo: 7_710, + canUseGeneralPicture: false, + }, + sanctions: {}, + identity: { + kakaoVerified: true, + canCreateGeneral: true, + requiresKakaoVerification: false, + graceEndsAt: null, + }, + }, + gameTokenSecret! + ); + await page.addInitScript( + ({ token, gameProfile }) => { + window.localStorage.setItem('sammo-game-token', token); + window.localStorage.setItem('sammo-game-profile', gameProfile); + }, + { token: gameToken, gameProfile: profile } + ); +}; + +test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and Chromium', () => { + test.skip(!hasLiveFixture, 'live NPC possession token 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'); + const profileId = profile.split(':', 1)[0] ?? ''; + if (schema !== profileId || !schema.endsWith('npc_possession_integration')) { + throw new Error( + `Refusing mismatched or non-dedicated schema: ${schema ?? '(missing)'} != ${profileId ?? '(missing)'}` + ); + } + const previousSeed = process.env.INTEGRATION_WORLD_SEED; + process.env.INTEGRATION_WORLD_SEED = 'npc-possession-live-seed'; + try { + await seedScenarioToDatabase({ + scenarioId, + databaseUrl: databaseUrl!, + now: new Date('2099-07-31T12:00:00.000Z'), + installOptions: { + turnTermMinutes: 5, + npcMode: 1, + 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(); + await db.npcSelectionToken.deleteMany(); + const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); + await db.general.createMany({ + data: Array.from({ length: 12 }, (_, index) => ({ + id: index + 1, + userId: null, + name: `실브라우저후보${index + 1}`, + nationId: 0, + cityId: city.id, + npcState: 2, + leadership: 40 + index, + strength: 50 + index, + intel: 60 + index, + turnTime: new Date('2099-07-31T12:05:00.000Z'), + personalCode: 'che_안전', + specialCode: 'che_인덕', + special2Code: 'che_무쌍', + picture: 'default.jpg', + imageServer: 0, + meta: { killturn: 6 }, + penalty: {}, + })), + }); + runtime = await createTurnDaemonRuntime({ + profile, + databaseUrl: databaseUrl!, + enableDatabaseFlush: true, + enableLeaseHeartbeat: false, + leaseOwnerId: 'npc-possession-live-daemon', + }); + daemonLoop = runtime.lifecycle.start(); + }); + + test.afterAll(async () => { + if (runtime) { + await runtime.lifecycle.stop('NPC possession live complete'); + await daemonLoop; + await runtime.close(); + } + await closeDb?.(); + }); + + test('replays one completed possession after the browser receives an indeterminate timeout', async ({ + page, + }, testInfo) => { + const requestIds: string[] = []; + let injectTimeout = true; + await installSession(page); + await page.route('**/image/icons/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'image/svg+xml', + body: '', + }); + }); + await page.route('**/trpc/join.possessGeneral?batch=1', async (route) => { + const findClientRequestId = (value: unknown): string | undefined => { + if (!value || typeof value !== 'object') return undefined; + if ('clientRequestId' in value && typeof value.clientRequestId === 'string') { + return value.clientRequestId; + } + for (const nested of Object.values(value)) { + const found = findClientRequestId(nested); + if (found) return found; + } + return undefined; + }; + const clientRequestId = findClientRequestId(route.request().postDataJSON()); + expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/); + requestIds.push(clientRequestId!); + if (!injectTimeout) { + await route.continue(); + return; + } + injectTimeout = false; + const accepted = await route.fetch(); + expect(accepted.ok()).toBe(true); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + error: { + message: + 'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.', + code: -32008, + data: { + code: 'TIMEOUT', + httpStatus: 408, + path: 'join.possessGeneral', + }, + }, + }, + ]), + }); + }); + + await page.setViewportSize({ width: 1024, height: 900 }); + await page.goto('join?tab=possess'); + await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/); + await expect(page.locator('.npc-card')).toHaveCount(5); + const geometry = await page.locator('.npc-possession-section').evaluate((section) => ({ + width: section.getBoundingClientRect().width, + cardWidths: [...section.querySelectorAll('.npc-card')].map( + (card) => card.getBoundingClientRect().width + ), + })); + expect(geometry).toEqual({ + width: 1000, + cardWidths: [125, 125, 125, 125, 125], + }); + + const dialogs: string[] = []; + page.on('dialog', async (dialog) => { + dialogs.push(dialog.message()); + await dialog.accept(); + }); + const possessButton = page.locator('.npc-action').first(); + await possessButton.click(); + await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.'); + await expect.poll(() => db.general.count({ where: { userId } })).toBe(1); + const pending = await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action')); + expect(pending).toContain(requestIds[0]); + + await possessButton.click(); + await expect(page).toHaveURL(/\/hwe\/$/); + expect(requestIds).toHaveLength(2); + expect(requestIds[1]).toBe(requestIds[0]); + expect(await db.general.count({ where: { userId } })).toBe(1); + expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull(); + expect(dialogs).toContain('빙의에 성공했습니다.'); + const event = await db.inputEvent.findFirstOrThrow({ + where: { actorUserId: userId, eventType: 'npcPossessGeneral' }, + }); + expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 }); + + await page.screenshot({ + path: testInfo.outputPath('npc-possession-live-success.png'), + fullPage: true, + }); + }); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index a5b8d43..040ef12 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -29,6 +29,7 @@ export default defineConfig({ 'commandArgumentsLive.spec.ts', 'mainNavigation.spec.ts', 'session-auth.spec.ts', + 'npcPossession.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/game-frontend/e2e/playwright.live.tsconfig.json b/app/game-frontend/e2e/playwright.live.tsconfig.json index 9af68ba..a284204 100644 --- a/app/game-frontend/e2e/playwright.live.tsconfig.json +++ b/app/game-frontend/e2e/playwright.live.tsconfig.json @@ -10,5 +10,10 @@ "target": "ES2022", "types": ["node", "@playwright/test"] }, - "include": ["./joinGeneralLive.spec.ts", "./joinGeneral.live.playwright.config.mjs"] + "include": [ + "./joinGeneralLive.spec.ts", + "./joinGeneral.live.playwright.config.mjs", + "./npcPossessionLive.spec.ts", + "./npcPossession.live.playwright.config.mjs" + ] } diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index c7ec276..477a3ae 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -15,6 +15,8 @@ "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "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", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/env.d.ts b/app/game-frontend/src/env.d.ts index 9075980..2d4ff65 100644 --- a/app/game-frontend/src/env.d.ts +++ b/app/game-frontend/src/env.d.ts @@ -14,6 +14,7 @@ interface ImportMetaEnv { readonly VITE_GAME_ASSET_URL?: string; readonly VITE_GAME_PROFILE?: string; readonly VITE_GATEWAY_WEB_URL?: string; + readonly VITE_GATEWAY_USER_ICON_BASE_URL?: string; readonly VITE_BOARD_COMMUNITY_URL?: string; readonly VITE_BOARD_REQUEST_URL?: string; readonly VITE_BOARD_TIP_URL?: string; diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index b7d58ae..0d7afd2 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -1,15 +1,23 @@