feat: make general creation daemon-atomic
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { parseGameSessionTokenPayload, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { isValid, parseISO } from 'date-fns';
|
||||
|
||||
interface RedisClientLike {
|
||||
@@ -10,49 +10,11 @@ interface RedisClientLike {
|
||||
|
||||
const ACCESS_TOKEN_PREFIX = 'ga_';
|
||||
|
||||
const buildAccessKey = (profileName: string, token: string): string =>
|
||||
`sammo:game:access:${profileName}:${token}`;
|
||||
const buildAccessKey = (profileName: string, token: string): string => `sammo:game:access:${profileName}:${token}`;
|
||||
|
||||
const buildGatewayUsedKey = (profileName: string, sessionId: string): string =>
|
||||
`sammo:game:gateway-used:${profileName}:${sessionId}`;
|
||||
|
||||
const parsePayload = (value: unknown): GameSessionTokenPayload | null => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const payload = value as Partial<GameSessionTokenPayload>;
|
||||
if (payload.version !== 1) {
|
||||
return null;
|
||||
}
|
||||
if (typeof payload.profile !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (typeof payload.issuedAt !== 'string' || typeof payload.expiresAt !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (typeof payload.sessionId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (!payload.user || typeof payload.user !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const user = payload.user as Partial<GameSessionTokenPayload['user']>;
|
||||
if (
|
||||
typeof user.id !== 'string' ||
|
||||
typeof user.username !== 'string' ||
|
||||
typeof user.displayName !== 'string' ||
|
||||
!Array.isArray(user.roles) ||
|
||||
(user.legacyMemberNo !== undefined &&
|
||||
(!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!payload.sanctions || typeof payload.sanctions !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return payload as GameSessionTokenPayload;
|
||||
};
|
||||
|
||||
const resolveTtlSeconds = (expiresAt: string): number => {
|
||||
const parsed = parseISO(expiresAt);
|
||||
if (!isValid(parsed)) {
|
||||
@@ -96,7 +58,7 @@ export class RedisAccessTokenStore {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const payload = parsePayload(JSON.parse(raw));
|
||||
const payload = parseGameSessionTokenPayload(JSON.parse(raw));
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,24 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import type { DatabaseClient, GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||
import { asNumber, asRecord, asStringArray, LiteHashDRBG } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
||||
import {
|
||||
isPersonalityTraitKey,
|
||||
isWarTraitKey,
|
||||
JOIN_PERSONALITY_TRAIT_KEYS,
|
||||
loadPersonalityTraitModules,
|
||||
loadWarTraitModules,
|
||||
PersonalityTraitLoader,
|
||||
PERSONALITY_TRAIT_KEYS,
|
||||
WarTraitLoader,
|
||||
WAR_TRAIT_KEYS,
|
||||
} from '@sammo-ts/logic';
|
||||
import {
|
||||
appendInheritanceLog,
|
||||
readInheritancePoint,
|
||||
resolveInheritConstants,
|
||||
setInheritancePoint,
|
||||
} from '../../services/inheritance.js';
|
||||
import {
|
||||
getSelectionPoolStatus,
|
||||
reserveSelectionPool,
|
||||
resolveSelectionMaxGeneral,
|
||||
} from '../../services/selectPool.js';
|
||||
import { readInheritancePoint, resolveInheritConstants } from '../../services/inheritance.js';
|
||||
import { getSelectionPoolStatus, reserveSelectionPool, resolveSelectionMaxGeneral } from '../../services/selectPool.js';
|
||||
import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTransport.js';
|
||||
|
||||
const resolveSelectionCommandResult = (
|
||||
result:
|
||||
| Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>>
|
||||
| null,
|
||||
result: Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>> | null,
|
||||
expectedType: 'selectPoolCreate' | 'selectPoolReselect'
|
||||
): { ok: true; generalId: number } => {
|
||||
if (!result) {
|
||||
@@ -67,13 +55,66 @@ const resolveSelectionRequestId = (
|
||||
if (!contextRequestId) {
|
||||
return undefined;
|
||||
}
|
||||
const path =
|
||||
operation === 'create'
|
||||
? 'join.selectPoolGeneral'
|
||||
: 'join.reselectPoolGeneral';
|
||||
const path = operation === 'create' ? 'join.selectPoolGeneral' : 'join.reselectPoolGeneral';
|
||||
return `${contextRequestId}:${path}`;
|
||||
};
|
||||
|
||||
const resolveJoinCreateCommandResult = (
|
||||
result: Awaited<ReturnType<GameApiContext['turnDaemon']['requestCommand']>> | null
|
||||
): { ok: true; generalId: number } => {
|
||||
if (!result) {
|
||||
throw new TRPCError({
|
||||
code: 'TIMEOUT',
|
||||
message:
|
||||
'장수 생성 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
|
||||
});
|
||||
}
|
||||
if (result.type !== 'joinCreateGeneral') {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '턴 데몬이 올바르지 않은 장수 생성 결과를 반환했습니다.',
|
||||
});
|
||||
}
|
||||
if (!result.ok) {
|
||||
throw new TRPCError({
|
||||
code: result.code,
|
||||
message: result.reason,
|
||||
});
|
||||
}
|
||||
return { ok: true, generalId: result.generalId };
|
||||
};
|
||||
|
||||
const resolveJoinCreateRequestId = (
|
||||
contextRequestId: string | undefined,
|
||||
userId: string,
|
||||
clientRequestId: string | undefined
|
||||
): string | undefined => {
|
||||
if (clientRequestId) {
|
||||
return `join-create:${userId}:${clientRequestId}`;
|
||||
}
|
||||
return contextRequestId ? `${contextRequestId}:join.createGeneral` : undefined;
|
||||
};
|
||||
|
||||
const requestJoinCreateCommand = async (
|
||||
ctx: GameApiContext,
|
||||
command: Parameters<GameApiContext['turnDaemon']['requestCommand']>[0]
|
||||
) => {
|
||||
try {
|
||||
return await ctx.turnDaemon.requestCommand(command);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ConflictingTurnDaemonCommandError ||
|
||||
(error instanceof Error && error.name === 'ConflictingTurnDaemonCommandError')
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 접수된 장수 생성 요청과 입력이 다릅니다. 새 요청 번호로 다시 시도해 주세요.',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const DEFAULT_JOIN_STAT = {
|
||||
total: 165,
|
||||
min: 15,
|
||||
@@ -82,12 +123,8 @@ const DEFAULT_JOIN_STAT = {
|
||||
bonusMax: 5,
|
||||
};
|
||||
|
||||
const buildSpecialityAge = (
|
||||
retirementYear: number,
|
||||
age: number,
|
||||
relativeYear: number,
|
||||
divisor: number
|
||||
): number => Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
||||
const buildSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number =>
|
||||
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
||||
|
||||
export const resolveJoinSpecialityAges = (options: {
|
||||
retirementYear: number;
|
||||
@@ -116,90 +153,30 @@ const resolveJoinStat = (worldState: WorldStateRow) => {
|
||||
};
|
||||
};
|
||||
|
||||
const resolveJoinPolicy = (worldState: WorldStateRow) => {
|
||||
const config = asRecord(worldState.config);
|
||||
const joinMode = typeof config.joinMode === 'string' ? config.joinMode : 'full';
|
||||
const blockGeneralCreate =
|
||||
typeof config.blockGeneralCreate === 'number' && Number.isFinite(config.blockGeneralCreate)
|
||||
? Math.floor(config.blockGeneralCreate)
|
||||
: 0;
|
||||
return { joinMode, blockGeneralCreate };
|
||||
};
|
||||
|
||||
|
||||
const hashString = (value: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
const pickFromList = (values: string[], seed: string): string | null => {
|
||||
if (!values.length) {
|
||||
return null;
|
||||
}
|
||||
const index = hashString(seed) % values.length;
|
||||
return values[index] ?? null;
|
||||
};
|
||||
|
||||
const buildTurnTimeZones = (tickMinutes: number): string[] => {
|
||||
const buildTurnTimeZones = (tickSeconds: number): string[] => {
|
||||
const zones: string[] = [];
|
||||
const legacyZoneSeconds = Math.max(1, Math.floor(tickSeconds / 60));
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
const totalMinutes = i * tickMinutes;
|
||||
const hour = Math.floor(totalMinutes / 60) % 24;
|
||||
const minute = totalMinutes % 60;
|
||||
zones.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`);
|
||||
const startSeconds = i * legacyZoneSeconds;
|
||||
const endSeconds = startSeconds + legacyZoneSeconds - 1;
|
||||
const format = (totalSeconds: number, fraction: string): string =>
|
||||
`${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}.${fraction}`;
|
||||
zones.push(`${format(startSeconds, '000')} ~ ${format(endSeconds, '999')}`);
|
||||
}
|
||||
return zones;
|
||||
};
|
||||
|
||||
const alignToTurnBase = (time: Date, tickMinutes: number): Date => {
|
||||
const base = new Date(time.getFullYear(), time.getMonth(), time.getDate() - 1, 1, 0, 0, 0);
|
||||
const elapsedMinutes = Math.floor((time.getTime() - base.getTime()) / 60000);
|
||||
const alignedMinutes = elapsedMinutes - (elapsedMinutes % tickMinutes);
|
||||
return new Date(base.getTime() + alignedMinutes * 60000);
|
||||
};
|
||||
|
||||
const nextRangeInt = (rng: LiteHashDRBG, minInclusive: number, maxInclusive: number): number => {
|
||||
if (maxInclusive <= minInclusive) {
|
||||
return minInclusive;
|
||||
}
|
||||
return minInclusive + rng.nextInt(maxInclusive - minInclusive);
|
||||
};
|
||||
|
||||
const pickWeightedIndex = (rng: LiteHashDRBG, weights: number[]): number => {
|
||||
const total = weights.reduce((acc, value) => acc + value, 0);
|
||||
if (total <= 0) {
|
||||
return 0;
|
||||
}
|
||||
let cursor = rng.nextFloat1() * total;
|
||||
for (let i = 0; i < weights.length; i += 1) {
|
||||
cursor -= weights[i] ?? 0;
|
||||
if (cursor <= 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return weights.length - 1;
|
||||
};
|
||||
|
||||
const buildRandomBonus = (rng: LiteHashDRBG, baseStats: [number, number, number]): [number, number, number] => {
|
||||
const count = rng.nextInt(2) + 3;
|
||||
const bonus: [number, number, number] = [0, 0, 0];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const index = pickWeightedIndex(rng, baseStats);
|
||||
bonus[index] += 1;
|
||||
}
|
||||
return bonus;
|
||||
};
|
||||
|
||||
let cachedPersonalityOptions: Array<{ key: string; name: string; info: string }> | null = null;
|
||||
const zJoinPersonality = z.enum(['Random', ...JOIN_PERSONALITY_TRAIT_KEYS]);
|
||||
|
||||
const loadPersonalityOptions = async () => {
|
||||
if (cachedPersonalityOptions) {
|
||||
return cachedPersonalityOptions;
|
||||
}
|
||||
const modules = await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS], new PersonalityTraitLoader());
|
||||
const modules = await loadPersonalityTraitModules([...JOIN_PERSONALITY_TRAIT_KEYS], new PersonalityTraitLoader());
|
||||
cachedPersonalityOptions = modules.map((trait) => ({
|
||||
key: trait.key,
|
||||
name: trait.name,
|
||||
@@ -233,8 +210,7 @@ export const joinRouter = router({
|
||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
||||
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
|
||||
|
||||
const [personalities, warSpecials, nationRows, userGeneralCount, npcGeneralCount] =
|
||||
await Promise.all([
|
||||
const [personalities, warSpecials, nationRows, userGeneralCount, npcGeneralCount] = await Promise.all([
|
||||
loadPersonalityOptions(),
|
||||
loadWarOptions(warKeys),
|
||||
ctx.db.nation.findMany({
|
||||
@@ -265,40 +241,24 @@ export const joinRouter = router({
|
||||
const inheritTotalPoint = ctx.auth?.user.id
|
||||
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
|
||||
: 0;
|
||||
const selectionPool = await getSelectionPoolStatus(
|
||||
ctx.db,
|
||||
worldState,
|
||||
ctx.auth?.user.id ?? ''
|
||||
);
|
||||
const selectionPool = await getSelectionPoolStatus(ctx.db, worldState, ctx.auth?.user.id ?? '');
|
||||
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
|
||||
const maxGeneral = resolveSelectionMaxGeneral(worldState);
|
||||
const inheritCitiesRaw = await ctx.db.city.findMany({
|
||||
where: { level: { in: [5, 6] }, nationId: 0 },
|
||||
const inheritCities = await ctx.db.city.findMany({
|
||||
select: { id: true, name: true, level: true, region: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
const inheritCities =
|
||||
inheritCitiesRaw.length > 0
|
||||
? inheritCitiesRaw
|
||||
: await ctx.db.city.findMany({
|
||||
where: { level: { in: [5, 6] } },
|
||||
select: { id: true, name: true, level: true, region: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
return {
|
||||
rules: {
|
||||
stat: resolveJoinStat(worldState),
|
||||
allowCustomName: true,
|
||||
allowCustomName: (Math.floor(asNumber(config.blockGeneralCreate, 0)) & 2) === 0,
|
||||
},
|
||||
user: {
|
||||
id: ctx.auth?.user.id ?? '',
|
||||
displayName: ctx.auth?.user.displayName ?? '',
|
||||
},
|
||||
personalities: [
|
||||
{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' },
|
||||
...personalities,
|
||||
],
|
||||
personalities: [{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' }, ...personalities],
|
||||
warSpecials,
|
||||
nations,
|
||||
serverInfo: {
|
||||
@@ -318,7 +278,7 @@ export const joinRouter = router({
|
||||
inheritBornStatPoint: inheritConst.inheritBornStatPoint,
|
||||
},
|
||||
availableCities: inheritCities,
|
||||
turnTimeZones: buildTurnTimeZones(tickMinutes),
|
||||
turnTimeZones: buildTurnTimeZones(worldState.tickSeconds),
|
||||
availableSpecialWar: warSpecials,
|
||||
},
|
||||
selectionPool,
|
||||
@@ -363,12 +323,7 @@ export const joinRouter = router({
|
||||
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
const commandRequestId = resolveSelectionRequestId(
|
||||
ctx.requestId,
|
||||
userId,
|
||||
input.clientRequestId,
|
||||
'create'
|
||||
);
|
||||
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
|
||||
const result = await ctx.turnDaemon.requestCommand({
|
||||
type: 'selectPoolCreate',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
@@ -408,15 +363,16 @@ export const joinRouter = router({
|
||||
});
|
||||
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
||||
}),
|
||||
createGeneral: authedProcedure
|
||||
createGeneral: engineAuthedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(18),
|
||||
leadership: z.number().int(),
|
||||
strength: z.number().int(),
|
||||
intel: z.number().int(),
|
||||
pic: z.boolean().optional(),
|
||||
character: z.string(),
|
||||
pic: z.boolean(),
|
||||
character: zJoinPersonality,
|
||||
clientRequestId: z.string().uuid().optional(),
|
||||
inheritSpecial: z.string().optional(),
|
||||
inheritTurntimeZone: z.number().int().optional(),
|
||||
inheritCity: z.number().int().optional(),
|
||||
@@ -424,337 +380,49 @@ export const joinRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.auth?.user.id;
|
||||
if (!userId) {
|
||||
const auth = ctx.auth;
|
||||
if (!auth) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||
}
|
||||
if (ctx.auth?.identity?.canCreateGeneral === false) {
|
||||
if (auth.identity?.canCreateGeneral === false) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const worldState = await ctx.db.worldState.findFirst();
|
||||
if (!worldState) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: 'World state is not initialized.',
|
||||
});
|
||||
}
|
||||
const selectionPool = await getSelectionPoolStatus(ctx.db, worldState, userId);
|
||||
if (selectionPool.enabled) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '장수 선택 목록에서 장수를 골라 주세요.',
|
||||
});
|
||||
}
|
||||
|
||||
const joinPolicy = resolveJoinPolicy(worldState);
|
||||
if (joinPolicy.blockGeneralCreate === 1) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: '장수 생성이 제한된 서버입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const inheritConst = resolveInheritConstants(worldState);
|
||||
const configConst = asRecord(asRecord(worldState.config).const);
|
||||
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
|
||||
const inheritBonus = input.inheritBonusStat ?? null;
|
||||
if (inheritBonus) {
|
||||
const bonusSum = inheritBonus.reduce((acc, value) => acc + value, 0);
|
||||
if (inheritBonus.some((value) => value < 0)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '보너스 능력치가 음수입니다. 다시 가입해주세요!',
|
||||
});
|
||||
}
|
||||
if (bonusSum !== 0 && (bonusSum < 3 || bonusSum > 5)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '보너스 능력치 합이 잘못 지정되었습니다. 다시 가입해주세요!',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (input.inheritSpecial !== undefined) {
|
||||
if (!isWarTraitKey(input.inheritSpecial)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '전투 특기가 잘못 지정되었습니다.',
|
||||
});
|
||||
}
|
||||
if (availableSpecialWar.length > 0 && !availableSpecialWar.includes(input.inheritSpecial)) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '허용되지 않은 전투 특기입니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (input.inheritTurntimeZone !== undefined) {
|
||||
if (input.inheritTurntimeZone < 0 || input.inheritTurntimeZone > 59) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '턴 시간 지정 범위가 올바르지 않습니다.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const statRule = resolveJoinStat(worldState);
|
||||
const statTotal = input.leadership + input.strength + input.intel;
|
||||
|
||||
if (
|
||||
input.leadership < statRule.min ||
|
||||
input.strength < statRule.min ||
|
||||
input.intel < statRule.min ||
|
||||
input.leadership > statRule.max ||
|
||||
input.strength > statRule.max ||
|
||||
input.intel > statRule.max
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '능력치 범위를 벗어났습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (statTotal > statRule.total) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `능력치 합이 ${statRule.total}을 초과했습니다.`,
|
||||
});
|
||||
}
|
||||
|
||||
const personalityOptions = await loadPersonalityOptions();
|
||||
const personalityKeys = personalityOptions.map((trait) => trait.key);
|
||||
const resolveGeneralName = async (): Promise<string> => {
|
||||
if (joinPolicy.blockGeneralCreate !== 2) {
|
||||
return input.name;
|
||||
}
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const candidate = randomBytes(5).toString('hex');
|
||||
const exists = await ctx.db.general.findFirst({ where: { name: candidate } });
|
||||
if (!exists) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '랜덤 장수명 생성에 실패했습니다.',
|
||||
});
|
||||
};
|
||||
|
||||
const generalName = await resolveGeneralName();
|
||||
const chosenPersonality =
|
||||
input.character === 'Random'
|
||||
? pickFromList(personalityKeys, `${userId}:${generalName}`) ?? 'None'
|
||||
: isPersonalityTraitKey(input.character)
|
||||
? input.character
|
||||
: 'None';
|
||||
|
||||
const createGeneral = async (db: DatabaseClient) => {
|
||||
const existing = await db.general.findFirst({ where: { userId } });
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '이미 장수가 생성되어 있습니다.',
|
||||
});
|
||||
}
|
||||
const nameExists = await db.general.findFirst({ where: { name: generalName } });
|
||||
if (nameExists) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 존재하는 장수명입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const maxId = await db.general.aggregate({ _max: { id: true } });
|
||||
const nextId = (maxId._max.id ?? 0) + 1;
|
||||
const cityList = await db.city.findMany({
|
||||
select: { id: true, level: true, nationId: true, name: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
if (!cityList.length) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '도시 정보를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const neutralCities = cityList.filter((city) => city.level >= 5 && city.level <= 6 && city.nationId === 0);
|
||||
const candidateCities =
|
||||
neutralCities.length > 0 ? neutralCities : cityList.filter((city) => city.level >= 5 && city.level <= 6);
|
||||
if (!candidateCities.length) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '생성 가능한 도시가 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
let inheritRequiredPoint = 0;
|
||||
if (input.inheritCity !== undefined) {
|
||||
inheritRequiredPoint += inheritConst.inheritBornCityPoint;
|
||||
}
|
||||
if (input.inheritSpecial !== undefined) {
|
||||
inheritRequiredPoint += inheritConst.inheritBornSpecialPoint;
|
||||
}
|
||||
if (input.inheritTurntimeZone !== undefined) {
|
||||
inheritRequiredPoint += inheritConst.inheritBornTurntimePoint;
|
||||
}
|
||||
if (inheritBonus && inheritBonus.reduce((acc, value) => acc + value, 0) > 0) {
|
||||
inheritRequiredPoint += inheritConst.inheritBornStatPoint;
|
||||
}
|
||||
|
||||
const currentPoint = await readInheritancePoint(db, userId, 'previous');
|
||||
if (currentPoint < inheritRequiredPoint) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '유산 포인트가 부족합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const hiddenSeed = String(asRecord(worldState.meta).hiddenSeed ?? 'inherit');
|
||||
const rng = new LiteHashDRBG(`${hiddenSeed}:MakeGeneral:${userId}:${generalName}`);
|
||||
|
||||
const bonusStatSum = inheritBonus ? inheritBonus.reduce((acc, value) => acc + value, 0) : 0;
|
||||
const randomBonus =
|
||||
!inheritBonus || bonusStatSum === 0
|
||||
? buildRandomBonus(
|
||||
rng,
|
||||
[input.leadership, input.strength, input.intel]
|
||||
)
|
||||
: (inheritBonus as [number, number, number]);
|
||||
|
||||
const finalLeadership = input.leadership + randomBonus[0];
|
||||
const finalStrength = input.strength + randomBonus[1];
|
||||
const finalIntel = input.intel + randomBonus[2];
|
||||
const age = 20 + (randomBonus[0] + randomBonus[1] + randomBonus[2]) * 2 - nextRangeInt(rng, 0, 1);
|
||||
|
||||
const selectedCity =
|
||||
typeof input.inheritCity === 'number'
|
||||
? candidateCities.find((city) => city.id === input.inheritCity)
|
||||
: null;
|
||||
if (input.inheritCity !== undefined && !selectedCity) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '지정한 도시를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const cityIndex = nextRangeInt(rng, 0, candidateCities.length - 1);
|
||||
const cityId = (selectedCity ?? candidateCities[cityIndex] ?? candidateCities[0]).id;
|
||||
|
||||
const defaultSpecialDomestic =
|
||||
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None';
|
||||
const defaultSpecialWar =
|
||||
typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||
const retirementYear =
|
||||
typeof configConst.retirementYear === 'number' && Number.isFinite(configConst.retirementYear)
|
||||
? configConst.retirementYear
|
||||
: 80;
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
const scenarioMeta = asRecord(worldMeta.scenarioMeta);
|
||||
const startYear =
|
||||
typeof scenarioMeta.startYear === 'number' && Number.isFinite(scenarioMeta.startYear)
|
||||
? scenarioMeta.startYear
|
||||
: worldState.currentYear;
|
||||
const relativeYear = Math.max(
|
||||
worldState.currentYear - startYear,
|
||||
0
|
||||
);
|
||||
const scenarioId = Number(worldMeta.scenarioId ?? worldState.scenarioCode);
|
||||
const specialityAges = resolveJoinSpecialityAges({
|
||||
retirementYear,
|
||||
age,
|
||||
relativeYear,
|
||||
scenarioId,
|
||||
});
|
||||
|
||||
const specialWar =
|
||||
input.inheritSpecial && isWarTraitKey(input.inheritSpecial) ? input.inheritSpecial : defaultSpecialWar;
|
||||
|
||||
const tickMinutes = Math.max(1, Math.round(worldState.tickSeconds / 60));
|
||||
const baseTime = alignToTurnBase(new Date(), tickMinutes);
|
||||
let turnTime = new Date(baseTime.getTime() + rng.nextFloat1() * tickMinutes * 60000);
|
||||
if (input.inheritTurntimeZone !== undefined) {
|
||||
const offsetMinutes = input.inheritTurntimeZone * tickMinutes + rng.nextFloat1() * tickMinutes;
|
||||
turnTime = new Date(baseTime.getTime() + offsetMinutes * 60000);
|
||||
}
|
||||
if (turnTime.getTime() <= Date.now()) {
|
||||
turnTime = new Date(turnTime.getTime() + tickMinutes * 60000);
|
||||
}
|
||||
|
||||
const logEntries: string[] = [];
|
||||
if (input.inheritSpecial && isWarTraitKey(input.inheritSpecial)) {
|
||||
const [special] = await loadWarOptions([input.inheritSpecial]);
|
||||
const specialName = special?.name ?? input.inheritSpecial;
|
||||
logEntries.push(`${specialName} 전투 특기를 가진 천재 생성`);
|
||||
}
|
||||
if (input.inheritCity !== undefined && selectedCity) {
|
||||
logEntries.push(`${selectedCity.name}에 장수 생성`);
|
||||
}
|
||||
if (inheritBonus && inheritBonus.reduce((acc, value) => acc + value, 0) > 0) {
|
||||
logEntries.push(
|
||||
`${inheritBonus[0]}, ${inheritBonus[1]}, ${inheritBonus[2]} 보너스 능력치로 생성`
|
||||
);
|
||||
}
|
||||
if (input.inheritTurntimeZone !== undefined) {
|
||||
const zones = buildTurnTimeZones(tickMinutes);
|
||||
const zoneLabel = zones[input.inheritTurntimeZone];
|
||||
if (zoneLabel) {
|
||||
logEntries.push(`턴 시간 ${zoneLabel} 로 지정`);
|
||||
}
|
||||
}
|
||||
|
||||
const general = await db.general.create({
|
||||
data: {
|
||||
id: nextId,
|
||||
userId,
|
||||
name: generalName,
|
||||
nationId: 0,
|
||||
cityId,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
leadership: finalLeadership,
|
||||
strength: finalStrength,
|
||||
intel: finalIntel,
|
||||
personalCode: chosenPersonality ?? 'None',
|
||||
specialCode: defaultSpecialDomestic,
|
||||
special2Code: specialWar,
|
||||
turnTime,
|
||||
age,
|
||||
startAge: age,
|
||||
meta: {
|
||||
createdBy: 'join',
|
||||
ownerName: ctx.auth?.user.displayName ?? '',
|
||||
killturn: 24,
|
||||
specage: specialityAges.domestic,
|
||||
specage2: specialityAges.war,
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.generalAccessLog.upsert({
|
||||
where: { generalId: general.id },
|
||||
update: {
|
||||
userId,
|
||||
lastRefresh: new Date(),
|
||||
},
|
||||
create: {
|
||||
generalId: general.id,
|
||||
userId,
|
||||
lastRefresh: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (inheritRequiredPoint > 0) {
|
||||
await setInheritancePoint(db, userId, 'previous', currentPoint - inheritRequiredPoint);
|
||||
}
|
||||
for (const entry of logEntries) {
|
||||
await appendInheritanceLog(db, userId, worldState.currentYear, worldState.currentMonth, entry);
|
||||
}
|
||||
|
||||
return { ok: true, generalId: general.id };
|
||||
};
|
||||
|
||||
return ctx.db.$transaction ? ctx.db.$transaction(createGeneral) : createGeneral(ctx.db);
|
||||
const userId = auth.user.id;
|
||||
const commandRequestId = resolveJoinCreateRequestId(ctx.requestId, userId, input.clientRequestId);
|
||||
const result = await requestJoinCreateCommand(ctx, {
|
||||
type: 'joinCreateGeneral',
|
||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||
userId,
|
||||
ownerDisplayName: auth.user.displayName,
|
||||
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
||||
name: input.name,
|
||||
leadership: input.leadership,
|
||||
strength: input.strength,
|
||||
intel: input.intel,
|
||||
pic: input.pic,
|
||||
character: input.character,
|
||||
profileId: ctx.profile.id,
|
||||
...(auth.user.picture !== undefined ? { ownerPicture: auth.user.picture } : {}),
|
||||
...(auth.user.imageServer !== undefined ? { ownerImageServer: auth.user.imageServer } : {}),
|
||||
...(auth.user.canUseGeneralPicture !== undefined
|
||||
? {
|
||||
ownerCanUsePicture: auth.user.canUseGeneralPicture,
|
||||
}
|
||||
: {}),
|
||||
...(auth.sanctions.legacyPenalty !== undefined
|
||||
? {
|
||||
ownerLegacyPenalty: auth.sanctions.legacyPenalty,
|
||||
}
|
||||
: {}),
|
||||
...(input.inheritSpecial !== undefined ? { inheritSpecial: input.inheritSpecial } : {}),
|
||||
...(input.inheritTurntimeZone !== undefined ? { inheritTurntimeZone: input.inheritTurntimeZone } : {}),
|
||||
...(input.inheritCity !== undefined ? { inheritCity: input.inheritCity } : {}),
|
||||
...(input.inheritBonusStat !== undefined ? { inheritBonusStat: input.inheritBonusStat } : {}),
|
||||
});
|
||||
return resolveJoinCreateCommandResult(result);
|
||||
}),
|
||||
listPossessCandidates: authedProcedure
|
||||
.input(
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
type 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.CREATE_GENERAL_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const profile = 'hwe:2';
|
||||
const userId = 'create-general-integration-user';
|
||||
const failureUserId = 'create-general-integration-failure-user';
|
||||
const rejectedUserId = 'create-general-integration-rejected-user';
|
||||
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('create_general_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-30T00:00:00.000Z',
|
||||
expiresAt: '2026-08-30T00:00:00.000Z',
|
||||
sessionId: `create-general-${id}`,
|
||||
user: {
|
||||
id,
|
||||
username: id,
|
||||
displayName,
|
||||
roles: ['user'],
|
||||
legacyMemberNo,
|
||||
picture: 'custom-owner.webp',
|
||||
imageServer: 2,
|
||||
canUseGeneralPicture: true,
|
||||
},
|
||||
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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
integration('generic general creation through the durable turn daemon', () => {
|
||||
let db: GamePrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let runtime: TurnDaemonRuntime | undefined;
|
||||
let daemonLoop: Promise<void> | undefined;
|
||||
let turnDaemon: TurnDaemonTransport;
|
||||
|
||||
const buildContext = (requestId: string, auth: GameSessionTokenPayload): 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,
|
||||
accessTokenStore: new RedisAccessTokenStore(redisClient, profile),
|
||||
flushStore: new InMemoryFlushStore(),
|
||||
gameTokenSecret: 'create-general-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 = 'create-general-integration-seed';
|
||||
try {
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 2,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date('2099-07-30T12:00:00.000Z'),
|
||||
installOptions: {
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 0,
|
||||
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.inheritanceLog.deleteMany({
|
||||
where: { userId: { in: [userId, failureUserId] } },
|
||||
});
|
||||
await db.oldGeneral.deleteMany({
|
||||
where: { serverId: { startsWith: 'create-general-old-' } },
|
||||
});
|
||||
await db.gameHistory.deleteMany({
|
||||
where: { serverId: { startsWith: 'create-general-old-' } },
|
||||
});
|
||||
await db.gameHistory.createMany({
|
||||
data: [1, 2, 3, 4].map((index) => ({
|
||||
serverId: `create-general-old-${index}`,
|
||||
date: new Date(`2026-0${5 - index}-01T00:00:00.000Z`),
|
||||
winnerNation: 1,
|
||||
map: 'miniche_b',
|
||||
season: 1,
|
||||
scenario: 2,
|
||||
scenarioName: '통합 과거기',
|
||||
env: {},
|
||||
})),
|
||||
});
|
||||
await db.oldGeneral.create({
|
||||
data: {
|
||||
serverId: 'create-general-old-4',
|
||||
generalNo: 1,
|
||||
owner: userId,
|
||||
name: '과거장수',
|
||||
lastYearMonth: 18001,
|
||||
turnTime: new Date('2026-01-01T00:00:00.000Z'),
|
||||
data: {},
|
||||
},
|
||||
});
|
||||
for (const ownerUserId of [userId, failureUserId]) {
|
||||
await db.inheritancePoint.upsert({
|
||||
where: {
|
||||
userId_key: { userId: ownerUserId, key: 'previous' },
|
||||
},
|
||||
update: { value: 10_000 },
|
||||
create: {
|
||||
userId: ownerUserId,
|
||||
key: 'previous',
|
||||
value: 10_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const [key, value] of [
|
||||
['active_action', 250.9],
|
||||
['combat', 100.8],
|
||||
] as const) {
|
||||
await db.inheritancePoint.upsert({
|
||||
where: { userId_key: { userId, key } },
|
||||
update: { value },
|
||||
create: { userId, key, value },
|
||||
});
|
||||
}
|
||||
await startRuntime('create-general-integration-daemon');
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await stopRuntime('create-general integration complete');
|
||||
await closeDb?.();
|
||||
}, 30_000);
|
||||
|
||||
it('commits one complete ref-shaped general and survives a daemon reload', async () => {
|
||||
const auth = buildAuth(userId, '생성사용자', 4242);
|
||||
const config = await appRouter.createCaller(buildContext('create-general-config', auth)).join.getConfig();
|
||||
expect(config.personalities.map(({ key }) => key)).not.toContain('che_은둔');
|
||||
expect(config.inherit.turnTimeZones[1]).toBe('00:05.000 ~ 00:09.999');
|
||||
const city = await db.city.findFirstOrThrow({ orderBy: { id: 'asc' } });
|
||||
const clientRequestId = '11111111-1111-4111-8111-111111111111';
|
||||
const input = {
|
||||
name: '일/반-장수#',
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
pic: true,
|
||||
character: 'che_안전' as const,
|
||||
clientRequestId,
|
||||
inheritTurntimeZone: 7,
|
||||
inheritCity: city.id,
|
||||
inheritBonusStat: [2, 1, 1] as [number, number, number],
|
||||
};
|
||||
|
||||
const first = await appRouter
|
||||
.createCaller(buildContext('create-general-http-a', auth))
|
||||
.join.createGeneral(input);
|
||||
const retried = await appRouter
|
||||
.createCaller(buildContext('create-general-http-b', auth))
|
||||
.join.createGeneral(input);
|
||||
expect(retried).toEqual(first);
|
||||
|
||||
const created = await db.general.findUniqueOrThrow({
|
||||
where: { id: first.generalId },
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
userId,
|
||||
name: '일반장수',
|
||||
nationId: 0,
|
||||
cityId: city.id,
|
||||
npcState: 0,
|
||||
leadership: 57,
|
||||
strength: 56,
|
||||
intel: 56,
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture: 'custom-owner.webp',
|
||||
imageServer: 2,
|
||||
crewTypeId: 1100,
|
||||
personalCode: 'che_안전',
|
||||
penalty: {
|
||||
ban: 2,
|
||||
chat: 3,
|
||||
},
|
||||
});
|
||||
expect(created.affinity).toBeGreaterThanOrEqual(1);
|
||||
expect(created.affinity).toBeLessThanOrEqual(150);
|
||||
expect(created.meta).toMatchObject({
|
||||
createdBy: 'join',
|
||||
ownerName: '생성사용자',
|
||||
killturn: 6,
|
||||
inherit_spent_dyn: 4500,
|
||||
});
|
||||
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
|
||||
id: created.id,
|
||||
userId,
|
||||
name: created.name,
|
||||
cityId: city.id,
|
||||
inheritancePoints: {
|
||||
previous: 7351,
|
||||
},
|
||||
});
|
||||
expect(await db.general.count({ where: { userId } })).toBe(1);
|
||||
expect(await db.generalTurn.count({ where: { generalId: created.id } })).toBe(30);
|
||||
expect(await db.generalTurnRevision.count({ where: { generalId: created.id } })).toBe(1);
|
||||
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(RANK_DATA_TYPES.length);
|
||||
expect(
|
||||
await db.rankData.findUniqueOrThrow({
|
||||
where: {
|
||||
generalId_type: {
|
||||
generalId: created.id,
|
||||
type: 'inherit_spent_dyn',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toMatchObject({ nationId: 0, value: 4500 });
|
||||
const access = await db.generalAccessLog.findUniqueOrThrow({
|
||||
where: { generalId: created.id },
|
||||
});
|
||||
expect(access).toMatchObject({ userId });
|
||||
expect(
|
||||
await db.inheritancePoint.findUniqueOrThrow({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
})
|
||||
).toMatchObject({ value: 7351 });
|
||||
expect(await db.inheritancePoint.count({ where: { userId } })).toBe(1);
|
||||
expect(await db.inheritanceLog.count({ where: { userId } })).toBe(9);
|
||||
expect(
|
||||
await db.inheritanceLog.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
text: '신규/복귀 생성으로 포인트 1500 지급',
|
||||
},
|
||||
})
|
||||
).not.toBeNull();
|
||||
const event = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId: `join-create:${userId}:${clientRequestId}` },
|
||||
});
|
||||
expect(event).toMatchObject({
|
||||
target: 'ENGINE',
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: userId,
|
||||
});
|
||||
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
||||
const turnGridOffsetSeconds =
|
||||
((created.turnTime.getTime() - runtime!.world.getState().lastTurnTime.getTime()) / 1000 + 300) % 300;
|
||||
expect(turnGridOffsetSeconds).toBeGreaterThanOrEqual(35);
|
||||
expect(turnGridOffsetSeconds).toBeLessThan(40);
|
||||
|
||||
await stopRuntime('verify generic join reload');
|
||||
await startRuntime('create-general-integration-reloaded-daemon');
|
||||
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
|
||||
id: created.id,
|
||||
userId,
|
||||
name: created.name,
|
||||
cityId: city.id,
|
||||
inheritancePoints: {
|
||||
previous: 7351,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('create-general-conflict', auth)).join.createGeneral({
|
||||
...input,
|
||||
name: '다른장수',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(await db.general.count({ where: { userId } })).toBe(1);
|
||||
}, 45_000);
|
||||
|
||||
it('rejects an unaffordable inheritance before consuming allocator state', async () => {
|
||||
const auth = buildAuth(rejectedUserId, '거절사용자', 4444);
|
||||
const requestUuid = '33333333-3333-4333-8333-333333333333';
|
||||
const requestId = `join-create:${rejectedUserId}:${requestUuid}`;
|
||||
const runtimeMetaBefore = runtime!.world.getState().meta;
|
||||
const persistedMetaBefore = (await db.worldState.findFirstOrThrow()).meta;
|
||||
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext('create-general-rejected-http', auth)).join.createGeneral({
|
||||
name: '포인트부족',
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
pic: false,
|
||||
character: 'che_안전',
|
||||
inheritSpecial: 'che_무쌍',
|
||||
clientRequestId: requestUuid,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '유산 포인트가 부족합니다. 다시 가입해주세요!',
|
||||
});
|
||||
|
||||
expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0);
|
||||
expect(runtime!.world.getState().meta).toEqual(runtimeMetaBefore);
|
||||
expect((await db.worldState.findFirstOrThrow()).meta).toEqual(persistedMetaBefore);
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
target: 'ENGINE',
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 1,
|
||||
actorUserId: rejectedUserId,
|
||||
result: {
|
||||
type: 'joinCreateGeneral',
|
||||
ok: false,
|
||||
code: 'BAD_REQUEST',
|
||||
},
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('rolls back a hard failure and retries the ENGINE event exactly once', async () => {
|
||||
const auth = buildAuth(failureUserId, '실패사용자', 4343);
|
||||
const requestUuid = '22222222-2222-4222-8222-222222222222';
|
||||
const requestId = `join-create:${failureUserId}:${requestUuid}`;
|
||||
const triggerName = 'create_general_fail_first_log';
|
||||
const functionName = 'create_general_fail_first_log_fn';
|
||||
|
||||
await db.$executeRawUnsafe(`
|
||||
CREATE OR REPLACE FUNCTION "${schemaName}"."${functionName}"()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW.meta ->> 'ownerUserId' = '${failureUserId}'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "${schemaName}"."input_event"
|
||||
WHERE "request_id" = '${requestId}'
|
||||
AND "status" = 'PROCESSING'
|
||||
AND "attempts" = 1
|
||||
)
|
||||
THEN
|
||||
RAISE EXCEPTION 'injected first generic join 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('create-general-failure-http', auth)).join.createGeneral({
|
||||
name: '재시장수',
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
pic: false,
|
||||
character: 'Random',
|
||||
clientRequestId: requestUuid,
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, generalId: expect.any(Number) });
|
||||
} finally {
|
||||
await db.$executeRawUnsafe(`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`);
|
||||
await db.$executeRawUnsafe(`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`);
|
||||
}
|
||||
|
||||
const created = await db.general.findFirstOrThrow({
|
||||
where: { userId: failureUserId },
|
||||
});
|
||||
expect(runtime!.world.getGeneralById(created.id)).toMatchObject({
|
||||
id: created.id,
|
||||
userId: failureUserId,
|
||||
name: created.name,
|
||||
});
|
||||
expect(await db.general.count({ where: { userId: failureUserId } })).toBe(1);
|
||||
expect(await db.generalTurn.count({ where: { generalId: created.id } })).toBe(30);
|
||||
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(RANK_DATA_TYPES.length);
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 2,
|
||||
actorUserId: failureUserId,
|
||||
error: null,
|
||||
});
|
||||
}, 45_000);
|
||||
|
||||
it('rejects a forged ENGINE event whose actor does not own the command', async () => {
|
||||
const requestId = 'join-create:forged-owner:forged-request';
|
||||
await db.inputEvent.create({
|
||||
data: {
|
||||
requestId,
|
||||
target: 'ENGINE',
|
||||
eventType: 'joinCreateGeneral',
|
||||
actorUserId: 'different-actor',
|
||||
payload: {
|
||||
type: 'joinCreateGeneral',
|
||||
requestId,
|
||||
userId: 'forged-owner',
|
||||
ownerDisplayName: '위조사용자',
|
||||
seedOwnerIdentity: 4545,
|
||||
name: '위조장수',
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
pic: false,
|
||||
character: 'che_안전',
|
||||
profileId: 'hwe',
|
||||
} as GamePrisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const deadline = Date.now() + 5_000;
|
||||
let event = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId },
|
||||
});
|
||||
while (event.status !== 'FAILED' && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
event = await db.inputEvent.findUniqueOrThrow({
|
||||
where: { requestId },
|
||||
});
|
||||
}
|
||||
expect(event).toMatchObject({
|
||||
status: 'FAILED',
|
||||
actorUserId: 'different-actor',
|
||||
attempts: 3,
|
||||
error: expect.stringContaining('actor does not match'),
|
||||
});
|
||||
expect(await db.general.count({ where: { userId: 'forged-owner' } })).toBe(0);
|
||||
expect(runtime!.world.listGenerals()).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
|
||||
);
|
||||
}, 10_000);
|
||||
});
|
||||
@@ -281,9 +281,9 @@ describe('appRouter', () => {
|
||||
});
|
||||
|
||||
it('rejects unauthenticated or game-blocked auth status checks', async () => {
|
||||
await expect(
|
||||
appRouter.createCaller(buildContext({ auth: null })).auth.status()
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
await expect(appRouter.createCaller(buildContext({ auth: null })).auth.status()).rejects.toMatchObject({
|
||||
code: 'UNAUTHORIZED',
|
||||
});
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(
|
||||
@@ -346,6 +346,7 @@ describe('appRouter', () => {
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
pic: false,
|
||||
character: 'Random',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
|
||||
@@ -2,11 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { RANK_DATA_TYPES } from '@sammo-ts/common';
|
||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||
import {
|
||||
createTurnDaemonRuntime,
|
||||
seedScenarioToDatabase,
|
||||
type TurnDaemonRuntime,
|
||||
} from '@sammo-ts/game-engine';
|
||||
import { createTurnDaemonRuntime, seedScenarioToDatabase, type TurnDaemonRuntime } from '@sammo-ts/game-engine';
|
||||
import {
|
||||
createGamePostgresConnector,
|
||||
type GamePrisma,
|
||||
@@ -29,7 +25,7 @@ const otherUserId = 'select-pool-integration-other-user';
|
||||
const foreignUserId = 'select-pool-integration-foreign-user';
|
||||
const failureUserId = 'select-pool-integration-failure-user';
|
||||
const profile = 'hwe:903';
|
||||
const schemaName = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') ?? '' : '';
|
||||
const schemaName = databaseUrl ? (new URL(databaseUrl).searchParams.get('schema') ?? '') : '';
|
||||
|
||||
const assertDedicatedDatabase = (rawUrl: string): void => {
|
||||
const schema = new URL(rawUrl).searchParams.get('schema');
|
||||
@@ -97,10 +93,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
let turnDaemon: TurnDaemonTransport;
|
||||
let worldStateId: number;
|
||||
|
||||
const buildContext = (
|
||||
requestId: string,
|
||||
actorAuth: GameSessionTokenPayload = auth
|
||||
): GameApiContext => {
|
||||
const buildContext = (requestId: string, actorAuth: GameSessionTokenPayload = auth): GameApiContext => {
|
||||
const redisClient = {
|
||||
get: async () => null,
|
||||
set: async () => null,
|
||||
@@ -257,12 +250,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
select: { nationId: true, type: true, value: true },
|
||||
});
|
||||
expect(initialRankRows).toHaveLength(RANK_DATA_TYPES.length);
|
||||
expect(initialRankRows.map(({ type }) => type).sort()).toEqual(
|
||||
[...RANK_DATA_TYPES].sort()
|
||||
);
|
||||
expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe(
|
||||
true
|
||||
);
|
||||
expect(initialRankRows.map(({ type }) => type).sort()).toEqual([...RANK_DATA_TYPES].sort());
|
||||
expect(initialRankRows.every(({ nationId, value }) => nationId === 0 && value === 0)).toBe(true);
|
||||
expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1);
|
||||
expect(await db.selectPoolEntry.count({ where: { ownerUserId: userId } })).toBe(0);
|
||||
expect(
|
||||
@@ -293,9 +282,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
const reselection = await appRouter
|
||||
.createCaller(buildContext('select-pool-reserve-reselection'))
|
||||
.join.getSelectionPool();
|
||||
const target = reselection.candidates.find(
|
||||
(candidate) => candidate.generalName !== initial.name
|
||||
)!;
|
||||
const target = reselection.candidates.find((candidate) => candidate.generalName !== initial.name)!;
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('select-pool-reselect'))
|
||||
@@ -328,9 +315,11 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
},
|
||||
});
|
||||
expect(await db.selectPoolEntry.count({ where: { generalId: initial.id } })).toBe(1);
|
||||
expect(
|
||||
await db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: target.uniqueName } })
|
||||
).toMatchObject({ generalId: initial.id, ownerUserId: null, reservedUntil: null });
|
||||
expect(await db.selectPoolEntry.findUniqueOrThrow({ where: { uniqueName: target.uniqueName } })).toMatchObject({
|
||||
generalId: initial.id,
|
||||
ownerUserId: null,
|
||||
reservedUntil: null,
|
||||
});
|
||||
expect(
|
||||
await db.logEntry.count({
|
||||
where: { meta: { path: ['ownerUserId'], equals: userId } },
|
||||
@@ -345,9 +334,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
patch: { meta: { postReselectionFlush: 1 } },
|
||||
})
|
||||
).resolves.toMatchObject({ type: 'patchGeneral', ok: true });
|
||||
await expect(
|
||||
db.general.findUniqueOrThrow({ where: { id: initial.id } })
|
||||
).resolves.toMatchObject({
|
||||
await expect(db.general.findUniqueOrThrow({ where: { id: initial.id } })).resolves.toMatchObject({
|
||||
name: target.generalName,
|
||||
leadership: target.leadership,
|
||||
strength: target.strength,
|
||||
@@ -376,23 +363,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
.createCaller(buildContext('select-pool-full-reselection-reserve'))
|
||||
.join.getSelectionPool();
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('select-pool-full-reselection'))
|
||||
.join.reselectPoolGeneral({
|
||||
uniqueName: fullReselection.candidates[0]!.uniqueName,
|
||||
})
|
||||
appRouter.createCaller(buildContext('select-pool-full-reselection')).join.reselectPoolGeneral({
|
||||
uniqueName: fullReselection.candidates[0]!.uniqueName,
|
||||
})
|
||||
).resolves.toEqual({ ok: true, generalId: initial.id });
|
||||
|
||||
const otherReservation = await appRouter
|
||||
.createCaller(buildContext('select-pool-full-new-user-reserve', otherAuth))
|
||||
.join.getSelectionPool();
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('select-pool-full-new-user-create', otherAuth))
|
||||
.join.selectPoolGeneral({
|
||||
uniqueName: otherReservation.candidates[0]!.uniqueName,
|
||||
personality: 'che_안전',
|
||||
})
|
||||
appRouter.createCaller(buildContext('select-pool-full-new-user-create', otherAuth)).join.selectPoolGeneral({
|
||||
uniqueName: otherReservation.candidates[0]!.uniqueName,
|
||||
personality: 'che_안전',
|
||||
})
|
||||
).rejects.toMatchObject({ message: '더 이상 등록 할 수 없습니다.' });
|
||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
|
||||
await db.worldState.update({
|
||||
@@ -408,12 +391,10 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
const candidate = reservation.candidates[0]!;
|
||||
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('select-pool-foreign-token', foreignAuth))
|
||||
.join.selectPoolGeneral({
|
||||
uniqueName: candidate.uniqueName,
|
||||
personality: 'che_안전',
|
||||
})
|
||||
appRouter.createCaller(buildContext('select-pool-foreign-token', foreignAuth)).join.selectPoolGeneral({
|
||||
uniqueName: candidate.uniqueName,
|
||||
personality: 'che_안전',
|
||||
})
|
||||
).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' });
|
||||
expect(await db.general.count({ where: { userId: foreignUserId } })).toBe(0);
|
||||
|
||||
@@ -422,25 +403,22 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
data: { reservedUntil: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('select-pool-expired-token', otherAuth))
|
||||
.join.selectPoolGeneral({
|
||||
uniqueName: candidate.uniqueName,
|
||||
personality: 'che_안전',
|
||||
})
|
||||
appRouter.createCaller(buildContext('select-pool-expired-token', otherAuth)).join.selectPoolGeneral({
|
||||
uniqueName: candidate.uniqueName,
|
||||
personality: 'che_안전',
|
||||
})
|
||||
).rejects.toMatchObject({ message: '유효한 장수 목록이 없습니다.' });
|
||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
|
||||
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('select-pool-generic-bypass', otherAuth))
|
||||
.join.createGeneral({
|
||||
name: '우회장수',
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
character: 'che_안전',
|
||||
})
|
||||
appRouter.createCaller(buildContext('select-pool-generic-bypass', otherAuth)).join.createGeneral({
|
||||
name: '우회장수',
|
||||
leadership: 55,
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
pic: false,
|
||||
character: 'che_안전',
|
||||
})
|
||||
).rejects.toMatchObject({ message: '장수 선택 목록에서 장수를 골라 주세요.' });
|
||||
|
||||
const input = {
|
||||
@@ -453,24 +431,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
});
|
||||
const runtimeAllocatorBefore = runtime!.world.getState().meta.lastGeneralId;
|
||||
const persistedAllocatorBefore = (
|
||||
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }))
|
||||
.meta as Record<string, unknown>
|
||||
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).meta as Record<string, unknown>
|
||||
).lastGeneralId;
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('select-pool-invalid-personality', otherAuth))
|
||||
.join.selectPoolGeneral({
|
||||
...input,
|
||||
personality: 'not-a-personality',
|
||||
clientRequestId: '11111111-1111-4111-8111-111111111111',
|
||||
})
|
||||
appRouter.createCaller(buildContext('select-pool-invalid-personality', otherAuth)).join.selectPoolGeneral({
|
||||
...input,
|
||||
personality: 'not-a-personality',
|
||||
clientRequestId: '11111111-1111-4111-8111-111111111111',
|
||||
})
|
||||
).rejects.toMatchObject({ message: '올바르지 않은 성격입니다.' });
|
||||
expect(runtime!.world.getState().meta.lastGeneralId).toBe(runtimeAllocatorBefore);
|
||||
expect(
|
||||
(
|
||||
(await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } }))
|
||||
.meta as Record<string, unknown>
|
||||
).lastGeneralId
|
||||
((await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).meta as Record<string, unknown>)
|
||||
.lastGeneralId
|
||||
).toBe(persistedAllocatorBefore);
|
||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(0);
|
||||
|
||||
@@ -534,21 +507,15 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
|
||||
try {
|
||||
await expect(
|
||||
appRouter
|
||||
.createCaller(buildContext('select-pool-failure-http', failureAuth))
|
||||
.join.selectPoolGeneral({
|
||||
uniqueName: candidate.uniqueName,
|
||||
personality: 'che_안전',
|
||||
clientRequestId: requestUuid,
|
||||
})
|
||||
appRouter.createCaller(buildContext('select-pool-failure-http', failureAuth)).join.selectPoolGeneral({
|
||||
uniqueName: candidate.uniqueName,
|
||||
personality: 'che_안전',
|
||||
clientRequestId: requestUuid,
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, generalId: expect.any(Number) });
|
||||
} finally {
|
||||
await db.$executeRawUnsafe(
|
||||
`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`
|
||||
);
|
||||
await db.$executeRawUnsafe(
|
||||
`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`
|
||||
);
|
||||
await db.$executeRawUnsafe(`DROP TRIGGER IF EXISTS "${triggerName}" ON "${schemaName}"."log_entry"`);
|
||||
await db.$executeRawUnsafe(`DROP FUNCTION IF EXISTS "${schemaName}"."${functionName}"()`);
|
||||
}
|
||||
|
||||
const created = await db.general.findFirstOrThrow({ where: { userId: failureUserId } });
|
||||
@@ -560,9 +527,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
expect(await db.general.count({ where: { userId: failureUserId } })).toBe(1);
|
||||
expect(await db.generalTurn.count({ where: { generalId: created.id } })).toBe(30);
|
||||
expect(await db.generalTurnRevision.count({ where: { generalId: created.id } })).toBe(1);
|
||||
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(
|
||||
RANK_DATA_TYPES.length
|
||||
);
|
||||
expect(await db.rankData.count({ where: { generalId: created.id } })).toBe(RANK_DATA_TYPES.length);
|
||||
expect(await db.generalAccessLog.count({ where: { generalId: created.id } })).toBe(1);
|
||||
expect(await db.selectPoolEntry.count({ where: { generalId: created.id } })).toBe(1);
|
||||
expect(
|
||||
@@ -570,9 +535,7 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
||||
where: { meta: { path: ['ownerUserId'], equals: failureUserId } },
|
||||
})
|
||||
).toBe(2);
|
||||
await expect(
|
||||
db.inputEvent.findUniqueOrThrow({ where: { requestId } })
|
||||
).resolves.toMatchObject({
|
||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||
status: 'SUCCEEDED',
|
||||
attempts: 2,
|
||||
actorUserId: failureUserId,
|
||||
|
||||
@@ -23,6 +23,7 @@ export * from './turn/engineStateManager.js';
|
||||
export * from './turn/inMemoryStateStore.js';
|
||||
export * from './turn/inMemoryTurnProcessor.js';
|
||||
export * from './turn/databaseHooks.js';
|
||||
export * from './turn/joinCreateGeneralService.js';
|
||||
export * from './turn/selectPoolService.js';
|
||||
export * from './turn/turnDaemon.js';
|
||||
export * from './turn/cli.js';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { createGamePostgresConnector, type InputJsonValue, type TurnEngineEventCreateManyInput } from '@sammo-ts/infra';
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
import {
|
||||
buildScenarioBootstrap,
|
||||
resolveScenarioGeneralDeathMonth,
|
||||
@@ -233,7 +233,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const connector = createGamePostgresConnector({ url: options.databaseUrl });
|
||||
const now = options.now ?? new Date();
|
||||
const tickSeconds =
|
||||
install?.turnTermMinutes !== undefined ? install.turnTermMinutes * 60 : options.tickSeconds ?? DEFAULT_TICK_SECONDS;
|
||||
install?.turnTermMinutes !== undefined
|
||||
? install.turnTermMinutes * 60
|
||||
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
|
||||
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
||||
const sync = install?.sync ?? false;
|
||||
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
||||
@@ -241,10 +243,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const generalRice = options.defaultGeneralRice ?? DEFAULT_GENERAL_RICE;
|
||||
|
||||
const scenarioConst = asRecord(seed.scenarioConfig.const);
|
||||
if (
|
||||
typeof scenarioConst.openingPartYear !== 'number' ||
|
||||
Number.isNaN(scenarioConst.openingPartYear)
|
||||
) {
|
||||
if (typeof scenarioConst.openingPartYear !== 'number' || Number.isNaN(scenarioConst.openingPartYear)) {
|
||||
scenarioConst.openingPartYear = DEFAULT_OPENING_PART_YEAR;
|
||||
}
|
||||
const scenarioConfig = {
|
||||
@@ -268,6 +267,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
const worldMeta: Record<string, unknown> = {
|
||||
scenarioId: options.scenarioId,
|
||||
scenarioMeta: seed.scenarioMeta,
|
||||
genius: Math.max(0, Math.floor(asNumber(scenarioConst.defaultMaxGenius, 5))),
|
||||
starttime: formatDateTime(startState.startTime),
|
||||
turntime: formatDateTime(now),
|
||||
opentime: formatDateTime(now),
|
||||
@@ -283,9 +283,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
|
||||
const integrationSeed = process.env[INTEGRATION_WORLD_SEED_ENV]?.trim();
|
||||
worldMeta.hiddenSeed =
|
||||
integrationSeed && integrationSeed.length > 0
|
||||
? integrationSeed
|
||||
: randomBytes(16).toString('hex');
|
||||
integrationSeed && integrationSeed.length > 0 ? integrationSeed : randomBytes(16).toString('hex');
|
||||
|
||||
if (install?.preopenAt) {
|
||||
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
||||
@@ -484,8 +482,7 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
||||
: resolveScenarioGeneralDeathMonth({
|
||||
scenarioTitle: String(seed.scenarioMeta?.title ?? ''),
|
||||
startYear: seed.scenarioMeta?.startYear ?? null,
|
||||
contextLabel:
|
||||
typeof meta.source === 'string' ? meta.source : 'general',
|
||||
contextLabel: typeof meta.source === 'string' ? meta.source : 'general',
|
||||
generalId: general.id,
|
||||
generalName: general.name,
|
||||
deathYear: general.deathYear,
|
||||
|
||||
@@ -243,6 +243,31 @@ const zPatchGeneral = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const zJoinCreateGeneral = z
|
||||
.object({
|
||||
type: z.literal('joinCreateGeneral'),
|
||||
requestId: z.string().optional(),
|
||||
userId: z.string().min(1),
|
||||
ownerDisplayName: z.string(),
|
||||
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
||||
name: z.string().min(1).max(18),
|
||||
leadership: zFiniteNumber,
|
||||
strength: zFiniteNumber,
|
||||
intel: zFiniteNumber,
|
||||
pic: z.boolean(),
|
||||
character: z.string().min(1),
|
||||
profileId: z.string().min(1),
|
||||
ownerPicture: z.string().optional(),
|
||||
ownerImageServer: zFiniteNumber.optional(),
|
||||
ownerCanUsePicture: z.boolean().optional(),
|
||||
ownerLegacyPenalty: zRecord.optional(),
|
||||
inheritSpecial: z.string().optional(),
|
||||
inheritTurntimeZone: zFiniteNumber.optional(),
|
||||
inheritCity: zFiniteNumber.optional(),
|
||||
inheritBonusStat: z.tuple([zFiniteNumber, zFiniteNumber, zFiniteNumber]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const zSelectPoolCreate = z
|
||||
.object({
|
||||
type: z.literal('selectPoolCreate'),
|
||||
@@ -295,7 +320,12 @@ const zShutdown = z.object({
|
||||
const zShiftSchedule = z.object({
|
||||
type: z.literal('shiftSchedule'),
|
||||
actionId: z.string().uuid(),
|
||||
deltaMinutes: z.number().int().min(-1440).max(1440).refine((value) => value !== 0),
|
||||
deltaMinutes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(-1440)
|
||||
.max(1440)
|
||||
.refine((value) => value !== 0),
|
||||
});
|
||||
|
||||
const normalizeAuctionFinalize: CommandNormalizer<'auctionFinalize'> = (envelope) => {
|
||||
@@ -506,6 +536,14 @@ const normalizePatchGeneral: CommandNormalizer<'patchGeneral'> = (envelope) => {
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeJoinCreateGeneral: CommandNormalizer<'joinCreateGeneral'> = (envelope) => {
|
||||
const command = parseWith(zJoinCreateGeneral, envelope.command);
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
return { ...command, requestId: envelope.requestId };
|
||||
};
|
||||
|
||||
const normalizeSelectPoolCreate: CommandNormalizer<'selectPoolCreate'> = (envelope) => {
|
||||
const command = parseWith(zSelectPoolCreate, envelope.command);
|
||||
if (!command) {
|
||||
@@ -585,6 +623,7 @@ const normalizers: CommandNormalizerMap = {
|
||||
adjustGeneralMeta: normalizeAdjustGeneralMeta,
|
||||
tournamentMatchResult: normalizeTournamentMatchResult,
|
||||
patchGeneral: normalizePatchGeneral,
|
||||
joinCreateGeneral: normalizeJoinCreateGeneral,
|
||||
selectPoolCreate: normalizeSelectPoolCreate,
|
||||
selectPoolReselect: normalizeSelectPoolReselect,
|
||||
getStatus: normalizeGetStatus,
|
||||
|
||||
@@ -302,7 +302,13 @@ const buildPersistedGeneralMeta = (
|
||||
const buildInitialRankRows = (
|
||||
general: ReturnType<InMemoryTurnWorld['consumeDirtyState']>['generals'][number]
|
||||
): Array<{ generalId: number; nationId: number; type: string; value: number }> =>
|
||||
buildPersistedRankRows(general).map((row) => ({ ...row, nationId: 0, value: 0 }));
|
||||
buildPersistedRankRows(general).map((row) => ({
|
||||
...row,
|
||||
nationId: 0,
|
||||
// Ref Join은 전체 rank_data를 0으로 만든 직후 장수 생성에 사용한
|
||||
// 유산 포인트만 inherit_spent_dyn에 반영한다.
|
||||
value: row.type === 'inherit_spent_dyn' ? row.value : 0,
|
||||
}));
|
||||
|
||||
const RANK_DATA_UPSERT_BATCH_SIZE = 1_000;
|
||||
|
||||
@@ -362,6 +368,7 @@ const buildGeneralUpdate = (
|
||||
specialCode: toCode(general.role.specialDomestic),
|
||||
special2Code: toCode(general.role.specialWar),
|
||||
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||
penalty: asJson(general.penalty ?? {}),
|
||||
meta: buildPersistedGeneralMeta(general),
|
||||
turnTime: general.turnTime,
|
||||
recentWarTime: general.recentWarTime ?? null,
|
||||
@@ -405,6 +412,7 @@ const buildGeneralCreate = (
|
||||
specialCode: toCode(general.role.specialDomestic),
|
||||
special2Code: toCode(general.role.specialWar),
|
||||
lastTurn: asJson(general.lastTurn ?? { command: '휴식' }),
|
||||
penalty: asJson(general.penalty ?? {}),
|
||||
meta: buildPersistedGeneralMeta(general),
|
||||
turnTime: general.turnTime,
|
||||
recentWarTime: general.recentWarTime ?? null,
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { City, LogEntryDraft, MessageDraft, Nation, ScenarioConfig, Troop, TurnSchedule } from '@sammo-ts/logic';
|
||||
import type {
|
||||
City,
|
||||
LogEntryDraft,
|
||||
MessageDraft,
|
||||
Nation,
|
||||
ScenarioConfig,
|
||||
Troop,
|
||||
TurnSchedule,
|
||||
UnitSetDefinition,
|
||||
} from '@sammo-ts/logic';
|
||||
import { getNextTurnAt } from '@sammo-ts/logic';
|
||||
|
||||
import type { TurnCheckpoint } from '../lifecycle/types.js';
|
||||
@@ -338,12 +347,14 @@ export class InMemoryTurnWorld {
|
||||
private readonly pendingNationBettingOpens: PendingNationBettingOpen[] = [];
|
||||
private readonly pendingNationBettingFinishes: PendingNationBettingFinish[] = [];
|
||||
private readonly scenarioConfig: ScenarioConfig;
|
||||
private readonly unitSet?: UnitSetDefinition;
|
||||
private checkpoint?: TurnCheckpoint;
|
||||
private state: TurnWorldState;
|
||||
|
||||
constructor(state: TurnWorldState, snapshot: TurnWorldSnapshot, options: InMemoryTurnWorldOptions) {
|
||||
this.state = { ...state };
|
||||
this.scenarioConfig = snapshot.scenarioConfig;
|
||||
this.unitSet = snapshot.unitSet;
|
||||
this.schedule = options.schedule;
|
||||
this.generalTurnHandler =
|
||||
options.generalTurnHandler ??
|
||||
@@ -547,6 +558,10 @@ export class InMemoryTurnWorld {
|
||||
return this.scenarioConfig;
|
||||
}
|
||||
|
||||
getUnitSet(): UnitSetDefinition | undefined {
|
||||
return this.unitSet;
|
||||
}
|
||||
|
||||
getGeneralById(id: number): TurnGeneral | null {
|
||||
return this.generals.get(id) ?? null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,857 @@
|
||||
import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common';
|
||||
import { GamePrisma } from '@sammo-ts/infra';
|
||||
import {
|
||||
ActionLogger,
|
||||
buildAuctionAlias,
|
||||
buildAuctionAliasPool,
|
||||
getLegacyStringWidth,
|
||||
isPersonalityTraitKey,
|
||||
isWarTraitKey,
|
||||
JOIN_PERSONALITY_TRAIT_KEYS,
|
||||
loadWarTraitModules,
|
||||
LogFormat,
|
||||
normalizeTroopName,
|
||||
simpleSerialize,
|
||||
TraitSelector,
|
||||
WarTraitLoader,
|
||||
WAR_TRAIT_KEYS,
|
||||
} from '@sammo-ts/logic';
|
||||
import type { WarTraitKey } from '@sammo-ts/logic';
|
||||
|
||||
import type { DatabaseClient, GamePrisma as GamePrismaTypes } from '@sammo-ts/infra';
|
||||
import type { InMemoryTurnWorld } from './inMemoryWorld.js';
|
||||
import type { TurnGeneral } from './types.js';
|
||||
|
||||
type WorldStateRow = GamePrismaTypes.WorldStateGetPayload<Record<string, never>>;
|
||||
|
||||
export type JoinCreateGeneralErrorCode =
|
||||
'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||
|
||||
export class JoinCreateGeneralError extends Error {
|
||||
constructor(
|
||||
readonly code: JoinCreateGeneralErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'JoinCreateGeneralError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface JoinCreateGeneralInput {
|
||||
userId: string;
|
||||
ownerDisplayName: string;
|
||||
seedOwnerIdentity: string | number;
|
||||
name: string;
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intel: number;
|
||||
pic: boolean;
|
||||
character: string;
|
||||
profileId: string;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
ownerCanUsePicture?: boolean;
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
inheritSpecial?: string;
|
||||
inheritTurntimeZone?: number;
|
||||
inheritCity?: number;
|
||||
inheritBonusStat?: [number, number, number];
|
||||
}
|
||||
|
||||
interface InheritConstants {
|
||||
inheritBornSpecialPoint: number;
|
||||
inheritBornTurntimePoint: number;
|
||||
inheritBornCityPoint: number;
|
||||
inheritBornStatPoint: number;
|
||||
}
|
||||
|
||||
const DEFAULT_JOIN_STAT = {
|
||||
total: 165,
|
||||
min: 15,
|
||||
max: 80,
|
||||
};
|
||||
const DEFAULT_MAX_GENERAL = 500;
|
||||
const DEFAULT_MAX_GENIUS = 5;
|
||||
const DEFAULT_GENERAL_GOLD = 1000;
|
||||
const DEFAULT_GENERAL_RICE = 1000;
|
||||
const DEFAULT_CREW_TYPE_ID = 1100;
|
||||
const MAX_GENERAL_TURNS = 30;
|
||||
const DEFAULT_TURN_ACTION = '휴식';
|
||||
const LEGACY_TIMEZONE_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const LEGACY_JOIN_REMOVED_CHARACTERS = /[ⓝⓜⓖⓞⓧ㉥\\/`#|-]/gu;
|
||||
|
||||
const fail = (code: JoinCreateGeneralErrorCode, message: string): never => {
|
||||
throw new JoinCreateGeneralError(code, message);
|
||||
};
|
||||
|
||||
const normalizeJoinName = (value: string): string =>
|
||||
normalizeTroopName(value).replace(LEGACY_JOIN_REMOVED_CHARACTERS, '');
|
||||
|
||||
const resolveLegacyPenalty = (
|
||||
rawPenalty: Record<string, unknown> | undefined,
|
||||
profileId: string,
|
||||
acceptedAt: Date
|
||||
): Record<string, unknown> => {
|
||||
if (!rawPenalty) {
|
||||
return {};
|
||||
}
|
||||
const merged = {
|
||||
...asRecord(rawPenalty.any),
|
||||
...asRecord(rawPenalty[profileId]),
|
||||
};
|
||||
const acceptedAtSeconds = acceptedAt.getTime() / 1000;
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, rawEntry] of Object.entries(merged)) {
|
||||
const entry = asRecord(rawEntry);
|
||||
if (asNumber(entry.expire, 0) > acceptedAtSeconds && Object.hasOwn(entry, 'value')) {
|
||||
result[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const resolveStatRule = (worldState: WorldStateRow) => {
|
||||
const stat = asRecord(asRecord(worldState.config).stat);
|
||||
return {
|
||||
total: asNumber(stat.total, DEFAULT_JOIN_STAT.total),
|
||||
min: asNumber(stat.min, DEFAULT_JOIN_STAT.min),
|
||||
max: asNumber(stat.max, DEFAULT_JOIN_STAT.max),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveInheritConstants = (worldState: WorldStateRow): InheritConstants => {
|
||||
const configConst = asRecord(asRecord(worldState.config).const);
|
||||
return {
|
||||
inheritBornSpecialPoint: asNumber(configConst.inheritBornSpecialPoint, 6000),
|
||||
inheritBornTurntimePoint: asNumber(configConst.inheritBornTurntimePoint, 2500),
|
||||
inheritBornCityPoint: asNumber(configConst.inheritBornCityPoint, 1000),
|
||||
inheritBornStatPoint: asNumber(configConst.inheritBornStatPoint, 1000),
|
||||
};
|
||||
};
|
||||
|
||||
const isSelectionPoolWorld = (worldState: WorldStateRow): boolean => {
|
||||
const config = asRecord(worldState.config);
|
||||
const map = asRecord(config.map);
|
||||
return asNumber(config.npcMode, 0) === 2 && map.targetGeneralPool === 'SPoolUnderU30';
|
||||
};
|
||||
|
||||
const resolveMaxGeneral = (worldState: WorldStateRow): number => {
|
||||
const config = asRecord(worldState.config);
|
||||
const configConst = asRecord(config.const);
|
||||
return Math.max(
|
||||
0,
|
||||
Math.floor(
|
||||
asNumber(config.maxGeneral ?? configConst.defaultMaxGeneral ?? configConst.maxGeneral, DEFAULT_MAX_GENERAL)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
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', '장수 생성 비밀 seed가 설정되지 않았습니다.');
|
||||
};
|
||||
|
||||
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 buildJoinCreateGeneralSeed = (
|
||||
hiddenSeed: string | number,
|
||||
ownerIdentity: string | number,
|
||||
acceptedAt: Date
|
||||
): string => simpleSerialize(hiddenSeed, 'MakeGeneral', ownerIdentity, formatLegacySeedTime(acceptedAt));
|
||||
|
||||
const lockJoinMutation = async (db: DatabaseClient, userId: string): Promise<void> => {
|
||||
await db.$executeRaw(GamePrisma.sql`SELECT pg_advisory_xact_lock(hashtextextended(${`join-create:${userId}`}, 0))`);
|
||||
await db.$executeRaw(GamePrisma.sql`LOCK TABLE "general" IN SHARE ROW EXCLUSIVE MODE`);
|
||||
};
|
||||
|
||||
const assertGeneralIdSnapshotMatches = async (db: DatabaseClient, world: InMemoryTurnWorld): Promise<void> => {
|
||||
const persistedIds = (
|
||||
await db.general.findMany({
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
})
|
||||
).map(({ id }) => id);
|
||||
const runtimeIds = world
|
||||
.listGenerals()
|
||||
.map(({ id }) => id)
|
||||
.sort((left, right) => left - right);
|
||||
if (persistedIds.length !== runtimeIds.length || persistedIds.some((id, index) => id !== runtimeIds[index])) {
|
||||
throw new Error('DB와 턴 데몬의 장수 번호 목록이 일치하지 않아 장수를 생성할 수 없습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const applyInheritanceUser = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<number> => {
|
||||
const rows = await db.$queryRaw<Array<{ key: string; value: number }>>(
|
||||
GamePrisma.sql`
|
||||
SELECT key, value
|
||||
FROM inheritance_point
|
||||
WHERE user_id = ${userId}
|
||||
ORDER BY id ASC
|
||||
FOR UPDATE
|
||||
`
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (rows.length === 1 && rows[0]?.key === 'previous') {
|
||||
return rows[0].value;
|
||||
}
|
||||
|
||||
const previousPoint = rows.find((row) => row.key === 'previous')?.value ?? 0;
|
||||
const totalPoint = Math.trunc(rows.reduce((sum, row) => sum + row.value, 0));
|
||||
for (const row of rows) {
|
||||
await db.inheritanceLog.create({
|
||||
data: {
|
||||
userId,
|
||||
year,
|
||||
month,
|
||||
text: `${row.key} 포인트 ${row.value} 증가`,
|
||||
},
|
||||
});
|
||||
}
|
||||
await db.inheritanceLog.create({
|
||||
data: {
|
||||
userId,
|
||||
year,
|
||||
month,
|
||||
text: `포인트 ${previousPoint} => ${totalPoint}`,
|
||||
},
|
||||
});
|
||||
await db.inheritancePoint.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
key: { not: 'previous' },
|
||||
},
|
||||
});
|
||||
await setInheritancePoint(db, userId, totalPoint);
|
||||
return totalPoint;
|
||||
};
|
||||
|
||||
const setInheritancePoint = async (db: DatabaseClient, userId: string, value: number): Promise<void> => {
|
||||
await db.inheritancePoint.upsert({
|
||||
where: { userId_key: { userId, key: 'previous' } },
|
||||
update: { value },
|
||||
create: { userId, key: 'previous', value },
|
||||
});
|
||||
};
|
||||
|
||||
const appendInheritanceLog = async (
|
||||
db: DatabaseClient,
|
||||
userId: string,
|
||||
year: number,
|
||||
month: number,
|
||||
text: string
|
||||
): Promise<void> => {
|
||||
await db.inheritanceLog.create({
|
||||
data: { userId, year, month, text },
|
||||
});
|
||||
};
|
||||
|
||||
const calculateInheritanceCost = (
|
||||
input: JoinCreateGeneralInput,
|
||||
constants: InheritConstants,
|
||||
inheritBonus: [number, number, number] | null
|
||||
): number => {
|
||||
let required = 0;
|
||||
if (input.inheritCity !== undefined) {
|
||||
required += constants.inheritBornCityPoint;
|
||||
}
|
||||
if (inheritBonus) {
|
||||
required += constants.inheritBornStatPoint;
|
||||
}
|
||||
if (input.inheritSpecial !== undefined) {
|
||||
required += constants.inheritBornSpecialPoint;
|
||||
}
|
||||
if (input.inheritTurntimeZone !== undefined) {
|
||||
required += constants.inheritBornTurntimePoint;
|
||||
}
|
||||
return required;
|
||||
};
|
||||
|
||||
const validateAndNormalizeBonus = (bonus: [number, number, number] | undefined): [number, number, number] | null => {
|
||||
if (!bonus) {
|
||||
return null;
|
||||
}
|
||||
if (bonus.some((value) => !Number.isInteger(value) || value < 0)) {
|
||||
fail('BAD_REQUEST', '보너스 능력치가 음수입니다. 다시 가입해주세요!');
|
||||
}
|
||||
const sum = bonus.reduce((acc, value) => acc + value, 0);
|
||||
if (sum === 0) {
|
||||
return null;
|
||||
}
|
||||
if (sum < 3 || sum > 5) {
|
||||
fail('BAD_REQUEST', '보너스 능력치 합이 잘못 지정되었습니다. 다시 가입해주세요!');
|
||||
}
|
||||
return bonus;
|
||||
};
|
||||
|
||||
const buildRandomBonus = (rng: RandUtil, baseStats: [number, number, number]): [number, number, number] => {
|
||||
const result: [number, number, number] = [0, 0, 0];
|
||||
const count = rng.nextRangeInt(3, 5);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const selected = Number(
|
||||
rng.choiceUsingWeight({
|
||||
0: baseStats[0],
|
||||
1: baseStats[1],
|
||||
2: baseStats[2],
|
||||
})
|
||||
);
|
||||
result[selected as 0 | 1 | 2] += 1;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const resolveRelativeYear = (worldState: WorldStateRow): number => {
|
||||
const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta);
|
||||
const startYear = asNumber(scenarioMeta.startYear, worldState.currentYear);
|
||||
return Math.max(worldState.currentYear - startYear, 0);
|
||||
};
|
||||
|
||||
const resolveSpecialityAge = (retirementYear: number, age: number, relativeYear: number, divisor: number): number =>
|
||||
Math.max(Math.round((retirementYear - age) / divisor - relativeYear / 2), 3) + age;
|
||||
|
||||
export const cutJoinTurnTime = (value: Date, tickSeconds: number): Date => {
|
||||
const koreaTime = new Date(value.getTime() + LEGACY_TIMEZONE_OFFSET_MS);
|
||||
const baseTime =
|
||||
Date.UTC(koreaTime.getUTCFullYear(), koreaTime.getUTCMonth(), koreaTime.getUTCDate() - 1, 1) -
|
||||
LEGACY_TIMEZONE_OFFSET_MS;
|
||||
const elapsedSeconds = Math.floor((value.getTime() - baseTime) / 1000);
|
||||
const alignedSeconds = elapsedSeconds - (elapsedSeconds % tickSeconds);
|
||||
return new Date(baseTime + alignedSeconds * 1000);
|
||||
};
|
||||
|
||||
const resolveTurnTime = (
|
||||
rng: RandUtil,
|
||||
worldState: WorldStateRow,
|
||||
acceptedAt: Date,
|
||||
runtimeTurnTime: Date,
|
||||
inheritTurntimeZone: number | undefined
|
||||
): Date => {
|
||||
const tickSeconds = Math.max(1, Math.floor(worldState.tickSeconds));
|
||||
const base = runtimeTurnTime;
|
||||
let offsetSeconds: number;
|
||||
let offsetMicros: number;
|
||||
let turnTimeBase: Date;
|
||||
if (inheritTurntimeZone !== undefined) {
|
||||
const legacyTurnTermMinutes = Math.max(1, Math.floor(tickSeconds / 60));
|
||||
turnTimeBase = cutJoinTurnTime(base, tickSeconds);
|
||||
offsetSeconds = inheritTurntimeZone * legacyTurnTermMinutes + rng.nextRangeInt(0, legacyTurnTermMinutes - 1);
|
||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||
} else {
|
||||
turnTimeBase = base;
|
||||
offsetSeconds = rng.nextRangeInt(0, tickSeconds - 1);
|
||||
offsetMicros = rng.nextRangeInt(0, 999_999);
|
||||
}
|
||||
let turnTime = new Date(turnTimeBase.getTime() + offsetSeconds * 1000 + offsetMicros / 1000);
|
||||
if (turnTime.getTime() <= acceptedAt.getTime()) {
|
||||
turnTime = new Date(turnTime.getTime() + tickSeconds * 1000);
|
||||
}
|
||||
return turnTime;
|
||||
};
|
||||
|
||||
const resolveCatchupExperience = async (db: DatabaseClient, relativeYear: number): Promise<number> => {
|
||||
if (relativeYear < 3) {
|
||||
return 0;
|
||||
}
|
||||
const count = await db.general.count({
|
||||
where: { nationId: { not: 0 }, npcState: { lt: 4 } },
|
||||
});
|
||||
if (count === 0) {
|
||||
return 0;
|
||||
}
|
||||
const order = Math.max(1, Math.round(count * 0.2));
|
||||
const row = await db.general.findFirst({
|
||||
where: { nationId: { not: 0 }, npcState: { lt: 4 } },
|
||||
orderBy: [{ experience: 'asc' }, { id: 'asc' }],
|
||||
skip: order - 1,
|
||||
select: { experience: true },
|
||||
});
|
||||
return Math.round((row?.experience ?? 0) * 0.8);
|
||||
};
|
||||
|
||||
const resolveRestInheritanceBonus = async (
|
||||
db: DatabaseClient,
|
||||
worldState: WorldStateRow,
|
||||
userId: string
|
||||
): Promise<number> => {
|
||||
const meta = asRecord(worldState.meta);
|
||||
const serverId = typeof meta.serverId === 'string' ? meta.serverId : '';
|
||||
const season = Math.floor(asNumber(meta.season, 0));
|
||||
if (!serverId || season < 1) {
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
(await db.inheritanceResult.count({
|
||||
where: { serverId, owner: userId },
|
||||
})) > 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const oldGames = await db.gameHistory.findMany({
|
||||
where: {
|
||||
winnerNation: { not: null },
|
||||
season: { gte: 1 },
|
||||
serverId: { not: serverId },
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
take: 8,
|
||||
select: { serverId: true },
|
||||
});
|
||||
if (oldGames.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const oldServerIds = oldGames.map(({ serverId: oldServerId }) => oldServerId);
|
||||
const playedRows = await db.oldGeneral.findMany({
|
||||
where: {
|
||||
owner: userId,
|
||||
serverId: { in: oldServerIds },
|
||||
},
|
||||
distinct: ['serverId'],
|
||||
select: { serverId: true },
|
||||
});
|
||||
const played = new Set(playedRows.map(({ serverId: oldServerId }) => oldServerId));
|
||||
let missedGames = 0;
|
||||
for (const { serverId: oldServerId } of oldGames) {
|
||||
if (played.has(oldServerId)) {
|
||||
break;
|
||||
}
|
||||
missedGames += 1;
|
||||
}
|
||||
return missedGames * 500;
|
||||
};
|
||||
|
||||
const pushCreationLogs = (
|
||||
world: InMemoryTurnWorld,
|
||||
options: {
|
||||
generalId: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
cityName: string;
|
||||
bonus: [number, number, number];
|
||||
age: number;
|
||||
genius: boolean;
|
||||
specialWarName: string;
|
||||
}
|
||||
): void => {
|
||||
const logger = new ActionLogger({ generalId: options.generalId });
|
||||
const josaRa = JosaUtil.pick(options.name, '라');
|
||||
if (options.genius) {
|
||||
logger.pushGlobalActionLog(
|
||||
`<G><b>${options.cityName}</b></>에서 <Y>${options.name}</>${josaRa}는 기재가 천하에 이름을 알립니다.`
|
||||
);
|
||||
logger.pushGlobalActionLog(
|
||||
`<C>${options.specialWarName}</> 특기를 가진 <C>천재</>의 등장으로 온 천하가 떠들썩합니다.`
|
||||
);
|
||||
logger.pushGlobalHistoryLog(`<L><b>【천재】</b></><G><b>${options.cityName}</b></>에 천재가 등장했습니다.`);
|
||||
} else {
|
||||
logger.pushGlobalActionLog(
|
||||
`<G><b>${options.cityName}</b></>에서 <Y>${options.name}</>${josaRa}는 호걸이 천하에 이름을 알립니다.`
|
||||
);
|
||||
}
|
||||
logger.pushGeneralHistoryLog(`<Y>${options.name}</>, <G>${options.cityName}</>에서 큰 뜻을 품다.`);
|
||||
logger.pushGeneralActionLog(
|
||||
[
|
||||
'삼국지 모의전투 PHP의 세계에 오신 것을 환영합니다 ^o^',
|
||||
'처음 하시는 경우에는 <D>도움말</>을 참고하시고,',
|
||||
'문의사항이 있으시면 게시판에 글을 남겨주시면 되겠네요~',
|
||||
'부디 즐거운 삼모전 되시길 바랍니다 ^^',
|
||||
`통솔 <C>${options.bonus[0]}</> 무력 <C>${options.bonus[1]}</> 지력 <C>${options.bonus[2]}</> 의 보너스를 받으셨습니다.`,
|
||||
`연령은 <C>${options.age}</>세로 시작합니다.`,
|
||||
],
|
||||
LogFormat.PLAIN
|
||||
);
|
||||
if (options.genius) {
|
||||
logger.pushGeneralActionLog(
|
||||
`축하합니다! 천재로 태어나 처음부터 <C>${options.specialWarName}</> 특기를 가지게 됩니다!`,
|
||||
LogFormat.PLAIN
|
||||
);
|
||||
logger.pushGeneralHistoryLog(`<C>${options.specialWarName}</> 특기를 가진 천재로 탄생.`);
|
||||
}
|
||||
for (const entry of logger.flush()) {
|
||||
world.pushLog({
|
||||
...entry,
|
||||
meta: {
|
||||
...(entry.meta ?? {}),
|
||||
ownerUserId: options.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createGeneralFromJoin = async (options: {
|
||||
db: DatabaseClient;
|
||||
world: InMemoryTurnWorld;
|
||||
worldState: WorldStateRow;
|
||||
input: JoinCreateGeneralInput;
|
||||
acceptedAt: Date;
|
||||
}): Promise<{ ok: true; generalId: number }> => {
|
||||
const { db, world, worldState, input, acceptedAt } = options;
|
||||
await lockJoinMutation(db, input.userId);
|
||||
await assertGeneralIdSnapshotMatches(db, world);
|
||||
|
||||
if (isSelectionPoolWorld(worldState)) {
|
||||
fail('PRECONDITION_FAILED', '장수 선택 목록에서 장수를 골라 주세요.');
|
||||
}
|
||||
const config = asRecord(worldState.config);
|
||||
const configConst = asRecord(config.const);
|
||||
const blockGeneralCreate = Math.floor(asNumber(config.blockGeneralCreate, 0));
|
||||
if ((blockGeneralCreate & 1) !== 0) {
|
||||
fail('FORBIDDEN', '장수 직접 생성이 불가능한 모드입니다.');
|
||||
}
|
||||
|
||||
if (
|
||||
world.listGenerals().some((general) => general.userId === input.userId) ||
|
||||
(await db.general.findFirst({
|
||||
where: { userId: input.userId },
|
||||
select: { id: true },
|
||||
}))
|
||||
) {
|
||||
fail('PRECONDITION_FAILED', '이미 등록하셨습니다!');
|
||||
}
|
||||
|
||||
const normalizedName = normalizeJoinName(input.name);
|
||||
if (!normalizedName || getLegacyStringWidth(normalizedName) < 1) {
|
||||
fail('BAD_REQUEST', '이름이 짧습니다. 다시 가입해주세요!');
|
||||
}
|
||||
if (getLegacyStringWidth(normalizedName) > 18) {
|
||||
fail('BAD_REQUEST', '이름이 유효하지 않습니다. 다시 가입해주세요!');
|
||||
}
|
||||
if (
|
||||
world.listGenerals().some((general) => general.name === normalizedName) ||
|
||||
(await db.general.findFirst({
|
||||
where: { name: normalizedName },
|
||||
select: { id: true },
|
||||
}))
|
||||
) {
|
||||
fail('CONFLICT', '이미 있는 장수입니다. 다른 이름으로 등록해 주세요!');
|
||||
}
|
||||
|
||||
const activeCount = await db.general.count({ where: { npcState: { lt: 2 } } });
|
||||
if (activeCount >= resolveMaxGeneral(worldState)) {
|
||||
fail('PRECONDITION_FAILED', '더이상 등록할 수 없습니다!');
|
||||
}
|
||||
|
||||
const statRule = resolveStatRule(worldState);
|
||||
const stats = [input.leadership, input.strength, input.intel] as const;
|
||||
if (stats.some((value) => !Number.isInteger(value) || value < statRule.min || value > statRule.max)) {
|
||||
fail('BAD_REQUEST', '능력치 범위를 벗어났습니다.');
|
||||
}
|
||||
if (stats.reduce((sum, value) => sum + value, 0) > statRule.total) {
|
||||
fail('BAD_REQUEST', `능력치가 ${statRule.total}을 넘어섰습니다. 다시 가입해주세요!`);
|
||||
}
|
||||
|
||||
if (
|
||||
input.character !== 'Random' &&
|
||||
(!isPersonalityTraitKey(input.character) || !JOIN_PERSONALITY_TRAIT_KEYS.some((key) => key === input.character))
|
||||
) {
|
||||
fail('BAD_REQUEST', '성격이 잘못 지정되었습니다.');
|
||||
}
|
||||
const availableSpecialWar = Array.isArray(configConst.availableSpecialWar)
|
||||
? configConst.availableSpecialWar.filter(
|
||||
(value): value is WarTraitKey => typeof value === 'string' && isWarTraitKey(value)
|
||||
)
|
||||
: [...WAR_TRAIT_KEYS];
|
||||
if (
|
||||
input.inheritSpecial !== undefined &&
|
||||
(!isWarTraitKey(input.inheritSpecial) || !availableSpecialWar.includes(input.inheritSpecial))
|
||||
) {
|
||||
fail('BAD_REQUEST', '전투 특기가 잘못 지정되었습니다.');
|
||||
}
|
||||
if (
|
||||
input.inheritTurntimeZone !== undefined &&
|
||||
(!Number.isInteger(input.inheritTurntimeZone) ||
|
||||
input.inheritTurntimeZone < 0 ||
|
||||
input.inheritTurntimeZone > 59)
|
||||
) {
|
||||
fail('BAD_REQUEST', '턴 시간 지정 범위가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const inheritBonus = validateAndNormalizeBonus(input.inheritBonusStat);
|
||||
const inheritConstants = resolveInheritConstants(worldState);
|
||||
const inheritRequiredPoint = calculateInheritanceCost(input, inheritConstants, inheritBonus);
|
||||
const currentInheritancePoint = await applyInheritanceUser(
|
||||
db,
|
||||
input.userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth
|
||||
);
|
||||
if (currentInheritancePoint < inheritRequiredPoint) {
|
||||
fail('BAD_REQUEST', '유산 포인트가 부족합니다. 다시 가입해주세요!');
|
||||
}
|
||||
|
||||
const allCities = world.listCities().sort((left, right) => left.id - right.id);
|
||||
const selectedCity =
|
||||
input.inheritCity === undefined ? null : (allCities.find((city) => city.id === input.inheritCity) ?? null);
|
||||
if (input.inheritCity !== undefined && !selectedCity) {
|
||||
fail('BAD_REQUEST', '도시가 잘못 지정되었습니다. 다시 가입해주세요!');
|
||||
}
|
||||
const neutralCities = allCities.filter((city) => city.level >= 5 && city.level <= 6 && city.nationId === 0);
|
||||
const randomCities =
|
||||
neutralCities.length > 0 ? neutralCities : allCities.filter((city) => city.level >= 5 && city.level <= 6);
|
||||
if (!selectedCity && randomCities.length === 0) {
|
||||
fail('PRECONDITION_FAILED', '생성 가능한 도시가 없습니다.');
|
||||
}
|
||||
|
||||
const hiddenSeed = readHiddenSeed(worldState);
|
||||
const rng = new RandUtil(
|
||||
new LiteHashDRBG(buildJoinCreateGeneralSeed(hiddenSeed, input.seedOwnerIdentity, acceptedAt))
|
||||
);
|
||||
const worldMeta = asRecord(worldState.meta);
|
||||
const currentGenius = Math.max(
|
||||
0,
|
||||
Math.floor(asNumber(worldMeta.genius, asNumber(configConst.defaultMaxGenius, DEFAULT_MAX_GENIUS)))
|
||||
);
|
||||
if (input.inheritSpecial !== undefined && currentGenius === 0) {
|
||||
fail('PRECONDITION_FAILED', '이미 천재가 모두 나타났습니다. 다시 가입해주세요!');
|
||||
}
|
||||
const geniusRequested = input.inheritSpecial !== undefined || rng.nextBool(0.01);
|
||||
const genius = geniusRequested && currentGenius > 0;
|
||||
|
||||
const city = selectedCity ?? rng.choice(randomCities);
|
||||
const bonus = inheritBonus ?? buildRandomBonus(rng, [...stats]);
|
||||
const finalStats = {
|
||||
leadership: input.leadership + bonus[0],
|
||||
strength: input.strength + bonus[1],
|
||||
intelligence: input.intel + bonus[2],
|
||||
};
|
||||
const age = 20 + (bonus[0] + bonus[1] + bonus[2]) * 2 - rng.nextRangeInt(0, 1);
|
||||
const relativeYear = resolveRelativeYear(worldState);
|
||||
const retirementYear = asNumber(configConst.retirementYear, 80);
|
||||
const scenarioId = Number(worldMeta.scenarioId ?? worldState.scenarioCode);
|
||||
let specialityDomesticAge = resolveSpecialityAge(retirementYear, age, relativeYear, 12);
|
||||
let specialityWarAge = resolveSpecialityAge(retirementYear, age, relativeYear, 6);
|
||||
let specialWar = typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None';
|
||||
let specialWarName = specialWar;
|
||||
if (genius) {
|
||||
specialityWarAge = age;
|
||||
if (input.inheritSpecial) {
|
||||
specialWar = input.inheritSpecial;
|
||||
} else {
|
||||
const traits = await loadWarTraitModules(availableSpecialWar, new WarTraitLoader());
|
||||
specialWar =
|
||||
TraitSelector.pickWarTrait(
|
||||
rng,
|
||||
finalStats,
|
||||
[0, 0, 0, 0, 0],
|
||||
traits,
|
||||
[],
|
||||
world.getScenarioConfig().stat
|
||||
) ?? specialWar;
|
||||
}
|
||||
const [trait] = await loadWarTraitModules(
|
||||
[specialWar].filter((key) => isWarTraitKey(key)),
|
||||
new WarTraitLoader()
|
||||
);
|
||||
specialWarName = trait?.name ?? specialWar;
|
||||
}
|
||||
if (Number.isFinite(scenarioId) && scenarioId >= 1000) {
|
||||
specialityDomesticAge = age + 3;
|
||||
specialityWarAge = age + 3;
|
||||
}
|
||||
|
||||
const experience = await resolveCatchupExperience(db, relativeYear);
|
||||
const turnTime = resolveTurnTime(
|
||||
rng,
|
||||
worldState,
|
||||
acceptedAt,
|
||||
world.getState().lastTurnTime,
|
||||
input.inheritTurntimeZone
|
||||
);
|
||||
const personality = input.character === 'Random' ? rng.choice([...JOIN_PERSONALITY_TRAIT_KEYS]) : input.character;
|
||||
const affinity = rng.nextRangeInt(1, 150);
|
||||
const generalId = world.getNextGeneralId();
|
||||
let obfuscatedNamePool = Array.isArray(worldMeta.obfuscatedNamePool)
|
||||
? worldMeta.obfuscatedNamePool.filter((value): value is string => typeof value === 'string')
|
||||
: [];
|
||||
if ((blockGeneralCreate & 2) !== 0 && obfuscatedNamePool.length === 0) {
|
||||
obfuscatedNamePool = buildAuctionAliasPool(hiddenSeed, configConst);
|
||||
world.updateWorldMeta({ obfuscatedNamePool });
|
||||
}
|
||||
const finalName =
|
||||
(blockGeneralCreate & 2) !== 0
|
||||
? buildAuctionAlias(generalId, hiddenSeed, {
|
||||
...configConst,
|
||||
obfuscatedNamePool,
|
||||
})
|
||||
: normalizedName;
|
||||
const showImgLevel = Math.floor(asNumber(config.showImgLevel, 0));
|
||||
const canUseOwnerPicture =
|
||||
showImgLevel >= 1 &&
|
||||
input.pic === true &&
|
||||
input.ownerCanUsePicture === true &&
|
||||
typeof input.ownerPicture === 'string' &&
|
||||
input.ownerPicture.length > 0 &&
|
||||
input.ownerPicture !== 'default.jpg';
|
||||
const picture = canUseOwnerPicture ? input.ownerPicture! : 'default.jpg';
|
||||
const imageServer = canUseOwnerPicture ? Math.max(0, Math.floor(input.ownerImageServer ?? 0)) : 0;
|
||||
const nextInheritancePoint = currentInheritancePoint - inheritRequiredPoint;
|
||||
const restInheritanceBonus = await resolveRestInheritanceBonus(db, worldState, input.userId);
|
||||
const finalInheritancePoint = nextInheritancePoint + restInheritanceBonus;
|
||||
const general: TurnGeneral = {
|
||||
id: generalId,
|
||||
userId: input.userId,
|
||||
name: finalName,
|
||||
nationId: 0,
|
||||
cityId: city.id,
|
||||
troopId: 0,
|
||||
npcState: 0,
|
||||
affinity,
|
||||
// Ref Join은 두 컬럼을 지정하지 않아 schema 기본값 180/300을 유지한다.
|
||||
bornYear: 180,
|
||||
deadYear: 300,
|
||||
picture,
|
||||
imageServer,
|
||||
stats: finalStats,
|
||||
experience,
|
||||
dedication: 0,
|
||||
officerLevel: 0,
|
||||
injury: 0,
|
||||
gold: asNumber(configConst.defaultGold, DEFAULT_GENERAL_GOLD),
|
||||
rice: asNumber(configConst.defaultRice, DEFAULT_GENERAL_RICE),
|
||||
crew: 0,
|
||||
crewTypeId: world.getUnitSet()?.defaultCrewTypeId ?? DEFAULT_CREW_TYPE_ID,
|
||||
train: 0,
|
||||
atmos: 0,
|
||||
turnTime,
|
||||
age,
|
||||
startAge: age,
|
||||
role: {
|
||||
personality,
|
||||
specialDomestic:
|
||||
typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None',
|
||||
specialWar,
|
||||
items: {
|
||||
horse: null,
|
||||
weapon: null,
|
||||
book: null,
|
||||
item: null,
|
||||
},
|
||||
},
|
||||
triggerState: {
|
||||
flags: {},
|
||||
counters: {},
|
||||
modifiers: {},
|
||||
meta: {},
|
||||
},
|
||||
lastTurn: { command: DEFAULT_TURN_ACTION },
|
||||
penalty: resolveLegacyPenalty(input.ownerLegacyPenalty, input.profileId, acceptedAt),
|
||||
inheritancePoints: {
|
||||
previous: finalInheritancePoint,
|
||||
},
|
||||
refreshScoreTotal: 0,
|
||||
meta: {
|
||||
createdBy: 'join',
|
||||
ownerName: input.ownerDisplayName,
|
||||
owner_name: input.ownerDisplayName,
|
||||
killturn: 6,
|
||||
npc_org: 0,
|
||||
newmsg: 0,
|
||||
makelimit: 0,
|
||||
block: 0,
|
||||
dedlevel: 0,
|
||||
explevel: 0,
|
||||
officerCity: 0,
|
||||
officer_city: 0,
|
||||
permission: 'normal',
|
||||
belong: 1,
|
||||
betray: relativeYear >= 4 ? 2 : 0,
|
||||
specage: specialityDomesticAge,
|
||||
specage2: specialityWarAge,
|
||||
defence_train: 80,
|
||||
tnmt: 1,
|
||||
myset: 6,
|
||||
tournament: 0,
|
||||
newvote: 0,
|
||||
inherit_spent_dyn: inheritRequiredPoint,
|
||||
},
|
||||
};
|
||||
if (!world.addGeneral(general)) {
|
||||
throw new Error(`장수 번호 ${generalId}를 할당할 수 없습니다.`);
|
||||
}
|
||||
if (genius) {
|
||||
world.updateWorldMeta({ genius: currentGenius - 1 });
|
||||
}
|
||||
|
||||
await db.generalTurn.createMany({
|
||||
data: Array.from({ length: MAX_GENERAL_TURNS }, (_, turnIdx) => ({
|
||||
generalId,
|
||||
turnIdx,
|
||||
actionCode: DEFAULT_TURN_ACTION,
|
||||
arg: {},
|
||||
})),
|
||||
});
|
||||
await db.generalTurnRevision.create({
|
||||
data: { generalId, revision: 0 },
|
||||
});
|
||||
await db.generalAccessLog.upsert({
|
||||
where: { generalId },
|
||||
update: { userId: input.userId, lastRefresh: acceptedAt },
|
||||
create: {
|
||||
generalId,
|
||||
userId: input.userId,
|
||||
lastRefresh: acceptedAt,
|
||||
},
|
||||
});
|
||||
if (inheritRequiredPoint > 0) {
|
||||
await setInheritancePoint(db, input.userId, nextInheritancePoint);
|
||||
const inheritLogs: string[] = [];
|
||||
if (input.inheritSpecial) {
|
||||
inheritLogs.push(`${specialWarName} 전투 특기를 가진 천재 생성`);
|
||||
}
|
||||
if (input.inheritCity !== undefined) {
|
||||
inheritLogs.push(`${city.name}에 장수 생성`);
|
||||
}
|
||||
if (inheritBonus) {
|
||||
inheritLogs.push(`${inheritBonus[0]}, ${inheritBonus[1]}, ${inheritBonus[2]} 보너스 능력치로 생성`);
|
||||
}
|
||||
if (input.inheritTurntimeZone !== undefined) {
|
||||
const zoneStart = input.inheritTurntimeZone * Math.max(1, Math.floor(worldState.tickSeconds / 60));
|
||||
inheritLogs.push(
|
||||
`턴 시간 ${String(Math.floor(zoneStart / 60)).padStart(2, '0')}:${String(zoneStart % 60).padStart(
|
||||
2,
|
||||
'0'
|
||||
)} 로 지정`
|
||||
);
|
||||
}
|
||||
inheritLogs.push(`장수 생성으로 포인트 ${inheritRequiredPoint} 소모`);
|
||||
for (const text of inheritLogs) {
|
||||
await appendInheritanceLog(db, input.userId, worldState.currentYear, worldState.currentMonth, text);
|
||||
}
|
||||
}
|
||||
if (restInheritanceBonus > 0) {
|
||||
await setInheritancePoint(db, input.userId, finalInheritancePoint);
|
||||
await appendInheritanceLog(
|
||||
db,
|
||||
input.userId,
|
||||
worldState.currentYear,
|
||||
worldState.currentMonth,
|
||||
`신규/복귀 생성으로 포인트 ${restInheritanceBonus} 지급`
|
||||
);
|
||||
}
|
||||
pushCreationLogs(world, {
|
||||
generalId,
|
||||
userId: input.userId,
|
||||
name: finalName,
|
||||
cityName: city.name,
|
||||
bonus,
|
||||
age,
|
||||
genius,
|
||||
specialWarName,
|
||||
});
|
||||
return { ok: true, generalId };
|
||||
};
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
reselectGeneralFromSelectionPool,
|
||||
SelectPoolError,
|
||||
} from './selectPoolService.js';
|
||||
import { createGeneralFromJoin, JoinCreateGeneralError } from './joinCreateGeneralService.js';
|
||||
|
||||
let itemRegistryPromise: Promise<Map<string, ItemModule>> | null = null;
|
||||
|
||||
@@ -131,21 +132,85 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => {
|
||||
|
||||
const resolveCommandAcceptedAt = async (
|
||||
db: DatabaseClient,
|
||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' | 'selectPoolReselect' }>
|
||||
command: Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' | '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 },
|
||||
select: { createdAt: true, actorUserId: true },
|
||||
});
|
||||
if (!event) {
|
||||
throw new Error(`ENGINE input event ${command.requestId} is missing.`);
|
||||
}
|
||||
if (event.actorUserId !== command.userId) {
|
||||
throw new Error(`ENGINE input event actor does not match ${command.type} user.`);
|
||||
}
|
||||
return event.createdAt;
|
||||
};
|
||||
|
||||
async function handleJoinCreateGeneral(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>
|
||||
): Promise<TurnDaemonCommandResult> {
|
||||
const db = requireCommandDatabase(ctx);
|
||||
const worldState = await db.worldState.findUnique({
|
||||
where: { id: ctx.world.getState().id },
|
||||
});
|
||||
if (!worldState) {
|
||||
throw new Error('Join world state is missing.');
|
||||
}
|
||||
const acceptedAt = await resolveCommandAcceptedAt(db, command);
|
||||
try {
|
||||
return {
|
||||
type: 'joinCreateGeneral',
|
||||
...(await createGeneralFromJoin({
|
||||
db,
|
||||
world: ctx.world,
|
||||
worldState,
|
||||
input: {
|
||||
userId: command.userId,
|
||||
ownerDisplayName: command.ownerDisplayName,
|
||||
seedOwnerIdentity: command.seedOwnerIdentity,
|
||||
name: command.name,
|
||||
leadership: command.leadership,
|
||||
strength: command.strength,
|
||||
intel: command.intel,
|
||||
pic: command.pic,
|
||||
character: command.character,
|
||||
profileId: command.profileId,
|
||||
...(command.ownerPicture !== undefined ? { ownerPicture: command.ownerPicture } : {}),
|
||||
...(command.ownerImageServer !== undefined ? { ownerImageServer: command.ownerImageServer } : {}),
|
||||
...(command.ownerCanUsePicture !== undefined
|
||||
? { ownerCanUsePicture: command.ownerCanUsePicture }
|
||||
: {}),
|
||||
...(command.ownerLegacyPenalty !== undefined
|
||||
? { ownerLegacyPenalty: command.ownerLegacyPenalty }
|
||||
: {}),
|
||||
...(command.inheritSpecial !== undefined ? { inheritSpecial: command.inheritSpecial } : {}),
|
||||
...(command.inheritTurntimeZone !== undefined
|
||||
? { inheritTurntimeZone: command.inheritTurntimeZone }
|
||||
: {}),
|
||||
...(command.inheritCity !== undefined ? { inheritCity: command.inheritCity } : {}),
|
||||
...(command.inheritBonusStat !== undefined ? { inheritBonusStat: command.inheritBonusStat } : {}),
|
||||
},
|
||||
acceptedAt,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof JoinCreateGeneralError) {
|
||||
return {
|
||||
type: 'joinCreateGeneral',
|
||||
ok: false,
|
||||
code: error.code,
|
||||
reason: error.message,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectPoolCreate(
|
||||
ctx: CommandHandlerContext,
|
||||
command: Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
|
||||
@@ -1952,16 +2017,12 @@ export const createTurnDaemonCommandHandler = (options: {
|
||||
handleTournamentMatchResult(ctx, command as Extract<TurnDaemonCommand, { type: 'tournamentMatchResult' }>),
|
||||
patchGeneral: (command) =>
|
||||
handlePatchGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'patchGeneral' }>),
|
||||
joinCreateGeneral: (command) =>
|
||||
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
|
||||
selectPoolCreate: (command) =>
|
||||
handleSelectPoolCreate(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>
|
||||
),
|
||||
handleSelectPoolCreate(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolCreate' }>),
|
||||
selectPoolReselect: (command) =>
|
||||
handleSelectPoolReselect(
|
||||
ctx,
|
||||
command as Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>
|
||||
),
|
||||
handleSelectPoolReselect(ctx, command as Extract<TurnDaemonCommand, { type: 'selectPoolReselect' }>),
|
||||
shiftSchedule: (command) =>
|
||||
handleShiftSchedule(ctx, command as Extract<TurnDaemonCommand, { type: 'shiftSchedule' }>),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildJoinCreateGeneralSeed, cutJoinTurnTime } from '../src/turn/joinCreateGeneralService.js';
|
||||
|
||||
describe('generic join legacy time contracts', () => {
|
||||
it('builds the Ref MakeGeneral seed from the Seoul whole-second timestamp', () => {
|
||||
expect(buildJoinCreateGeneralSeed('seed', 42, new Date('2026-07-30T23:59:58.987Z'))).toBe(
|
||||
'str(4,seed)|str(11,MakeGeneral)|int(42)|str(19,2026-07-31 08:59:58)'
|
||||
);
|
||||
});
|
||||
|
||||
it('aligns a 120-minute turn from the Ref Asia/Seoul previous-day 01:00 anchor', () => {
|
||||
expect(cutJoinTurnTime(new Date('2026-07-30T03:34:56.789Z'), 120 * 60).toISOString()).toBe(
|
||||
'2026-07-30T02:00:00.000Z'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
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 ?? 15126);
|
||||
const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15127);
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'hwe').replace(/^\/+|\/+$/g, '')}`;
|
||||
const profileId = process.env.PLAYWRIGHT_PROFILE_ID ?? 'create_general_integration';
|
||||
const scenario = process.env.PLAYWRIGHT_SCENARIO ?? '2';
|
||||
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? `${profileId}:${scenario}`;
|
||||
const baseURL = `http://127.0.0.1:${frontendPort}${basePath}/`;
|
||||
const gameApiUrl = `http://127.0.0.1:${apiPort}/trpc`;
|
||||
const databaseUrl = process.env.JOIN_LIVE_DATABASE_URL ?? '';
|
||||
const redisUrl = process.env.JOIN_LIVE_REDIS_URL ?? '';
|
||||
const gameSecret = process.env.JOIN_LIVE_GAME_SECRET ?? '';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: ['joinGeneralLive.spec.ts'],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
reporter: [['list']],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/join-general-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,200 @@
|
||||
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.JOIN_LIVE_DATABASE_URL;
|
||||
const gameTokenSecret = process.env.JOIN_LIVE_GAME_SECRET;
|
||||
const profile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'create_general_integration:2';
|
||||
const userId = 'join-general-live-user';
|
||||
const hasLiveFixture = Boolean(databaseUrl && gameTokenSecret);
|
||||
|
||||
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: `join-live-${randomUUID()}`,
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'join-live-user',
|
||||
displayName: '브라우저생성',
|
||||
roles: ['user'],
|
||||
legacyMemberNo: 7_700,
|
||||
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('generic general creation through live PostgreSQL, Redis, API, and Chromium', () => {
|
||||
test.skip(!hasLiveFixture, 'live join 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');
|
||||
if (schema !== 'create_general_integration') {
|
||||
throw new Error(`Refusing to mutate non-dedicated schema: ${schema ?? '(missing)'}`);
|
||||
}
|
||||
await seedScenarioToDatabase({
|
||||
scenarioId: 2,
|
||||
databaseUrl: databaseUrl!,
|
||||
now: new Date('2099-07-30T12:00:00.000Z'),
|
||||
installOptions: {
|
||||
turnTermMinutes: 5,
|
||||
npcMode: 0,
|
||||
showImgLevel: 3,
|
||||
serverId: profile,
|
||||
season: 1,
|
||||
},
|
||||
});
|
||||
const connector = createGamePostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.inputEvent.deleteMany();
|
||||
runtime = await createTurnDaemonRuntime({
|
||||
profile,
|
||||
databaseUrl: databaseUrl!,
|
||||
enableDatabaseFlush: true,
|
||||
enableLeaseHeartbeat: false,
|
||||
leaseOwnerId: 'join-general-live-daemon',
|
||||
});
|
||||
daemonLoop = runtime.lifecycle.start();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (runtime) {
|
||||
await runtime.lifecycle.stop('join general live complete');
|
||||
await daemonLoop;
|
||||
await runtime.close();
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
test('keeps the request id across an accepted timeout and creates exactly once', async ({ page }, testInfo) => {
|
||||
const requestIds: string[] = [];
|
||||
let injectTimeout = true;
|
||||
await installSession(page);
|
||||
await page.route('**/trpc/join.createGeneral?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:
|
||||
'장수 생성 요청은 접수됐지만 처리 결과를 아직 확인하지 못했습니다. 같은 요청으로 다시 시도해 주세요.',
|
||||
code: -32008,
|
||||
data: {
|
||||
code: 'TIMEOUT',
|
||||
httpStatus: 408,
|
||||
path: 'join.createGeneral',
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
await page.goto('join');
|
||||
await expect(page.getByRole('heading', { name: '장수 생성/빙의' })).toBeVisible();
|
||||
const createButton = page.locator('.form-actions').getByRole('button', {
|
||||
name: '장수 생성',
|
||||
exact: true,
|
||||
});
|
||||
await expect(createButton).toBeEnabled();
|
||||
await expect(page.getByText('은둔', { exact: true })).toHaveCount(0);
|
||||
|
||||
const geometry = await page.locator('.join-page').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
width: rect.width,
|
||||
minHeight: style.minHeight,
|
||||
padding: style.padding,
|
||||
};
|
||||
});
|
||||
expect(geometry).toEqual({
|
||||
width: 1200,
|
||||
minHeight: '900px',
|
||||
padding: '24px',
|
||||
});
|
||||
await createButton.focus();
|
||||
await expect(createButton).toBeFocused();
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('join-general-focused.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await createButton.click();
|
||||
await expect(page.locator('.join-error')).toContainText('같은 요청으로 다시 시도해 주세요.');
|
||||
await expect.poll(() => db.general.count({ where: { userId } })).toBe(1);
|
||||
const storedPending = await page.evaluate(() =>
|
||||
window.sessionStorage.getItem('sammo-join-create-pending-action')
|
||||
);
|
||||
expect(storedPending).toContain(requestIds[0]);
|
||||
|
||||
await createButton.click();
|
||||
await expect(page).toHaveURL(/\/hwe\/$/);
|
||||
expect(requestIds).toHaveLength(2);
|
||||
expect(requestIds[1]).toBe(requestIds[0]);
|
||||
await expect.poll(() => db.general.count({ where: { userId } })).toBe(1);
|
||||
const created = await db.general.findFirstOrThrow({ where: { userId } });
|
||||
expect(created).toMatchObject({
|
||||
name: '브라우저생성',
|
||||
picture: 'default.jpg',
|
||||
});
|
||||
const event = await db.inputEvent.findFirstOrThrow({
|
||||
where: { actorUserId: userId, eventType: 'joinCreateGeneral' },
|
||||
});
|
||||
expect(event).toMatchObject({ status: 'SUCCEEDED', attempts: 1 });
|
||||
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-join-create-pending-action'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"types": ["node", "@playwright/test"]
|
||||
},
|
||||
"include": ["./joinGeneralLive.spec.ts", "./joinGeneral.live.playwright.config.mjs"]
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs",
|
||||
"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",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node -e \"console.log('test not configured')\"",
|
||||
|
||||
@@ -10,7 +10,14 @@ import { cityLevelMap, regionMap } from '../utils/nationFormat';
|
||||
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 JoinForm = Omit<JoinInput, 'inheritBonusStat'> & { inheritBonusStat: [number, number, number] };
|
||||
type JoinForm = Omit<JoinInput, 'inheritBonusStat' | 'clientRequestId'> & {
|
||||
inheritBonusStat: [number, number, number];
|
||||
};
|
||||
type PendingJoinAction = {
|
||||
ownerUserId: string;
|
||||
input: JoinForm;
|
||||
clientRequestId: string;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
@@ -21,6 +28,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 form = ref<JoinForm>({
|
||||
name: '',
|
||||
@@ -32,6 +40,55 @@ const form = ref<JoinForm>({
|
||||
inheritBonusStat: [0, 0, 0],
|
||||
});
|
||||
|
||||
const readPendingJoin = (): PendingJoinAction | null => {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(pendingJoinStorageKey);
|
||||
if (!raw) return null;
|
||||
const value = JSON.parse(raw) as Partial<PendingJoinAction>;
|
||||
if (
|
||||
!value.input ||
|
||||
typeof value.input !== 'object' ||
|
||||
typeof value.ownerUserId !== 'string' ||
|
||||
typeof value.clientRequestId !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return value as PendingJoinAction;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const cloneJoinInput = (): JoinForm => JSON.parse(JSON.stringify(form.value)) as JoinForm;
|
||||
|
||||
const getPendingJoin = (): PendingJoinAction => {
|
||||
const input = cloneJoinInput();
|
||||
const current = readPendingJoin();
|
||||
const ownerUserId = joinConfig.value?.user.id ?? '';
|
||||
if (current && current.ownerUserId === ownerUserId && JSON.stringify(current.input) === JSON.stringify(input)) {
|
||||
return current;
|
||||
}
|
||||
const pending: PendingJoinAction = {
|
||||
ownerUserId,
|
||||
input,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
};
|
||||
window.sessionStorage.setItem(pendingJoinStorageKey, JSON.stringify(pending));
|
||||
return pending;
|
||||
};
|
||||
|
||||
const clearPendingJoin = (pending: PendingJoinAction): void => {
|
||||
if (readPendingJoin()?.clientRequestId === pending.clientRequestId) {
|
||||
window.sessionStorage.removeItem(pendingJoinStorageKey);
|
||||
}
|
||||
};
|
||||
|
||||
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 npcLoading = ref(false);
|
||||
const npcError = ref<string | null>(null);
|
||||
@@ -199,8 +256,13 @@ const loadConfig = async () => {
|
||||
return;
|
||||
}
|
||||
joinConfig.value = config;
|
||||
form.value.name = config.user.displayName || '';
|
||||
applyBalancedStats();
|
||||
const pending = readPendingJoin();
|
||||
if (pending?.ownerUserId === config.user.id) {
|
||||
form.value = pending.input;
|
||||
} else {
|
||||
form.value.name = config.rules.allowCustomName ? config.user.displayName || '' : '무작위';
|
||||
applyBalancedStats();
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'join_config_failed';
|
||||
} finally {
|
||||
@@ -234,13 +296,21 @@ const submitJoin = async () => {
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
const pending = getPendingJoin();
|
||||
try {
|
||||
await trpc.join.createGeneral.mutate(form.value);
|
||||
await trpc.join.createGeneral.mutate({
|
||||
...pending.input,
|
||||
clientRequestId: pending.clientRequestId,
|
||||
});
|
||||
clearPendingJoin(pending);
|
||||
await session.refreshGeneralStatus();
|
||||
if (session.hasGeneral) {
|
||||
await router.push({ name: 'home' });
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isIndeterminateTimeout(err)) {
|
||||
clearPendingJoin(pending);
|
||||
}
|
||||
error.value = err instanceof Error ? err.message : 'join_failed';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
@@ -315,7 +385,13 @@ onMounted(() => {
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>장수명</span>
|
||||
<input v-model="form.name" type="text" class="form-input" />
|
||||
<input
|
||||
v-if="joinConfig?.rules.allowCustomName"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-input"
|
||||
/>
|
||||
<span v-else>무작위</span>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>성격</span>
|
||||
|
||||
@@ -20,9 +20,13 @@ const readObject = <T extends object>(value: unknown, fallback: T): T => {
|
||||
const readLegacyMemberNo = (value: unknown): number | undefined => {
|
||||
const legacyData = readObject<Record<string, unknown>>(value, {});
|
||||
const memberNo = legacyData.memberNo;
|
||||
return typeof memberNo === 'number' && Number.isSafeInteger(memberNo) && memberNo > 0
|
||||
? memberNo
|
||||
: undefined;
|
||||
return typeof memberNo === 'number' && Number.isSafeInteger(memberNo) && memberNo > 0 ? memberNo : undefined;
|
||||
};
|
||||
|
||||
const readLegacyGrade = (value: unknown): number | undefined => {
|
||||
const legacyData = readObject<Record<string, unknown>>(value, {});
|
||||
const grade = legacyData.grade;
|
||||
return typeof grade === 'number' && Number.isSafeInteger(grade) ? grade : undefined;
|
||||
};
|
||||
|
||||
const mapUser = (row: {
|
||||
@@ -71,6 +75,7 @@ const mapUser = (row: {
|
||||
passwordSalt: row.passwordSalt,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
legacyMemberNo: readLegacyMemberNo(row.legacyData),
|
||||
legacyGrade: readLegacyGrade(row.legacyData),
|
||||
});
|
||||
|
||||
export const createPostgresUserRepository = (
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface UserRecord {
|
||||
passwordSalt: string;
|
||||
createdAt: string;
|
||||
legacyMemberNo?: number;
|
||||
legacyGrade?: number;
|
||||
}
|
||||
|
||||
export interface PublicUser {
|
||||
@@ -43,6 +44,7 @@ export interface UserSanctions {
|
||||
notes?: string;
|
||||
profileIconResetAt?: string;
|
||||
serverRestrictions?: Record<string, UserServerRestriction>;
|
||||
legacyPenalty?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UserServerRestriction {
|
||||
|
||||
@@ -13,12 +13,7 @@ import type { UserOAuthInfo } from './auth/userRepository.js';
|
||||
import { adminRouter } from './adminRouter.js';
|
||||
import { accountRouter } from './account/router.js';
|
||||
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
|
||||
import {
|
||||
openPassword,
|
||||
zDisplayName,
|
||||
zPasswordEnvelope,
|
||||
zRegistrationUsername,
|
||||
} from './auth/registrationInput.js';
|
||||
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
|
||||
|
||||
const zUsername = z
|
||||
.string()
|
||||
@@ -667,14 +662,17 @@ export const appRouter = router({
|
||||
expiresAt: addSeconds(now, ctx.gameSessionTtlSeconds).toISOString(),
|
||||
sessionId: gameSession.gameToken,
|
||||
user: {
|
||||
id: gameSession.userId,
|
||||
username: gameSession.username,
|
||||
displayName: gameSession.displayName,
|
||||
roles: gameSession.roles,
|
||||
createdAt: gameSession.createdAt,
|
||||
legacyMemberNo: gameSession.legacyMemberNo,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
picture: user.picture,
|
||||
imageServer: user.imageServer,
|
||||
canUseGeneralPicture: user.legacyGrade === undefined || user.legacyGrade >= 1,
|
||||
roles: user.roles,
|
||||
createdAt: user.createdAt,
|
||||
legacyMemberNo: user.legacyMemberNo,
|
||||
},
|
||||
sanctions: gameSession.sanctions,
|
||||
sanctions: user.sanctions,
|
||||
identity: {
|
||||
kakaoVerified: localAccountPolicy.kakaoVerified,
|
||||
canCreateGeneral: localAccountPolicy.canCreateGeneral,
|
||||
|
||||
@@ -79,7 +79,8 @@ const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: nu
|
||||
];
|
||||
const profiles = {
|
||||
listProfiles: async () => profileRows,
|
||||
getProfile: async (profileName: string) => profileRows.find((profile) => profile.profileName === profileName) ?? null,
|
||||
getProfile: async (profileName: string) =>
|
||||
profileRows.find((profile) => profile.profileName === profileName) ?? null,
|
||||
upsertProfile: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
@@ -544,6 +545,49 @@ describe('gateway auth flow', () => {
|
||||
expect(validated?.user).not.toHaveProperty('legacyMemberNo');
|
||||
});
|
||||
|
||||
it('issues each game token from the latest user roles, sanctions, and icon', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const user = await users.createUser({
|
||||
username: 'fresh-game-identity',
|
||||
password: 'secretpass',
|
||||
});
|
||||
user.legacyGrade = 0;
|
||||
const session = await sessions.createSession(user);
|
||||
|
||||
await users.updateRoles(user.id, ['user', 'latest-role']);
|
||||
await users.updateSanctions(user.id, {
|
||||
warningCount: 2,
|
||||
legacyPenalty: {
|
||||
any: {
|
||||
chat: { expire: 4_102_444_800, value: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
await users.updateIcon(user.id, 'latest-owner.webp', 3, new Date('2026-07-30T12:00:00.000Z'));
|
||||
|
||||
const issued = await caller.auth.issueGameSession({
|
||||
sessionToken: session.sessionToken,
|
||||
profile: 'che:default',
|
||||
});
|
||||
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
|
||||
|
||||
expect(payload?.user).toMatchObject({
|
||||
id: user.id,
|
||||
roles: ['user', 'latest-role'],
|
||||
picture: 'latest-owner.webp',
|
||||
imageServer: 3,
|
||||
canUseGeneralPicture: false,
|
||||
});
|
||||
expect(payload?.sanctions).toMatchObject({
|
||||
warningCount: 2,
|
||||
legacyPenalty: {
|
||||
any: {
|
||||
chat: { expire: 4_102_444_800, value: 1 },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('revokes the gateway session and every linked game session on logout', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const user = await users.createUser({
|
||||
@@ -573,10 +617,10 @@ describe('account self service', () => {
|
||||
const session = await sessions.createSession(user);
|
||||
|
||||
await expect(
|
||||
caller.account.changePassword({
|
||||
sessionToken: session.sessionToken,
|
||||
currentCredential: sealPassword('wrong-password'),
|
||||
newCredential: sealPassword('next-password'),
|
||||
caller.account.changePassword({
|
||||
sessionToken: session.sessionToken,
|
||||
currentCredential: sealPassword('wrong-password'),
|
||||
newCredential: sealPassword('next-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface UserSanctions {
|
||||
notes?: string;
|
||||
profileIconResetAt?: string;
|
||||
serverRestrictions?: Record<string, UserServerRestriction>;
|
||||
legacyPenalty?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UserServerRestriction {
|
||||
@@ -23,6 +24,9 @@ export interface GatewayUserInfo {
|
||||
username: string;
|
||||
displayName: string;
|
||||
roles: string[];
|
||||
picture?: string;
|
||||
imageServer?: number;
|
||||
canUseGeneralPicture?: boolean;
|
||||
createdAt?: string;
|
||||
legacyMemberNo?: number;
|
||||
}
|
||||
@@ -58,7 +62,7 @@ export const encryptGameSessionToken = (payload: GameSessionTokenPayload, secret
|
||||
return `${toBase64Url(iv)}.${toBase64Url(ciphertext)}.${toBase64Url(tag)}`;
|
||||
};
|
||||
|
||||
const parsePayload = (value: unknown): GameSessionTokenPayload | null => {
|
||||
export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPayload | null => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
@@ -84,8 +88,10 @@ const parsePayload = (value: unknown): GameSessionTokenPayload | null => {
|
||||
typeof user.username !== 'string' ||
|
||||
typeof user.displayName !== 'string' ||
|
||||
!Array.isArray(user.roles) ||
|
||||
(user.legacyMemberNo !== undefined &&
|
||||
(!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
|
||||
(user.picture !== undefined && typeof user.picture !== 'string') ||
|
||||
(user.imageServer !== undefined && (!Number.isSafeInteger(user.imageServer) || user.imageServer < 0)) ||
|
||||
(user.canUseGeneralPicture !== undefined && typeof user.canUseGeneralPicture !== 'boolean') ||
|
||||
(user.legacyMemberNo !== undefined && (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -123,7 +129,7 @@ export const decryptGameSessionToken = (token: string, secret: string): GameSess
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
||||
return parsePayload(JSON.parse(plaintext));
|
||||
return parseGameSessionTokenPayload(JSON.parse(plaintext));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -204,6 +204,28 @@ export type TurnDaemonCommand =
|
||||
specialWar?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'joinCreateGeneral';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
ownerDisplayName: string;
|
||||
seedOwnerIdentity: string | number;
|
||||
name: string;
|
||||
leadership: number;
|
||||
strength: number;
|
||||
intel: number;
|
||||
pic: boolean;
|
||||
character: string;
|
||||
profileId: string;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
ownerCanUsePicture?: boolean;
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
inheritSpecial?: string;
|
||||
inheritTurntimeZone?: number;
|
||||
inheritCity?: number;
|
||||
inheritBonusStat?: [number, number, number];
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolCreate';
|
||||
requestId?: string;
|
||||
@@ -476,6 +498,17 @@ export type TurnDaemonCommandResult =
|
||||
generalId: number;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'joinCreateGeneral';
|
||||
ok: true;
|
||||
generalId: number;
|
||||
}
|
||||
| {
|
||||
type: 'joinCreateGeneral';
|
||||
ok: false;
|
||||
code: 'BAD_REQUEST' | 'FORBIDDEN' | 'PRECONDITION_FAILED' | 'CONFLICT' | 'INTERNAL_SERVER_ERROR';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'selectPoolCreate';
|
||||
ok: true;
|
||||
|
||||
@@ -204,6 +204,7 @@ export interface TurnEngineGeneralUpdateInput {
|
||||
specialCode: string;
|
||||
special2Code: string;
|
||||
lastTurn: InputJsonValue;
|
||||
penalty: InputJsonValue;
|
||||
meta: InputJsonValue;
|
||||
turnTime: Date;
|
||||
recentWarTime: Date | null;
|
||||
|
||||
@@ -16,6 +16,11 @@ export const PERSONALITY_TRAIT_KEYS = [
|
||||
|
||||
export type PersonalityTraitKey = (typeof PERSONALITY_TRAIT_KEYS)[number];
|
||||
|
||||
// Ref GameConst::$availablePersonality에는 은둔이 포함되지 않는다.
|
||||
export const JOIN_PERSONALITY_TRAIT_KEYS = PERSONALITY_TRAIT_KEYS.filter(
|
||||
(key): key is Exclude<PersonalityTraitKey, 'che_은둔'> => key !== 'che_은둔'
|
||||
);
|
||||
|
||||
export type PersonalityTraitModule = TraitModule;
|
||||
|
||||
export type PersonalityTraitImporter = () => Promise<TraitModuleExport>;
|
||||
|
||||
@@ -179,11 +179,14 @@ const readNameParts = (value: unknown, fallback: readonly string[]): string[] =>
|
||||
return result.length > 0 ? result : [...fallback];
|
||||
};
|
||||
|
||||
export const buildAuctionAlias = (
|
||||
generalId: number,
|
||||
export const buildAuctionAliasPool = (
|
||||
hiddenSeed: string | number,
|
||||
configConst: Record<string, unknown> = {}
|
||||
): string => {
|
||||
): string[] => {
|
||||
const persistedPool = readNameParts(configConst.obfuscatedNamePool, []);
|
||||
if (persistedPool.length > 0) {
|
||||
return persistedPool;
|
||||
}
|
||||
const firstNames = readNameParts(configConst.randGenFirstName, LEGACY_RANDOM_GENERAL_FIRST_NAMES);
|
||||
const middleNames = readNameParts(configConst.randGenMiddleName, ['']);
|
||||
const lastNames = readNameParts(configConst.randGenLastName, LEGACY_RANDOM_GENERAL_LAST_NAMES);
|
||||
@@ -195,7 +198,15 @@ export const buildAuctionAlias = (
|
||||
}
|
||||
}
|
||||
}
|
||||
const shuffled = new RandUtil(new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))).shuffle(pool);
|
||||
return new RandUtil(new LiteHashDRBG(simpleSerialize(hiddenSeed, 'obfuscatedNamePool'))).shuffle(pool);
|
||||
};
|
||||
|
||||
export const buildAuctionAlias = (
|
||||
generalId: number,
|
||||
hiddenSeed: string | number,
|
||||
configConst: Record<string, unknown> = {}
|
||||
): string => {
|
||||
const shuffled = buildAuctionAliasPool(hiddenSeed, configConst);
|
||||
const normalizedId = Math.max(0, Math.floor(generalId));
|
||||
const duplicateIndex = Math.floor(normalizedId / shuffled.length);
|
||||
const name = shuffled[normalizedId % shuffled.length] ?? `익명${normalizedId}`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAuctionAlias } from '../src/auction/alias.js';
|
||||
import { buildAuctionAlias, buildAuctionAliasPool } from '../src/auction/alias.js';
|
||||
|
||||
describe('buildAuctionAlias', () => {
|
||||
it('returns a stable alias for the same world seed and general id', () => {
|
||||
@@ -22,4 +22,18 @@ describe('buildAuctionAlias', () => {
|
||||
expect(buildAuctionAlias(1, 'seed', config)).toMatch(/^청운(객|상)$/);
|
||||
expect(buildAuctionAlias(2, 'seed', config)).toMatch(/^청운(객|상)1$/);
|
||||
});
|
||||
|
||||
it('reuses the world-persisted alias pool after general creation changes RNG inputs', () => {
|
||||
const config = {
|
||||
obfuscatedNamePool: ['고정별호A', '고정별호B'],
|
||||
randGenFirstName: ['변경'],
|
||||
randGenMiddleName: ['된'],
|
||||
randGenLastName: ['이름'],
|
||||
};
|
||||
|
||||
expect(buildAuctionAliasPool('new-seed', config)).toEqual(['고정별호A', '고정별호B']);
|
||||
expect(buildAuctionAlias(0, 'new-seed', config)).toBe('고정별호A');
|
||||
expect(buildAuctionAlias(1, 'new-seed', config)).toBe('고정별호B');
|
||||
expect(buildAuctionAlias(2, 'new-seed', config)).toBe('고정별호A1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "ludo_rathowm",
|
||||
"name": "ludo_rathowm",
|
||||
"defaultCrewTypeId": 1100,
|
||||
"defaultCrewTypeId": 217100,
|
||||
"armTypes": {
|
||||
"1": "보병",
|
||||
"2": "궁병",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "siegetank",
|
||||
"name": "siegetank",
|
||||
"defaultCrewTypeId": 1100,
|
||||
"defaultCrewTypeId": 1500,
|
||||
"armTypes": {
|
||||
"1": "보병",
|
||||
"2": "궁병",
|
||||
|
||||
@@ -329,6 +329,7 @@ describe('auction integration flow', () => {
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
character: 'Random',
|
||||
pic: true,
|
||||
});
|
||||
userSessions.push({
|
||||
username: user.username,
|
||||
|
||||
@@ -322,6 +322,7 @@ describe('integration initialization flow', () => {
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
character: 'Random',
|
||||
pic: true,
|
||||
});
|
||||
const generalInfo = await userGameClient.general.me.query();
|
||||
if (!generalInfo?.general) {
|
||||
|
||||
@@ -639,6 +639,7 @@ describe('pm2 orchestrator e2e', () => {
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
character: 'Random',
|
||||
pic: true,
|
||||
});
|
||||
expect(createdGeneral.generalId).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -230,10 +230,7 @@ describe('actual tournament lifecycle', () => {
|
||||
for (const [username, displayName] of users) {
|
||||
const login = await gatewayClient.auth.login.mutate({
|
||||
username,
|
||||
credential: sealGatewayPassword(
|
||||
`${username}-pass`,
|
||||
await gatewayClient.auth.passwordKey.query()
|
||||
),
|
||||
credential: sealGatewayPassword(`${username}-pass`, await gatewayClient.auth.passwordKey.query()),
|
||||
});
|
||||
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
|
||||
sessionToken: login.sessionToken,
|
||||
@@ -252,6 +249,7 @@ describe('actual tournament lifecycle', () => {
|
||||
strength: 55,
|
||||
intel: 55,
|
||||
character: 'Random',
|
||||
pic: true,
|
||||
});
|
||||
generalIds.set(username, created.generalId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user