feat: port NPC possession through turn daemon
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra';
|
||||
|
||||
import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js';
|
||||
import { InMemoryFlushStore } from '../src/auth/flushStore.js';
|
||||
import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js';
|
||||
import type { GameApiContext } from '../src/context.js';
|
||||
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
||||
import type { TurnDaemonTransport } from '../src/daemon/transport.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
|
||||
const databaseUrl = process.env.NPC_POSSESSION_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const profile = 'hwe:2';
|
||||
const userId = 'npc-possession-integration-user';
|
||||
const otherUserId = 'npc-possession-integration-other';
|
||||
const failureUserId = 'npc-possession-integration-failure';
|
||||
const rejectedUserId = 'npc-possession-integration-rejected';
|
||||
const delayedUserId = 'npc-possession-integration-delayed';
|
||||
const cleanupUserId = 'npc-possession-integration-cleanup';
|
||||
const raceUserId = 'npc-possession-integration-race';
|
||||
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
|
||||
|
||||
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||
const schema = new URL(rawUrl).searchParams.get('schema');
|
||||
if (!schema?.endsWith('npc_possession_integration')) {
|
||||
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
|
||||
}
|
||||
if (!/^[a-z0-9_]+$/.test(schema)) {
|
||||
throw new Error(`Refusing unsafe schema name: ${schema}`);
|
||||
}
|
||||
};
|
||||
|
||||
const buildAuth = (id: string, displayName: string, legacyMemberNo: number): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile,
|
||||
issuedAt: '2026-07-31T00:00:00.000Z',
|
||||
expiresAt: '2026-08-31T00:00:00.000Z',
|
||||
sessionId: `npc-possession-${id}`,
|
||||
user: {
|
||||
id,
|
||||
username: id,
|
||||
displayName,
|
||||
roles: ['user'],
|
||||
legacyMemberNo,
|
||||
},
|
||||
sanctions: {
|
||||
legacyPenalty: {
|
||||
any: {
|
||||
ban: { expire: 4_102_444_800, value: 1 },
|
||||
expired: { expire: 1, value: 9 },
|
||||
},
|
||||
hwe: {
|
||||
ban: { expire: 4_102_444_800, value: 2 },
|
||||
chat: { expire: 4_102_444_800, value: 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
identity: {
|
||||
kakaoVerified: true,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
graceEndsAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
integration('mode 1 NPC possession through token reservation and the durable daemon', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let runtime: TurnDaemonRuntime | undefined;
|
||||
let daemonLoop: Promise<void> | undefined;
|
||||
let turnDaemon: TurnDaemonTransport;
|
||||
|
||||
const auth = buildAuth(userId, '빙의사용자', 7_701);
|
||||
const otherAuth = buildAuth(otherUserId, '다른사용자', 7_702);
|
||||
const failureAuth = buildAuth(failureUserId, '재시도사용자', 7_703);
|
||||
const rejectedAuth = buildAuth(rejectedUserId, '거절사용자', 7_704);
|
||||
const delayedAuth = buildAuth(delayedUserId, '지연사용자', 7_705);
|
||||
const cleanupAuth = buildAuth(cleanupUserId, '정리사용자', 7_706);
|
||||
const raceAuth = buildAuth(raceUserId, '경합사용자', 7_707);
|
||||
|
||||
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
|
||||
const redisClient = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
};
|
||||
return {
|
||||
requestId,
|
||||
db,
|
||||
redis: redisClient as unknown as RedisConnector['client'],
|
||||
turnDaemon,
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile: { id: 'hwe', scenario: '2', name: profile },
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
auth: actorAuth,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, profile),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'npc-possession-test-secret',
|
||||
};
|
||||
};
|
||||
|
||||
const stopRuntime = async (reason: string): Promise<void> => {
|
||||
if (!runtime) return;
|
||||
await runtime.lifecycle.stop(reason);
|
||||
await daemonLoop;
|
||||
await runtime.close();
|
||||
runtime = undefined;
|
||||
daemonLoop = undefined;
|
||||
};
|
||||
|
||||
const startRuntime = async (ownerId: string): Promise<void> => {
|
||||
runtime = await createTurnDaemonRuntime({
|
||||
profile,
|
||||
databaseUrl: databaseUrl!,
|
||||
enableDatabaseFlush: true,
|
||||
enableLeaseHeartbeat: false,
|
||||
leaseOwnerId: ownerId,
|
||||
});
|
||||
turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000);
|
||||
daemonLoop = runtime.lifecycle.start();
|
||||
await expect(turnDaemon.requestStatus(10_000)).resolves.toMatchObject({
|
||||
state: expect.any(String),
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedDatabase(databaseUrl!);
|
||||
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
|
||||
process.env.INTEGRATION_WORLD_SEED = 'npc-possession-integration-seed';
|
||||
try {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 2,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date('2099-07-31T12:00:00.000Z'),
|
||||
installOptions: {
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 1,
|
||||
showImgLevel: 3,
|
||||
serverId: profile,
|
||||
season: 1,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (previousSeed === undefined) {
|
||||
delete process.env.INTEGRATION_WORLD_SEED;
|
||||
} else {
|
||||
process.env.INTEGRATION_WORLD_SEED = previousSeed;
|
||||
}
|
||||
}
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany();
|
||||
await db.logEntry.deleteMany();
|
||||
await db.npcSelectionToken.deleteMany();
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
await db.general.createMany({
|
||||
data: Array.from({ length: 24 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
userId: null,
|
||||
name: `빙의후보${index + 1}`,
|
||||
nationId: 0,
|
||||
cityId: city.id,
|
||||
npcState: 2,
|
||||
leadership: 40 + index,
|
||||
strength: 50 + index,
|
||||
intel: 60 + index,
|
||||
turnTime: new Date('2099-07-31T12:05:00.000Z'),
|
||||
personalCode: 'che_안전',
|
||||
specialCode: 'che_인덕',
|
||||
special2Code: 'che_무쌍',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
meta: { killturn: 6 },
|
||||
penalty: {},
|
||||
})),
|
||||
});
|
||||
await startRuntime('npc-possession-integration-daemon');
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await stopRuntime('npc possession integration complete');
|
||||
await closeDb?.();
|
||||
}, 30_000);
|
||||
|
||||
it('reserves at most five exact type-2 NPCs and preserves Ref refresh/keep timing', async () => {
|
||||
const config = await appRouter.createCaller(buildContext('npc-possession-config')).join.getConfig();
|
||||
expect(config.npcPossession).toEqual({ enabled: true });
|
||||
|
||||
const [first, concurrentSameOwner] = await Promise.all([
|
||||
appRouter.createCaller(buildContext('npc-possession-token-a')).join.listPossessCandidates({}),
|
||||
appRouter.createCaller(buildContext('npc-possession-token-concurrent')).join.listPossessCandidates({}),
|
||||
]);
|
||||
expect(concurrentSameOwner).toEqual(first);
|
||||
expect(await db.npcSelectionToken.count({ where: { ownerUserId: userId } })).toBe(1);
|
||||
expect(first.candidates.length).toBeGreaterThan(0);
|
||||
expect(first.candidates.length).toBeLessThanOrEqual(5);
|
||||
expect(new Set(first.candidates.map(({ id }) => id)).size).toBe(first.candidates.length);
|
||||
expect(first.pickMoreSeconds).toBe(0);
|
||||
expect(first.candidates.every(({ keepCount }) => keepCount === 3)).toBe(true);
|
||||
const rows = await db.general.findMany({
|
||||
where: { id: { in: first.candidates.map(({ id }) => id) } },
|
||||
select: { id: true, userId: true, npcState: true },
|
||||
});
|
||||
expect(rows).toHaveLength(first.candidates.length);
|
||||
expect(rows.every((row) => row.userId === null && row.npcState === 2)).toBe(true);
|
||||
|
||||
const reused = await appRouter
|
||||
.createCaller(buildContext('npc-possession-token-b'))
|
||||
.join.listPossessCandidates({});
|
||||
expect(reused).toEqual(first);
|
||||
|
||||
const kept = first.candidates[0]!;
|
||||
const refreshed = await appRouter
|
||||
.createCaller(buildContext('npc-possession-token-refresh'))
|
||||
.join.listPossessCandidates({ refresh: true, keepIds: [kept.id] });
|
||||
expect(refreshed.tokenNonce).not.toBe(first.tokenNonce);
|
||||
expect(refreshed.pickMoreSeconds).toBeGreaterThan(0);
|
||||
expect(refreshed.candidates.find(({ id }) => id === kept.id)?.keepCount).toBe(2);
|
||||
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('npc-possession-token-too-early'))
|
||||
.join.listPossessCandidates({ refresh: true, keepIds: [] })
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '아직 다시 뽑을 수 없습니다',
|
||||
});
|
||||
|
||||
const other = await appRouter
|
||||
.createCaller(buildContext('npc-possession-token-other', otherAuth))
|
||||
.join.listPossessCandidates({});
|
||||
const firstIds = new Set(refreshed.candidates.map(({ id }) => id));
|
||||
expect(other.candidates.some(({ id }) => firstIds.has(id))).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
it('commits exactly one of two concurrent picks and keeps retry, logs, token and reload atomic', async () => {
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-token-current'))
|
||||
.join.listPossessCandidates({});
|
||||
const firstCandidate = reservation.candidates[0]!;
|
||||
const secondCandidate = reservation.candidates[1]!;
|
||||
const firstClientRequestId = '11111111-1111-4111-8111-111111111111';
|
||||
const secondClientRequestId = '22222222-2222-4222-8222-222222222222';
|
||||
const firstInput = {
|
||||
generalId: firstCandidate.id,
|
||||
tokenNonce: reservation.tokenNonce,
|
||||
clientRequestId: firstClientRequestId,
|
||||
};
|
||||
const secondInput = {
|
||||
generalId: secondCandidate.id,
|
||||
tokenNonce: reservation.tokenNonce,
|
||||
clientRequestId: secondClientRequestId,
|
||||
};
|
||||
const concurrent = await Promise.allSettled([
|
||||
appRouter.createCaller(buildContext('npc-possession-http-a')).join.possessGeneral(firstInput),
|
||||
appRouter.createCaller(buildContext('npc-possession-http-b')).join.possessGeneral(secondInput),
|
||||
]);
|
||||
expect(concurrent.filter(({ status }) => status === 'fulfilled')).toHaveLength(1);
|
||||
expect(concurrent.filter(({ status }) => status === 'rejected')).toHaveLength(1);
|
||||
const fulfilledIndex = concurrent.findIndex(({ status }) => status === 'fulfilled');
|
||||
const candidate = fulfilledIndex === 0 ? firstCandidate : secondCandidate;
|
||||
const input = fulfilledIndex === 0 ? firstInput : secondInput;
|
||||
const clientRequestId = input.clientRequestId;
|
||||
const first = concurrent[fulfilledIndex]!;
|
||||
if (first.status !== 'fulfilled') {
|
||||
throw new Error('Exactly one NPC possession must succeed.');
|
||||
}
|
||||
const retried = await appRouter
|
||||
.createCaller(buildContext('npc-possession-http-retry'))
|
||||
.join.possessGeneral(input);
|
||||
expect(retried).toEqual(first.value);
|
||||
|
||||
const persisted = await db.general.findUniqueOrThrow({ where: { id: candidate.id } });
|
||||
expect(persisted).toMatchObject({
|
||||
userId,
|
||||
npcState: 1,
|
||||
penalty: {
|
||||
ban: 2,
|
||||
chat: 3,
|
||||
},
|
||||
});
|
||||
expect(persisted.meta).toMatchObject({
|
||||
npc_org: 2,
|
||||
ownerName: '빙의사용자',
|
||||
owner_name: '빙의사용자',
|
||||
killturn: 6,
|
||||
defence_train: 80,
|
||||
permission: 'normal',
|
||||
});
|
||||
expect(runtime!.world.getGeneralById(candidate.id)).toMatchObject({
|
||||
userId,
|
||||
npcState: 1,
|
||||
penalty: {
|
||||
ban: 2,
|
||||
chat: 3,
|
||||
},
|
||||
});
|
||||
expect(await db.general.count({ where: { userId } })).toBe(1);
|
||||
expect(await db.npcSelectionToken.findUnique({ where: { ownerUserId: userId } })).toBeNull();
|
||||
const access = await db.generalAccessLog.findUniqueOrThrow({ where: { generalId: candidate.id } });
|
||||
expect(access).toMatchObject({
|
||||
userId,
|
||||
refresh: 0,
|
||||
refreshTotal: 0,
|
||||
refreshScore: 0,
|
||||
refreshScoreTotal: 0,
|
||||
});
|
||||
const requestId = `npc-possess:${userId}:${clientRequestId}`;
|
||||
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||
expect(event).toMatchObject({
|
||||
target: 'ENGINE',
|
||||
eventType: 'npcPossessGeneral',
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: userId,
|
||||
});
|
||||
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
||||
const logs = await db.logEntry.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ generalId: candidate.id, text: { contains: '빙의되다' } },
|
||||
{ text: { contains: '빙의</>됩니다' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(logs).toHaveLength(2);
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-lobby-after')).lobby.info()
|
||||
).resolves.toMatchObject({
|
||||
userCnt: 1,
|
||||
npcCnt: 23,
|
||||
npcPossessionEnabled: true,
|
||||
selectionPoolEnabled: false,
|
||||
myGeneral: {
|
||||
name: candidate.name,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-conflict')).join.possessGeneral({
|
||||
...input,
|
||||
generalId: candidate.id === firstCandidate.id ? secondCandidate.id : firstCandidate.id,
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
|
||||
await stopRuntime('verify NPC possession reload');
|
||||
await startRuntime('npc-possession-integration-reloaded-daemon');
|
||||
expect(runtime!.world.getGeneralById(candidate.id)).toMatchObject({
|
||||
userId,
|
||||
npcState: 1,
|
||||
penalty: {
|
||||
ban: 2,
|
||||
chat: 3,
|
||||
},
|
||||
});
|
||||
}, 45_000);
|
||||
|
||||
it('keeps an accepted token through wall-clock expiry until the queued ENGINE event finishes', async () => {
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
|
||||
.join.listPossessCandidates({});
|
||||
const candidate = reservation.candidates[0]!;
|
||||
const clientRequestId = '88888888-8888-4888-8888-888888888888';
|
||||
const requestId = `npc-possess:${delayedUserId}:${clientRequestId}`;
|
||||
const input = {
|
||||
generalId: candidate.id,
|
||||
tokenNonce: reservation.tokenNonce,
|
||||
clientRequestId,
|
||||
};
|
||||
|
||||
await stopRuntime('hold accepted NPC possession past token expiry');
|
||||
turnDaemon = new DatabaseTurnDaemonTransport(db, 100);
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-delayed-http', delayedAuth)).join.possessGeneral(input)
|
||||
).rejects.toMatchObject({ code: 'TIMEOUT' });
|
||||
|
||||
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||
const acceptedSecond = new Date(Math.floor(event.createdAt.getTime() / 1000) * 1000);
|
||||
await db.npcSelectionToken.update({
|
||||
where: { ownerUserId: delayedUserId },
|
||||
data: { validUntil: acceptedSecond },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
|
||||
await appRouter
|
||||
.createCaller(buildContext('npc-possession-cleanup-token', cleanupAuth))
|
||||
.join.listPossessCandidates({});
|
||||
await expect(
|
||||
db.npcSelectionToken.findUnique({ where: { ownerUserId: delayedUserId } })
|
||||
).resolves.not.toBeNull();
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('npc-possession-delayed-refresh', delayedAuth))
|
||||
.join.listPossessCandidates({ refresh: true, keepIds: [] })
|
||||
).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
message: 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.',
|
||||
});
|
||||
|
||||
await startRuntime('npc-possession-delayed-retry-daemon');
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input)
|
||||
).resolves.toEqual({ ok: true, generalId: candidate.id });
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
});
|
||||
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(1);
|
||||
}, 45_000);
|
||||
|
||||
it('serializes durable enqueue before a token refresh can replace its nonce', async () => {
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-race-token', raceAuth))
|
||||
.join.listPossessCandidates({});
|
||||
const candidate = reservation.candidates[0]!;
|
||||
const clientRequestId = '99999999-9999-4999-8999-999999999999';
|
||||
const requestId = `npc-possess:${raceUserId}:${clientRequestId}`;
|
||||
const input = {
|
||||
generalId: candidate.id,
|
||||
tokenNonce: reservation.tokenNonce,
|
||||
clientRequestId,
|
||||
};
|
||||
|
||||
await stopRuntime('hold NPC possession enqueue behind the reservation lock');
|
||||
turnDaemon = new DatabaseTurnDaemonTransport(db, 100);
|
||||
|
||||
let releaseLock!: () => void;
|
||||
let markLockReady!: () => void;
|
||||
const lockReady = new Promise<void>((resolve) => {
|
||||
markLockReady = resolve;
|
||||
});
|
||||
const lockRelease = new Promise<void>((resolve) => {
|
||||
releaseLock = resolve;
|
||||
});
|
||||
const blocker = db.$transaction(
|
||||
async (transaction) => {
|
||||
await transaction.$executeRaw(
|
||||
GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended('npc-possession', 1))`
|
||||
);
|
||||
markLockReady();
|
||||
await lockRelease;
|
||||
},
|
||||
{ timeout: 5_000 }
|
||||
);
|
||||
await lockReady;
|
||||
|
||||
const enqueue = appRouter
|
||||
.createCaller(buildContext('npc-possession-race-http', raceAuth))
|
||||
.join.possessGeneral(input);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
await expect(db.inputEvent.findUnique({ where: { requestId } })).resolves.toBeNull();
|
||||
|
||||
releaseLock();
|
||||
await blocker;
|
||||
await expect(enqueue).rejects.toMatchObject({ code: 'TIMEOUT' });
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('npc-possession-race-refresh', raceAuth))
|
||||
.join.listPossessCandidates({ refresh: true, keepIds: [] })
|
||||
).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
message: 'NPC 빙의 요청 처리 중에는 후보를 다시 뽑을 수 없습니다.',
|
||||
});
|
||||
await expect(db.npcSelectionToken.findUnique({ where: { ownerUserId: raceUserId } })).resolves.toMatchObject({
|
||||
nonce: reservation.tokenNonce,
|
||||
});
|
||||
|
||||
await startRuntime('npc-possession-race-retry-daemon');
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-race-retry', raceAuth)).join.possessGeneral(input)
|
||||
).resolves.toEqual({ ok: true, generalId: candidate.id });
|
||||
}, 45_000);
|
||||
|
||||
it('rolls back a late log failure and retries the same ENGINE event once', async () => {
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-failure-token', failureAuth))
|
||||
.join.listPossessCandidates({});
|
||||
const candidate = reservation.candidates[0]!;
|
||||
const clientRequestId = '33333333-3333-4333-8333-333333333333';
|
||||
const requestId = `npc-possess:${failureUserId}:${clientRequestId}`;
|
||||
const triggerName = 'npc_possession_fail_first_log';
|
||||
const functionName = 'npc_possession_fail_first_log_fn';
|
||||
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE OR REPLACE FUNCTION "${schemaName}"."${functionName}"()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.text LIKE '%재시도사용자%'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "${schemaName}"."input_event"
|
||||
WHERE "request_id" = '${requestId}'
|
||||
AND "status" = 'PROCESSING'
|
||||
AND "attempts" = 1
|
||||
)
|
||||
THEN
|
||||
RAISE EXCEPTION 'injected first NPC possession log failure';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
`);
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE TRIGGER "${triggerName}"
|
||||
BEFORE INSERT ON "${schemaName}"."log_entry"
|
||||
FOR EACH ROW EXECUTE FUNCTION "${schemaName}"."${functionName}"()
|
||||
`);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-failure-http', failureAuth)).join.possessGeneral({
|
||||
generalId: candidate.id,
|
||||
tokenNonce: reservation.tokenNonce,
|
||||
clientRequestId,
|
||||
})
|
||||
).resolves.toEqual({ ok: true, generalId: candidate.id });
|
||||
} finally {
|
||||
await db.$executeRawUnsafe(`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`);
|
||||
await db.$executeRawUnsafe(`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`);
|
||||
}
|
||||
|
||||
expect(await db.general.count({ where: { userId: failureUserId } })).toBe(1);
|
||||
expect(runtime!.world.getGeneralById(candidate.id)).toMatchObject({
|
||||
userId: failureUserId,
|
||||
npcState: 1,
|
||||
});
|
||||
expect(await db.npcSelectionToken.findUnique({ where: { ownerUserId: failureUserId } })).toBeNull();
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 2,
|
||||
actorUserId: failureUserId,
|
||||
error: null,
|
||||
});
|
||||
expect(
|
||||
await db.logEntry.count({
|
||||
where: { text: { contains: '재시도사용자' } },
|
||||
})
|
||||
).toBe(2);
|
||||
}, 45_000);
|
||||
|
||||
it('rejects wrong mode, foreign nonce, unlisted ID, expiry and a full server without mutation', async () => {
|
||||
await db.npcSelectionToken.deleteMany({ where: { ownerUserId: cleanupUserId } });
|
||||
const worldState = await db.worldState.findFirstOrThrow();
|
||||
const originalConfig = worldState.config as GamePrisma.InputJsonObject;
|
||||
await db.worldState.update({
|
||||
where: { id: worldState.id },
|
||||
data: {
|
||||
config: {
|
||||
...originalConfig,
|
||||
npcMode: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('npc-possession-reject-mode-token', rejectedAuth))
|
||||
.join.listPossessCandidates({})
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '빙의 가능한 서버가 아닙니다',
|
||||
});
|
||||
|
||||
await db.worldState.update({
|
||||
where: { id: worldState.id },
|
||||
data: { config: originalConfig },
|
||||
});
|
||||
const reservation = await appRouter
|
||||
.createCaller(buildContext('npc-possession-reject-token', rejectedAuth))
|
||||
.join.listPossessCandidates({});
|
||||
const foreignToken = await db.npcSelectionToken.findUniqueOrThrow({
|
||||
where: { ownerUserId: otherUserId },
|
||||
});
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-reject-foreign', rejectedAuth)).join.possessGeneral({
|
||||
generalId: reservation.candidates[0]!.id,
|
||||
tokenNonce: foreignToken.nonce,
|
||||
clientRequestId: '44444444-4444-4444-8444-444444444444',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '유효한 장수 목록이 없습니다.',
|
||||
});
|
||||
|
||||
const reservedIds = new Set(reservation.candidates.map(({ id }) => id));
|
||||
const unlisted = await db.general.findFirstOrThrow({
|
||||
where: {
|
||||
userId: null,
|
||||
npcState: 2,
|
||||
id: { notIn: [...reservedIds] },
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-reject-unlisted', rejectedAuth)).join.possessGeneral({
|
||||
generalId: unlisted.id,
|
||||
tokenNonce: reservation.tokenNonce,
|
||||
clientRequestId: '55555555-5555-4555-8555-555555555555',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '선택한 장수가 목록에 없습니다.',
|
||||
});
|
||||
|
||||
await db.npcSelectionToken.update({
|
||||
where: { ownerUserId: rejectedUserId },
|
||||
data: { validUntil: new Date('2000-01-01T00:00:00.000Z') },
|
||||
});
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
|
||||
generalId: reservation.candidates[0]!.id,
|
||||
tokenNonce: reservation.tokenNonce,
|
||||
clientRequestId: '66666666-6666-4666-8666-666666666666',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '유효한 장수 목록이 없습니다.',
|
||||
});
|
||||
|
||||
const fresh = await appRouter
|
||||
.createCaller(buildContext('npc-possession-reject-fresh-token', rejectedAuth))
|
||||
.join.listPossessCandidates({});
|
||||
const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } });
|
||||
await db.worldState.update({
|
||||
where: { id: worldState.id },
|
||||
data: {
|
||||
config: {
|
||||
...originalConfig,
|
||||
npcMode: 1,
|
||||
maxGeneral: activeCount,
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('npc-possession-reject-cap', rejectedAuth)).join.possessGeneral({
|
||||
generalId: fresh.candidates[0]!.id,
|
||||
tokenNonce: fresh.tokenNonce,
|
||||
clientRequestId: '77777777-7777-4777-8777-777777777777',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '더 이상 등록 할 수 없습니다.',
|
||||
});
|
||||
await db.worldState.update({
|
||||
where: { id: worldState.id },
|
||||
data: { config: originalConfig },
|
||||
});
|
||||
|
||||
expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0);
|
||||
expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false);
|
||||
}, 45_000);
|
||||
});
|
||||
@@ -16,12 +16,16 @@ const profile: GameProfile = {
|
||||
name: 'che:default',
|
||||
};
|
||||
|
||||
const buildContext = (): GameApiContext => {
|
||||
const buildContext = (auth: GameSessionTokenPayload | null = null): GameApiContext => {
|
||||
const generalRows = [
|
||||
{
|
||||
id: 10,
|
||||
name: '관우',
|
||||
picture: '10.jpg',
|
||||
imageServer: 0,
|
||||
npcState: 1,
|
||||
age: 42,
|
||||
officerLevel: 5,
|
||||
nationId: 1,
|
||||
leadership: 90,
|
||||
strength: 95,
|
||||
@@ -31,12 +35,16 @@ const buildContext = (): GameApiContext => {
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
meta: { owner_name: '악령 관우', explevel: 4 },
|
||||
meta: { owner_name: '악령 관우', explevel: 4, killturn: 7 },
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
name: '조운',
|
||||
picture: '20.jpg',
|
||||
imageServer: 1,
|
||||
npcState: 2,
|
||||
age: 35,
|
||||
officerLevel: 0,
|
||||
nationId: 0,
|
||||
leadership: 90,
|
||||
strength: 95,
|
||||
@@ -48,16 +56,61 @@ const buildContext = (): GameApiContext => {
|
||||
special2Code: 'None',
|
||||
meta: { owner_name: '노출 금지', explevel: 5 },
|
||||
},
|
||||
{
|
||||
id: 30,
|
||||
name: '유비',
|
||||
picture: '30.jpg',
|
||||
imageServer: 0,
|
||||
npcState: 0,
|
||||
age: 44,
|
||||
officerLevel: 12,
|
||||
nationId: 1,
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
intel: 85,
|
||||
experience: 16_000,
|
||||
dedication: 10_000,
|
||||
personalCode: 'None',
|
||||
specialCode: 'None',
|
||||
special2Code: 'None',
|
||||
meta: { owner_name: '노출 금지', explevel: 9 },
|
||||
},
|
||||
];
|
||||
const db = {
|
||||
general: {
|
||||
findMany: async (args: { where: { npcState: { gt: number } } }) => {
|
||||
expect(args.where).toEqual({ npcState: { gt: 0 } });
|
||||
findMany: async (args: { where?: { npcState: { gt: number } } }) => {
|
||||
if (args.where) {
|
||||
expect(args.where).toEqual({ npcState: { gt: 0 } });
|
||||
return generalRows.filter((general) => general.npcState > 0);
|
||||
}
|
||||
return generalRows;
|
||||
},
|
||||
},
|
||||
nation: {
|
||||
findMany: async () => [{ id: 1, name: '촉' }],
|
||||
findMany: async () => [{ id: 1, name: '촉', level: 5 }],
|
||||
},
|
||||
npcSelectionToken: {
|
||||
findMany: async (args: { where: { validUntil: { gte: Date } } }) => {
|
||||
expect(args.where.validUntil.gte).toBeInstanceOf(Date);
|
||||
return [
|
||||
{
|
||||
pickResult: {
|
||||
10: { keepCount: 2 },
|
||||
invalid: { keepCount: 'bad' },
|
||||
},
|
||||
},
|
||||
{
|
||||
pickResult: {
|
||||
20: { keepCount: -1 },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({
|
||||
config: { const: { maxLevel: 255, maxDedLevel: 30 } },
|
||||
}),
|
||||
},
|
||||
};
|
||||
const redis = {
|
||||
@@ -70,7 +123,7 @@ const buildContext = (): GameApiContext => {
|
||||
turnDaemon: new InMemoryTurnDaemonTransport(),
|
||||
battleSim: new InMemoryBattleSimTransport(),
|
||||
profile,
|
||||
auth: null as GameSessionTokenPayload | null,
|
||||
auth,
|
||||
uploadDir: 'uploads',
|
||||
uploadPath: '/uploads',
|
||||
uploadPublicUrl: null,
|
||||
@@ -89,11 +142,17 @@ describe('public.getNpcList', () => {
|
||||
{
|
||||
id: 10,
|
||||
name: '관우',
|
||||
picture: '10.jpg',
|
||||
imageServer: 0,
|
||||
npcState: 1,
|
||||
ownerName: '악령 관우',
|
||||
age: 42,
|
||||
level: 4,
|
||||
officerLevel: 5,
|
||||
killturn: 7,
|
||||
nationId: 1,
|
||||
nationName: '촉',
|
||||
nationLevel: 5,
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
@@ -102,16 +161,24 @@ describe('public.getNpcList', () => {
|
||||
strength: 95,
|
||||
intelligence: 75,
|
||||
experience: 800,
|
||||
experienceText: '무명',
|
||||
dedication: 700,
|
||||
dedicationText: '28품관',
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
name: '조운',
|
||||
picture: '20.jpg',
|
||||
imageServer: 1,
|
||||
npcState: 2,
|
||||
ownerName: '',
|
||||
age: 35,
|
||||
level: 5,
|
||||
officerLevel: 0,
|
||||
killturn: 0,
|
||||
nationId: 0,
|
||||
nationName: '-',
|
||||
nationLevel: 0,
|
||||
personality: null,
|
||||
specialDomestic: null,
|
||||
specialWar: null,
|
||||
@@ -120,13 +187,53 @@ describe('public.getNpcList', () => {
|
||||
strength: 95,
|
||||
intelligence: 75,
|
||||
experience: 900,
|
||||
experienceText: '무명',
|
||||
dedication: 600,
|
||||
dedicationText: '28품관',
|
||||
},
|
||||
]);
|
||||
expect(result.tokenKeepCounts).toEqual({});
|
||||
expect(JSON.stringify(result)).not.toContain('노출 금지');
|
||||
expect(JSON.stringify(result)).not.toContain('userId');
|
||||
});
|
||||
|
||||
it('returns the full id-ordered list and every active reservation only to an authenticated caller', async () => {
|
||||
const auth: GameSessionTokenPayload = {
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-31T00:00:00.000Z',
|
||||
expiresAt: '2026-08-31T00:00:00.000Z',
|
||||
sessionId: 'npc-list-owner',
|
||||
user: {
|
||||
id: 'owner-user',
|
||||
username: 'owner-user',
|
||||
displayName: '소유자',
|
||||
roles: ['user'],
|
||||
legacyMemberNo: 101,
|
||||
},
|
||||
sanctions: {},
|
||||
};
|
||||
|
||||
const result = await appRouter
|
||||
.createCaller(buildContext(auth))
|
||||
.public.getNpcList({ sort: 1, includeAllWithToken: true });
|
||||
|
||||
expect(result.tokenKeepCounts).toEqual({ 10: 2, 20: 0 });
|
||||
expect(result.generals.map((general) => general.id)).toEqual([10, 20, 30]);
|
||||
expect(result.generals[2]).toMatchObject({
|
||||
npcState: 0,
|
||||
level: 40,
|
||||
experienceText: '지역적',
|
||||
dedicationText: '21품관',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the token-aware full list without an authenticated session', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext()).public.getNpcList({ sort: 1, includeAllWithToken: true })
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
|
||||
it('keeps pool rows before possessed NPCs when the selected value is tied', async () => {
|
||||
const result = await appRouter.createCaller(buildContext()).public.getNpcList({ sort: 3 });
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildNpcSelectionTokenSeed } from '../src/turn/npcPossessionService.js';
|
||||
|
||||
describe('NPC possession legacy token contracts', () => {
|
||||
it('builds the Ref SelectNPCToken seed from the Seoul whole-second timestamp', () => {
|
||||
expect(buildNpcSelectionTokenSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe(
|
||||
'str(4,seed)|str(14,SelectNPCToken)|int(42)|str(19,2026-07-31 08:59:58)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15134);
|
||||
const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15135);
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`;
|
||||
const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'npc_possession_integration';
|
||||
const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2';
|
||||
const expectedGameProfile = `${profileId}:${scenario}`;
|
||||
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? expectedGameProfile;
|
||||
const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`;
|
||||
const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`;
|
||||
const databaseUrl = process.env.NPC_POSSESSION_LIVE_DATABASE_URL ?? '';
|
||||
const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL ?? '';
|
||||
const gameSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET ?? '';
|
||||
const databaseSchema = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
|
||||
if (databaseUrl && databaseSchema !== profileId) {
|
||||
throw new Error(
|
||||
`NPC possession live schema must exactly match PLAYWRIGHT_PROFILE_ID: ${databaseSchema ?? '(missing)'} != ${profileId}`
|
||||
);
|
||||
}
|
||||
if (gameProfile !== expectedGameProfile) {
|
||||
throw new Error(
|
||||
`PLAYWRIGHT_GAME_PROFILE must match profile and scenario: ${gameProfile} != ${expectedGameProfile}`
|
||||
);
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['npcPossessionLive.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
reporter: [['list']],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/npc-possession-live'),
|
||||
use: {
|
||||
baseURL,
|
||||
...devices['Desktop Chrome'],
|
||||
deviceScaleFactor: 1,
|
||||
locale: 'ko-KR',
|
||||
timezoneId: 'Asia/Seoul',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: 'node app/game-api/dist/index.js',
|
||||
cwd: repositoryRoot,
|
||||
url: `http://127.0.0.1:${apiPort}/healthz`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
DATABASE_URL: databaseUrl,
|
||||
REDIS_URL: redisUrl,
|
||||
GAME_TOKEN_SECRET: gameSecret,
|
||||
GAME_API_HOST: '127.0.0.1',
|
||||
GAME_API_PORT: String(apiPort),
|
||||
PROFILE: profileId,
|
||||
SCENARIO: scenario,
|
||||
GAME_PROFILE_NAME: gameProfile,
|
||||
},
|
||||
},
|
||||
{
|
||||
command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${frontendPort}`,
|
||||
cwd: repositoryRoot,
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
type FixtureState = {
|
||||
reservationCalls: number;
|
||||
reservationInputs: Array<Record<string, unknown>>;
|
||||
rawBodies: unknown[];
|
||||
possessInputs: Array<Record<string, unknown>>;
|
||||
hasGeneral: boolean;
|
||||
injectTimeout: boolean;
|
||||
};
|
||||
|
||||
const candidates = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `빙의후보${index + 1}`,
|
||||
nation: { id: 0, name: '재야', color: '#aaaaaa' },
|
||||
stats: {
|
||||
leadership: 40 + index,
|
||||
strength: 50 + index,
|
||||
intelligence: 60 + index,
|
||||
},
|
||||
picture: 'default.jpg',
|
||||
imageServer: index === 0 ? 1 : 0,
|
||||
personality: { code: 'che_안전', name: '안전', info: '안전을 중시합니다.' },
|
||||
specialDomestic: { code: 'che_인덕', name: '인덕', info: '인덕 설명' },
|
||||
specialWar: { code: 'che_무쌍', name: '무쌍', info: '무쌍 설명' },
|
||||
keepCount: 3,
|
||||
}));
|
||||
const generalList = Array.from({ length: 55 }, (_, index) => {
|
||||
const candidate = candidates[index % candidates.length]!;
|
||||
return {
|
||||
id: index + 1,
|
||||
name: `전체장수${String(index + 1).padStart(2, '0')}`,
|
||||
picture: candidate.picture,
|
||||
imageServer: candidate.imageServer,
|
||||
npcState: index === 54 ? 1 : 2,
|
||||
ownerName: index === 54 ? '빙의자' : '',
|
||||
age: 25 + (index % 20),
|
||||
level: 3 + (index % 5),
|
||||
officerLevel: index % 5,
|
||||
killturn: index % 10,
|
||||
nationId: 0,
|
||||
nationName: '재야',
|
||||
nationLevel: 0,
|
||||
personality: { key: 'che_안전', name: '안전', info: '안전을 중시합니다.' },
|
||||
specialDomestic: { key: 'che_인덕', name: '인덕', info: '인덕 설명' },
|
||||
specialWar: { key: 'che_무쌍', name: '무쌍', info: '무쌍 설명' },
|
||||
statTotal: 150 + index,
|
||||
leadership: 40 + (index % 30),
|
||||
strength: 50 + (index % 20),
|
||||
intelligence: 60 + (index % 10),
|
||||
experience: 800 + index,
|
||||
experienceText: '무명',
|
||||
dedication: 700 + index,
|
||||
dedicationText: '28품관',
|
||||
};
|
||||
});
|
||||
generalList.push({
|
||||
...generalList[0]!,
|
||||
id: 56,
|
||||
name: '사용자장수',
|
||||
npcState: 0,
|
||||
ownerName: '',
|
||||
statTotal: 230,
|
||||
leadership: 80,
|
||||
strength: 70,
|
||||
intelligence: 80,
|
||||
experience: 16_000,
|
||||
experienceText: '지역적',
|
||||
dedication: 10_000,
|
||||
dedicationText: '21품관',
|
||||
});
|
||||
|
||||
const findInput = (value: unknown): Record<string, unknown> => {
|
||||
if (!value || typeof value !== 'object') return {};
|
||||
if ('json' in value && value.json && typeof value.json === 'object') {
|
||||
return value.json as Record<string, unknown>;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
['refresh', 'keepIds', 'generalId', 'tokenNonce', 'clientRequestId'].some((key) => Object.hasOwn(record, key))
|
||||
) {
|
||||
return record;
|
||||
}
|
||||
for (const nested of Object.values(value)) {
|
||||
const input = findInput(nested);
|
||||
if (Object.keys(input).length > 0) return input;
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, state: FixtureState): Promise<void> => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('sammo-game-token', 'ga_npc_possession');
|
||||
localStorage.setItem('sammo-game-profile', 'che:default');
|
||||
});
|
||||
await page.route('**/image/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||
});
|
||||
});
|
||||
await page.route('**/gateway/api/user-icons/default.jpg', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#8855aa"/></svg>',
|
||||
});
|
||||
});
|
||||
await page.route('**/events**', async (route) => route.abort());
|
||||
await page.route(`**${basePath}/api/trpc/**`, async (route) => {
|
||||
const operations = operationNames(route);
|
||||
const rawBody: unknown = route.request().postData() ? route.request().postDataJSON() : {};
|
||||
state.rawBodies.push(rawBody);
|
||||
const body = rawBody && typeof rawBody === 'object' ? (rawBody as Record<string, unknown>) : {};
|
||||
const results = operations.map((operation, index) => {
|
||||
const input = findInput(body[String(index)] ?? body);
|
||||
if (operation === 'lobby.info') {
|
||||
return response({
|
||||
myGeneral: state.hasGeneral ? { id: 1, name: '빙의후보1' } : null,
|
||||
year: 180,
|
||||
month: 1,
|
||||
turnTerm: 5,
|
||||
});
|
||||
}
|
||||
if (operation === 'join.getConfig') {
|
||||
return response({
|
||||
rules: {
|
||||
stat: { total: 165, min: 15, max: 80, bonusMin: 3, bonusMax: 5 },
|
||||
allowCustomName: true,
|
||||
},
|
||||
user: { id: 'npc-user', displayName: '빙의사용자', canCreateGeneral: true },
|
||||
personalities: [{ key: 'Random', name: '???', info: '' }],
|
||||
warSpecials: [],
|
||||
nations: [],
|
||||
serverInfo: {
|
||||
currentYear: 180,
|
||||
currentMonth: 1,
|
||||
tickMinutes: 5,
|
||||
maxGeneral: 500,
|
||||
userGeneralCount: 0,
|
||||
npcGeneralCount: 12,
|
||||
},
|
||||
inherit: {
|
||||
totalPoint: 0,
|
||||
costs: {
|
||||
inheritBornSpecialPoint: 0,
|
||||
inheritBornTurntimePoint: 0,
|
||||
inheritBornCityPoint: 0,
|
||||
inheritBornStatPoint: 0,
|
||||
},
|
||||
availableCities: [],
|
||||
turnTimeZones: [],
|
||||
availableSpecialWar: [],
|
||||
},
|
||||
selectionPool: { enabled: false },
|
||||
npcPossession: { enabled: true },
|
||||
});
|
||||
}
|
||||
if (operation === 'join.listPossessCandidates') {
|
||||
state.reservationCalls += 1;
|
||||
state.reservationInputs.push(input);
|
||||
const refresh = input.refresh === true;
|
||||
const now = Date.now();
|
||||
const keepIds = Array.isArray(input.keepIds) ? input.keepIds : [];
|
||||
return response({
|
||||
tokenNonce: refresh ? 202 : 101,
|
||||
validUntil: new Date(now + 90_000).toISOString(),
|
||||
pickMoreFrom: new Date(refresh ? now + 1_200 : now - 1_000).toISOString(),
|
||||
pickMoreSeconds: refresh ? 2 : 0,
|
||||
candidates: candidates.map((candidate) => ({
|
||||
...candidate,
|
||||
keepCount: refresh && keepIds.includes(candidate.id) ? 2 : 3,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (operation === 'public.getNpcList') {
|
||||
return response({
|
||||
sort: 1,
|
||||
generals: generalList,
|
||||
tokenKeepCounts: { 1: 2, 2: 1 },
|
||||
});
|
||||
}
|
||||
if (operation === 'join.possessGeneral') {
|
||||
state.possessInputs.push(input);
|
||||
if (state.injectTimeout) {
|
||||
state.injectTimeout = false;
|
||||
return {
|
||||
error: {
|
||||
message:
|
||||
'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
|
||||
code: -32008,
|
||||
data: {
|
||||
code: 'TIMEOUT',
|
||||
httpStatus: 408,
|
||||
path: 'join.possessGeneral',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
state.hasGeneral = true;
|
||||
return response({ ok: true, generalId: Number(input.generalId) });
|
||||
}
|
||||
return response({});
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(operations.length === 1 ? results[0] : results),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
test('renders Ref-shaped token cards, preserves keep cooldown and retries possession with one ID', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: FixtureState = {
|
||||
reservationCalls: 0,
|
||||
reservationInputs: [],
|
||||
rawBodies: [],
|
||||
possessInputs: [],
|
||||
hasGeneral: false,
|
||||
injectTimeout: true,
|
||||
};
|
||||
await installFixture(page, state);
|
||||
await page.setViewportSize({ width: 1024, height: 900 });
|
||||
await page.goto('join?tab=possess');
|
||||
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
|
||||
|
||||
await expect(page.locator('.npc-card')).toHaveCount(5);
|
||||
await expect(page.getByText(/까지 유효/)).toBeVisible();
|
||||
const geometry = await page.locator('.npc-possession-section').evaluate((section) => {
|
||||
const sectionRect = section.getBoundingClientRect();
|
||||
const cards = [...section.querySelectorAll<HTMLElement>('.npc-card')];
|
||||
const image = section.querySelector<HTMLImageElement>('.npc-card-image');
|
||||
return {
|
||||
sectionWidth: sectionRect.width,
|
||||
cardWidths: cards.map((card) => card.getBoundingClientRect().width),
|
||||
imageWidth: image?.getBoundingClientRect().width,
|
||||
imageHeight: image?.getBoundingClientRect().height,
|
||||
imageNaturalWidth: image?.naturalWidth,
|
||||
imageNaturalHeight: image?.naturalHeight,
|
||||
};
|
||||
});
|
||||
expect(geometry).toEqual({
|
||||
sectionWidth: 1000,
|
||||
cardWidths: [125, 125, 125, 125, 125],
|
||||
imageWidth: 64,
|
||||
imageHeight: 64,
|
||||
imageNaturalWidth: 64,
|
||||
imageNaturalHeight: 64,
|
||||
});
|
||||
const tooltip = page.locator('.npc-tooltip').first();
|
||||
const tooltipPopup = tooltip.getByRole('tooltip');
|
||||
await expect(tooltipPopup).toBeHidden();
|
||||
await tooltip.hover();
|
||||
await expect(tooltipPopup).toBeVisible();
|
||||
await tooltip.focus();
|
||||
await expect(tooltip).toBeFocused();
|
||||
await expect(tooltipPopup).toHaveText('안전을 중시합니다.');
|
||||
await expect(page.locator('.npc-card-image').first()).toHaveAttribute('src', '/gateway/api/user-icons/default.jpg');
|
||||
|
||||
await page.locator('#btn-load-general-list').click();
|
||||
await expect(page.locator('#tb-general-list')).toBeVisible();
|
||||
await expect(page.locator('#tb-general-list tbody tr')).toHaveCount(50);
|
||||
await expect(page.locator('#tb-general-list tbody tr').first()).toHaveAttribute('data-general-id', '56');
|
||||
await expect(page.locator('#tb-general-list tbody tr').first()).toHaveAttribute('data-reservation-state', '2');
|
||||
const selectedRow = page.locator('#tb-general-list tbody tr[data-general-id="1"]');
|
||||
await expect(selectedRow).toHaveAttribute('data-reservation-state', '1');
|
||||
await expect(selectedRow.locator('.npc-general-name')).toHaveCSS('color', 'rgb(238, 130, 238)');
|
||||
await expect(selectedRow.locator('.npc-general-name')).toContainText('(2회)');
|
||||
const listGeometry = await page.locator('#tb-general-list').evaluate((table) => {
|
||||
const rect = table.getBoundingClientRect();
|
||||
const icon = table.querySelector<HTMLImageElement>('.npc-general-icon');
|
||||
return {
|
||||
width: rect.width,
|
||||
iconWidth: icon?.getBoundingClientRect().width,
|
||||
iconHeight: icon?.getBoundingClientRect().height,
|
||||
iconNaturalWidth: icon?.naturalWidth,
|
||||
iconNaturalHeight: icon?.naturalHeight,
|
||||
};
|
||||
});
|
||||
expect(listGeometry).toEqual({
|
||||
width: 970,
|
||||
iconWidth: 64,
|
||||
iconHeight: 64,
|
||||
iconNaturalWidth: 64,
|
||||
iconNaturalHeight: 64,
|
||||
});
|
||||
await page.locator('#btn-print-more-generals').click();
|
||||
await expect(page.locator('#tb-general-list tbody tr')).toHaveCount(56);
|
||||
await expect(page.locator('#btn-print-more-generals')).toHaveCount(0);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('npc-possession-candidates-and-list.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await page.locator('.npc-keep input').first().check();
|
||||
await page.getByRole('button', { name: '다른 장수 보기' }).click();
|
||||
await expect.poll(() => state.reservationCalls).toBe(2);
|
||||
expect(state.reservationInputs.at(-1), JSON.stringify(state.rawBodies.at(-1))).toMatchObject({
|
||||
refresh: true,
|
||||
keepIds: [1],
|
||||
});
|
||||
await expect(page.locator('.npc-keep').first()).toContainText('보관(2회)');
|
||||
const refreshButton = page.locator('#btn-pick-more');
|
||||
await expect(refreshButton).toBeDisabled();
|
||||
await expect(refreshButton).toContainText(/다른 장수 보기\([12]초\)/);
|
||||
await expect(refreshButton).toBeEnabled({ timeout: 3_000 });
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogs.push(dialog.message());
|
||||
if (
|
||||
dialog.type() === 'confirm' &&
|
||||
dialogs.filter((message) => message.startsWith('빙의할까요?')).length === 1
|
||||
) {
|
||||
await dialog.dismiss();
|
||||
return;
|
||||
}
|
||||
await dialog.accept();
|
||||
});
|
||||
const possessButton = page.locator('.npc-action').first();
|
||||
await possessButton.click();
|
||||
expect(state.possessInputs).toHaveLength(0);
|
||||
|
||||
await possessButton.click();
|
||||
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
|
||||
expect(state.possessInputs).toHaveLength(1);
|
||||
const firstRequestId = state.possessInputs[0]?.clientRequestId;
|
||||
expect(firstRequestId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toContain(
|
||||
firstRequestId as string
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const expiredNow = Date.now() + 120_000;
|
||||
Date.now = () => expiredNow;
|
||||
});
|
||||
await expect(page.locator('.npc-token-expired')).toBeVisible();
|
||||
await expect(possessButton).toBeEnabled();
|
||||
await expect(refreshButton).toBeDisabled();
|
||||
await page.locator('#btn-retry-possession').click();
|
||||
await expect(page).toHaveURL(new RegExp(`${basePath}/$`));
|
||||
expect(state.possessInputs).toHaveLength(2);
|
||||
expect(state.possessInputs[1]?.clientRequestId).toBe(firstRequestId);
|
||||
expect(dialogs).toContain('빙의에 성공했습니다.');
|
||||
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull();
|
||||
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('npc-possession-success.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase } from '../../game-engine/dist/index.js';
|
||||
import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js';
|
||||
|
||||
const databaseUrl = process.env.NPC_POSSESSION_LIVE_DATABASE_URL;
|
||||
const redisUrl = process.env.NPC_POSSESSION_LIVE_REDIS_URL;
|
||||
const gameTokenSecret = process.env.NPC_POSSESSION_LIVE_GAME_SECRET;
|
||||
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'npc_possession_integration:2';
|
||||
const scenarioId = Number(process.env.PLAYWRIGHT_SCENARIO ?? '2');
|
||||
const userId = 'npc-possession-live-user';
|
||||
const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret);
|
||||
|
||||
if (!Number.isSafeInteger(scenarioId) || scenarioId <= 0 || profile !== `${profile.split(':', 1)[0]}:${scenarioId}`) {
|
||||
throw new Error(`NPC possession live scenario/profile mismatch: ${profile} / ${scenarioId}`);
|
||||
}
|
||||
|
||||
const installSession = async (page: Page): Promise<void> => {
|
||||
const now = new Date();
|
||||
const gameToken = encryptGameSessionToken(
|
||||
{
|
||||
version: 1,
|
||||
profile,
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: new Date(now.getTime() + 3_600_000).toISOString(),
|
||||
sessionId: `npc-possession-live-${randomUUID()}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'npc-possession-live-user',
|
||||
displayName: '브라우저빙의',
|
||||
roles: ['user'],
|
||||
legacyMemberNo: 7_710,
|
||||
canUseGeneralPicture: false,
|
||||
},
|
||||
sanctions: {},
|
||||
identity: {
|
||||
kakaoVerified: true,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: false,
|
||||
graceEndsAt: null,
|
||||
},
|
||||
},
|
||||
gameTokenSecret!
|
||||
);
|
||||
await page.addInitScript(
|
||||
({ token, gameProfile }) => {
|
||||
window.localStorage.setItem('sammo-game-token', token);
|
||||
window.localStorage.setItem('sammo-game-profile', gameProfile);
|
||||
},
|
||||
{ token: gameToken, gameProfile: profile }
|
||||
);
|
||||
};
|
||||
|
||||
test.describe('NPC possession through live PostgreSQL, Redis, API, daemon, and Chromium', () => {
|
||||
test.skip(!hasLiveFixture, 'live NPC possession token and database are required');
|
||||
|
||||
let db: ReturnType<typeof createGamePostgresConnector>['prisma'];
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let runtime: Awaited<ReturnType<typeof createTurnDaemonRuntime>> | undefined;
|
||||
let daemonLoop: Promise<void> | undefined;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const schema = new URL(databaseUrl!).searchParams.get('schema');
|
||||
const profileId = profile.split(':', 1)[0] ?? '';
|
||||
if (schema !== profileId || !schema.endsWith('npc_possession_integration')) {
|
||||
throw new Error(
|
||||
`Refusing mismatched or non-dedicated schema: ${schema ?? '(missing)'} != ${profileId ?? '(missing)'}`
|
||||
);
|
||||
}
|
||||
const previousSeed = process.env.INTEGRATION_WORLD_SEED;
|
||||
process.env.INTEGRATION_WORLD_SEED = 'npc-possession-live-seed';
|
||||
try {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date('2099-07-31T12:00:00.000Z'),
|
||||
installOptions: {
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 1,
|
||||
showImgLevel: 3,
|
||||
serverId: profile,
|
||||
season: 1,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (previousSeed === undefined) {
|
||||
delete process.env.INTEGRATION_WORLD_SEED;
|
||||
} else {
|
||||
process.env.INTEGRATION_WORLD_SEED = previousSeed;
|
||||
}
|
||||
}
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany();
|
||||
await db.logEntry.deleteMany();
|
||||
await db.npcSelectionToken.deleteMany();
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
await db.general.createMany({
|
||||
data: Array.from({ length: 12 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
userId: null,
|
||||
name: `실브라우저후보${index + 1}`,
|
||||
nationId: 0,
|
||||
cityId: city.id,
|
||||
npcState: 2,
|
||||
leadership: 40 + index,
|
||||
strength: 50 + index,
|
||||
intel: 60 + index,
|
||||
turnTime: new Date('2099-07-31T12:05:00.000Z'),
|
||||
personalCode: 'che_안전',
|
||||
specialCode: 'che_인덕',
|
||||
special2Code: 'che_무쌍',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
meta: { killturn: 6 },
|
||||
penalty: {},
|
||||
})),
|
||||
});
|
||||
runtime = await createTurnDaemonRuntime({
|
||||
profile,
|
||||
databaseUrl: databaseUrl!,
|
||||
enableDatabaseFlush: true,
|
||||
enableLeaseHeartbeat: false,
|
||||
leaseOwnerId: 'npc-possession-live-daemon',
|
||||
});
|
||||
daemonLoop = runtime.lifecycle.start();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (runtime) {
|
||||
await runtime.lifecycle.stop('NPC possession live complete');
|
||||
await daemonLoop;
|
||||
await runtime.close();
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
test('replays one completed possession after the browser receives an indeterminate timeout', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requestIds: string[] = [];
|
||||
let injectTimeout = true;
|
||||
await installSession(page);
|
||||
await page.route('**/image/icons/**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'image/svg+xml',
|
||||
body: '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#777"/></svg>',
|
||||
});
|
||||
});
|
||||
await page.route('**/trpc/join.possessGeneral?batch=1', async (route) => {
|
||||
const findClientRequestId = (value: unknown): string | undefined => {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
if ('clientRequestId' in value && typeof value.clientRequestId === 'string') {
|
||||
return value.clientRequestId;
|
||||
}
|
||||
for (const nested of Object.values(value)) {
|
||||
const found = findClientRequestId(nested);
|
||||
if (found) return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const clientRequestId = findClientRequestId(route.request().postDataJSON());
|
||||
expect(clientRequestId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
requestIds.push(clientRequestId!);
|
||||
if (!injectTimeout) {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
injectTimeout = false;
|
||||
const accepted = await route.fetch();
|
||||
expect(accepted.ok()).toBe(true);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{
|
||||
error: {
|
||||
message:
|
||||
'NPC 빙의 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
|
||||
code: -32008,
|
||||
data: {
|
||||
code: 'TIMEOUT',
|
||||
httpStatus: 408,
|
||||
path: 'join.possessGeneral',
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1024, height: 900 });
|
||||
await page.goto('join?tab=possess');
|
||||
await expect(page.getByRole('button', { name: 'NPC 빙의' })).toHaveClass(/active/);
|
||||
await expect(page.locator('.npc-card')).toHaveCount(5);
|
||||
const geometry = await page.locator('.npc-possession-section').evaluate((section) => ({
|
||||
width: section.getBoundingClientRect().width,
|
||||
cardWidths: [...section.querySelectorAll<HTMLElement>('.npc-card')].map(
|
||||
(card) => card.getBoundingClientRect().width
|
||||
),
|
||||
}));
|
||||
expect(geometry).toEqual({
|
||||
width: 1000,
|
||||
cardWidths: [125, 125, 125, 125, 125],
|
||||
});
|
||||
|
||||
const dialogs: string[] = [];
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogs.push(dialog.message());
|
||||
await dialog.accept();
|
||||
});
|
||||
const possessButton = page.locator('.npc-action').first();
|
||||
await possessButton.click();
|
||||
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
|
||||
await expect.poll(() => db.general.count({ where: { userId } })).toBe(1);
|
||||
const pending = await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'));
|
||||
expect(pending).toContain(requestIds[0]);
|
||||
|
||||
await possessButton.click();
|
||||
await expect(page).toHaveURL(/\/hwe\/$/);
|
||||
expect(requestIds).toHaveLength(2);
|
||||
expect(requestIds[1]).toBe(requestIds[0]);
|
||||
expect(await db.general.count({ where: { userId } })).toBe(1);
|
||||
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-npc-possess-pending-action'))).toBeNull();
|
||||
expect(dialogs).toContain('빙의에 성공했습니다.');
|
||||
const event = await db.inputEvent.findFirstOrThrow({
|
||||
where: { actorUserId: userId, eventType: 'npcPossessGeneral' },
|
||||
});
|
||||
expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
|
||||
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('npc-possession-live-success.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
'commandArgumentsLive.spec.ts',
|
||||
'mainNavigation.spec.ts',
|
||||
'session-auth.spec.ts',
|
||||
'npcPossession.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"target": "ES2022",
|
||||
"types": ["node", "@playwright/test"]
|
||||
},
|
||||
"include": ["./joinGeneralLive.spec.ts", "./joinGeneral.live.playwright.config.mjs"]
|
||||
"include": [
|
||||
"./joinGeneralLive.spec.ts",
|
||||
"./joinGeneral.live.playwright.config.mjs",
|
||||
"./npcPossessionLive.spec.ts",
|
||||
"./npcPossession.live.playwright.config.mjs"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||
"test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs",
|
||||
"test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node -e \"console.log('test not configured')\"",
|
||||
|
||||
Vendored
+1
@@ -14,6 +14,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_GAME_ASSET_URL?: string;
|
||||
readonly VITE_GAME_PROFILE?: string;
|
||||
readonly VITE_GATEWAY_WEB_URL?: string;
|
||||
readonly VITE_GATEWAY_USER_ICON_BASE_URL?: string;
|
||||
readonly VITE_BOARD_COMMUNITY_URL?: string;
|
||||
readonly VITE_BOARD_REQUEST_URL?: string;
|
||||
readonly VITE_BOARD_TIP_URL?: string;
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { RouterLink, useRouter } from 'vue-router';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import PanelCard from '../components/ui/PanelCard.vue';
|
||||
import SkeletonLines from '../components/ui/SkeletonLines.vue';
|
||||
import { trpc } from '../utils/trpc';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { cityLevelMap, regionMap } from '../utils/nationFormat';
|
||||
import { cityLevelMap, formatOfficerLevelText, regionMap } from '../utils/nationFormat';
|
||||
import { getNpcColor } from '../utils/npcColor';
|
||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||
|
||||
type JoinConfig = Awaited<ReturnType<typeof trpc.join.getConfig.query>>;
|
||||
type JoinInput = Parameters<typeof trpc.join.createGeneral.mutate>[0];
|
||||
type PossessCandidate = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.query>>[0];
|
||||
type PossessReservation = Awaited<ReturnType<typeof trpc.join.listPossessCandidates.mutate>>;
|
||||
type PossessCandidate = PossessReservation['candidates'][number];
|
||||
type NpcGeneralList = Awaited<ReturnType<typeof trpc.public.getNpcList.query>>;
|
||||
type NpcGeneralRow = NpcGeneralList['generals'][number] & {
|
||||
reservationState: 0 | 1 | 2;
|
||||
keepCount: number | null;
|
||||
};
|
||||
type JoinForm = Omit<JoinInput, 'inheritBonusStat' | 'clientRequestId'> & {
|
||||
inheritBonusStat: [number, number, number];
|
||||
};
|
||||
@@ -18,8 +26,15 @@ type PendingJoinAction = {
|
||||
input: JoinForm;
|
||||
clientRequestId: string;
|
||||
};
|
||||
type PendingPossessAction = {
|
||||
ownerUserId: string;
|
||||
generalId: number;
|
||||
tokenNonce: number;
|
||||
clientRequestId: string;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const session = useSessionStore();
|
||||
|
||||
const loading = ref(true);
|
||||
@@ -29,6 +44,7 @@ const submitting = ref(false);
|
||||
const joinConfig = ref<JoinConfig | null>(null);
|
||||
const activeTab = ref<'create' | 'possess'>('create');
|
||||
const pendingJoinStorageKey = 'sammo-join-create-pending-action';
|
||||
const pendingPossessStorageKey = 'sammo-npc-possess-pending-action';
|
||||
|
||||
const form = ref<JoinForm>({
|
||||
name: '',
|
||||
@@ -83,17 +99,147 @@ const clearPendingJoin = (pending: PendingJoinAction): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const readPendingPossess = (): PendingPossessAction | null => {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(pendingPossessStorageKey);
|
||||
if (!raw) return null;
|
||||
const value = JSON.parse(raw) as Partial<PendingPossessAction>;
|
||||
if (
|
||||
typeof value.ownerUserId !== 'string' ||
|
||||
typeof value.generalId !== 'number' ||
|
||||
typeof value.tokenNonce !== 'number' ||
|
||||
typeof value.clientRequestId !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as PendingPossessAction;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getPendingPossess = (generalId: number, tokenNonce: number): PendingPossessAction => {
|
||||
const ownerUserId = joinConfig.value?.user.id ?? '';
|
||||
const current = pendingPossessAction.value ?? readPendingPossess();
|
||||
if (
|
||||
current &&
|
||||
current.ownerUserId === ownerUserId &&
|
||||
current.generalId === generalId &&
|
||||
current.tokenNonce === tokenNonce
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
const pending: PendingPossessAction = {
|
||||
ownerUserId,
|
||||
generalId,
|
||||
tokenNonce,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
};
|
||||
window.sessionStorage.setItem(pendingPossessStorageKey, JSON.stringify(pending));
|
||||
pendingPossessAction.value = pending;
|
||||
return pending;
|
||||
};
|
||||
|
||||
const clearPendingPossess = (pending: PendingPossessAction): void => {
|
||||
if (readPendingPossess()?.clientRequestId === pending.clientRequestId) {
|
||||
window.sessionStorage.removeItem(pendingPossessStorageKey);
|
||||
}
|
||||
if (pendingPossessAction.value?.clientRequestId === pending.clientRequestId) {
|
||||
pendingPossessAction.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const isIndeterminateTimeout = (value: unknown): boolean => {
|
||||
if (!value || typeof value !== 'object' || !('data' in value)) return false;
|
||||
const data = value.data;
|
||||
return Boolean(data && typeof data === 'object' && 'code' in data && data.code === 'TIMEOUT');
|
||||
};
|
||||
|
||||
const npcCandidates = ref<PossessCandidate[]>([]);
|
||||
const isTrpcBusinessError = (value: unknown): boolean => {
|
||||
if (!value || typeof value !== 'object' || !('data' in value)) return false;
|
||||
const data = value.data;
|
||||
return Boolean(data && typeof data === 'object' && 'code' in data && typeof data.code === 'string');
|
||||
};
|
||||
|
||||
const npcImageUrl = (candidate: { picture: string | null; imageServer: number }): string => {
|
||||
const picture = candidate.picture ?? 'default.jpg';
|
||||
const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
|
||||
return candidate.imageServer
|
||||
? `${userIconBaseUrl.replace(/\/$/, '')}/${encodeURIComponent(picture)}`
|
||||
: `/image/icons/${encodeURIComponent(picture)}`;
|
||||
};
|
||||
|
||||
const useDefaultNpcImage = (event: Event): void => {
|
||||
const image = event.currentTarget;
|
||||
if (image instanceof HTMLImageElement && !image.src.endsWith('/image/icons/default.jpg')) {
|
||||
image.src = '/image/icons/default.jpg';
|
||||
}
|
||||
};
|
||||
|
||||
const npcReservation = ref<PossessReservation | null>(null);
|
||||
const npcLoading = ref(false);
|
||||
const npcError = ref<string | null>(null);
|
||||
const npcOffset = ref(0);
|
||||
const npcLimit = 20;
|
||||
const keptNpcIds = ref<number[]>([]);
|
||||
const nowMs = ref(Date.now());
|
||||
const npcPickMoreAvailableAtMs = ref(0);
|
||||
const pendingPossessAction = ref<PendingPossessAction | null>(null);
|
||||
const npcGeneralList = ref<NpcGeneralList | null>(null);
|
||||
const npcGeneralListLoading = ref(false);
|
||||
const npcGeneralListError = ref('');
|
||||
const npcGeneralListVisibleCount = ref(50);
|
||||
let npcTimer: number | null = null;
|
||||
|
||||
const npcCandidates = computed<PossessCandidate[]>(() => npcReservation.value?.candidates ?? []);
|
||||
const npcValidUntilMs = computed(() => {
|
||||
const value = npcReservation.value?.validUntil;
|
||||
return value ? new Date(value).getTime() : 0;
|
||||
});
|
||||
const npcExpired = computed(() => npcValidUntilMs.value > 0 && npcValidUntilMs.value < nowMs.value);
|
||||
const npcPickMoreSeconds = computed(() =>
|
||||
Math.max(0, Math.ceil((npcPickMoreAvailableAtMs.value - nowMs.value) / 1000))
|
||||
);
|
||||
const hasPendingPossession = computed(
|
||||
() => pendingPossessAction.value !== null && pendingPossessAction.value.ownerUserId === joinConfig.value?.user.id
|
||||
);
|
||||
const isPendingPossessCandidate = (candidate: PossessCandidate): boolean => {
|
||||
const pending = pendingPossessAction.value;
|
||||
return Boolean(
|
||||
pending &&
|
||||
pending.ownerUserId === joinConfig.value?.user.id &&
|
||||
pending.generalId === candidate.id &&
|
||||
pending.tokenNonce === npcReservation.value?.tokenNonce
|
||||
);
|
||||
};
|
||||
const npcGeneralRows = computed<NpcGeneralRow[]>(() => {
|
||||
const list = npcGeneralList.value;
|
||||
if (!list) {
|
||||
return [];
|
||||
}
|
||||
return list.generals
|
||||
.map((general) => {
|
||||
const keepCount = list.tokenKeepCounts[String(general.id)];
|
||||
return {
|
||||
...general,
|
||||
reservationState: general.npcState < 2 ? 2 : keepCount !== undefined ? 1 : 0,
|
||||
keepCount: keepCount ?? null,
|
||||
} as NpcGeneralRow;
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.reservationState - left.reservationState ||
|
||||
right.statTotal - left.statTotal ||
|
||||
// Ref select_npc.ts has this asymmetric comparator. Keep it because the visible order is contractual.
|
||||
right.leadership - left.statTotal ||
|
||||
(left.name < right.name ? -1 : left.name > right.name ? 1 : 0)
|
||||
);
|
||||
});
|
||||
const visibleNpcGeneralRows = computed(() => npcGeneralRows.value.slice(0, npcGeneralListVisibleCount.value));
|
||||
const npcValidColor = computed(() => {
|
||||
const remaining = npcValidUntilMs.value - nowMs.value;
|
||||
if (remaining > 30_000) return '#ffffff';
|
||||
const channel = Math.max(0, Math.min(255, Math.round((remaining / 30_000) * 255)));
|
||||
return `rgb(255, ${channel}, ${channel})`;
|
||||
});
|
||||
|
||||
const statRules = computed(() => joinConfig.value?.rules.stat ?? null);
|
||||
const statTotal = computed(() => form.value.leadership + form.value.strength + form.value.intel);
|
||||
@@ -256,6 +402,11 @@ const loadConfig = async () => {
|
||||
return;
|
||||
}
|
||||
joinConfig.value = config;
|
||||
const storedPossession = readPendingPossess();
|
||||
pendingPossessAction.value = storedPossession?.ownerUserId === config.user.id ? storedPossession : null;
|
||||
if (route.query.tab === 'possess' && config.npcPossession.enabled && config.user.canCreateGeneral) {
|
||||
activeTab.value = 'possess';
|
||||
}
|
||||
const pending = readPendingJoin();
|
||||
if (pending?.ownerUserId === config.user.id) {
|
||||
form.value = pending.input;
|
||||
@@ -270,21 +421,31 @@ const loadConfig = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadNpcCandidates = async (reset = false) => {
|
||||
const loadNpcCandidates = async (refresh = false) => {
|
||||
npcLoading.value = true;
|
||||
npcError.value = null;
|
||||
try {
|
||||
if (reset) {
|
||||
npcOffset.value = 0;
|
||||
}
|
||||
const list = await trpc.join.listPossessCandidates.query({
|
||||
limit: npcLimit,
|
||||
offset: npcOffset.value,
|
||||
const reservation = await trpc.join.listPossessCandidates.mutate({
|
||||
refresh,
|
||||
...(refresh ? { keepIds: keptNpcIds.value } : {}),
|
||||
});
|
||||
npcCandidates.value = reset ? list : [...npcCandidates.value, ...list];
|
||||
npcOffset.value += list.length;
|
||||
npcReservation.value = reservation;
|
||||
keptNpcIds.value = [];
|
||||
const receivedAt = Date.now();
|
||||
nowMs.value = receivedAt;
|
||||
npcPickMoreAvailableAtMs.value = receivedAt + reservation.pickMoreSeconds * 1000;
|
||||
} catch (err) {
|
||||
npcError.value = err instanceof Error ? err.message : 'npc_list_failed';
|
||||
if (refresh) {
|
||||
window.alert(npcError.value);
|
||||
if (isTrpcBusinessError(err)) {
|
||||
window.location.reload();
|
||||
}
|
||||
} else if (isTrpcBusinessError(err)) {
|
||||
window.alert(npcError.value);
|
||||
} else {
|
||||
window.alert(`알 수 없는 에러: ${npcError.value}`);
|
||||
}
|
||||
} finally {
|
||||
npcLoading.value = false;
|
||||
}
|
||||
@@ -317,34 +478,96 @@ const submitJoin = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const possessGeneral = async (generalId: number) => {
|
||||
const submitPossession = async (pending: PendingPossessAction) => {
|
||||
if (submitting.value) {
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await trpc.join.possessGeneral.mutate({ generalId });
|
||||
await trpc.join.possessGeneral.mutate({
|
||||
generalId: pending.generalId,
|
||||
tokenNonce: pending.tokenNonce,
|
||||
clientRequestId: pending.clientRequestId,
|
||||
});
|
||||
clearPendingPossess(pending);
|
||||
window.alert('빙의에 성공했습니다.');
|
||||
await session.refreshGeneralStatus();
|
||||
if (session.hasGeneral) {
|
||||
await router.push({ name: 'home' });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isIndeterminateTimeout(err)) {
|
||||
clearPendingPossess(pending);
|
||||
}
|
||||
error.value = err instanceof Error ? err.message : 'possess_failed';
|
||||
if (isTrpcBusinessError(err) && !isIndeterminateTimeout(err)) {
|
||||
window.alert(error.value);
|
||||
window.location.reload();
|
||||
} else if (!isIndeterminateTimeout(err)) {
|
||||
window.alert(`알 수 없는 에러: ${error.value}`);
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const possessGeneral = async (candidate: PossessCandidate) => {
|
||||
const reservation = npcReservation.value;
|
||||
if (
|
||||
submitting.value ||
|
||||
!reservation ||
|
||||
(hasPendingPossession.value && !isPendingPossessCandidate(candidate)) ||
|
||||
!window.confirm(`빙의할까요? : ${candidate.name}`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await submitPossession(getPendingPossess(candidate.id, reservation.tokenNonce));
|
||||
};
|
||||
|
||||
const retryPendingPossession = async () => {
|
||||
const pending = pendingPossessAction.value;
|
||||
if (!pending || pending.ownerUserId !== joinConfig.value?.user.id) {
|
||||
return;
|
||||
}
|
||||
await submitPossession(pending);
|
||||
};
|
||||
|
||||
const loadNpcGeneralList = async () => {
|
||||
if (npcGeneralListLoading.value) {
|
||||
return;
|
||||
}
|
||||
npcGeneralListLoading.value = true;
|
||||
npcGeneralListError.value = '';
|
||||
try {
|
||||
npcGeneralList.value = await trpc.public.getNpcList.query({ sort: 1, includeAllWithToken: true });
|
||||
npcGeneralListVisibleCount.value = 50;
|
||||
} catch (err) {
|
||||
npcGeneralListError.value = err instanceof Error ? err.message : 'npc_general_list_failed';
|
||||
window.alert(`실패했습니다: ${npcGeneralListError.value}`);
|
||||
} finally {
|
||||
npcGeneralListLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(activeTab, (value) => {
|
||||
if (value === 'possess' && npcCandidates.value.length === 0) {
|
||||
void loadNpcCandidates(true);
|
||||
if (value === 'possess' && !npcReservation.value) {
|
||||
void loadNpcCandidates(false);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
npcTimer = window.setInterval(() => {
|
||||
nowMs.value = Date.now();
|
||||
}, 250);
|
||||
void loadConfig();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (npcTimer !== null) {
|
||||
window.clearInterval(npcTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -357,8 +580,21 @@ onMounted(() => {
|
||||
<div class="join-tabs">
|
||||
<RouterLink class="simulator-link" to="/past-plays">내 지난 플레이</RouterLink>
|
||||
<RouterLink class="simulator-link" to="/battle-simulator">전투 시뮬레이터</RouterLink>
|
||||
<button :class="{ active: activeTab === 'create' }" @click="activeTab = 'create'">장수 생성</button>
|
||||
<button :class="{ active: activeTab === 'possess' }" @click="activeTab = 'possess'">NPC 빙의</button>
|
||||
<button
|
||||
:class="{ active: activeTab === 'create' }"
|
||||
:disabled="joinConfig?.user.canCreateGeneral === false"
|
||||
@click="activeTab = 'create'"
|
||||
>
|
||||
장수 생성
|
||||
</button>
|
||||
<button
|
||||
v-if="joinConfig?.npcPossession.enabled"
|
||||
:class="{ active: activeTab === 'possess' }"
|
||||
:disabled="joinConfig?.user.canCreateGeneral === false"
|
||||
@click="activeTab = 'possess'"
|
||||
>
|
||||
NPC 빙의
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -543,36 +779,231 @@ onMounted(() => {
|
||||
</PanelCard>
|
||||
</section>
|
||||
|
||||
<section v-else class="join-grid">
|
||||
<PanelCard title="빙의 가능한 NPC 목록" subtitle="NPC 타입2 장수를 선택해 빙의합니다.">
|
||||
<template #actions>
|
||||
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates(true)">목록 새로고침</button>
|
||||
</template>
|
||||
<section v-else class="npc-possession-section">
|
||||
<PanelCard title="장수 빙의">
|
||||
<div v-if="npcError" class="muted">{{ npcError }}</div>
|
||||
<div v-if="npcLoading && npcCandidates.length === 0">
|
||||
<SkeletonLines :lines="3" />
|
||||
</div>
|
||||
<div v-else-if="npcCandidates.length === 0" class="muted">빙의 가능한 NPC가 없습니다.</div>
|
||||
<div v-else class="npc-list">
|
||||
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
||||
<div class="npc-header">
|
||||
<div class="npc-name">{{ npc.name }}</div>
|
||||
<div class="npc-nation" :style="{ color: npc.nation.color }">
|
||||
{{ npc.nation.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="npc-meta">
|
||||
<div>통솔 {{ npc.stats.leadership }}</div>
|
||||
<div>무력 {{ npc.stats.strength }}</div>
|
||||
<div>지력 {{ npc.stats.intelligence }}</div>
|
||||
<div>나이 {{ npc.age }}</div>
|
||||
<div>도시 {{ npc.city?.name ?? '-' }}</div>
|
||||
</div>
|
||||
<button class="npc-action" :disabled="submitting" @click="possessGeneral(npc.id)">빙의</button>
|
||||
<template v-else>
|
||||
<div class="npc-token-status">
|
||||
<span v-if="!npcExpired">
|
||||
(<span :style="{ color: npcValidColor }">{{
|
||||
npcReservation?.validUntil ? formatSeoulDateTime(npcReservation.validUntil) : ''
|
||||
}}</span
|
||||
>까지 유효)
|
||||
</span>
|
||||
<span v-else class="npc-token-expired">- 만료 -</span>
|
||||
</div>
|
||||
</div>
|
||||
<form class="npc-card-holder" @submit.prevent>
|
||||
<div v-for="npc in npcCandidates" :key="npc.id" class="npc-card">
|
||||
<h4 class="npc-card-name">{{ npc.name }}</h4>
|
||||
<h4>
|
||||
<img
|
||||
class="npc-card-image"
|
||||
:src="npcImageUrl(npc)"
|
||||
:alt="`${npc.name} 얼굴`"
|
||||
width="64"
|
||||
height="64"
|
||||
@error="useDefaultNpcImage"
|
||||
/>
|
||||
</h4>
|
||||
<p>
|
||||
{{ npc.stats.leadership }} / {{ npc.stats.strength }} / {{ npc.stats.intelligence
|
||||
}}<br />
|
||||
<span :style="{ color: npc.nation.color }">{{ npc.nation.name }}</span
|
||||
><br />
|
||||
<span class="npc-tooltip" tabindex="0">
|
||||
{{ npc.personality.name }}
|
||||
<span role="tooltip">{{ npc.personality.info }}</span>
|
||||
</span>
|
||||
<br />
|
||||
<span class="npc-tooltip" tabindex="0">
|
||||
{{ npc.specialDomestic.name }}
|
||||
<span role="tooltip">{{ npc.specialDomestic.info }}</span>
|
||||
</span>
|
||||
/
|
||||
<span class="npc-tooltip" tabindex="0">
|
||||
{{ npc.specialWar.name }}
|
||||
<span role="tooltip">{{ npc.specialWar.info }}</span>
|
||||
</span>
|
||||
</p>
|
||||
<button
|
||||
class="npc-action"
|
||||
type="button"
|
||||
:disabled="submitting || (hasPendingPossession && !isPendingPossessCandidate(npc))"
|
||||
@click="possessGeneral(npc)"
|
||||
>
|
||||
빙의하기
|
||||
</button>
|
||||
<label class="npc-keep">
|
||||
<input
|
||||
v-model="keptNpcIds"
|
||||
type="checkbox"
|
||||
:value="npc.id"
|
||||
:disabled="npc.keepCount <= 0"
|
||||
/>
|
||||
보관({{ npc.keepCount }}회)
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<div class="npc-footer">
|
||||
<button class="ghost" :disabled="npcLoading" @click="loadNpcCandidates()">더 보기</button>
|
||||
<button
|
||||
id="btn-pick-more"
|
||||
class="ghost"
|
||||
type="button"
|
||||
:disabled="npcLoading || npcPickMoreSeconds > 0 || submitting || hasPendingPossession"
|
||||
@click="loadNpcCandidates(true)"
|
||||
>
|
||||
다른 장수 보기<span v-if="npcPickMoreSeconds > 0">({{ npcPickMoreSeconds }}초)</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="hasPendingPossession"
|
||||
id="btn-retry-possession"
|
||||
class="ghost"
|
||||
type="button"
|
||||
:disabled="submitting"
|
||||
@click="retryPendingPossession"
|
||||
>
|
||||
접수 결과 다시 확인
|
||||
</button>
|
||||
<button
|
||||
id="btn-load-general-list"
|
||||
class="ghost npc-list-link"
|
||||
type="button"
|
||||
:disabled="npcGeneralListLoading"
|
||||
@click="loadNpcGeneralList"
|
||||
>
|
||||
{{ npcGeneralListLoading ? '불러오는 중...' : '장수 목록 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="npcGeneralListError" class="npc-general-list-error" role="alert">
|
||||
{{ npcGeneralListError }}
|
||||
</div>
|
||||
<div v-if="npcGeneralList" class="npc-general-list-wrap">
|
||||
<table id="tb-general-list" class="npc-general-table">
|
||||
<colgroup>
|
||||
<col style="width: 64px" />
|
||||
<col style="width: 140px" />
|
||||
<col style="width: 40px" />
|
||||
<col style="width: 40px" />
|
||||
<col style="width: 80px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 140px" />
|
||||
<col style="width: 50px" />
|
||||
<col style="width: 50px" />
|
||||
<col style="width: 75px" />
|
||||
<col style="width: 60px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 45px" />
|
||||
<col style="width: 45px" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>얼 굴</th>
|
||||
<th>이 름</th>
|
||||
<th>연령</th>
|
||||
<th>성격</th>
|
||||
<th>특기</th>
|
||||
<th>레 벨</th>
|
||||
<th>국 가</th>
|
||||
<th>명 성</th>
|
||||
<th>계 급</th>
|
||||
<th>관 직</th>
|
||||
<th>종능</th>
|
||||
<th>통솔</th>
|
||||
<th>무력</th>
|
||||
<th>지력</th>
|
||||
<th>삭턴</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="general in visibleNpcGeneralRows"
|
||||
:key="general.id"
|
||||
:data-general-id="general.id"
|
||||
:data-reservation-state="general.reservationState"
|
||||
>
|
||||
<td>
|
||||
<img
|
||||
class="npc-general-icon"
|
||||
:src="npcImageUrl(general)"
|
||||
:alt="`${general.name} 얼굴`"
|
||||
width="64"
|
||||
height="64"
|
||||
@error="useDefaultNpcImage"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
class="npc-general-name"
|
||||
:style="{
|
||||
color:
|
||||
general.reservationState === 1
|
||||
? 'violet'
|
||||
: general.npcState > 0
|
||||
? getNpcColor(general.npcState)
|
||||
: '',
|
||||
}"
|
||||
>
|
||||
{{ general.name }}
|
||||
<template v-if="general.ownerName">
|
||||
<br /><small>({{ general.ownerName }})</small>
|
||||
</template>
|
||||
<template v-if="general.reservationState === 1">
|
||||
<br /><small>({{ general.keepCount }}회)</small>
|
||||
</template>
|
||||
</td>
|
||||
<td>{{ general.age }}세</td>
|
||||
<td>
|
||||
<span v-if="general.personality" class="npc-tooltip" tabindex="0">
|
||||
{{ general.personality.name }}
|
||||
<span role="tooltip">{{ general.personality.info }}</span>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="general.specialDomestic" class="npc-tooltip" tabindex="0">
|
||||
{{ general.specialDomestic.name }}
|
||||
<span role="tooltip">{{ general.specialDomestic.info }}</span>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
/
|
||||
<span v-if="general.specialWar" class="npc-tooltip" tabindex="0">
|
||||
{{ general.specialWar.name }}
|
||||
<span role="tooltip">{{ general.specialWar.info }}</span>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td>Lv {{ general.level }}</td>
|
||||
<td>{{ general.nationName }}</td>
|
||||
<td>{{ general.experienceText }}</td>
|
||||
<td>{{ general.dedicationText }}</td>
|
||||
<td>{{ formatOfficerLevelText(general.officerLevel, general.nationLevel) }}</td>
|
||||
<td>{{ general.statTotal }}</td>
|
||||
<td>{{ general.leadership }}</td>
|
||||
<td>{{ general.strength }}</td>
|
||||
<td>{{ general.intelligence }}</td>
|
||||
<td>{{ general.killturn }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot v-if="visibleNpcGeneralRows.length < npcGeneralRows.length">
|
||||
<tr>
|
||||
<td colspan="15">
|
||||
<button
|
||||
id="btn-print-more-generals"
|
||||
type="button"
|
||||
@click="npcGeneralListVisibleCount += 50"
|
||||
>
|
||||
더 보기
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</PanelCard>
|
||||
</section>
|
||||
@@ -779,47 +1210,178 @@ onMounted(() => {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.npc-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
.npc-possession-section {
|
||||
width: 1000px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.npc-token-status {
|
||||
min-height: 22px;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.npc-token-expired {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.npc-card-holder {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npc-card {
|
||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
width: 125px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.npc-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
.npc-card h4,
|
||||
.npc-card p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.npc-name {
|
||||
font-weight: 600;
|
||||
.npc-card-name {
|
||||
min-height: 25px;
|
||||
border: 1px solid rgba(201, 164, 90, 0.3);
|
||||
font-size: 1rem;
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
.npc-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(232, 221, 196, 0.7);
|
||||
.npc-card-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.npc-card p {
|
||||
min-height: 78px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.npc-tooltip {
|
||||
position: relative;
|
||||
cursor: help;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
.npc-tooltip [role='tooltip'] {
|
||||
display: none;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 4px);
|
||||
width: 220px;
|
||||
padding: 5px 7px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid #888;
|
||||
background: #202020;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.npc-tooltip:hover [role='tooltip'],
|
||||
.npc-tooltip:focus [role='tooltip'] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.npc-action {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.npc-keep {
|
||||
display: block;
|
||||
padding-left: 15px;
|
||||
text-indent: -15px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.npc-keep input {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
vertical-align: bottom;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.npc-footer {
|
||||
margin-top: 8px;
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2ch;
|
||||
}
|
||||
|
||||
.npc-list-link {
|
||||
display: inline-block;
|
||||
border: 1px solid rgba(201, 164, 90, 0.4);
|
||||
padding: 4px 8px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.npc-general-list-error {
|
||||
margin-bottom: 8px;
|
||||
color: rgba(240, 150, 150, 0.9);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.npc-general-list-wrap {
|
||||
width: 970px;
|
||||
margin: 0 auto 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.npc-general-table {
|
||||
width: 970px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.npc-general-table th,
|
||||
.npc-general-table td {
|
||||
border: 1px solid gray;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.npc-general-table th {
|
||||
height: 24px;
|
||||
background: rgba(201, 164, 90, 0.15);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.npc-general-table tbody tr {
|
||||
height: 65px;
|
||||
}
|
||||
|
||||
.npc-general-icon {
|
||||
display: block;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.npc-general-name small {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
#btn-print-more-generals {
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.muted {
|
||||
|
||||
@@ -39,9 +39,7 @@ const resetScenario = async (page: Page, scenarioId: string, sourceCommit: strin
|
||||
const previousLatestOperation = await latestOperation.textContent();
|
||||
await page.getByTestId('request-reset').click();
|
||||
await expect(page.getByText('초기화 작업을 시작했습니다.')).toBeVisible();
|
||||
await expect
|
||||
.poll(() => latestOperation.textContent(), { timeout: 15_000 })
|
||||
.not.toBe(previousLatestOperation);
|
||||
await expect.poll(() => latestOperation.textContent(), { timeout: 15_000 }).not.toBe(previousLatestOperation);
|
||||
await expect(latestOperation).toContainText(sourceCommit, { timeout: 15_000 });
|
||||
await expect(latestOperation.locator('td').nth(3)).toHaveText('SUCCEEDED', {
|
||||
timeout: 300_000,
|
||||
@@ -71,7 +69,9 @@ const resetScenario = async (page: Page, scenarioId: string, sourceCommit: strin
|
||||
|
||||
type PossessCandidateBatch = Array<{
|
||||
result?: {
|
||||
data?: Array<{ picture?: string | null }>;
|
||||
data?: {
|
||||
candidates?: Array<{ picture?: string | null }>;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
@@ -99,11 +99,10 @@ const inspectScenarioIcons = async (
|
||||
await page.getByRole('button', { name: 'NPC 빙의' }).click();
|
||||
const payload = (await (await candidatesResponse).json()) as PossessCandidateBatch;
|
||||
const iconPaths = payload
|
||||
.flatMap((entry) => entry.result?.data ?? [])
|
||||
.flatMap((entry) => entry.result?.data?.candidates ?? [])
|
||||
.map((candidate) => candidate.picture)
|
||||
.filter(
|
||||
(picture): picture is string =>
|
||||
typeof picture === 'string' && picture.startsWith(`${expectedDirectory}/`)
|
||||
(picture): picture is string => typeof picture === 'string' && picture.startsWith(`${expectedDirectory}/`)
|
||||
);
|
||||
expect(iconPaths.length).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -18,12 +18,37 @@ const fulfillTrpc = async (route: Route, results: unknown[]): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page) => {
|
||||
type LobbyFixtureOptions = {
|
||||
canCreateGeneral?: boolean;
|
||||
myGeneral?: {
|
||||
name: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
} | null;
|
||||
selectionPoolEnabled?: boolean;
|
||||
npcPossessionEnabled?: boolean;
|
||||
userCnt?: number;
|
||||
maxUserCnt?: number;
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => {
|
||||
const {
|
||||
canCreateGeneral = true,
|
||||
myGeneral = {
|
||||
name: '선택장수',
|
||||
picture: 'account-hash.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
selectionPoolEnabled = true,
|
||||
npcPossessionEnabled = false,
|
||||
userCnt = 1,
|
||||
maxUserCnt = 500,
|
||||
} = options;
|
||||
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem('sammo-session-token', 'gateway-lobby-session');
|
||||
});
|
||||
await page.route('http://127.0.0.1:15130/api/trpc/**', async (route) => {
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
if (operation === 'me') {
|
||||
return response({
|
||||
@@ -57,7 +82,7 @@ const installFixture = async (page: Page) => {
|
||||
color: '#ffffff',
|
||||
localAccountPolicy: {
|
||||
accessAllowed: true,
|
||||
canCreateGeneral: true,
|
||||
canCreateGeneral,
|
||||
requiresKakaoVerification: false,
|
||||
graceEndsAt: null,
|
||||
},
|
||||
@@ -90,8 +115,8 @@ const installFixture = async (page: Page) => {
|
||||
return response({
|
||||
year: 180,
|
||||
month: 1,
|
||||
userCnt: 1,
|
||||
maxUserCnt: 500,
|
||||
userCnt,
|
||||
maxUserCnt,
|
||||
npcCnt: 0,
|
||||
nationCnt: 0,
|
||||
turnTerm: 5,
|
||||
@@ -101,12 +126,9 @@ const installFixture = async (page: Page) => {
|
||||
turntime: '2026-07-30 00:05:00',
|
||||
otherTextInfo: '',
|
||||
isUnited: 0,
|
||||
selectionPoolEnabled: true,
|
||||
myGeneral: {
|
||||
name: '선택장수',
|
||||
picture: 'account-hash.png',
|
||||
imageServer: 1,
|
||||
},
|
||||
selectionPoolEnabled,
|
||||
npcPossessionEnabled,
|
||||
myGeneral,
|
||||
});
|
||||
}
|
||||
if (operation === 'public.getMapLayout') {
|
||||
@@ -132,9 +154,7 @@ const installFixture = async (page: Page) => {
|
||||
return gameOperations;
|
||||
};
|
||||
|
||||
test('exchanges the gateway token before loading authenticated lobby general data', async ({
|
||||
page,
|
||||
}) => {
|
||||
test('exchanges the gateway token before loading authenticated lobby general data', async ({ page }) => {
|
||||
const gameOperations = await installFixture(page);
|
||||
|
||||
await page.goto('lobby');
|
||||
@@ -142,10 +162,7 @@ test('exchanges the gateway token before loading authenticated lobby general dat
|
||||
await expect(row).toContainText('선택장수');
|
||||
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
|
||||
const portrait = row.locator('img');
|
||||
await expect(portrait).toHaveAttribute(
|
||||
'src',
|
||||
'/gateway/api/user-icons/account-hash.png'
|
||||
);
|
||||
await expect(portrait).toHaveAttribute('src', '/gateway/api/user-icons/account-hash.png');
|
||||
await expect.poll(() => portrait.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
|
||||
|
||||
expect(gameOperations.find(({ operation }) => operation === 'auth.exchangeGatewayToken')).toEqual({
|
||||
@@ -157,3 +174,59 @@ test('exchanges the gateway token before loading authenticated lobby general dat
|
||||
authorization: 'Bearer ga_lobby-access-token',
|
||||
});
|
||||
});
|
||||
|
||||
test('applies the signed general-acquisition policy to both create and possession actions', async ({ page }) => {
|
||||
await installFixture(page, {
|
||||
canCreateGeneral: false,
|
||||
myGeneral: null,
|
||||
selectionPoolEnabled: false,
|
||||
npcPossessionEnabled: true,
|
||||
});
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||
await expect(row.getByRole('button', { name: '인증 필요' })).toBeDisabled();
|
||||
await expect(row.getByRole('button', { name: '장수빙의' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('opens the mode-1 possession tab with a fresh gateway game token', async ({ page }) => {
|
||||
await installFixture(page, {
|
||||
myGeneral: null,
|
||||
selectionPoolEnabled: false,
|
||||
npcPossessionEnabled: true,
|
||||
});
|
||||
await page.route('**/hwe/join?**', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<title>NPC possession target</title>',
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||
await expect(row.getByRole('button', { name: '장수빙의' })).toBeEnabled();
|
||||
await row.getByRole('button', { name: '장수빙의' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/hwe\/join\?/);
|
||||
const target = new URL(page.url());
|
||||
expect(target.searchParams.get('tab')).toBe('possess');
|
||||
expect(target.searchParams.get('profile')).toBe('hwe:903');
|
||||
expect(target.searchParams.get('gameToken')).toBe('encrypted-gateway-game-token');
|
||||
});
|
||||
|
||||
test('shows registration closed instead of acquisition actions at the Ref capacity boundary', async ({ page }) => {
|
||||
await installFixture(page, {
|
||||
myGeneral: null,
|
||||
selectionPoolEnabled: false,
|
||||
npcPossessionEnabled: true,
|
||||
userCnt: 300,
|
||||
maxUserCnt: 300,
|
||||
});
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||
await expect(row).toContainText('장수 등록 마감');
|
||||
await expect(row.getByRole('button', { name: '장수생성' })).toHaveCount(0);
|
||||
await expect(row.getByRole('button', { name: '장수빙의' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export default defineConfig({
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
'VITE_APP_BASE_PATH=/gateway pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130',
|
||||
"VITE_APP_BASE_PATH=/gateway VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130",
|
||||
cwd: repositoryRoot,
|
||||
url: 'http://127.0.0.1:15130/gateway/',
|
||||
reuseExistingServer: false,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc pnpm build && playwright test --config e2e/playwright.config.mjs",
|
||||
"test:e2e:operations": "VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' pnpm build && playwright test --config e2e/playwright.config.mjs",
|
||||
"test:e2e:hwe-lifecycle": "playwright test --config e2e/hwe-lifecycle.playwright.config.mjs",
|
||||
"test:e2e:general-icons": "playwright test --config e2e/general-icon-lifecycle.playwright.config.mjs",
|
||||
"build": "vue-tsc && vite build",
|
||||
|
||||
@@ -39,8 +39,7 @@ const canAccessAdmin = computed(
|
||||
) ?? false
|
||||
);
|
||||
const needsKakaoVerification = computed(() => me.value !== null && !me.value.kakaoVerified);
|
||||
const userIconBaseUrl =
|
||||
import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
|
||||
const userIconBaseUrl = import.meta.env.VITE_GATEWAY_USER_ICON_BASE_URL ?? '/gateway/api/user-icons';
|
||||
|
||||
const formatGraceEndsAt = (value: string | null | undefined): string =>
|
||||
value ? new Date(value).toLocaleString('ko-KR') : '';
|
||||
@@ -91,9 +90,7 @@ onMounted(async () => {
|
||||
console.error(`Failed to authenticate lobby game session for ${profile.profileName}`, error);
|
||||
}
|
||||
}
|
||||
const gameTrpc = gameToken
|
||||
? createGameTrpc(profile.profile, profile.apiPort, gameToken)
|
||||
: publicGameTrpc;
|
||||
const gameTrpc = gameToken ? createGameTrpc(profile.profile, profile.apiPort, gameToken) : publicGameTrpc;
|
||||
const [infoResult, layoutResult, mapResult] = await Promise.allSettled([
|
||||
gameTrpc.lobby.info.query(),
|
||||
gameTrpc.public.getMapLayout.query(),
|
||||
@@ -225,8 +222,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
<div>
|
||||
<strong class="block text-amber-300">카카오 인증이 필요합니다.</strong>
|
||||
<span class="text-sm">
|
||||
유예기간이 지나면 게임에 입장할 수 없습니다. che·kwe·twe는 인증 전 장수 생성도
|
||||
제한됩니다.
|
||||
유예기간이 지나면 게임에 입장할 수 없습니다. che·kwe·twe는 인증 전 장수 생성도 제한됩니다.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -337,11 +333,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
class="w-12 h-12 mx-auto bg-zinc-800 rounded overflow-hidden border border-zinc-700"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
resolveGeneralPicture(
|
||||
profileDetails[profile.profileName]!.myGeneral!
|
||||
)
|
||||
"
|
||||
:src="resolveGeneralPicture(profileDetails[profile.profileName]!.myGeneral!)"
|
||||
class="w-full h-full object-cover"
|
||||
@error="handleGeneralPictureError"
|
||||
/>
|
||||
@@ -365,30 +357,52 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
>
|
||||
입장
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||
:disabled="
|
||||
entryLoading[profile.profileName] ||
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
"
|
||||
@click="
|
||||
handleEnter(
|
||||
profile,
|
||||
profileDetails[profile.profileName]?.selectionPoolEnabled
|
||||
? '/select-general'
|
||||
: '/join'
|
||||
)
|
||||
<div
|
||||
v-else-if="
|
||||
profileDetails[profile.profileName]!.userCnt >=
|
||||
profileDetails[profile.profileName]!.maxUserCnt
|
||||
"
|
||||
class="text-zinc-500"
|
||||
>
|
||||
{{
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
? '인증 필요'
|
||||
: profileDetails[profile.profileName]?.selectionPoolEnabled
|
||||
? '장수선택'
|
||||
: '장수생성'
|
||||
}}
|
||||
</button>
|
||||
- 장수 등록 마감 -
|
||||
</div>
|
||||
<div v-else class="grid gap-1">
|
||||
<button
|
||||
v-if="profileDetails[profile.profileName]?.selectionPoolEnabled"
|
||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||
:disabled="entryLoading[profile.profileName]"
|
||||
@click="handleEnter(profile, '/select-general')"
|
||||
>
|
||||
장수선택
|
||||
</button>
|
||||
<template v-else>
|
||||
<button
|
||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||
:disabled="
|
||||
entryLoading[profile.profileName] ||
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
"
|
||||
@click="handleEnter(profile, '/join')"
|
||||
>
|
||||
{{
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
? '인증 필요'
|
||||
: '장수생성'
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
v-if="profileDetails[profile.profileName]?.npcPossessionEnabled"
|
||||
class="w-full bg-zinc-700 hover:bg-zinc-600 text-white py-1.5 rounded text-sm transition-colors"
|
||||
:disabled="
|
||||
entryLoading[profile.profileName] ||
|
||||
profile.localAccountPolicy?.canCreateGeneral === false
|
||||
"
|
||||
@click="handleEnter(profile, '/join?tab=possess')"
|
||||
>
|
||||
장수빙의
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="profile.status === 'STOPPED'">
|
||||
<span class="text-zinc-700">-</span>
|
||||
|
||||
@@ -226,6 +226,16 @@ export type TurnDaemonCommand =
|
||||
inheritCity?: number;
|
||||
inheritBonusStat?: [number, number, number];
|
||||
}
|
||||
| {
|
||||
type: 'npcPossessGeneral';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
ownerDisplayName: string;
|
||||
profileId: string;
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
generalId: number;
|
||||
tokenNonce: number;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolCreate';
|
||||
requestId?: string;
|
||||
@@ -509,6 +519,17 @@ export type TurnDaemonCommandResult =
|
||||
code: 'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'npcPossessGeneral';
|
||||
ok: true;
|
||||
generalId: number;
|
||||
}
|
||||
| {
|
||||
type: 'npcPossessGeneral';
|
||||
ok: false;
|
||||
code: 'BAD_REQUEST' | 'NOT_FOUND' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolCreate';
|
||||
ok: true;
|
||||
|
||||
@@ -154,7 +154,7 @@ model City {
|
||||
|
||||
model General {
|
||||
id Int @id
|
||||
userId String? @map("user_id")
|
||||
userId String? @unique(map: "general_user_id_key") @map("user_id")
|
||||
name String
|
||||
nationId Int @default(0) @map("nation_id")
|
||||
cityId Int @default(0) @map("city_id")
|
||||
@@ -211,6 +211,19 @@ model SelectPoolEntry {
|
||||
@@map("select_pool")
|
||||
}
|
||||
|
||||
model NpcSelectionToken {
|
||||
ownerUserId String @id @map("owner_user_id")
|
||||
validUntil DateTime @map("valid_until")
|
||||
pickMoreFrom DateTime @map("pick_more_from")
|
||||
pickResult Json @map("pick_result")
|
||||
nonce Int
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([validUntil])
|
||||
@@map("select_npc_token")
|
||||
}
|
||||
|
||||
model GeneralAccessLog {
|
||||
id Int @id @default(autoincrement())
|
||||
generalId Int @unique @map("general_id")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
BEGIN;
|
||||
|
||||
-- Core의 한 사용자당 한 장수 invariant를 DB에서도 보장한다. 기존 중복 owner가
|
||||
-- 있으면 어떤 DDL도 적용하기 전에 owner와 장수 ID를 식별해 실패한다.
|
||||
DO $$
|
||||
DECLARE
|
||||
duplicate_owners TEXT;
|
||||
BEGIN
|
||||
SELECT string_agg(
|
||||
format('%s(count=%s, general_ids=%s)', "user_id", owner_count, general_ids),
|
||||
', '
|
||||
)
|
||||
INTO duplicate_owners
|
||||
FROM (
|
||||
SELECT
|
||||
"user_id",
|
||||
count(*) AS owner_count,
|
||||
string_agg("id"::TEXT, ',' ORDER BY "id") AS general_ids
|
||||
FROM "general"
|
||||
WHERE "user_id" IS NOT NULL
|
||||
GROUP BY "user_id"
|
||||
HAVING count(*) > 1
|
||||
ORDER BY "user_id"
|
||||
LIMIT 20
|
||||
) AS duplicate_rows;
|
||||
|
||||
IF duplicate_owners IS NOT NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'general.user_id duplicate owners must be reviewed before migration: %',
|
||||
duplicate_owners;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE "select_npc_token" (
|
||||
"owner_user_id" TEXT NOT NULL,
|
||||
"valid_until" TIMESTAMP(3) NOT NULL,
|
||||
"pick_more_from" TIMESTAMP(3) NOT NULL,
|
||||
"pick_result" JSONB NOT NULL,
|
||||
"nonce" INTEGER NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "select_npc_token_pkey" PRIMARY KEY ("owner_user_id")
|
||||
);
|
||||
|
||||
CREATE INDEX "select_npc_token_valid_until_idx" ON "select_npc_token"("valid_until");
|
||||
|
||||
CREATE UNIQUE INDEX "general_user_id_key" ON "general"("user_id");
|
||||
|
||||
COMMIT;
|
||||
@@ -7,6 +7,7 @@ export interface DatabaseClient {
|
||||
worldState: GamePrisma.WorldStateDelegate;
|
||||
general: GamePrisma.GeneralDelegate;
|
||||
selectPoolEntry: GamePrisma.SelectPoolEntryDelegate;
|
||||
npcSelectionToken: GamePrisma.NpcSelectionTokenDelegate;
|
||||
generalAccessLog: GamePrisma.GeneralAccessLogDelegate;
|
||||
trafficPeriod: GamePrisma.TrafficPeriodDelegate;
|
||||
trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate;
|
||||
|
||||
@@ -34,8 +34,9 @@ set +a
|
||||
|
||||
integration_schema=${CONDITIONAL_INTEGRATION_SCHEMA:-conditional_integration}
|
||||
scenario_schema=${SCENARIO_SEED_INTEGRATION_SCHEMA:-conditional_scenario_seed}
|
||||
npc_possession_schema=${NPC_POSSESSION_INTEGRATION_SCHEMA:-conditional_$(date +%s)_$$_npc_possession_integration}
|
||||
|
||||
for schema in "$integration_schema" "$scenario_schema"; do
|
||||
for schema in "$integration_schema" "$scenario_schema" "$npc_possession_schema"; do
|
||||
case "$schema" in
|
||||
''|[!a-z_]*|*[!a-z0-9_]*)
|
||||
echo "integration schema must be a lowercase PostgreSQL identifier: $schema" >&2
|
||||
@@ -111,6 +112,18 @@ run_marked_tests tools/integration-tests \
|
||||
'TURN_DIFFERENTIAL_DATABASE_URL|INPUT_EVENT_DATABASE_URL' \
|
||||
"PostgreSQL snapshot"
|
||||
|
||||
npc_possession_database_url=$(build_database_url "$npc_possession_schema")
|
||||
(
|
||||
export POSTGRES_SCHEMA=$npc_possession_schema
|
||||
export DATABASE_URL=$npc_possession_database_url
|
||||
export NPC_POSSESSION_DATABASE_URL=$npc_possession_database_url
|
||||
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game
|
||||
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:game
|
||||
run_marked_tests app/game-api \
|
||||
'NPC_POSSESSION_DATABASE_URL' \
|
||||
"NPC possession PostgreSQL"
|
||||
)
|
||||
|
||||
if [ "${TURN_DIFFERENTIAL_REFERENCE:-}" = "1" ]; then
|
||||
live_sortie_schema=${LIVE_SORTIE_PERSISTENCE_SCHEMA:-conditional_live_sortie_persistence}
|
||||
case "$live_sortie_schema" in
|
||||
|
||||
Reference in New Issue
Block a user