import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import type { GamePrisma } from '@sammo-ts/infra'; import { authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; import { assertNationAccess, resolveNationPermission } from '../nation/shared.js'; const resolvePermissionLevel = async (ctx: Parameters[0], nationId: number) => { const nation = await ctx.db.nation.findUnique({ where: { id: nationId }, select: { meta: true }, }); if (!nation) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } const general = await getMyGeneral(ctx); return resolveNationPermission(general, nation.meta, true); }; const mapLetterState = (state: string): 'PROPOSED' | 'ACTIVATED' | 'CANCELLED' | 'REPLACED' => { if (state === 'ACTIVATED') return 'ACTIVATED'; if (state === 'CANCELLED') return 'CANCELLED'; if (state === 'REPLACED') return 'REPLACED'; return 'PROPOSED'; }; export const diplomacyRouter = router({ getLetters: authedProcedure.query(async ({ ctx }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); const permission = await resolvePermissionLevel(ctx, me.nationId); if (permission < 1) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } const letters = await ctx.db.diplomacyLetter.findMany({ where: { state: { not: 'CANCELLED' }, OR: [{ srcNationId: me.nationId }, { destNationId: me.nationId }], }, orderBy: { date: 'desc' }, }); const nations = await ctx.db.nation.findMany({ where: { id: { not: me.nationId } }, select: { id: true, name: true, color: true, level: true }, orderBy: { id: 'asc' }, }); const result = letters.map((letter) => { const aux = asRecord(letter.aux); const src = asRecord(aux.src); const dest = asRecord(aux.dest); const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null; const detail = permission < 3 && letter.textDetail ? '(권한이 부족합니다)' : letter.textDetail; const reason = asRecord(aux.reason); return { id: letter.id, src: { nationId: letter.srcNationId, nationName: typeof src.nationName === 'string' ? src.nationName : '', nationColor: typeof src.nationColor === 'string' ? src.nationColor : '', generalId: typeof src.generalId === 'number' ? src.generalId : null, generalName: typeof src.generalName === 'string' ? src.generalName : null, generalIcon: typeof src.generalIcon === 'string' ? src.generalIcon : null, }, dest: { nationId: letter.destNationId, nationName: typeof dest.nationName === 'string' ? dest.nationName : '', nationColor: typeof dest.nationColor === 'string' ? dest.nationColor : '', generalId: typeof dest.generalId === 'number' ? dest.generalId : null, generalName: typeof dest.generalName === 'string' ? dest.generalName : null, generalIcon: typeof dest.generalIcon === 'string' ? dest.generalIcon : null, }, prevId: letter.prevId, state: mapLetterState(letter.state), stateOpt, brief: letter.textBrief, detail, date: letter.date.toISOString(), reason: { who: typeof reason.who === 'number' ? reason.who : null, action: typeof reason.action === 'string' ? reason.action : null, text: typeof reason.reason === 'string' ? reason.reason : null, }, }; }); return { letters: result, nations, myNationId: me.nationId, permission, }; }), sendLetter: authedProcedure .input( z.object({ destNationId: z.number().int().positive(), prevId: z.number().int().positive().nullable().optional(), brief: z.string().trim().min(1).max(2000), detail: z.string().trim().max(20000), }) ) .mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); const permission = await resolvePermissionLevel(ctx, me.nationId); if (permission < 4) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } if (input.destNationId === me.nationId) { throw new TRPCError({ code: 'BAD_REQUEST', message: '자국으로 보낼 수 없습니다.' }); } let destNationId = input.destNationId; let prevId = input.prevId ?? null; if (prevId && prevId < 1) { prevId = null; } if (prevId) { const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId, OR: [ { srcNationId: { in: [me.nationId, destNationId] }, destNationId: { in: [me.nationId, destNationId] }, }, ], }, }); if (!prevLetter) { throw new TRPCError({ code: 'NOT_FOUND', message: '이전 문서가 없습니다.' }); } const newer = await ctx.db.diplomacyLetter.findFirst({ where: { prevId, state: { not: 'CANCELLED' }, }, select: { id: true }, }); if (newer) { throw new TRPCError({ code: 'BAD_REQUEST', message: '해당 문서에 대한 새로운 문서가 이미 있습니다.', }); } if (prevLetter.state === 'PROPOSED') { const aux = asRecord(prevLetter.aux); aux.reason = { who: me.id, action: 'new_letter', reason: 'new_letter', }; await ctx.db.diplomacyLetter.update({ where: { id: prevId }, data: { state: 'REPLACED', aux: aux as GamePrisma.InputJsonValue }, }); } destNationId = prevLetter.srcNationId === me.nationId ? prevLetter.destNationId : prevLetter.srcNationId; } const nations = await ctx.db.nation.findMany({ where: { id: { in: [me.nationId, destNationId] } }, select: { id: true, name: true, color: true }, }); if (nations.length !== 2) { throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 국가입니다.' }); } const srcNation = nations.find((nation) => nation.id === me.nationId); const destNation = nations.find((nation) => nation.id === destNationId); if (!srcNation || !destNation) { throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 국가입니다.' }); } const aux = { src: { nationName: srcNation.name, nationColor: srcNation.color, generalId: me.id, generalName: me.name, generalIcon: null, }, dest: { nationName: destNation.name, nationColor: destNation.color, }, }; const created = await ctx.db.diplomacyLetter.create({ data: { srcNationId: srcNation.id, destNationId: destNation.id, prevId, state: 'PROPOSED', textBrief: input.brief, textDetail: input.detail, srcSignerId: me.id, aux: aux as GamePrisma.InputJsonValue, }, }); return { id: created.id }; }), respondLetter: authedProcedure .input( z.object({ letterId: z.number().int().positive(), agree: z.boolean(), reason: z.string().trim().max(2000).optional(), }) ) .mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); const permission = await resolvePermissionLevel(ctx, me.nationId); if (permission < 4) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } const letter = await ctx.db.diplomacyLetter.findFirst({ where: { id: input.letterId, destNationId: me.nationId, state: 'PROPOSED', }, }); if (!letter) { throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' }); } const aux = asRecord(letter.aux); if (input.agree) { const dest = asRecord(aux.dest); dest.generalId = me.id; dest.generalName = me.name; dest.generalIcon = null; aux.dest = dest; await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { state: 'ACTIVATED', destSignerId: me.id, aux: aux as GamePrisma.InputJsonValue, }, }); let prevId = letter.prevId; while (prevId) { const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } }); if (!prevLetter || prevLetter.state === 'CANCELLED') { break; } await ctx.db.diplomacyLetter.update({ where: { id: prevId }, data: { state: 'REPLACED' }, }); prevId = prevLetter.prevId; } } else { aux.reason = { who: me.id, action: 'disagree', reason: input.reason ?? '', }; await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue }, }); } return { ok: true }; }), rollbackLetter: authedProcedure .input(z.object({ letterId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); const permission = await resolvePermissionLevel(ctx, me.nationId); if (permission < 4) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } const letter = await ctx.db.diplomacyLetter.findFirst({ where: { id: input.letterId, srcNationId: me.nationId, state: 'PROPOSED', }, }); if (!letter) { throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' }); } const aux = asRecord(letter.aux); aux.reason = { who: me.id, action: 'cancelled', reason: '회수', }; await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue }, }); return { ok: true }; }), destroyLetter: authedProcedure .input(z.object({ letterId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { const me = await getMyGeneral(ctx); assertNationAccess(me); const permission = await resolvePermissionLevel(ctx, me.nationId); if (permission < 4) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); } const letter = await ctx.db.diplomacyLetter.findFirst({ where: { id: input.letterId, state: 'ACTIVATED', OR: [{ srcNationId: me.nationId }, { destNationId: me.nationId }], }, }); if (!letter) { throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' }); } const aux = asRecord(letter.aux); const stateOpt = typeof aux.state_opt === 'string' ? aux.state_opt : null; const myStateOpt = letter.srcNationId === me.nationId ? 'try_destroy_src' : 'try_destroy_dest'; if (stateOpt === myStateOpt) { throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 파기 신청을 했습니다.' }); } if (stateOpt && stateOpt !== myStateOpt) { aux.reason = { who: me.id, action: 'destroy', reason: '파기', }; await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue }, }); return { state: 'CANCELLED' }; } aux.state_opt = myStateOpt; await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { aux: aux as GamePrisma.InputJsonValue }, }); return { state: 'ACTIVATED' }; }), });