import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { asRecord, type TurnDaemonCommandResult } from '@sammo-ts/common'; import { isValidTroopNameWidth, normalizeTroopName, resolveTroopSecretPermission } from '@sammo-ts/logic'; import { accessAuthedProcedure, engineAuthedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; const troopNameSchema = z .string() .refine(isValidTroopNameWidth, '부대 이름은 전각 9자 또는 반각 18자 이하여야 합니다.'); const normalizeRequiredTroopName = (value: string): string => { const name = normalizeTroopName(value); if (!name) { throw new TRPCError({ code: 'BAD_REQUEST', message: '부대 이름이 없습니다.' }); } return name; }; const assertCommandResult = ( result: TurnDaemonCommandResult | null, expectedType: T ): never => { if (!result) { throw new TRPCError({ code: 'TIMEOUT', message: 'Turn daemon did not respond.' }); } throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Unexpected turn daemon response for ${expectedType}.`, }); }; export const troopRouter = router({ getList: accessAuthedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); if (me.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.' }); } const [nation, troops, generals, cities, worldState] = await Promise.all([ ctx.db.nation.findUnique({ where: { id: me.nationId }, select: { id: true, name: true, meta: true }, }), ctx.db.troop.findMany({ where: { nationId: me.nationId }, select: { troopLeaderId: true, nationId: true, name: true }, }), ctx.db.general.findMany({ where: { nationId: me.nationId }, select: { id: true, name: true, cityId: true, troopId: true, picture: true, imageServer: true, turnTime: true, leadership: true, strength: true, intel: true, experience: true, meta: true, }, }), ctx.db.city.findMany({ select: { id: true, name: true }, }), ctx.db.worldState.findFirst({ select: { config: true } }), ]); if (!nation) { throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); } const troopLeaderIds = troops.map((troop) => troop.troopLeaderId); const turns = troopLeaderIds.length === 0 ? [] : await ctx.db.generalTurn.findMany({ where: { generalId: { in: troopLeaderIds }, turnIdx: { lt: 5 } }, select: { generalId: true, turnIdx: true, actionCode: true }, orderBy: [{ generalId: 'asc' }, { turnIdx: 'asc' }], }); const cityNames = new Map(cities.map((city) => [city.id, city.name])); const generalMap = new Map(generals.map((general) => [general.id, general])); const reservedByLeader = new Map(); const worldConfig = asRecord(worldState?.config); const constValues = asRecord(worldConfig.const ?? worldConfig.consts); const statUpgradeLimit = typeof constValues.upgradeLimit === 'number' && Number.isFinite(constValues.upgradeLimit) ? constValues.upgradeLimit : 30; for (const turn of turns) { const list = reservedByLeader.get(turn.generalId) ?? []; // Ref 부대 편성은 앞쪽 슬롯이 집합인지 여부만 공개하고 다른 명령은 가립니다. list.push(turn.actionCode === 'che_집합' ? '집합' : '-'); reservedByLeader.set(turn.generalId, list); } const mappedTroops = troops .map((troop) => { const leader = generalMap.get(troop.troopLeaderId); return { id: troop.troopLeaderId, name: troop.name, nationId: troop.nationId, turnTime: leader?.turnTime.toISOString() ?? null, reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [], leader: leader ? { id: leader.id, name: leader.name, cityId: leader.cityId, cityName: cityNames.get(leader.cityId) ?? '알 수 없음', picture: leader.picture, imageServer: leader.imageServer, } : null, members: generals .filter((general) => general.troopId === troop.troopLeaderId) .map((general) => { const meta = asRecord(general.meta); const metaNumber = (key: string): number => { const value = meta[key]; return typeof value === 'number' && Number.isFinite(value) ? value : 0; }; return { id: general.id, name: general.name, cityId: general.cityId, cityName: cityNames.get(general.cityId) ?? '알 수 없음', stats: { leadership: general.leadership, strength: general.strength, intelligence: general.intel, }, experience: general.experience, progression: { experienceLevel: metaNumber('explevel'), statExperience: { leadership: metaNumber('leadership_exp'), strength: metaNumber('strength_exp'), intelligence: metaNumber('intel_exp'), }, statUpgradeLimit, dex: [1, 2, 3, 4, 5].map((index) => metaNumber(`dex${index}`)), }, }; }), }; }) .sort((left, right) => { const timeOrder = (left.turnTime ?? '').localeCompare(right.turnTime ?? ''); return timeOrder || left.id - right.id; }); return { nation: { id: nation.id, name: nation.name }, me: { id: me.id, troopId: me.troopId }, permission: resolveTroopSecretPermission(me, nation.meta, false), troops: mappedTroops, }; }), create: engineAuthedProcedure.input(z.object({ troopName: troopNameSchema })).mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); const troopName = normalizeRequiredTroopName(input.troopName); if (me.troopId !== 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '이미 부대에 소속되어 있습니다.', }); } if (me.nationId <= 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어 있지 않습니다.', }); } const result = await ctx.turnDaemon.requestCommand({ type: 'troopCreate', ...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.create:engine:0:troopCreate` } : {}), generalId: me.id, troopName, }); if (!result || result.type !== 'troopCreate') { return assertCommandResult(result, 'troopCreate'); } if (!result.ok) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason }); } return { ok: true, troopId: result.troopId, troopName: result.troopName }; }), join: engineAuthedProcedure .input(z.object({ troopId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'troopJoin', ...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.join:engine:0:troopJoin` } : {}), generalId: me.id, troopId: input.troopId, }); if (!result || result.type !== 'troopJoin') { return assertCommandResult(result, 'troopJoin'); } if (!result.ok) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason }); } return { ok: true }; }), exit: engineAuthedProcedure.mutation(async ({ ctx }) => { const me = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'troopExit', ...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.exit:engine:0:troopExit` } : {}), generalId: me.id, }); if (!result || result.type !== 'troopExit') { return assertCommandResult(result, 'troopExit'); } if (!result.ok) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: result.reason }); } return { ok: true, wasLeader: result.wasLeader }; }), kick: engineAuthedProcedure .input( z.object({ troopId: z.number().int().positive(), targetGeneralId: z.number().int().positive(), }) ) .mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); if (me.id !== input.troopId || me.troopId !== me.id) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } const target = await ctx.db.general.findUnique({ where: { id: input.targetGeneralId }, select: { id: true, troopId: true }, }); if (!target) { throw new TRPCError({ code: 'BAD_REQUEST', message: '장수 정보를 찾을 수 없습니다.' }); } if (target.troopId === 0) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대에 소속되어 있지 않습니다.' }); } if (target.troopId !== input.troopId) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '다른 부대에 소속되어 있습니다.' }); } if (target.id === input.troopId) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대장을 추방할 수 없습니다.' }); } const result = await ctx.turnDaemon.requestCommand({ type: 'troopKick', ...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.kick:engine:0:troopKick` } : {}), generalId: me.id, troopId: input.troopId, targetGeneralId: input.targetGeneralId, }); if (!result || result.type !== 'troopKick') { return assertCommandResult(result, 'troopKick'); } if (!result.ok) { const code = result.reason === '권한이 부족합니다.' ? 'FORBIDDEN' : 'PRECONDITION_FAILED'; throw new TRPCError({ code, message: result.reason }); } return { ok: true }; }), rename: engineAuthedProcedure .input( z.object({ troopId: z.number().int().positive(), troopName: troopNameSchema, }) ) .mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); const troopName = normalizeRequiredTroopName(input.troopName); const nation = await ctx.db.nation.findUnique({ where: { id: me.nationId }, select: { meta: true }, }); const permission = resolveTroopSecretPermission(me, nation?.meta ?? {}, false); if (me.id !== input.troopId && permission < 4) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } const troop = await ctx.db.troop.findUnique({ where: { troopLeaderId: input.troopId }, select: { nationId: true }, }); if (!troop || me.nationId <= 0 || troop.nationId !== me.nationId) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '부대가 없습니다.' }); } const result = await ctx.turnDaemon.requestCommand({ type: 'troopRename', ...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.rename:engine:0:troopRename` } : {}), generalId: me.id, troopId: input.troopId, troopName, }); if (!result || result.type !== 'troopRename') { return assertCommandResult(result, 'troopRename'); } if (!result.ok) { const code = result.reason === '권한이 부족합니다.' ? 'FORBIDDEN' : 'PRECONDITION_FAILED'; throw new TRPCError({ code, message: result.reason }); } return { ok: true, troopName: result.troopName }; }), });