feat: port NPC possession through turn daemon

This commit is contained in:
2026-07-31 03:46:02 +00:00
parent d82e493109
commit 2de8f64da4
30 changed files with 3271 additions and 319 deletions
+84 -14
View File
@@ -1,8 +1,7 @@
import { randomUUID } from 'node:crypto';
import type { GamePrisma } from '@sammo-ts/infra';
import { GamePrisma, type DatabaseClient } from '@sammo-ts/infra';
import type { DatabaseClient } from '../context.js';
import type { TurnDaemonTransport } from './transport.js';
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
@@ -39,6 +38,16 @@ export class FailedTurnDaemonCommandError extends Error {
}
}
export class RejectedNpcPossessionCommandError extends Error {
constructor(
readonly code: 'PRECONDITION_FAILED',
message: string
) {
super(message);
this.name = 'RejectedNpcPossessionCommandError';
}
}
export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
constructor(
private readonly db: DatabaseClient,
@@ -48,20 +57,63 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
async sendCommand(command: TurnDaemonCommand): Promise<string> {
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
try {
await this.db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
payload: asJson(durableCommand),
actorUserId:
'userId' in command && typeof command.userId === 'string'
? command.userId
: null,
},
if (command.type === 'npcPossessGeneral') {
const existing = await this.db.inputEvent.findUnique({
where: { requestId },
select: { eventType: true, payload: true },
});
if (existing) {
if (
existing.eventType !== command.type ||
stableJson(existing.payload) !== stableJson(durableCommand)
) {
throw new ConflictingTurnDaemonCommandError(requestId);
}
return requestId;
}
}
try {
if (command.type === 'npcPossessGeneral' && this.db.$transaction) {
const rejectionReason = await this.db.$transaction(async (transaction) => {
await transaction.$executeRaw(
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))`
);
await transaction.$executeRaw(
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`npc-possession:${command.userId}`}, 1))`
);
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
const token = await transaction.npcSelectionToken.findFirst({
where: {
ownerUserId: command.userId,
nonce: command.tokenNonce,
validUntil: { gte: acceptedAt },
},
select: { pickResult: true },
});
if (!token) {
return '유효한 장수 목록이 없습니다.';
}
if (
!token.pickResult ||
typeof token.pickResult !== 'object' ||
Array.isArray(token.pickResult) ||
!Object.hasOwn(token.pickResult, String(command.generalId))
) {
return '선택한 장수가 목록에 없습니다.';
}
await this.createInputEvent(transaction, durableCommand, requestId, acceptedAt);
return null;
});
if (rejectionReason) {
throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason);
}
} else {
await this.createInputEvent(this.db, durableCommand, requestId);
}
} catch (error) {
if (error instanceof RejectedNpcPossessionCommandError) {
throw error;
}
const isUniqueConflict =
typeof error === 'object' && error !== null && 'code' in error && error.code === 'P2002';
if (!isUniqueConflict) {
@@ -78,6 +130,24 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
return requestId;
}
private async createInputEvent(
db: DatabaseClient,
command: TurnDaemonCommand,
requestId: string,
createdAt?: Date
): Promise<void> {
await db.inputEvent.create({
data: {
requestId,
target: 'ENGINE',
eventType: command.type,
payload: asJson(command),
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
...(createdAt ? { createdAt } : {}),
},
});
}
async requestCommand(command: TurnDaemonCommand, timeoutMs?: number): Promise<TurnDaemonCommandResult | null> {
const requestId = await this.sendCommand(command);
return this.waitForResult<TurnDaemonCommandResult>(requestId, timeoutMs);
+135 -140
View File
@@ -15,7 +15,11 @@ import {
} from '@sammo-ts/logic';
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
import {
ConflictingTurnDaemonCommandError,
RejectedNpcPossessionCommandError,
} from '../../daemon/databaseTransport.js';
import { NpcPossessionError, reserveNpcPossessionCandidates } from '@sammo-ts/game-engine';
const resolveSelectionCommandResult = (
result: Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>> | null,
@@ -115,6 +119,71 @@ const requestJoinCreateCommand = async (
}
};
const resolveNpcPossessionCommandResult = (
result: Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>> | null
): { ok: true; generalId: number } => {
if (!result) {
throw new TRPCError({
code: 'TIMEOUT',
message:
'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
});
}
if (result.type !== 'npcPossessGeneral') {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: '턴 데몬이 올바르지 않은 NPC 빙의 결과를 반환했습니다.',
});
}
if (!result.ok) {
throw new TRPCError({
code: result.code,
message: result.reason,
});
}
return { ok: true, generalId: result.generalId };
};
const resolveNpcPossessionRequestId = (
contextRequestId: string | undefined,
userId: string,
clientRequestId: string | undefined
): string | undefined => {
if (clientRequestId) {
return `npc-possess:${userId}:${clientRequestId}`;
}
return contextRequestId ? `${contextRequestId}:join.possessGeneral` : undefined;
};
const requestNpcPossessionCommand = async (
ctx: GameApiContext,
command: Parameters<GameApiContext['turnDaemon']['requestCommand']>[0]
) => {
try {
return await ctx.turnDaemon.requestCommand(command);
} catch (error) {
if (
error instanceof RejectedNpcPossessionCommandError ||
(error instanceof Error && error.name === 'RejectedNpcPossessionCommandError')
) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: error.message,
});
}
if (
error instanceof ConflictingTurnDaemonCommandError ||
(error instanceof Error && error.name === 'ConflictingTurnDaemonCommandError')
) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 접수된 NPC 빙의 요청과 입력이 다릅니다. 새 요청 번호로 다시 시도해 주세요.',
});
}
throw error;
}
};
const DEFAULT_JOIN_STAT = {
total: 165,
min: 15,
@@ -257,6 +326,7 @@ export const joinRouter = router({
user: {
id: ctx.auth?.user.id ?? '',
displayName: ctx.auth?.user.displayName ?? '',
canCreateGeneral: ctx.auth?.identity?.canCreateGeneral !== false,
},
personalities: [{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, ...personalities],
warSpecials,
@@ -282,6 +352,9 @@ export const joinRouter = router({
availableSpecialWar: warSpecials,
},
selectionPool,
npcPossession: {
enabled: asNumber(config.npcMode ?? config.npcmode, 0) === 1,
},
};
}),
getSelectionPool: authedProcedure.mutation(async ({ ctx }) => {
@@ -427,155 +500,77 @@ export const joinRouter = router({
listPossessCandidates: authedProcedure
.input(
z.object({
limit: z.number().int().min(1).max(50).optional(),
offset: z.number().int().min(0).optional(),
})
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 20;
const offset = input.offset ?? 0;
const candidates = await ctx.db.general.findMany({
where: {
userId: null,
npcState: { gte: 2 },
},
orderBy: { id: 'asc' },
skip: offset,
take: limit,
select: {
id: true,
name: true,
npcState: true,
nationId: true,
cityId: true,
leadership: true,
strength: true,
intel: true,
age: true,
officerLevel: true,
personalCode: true,
specialCode: true,
special2Code: true,
picture: true,
imageServer: true,
},
});
const [nationRows, cityRows] = await Promise.all([
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
ctx.db.city.findMany({ select: { id: true, name: true } }),
]);
const nationMap = new Map(nationRows.map((nation) => [nation.id, nation]));
const cityMap = new Map(cityRows.map((city) => [city.id, city]));
return candidates.map((candidate) => {
const nation = nationMap.get(candidate.nationId);
const city = cityMap.get(candidate.cityId);
return {
id: candidate.id,
name: candidate.name,
npcState: candidate.npcState,
nation: nation
? { id: nation.id, name: nation.name, color: nation.color }
: { id: 0, name: '재야', color: '#666666' },
city: city ? { id: city.id, name: city.name } : null,
stats: {
leadership: candidate.leadership,
strength: candidate.strength,
intelligence: candidate.intel,
},
age: candidate.age,
officerLevel: candidate.officerLevel,
personality: candidate.personalCode,
special: candidate.specialCode,
specialWar: candidate.special2Code,
picture: candidate.picture,
imageServer: candidate.imageServer,
};
});
}),
possessGeneral: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
refresh: z.boolean().optional(),
keepIds: z.array(z.number().int().positive()).max(5).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
const auth = ctx.auth;
if (!auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const existing = await ctx.db.general.findFirst({ where: { userId } });
if (existing) {
if (auth.identity?.canCreateGeneral === false) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '이 장수 생성되어 있습니다.',
code: 'FORBIDDEN',
message: '이 서버에서는 카카오 인증을 완료해야 장수 생성할 수 있습니다.',
});
}
await ctx.db.$transaction!(async (db) => {
const [candidate, worldState] = await Promise.all([
db.general.findUnique({
where: { id: input.generalId },
select: { npcState: true, meta: true },
}),
db.worldState.findFirst({
select: { currentYear: true, currentMonth: true },
}),
]);
if (!candidate || candidate.npcState < 2 || !worldState) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '빙의 가능한 장수를 찾지 못했습니다.',
});
}
const now = new Date();
const updated = await db.general.updateMany({
where: {
id: input.generalId,
userId: null,
npcState: candidate.npcState,
},
data: {
userId,
npcState: 1,
meta: {
...asRecord(candidate.meta),
npc_org: candidate.npcState,
owner_name: ctx.auth?.user.displayName ?? '',
pickYearMonth: worldState.currentYear * 12 + worldState.currentMonth - 1,
killturn: 6,
defence_train: 80,
},
updatedAt: now,
},
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
if (updated.count === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '빙의 가능한 장수를 찾지 못했습니다.',
});
}
await db.generalAccessLog.upsert({
where: { generalId: input.generalId },
update: {
userId,
lastRefresh: now,
refresh: 0,
refreshTotal: 0,
refreshScore: 0,
refreshScoreTotal: 0,
},
create: {
generalId: input.generalId,
userId,
lastRefresh: now,
},
}
try {
return await reserveNpcPossessionCandidates({
db: ctx.db,
worldState,
userId: auth.user.id,
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
refresh: input.refresh,
keepIds: input.keepIds,
});
} catch (error) {
if (error instanceof NpcPossessionError) {
throw new TRPCError({ code: error.code, message: error.message });
}
throw error;
}
}),
possessGeneral: engineAuthedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
tokenNonce: z.number().int().nonnegative(),
clientRequestId: z.string().uuid().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const auth = ctx.auth;
if (!auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
if (auth.identity?.canCreateGeneral === false) {
throw new TRPCError({
code: 'FORBIDDEN',
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
});
}
const userId = auth.user.id;
const commandRequestId = resolveNpcPossessionRequestId(ctx.requestId, userId, input.clientRequestId);
const result = await requestNpcPossessionCommand(ctx, {
type: 'npcPossessGeneral',
...(commandRequestId ? { requestId: commandRequestId } : {}),
userId,
ownerDisplayName: auth.user.displayName,
profileId: ctx.profile.id,
...(auth.sanctions.legacyPenalty !== undefined
? { ownerLegacyPenalty: auth.sanctions.legacyPenalty }
: {}),
generalId: input.generalId,
tokenNonce: input.tokenNonce,
});
return { ok: true };
return resolveNpcPossessionCommandResult(result);
}),
});
+5 -4
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
import { isSelectionPoolWorld } from '../../services/selectPool.js';
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
import { procedure, router } from '../../trpc.js';
export const lobbyRouter = router({
@@ -20,8 +20,8 @@ export const lobbyRouter = router({
meta: zWorldStateMeta.parse(rawWorldState.meta),
};
const userCnt = await ctx.db.general.count({ where: { npcState: 0 } });
const npcCnt = await ctx.db.general.count({ where: { npcState: { gt: 0 } } });
const userCnt = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
let myGeneral = null;
@@ -43,7 +43,7 @@ export const lobbyRouter = router({
year: worldState.currentYear,
month: worldState.currentMonth,
userCnt,
maxUserCnt: worldState.config.maxUserCnt ?? 500,
maxUserCnt: resolveSelectionMaxGeneral(rawWorldState),
npcCnt,
nationCnt,
turnTerm: worldState.tickSeconds / 60,
@@ -54,6 +54,7 @@ export const lobbyRouter = router({
otherTextInfo: worldState.meta.otherTextInfo ?? '',
isUnited: worldState.meta.isUnited ?? 0,
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
npcPossessionEnabled: worldState.config.npcMode === 1,
myGeneral,
};
}),
+103 -22
View File
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server';
import { asRecord } from '@sammo-ts/common';
import { asNumber, asRecord } from '@sammo-ts/common';
import { LogCategory, LogScope } from '@sammo-ts/infra';
import { z } from 'zod';
@@ -173,6 +173,33 @@ const readFiniteMetaNumber = (meta: Record<string, unknown>, key: string): numbe
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
};
const resolveExperienceLevel = (experience: number, maxLevel: number): number => {
const level = experience < 1_000 ? Math.trunc(experience / 100) : Math.trunc(Math.sqrt(experience / 10));
return Math.max(0, Math.min(level, maxLevel));
};
const resolveHonorText = (experience: number): string => {
if (experience < 640) return '전무';
if (experience < 2_560) return '무명';
if (experience < 5_760) return '신동';
if (experience < 10_240) return '약간';
if (experience < 16_000) return '평범';
if (experience < 23_040) return '지역적';
if (experience < 31_360) return '전국적';
if (experience < 40_960) return '세계적';
if (experience < 45_000) return '유명';
if (experience < 51_840) return '명사';
if (experience < 55_000) return '호걸';
if (experience < 64_000) return '효웅';
if (experience < 77_440) return '영웅';
return '구세주';
};
const resolveDedicationText = (dedication: number, maxLevel: number): string => {
const level = Math.max(0, Math.min(Math.ceil(Math.sqrt(dedication) / 10), maxLevel));
return level === 0 ? '무품관' : `${maxLevel - level + 1}품관`;
};
const compareString = (left: string, right: string): number => {
if (left === right) {
return 0;
@@ -180,16 +207,21 @@ const compareString = (left: string, right: string): number => {
return left < right ? -1 : 1;
};
const sortNpcList = <T extends {
name: string;
nationId: number;
statTotal: number;
leadership: number;
strength: number;
intelligence: number;
experience: number;
dedication: number;
}>(rows: T[], sort: NpcListSort): T[] =>
const sortNpcList = <
T extends {
name: string;
nationId: number;
statTotal: number;
leadership: number;
strength: number;
intelligence: number;
experience: number;
dedication: number;
},
>(
rows: T[],
sort: NpcListSort
): T[] =>
rows.sort((left, right) => {
switch (sort) {
case 2:
@@ -450,18 +482,28 @@ export const publicRouter = router({
z
.object({
sort: z.number().int().min(1).max(8).catch(1).optional(),
includeAllWithToken: z.boolean().optional(),
})
.optional()
)
.query(async ({ ctx, input }) => {
const sort = (input?.sort ?? 1) as NpcListSort;
const [generals, nations] = await Promise.all([
const includeAllWithToken = input?.includeAllWithToken === true;
if (includeAllWithToken && !ctx.auth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const now = new Date(Math.floor(Date.now() / 1000) * 1000);
const [generals, nations, activeTokens, worldState] = await Promise.all([
ctx.db.general.findMany({
where: { npcState: { gt: 0 } },
...(includeAllWithToken ? {} : { where: { npcState: { gt: 0 } } }),
select: {
id: true,
name: true,
picture: true,
imageServer: true,
npcState: true,
age: true,
officerLevel: true,
nationId: true,
leadership: true,
strength: true,
@@ -476,8 +518,19 @@ export const publicRouter = router({
orderBy: { id: 'asc' },
}),
ctx.db.nation.findMany({
select: { id: true, name: true },
select: { id: true, name: true, level: true },
}),
includeAllWithToken
? ctx.db.npcSelectionToken.findMany({
where: { validUntil: { gte: now } },
select: { pickResult: true },
})
: [],
includeAllWithToken
? ctx.db.worldState.findFirst({
select: { config: true },
})
: null,
]);
const personalityKeys = generals.map((general) => normalizeTraitKey(general.personalCode));
@@ -488,12 +541,21 @@ export const publicRouter = router({
loadTraitNames(domesticKeys, 'domestic'),
loadTraitNames(warKeys, 'war'),
]);
const nationMap = new Map(nations.map((nation) => [nation.id, nation.name]));
const nationMap = new Map(nations.map((nation) => [nation.id, nation]));
const worldConfig = asRecord(worldState?.config);
const worldConstants = asRecord(worldConfig.const);
const maxLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxLevel, 255)));
const maxDedLevel = Math.max(0, Math.floor(asNumber(worldConstants.maxDedLevel, 30)));
// Legacy select_pool rows preceded possessed NPC rows before its stable-value sort.
const pool = generals.filter((general) => general.npcState >= 2);
const possessed = generals.filter((general) => general.npcState === 1);
const rows = [...pool, ...possessed].map((general) => {
// Legacy public NPC list put pool rows before possessed rows. The token-aware
// selection list instead consumes the raw id-ordered full list before its own comparator.
const sourceRows = includeAllWithToken
? generals
: [
...generals.filter((general) => general.npcState >= 2),
...generals.filter((general) => general.npcState === 1),
];
const rows = sourceRows.map((general) => {
const meta = asRecord(general.meta);
const personalityKey = normalizeTraitKey(general.personalCode);
const domesticKey = normalizeTraitKey(general.specialCode);
@@ -510,11 +572,19 @@ export const publicRouter = router({
return {
id: general.id,
name: general.name,
picture: general.picture,
imageServer: general.imageServer,
npcState: general.npcState,
ownerName,
level: readFiniteMetaNumber(meta, 'explevel'),
age: general.age,
level: includeAllWithToken
? resolveExperienceLevel(general.experience, maxLevel)
: readFiniteMetaNumber(meta, 'explevel'),
officerLevel: general.officerLevel,
killturn: readFiniteMetaNumber(meta, 'killturn'),
nationId: general.nationId,
nationName: nationMap.get(general.nationId) ?? '-',
nationName: nationMap.get(general.nationId)?.name ?? '-',
nationLevel: nationMap.get(general.nationId)?.level ?? 0,
personality: personalityKey
? {
key: personalityKey,
@@ -541,13 +611,24 @@ export const publicRouter = router({
strength: general.strength,
intelligence: general.intel,
experience: general.experience,
experienceText: resolveHonorText(general.experience),
dedication: general.dedication,
dedicationText: resolveDedicationText(general.dedication, maxDedLevel),
};
});
const tokenKeepCounts = Object.fromEntries(
activeTokens.flatMap((token) =>
Object.entries(asRecord(token.pickResult)).flatMap(([generalId, value]) => {
const keepCount = asNumber(asRecord(value).keepCount, Number.NaN);
return Number.isFinite(keepCount) ? [[generalId, Math.max(0, Math.floor(keepCount))]] : [];
})
)
);
return {
sort,
generals: sortNpcList(rows, sort),
generals: includeAllWithToken ? rows : sortNpcList(rows, sort),
tokenKeepCounts,
};
}),
});