From 115218ded89ce5e90aff1d46c2cfd4a3904d915f Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 30 Jul 2026 23:27:59 +0000 Subject: [PATCH] feat: port scenario 903 select-pool flow --- app/game-api/package.json | 1 + app/game-api/src/auth/accessTokenStore.ts | 4 +- app/game-api/src/battleSim/processor.ts | 9 +- app/game-api/src/battleSim/schema.ts | 1 + .../src/battleSim/simulatorOptions.ts | 8 +- app/game-api/src/battleSim/types.ts | 1 + app/game-api/src/daemon/databaseTransport.ts | 18 +- app/game-api/src/router/auth/index.ts | 9 +- app/game-api/src/router/battle/index.ts | 3 + app/game-api/src/router/join/index.ts | 168 +- app/game-api/src/router/lobby/index.ts | 5 +- app/game-api/src/router/nation/shared.ts | 13 + app/game-api/src/services/selectPool.ts | 12 + app/game-api/src/trpc.ts | 5 + app/game-api/test/battleSimProcessor.test.ts | 10 + app/game-api/test/battleSimRouter.test.ts | 4 + .../inputEventBoundary.integration.test.ts | 27 +- app/game-api/test/router.test.ts | 23 + .../test/selectPool.integration.test.ts | 582 ++++++ app/game-api/test/selectPoolRng.test.ts | 75 + app/game-api/tsconfig.json | 11 +- app/game-engine/src/index.ts | 2 + .../src/lifecycle/databaseCommandQueue.ts | 70 +- .../src/lifecycle/turnDaemonLifecycle.ts | 11 + app/game-engine/src/lifecycle/types.ts | 1 + .../src/scenario/generalPoolLoader.ts | 93 + .../src/scenario/scenarioSeeder.ts | 39 +- app/game-engine/src/turn/commandRegistry.ts | 40 + app/game-engine/src/turn/databaseHooks.ts | 15 + app/game-engine/src/turn/inMemoryWorld.ts | 7 +- app/game-engine/src/turn/selectPoolService.ts | 889 ++++++++ app/game-engine/src/turn/types.ts | 1 + .../src/turn/worldCommandHandler.ts | 119 +- app/game-engine/src/turn/worldLoader.ts | 1 + .../databaseCommandQueue.integration.test.ts | 44 + .../test/generalPoolLoader.test.ts | 64 + .../test/inputEventAtomicity.test.ts | 12 + app/game-engine/test/scenarioSeeder.test.ts | 171 ++ ...PoolReleasePersistence.integration.test.ts | 274 +++ app/game-engine/test/turnOrder.test.ts | 5 +- app/game-frontend/e2e/battleSimulator.spec.ts | 62 +- app/game-frontend/e2e/playwright.config.mjs | 1 + .../selectGeneral.live.playwright.config.mjs | 40 + .../e2e/selectGeneralLive.spec.ts | 519 +++++ app/game-frontend/e2e/session-auth.spec.ts | 114 + .../components/battle/BattleGeneralCard.vue | 15 + app/game-frontend/src/router/index.ts | 9 + app/game-frontend/src/stores/session.ts | 37 +- .../src/utils/battleSimulatorTypes.ts | 2 + app/game-frontend/src/utils/legacyDateTime.ts | 22 + .../src/views/BattleSimulatorView.vue | 6 + app/game-frontend/src/views/JoinView.vue | 4 + app/game-frontend/src/views/MyPageView.vue | 34 +- .../src/views/SelectGeneralView.vue | 728 +++++++ .../src/auth/inMemorySessionService.ts | 2 + .../src/auth/postgresUserRepository.ts | 10 + .../src/auth/redisSessionService.ts | 2 + app/gateway-api/src/auth/sessionService.ts | 2 + app/gateway-api/src/auth/userRepository.ts | 1 + app/gateway-api/src/router.ts | 1 + app/gateway-api/test/authFlow.test.ts | 28 + .../e2e/lobby-game-auth.spec.ts | 159 ++ .../e2e/playwright.config.mjs | 1 + app/gateway-frontend/src/utils/gameTrpc.ts | 3 +- app/gateway-frontend/src/views/LobbyView.vue | 59 +- packages/common/src/auth/gameToken.ts | 5 +- packages/common/src/turnDaemon/types.ts | 38 + packages/infra/prisma/game.prisma | 13 + .../migration.sql | 17 + packages/infra/src/db.ts | 1 + packages/infra/src/turnEngineDb.ts | 6 + packages/logic/src/actionModules/bundle.ts | 12 +- .../traits/eventDomestic/index.ts | 126 ++ .../logic/src/actionModules/traits/index.ts | 1 + .../src/actionModules/traits/war/che_견고.ts | 9 +- packages/logic/src/war/triggers/che_견고.ts | 4 +- .../logic/test/eventDomesticTrait.test.ts | 83 + pnpm-lock.yaml | 3 + resources/general-pool/SPoolUnderU30.json | 1849 +++++++++++++++++ .../test/battleDifferential.test.ts | 32 +- 80 files changed, 6859 insertions(+), 48 deletions(-) create mode 100644 app/game-api/src/services/selectPool.ts create mode 100644 app/game-api/test/selectPool.integration.test.ts create mode 100644 app/game-api/test/selectPoolRng.test.ts create mode 100644 app/game-engine/src/scenario/generalPoolLoader.ts create mode 100644 app/game-engine/src/turn/selectPoolService.ts create mode 100644 app/game-engine/test/generalPoolLoader.test.ts create mode 100644 app/game-engine/test/selectPoolReleasePersistence.integration.test.ts create mode 100644 app/game-frontend/e2e/selectGeneral.live.playwright.config.mjs create mode 100644 app/game-frontend/e2e/selectGeneralLive.spec.ts create mode 100644 app/game-frontend/e2e/session-auth.spec.ts create mode 100644 app/game-frontend/src/utils/legacyDateTime.ts create mode 100644 app/game-frontend/src/views/SelectGeneralView.vue create mode 100644 app/gateway-frontend/e2e/lobby-game-auth.spec.ts create mode 100644 packages/infra/prisma/migrations/20260730002000_add_select_pool/migration.sql create mode 100644 packages/logic/src/actionModules/traits/eventDomestic/index.ts create mode 100644 packages/logic/test/eventDomesticTrait.test.ts create mode 100644 resources/general-pool/SPoolUnderU30.json diff --git a/app/game-api/package.json b/app/game-api/package.json index e4499f9..700d003 100644 --- a/app/game-api/package.json +++ b/app/game-api/package.json @@ -34,6 +34,7 @@ "@fastify/cors": "^11.2.0", "@fastify/static": "^9.0.0", "@sammo-ts/common": "workspace:*", + "@sammo-ts/game-engine": "workspace:*", "@sammo-ts/infra": "workspace:*", "@sammo-ts/logic": "workspace:*", "@trpc/server": "^11.8.1", diff --git a/app/game-api/src/auth/accessTokenStore.ts b/app/game-api/src/auth/accessTokenStore.ts index ac975cd..4c9b6cd 100644 --- a/app/game-api/src/auth/accessTokenStore.ts +++ b/app/game-api/src/auth/accessTokenStore.ts @@ -41,7 +41,9 @@ const parsePayload = (value: unknown): GameSessionTokenPayload | null => { typeof user.id !== 'string' || typeof user.username !== 'string' || typeof user.displayName !== 'string' || - !Array.isArray(user.roles) + !Array.isArray(user.roles) || + (user.legacyMemberNo !== undefined && + (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0)) ) { return null; } diff --git a/app/game-api/src/battleSim/processor.ts b/app/game-api/src/battleSim/processor.ts index cf27089..ef4f3ef 100644 --- a/app/game-api/src/battleSim/processor.ts +++ b/app/game-api/src/battleSim/processor.ts @@ -19,7 +19,9 @@ import { createTraitCatalog, createOfficerLevelActionModules, DOMESTIC_TRAIT_KEYS, + EVENT_DOMESTIC_TRAIT_KEYS, loadDomesticTraitModules, + loadEventDomesticTraitModules, loadNationTraitModules, loadPersonalityTraitModules, loadWarTraitModules, @@ -52,7 +54,10 @@ const itemWarModules: WarActionModule[] = createItemActionModules( ).war; const crewTypeWarTriggerRegistry = createCrewTypeWarTriggerRegistry(); const traitCatalog = createTraitCatalog({ - domestic: await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS]), + domestic: [ + ...(await loadDomesticTraitModules([...DOMESTIC_TRAIT_KEYS])), + ...(await loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS])), + ], war: await loadWarTraitModules([...WAR_TRAIT_KEYS]), personality: await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]), nation: await loadNationTraitModules([...NATION_TRAIT_KEYS]), @@ -145,7 +150,7 @@ const mapGeneralPayload = (payload: BattleSimJobPayload['attackerGeneral']): Gen officerLevel: payload.officer_level, role: { personality: payload.personal, - specialDomestic: null, + specialDomestic: payload.special ?? null, specialWar: payload.special2, items: { horse: normalizeItemCode(payload.horse), diff --git a/app/game-api/src/battleSim/schema.ts b/app/game-api/src/battleSim/schema.ts index 3327a81..5941911 100644 --- a/app/game-api/src/battleSim/schema.ts +++ b/app/game-api/src/battleSim/schema.ts @@ -8,6 +8,7 @@ export const zBattleSimGeneral = z.object({ nation: z.number().int().positive(), turntime: z.string().min(1), personal: z.string().nullable(), + special: z.string().nullable().optional(), special2: z.string().nullable(), crew: z.number().int().min(0), crewtype: z.number().int().positive(), diff --git a/app/game-api/src/battleSim/simulatorOptions.ts b/app/game-api/src/battleSim/simulatorOptions.ts index 6a1779f..51b80dd 100644 --- a/app/game-api/src/battleSim/simulatorOptions.ts +++ b/app/game-api/src/battleSim/simulatorOptions.ts @@ -1,5 +1,7 @@ import { ITEM_KEYS, + EVENT_DOMESTIC_TRAIT_KEYS, + loadEventDomesticTraitModules, loadItemModules, loadNationTraitModules, loadPersonalityTraitModules, @@ -95,22 +97,26 @@ const toTraitOption = (module: TraitModule): BattleSimTraitOption => ({ let cachedTraitOptions: Promise<{ nationTypes: BattleSimTraitOption[]; + eventDomesticTraits: BattleSimTraitOption[]; warTraits: BattleSimTraitOption[]; personalities: BattleSimTraitOption[]; }> | null = null; export const loadBattleSimTraitOptions = async (): Promise<{ nationTypes: BattleSimTraitOption[]; + eventDomesticTraits: BattleSimTraitOption[]; warTraits: BattleSimTraitOption[]; personalities: BattleSimTraitOption[]; }> => { if (!cachedTraitOptions) { cachedTraitOptions = Promise.all([ loadNationTraitModules([...NATION_TRAIT_KEYS]), + loadEventDomesticTraitModules([...EVENT_DOMESTIC_TRAIT_KEYS]), loadWarTraitModules([...WAR_TRAIT_KEYS]), loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS]), - ]).then(([nationTraits, warTraits, personalities]) => ({ + ]).then(([nationTraits, eventDomesticTraits, warTraits, personalities]) => ({ nationTypes: nationTraits.map(toTraitOption), + eventDomesticTraits: eventDomesticTraits.map(toTraitOption), warTraits: warTraits.map(toTraitOption), personalities: personalities.map(toTraitOption), })); diff --git a/app/game-api/src/battleSim/types.ts b/app/game-api/src/battleSim/types.ts index 1369a68..fb981a6 100644 --- a/app/game-api/src/battleSim/types.ts +++ b/app/game-api/src/battleSim/types.ts @@ -10,6 +10,7 @@ export interface BattleSimGeneralPayload { nation: number; turntime: string; personal: string | null; + special?: string | null; special2: string | null; crew: number; crewtype: number; diff --git a/app/game-api/src/daemon/databaseTransport.ts b/app/game-api/src/daemon/databaseTransport.ts index 8aeef74..ec3f9bc 100644 --- a/app/game-api/src/daemon/databaseTransport.ts +++ b/app/game-api/src/daemon/databaseTransport.ts @@ -29,6 +29,16 @@ export class ConflictingTurnDaemonCommandError extends Error { } } +export class FailedTurnDaemonCommandError extends Error { + constructor( + readonly requestId: string, + readonly storedError: string | null + ) { + super(storedError ?? `Engine input event ${requestId} failed.`); + this.name = 'FailedTurnDaemonCommandError'; + } +} + export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { constructor( private readonly db: DatabaseClient, @@ -45,6 +55,10 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { target: 'ENGINE', eventType: command.type, payload: asJson(durableCommand), + actorUserId: + 'userId' in command && typeof command.userId === 'string' + ? command.userId + : null, }, }); } catch (error) { @@ -80,13 +94,13 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport { while (Date.now() < deadline) { const event = await this.db.inputEvent.findUnique({ where: { requestId }, - select: { status: true, result: true }, + select: { status: true, result: true, error: true }, }); if (event?.status === 'SUCCEEDED') { return event.result as T; } if (event?.status === 'FAILED') { - return null; + throw new FailedTurnDaemonCommandError(requestId, event.error); } await delay(Math.min(50, Math.max(1, deadline - Date.now()))); } diff --git a/app/game-api/src/router/auth/index.ts b/app/game-api/src/router/auth/index.ts index 653c393..b97db67 100644 --- a/app/game-api/src/router/auth/index.ts +++ b/app/game-api/src/router/auth/index.ts @@ -5,7 +5,7 @@ import { isAfter, isValid, parseISO } from 'date-fns'; import { z } from 'zod'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; -import { procedure, router } from '../../trpc.js'; +import { authedProcedure, procedure, router } from '../../trpc.js'; const parseDate = (value: string): Date | null => { const parsed = parseISO(value); @@ -45,6 +45,13 @@ const verifyGatewayToken = ( }; export const authRouter = router({ + status: authedProcedure.query(({ ctx }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + return { userId }; + }), exchangeGatewayToken: procedure .input(z.object({ gatewayToken: z.string().min(1) })) .mutation(async ({ ctx, input }) => { diff --git a/app/game-api/src/router/battle/index.ts b/app/game-api/src/router/battle/index.ts index f77da37..5768462 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -112,6 +112,7 @@ export const battleRouter = router({ crewTypes, }, nationTypes: traits.nationTypes, + eventDomesticTraits: traits.eventDomesticTraits, warTraits: traits.warTraits, personalities: traits.personalities, items, @@ -207,6 +208,7 @@ export const battleRouter = router({ bookCode: true, itemCode: true, personalCode: true, + specialCode: true, special2Code: true, meta: true, }, @@ -241,6 +243,7 @@ export const battleRouter = router({ injury: general.injury, rice: general.rice, personal: normalizeOptionalKey(general.personalCode), + special: normalizeOptionalKey(general.specialCode), special2: normalizeOptionalKey(general.special2Code), crew: general.crew, crewtype: general.crewTypeId, diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 66c791a..6d54ef5 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -2,8 +2,8 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { randomBytes } from 'node:crypto'; -import type { DatabaseClient, WorldStateRow } from '../../context.js'; -import { authedProcedure, router } from '../../trpc.js'; +import type { DatabaseClient, GameApiContext, WorldStateRow } from '../../context.js'; +import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common'; import { isPersonalityTraitKey, @@ -21,6 +21,58 @@ import { resolveInheritConstants, setInheritancePoint, } from '../../services/inheritance.js'; +import { + getSelectionPoolStatus, + reserveSelectionPool, + resolveSelectionMaxGeneral, +} from '../../services/selectPool.js'; + +const resolveSelectionCommandResult = ( + result: + | Awaited> + | null, + expectedType: 'selectPoolCreate' | 'selectPoolReselect' +): { ok: true; generalId: number } => { + if (!result) { + throw new TRPCError({ + code: 'TIMEOUT', + message: + '장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.', + }); + } + if (result.type !== expectedType) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: '턴 데몬이 올바르지 않은 장수 선택 결과를 반환했습니다.', + }); + } + if (!result.ok) { + throw new TRPCError({ + code: result.code, + message: result.reason, + }); + } + return { ok: true, generalId: result.generalId }; +}; + +const resolveSelectionRequestId = ( + contextRequestId: string | undefined, + userId: string, + clientRequestId: string | undefined, + operation: 'create' | 'reselect' +): string | undefined => { + if (clientRequestId) { + return `select-pool:${userId}:${clientRequestId}:${operation}`; + } + if (!contextRequestId) { + return undefined; + } + const path = + operation === 'create' + ? 'join.selectPoolGeneral' + : 'join.reselectPoolGeneral'; + return `${contextRequestId}:${path}`; +}; const DEFAULT_JOIN_STAT = { total: 165, @@ -181,10 +233,12 @@ export const joinRouter = router({ const availableSpecialWar = asStringArray(configConst.availableSpecialWar); const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS]; - const [personalities, warSpecials, nationRows] = await Promise.all([ + const [personalities, warSpecials, nationRows, userGeneralCount, npcGeneralCount] = + await Promise.all([ loadPersonalityOptions(), loadWarOptions(warKeys), ctx.db.nation.findMany({ + where: { id: { gt: 0 } }, select: { id: true, name: true, @@ -193,6 +247,8 @@ export const joinRouter = router({ }, orderBy: { id: 'asc' }, }), + ctx.db.general.count({ where: { npcState: { lt: 2 } } }), + ctx.db.general.count({ where: { npcState: { gte: 2 } } }), ]); const nations = nationRows.map((nation) => { @@ -209,7 +265,13 @@ export const joinRouter = router({ const inheritTotalPoint = ctx.auth?.user.id ? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous') : 0; + const selectionPool = await getSelectionPoolStatus( + ctx.db, + worldState, + ctx.auth?.user.id ?? '' + ); const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60)); + const maxGeneral = resolveSelectionMaxGeneral(worldState); const inheritCitiesRaw = await ctx.db.city.findMany({ where: { level: { in: [5, 6] }, nationId: 0 }, select: { id: true, name: true, level: true, region: true }, @@ -239,6 +301,14 @@ export const joinRouter = router({ ], warSpecials, nations, + serverInfo: { + currentYear: worldState.currentYear, + currentMonth: worldState.currentMonth, + tickMinutes, + maxGeneral, + userGeneralCount, + npcGeneralCount, + }, inherit: { totalPoint: inheritTotalPoint, costs: { @@ -251,8 +321,93 @@ export const joinRouter = router({ turnTimeZones: buildTurnTimeZones(tickMinutes), availableSpecialWar: warSpecials, }, + selectionPool, }; }), + getSelectionPool: authedProcedure.mutation(async ({ ctx }) => { + const userId = ctx.auth?.user.id; + if (!userId) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const worldState = await ctx.db.worldState.findFirst(); + if (!worldState) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'World state is not initialized.', + }); + } + return reserveSelectionPool({ + db: ctx.db, + worldState, + userId, + seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId, + }); + }), + selectPoolGeneral: engineAuthedProcedure + .input( + z.object({ + uniqueName: z.string().min(1).max(20), + personality: z.string().min(1), + clientRequestId: z.string().uuid().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const auth = ctx.auth; + if (!auth) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const userId = auth.user.id; + if (auth.identity?.canCreateGeneral === false) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', + }); + } + const commandRequestId = resolveSelectionRequestId( + ctx.requestId, + userId, + input.clientRequestId, + 'create' + ); + const result = await ctx.turnDaemon.requestCommand({ + type: 'selectPoolCreate', + ...(commandRequestId ? { requestId: commandRequestId } : {}), + userId, + ownerDisplayName: auth.user.displayName, + uniqueName: input.uniqueName, + personality: input.personality, + seedOwnerIdentity: auth.user.legacyMemberNo ?? userId, + }); + return resolveSelectionCommandResult(result, 'selectPoolCreate'); + }), + reselectPoolGeneral: engineAuthedProcedure + .input( + z.object({ + uniqueName: z.string().min(1).max(20), + clientRequestId: z.string().uuid().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const auth = ctx.auth; + if (!auth) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + const userId = auth.user.id; + const commandRequestId = resolveSelectionRequestId( + ctx.requestId, + userId, + input.clientRequestId, + 'reselect' + ); + const result = await ctx.turnDaemon.requestCommand({ + type: 'selectPoolReselect', + ...(commandRequestId ? { requestId: commandRequestId } : {}), + userId, + ownerDisplayName: auth.user.displayName, + uniqueName: input.uniqueName, + }); + return resolveSelectionCommandResult(result, 'selectPoolReselect'); + }), createGeneral: authedProcedure .input( z.object({ @@ -287,6 +442,13 @@ export const joinRouter = router({ message: 'World state is not initialized.', }); } + const selectionPool = await getSelectionPoolStatus(ctx.db, worldState, userId); + if (selectionPool.enabled) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '장수 선택 목록에서 장수를 골라 주세요.', + }); + } const joinPolicy = resolveJoinPolicy(worldState); if (joinPolicy.blockGeneralCreate === 1) { diff --git a/app/game-api/src/router/lobby/index.ts b/app/game-api/src/router/lobby/index.ts index d2da2e8..8071541 100644 --- a/app/game-api/src/router/lobby/index.ts +++ b/app/game-api/src/router/lobby/index.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { zWorldStateConfig, zWorldStateMeta } from '../../context.js'; +import { isSelectionPoolWorld } from '../../services/selectPool.js'; import { procedure, router } from '../../trpc.js'; export const lobbyRouter = router({ @@ -27,12 +28,13 @@ export const lobbyRouter = router({ if (ctx.auth?.user.id) { const general = await ctx.db.general.findFirst({ where: { userId: ctx.auth.user.id }, - select: { name: true, picture: true }, + select: { name: true, picture: true, imageServer: true }, }); if (general) { myGeneral = { name: general.name, picture: general.picture, + imageServer: general.imageServer, }; } } @@ -51,6 +53,7 @@ export const lobbyRouter = router({ turntime: worldState.meta.turntime ?? '', otherTextInfo: worldState.meta.otherTextInfo ?? '', isUnited: worldState.meta.isUnited ?? 0, + selectionPoolEnabled: isSelectionPoolWorld(rawWorldState), myGeneral, }; }), diff --git a/app/game-api/src/router/nation/shared.ts b/app/game-api/src/router/nation/shared.ts index 2a8c147..b49ab68 100644 --- a/app/game-api/src/router/nation/shared.ts +++ b/app/game-api/src/router/nation/shared.ts @@ -5,11 +5,14 @@ import { asNumber, asRecord } from '@sammo-ts/common'; import { createIncomeActionContext, DomesticTraitLoader, + EventDomesticTraitLoader, isDomesticTraitKey, + isEventDomesticTraitKey, isNationTraitKey, isPersonalityTraitKey, isWarTraitKey, loadDomesticTraitModules, + loadEventDomesticTraitModules, loadNationTraitModules, loadPersonalityTraitModules, loadWarTraitModules, @@ -301,12 +304,22 @@ export const loadTraitNames = async (keys: Array, kind: keyof Tra if (kind === 'domestic') { const filtered = missing.filter((key) => isDomesticTraitKey(key)); + const eventFiltered = missing.filter((key) => isEventDomesticTraitKey(key)); if (filtered.length) { const modules = await loadDomesticTraitModules(filtered, new DomesticTraitLoader()); for (const module of modules) { cache.set(module.key, { name: module.name, info: module.info ?? '' }); } } + if (eventFiltered.length) { + const modules = await loadEventDomesticTraitModules( + eventFiltered, + new EventDomesticTraitLoader() + ); + for (const module of modules) { + cache.set(module.key, { name: module.name, info: module.info ?? '' }); + } + } } else if (kind === 'war') { const filtered = missing.filter((key) => isWarTraitKey(key)); if (filtered.length) { diff --git a/app/game-api/src/services/selectPool.ts b/app/game-api/src/services/selectPool.ts new file mode 100644 index 0000000..265075d --- /dev/null +++ b/app/game-api/src/services/selectPool.ts @@ -0,0 +1,12 @@ +export { + buildSelectPoolSeed, + claimWeightedSelectionCandidates, + getSelectionPoolStatus, + isSelectionPoolWorld, + reserveSelectionPool, + resolveSelectionMaxGeneral, + SelectPoolError, + type SelectPoolCandidateDto, + type SelectPoolCandidateInfo, + type SelectPoolReservationDto, +} from '@sammo-ts/game-engine'; diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 0c225b8..3b969cd 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -71,6 +71,11 @@ export const router = t.router; export const procedure = t.procedure.use(inputEventMiddleware); export const authedProcedure: typeof procedure = procedure.use(requireAuthMiddleware); +// 턴 데몬이 ENGINE input_event와 world/DB 변경을 자체 transaction으로 +// 커밋하는 mutation에 사용한다. API input-event transaction으로 한 번 더 +// 감싸면 daemon이 아직 commit되지 않은 command를 볼 수 없어 교착된다. +export const engineAuthedProcedure: typeof procedure = t.procedure.use(requireAuthMiddleware); + // 페이지 조회 계측처럼 game state/input-event 원장과 무관한 세션 보조 // mutation에 사용한다. gameplay state 변경에는 사용하지 않는다. export const sessionActivityProcedure = t.procedure; diff --git a/app/game-api/test/battleSimProcessor.test.ts b/app/game-api/test/battleSimProcessor.test.ts index 1a438f8..c66ebeb 100644 --- a/app/game-api/test/battleSimProcessor.test.ts +++ b/app/game-api/test/battleSimProcessor.test.ts @@ -300,6 +300,16 @@ describe('battle sim processor', () => { expect(strong.killed).toBeGreaterThan(baseline.killed ?? 0); }); + it('applies the event-domestic trait carried by an imported general', () => { + const baseline = processBattleSimJob(buildPayload('battle')); + const payload = buildPayload('battle'); + payload.attackerGeneral.special = 'che_event_무쌍'; + const eventDomestic = processBattleSimJob(payload); + + expect(eventDomestic.killed).toBeGreaterThan(baseline.killed ?? 0); + expect(eventDomestic).not.toEqual(baseline); + }); + it('keeps StrongAttacker city combat identical but applies MoreEffect to it', () => { const baselinePayload = buildPayload('battle'); baselinePayload.defenderGenerals = []; diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index 0147e1f..ff3a96c 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -427,6 +427,7 @@ describe('battle simulator general import permissions', () => { bookCode: null, itemCode: null, personalCode: null, + specialCode: null, special2Code: null, meta: {}, ...overrides, @@ -447,6 +448,7 @@ describe('battle simulator general import permissions', () => { weaponCode: 'che_의천검', bookCode: 'che_손자병법', itemCode: 'che_옥새', + specialCode: 'che_event_신산', meta: { dex1: 10000, rank_warnum: 33, @@ -483,6 +485,7 @@ describe('battle simulator general import permissions', () => { warnum: 33, killnum: 22, killcrew: 1111, + special: 'che_event_신산', }); const redacted = await foreign.battle.getGeneralDetail({ generalId: ally.id }); @@ -499,6 +502,7 @@ describe('battle simulator general import permissions', () => { warnum: 0, killnum: 0, killcrew: 0, + special: 'che_event_신산', }); }); diff --git a/app/game-api/test/inputEventBoundary.integration.test.ts b/app/game-api/test/inputEventBoundary.integration.test.ts index f77728d..efcb011 100644 --- a/app/game-api/test/inputEventBoundary.integration.test.ts +++ b/app/game-api/test/inputEventBoundary.integration.test.ts @@ -2,7 +2,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra'; import { DuplicateInputEventError, executeInputEvent } from '../src/inputEventBoundary.js'; -import { ConflictingTurnDaemonCommandError, DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js'; +import { + ConflictingTurnDaemonCommandError, + DatabaseTurnDaemonTransport, + FailedTurnDaemonCommandError, +} from '../src/daemon/databaseTransport.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); @@ -144,4 +148,25 @@ integration('API input event boundary', () => { ConflictingTurnDaemonCommandError ); }); + + it('distinguishes a stored terminal engine failure from a result timeout', async () => { + const transport = new DatabaseTurnDaemonTransport(db, 100); + const requestId = 'integration:api:engine-failed'; + const command = { type: 'vacation' as const, requestId, generalId: 7 }; + await transport.sendCommand(command); + await db.inputEvent.update({ + where: { requestId }, + data: { + status: 'FAILED', + error: 'injected terminal engine failure', + completedAt: new Date(), + }, + }); + + await expect(transport.requestCommand(command)).rejects.toMatchObject({ + name: FailedTurnDaemonCommandError.name, + requestId, + storedError: 'injected terminal engine failure', + }); + }); }); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 52b10fb..c72ba78 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -274,6 +274,29 @@ describe('appRouter', () => { }); }); + it('reports the authenticated game access-token identity', async () => { + const caller = appRouter.createCaller(buildContext({ auth: buildAuth() })); + + await expect(caller.auth.status()).resolves.toEqual({ userId: 'user-1' }); + }); + + it('rejects unauthenticated or game-blocked auth status checks', async () => { + await expect( + appRouter.createCaller(buildContext({ auth: null })).auth.status() + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect( + appRouter + .createCaller( + buildContext({ + auth: buildAuth({ + suspendedUntil: '2099-01-01T00:00:00.000Z', + }), + }) + ) + .auth.status() + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + it('does not apply an expired or message-only restriction to other game APIs', async () => { const caller = appRouter.createCaller( buildContext({ diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts new file mode 100644 index 0000000..c304861 --- /dev/null +++ b/app/game-api/test/selectPool.integration.test.ts @@ -0,0 +1,582 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { RANK_DATA_TYPES } from '@sammo-ts/common'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { + createTurnDaemonRuntime, + seedScenarioToDatabase, + type TurnDaemonRuntime, +} from '@sammo-ts/game-engine'; +import { + createGamePostgresConnector, + type 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.SELECT_POOL_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const userId = 'select-pool-integration-user'; +const otherUserId = 'select-pool-integration-other-user'; +const foreignUserId = 'select-pool-integration-foreign-user'; +const failureUserId = 'select-pool-integration-failure-user'; +const profile = 'hwe:903'; +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('select_pool_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 auth: GameSessionTokenPayload = { + version: 1, + profile, + issuedAt: '2026-07-30T00:00:00.000Z', + expiresAt: '2026-08-30T00:00:00.000Z', + sessionId: 'select-pool-integration-session', + user: { + id: userId, + username: 'select-pool-user', + displayName: '선택사용자', + roles: ['user'], + }, + sanctions: {}, +}; + +const otherAuth: GameSessionTokenPayload = { + ...auth, + sessionId: 'select-pool-integration-other-session', + user: { + ...auth.user, + id: otherUserId, + username: 'select-pool-other', + displayName: '다른사용자', + }, +}; + +const foreignAuth: GameSessionTokenPayload = { + ...auth, + sessionId: 'select-pool-integration-foreign-session', + user: { + ...auth.user, + id: foreignUserId, + username: 'select-pool-foreign', + displayName: '외국사용자', + }, +}; + +const failureAuth: GameSessionTokenPayload = { + ...auth, + sessionId: 'select-pool-integration-failure-session', + user: { + ...auth.user, + id: failureUserId, + username: 'select-pool-failure', + displayName: '실패사용자', + }, +}; + +integration('scenario 903 select pool through the durable turn daemon', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + let runtime: TurnDaemonRuntime | undefined; + let daemonLoop: Promise | undefined; + let turnDaemon: TurnDaemonTransport; + let worldStateId: number; + + 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: '903', name: profile }, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + auth: actorAuth, + accessTokenStore: new RedisAccessTokenStore(redisClient, profile), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'select-pool-test-secret', + }; + }; + + beforeAll(async () => { + assertDedicatedDatabase(databaseUrl!); + const previousSeed = process.env.INTEGRATION_WORLD_SEED; + process.env.INTEGRATION_WORLD_SEED = 'select-pool-integration-seed'; + try { + await seedScenarioToDatabase({ + scenarioId: 903, + databaseUrl: databaseUrl!, + now: new Date('2099-07-30T12:00:00.000Z'), + installOptions: { + turnTermMinutes: 5, + npcMode: 2, + 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(); + worldStateId = (await db.worldState.findFirstOrThrow()).id; + + runtime = await createTurnDaemonRuntime({ + profile, + databaseUrl: databaseUrl!, + enableDatabaseFlush: true, + enableLeaseHeartbeat: false, + leaseOwnerId: 'select-pool-integration-daemon', + }); + turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000); + daemonLoop = runtime.lifecycle.start(); + await expect(turnDaemon.requestStatus(10_000)).resolves.toMatchObject({ + state: expect.any(String), + }); + }, 60_000); + + afterAll(async () => { + if (runtime) { + await runtime.lifecycle.stop('select-pool integration complete'); + await daemonLoop; + await runtime.close(); + } + await closeDb?.(); + }, 30_000); + + it('creates and reselects in one durable DB and in-memory command boundary', async () => { + await expect( + appRouter.createCaller(buildContext('select-pool-public-lobby')).lobby.info() + ).resolves.toMatchObject({ selectionPoolEnabled: true }); + await expect( + appRouter.createCaller(buildContext('select-pool-config')).join.getConfig() + ).resolves.toMatchObject({ + serverInfo: { + currentYear: 180, + currentMonth: 1, + tickMinutes: 5, + maxGeneral: 500, + }, + }); + + const [firstReservation, concurrentReservation] = await Promise.all([ + appRouter.createCaller(buildContext('select-pool-reserve-a')).join.getSelectionPool(), + appRouter.createCaller(buildContext('select-pool-reserve-b')).join.getSelectionPool(), + ]); + expect(concurrentReservation).toEqual(firstReservation); + expect(firstReservation.candidates).toHaveLength(14); + expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(14); + + const attempts = await Promise.allSettled([ + appRouter.createCaller(buildContext('select-pool-create-a')).join.selectPoolGeneral({ + uniqueName: firstReservation.candidates[0]!.uniqueName, + personality: 'che_안전', + }), + appRouter.createCaller(buildContext('select-pool-create-b')).join.selectPoolGeneral({ + uniqueName: firstReservation.candidates[1]!.uniqueName, + personality: 'che_유지', + }), + ]); + expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(1); + expect(attempts.filter((attempt) => attempt.status === 'rejected')).toHaveLength(1); + + const initial = await db.general.findFirstOrThrow({ where: { userId } }); + const initialRuntime = runtime!.world.getGeneralById(initial.id); + expect(initialRuntime).toMatchObject({ + id: initial.id, + userId, + name: initial.name, + imageServer: initial.imageServer, + stats: { + leadership: initial.leadership, + strength: initial.strength, + intelligence: initial.intel, + }, + }); + expect(initial.id).toBeGreaterThan( + Math.max( + ...runtime!.world + .listGenerals() + .filter((general) => general.id !== initial.id) + .map((general) => general.id) + ) + ); + expect( + await db.generalTurn.findMany({ + where: { generalId: initial.id }, + orderBy: { turnIdx: 'asc' }, + select: { turnIdx: true, actionCode: true, arg: true }, + }) + ).toEqual( + Array.from({ length: 30 }, (_, turnIdx) => ({ + turnIdx, + actionCode: '휴식', + arg: {}, + })) + ); + await expect( + db.generalTurnRevision.findUniqueOrThrow({ where: { generalId: initial.id } }) + ).resolves.toMatchObject({ revision: 0, leaseOwner: null, leaseExpiresAt: null }); + const initialRankRows = await db.rankData.findMany({ + where: { generalId: initial.id }, + orderBy: { type: 'asc' }, + select: { nationId: true, type: true, value: true }, + }); + expect(initialRankRows).toHaveLength(RANK_DATA_TYPES.length); + expect(initialRankRows.map(({ type }) => type).sort()).toEqual( + [...RANK_DATA_TYPES].sort() + ); + expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe( + true + ); + expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1); + expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(0); + expect( + await db.logEntry.count({ + where: { meta: { path: ['ownerUserId'], equals: userId } }, + }) + ).toBe(2); + + await expect( + appRouter.createCaller(buildContext('select-pool-cooldown')).join.getSelectionPool() + ).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' }); + + const cooledAt = '2026-07-29T00:00:00.000Z'; + await expect( + turnDaemon.requestCommand({ + type: 'patchGeneral', + requestId: 'select-pool-cooldown-patch', + generalId: initial.id, + patch: { + meta: { + next_change: cooledAt, + nextChangeAt: cooledAt, + }, + }, + }) + ).resolves.toMatchObject({ type: 'patchGeneral', ok: true, generalId: initial.id }); + + const reselection = await appRouter + .createCaller(buildContext('select-pool-reserve-reselection')) + .join.getSelectionPool(); + const target = reselection.candidates.find( + (candidate) => candidate.generalName !== initial.name + )!; + await expect( + appRouter + .createCaller(buildContext('select-pool-reselect')) + .join.reselectPoolGeneral({ uniqueName: target.uniqueName }) + ).resolves.toEqual({ ok: true, generalId: initial.id }); + + const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } }); + expect(updated).toMatchObject({ + id: initial.id, + userId, + name: target.generalName, + leadership: target.leadership, + strength: target.strength, + intel: target.intel, + personalCode: initial.personalCode, + specialCode: target.specialDomestic, + imageServer: target.imageServer, + picture: target.picture, + }); + expect(runtime!.world.getGeneralById(initial.id)).toMatchObject({ + id: initial.id, + userId, + name: target.generalName, + imageServer: target.imageServer, + picture: target.picture, + stats: { + leadership: target.leadership, + strength: target.strength, + intelligence: target.intel, + }, + }); + expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1); + expect( + await db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: target.uniqueName } }) + ).toMatchObject({ generalId: initial.id, ownerUserId: null, reservedUntil: null }); + expect( + await db.logEntry.count({ + where: { meta: { path: ['ownerUserId'], equals: userId } }, + }) + ).toBe(4); + + await expect( + turnDaemon.requestCommand({ + type: 'patchGeneral', + requestId: 'select-pool-post-reselection-flush', + generalId: initial.id, + patch: { meta: { postReselectionFlush: 1 } }, + }) + ).resolves.toMatchObject({ type: 'patchGeneral', ok: true }); + await expect( + db.general.findUniqueOrThrow({ where: { id: initial.id } }) + ).resolves.toMatchObject({ + name: target.generalName, + leadership: target.leadership, + strength: target.strength, + intel: target.intel, + }); + + const fullWorld = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }); + const fullConfig = fullWorld.config as Record; + await db.worldState.update({ + where: { id: worldStateId }, + data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue }, + }); + const secondCooledAt = '2026-07-28T00:00:00.000Z'; + await turnDaemon.requestCommand({ + type: 'patchGeneral', + requestId: 'select-pool-full-cooldown-patch', + generalId: initial.id, + patch: { + meta: { + next_change: secondCooledAt, + nextChangeAt: secondCooledAt, + }, + }, + }); + const fullReselection = await appRouter + .createCaller(buildContext('select-pool-full-reselection-reserve')) + .join.getSelectionPool(); + await expect( + appRouter + .createCaller(buildContext('select-pool-full-reselection')) + .join.reselectPoolGeneral({ + uniqueName: fullReselection.candidates[0]!.uniqueName, + }) + ).resolves.toEqual({ ok: true, generalId: initial.id }); + + const otherReservation = await appRouter + .createCaller(buildContext('select-pool-full-new-user-reserve', otherAuth)) + .join.getSelectionPool(); + await expect( + appRouter + .createCaller(buildContext('select-pool-full-new-user-create', otherAuth)) + .join.selectPoolGeneral({ + uniqueName: otherReservation.candidates[0]!.uniqueName, + personality: 'che_안전', + }) + ).rejects.toMatchObject({ message: '더 이상 등록 할 수 없습니다.' }); + expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0); + await db.worldState.update({ + where: { id: worldStateId }, + data: { config: fullConfig as GamePrisma.InputJsonValue }, + }); + }, 30_000); + + it('keeps a stable ENGINE event for retries and rejects reservation bypasses', async () => { + const reservation = await appRouter + .createCaller(buildContext('select-pool-other-reserve', otherAuth)) + .join.getSelectionPool(); + const candidate = reservation.candidates[0]!; + + await expect( + appRouter + .createCaller(buildContext('select-pool-foreign-token', foreignAuth)) + .join.selectPoolGeneral({ + uniqueName: candidate.uniqueName, + personality: 'che_안전', + }) + ).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' }); + expect(await db.general.count({ where: { userId: foreignUserId } })).toBe(0); + + await db.selectPoolEntry.update({ + where: { uniqueName: candidate.uniqueName }, + data: { reservedUntil: new Date(Date.now() - 60_000) }, + }); + await expect( + appRouter + .createCaller(buildContext('select-pool-expired-token', otherAuth)) + .join.selectPoolGeneral({ + uniqueName: candidate.uniqueName, + personality: 'che_안전', + }) + ).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' }); + expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0); + + await expect( + appRouter + .createCaller(buildContext('select-pool-generic-bypass', otherAuth)) + .join.createGeneral({ + name: '우회장수', + leadership: 55, + strength: 55, + intel: 55, + character: 'che_안전', + }) + ).rejects.toMatchObject({ message: '장수 선택 목록에서 장수를 골라 주세요.' }); + + const input = { + uniqueName: reservation.candidates[1]!.uniqueName, + personality: 'che_안전', + }; + await db.selectPoolEntry.updateMany({ + where: { ownerUserId: otherUserId, generalId: null }, + data: { reservedUntil: new Date(Date.now() + 60_000) }, + }); + const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId; + const persistedAllocatorBefore = ( + (await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })) + .meta as Record + ).lastGeneralId; + await expect( + appRouter + .createCaller(buildContext('select-pool-invalid-personality', otherAuth)) + .join.selectPoolGeneral({ + ...input, + personality: 'not-a-personality', + clientRequestId: '11111111-1111-4111-8111-111111111111', + }) + ).rejects.toMatchObject({ message: '올바르지 않은 성격입니다.' }); + expect(runtime!.world.getState().meta.lastGeneralId).toBe(runtimeAllocatorBefore); + expect( + ( + (await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })) + .meta as Record + ).lastGeneralId + ).toBe(persistedAllocatorBefore); + expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0); + + const stableClientRequestId = '22222222-2222-4222-8222-222222222222'; + const stableInput = { ...input, clientRequestId: stableClientRequestId }; + const first = await appRouter + .createCaller(buildContext('select-pool-http-attempt-a', otherAuth)) + .join.selectPoolGeneral(stableInput); + const retried = await appRouter + .createCaller(buildContext('select-pool-http-attempt-b', otherAuth)) + .join.selectPoolGeneral(stableInput); + expect(retried).toEqual(first); + expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1); + await expect( + db.inputEvent.findUniqueOrThrow({ + where: { + requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`, + }, + }) + ).resolves.toMatchObject({ + status: 'SUCCEEDED', + attempts: 1, + actorUserId: otherUserId, + }); + }, 30_000); + + it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => { + const reservation = await appRouter + .createCaller(buildContext('select-pool-failure-reserve', failureAuth)) + .join.getSelectionPool(); + const candidate = reservation.candidates[0]!; + const requestUuid = '33333333-3333-4333-8333-333333333333'; + const requestId = `select-pool:${failureUserId}:${requestUuid}:create`; + const triggerName = 'select_pool_fail_first_log'; + const functionName = 'select_pool_fail_first_log_fn'; + + await db.$executeRawUnsafe(` + CREATE OR REPLACE FUNCTION "${schemaName}"."${functionName}"() + RETURNS trigger AS $$ + BEGIN + IF NEW.meta ->> 'ownerUserId' = '${failureUserId}' + AND EXISTS ( + SELECT 1 + FROM "${schemaName}"."input_event" + WHERE "request_id" = '${requestId}' + AND "status" = 'PROCESSING' + AND "attempts" = 1 + ) + THEN + RAISE EXCEPTION 'injected first selection 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('select-pool-failure-http', failureAuth)) + .join.selectPoolGeneral({ + uniqueName: candidate.uniqueName, + personality: 'che_안전', + clientRequestId: requestUuid, + }) + ).resolves.toMatchObject({ ok: true, generalId: expect.any(Number) }); + } finally { + await db.$executeRawUnsafe( + `DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"` + ); + await db.$executeRawUnsafe( + `DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()` + ); + } + + const created = await db.general.findFirstOrThrow({ where: { userId: failureUserId } }); + expect(runtime!.world.getGeneralById(created.id)).toMatchObject({ + id: created.id, + userId: failureUserId, + name: created.name, + }); + expect(await db.general.count({ where: { userId: failureUserId } })).toBe(1); + expect(await db.generalTurn.count({ where: { generalId: created.id } })).toBe(30); + expect(await db.generalTurnRevision.count({ where: { generalId: created.id } })).toBe(1); + expect(await db.rankData.count({ where: { generalId: created.id } })).toBe( + RANK_DATA_TYPES.length + ); + expect(await db.generalAccessLog.count({ where: { generalId: created.id } })).toBe(1); + expect(await db.selectPoolEntry.count({ where: { generalId: created.id } })).toBe(1); + expect( + await db.logEntry.count({ + where: { meta: { path: ['ownerUserId'], equals: failureUserId } }, + }) + ).toBe(2); + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId } }) + ).resolves.toMatchObject({ + status: 'SUCCEEDED', + attempts: 2, + actorUserId: failureUserId, + error: null, + }); + }, 30_000); +}); diff --git a/app/game-api/test/selectPoolRng.test.ts b/app/game-api/test/selectPoolRng.test.ts new file mode 100644 index 0000000..9f66dcc --- /dev/null +++ b/app/game-api/test/selectPoolRng.test.ts @@ -0,0 +1,75 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { LiteHashDRBG, RandUtil } from '@sammo-ts/common'; + +import { + buildSelectPoolSeed, + claimWeightedSelectionCandidates, +} from '../src/services/selectPool.js'; + +interface PoolResource { + data: Array< + [ + string, + number, + number, + number, + string, + [number, number, number, number, number], + 0 | 1, + string, + ] + >; +} + +const loadWeightedRows = async (): Promise> => { + const filePath = path.resolve( + import.meta.dirname, + '../../../resources/general-pool/SPoolUnderU30.json' + ); + const resource = JSON.parse(await fs.readFile(filePath, 'utf8')) as PoolResource; + return resource.data.map((row, index) => [ + { id: index + 1 }, + row[5].reduce((sum, value) => sum + value, 0), + ]); +}; + +const drawVector = async ( + hiddenSeed: string +): Promise<{ selected: number[]; draws: number[] }> => { + const weighted = await loadWeightedRows(); + const now = new Date('2026-07-30T03:34:56.000Z'); + const draws: number[] = []; + const selected = await claimWeightedSelectionCandidates({ + weighted, + rng: new RandUtil(new LiteHashDRBG(buildSelectPoolSeed(hiddenSeed, 42, now))), + count: 14, + claim: async () => true, + onDraw: (candidate) => draws.push(candidate.id), + }); + return { selected: selected.map((candidate) => candidate.id), draws }; +}; + +describe('select pool Ref RNG parity', () => { + it('uses the legacy seed serialization and fixed UnderS30 draw vector', async () => { + const now = new Date('2026-07-30T03:34:56.000Z'); + expect(buildSelectPoolSeed('vector-hidden', 42, now)).toBe( + 'str(13,vector-hidden)|str(10,selectPool)|int(42)|str(19,2026-07-30 12:34:56)' + ); + + await expect(drawVector('vector-hidden')).resolves.toEqual({ + selected: [72, 1283, 110, 1659, 608, 1408, 1543, 1573, 1096, 1081, 278, 1256, 872, 1369], + draws: [72, 1283, 110, 1659, 608, 1408, 1543, 1573, 1096, 1081, 278, 1256, 872, 1369], + }); + }); + + it('consumes duplicate draws without removing the candidate from the weighted pool', async () => { + await expect(drawVector('vector-hidden-28')).resolves.toEqual({ + selected: [314, 865, 1485, 1382, 110, 550, 27, 368, 399, 1298, 152, 39, 189, 760], + draws: [314, 865, 1485, 1382, 110, 550, 27, 368, 399, 1298, 27, 152, 39, 189, 760], + }); + }); +}); diff --git a/app/game-api/tsconfig.json b/app/game-api/tsconfig.json index 55e4559..fd0712c 100644 --- a/app/game-api/tsconfig.json +++ b/app/game-api/tsconfig.json @@ -11,6 +11,12 @@ "@sammo-ts/common/*": [ "../../packages/common/src/*" ], + "@sammo-ts/game-engine": [ + "../../app/game-engine/src/index.ts" + ], + "@sammo-ts/game-engine/*": [ + "../../app/game-engine/src/*" + ], "@sammo-ts/infra": [ "../../packages/infra/src/index.ts" ], @@ -39,6 +45,9 @@ }, { "path": "../../packages/logic" + }, + { + "path": "../game-engine" } ] -} \ No newline at end of file +} diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index d730560..edc698a 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -12,6 +12,7 @@ export * from './lifecycle/turnDaemonLifecycle.js'; export * from './lifecycle/getNextTickTime.js'; export * from './scenario/scenarioLoader.js'; export * from './scenario/scenarioComposition.js'; +export * from './scenario/generalPoolLoader.js'; export * from './scenario/databaseUrl.js'; export * from './scenario/mapLoader.js'; export * from './scenario/scenarioSeeder.js'; @@ -22,6 +23,7 @@ export * from './turn/engineStateManager.js'; export * from './turn/inMemoryStateStore.js'; export * from './turn/inMemoryTurnProcessor.js'; export * from './turn/databaseHooks.js'; +export * from './turn/selectPoolService.js'; export * from './turn/turnDaemon.js'; export * from './turn/cli.js'; diff --git a/app/game-engine/src/lifecycle/databaseCommandQueue.ts b/app/game-engine/src/lifecycle/databaseCommandQueue.ts index 49e70e4..6e4d834 100644 --- a/app/game-engine/src/lifecycle/databaseCommandQueue.ts +++ b/app/game-engine/src/lifecycle/databaseCommandQueue.ts @@ -17,6 +17,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T private readonly localQueue: TurnDaemonCommand[] = []; private readonly workerId = randomUUID(); private readonly leaseDurationMs = 60_000; + private readonly maxAttempts = 3; constructor(private readonly db: GamePrismaClient) {} @@ -62,6 +63,48 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T await this.complete(requestId, result); } + async publishCommandError(requestId: string, error: unknown): Promise { + const message = error instanceof Error ? error.message : 'Unknown command error.'; + await this.db.$transaction(async (transaction) => { + const event = await transaction.inputEvent.findUnique({ + where: { requestId }, + select: { + status: true, + target: true, + lockedBy: true, + attempts: true, + }, + }); + if ( + !event || + event.target !== 'ENGINE' || + event.status !== 'PROCESSING' || + event.lockedBy !== this.workerId + ) { + return; + } + const terminal = event.attempts >= this.maxAttempts; + await transaction.inputEvent.updateMany({ + where: { + requestId, + target: 'ENGINE', + status: 'PROCESSING', + lockedBy: this.workerId, + attempts: event.attempts, + }, + data: { + status: terminal ? 'FAILED' : 'PENDING', + processingAt: null, + lockedBy: null, + leaseUntil: null, + completedAt: terminal ? new Date() : null, + result: GamePrisma.DbNull, + error: message, + }, + }); + }); + } + private async claimPending(limit = 100): Promise { await this.recoverExpiredLeases(); return this.db.$transaction(async (transaction) => { @@ -132,8 +175,13 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T } private async complete(requestId: string, result: unknown): Promise { - await this.db.inputEvent.update({ - where: { requestId }, + const completed = await this.db.inputEvent.updateMany({ + where: { + requestId, + target: 'ENGINE', + status: 'PROCESSING', + lockedBy: this.workerId, + }, data: { status: 'SUCCEEDED', result: asJson(result), @@ -143,6 +191,24 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T leaseUntil: null, }, }); + if (completed.count > 0) { + return; + } + // Database hooks commit mutation results atomically with game state and + // may already have set SUCCEEDED. Only the worker that still owns the + // lease may clear that committed row's claim metadata. + await this.db.inputEvent.updateMany({ + where: { + requestId, + target: 'ENGINE', + status: 'SUCCEEDED', + lockedBy: this.workerId, + }, + data: { + lockedBy: null, + leaseUntil: null, + }, + }); } private async recoverExpiredLeases(): Promise { diff --git a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts index 19c095d..6698114 100644 --- a/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts +++ b/app/game-engine/src/lifecycle/turnDaemonLifecycle.ts @@ -310,6 +310,17 @@ export class TurnDaemonLifecycle { this.status.paused = true; this.errorPaused = true; this.status.lastError = error instanceof Error ? error.message : 'Unknown command error.'; + if (command.requestId && this.commandResponder?.publishCommandError) { + try { + await this.commandResponder.publishCommandError(command.requestId, error); + } catch (reportError) { + const reportMessage = + reportError instanceof Error + ? reportError.message + : 'Unknown command failure reporting error.'; + this.status.lastError = `${this.status.lastError} (failure report: ${reportMessage})`; + } + } await this.hooks?.onRunError?.(error); return; } diff --git a/app/game-engine/src/lifecycle/types.ts b/app/game-engine/src/lifecycle/types.ts index 7307db7..a29ce45 100644 --- a/app/game-engine/src/lifecycle/types.ts +++ b/app/game-engine/src/lifecycle/types.ts @@ -33,6 +33,7 @@ export interface TurnDaemonCommandExecutionContext { export interface TurnDaemonCommandResponder { publishStatus(requestId: string, status: TurnDaemonStatus): Promise; publishCommandResult(requestId: string, result: TurnDaemonCommandResult): Promise; + publishCommandError?(requestId: string, error: unknown): Promise; } export type { Clock } from '@sammo-ts/common'; diff --git a/app/game-engine/src/scenario/generalPoolLoader.ts b/app/game-engine/src/scenario/generalPoolLoader.ts new file mode 100644 index 0000000..799d29b --- /dev/null +++ b/app/game-engine/src/scenario/generalPoolLoader.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { isRecord } from '@sammo-ts/common'; +import { isEventDomesticTraitKey } from '@sammo-ts/logic'; + +import { resolveWorkspaceRoot } from '../paths.js'; + +const DEFAULT_GENERAL_POOL_ROOT = path.resolve(resolveWorkspaceRoot(), 'resources', 'general-pool'); +const SUPPORTED_POOL = 'SPoolUnderU30'; +const EXPECTED_COLUMNS = [ + 'generalName', + 'leadership', + 'strength', + 'intel', + 'specialDomestic', + 'dex', + 'imgsvr', + 'picture', +] as const; + +export interface GeneralPoolSeedEntry { + uniqueName: string; + info: Record; +} + +export interface GeneralPoolLoaderOptions { + generalPoolRoot?: string; +} + +const readPoolResource = async (filePath: string): Promise => + JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown; + +const normalizePoolRow = (row: unknown, index: number): GeneralPoolSeedEntry => { + if (!Array.isArray(row) || row.length !== EXPECTED_COLUMNS.length) { + throw new Error(`General pool row ${index} does not match the expected ${EXPECTED_COLUMNS.length} columns.`); + } + const info = Object.fromEntries(EXPECTED_COLUMNS.map((column, columnIndex) => [column, row[columnIndex]])); + const uniqueName = info.generalName; + if (typeof uniqueName !== 'string' || uniqueName.length === 0) { + throw new Error(`General pool row ${index} has no generalName.`); + } + if (uniqueName.length > 20) { + throw new Error(`General pool row ${index} has a generalName longer than the select_pool key.`); + } + if ( + !Number.isInteger(info.leadership) || + !Number.isInteger(info.strength) || + !Number.isInteger(info.intel) || + typeof info.specialDomestic !== 'string' || + !isEventDomesticTraitKey(info.specialDomestic) || + !Array.isArray(info.dex) || + info.dex.length !== 5 || + info.dex.some((value) => typeof value !== 'number' || !Number.isInteger(value) || value < 0) || + info.dex.reduce((sum, value) => sum + Number(value), 0) <= 0 || + (info.imgsvr !== 0 && info.imgsvr !== 1) || + typeof info.picture !== 'string' + ) { + throw new Error(`General pool row ${index} contains invalid candidate data.`); + } + return { + uniqueName, + info: { + ...info, + uniqueName, + }, + }; +}; + +export const loadGeneralPoolEntries = async ( + poolName: string, + options?: GeneralPoolLoaderOptions +): Promise => { + if (poolName !== SUPPORTED_POOL) { + throw new Error(`Unsupported general pool: ${poolName}.`); + } + const root = path.resolve(options?.generalPoolRoot ?? DEFAULT_GENERAL_POOL_ROOT); + const raw = await readPoolResource(path.resolve(root, `${poolName}.json`)); + if (!isRecord(raw) || !Array.isArray(raw.columns) || !Array.isArray(raw.data)) { + throw new Error(`General pool ${poolName} is not a valid resource.`); + } + if ( + raw.columns.length !== EXPECTED_COLUMNS.length || + raw.columns.some((column, index) => column !== EXPECTED_COLUMNS[index]) + ) { + throw new Error(`General pool ${poolName} has an unexpected column contract.`); + } + const entries = raw.data.map(normalizePoolRow); + if (new Set(entries.map((entry) => entry.uniqueName)).size !== entries.length) { + throw new Error(`General pool ${poolName} contains duplicate unique names.`); + } + return entries; +}; diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index 19b9937..a89d87b 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'node:crypto'; + import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra'; import { asRecord } from '@sammo-ts/common'; import { @@ -14,6 +16,8 @@ import type { ScenarioLoaderOptions } from './scenarioLoader.js'; import { loadScenarioDefinitionById } from './scenarioLoader.js'; import type { UnitSetLoaderOptions } from './unitSetLoader.js'; import { loadUnitSetDefinitionByName } from './unitSetLoader.js'; +import type { GeneralPoolLoaderOptions } from './generalPoolLoader.js'; +import { loadGeneralPoolEntries } from './generalPoolLoader.js'; import { applyInitialChangeCityEvents } from '../turn/monthlyChangeCityAction.js'; const DEFAULT_TICK_SECONDS = 120 * 60; @@ -51,6 +55,7 @@ export interface ScenarioSeedOptions { scenarioOptions?: ScenarioLoaderOptions; mapOptions?: MapLoaderOptions; unitSetOptions?: UnitSetLoaderOptions; + generalPoolOptions?: GeneralPoolLoaderOptions; resetTables?: boolean; now?: Date; tickSeconds?: number; @@ -209,6 +214,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const scenarioDefinition = includeExtendedGeneral ? scenario : { ...scenario, generalsEx: [] }; const map = await loadMapDefinitionByName(scenario.config.environment.mapName, options.mapOptions); const unitSet = await loadUnitSetDefinitionByName(scenario.config.environment.unitSet, options.unitSetOptions); + const targetGeneralPool = + typeof scenario.config.map.targetGeneralPool === 'string' ? scenario.config.map.targetGeneralPool : null; + const generalPoolEntries = targetGeneralPool + ? await loadGeneralPoolEntries(targetGeneralPool, options.generalPoolOptions) + : []; const { seed, warnings } = buildScenarioBootstrap({ scenario: scenarioDefinition, @@ -271,10 +281,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom worldMeta.serverId = install.serverId.trim(); } - const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]; - if (typeof integrationSeed === 'string' && integrationSeed.trim().length > 0) { - worldMeta.hiddenSeed = integrationSeed.trim(); - } + const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]?.trim(); + worldMeta.hiddenSeed = + integrationSeed && integrationSeed.length > 0 + ? integrationSeed + : randomBytes(16).toString('hex'); if (install?.preopenAt) { worldMeta.preopenAt = formatDateTime(install.preopenAt); @@ -286,6 +297,8 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom options: install.autorunUser.options, }; } + const archivedWorldMeta = { ...worldMeta }; + delete archivedWorldMeta.hiddenSeed; await connector.connect(); try { @@ -297,6 +310,11 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom if (eventTableReady) { await prisma.event.deleteMany(); } + await prisma.selectPoolEntry.deleteMany(); + await prisma.generalTurn.deleteMany(); + await prisma.generalTurnRevision.deleteMany(); + await prisma.rankData.deleteMany(); + await prisma.generalAccessLog.deleteMany(); await prisma.diplomacy.deleteMany(); await prisma.general.deleteMany(); await prisma.troop.deleteMany(); @@ -316,6 +334,15 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom }, }); + if (generalPoolEntries.length > 0) { + await prisma.selectPoolEntry.createMany({ + data: generalPoolEntries.map((entry) => ({ + uniqueName: entry.uniqueName, + info: asJson(entry.info), + })), + }); + } + if (typeof worldMeta.serverId === 'string' && worldMeta.serverId) { await prisma.gameHistory.upsert({ where: { serverId: worldMeta.serverId }, @@ -332,7 +359,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom scenarioName: String(seed.scenarioMeta?.title ?? ''), env: asJson({ config: scenarioConfig, - meta: worldMeta, + meta: archivedWorldMeta, }), }, update: { @@ -347,7 +374,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom scenarioName: String(seed.scenarioMeta?.title ?? ''), env: asJson({ config: scenarioConfig, - meta: worldMeta, + meta: archivedWorldMeta, }), }, }); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index d2c23d4..a0177e8 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -243,6 +243,28 @@ const zPatchGeneral = z.object({ }), }); +const zSelectPoolCreate = z + .object({ + type: z.literal('selectPoolCreate'), + requestId: z.string().optional(), + userId: z.string().min(1), + ownerDisplayName: z.string().min(1), + uniqueName: z.string().min(1).max(20), + personality: z.string().min(1), + seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]), + }) + .strict(); + +const zSelectPoolReselect = z + .object({ + type: z.literal('selectPoolReselect'), + requestId: z.string().optional(), + userId: z.string().min(1), + ownerDisplayName: z.string().min(1), + uniqueName: z.string().min(1).max(20), + }) + .strict(); + const zGetStatus = z.object({ type: z.literal('getStatus'), requestId: z.string().optional(), @@ -484,6 +506,22 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => { return { ...command, requestId: envelope.requestId }; }; +const normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelope) => { + const command = parseWith(zSelectPoolCreate, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + +const normalizeSelectPoolReselect: CommandNormalizer<'selectPoolReselect'> = (envelope) => { + const command = parseWith(zSelectPoolReselect, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeGetStatus: CommandNormalizer<'getStatus'> = (envelope) => { const command = parseWith(zGetStatus, envelope.command); if (!command) { @@ -547,6 +585,8 @@ const normalizers: CommandNormalizerMap = { adjustGeneralMeta: normalizeAdjustGeneralMeta, tournamentMatchResult: normalizeTournamentMatchResult, patchGeneral: normalizePatchGeneral, + selectPoolCreate: normalizeSelectPoolCreate, + selectPoolReselect: normalizeSelectPoolReselect, getStatus: normalizeGetStatus, run: normalizeRun, pause: normalizePause, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index bcc2fcc..89a6f4f 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -351,6 +351,8 @@ const buildGeneralUpdate = ( bornYear: general.bornYear, deadYear: general.deadYear, picture: general.picture ?? null, + imageServer: general.imageServer ?? 0, + startAge: general.startAge ?? general.age, npcState: general.npcState, horseCode: toCode(general.role.items.horse), weaponCode: toCode(general.role.items.weapon), @@ -369,6 +371,7 @@ const buildGeneralCreate = ( general: ReturnType['generals'][number] ): TurnEngineGeneralCreateManyInput => ({ id: general.id, + userId: general.userId ?? null, name: general.name, nationId: general.nationId, cityId: general.cityId, @@ -392,6 +395,8 @@ const buildGeneralCreate = ( bornYear: general.bornYear, deadYear: general.deadYear, picture: general.picture ?? null, + imageServer: general.imageServer ?? 0, + startAge: general.startAge ?? general.age, horseCode: toCode(general.role.items.horse), weaponCode: toCode(general.role.items.weapon), bookCode: toCode(general.role.items.book), @@ -799,6 +804,14 @@ export const createDatabaseTurnHooks = async ( } if (deletedGenerals.length > 0) { + await prisma.selectPoolEntry.updateMany({ + where: { generalId: { in: deletedGenerals } }, + data: { + generalId: null, + ownerUserId: null, + reservedUntil: null, + }, + }); if (prisma.generalTurnRevision) { await prisma.generalTurnRevision.deleteMany({ where: { generalId: { in: deletedGenerals } }, @@ -969,6 +982,8 @@ export const createDatabaseTurnHooks = async ( result: asJson(commandCompletion.result), completedAt: new Date(), error: null, + lockedBy: null, + leaseUntil: null, }, }); } diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 3e544e9..69eb4ce 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -881,10 +881,9 @@ export class InMemoryTurnWorld { getNextGeneralId(): number { const meta = this.state.meta as Record; let lastId = (meta.lastGeneralId as number | undefined) ?? 0; - if (lastId === 0) { - const currentIds = Array.from(this.generals.keys()); - lastId = currentIds.length > 0 ? Math.max(...currentIds) : 0; - } + const currentIds = Array.from(this.generals.keys()); + const currentMaxId = currentIds.length > 0 ? Math.max(...currentIds) : 0; + lastId = Math.max(lastId, currentMaxId); const nextId = lastId + 1; this.state = { diff --git a/app/game-engine/src/turn/selectPoolService.ts b/app/game-engine/src/turn/selectPoolService.ts new file mode 100644 index 0000000..a614c43 --- /dev/null +++ b/app/game-engine/src/turn/selectPoolService.ts @@ -0,0 +1,889 @@ +import { z } from 'zod'; + +import { + asNumber, + asRecord, + JosaUtil, + LiteHashDRBG, + RandUtil, +} from '@sammo-ts/common'; +import { GamePrisma, LogCategory, LogScope } from '@sammo-ts/infra'; +import { + EventDomesticTraitLoader, + isEventDomesticTraitKey, + isPersonalityTraitKey, + PERSONALITY_TRAIT_KEYS, + simpleSerialize, +} from '@sammo-ts/logic'; + +import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra'; +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; +import type { TurnGeneral } from './types.js'; + +type WorldStateRow = GamePrismaTypes.WorldStateGetPayload>; + +export type SelectPoolErrorCode = + | 'BAD_REQUEST' + | 'PRECONDITION_FAILED' + | 'CONFLICT' + | 'INTERNAL_SERVER_ERROR'; + +export class SelectPoolError extends Error { + constructor( + readonly code: SelectPoolErrorCode, + message: string + ) { + super(message); + this.name = 'SelectPoolError'; + } +} + +const SUPPORTED_POOL = 'SPoolUnderU30'; +const RESERVATION_COUNT = 14; +const RESERVATION_TURN_MULTIPLIER = 2; +const RESELECTION_TURN_MULTIPLIER = 12; +const DEFAULT_MAX_GENERAL = 500; +const DEFAULT_CREW_TYPE_ID = 1100; +const MAX_GENERAL_TURNS = 30; +const DEFAULT_TURN_ACTION = '휴식'; +const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000; + +const zCandidateInfo = z.object({ + uniqueName: z.string().min(1), + generalName: z.string().min(1), + leadership: z.number().int(), + strength: z.number().int(), + intel: z.number().int(), + specialDomestic: z.string().min(1), + specialWar: z.string().min(1).optional(), + ego: z.string().min(1).optional(), + experience: z.number().int().optional(), + dedication: z.number().int().optional(), + dex: z.tuple([z.number(), z.number(), z.number(), z.number(), z.number()]), + imgsvr: z.union([z.literal(0), z.literal(1)]), + picture: z.string(), +}); + +export type SelectPoolCandidateInfo = z.infer; + +interface SelectPoolRow { + id: number; + uniqueName: string; + ownerUserId: string | null; + generalId: number | null; + reservedUntil: Date | null; + info: unknown; +} + +export interface SelectPoolCandidateDto { + uniqueName: string; + generalName: string; + leadership: number; + strength: number; + intel: number; + specialDomestic: string; + specialDomesticName: string; + specialDomesticInfo: string; + specialWar: string | null; + ego: string | null; + dex: [number, number, number, number, number]; + imageServer: 0 | 1; + picture: string; +} + +export interface SelectPoolReservationDto { + poolName: typeof SUPPORTED_POOL; + hasGeneral: boolean; + validUntil: string; + candidates: SelectPoolCandidateDto[]; +} + +const fail = ( + code: SelectPoolErrorCode, + message: string +): never => { + throw new SelectPoolError(code, message); +}; + +const resolvePoolName = (worldState: WorldStateRow): string | null => { + const config = asRecord(worldState.config); + const map = asRecord(config.map); + return typeof map.targetGeneralPool === 'string' ? map.targetGeneralPool : null; +}; + +const resolvePoolAllowOptions = (worldState: WorldStateRow): string[] => { + const map = asRecord(asRecord(worldState.config).map); + return Array.isArray(map.generalPoolAllowOption) + ? map.generalPoolAllowOption.filter((value): value is string => typeof value === 'string') + : []; +}; + +const resolveTurnTermMinutes = (worldState: WorldStateRow): number => { + const config = asRecord(worldState.config); + const configured = asNumber(config.turnTermMinutes, Math.round(worldState.tickSeconds / 60)); + return Math.max(1, Math.abs(Math.trunc(configured))); +}; + +export const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => { + const config = asRecord(worldState.config); + return asNumber(config.npcMode, 0) === 2 && resolvePoolName(worldState) === SUPPORTED_POOL; +}; + +export const resolveSelectionMaxGeneral = (worldState: WorldStateRow): number => { + const config = asRecord(worldState.config); + const configConst = asRecord(config.const); + return Math.max( + 0, + Math.floor( + asNumber( + config.maxGeneral ?? + configConst.defaultMaxGeneral ?? + configConst.maxGeneral, + DEFAULT_MAX_GENERAL + ) + ) + ); +}; + +const requirePoolWorld = (worldState: WorldStateRow): void => { + if (!isSelectionPoolWorld(worldState)) { + fail('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다'); + } +}; + +const parseCandidate = (row: Pick): SelectPoolCandidateInfo => { + const info = zCandidateInfo.safeParse(row.info); + if (!info.success || !info.data) { + throw new SelectPoolError( + 'INTERNAL_SERVER_ERROR', + `장수 선택 후보 정보가 올바르지 않습니다: ${row.uniqueName}` + ); + } + const candidate = info.data; + if (candidate.uniqueName !== row.uniqueName) { + throw new SelectPoolError( + 'INTERNAL_SERVER_ERROR', + `장수 선택 후보 정보가 올바르지 않습니다: ${row.uniqueName}` + ); + } + return candidate; +}; + +const candidateWeight = (candidate: SelectPoolCandidateInfo): number => + candidate.dex.reduce((sum, value) => sum + value, 0); + +const eventDomesticTraitLoader = new EventDomesticTraitLoader(); + +const toCandidateDto = async ( + candidate: SelectPoolCandidateInfo +): Promise => { + const trait = isEventDomesticTraitKey(candidate.specialDomestic) + ? await eventDomesticTraitLoader.load(candidate.specialDomestic) + : null; + return { + uniqueName: candidate.uniqueName, + generalName: candidate.generalName, + leadership: candidate.leadership, + strength: candidate.strength, + intel: candidate.intel, + specialDomestic: candidate.specialDomestic, + specialDomesticName: + trait?.name ?? candidate.specialDomestic.replace(/^che_event_/, ''), + specialDomesticInfo: trait?.info ?? '', + specialWar: candidate.specialWar ?? null, + ego: candidate.ego ?? null, + dex: candidate.dex, + imageServer: candidate.imgsvr, + picture: candidate.picture, + }; +}; + +const toReservationDto = ( + rows: Array>, + hasGeneral: boolean +): Promise => { + const validUntil = rows[0]?.reservedUntil; + if (!validUntil) { + throw new SelectPoolError( + 'INTERNAL_SERVER_ERROR', + '장수 선택 후보의 유효기간이 없습니다.' + ); + } + const expiresAt = validUntil; + const sorted = rows + .map((row) => ({ id: row.id, info: parseCandidate(row) })) + .sort( + (left, right) => + candidateWeight(left.info) - candidateWeight(right.info) || left.id - right.id + ); + return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({ + poolName: SUPPORTED_POOL, + hasGeneral, + validUntil: expiresAt.toISOString(), + candidates, + })); +}; + +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 buildSelectPoolSeed = ( + hiddenSeed: string | number, + ownerIdentity: string | number, + now: Date +): string => simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, formatLegacySeedTime(now)); + +export const claimWeightedSelectionCandidates = async (options: { + weighted: [T, number][]; + rng: RandUtil; + count: number; + claim(candidate: T): Promise; + onDraw?(candidate: T): void; + maxAttempts?: number; +}): Promise => { + const claimed: T[] = []; + const claimedIds = new Set(); + const maxAttempts = options.maxAttempts ?? Math.max(options.weighted.length * 8, 1000); + let attempts = 0; + while (claimed.length < options.count && attempts < maxAttempts) { + attempts += 1; + const candidate = options.rng.choiceUsingWeightPair(options.weighted); + options.onDraw?.(candidate); + if (claimedIds.has(candidate.id) || !(await options.claim(candidate))) { + continue; + } + claimedIds.add(candidate.id); + claimed.push(candidate); + } + return claimed; +}; + +const readNextChangeAt = (generalMeta: unknown): Date | null => { + const meta = asRecord(generalMeta); + const raw = meta.next_change ?? meta.nextChangeAt; + if (typeof raw !== 'string') { + return null; + } + const parsed = new Date(raw); + return Number.isNaN(parsed.getTime()) ? null : parsed; +}; + +const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => { + const meta = asRecord(worldState.meta); + const value = meta.hiddenSeed ?? meta.seed; + return typeof value === 'string' || typeof value === 'number' + ? value + : fail('INTERNAL_SERVER_ERROR', '장수 선택 비밀 seed가 설정되지 않았습니다.'); +}; + +const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise => { + await db.$executeRaw( + GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`select_pool:${userId}`}, 903))` + ); +}; + +const requireSelectionToken = async ( + db: DatabaseClient, + userId: string, + uniqueName: string, + now: Date +): Promise => { + const token = await db.selectPoolEntry.findFirst({ + where: { + ownerUserId: userId, + uniqueName, + reservedUntil: { gte: now }, + generalId: null, + }, + }); + if (!token) { + fail('PRECONDITION_FAILED', '유효한 장수 목록이 없습니다.'); + } + return token as SelectPoolRow; +}; + +export const reserveSelectionPool = async (options: { + db: DatabaseClient; + worldState: WorldStateRow; + userId: string; + now?: Date; + seedOwnerIdentity?: string | number; +}): Promise => { + const { db, worldState, userId } = options; + requirePoolWorld(worldState); + const now = options.now ?? new Date(); + await lockSelectionUser(db, userId); + const general = await db.general.findFirst({ + where: { userId }, + select: { id: true, meta: true }, + }); + const nextChangeAt = general ? readNextChangeAt(general.meta) : null; + if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) { + fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다'); + } + + const existing = await db.selectPoolEntry.findMany({ + where: { + ownerUserId: userId, + reservedUntil: { gte: now }, + generalId: null, + }, + orderBy: { id: 'asc' }, + }); + if (existing.length > 0) { + return toReservationDto(existing as SelectPoolRow[], Boolean(general)); + } + + await db.selectPoolEntry.updateMany({ + where: { + reservedUntil: { lt: now }, + generalId: null, + }, + data: { + ownerUserId: null, + reservedUntil: null, + }, + }); + + const available = (await db.selectPoolEntry.findMany({ + where: { + ownerUserId: null, + reservedUntil: null, + generalId: null, + }, + orderBy: { id: 'asc' }, + })) as SelectPoolRow[]; + if (available.length < RESERVATION_COUNT) { + fail('PRECONDITION_FAILED', 'pool 부족'); + } + + const rng = new RandUtil( + new LiteHashDRBG( + buildSelectPoolSeed( + getWorldHiddenSeed(worldState), + options.seedOwnerIdentity ?? userId, + now + ) + ) + ); + const weighted = available.map((row) => [row, candidateWeight(parseCandidate(row))] as [SelectPoolRow, number]); + const reservedUntil = new Date( + now.getTime() + resolveTurnTermMinutes(worldState) * RESERVATION_TURN_MULTIPLIER * 60_000 + ); + const selected = await claimWeightedSelectionCandidates({ + weighted, + rng, + count: RESERVATION_COUNT, + claim: async (candidate) => { + const claimed = await db.selectPoolEntry.updateMany({ + where: { + id: candidate.id, + ownerUserId: null, + reservedUntil: null, + generalId: null, + }, + data: { + ownerUserId: userId, + reservedUntil, + }, + }); + return claimed.count > 0; + }, + }); + const reserved = selected.map((candidate) => ({ + ...candidate, + ownerUserId: userId, + reservedUntil, + })); + if (reserved.length !== RESERVATION_COUNT) { + fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.'); + } + return toReservationDto(reserved, Boolean(general)); +}; + +const lockSelectionMutationTables = async (db: DatabaseClient): Promise => { + await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`); + await db.$executeRaw(GamePrisma.sql`LOCK TABLE "select_pool" IN SHARE ROW EXCLUSIVE MODE`); +}; + +const assertGeneralIdSnapshotMatches = async ( + db: DatabaseClient, + world: InMemoryTurnWorld +): Promise => { + const persistedIds = ( + await db.general.findMany({ + select: { id: true }, + orderBy: { id: 'asc' }, + }) + ).map(({ id }) => id); + const runtimeIds = world + .listGenerals() + .map(({ id }) => id) + .sort((left, right) => left - right); + if ( + persistedIds.length !== runtimeIds.length || + persistedIds.some((id, index) => id !== runtimeIds[index]) + ) { + throw new Error( + 'DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.' + ); + } +}; + +const clearUnusedReservations = async (db: DatabaseClient, userId: string, now: Date): Promise => { + await db.selectPoolEntry.updateMany({ + where: { + generalId: null, + OR: [{ ownerUserId: userId }, { reservedUntil: { lt: now } }], + }, + data: { + ownerUserId: null, + reservedUntil: null, + }, + }); +}; + +const resolveSpecialityAges = (worldState: WorldStateRow, age: number): { domestic: number; war: number } => { + const configConst = asRecord(asRecord(worldState.config).const); + const retirementYear = asNumber(configConst.retirementYear, 80); + const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta); + const startYear = asNumber(scenarioMeta.startYear, worldState.currentYear); + const relativeYear = Math.max(worldState.currentYear - startYear, 0); + const build = (divisor: number): number => + Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; + return { domestic: build(12), war: build(6) }; +}; + +const resolveRandomPersonality = ( + worldState: WorldStateRow, + ownerIdentity: string | number, + uniqueName: string +): string => + new RandUtil( + new LiteHashDRBG( + simpleSerialize( + getWorldHiddenSeed(worldState), + 'selectPickedGeneralPersonality', + ownerIdentity, + uniqueName + ) + ) + ).choice([...PERSONALITY_TRAIT_KEYS]); + +const resolveSelectedPersonality = ( + worldState: WorldStateRow, + ownerIdentity: string | number, + uniqueName: string, + requested: string +): string => { + if (!resolvePoolAllowOptions(worldState).includes('ego')) { + return 'None'; + } + if (requested === 'Random') { + return resolveRandomPersonality(worldState, ownerIdentity, uniqueName); + } + if (!isPersonalityTraitKey(requested)) { + fail('BAD_REQUEST', '올바르지 않은 성격입니다.'); + } + return requested; +}; + +const resolvePoolRng = ( + worldState: WorldStateRow, + ownerIdentity: string | number, + uniqueName: string +): RandUtil => + new RandUtil( + new LiteHashDRBG( + simpleSerialize( + getWorldHiddenSeed(worldState), + 'selectPickedGeneral', + ownerIdentity, + uniqueName + ) + ) + ); + +const resolveTurnTimeBase = (worldState: WorldStateRow, now: Date): Date => { + const raw = asRecord(worldState.meta).turntime; + if (typeof raw === 'string') { + const parsed = new Date(raw); + if (!Number.isNaN(parsed.getTime())) { + return parsed; + } + } + return now; +}; + +const buildInitialTurnTime = (rng: RandUtil, worldState: WorldStateRow, now: Date): Date => { + const termSeconds = resolveTurnTermMinutes(worldState) * 60; + const seconds = rng.nextRangeInt(0, termSeconds - 1); + const microseconds = rng.nextRangeInt(0, 999_999); + return new Date(resolveTurnTimeBase(worldState, now).getTime() + seconds * 1000 + microseconds / 1000); +}; + +const appendSelectionLogs = async (options: { + db: DatabaseClient; + worldState: WorldStateRow; + generalId: number; + ownerUserId: string; + generalText: string; + globalText: string; +}): Promise => { + const common = { + year: options.worldState.currentYear, + month: options.worldState.currentMonth, + nationId: null, + userId: null, + meta: { ownerUserId: options.ownerUserId }, + }; + await options.db.logEntry.createMany({ + data: [ + { + ...common, + scope: LogScope.GENERAL, + category: LogCategory.HISTORY, + generalId: options.generalId, + text: options.generalText, + }, + { + ...common, + scope: LogScope.SYSTEM, + category: LogCategory.ACTION, + generalId: null, + text: options.globalText, + }, + ], + }); +}; + +export const createGeneralFromSelectionPool = async (options: { + db: DatabaseClient; + world: InMemoryTurnWorld; + worldState: WorldStateRow; + userId: string; + ownerDisplayName: string; + uniqueName: string; + personality: string; + now?: Date; + seedOwnerIdentity?: string | number; +}): Promise<{ ok: true; generalId: number }> => { + const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options; + requirePoolWorld(worldState); + const now = options.now ?? new Date(); + await lockSelectionUser(db, userId); + await lockSelectionMutationTables(db); + await assertGeneralIdSnapshotMatches(db, world); + if ( + world.listGenerals().some((general) => general.userId === userId) || + (await db.general.findFirst({ where: { userId }, select: { id: true } })) + ) { + fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.'); + } + const token = await requireSelectionToken(db, userId, uniqueName, now); + const info = parseCandidate(token); + + const config = asRecord(worldState.config); + const configConst = asRecord(config.const); + const maxGeneral = resolveSelectionMaxGeneral(worldState); + const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } }); + if (activeCount >= maxGeneral) { + fail('PRECONDITION_FAILED', '더 이상 등록 할 수 없습니다.'); + } + + const seedOwnerIdentity = options.seedOwnerIdentity ?? userId; + const rng = resolvePoolRng(worldState, seedOwnerIdentity, uniqueName); + const affinity = rng.nextRangeInt(1, 150); + const cities = await db.city.findMany({ select: { id: true, name: true }, orderBy: { id: 'asc' } }); + if (cities.length === 0) { + fail('PRECONDITION_FAILED', '생성 가능한 도시가 없습니다.'); + } + const city = rng.choice(cities); + const turnTime = buildInitialTurnTime(rng, worldState, now); + const age = 20; + const specialityAges = resolveSpecialityAges(worldState, age); + const nextChangeAt = new Date( + now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000 + ); + const showImgLevel = asNumber(config.showImgLevel, 0); + const picture = showImgLevel >= 3 ? info.picture : 'default.jpg'; + const defaultSpecialWar = + typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None'; + const personality = resolveSelectedPersonality( + worldState, + seedOwnerIdentity, + uniqueName, + options.personality + ); + // 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다. + // SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저 + // getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다. + const generalId = world.getNextGeneralId(); + + const general: TurnGeneral = { + id: generalId, + userId, + name: info.generalName, + nationId: 0, + cityId: city.id, + troopId: 0, + npcState: 0, + affinity, + bornYear: worldState.currentYear - age, + deadYear: worldState.currentYear + 60, + picture, + imageServer: info.imgsvr, + stats: { + leadership: info.leadership, + strength: info.strength, + intelligence: info.intel, + }, + experience: info.experience ?? age * 100, + dedication: info.dedication ?? age * 100, + officerLevel: 0, + injury: 0, + gold: 1000, + rice: 1000, + crew: 0, + crewTypeId: DEFAULT_CREW_TYPE_ID, + train: 0, + atmos: 0, + turnTime, + age, + startAge: age, + role: { + personality, + specialDomestic: info.specialDomestic, + specialWar: info.specialWar ?? defaultSpecialWar, + items: { + horse: null, + weapon: null, + book: null, + item: null, + }, + }, + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + lastTurn: { command: DEFAULT_TURN_ACTION }, + penalty: {}, + refreshScoreTotal: 0, + meta: { + createdBy: 'select_pool', + ownerName: ownerDisplayName, + owner_name: ownerDisplayName, + killturn: 5, + specage: specialityAges.domestic, + specage2: specialityAges.war, + dex1: info.dex[0], + dex2: info.dex[1], + dex3: info.dex[2], + dex4: info.dex[3], + dex5: info.dex[4], + next_change: nextChangeAt.toISOString(), + nextChangeAt: nextChangeAt.toISOString(), + npc_org: 0, + }, + }; + if (!world.addGeneral(general)) { + throw new Error(`장수 번호 ${generalId}를 할당할 수 없습니다.`); + } + await db.generalTurn.createMany({ + data: Array.from({ length: MAX_GENERAL_TURNS }, (_, turnIdx) => ({ + generalId, + turnIdx, + actionCode: DEFAULT_TURN_ACTION, + arg: {}, + })), + }); + await db.generalTurnRevision.create({ + data: { + generalId, + revision: 0, + }, + }); + const occupied = await db.selectPoolEntry.updateMany({ + where: { + id: token.id, + ownerUserId: userId, + reservedUntil: { gte: now }, + generalId: null, + }, + data: { + generalId, + ownerUserId: null, + reservedUntil: null, + }, + }); + if (occupied.count === 0) { + throw new Error('장수 등록 중 선택 후보 점유에 실패했습니다.'); + } + await db.generalAccessLog.upsert({ + where: { generalId }, + update: { userId, lastRefresh: now }, + create: { generalId, userId, lastRefresh: now }, + }); + await clearUnusedReservations(db, userId, now); + + const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이'); + const generalJosaRo = JosaUtil.pick(info.generalName, '로'); + await appendSelectionLogs({ + db, + worldState, + generalId, + ownerUserId: userId, + generalText: `${info.generalName}, ${city.name}에서 등장`, + globalText: `${city.name}에서 ${ownerDisplayName}${ownerJosaYi} ${info.generalName}${generalJosaRo} 등장합니다.`, + }); + return { ok: true, generalId }; +}; + +export const reselectGeneralFromSelectionPool = async (options: { + db: DatabaseClient; + world: InMemoryTurnWorld; + worldState: WorldStateRow; + userId: string; + ownerDisplayName: string; + uniqueName: string; + now?: Date; +}): Promise<{ ok: true; generalId: number }> => { + const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options; + requirePoolWorld(worldState); + const now = options.now ?? new Date(); + await lockSelectionUser(db, userId); + await lockSelectionMutationTables(db); + const persistedGeneral = await db.general.findFirst({ where: { userId } }); + const general = world.listGenerals().find((candidate) => candidate.userId === userId); + if (!persistedGeneral || !general) { + throw new SelectPoolError( + 'PRECONDITION_FAILED', + '장수가 생성하지 않았습니다. 이미 사망하지 않았는지 확인해보세요.' + ); + } + if (persistedGeneral.id !== general.id) { + fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.'); + } + const nextChangeAt = readNextChangeAt(general.meta); + if (nextChangeAt && nextChangeAt.getTime() > now.getTime()) { + fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다'); + } + const token = await requireSelectionToken(db, userId, uniqueName, now); + const info = parseCandidate(token); + + const provisionalGeneralId = -general.id; + const claimed = await db.selectPoolEntry.updateMany({ + where: { + id: token.id, + ownerUserId: userId, + reservedUntil: { gte: now }, + generalId: null, + }, + data: { + generalId: provisionalGeneralId, + ownerUserId: null, + reservedUntil: null, + }, + }); + if (claimed.count === 0) { + throw new Error('장수 재선택 중 선택 후보 점유에 실패했습니다.'); + } + await db.selectPoolEntry.updateMany({ + where: { generalId: general.id }, + data: { generalId: null, ownerUserId: null, reservedUntil: null }, + }); + const finalized = await db.selectPoolEntry.updateMany({ + where: { + id: token.id, + generalId: provisionalGeneralId, + }, + data: { + generalId: general.id, + }, + }); + if (finalized.count === 0) { + throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.'); + } + + const currentMeta = asRecord(general.meta); + const cooldown = new Date( + now.getTime() + resolveTurnTermMinutes(worldState) * RESELECTION_TURN_MULTIPLIER * 60_000 + ); + const updatedMeta = { + ...currentMeta, + ownerName: ownerDisplayName, + owner_name: ownerDisplayName, + dex1: info.dex[0], + dex2: info.dex[1], + dex3: info.dex[2], + dex4: info.dex[3], + dex5: info.dex[4], + next_change: cooldown.toISOString(), + nextChangeAt: cooldown.toISOString(), + }; + const updated = world.updateGeneral(general.id, { + name: info.generalName, + stats: { + leadership: info.leadership, + strength: info.strength, + intelligence: info.intel, + }, + role: { + ...general.role, + personality: info.ego ?? general.role.personality, + specialDomestic: info.specialDomestic, + specialWar: info.specialWar ?? general.role.specialWar, + }, + picture: info.picture, + imageServer: info.imgsvr, + meta: updatedMeta as unknown as TurnGeneral['meta'], + }); + if (!updated) { + throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.'); + } + await clearUnusedReservations(db, userId, now); + + const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이'); + const generalJosaRo = JosaUtil.pick(info.generalName, '로'); + await appendSelectionLogs({ + db, + worldState, + generalId: general.id, + ownerUserId: userId, + generalText: `장수를 ${general.name}에서 ${info.generalName}${generalJosaRo} 변경`, + globalText: `${ownerDisplayName}${ownerJosaYi} 장수를 ${general.name}에서 ${info.generalName}${generalJosaRo} 변경합니다.`, + }); + return { ok: true, generalId: general.id }; +}; + +export const getSelectionPoolStatus = async ( + db: DatabaseClient, + worldState: WorldStateRow, + userId: string +): Promise<{ + enabled: boolean; + poolName: string | null; + allowOptions: string[]; + hasGeneral: boolean; + nextChangeAt: string | null; +}> => { + const poolName = resolvePoolName(worldState); + const enabled = isSelectionPoolWorld(worldState); + const general = await db.general.findFirst({ where: { userId }, select: { meta: true } }); + return { + enabled, + poolName, + allowOptions: resolvePoolAllowOptions(worldState), + hasGeneral: Boolean(general), + nextChangeAt: general ? readNextChangeAt(general.meta)?.toISOString() ?? null : null, + }; +}; diff --git a/app/game-engine/src/turn/types.ts b/app/game-engine/src/turn/types.ts index 5b42ec4..28d7904 100644 --- a/app/game-engine/src/turn/types.ts +++ b/app/game-engine/src/turn/types.ts @@ -27,6 +27,7 @@ export interface TurnGeneral extends General { deadYear?: number; affinity?: number | null; picture?: string | null; + imageServer?: number; turnTime: Date; recentWarTime?: Date | null; lastTurn?: GeneralLastTurn; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 02de6eb..9b6c336 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -4,7 +4,7 @@ import type { TurnDaemonCommandExecutionContext, TurnDaemonCommandResult, } from '../lifecycle/types.js'; -import { GamePrisma } from '@sammo-ts/infra'; +import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra'; import { asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { LogCategory, @@ -40,6 +40,11 @@ import { IMMEDIATE_TROOP_JOIN_MOVE_HANDLER, LEGACY_TROOP_JOIN_EVENT, } from './scenarioStaticEvents.js'; +import { + createGeneralFromSelectionPool, + reselectGeneralFromSelectionPool, + SelectPoolError, +} from './selectPoolService.js'; let itemRegistryPromise: Promise> | null = null; @@ -117,6 +122,108 @@ interface CommandHandlerContext { tournamentRewardFinalizer?: TournamentRewardFinalizer; } +const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => { + if (!ctx.commandDb) { + throw new Error('ENGINE mutation transaction is required for selection-pool commands.'); + } + return ctx.commandDb as unknown as DatabaseClient; +}; + +const resolveCommandAcceptedAt = async ( + db: DatabaseClient, + command: Extract +): 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 }, + }); + if (!event) { + throw new Error(`ENGINE input event ${command.requestId} is missing.`); + } + return event.createdAt; +}; + +async function handleSelectPoolCreate( + 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('Selection-pool world state is missing.'); + } + const acceptedAt = await resolveCommandAcceptedAt(db, command); + try { + return { + type: 'selectPoolCreate', + ...(await createGeneralFromSelectionPool({ + db, + world: ctx.world, + worldState, + userId: command.userId, + ownerDisplayName: command.ownerDisplayName, + uniqueName: command.uniqueName, + personality: command.personality, + seedOwnerIdentity: command.seedOwnerIdentity, + now: acceptedAt, + })), + }; + } catch (error) { + if (error instanceof SelectPoolError) { + return { + type: 'selectPoolCreate', + ok: false, + code: error.code, + reason: error.message, + }; + } + throw error; + } +} + +async function handleSelectPoolReselect( + 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('Selection-pool world state is missing.'); + } + const acceptedAt = await resolveCommandAcceptedAt(db, command); + try { + return { + type: 'selectPoolReselect', + ...(await reselectGeneralFromSelectionPool({ + db, + world: ctx.world, + worldState, + userId: command.userId, + ownerDisplayName: command.ownerDisplayName, + uniqueName: command.uniqueName, + now: acceptedAt, + })), + }; + } catch (error) { + if (error instanceof SelectPoolError) { + return { + type: 'selectPoolReselect', + ok: false, + code: error.code, + reason: error.message, + }; + } + throw error; + } +} + interface AuctionFinalizer { finalize(auctionId: number, db?: GamePrisma.TransactionClient): Promise; } @@ -1845,6 +1952,16 @@ export const createTurnDaemonCommandHandler = (options: { handleTournamentMatchResult(ctx, command as Extract), patchGeneral: (command) => handlePatchGeneral(ctx, command as Extract), + selectPoolCreate: (command) => + handleSelectPoolCreate( + ctx, + command as Extract + ), + selectPoolReselect: (command) => + handleSelectPoolReselect( + ctx, + command as Extract + ), shiftSchedule: (command) => handleShiftSchedule(ctx, command as Extract), }; diff --git a/app/game-engine/src/turn/worldLoader.ts b/app/game-engine/src/turn/worldLoader.ts index 165a47c..e0e9d4e 100644 --- a/app/game-engine/src/turn/worldLoader.ts +++ b/app/game-engine/src/turn/worldLoader.ts @@ -237,6 +237,7 @@ const mapGeneralRow = ( deadYear: row.deadYear, affinity: row.affinity, picture: row.picture, + imageServer: row.imageServer, triggerState: { flags: {}, counters: {}, diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index 2510111..fce224d 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -93,4 +93,48 @@ integration('database command queue', () => { lockedBy: 'active-worker', }); }); + + it('retries an owned command twice and then records a terminal failure', async () => { + const requestId = 'integration:engine:bounded-failure'; + await db.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: 'vacation', + payload: { + type: 'vacation', + requestId, + generalId: 10, + } as GamePrisma.InputJsonValue, + }, + }); + + const owner = new DatabaseTurnDaemonCommandQueue(db); + const stale = new DatabaseTurnDaemonCommandQueue(db); + for (const attempt of [1, 2, 3]) { + await expect(owner.drain()).resolves.toEqual([ + { type: 'vacation', requestId, generalId: 10 }, + ]); + await stale.publishCommandError(requestId, new Error('stale worker failure')); + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId } }) + ).resolves.toMatchObject({ + status: 'PROCESSING', + attempts: attempt, + }); + + await owner.publishCommandError(requestId, new Error('injected command failure')); + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId } }) + ).resolves.toMatchObject({ + status: attempt < 3 ? 'PENDING' : 'FAILED', + attempts: attempt, + error: 'injected command failure', + lockedBy: null, + leaseUntil: null, + }); + } + + await expect(owner.drain()).resolves.toEqual([]); + }); }); diff --git a/app/game-engine/test/generalPoolLoader.test.ts b/app/game-engine/test/generalPoolLoader.test.ts new file mode 100644 index 0000000..0d7ff82 --- /dev/null +++ b/app/game-engine/test/generalPoolLoader.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { loadGeneralPoolEntries } from '../src/scenario/generalPoolLoader.js'; + +describe('SPoolUnderU30 resource', () => { + it('preserves the Ref UnderS30 row contract and ordering', async () => { + const entries = await loadGeneralPoolEntries('SPoolUnderU30'); + const weights = entries.map((entry) => { + const dex = entry.info.dex as number[]; + return dex.reduce((sum, value) => sum + value, 0); + }); + + expect(entries).toHaveLength(1844); + expect(new Set(entries.map((entry) => entry.uniqueName)).size).toBe(1844); + expect(Math.min(...weights)).toBe(100122); + expect(Math.max(...weights)).toBe(2582699); + const traitFrequencies = entries.reduce>((result, entry) => { + const key = String(entry.info.specialDomestic); + result[key] = (result[key] ?? 0) + 1; + return result; + }, {}); + expect(traitFrequencies).toEqual({ + che_event_격노: 152, + che_event_견고: 91, + che_event_공성: 8, + che_event_궁병: 12, + che_event_귀병: 38, + che_event_기병: 12, + che_event_돌격: 98, + che_event_무쌍: 100, + che_event_반계: 81, + che_event_보병: 10, + che_event_신산: 99, + che_event_신중: 106, + che_event_위압: 85, + che_event_의술: 37, + che_event_저격: 251, + che_event_집중: 125, + che_event_징병: 169, + che_event_척사: 166, + che_event_필살: 156, + che_event_환술: 48, + }); + expect(entries[0]).toEqual({ + uniqueName: '⑨탈곡기', + info: { + generalName: '⑨탈곡기', + leadership: 69, + strength: 12, + intel: 80, + specialDomestic: 'che_event_징병', + dex: [12066, 27302, 29463, 307356, 16448], + imgsvr: 1, + picture: '9ed8be6.gif?=20190417', + uniqueName: '⑨탈곡기', + }, + }); + expect(entries.at(-1)?.uniqueName).toBe('④야부키 나코'); + }); + + it('rejects an unsupported pool instead of silently substituting data', async () => { + await expect(loadGeneralPoolEntries('SPoolUnknown')).rejects.toThrow('Unsupported general pool'); + }); +}); diff --git a/app/game-engine/test/inputEventAtomicity.test.ts b/app/game-engine/test/inputEventAtomicity.test.ts index 89a5288..99f51f4 100644 --- a/app/game-engine/test/inputEventAtomicity.test.ts +++ b/app/game-engine/test/inputEventAtomicity.test.ts @@ -254,6 +254,7 @@ describe('input event atomicity', () => { resolveError = resolve; }); const publishCommandResult = vi.fn(async () => {}); + const publishCommandError = vi.fn(async () => {}); let engineState = { value: 'before' }; const stateManager = new EngineStateManager(); stateManager.register('test', { @@ -287,6 +288,7 @@ describe('input event atomicity', () => { commandResponder: { publishStatus: async () => {}, publishCommandResult, + publishCommandError, }, }, { @@ -305,6 +307,10 @@ describe('input event atomicity', () => { lastError: 'injected commit failure', }); expect(publishCommandResult).not.toHaveBeenCalled(); + expect(publishCommandError).toHaveBeenCalledWith( + 'event-2', + expect.objectContaining({ message: 'injected commit failure' }) + ); expect(engineState).toEqual({ value: 'before' }); expect(stateManager.getRevision()).toBe(0); @@ -320,6 +326,7 @@ describe('input event atomicity', () => { }); const commitCommand = vi.fn(async () => {}); const publishCommandResult = vi.fn(async () => {}); + const publishCommandError = vi.fn(async () => {}); let engineState = { value: 'before' }; const stateManager = new EngineStateManager(); stateManager.register('test', { @@ -351,6 +358,7 @@ describe('input event atomicity', () => { commandResponder: { publishStatus: async () => {}, publishCommandResult, + publishCommandError, }, }, { @@ -370,6 +378,10 @@ describe('input event atomicity', () => { }); expect(commitCommand).not.toHaveBeenCalled(); expect(publishCommandResult).not.toHaveBeenCalled(); + expect(publishCommandError).toHaveBeenCalledWith( + 'event-3', + expect.objectContaining({ message: 'injected handler failure' }) + ); expect(engineState).toEqual({ value: 'before' }); expect(stateManager.getRevision()).toBe(0); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index c21fbb2..6bf7594 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -30,6 +30,12 @@ type ScenarioSeederPrismaClient = { count(): Promise; findFirst(): Promise<{ age: number; startAge: number; meta: unknown } | null>; }; + selectPoolEntry: { + count(): Promise; + findFirst(args: { + orderBy: { id: 'asc' | 'desc' }; + }): Promise<{ uniqueName: string; info: unknown } | null>; + }; diplomacy: { count(): Promise; findFirst(args: { @@ -48,6 +54,10 @@ type ScenarioSeederPrismaClient = { currentMonth: number; } | null>; }; + gameHistory: { + findUnique(args: { where: { serverId: string } }): Promise<{ env: unknown } | null>; + deleteMany(args: { where: { serverId: string } }): Promise<{ count: number }>; + }; }; const requiredTables = ['world_state', 'nation', 'city', 'general', 'diplomacy', 'troop', 'event']; @@ -254,6 +264,167 @@ describeDb('scenario database seed', () => { await connector.disconnect(); } }); + + test('seeds the exact scenario 903 UnderS30 selection pool', async () => { + await seedScenarioToDatabase({ + scenarioId: 903, + databaseUrl, + }); + + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; + expect(await prisma.selectPoolEntry.count()).toBe(1844); + expect(await prisma.selectPoolEntry.findFirst({ orderBy: { id: 'asc' } })).toMatchObject({ + uniqueName: '⑨탈곡기', + info: { + generalName: '⑨탈곡기', + specialDomestic: 'che_event_징병', + }, + }); + expect(await prisma.selectPoolEntry.findFirst({ orderBy: { id: 'desc' } })).toMatchObject({ + uniqueName: '④야부키 나코', + }); + } finally { + await connector.disconnect(); + } + }); + + test('clears general lifecycle rows before reusing general ids on reseed', async () => { + await seedScenarioToDatabase({ + scenarioId: 903, + databaseUrl, + }); + + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const prisma = connector.prisma; + const generalId = 990_903; + const createSelectedGeneral = async (): Promise => { + await prisma.general.create({ + data: { + id: generalId, + userId: 'scenario-reseed-user', + name: '재설치선택장수', + turnTime: new Date('2026-07-30T12:00:00.000Z'), + }, + }); + }; + const createLifecycleRows = async (): Promise => { + await prisma.generalTurn.create({ + data: { + generalId, + turnIdx: 0, + actionCode: '휴식', + }, + }); + await prisma.generalTurnRevision.create({ + data: { + generalId, + revision: 7, + }, + }); + await prisma.rankData.create({ + data: { + nationId: 0, + generalId, + type: 'experience', + value: 123, + }, + }); + await prisma.generalAccessLog.create({ + data: { + generalId, + userId: 'scenario-reseed-user', + }, + }); + }; + const expectLifecycleRows = async (count: number): Promise => { + await expect( + Promise.all([ + prisma.generalTurn.count({ where: { generalId } }), + prisma.generalTurnRevision.count({ where: { generalId } }), + prisma.rankData.count({ where: { generalId } }), + prisma.generalAccessLog.count({ where: { generalId } }), + ]) + ).resolves.toEqual([count, count, count, count]); + }; + + await createSelectedGeneral(); + await createLifecycleRows(); + await expectLifecycleRows(1); + + await seedScenarioToDatabase({ + scenarioId: 903, + databaseUrl, + }); + await expectLifecycleRows(0); + await expect(prisma.general.findUnique({ where: { id: generalId } })).resolves.toBeNull(); + + await createSelectedGeneral(); + await createLifecycleRows(); + await expectLifecycleRows(1); + + await seedScenarioToDatabase({ + scenarioId: 903, + databaseUrl, + }); + await expectLifecycleRows(0); + } finally { + await connector.disconnect(); + } + }); + + test('persists a private hidden seed without copying it into game history', async () => { + const envName = 'INTEGRATION_WORLD_SEED'; + const originalSeed = process.env[envName]; + const serverId = 'scenario-seeder-hidden-seed-test'; + const connector = createGamePostgresConnector({ url: databaseUrl }); + try { + process.env[envName] = 'scenario-seeder-explicit-hidden-seed'; + await seedScenarioToDatabase({ + scenarioId: 903, + databaseUrl, + installOptions: { serverId }, + }); + + await connector.connect(); + const prisma = connector.prisma as unknown as ScenarioSeederPrismaClient; + const explicitWorld = await prisma.worldState.findFirst(); + expect(explicitWorld?.meta).toMatchObject({ + hiddenSeed: 'scenario-seeder-explicit-hidden-seed', + }); + const explicitHistory = await prisma.gameHistory.findUnique({ where: { serverId } }); + expect((explicitHistory?.env as { meta?: Record })?.meta).not.toHaveProperty( + 'hiddenSeed' + ); + + delete process.env[envName]; + await seedScenarioToDatabase({ + scenarioId: 903, + databaseUrl, + installOptions: { serverId }, + }); + const randomWorld = await prisma.worldState.findFirst(); + const randomSeed = (randomWorld?.meta as Record)?.hiddenSeed; + expect(randomSeed).toMatch(/^[0-9a-f]{32}$/); + expect(randomSeed).not.toBe('scenario-seeder-explicit-hidden-seed'); + const randomHistory = await prisma.gameHistory.findUnique({ where: { serverId } }); + expect((randomHistory?.env as { meta?: Record })?.meta).not.toHaveProperty( + 'hiddenSeed' + ); + await prisma.gameHistory.deleteMany({ where: { serverId } }); + } finally { + if (originalSeed === undefined) { + delete process.env[envName]; + } else { + process.env[envName] = originalSeed; + } + await connector.disconnect(); + } + }); }); describe('tracked scenario composition', () => { diff --git a/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts b/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts new file mode 100644 index 0000000..0cc5633 --- /dev/null +++ b/app/game-engine/test/selectPoolReleasePersistence.integration.test.ts @@ -0,0 +1,274 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + createGamePostgresConnector, + type GamePrisma, + type GamePrismaClient, +} from '@sammo-ts/infra'; + +import { createDatabaseTurnHooks } from '../src/turn/databaseHooks.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { loadTurnWorldFromDatabase } from '../src/turn/worldLoader.js'; + +const databaseUrl = process.env.SELECT_POOL_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const generalId = 990_904; +const cityId = 990_904; +const scenarioCode = 'select-pool-release-integration'; + +const assertDedicatedDatabase = (rawUrl: string): void => { + const schema = new URL(rawUrl).searchParams.get('schema'); + if (!schema?.endsWith('select_pool_integration')) { + throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`); + } +}; + +integration('select pool release during general deletion', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + assertDedicatedDatabase(databaseUrl!); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + + await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"'); + await db.selectPoolEntry.deleteMany({ + where: { uniqueName: 'release-candidate' }, + }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.city.deleteMany({ where: { id: cityId } }); + await db.worldState.deleteMany({ where: { scenarioCode } }); + + await db.worldState.create({ + data: { + scenarioCode, + currentYear: 180, + currentMonth: 1, + tickSeconds: 300, + config: { + npcMode: 2, + turnTermMinutes: 5, + stat: { + total: 165, + min: 15, + max: 80, + npcTotal: 165, + npcMax: 80, + npcMin: 15, + chiefMin: 40, + }, + iconPath: '.', + map: { + targetGeneralPool: 'SPoolUnderU30', + generalPoolAllowOption: ['ego'], + }, + const: {}, + environment: { + mapName: 'che', + unitSet: 'che', + }, + }, + meta: { + hiddenSeed: 'select-pool-release-seed', + killturn: 5, + turntime: '2026-07-30T12:00:00.000Z', + }, + }, + }); + await db.city.create({ + data: { + id: cityId, + name: '해제성', + level: 5, + nationId: 0, + population: 10_000, + populationMax: 20_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + region: 1, + }, + }); + await db.general.create({ + data: { + id: generalId, + userId: 'select-pool-release-user', + name: '해제대상', + nationId: 0, + cityId, + troopId: 0, + npcState: 0, + affinity: 1, + bornYear: 160, + deadYear: 240, + picture: 'default.jpg', + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + experience: 2_000, + dedication: 2_000, + officerLevel: 0, + turnTime: new Date('2026-07-30T12:01:00.000Z'), + age: 20, + startAge: 20, + personalCode: 'che_안전', + specialCode: 'che_event_신산', + special2Code: 'che_무쌍', + meta: { + killturn: 5, + dex1: 100_000, + dex2: 100_000, + dex3: 100_000, + dex4: 100_000, + dex5: 100_000, + }, + }, + }); + await db.selectPoolEntry.create({ + data: { + uniqueName: 'release-candidate', + ownerUserId: null, + generalId, + reservedUntil: null, + info: { + uniqueName: 'release-candidate', + generalName: '해제대상', + leadership: 50, + strength: 50, + intel: 50, + specialDomestic: 'che_event_신산', + dex: [100_000, 100_000, 100_000, 100_000, 100_000], + imgsvr: 0, + picture: 'default.jpg', + } as GamePrisma.InputJsonValue, + }, + }); + }); + + afterAll(async () => { + if (!db) { + await closeDb?.(); + return; + } + await db.$executeRawUnsafe('DROP TABLE IF EXISTS "select_pool_delete_blocker"'); + await db.selectPoolEntry.deleteMany({ + where: { uniqueName: 'release-candidate' }, + }); + await db.general.deleteMany({ where: { id: generalId } }); + await db.city.deleteMany({ where: { id: cityId } }); + await db.worldState.deleteMany({ where: { scenarioCode } }); + await closeDb?.(); + }); + + it('round-trips independent event-domestic and war trait slots through a dirty flush', async () => { + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] }, + }); + const before = world.getGeneralById(generalId); + expect(before?.role).toMatchObject({ + specialDomestic: 'che_event_신산', + specialWar: 'che_무쌍', + }); + expect(world.updateGeneral(generalId, { gold: (before?.gold ?? 0) + 1 })).not.toBeNull(); + + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + await hooks.hooks.flushChanges?.({ + lastTurnTime: loaded.state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + } finally { + await hooks.close(); + } + + await expect(db.general.findUniqueOrThrow({ where: { id: generalId } })).resolves.toMatchObject({ + specialCode: 'che_event_신산', + special2Code: 'che_무쌍', + }); + const reloaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + expect( + reloaded.snapshot.generals.find((general) => general.id === generalId)?.role + ).toMatchObject({ + specialDomestic: 'che_event_신산', + specialWar: 'che_무쌍', + }); + }); + + it('rolls back a failed flush, then releases all Ref fields before deleting the general', async () => { + const loaded = await loadTurnWorldFromDatabase({ databaseUrl: databaseUrl! }); + const world = new InMemoryTurnWorld(loaded.state, loaded.snapshot, { + schedule: { entries: [{ startMinute: 0, tickMinutes: 5 }] }, + }); + expect(world.removeGeneral(generalId)).toBe(true); + + const hooks = await createDatabaseTurnHooks(databaseUrl!, world); + try { + await db.$executeRawUnsafe(` + CREATE TABLE "select_pool_delete_blocker" ( + "general_id" INTEGER PRIMARY KEY + REFERENCES "general"("id") ON DELETE RESTRICT + ) + `); + await db.$executeRawUnsafe( + `INSERT INTO "select_pool_delete_blocker" ("general_id") VALUES (${generalId})` + ); + + await expect( + hooks.hooks.flushChanges?.({ + lastTurnTime: loaded.state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }) + ).rejects.toThrow(); + await expect(db.general.findUnique({ where: { id: generalId } })).resolves.not.toBeNull(); + await expect( + db.selectPoolEntry.findUniqueOrThrow({ + where: { uniqueName: 'release-candidate' }, + }) + ).resolves.toMatchObject({ + generalId, + ownerUserId: null, + reservedUntil: null, + }); + + await db.$executeRawUnsafe('DROP TABLE "select_pool_delete_blocker"'); + await hooks.hooks.flushChanges?.({ + lastTurnTime: loaded.state.lastTurnTime.toISOString(), + processedGenerals: 0, + processedTurns: 0, + durationMs: 0, + partial: false, + }); + + await expect(db.general.findUnique({ where: { id: generalId } })).resolves.toBeNull(); + await expect( + db.selectPoolEntry.findUniqueOrThrow({ + where: { uniqueName: 'release-candidate' }, + }) + ).resolves.toMatchObject({ + generalId: null, + ownerUserId: null, + reservedUntil: null, + }); + } finally { + await hooks.close(); + } + }); +}); diff --git a/app/game-engine/test/turnOrder.test.ts b/app/game-engine/test/turnOrder.test.ts index 0800080..b52f7a5 100644 --- a/app/game-engine/test/turnOrder.test.ts +++ b/app/game-engine/test/turnOrder.test.ts @@ -135,7 +135,7 @@ describe('InMemoryTurnProcessor ordering', () => { currentMonth: 1, tickSeconds: 3600, lastTurnTime: baseTime, - meta: {}, + meta: { lastGeneralId: 1 }, }; const world = new InMemoryTurnWorld(state, snapshot, { @@ -157,5 +157,8 @@ describe('InMemoryTurnProcessor ordering', () => { }); expect(executed).toEqual([2, 3, 1]); + expect(world.getNextGeneralId()).toBe(4); + expect(world.getNextGeneralId()).toBe(5); + expect(world.getState().meta).toMatchObject({ lastGeneralId: 5 }); }); }); diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index b9f27ec..b29c1e6 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -52,6 +52,7 @@ const simulatorOptions = { ], }, nationTypes: [{ key: 'che_중립', name: '중립', info: '특별한 효과 없음' }], + eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }], warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }], personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }], items: { horse: [], weapon: [], book: [], item: [] }, @@ -163,6 +164,7 @@ type Fixture = { queueFirst?: boolean; pollingCount: number; requests: string[]; + simulationPayloads: unknown[]; }; const installImages = async (page: Page) => { @@ -185,8 +187,14 @@ const installApi = async (page: Page, fixture: Fixture) => { }); await page.route('**/che/api/trpc/**', async (route) => { const operations = operationNames(route); - const results = operations.map((operation) => { + const rawRequestBody: unknown = route.request().postData() ? route.request().postDataJSON() : {}; + const requestBody = + rawRequestBody && typeof rawRequestBody === 'object' + ? (rawRequestBody as Record) + : {}; + const results = operations.map((operation, operationIndex) => { fixture.requests.push(operation); + if (operation === 'auth.status') return response({ userId: 'battle-sim-user' }); if (operation === 'lobby.info') { return response({ year: 205, @@ -206,6 +214,18 @@ const installApi = async (page: Page, fixture: Fixture) => { } if (operation === 'battle.getGeneralDetail') return response(importedGeneral); if (operation === 'battle.simulate') { + const rawPayload = + requestBody[String(operationIndex)] ?? (operations.length === 1 ? requestBody : undefined); + const payload = + rawPayload && typeof rawPayload === 'object' + ? (rawPayload as { + json?: unknown; + input?: { json?: unknown }; + }) + : undefined; + fixture.simulationPayloads.push( + payload?.json ?? payload?.input?.json ?? rawPayload + ); if (fixture.failNextSimulation) { fixture.failNextSimulation = false; return errorResponse(operation, '시뮬레이터 입력 오류'); @@ -244,7 +264,13 @@ const gotoSimulator = async (page: Page) => { }; test('operates independent/game presets, imports my general, and renders battle logs', async ({ page }) => { - const fixture: Fixture = { hasGeneral: true, queueFirst: true, pollingCount: 0, requests: [] }; + const fixture: Fixture = { + hasGeneral: true, + queueFirst: true, + pollingCount: 0, + requests: [], + simulationPayloads: [], + }; await installApi(page, fixture); await page.setViewportSize({ width: 1280, height: 900 }); await gotoSimulator(page); @@ -265,6 +291,9 @@ test('operates independent/game presets, imports my general, and renders battle await page.getByRole('button', { name: '내 장수를 출병자로' }).click(); await expect(page.getByLabel('이름').first()).toHaveValue('유비'); await expect(page.getByLabel('병사').first()).toHaveValue('4321'); + const attackerDomesticTrait = page.getByLabel('내정특기').first(); + await attackerDomesticTrait.selectOption('che_event_신산'); + await expect(attackerDomesticTrait).toHaveValue('che_event_신산'); const battleButton = page.getByRole('button', { name: '전투', exact: true }); await battleButton.hover(); @@ -276,6 +305,34 @@ test('operates independent/game presets, imports my general, and renders battle await expect(page.getByText('5', { exact: true })).toBeVisible(); expect(fixture.pollingCount).toBe(2); expect(fixture.requests).toContain('battle.getSimulation'); + expect(fixture.simulationPayloads[0]).toMatchObject({ + attackerGeneral: { special: 'che_event_신산' }, + }); + + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: '모두 저장' }).click(); + const download = await downloadPromise; + const downloadPath = await download.path(); + expect(downloadPath).not.toBeNull(); + const exportedBattle = JSON.parse(await readFile(downloadPath!, 'utf8')) as { + objType: string; + data: { attackerGeneral: { special?: string | null } }; + }; + expect(exportedBattle).toMatchObject({ + objType: 'battle', + data: { attackerGeneral: { special: 'che_event_신산' } }, + }); + + await attackerDomesticTrait.selectOption({ label: '-' }); + await expect(attackerDomesticTrait).toHaveValue('-'); + await page.locator('.header-actions input[type="file"]').setInputFiles(downloadPath!); + await expect(attackerDomesticTrait).toHaveValue('che_event_신산'); + + await battleButton.click(); + await expect.poll(() => fixture.simulationPayloads.length).toBe(2); + expect(fixture.simulationPayloads[1]).toMatchObject({ + attackerGeneral: { special: 'che_event_신산' }, + }); if (artifactRoot) { await page.screenshot({ @@ -292,6 +349,7 @@ test('keeps simulation available without a game general and preserves input afte failNextSimulation: true, pollingCount: 0, requests: [], + simulationPayloads: [], }; await installApi(page, fixture); await page.setViewportSize({ width: 500, height: 900 }); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 5344295..a5b8d43 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -28,6 +28,7 @@ export default defineConfig({ 'commandArguments.spec.ts', 'commandArgumentsLive.spec.ts', 'mainNavigation.spec.ts', + 'session-auth.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/game-frontend/e2e/selectGeneral.live.playwright.config.mjs b/app/game-frontend/e2e/selectGeneral.live.playwright.config.mjs new file mode 100644 index 0000000..926029f --- /dev/null +++ b/app/game-frontend/e2e/selectGeneral.live.playwright.config.mjs @@ -0,0 +1,40 @@ +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 port = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15124); +const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`; +const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:903'; +const baseURL = `http://127.0.0.1:${port}${basePath}/`; +const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? 'http://127.0.0.1:15125/trpc'; + +export default defineConfig({ + testDir: '.', + testMatch: ['selectGeneralLive.spec.ts'], + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/select-pool-live'), + use: { + baseURL, + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + colorScheme: 'dark', + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: { + 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 ${port}`, + cwd: repositoryRoot, + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/app/game-frontend/e2e/selectGeneralLive.spec.ts b/app/game-frontend/e2e/selectGeneralLive.spec.ts new file mode 100644 index 0000000..4f576e2 --- /dev/null +++ b/app/game-frontend/e2e/selectGeneralLive.spec.ts @@ -0,0 +1,519 @@ +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { expect, test, type Page } from '@playwright/test'; +import { encryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; +import { + createGamePostgresConnector, + type GamePrisma, + type GamePrismaClient, +} from '@sammo-ts/infra'; + +const gameTokenSecret = process.env.SELECT_POOL_LIVE_GAME_SECRET; +const databaseUrl = process.env.SELECT_POOL_LIVE_DATABASE_URL; +const userId = process.env.SELECT_POOL_LIVE_USER_ID; +const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'hwe:903'; +const hasLiveFixture = Boolean(gameTokenSecret && databaseUrl && userId); +const workspaceRoot = + process.env.SAMMO_WORKSPACE_ROOT ?? + path.resolve(import.meta.dirname, '../../../../../sam_rebuild'); +const defaultIcon = path.resolve( + process.env.SELECT_POOL_LIVE_DEFAULT_ICON ?? + path.join(workspaceRoot, 'image/icons/default.jpg') +); +const walnutTexture = path.join(workspaceRoot, 'image/game/back_walnut.jpg'); +const greenTexture = path.join(workspaceRoot, 'image/game/back_green.jpg'); +const fixtureNationIds = [990_901, 990_902, 990_903]; + +interface AssetTracker { + userIconRequests: number; +} + +const installSession = async (page: Page, tracker?: AssetTracker): Promise => { + const now = new Date(); + const gameToken = encryptGameSessionToken( + { + version: 1, + profile, + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 3_600_000).toISOString(), + sessionId: `select-pool-live-${randomUUID()}`, + user: { + id: userId!, + username: 'select-pool-live', + displayName: '선택실사용자', + roles: ['user'], + legacyMemberNo: 42, + }, + sanctions: {}, + identity: { + kakaoVerified: true, + canCreateGeneral: true, + requiresKakaoVerification: false, + graceEndsAt: null, + }, + }, + gameTokenSecret! + ); + await page.addInitScript( + ({ token, gameProfile }) => { + if (!window.localStorage.getItem('sammo-game-token')) { + window.localStorage.setItem('sammo-game-token', token); + } + window.localStorage.setItem('sammo-game-profile', gameProfile); + }, + { token: gameToken, gameProfile: profile } + ); + await page.addInitScript(() => { + const values = [1, 0]; + Object.defineProperty(window.crypto, 'getRandomValues', { + configurable: true, + value: (array: T): T => { + if (array && (array as Uint32Array).length > 0) { + (array as Uint32Array)[0] = values.shift() ?? 0; + } + return array; + }, + }); + }); + await page.route('**/image/icons/**', (route) => + route.fulfill({ path: defaultIcon, contentType: 'image/jpeg' }) + ); + await page.route('**/gateway/api/user-icons/**', (route) => { + if (tracker) { + tracker.userIconRequests += 1; + } + return route.fulfill({ status: 404, body: '' }); + }); + await page.route('**/image/game/back_walnut.jpg', (route) => + route.fulfill({ path: walnutTexture, contentType: 'image/jpeg' }) + ); + await page.route('**/image/game/back_green.jpg', (route) => + route.fulfill({ path: greenTexture, contentType: 'image/jpeg' }) + ); +}; + +const waitForPool = async (page: Page): Promise => { + await expect(page.locator('.card-holder > .general-card')).toHaveCount(14); + await page.evaluate(async () => { + await document.fonts.ready; + }); +}; + +test.describe('scenario 903 live selection pool', () => { + test.skip(!hasLiveFixture, 'live selection-pool token and database are required'); + + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + test.beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + }); + + test.afterAll(async () => { + await closeDb?.(); + }); + + test('renders Ref-width desktop/mobile cards, tooltip, focus, and expiration states', async ({ + page, + }, testInfo) => { + await page.clock.install({ time: new Date() }); + const assetTracker: AssetTracker = { userIconRequests: 0 }; + await installSession(page, assetTracker); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('select-general'); + await waitForPool(page); + await expect(page.locator('.server-info-table')).toContainText( + '현재 : 180年 1月 (5분 턴 서버)' + ); + await expect(page.locator('.server-info-table')).toContainText( + '등록 장수 : 유저 0 / 500 명' + ); + await expect(page.locator('.invitation-table')).toContainText('임관 권유 메시지'); + + const geometry = await page.evaluate(() => { + const root = document.querySelector('.select-pool-page')!; + const cards = Array.from( + document.querySelectorAll('.card-holder > .general-card') + ); + const images = Array.from( + document.querySelectorAll('.card-holder .portrait img') + ); + const selectionBody = document.querySelector('.selection-body')!; + const createSection = document.querySelector('.create-section')!; + const createBody = document.querySelector('.create-body')!; + const pageTitle = document.querySelector('.page-title')!; + const serverInfoTable = + document.querySelector('.server-info-table')!; + const invitationTable = + document.querySelector('.invitation-table')!; + const footerBack = document.querySelector('.footer-back')!; + const footerBanner = document.querySelector('.footer-banner')!; + const firstButton = document.querySelector( + '.card-holder .select-button' + )!; + return { + root: root.getBoundingClientRect().toJSON(), + pageTitle: pageTitle.getBoundingClientRect().toJSON(), + serverInfoTable: serverInfoTable.getBoundingClientRect().toJSON(), + invitationTable: invitationTable.getBoundingClientRect().toJSON(), + cards: cards.map((card) => card.getBoundingClientRect().toJSON()), + images: images.map((image) => ({ + rect: image.getBoundingClientRect().toJSON(), + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + objectFit: getComputedStyle(image).objectFit, + })), + selectionBody: selectionBody.getBoundingClientRect().toJSON(), + createSection: createSection.getBoundingClientRect().toJSON(), + createBody: createBody.getBoundingClientRect().toJSON(), + footerBack: footerBack.getBoundingClientRect().toJSON(), + footerBanner: footerBanner.getBoundingClientRect().toJSON(), + firstButton: firstButton.getBoundingClientRect().toJSON(), + rootStyle: { + fontFamily: getComputedStyle(root).fontFamily, + fontSize: getComputedStyle(root).fontSize, + lineHeight: getComputedStyle(root).lineHeight, + backgroundImage: getComputedStyle(root).backgroundImage, + }, + scrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(geometry.root).toMatchObject({ x: 100, y: 8, width: 1000 }); + expect(geometry.pageTitle).toMatchObject({ x: 100, y: 8, width: 1000 }); + expect(Math.abs(geometry.pageTitle.height - 42.1875)).toBeLessThan(0.6); + expect(Math.abs(geometry.serverInfoTable.y - 50.1875)).toBeLessThan(0.6); + expect(Math.abs(geometry.serverInfoTable.height - 40.375)).toBeLessThan(0.6); + expect(Math.abs(geometry.invitationTable.x - 553)).toBeLessThan(0.6); + expect(Math.abs(geometry.invitationTable.y - 90.5625)).toBeLessThan(0.6); + expect(geometry.invitationTable.width).toBe(94); + expect(Math.abs(geometry.invitationTable.height - 20.1875)).toBeLessThan(0.1); + expect(geometry.rootStyle).toMatchObject({ + fontSize: '14px', + lineHeight: '18.2px', + }); + expect(geometry.rootStyle.fontFamily).toContain('Pretendard'); + expect(geometry.rootStyle.backgroundImage).toContain('back_walnut.jpg'); + expect(geometry.cards.every((card) => card.width === 127)).toBe(true); + expect(new Set(geometry.cards.map((card) => card.y)).size).toBe(2); + const shortestCard = Math.min(...geometry.cards.map((card) => card.height)); + expect(Math.abs(shortestCard - 254.875)).toBeLessThan(0.6); + expect( + geometry.images.every( + (image, index) => + image.rect.width === 64 && + image.rect.height === 64 && + Math.abs( + image.rect.x - + (geometry.cards[index]!.x + + (geometry.cards[index]!.width - image.rect.width) / 2) + ) < 0.1 + ) + ).toBe(true); + expect( + geometry.images.every( + (image) => image.naturalWidth > 0 && image.naturalHeight > 0 + ) + ).toBe(true); + expect(geometry.images.every((image) => image.objectFit === 'fill')).toBe(true); + const fallbackImages = page.locator( + '.card-holder .portrait img[data-fallback-applied="true"]' + ); + await expect(fallbackImages).not.toHaveCount(0); + expect(assetTracker.userIconRequests).toBe(await fallbackImages.count()); + expect(Math.abs(geometry.selectionBody.y - 130.9375)).toBeLessThan(0.6); + expect(Math.abs(geometry.createSection.height - 87.375)).toBeLessThan(0.6); + expect(geometry.firstButton.height).toBe(19); + expect(geometry.footerBanner.height).toBeCloseTo(20.1875, 3); + await expect(page.locator('.invitation-table tbody tr')).toHaveCount(0); + await expect(page.locator('.footer-banner')).toContainText( + '삼국지 모의전투 HiDCHe core2026' + ); + await expect(page.locator('.footer-banner a')).toHaveText('Credit'); + + const firstTrait = page.locator('.card-holder .trait-tooltip').first(); + await firstTrait.hover(); + await expect(firstTrait.getByRole('tooltip')).toBeVisible(); + await page.screenshot({ + path: testInfo.outputPath('select-general-desktop-hover.png'), + fullPage: true, + }); + + const firstButton = page.locator('.card-holder .select-button').first(); + await firstButton.focus(); + await expect(firstButton).toHaveCSS('outline-style', 'auto'); + await expect(firstButton).toHaveCSS('outline-width', '1px'); + await firstButton.hover(); + await expect(firstButton).toHaveCSS('background-color', 'rgb(25, 25, 25)'); + + await page.setViewportSize({ width: 500, height: 900 }); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth)) + .toBeGreaterThanOrEqual(1008); + await page.screenshot({ + path: testInfo.outputPath('select-general-mobile.png'), + fullPage: true, + }); + + const validText = await page.locator('.selection-body small span').textContent(); + expect(validText).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/); + const displayedExpiry = new Date(`${validText!.replace(' ', 'T')}+09:00`).getTime(); + const delta = Math.max(displayedExpiry - Date.now(), 0); + await page.clock.fastForward(delta); + await expect(page.locator('.expired-text')).toHaveCount(0); + await page.clock.fastForward(2_000); + await expect(page.locator('.expired-text')).toHaveText('- 만료 -'); + }); + + test('shuffles non-neutral invitation nations with Ref-style row content and colors', async ({ + page, + }) => { + await db.nation.deleteMany({ where: { id: { in: fixtureNationIds } } }); + await db.nation.createMany({ + data: [ + { + id: fixtureNationIds[0]!, + name: '테스트국A', + color: '#330000', + level: 1, + meta: { infoText: 'A 권유' }, + }, + { + id: fixtureNationIds[1]!, + name: '테스트국B', + color: '#FFFF00', + level: 1, + meta: { infoText: 'B 권유' }, + }, + { + id: fixtureNationIds[2]!, + name: '테스트국C', + color: '#000080', + level: 1, + meta: { infoText: 'C 권유' }, + }, + ], + }); + try { + await installSession(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('select-general'); + await waitForPool(page); + + const rows = page.locator('.invitation-table tbody tr'); + await expect(rows.locator('.invitation-nation')).toHaveText([ + '테스트국C', + '테스트국A', + '테스트국B', + ]); + await expect(rows.locator('.invitation-message')).toHaveText([ + 'C 권유', + 'A 권유', + 'B 권유', + ]); + await expect(rows.nth(0)).toHaveCSS('background-color', 'rgb(0, 0, 128)'); + await expect(rows.nth(1)).toHaveCSS('background-color', 'rgb(51, 0, 0)'); + await expect(rows.nth(2)).toHaveCSS('background-color', 'rgb(255, 255, 0)'); + await expect(rows.nth(0)).toHaveCSS('color', 'rgb(255, 255, 255)'); + await expect(rows.nth(2)).toHaveCSS('color', 'rgb(0, 0, 0)'); + const invitationGeometry = await page.locator('.invitation-table').evaluate((table) => { + const rect = table.getBoundingClientRect(); + return { x: rect.x, width: rect.width }; + }); + expect(invitationGeometry).toEqual({ x: 100, width: 1000 }); + } finally { + await db.nation.deleteMany({ where: { id: { in: fixtureNationIds } } }); + } + }); + + test('creates, rejects cooldown, exposes MyPage action, and reselects through the live API', async ({ + page, + }) => { + const dialogs: string[] = []; + const createClientRequestIds: string[] = []; + let injectCreateTimeout = true; + page.on('dialog', async (dialog) => { + dialogs.push(dialog.message()); + await dialog.accept(); + }); + await page.route('**/trpc/join.selectPoolGeneral?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}$/); + createClientRequestIds.push(clientRequestId!); + if (!injectCreateTimeout) { + await route.continue(); + return; + } + injectCreateTimeout = false; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + error: { + message: + '장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.', + code: -32008, + data: { + code: 'TIMEOUT', + httpStatus: 408, + path: 'join.selectPoolGeneral', + }, + }, + }, + ]), + }); + }); + const assetTracker: AssetTracker = { userIconRequests: 0 }; + await installSession(page, assetTracker); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('select-general'); + await waitForPool(page); + + const candidateCards = page.locator('.card-holder > .general-card'); + const fallbackCard = candidateCards + .filter({ has: page.locator('img[data-fallback-applied="true"]') }) + .first(); + await expect(fallbackCard).toBeVisible(); + const initialName = await fallbackCard.locator('h4').first().textContent(); + const userIconRequestsBeforePreview = assetTracker.userIconRequests; + await fallbackCard.locator('.select-button').click(); + await expect(page.locator('.selected-card')).toHaveCount(1); + await expect( + page.locator('.selected-card img[data-fallback-applied="true"]') + ).toBeVisible(); + expect(assetTracker.userIconRequests).toBeGreaterThan(userIconRequestsBeforePreview); + await page.locator('.custom-form select').selectOption('che_안전'); + await page.getByRole('button', { name: '다시입력' }).click(); + await expect(page.locator('.custom-form select')).toHaveValue('Random'); + await expect(page.locator('.selected-card')).toHaveCount(1); + await page.locator('.custom-form select').selectOption('che_안전'); + await page.locator('#build-general').click(); + await expect.poll(() => dialogs).toContain( + '실패했습니다: 장수 선택 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.' + ); + await waitForPool(page); + const retryCard = page + .locator('.card-holder > .general-card') + .filter({ has: page.locator('h4', { hasText: initialName?.trim() ?? '' }) }) + .first(); + await retryCard.locator('.select-button').click(); + await page.locator('.custom-form select').selectOption('che_안전'); + await page.locator('#build-general').click(); + await expect(page).toHaveURL(/\/hwe\/$/); + expect(dialogs.filter((message) => message === '이 장수로 생성할까요?')).toHaveLength(2); + await expect.poll(() => dialogs).toContain('선택한 장수로 생성했습니다.'); + expect(createClientRequestIds).toHaveLength(2); + expect(createClientRequestIds[1]).toBe(createClientRequestIds[0]); + + const created = await db.general.findFirstOrThrow({ where: { userId } }); + expect(created.name).toBe(initialName?.trim()); + expect(created.personalCode).toBe('che_안전'); + expect(created.specialCode).toMatch(/^che_event_/); + const createEvent = await db.inputEvent.findFirstOrThrow({ + where: { actorUserId: userId, eventType: 'selectPoolCreate' }, + orderBy: { sequence: 'desc' }, + }); + expect(createEvent).toMatchObject({ status: 'SUCCEEDED', attempts: 1 }); + expect(createEvent.requestId).toMatch( + new RegExp(`^select-pool:${userId}:[0-9a-f-]{36}:create$`) + ); + expect( + await page.evaluate(() => + window.sessionStorage.getItem('sammo-select-pool-pending-action') + ) + ).toBeNull(); + + await page.goto('my-page'); + const actionLink = page.locator('.select-general-link'); + await expect(actionLink).toBeVisible(); + await expect(actionLink.locator('..')).toContainText( + /다른 장수 선택\s*\(\d{4}-\d{2}-\d{2}/ + ); + await expect(actionLink).toHaveCSS('width', '160px'); + await expect(actionLink).toHaveCSS('height', '30px'); + + dialogs.length = 0; + await page.goto('select-general'); + await expect.poll(() => dialogs).toContain('실패했습니다: 아직 다시 고를 수 없습니다'); + await expect(page.locator('.error-text')).toHaveText('아직 다시 고를 수 없습니다'); + + const availableAt = '2026-07-29T00:00:00.000Z'; + const cooldownRequestId = `select-pool-live-cooldown-${randomUUID()}`; + await db.inputEvent.create({ + data: { + requestId: cooldownRequestId, + target: 'ENGINE', + eventType: 'patchGeneral', + payload: { + type: 'patchGeneral', + requestId: cooldownRequestId, + generalId: created.id, + patch: { + meta: { + next_change: availableAt, + nextChangeAt: availableAt, + }, + }, + } as GamePrisma.InputJsonValue, + }, + }); + await expect + .poll( + async () => + ( + await db.inputEvent.findUniqueOrThrow({ + where: { requestId: cooldownRequestId }, + }) + ).status + ) + .toBe('SUCCEEDED'); + + dialogs.length = 0; + await page.reload(); + await waitForPool(page); + const cards = page.locator('.card-holder > .general-card'); + const names = await cards.locator('h4').allTextContents(); + const targetIndex = names.findIndex((name) => name.trim() !== created.name); + expect(targetIndex).toBeGreaterThanOrEqual(0); + const targetName = names[targetIndex]!.trim(); + await cards.nth(targetIndex).locator('.select-button').click(); + await expect(page).toHaveURL(/\/hwe\/$/); + await expect.poll(() => dialogs).toContain(`이 장수를 선택할까요? : ${targetName}`); + await expect.poll(() => dialogs).toContain('선택한 장수로 변경했습니다.'); + + await expect + .poll(async () => (await db.general.findUniqueOrThrow({ where: { id: created.id } })).name) + .toBe(targetName); + const reselectEvent = await db.inputEvent.findFirstOrThrow({ + where: { actorUserId: userId, eventType: 'selectPoolReselect' }, + orderBy: { sequence: 'desc' }, + }); + expect(reselectEvent).toMatchObject({ status: 'SUCCEEDED', attempts: 1 }); + expect(reselectEvent.requestId).toMatch( + new RegExp(`^select-pool:${userId}:[0-9a-f-]{36}:reselect$`) + ); + expect( + await page.evaluate(() => + window.sessionStorage.getItem('sammo-select-pool-pending-action') + ) + ).toBeNull(); + }); +}); diff --git a/app/game-frontend/e2e/session-auth.spec.ts b/app/game-frontend/e2e/session-auth.spec.ts new file mode 100644 index 0000000..8ee04ef --- /dev/null +++ b/app/game-frontend/e2e/session-auth.spec.ts @@ -0,0 +1,114 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); + +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const publicResponse = (operation: string): unknown => { + if (operation === 'public.getMapLayout') { + return response({ mapName: 'che', cityList: [] }); + } + if (operation === 'public.getCachedMap') { + return response({ year: 180, month: 1, cityList: [], nationList: [], history: [] }); + } + if (operation === 'public.getWorldTrend') { + return response({ year: 180, month: 1, turnTerm: 5 }); + } + if (operation === 'public.getNationList' || operation === 'public.getGeneralList') { + return response([]); + } + throw new Error(`Unhandled public tRPC operation: ${operation}`); +}; + +const seedGameStorage = async (page: Page, gameToken: string): Promise => { + await page.addInitScript((token) => { + window.localStorage.setItem('sammo-game-token', token); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }, gameToken); +}; + +test('removes an invalid ga_ token and redirects an authenticated route to public', async ({ + page, +}) => { + await seedGameStorage(page, 'ga_invalid'); + let gatewayRequests = 0; + await page.route('http://127.0.0.1:15120/api/trpc/**', async (route) => { + gatewayRequests += 1; + await route.abort(); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationNames(route); + if (operations.includes('auth.status')) { + expect(route.request().headers().authorization).toBe('Bearer ga_invalid'); + await route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ error: 'invalid token' }), + }); + return; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(operations.map(publicResponse)), + }); + }); + + await page.goto('select-general'); + await expect(page).toHaveURL(/\/che\/public$/); + await expect(page.getByRole('heading', { name: '공개 동향' })).toBeVisible(); + expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBeNull(); + expect(gatewayRequests).toBe(0); +}); + +test('keeps a valid ga_ token when only lobby.info is unavailable', async ({ page }) => { + await seedGameStorage(page, 'ga_valid'); + page.on('dialog', (dialog) => dialog.accept()); + let gatewayRequests = 0; + let statusRequests = 0; + let lobbyRequests = 0; + await page.route('http://127.0.0.1:15120/api/trpc/**', async (route) => { + gatewayRequests += 1; + await route.abort(); + }); + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationNames(route); + if (operations.includes('auth.status')) { + statusRequests += 1; + expect(route.request().headers().authorization).toBe('Bearer ga_valid'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([response({ userId: 'valid-user' })]), + }); + return; + } + if (operations.includes('lobby.info')) { + lobbyRequests += 1; + await route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'lobby unavailable' }), + }); + return; + } + await route.fulfill({ + status: 503, + contentType: 'application/json', + body: JSON.stringify({ error: 'fixture page data unavailable' }), + }); + }); + + await page.goto('select-general'); + await expect(page).toHaveURL(/\/che\/select-general$/); + await expect(page.locator('.page-title')).toContainText('장 수 선 택'); + expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe( + 'ga_valid' + ); + expect(statusRequests).toBe(1); + expect(lobbyRequests).toBe(1); + expect(gatewayRequests).toBe(0); +}); diff --git a/app/game-frontend/src/components/battle/BattleGeneralCard.vue b/app/game-frontend/src/components/battle/BattleGeneralCard.vue index ed7eb05..710f7ec 100644 --- a/app/game-frontend/src/components/battle/BattleGeneralCard.vue +++ b/app/game-frontend/src/components/battle/BattleGeneralCard.vue @@ -206,6 +206,21 @@ const officerLevelOptions = [ 사기 + + +