import { randomInt } from 'node:crypto'; import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil, type RNG } from '@sammo-ts/common'; import { GamePrisma, type DatabaseClient, type GamePrisma as GamePrismaTypes } from '@sammo-ts/infra'; import { ActionLogger, DomesticTraitLoader, isDomesticTraitKey, isPersonalityTraitKey, isWarTraitKey, PersonalityTraitLoader, simpleSerialize, WarTraitLoader, } from '@sammo-ts/logic'; import { z } from 'zod'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import { resolveLegacyPenalty } from './joinCreateGeneralService.js'; type WorldStateRow = GamePrismaTypes.WorldStateGetPayload>; export type NpcPossessionErrorCode = 'BAD_REQUEST' | 'NOT_FOUND' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR'; export class NpcPossessionError extends Error { constructor( readonly code: NpcPossessionErrorCode, message: string ) { super(message); this.name = 'NpcPossessionError'; } } const zTraitSnapshot = z.object({ code: z.string(), name: z.string(), info: z.string(), }); const zNpcPossessionCandidate = z.object({ id: z.number().int().positive(), name: z.string(), nation: z.object({ id: z.number().int(), name: z.string(), color: z.string(), }), stats: z.object({ leadership: z.number().int(), strength: z.number().int(), intelligence: z.number().int(), }), picture: z.string().nullable(), imageServer: z.number().int(), personality: zTraitSnapshot, specialDomestic: zTraitSnapshot, specialWar: zTraitSnapshot, keepCount: z.number().int().min(0), }); const zNpcPossessionPickResult = z.record(z.string(), zNpcPossessionCandidate); export type NpcPossessionCandidate = z.infer; export interface NpcPossessionReservation { tokenNonce: number; validUntil: string; pickMoreFrom: string; pickMoreSeconds: number; candidates: NpcPossessionCandidate[]; } export interface NpcPossessionSelectionObserver { onRandomDraw?: (value: number) => void; onCandidateDraw?: (selectedId: string) => void; } interface NpcSelectionTokenRow { ownerUserId: string; validUntil: Date; pickMoreFrom: Date; pickResult: unknown; nonce: number; } const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000; const VALID_SECONDS = 90; const PICK_MORE_SECONDS = 10; const KEEP_COUNT = 3; const MAX_PICK_COUNT = 5; const FIRST_PICK_MORE_FROM = new Date('2000-01-01T01:00:00.000Z'); const DEFAULT_MAX_GENERAL = 500; const fail = (code: NpcPossessionErrorCode, message: string): never => { throw new NpcPossessionError(code, message); }; const truncateToSeconds = (value: Date): Date => new Date(Math.floor(value.getTime() / 1000) * 1000); const formatLegacySeedTime = (value: Date): string => { const pad = (part: number): string => String(part).padStart(2, '0'); const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS); return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad( koreaTime.getUTCDate() )} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(koreaTime.getUTCSeconds())}`; }; export const buildNpcSelectionTokenSeed = ( hiddenSeed: string | number, ownerIdentity: string | number, now: Date ): string => simpleSerialize(hiddenSeed, 'SelectNPCToken', ownerIdentity, formatLegacySeedTime(now)); const readHiddenSeed = (worldState: WorldStateRow): string | number => { const meta = asRecord(worldState.meta); const value = meta.hiddenSeed ?? meta.seed; if (typeof value === 'string' || typeof value === 'number') { return value; } return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 비밀 seed가 설정되지 않았습니다.'); }; const resolveTurnTermMinutes = (worldState: WorldStateRow): number => { const config = asRecord(worldState.config); return Math.max(1, Math.abs(Math.trunc(asNumber(config.turnTermMinutes, Math.round(worldState.tickSeconds / 60))))); }; const resolveMaxGeneral = (worldState: WorldStateRow): number => { const config = asRecord(worldState.config); const configConst = asRecord(config.const); return Math.max( 0, Math.floor( asNumber( config.maxGeneral ?? config.maxgeneral ?? configConst.defaultMaxGeneral ?? configConst.maxGeneral, DEFAULT_MAX_GENERAL ) ) ); }; const requireNpcPossessionWorld = (worldState: WorldStateRow): void => { const config = asRecord(worldState.config); if (asNumber(config.npcMode ?? config.npcmode, 0) !== 1) { fail('PRECONDITION_FAILED', '빙의 가능한 서버가 아닙니다'); } }; const lockNpcPossession = async (db: DatabaseClient, userId: string): Promise => { // Ref의 서로 다른 owner token 중복과 동일 owner 다중 빙의 race는 데이터 손상 // 가능성이 있어, 후보 예약과 최종 점유 모두 같은 lock 순서로 직렬화한다. await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))`); await db.$executeRaw( GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${userId}`}, 1))` ); }; const parsePickResult = (value: unknown): Record => { const parsed = zNpcPossessionPickResult.safeParse(value); if (!parsed.success) { return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 후보 정보가 올바르지 않습니다.'); } return parsed.data; }; const toReservation = ( token: Pick, now: Date ): NpcPossessionReservation => { const pickResult = parsePickResult(token.pickResult); return { tokenNonce: token.nonce, validUntil: token.validUntil.toISOString(), pickMoreFrom: token.pickMoreFrom.toISOString(), pickMoreSeconds: Math.max(0, Math.ceil((token.pickMoreFrom.getTime() - now.getTime()) / 1000)), candidates: Object.values(pickResult).sort( (left, right) => left.stats.leadership + left.stats.strength + left.stats.intelligence - (right.stats.leadership + right.stats.strength + right.stats.intelligence) || left.id - right.id ), }; }; const loadTraitSnapshot = async ( code: string, kind: 'personality' | 'domestic' | 'war' ): Promise> => { const fallback = { code, name: code === 'None' ? '-' : code, info: code === 'None' ? '없음' : '' }; if (kind === 'personality' && isPersonalityTraitKey(code)) { const trait = await new PersonalityTraitLoader().load(code); return { code, name: trait.name, info: trait.info ?? '' }; } if (kind === 'domestic' && isDomesticTraitKey(code)) { const trait = await new DomesticTraitLoader().load(code); return { code, name: trait.name, info: trait.info ?? '' }; } if (kind === 'war' && isWarTraitKey(code)) { const trait = await new WarTraitLoader().load(code); return { code, name: trait.name, info: trait.info ?? '' }; } return fallback; }; const buildCandidateSnapshot = async ( row: { id: number; name: string; nationId: number; leadership: number; strength: number; intel: number; picture: string | null; imageServer: number; personalCode: string; specialCode: string; special2Code: string; }, nation: { id: number; name: string; color: string } | undefined ): Promise => { const [personality, specialDomestic, specialWar] = await Promise.all([ loadTraitSnapshot(row.personalCode, 'personality'), loadTraitSnapshot(row.specialCode, 'domestic'), loadTraitSnapshot(row.special2Code, 'war'), ]); return { id: row.id, name: row.name, nation: nation ?? { id: 0, name: '재야', color: '#666666' }, stats: { leadership: row.leadership, strength: row.strength, intelligence: row.intel, }, picture: row.picture, imageServer: row.imageServer, personality, specialDomestic, specialWar, keepCount: KEEP_COUNT, }; }; export const chooseNpcPossessionCandidates = ( candidates: NpcPossessionCandidate[], kept: Record, rng: RandUtil, onDraw?: (selectedId: string) => void ): Record => { const picked = { ...kept }; const weights = Object.fromEntries( candidates.map((candidate) => [ String(candidate.id), Math.pow(candidate.stats.leadership + candidate.stats.strength + candidate.stats.intelligence, 1.5), ]) ); const byId = new Map(candidates.map((candidate) => [String(candidate.id), candidate])); const pickLimit = Math.min(candidates.length, MAX_PICK_COUNT); while (Object.keys(picked).length < pickLimit) { const selectedId = String(rng.choiceUsingWeight(weights)); onDraw?.(selectedId); if (!Object.hasOwn(picked, selectedId)) { const candidate = byId.get(selectedId); if (!candidate) { throw new Error(`NPC 빙의 후보 ${selectedId}를 찾을 수 없습니다.`); } picked[selectedId] = candidate; } } return picked; }; class ObservedRandUtil extends RandUtil { constructor( rng: RNG, private readonly onRandomDraw: (value: number) => void ) { super(rng); } public override nextFloat1(): number { const value = super.nextFloat1(); this.onRandomDraw(value); return value; } } export const reserveNpcPossessionCandidates = async (options: { db: DatabaseClient; worldState: WorldStateRow; userId: string; ownerIdentity: string | number; refresh?: boolean; keepIds?: number[]; now?: Date; selectionObserver?: NpcPossessionSelectionObserver; }): Promise => { const { db, worldState, userId } = options; requireNpcPossessionWorld(worldState); const now = truncateToSeconds(options.now ?? new Date()); await lockNpcPossession(db, userId); if (await db.general.findFirst({ where: { userId }, select: { id: true } })) { fail('PRECONDITION_FAILED', '이미 장수가 생성되었습니다'); } const inFlightPossession = await db.inputEvent.findFirst({ where: { target: 'ENGINE', eventType: 'npcPossessGeneral', actorUserId: userId, status: { in: ['PENDING', 'PROCESSING'] }, }, select: { requestId: true }, }); let existing = (await db.npcSelectionToken.findUnique({ where: { ownerUserId: userId }, })) as NpcSelectionTokenRow | null; if (inFlightPossession) { const inFlightToken = existing; if (!inFlightToken) { return fail('CONFLICT', '처리 중인 NPC 빙의 요청이 있습니다.'); } if (options.refresh) { fail('CONFLICT', 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.'); } return toReservation(inFlightToken, now); } if (existing && existing.validUntil.getTime() < now.getTime()) { await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId, nonce: existing.nonce, validUntil: { lt: now }, }, }); existing = null; } const kept: Record = {}; if (existing && options.refresh) { if (now.getTime() < existing.pickMoreFrom.getTime()) { fail('PRECONDITION_FAILED', '아직 다시 뽑을 수 없습니다'); } const oldPick = parsePickResult(existing.pickResult); for (const keepId of options.keepIds ?? []) { const key = String(keepId); const candidate = oldPick[key]; if (candidate && candidate.keepCount > 0) { kept[key] = { ...candidate, keepCount: candidate.keepCount - 1 }; } } // Ref는 모든 후보를 보관하면 refresh를 취소하며 차감도 저장하지 않는다. if (Object.keys(kept).length === Object.keys(oldPick).length) { return toReservation(existing, now); } } else if (existing) { return toReservation(existing, now); } const reservedRows = await db.npcSelectionToken.findMany({ where: { ownerUserId: { not: userId }, validUntil: { gte: now }, }, select: { pickResult: true }, }); const reservedIds = new Set( reservedRows.flatMap((row) => Object.keys(parsePickResult(row.pickResult)).map((id) => Number(id))) ); const generalRows = await db.general.findMany({ where: { userId: null, npcState: 2, ...(reservedIds.size > 0 ? { id: { notIn: [...reservedIds] } } : {}), }, orderBy: { id: 'asc' }, select: { id: true, name: true, nationId: true, leadership: true, strength: true, intel: true, picture: true, imageServer: true, personalCode: true, specialCode: true, special2Code: true, }, }); const nationRows = await db.nation.findMany({ select: { id: true, name: true, color: true }, }); const nations = new Map(nationRows.map((nation) => [nation.id, nation])); const candidates = await Promise.all( generalRows.map((row) => buildCandidateSnapshot(row, nations.get(row.nationId))) ); const selectionRng = new LiteHashDRBG( buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, now) ); const rng = options.selectionObserver?.onRandomDraw ? new ObservedRandUtil(selectionRng, options.selectionObserver.onRandomDraw) : new RandUtil(selectionRng); const pickResult = chooseNpcPossessionCandidates(candidates, kept, rng, options.selectionObserver?.onCandidateDraw); const turnTermMinutes = resolveTurnTermMinutes(worldState); const validUntil = new Date(now.getTime() + Math.max(VALID_SECONDS, turnTermMinutes * 40) * 1000); const refreshedPickMoreFrom = new Date( now.getTime() + Math.max(PICK_MORE_SECONDS, Math.round(Math.pow(turnTermMinutes, 0.672) * 8)) * 1000 ); const nonce = randomInt(0, 0x10000000); if (existing) { const updated = await db.npcSelectionToken.updateMany({ where: { ownerUserId: userId, nonce: existing.nonce }, data: { validUntil, pickMoreFrom: refreshedPickMoreFrom, pickResult: pickResult as GamePrisma.InputJsonValue, nonce, }, }); if (updated.count === 0) { fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요'); } return toReservation({ validUntil, pickMoreFrom: refreshedPickMoreFrom, pickResult, nonce }, now); } try { await db.npcSelectionToken.create({ data: { ownerUserId: userId, validUntil, pickMoreFrom: FIRST_PICK_MORE_FROM, pickResult: pickResult as GamePrisma.InputJsonValue, nonce, }, }); } catch (error) { if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002') { fail('CONFLICT', '중복 요청, 다시 랜덤 토큰을 확인해주세요'); } throw error; } return toReservation({ validUntil, pickMoreFrom: FIRST_PICK_MORE_FROM, pickResult, nonce }, now); }; export const possessNpcGeneral = async (options: { db: DatabaseClient; world: InMemoryTurnWorld; worldState: WorldStateRow; userId: string; ownerDisplayName: string; profileId: string; ownerLegacyPenalty?: Record; generalId: number; tokenNonce: number; acceptedAt: Date; }): Promise<{ ok: true; generalId: number }> => { const { db, world, worldState, userId, generalId, acceptedAt } = options; const tokenAcceptedAt = truncateToSeconds(acceptedAt); requireNpcPossessionWorld(worldState); await lockNpcPossession(db, userId); await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`); if ( world.listGenerals().some((general) => general.userId === userId) || (await db.general.findFirst({ where: { userId }, select: { id: true } })) ) { fail('PRECONDITION_FAILED', '이미 장수가 생성되어 있습니다.'); } const token = (await db.npcSelectionToken.findFirst({ where: { ownerUserId: userId, nonce: options.tokenNonce, validUntil: { gte: tokenAcceptedAt }, }, })) as NpcSelectionTokenRow | null; if (!token) { return fail('PRECONDITION_FAILED', '유효한 장수 목록이 없습니다.'); } const pickResult = parsePickResult(token.pickResult); const picked = pickResult[String(generalId)]; if (!picked) { fail('PRECONDITION_FAILED', '선택한 장수가 목록에 없습니다.'); } const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } }); if (activeCount >= resolveMaxGeneral(worldState)) { fail('PRECONDITION_FAILED', '더 이상 등록 할 수 없습니다.'); } const row = await db.general.findUnique({ where: { id: generalId }, select: { userId: true, npcState: true }, }); const general = world.getGeneralById(generalId); if (!row || row.userId !== null || row.npcState !== 2 || !general || general.userId || general.npcState !== 2) { return fail('NOT_FOUND', '장수 등록에 실패했습니다.'); } const penalty = resolveLegacyPenalty(options.ownerLegacyPenalty, options.profileId, acceptedAt); world.updateGeneral(generalId, { userId, npcState: 1, penalty, meta: { ...general.meta, npc_org: 2, ownerName: options.ownerDisplayName, owner_name: options.ownerDisplayName, pickYearMonth: worldState.currentYear * 12 + worldState.currentMonth - 1, killturn: 6, defence_train: 80, permission: 'normal', }, }); await db.generalAccessLog.upsert({ where: { generalId }, update: { userId, lastRefresh: acceptedAt, refresh: 0, refreshTotal: 0, refreshScore: 0, refreshScoreTotal: 0, }, create: { generalId, userId, lastRefresh: acceptedAt, }, }); await db.npcSelectionToken.deleteMany({ where: { ownerUserId: userId } }); const logger = new ActionLogger({ generalId }); const josaYi = JosaUtil.pick(options.ownerDisplayName, '이'); logger.pushGeneralHistoryLog(`${picked.name}의 육체에 ${options.ownerDisplayName}${josaYi} 빙의되다.`); logger.pushGlobalActionLog( `${picked.name}의 육체에 ${options.ownerDisplayName}${josaYi} 빙의됩니다!` ); for (const log of logger.flush()) { world.pushLog(log); } return { ok: true, generalId }; };