feat: port NPC possession through turn daemon
This commit is contained in:
@@ -24,6 +24,7 @@ export * from './turn/inMemoryStateStore.js';
|
||||
export * from './turn/inMemoryTurnProcessor.js';
|
||||
export * from './turn/databaseHooks.js';
|
||||
export * from './turn/joinCreateGeneralService.js';
|
||||
export * from './turn/npcPossessionService.js';
|
||||
export * from './turn/selectPoolService.js';
|
||||
export * from './turn/turnDaemon.js';
|
||||
export * from './turn/cli.js';
|
||||
|
||||
@@ -270,6 +270,19 @@ const zJoinCreateGeneral = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const zNpcPossessGeneral = z
|
||||
.object({
|
||||
type: z.literal('npcPossessGeneral'),
|
||||
requestId: z.string().optional(),
|
||||
userId: z.string().min(1),
|
||||
ownerDisplayName: z.string(),
|
||||
profileId: z.string().min(1),
|
||||
ownerLegacyPenalty: zRecord.optional(),
|
||||
generalId: z.number().int().positive(),
|
||||
tokenNonce: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const zSelectPoolCreate = z
|
||||
.object({
|
||||
type: z.literal('selectPoolCreate'),
|
||||
@@ -546,6 +559,14 @@ const normalizeJoinCreateGeneral: CommandNormalizer<'joinCreateGeneral'> = (enve
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeNpcPossessGeneral: CommandNormalizer<'npcPossessGeneral'> = (envelope) => {
|
||||
const command = parseWith(zNpcPossessGeneral, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelope) => {
|
||||
const command = parseWith(zSelectPoolCreate, envelope.command);
|
||||
if (!command) {
|
||||
@@ -626,6 +647,7 @@ const normalizers: CommandNormalizerMap = {
|
||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||
patchGeneral: normalizePatchGeneral,
|
||||
joinCreateGeneral: normalizeJoinCreateGeneral,
|
||||
npcPossessGeneral: normalizeNpcPossessGeneral,
|
||||
selectPoolCreate: normalizeSelectPoolCreate,
|
||||
selectPoolReselect: normalizeSelectPoolReselect,
|
||||
getStatus: normalizeGetStatus,
|
||||
|
||||
@@ -87,7 +87,7 @@ const fail = (code: JoinCreateGeneralErrorCode, message: string): never => {
|
||||
const normalizeJoinName = (value: string): string =>
|
||||
normalizeTroopName(value).replace(LEGACY_JOIN_REMOVED_CHARACTERS, '');
|
||||
|
||||
const resolveLegacyPenalty = (
|
||||
export const resolveLegacyPenalty = (
|
||||
rawPenalty: Record<string, unknown> | undefined,
|
||||
profileId: string,
|
||||
acceptedAt: Date
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } 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[];
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
const chooseCandidates = (
|
||||
candidates: NpcPossessionCandidate[],
|
||||
kept: Record<string, NpcPossessionCandidate>,
|
||||
rng: RandUtil
|
||||
): 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));
|
||||
if (!Object.hasOwn(picked, selectedId)) {
|
||||
const candidate = byId.get(selectedId);
|
||||
if (!candidate) {
|
||||
throw new Error(`NPC 빙의 후보 ${selectedId}를 찾을 수 없습니다.`);
|
||||
}
|
||||
picked[selectedId] = candidate;
|
||||
}
|
||||
}
|
||||
return picked;
|
||||
};
|
||||
|
||||
export const reserveNpcPossessionCandidates = async (options: {
|
||||
db: DatabaseClient;
|
||||
worldState: WorldStateRow;
|
||||
userId: string;
|
||||
ownerIdentity: string | number;
|
||||
refresh?: boolean;
|
||||
keepIds?: number[];
|
||||
now?: Date;
|
||||
}): 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 rng = new RandUtil(
|
||||
new LiteHashDRBG(buildNpcSelectionTokenSeed(readHiddenSeed(worldState), options.ownerIdentity, now))
|
||||
);
|
||||
const pickResult = chooseCandidates(candidates, kept, rng);
|
||||
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 };
|
||||
};
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
SelectPoolError,
|
||||
} from './selectPoolService.js';
|
||||
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
||||
import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js';
|
||||
|
||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||
|
||||
@@ -138,14 +139,17 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
|
||||
const resolveCommandAcceptedAt = async (
|
||||
db: DatabaseClient,
|
||||
command: Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' | 'selectPoolCreate' | 'selectPoolReselect' }>
|
||||
command: Extract<
|
||||
TurnDaemonCommand,
|
||||
{ type: 'joinCreateGeneral' | 'npcPossessGeneral' | 'selectPoolCreate' | 'selectPoolReselect' }
|
||||
>
|
||||
): Promise<Date> => {
|
||||
if (!command.requestId) {
|
||||
throw new Error(`${command.type} requestId is required.`);
|
||||
}
|
||||
const event = await db.inputEvent.findUnique({
|
||||
where: { requestId: command.requestId },
|
||||
select: { createdAt: true, actorUserId: true },
|
||||
select: { createdAt: true, actorUserId: true, target: true, eventType: true },
|
||||
});
|
||||
if (!event) {
|
||||
throw new Error(`ENGINE input event ${command.requestId} is missing.`);
|
||||
@@ -153,6 +157,9 @@ const resolveCommandAcceptedAt = async (
|
||||
if (event.actorUserId !== command.userId) {
|
||||
throw new Error(`ENGINE input event actor does not match ${command.type} user.`);
|
||||
}
|
||||
if (event.target !== 'ENGINE' || event.eventType !== command.type) {
|
||||
throw new Error(`ENGINE input event type does not match ${command.type}.`);
|
||||
}
|
||||
return event.createdAt;
|
||||
};
|
||||
|
||||
@@ -241,6 +248,47 @@ async function handleJoinCreateGeneral(
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNpcPossessGeneral(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const worldState = await db.worldState.findUnique({
|
||||
where: { id: ctx.world.getState().id },
|
||||
});
|
||||
if (!worldState) {
|
||||
throw new Error('NPC possession world state is missing.');
|
||||
}
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
try {
|
||||
return {
|
||||
type: 'npcPossessGeneral',
|
||||
...(await possessNpcGeneral({
|
||||
db,
|
||||
world: ctx.world,
|
||||
worldState,
|
||||
userId: command.userId,
|
||||
ownerDisplayName: command.ownerDisplayName,
|
||||
profileId: command.profileId,
|
||||
...(command.ownerLegacyPenalty !== undefined ? { ownerLegacyPenalty: command.ownerLegacyPenalty } : {}),
|
||||
generalId: command.generalId,
|
||||
tokenNonce: command.tokenNonce,
|
||||
acceptedAt,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof NpcPossessionError) {
|
||||
return {
|
||||
type: 'npcPossessGeneral',
|
||||
ok: false,
|
||||
code: error.code,
|
||||
reason: error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectPoolCreate(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
|
||||
@@ -2131,6 +2179,8 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
||||
joinCreateGeneral: (command) =>
|
||||
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
|
||||
npcPossessGeneral: (command) =>
|
||||
handleNpcPossessGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
|
||||
selectPoolCreate: (command) =>
|
||||
handleSelectPoolCreate(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>),
|
||||
selectPoolReselect: (command) =>
|
||||
|
||||
Reference in New Issue
Block a user