import { z } from 'zod'; import { asNumber, asRecord, GAME_TICKS_PER_TURN, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { acquireGameSchemaAdvisoryXactLock, GamePrisma } from '@sammo-ts/infra'; import { EventDomesticTraitLoader, isEventDomesticTraitKey, isPersonalityTraitKey, isWarTraitKey, LogCategory, LogScope, PERSONALITY_TRAIT_KEYS, CENTENNIAL_ALL_STAR_AUX_KEY, CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT, CENTENNIAL_ALL_STAR_POOL, applyCentennialAllStarTarget, calculateCentennialUserCurrentTargetStats, calculateCentennialUserInitialStats, buildScenarioGeneralPoolClaimMeta, initialCentennialAllStarAux, parseScenarioGeneralPoolCandidate, prepareCentennialLegacyUserReselection, simpleSerialize, WarTraitLoader, } from '@sammo-ts/logic'; import type { CentennialAllStarEnvironment, CentennialAllStarRules, CentennialAllStarTarget } from '@sammo-ts/logic'; import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import { buildPrestartDeleteAfter } from './prestartDeletion.js'; import type { TurnGeneral, TurnGeneralPoolEntry } from './types.js'; type WorldStateRow = GamePrismaTypes.WorldStateGetPayload>; export type SelectPoolErrorCode = 'BAD_REQUEST' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR'; export class SelectPoolError extends Error { constructor( readonly code: SelectPoolErrorCode, message: string ) { super(message); this.name = 'SelectPoolError'; } } const LEGACY_SELECTION_POOL = 'SPoolUnderU30'; const SUPPORTED_POOLS = new Set([LEGACY_SELECTION_POOL, CENTENNIAL_ALL_STAR_POOL]); const RESERVATION_COUNT = 14; const RESERVATION_TURN_MULTIPLIER = 2; const RESELECTION_TURN_MULTIPLIER = 12; const DEFAULT_MAX_GENERAL = 500; const DEFAULT_CREW_TYPE_ID = 1100; const MAX_GENERAL_TURNS = 30; const DEFAULT_TURN_ACTION = '휴식'; export const resolveSelectionPoolUserIcon = (options: { showImgLevel: number; ownerPicture?: string; ownerImageServer?: number; }): { picture: string; imageServer: number } => { const useOwnerPicture = options.showImgLevel >= 1 && typeof options.ownerPicture === 'string' && options.ownerPicture !== 'default.jpg'; return useOwnerPicture ? { picture: options.ownerPicture!, imageServer: options.ownerImageServer ?? 1 } : { picture: 'default.jpg', imageServer: 0 }; }; const zCandidateInfo = z.object({ uniqueName: z.string().min(1), generalName: z.string().min(1), leadership: z.number().int(), strength: z.number().int(), intel: z.number().int(), specialDomestic: z.string().min(1).nullable(), specialWar: z.string().min(1).optional(), ego: z.string().min(1).optional(), experience: z.number().int().optional(), dedication: z.number().int().optional(), dex: z.tuple([z.number(), z.number(), z.number(), z.number(), z.number()]), imgsvr: z.union([z.literal(0), z.literal(1)]), picture: z.string(), }); export type SelectPoolCandidateInfo = z.infer; interface SelectPoolRow { id: number; uniqueName: string; ownerUserId: string | null; generalId: number | null; reservedUntil: Date | null; reservedUntilTick: bigint | null; info: unknown; } export interface SelectPoolCandidateDto { uniqueName: string; generalName: string; leadership: number; strength: number; intel: number; specialDomestic: string | null; specialDomesticName: string | null; specialDomesticInfo: string; specialWar: string | null; specialWarName: string | null; specialWarInfo: string; ego: string | null; dex: [number, number, number, number, number]; imageServer: 0 | 1; picture: string; } export interface SelectPoolReservationDto { poolName: string; hasGeneral: boolean; validUntil: string; candidates: SelectPoolCandidateDto[]; } const fail = (code: SelectPoolErrorCode, message: string): never => { throw new SelectPoolError(code, message); }; const resolvePoolName = (worldState: WorldStateRow): string | null => { const config = asRecord(worldState.config); const map = asRecord(config.map); return typeof map.targetGeneralPool === 'string' ? map.targetGeneralPool : null; }; const resolvePoolAllowOptions = (worldState: WorldStateRow): string[] => { const map = asRecord(asRecord(worldState.config).map); return Array.isArray(map.generalPoolAllowOption) ? map.generalPoolAllowOption.filter((value): value is string => typeof value === 'string') : []; }; const resolveTurnTermMinutes = (worldState: WorldStateRow): number => { const config = asRecord(worldState.config); const configured = asNumber(config.turnTermMinutes, Math.round(worldState.tickSeconds / 60)); return Math.max(1, Math.abs(Math.trunc(configured))); }; export const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => { const config = asRecord(worldState.config); const poolName = resolvePoolName(worldState); return asNumber(config.npcMode, 0) === 2 && poolName !== null && SUPPORTED_POOLS.has(poolName); }; export const resolveSelectionMaxGeneral = (worldState: WorldStateRow): number => { const config = asRecord(worldState.config); const configConst = asRecord(config.const); return Math.max( 0, Math.floor( asNumber(config.maxGeneral ?? configConst.defaultMaxGeneral ?? configConst.maxGeneral, DEFAULT_MAX_GENERAL) ) ); }; const requirePoolWorld = (worldState: WorldStateRow): void => { if (!isSelectionPoolWorld(worldState)) { fail('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다'); } }; const parseCandidate = (row: Pick): SelectPoolCandidateInfo => { const info = zCandidateInfo.safeParse(row.info); if (!info.success || !info.data) { throw new SelectPoolError( 'INTERNAL_SERVER_ERROR', `장수 선택 후보 정보가 올바르지 않습니다: ${row.uniqueName}` ); } const candidate = info.data; if (candidate.uniqueName !== row.uniqueName) { throw new SelectPoolError( 'INTERNAL_SERVER_ERROR', `장수 선택 후보 정보가 올바르지 않습니다: ${row.uniqueName}` ); } return candidate; }; export const calculateSelectionCandidateWeight = ( poolName: string, candidate: SelectPoolCandidateInfo, ownerIsUser: boolean ): number => { const dexWeight = candidate.dex.reduce((sum, value) => sum + value, 0); if (poolName !== CENTENNIAL_ALL_STAR_POOL) { return dexWeight; } const eligibleDexWeight = Math.max(100_000, dexWeight); if (!ownerIsUser) { return eligibleDexWeight; } const statTotal = candidate.leadership + candidate.strength + candidate.intel; const normalizedStat = Math.min(1, Math.max(0, (statTotal - 160) / 30)); return eligibleDexWeight * (1 + 0.5 * normalizedStat); }; const resolveCentennialEnvironment = (worldState: WorldStateRow): CentennialAllStarEnvironment => { const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta); return { startYear: Math.trunc(asNumber(scenarioMeta.startYear, worldState.currentYear)), year: worldState.currentYear, month: worldState.currentMonth, }; }; const resolveCentennialRules = (worldState: WorldStateRow): CentennialAllStarRules => { const config = asRecord(worldState.config); const stat = asRecord(config.stat); const configConst = asRecord(config.const); const defaultSpecialDomestic = configConst.defaultSpecialDomestic; return { defaultStatMin: asNumber(stat.min, 15), defaultStatMax: asNumber(stat.max, 80), defaultStatTotal: asNumber(stat.total, 165), maxStatLevel: asNumber(configConst.maxLevel, 255), defaultSpecialDomestic: typeof defaultSpecialDomestic === 'string' ? defaultSpecialDomestic : 'None', dexLimit: asNumber(configConst.dexLimit, CENTENNIAL_ALL_STAR_DEFAULT_DEX_LIMIT), }; }; const asCentennialTarget = (candidate: SelectPoolCandidateInfo): CentennialAllStarTarget => ({ ...candidate, specialDomestic: candidate.specialDomestic, }); const eventDomesticTraitLoader = new EventDomesticTraitLoader(); const warTraitLoader = new WarTraitLoader(); const toCandidateDto = async (candidate: SelectPoolCandidateInfo): Promise => { const trait = candidate.specialDomestic && isEventDomesticTraitKey(candidate.specialDomestic) ? await eventDomesticTraitLoader.load(candidate.specialDomestic) : null; const warTrait = candidate.specialWar && isWarTraitKey(candidate.specialWar) ? await warTraitLoader.load(candidate.specialWar) : null; return { uniqueName: candidate.uniqueName, generalName: candidate.generalName, leadership: candidate.leadership, strength: candidate.strength, intel: candidate.intel, specialDomestic: candidate.specialDomestic, specialDomesticName: trait?.name ?? candidate.specialDomestic?.replace(/^che_event_/, '') ?? null, specialDomesticInfo: trait?.info ?? '', specialWar: candidate.specialWar ?? null, specialWarName: warTrait?.name ?? candidate.specialWar?.replace(/^che_(?:event_)?/u, '') ?? null, specialWarInfo: warTrait?.info ?? '', ego: candidate.ego ?? null, dex: candidate.dex, imageServer: candidate.imgsvr, picture: candidate.picture, }; }; const toReservationDto = ( rows: Array>, hasGeneral: boolean, worldState: WorldStateRow, world: InMemoryTurnWorld ): Promise => { const first = rows[0]; if (!first || first.reservedUntilTick === null) { throw new SelectPoolError('INTERNAL_SERVER_ERROR', '장수 선택 후보의 유효기간이 없습니다.'); } const expiresAt = world.gameTickToDate(toSafeReservationTick(first.reservedUntilTick, first.uniqueName)); const poolName = resolvePoolName(worldState); if (!poolName || !SUPPORTED_POOLS.has(poolName)) { throw new SelectPoolError('PRECONDITION_FAILED', '선택 가능한 서버가 아닙니다'); } const centennialEnvironment = resolveCentennialEnvironment(worldState); const centennialRules = resolveCentennialRules(worldState); const sorted = rows .map((row) => { const raw = parseCandidate(row); if (poolName !== CENTENNIAL_ALL_STAR_POOL) { return { id: row.id, info: raw }; } const target = asCentennialTarget(raw); const display = hasGeneral ? calculateCentennialUserCurrentTargetStats(target, centennialEnvironment, centennialRules) : calculateCentennialUserInitialStats(target, centennialRules); return { id: row.id, info: { ...raw, leadership: display.leadership, strength: display.strength, intel: display.intel, }, }; }) .sort( (left, right) => left.info.dex.reduce((sum, value) => sum + value, 0) - right.info.dex.reduce((sum, value) => sum + value, 0) || left.id - right.id ); return Promise.all(sorted.map((entry) => toCandidateDto(entry.info))).then((candidates) => ({ poolName, hasGeneral, validUntil: expiresAt.toISOString(), candidates, })); }; export const buildSelectPoolSeed = ( hiddenSeed: string | number, ownerIdentity: string | number, nowTick: number ): string => simpleSerialize(hiddenSeed, 'selectPool', ownerIdentity, nowTick); export const claimWeightedSelectionCandidates = async (options: { weighted: [T, number][]; rng: RandUtil; count: number; claim(candidate: T): Promise; onDraw?(candidate: T): void; maxAttempts?: number; }): Promise => { const claimed: T[] = []; const claimedIds = new Set(); const maxAttempts = options.maxAttempts ?? Math.max(options.weighted.length * 8, 1000); let attempts = 0; while (claimed.length < options.count && attempts < maxAttempts) { attempts += 1; const candidate = options.rng.choiceUsingWeightPair(options.weighted); options.onDraw?.(candidate); if (claimedIds.has(candidate.id) || !(await options.claim(candidate))) { continue; } claimedIds.add(candidate.id); claimed.push(candidate); } return claimed; }; const readNextChangeAt = (generalMeta: unknown): Date | null => { const meta = asRecord(generalMeta); const raw = meta.next_change ?? meta.nextChangeAt; if (typeof raw !== 'string') { return null; } const parsed = new Date(raw); return Number.isNaN(parsed.getTime()) ? null : parsed; }; const readNextChangeTick = (generalMeta: unknown): number | null => { const raw = asRecord(generalMeta).next_change_tick; const parsed = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() ? Number(raw) : Number.NaN; return Number.isSafeInteger(parsed) ? parsed : null; }; const assertReselectionCooldown = (generalMeta: unknown, processingGameTick: number): void => { const projection = readNextChangeAt(generalMeta); const deadline = readNextChangeTick(generalMeta); if (projection && deadline === null) { fail('INTERNAL_SERVER_ERROR', '장수 재선택 cooldown의 GAME_TIME authority가 없습니다.'); } if (deadline !== null && deadline > processingGameTick) { fail('PRECONDITION_FAILED', '아직 다시 고를 수 없습니다'); } }; const getWorldHiddenSeed = (worldState: WorldStateRow): string | number => { const meta = asRecord(worldState.meta); const value = meta.hiddenSeed ?? meta.seed; return typeof value === 'string' || typeof value === 'number' ? value : fail('INTERNAL_SERVER_ERROR', '장수 선택 비밀 seed가 설정되지 않았습니다.'); }; const toSafeReservationTick = (value: bigint | number, uniqueName: string): number => { const tick = Number(value); if (!Number.isSafeInteger(tick)) { fail('INTERNAL_SERVER_ERROR', `장수 선택 후보 ${uniqueName}의 예약 tick이 안전한 정수 범위를 벗어났습니다.`); } return tick; }; const isReservationActive = (row: SelectPoolRow, nowTick: number): boolean => row.reservedUntilTick !== null && toSafeReservationTick(row.reservedUntilTick, row.uniqueName) >= nowTick; const lockSelectionUser = async (db: DatabaseClient, userId: string): Promise => { await acquireGameSchemaAdvisoryXactLock(db, `select-pool:user:${userId}`); }; const requireSelectionToken = async ( db: DatabaseClient, userId: string, uniqueName: string, nowTick: number ): Promise => { const token = await db.selectPoolEntry.findFirst({ where: { ownerUserId: userId, uniqueName, generalId: null, reservedUntilTick: { gte: BigInt(nowTick) }, }, }); if (!token) { fail('PRECONDITION_FAILED', '유효한 장수 목록이 없습니다.'); } return token as SelectPoolRow; }; const mapSelectionPoolRow = (row: SelectPoolRow): TurnGeneralPoolEntry => ({ id: row.id, uniqueName: row.uniqueName, ownerUserId: row.ownerUserId, generalId: row.generalId, reservedUntil: row.reservedUntil ? new Date(row.reservedUntil.getTime()) : null, reservedUntilTick: row.reservedUntilTick === null ? null : toSafeReservationTick(row.reservedUntilTick, row.uniqueName), candidate: parseScenarioGeneralPoolCandidate({ id: row.id, uniqueName: row.uniqueName, info: row.info }), }); const synchronizeSelectionPoolWorld = async ( db: DatabaseClient, world: InMemoryTurnWorld ): Promise => { const rows = (await db.selectPoolEntry.findMany({ orderBy: { id: 'asc' } })) as SelectPoolRow[]; world.replaceGeneralPoolEntries(rows.map(mapSelectionPoolRow)); return rows; }; export const reserveSelectionPool = async (options: { db: DatabaseClient; world: InMemoryTurnWorld; worldState: WorldStateRow; userId: string; now?: Date; processingGameTick: number; seedOwnerIdentity?: string | number; }): Promise => { const { db, world, worldState, userId } = options; requirePoolWorld(worldState); const now = options.now ?? new Date(); const processingGameTick = options.processingGameTick; if (!Number.isSafeInteger(processingGameTick)) { fail('INTERNAL_SERVER_ERROR', '장수 선택 처리 tick이 안전한 정수 범위를 벗어났습니다.'); } await lockSelectionUser(db, userId); await lockSelectionMutationTables(db); const general = await db.general.findFirst({ where: { userId }, select: { id: true, meta: true }, }); if (general) assertReselectionCooldown(general.meta, processingGameTick); let currentRows = await synchronizeSelectionPoolWorld(db, world); const existing = currentRows.filter( (row) => row.ownerUserId === userId && row.generalId === null && isReservationActive(row, processingGameTick) ); if (existing.length > 0) { return toReservationDto(existing, Boolean(general), worldState, world); } await db.selectPoolEntry.updateMany({ where: { generalId: null, OR: [ { reservedUntilTick: { lt: BigInt(processingGameTick) } }, { reservedUntilTick: null, reservedUntil: { not: null } }, ], }, data: { ownerUserId: null, reservedUntil: null, reservedUntilTick: null, }, }); currentRows = await synchronizeSelectionPoolWorld(db, world); const availableIds = new Set( world.listGeneralPoolCandidates(now, processingGameTick)?.map((candidate) => candidate.poolEntryId) ?? [] ); const available = currentRows.filter( (row) => availableIds.has(row.id) && row.ownerUserId === null && row.reservedUntil === null && row.reservedUntilTick === null && row.generalId === null ); if (available.length < RESERVATION_COUNT) { fail('PRECONDITION_FAILED', 'pool 부족'); } const rng = new RandUtil( new LiteHashDRBG( buildSelectPoolSeed(getWorldHiddenSeed(worldState), options.seedOwnerIdentity ?? userId, processingGameTick) ) ); const poolName = resolvePoolName(worldState)!; const weighted = available.map( (row) => [row, calculateSelectionCandidateWeight(poolName, parseCandidate(row), true)] as [SelectPoolRow, number] ); const reservedUntilTick = processingGameTick + RESERVATION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN; if (!Number.isSafeInteger(reservedUntilTick)) { fail('INTERNAL_SERVER_ERROR', '장수 선택 예약 tick이 안전한 정수 범위를 벗어났습니다.'); } const reservedUntil = world.gameTickToDate(reservedUntilTick); const selected = await claimWeightedSelectionCandidates({ weighted, rng, count: RESERVATION_COUNT, claim: async () => true, }); const reserved = selected.map((candidate) => ({ ...candidate, ownerUserId: userId, reservedUntil, reservedUntilTick: BigInt(reservedUntilTick), })); if (reserved.length !== RESERVATION_COUNT) { fail('CONFLICT', '장수 선택 후보를 예약하지 못했습니다. 다시 시도해 주세요.'); } const reservation = await toReservationDto(reserved, Boolean(general), worldState, world); const claimed = await db.selectPoolEntry.updateMany({ where: { id: { in: selected.map((candidate) => candidate.id) }, ownerUserId: null, reservedUntil: null, reservedUntilTick: null, generalId: null, }, data: { ownerUserId: userId, reservedUntil, reservedUntilTick: BigInt(reservedUntilTick), }, }); if (claimed.count !== RESERVATION_COUNT) { throw new Error('장수 선택 후보의 DB 점유 수가 턴 데몬 선택 결과와 일치하지 않습니다.'); } await synchronizeSelectionPoolWorld(db, world); return reservation; }; const lockSelectionMutationTables = async (db: DatabaseClient): Promise => { await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`); await db.$executeRaw(GamePrisma.sql`LOCK TABLE "select_pool" IN SHARE ROW EXCLUSIVE MODE`); }; const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemoryTurnWorld): Promise => { const persistedIds = ( await db.general.findMany({ select: { id: true }, orderBy: { id: 'asc' }, }) ).map(({ id }) => id); const runtimeIds = world .listGenerals() .map(({ id }) => id) .sort((left, right) => left - right); if (persistedIds.length !== runtimeIds.length || persistedIds.some((id, index) => id !== runtimeIds[index])) { throw new Error('DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.'); } }; const clearUnusedReservations = async ( db: DatabaseClient, userId: string, nowTick: number ): Promise => { await db.selectPoolEntry.updateMany({ where: { generalId: null, OR: [ { ownerUserId: userId }, { reservedUntilTick: { lt: BigInt(nowTick) } }, { reservedUntilTick: null, reservedUntil: { not: null } }, ], }, data: { ownerUserId: null, reservedUntil: null, reservedUntilTick: null, }, }); }; const resolveSpecialityAges = (worldState: WorldStateRow, age: number): { domestic: number; war: number } => { const configConst = asRecord(asRecord(worldState.config).const); const retirementYear = asNumber(configConst.retirementYear, 80); const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta); const startYear = asNumber(scenarioMeta.startYear, worldState.currentYear); const relativeYear = Math.max(worldState.currentYear - startYear, 0); const build = (divisor: number): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age; return { domestic: build(12), war: build(6) }; }; const resolveRandomPersonality = ( worldState: WorldStateRow, ownerIdentity: string | number, uniqueName: string ): string => new RandUtil( new LiteHashDRBG( simpleSerialize(getWorldHiddenSeed(worldState), 'selectPickedGeneralPersonality', ownerIdentity, uniqueName) ) ).choice([...PERSONALITY_TRAIT_KEYS]); const resolveSelectedPersonality = ( worldState: WorldStateRow, ownerIdentity: string | number, uniqueName: string, requested: string ): string => { if (!resolvePoolAllowOptions(worldState).includes('ego')) { return 'None'; } if (requested === 'Random') { return resolveRandomPersonality(worldState, ownerIdentity, uniqueName); } if (!isPersonalityTraitKey(requested)) { fail('BAD_REQUEST', '올바르지 않은 성격입니다.'); } return requested; }; const resolvePoolRng = (worldState: WorldStateRow, ownerIdentity: string | number, uniqueName: string): RandUtil => new RandUtil( new LiteHashDRBG( simpleSerialize(getWorldHiddenSeed(worldState), 'selectPickedGeneral', ownerIdentity, uniqueName) ) ); const resolveTurnTimeBase = (worldState: WorldStateRow, now: Date): Date => { const raw = asRecord(worldState.meta).turntime; if (typeof raw === 'string') { const parsed = new Date(raw); if (!Number.isNaN(parsed.getTime())) { return parsed; } } return now; }; export const buildInitialTurnTime = ( rng: Pick, worldState: WorldStateRow, now: Date, notBefore: Date = now ): Date => { const termSeconds = resolveTurnTermMinutes(worldState) * 60; const seconds = rng.nextRangeInt(0, termSeconds - 1); const microseconds = rng.nextRangeInt(0, 999_999); const base = resolveTurnTimeBase(worldState, now); const safeBase = base.getTime() < notBefore.getTime() ? notBefore : base; return new Date(safeBase.getTime() + seconds * 1000 + microseconds / 1000); }; const appendSelectionLogs = async (options: { db: DatabaseClient; worldState: WorldStateRow; generalId: number; ownerUserId: string; generalText: string; globalText: string; }): Promise => { const serverId = asRecord(options.worldState.meta).serverId; const common = { serverId: typeof serverId === 'string' && serverId.trim() ? serverId : null, year: options.worldState.currentYear, month: options.worldState.currentMonth, nationId: null, userId: null, meta: { ownerUserId: options.ownerUserId }, }; await options.db.logEntry.createMany({ data: [ { ...common, scope: LogScope.GENERAL, category: LogCategory.HISTORY, generalId: options.generalId, text: options.generalText, }, { ...common, scope: LogScope.SYSTEM, category: LogCategory.ACTION, generalId: null, text: options.globalText, }, ], }); }; export const createGeneralFromSelectionPool = async (options: { db: DatabaseClient; world: InMemoryTurnWorld; worldState: WorldStateRow; userId: string; ownerDisplayName: string; uniqueName: string; personality: string; now?: Date; turnScheduleAt?: Date; operationalAcceptedAt: Date; processingGameTick: number; seedOwnerIdentity?: string | number; ownerPicture?: string; ownerImageServer?: number; ownerIconRevision?: string; }): Promise<{ ok: true; generalId: number }> => { const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options; requirePoolWorld(worldState); const now = options.now ?? new Date(); const nowTick = options.processingGameTick; if (!Number.isSafeInteger(nowTick)) { fail('INTERNAL_SERVER_ERROR', '장수 생성 처리 tick이 안전한 정수 범위를 벗어났습니다.'); } await lockSelectionUser(db, userId); await lockSelectionMutationTables(db); await synchronizeSelectionPoolWorld(db, world); await assertGeneralIdSnapshotMatches(db, world); if ( world.listGenerals().some((general) => general.userId === userId) || (await db.general.findFirst({ where: { userId }, select: { id: true } })) ) { fail('PRECONDITION_FAILED', '이미 장수를 생성했습니다.'); } const token = await requireSelectionToken(db, userId, uniqueName, nowTick); const info = parseCandidate(token); const poolName = resolvePoolName(worldState)!; const isCentennial = poolName === CENTENNIAL_ALL_STAR_POOL; const centennialTarget = isCentennial ? asCentennialTarget(info) : null; const centennialRules = resolveCentennialRules(worldState); const centennialInitialStats = centennialTarget ? calculateCentennialUserInitialStats(centennialTarget, centennialRules) : null; const config = asRecord(worldState.config); const configConst = asRecord(config.const); const maxGeneral = resolveSelectionMaxGeneral(worldState); const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } }); if (activeCount >= maxGeneral) { fail('PRECONDITION_FAILED', '더 이상 등록 할 수 없습니다.'); } const seedOwnerIdentity = options.seedOwnerIdentity ?? userId; const rng = resolvePoolRng(worldState, seedOwnerIdentity, uniqueName); const affinity = rng.nextRangeInt(1, 150); const allCities = await db.city.findMany({ select: { id: true, name: true, level: true, nationId: true }, orderBy: { id: 'asc' }, }); const centennialCities = allCities.filter((city) => city.level >= 5 && city.level <= 6); const neutralCentennialCities = centennialCities.filter((city) => city.nationId === 0); const cities = isCentennial ? neutralCentennialCities.length > 0 ? neutralCentennialCities : centennialCities : allCities; if (cities.length === 0) { fail( 'PRECONDITION_FAILED', isCentennial ? '장수를 생성할 소·중성이 없습니다.' : '생성 가능한 도시가 없습니다.' ); } const city = rng.choice(cities); const turnTime = buildInitialTurnTime(rng, worldState, now, options.turnScheduleAt ?? now); const age = 20; const specialityAges = resolveSpecialityAges(worldState, age); const nextChangeTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN; const nextChangeAt = world.gameTickToDate(nextChangeTick); const prestartDeleteAfter = buildPrestartDeleteAfter(options.operationalAcceptedAt, worldState.tickSeconds, config); // 후보 picture는 NPC용 preset이다. 후보가 사람 장수(npcState=0)가 되는 // 순간부터는 명시적으로 선택한 계정 전용 아이콘 또는 기본 아이콘만 허용한다. const { picture, imageServer } = resolveSelectionPoolUserIcon({ showImgLevel: asNumber(config.showImgLevel, 0), ownerPicture: options.ownerPicture, ownerImageServer: options.ownerImageServer, }); const useOwnerPicture = picture !== 'default.jpg'; const defaultSpecialWar = typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None'; const defaultSpecialDomestic = typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None'; const personality = resolveSelectedPersonality(worldState, seedOwnerIdentity, uniqueName, options.personality); // 모든 사용자 입력과 DB 선조건을 검증한 뒤에만 allocator를 변경한다. // SelectPoolError는 정상 command 결과로 commit되므로 이보다 먼저 // getNextGeneralId()를 호출하면 실패한 요청도 lastGeneralId를 소비한다. const generalId = world.getNextGeneralId(); const generalMeta: TurnGeneral['meta'] = { createdBy: 'select_pool', ownerName: ownerDisplayName, owner_name: ownerDisplayName, killturn: 5, specage: specialityAges.domestic, specage2: specialityAges.war, dex1: isCentennial ? 0 : info.dex[0], dex2: isCentennial ? 0 : info.dex[1], dex3: isCentennial ? 0 : info.dex[2], dex4: isCentennial ? 0 : info.dex[3], dex5: isCentennial ? 0 : info.dex[4], next_change: nextChangeAt.toISOString(), nextChangeAt: nextChangeAt.toISOString(), next_change_tick: nextChangeTick, prestart_delete_after: prestartDeleteAfter.toISOString(), ...(useOwnerPicture && options.ownerIconRevision ? { accountIconUpdatedAt: options.ownerIconRevision } : {}), npc_org: 0, ...buildScenarioGeneralPoolClaimMeta( parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }), now ), }; if (centennialTarget && centennialInitialStats) { const mutableMeta: Record = generalMeta; mutableMeta[CENTENNIAL_ALL_STAR_AUX_KEY] = initialCentennialAllStarAux( centennialTarget, centennialRules, centennialInitialStats ); } const general: TurnGeneral = { id: generalId, userId, name: info.generalName, nationId: 0, cityId: city.id, troopId: 0, npcState: 0, affinity, bornYear: worldState.currentYear - age, deadYear: worldState.currentYear + 60, picture, imageServer, stats: { leadership: centennialInitialStats?.leadership ?? info.leadership, strength: centennialInitialStats?.strength ?? info.strength, intelligence: centennialInitialStats?.intel ?? info.intel, }, experience: info.experience ?? age * 100, dedication: info.dedication ?? age * 100, officerLevel: 0, injury: 0, gold: 1000, rice: 1000, crew: 0, crewTypeId: DEFAULT_CREW_TYPE_ID, train: 0, atmos: 0, turnTime, age, startAge: age, role: { personality, specialDomestic: isCentennial ? defaultSpecialDomestic : info.specialDomestic, specialWar: isCentennial ? defaultSpecialWar : (info.specialWar ?? defaultSpecialWar), items: { horse: null, weapon: null, book: null, item: null, }, }, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {}, }, lastTurn: { command: DEFAULT_TURN_ACTION }, penalty: {}, refreshScoreTotal: 0, meta: generalMeta, }; if (!world.addGeneral(general)) { throw new Error(`장수 번호 ${generalId}를 할당할 수 없습니다.`); } await db.generalTurn.createMany({ data: Array.from({ length: MAX_GENERAL_TURNS }, (_, turnIdx) => ({ generalId, turnIdx, actionCode: DEFAULT_TURN_ACTION, arg: {}, })), }); await db.generalTurnRevision.create({ data: { generalId, revision: 0, }, }); const occupied = await db.selectPoolEntry.updateMany({ where: { id: token.id, ownerUserId: userId, generalId: null, reservedUntilTick: { gte: BigInt(nowTick) }, }, data: { generalId, ownerUserId: null, reservedUntil: null, reservedUntilTick: null, }, }); if (occupied.count === 0) { throw new Error('장수 등록 중 선택 후보 점유에 실패했습니다.'); } await db.generalAccessLog.upsert({ where: { generalId }, update: { userId, lastRefresh: options.operationalAcceptedAt }, create: { generalId, userId, lastRefresh: options.operationalAcceptedAt }, }); await clearUnusedReservations(db, userId, nowTick); await synchronizeSelectionPoolWorld(db, world); const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이'); const generalJosaRo = JosaUtil.pick(info.generalName, '로'); await appendSelectionLogs({ db, worldState, generalId, ownerUserId: userId, generalText: `${info.generalName}, ${city.name}에서 등장`, globalText: `${city.name}에서 ${ownerDisplayName}${ownerJosaYi} ${info.generalName}${generalJosaRo} 등장합니다.`, }); return { ok: true, generalId }; }; export const reselectGeneralFromSelectionPool = async (options: { db: DatabaseClient; world: InMemoryTurnWorld; worldState: WorldStateRow; userId: string; ownerDisplayName: string; uniqueName: string; now?: Date; processingGameTick: number; }): Promise<{ ok: true; generalId: number }> => { const { db, world, worldState, userId, ownerDisplayName, uniqueName } = options; requirePoolWorld(worldState); const now = options.now ?? new Date(); const nowTick = options.processingGameTick; if (!Number.isSafeInteger(nowTick)) { fail('INTERNAL_SERVER_ERROR', '장수 재선택 처리 tick이 안전한 정수 범위를 벗어났습니다.'); } await lockSelectionUser(db, userId); await lockSelectionMutationTables(db); await synchronizeSelectionPoolWorld(db, world); const persistedGeneral = await db.general.findFirst({ where: { userId } }); const general = world.listGenerals().find((candidate) => candidate.userId === userId); if (!persistedGeneral || !general) { throw new SelectPoolError( 'PRECONDITION_FAILED', '장수가 생성하지 않았습니다. 이미 사망하지 않았는지 확인해보세요.' ); } if (persistedGeneral.id !== general.id) { fail('INTERNAL_SERVER_ERROR', 'DB와 턴 데몬의 장수 소유 정보가 일치하지 않습니다.'); } assertReselectionCooldown(general.meta, nowTick); const token = await requireSelectionToken(db, userId, uniqueName, nowTick); const info = parseCandidate(token); const isCentennial = resolvePoolName(worldState) === CENTENNIAL_ALL_STAR_POOL; const provisionalGeneralId = -general.id; const claimed = await db.selectPoolEntry.updateMany({ where: { id: token.id, ownerUserId: userId, generalId: null, reservedUntilTick: { gte: BigInt(nowTick) }, }, data: { generalId: provisionalGeneralId, ownerUserId: null, reservedUntil: null, reservedUntilTick: null, }, }); if (claimed.count === 0) { throw new Error('장수 재선택 중 선택 후보 점유에 실패했습니다.'); } await db.selectPoolEntry.updateMany({ where: { generalId: general.id }, data: { generalId: null, ownerUserId: null, reservedUntil: null, reservedUntilTick: null }, }); const finalized = await db.selectPoolEntry.updateMany({ where: { id: token.id, generalId: provisionalGeneralId, }, data: { generalId: general.id, }, }); if (finalized.count === 0) { throw new Error('장수 재선택 중 선택 후보 확정에 실패했습니다.'); } const cooldownTick = nowTick + RESELECTION_TURN_MULTIPLIER * GAME_TICKS_PER_TURN; const cooldown = world.gameTickToDate(cooldownTick); const centennialBaseGeneral = isCentennial ? { ...general, meta: prepareCentennialLegacyUserReselection(general, resolveCentennialRules(worldState)), } : general; const centennialGrowth = isCentennial ? applyCentennialAllStarTarget( centennialBaseGeneral, asCentennialTarget(info), resolveCentennialEnvironment(worldState), resolveCentennialRules(worldState) ) : null; const updatedMeta: TurnGeneral['meta'] = { ...(centennialGrowth?.meta ?? general.meta), ownerName: ownerDisplayName, owner_name: ownerDisplayName, ...(!isCentennial ? { dex1: info.dex[0], dex2: info.dex[1], dex3: info.dex[2], dex4: info.dex[3], dex5: info.dex[4], } : {}), next_change: cooldown.toISOString(), nextChangeAt: cooldown.toISOString(), next_change_tick: cooldownTick, ...buildScenarioGeneralPoolClaimMeta( parseScenarioGeneralPoolCandidate({ id: token.id, uniqueName: token.uniqueName, info: token.info }), now ), }; const reselectionIcon = resolveSelectionPoolUserIcon({ showImgLevel: 0 }); const updated = world.updateGeneral(general.id, { name: info.generalName, stats: centennialGrowth?.stats ?? { leadership: info.leadership, strength: info.strength, intelligence: info.intel, }, role: centennialGrowth?.role ?? { ...general.role, personality: info.ego ?? general.role.personality, specialDomestic: info.specialDomestic, specialWar: info.specialWar ?? general.role.specialWar, }, // 재선택 후보의 preset은 유저 장수에 이어 붙이지 않는다. 전용 아이콘을 // 다시 고르는 UI가 없는 현재 경로는 안전한 기본 아이콘으로 되돌린다. picture: reselectionIcon.picture, imageServer: reselectionIcon.imageServer, meta: updatedMeta, }); if (!updated) { throw new Error('턴 데몬에서 장수 정보를 갱신하지 못했습니다.'); } await clearUnusedReservations(db, userId, nowTick); await synchronizeSelectionPoolWorld(db, world); const ownerJosaYi = JosaUtil.pick(ownerDisplayName, '이'); const generalJosaRo = JosaUtil.pick(info.generalName, '로'); await appendSelectionLogs({ db, worldState, generalId: general.id, ownerUserId: userId, generalText: `장수를 ${general.name}에서 ${info.generalName}${generalJosaRo} 변경`, globalText: `${ownerDisplayName}${ownerJosaYi} 장수를 ${general.name}에서 ${info.generalName}${generalJosaRo} 변경합니다.`, }); return { ok: true, generalId: general.id }; }; export const getSelectionPoolStatus = async ( db: DatabaseClient, worldState: WorldStateRow, userId: string ): Promise<{ enabled: boolean; poolName: string | null; allowOptions: string[]; hasGeneral: boolean; nextChangeAt: string | null; }> => { const poolName = resolvePoolName(worldState); const enabled = isSelectionPoolWorld(worldState); const general = await db.general.findFirst({ where: { userId }, select: { meta: true } }); return { enabled, poolName, allowOptions: resolvePoolAllowOptions(worldState), hasGeneral: Boolean(general), nextChangeAt: general ? (readNextChangeAt(general.meta)?.toISOString() ?? null) : null, }; };