Files
core2026/app/game-api/src/router/join/index.ts
T
Hide_D a9ddffa97c feat: add integration tests for initialization flow
- Implemented end-to-end integration tests covering database reset, bootstrap admin creation, demo user provisioning, scenario installation, and general creation.
- Added support for deterministic seeding and auto admin general creation.
- Created a new package for integration tests with necessary configurations and scripts.
- Updated various modules to support new features, including admin user handling and scenario seeding.
- Enhanced error handling and validation in the join and orchestrator modules.
2026-01-18 06:32:19 +00:00

445 lines
17 KiB
TypeScript

import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import type { WorldStateRow } from '../../context.js';
import { authedProcedure, router } from '../../trpc.js';
import { asNumber, asRecord, asStringArray, parseBooleanWithFallback } from '@sammo-ts/common';
import {
isPersonalityTraitKey,
isWarTraitKey,
loadPersonalityTraitModules,
loadWarTraitModules,
PersonalityTraitLoader,
PERSONALITY_TRAIT_KEYS,
WarTraitLoader,
WAR_TRAIT_KEYS,
} from '@sammo-ts/logic';
const DEFAULT_JOIN_STAT = {
total: 165,
min: 15,
max: 80,
bonusMin: 3,
bonusMax: 5,
};
const resolveJoinStat = (worldState: WorldStateRow) => {
const config = asRecord(worldState.config);
const stat = asRecord(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),
bonusMin: DEFAULT_JOIN_STAT.bonusMin,
bonusMax: DEFAULT_JOIN_STAT.bonusMax,
};
};
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;
};
let cachedPersonalityOptions: Array<{ key: string; name: string; info: string }> | null = null;
const loadPersonalityOptions = async () => {
if (cachedPersonalityOptions) {
return cachedPersonalityOptions;
}
const modules = await loadPersonalityTraitModules([...PERSONALITY_TRAIT_KEYS], new PersonalityTraitLoader());
cachedPersonalityOptions = modules.map((trait) => ({
key: trait.key,
name: trait.name,
info: trait.info ?? '',
}));
return cachedPersonalityOptions;
};
const loadWarOptions = async (keys: string[]) => {
const unique = Array.from(new Set(keys.filter((key) => isWarTraitKey(key))));
const modules = await loadWarTraitModules(unique, new WarTraitLoader());
return modules.map((trait) => ({
key: trait.key,
name: trait.name,
info: trait.info ?? '',
}));
};
export const joinRouter = router({
getConfig: authedProcedure.query(async ({ ctx }) => {
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const config = asRecord(worldState.config);
const configConst = asRecord(config.const);
const availableSpecialWar = asStringArray(configConst.availableSpecialWar);
const warKeys = availableSpecialWar.length > 0 ? availableSpecialWar : [...WAR_TRAIT_KEYS];
const [personalities, warSpecials, nationRows] = await Promise.all([
loadPersonalityOptions(),
loadWarOptions(warKeys),
ctx.db.nation.findMany({
select: {
id: true,
name: true,
color: true,
meta: true,
},
orderBy: { id: 'asc' },
}),
]);
const nations = nationRows.map((nation) => {
const meta = asRecord(nation.meta);
return {
id: nation.id,
name: nation.name,
color: nation.color,
scoutMessage: typeof meta.infoText === 'string' ? meta.infoText : null,
};
});
return {
rules: {
stat: resolveJoinStat(worldState),
allowCustomName: true,
},
user: {
id: ctx.auth?.user.id ?? '',
displayName: ctx.auth?.user.displayName ?? '',
},
personalities: [
{ key: 'Random', name: '???', info: '무작위 성격을 선택합니다.' },
...personalities,
],
warSpecials,
nations,
};
}),
createGeneral: authedProcedure
.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(),
inheritSpecial: z.string().optional(),
inheritTurntimeZone: z.number().int().optional(),
inheritCity: z.number().int().optional(),
inheritBonusStat: z.tuple([z.number().int(), z.number().int(), z.number().int()]).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const worldState = await ctx.db.worldState.findFirst();
if (!worldState) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'World state is not initialized.',
});
}
const joinPolicy = resolveJoinPolicy(worldState);
if (joinPolicy.blockGeneralCreate === 1) {
throw new TRPCError({
code: 'FORBIDDEN',
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';
return ctx.db.$transaction(async (db) => {
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 },
orderBy: { id: 'asc' },
});
if (!cityList.length) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '도시 정보를 찾을 수 없습니다.',
});
}
// 통합 테스트 전용: ENV로 지정한 경우에만 도시 지정 허용.
const allowCityOverride = parseBooleanWithFallback(process.env.INTEGRATION_JOIN_ALLOW_CITY, false);
if (allowCityOverride && typeof input.inheritCity === 'number') {
const override = cityList.find((city) => city.id === input.inheritCity);
if (!override) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '지정한 도시를 찾을 수 없습니다.',
});
}
if (override.level !== 5 && override.level !== 6) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '통합 테스트에서는 소성/중성 도시만 지정할 수 있습니다.',
});
}
const general = await db.general.create({
data: {
id: nextId,
userId,
name: generalName,
nationId: 0,
cityId: override.id,
troopId: 0,
npcState: 0,
leadership: input.leadership,
strength: input.strength,
intel: input.intel,
personalCode: chosenPersonality ?? 'None',
specialCode: 'None',
special2Code: 'None',
turnTime: new Date(),
meta: {
createdBy: 'join',
},
},
});
return { ok: true, generalId: general.id };
}
const cityIndex = hashString(userId) % cityList.length;
const cityId = cityList[cityIndex]?.id ?? cityList[0].id;
const general = await db.general.create({
data: {
id: nextId,
userId,
name: generalName,
nationId: 0,
cityId,
troopId: 0,
npcState: 0,
leadership: input.leadership,
strength: input.strength,
intel: input.intel,
personalCode: chosenPersonality ?? 'None',
specialCode: 'None',
special2Code: 'None',
turnTime: new Date(),
meta: {
createdBy: 'join',
},
},
});
return { ok: true, generalId: general.id };
});
}),
listPossessCandidates: authedProcedure
.input(
z.object({
limit: z.number().int().min(1).max(50).optional(),
offset: z.number().int().min(0).optional(),
})
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 20;
const offset = input.offset ?? 0;
const candidates = await ctx.db.general.findMany({
where: {
userId: null,
npcState: { gte: 2 },
},
orderBy: { id: 'asc' },
skip: offset,
take: limit,
select: {
id: true,
name: true,
npcState: true,
nationId: true,
cityId: true,
leadership: true,
strength: true,
intel: true,
age: true,
officerLevel: true,
personalCode: true,
specialCode: true,
special2Code: true,
picture: true,
imageServer: true,
},
});
const [nationRows, cityRows] = await Promise.all([
ctx.db.nation.findMany({ select: { id: true, name: true, color: true } }),
ctx.db.city.findMany({ select: { id: true, name: true } }),
]);
const nationMap = new Map(nationRows.map((nation) => [nation.id, nation]));
const cityMap = new Map(cityRows.map((city) => [city.id, city]));
return candidates.map((candidate) => {
const nation = nationMap.get(candidate.nationId);
const city = cityMap.get(candidate.cityId);
return {
id: candidate.id,
name: candidate.name,
npcState: candidate.npcState,
nation: nation
? { id: nation.id, name: nation.name, color: nation.color }
: { id: 0, name: '재야', color: '#666666' },
city: city ? { id: city.id, name: city.name } : null,
stats: {
leadership: candidate.leadership,
strength: candidate.strength,
intelligence: candidate.intel,
},
age: candidate.age,
officerLevel: candidate.officerLevel,
personality: candidate.personalCode,
special: candidate.specialCode,
specialWar: candidate.special2Code,
picture: candidate.picture,
imageServer: candidate.imageServer,
};
});
}),
possessGeneral: authedProcedure
.input(
z.object({
generalId: z.number().int().positive(),
})
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.auth?.user.id;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const existing = await ctx.db.general.findFirst({ where: { userId } });
if (existing) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '이미 장수가 생성되어 있습니다.',
});
}
const updated = await ctx.db.general.updateMany({
where: {
id: input.generalId,
userId: null,
npcState: { gte: 2 },
},
data: {
userId,
npcState: 1,
updatedAt: new Date(),
},
});
if (updated.count === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '빙의 가능한 장수를 찾지 못했습니다.',
});
}
return { ok: true };
}),
});