import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { LogCategory, LogScope } from '@sammo-ts/infra'; import { asRecord } from '@sammo-ts/common'; import { authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; const zGeneralSettings = z.object({ tnmt: z.number().int().optional(), defence_train: z.number().int().optional(), use_treatment: z.number().int().optional(), use_auto_nation_turn: z.number().int().optional(), }); const zGeneralLogType = z.enum(['generalHistory', 'battleDetail', 'battleResult', 'generalAction']); const FRONT_RECORD_LIMIT = 15; const readNumber = (value: unknown, fallback: number): number => { if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string') { const parsed = Number(value); if (Number.isFinite(parsed)) { return parsed; } } return fallback; }; const normalizeItemCode = (value: string | null): string | null => { if (!value || value === 'None') { return null; } return value; }; const resolveUserSettings = (meta: Record) => { // The legacy general columns are persisted at the top level of General.meta. // Keep reading the short-lived nested shape for installations that ran the // initial rewrite implementation before this compatibility fix. const nestedSettings = asRecord(meta.userSettings); const readSetting = (key: string): unknown => meta[key] ?? nestedSettings[key]; const mysetRaw = readSetting('myset'); const myset = typeof mysetRaw === 'number' && Number.isFinite(mysetRaw) ? mysetRaw : null; return { tnmt: readNumber(readSetting('tnmt'), 1), defence_train: readNumber(readSetting('defence_train'), 80), use_treatment: readNumber(readSetting('use_treatment'), 10), use_auto_nation_turn: readNumber(readSetting('use_auto_nation_turn'), 1), myset, }; }; const resolvePenalty = (penalty: unknown): Record => { const penaltyRecord = asRecord(penalty); const result: Record = {}; for (const [key, value] of Object.entries(penaltyRecord)) { if (typeof value === 'number' && Number.isFinite(value)) { result[key] = value; continue; } if (typeof value === 'string') { const parsed = Number(value); if (Number.isFinite(parsed)) { result[key] = parsed; } } } return result; }; export const generalRouter = router({ me: authedProcedure.query(async ({ ctx }) => { const userId = ctx.auth?.user.id; if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } const general = await ctx.db.general.findFirst({ where: { userId }, select: { id: true, name: true, npcState: true, nationId: true, cityId: true, troopId: true, picture: true, imageServer: true, leadership: true, strength: true, intel: true, officerLevel: true, gold: true, rice: true, crew: true, train: true, atmos: true, injury: true, experience: true, dedication: true, weaponCode: true, horseCode: true, bookCode: true, itemCode: true, meta: true, penalty: true, }, }); if (!general) { return null; } const [city, nation] = await Promise.all([ general.cityId > 0 ? ctx.db.city.findUnique({ where: { id: general.cityId }, select: { id: true, name: true, level: true, nationId: true, population: true, agriculture: true, commerce: true, security: true, defence: true, wall: true, supplyState: true, frontState: true, }, }) : null, general.nationId > 0 ? ctx.db.nation.findUnique({ where: { id: general.nationId }, select: { id: true, name: true, color: true, level: true, gold: true, rice: true, tech: true, typeCode: true, capitalCityId: true, }, }) : null, ]); const metaRecord = asRecord(general.meta); const settings = resolveUserSettings(metaRecord); const penalties = resolvePenalty(general.penalty); return { general: { id: general.id, name: general.name, npcState: general.npcState, nationId: general.nationId, cityId: general.cityId, troopId: general.troopId, picture: general.picture, imageServer: general.imageServer, officerLevel: general.officerLevel, stats: { leadership: general.leadership, strength: general.strength, intelligence: general.intel, }, gold: general.gold, rice: general.rice, crew: general.crew, train: general.train, atmos: general.atmos, injury: general.injury, experience: general.experience, dedication: general.dedication, items: { horse: normalizeItemCode(general.horseCode), weapon: normalizeItemCode(general.weaponCode), book: normalizeItemCode(general.bookCode), item: normalizeItemCode(general.itemCode), }, }, city, nation, settings, penalties, }; }), dieOnPrestart: authedProcedure.mutation(async ({ ctx }) => { const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'dieOnPrestart', generalId: general.id, }); if (!result || result.type !== 'dieOnPrestart') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } if (!result.ok) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); } return { ok: true }; }), buildNationCandidate: authedProcedure.mutation(async ({ ctx }) => { const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'buildNationCandidate', generalId: general.id, }); if (!result || result.type !== 'buildNationCandidate') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } if (!result.ok) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); } return { ok: true }; }), instantRetreat: authedProcedure.mutation(async ({ ctx }) => { const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'instantRetreat', generalId: general.id, }); if (!result || result.type !== 'instantRetreat') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } if (!result.ok) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); } return { ok: true }; }), vacation: authedProcedure.mutation(async ({ ctx }) => { const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'vacation', generalId: general.id, }); if (!result || result.type !== 'vacation') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } if (!result.ok) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); } return { ok: true }; }), setMySetting: authedProcedure.input(zGeneralSettings).mutation(async ({ ctx, input }) => { const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'setMySetting', generalId: general.id, settings: input, }); if (!result || result.type !== 'setMySetting') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } if (!result.ok) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); } return { ok: true }; }), dropItem: authedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => { const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'dropItem', generalId: general.id, itemType: input.itemType, }); if (!result || result.type !== 'dropItem') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } if (!result.ok) { throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); } return { ok: true }; }), getMyLog: authedProcedure .input( z.object({ type: zGeneralLogType, beforeId: z.number().int().positive().optional(), }) ) .query(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); const categoryMap: Record, LogCategory> = { generalHistory: LogCategory.HISTORY, generalAction: LogCategory.ACTION, battleResult: LogCategory.BATTLE_BRIEF, battleDetail: LogCategory.BATTLE_DETAIL, }; const logs = await ctx.db.logEntry.findMany({ where: { generalId: me.id, scope: LogScope.GENERAL, category: categoryMap[input.type], ...(input.beforeId ? { id: { lt: input.beforeId } } : {}), }, orderBy: { id: 'desc' }, take: 24, }); return { type: input.type, logs: logs.map((entry) => ({ id: entry.id, text: entry.text, })), }; }), getFrontRecords: authedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); const select = { id: true, text: true, } as const; const orderBy = { id: 'desc' } as const; const [global, general, history] = await Promise.all([ ctx.db.logEntry.findMany({ where: { scope: LogScope.SYSTEM, category: LogCategory.ACTION, }, select, orderBy, take: FRONT_RECORD_LIMIT, }), ctx.db.logEntry.findMany({ where: { scope: LogScope.GENERAL, category: LogCategory.ACTION, generalId: me.id, }, select, orderBy, take: FRONT_RECORD_LIMIT, }), ctx.db.logEntry.findMany({ where: { scope: LogScope.SYSTEM, category: LogCategory.HISTORY, }, select, orderBy, take: FRONT_RECORD_LIMIT, }), ]); return { global, general, history, }; }), });