diff --git a/app/game-api/src/auth/accessTokenStore.ts b/app/game-api/src/auth/accessTokenStore.ts index 4c9b6cd..030fb46 100644 --- a/app/game-api/src/auth/accessTokenStore.ts +++ b/app/game-api/src/auth/accessTokenStore.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; -import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { parseGameSessionTokenPayload, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { isValid, parseISO } from 'date-fns'; interface RedisClientLike { @@ -10,49 +10,11 @@ interface RedisClientLike { const ACCESS_TOKEN_PREFIX = 'ga_'; -const buildAccessKey = (profileName: string, token: string): string => - `sammo:game:access:${profileName}:${token}`; +const buildAccessKey = (profileName: string, token: string): string => `sammo:game:access:${profileName}:${token}`; const buildGatewayUsedKey = (profileName: string, sessionId: string): string => `sammo:game:gateway-used:${profileName}:${sessionId}`; -const parsePayload = (value: unknown): GameSessionTokenPayload | null => { - if (!value || typeof value !== 'object') { - return null; - } - const payload = value as Partial; - if (payload.version !== 1) { - return null; - } - if (typeof payload.profile !== 'string') { - return null; - } - if (typeof payload.issuedAt !== 'string' || typeof payload.expiresAt !== 'string') { - return null; - } - if (typeof payload.sessionId !== 'string') { - return null; - } - if (!payload.user || typeof payload.user !== 'object') { - return null; - } - const user = payload.user as Partial; - if ( - typeof user.id !== 'string' || - typeof user.username !== 'string' || - typeof user.displayName !== 'string' || - !Array.isArray(user.roles) || - (user.legacyMemberNo !== undefined && - (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0)) - ) { - return null; - } - if (!payload.sanctions || typeof payload.sanctions !== 'object') { - return null; - } - return payload as GameSessionTokenPayload; -}; - const resolveTtlSeconds = (expiresAt: string): number => { const parsed = parseISO(expiresAt); if (!isValid(parsed)) { @@ -96,7 +58,7 @@ export class RedisAccessTokenStore { return null; } try { - const payload = parsePayload(JSON.parse(raw)); + const payload = parseGameSessionTokenPayload(JSON.parse(raw)); if (!payload) { return null; } diff --git a/app/game-api/src/router/join/index.ts b/app/game-api/src/router/join/index.ts index 6d54ef5..b3da10b 100644 --- a/app/game-api/src/router/join/index.ts +++ b/app/game-api/src/router/join/index.ts @@ -1,36 +1,24 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { randomBytes } from 'node:crypto'; -import type { DatabaseClient, GameApiContext, WorldStateRow } from '../../context.js'; +import type { GameApiContext, WorldStateRow } from '../../context.js'; import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; -import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common'; +import { asNumber, asRecord, asStringArray } from '@sammo-ts/common'; import { - isPersonalityTraitKey, isWarTraitKey, + JOIN_PERSONALITY_TRAIT_KEYS, loadPersonalityTraitModules, loadWarTraitModules, PersonalityTraitLoader, - PERSONALITY_TRAIT_KEYS, WarTraitLoader, WAR_TRAIT_KEYS, } from '@sammo-ts/logic'; -import { - appendInheritanceLog, - readInheritancePoint, - resolveInheritConstants, - setInheritancePoint, -} from '../../services/inheritance.js'; -import { - getSelectionPoolStatus, - reserveSelectionPool, - resolveSelectionMaxGeneral, -} from '../../services/selectPool.js'; +import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js'; +import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js'; +import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js'; const resolveSelectionCommandResult = ( - result: - | Awaited> - | null, + result: Awaited> | null, expectedType: 'selectPoolCreate' | 'selectPoolReselect' ): { ok: true; generalId: number } => { if (!result) { @@ -67,13 +55,66 @@ const resolveSelectionRequestId = ( if (!contextRequestId) { return undefined; } - const path = - operation === 'create' - ? 'join.selectPoolGeneral' - : 'join.reselectPoolGeneral'; + const path = operation === 'create' ? 'join.selectPoolGeneral' : 'join.reselectPoolGeneral'; return `${contextRequestId}:${path}`; }; +const resolveJoinCreateCommandResult = ( + result: Awaited> | null +): { ok: true; generalId: number } => { + if (!result) { + throw new TRPCError({ + code: 'TIMEOUT', + message: + '장수 생성 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.', + }); + } + if (result.type !== 'joinCreateGeneral') { + 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 resolveJoinCreateRequestId = ( + contextRequestId: string | undefined, + userId: string, + clientRequestId: string | undefined +): string | undefined => { + if (clientRequestId) { + return `join-create:${userId}:${clientRequestId}`; + } + return contextRequestId ? `${contextRequestId}:join.createGeneral` : undefined; +}; + +const requestJoinCreateCommand = async ( + ctx: GameApiContext, + command: Parameters[0] +) => { + try { + return await ctx.turnDaemon.requestCommand(command); + } catch (error) { + if ( + error instanceof ConflictingTurnDaemonCommandError || + (error instanceof Error && error.name === 'ConflictingTurnDaemonCommandError') + ) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이미 접수된 장수 생성 요청과 입력이 다릅니다. 새 요청 번호로 다시 시도해 주세요.', + }); + } + throw error; + } +}; + const DEFAULT_JOIN_STAT = { total: 165, min: 15, @@ -82,12 +123,8 @@ const DEFAULT_JOIN_STAT = { bonusMax: 5, }; -const buildSpecialityAge = ( - retirementYear: number, - age: number, - relativeYear: number, - divisor: number -): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; +const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number => + Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; export const resolveJoinSpecialityAges = (options: { retirementYear: number; @@ -116,90 +153,30 @@ const resolveJoinStat = (worldState: WorldStateRow) => { }; }; -const resolveJoinPolicy = (worldState: WorldStateRow) => { - const config = asRecord(worldState.config); - const joinMode = typeof config.joinMode === 'string' ? config.joinMode : 'full'; - const blockGeneralCreate = - typeof config.blockGeneralCreate === 'number' && Number.isFinite(config.blockGeneralCreate) - ? Math.floor(config.blockGeneralCreate) - : 0; - return { joinMode, blockGeneralCreate }; -}; - - -const hashString = (value: string): number => { - let hash = 0; - for (let i = 0; i < value.length; i += 1) { - hash = (hash * 31 + value.charCodeAt(i)) >>> 0; - } - return hash; -}; - -const pickFromList = (values: string[], seed: string): string | null => { - if (!values.length) { - return null; - } - const index = hashString(seed) % values.length; - return values[index] ?? null; -}; - -const buildTurnTimeZones = (tickMinutes: number): string[] => { +const buildTurnTimeZones = (tickSeconds: number): string[] => { const zones: string[] = []; + const legacyZoneSeconds = Math.max(1, Math.floor(tickSeconds / 60)); for (let i = 0; i < 60; i += 1) { - const totalMinutes = i * tickMinutes; - const hour = Math.floor(totalMinutes / 60) % 24; - const minute = totalMinutes % 60; - zones.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`); + const startSeconds = i * legacyZoneSeconds; + const endSeconds = startSeconds + legacyZoneSeconds - 1; + const format = (totalSeconds: number, fraction: string): string => + `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart( + 2, + '0' + )}.${fraction}`; + zones.push(`${format(startSeconds, '000')} ~ ${format(endSeconds, '999')}`); } return zones; }; -const alignToTurnBase = (time: Date, tickMinutes: number): Date => { - const base = new Date(time.getFullYear(), time.getMonth(), time.getDate() - 1, 1, 0, 0, 0); - const elapsedMinutes = Math.floor((time.getTime() - base.getTime()) / 60000); - const alignedMinutes = elapsedMinutes - (elapsedMinutes % tickMinutes); - return new Date(base.getTime() + alignedMinutes * 60000); -}; - -const nextRangeInt = (rng: LiteHashDRBG, minInclusive: number, maxInclusive: number): number => { - if (maxInclusive <= minInclusive) { - return minInclusive; - } - return minInclusive + rng.nextInt(maxInclusive - minInclusive); -}; - -const pickWeightedIndex = (rng: LiteHashDRBG, weights: number[]): number => { - const total = weights.reduce((acc, value) => acc + value, 0); - if (total <= 0) { - return 0; - } - let cursor = rng.nextFloat1() * total; - for (let i = 0; i < weights.length; i += 1) { - cursor -= weights[i] ?? 0; - if (cursor <= 0) { - return i; - } - } - return weights.length - 1; -}; - -const buildRandomBonus = (rng: LiteHashDRBG, baseStats: [number, number, number]): [number, number, number] => { - const count = rng.nextInt(2) + 3; - const bonus: [number, number, number] = [0, 0, 0]; - for (let i = 0; i < count; i += 1) { - const index = pickWeightedIndex(rng, baseStats); - bonus[index] += 1; - } - return bonus; -}; - let cachedPersonalityOptions: Array<{ key: string; name: string; info: string }> | null = null; +const zJoinPersonality = z.enum(['Random', ...JOIN_PERSONALITY_TRAIT_KEYS]); const loadPersonalityOptions = async () => { if (cachedPersonalityOptions) { return cachedPersonalityOptions; } - const modules = await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS], new PersonalityTraitLoader()); + const modules = await loadPersonalityTraitModules([...JOIN_PERSONALITY_TRAIT_KEYS], new PersonalityTraitLoader()); cachedPersonalityOptions = modules.map((trait) => ({ key: trait.key, name: trait.name, @@ -233,8 +210,7 @@ export const joinRouter = router({ const availableSpecialWar = asStringArray(configConst.availableSpecialWar); const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS]; - const [personalities, warSpecials, nationRows, userGeneralCount, npcGeneralCount] = - await Promise.all([ + const [personalities, warSpecials, nationRows, userGeneralCount, npcGeneralCount] = await Promise.all([ loadPersonalityOptions(), loadWarOptions(warKeys), ctx.db.nation.findMany({ @@ -265,40 +241,24 @@ 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 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 }, + const inheritCities = await ctx.db.city.findMany({ select: { id: true, name: true, level: true, region: true }, orderBy: { id: 'asc' }, }); - const inheritCities = - inheritCitiesRaw.length > 0 - ? inheritCitiesRaw - : await ctx.db.city.findMany({ - where: { level: { in: [5, 6] } }, - select: { id: true, name: true, level: true, region: true }, - orderBy: { id: 'asc' }, - }); return { rules: { stat: resolveJoinStat(worldState), - allowCustomName: true, + allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0, }, user: { id: ctx.auth?.user.id ?? '', displayName: ctx.auth?.user.displayName ?? '', }, - personalities: [ - { key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, - ...personalities, - ], + personalities: [{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, ...personalities], warSpecials, nations, serverInfo: { @@ -318,7 +278,7 @@ export const joinRouter = router({ inheritBornStatPoint: inheritConst.inheritBornStatPoint, }, availableCities: inheritCities, - turnTimeZones: buildTurnTimeZones(tickMinutes), + turnTimeZones: buildTurnTimeZones(worldState.tickSeconds), availableSpecialWar: warSpecials, }, selectionPool, @@ -363,12 +323,7 @@ export const joinRouter = router({ message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', }); } - const commandRequestId = resolveSelectionRequestId( - ctx.requestId, - userId, - input.clientRequestId, - 'create' - ); + const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create'); const result = await ctx.turnDaemon.requestCommand({ type: 'selectPoolCreate', ...(commandRequestId ? { requestId: commandRequestId } : {}), @@ -408,15 +363,16 @@ export const joinRouter = router({ }); return resolveSelectionCommandResult(result, 'selectPoolReselect'); }), - createGeneral: authedProcedure + createGeneral: engineAuthedProcedure .input( z.object({ name: z.string().min(1).max(18), leadership: z.number().int(), strength: z.number().int(), intel: z.number().int(), - pic: z.boolean().optional(), - character: z.string(), + pic: z.boolean(), + character: zJoinPersonality, + clientRequestId: z.string().uuid().optional(), inheritSpecial: z.string().optional(), inheritTurntimeZone: z.number().int().optional(), inheritCity: z.number().int().optional(), @@ -424,337 +380,49 @@ export const joinRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const userId = ctx.auth?.user.id; - if (!userId) { + const auth = ctx.auth; + if (!auth) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } - if (ctx.auth?.identity?.canCreateGeneral === false) { + if (auth.identity?.canCreateGeneral === false) { throw new TRPCError({ code: 'FORBIDDEN', message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.', }); } - - const worldState = await ctx.db.worldState.findFirst(); - if (!worldState) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - 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) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: '장수 생성이 제한된 서버입니다.', - }); - } - - const inheritConst = resolveInheritConstants(worldState); - const configConst = asRecord(asRecord(worldState.config).const); - const availableSpecialWar = asStringArray(configConst.availableSpecialWar); - const inheritBonus = input.inheritBonusStat ?? null; - if (inheritBonus) { - const bonusSum = inheritBonus.reduce((acc, value) => acc + value, 0); - if (inheritBonus.some((value) => value < 0)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '보너스 능력치가 음수입니다. 다시 가입해주세요!', - }); - } - if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '보너스 능력치 합이 잘못 지정되었습니다. 다시 가입해주세요!', - }); - } - } - if (input.inheritSpecial !== undefined) { - if (!isWarTraitKey(input.inheritSpecial)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '전투 특기가 잘못 지정되었습니다.', - }); - } - if (availableSpecialWar.length > 0 && !availableSpecialWar.includes(input.inheritSpecial)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '허용되지 않은 전투 특기입니다.', - }); - } - } - if (input.inheritTurntimeZone !== undefined) { - if (input.inheritTurntimeZone < 0 || input.inheritTurntimeZone > 59) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '턴 시간 지정 범위가 올바르지 않습니다.', - }); - } - } - - const statRule = resolveJoinStat(worldState); - const statTotal = input.leadership + input.strength + input.intel; - - if ( - input.leadership < statRule.min || - input.strength < statRule.min || - input.intel < statRule.min || - input.leadership > statRule.max || - input.strength > statRule.max || - input.intel > statRule.max - ) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '능력치 범위를 벗어났습니다.', - }); - } - - if (statTotal > statRule.total) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `능력치 합이 ${statRule.total}을 초과했습니다.`, - }); - } - - const personalityOptions = await loadPersonalityOptions(); - const personalityKeys = personalityOptions.map((trait) => trait.key); - const resolveGeneralName = async (): Promise => { - if (joinPolicy.blockGeneralCreate !== 2) { - return input.name; - } - for (let attempt = 0; attempt < 5; attempt += 1) { - const candidate = randomBytes(5).toString('hex'); - const exists = await ctx.db.general.findFirst({ where: { name: candidate } }); - if (!exists) { - return candidate; - } - } - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: '랜덤 장수명 생성에 실패했습니다.', - }); - }; - - const generalName = await resolveGeneralName(); - const chosenPersonality = - input.character === 'Random' - ? pickFromList(personalityKeys, `${userId}:${generalName}`) ?? 'None' - : isPersonalityTraitKey(input.character) - ? input.character - : 'None'; - - const createGeneral = async (db: DatabaseClient) => { - const existing = await db.general.findFirst({ where: { userId } }); - if (existing) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: '이미 장수가 생성되어 있습니다.', - }); - } - const nameExists = await db.general.findFirst({ where: { name: generalName } }); - if (nameExists) { - throw new TRPCError({ - code: 'CONFLICT', - message: '이미 존재하는 장수명입니다.', - }); - } - - const maxId = await db.general.aggregate({ _max: { id: true } }); - const nextId = (maxId._max.id ?? 0) + 1; - const cityList = await db.city.findMany({ - select: { id: true, level: true, nationId: true, name: true }, - orderBy: { id: 'asc' }, - }); - if (!cityList.length) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: '도시 정보를 찾을 수 없습니다.', - }); - } - const neutralCities = cityList.filter((city) => city.level >= 5 && city.level <= 6 && city.nationId === 0); - const candidateCities = - neutralCities.length > 0 ? neutralCities : cityList.filter((city) => city.level >= 5 && city.level <= 6); - if (!candidateCities.length) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: '생성 가능한 도시가 없습니다.', - }); - } - - let inheritRequiredPoint = 0; - if (input.inheritCity !== undefined) { - inheritRequiredPoint += inheritConst.inheritBornCityPoint; - } - if (input.inheritSpecial !== undefined) { - inheritRequiredPoint += inheritConst.inheritBornSpecialPoint; - } - if (input.inheritTurntimeZone !== undefined) { - inheritRequiredPoint += inheritConst.inheritBornTurntimePoint; - } - if (inheritBonus && inheritBonus.reduce((acc, value) => acc + value, 0) > 0) { - inheritRequiredPoint += inheritConst.inheritBornStatPoint; - } - - const currentPoint = await readInheritancePoint(db, userId, 'previous'); - if (currentPoint < inheritRequiredPoint) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '유산 포인트가 부족합니다.', - }); - } - - const hiddenSeed = String(asRecord(worldState.meta).hiddenSeed ?? 'inherit'); - const rng = new LiteHashDRBG(`${hiddenSeed}:MakeGeneral:${userId}:${generalName}`); - - const bonusStatSum = inheritBonus ? inheritBonus.reduce((acc, value) => acc + value, 0) : 0; - const randomBonus = - !inheritBonus || bonusStatSum === 0 - ? buildRandomBonus( - rng, - [input.leadership, input.strength, input.intel] - ) - : (inheritBonus as [number, number, number]); - - const finalLeadership = input.leadership + randomBonus[0]; - const finalStrength = input.strength + randomBonus[1]; - const finalIntel = input.intel + randomBonus[2]; - const age = 20 + (randomBonus[0] + randomBonus[1] + randomBonus[2]) * 2 - nextRangeInt(rng, 0, 1); - - const selectedCity = - typeof input.inheritCity === 'number' - ? candidateCities.find((city) => city.id === input.inheritCity) - : null; - if (input.inheritCity !== undefined && !selectedCity) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '지정한 도시를 찾을 수 없습니다.', - }); - } - - const cityIndex = nextRangeInt(rng, 0, candidateCities.length - 1); - const cityId = (selectedCity ?? candidateCities[cityIndex] ?? candidateCities[0]).id; - - const defaultSpecialDomestic = - typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None'; - const defaultSpecialWar = - typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None'; - const retirementYear = - typeof configConst.retirementYear === 'number' && Number.isFinite(configConst.retirementYear) - ? configConst.retirementYear - : 80; - const worldMeta = asRecord(worldState.meta); - const scenarioMeta = asRecord(worldMeta.scenarioMeta); - const startYear = - typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear) - ? scenarioMeta.startYear - : worldState.currentYear; - const relativeYear = Math.max( - worldState.currentYear - startYear, - 0 - ); - const scenarioId = Number(worldMeta.scenarioId ?? worldState.scenarioCode); - const specialityAges = resolveJoinSpecialityAges({ - retirementYear, - age, - relativeYear, - scenarioId, - }); - - const specialWar = - input.inheritSpecial && isWarTraitKey(input.inheritSpecial) ? input.inheritSpecial : defaultSpecialWar; - - const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60)); - const baseTime = alignToTurnBase(new Date(), tickMinutes); - let turnTime = new Date(baseTime.getTime() + rng.nextFloat1() * tickMinutes * 60000); - if (input.inheritTurntimeZone !== undefined) { - const offsetMinutes = input.inheritTurntimeZone * tickMinutes + rng.nextFloat1() * tickMinutes; - turnTime = new Date(baseTime.getTime() + offsetMinutes * 60000); - } - if (turnTime.getTime() <= Date.now()) { - turnTime = new Date(turnTime.getTime() + tickMinutes * 60000); - } - - const logEntries: string[] = []; - if (input.inheritSpecial && isWarTraitKey(input.inheritSpecial)) { - const [special] = await loadWarOptions([input.inheritSpecial]); - const specialName = special?.name ?? input.inheritSpecial; - logEntries.push(`${specialName} 전투 특기를 가진 천재 생성`); - } - if (input.inheritCity !== undefined && selectedCity) { - logEntries.push(`${selectedCity.name}에 장수 생성`); - } - if (inheritBonus && inheritBonus.reduce((acc, value) => acc + value, 0) > 0) { - logEntries.push( - `${inheritBonus[0]}, ${inheritBonus[1]}, ${inheritBonus[2]} 보너스 능력치로 생성` - ); - } - if (input.inheritTurntimeZone !== undefined) { - const zones = buildTurnTimeZones(tickMinutes); - const zoneLabel = zones[input.inheritTurntimeZone]; - if (zoneLabel) { - logEntries.push(`턴 시간 ${zoneLabel} 로 지정`); - } - } - - const general = await db.general.create({ - data: { - id: nextId, - userId, - name: generalName, - nationId: 0, - cityId, - troopId: 0, - npcState: 0, - leadership: finalLeadership, - strength: finalStrength, - intel: finalIntel, - personalCode: chosenPersonality ?? 'None', - specialCode: defaultSpecialDomestic, - special2Code: specialWar, - turnTime, - age, - startAge: age, - meta: { - createdBy: 'join', - ownerName: ctx.auth?.user.displayName ?? '', - killturn: 24, - specage: specialityAges.domestic, - specage2: specialityAges.war, - }, - }, - }); - await db.generalAccessLog.upsert({ - where: { generalId: general.id }, - update: { - userId, - lastRefresh: new Date(), - }, - create: { - generalId: general.id, - userId, - lastRefresh: new Date(), - }, - }); - - if (inheritRequiredPoint > 0) { - await setInheritancePoint(db, userId, 'previous', currentPoint - inheritRequiredPoint); - } - for (const entry of logEntries) { - await appendInheritanceLog(db, userId, worldState.currentYear, worldState.currentMonth, entry); - } - - return { ok: true, generalId: general.id }; - }; - - return ctx.db.$transaction ? ctx.db.$transaction(createGeneral) : createGeneral(ctx.db); + const userId = auth.user.id; + const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId); + const result = await requestJoinCreateCommand(ctx, { + type: 'joinCreateGeneral', + ...(commandRequestId ? { requestId: commandRequestId } : {}), + userId, + ownerDisplayName: auth.user.displayName, + seedOwnerIdentity: auth.user.legacyMemberNo ?? userId, + name: input.name, + leadership: input.leadership, + strength: input.strength, + intel: input.intel, + pic: input.pic, + character: input.character, + profileId: ctx.profile.id, + ...(auth.user.picture !== undefined ? { ownerPicture: auth.user.picture } : {}), + ...(auth.user.imageServer !== undefined ? { ownerImageServer: auth.user.imageServer } : {}), + ...(auth.user.canUseGeneralPicture !== undefined + ? { + ownerCanUsePicture: auth.user.canUseGeneralPicture, + } + : {}), + ...(auth.sanctions.legacyPenalty !== undefined + ? { + ownerLegacyPenalty: auth.sanctions.legacyPenalty, + } + : {}), + ...(input.inheritSpecial !== undefined ? { inheritSpecial: input.inheritSpecial } : {}), + ...(input.inheritTurntimeZone !== undefined ? { inheritTurntimeZone: input.inheritTurntimeZone } : {}), + ...(input.inheritCity !== undefined ? { inheritCity: input.inheritCity } : {}), + ...(input.inheritBonusStat !== undefined ? { inheritBonusStat: input.inheritBonusStat } : {}), + }); + return resolveJoinCreateCommandResult(result); }), listPossessCandidates: authedProcedure .input( diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts new file mode 100644 index 0000000..9b05ef0 --- /dev/null +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -0,0 +1,508 @@ +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.CREATE_GENERAL_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const profile = 'hwe:2'; +const userId = 'create-general-integration-user'; +const failureUserId = 'create-general-integration-failure-user'; +const rejectedUserId = 'create-general-integration-rejected-user'; +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('create_general_integration')) { + throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`); + } + if (!/^[a-z0-9_]+$/.test(schema)) { + throw new Error(`Refusing unsafe schema name: ${schema}`); + } +}; + +const buildAuth = (id: string, displayName: string, legacyMemberNo: number): GameSessionTokenPayload => ({ + version: 1, + profile, + issuedAt: '2026-07-30T00:00:00.000Z', + expiresAt: '2026-08-30T00:00:00.000Z', + sessionId: `create-general-${id}`, + user: { + id, + username: id, + displayName, + roles: ['user'], + legacyMemberNo, + picture: 'custom-owner.webp', + imageServer: 2, + canUseGeneralPicture: true, + }, + sanctions: { + legacyPenalty: { + any: { + ban: { expire: 4_102_444_800, value: 1 }, + expired: { expire: 1, value: 9 }, + }, + hwe: { + ban: { expire: 4_102_444_800, value: 2 }, + chat: { expire: 4_102_444_800, value: 3 }, + }, + }, + }, +}); + +integration('generic general creation through the durable turn daemon', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + let runtime: TurnDaemonRuntime | undefined; + let daemonLoop: Promise | undefined; + let turnDaemon: TurnDaemonTransport; + + const buildContext = (requestId: string, auth: GameSessionTokenPayload): GameApiContext => { + const redisClient = { + get: async () => null, + set: async () => null, + }; + return { + requestId, + db, + redis: redisClient as unknown as RedisConnector['client'], + turnDaemon, + battleSim: new InMemoryBattleSimTransport(), + profile: { id: 'hwe', scenario: '2', name: profile }, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + auth, + accessTokenStore: new RedisAccessTokenStore(redisClient, profile), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'create-general-test-secret', + }; + }; + + const stopRuntime = async (reason: string): Promise => { + if (!runtime) { + return; + } + await runtime.lifecycle.stop(reason); + await daemonLoop; + await runtime.close(); + runtime = undefined; + daemonLoop = undefined; + }; + + const startRuntime = async (ownerId: string): Promise => { + runtime = await createTurnDaemonRuntime({ + profile, + databaseUrl: databaseUrl!, + enableDatabaseFlush: true, + enableLeaseHeartbeat: false, + leaseOwnerId: ownerId, + }); + turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000); + daemonLoop = runtime.lifecycle.start(); + await expect(turnDaemon.requestStatus(10_000)).resolves.toMatchObject({ + state: expect.any(String), + }); + }; + + beforeAll(async () => { + assertDedicatedDatabase(databaseUrl!); + const previousSeed = process.env.INTEGRATION_WORLD_SEED; + process.env.INTEGRATION_WORLD_SEED = 'create-general-integration-seed'; + try { + await seedScenarioToDatabase({ + scenarioId: 2, + databaseUrl: databaseUrl!, + now: new Date('2099-07-30T12:00:00.000Z'), + installOptions: { + turnTermMinutes: 5, + npcMode: 0, + showImgLevel: 3, + serverId: profile, + season: 1, + }, + }); + } finally { + if (previousSeed === undefined) { + delete process.env.INTEGRATION_WORLD_SEED; + } else { + process.env.INTEGRATION_WORLD_SEED = previousSeed; + } + } + + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.inputEvent.deleteMany(); + await db.logEntry.deleteMany(); + await db.inheritanceLog.deleteMany({ + where: { userId: { in: [userId, failureUserId] } }, + }); + await db.oldGeneral.deleteMany({ + where: { serverId: { startsWith: 'create-general-old-' } }, + }); + await db.gameHistory.deleteMany({ + where: { serverId: { startsWith: 'create-general-old-' } }, + }); + await db.gameHistory.createMany({ + data: [1, 2, 3, 4].map((index) => ({ + serverId: `create-general-old-${index}`, + date: new Date(`2026-0${5 - index}-01T00:00:00.000Z`), + winnerNation: 1, + map: 'miniche_b', + season: 1, + scenario: 2, + scenarioName: '통합 과거기', + env: {}, + })), + }); + await db.oldGeneral.create({ + data: { + serverId: 'create-general-old-4', + generalNo: 1, + owner: userId, + name: '과거장수', + lastYearMonth: 18001, + turnTime: new Date('2026-01-01T00:00:00.000Z'), + data: {}, + }, + }); + for (const ownerUserId of [userId, failureUserId]) { + await db.inheritancePoint.upsert({ + where: { + userId_key: { userId: ownerUserId, key: 'previous' }, + }, + update: { value: 10_000 }, + create: { + userId: ownerUserId, + key: 'previous', + value: 10_000, + }, + }); + } + for (const [key, value] of [ + ['active_action', 250.9], + ['combat', 100.8], + ] as const) { + await db.inheritancePoint.upsert({ + where: { userId_key: { userId, key } }, + update: { value }, + create: { userId, key, value }, + }); + } + await startRuntime('create-general-integration-daemon'); + }, 60_000); + + afterAll(async () => { + await stopRuntime('create-general integration complete'); + await closeDb?.(); + }, 30_000); + + it('commits one complete ref-shaped general and survives a daemon reload', async () => { + const auth = buildAuth(userId, '생성사용자', 4242); + const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig(); + expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔'); + expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999'); + const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } }); + const clientRequestId = '11111111-1111-4111-8111-111111111111'; + const input = { + name: '일/반-장수#', + leadership: 55, + strength: 55, + intel: 55, + pic: true, + character: 'che_안전' as const, + clientRequestId, + inheritTurntimeZone: 7, + inheritCity: city.id, + inheritBonusStat: [2, 1, 1] as [number, number, number], + }; + + const first = await appRouter + .createCaller(buildContext('create-general-http-a', auth)) + .join.createGeneral(input); + const retried = await appRouter + .createCaller(buildContext('create-general-http-b', auth)) + .join.createGeneral(input); + expect(retried).toEqual(first); + + const created = await db.general.findUniqueOrThrow({ + where: { id: first.generalId }, + }); + expect(created).toMatchObject({ + userId, + name: '일반장수', + nationId: 0, + cityId: city.id, + npcState: 0, + leadership: 57, + strength: 56, + intel: 56, + bornYear: 180, + deadYear: 300, + picture: 'custom-owner.webp', + imageServer: 2, + crewTypeId: 1100, + personalCode: 'che_안전', + penalty: { + ban: 2, + chat: 3, + }, + }); + expect(created.affinity).toBeGreaterThanOrEqual(1); + expect(created.affinity).toBeLessThanOrEqual(150); + expect(created.meta).toMatchObject({ + createdBy: 'join', + ownerName: '생성사용자', + killturn: 6, + inherit_spent_dyn: 4500, + }); + expect(runtime!.world.getGeneralById(created.id)).toMatchObject({ + id: created.id, + userId, + name: created.name, + cityId: city.id, + inheritancePoints: { + previous: 7351, + }, + }); + expect(await db.general.count({ where: { userId } })).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.rankData.findUniqueOrThrow({ + where: { + generalId_type: { + generalId: created.id, + type: 'inherit_spent_dyn', + }, + }, + }) + ).toMatchObject({ nationId: 0, value: 4500 }); + const access = await db.generalAccessLog.findUniqueOrThrow({ + where: { generalId: created.id }, + }); + expect(access).toMatchObject({ userId }); + expect( + await db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId, key: 'previous' } }, + }) + ).toMatchObject({ value: 7351 }); + expect(await db.inheritancePoint.count({ where: { userId } })).toBe(1); + expect(await db.inheritanceLog.count({ where: { userId } })).toBe(9); + expect( + await db.inheritanceLog.findFirst({ + where: { + userId, + text: '신규/복귀 생성으로 포인트 1500 지급', + }, + }) + ).not.toBeNull(); + const event = await db.inputEvent.findUniqueOrThrow({ + where: { requestId: `join-create:${userId}:${clientRequestId}` }, + }); + expect(event).toMatchObject({ + target: 'ENGINE', + status: 'SUCCEEDED', + attempts: 1, + actorUserId: userId, + }); + expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime()); + const turnGridOffsetSeconds = + ((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300; + expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35); + expect(turnGridOffsetSeconds).toBeLessThan(40); + + await stopRuntime('verify generic join reload'); + await startRuntime('create-general-integration-reloaded-daemon'); + expect(runtime!.world.getGeneralById(created.id)).toMatchObject({ + id: created.id, + userId, + name: created.name, + cityId: city.id, + inheritancePoints: { + previous: 7351, + }, + }); + + await expect( + appRouter.createCaller(buildContext('create-general-conflict', auth)).join.createGeneral({ + ...input, + name: '다른장수', + }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(await db.general.count({ where: { userId } })).toBe(1); + }, 45_000); + + it('rejects an unaffordable inheritance before consuming allocator state', async () => { + const auth = buildAuth(rejectedUserId, '거절사용자', 4444); + const requestUuid = '33333333-3333-4333-8333-333333333333'; + const requestId = `join-create:${rejectedUserId}:${requestUuid}`; + const runtimeMetaBefore = runtime!.world.getState().meta; + const persistedMetaBefore = (await db.worldState.findFirstOrThrow()).meta; + + await expect( + appRouter.createCaller(buildContext('create-general-rejected-http', auth)).join.createGeneral({ + name: '포인트부족', + leadership: 55, + strength: 55, + intel: 55, + pic: false, + character: 'che_안전', + inheritSpecial: 'che_무쌍', + clientRequestId: requestUuid, + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '유산 포인트가 부족합니다. 다시 가입해주세요!', + }); + + expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0); + expect(runtime!.world.getState().meta).toEqual(runtimeMetaBefore); + expect((await db.worldState.findFirstOrThrow()).meta).toEqual(persistedMetaBefore); + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ + target: 'ENGINE', + status: 'SUCCEEDED', + attempts: 1, + actorUserId: rejectedUserId, + result: { + type: 'joinCreateGeneral', + ok: false, + code: 'BAD_REQUEST', + }, + }); + }, 30_000); + + it('rolls back a hard failure and retries the ENGINE event exactly once', async () => { + const auth = buildAuth(failureUserId, '실패사용자', 4343); + const requestUuid = '22222222-2222-4222-8222-222222222222'; + const requestId = `join-create:${failureUserId}:${requestUuid}`; + const triggerName = 'create_general_fail_first_log'; + const functionName = 'create_general_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 generic join 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('create-general-failure-http', auth)).join.createGeneral({ + name: '재시장수', + leadership: 55, + strength: 55, + intel: 55, + pic: false, + character: 'Random', + 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.rankData.count({ where: { generalId: created.id } })).toBe(RANK_DATA_TYPES.length); + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ + status: 'SUCCEEDED', + attempts: 2, + actorUserId: failureUserId, + error: null, + }); + }, 45_000); + + it('rejects a forged ENGINE event whose actor does not own the command', async () => { + const requestId = 'join-create:forged-owner:forged-request'; + await db.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: 'joinCreateGeneral', + actorUserId: 'different-actor', + payload: { + type: 'joinCreateGeneral', + requestId, + userId: 'forged-owner', + ownerDisplayName: '위조사용자', + seedOwnerIdentity: 4545, + name: '위조장수', + leadership: 55, + strength: 55, + intel: 55, + pic: false, + character: 'che_안전', + profileId: 'hwe', + } as GamePrisma.InputJsonValue, + }, + }); + + const deadline = Date.now() + 5_000; + let event = await db.inputEvent.findUniqueOrThrow({ + where: { requestId }, + }); + while (event.status !== 'FAILED' && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 25)); + event = await db.inputEvent.findUniqueOrThrow({ + where: { requestId }, + }); + } + expect(event).toMatchObject({ + status: 'FAILED', + actorUserId: 'different-actor', + attempts: 3, + error: expect.stringContaining('actor does not match'), + }); + expect(await db.general.count({ where: { userId: 'forged-owner' } })).toBe(0); + expect(runtime!.world.listGenerals()).not.toEqual( + expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })]) + ); + }, 10_000); +}); diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index c72ba78..5f83ac7 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -281,9 +281,9 @@ describe('appRouter', () => { }); 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: null })).auth.status()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); await expect( appRouter .createCaller( @@ -346,6 +346,7 @@ describe('appRouter', () => { leadership: 55, strength: 55, intel: 55, + pic: false, character: 'Random', }) ).rejects.toMatchObject({ diff --git a/app/game-api/test/selectPool.integration.test.ts b/app/game-api/test/selectPool.integration.test.ts index c304861..fe0a43e 100644 --- a/app/game-api/test/selectPool.integration.test.ts +++ b/app/game-api/test/selectPool.integration.test.ts @@ -2,11 +2,7 @@ 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 { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine'; import { createGamePostgresConnector, type GamePrisma, @@ -29,7 +25,7 @@ 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 schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : ''; const assertDedicatedDatabase = (rawUrl: string): void => { const schema = new URL(rawUrl).searchParams.get('schema'); @@ -97,10 +93,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { let turnDaemon: TurnDaemonTransport; let worldStateId: number; - const buildContext = ( - requestId: string, - actorAuth: GameSessionTokenPayload = auth - ): GameApiContext => { + const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => { const redisClient = { get: async () => null, set: async () => null, @@ -257,12 +250,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => { 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(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( @@ -293,9 +282,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { const reselection = await appRouter .createCaller(buildContext('select-pool-reserve-reselection')) .join.getSelectionPool(); - const target = reselection.candidates.find( - (candidate) => candidate.generalName !== initial.name - )!; + const target = reselection.candidates.find((candidate) => candidate.generalName !== initial.name)!; await expect( appRouter .createCaller(buildContext('select-pool-reselect')) @@ -328,9 +315,11 @@ integration('scenario 903 select pool through the durable turn daemon', () => { }, }); 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.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 } }, @@ -345,9 +334,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { patch: { meta: { postReselectionFlush: 1 } }, }) ).resolves.toMatchObject({ type: 'patchGeneral', ok: true }); - await expect( - db.general.findUniqueOrThrow({ where: { id: initial.id } }) - ).resolves.toMatchObject({ + await expect(db.general.findUniqueOrThrow({ where: { id: initial.id } })).resolves.toMatchObject({ name: target.generalName, leadership: target.leadership, strength: target.strength, @@ -376,23 +363,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => { .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, - }) + 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_안전', - }) + 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({ @@ -408,12 +391,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => { const candidate = reservation.candidates[0]!; await expect( - appRouter - .createCaller(buildContext('select-pool-foreign-token', foreignAuth)) - .join.selectPoolGeneral({ - uniqueName: candidate.uniqueName, - personality: 'che_안전', - }) + 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); @@ -422,25 +403,22 @@ integration('scenario 903 select pool through the durable turn daemon', () => { 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_안전', - }) + 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_안전', - }) + appRouter.createCaller(buildContext('select-pool-generic-bypass', otherAuth)).join.createGeneral({ + name: '우회장수', + leadership: 55, + strength: 55, + intel: 55, + pic: false, + character: 'che_안전', + }) ).rejects.toMatchObject({ message: '장수 선택 목록에서 장수를 골라 주세요.' }); const input = { @@ -453,24 +431,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => { }); const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId; const persistedAllocatorBefore = ( - (await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })) - .meta as Record + (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', - }) + 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 + ((await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).meta as Record) + .lastGeneralId ).toBe(persistedAllocatorBefore); expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0); @@ -534,21 +507,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => { try { await expect( - appRouter - .createCaller(buildContext('select-pool-failure-http', failureAuth)) - .join.selectPoolGeneral({ - uniqueName: candidate.uniqueName, - personality: 'che_안전', - clientRequestId: requestUuid, - }) + 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}"()` - ); + 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 } }); @@ -560,9 +527,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { 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.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( @@ -570,9 +535,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => { where: { meta: { path: ['ownerUserId'], equals: failureUserId } }, }) ).toBe(2); - await expect( - db.inputEvent.findUniqueOrThrow({ where: { requestId } }) - ).resolves.toMatchObject({ + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ status: 'SUCCEEDED', attempts: 2, actorUserId: failureUserId, diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index edc698a..8bcb1ae 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -23,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/joinCreateGeneralService.js'; export * from './turn/selectPoolService.js'; export * from './turn/turnDaemon.js'; export * from './turn/cli.js'; diff --git a/app/game-engine/src/scenario/scenarioSeeder.ts b/app/game-engine/src/scenario/scenarioSeeder.ts index a89d87b..fc6e5a7 100644 --- a/app/game-engine/src/scenario/scenarioSeeder.ts +++ b/app/game-engine/src/scenario/scenarioSeeder.ts @@ -1,7 +1,7 @@ import { randomBytes } from 'node:crypto'; import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra'; -import { asRecord } from '@sammo-ts/common'; +import { asNumber, asRecord } from '@sammo-ts/common'; import { buildScenarioBootstrap, resolveScenarioGeneralDeathMonth, @@ -233,7 +233,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const connector = createGamePostgresConnector({ url: options.databaseUrl }); const now = options.now ?? new Date(); const tickSeconds = - install?.turnTermMinutes !== undefined ? install.turnTermMinutes * 60 : options.tickSeconds ?? DEFAULT_TICK_SECONDS; + install?.turnTermMinutes !== undefined + ? install.turnTermMinutes * 60 + : (options.tickSeconds ?? DEFAULT_TICK_SECONDS); const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60)); const sync = install?.sync ?? false; const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync); @@ -241,10 +243,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE; const scenarioConst = asRecord(seed.scenarioConfig.const); - if ( - typeof scenarioConst.openingPartYear !== 'number' || - Number.isNaN(scenarioConst.openingPartYear) - ) { + if (typeof scenarioConst.openingPartYear !== 'number' || Number.isNaN(scenarioConst.openingPartYear)) { scenarioConst.openingPartYear = DEFAULT_OPENING_PART_YEAR; } const scenarioConfig = { @@ -268,6 +267,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const worldMeta: Record = { scenarioId: options.scenarioId, scenarioMeta: seed.scenarioMeta, + genius: Math.max(0, Math.floor(asNumber(scenarioConst.defaultMaxGenius, 5))), starttime: formatDateTime(startState.startTime), turntime: formatDateTime(now), opentime: formatDateTime(now), @@ -283,9 +283,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]?.trim(); worldMeta.hiddenSeed = - integrationSeed && integrationSeed.length > 0 - ? integrationSeed - : randomBytes(16).toString('hex'); + integrationSeed && integrationSeed.length > 0 ? integrationSeed : randomBytes(16).toString('hex'); if (install?.preopenAt) { worldMeta.preopenAt = formatDateTime(install.preopenAt); @@ -484,8 +482,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom : resolveScenarioGeneralDeathMonth({ scenarioTitle: String(seed.scenarioMeta?.title ?? ''), startYear: seed.scenarioMeta?.startYear ?? null, - contextLabel: - typeof meta.source === 'string' ? meta.source : 'general', + contextLabel: typeof meta.source === 'string' ? meta.source : 'general', generalId: general.id, generalName: general.name, deathYear: general.deathYear, diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index a0177e8..04c8206 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -243,6 +243,31 @@ const zPatchGeneral = z.object({ }), }); +const zJoinCreateGeneral = z + .object({ + type: z.literal('joinCreateGeneral'), + requestId: z.string().optional(), + userId: z.string().min(1), + ownerDisplayName: z.string(), + seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]), + name: z.string().min(1).max(18), + leadership: zFiniteNumber, + strength: zFiniteNumber, + intel: zFiniteNumber, + pic: z.boolean(), + character: z.string().min(1), + profileId: z.string().min(1), + ownerPicture: z.string().optional(), + ownerImageServer: zFiniteNumber.optional(), + ownerCanUsePicture: z.boolean().optional(), + ownerLegacyPenalty: zRecord.optional(), + inheritSpecial: z.string().optional(), + inheritTurntimeZone: zFiniteNumber.optional(), + inheritCity: zFiniteNumber.optional(), + inheritBonusStat: z.tuple([zFiniteNumber, zFiniteNumber, zFiniteNumber]).optional(), + }) + .strict(); + const zSelectPoolCreate = z .object({ type: z.literal('selectPoolCreate'), @@ -295,7 +320,12 @@ const zShutdown = z.object({ const zShiftSchedule = z.object({ type: z.literal('shiftSchedule'), actionId: z.string().uuid(), - deltaMinutes: z.number().int().min(-1440).max(1440).refine((value) => value !== 0), + deltaMinutes: z + .number() + .int() + .min(-1440) + .max(1440) + .refine((value) => value !== 0), }); const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope) => { @@ -506,6 +536,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => { return { ...command, requestId: envelope.requestId }; }; +const normalizeJoinCreateGeneral: CommandNormalizer<'joinCreateGeneral'> = (envelope) => { + const command = parseWith(zJoinCreateGeneral, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelope) => { const command = parseWith(zSelectPoolCreate, envelope.command); if (!command) { @@ -585,6 +623,7 @@ const normalizers: CommandNormalizerMap = { adjustGeneralMeta: normalizeAdjustGeneralMeta, tournamentMatchResult: normalizeTournamentMatchResult, patchGeneral: normalizePatchGeneral, + joinCreateGeneral: normalizeJoinCreateGeneral, selectPoolCreate: normalizeSelectPoolCreate, selectPoolReselect: normalizeSelectPoolReselect, getStatus: normalizeGetStatus, diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index 89a6f4f..9c072ad 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -302,7 +302,13 @@ const buildPersistedGeneralMeta = ( const buildInitialRankRows = ( general: ReturnType['generals'][number] ): Array<{ generalId: number; nationId: number; type: string; value: number }> => - buildPersistedRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 })); + buildPersistedRankRows(general).map((row) => ({ + ...row, + nationId: 0, + // Ref Join은 전체 rank_data를 0으로 만든 직후 장수 생성에 사용한 + // 유산 포인트만 inherit_spent_dyn에 반영한다. + value: row.type === 'inherit_spent_dyn' ? row.value : 0, + })); const RANK_DATA_UPSERT_BATCH_SIZE = 1_000; @@ -362,6 +368,7 @@ const buildGeneralUpdate = ( specialCode: toCode(general.role.specialDomestic), special2Code: toCode(general.role.specialWar), lastTurn: asJson(general.lastTurn ?? { command: '휴식' }), + penalty: asJson(general.penalty ?? {}), meta: buildPersistedGeneralMeta(general), turnTime: general.turnTime, recentWarTime: general.recentWarTime ?? null, @@ -405,6 +412,7 @@ const buildGeneralCreate = ( specialCode: toCode(general.role.specialDomestic), special2Code: toCode(general.role.specialWar), lastTurn: asJson(general.lastTurn ?? { command: '휴식' }), + penalty: asJson(general.penalty ?? {}), meta: buildPersistedGeneralMeta(general), turnTime: general.turnTime, recentWarTime: general.recentWarTime ?? null, diff --git a/app/game-engine/src/turn/inMemoryWorld.ts b/app/game-engine/src/turn/inMemoryWorld.ts index 69eb4ce..cbf7747 100644 --- a/app/game-engine/src/turn/inMemoryWorld.ts +++ b/app/game-engine/src/turn/inMemoryWorld.ts @@ -1,4 +1,13 @@ -import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic'; +import type { + City, + LogEntryDraft, + MessageDraft, + Nation, + ScenarioConfig, + Troop, + TurnSchedule, + UnitSetDefinition, +} from '@sammo-ts/logic'; import { getNextTurnAt } from '@sammo-ts/logic'; import type { TurnCheckpoint } from '../lifecycle/types.js'; @@ -338,12 +347,14 @@ export class InMemoryTurnWorld { private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = []; private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = []; private readonly scenarioConfig: ScenarioConfig; + private readonly unitSet?: UnitSetDefinition; private checkpoint?: TurnCheckpoint; private state: TurnWorldState; constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) { this.state = { ...state }; this.scenarioConfig = snapshot.scenarioConfig; + this.unitSet = snapshot.unitSet; this.schedule = options.schedule; this.generalTurnHandler = options.generalTurnHandler ?? @@ -547,6 +558,10 @@ export class InMemoryTurnWorld { return this.scenarioConfig; } + getUnitSet(): UnitSetDefinition | undefined { + return this.unitSet; + } + getGeneralById(id: number): TurnGeneral | null { return this.generals.get(id) ?? null; } diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts new file mode 100644 index 0000000..b71cf52 --- /dev/null +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -0,0 +1,857 @@ +import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; +import { + ActionLogger, + buildAuctionAlias, + buildAuctionAliasPool, + getLegacyStringWidth, + isPersonalityTraitKey, + isWarTraitKey, + JOIN_PERSONALITY_TRAIT_KEYS, + loadWarTraitModules, + LogFormat, + normalizeTroopName, + simpleSerialize, + TraitSelector, + WarTraitLoader, + WAR_TRAIT_KEYS, +} from '@sammo-ts/logic'; +import type { WarTraitKey } 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 JoinCreateGeneralErrorCode = + 'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR'; + +export class JoinCreateGeneralError extends Error { + constructor( + readonly code: JoinCreateGeneralErrorCode, + message: string + ) { + super(message); + this.name = 'JoinCreateGeneralError'; + } +} + +export interface JoinCreateGeneralInput { + userId: string; + ownerDisplayName: string; + seedOwnerIdentity: string | number; + name: string; + leadership: number; + strength: number; + intel: number; + pic: boolean; + character: string; + profileId: string; + ownerPicture?: string; + ownerImageServer?: number; + ownerCanUsePicture?: boolean; + ownerLegacyPenalty?: Record; + inheritSpecial?: string; + inheritTurntimeZone?: number; + inheritCity?: number; + inheritBonusStat?: [number, number, number]; +} + +interface InheritConstants { + inheritBornSpecialPoint: number; + inheritBornTurntimePoint: number; + inheritBornCityPoint: number; + inheritBornStatPoint: number; +} + +const DEFAULT_JOIN_STAT = { + total: 165, + min: 15, + max: 80, +}; +const DEFAULT_MAX_GENERAL = 500; +const DEFAULT_MAX_GENIUS = 5; +const DEFAULT_GENERAL_GOLD = 1000; +const DEFAULT_GENERAL_RICE = 1000; +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 LEGACY_JOIN_REMOVED_CHARACTERS = /[ⓝⓜⓖⓞⓧ㉥\\/`#|-]/gu; + +const fail = (code: JoinCreateGeneralErrorCode, message: string): never => { + throw new JoinCreateGeneralError(code, message); +}; + +const normalizeJoinName = (value: string): string => + normalizeTroopName(value).replace(LEGACY_JOIN_REMOVED_CHARACTERS, ''); + +const resolveLegacyPenalty = ( + rawPenalty: Record | undefined, + profileId: string, + acceptedAt: Date +): Record => { + if (!rawPenalty) { + return {}; + } + const merged = { + ...asRecord(rawPenalty.any), + ...asRecord(rawPenalty[profileId]), + }; + const acceptedAtSeconds = acceptedAt.getTime() / 1000; + const result: Record = {}; + for (const [key, rawEntry] of Object.entries(merged)) { + const entry = asRecord(rawEntry); + if (asNumber(entry.expire, 0) > acceptedAtSeconds && Object.hasOwn(entry, 'value')) { + result[key] = entry.value; + } + } + return result; +}; + +const resolveStatRule = (worldState: WorldStateRow) => { + const stat = asRecord(asRecord(worldState.config).stat); + return { + total: asNumber(stat.total, DEFAULT_JOIN_STAT.total), + min: asNumber(stat.min, DEFAULT_JOIN_STAT.min), + max: asNumber(stat.max, DEFAULT_JOIN_STAT.max), + }; +}; + +const resolveInheritConstants = (worldState: WorldStateRow): InheritConstants => { + const configConst = asRecord(asRecord(worldState.config).const); + return { + inheritBornSpecialPoint: asNumber(configConst.inheritBornSpecialPoint, 6000), + inheritBornTurntimePoint: asNumber(configConst.inheritBornTurntimePoint, 2500), + inheritBornCityPoint: asNumber(configConst.inheritBornCityPoint, 1000), + inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, 1000), + }; +}; + +const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => { + const config = asRecord(worldState.config); + const map = asRecord(config.map); + return asNumber(config.npcMode, 0) === 2 && map.targetGeneralPool === 'SPoolUnderU30'; +}; + +const resolveMaxGeneral = (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 readHiddenSeed = (worldState: WorldStateRow): string | number => { + const meta = asRecord(worldState.meta); + const value = meta.hiddenSeed ?? meta.seed; + if (typeof value === 'string' || typeof value === 'number') { + return value; + } + return fail('INTERNAL_SERVER_ERROR', '장수 생성 비밀 seed가 설정되지 않았습니다.'); +}; + +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 buildJoinCreateGeneralSeed = ( + hiddenSeed: string | number, + ownerIdentity: string | number, + acceptedAt: Date +): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, formatLegacySeedTime(acceptedAt)); + +const lockJoinMutation = async (db: DatabaseClient, userId: string): Promise => { + await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`join-create:${userId}`}, 0))`); + await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" 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 applyInheritanceUser = async ( + db: DatabaseClient, + userId: string, + year: number, + month: number +): Promise => { + const rows = await db.$queryRaw>( + GamePrisma.sql` + SELECT key, value + FROM inheritance_point + WHERE user_id = ${userId} + ORDER BY id ASC + FOR UPDATE + ` + ); + if (rows.length === 0) { + return 0; + } + if (rows.length === 1 && rows[0]?.key === 'previous') { + return rows[0].value; + } + + const previousPoint = rows.find((row) => row.key === 'previous')?.value ?? 0; + const totalPoint = Math.trunc(rows.reduce((sum, row) => sum + row.value, 0)); + for (const row of rows) { + await db.inheritanceLog.create({ + data: { + userId, + year, + month, + text: `${row.key} 포인트 ${row.value} 증가`, + }, + }); + } + await db.inheritanceLog.create({ + data: { + userId, + year, + month, + text: `포인트 ${previousPoint} => ${totalPoint}`, + }, + }); + await db.inheritancePoint.deleteMany({ + where: { + userId, + key: { not: 'previous' }, + }, + }); + await setInheritancePoint(db, userId, totalPoint); + return totalPoint; +}; + +const setInheritancePoint = async (db: DatabaseClient, userId: string, value: number): Promise => { + await db.inheritancePoint.upsert({ + where: { userId_key: { userId, key: 'previous' } }, + update: { value }, + create: { userId, key: 'previous', value }, + }); +}; + +const appendInheritanceLog = async ( + db: DatabaseClient, + userId: string, + year: number, + month: number, + text: string +): Promise => { + await db.inheritanceLog.create({ + data: { userId, year, month, text }, + }); +}; + +const calculateInheritanceCost = ( + input: JoinCreateGeneralInput, + constants: InheritConstants, + inheritBonus: [number, number, number] | null +): number => { + let required = 0; + if (input.inheritCity !== undefined) { + required += constants.inheritBornCityPoint; + } + if (inheritBonus) { + required += constants.inheritBornStatPoint; + } + if (input.inheritSpecial !== undefined) { + required += constants.inheritBornSpecialPoint; + } + if (input.inheritTurntimeZone !== undefined) { + required += constants.inheritBornTurntimePoint; + } + return required; +}; + +const validateAndNormalizeBonus = (bonus: [number, number, number] | undefined): [number, number, number] | null => { + if (!bonus) { + return null; + } + if (bonus.some((value) => !Number.isInteger(value) || value < 0)) { + fail('BAD_REQUEST', '보너스 능력치가 음수입니다. 다시 가입해주세요!'); + } + const sum = bonus.reduce((acc, value) => acc + value, 0); + if (sum === 0) { + return null; + } + if (sum < 3 || sum > 5) { + fail('BAD_REQUEST', '보너스 능력치 합이 잘못 지정되었습니다. 다시 가입해주세요!'); + } + return bonus; +}; + +const buildRandomBonus = (rng: RandUtil, baseStats: [number, number, number]): [number, number, number] => { + const result: [number, number, number] = [0, 0, 0]; + const count = rng.nextRangeInt(3, 5); + for (let index = 0; index < count; index += 1) { + const selected = Number( + rng.choiceUsingWeight({ + 0: baseStats[0], + 1: baseStats[1], + 2: baseStats[2], + }) + ); + result[selected as 0 | 1 | 2] += 1; + } + return result; +}; + +const resolveRelativeYear = (worldState: WorldStateRow): number => { + const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta); + const startYear = asNumber(scenarioMeta.startYear, worldState.currentYear); + return Math.max(worldState.currentYear - startYear, 0); +}; + +const resolveSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number => + Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; + +export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => { + const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS); + const baseTime = + Date.UTC(koreaTime.getUTCFullYear(), koreaTime.getUTCMonth(), koreaTime.getUTCDate() - 1, 1) - + LEGACY_TIMEZONE_OFFSET_MS; + const elapsedSeconds = Math.floor((value.getTime() - baseTime) / 1000); + const alignedSeconds = elapsedSeconds - (elapsedSeconds % tickSeconds); + return new Date(baseTime + alignedSeconds * 1000); +}; + +const resolveTurnTime = ( + rng: RandUtil, + worldState: WorldStateRow, + acceptedAt: Date, + runtimeTurnTime: Date, + inheritTurntimeZone: number | undefined +): Date => { + const tickSeconds = Math.max(1, Math.floor(worldState.tickSeconds)); + const base = runtimeTurnTime; + let offsetSeconds: number; + let offsetMicros: number; + let turnTimeBase: Date; + if (inheritTurntimeZone !== undefined) { + const legacyTurnTermMinutes = Math.max(1, Math.floor(tickSeconds / 60)); + turnTimeBase = cutJoinTurnTime(base, tickSeconds); + offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1); + offsetMicros = rng.nextRangeInt(0, 999_999); + } else { + turnTimeBase = base; + offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1); + offsetMicros = rng.nextRangeInt(0, 999_999); + } + let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000); + if (turnTime.getTime() <= acceptedAt.getTime()) { + turnTime = new Date(turnTime.getTime() + tickSeconds * 1000); + } + return turnTime; +}; + +const resolveCatchupExperience = async (db: DatabaseClient, relativeYear: number): Promise => { + if (relativeYear < 3) { + return 0; + } + const count = await db.general.count({ + where: { nationId: { not: 0 }, npcState: { lt: 4 } }, + }); + if (count === 0) { + return 0; + } + const order = Math.max(1, Math.round(count * 0.2)); + const row = await db.general.findFirst({ + where: { nationId: { not: 0 }, npcState: { lt: 4 } }, + orderBy: [{ experience: 'asc' }, { id: 'asc' }], + skip: order - 1, + select: { experience: true }, + }); + return Math.round((row?.experience ?? 0) * 0.8); +}; + +const resolveRestInheritanceBonus = async ( + db: DatabaseClient, + worldState: WorldStateRow, + userId: string +): Promise => { + const meta = asRecord(worldState.meta); + const serverId = typeof meta.serverId === 'string' ? meta.serverId : ''; + const season = Math.floor(asNumber(meta.season, 0)); + if (!serverId || season < 1) { + return 0; + } + if ( + (await db.inheritanceResult.count({ + where: { serverId, owner: userId }, + })) > 0 + ) { + return 0; + } + const oldGames = await db.gameHistory.findMany({ + where: { + winnerNation: { not: null }, + season: { gte: 1 }, + serverId: { not: serverId }, + }, + orderBy: { date: 'desc' }, + take: 8, + select: { serverId: true }, + }); + if (oldGames.length === 0) { + return 0; + } + const oldServerIds = oldGames.map(({ serverId: oldServerId }) => oldServerId); + const playedRows = await db.oldGeneral.findMany({ + where: { + owner: userId, + serverId: { in: oldServerIds }, + }, + distinct: ['serverId'], + select: { serverId: true }, + }); + const played = new Set(playedRows.map(({ serverId: oldServerId }) => oldServerId)); + let missedGames = 0; + for (const { serverId: oldServerId } of oldGames) { + if (played.has(oldServerId)) { + break; + } + missedGames += 1; + } + return missedGames * 500; +}; + +const pushCreationLogs = ( + world: InMemoryTurnWorld, + options: { + generalId: number; + userId: string; + name: string; + cityName: string; + bonus: [number, number, number]; + age: number; + genius: boolean; + specialWarName: string; + } +): void => { + const logger = new ActionLogger({ generalId: options.generalId }); + const josaRa = JosaUtil.pick(options.name, '라'); + if (options.genius) { + logger.pushGlobalActionLog( + `${options.cityName}에서 ${options.name}${josaRa}는 기재가 천하에 이름을 알립니다.` + ); + logger.pushGlobalActionLog( + `${options.specialWarName} 특기를 가진 천재의 등장으로 온 천하가 떠들썩합니다.` + ); + logger.pushGlobalHistoryLog(`【천재】${options.cityName}에 천재가 등장했습니다.`); + } else { + logger.pushGlobalActionLog( + `${options.cityName}에서 ${options.name}${josaRa}는 호걸이 천하에 이름을 알립니다.` + ); + } + logger.pushGeneralHistoryLog(`${options.name}, ${options.cityName}에서 큰 뜻을 품다.`); + logger.pushGeneralActionLog( + [ + '삼국지 모의전투 PHP의 세계에 오신 것을 환영합니다 ^o^', + '처음 하시는 경우에는 도움말을 참고하시고,', + '문의사항이 있으시면 게시판에 글을 남겨주시면 되겠네요~', + '부디 즐거운 삼모전 되시길 바랍니다 ^^', + `통솔 ${options.bonus[0]} 무력 ${options.bonus[1]} 지력 ${options.bonus[2]} 의 보너스를 받으셨습니다.`, + `연령은 ${options.age}세로 시작합니다.`, + ], + LogFormat.PLAIN + ); + if (options.genius) { + logger.pushGeneralActionLog( + `축하합니다! 천재로 태어나 처음부터 ${options.specialWarName} 특기를 가지게 됩니다!`, + LogFormat.PLAIN + ); + logger.pushGeneralHistoryLog(`${options.specialWarName} 특기를 가진 천재로 탄생.`); + } + for (const entry of logger.flush()) { + world.pushLog({ + ...entry, + meta: { + ...(entry.meta ?? {}), + ownerUserId: options.userId, + }, + }); + } +}; + +export const createGeneralFromJoin = async (options: { + db: DatabaseClient; + world: InMemoryTurnWorld; + worldState: WorldStateRow; + input: JoinCreateGeneralInput; + acceptedAt: Date; +}): Promise<{ ok: true; generalId: number }> => { + const { db, world, worldState, input, acceptedAt } = options; + await lockJoinMutation(db, input.userId); + await assertGeneralIdSnapshotMatches(db, world); + + if (isSelectionPoolWorld(worldState)) { + fail('PRECONDITION_FAILED', '장수 선택 목록에서 장수를 골라 주세요.'); + } + const config = asRecord(worldState.config); + const configConst = asRecord(config.const); + const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0)); + if ((blockGeneralCreate & 1) !== 0) { + fail('FORBIDDEN', '장수 직접 생성이 불가능한 모드입니다.'); + } + + if ( + world.listGenerals().some((general) => general.userId === input.userId) || + (await db.general.findFirst({ + where: { userId: input.userId }, + select: { id: true }, + })) + ) { + fail('PRECONDITION_FAILED', '이미 등록하셨습니다!'); + } + + const normalizedName = normalizeJoinName(input.name); + if (!normalizedName || getLegacyStringWidth(normalizedName) < 1) { + fail('BAD_REQUEST', '이름이 짧습니다. 다시 가입해주세요!'); + } + if (getLegacyStringWidth(normalizedName) > 18) { + fail('BAD_REQUEST', '이름이 유효하지 않습니다. 다시 가입해주세요!'); + } + if ( + world.listGenerals().some((general) => general.name === normalizedName) || + (await db.general.findFirst({ + where: { name: normalizedName }, + select: { id: true }, + })) + ) { + fail('CONFLICT', '이미 있는 장수입니다. 다른 이름으로 등록해 주세요!'); + } + + const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } }); + if (activeCount >= resolveMaxGeneral(worldState)) { + fail('PRECONDITION_FAILED', '더이상 등록할 수 없습니다!'); + } + + const statRule = resolveStatRule(worldState); + const stats = [input.leadership, input.strength, input.intel] as const; + if (stats.some((value) => !Number.isInteger(value) || value < statRule.min || value > statRule.max)) { + fail('BAD_REQUEST', '능력치 범위를 벗어났습니다.'); + } + if (stats.reduce((sum, value) => sum + value, 0) > statRule.total) { + fail('BAD_REQUEST', `능력치가 ${statRule.total}을 넘어섰습니다. 다시 가입해주세요!`); + } + + if ( + input.character !== 'Random' && + (!isPersonalityTraitKey(input.character) || !JOIN_PERSONALITY_TRAIT_KEYS.some((key) => key === input.character)) + ) { + fail('BAD_REQUEST', '성격이 잘못 지정되었습니다.'); + } + const availableSpecialWar = Array.isArray(configConst.availableSpecialWar) + ? configConst.availableSpecialWar.filter( + (value): value is WarTraitKey => typeof value === 'string' && isWarTraitKey(value) + ) + : [...WAR_TRAIT_KEYS]; + if ( + input.inheritSpecial !== undefined && + (!isWarTraitKey(input.inheritSpecial) || !availableSpecialWar.includes(input.inheritSpecial)) + ) { + fail('BAD_REQUEST', '전투 특기가 잘못 지정되었습니다.'); + } + if ( + input.inheritTurntimeZone !== undefined && + (!Number.isInteger(input.inheritTurntimeZone) || + input.inheritTurntimeZone < 0 || + input.inheritTurntimeZone > 59) + ) { + fail('BAD_REQUEST', '턴 시간 지정 범위가 올바르지 않습니다.'); + } + + const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat); + const inheritConstants = resolveInheritConstants(worldState); + const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus); + const currentInheritancePoint = await applyInheritanceUser( + db, + input.userId, + worldState.currentYear, + worldState.currentMonth + ); + if (currentInheritancePoint < inheritRequiredPoint) { + fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!'); + } + + const allCities = world.listCities().sort((left, right) => left.id - right.id); + const selectedCity = + input.inheritCity === undefined ? null : (allCities.find((city) => city.id === input.inheritCity) ?? null); + if (input.inheritCity !== undefined && !selectedCity) { + fail('BAD_REQUEST', '도시가 잘못 지정되었습니다. 다시 가입해주세요!'); + } + const neutralCities = allCities.filter((city) => city.level >= 5 && city.level <= 6 && city.nationId === 0); + const randomCities = + neutralCities.length > 0 ? neutralCities : allCities.filter((city) => city.level >= 5 && city.level <= 6); + if (!selectedCity && randomCities.length === 0) { + fail('PRECONDITION_FAILED', '생성 가능한 도시가 없습니다.'); + } + + const hiddenSeed = readHiddenSeed(worldState); + const rng = new RandUtil( + new LiteHashDRBG(buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, acceptedAt)) + ); + const worldMeta = asRecord(worldState.meta); + const currentGenius = Math.max( + 0, + Math.floor(asNumber(worldMeta.genius, asNumber(configConst.defaultMaxGenius, DEFAULT_MAX_GENIUS))) + ); + if (input.inheritSpecial !== undefined && currentGenius === 0) { + fail('PRECONDITION_FAILED', '이미 천재가 모두 나타났습니다. 다시 가입해주세요!'); + } + const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01); + const genius = geniusRequested && currentGenius > 0; + + const city = selectedCity ?? rng.choice(randomCities); + const bonus = inheritBonus ?? buildRandomBonus(rng, [...stats]); + const finalStats = { + leadership: input.leadership + bonus[0], + strength: input.strength + bonus[1], + intelligence: input.intel + bonus[2], + }; + const age = 20 + (bonus[0] + bonus[1] + bonus[2]) * 2 - rng.nextRangeInt(0, 1); + const relativeYear = resolveRelativeYear(worldState); + const retirementYear = asNumber(configConst.retirementYear, 80); + const scenarioId = Number(worldMeta.scenarioId ?? worldState.scenarioCode); + let specialityDomesticAge = resolveSpecialityAge(retirementYear, age, relativeYear, 12); + let specialityWarAge = resolveSpecialityAge(retirementYear, age, relativeYear, 6); + let specialWar = typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None'; + let specialWarName = specialWar; + if (genius) { + specialityWarAge = age; + if (input.inheritSpecial) { + specialWar = input.inheritSpecial; + } else { + const traits = await loadWarTraitModules(availableSpecialWar, new WarTraitLoader()); + specialWar = + TraitSelector.pickWarTrait( + rng, + finalStats, + [0, 0, 0, 0, 0], + traits, + [], + world.getScenarioConfig().stat + ) ?? specialWar; + } + const [trait] = await loadWarTraitModules( + [specialWar].filter((key) => isWarTraitKey(key)), + new WarTraitLoader() + ); + specialWarName = trait?.name ?? specialWar; + } + if (Number.isFinite(scenarioId) && scenarioId >= 1000) { + specialityDomesticAge = age + 3; + specialityWarAge = age + 3; + } + + const experience = await resolveCatchupExperience(db, relativeYear); + const turnTime = resolveTurnTime( + rng, + worldState, + acceptedAt, + world.getState().lastTurnTime, + input.inheritTurntimeZone + ); + const personality = input.character === 'Random' ? rng.choice([...JOIN_PERSONALITY_TRAIT_KEYS]) : input.character; + const affinity = rng.nextRangeInt(1, 150); + const generalId = world.getNextGeneralId(); + let obfuscatedNamePool = Array.isArray(worldMeta.obfuscatedNamePool) + ? worldMeta.obfuscatedNamePool.filter((value): value is string => typeof value === 'string') + : []; + if ((blockGeneralCreate & 2) !== 0 && obfuscatedNamePool.length === 0) { + obfuscatedNamePool = buildAuctionAliasPool(hiddenSeed, configConst); + world.updateWorldMeta({ obfuscatedNamePool }); + } + const finalName = + (blockGeneralCreate & 2) !== 0 + ? buildAuctionAlias(generalId, hiddenSeed, { + ...configConst, + obfuscatedNamePool, + }) + : normalizedName; + const showImgLevel = Math.floor(asNumber(config.showImgLevel, 0)); + const canUseOwnerPicture = + showImgLevel >= 1 && + input.pic === true && + input.ownerCanUsePicture === true && + typeof input.ownerPicture === 'string' && + input.ownerPicture.length > 0 && + input.ownerPicture !== 'default.jpg'; + const picture = canUseOwnerPicture ? input.ownerPicture! : 'default.jpg'; + const imageServer = canUseOwnerPicture ? Math.max(0, Math.floor(input.ownerImageServer ?? 0)) : 0; + const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint; + const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId); + const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus; + const general: TurnGeneral = { + id: generalId, + userId: input.userId, + name: finalName, + nationId: 0, + cityId: city.id, + troopId: 0, + npcState: 0, + affinity, + // Ref Join은 두 컬럼을 지정하지 않아 schema 기본값 180/300을 유지한다. + bornYear: 180, + deadYear: 300, + picture, + imageServer, + stats: finalStats, + experience, + dedication: 0, + officerLevel: 0, + injury: 0, + gold: asNumber(configConst.defaultGold, DEFAULT_GENERAL_GOLD), + rice: asNumber(configConst.defaultRice, DEFAULT_GENERAL_RICE), + crew: 0, + crewTypeId: world.getUnitSet()?.defaultCrewTypeId ?? DEFAULT_CREW_TYPE_ID, + train: 0, + atmos: 0, + turnTime, + age, + startAge: age, + role: { + personality, + specialDomestic: + typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None', + specialWar, + items: { + horse: null, + weapon: null, + book: null, + item: null, + }, + }, + triggerState: { + flags: {}, + counters: {}, + modifiers: {}, + meta: {}, + }, + lastTurn: { command: DEFAULT_TURN_ACTION }, + penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, acceptedAt), + inheritancePoints: { + previous: finalInheritancePoint, + }, + refreshScoreTotal: 0, + meta: { + createdBy: 'join', + ownerName: input.ownerDisplayName, + owner_name: input.ownerDisplayName, + killturn: 6, + npc_org: 0, + newmsg: 0, + makelimit: 0, + block: 0, + dedlevel: 0, + explevel: 0, + officerCity: 0, + officer_city: 0, + permission: 'normal', + belong: 1, + betray: relativeYear >= 4 ? 2 : 0, + specage: specialityDomesticAge, + specage2: specialityWarAge, + defence_train: 80, + tnmt: 1, + myset: 6, + tournament: 0, + newvote: 0, + inherit_spent_dyn: inheritRequiredPoint, + }, + }; + if (!world.addGeneral(general)) { + throw new Error(`장수 번호 ${generalId}를 할당할 수 없습니다.`); + } + if (genius) { + world.updateWorldMeta({ genius: currentGenius - 1 }); + } + + 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 }, + }); + await db.generalAccessLog.upsert({ + where: { generalId }, + update: { userId: input.userId, lastRefresh: acceptedAt }, + create: { + generalId, + userId: input.userId, + lastRefresh: acceptedAt, + }, + }); + if (inheritRequiredPoint > 0) { + await setInheritancePoint(db, input.userId, nextInheritancePoint); + const inheritLogs: string[] = []; + if (input.inheritSpecial) { + inheritLogs.push(`${specialWarName} 전투 특기를 가진 천재 생성`); + } + if (input.inheritCity !== undefined) { + inheritLogs.push(`${city.name}에 장수 생성`); + } + if (inheritBonus) { + inheritLogs.push(`${inheritBonus[0]}, ${inheritBonus[1]}, ${inheritBonus[2]} 보너스 능력치로 생성`); + } + if (input.inheritTurntimeZone !== undefined) { + const zoneStart = input.inheritTurntimeZone * Math.max(1, Math.floor(worldState.tickSeconds / 60)); + inheritLogs.push( + `턴 시간 ${String(Math.floor(zoneStart / 60)).padStart(2, '0')}:${String(zoneStart % 60).padStart( + 2, + '0' + )} 로 지정` + ); + } + inheritLogs.push(`장수 생성으로 포인트 ${inheritRequiredPoint} 소모`); + for (const text of inheritLogs) { + await appendInheritanceLog(db, input.userId, worldState.currentYear, worldState.currentMonth, text); + } + } + if (restInheritanceBonus > 0) { + await setInheritancePoint(db, input.userId, finalInheritancePoint); + await appendInheritanceLog( + db, + input.userId, + worldState.currentYear, + worldState.currentMonth, + `신규/복귀 생성으로 포인트 ${restInheritanceBonus} 지급` + ); + } + pushCreationLogs(world, { + generalId, + userId: input.userId, + name: finalName, + cityName: city.name, + bonus, + age, + genius, + specialWarName, + }); + return { ok: true, generalId }; +}; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 9b6c336..e0728be 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -45,6 +45,7 @@ import { reselectGeneralFromSelectionPool, SelectPoolError, } from './selectPoolService.js'; +import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js'; let itemRegistryPromise: Promise> | null = null; @@ -131,21 +132,85 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => { const resolveCommandAcceptedAt = async ( db: DatabaseClient, - command: Extract + 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 }, + select: { createdAt: true, actorUserId: true }, }); if (!event) { throw new Error(`ENGINE input event ${command.requestId} is missing.`); } + if (event.actorUserId !== command.userId) { + throw new Error(`ENGINE input event actor does not match ${command.type} user.`); + } return event.createdAt; }; +async function handleJoinCreateGeneral( + 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('Join world state is missing.'); + } + const acceptedAt = await resolveCommandAcceptedAt(db, command); + try { + return { + type: 'joinCreateGeneral', + ...(await createGeneralFromJoin({ + db, + world: ctx.world, + worldState, + input: { + userId: command.userId, + ownerDisplayName: command.ownerDisplayName, + seedOwnerIdentity: command.seedOwnerIdentity, + name: command.name, + leadership: command.leadership, + strength: command.strength, + intel: command.intel, + pic: command.pic, + character: command.character, + profileId: command.profileId, + ...(command.ownerPicture !== undefined ? { ownerPicture: command.ownerPicture } : {}), + ...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}), + ...(command.ownerCanUsePicture !== undefined + ? { ownerCanUsePicture: command.ownerCanUsePicture } + : {}), + ...(command.ownerLegacyPenalty !== undefined + ? { ownerLegacyPenalty: command.ownerLegacyPenalty } + : {}), + ...(command.inheritSpecial !== undefined ? { inheritSpecial: command.inheritSpecial } : {}), + ...(command.inheritTurntimeZone !== undefined + ? { inheritTurntimeZone: command.inheritTurntimeZone } + : {}), + ...(command.inheritCity !== undefined ? { inheritCity: command.inheritCity } : {}), + ...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}), + }, + acceptedAt, + })), + }; + } catch (error) { + if (error instanceof JoinCreateGeneralError) { + return { + type: 'joinCreateGeneral', + ok: false, + code: error.code, + reason: error.message, + }; + } + throw error; + } +} + async function handleSelectPoolCreate( ctx: CommandHandlerContext, command: Extract @@ -1952,16 +2017,12 @@ export const createTurnDaemonCommandHandler = (options: { handleTournamentMatchResult(ctx, command as Extract), patchGeneral: (command) => handlePatchGeneral(ctx, command as Extract), + joinCreateGeneral: (command) => + handleJoinCreateGeneral(ctx, command as Extract), selectPoolCreate: (command) => - handleSelectPoolCreate( - ctx, - command as Extract - ), + handleSelectPoolCreate(ctx, command as Extract), selectPoolReselect: (command) => - handleSelectPoolReselect( - ctx, - command as Extract - ), + handleSelectPoolReselect(ctx, command as Extract), shiftSchedule: (command) => handleShiftSchedule(ctx, command as Extract), }; diff --git a/app/game-engine/test/joinCreateGeneralService.test.ts b/app/game-engine/test/joinCreateGeneralService.test.ts new file mode 100644 index 0000000..c71e887 --- /dev/null +++ b/app/game-engine/test/joinCreateGeneralService.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { buildJoinCreateGeneralSeed, cutJoinTurnTime } from '../src/turn/joinCreateGeneralService.js'; + +describe('generic join legacy time contracts', () => { + it('builds the Ref MakeGeneral seed from the Seoul whole-second timestamp', () => { + expect(buildJoinCreateGeneralSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe( + 'str(4,seed)|str(11,MakeGeneral)|int(42)|str(19,2026-07-31 08:59:58)' + ); + }); + + it('aligns a 120-minute turn from the Ref Asia/Seoul previous-day 01:00 anchor', () => { + expect(cutJoinTurnTime(new Date('2026-07-30T03:34:56.789Z'), 120 * 60).toISOString()).toBe( + '2026-07-30T02:00:00.000Z' + ); + }); +}); diff --git a/app/game-frontend/e2e/joinGeneral.live.playwright.config.mjs b/app/game-frontend/e2e/joinGeneral.live.playwright.config.mjs new file mode 100644 index 0000000..15d2df5 --- /dev/null +++ b/app/game-frontend/e2e/joinGeneral.live.playwright.config.mjs @@ -0,0 +1,64 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15126); +const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15127); +const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`; +const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'create_general_integration'; +const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2'; +const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? `${profileId}:${scenario}`; +const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`; +const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`; +const databaseUrl = process.env.JOIN_LIVE_DATABASE_URL ?? ''; +const redisUrl = process.env.JOIN_LIVE_REDIS_URL ?? ''; +const gameSecret = process.env.JOIN_LIVE_GAME_SECRET ?? ''; + +export default defineConfig({ + testDir: '.', + testMatch: ['joinGeneralLive.spec.ts'], + fullyParallel: false, + workers: 1, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/join-general-live'), + use: { + baseURL, + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: [ + { + command: 'node app/game-api/dist/index.js', + cwd: repositoryRoot, + url: `http://127.0.0.1:${apiPort}/healthz`, + reuseExistingServer: false, + timeout: 120_000, + env: { + DATABASE_URL: databaseUrl, + REDIS_URL: redisUrl, + GAME_TOKEN_SECRET: gameSecret, + GAME_API_HOST: '127.0.0.1', + GAME_API_PORT: String(apiPort), + PROFILE: profileId, + SCENARIO: scenario, + GAME_PROFILE_NAME: gameProfile, + }, + }, + { + command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`, + cwd: repositoryRoot, + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + }, + ], +}); diff --git a/app/game-frontend/e2e/joinGeneralLive.spec.ts b/app/game-frontend/e2e/joinGeneralLive.spec.ts new file mode 100644 index 0000000..8da2c7b --- /dev/null +++ b/app/game-frontend/e2e/joinGeneralLive.spec.ts @@ -0,0 +1,200 @@ +import { randomUUID } from 'node:crypto'; + +import { expect, test, type Page } from '@playwright/test'; +import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js'; +import { createTurnDaemonRuntime, seedScenarioToDatabase } from '../../game-engine/dist/index.js'; +import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js'; + +const databaseUrl = process.env.JOIN_LIVE_DATABASE_URL; +const gameTokenSecret = process.env.JOIN_LIVE_GAME_SECRET; +const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'create_general_integration:2'; +const userId = 'join-general-live-user'; +const hasLiveFixture = Boolean(databaseUrl && gameTokenSecret); + +const installSession = async (page: Page): Promise => { + const now = new Date(); + const gameToken = encryptGameSessionToken( + { + version: 1, + profile, + issuedAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 3_600_000).toISOString(), + sessionId: `join-live-${randomUUID()}`, + user: { + id: userId, + username: 'join-live-user', + displayName: '브라우저생성', + roles: ['user'], + legacyMemberNo: 7_700, + canUseGeneralPicture: false, + }, + sanctions: {}, + identity: { + kakaoVerified: true, + canCreateGeneral: true, + requiresKakaoVerification: false, + graceEndsAt: null, + }, + }, + gameTokenSecret! + ); + await page.addInitScript( + ({ token, gameProfile }) => { + window.localStorage.setItem('sammo-game-token', token); + window.localStorage.setItem('sammo-game-profile', gameProfile); + }, + { token: gameToken, gameProfile: profile } + ); +}; + +test.describe('generic general creation through live PostgreSQL, Redis, API, and Chromium', () => { + test.skip(!hasLiveFixture, 'live join token and database are required'); + + let db: ReturnType['prisma']; + let closeDb: (() => Promise) | undefined; + let runtime: Awaited> | undefined; + let daemonLoop: Promise | undefined; + + test.beforeAll(async () => { + const schema = new URL(databaseUrl!).searchParams.get('schema'); + if (schema !== 'create_general_integration') { + throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`); + } + await seedScenarioToDatabase({ + scenarioId: 2, + databaseUrl: databaseUrl!, + now: new Date('2099-07-30T12:00:00.000Z'), + installOptions: { + turnTermMinutes: 5, + npcMode: 0, + showImgLevel: 3, + serverId: profile, + season: 1, + }, + }); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.inputEvent.deleteMany(); + runtime = await createTurnDaemonRuntime({ + profile, + databaseUrl: databaseUrl!, + enableDatabaseFlush: true, + enableLeaseHeartbeat: false, + leaseOwnerId: 'join-general-live-daemon', + }); + daemonLoop = runtime.lifecycle.start(); + }); + + test.afterAll(async () => { + if (runtime) { + await runtime.lifecycle.stop('join general live complete'); + await daemonLoop; + await runtime.close(); + } + await closeDb?.(); + }); + + test('keeps the request id across an accepted timeout and creates exactly once', async ({ page }, testInfo) => { + const requestIds: string[] = []; + let injectTimeout = true; + await installSession(page); + await page.route('**/trpc/join.createGeneral?batch=1', async (route) => { + const findClientRequestId = (value: unknown): string | undefined => { + if (!value || typeof value !== 'object') return undefined; + if ('clientRequestId' in value && typeof value.clientRequestId === 'string') { + return value.clientRequestId; + } + for (const nested of Object.values(value)) { + const found = findClientRequestId(nested); + if (found) return found; + } + return undefined; + }; + const clientRequestId = findClientRequestId(route.request().postDataJSON()); + expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/); + requestIds.push(clientRequestId!); + if (!injectTimeout) { + await route.continue(); + return; + } + injectTimeout = false; + const accepted = await route.fetch(); + expect(accepted.ok()).toBe(true); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([ + { + error: { + message: + '장수 생성 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.', + code: -32008, + data: { + code: 'TIMEOUT', + httpStatus: 408, + path: 'join.createGeneral', + }, + }, + }, + ]), + }); + }); + + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('join'); + await expect(page.getByRole('heading', { name: '장수 생성/빙의' })).toBeVisible(); + const createButton = page.locator('.form-actions').getByRole('button', { + name: '장수 생성', + exact: true, + }); + await expect(createButton).toBeEnabled(); + await expect(page.getByText('은둔', { exact: true })).toHaveCount(0); + + const geometry = await page.locator('.join-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + width: rect.width, + minHeight: style.minHeight, + padding: style.padding, + }; + }); + expect(geometry).toEqual({ + width: 1200, + minHeight: '900px', + padding: '24px', + }); + await createButton.focus(); + await expect(createButton).toBeFocused(); + await page.screenshot({ + path: testInfo.outputPath('join-general-focused.png'), + fullPage: true, + }); + + await createButton.click(); + await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.'); + await expect.poll(() => db.general.count({ where: { userId } })).toBe(1); + const storedPending = await page.evaluate(() => + window.sessionStorage.getItem('sammo-join-create-pending-action') + ); + expect(storedPending).toContain(requestIds[0]); + + await createButton.click(); + await expect(page).toHaveURL(/\/hwe\/$/); + expect(requestIds).toHaveLength(2); + expect(requestIds[1]).toBe(requestIds[0]); + await expect.poll(() => db.general.count({ where: { userId } })).toBe(1); + const created = await db.general.findFirstOrThrow({ where: { userId } }); + expect(created).toMatchObject({ + name: '브라우저생성', + picture: 'default.jpg', + }); + const event = await db.inputEvent.findFirstOrThrow({ + where: { actorUserId: userId, eventType: 'joinCreateGeneral' }, + }); + expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 }); + expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-join-create-pending-action'))).toBeNull(); + }); +}); diff --git a/app/game-frontend/e2e/playwright.live.tsconfig.json b/app/game-frontend/e2e/playwright.live.tsconfig.json new file mode 100644 index 0000000..9af68ba --- /dev/null +++ b/app/game-frontend/e2e/playwright.live.tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowJs": true, + "esModuleInterop": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["node", "@playwright/test"] + }, + "include": ["./joinGeneralLive.spec.ts", "./joinGeneral.live.playwright.config.mjs"] +} diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index c0e9b33..c7ec276 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -14,6 +14,7 @@ "test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs", "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "node -e \"console.log('test not configured')\"", diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue index 44c7f5f..b7d58ae 100644 --- a/app/game-frontend/src/views/JoinView.vue +++ b/app/game-frontend/src/views/JoinView.vue @@ -10,7 +10,14 @@ import { cityLevelMap, regionMap } from '../utils/nationFormat'; type JoinConfig = Awaited>; type JoinInput = Parameters[0]; type PossessCandidate = Awaited>[0]; -type JoinForm = Omit & { inheritBonusStat: [number, number, number] }; +type JoinForm = Omit & { + inheritBonusStat: [number, number, number]; +}; +type PendingJoinAction = { + ownerUserId: string; + input: JoinForm; + clientRequestId: string; +}; const router = useRouter(); const session = useSessionStore(); @@ -21,6 +28,7 @@ const submitting = ref(false); const joinConfig = ref(null); const activeTab = ref<'create' | 'possess'>('create'); +const pendingJoinStorageKey = 'sammo-join-create-pending-action'; const form = ref({ name: '', @@ -32,6 +40,55 @@ const form = ref({ inheritBonusStat: [0, 0, 0], }); +const readPendingJoin = (): PendingJoinAction | null => { + try { + const raw = window.sessionStorage.getItem(pendingJoinStorageKey); + if (!raw) return null; + const value = JSON.parse(raw) as Partial; + if ( + !value.input || + typeof value.input !== 'object' || + typeof value.ownerUserId !== 'string' || + typeof value.clientRequestId !== 'string' + ) { + return null; + } + return value as PendingJoinAction; + } catch { + return null; + } +}; + +const cloneJoinInput = (): JoinForm => JSON.parse(JSON.stringify(form.value)) as JoinForm; + +const getPendingJoin = (): PendingJoinAction => { + const input = cloneJoinInput(); + const current = readPendingJoin(); + const ownerUserId = joinConfig.value?.user.id ?? ''; + if (current && current.ownerUserId === ownerUserId && JSON.stringify(current.input) === JSON.stringify(input)) { + return current; + } + const pending: PendingJoinAction = { + ownerUserId, + input, + clientRequestId: crypto.randomUUID(), + }; + window.sessionStorage.setItem(pendingJoinStorageKey, JSON.stringify(pending)); + return pending; +}; + +const clearPendingJoin = (pending: PendingJoinAction): void => { + if (readPendingJoin()?.clientRequestId === pending.clientRequestId) { + window.sessionStorage.removeItem(pendingJoinStorageKey); + } +}; + +const isIndeterminateTimeout = (value: unknown): boolean => { + if (!value || typeof value !== 'object' || !('data' in value)) return false; + const data = value.data; + return Boolean(data && typeof data === 'object' && 'code' in data && data.code === 'TIMEOUT'); +}; + const npcCandidates = ref([]); const npcLoading = ref(false); const npcError = ref(null); @@ -199,8 +256,13 @@ const loadConfig = async () => { return; } joinConfig.value = config; - form.value.name = config.user.displayName || ''; - applyBalancedStats(); + const pending = readPendingJoin(); + if (pending?.ownerUserId === config.user.id) { + form.value = pending.input; + } else { + form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위'; + applyBalancedStats(); + } } catch (err) { error.value = err instanceof Error ? err.message : 'join_config_failed'; } finally { @@ -234,13 +296,21 @@ const submitJoin = async () => { } submitting.value = true; error.value = null; + const pending = getPendingJoin(); try { - await trpc.join.createGeneral.mutate(form.value); + await trpc.join.createGeneral.mutate({ + ...pending.input, + clientRequestId: pending.clientRequestId, + }); + clearPendingJoin(pending); await session.refreshGeneralStatus(); if (session.hasGeneral) { await router.push({ name: 'home' }); } } catch (err) { + if (!isIndeterminateTimeout(err)) { + clearPendingJoin(pending); + } error.value = err instanceof Error ? err.message : 'join_failed'; } finally { submitting.value = false; @@ -315,7 +385,13 @@ onMounted(() => {