From 87e46396632446f5d4eb9b77363f4f0b0351aa4f Mon Sep 17 00:00:00 2001 From: Hide_D Date: Wed, 28 Jan 2026 15:35:17 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=99=B8=EA=B5=90=EB=B6=80=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=99=B8=EA=B5=90?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EA=B4=80=EB=A0=A8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router.ts | 2 + app/game-api/src/router/diplomacy/index.ts | 375 ++++++++++++ app/game-frontend/src/router/index.ts | 10 + app/game-frontend/src/views/DiplomacyView.vue | 568 ++++++++++++++++++ app/game-frontend/src/views/MainView.vue | 3 +- packages/infra/prisma/game.prisma | 26 + .../migration.sql | 19 + packages/infra/src/db.ts | 1 + 8 files changed, 1003 insertions(+), 1 deletion(-) create mode 100644 app/game-api/src/router/diplomacy/index.ts create mode 100644 app/game-frontend/src/views/DiplomacyView.vue create mode 100644 packages/infra/prisma/migrations/20260128001000_add_diplomacy_letters/migration.sql diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index 9625926..c481325 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -18,6 +18,7 @@ import { worldRouter } from './router/world/index.js'; import { auctionRouter } from './router/auction/index.js'; import { tournamentRouter } from './router/tournament/index.js'; import { boardRouter } from './router/board/index.js'; +import { diplomacyRouter } from './router/diplomacy/index.js'; export const appRouter = router({ health: healthRouter, @@ -38,6 +39,7 @@ export const appRouter = router({ auction: auctionRouter, tournament: tournamentRouter, board: boardRouter, + diplomacy: diplomacyRouter, }); export type AppRouter = typeof appRouter; diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts new file mode 100644 index 0000000..4854dc0 --- /dev/null +++ b/app/game-api/src/router/diplomacy/index.ts @@ -0,0 +1,375 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { asRecord } from '@sammo-ts/common'; +import { 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 zLetterState = z.enum(['PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED']); + +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): z.infer => { + 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' }; + }), +}); \ No newline at end of file diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index 6881d67..efc0bea 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -19,6 +19,7 @@ import MySettingsView from '../views/MySettingsView.vue'; import BoardView from '../views/BoardView.vue'; import NationAffairsView from '../views/NationAffairsView.vue'; import ScoutMessageView from '../views/ScoutMessageView.vue'; +import DiplomacyView from '../views/DiplomacyView.vue'; import { useSessionStore } from '../stores/session'; const routes = [ @@ -81,6 +82,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/diplomacy', + name: 'diplomacy', + component: DiplomacyView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/nation/generals', name: 'nation-generals', diff --git a/app/game-frontend/src/views/DiplomacyView.vue b/app/game-frontend/src/views/DiplomacyView.vue new file mode 100644 index 0000000..fc51fdc --- /dev/null +++ b/app/game-frontend/src/views/DiplomacyView.vue @@ -0,0 +1,568 @@ + + + + + \ No newline at end of file diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index ad75cac..67bc5c0 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -94,7 +94,8 @@ watch( 세력 도시 세력 장수 인사부 - 내무부 + 내무부 + 외교부 사령부 감찰부 전투 시뮬레이터 diff --git a/packages/infra/prisma/game.prisma b/packages/infra/prisma/game.prisma index c0dc70e..00c4a1f 100644 --- a/packages/infra/prisma/game.prisma +++ b/packages/infra/prisma/game.prisma @@ -37,6 +37,13 @@ enum AuctionType { UNIQUE_ITEM } +enum DiplomacyLetterState { + PROPOSED + ACTIVATED + CANCELLED + REPLACED +} + model WorldState { id Int @id @default(autoincrement()) scenarioCode String @map("scenario_code") @@ -187,6 +194,25 @@ model Diplomacy { @@map("diplomacy") } +model DiplomacyLetter { + id Int @id @default(autoincrement()) + srcNationId Int @map("src_nation_id") + destNationId Int @map("dest_nation_id") + prevId Int? @map("prev_id") + state DiplomacyLetterState @default(PROPOSED) + textBrief String @map("text_brief") + textDetail String @map("text_detail") + date DateTime @default(now()) @map("date") + srcSignerId Int @map("src_signer") + destSignerId Int? @map("dest_signer") + aux Json @default(dbgenerated("'{}'::jsonb")) + + @@index([srcNationId, destNationId]) + @@index([destNationId, srcNationId]) + @@index([state, date]) + @@map("diplomacy_letter") +} + model Event { id Int @id @default(autoincrement()) targetCode String @map("target_code") diff --git a/packages/infra/prisma/migrations/20260128001000_add_diplomacy_letters/migration.sql b/packages/infra/prisma/migrations/20260128001000_add_diplomacy_letters/migration.sql new file mode 100644 index 0000000..11869b4 --- /dev/null +++ b/packages/infra/prisma/migrations/20260128001000_add_diplomacy_letters/migration.sql @@ -0,0 +1,19 @@ +CREATE TYPE "diplomacy_letter_state" AS ENUM ('PROPOSED', 'ACTIVATED', 'CANCELLED', 'REPLACED'); + +CREATE TABLE "diplomacy_letter" ( + "id" SERIAL PRIMARY KEY, + "src_nation_id" INTEGER NOT NULL, + "dest_nation_id" INTEGER NOT NULL, + "prev_id" INTEGER, + "state" "diplomacy_letter_state" NOT NULL DEFAULT 'PROPOSED', + "text_brief" TEXT NOT NULL, + "text_detail" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "src_signer" INTEGER NOT NULL, + "dest_signer" INTEGER, + "aux" JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX "diplomacy_letter_src_dest_idx" ON "diplomacy_letter"("src_nation_id", "dest_nation_id"); +CREATE INDEX "diplomacy_letter_dest_src_idx" ON "diplomacy_letter"("dest_nation_id", "src_nation_id"); +CREATE INDEX "diplomacy_letter_state_date_idx" ON "diplomacy_letter"("state", "date"); \ No newline at end of file diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index d7a2182..3b8d2df 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -9,6 +9,7 @@ export interface DatabaseClient { city: GamePrisma.CityDelegate; nation: GamePrisma.NationDelegate; diplomacy: GamePrisma.DiplomacyDelegate; + diplomacyLetter: GamePrisma.DiplomacyLetterDelegate; generalTurn: GamePrisma.GeneralTurnDelegate; nationTurn: GamePrisma.NationTurnDelegate; troop: GamePrisma.TroopDelegate;