Files
core2026/app/game-engine/src/turn/npcPossessionService.ts
T

551 lines
19 KiB
TypeScript

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<Record<string, never>>;
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<typeof zNpcPossessionCandidate>;
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<void> => {
// 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<string, NpcPossessionCandidate> => {
const parsed = zNpcPossessionPickResult.safeParse(value);
if (!parsed.success) {
return fail('INTERNAL_SERVER_ERROR', 'NPC 빙의 후보 정보가 올바르지 않습니다.');
}
return parsed.data;
};
const toReservation = (
token: Pick<NpcSelectionTokenRow, 'validUntil' | 'pickMoreFrom' | 'pickResult' | 'nonce'>,
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<z.infer<typeof zTraitSnapshot>> => {
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<NpcPossessionCandidate> => {
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<string, NpcPossessionCandidate>,
rng: RandUtil,
onDraw?: (selectedId: string) => void
): Record<string, NpcPossessionCandidate> => {
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<NpcPossessionReservation> => {
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<string, NpcPossessionCandidate> = {};
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<string, unknown>;
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(`<Y>${picked.name}</>의 육체에 <Y>${options.ownerDisplayName}</>${josaYi} 빙의되다.`);
logger.pushGlobalActionLog(
`<Y>${picked.name}</>의 육체에 <Y>${options.ownerDisplayName}</>${josaYi} <S>빙의</>됩니다!`
);
for (const log of logger.flush()) {
world.pushLog(log);
}
return { ok: true, generalId };
};