router 분리
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { WorldStateRow } from '../../context.js';
|
||||
import { authedProcedure, router } from '../../trpc.js';
|
||||
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 asRecord = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
|
||||
const asNumber = (value: unknown, fallback: number): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
|
||||
const asStringArray = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
|
||||
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 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 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 chosenPersonality =
|
||||
input.character === 'Random'
|
||||
? pickFromList(personalityKeys, `${userId}:${input.name}`) ?? '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: input.name } });
|
||||
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 }, orderBy: { id: 'asc' } });
|
||||
if (!cityList.length) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '도시 정보를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
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: input.name,
|
||||
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 };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user