From cbee0d4c2042744c1ccb9f67b6cd652227993985 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Sat, 24 Jan 2026 03:16:05 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B5=AD=EA=B0=80=20=EB=A9=94=ED=83=80?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/nation/index.ts | 61 ++++++++++------ app/game-api/src/router/npc/index.ts | 73 +++++++++++++------ .../src/lifecycle/redisCommandStream.ts | 12 +++ .../src/turn/worldCommandHandler.ts | 48 ++++++++++++ packages/common/src/turnDaemon/types.ts | 24 +++++- 5 files changed, 169 insertions(+), 49 deletions(-) diff --git a/app/game-api/src/router/nation/index.ts b/app/game-api/src/router/nation/index.ts index 05ce215..1536c8f 100644 --- a/app/game-api/src/router/nation/index.ts +++ b/app/game-api/src/router/nation/index.ts @@ -429,26 +429,35 @@ const assertNationEditable = ( }; const updateNationMeta = async ( - ctx: Pick, + ctx: Pick, nationId: number, - updates: Record + updates: Record, + currentMeta: Record ): Promise => { - const nation = await ctx.db.nation.findUnique({ - where: { id: nationId }, - select: { meta: true }, + const expectedUpdatedAt = typeof currentMeta._updatedAt === 'string' ? currentMeta._updatedAt : undefined; + const result = await ctx.turnDaemon.requestCommand({ + type: 'setNationMeta', + nationId, + updates, + expectedUpdatedAt, }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); + if (!result || result.type !== 'setNationMeta') { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } - const meta = asRecord(nation.meta); - const nextMeta = { ...meta, ...updates } as InputJsonValue; - await ctx.db.nation.update({ - where: { id: nationId }, - data: { - meta: nextMeta, - }, - }); - return nextMeta; + if (!result.ok) { + if (result.reason === 'CONFLICT') { + throw new TRPCError({ + code: 'CONFLICT', + message: '다른 사용자가 정책을 변경했습니다. 재시도하거나 현재 상태로 갱신해주세요.', + }); + } + throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); + } + return { + ...currentMeta, + ...updates, + _updatedAt: result.updatedAt, + } as InputJsonValue; }; const mapGeneralList = async ( @@ -1239,9 +1248,10 @@ export const nationRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } assertNationEditable(me, nation.meta); + const nationMeta = asRecord(nation.meta); await updateNationMeta(ctx, me.nationId, { notice: input.msg, - }); + }, nationMeta); return { ok: true }; }), setScoutMsg: authedProcedure @@ -1261,9 +1271,10 @@ export const nationRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } assertNationEditable(me, nation.meta); + const nationMeta = asRecord(nation.meta); await updateNationMeta(ctx, me.nationId, { infoText: input.msg, - }); + }, nationMeta); return { ok: true }; }), setRate: authedProcedure @@ -1283,9 +1294,10 @@ export const nationRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } assertNationEditable(me, nation.meta); + const nationMeta = asRecord(nation.meta); await updateNationMeta(ctx, me.nationId, { rate: input.amount, - }); + }, nationMeta); return { ok: true }; }), setBill: authedProcedure @@ -1305,9 +1317,10 @@ export const nationRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } assertNationEditable(me, nation.meta); + const nationMeta = asRecord(nation.meta); await updateNationMeta(ctx, me.nationId, { bill: input.amount, - }); + }, nationMeta); return { ok: true }; }), setSecretLimit: authedProcedure @@ -1327,9 +1340,10 @@ export const nationRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } assertNationEditable(me, nation.meta); + const nationMeta = asRecord(nation.meta); await updateNationMeta(ctx, me.nationId, { secretlimit: input.amount, - }); + }, nationMeta); return { ok: true }; }), setBlockWar: authedProcedure @@ -1359,7 +1373,7 @@ export const nationRouter = router({ await updateNationMeta(ctx, me.nationId, { war: input.value ? 1 : 0, available_war_setting_cnt: nextRemain, - }); + }, meta); return { availableCnt: nextRemain }; }), setBlockScout: authedProcedure @@ -1379,9 +1393,10 @@ export const nationRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } assertNationEditable(me, nation.meta); + const nationMeta = asRecord(nation.meta); await updateNationMeta(ctx, me.nationId, { scout: input.value ? 1 : 0, - }); + }, nationMeta); return { ok: true }; }), getBattleCenter: authedProcedure.query(async ({ ctx }) => { diff --git a/app/game-api/src/router/npc/index.ts b/app/game-api/src/router/npc/index.ts index 252fd01..e4d7781 100644 --- a/app/game-api/src/router/npc/index.ts +++ b/app/game-api/src/router/npc/index.ts @@ -8,6 +8,7 @@ import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js' import { authedProcedure, router } from '../../trpc.js'; import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js'; +import type { GameApiContext } from '../../context.js'; import { getMyGeneral } from '../shared/general.js'; import { resolveSecretPermission } from '../shared/secretPermission.js'; @@ -42,6 +43,33 @@ type SetterInfo = { date: string | null; }; +const updateNationMeta = async ( + ctx: Pick, + nationId: number, + updates: Record, + currentMeta: Record +): Promise => { + const expectedUpdatedAt = typeof currentMeta._updatedAt === 'string' ? currentMeta._updatedAt : undefined; + const result = await ctx.turnDaemon.requestCommand({ + type: 'setNationMeta', + nationId, + updates, + expectedUpdatedAt, + }); + if (!result || result.type !== 'setNationMeta') { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); + } + if (!result.ok) { + if (result.reason === 'CONFLICT') { + throw new TRPCError({ + code: 'CONFLICT', + message: '다른 사용자가 정책을 변경했습니다. 재시도하거나 현재 상태로 갱신해주세요.', + }); + } + throw new TRPCError({ code: 'BAD_REQUEST', message: result.reason }); + } +}; + const DEFAULT_NATION_PRIORITY = [ '불가침제의', '선전포고', @@ -665,15 +693,14 @@ export const npcRouter = router({ valueSetTime: new Date().toISOString(), }; - await ctx.db.nation.update({ - where: { id: nation.id }, - data: { - meta: { - ...nationMeta, - npc_nation_policy: nextPolicyRoot, - }, + await updateNationMeta( + ctx, + nation.id, + { + npc_nation_policy: nextPolicyRoot, }, - }); + nationMeta + ); return { ok: true }; }), @@ -722,15 +749,14 @@ export const npcRouter = router({ prioritySetTime: new Date().toISOString(), }; - await ctx.db.nation.update({ - where: { id: nation.id }, - data: { - meta: { - ...nationMeta, - npc_nation_policy: nextPolicyRoot, - }, + await updateNationMeta( + ctx, + nation.id, + { + npc_nation_policy: nextPolicyRoot, }, - }); + nationMeta + ); return { ok: true }; }), @@ -778,15 +804,14 @@ export const npcRouter = router({ prioritySetTime: new Date().toISOString(), }; - await ctx.db.nation.update({ - where: { id: nation.id }, - data: { - meta: { - ...nationMeta, - npc_general_policy: nextPolicyRoot, - }, + await updateNationMeta( + ctx, + nation.id, + { + npc_general_policy: nextPolicyRoot, }, - }); + nationMeta + ); return { ok: true }; }), diff --git a/app/game-engine/src/lifecycle/redisCommandStream.ts b/app/game-engine/src/lifecycle/redisCommandStream.ts index 66f4232..be19639 100644 --- a/app/game-engine/src/lifecycle/redisCommandStream.ts +++ b/app/game-engine/src/lifecycle/redisCommandStream.ts @@ -282,6 +282,18 @@ const normalizeCommand = (envelope: TurnDaemonCommandEnvelope): TurnDaemonComman top4, }; } + case 'setNationMeta': { + if (typeof command.nationId !== 'number' || !command.updates || typeof command.updates !== 'object') { + return null; + } + return { + type: 'setNationMeta', + requestId: envelope.requestId, + nationId: command.nationId, + updates: command.updates as Record, + expectedUpdatedAt: typeof command.expectedUpdatedAt === 'string' ? command.expectedUpdatedAt : undefined, + }; + } case 'getStatus': { const requestId = typeof command.requestId === 'string' ? command.requestId : envelope.requestId; return { type: 'getStatus', requestId }; diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index de02b22..4255d92 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -41,6 +41,52 @@ interface TournamentRewardFinalizer { finalize(command: Extract): Promise; } +async function handleSetNationMeta( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const { world, hooks } = ctx; + const nation = world.getNationById(command.nationId); + if (!nation) { + return { + type: 'setNationMeta', + ok: false, + nationId: command.nationId, + reason: '국가 정보를 찾을 수 없습니다.', + }; + } + + const meta = (nation.meta ?? {}) as Record; + const currentUpdatedAt = typeof meta._updatedAt === 'string' ? meta._updatedAt : undefined; + if (command.expectedUpdatedAt && currentUpdatedAt && command.expectedUpdatedAt !== currentUpdatedAt) { + return { + type: 'setNationMeta', + ok: false, + nationId: command.nationId, + reason: 'CONFLICT', + currentUpdatedAt, + }; + } + + const updatedAt = new Date().toISOString(); + const nextMeta = { + ...meta, + ...command.updates, + _updatedAt: updatedAt, + }; + + world.updateNation(command.nationId, { + meta: nextMeta, + }); + await flushWorld(world, hooks); + return { + type: 'setNationMeta', + ok: true, + nationId: command.nationId, + updatedAt, + }; +} + async function handleTroopJoin( ctx: CommandHandlerContext, command: Extract @@ -614,6 +660,8 @@ export const createTurnDaemonCommandHandler = (options: { return handleTournamentBettingPayout(ctx, command); case 'tournamentReward': return handleTournamentReward(ctx, command); + case 'setNationMeta': + return handleSetNationMeta(ctx, command); default: return null; } diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index c5d56a7..908a723 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -115,7 +115,14 @@ export type TurnDaemonCommand = top16: number[]; top8: number[]; top4: number[]; - }; + } + | { + type: 'setNationMeta'; + requestId?: string; + nationId: number; + updates: Record; + expectedUpdatedAt?: string; + }; export type TurnDaemonCommandResult = | { @@ -207,7 +214,20 @@ export type TurnDaemonCommandResult = winnerId: number; runnerUpId: number; reason: string; - }; + } + | { + type: 'setNationMeta'; + ok: true; + nationId: number; + updatedAt: string; + } + | { + type: 'setNationMeta'; + ok: false; + nationId: number; + reason: string; + currentUpdatedAt?: string; + }; export type TurnDaemonEvent = | { type: 'status'; requestId?: string; status: TurnDaemonStatus }