From 630bc291002f2219eeb5267a3b19ff99b95acbe7 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 12:10:57 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=8A=B9=EC=88=98=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EC=BB=A4=EB=A7=A8=EB=93=9C=EC=9D=98=20Ref=20=ED=98=B8?= =?UTF-8?q?=ED=99=98=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EB=B3=B4=EA=B0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 수뇌 국가 설정과 NPC 정책을 actor-bound ENGINE mutation으로 옮기고, 추방·등용·점령·멸망·아이템 폐기의 특수 분기를 Ref와 맞춘다. 요청 ID를 사용자·프로필별로 격리하고 토너먼트 손상 projection을 fail-closed하며 실제 DB 및 Ref 차등 회귀를 보강한다. --- app/game-api/src/auction/open.ts | 4 + app/game-api/src/requestId.ts | 24 + app/game-api/src/router/auction/index.ts | 10 + app/game-api/src/router/diplomacy/index.ts | 3 + app/game-api/src/router/general/index.ts | 263 +++++----- app/game-api/src/router/inherit/index.ts | 1 + .../src/router/nation/endpoints/appoint.ts | 6 +- .../nation/endpoints/changePermission.ts | 6 +- .../src/router/nation/endpoints/kick.ts | 6 +- .../src/router/nation/endpoints/setBill.ts | 21 +- .../router/nation/endpoints/setBlockScout.ts | 44 +- .../router/nation/endpoints/setBlockWar.ts | 32 +- .../src/router/nation/endpoints/setNotice.ts | 29 +- .../src/router/nation/endpoints/setRate.ts | 21 +- .../router/nation/endpoints/setScoutMsg.ts | 28 +- .../router/nation/endpoints/setSecretLimit.ts | 24 +- .../src/router/nation/settingInput.ts | 12 + app/game-api/src/router/nation/shared.ts | 54 +- app/game-api/src/router/npc/index.ts | 473 +++--------------- app/game-api/src/router/shared/general.ts | 23 +- app/game-api/src/router/shared/turnDaemon.ts | 10 + app/game-api/src/router/troop/index.ts | 14 +- app/game-api/src/router/vote/index.ts | 7 +- app/game-api/src/server.ts | 13 +- app/game-api/src/tournament/store.ts | 139 ++++- app/game-api/test/auctionRouter.test.ts | 3 + app/game-api/test/diplomacyRouter.test.ts | 10 + .../directMutationJournalInventory.test.ts | 20 +- app/game-api/test/idempotentTransport.test.ts | 9 +- .../test/inGameMenuPermissions.test.ts | 40 +- .../inputEventBoundary.integration.test.ts | 29 +- app/game-api/test/nationHtmlRouter.test.ts | 62 ++- .../test/nationPersonnelRouter.test.ts | 45 +- app/game-api/test/nationSettingRouter.test.ts | 206 ++++++++ app/game-api/test/npcPolicyRouter.test.ts | 76 ++- app/game-api/test/requestId.test.ts | 27 + app/game-api/test/scoutBlockRouter.test.ts | 37 +- .../securityTransport.integration.test.ts | 72 ++- .../test/tournamentStoreRevision.test.ts | 112 ++++- app/game-api/test/troopRouter.test.ts | 21 + app/game-api/test/voteRouter.test.ts | 6 +- app/game-engine/package.json | 4 + .../src/turn/actionableMessageResponse.ts | 45 +- app/game-engine/src/turn/commandRegistry.ts | 68 ++- .../src/turn/nationSettingMutation.ts | 171 +++++++ app/game-engine/src/turn/npcPolicyMutation.ts | 398 +++++++++++++++ app/game-engine/src/turn/oldNationArchive.ts | 8 + .../src/turn/reservedTurnHandler.ts | 16 +- app/game-engine/src/turn/turnDaemon.ts | 1 + .../src/turn/unificationPersistence.ts | 3 - .../src/turn/worldCommandHandler.ts | 260 +++++++--- .../test/actionableMessageResponse.test.ts | 71 +++ .../test/auctionBidCompatibility.test.ts | 2 + .../auctionFinalizerCompatibility.test.ts | 1 + .../test/authenticatedActorCommand.test.ts | 183 +++++++ .../databaseCommandQueue.integration.test.ts | 118 ++++- .../test/inputEventAtomicity.test.ts | 7 +- .../test/myInformationCommands.test.ts | 187 ++++++- .../test/nationPersonnelManagement.test.ts | 180 ++++++- .../test/nationSettingMutation.test.ts | 424 ++++++++++++++++ .../test/npcPolicyLifecycle.test.ts | 292 ++++++++++- app/game-engine/test/oldNationArchive.test.ts | 61 +++ app/game-engine/test/troopManagement.test.ts | 55 +- ...nificationFinalization.integration.test.ts | 6 + .../test/unificationHandler.test.ts | 10 +- .../test/unificationPersistence.test.ts | 13 +- app/game-engine/test/voteReward.test.ts | 21 +- app/game-engine/tsdown.config.ts | 1 + .../game-api-daemon-procedure-inventory.md | 19 +- ...e-api-direct-mutation-journal-inventory.md | 11 +- packages/common/src/turnDaemon/types.ts | 84 +++- .../src/actions/turn/general/che_등용수락.ts | 178 +++---- packages/logic/src/war/aftermath.ts | 24 + packages/logic/test/warAftermath.test.ts | 70 +++ 74 files changed, 3893 insertions(+), 1141 deletions(-) create mode 100644 app/game-api/src/requestId.ts create mode 100644 app/game-api/src/router/nation/settingInput.ts create mode 100644 app/game-api/src/router/shared/turnDaemon.ts create mode 100644 app/game-api/test/nationSettingRouter.test.ts create mode 100644 app/game-api/test/requestId.test.ts create mode 100644 app/game-engine/src/turn/nationSettingMutation.ts create mode 100644 app/game-engine/src/turn/npcPolicyMutation.ts create mode 100644 app/game-engine/test/authenticatedActorCommand.test.ts create mode 100644 app/game-engine/test/nationSettingMutation.test.ts diff --git a/app/game-api/src/auction/open.ts b/app/game-api/src/auction/open.ts index 5966d201..7be92d80 100644 --- a/app/game-api/src/auction/open.ts +++ b/app/game-api/src/auction/open.ts @@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server'; import type { GameApiContext } from '../context.js'; import { loadCurrentGameTime } from '../services/gameClock.js'; +import { throwIfCommandRejected } from '../router/shared/turnDaemon.js'; import { buildAuctionTimerKeys } from './keys.js'; import { resolveAuctionTimerScore } from './scheduler.js'; @@ -21,6 +22,7 @@ export type OpenAuctionInput = export const openAuctionWithDaemon = async ( ctx: GameApiContext, + userId: string, generalId: number, input: OpenAuctionInput, requestId?: string @@ -28,9 +30,11 @@ export const openAuctionWithDaemon = async ( const result = await ctx.turnDaemon.requestCommand({ type: 'auctionOpen', ...(requestId ? { requestId } : {}), + userId, generalId, ...input, }); + throwIfCommandRejected(result); if (!result || result.type !== 'auctionOpen') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } diff --git a/app/game-api/src/requestId.ts b/app/game-api/src/requestId.ts new file mode 100644 index 00000000..86dc0eef --- /dev/null +++ b/app/game-api/src/requestId.ts @@ -0,0 +1,24 @@ +import { createHash } from 'node:crypto'; + +export const scopeHttpIdempotencyKey = (options: { + rawKey: string | undefined; + profileId: string; + userId: string | null; +}): string | undefined => { + const rawKey = options.rawKey?.trim(); + if (!rawKey) { + return undefined; + } + // Request IDs are global in input_event. Hash the untrusted header with + // its authenticated principal and profile so equal client keys cannot + // collide across users or profiles, and oversized/punctuation-heavy + // headers never flow into DB identifiers or child command IDs. + const digest = createHash('sha256') + .update(options.profileId) + .update('\0') + .update(options.userId ?? 'anonymous') + .update('\0') + .update(rawKey) + .digest('hex'); + return `http:${digest}`; +}; diff --git a/app/game-api/src/router/auction/index.ts b/app/game-api/src/router/auction/index.ts index a14b6049..9d70f5bd 100644 --- a/app/game-api/src/router/auction/index.ts +++ b/app/game-api/src/router/auction/index.ts @@ -11,6 +11,7 @@ import { buildAuctionAlias } from '@sammo-ts/logic'; import { openAuctionWithDaemon } from '../../auction/open.js'; import { resolveAuctionTimerScore } from '../../auction/scheduler.js'; import { loadCurrentGameTime } from '../../services/gameClock.js'; +import { throwIfCommandRejected } from '../shared/turnDaemon.js'; const zBidInput = z.object({ auctionId: z.number().int().positive(), @@ -340,6 +341,7 @@ export const auctionRouter = router({ await ensureAuctionSeasonActive(ctx.db); return openAuctionWithDaemon( ctx, + auth.user.id, general.id, { auctionType: 'BUY_RICE', ...input }, ctx.requestId ? `${ctx.requestId}:auction.openBuyRice:engine:0:auctionOpen` : undefined @@ -351,6 +353,7 @@ export const auctionRouter = router({ await ensureAuctionSeasonActive(ctx.db); return openAuctionWithDaemon( ctx, + auth.user.id, general.id, { auctionType: 'SELL_RICE', ...input }, ctx.requestId ? `${ctx.requestId}:auction.openSellRice:engine:0:auctionOpen` : undefined @@ -362,6 +365,7 @@ export const auctionRouter = router({ await ensureAuctionSeasonActive(ctx.db); return openAuctionWithDaemon( ctx, + auth.user.id, general.id, { auctionType: 'UNIQUE_ITEM', @@ -425,12 +429,14 @@ export const auctionRouter = router({ const result = await ctx.turnDaemon.requestCommand({ type: 'auctionBid', ...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidBuyRice:engine:0:auctionBid` } : {}), + userId: auth.user.id, auctionId: auction.id, generalId: general.id, amount: input.amount, ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), tryExtendCloseDate: true, }); + throwIfCommandRejected(result); if (!result || result.type !== 'auctionBid') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } @@ -501,12 +507,14 @@ export const auctionRouter = router({ const result = await ctx.turnDaemon.requestCommand({ type: 'auctionBid', ...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidSellRice:engine:0:auctionBid` } : {}), + userId: auth.user.id, auctionId: auction.id, generalId: general.id, amount: input.amount, ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), tryExtendCloseDate: true, }); + throwIfCommandRejected(result); if (!result || result.type !== 'auctionBid') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } @@ -638,12 +646,14 @@ export const auctionRouter = router({ const result = await ctx.turnDaemon.requestCommand({ type: 'auctionBid', ...(ctx.requestId ? { requestId: `${ctx.requestId}:auction.bidUnique:engine:0:auctionBid` } : {}), + userId: auth.user.id, auctionId: auction.id, generalId: general.id, amount: input.amount, ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), tryExtendCloseDate: input.tryExtendCloseDate ?? false, }); + throwIfCommandRejected(result); if (!result || result.type !== 'auctionBid') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index cd08c3b1..8aa53cf2 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -5,6 +5,7 @@ import { asRecord } from '@sammo-ts/common'; import type { GamePrisma } from '@sammo-ts/infra'; import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js'; +import { loadCurrentGameTime } from '../../services/gameClock.js'; import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; import { assertNationAccess, resolveNationPermission } from '../nation/shared.js'; @@ -223,6 +224,7 @@ export const diplomacyRouter = router({ nationColor: destNation.color, }, }; + const letterDate = (await loadCurrentGameTime(ctx.db)).now; const created = await ctx.db.diplomacyLetter.create({ data: { @@ -232,6 +234,7 @@ export const diplomacyRouter = router({ state: 'PROPOSED', textBrief: purifyDiplomacyHtml(input.brief), textDetail: purifyDiplomacyHtml(input.detail), + date: letterDate, srcSignerId: me.id, aux: aux as GamePrisma.InputJsonValue, }, diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 45451a52..f7e47c90 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -26,7 +26,8 @@ import { resolveRegionName, sanitizeInternalDisplayCode, } from '../../services/gameDisplayNames.js'; -import { getMyGeneral } from '../shared/general.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; +import { throwIfCommandRejected } from '../shared/turnDaemon.js'; import { loadTraitNames, resolveNationBill, @@ -299,88 +300,97 @@ export const getGeneralContext = async (ctx: GameApiContext) => { const metaRecord = asRecord(general.meta); const officerCityId = readNumber(metaRecord.officerCity ?? metaRecord.officer_city ?? metaRecord.officerCityId, 0); - const [city, queriedNation, worldState, officerCity, troop, troopLeader, troopLeaderFirstTurn, accessLog, rankRows] = - 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, - populationMax: true, - agriculture: true, - agricultureMax: true, - commerce: true, - commerceMax: true, - security: true, - securityMax: true, - trust: true, - trade: true, - defence: true, - defenceMax: true, - wall: true, - wallMax: true, - region: 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, - meta: true, - }, - }) - : Promise.resolve(NEUTRAL_NATION_CONTEXT), - ctx.db.worldState.findFirst({ - select: { - currentYear: true, - currentMonth: true, - tickSeconds: true, - lastTurnTick: true, - config: true, - meta: true, - }, - }), - officerCityId > 0 - ? ctx.db.city.findUnique({ where: { id: officerCityId }, select: { name: true } }) - : Promise.resolve(null), - general.troopId > 0 - ? ctx.db.troop.findUnique({ where: { troopLeaderId: general.troopId }, select: { name: true } }) - : Promise.resolve(null), - general.troopId > 0 - ? ctx.db.general.findUnique({ where: { id: general.troopId }, select: { cityId: true } }) - : Promise.resolve(null), - general.troopId > 0 - ? ctx.db.generalTurn.findFirst({ - where: { generalId: general.troopId }, - orderBy: { turnIdx: 'asc' }, - select: { actionCode: true }, - }) - : Promise.resolve(null), - ctx.db.generalAccessLog.findUnique({ - where: { generalId: general.id }, - select: { refreshScore: true, refreshScoreTotal: true }, - }), - ctx.db.rankData.findMany({ - where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } }, - select: { type: true, value: true }, - }), - ]); + const [ + city, + queriedNation, + worldState, + officerCity, + troop, + troopLeader, + troopLeaderFirstTurn, + accessLog, + rankRows, + ] = 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, + populationMax: true, + agriculture: true, + agricultureMax: true, + commerce: true, + commerceMax: true, + security: true, + securityMax: true, + trust: true, + trade: true, + defence: true, + defenceMax: true, + wall: true, + wallMax: true, + region: 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, + meta: true, + }, + }) + : Promise.resolve(NEUTRAL_NATION_CONTEXT), + ctx.db.worldState.findFirst({ + select: { + currentYear: true, + currentMonth: true, + tickSeconds: true, + lastTurnTick: true, + config: true, + meta: true, + }, + }), + officerCityId > 0 + ? ctx.db.city.findUnique({ where: { id: officerCityId }, select: { name: true } }) + : Promise.resolve(null), + general.troopId > 0 + ? ctx.db.troop.findUnique({ where: { troopLeaderId: general.troopId }, select: { name: true } }) + : Promise.resolve(null), + general.troopId > 0 + ? ctx.db.general.findUnique({ where: { id: general.troopId }, select: { cityId: true } }) + : Promise.resolve(null), + general.troopId > 0 + ? ctx.db.generalTurn.findFirst({ + where: { generalId: general.troopId }, + orderBy: { turnIdx: 'asc' }, + select: { actionCode: true }, + }) + : Promise.resolve(null), + ctx.db.generalAccessLog.findUnique({ + where: { generalId: general.id }, + select: { refreshScore: true, refreshScoreTotal: true }, + }), + ctx.db.rankData.findMany({ + where: { generalId: general.id, type: { in: [...PERSONAL_RECORD_TYPES] } }, + select: { type: true, value: true }, + }), + ]); const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; const [capitalCity, cityNation, troopLeaderCity, nationPopulation, nationCrew, chiefAndCityOfficerRows] = @@ -450,14 +460,15 @@ export const getGeneralContext = async (ctx: GameApiContext) => { } } } - const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeDetails, itemDetails] = await Promise.all([ - loadTraitNames([general.personalCode], 'personality'), - loadTraitNames([general.specialCode], 'domestic'), - loadTraitNames([general.special2Code], 'war'), - loadTraitNames([nation.typeCode], 'nation'), - loadCrewTypeDisplayDetails(worldState, ctx.profile.id), - loadItemDisplayDetails([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]), - ]); + const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeDetails, itemDetails] = + await Promise.all([ + loadTraitNames([general.personalCode], 'personality'), + loadTraitNames([general.specialCode], 'domestic'), + loadTraitNames([general.special2Code], 'war'), + loadTraitNames([nation.typeCode], 'nation'), + loadCrewTypeDisplayDetails(worldState, ctx.profile.id), + loadItemDisplayDetails([general.horseCode, general.weaponCode, general.bookCode, general.itemCode]), + ]); const worldConfig = asRecord(worldState?.config); const constValues = asRecord(worldConfig.const ?? worldConfig.consts); @@ -711,15 +722,15 @@ export const generalRouter = router({ const selected = input?.iconId ? ctx.auth?.user.icons?.find((icon) => icon.id === input.iconId) : undefined; const resetToDefault = input?.resetToDefault === true; if (resetToDefault && input?.iconId) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.' }); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '아이콘 선택과 기본 아이콘 초기화를 함께 요청할 수 없습니다.', + }); } if (!resetToDefault && !input?.iconId) { throw new TRPCError({ code: 'BAD_REQUEST', message: '적용할 활성 전용 아이콘을 선택해 주세요.' }); } - if ( - resetToDefault && - (ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0) - ) { + if (resetToDefault && (ctx.auth?.user.picture !== 'default.jpg' || ctx.auth?.user.imageServer !== 0)) { throw new TRPCError({ code: 'FORBIDDEN', message: '현재 계정 아이콘이 기본 아이콘이 아닙니다.' }); } if (!resetToDefault && (!selected || ctx.auth?.user.canUseGeneralPicture === false)) { @@ -727,7 +738,10 @@ export const generalRouter = router({ } const iconRevision = ctx.auth?.user.iconUpdatedAt ?? (resetToDefault ? undefined : selected!.createdAt); if (!iconRevision) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '계정 아이콘 변경 시각을 확인할 수 없습니다.' }); + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '계정 아이콘 변경 시각을 확인할 수 없습니다.', + }); } const projection = resetToDefault ? { @@ -740,13 +754,7 @@ export const generalRouter = router({ imageServer: selected!.imageServer, revision: iconRevision, }; - return adjustAccountIconForUser( - ctx, - userId, - projection, - true, - input?.clientRequestId ?? ctx.requestId - ); + return adjustAccountIconForUser(ctx, userId, projection, true, input?.clientRequestId ?? ctx.requestId); }), me: authedProcedure.query(({ ctx }) => getGeneralContext(ctx)), ensureDieOnPrestartStatus: accessEngineAuthedProcedure.mutation(async ({ ctx }) => { @@ -795,12 +803,15 @@ export const generalRouter = router({ requestImmediateAction(ctx, input, 'instantRetreat') ), vacation: engineAuthedProcedure.mutation(async ({ ctx }) => { + const userId = getAuthenticatedUserId(ctx); const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'vacation', ...(ctx.requestId ? { requestId: `${ctx.requestId}:general.vacation:engine:0:vacation` } : {}), + userId, generalId: general.id, }); + throwIfCommandRejected(result); if (!result || result.type !== 'vacation') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } @@ -810,15 +821,16 @@ export const generalRouter = router({ return { ok: true }; }), setMySetting: accessEngineAuthedInputProcedure(zGeneralSettings).mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'setMySetting', - ...(ctx.requestId - ? { requestId: `${ctx.requestId}:general.setMySetting:engine:0:setMySetting` } - : {}), + ...(ctx.requestId ? { requestId: `${ctx.requestId}:general.setMySetting:engine:0:setMySetting` } : {}), + userId, generalId: general.id, settings: input, }); + throwIfCommandRejected(result); if (!result || result.type !== 'setMySetting') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } @@ -828,22 +840,27 @@ export const generalRouter = router({ return { ok: true }; }), - dropItem: engineAuthedProcedure.input(z.object({ itemType: z.string() })).mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - const result = await ctx.turnDaemon.requestCommand({ - type: 'dropItem', - ...(ctx.requestId ? { requestId: `${ctx.requestId}:general.dropItem:engine:0: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 }; - }), + dropItem: engineAuthedProcedure + .input(z.object({ itemType: z.enum(['horse', 'weapon', 'book', 'item']) })) + .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); + const general = await getMyGeneral(ctx); + const result = await ctx.turnDaemon.requestCommand({ + type: 'dropItem', + ...(ctx.requestId ? { requestId: `${ctx.requestId}:general.dropItem:engine:0:dropItem` } : {}), + userId, + generalId: general.id, + itemType: input.itemType, + }); + throwIfCommandRejected(result); + 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({ diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 6b4d02d5..76a1e3ae 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -416,6 +416,7 @@ export const inheritRouter = router({ } const result = await openAuctionWithDaemon( ctx, + userId, general.id, { auctionType: 'UNIQUE_ITEM', diff --git a/app/game-api/src/router/nation/endpoints/appoint.ts b/app/game-api/src/router/nation/endpoints/appoint.ts index 83fc6a2f..9d21e34a 100644 --- a/app/game-api/src/router/nation/endpoints/appoint.ts +++ b/app/game-api/src/router/nation/endpoints/appoint.ts @@ -2,7 +2,8 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { engineAuthedProcedure } from '../../../trpc.js'; -import { getMyGeneral } from '../../shared/general.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { throwIfCommandRejected } from '../../shared/turnDaemon.js'; export const appoint = engineAuthedProcedure .input( @@ -13,15 +14,18 @@ export const appoint = engineAuthedProcedure }) ) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'appoint', ...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.appoint:engine:0:appoint` } : {}), + userId, generalId: general.id, destGeneralId: input.destGeneralId, destCityId: input.destCityId, officerLevel: input.officerLevel, }); + throwIfCommandRejected(result); if (!result || result.type !== 'appoint') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } diff --git a/app/game-api/src/router/nation/endpoints/changePermission.ts b/app/game-api/src/router/nation/endpoints/changePermission.ts index c6070b0f..08a1daf6 100644 --- a/app/game-api/src/router/nation/endpoints/changePermission.ts +++ b/app/game-api/src/router/nation/endpoints/changePermission.ts @@ -2,7 +2,8 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { engineAuthedProcedure } from '../../../trpc.js'; -import { getMyGeneral } from '../../shared/general.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { throwIfCommandRejected } from '../../shared/turnDaemon.js'; export const changePermission = engineAuthedProcedure .input( @@ -15,16 +16,19 @@ export const changePermission = engineAuthedProcedure }) ) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'changePermission', ...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.changePermission:engine:0:changePermission` } : {}), + userId, generalId: general.id, isAmbassador: input.isAmbassador, targetGeneralIds: input.targetGeneralIds, }); + throwIfCommandRejected(result); if (!result || result.type !== 'changePermission') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } diff --git a/app/game-api/src/router/nation/endpoints/kick.ts b/app/game-api/src/router/nation/endpoints/kick.ts index a10668d8..f4e34fd4 100644 --- a/app/game-api/src/router/nation/endpoints/kick.ts +++ b/app/game-api/src/router/nation/endpoints/kick.ts @@ -2,18 +2,22 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { engineAuthedProcedure } from '../../../trpc.js'; -import { getMyGeneral } from '../../shared/general.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { throwIfCommandRejected } from '../../shared/turnDaemon.js'; export const kick = engineAuthedProcedure .input(z.object({ destGeneralId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const general = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'kick', ...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.kick:engine:0:kick` } : {}), + userId, generalId: general.id, destGeneralId: input.destGeneralId, }); + throwIfCommandRejected(result); if (!result || result.type !== 'kick') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); } diff --git a/app/game-api/src/router/nation/endpoints/setBill.ts b/app/game-api/src/router/nation/endpoints/setBill.ts index 3f330c57..feacdee5 100644 --- a/app/game-api/src/router/nation/endpoints/setBill.ts +++ b/app/game-api/src/router/nation/endpoints/setBill.ts @@ -1,19 +1,18 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord } from '@sammo-ts/common'; +import { engineAuthedProcedure } from '../../../trpc.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js'; -import { authedProcedure } from '../../../trpc.js'; -import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js'; - -export const setBill = authedProcedure +export const setBill = engineAuthedProcedure .input( z.object({ amount: z.number().int().min(20).max(200), }) ) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); assertNationAccess(me); const nation = await ctx.db.nation.findUnique({ @@ -24,14 +23,6 @@ export const setBill = authedProcedure 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 - ); + await updateNationSetting(ctx, userId, me, 'setBill', { kind: 'bill', amount: input.amount }); return { ok: true }; }); diff --git a/app/game-api/src/router/nation/endpoints/setBlockScout.ts b/app/game-api/src/router/nation/endpoints/setBlockScout.ts index 457fec6d..d74b9ff2 100644 --- a/app/game-api/src/router/nation/endpoints/setBlockScout.ts +++ b/app/game-api/src/router/nation/endpoints/setBlockScout.ts @@ -1,49 +1,31 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord } from '@sammo-ts/common'; +import { engineAuthedProcedure } from '../../../trpc.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js'; -import { authedProcedure } from '../../../trpc.js'; -import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js'; - -export const setBlockScout = authedProcedure +export const setBlockScout = engineAuthedProcedure .input( z.object({ value: z.boolean(), }) ) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); assertNationAccess(me); - const [nation, worldState] = await Promise.all([ - ctx.db.nation.findUnique({ - where: { id: me.nationId }, - select: { meta: true }, - }), - ctx.db.worldState.findFirst({ - select: { meta: true }, - }), - ]); + const nation = await ctx.db.nation.findUnique({ + where: { id: me.nationId }, + select: { meta: true }, + }); if (!nation) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } assertNationEditable(me, nation.meta); - const worldMeta = asRecord(worldState?.meta); - if (worldMeta.block_change_scout === true) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.', - }); - } - const nationMeta = asRecord(nation.meta); - await updateNationMeta( - ctx, - me.nationId, - { - scout: input.value ? 1 : 0, - }, - nationMeta - ); + await updateNationSetting(ctx, userId, me, 'setBlockScout', { + kind: 'blockScout', + value: input.value, + }); return { ok: true }; }); diff --git a/app/game-api/src/router/nation/endpoints/setBlockWar.ts b/app/game-api/src/router/nation/endpoints/setBlockWar.ts index 9a28b725..7113acb9 100644 --- a/app/game-api/src/router/nation/endpoints/setBlockWar.ts +++ b/app/game-api/src/router/nation/endpoints/setBlockWar.ts @@ -1,19 +1,18 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord } from '@sammo-ts/common'; +import { engineAuthedProcedure } from '../../../trpc.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js'; -import { authedProcedure } from '../../../trpc.js'; -import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, assertNationEditable, resolveWarSettingRemain, updateNationMeta } from '../shared.js'; - -export const setBlockWar = authedProcedure +export const setBlockWar = engineAuthedProcedure .input( z.object({ value: z.boolean(), }) ) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); assertNationAccess(me); const nation = await ctx.db.nation.findUnique({ @@ -25,20 +24,9 @@ export const setBlockWar = authedProcedure } assertNationEditable(me, nation.meta); - const meta = asRecord(nation.meta); - const remain = resolveWarSettingRemain(meta); - if (remain <= 0) { - throw new TRPCError({ code: 'BAD_REQUEST', message: '잔여 횟수가 부족합니다.' }); - } - const nextRemain = Math.max(0, remain - 1); - await updateNationMeta( - ctx, - me.nationId, - { - war: input.value ? 1 : 0, - available_war_setting_cnt: nextRemain, - }, - meta - ); - return { availableCnt: nextRemain }; + const result = await updateNationSetting(ctx, userId, me, 'setBlockWar', { + kind: 'blockWar', + value: input.value, + }); + return { availableCnt: result.availableCnt ?? 0 }; }); diff --git a/app/game-api/src/router/nation/endpoints/setNotice.ts b/app/game-api/src/router/nation/endpoints/setNotice.ts index f12fb1dc..69a504b0 100644 --- a/app/game-api/src/router/nation/endpoints/setNotice.ts +++ b/app/game-api/src/router/nation/endpoints/setNotice.ts @@ -1,20 +1,16 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord } from '@sammo-ts/common'; - import { purifyNationHtml } from '../../../security/nationHtml.js'; -import { authedProcedure } from '../../../trpc.js'; -import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js'; +import { engineAuthedProcedure } from '../../../trpc.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { legacyRequiredText } from '../settingInput.js'; +import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js'; -export const setNotice = authedProcedure - .input( - z.object({ - msg: z.string().min(1).max(16384), - }) - ) +export const setNotice = engineAuthedProcedure + .input(z.object({ msg: legacyRequiredText(16_384) })) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); assertNationAccess(me); const nation = await ctx.db.nation.findUnique({ @@ -25,16 +21,7 @@ export const setNotice = authedProcedure throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } assertNationEditable(me, nation.meta); - const nationMeta = asRecord(nation.meta); const msg = purifyNationHtml(input.msg); - await updateNationMeta( - ctx, - me.nationId, - { - notice: msg, - }, - nationMeta - ); - ctx.changeJournal?.mark('front.nation', me.nationId); + await updateNationSetting(ctx, userId, me, 'setNotice', { kind: 'notice', message: msg }); return { ok: true, msg }; }); diff --git a/app/game-api/src/router/nation/endpoints/setRate.ts b/app/game-api/src/router/nation/endpoints/setRate.ts index f2d25b35..64402c41 100644 --- a/app/game-api/src/router/nation/endpoints/setRate.ts +++ b/app/game-api/src/router/nation/endpoints/setRate.ts @@ -1,19 +1,18 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord } from '@sammo-ts/common'; +import { engineAuthedProcedure } from '../../../trpc.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js'; -import { authedProcedure } from '../../../trpc.js'; -import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js'; - -export const setRate = authedProcedure +export const setRate = engineAuthedProcedure .input( z.object({ amount: z.number().int().min(5).max(30), }) ) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); assertNationAccess(me); const nation = await ctx.db.nation.findUnique({ @@ -24,14 +23,6 @@ export const setRate = authedProcedure 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 - ); + await updateNationSetting(ctx, userId, me, 'setRate', { kind: 'rate', amount: input.amount }); return { ok: true }; }); diff --git a/app/game-api/src/router/nation/endpoints/setScoutMsg.ts b/app/game-api/src/router/nation/endpoints/setScoutMsg.ts index db89570b..102d88dd 100644 --- a/app/game-api/src/router/nation/endpoints/setScoutMsg.ts +++ b/app/game-api/src/router/nation/endpoints/setScoutMsg.ts @@ -1,20 +1,16 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord } from '@sammo-ts/common'; - import { purifyNationHtml } from '../../../security/nationHtml.js'; -import { authedProcedure } from '../../../trpc.js'; -import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js'; +import { engineAuthedProcedure } from '../../../trpc.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { legacyRequiredText } from '../settingInput.js'; +import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js'; -export const setScoutMsg = authedProcedure - .input( - z.object({ - msg: z.string().min(1).max(1000), - }) - ) +export const setScoutMsg = engineAuthedProcedure + .input(z.object({ msg: legacyRequiredText(1_000) })) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); assertNationAccess(me); const nation = await ctx.db.nation.findUnique({ @@ -25,15 +21,7 @@ export const setScoutMsg = authedProcedure throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); } assertNationEditable(me, nation.meta); - const nationMeta = asRecord(nation.meta); const msg = purifyNationHtml(input.msg); - await updateNationMeta( - ctx, - me.nationId, - { - infoText: msg, - }, - nationMeta - ); + await updateNationSetting(ctx, userId, me, 'setScoutMsg', { kind: 'scoutMessage', message: msg }); return { ok: true, msg }; }); diff --git a/app/game-api/src/router/nation/endpoints/setSecretLimit.ts b/app/game-api/src/router/nation/endpoints/setSecretLimit.ts index 9998a4b1..b58a1be3 100644 --- a/app/game-api/src/router/nation/endpoints/setSecretLimit.ts +++ b/app/game-api/src/router/nation/endpoints/setSecretLimit.ts @@ -1,19 +1,18 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord } from '@sammo-ts/common'; +import { engineAuthedProcedure } from '../../../trpc.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../../shared/general.js'; +import { assertNationAccess, assertNationEditable, updateNationSetting } from '../shared.js'; -import { authedProcedure } from '../../../trpc.js'; -import { getMyGeneral } from '../../shared/general.js'; -import { assertNationAccess, assertNationEditable, updateNationMeta } from '../shared.js'; - -export const setSecretLimit = authedProcedure +export const setSecretLimit = engineAuthedProcedure .input( z.object({ amount: z.number().int().min(1).max(99), }) ) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); assertNationAccess(me); const nation = await ctx.db.nation.findUnique({ @@ -24,14 +23,9 @@ export const setSecretLimit = authedProcedure 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 - ); + await updateNationSetting(ctx, userId, me, 'setSecretLimit', { + kind: 'secretLimit', + amount: input.amount, + }); return { ok: true }; }); diff --git a/app/game-api/src/router/nation/settingInput.ts b/app/game-api/src/router/nation/settingInput.ts new file mode 100644 index 00000000..54237c7b --- /dev/null +++ b/app/game-api/src/router/nation/settingInput.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +// PHP trim() removes only this default ASCII character list. JavaScript +// String#trim also removes Unicode spaces such as U+3000 and would reject a +// value that Ref accepts. +const hasLegacyTrimmedContent = (value: string): boolean => /[^ \t\n\r\0\v]/u.test(value); + +export const legacyRequiredText = (maximumCodePoints: number) => + z + .string() + .refine(hasLegacyTrimmedContent, '필수 입력입니다.') + .refine((value) => Array.from(value).length <= maximumCodePoints, `최대 ${maximumCodePoints}자까지 입력할 수 있습니다.`); diff --git a/app/game-api/src/router/nation/shared.ts b/app/game-api/src/router/nation/shared.ts index acd171b1..2caa21b5 100644 --- a/app/game-api/src/router/nation/shared.ts +++ b/app/game-api/src/router/nation/shared.ts @@ -1,7 +1,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asNumber, asRecord } from '@sammo-ts/common'; +import { asNumber, asRecord, type TurnDaemonCommand } from '@sammo-ts/common'; import { createIncomeActionContext, DomesticTraitLoader, @@ -26,7 +26,7 @@ import { type TriggerValue, } from '@sammo-ts/logic'; -import type { GameApiContext, InputJsonValue, WorldStateRow } from '../../context.js'; +import type { GameApiContext, WorldStateRow } from '../../context.js'; import { purifyNationHtml } from '../../security/nationHtml.js'; import { sanitizeInternalDisplayCode } from '../../services/gameDisplayNames.js'; import { resolveSecretPermission } from '../shared/secretPermission.js'; @@ -277,8 +277,10 @@ export const resolveNationScoutMessage = (meta: Record): string export const resolveWarSettingRemain = (meta: Record): number => { const legacy = readMetaNumber(meta, 'available_war_setting_cnt', -1); - const fallback = - legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', MAX_AVAILABLE_WAR_SETTING_CNT); + // Ref treats an absent counter as zero. The monthly refill is responsible + // for creating or replenishing it; a missing migration value must not grant + // ten free policy changes. + const fallback = legacy >= 0 ? legacy : readMetaNumber(meta, 'availableWarSettingCnt', 0); return Math.max(0, Math.min(MAX_AVAILABLE_WAR_SETTING_CNT, fallback)); }; @@ -428,38 +430,30 @@ export const assertNationEditable = ( } }; -export const updateNationMeta = async ( - ctx: Pick, - nationId: number, - updates: Record, - currentMeta: Record -): Promise => { - const expectedUpdatedAt = typeof currentMeta._updatedAt === 'string' ? currentMeta._updatedAt : undefined; +type NationSettingMutation = Extract['mutation']; + +export const updateNationSetting = async ( + ctx: Pick, + userId: string, + general: { id: number; nationId: number }, + endpoint: string, + mutation: NationSettingMutation +) => { const result = await ctx.turnDaemon.requestCommand({ - type: 'setNationMeta', - nationId, - updates, - expectedUpdatedAt, + type: 'setNationSetting', + ...(ctx.requestId ? { requestId: `${ctx.requestId}:nation.${endpoint}:engine:0:setNationSetting` } : {}), + userId, + generalId: general.id, + nationId: general.nationId, + mutation, }); - if (!result || result.type !== 'setNationMeta') { + if (!result || result.type !== 'setNationSetting') { 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 }); + throw new TRPCError({ code: result.code, message: result.reason }); } - ctx.changeJournal?.mark('nation.content', nationId); - ctx.changeJournal?.mark('dashboard.global'); - return { - ...currentMeta, - ...updates, - _updatedAt: result.updatedAt, - } as InputJsonValue; + return result; }; export const mapGeneralList = async ( diff --git a/app/game-api/src/router/npc/index.ts b/app/game-api/src/router/npc/index.ts index ec493c64..cec38f10 100644 --- a/app/game-api/src/router/npc/index.ts +++ b/app/game-api/src/router/npc/index.ts @@ -3,137 +3,24 @@ import { z } from 'zod'; import { asRecord, isRecord } from '@sammo-ts/common'; import { findCrewTypeById, getTechCost } from '@sammo-ts/logic/world/unitSet.js'; +import { + DEFAULT_GENERAL_PRIORITY, + DEFAULT_NATION_POLICY, + DEFAULT_NATION_PRIORITY, + type NationPolicy, +} from '@sammo-ts/game-engine/turn/npcPolicyMutation.js'; -import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; +import { accessEngineAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { loadUnitSetDefinitionByName } from '@sammo-ts/game-engine/scenario/unitSetLoader.js'; import type { GameApiContext } from '../../context.js'; import { getMyGeneral } from '../shared/general.js'; import { resolveSecretPermission } from '../shared/secretPermission.js'; -type NationPolicy = { - reqNationGold: number; - reqNationRice: number; - CombatForce: Record; - SupportForce: number[]; - DevelopForce: number[]; - reqHumanWarUrgentGold: number; - reqHumanWarUrgentRice: number; - reqHumanWarRecommandGold: number; - reqHumanWarRecommandRice: number; - reqHumanDevelGold: number; - reqHumanDevelRice: number; - reqNPCWarGold: number; - reqNPCWarRice: number; - reqNPCDevelGold: number; - reqNPCDevelRice: number; - minimumResourceActionAmount: number; - maximumResourceActionAmount: number; - minNPCWarLeadership: number; - minWarCrew: number; - minNPCRecruitCityPopulation: number; - safeRecruitCityPopulationRatio: number; - properWarTrainAtmos: number; - cureThreshold: number; -}; - type SetterInfo = { setter: string | null; 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 = [ - '불가침제의', - '선전포고', - '천도', - '유저장긴급포상', - '부대전방발령', - '유저장구출발령', - '유저장후방발령', - '부대유저장후방발령', - '유저장전방발령', - '유저장포상', - '부대구출발령', - '부대후방발령', - 'NPC긴급포상', - 'NPC구출발령', - 'NPC후방발령', - 'NPC포상', - 'NPC전방발령', - '유저장내정발령', - 'NPC내정발령', - 'NPC몰수', -] as const; - -const DEFAULT_GENERAL_PRIORITY = [ - 'NPC사망대비', - '귀환', - '금쌀구매', - '출병', - '긴급내정', - '전투준비', - '전방워프', - 'NPC헌납', - '징병', - '후방워프', - '전쟁내정', - '소집해제', - '일반내정', - '내정워프', -] as const; - -const DEFAULT_NATION_POLICY: NationPolicy = { - reqNationGold: 10000, - reqNationRice: 12000, - CombatForce: {}, - SupportForce: [], - DevelopForce: [], - reqHumanWarUrgentGold: 0, - reqHumanWarUrgentRice: 0, - reqHumanWarRecommandGold: 0, - reqHumanWarRecommandRice: 0, - reqHumanDevelGold: 10000, - reqHumanDevelRice: 10000, - reqNPCWarGold: 0, - reqNPCWarRice: 0, - reqNPCDevelGold: 0, - reqNPCDevelRice: 500, - minimumResourceActionAmount: 1000, - maximumResourceActionAmount: 10000, - minNPCWarLeadership: 40, - minWarCrew: 1500, - minNPCRecruitCityPopulation: 50000, - safeRecruitCityPopulationRatio: 0.5, - properWarTrainAtmos: 90, - cureThreshold: 10, -}; - const NATION_POLICY_KEYS = new Set(Object.keys(DEFAULT_NATION_POLICY) as Array); const INTEGER_POLICY_KEYS = [ @@ -310,7 +197,7 @@ const applyPolicyValues = (base: NationPolicy, values: Record): const floatKey = key as FloatPolicyKey; if (FLOAT_POLICY_KEYS.includes(floatKey)) { const value = readNumber(rawValue, next.safeRecruitCityPopulationRatio); - next.safeRecruitCityPopulationRatio = Math.max(0, value); + next.safeRecruitCityPopulationRatio = value; continue; } if (key === 'CombatForce' && isRecord(rawValue)) { @@ -415,34 +302,51 @@ const resolveSetterInfo = (policy: Record, kind: 'value' | 'pri }; }; -const validateGeneralPriority = (priority: string[]): string | null => { - const orderRequired: Array<[string, string]> = [['출병', '일반내정']]; - const mustHave = new Set(['출병', '일반내정']); - const orderMap = new Map(); - - for (const [idx, item] of priority.entries()) { - if (!DEFAULT_GENERAL_PRIORITY.includes(item as (typeof DEFAULT_GENERAL_PRIORITY)[number])) { - return `${item}은 올바른 명령이 아닙니다.`; - } - orderMap.set(item, idx); - mustHave.delete(item); +const requestNpcPolicyMutation = async ( + ctx: Pick, + endpoint: 'setNationPolicy' | 'setNationPriority' | 'setGeneralPriority', + mutation: + | { kind: 'nationPolicy'; values: Record } + | { kind: 'nationPriority'; priority: string[] } + | { kind: 'generalPriority'; priority: string[] } +): Promise<{ ok: true }> => { + if (!ctx.auth) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Unauthorized' }); } - - for (const [pre, post] of orderRequired) { - const preIdx = orderMap.get(pre); - const postIdx = orderMap.get(post); - if (preIdx === undefined || postIdx === undefined) { - continue; - } - if (preIdx > postIdx) { - return `${pre} 명령은 ${post} 명령보다 먼저여야 합니다.`; - } + const general = await getMyGeneral(ctx); + if (general.nationId <= 0) { + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '국가에 소속되어있지 않습니다.' }); } - - if (mustHave.size > 0) { - return `${Array.from(mustHave)[0]}은 항상 사용해야 합니다.`; + const nation = await ctx.db.nation.findUnique({ + where: { id: general.nationId }, + select: { id: true, meta: true }, + }); + if (!nation) { + throw new TRPCError({ code: 'NOT_FOUND', message: '국가 정보를 찾을 수 없습니다.' }); } - return null; + const nationMeta = asRecord(nation.meta); + const expectedUpdatedAt = + typeof nationMeta._npcPolicyUpdatedAt === 'string' + ? nationMeta._npcPolicyUpdatedAt + : typeof nationMeta._updatedAt === 'string' + ? nationMeta._updatedAt + : null; + const result = await ctx.turnDaemon.requestCommand({ + type: 'setNpcPolicy', + ...(ctx.requestId ? { requestId: `${ctx.requestId}:npc.${endpoint}:engine:0:setNpcPolicy` } : {}), + userId: ctx.auth.user.id, + generalId: general.id, + nationId: nation.id, + expectedUpdatedAt, + mutation, + }); + if (!result || result.type !== 'setNpcPolicy') { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); + } + if (!result.ok) { + throw new TRPCError({ code: result.code, message: result.reason }); + } + return { ok: true }; }; export const npcRouter = router({ @@ -542,272 +446,13 @@ export const npcRouter = router({ permissionLevel, }; }), - setNationPolicy: accessAuthedInputProcedure(z.record(z.string(), z.unknown())).mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } - - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } - - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } - - const keys = Object.keys(input); - for (const key of keys) { - if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); - } - } - - const troopRows = await ctx.db.troop.findMany({ - where: { nationId: general.nationId }, - select: { troopLeaderId: true }, - }); - const cityRows = await ctx.db.city.findMany({ select: { id: true } }); - - const troopSet = new Set(troopRows.map((row) => row.troopLeaderId)); - const citySet = new Set(cityRows.map((row) => row.id)); - const assigned = new Set(); - - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_nation_policy); - const nextValues = applyPolicyValues(DEFAULT_NATION_POLICY, asRecord(policyRoot.values)); - - for (const key of INTEGER_POLICY_KEYS) { - if (!(key in input)) { - continue; - } - const value = input[key]; - if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); - } - nextValues[key] = Math.max(0, value); - } - - for (const key of FLOAT_POLICY_KEYS) { - if (!(key in input)) { - continue; - } - const value = input[key]; - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 값이 아닙니다.` }); - } - nextValues[key] = value; - } - - if ('CombatForce' in input) { - const rawCombat = input.CombatForce; - if (!isRecord(rawCombat)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'CombatForce는 올바른 정책값이 아닙니다.' }); - } - const combatForce: Record = {}; - for (const [rawKey, rawValue] of Object.entries(rawCombat)) { - const leaderId = Number(rawKey); - if (!Number.isFinite(leaderId)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${rawKey}는 올바른 부대가 아닙니다.` }); - } - if (!troopSet.has(leaderId)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${leaderId}는 국가의 부대가 아닙니다.` }); - } - if (assigned.has(leaderId)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.`, - }); - } - if (!Array.isArray(rawValue) || rawValue.length < 2) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `${leaderId}의 입력양식이 올바르지 않습니다.`, - }); - } - const fromCity = Number(rawValue[0]); - const toCity = Number(rawValue[1]); - if (!citySet.has(fromCity) || !citySet.has(toCity)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `${leaderId}의 도시 ${fromCity}, ${toCity}가 올바른 도시 번호가 아닙니다.`, - }); - } - combatForce[leaderId] = [fromCity, toCity]; - assigned.add(leaderId); - } - nextValues.CombatForce = combatForce; - } - - for (const key of ['SupportForce', 'DevelopForce'] as const) { - if (!(key in input)) { - continue; - } - const rawList = input[key]; - if (!Array.isArray(rawList)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); - } - const list: number[] = []; - for (const rawValue of rawList) { - if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${key}는 올바른 정책값이 아닙니다.` }); - } - if (!troopSet.has(rawValue)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `${rawValue}는 국가의 부대가 아닙니다.`, - }); - } - if (assigned.has(rawValue)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: `부대(${rawValue})는 하나의 역할만 지정할 수 있습니다.`, - }); - } - assigned.add(rawValue); - list.push(rawValue); - } - if (key === 'SupportForce') { - nextValues.SupportForce = list; - } else { - nextValues.DevelopForce = list; - } - } - - const nextPolicyRoot = { - ...policyRoot, - values: nextValues, - valueSetter: general.name, - valueSetTime: new Date().toISOString(), - }; - - await updateNationMeta( - ctx, - nation.id, - { - npc_nation_policy: nextPolicyRoot, - }, - nationMeta - ); - - return { ok: true }; - }), - setNationPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } - - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } - - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } - - for (const item of input) { - if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) { - throw new TRPCError({ code: 'BAD_REQUEST', message: `${item}은 올바른 명령이 아닙니다.` }); - } - } - - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_nation_policy); - const nextPolicyRoot = { - ...policyRoot, - priority: input, - prioritySetter: general.name, - prioritySetTime: new Date().toISOString(), - }; - - await updateNationMeta( - ctx, - nation.id, - { - npc_nation_policy: nextPolicyRoot, - }, - nationMeta - ); - - return { ok: true }; - }), - setGeneralPriority: accessAuthedInputProcedure(z.array(z.string())).mutation(async ({ ctx, input }) => { - const general = await getMyGeneral(ctx); - if (general.nationId <= 0) { - throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'Nation membership required.' }); - } - - const nation = await ctx.db.nation.findUnique({ - where: { id: general.nationId }, - select: { id: true, meta: true }, - }); - if (!nation) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Nation not found' }); - } - - const permissionLevel = resolveSecretPermission( - { - nationId: general.nationId, - officerLevel: general.officerLevel, - meta: general.meta, - penalty: general.penalty, - }, - nation.meta - ); - if (permissionLevel < 3) { - throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); - } - - const validationError = validateGeneralPriority(input); - if (validationError) { - throw new TRPCError({ code: 'BAD_REQUEST', message: validationError }); - } - - const nationMeta = asRecord(nation.meta); - const policyRoot = asRecord(nationMeta.npc_general_policy); - const nextPolicyRoot = { - ...policyRoot, - priority: input, - prioritySetter: general.name, - prioritySetTime: new Date().toISOString(), - }; - - await updateNationMeta( - ctx, - nation.id, - { - npc_general_policy: nextPolicyRoot, - }, - nationMeta - ); - - return { ok: true }; - }), + setNationPolicy: accessEngineAuthedInputProcedure(z.record(z.string(), z.unknown())).mutation(({ ctx, input }) => + requestNpcPolicyMutation(ctx, 'setNationPolicy', { kind: 'nationPolicy', values: input }) + ), + setNationPriority: accessEngineAuthedInputProcedure(z.array(z.string())).mutation(({ ctx, input }) => + requestNpcPolicyMutation(ctx, 'setNationPriority', { kind: 'nationPriority', priority: input }) + ), + setGeneralPriority: accessEngineAuthedInputProcedure(z.array(z.string())).mutation(({ ctx, input }) => + requestNpcPolicyMutation(ctx, 'setGeneralPriority', { kind: 'generalPriority', priority: input }) + ), }); diff --git a/app/game-api/src/router/shared/general.ts b/app/game-api/src/router/shared/general.ts index 8b068140..fee645fb 100644 --- a/app/game-api/src/router/shared/general.ts +++ b/app/game-api/src/router/shared/general.ts @@ -1,12 +1,18 @@ import { TRPCError } from '@trpc/server'; import type { GameApiContext } from '../../context.js'; -export const getMyGeneral = async (ctx: Pick) => { - if (!ctx.auth?.user.id) { +export const getAuthenticatedUserId = (ctx: Pick): string => { + const userId = ctx.auth?.user.id; + if (!userId) { throw new TRPCError({ code: 'UNAUTHORIZED' }); } + return userId; +}; + +export const getMyGeneral = async (ctx: Pick) => { + const userId = getAuthenticatedUserId(ctx); const general = await ctx.db.general.findFirst({ - where: { userId: ctx.auth.user.id }, + where: { userId }, }); if (!general) { throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found' }); @@ -14,20 +20,15 @@ export const getMyGeneral = async (ctx: Pick) => return general; }; -export const getOwnedGeneral = async ( - ctx: Pick, - generalId: number -) => { - if (!ctx.auth?.user.id) { - throw new TRPCError({ code: 'UNAUTHORIZED' }); - } +export const getOwnedGeneral = async (ctx: Pick, generalId: number) => { + const userId = getAuthenticatedUserId(ctx); const general = await ctx.db.general.findUnique({ where: { id: generalId }, }); if (!general) { throw new TRPCError({ code: 'NOT_FOUND', message: 'General not found.' }); } - if (general.userId !== ctx.auth.user.id) { + if (general.userId !== userId) { throw new TRPCError({ code: 'FORBIDDEN', message: 'General is not owned by the authenticated user.', diff --git a/app/game-api/src/router/shared/turnDaemon.ts b/app/game-api/src/router/shared/turnDaemon.ts new file mode 100644 index 00000000..561b0932 --- /dev/null +++ b/app/game-api/src/router/shared/turnDaemon.ts @@ -0,0 +1,10 @@ +import { TRPCError } from '@trpc/server'; + +import type { TurnDaemonCommandResult } from '@sammo-ts/common'; + +export const throwIfCommandRejected = (result: TurnDaemonCommandResult | null): void => { + if (result?.type !== 'commandRejected') { + return; + } + throw new TRPCError({ code: 'FORBIDDEN', message: result.reason }); +}; diff --git a/app/game-api/src/router/troop/index.ts b/app/game-api/src/router/troop/index.ts index e13ed06b..aa91b68b 100644 --- a/app/game-api/src/router/troop/index.ts +++ b/app/game-api/src/router/troop/index.ts @@ -24,7 +24,8 @@ import { resolveRemainingMinutes, } from '../../services/generalBasicCardProjection.js'; import { loadTraitNames } from '../nation/shared.js'; -import { getMyGeneral } from '../shared/general.js'; +import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; +import { throwIfCommandRejected } from '../shared/turnDaemon.js'; const TROOP_PANEL_RECORD_TYPES = [ 'firenum', @@ -54,6 +55,7 @@ const assertCommandResult = { + throwIfCommandRejected(result); if (!result) { throw new TRPCError({ code: 'TIMEOUT', message: 'Turn daemon did not respond.' }); } @@ -427,6 +429,7 @@ export const troopRouter = router({ }; }), create: engineAuthedProcedure.input(z.object({ troopName: troopNameSchema })).mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); const troopName = normalizeRequiredTroopName(input.troopName); if (me.troopId !== 0) { @@ -444,6 +447,7 @@ export const troopRouter = router({ const result = await ctx.turnDaemon.requestCommand({ type: 'troopCreate', ...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.create:engine:0:troopCreate` } : {}), + userId, generalId: me.id, troopName, }); @@ -458,10 +462,12 @@ export const troopRouter = router({ join: engineAuthedProcedure .input(z.object({ troopId: z.number().int().positive() })) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'troopJoin', ...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.join:engine:0:troopJoin` } : {}), + userId, generalId: me.id, troopId: input.troopId, }); @@ -474,10 +480,12 @@ export const troopRouter = router({ return { ok: true }; }), exit: engineAuthedProcedure.mutation(async ({ ctx }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); const result = await ctx.turnDaemon.requestCommand({ type: 'troopExit', ...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.exit:engine:0:troopExit` } : {}), + userId, generalId: me.id, }); if (!result || result.type !== 'troopExit') { @@ -496,6 +504,7 @@ export const troopRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); if (me.id !== input.troopId || me.troopId !== me.id) { throw new TRPCError({ code: 'FORBIDDEN', message: '권한이 부족합니다.' }); @@ -520,6 +529,7 @@ export const troopRouter = router({ const result = await ctx.turnDaemon.requestCommand({ type: 'troopKick', ...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.kick:engine:0:troopKick` } : {}), + userId, generalId: me.id, troopId: input.troopId, targetGeneralId: input.targetGeneralId, @@ -541,6 +551,7 @@ export const troopRouter = router({ }) ) .mutation(async ({ ctx, input }) => { + const userId = getAuthenticatedUserId(ctx); const me = await getMyGeneral(ctx); const troopName = normalizeRequiredTroopName(input.troopName); const nation = await ctx.db.nation.findUnique({ @@ -562,6 +573,7 @@ export const troopRouter = router({ const result = await ctx.turnDaemon.requestCommand({ type: 'troopRename', ...(ctx.requestId ? { requestId: `${ctx.requestId}:troop.rename:engine:0:troopRename` } : {}), + userId, generalId: me.id, troopId: input.troopId, troopName, diff --git a/app/game-api/src/router/vote/index.ts b/app/game-api/src/router/vote/index.ts index 2f5213d8..af09b652 100644 --- a/app/game-api/src/router/vote/index.ts +++ b/app/game-api/src/router/vote/index.ts @@ -5,8 +5,9 @@ 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 { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js'; import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js'; +import { throwIfCommandRejected } from '../shared/turnDaemon.js'; const hasAdminRole = (roles: string[], profileName: string): boolean => { if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) { @@ -347,15 +348,19 @@ export const voteRouter = router({ if (new Set(sortedSelection).size !== sortedSelection.length) { throw new TRPCError({ code: 'BAD_REQUEST', message: '선택한 항목이 올바르지 않습니다.' }); } + const userId = getAuthenticatedUserId(ctx); const general = await getMyGeneral(ctx); const rewardResult = await ctx.turnDaemon.requestCommand({ type: 'voteReward', + ...(ctx.requestId ? { requestId: `${ctx.requestId}:vote.submitVote:engine:0:voteReward` } : {}), + userId, voteId: input.voteId, generalId: general.id, selection: sortedSelection, ...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }), }); + throwIfCommandRejected(rewardResult); if (!rewardResult || rewardResult.type !== 'voteReward') { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Unexpected response' }); diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index b306dd7d..11c14a1e 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -47,6 +47,7 @@ import { RemoteContentImageStore } from './services/remoteContentImageStore.js'; import { ReadModelOutboxWorker } from './realtime/outboxWorker.js'; import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js'; import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js'; +import { scopeHttpIdempotencyKey } from './requestId.js'; const extractBearerToken = (value: string | string[] | undefined): string | null => { if (!value) { @@ -247,11 +248,15 @@ export const createGameApiServer = async () => { createContext: async ({ req }: { req: FastifyRequest }) => { const token = extractBearerToken(req.headers.authorization); const auth = await resolveAuthFromToken(token, accessTokenStore, flushStore); + const rawIdempotencyKey = Array.isArray(req.headers['idempotency-key']) + ? req.headers['idempotency-key'][0] + : req.headers['idempotency-key']; return createGameApiContext({ - requestId: - (Array.isArray(req.headers['idempotency-key']) - ? req.headers['idempotency-key'][0] - : req.headers['idempotency-key']) || undefined, + requestId: scopeHttpIdempotencyKey({ + rawKey: rawIdempotencyKey, + profileId: config.profile, + userId: auth?.user.id ?? null, + }), db: postgres.prisma, redis: redis.client, turnDaemon, diff --git a/app/game-api/src/tournament/store.ts b/app/game-api/src/tournament/store.ts index 1c949c50..ba7d656d 100644 --- a/app/game-api/src/tournament/store.ts +++ b/app/game-api/src/tournament/store.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common'; +import { z } from 'zod'; import type { TournamentKeys } from './keys.js'; import type { TournamentBetEntry, TournamentMatchEntry, TournamentParticipantEntry, TournamentState } from './types.js'; @@ -19,15 +20,115 @@ interface RedisClientLike { publish?(channel: string, message: string): Promise; } -const safeJsonParse = (raw: string | null): T | null => { - if (!raw) { +export class CorruptTournamentProjectionError extends Error { + constructor(readonly key: string) { + super(`Tournament projection is malformed: ${key}`); + this.name = 'CorruptTournamentProjectionError'; + } +} + +const zTournamentState = z + .object({ + stage: z.number().int(), + phase: z.number().int(), + type: z.number().int(), + auto: z.boolean(), + openYear: z.number().int(), + openMonth: z.number().int(), + termSeconds: z.number(), + nextAt: z.string(), + bettingId: z.number().int().optional(), + bettingCloseAt: z.string().optional(), + winnerId: z.number().int().optional(), + bettingSettled: z.boolean().optional(), + rewardSettled: z.boolean().optional(), + participantsLockedAt: z.string().optional(), + lastError: z.string().optional(), + lastErrorAt: z.string().optional(), + }) + .passthrough(); + +const zTournamentParticipant = z + .object({ + id: z.number().int(), + name: z.string(), + leadership: z.number(), + strength: z.number(), + intel: z.number(), + level: z.number(), + groupId: z.number().int().optional(), + groupNo: z.number().int().optional(), + win: z.number().int().optional(), + draw: z.number().int().optional(), + lose: z.number().int().optional(), + gl: z.number().int().optional(), + seedRank: z.number().int().optional(), + finalRank: z.number().int().optional(), + preliminaryGroupId: z.number().int().optional(), + preliminaryGroupNo: z.number().int().optional(), + preliminaryRank: z.number().int().optional(), + preliminaryWin: z.number().int().optional(), + preliminaryDraw: z.number().int().optional(), + preliminaryLose: z.number().int().optional(), + preliminaryGl: z.number().int().optional(), + }) + .passthrough(); + +const zTournamentLogEntry = z + .object({ + phase: z.number().int(), + attackerEnergy: z.number(), + defenderEnergy: z.number(), + attackerDamage: z.number(), + defenderDamage: z.number(), + text: z.string(), + }) + .passthrough(); + +const zTournamentMatch = z + .object({ + id: z.number().int(), + stage: z.number().int(), + roundIndex: z.number().int(), + attackerId: z.number().int(), + defenderId: z.number().int(), + groupId: z.number().int().optional(), + winnerId: z.number().int().optional(), + log: z.array(z.string()).optional(), + logEntries: z.array(zTournamentLogEntry).optional(), + lastEnergy: z + .object({ + attacker: z.number(), + defender: z.number(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +const zTournamentBet = z + .object({ + generalId: z.number().int(), + targetId: z.number().int(), + amount: z.number(), + }) + .passthrough(); + +const parseProjection = (raw: string | null, key: string, schema: z.ZodType): T | null => { + if (raw === null) { return null; } + let value: unknown; try { - return JSON.parse(raw) as T; + value = JSON.parse(raw) as unknown; } catch { - return null; + throw new CorruptTournamentProjectionError(key); } + const parsed = schema.safeParse(value); + if (!parsed.success) { + throw new CorruptTournamentProjectionError(key); + } + return parsed.data; }; export class TournamentStore { @@ -61,7 +162,11 @@ export class TournamentStore { } async getState(): Promise { - return safeJsonParse(await this.redis.get(this.keys.stateKey)); + return parseProjection( + await this.redis.get(this.keys.stateKey), + this.keys.stateKey, + zTournamentState + ) as TournamentState | null; } async getSourceRevision(): Promise { @@ -77,7 +182,13 @@ export class TournamentStore { } async getParticipants(): Promise { - return safeJsonParse(await this.redis.get(this.keys.participantsKey)) ?? []; + return ( + parseProjection( + await this.redis.get(this.keys.participantsKey), + this.keys.participantsKey, + z.array(zTournamentParticipant) + ) ?? [] + ); } async setParticipants(participants: TournamentParticipantEntry[]): Promise { @@ -85,7 +196,13 @@ export class TournamentStore { } async getMatches(): Promise { - return safeJsonParse(await this.redis.get(this.keys.matchesKey)) ?? []; + return ( + parseProjection( + await this.redis.get(this.keys.matchesKey), + this.keys.matchesKey, + z.array(zTournamentMatch) + ) ?? [] + ); } async setMatches(matches: TournamentMatchEntry[]): Promise { @@ -93,7 +210,13 @@ export class TournamentStore { } async getBettingEntries(): Promise { - return safeJsonParse(await this.redis.get(this.keys.bettingKey)) ?? []; + return ( + parseProjection( + await this.redis.get(this.keys.bettingKey), + this.keys.bettingKey, + z.array(zTournamentBet) + ) ?? [] + ); } async setBettingEntries(entries: TournamentBetEntry[]): Promise { diff --git a/app/game-api/test/auctionRouter.test.ts b/app/game-api/test/auctionRouter.test.ts index b811269b..748a689b 100644 --- a/app/game-api/test/auctionRouter.test.ts +++ b/app/game-api/test/auctionRouter.test.ts @@ -241,6 +241,7 @@ describe('auction router actor and permission boundaries', () => { expect(fixture.requestCommand).toHaveBeenCalledWith({ type: 'auctionOpen', auctionType: 'BUY_RICE', + userId: 'user-1', generalId: 7, amount: 1000, closeTurnCnt: 3, @@ -269,6 +270,7 @@ describe('auction router actor and permission boundaries', () => { type: 'auctionOpen', requestId: 'http-auction-open:auction.openBuyRice:engine:0:auctionOpen', auctionType: 'BUY_RICE', + userId: 'user-1', generalId: 7, amount: 1000, closeTurnCnt: 3, @@ -381,6 +383,7 @@ describe('auction router actor and permission boundaries', () => { expect(fixture.requestCommand).toHaveBeenCalledWith({ type: 'auctionBid', + userId: 'user-1', auctionId: 31, generalId: 7, amount: 110, diff --git a/app/game-api/test/diplomacyRouter.test.ts b/app/game-api/test/diplomacyRouter.test.ts index ae091f80..a595b3a7 100644 --- a/app/game-api/test/diplomacyRouter.test.ts +++ b/app/game-api/test/diplomacyRouter.test.ts @@ -108,6 +108,15 @@ const buildContext = (officerLevel = 12, letter: Record = store findFirst: vi.fn(async () => null), create, }, + worldState: { + findFirst: vi.fn(async () => ({ + clockBaseTime: new Date('0185-01-01T00:00:00.000Z'), + clockTick: 6n, + clockMode: 'manual', + clockWallAnchor: new Date('2026-07-31T00:00:00.000Z'), + tickSeconds: 600, + })), + }, }; const redis = { get: async () => null, @@ -146,6 +155,7 @@ describe('diplomacy HTML API boundary', () => { textBrief: '

공개

', textDetail: '
  • 조건
자료', + date: new Date('0185-01-01T00:00:00.000Z'), }), }); }); diff --git a/app/game-api/test/directMutationJournalInventory.test.ts b/app/game-api/test/directMutationJournalInventory.test.ts index 9b524d5a..dd05d617 100644 --- a/app/game-api/test/directMutationJournalInventory.test.ts +++ b/app/game-api/test/directMutationJournalInventory.test.ts @@ -10,16 +10,6 @@ const classifications = { 'messages.delete', 'messages.respond', 'messages.send', - 'nation.setBill', - 'nation.setBlockScout', - 'nation.setBlockWar', - 'nation.setNotice', - 'nation.setRate', - 'nation.setScoutMsg', - 'nation.setSecretLimit', - 'npc.setGeneralPriority', - 'npc.setNationPolicy', - 'npc.setNationPriority', 'turns.repeatGeneral', 'turns.setGeneral', 'turns.setGeneralBulk', @@ -69,6 +59,16 @@ const classifications = { 'nation.appoint', 'nation.changePermission', 'nation.kick', + 'nation.setBill', + 'nation.setBlockScout', + 'nation.setBlockWar', + 'nation.setNotice', + 'nation.setRate', + 'nation.setScoutMsg', + 'nation.setSecretLimit', + 'npc.setGeneralPriority', + 'npc.setNationPolicy', + 'npc.setNationPriority', 'troop.create', 'troop.exit', 'troop.join', diff --git a/app/game-api/test/idempotentTransport.test.ts b/app/game-api/test/idempotentTransport.test.ts index 66175310..04891fe6 100644 --- a/app/game-api/test/idempotentTransport.test.ts +++ b/app/game-api/test/idempotentTransport.test.ts @@ -14,9 +14,9 @@ describe('IdempotentTurnDaemonTransport', () => { const firstAttempt = new IdempotentTurnDaemonTransport(inner, 'api-event'); const retry = new IdempotentTurnDaemonTransport(inner, 'api-event'); - await firstAttempt.sendCommand({ type: 'vacation', generalId: 7 }); - await firstAttempt.sendCommand({ type: 'dropItem', generalId: 7, itemType: 'weapon' }); - await retry.sendCommand({ type: 'vacation', generalId: 7 }); + await firstAttempt.sendCommand({ type: 'vacation', userId: 'user-7', generalId: 7 }); + await firstAttempt.sendCommand({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' }); + await retry.sendCommand({ type: 'vacation', userId: 'user-7', generalId: 7 }); expect(inner.commands.map((entry) => entry.requestId)).toEqual([ 'api-event:engine:0:vacation', @@ -29,6 +29,7 @@ describe('IdempotentTurnDaemonTransport', () => { const auctionBid = { type: 'auctionBid', requestId: 'auction-bid', + userId: 'user-7', auctionId: 31, generalId: 7, amount: 500, @@ -40,6 +41,7 @@ describe('IdempotentTurnDaemonTransport', () => { const voteReward = { type: 'voteReward', requestId: 'vote-reward', + userId: 'user-7', voteId: 1, generalId: 7, selection: [0], @@ -93,6 +95,7 @@ describe('IdempotentTurnDaemonTransport', () => { const persistedPayload = { type: 'voteReward' as const, requestId: 'vote-reward', + userId: 'user-7', voteId: 1, generalId: 7, selection: [0], diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 1ae5ada0..f2edd74a 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -595,6 +595,7 @@ describe('in-game my information ownership', () => { await caller.general.setMySetting({ tnmt: 1, defence_train: 999 }); expect(requestCommand).toHaveBeenCalledWith({ type: 'setMySetting', + userId: 'user-7', generalId: 7, settings: { tnmt: 1, defence_train: 999 }, }); @@ -622,18 +623,51 @@ describe('in-game my information ownership', () => { requestCommand, }); - await expect( - appRouter.createCaller(fixture.context).general.setMySetting({ tnmt: 1 }) - ).resolves.toEqual({ ok: true }); + await expect(appRouter.createCaller(fixture.context).general.setMySetting({ tnmt: 1 })).resolves.toEqual({ + ok: true, + }); expect(transaction).not.toHaveBeenCalled(); expect(requestCommand).toHaveBeenCalledWith({ type: 'setMySetting', requestId: 'http-general-setting:general.setMySetting:engine:0:setMySetting', + userId: 'user-7', generalId: 7, settings: { tnmt: 1 }, }); }); + it.each(['horse', 'weapon', 'book', 'item'] as const)( + 'dispatches the authenticated dropItem command for the %s slot', + async (itemType) => { + const requestCommand = vi.fn(async () => ({ + type: 'dropItem' as const, + ok: true as const, + generalId: 7, + })); + const fixture = createContext({ requestCommand }); + + await expect(appRouter.createCaller(fixture.context).general.dropItem({ itemType })).resolves.toEqual({ + ok: true, + }); + expect(requestCommand).toHaveBeenCalledWith({ + type: 'dropItem', + userId: 'user-7', + generalId: 7, + itemType, + }); + } + ); + + it('rejects an unknown dropItem slot before dispatching it to ENGINE', async () => { + const requestCommand = vi.fn(async () => ({ type: 'dropItem' as const, ok: true as const, generalId: 7 })); + const fixture = createContext({ requestCommand }); + + await expect( + appRouter.createCaller(fixture.context).general.dropItem({ itemType: 'armor' as never }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(requestCommand).not.toHaveBeenCalled(); + }); + it('uses the authenticated user for both the page and its logs without accepting a target general id', async () => { const otherUser = buildGeneral({ id: 8, userId: 'user-8', name: '타유저' }); const fixture = createContext({ targets: [buildGeneral(), otherUser] }); diff --git a/app/game-api/test/inputEventBoundary.integration.test.ts b/app/game-api/test/inputEventBoundary.integration.test.ts index 02112b59..a45e8f17 100644 --- a/app/game-api/test/inputEventBoundary.integration.test.ts +++ b/app/game-api/test/inputEventBoundary.integration.test.ts @@ -32,10 +32,7 @@ const payloadHasGeneral = (payload: unknown, generalId: number): boolean => { const changes = (payload as { changes?: unknown }).changes; return ( Array.isArray(changes) && - changes.some( - (change) => - Array.isArray(change) && change[0] === 'front.general' && change[1] === generalId - ) + changes.some((change) => Array.isArray(change) && change[0] === 'front.general' && change[1] === generalId) ); }; @@ -134,10 +131,7 @@ integration('API input event boundary', () => { ).resolves.toEqual({ ok: true }); expect(wakeSnapshot).toBeDefined(); - const [event, revision] = (await wakeSnapshot) as [ - { status: string }, - { revision: bigint }, - ]; + const [event, revision] = (await wakeSnapshot) as [{ status: string }, { revision: bigint }]; expect(event.status).toBe('SUCCEEDED'); expect(revision.revision).toBe(1n); expect(redisPublish).not.toHaveBeenCalled(); @@ -158,9 +152,7 @@ integration('API input event boundary', () => { } as unknown as GameApiContext; await expect( - journalBoundaryRouter - .createCaller(context) - .mutate({ generalId: journalGeneralIds[1], fail: true }) + journalBoundaryRouter.createCaller(context).mutate({ generalId: journalGeneralIds[1], fail: true }) ).rejects.toThrow('injected journal rollback'); await expect( @@ -257,21 +249,24 @@ integration('API input event boundary', () => { const transport = new DatabaseTurnDaemonTransport(db, 100); const requestId = 'integration:api:engine-child'; const acceptedWindowStart = Date.now(); - await transport.sendCommand({ type: 'vacation', requestId, generalId: 7 }); + await transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }); const acceptedWindowEnd = Date.now(); const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); + expect(event.actorUserId).toBe('user-7'); expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart); expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd); - await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 7 })).resolves.toBe(requestId); - await expect(transport.sendCommand({ type: 'vacation', requestId, generalId: 8 })).rejects.toBeInstanceOf( - ConflictingTurnDaemonCommandError - ); + await expect( + transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }) + ).resolves.toBe(requestId); + await expect( + transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 8 }) + ).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError); }); it('distinguishes a stored terminal engine failure from a result timeout', async () => { const transport = new DatabaseTurnDaemonTransport(db, 100); const requestId = 'integration:api:engine-failed'; - const command = { type: 'vacation' as const, requestId, generalId: 7 }; + const command = { type: 'vacation' as const, requestId, userId: 'user-7', generalId: 7 }; await transport.sendCommand(command); await db.inputEvent.update({ where: { requestId }, diff --git a/app/game-api/test/nationHtmlRouter.test.ts b/app/game-api/test/nationHtmlRouter.test.ts index 7a1cfd9c..e3a3fd13 100644 --- a/app/game-api/test/nationHtmlRouter.test.ts +++ b/app/game-api/test/nationHtmlRouter.test.ts @@ -73,8 +73,9 @@ const auth: GameSessionTokenPayload = { const buildContext = () => { const changeJournal = new ChangeJournal(); const requestCommand = vi.fn(async (command: unknown) => ({ - type: 'setNationMeta', + type: 'setNationSetting', ok: true, + nationId: 1, updatedAt: '2026-01-01T00:00:01.000Z', command, })); @@ -97,6 +98,7 @@ const buildContext = () => { battleSim: {} as GameApiContext['battleSim'], profile: { id: 'che', scenario: 'default', name: 'che:default' }, auth, + requestId: 'http-nation-html', changeJournal, uploadDir: 'uploads', uploadPath: '/uploads', @@ -131,29 +133,59 @@ describe('nation HTML API boundary', () => { }); expect(fixture.requestCommand).toHaveBeenCalledWith({ - type: 'setNationMeta', + type: 'setNationSetting', + requestId: `http-nation-html:nation.${procedure}:engine:0:setNationSetting`, + userId: 'user-1', + generalId: 1, nationId: 1, - updates: { - [metaKey]: msg, - }, - expectedUpdatedAt: undefined, + mutation: metaKey === 'notice' ? { kind: 'notice', message: msg } : { kind: 'scoutMessage', message: msg }, }); - expect(fixture.changeJournal.snapshot()).toEqual([ - { domain: 'dashboard.global', entityId: 0 }, - ...(procedure === 'setNotice' ? [{ domain: 'front.nation' as const, entityId: 1 }] : []), - { domain: 'nation.content', entityId: 1 }, - ]); + expect(fixture.changeJournal.snapshot()).toEqual([]); }); it.each(['setNotice', 'setScoutMsg'] as const)( - 'rejects an empty $procedure value like Ref required validation', + 'rejects empty and whitespace-only $procedure values like Ref required validation', async (procedure) => { - await expect(buildContext().caller.nation[procedure]({ msg: '' })).rejects.toMatchObject({ - code: 'BAD_REQUEST', - }); + for (const msg of ['', ' \t\n\v\0']) { + const fixture = buildContext(); + await expect(fixture.caller.nation[procedure]({ msg })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + } } ); + it.each(['setNotice', 'setScoutMsg'] as const)( + 'keeps PHP trim semantics for a Unicode-only $procedure value', + async (procedure) => { + const fixture = buildContext(); + await expect(fixture.caller.nation[procedure]({ msg: ' ' })).resolves.toMatchObject({ + ok: true, + msg: ' ', + }); + expect(fixture.requestCommand).toHaveBeenCalledOnce(); + } + ); + + it.each([ + ['setNotice', 'notice'], + ['setScoutMsg', 'scoutMessage'], + ] as const)('dispatches unsafe-only $procedure HTML as the empty string Ref persists', async (procedure, kind) => { + const fixture = buildContext(); + + await expect(fixture.caller.nation[procedure]({ msg: '' })).resolves.toEqual({ + ok: true, + msg: '', + }); + expect(fixture.requestCommand).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'setNationSetting', + mutation: { kind, message: '' }, + }) + ); + }); + it('purifies legacy stored values on every read resolver', () => { expect( resolveNationNotice({ diff --git a/app/game-api/test/nationPersonnelRouter.test.ts b/app/game-api/test/nationPersonnelRouter.test.ts index 59424490..611d12a7 100644 --- a/app/game-api/test/nationPersonnelRouter.test.ts +++ b/app/game-api/test/nationPersonnelRouter.test.ts @@ -140,9 +140,26 @@ describe('nation personnel router', () => { await caller.nation.changePermission({ isAmbassador: true, targetGeneralIds: [9] }); expect(requestCommand.mock.calls).toEqual([ - [{ type: 'appoint', generalId: 22, destGeneralId: 7, destCityId: 1, officerLevel: 4 }], - [{ type: 'kick', generalId: 22, destGeneralId: 8 }], - [{ type: 'changePermission', generalId: 22, isAmbassador: true, targetGeneralIds: [9] }], + [ + { + type: 'appoint', + userId: 'user-22', + generalId: 22, + destGeneralId: 7, + destCityId: 1, + officerLevel: 4, + }, + ], + [{ type: 'kick', userId: 'user-22', generalId: 22, destGeneralId: 8 }], + [ + { + type: 'changePermission', + userId: 'user-22', + generalId: 22, + isAmbassador: true, + targetGeneralIds: [9], + }, + ], ]); }); @@ -160,6 +177,7 @@ describe('nation personnel router', () => { expect(requestCommand).toHaveBeenCalledWith({ type: 'kick', requestId: 'http-nation-kick:nation.kick:engine:0:kick', + userId: 'user-22', generalId: 22, destGeneralId: 8, }); @@ -277,7 +295,7 @@ describe('nation personnel router', () => { }; const makeCommand = () => vi.fn(async () => ({ - type: 'setNationMeta', + type: 'setNationSetting', ok: true, nationId: 1, updatedAt: '2026-01-01T00:01:00.000Z', @@ -291,15 +309,13 @@ describe('nation personnel router', () => { .nation.setRate({ amount: 20 }) ).resolves.toEqual({ ok: true }); expect(headCommand).toHaveBeenCalledWith({ - type: 'setNationMeta', + type: 'setNationSetting', + userId: 'user-22', + generalId: 22, nationId: 1, - updates: { rate: 20 }, - expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + mutation: { kind: 'rate', amount: 20 }, }); - expect(changeJournal.snapshot()).toEqual([ - { domain: 'dashboard.global', entityId: 0 }, - { domain: 'nation.content', entityId: 1 }, - ]); + expect(changeJournal.snapshot()).toEqual([]); const ambassadorCommand = makeCommand(); const ambassador = { @@ -312,6 +328,13 @@ describe('nation personnel router', () => { .createCaller(createContext({ me: ambassador, db: nationDb, requestCommand: ambassadorCommand })) .nation.setRate({ amount: 25 }) ).resolves.toEqual({ ok: true }); + expect(ambassadorCommand).toHaveBeenCalledWith({ + type: 'setNationSetting', + userId: 'user-22', + generalId: 22, + nationId: 1, + mutation: { kind: 'rate', amount: 25 }, + }); const memberCommand = makeCommand(); const member = { ...baseGeneral, officerLevel: 1 }; diff --git a/app/game-api/test/nationSettingRouter.test.ts b/app/game-api/test/nationSettingRouter.test.ts new file mode 100644 index 00000000..bb7d57ca --- /dev/null +++ b/app/game-api/test/nationSettingRouter.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import type { RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import type { DatabaseClient, GameApiContext, GeneralRow } from '../src/context.js'; +import type { TurnDaemonTransport } from '../src/daemon/transport.js'; +import { appRouter } from '../src/router.js'; + +const general: GeneralRow = { + id: 71, + userId: 'authenticated-user', + name: '국가설정담당', + nationId: 3, + cityId: 1, + troopId: 0, + npcState: 0, + affinity: null, + bornYear: 180, + deadYear: 300, + picture: null, + imageServer: 0, + leadership: 50, + strength: 50, + intel: 50, + injury: 0, + experience: 0, + dedication: 0, + officerLevel: 5, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 0, + train: 0, + atmos: 0, + weaponCode: 'None', + bookCode: 'None', + horseCode: 'None', + itemCode: 'None', + turnTime: new Date('2026-01-01T00:00:00.000Z'), + recentWarTime: null, + age: 20, + startAge: 20, + personalCode: 'None', + specialCode: 'None', + special2Code: 'None', + lastTurn: {}, + meta: {}, + penalty: {}, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:default', + issuedAt: '2026-01-01T00:00:00.000Z', + expiresAt: '2026-01-02T00:00:00.000Z', + sessionId: 'session-setting', + user: { + id: 'authenticated-user', + username: 'tester', + displayName: 'Tester', + roles: [], + }, + sanctions: {}, +}; + +const buildContext = (authenticated = true) => { + const requestCommand = vi.fn(async (command: unknown) => { + const kind = (command as { mutation?: { kind?: string } }).mutation?.kind; + return { + type: 'setNationSetting' as const, + ok: true as const, + nationId: 3, + updatedAt: '2026-01-01T00:00:01.000Z', + ...(kind === 'blockWar' ? { availableCnt: 7 } : {}), + }; + }); + const transaction = vi.fn(async () => { + throw new Error('nation settings must not use an API input-event transaction'); + }); + const db = { + $transaction: transaction, + general: { + findFirst: vi.fn(async () => general), + }, + nation: { + findUnique: vi.fn(async () => ({ meta: {} })), + }, + }; + const redisClient = { + get: async () => null, + set: async () => null, + }; + const context: GameApiContext = { + db: db as unknown as DatabaseClient, + redis: {} as RedisConnector['client'], + turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, + battleSim: {} as GameApiContext['battleSim'], + profile: { id: 'che', scenario: 'default', name: 'che:default' }, + auth: authenticated ? auth : null, + requestId: 'http-setting-boundary', + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:default'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + }; + return { context, requestCommand, transaction }; +}; + +describe('nation setting router engine boundary', () => { + it('binds all semantic setting commands to the authenticated actor and a stable request id', async () => { + const fixture = buildContext(); + const caller = appRouter.createCaller(fixture.context); + + await expect(caller.nation.setNotice({ msg: '방침' })).resolves.toEqual({ + ok: true, + msg: '방침', + }); + await expect(caller.nation.setScoutMsg({ msg: '등용문' })).resolves.toEqual({ + ok: true, + msg: '등용문', + }); + await expect(caller.nation.setRate({ amount: 30 })).resolves.toEqual({ ok: true }); + await expect(caller.nation.setBill({ amount: 200 })).resolves.toEqual({ ok: true }); + await expect(caller.nation.setSecretLimit({ amount: 99 })).resolves.toEqual({ ok: true }); + await expect(caller.nation.setBlockWar({ value: false })).resolves.toEqual({ availableCnt: 7 }); + await expect(caller.nation.setBlockScout({ value: true })).resolves.toEqual({ ok: true }); + + expect(fixture.requestCommand.mock.calls.map(([command]) => command)).toEqual([ + { + type: 'setNationSetting', + requestId: 'http-setting-boundary:nation.setNotice:engine:0:setNationSetting', + userId: 'authenticated-user', + generalId: 71, + nationId: 3, + mutation: { kind: 'notice', message: '방침' }, + }, + { + type: 'setNationSetting', + requestId: 'http-setting-boundary:nation.setScoutMsg:engine:0:setNationSetting', + userId: 'authenticated-user', + generalId: 71, + nationId: 3, + mutation: { kind: 'scoutMessage', message: '등용문' }, + }, + { + type: 'setNationSetting', + requestId: 'http-setting-boundary:nation.setRate:engine:0:setNationSetting', + userId: 'authenticated-user', + generalId: 71, + nationId: 3, + mutation: { kind: 'rate', amount: 30 }, + }, + { + type: 'setNationSetting', + requestId: 'http-setting-boundary:nation.setBill:engine:0:setNationSetting', + userId: 'authenticated-user', + generalId: 71, + nationId: 3, + mutation: { kind: 'bill', amount: 200 }, + }, + { + type: 'setNationSetting', + requestId: 'http-setting-boundary:nation.setSecretLimit:engine:0:setNationSetting', + userId: 'authenticated-user', + generalId: 71, + nationId: 3, + mutation: { kind: 'secretLimit', amount: 99 }, + }, + { + type: 'setNationSetting', + requestId: 'http-setting-boundary:nation.setBlockWar:engine:0:setNationSetting', + userId: 'authenticated-user', + generalId: 71, + nationId: 3, + mutation: { kind: 'blockWar', value: false }, + }, + { + type: 'setNationSetting', + requestId: 'http-setting-boundary:nation.setBlockScout:engine:0:setNationSetting', + userId: 'authenticated-user', + generalId: 71, + nationId: 3, + mutation: { kind: 'blockScout', value: true }, + }, + ]); + expect(fixture.transaction).not.toHaveBeenCalled(); + }); + + it('rejects unauthenticated calls before querying or dispatching a setting command', async () => { + const fixture = buildContext(false); + + await expect(appRouter.createCaller(fixture.context).nation.setBill({ amount: 20 })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + expect(fixture.context.db.general.findFirst).not.toHaveBeenCalled(); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/app/game-api/test/npcPolicyRouter.test.ts b/app/game-api/test/npcPolicyRouter.test.ts index 052f35df..e81c013d 100644 --- a/app/game-api/test/npcPolicyRouter.test.ts +++ b/app/game-api/test/npcPolicyRouter.test.ts @@ -105,7 +105,7 @@ const createContext = ( const requestCommand = options.requestCommand ?? vi.fn(async () => ({ - type: 'setNationMeta', + type: 'setNpcPolicy', ok: true, nationId: 1, updatedAt: '2026-01-01T00:01:00.000Z', @@ -161,46 +161,43 @@ describe('NPC policy router', () => { }); }); - it('lets a secret-level reader load the page but rejects every mutation before daemon dispatch', async () => { + it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => { const reader = { ...baseGeneral, officerLevel: 2 }; - const fixture = createContext({ me: reader }); + const requestCommand = vi.fn(async () => ({ + type: 'setNpcPolicy' as const, + ok: false as const, + code: 'FORBIDDEN' as const, + reason: '권한이 부족합니다.', + nationId: 1, + })); + const fixture = createContext({ me: reader, requestCommand }); const caller = appRouter.createCaller(fixture.context); await expect(caller.npc.getPolicy()).resolves.toMatchObject({ permissionLevel: 1 }); await expect(caller.npc.setNationPriority(['천도'])).rejects.toMatchObject({ code: 'FORBIDDEN' }); - await expect(caller.npc.setGeneralPriority(['출병', '일반내정'])).rejects.toMatchObject({ - code: 'FORBIDDEN', - }); - await expect(caller.npc.setNationPolicy({ reqNationGold: 100 })).rejects.toMatchObject({ - code: 'FORBIDDEN', - }); - expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.requestCommand).toHaveBeenCalledOnce(); }); it.each([ ['군주', { ...baseGeneral, officerLevel: 12 }], ['감찰권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'auditor' } }], ['외교권자', { ...baseGeneral, officerLevel: 1, meta: { belong: 0, permission: 'ambassador' } }], - ])('%s can persist policy through the daemon-owned metadata command', async (_label, me) => { + ])('%s dispatches actor-bound policy intent to the daemon', async (_label, me) => { const fixture = createContext({ me }); await expect(appRouter.createCaller(fixture.context).npc.setNationPriority(['천도', '천도'])).resolves.toEqual({ ok: true, }); expect(fixture.requestCommand).toHaveBeenCalledWith({ - type: 'setNationMeta', + type: 'setNpcPolicy', + userId: 'user-22', + generalId: 22, nationId: 1, - updates: { - npc_nation_policy: expect.objectContaining({ - priority: ['천도', '천도'], - prioritySetter: '정책담당', - prioritySetTime: expect.any(String), - }), - }, expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + mutation: { kind: 'nationPriority', priority: ['천도', '천도'] }, }); }); - it('clamps legacy integer values, preserves float values, and validates troop ownership before dispatch', async () => { + it('forwards raw policy intent so ENGINE can validate current troop and city state atomically', async () => { const fixture = createContext(); const caller = appRouter.createCaller(fixture.context); @@ -211,43 +208,29 @@ describe('NPC policy router', () => { }); expect(fixture.requestCommand).toHaveBeenCalledWith( expect.objectContaining({ - updates: { - npc_nation_policy: expect.objectContaining({ - values: expect.objectContaining({ - reqNationGold: 0, - safeRecruitCityPopulationRatio: -0.5, - CombatForce: { 101: [1, 2] }, - }), - }), + type: 'setNpcPolicy', + mutation: { + kind: 'nationPolicy', + values: { + reqNationGold: -100, + safeRecruitCityPopulationRatio: -0.5, + CombatForce: { 101: [1, 2] }, + }, }, }) ); - - fixture.requestCommand.mockClear(); - await expect(caller.npc.setNationPolicy({ SupportForce: [999] })).rejects.toMatchObject({ - code: 'BAD_REQUEST', - }); - expect(fixture.requestCommand).not.toHaveBeenCalled(); }); - it('preserves duplicate legacy priority entries and enforces required general actions and ordering', async () => { + it('preserves duplicate priority entries in the dispatched intent', async () => { const fixture = createContext(); const caller = appRouter.createCaller(fixture.context); await caller.npc.setGeneralPriority(['출병', '출병', '일반내정']); expect(fixture.requestCommand).toHaveBeenCalledWith( expect.objectContaining({ - updates: { - npc_general_policy: expect.objectContaining({ - priority: ['출병', '출병', '일반내정'], - }), - }, + mutation: { kind: 'generalPriority', priority: ['출병', '출병', '일반내정'] }, }) ); - await expect(caller.npc.setGeneralPriority(['일반내정', '출병'])).rejects.toMatchObject({ - code: 'BAD_REQUEST', - }); - await expect(caller.npc.setGeneralPriority(['출병'])).rejects.toMatchObject({ code: 'BAD_REQUEST' }); }); it('blocks nationless, penalized, and stale writers without changing lifecycle state directly', async () => { @@ -262,10 +245,11 @@ describe('NPC policy router', () => { }); const staleCommand = vi.fn(async () => ({ - type: 'setNationMeta', + type: 'setNpcPolicy', ok: false, + code: 'CONFLICT', nationId: 1, - reason: 'CONFLICT', + reason: '다른 사용자가 정책을 변경했습니다.', })); const stale = createContext({ requestCommand: staleCommand }); await expect(appRouter.createCaller(stale.context).npc.setNationPriority(['천도'])).rejects.toMatchObject({ diff --git a/app/game-api/test/requestId.test.ts b/app/game-api/test/requestId.test.ts new file mode 100644 index 00000000..be0b67e6 --- /dev/null +++ b/app/game-api/test/requestId.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { scopeHttpIdempotencyKey } from '../src/requestId.js'; + +describe('HTTP idempotency request IDs', () => { + it('is stable for one principal and isolated across users and profiles', () => { + const first = scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-a' }); + expect(first).toBe(scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-a' })); + expect(first).not.toBe( + scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'hwe', userId: 'user-b' }) + ); + expect(first).not.toBe( + scopeHttpIdempotencyKey({ rawKey: 'same-client-key', profileId: 'che', userId: 'user-a' }) + ); + }); + + it('bounds and neutralizes untrusted header content while omitting blank keys', () => { + expect(scopeHttpIdempotencyKey({ rawKey: ' \n\t ', profileId: 'hwe', userId: 'user-a' })).toBeUndefined(); + const scoped = scopeHttpIdempotencyKey({ + rawKey: `${'x'.repeat(10_000)}:../../unexpected`, + profileId: 'hwe', + userId: null, + }); + expect(scoped).toMatch(/^http:[0-9a-f]{64}$/u); + expect(scoped).toHaveLength(69); + }); +}); diff --git a/app/game-api/test/scoutBlockRouter.test.ts b/app/game-api/test/scoutBlockRouter.test.ts index 89dccae9..2ee01be9 100644 --- a/app/game-api/test/scoutBlockRouter.test.ts +++ b/app/game-api/test/scoutBlockRouter.test.ts @@ -67,8 +67,14 @@ const auth: GameSessionTokenPayload = { sanctions: {}, }; -const buildContext = (blockChangeScout: boolean) => { - const requestCommand = vi.fn(); +const buildContext = () => { + const requestCommand = vi.fn(async () => ({ + type: 'setNationSetting' as const, + ok: false as const, + code: 'FORBIDDEN' as const, + nationId: 1, + reason: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.', + })); const db = { general: { findFirst: vi.fn(async () => general), @@ -77,7 +83,7 @@ const buildContext = (blockChangeScout: boolean) => { findUnique: vi.fn(async () => ({ meta: {} })), }, worldState: { - findFirst: vi.fn(async () => ({ meta: { block_change_scout: blockChangeScout } })), + findFirst: vi.fn(async () => ({ meta: { block_change_scout: true } })), }, }; const redisClient = { @@ -102,14 +108,21 @@ const buildContext = (blockChangeScout: boolean) => { }; describe('nation scout policy lock', () => { - it('rejects policy changes before daemon dispatch while the scenario lock is enabled', async () => { - const fixture = buildContext(true); - await expect(appRouter.createCaller(fixture.context).nation.setBlockScout({ value: false })).rejects.toMatchObject( - { - code: 'FORBIDDEN', - message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.', - } - ); - expect(fixture.requestCommand).not.toHaveBeenCalled(); + it('lets the engine recheck the scenario lock and maps its rejection', async () => { + const fixture = buildContext(); + await expect( + appRouter.createCaller(fixture.context).nation.setBlockScout({ value: false }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.', + }); + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'setNationSetting', + userId: 'user-1', + generalId: 1, + nationId: 1, + mutation: { kind: 'blockScout', value: false }, + }); + expect(fixture.context.db.worldState.findFirst).not.toHaveBeenCalled(); }); }); diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index eadea405..5f6820c2 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -16,6 +16,7 @@ import { } from '@sammo-ts/infra'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { scopeHttpIdempotencyKey } from '../src/requestId.js'; import { createGameApiServer } from '../src/server.js'; import { WebPushOutboxWorker } from '../src/services/webPushOutboxWorker.js'; @@ -41,6 +42,12 @@ const foreignNationId = 99_002; const fixtureNationIds = [ownerNationId, foreignNationId]; const fixtureWorldId = 990_001; const mutationRequestPrefix = `security-http-matrix-${process.pid}-`; +const matrixApiEventTypes = [ + 'messages.send', + 'turns.reserved.setGeneral', + 'turns.reserved.setNation', +] as const; +const fixtureActorUserIds = [userId, noGeneralUserId, sameNationUserId, foreignUserId, ordinaryUserId]; const secret = 'security-http-e2e-secret'; const redisPrefix = `sammo:security-http:${process.pid}`; const envKeys = [ @@ -303,7 +310,7 @@ const readReservedMutationState = async () => ({ orderBy: { id: 'asc' }, }), engineInputEvents: await db.inputEvent.findMany({ - where: { target: 'ENGINE', requestId: { startsWith: mutationRequestPrefix } }, + where: { target: 'ENGINE', actorUserId: { in: fixtureActorUserIds } }, select: { requestId: true, eventType: true, status: true, actorUserId: true }, orderBy: { sequence: 'asc' }, }), @@ -315,6 +322,31 @@ const readReservedMutationState = async () => ({ const quotePostgresIdentifier = (value: string): string => `"${value.replaceAll('"', '""')}"`; +const isMatrixApiInputEvent = (rowJson: string): boolean => { + const row = JSON.parse(rowJson) as { target?: unknown; event_type?: unknown }; + return row.target === 'API' && matrixApiEventTypes.includes(row.event_type as (typeof matrixApiEventTypes)[number]); +}; + +const deleteMatrixInputEvents = async (): Promise => { + await db.inputEvent.deleteMany({ + where: { + OR: [ + { requestId: { startsWith: mutationRequestPrefix } }, + { target: 'API', eventType: { in: [...matrixApiEventTypes] } }, + { target: 'ENGINE', actorUserId: { in: fixtureActorUserIds } }, + ], + }, + }); +}; + +const resolveScopedApiRequestId = (idempotencyKey: string, procedure: string, actorUserId: string): string => { + const scopedRequestId = scopeHttpIdempotencyKey({ rawKey: idempotencyKey, profileId, userId: actorUserId }); + if (!scopedRequestId) { + throw new Error('matrix idempotency key unexpectedly resolved to an empty request ID'); + } + return `${scopedRequestId}:${procedure}`; +}; + const readDurableSchemaStateExcludingMatrixApiJournal = async () => { const tables = await db.$queryRawUnsafe>( `SELECT table_name AS "tableName" @@ -326,16 +358,17 @@ const readDurableSchemaStateExcludingMatrixApiJournal = async () => { return Promise.all( tables.map(async ({ tableName }) => { const qualifiedTable = `${quotePostgresIdentifier(profileId)}.${quotePostgresIdentifier(tableName)}`; - const matrixApiFilter = - tableName === 'input_event' ? `WHERE NOT (target = 'API' AND request_id LIKE $1)` : ''; const rows = await db.$queryRawUnsafe>( `SELECT to_jsonb(snapshot_row)::text AS "rowJson" FROM ${qualifiedTable} AS snapshot_row - ${matrixApiFilter} - ORDER BY to_jsonb(snapshot_row)::text`, - ...(matrixApiFilter ? [`${mutationRequestPrefix}%`] : []) + ORDER BY to_jsonb(snapshot_row)::text` ); - return { tableName, rows: rows.map(({ rowJson }) => rowJson) }; + return { + tableName, + rows: rows + .map(({ rowJson }) => rowJson) + .filter((rowJson) => tableName !== 'input_event' || !isMatrixApiInputEvent(rowJson)), + }; }) ); }; @@ -389,12 +422,11 @@ const expectApiInputEvent = async ( procedure: string, expected: { actorUserId: string; status: 'FAILED' | 'SUCCEEDED' } | null ): Promise => { - const requestId = `${idempotencyKey}:${procedure}`; const events = await db.inputEvent.findMany({ - // beforeEach removes the whole matrix prefix. Query that complete - // namespace so an extra/rewritten API journal row cannot hide behind - // the full-schema snapshot's one explicitly allowed exclusion. - where: { target: 'API', requestId: { startsWith: mutationRequestPrefix } }, + // The HTTP boundary hashes the raw client key together with profile and + // actor. Query the whole procedure matrix so an unexpected extra row + // cannot hide behind the full-schema snapshot's explicit exclusion. + where: { target: 'API', eventType: { in: [...matrixApiEventTypes] } }, select: { requestId: true, target: true, @@ -417,6 +449,7 @@ const expectApiInputEvent = async ( expect(events).toEqual([]); return; } + const requestId = resolveScopedApiRequestId(idempotencyKey, procedure, expected.actorUserId); expect(events).toEqual([ { requestId, @@ -638,7 +671,7 @@ integration('game API security over HTTP transport', () => { afterAll(async () => { await server?.app.close(); await closeGatewayStatusStub(); - await db?.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } }); + if (db) await deleteMatrixInputEvents(); await db?.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db?.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } }); await db?.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); @@ -670,7 +703,7 @@ integration('game API security over HTTP transport', () => { }, 30_000); beforeEach(async () => { - await db.inputEvent.deleteMany({ where: { requestId: { startsWith: mutationRequestPrefix } } }); + await deleteMatrixInputEvents(); await db.trafficPeriodGeneral.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); await db.trafficPeriod.deleteMany({ where: { worldStateId: fixtureWorldId } }); await db.generalAccessLog.deleteMany({ where: { generalId: { in: fixtureGeneralIds } } }); @@ -1055,7 +1088,7 @@ integration('game API security over HTTP transport', () => { }); expect( await db.inputEvent.count({ - where: { target: 'ENGINE', requestId: { startsWith: idempotencyKey } }, + where: { target: 'ENGINE', actorUserId: userId }, }) ).toBe(0); await expect.poll(() => db.readModelOutbox.count()).toBe(1); @@ -1513,8 +1546,9 @@ integration('game API security over HTTP transport', () => { expect(await readRealtimeRedisState()).toEqual(redisBefore); const replayDurableBefore = await readDurableSchemaStateExcludingMatrixApiJournal(); const replayRedisBefore = await readRealtimeRedisState(); + const replayRequestId = resolveScopedApiRequestId(idempotencyKey, 'turns.reserved.setNation', userId); const replayJournalBefore = await db.inputEvent.findUniqueOrThrow({ - where: { requestId: `${idempotencyKey}:turns.reserved.setNation` }, + where: { requestId: replayRequestId }, }); const replay = await requestReservedNation(accessToken, idempotencyKey, generalId); expect(replay.response.status).toBe(409); @@ -1524,12 +1558,10 @@ integration('game API security over HTTP transport', () => { expect(await readRealtimeRedisState()).toEqual(replayRedisBefore); expect( await db.inputEvent.findUniqueOrThrow({ - where: { requestId: `${idempotencyKey}:turns.reserved.setNation` }, + where: { requestId: replayRequestId }, }) ).toEqual(replayJournalBefore); - expect(await db.inputEvent.count({ where: { requestId: `${idempotencyKey}:turns.reserved.setNation` } })).toBe( - 1 - ); + expect(await db.inputEvent.count({ where: { requestId: replayRequestId } })).toBe(1); await expectApiInputEvent(idempotencyKey, 'turns.reserved.setNation', { actorUserId: userId, status: 'SUCCEEDED', diff --git a/app/game-api/test/tournamentStoreRevision.test.ts b/app/game-api/test/tournamentStoreRevision.test.ts index eaa9bbb4..5cf3bbfd 100644 --- a/app/game-api/test/tournamentStoreRevision.test.ts +++ b/app/game-api/test/tournamentStoreRevision.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildTournamentKeys } from '../src/tournament/keys.js'; -import { TournamentStore } from '../src/tournament/store.js'; +import { CorruptTournamentProjectionError, TournamentStore } from '../src/tournament/store.js'; class AtomicMemoryRedis { readonly events: string[] = []; @@ -130,4 +130,114 @@ describe('TournamentStore source revision', () => { await expect(store.getSourceRevision()).resolves.toBe('50'); expect(redis.published).toHaveLength(100); }); + + it('distinguishes missing projections from malformed JSON and invalid shapes', async () => { + const redis = new AtomicMemoryRedis(); + const keys = buildTournamentKeys('corrupt:default'); + const store = new TournamentStore(redis, keys); + + await expect(store.getParticipants()).resolves.toEqual([]); + await redis.set(keys.participantsKey, '{broken'); + await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError); + await redis.set(keys.participantsKey, JSON.stringify([{ id: 'not-a-number' }])); + await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError); + }); + + it('rejects corrupt settlement flags instead of silently skipping tournament settlement', async () => { + const redis = new AtomicMemoryRedis(); + const keys = buildTournamentKeys('corrupt-settlement:default'); + const store = new TournamentStore(redis, keys); + const canonicalState = { + stage: 0, + phase: 0, + type: 0, + auto: false, + openYear: 185, + openMonth: 2, + termSeconds: 300, + nextAt: '2026-08-17T00:00:00.000Z', + bettingId: 123, + bettingCloseAt: '2026-08-17T00:05:00.000Z', + winnerId: 7, + bettingSettled: false, + rewardSettled: false, + participantsLockedAt: '2026-08-17T00:01:00.000Z', + lastError: 'retryable fixture', + lastErrorAt: '2026-08-17T00:02:00.000Z', + }; + + await redis.set(keys.stateKey, JSON.stringify(canonicalState)); + await expect(store.getState()).resolves.toEqual(canonicalState); + + for (const [field, value] of [ + ['winnerId', '7'], + ['bettingId', '123'], + ['rewardSettled', 'yes'], + ['bettingSettled', 1], + ] as const) { + await redis.set(keys.stateKey, JSON.stringify({ ...canonicalState, [field]: value })); + await expect(store.getState(), field).rejects.toBeInstanceOf(CorruptTournamentProjectionError); + } + }); + + it('validates known optional participant and match projection fields', async () => { + const redis = new AtomicMemoryRedis(); + const keys = buildTournamentKeys('corrupt-optional:default'); + const store = new TournamentStore(redis, keys); + const participant = { + id: 7, + name: '관우', + leadership: 90, + strength: 97, + intel: 75, + level: 5, + groupId: 0, + groupNo: 1, + win: 2, + draw: 1, + lose: 0, + gl: 10, + seedRank: 1, + finalRank: 2, + preliminaryGroupId: 0, + preliminaryGroupNo: 1, + preliminaryRank: 2, + preliminaryWin: 2, + preliminaryDraw: 1, + preliminaryLose: 0, + preliminaryGl: 10, + }; + const match = { + id: 1, + stage: 7, + roundIndex: 0, + groupId: 0, + attackerId: 7, + defenderId: 8, + winnerId: 7, + log: ['결과'], + logEntries: [ + { + phase: 1, + attackerEnergy: 90, + defenderEnergy: 0, + attackerDamage: 10, + defenderDamage: 100, + text: '결과', + }, + ], + lastEnergy: { attacker: 90, defender: 0 }, + }; + + await redis.set(keys.participantsKey, JSON.stringify([participant])); + await redis.set(keys.matchesKey, JSON.stringify([match])); + await expect(store.getParticipants()).resolves.toEqual([participant]); + await expect(store.getMatches()).resolves.toEqual([match]); + + await redis.set(keys.participantsKey, JSON.stringify([{ ...participant, win: '2' }])); + await expect(store.getParticipants()).rejects.toBeInstanceOf(CorruptTournamentProjectionError); + await redis.set(keys.matchesKey, JSON.stringify([{ ...match, lastEnergy: { attacker: '90', defender: 0 } }])); + await expect(store.getMatches()).rejects.toBeInstanceOf(CorruptTournamentProjectionError); + }); + }); diff --git a/app/game-api/test/troopRouter.test.ts b/app/game-api/test/troopRouter.test.ts index c2c40987..3516194c 100644 --- a/app/game-api/test/troopRouter.test.ts +++ b/app/game-api/test/troopRouter.test.ts @@ -295,6 +295,7 @@ describe('troop router permissions and mutations', () => { }); expect(requestCommand).toHaveBeenCalledWith({ type: 'troopCreate', + userId: 'user-1', generalId: 1, troopName: '백마대', }); @@ -317,11 +318,30 @@ describe('troop router permissions and mutations', () => { expect(requestCommand).toHaveBeenCalledWith({ type: 'troopCreate', requestId: 'http-troop-create:troop.create:engine:0:troopCreate', + userId: 'user-1', generalId: 1, troopName: '백마대', }); }); + it('maps an ENGINE actor-binding rejection to a forbidden API response', async () => { + const fixture = buildContext({ + result: { + type: 'commandRejected', + ok: false, + commandType: 'troopCreate', + reason: '명령 수행 장수의 현재 소유자가 일치하지 않습니다.', + }, + }); + + await expect( + appRouter.createCaller(fixture.context).troop.create({ troopName: '백마대' }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: '명령 수행 장수의 현재 소유자가 일치하지 않습니다.', + }); + }); + it('rejects troop creation before daemon dispatch when already assigned or the name is blank', async () => { const assigned = buildContext({ me: buildGeneral({ troopId: 9 }), @@ -372,6 +392,7 @@ describe('troop router permissions and mutations', () => { ).resolves.toEqual({ ok: true }); expect(authorized.requestCommand).toHaveBeenCalledWith({ type: 'troopKick', + userId: 'user-1', generalId: 1, troopId: 1, targetGeneralId: 3, diff --git a/app/game-api/test/voteRouter.test.ts b/app/game-api/test/voteRouter.test.ts index 5305d450..f958a6a3 100644 --- a/app/game-api/test/voteRouter.test.ts +++ b/app/game-api/test/voteRouter.test.ts @@ -97,6 +97,7 @@ const buildContext = (options: { metaDevelCost?: number; auctionTargets?: string[]; clockTick?: number; + requestId?: string; }) => { const auth = options.auth === undefined ? buildAuth() : options.auth; const general = options.general === undefined ? buildGeneral() : options.general; @@ -202,6 +203,7 @@ const buildContext = (options: { battleSim: {} as GameApiContext['battleSim'], profile: { id: 'che', scenario: 'default', name: 'che:default' }, auth, + ...(options.requestId ? { requestId: options.requestId } : {}), uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, @@ -247,13 +249,15 @@ describe('vote router actor and permission boundaries', () => { it('uses only the general owned by the authenticated user for voting and reward dispatch', async () => { const owned = buildGeneral({ id: 7, userId: 'user-1', name: '유비' }); - const fixture = buildContext({ general: owned, clockTick: 100 }); + const fixture = buildContext({ general: owned, clockTick: 100, requestId: 'http-vote-submit' }); await expect( appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] }) ).resolves.toEqual({ ok: true, wonLottery: false }); expect(fixture.requestCommand).toHaveBeenCalledWith({ type: 'voteReward', + requestId: 'http-vote-submit:vote.submitVote:engine:0:voteReward', + userId: 'user-1', voteId: 1, generalId: 7, selection: [0], diff --git a/app/game-engine/package.json b/app/game-engine/package.json index 873263a6..bf4694dc 100644 --- a/app/game-engine/package.json +++ b/app/game-engine/package.json @@ -58,6 +58,10 @@ "types": "./dist/turn/monthlyNationBettingAction.d.ts", "default": "./dist/turn/monthlyNationBettingAction.js" }, + "./turn/npcPolicyMutation.js": { + "types": "./dist/turn/npcPolicyMutation.d.ts", + "default": "./dist/turn/npcPolicyMutation.js" + }, "./turn/npcPossessionService.js": { "types": "./dist/turn/npcPossessionService.d.ts", "default": "./dist/turn/npcPossessionService.js" diff --git a/app/game-engine/src/turn/actionableMessageResponse.ts b/app/game-engine/src/turn/actionableMessageResponse.ts index 542de5c1..acaf4bf5 100644 --- a/app/game-engine/src/turn/actionableMessageResponse.ts +++ b/app/game-engine/src/turn/actionableMessageResponse.ts @@ -1,4 +1,4 @@ -import { asNumber, asRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; +import { asNumber, asRecord, isRecord, JosaUtil, LiteHashDRBG, RandUtil } from '@sammo-ts/common'; import { GamePrisma } from '@sammo-ts/infra'; import { LogCategory, LogFormat, LogScope, type MessageDraft, type MessagePayload } from '@sammo-ts/logic'; @@ -15,6 +15,7 @@ interface MessageRow { id: number; mailbox: number; type: string; + time: Date; validUntil: Date; message: unknown; } @@ -25,8 +26,31 @@ export interface ActionableMessageResponseResult { reason: string; } -const parsePayload = (value: unknown): MessagePayload => - (typeof value === 'string' ? JSON.parse(value) : value) as MessagePayload; +const parsePayload = (value: unknown): MessagePayload | null => { + let parsed: unknown; + try { + parsed = typeof value === 'string' ? JSON.parse(value) : value; + } catch { + return null; + } + const payload = asRecord(parsed); + const src = asRecord(payload.src); + const dest = asRecord(payload.dest); + const isTarget = (target: Record): boolean => + Number.isSafeInteger(target.generalId) && + Number.isSafeInteger(target.nationId) && + typeof target.generalName === 'string' && + typeof target.nationName === 'string' && + typeof target.color === 'string' && + typeof target.icon === 'string'; + if (!isTarget(src) || !isTarget(dest) || typeof payload.text !== 'string') { + return null; + } + if (payload.option !== undefined && payload.option !== null && !isRecord(payload.option)) { + return null; + } + return payload as unknown as MessagePayload; +}; const systemTarget: MessageDraft['src'] = { generalId: 0, @@ -37,6 +61,16 @@ const systemTarget: MessageDraft['src'] = { icon: '', }; +const isLegacyTruthy = (value: unknown): boolean => { + if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') { + return false; + } + if (Array.isArray(value)) { + return value.length > 0; + } + return true; +}; + const queuePrivateNotice = ( world: InMemoryTurnWorld, destination: MessagePayload['dest'], @@ -103,7 +137,7 @@ const fetchMessageForUpdate = async ( ): Promise => { const currentTick = BigInt(world.dateToGameTick(now)); const rows = await db.$queryRaw(GamePrisma.sql` - SELECT id, mailbox, type, valid_until AS "validUntil", message + SELECT id, mailbox, type, time, valid_until AS "validUntil", message FROM message WHERE id = ${messageId} AND ( @@ -130,7 +164,7 @@ const respondToScout = async (options: { if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) { return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' }; } - if (asRecord(payload.option).used === true) { + if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) { return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' }; } @@ -280,6 +314,7 @@ export const respondToActionableMessage = async (options: { const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now); if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' }; const payload = parsePayload(row.message); + if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' }; const action = asRecord(payload.option).action; if (action === 'scout') { return await respondToScout({ ...options, actorId: options.generalId, row, payload, now }); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 4f15dad3..7c3110a8 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -25,6 +25,8 @@ const parseWith = (schema: z.ZodType, value: unknown): T | null => { const zFiniteNumber = z.number().finite(); const zSafeInteger = zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER); const zRecord = z.record(z.string(), z.unknown()); +const zBoundedCodePointText = (maximumCodePoints: number) => + z.string().refine((value) => Array.from(value).length <= maximumCodePoints); const zRunReason = z.enum(['schedule', 'manual', 'poke']); const zTurnRunBudget = z.object({ @@ -42,6 +44,7 @@ const zAuctionFinalize = z.object({ const zAuctionOpen = z.object({ type: z.literal('auctionOpen'), + userId: z.string().min(1), generalId: zFiniteNumber, auctionType: z.enum(['BUY_RICE', 'SELL_RICE', 'UNIQUE_ITEM']), amount: zFiniteNumber, @@ -53,6 +56,7 @@ const zAuctionOpen = z.object({ const zAuctionBid = z.object({ type: z.literal('auctionBid'), + userId: z.string().min(1), auctionId: zFiniteNumber, generalId: zFiniteNumber, amount: zFiniteNumber, @@ -62,23 +66,27 @@ const zAuctionBid = z.object({ const zTroopJoin = z.object({ type: z.literal('troopJoin'), + userId: z.string().min(1), generalId: zFiniteNumber, troopId: zFiniteNumber, }); const zTroopCreate = z.object({ type: z.literal('troopCreate'), + userId: z.string().min(1), generalId: zFiniteNumber, troopName: z.string(), }); const zTroopExit = z.object({ type: z.literal('troopExit'), + userId: z.string().min(1), generalId: zFiniteNumber, }); const zTroopKick = z.object({ type: z.literal('troopKick'), + userId: z.string().min(1), generalId: zFiniteNumber, troopId: zFiniteNumber, targetGeneralId: zFiniteNumber, @@ -86,6 +94,7 @@ const zTroopKick = z.object({ const zTroopRename = z.object({ type: z.literal('troopRename'), + userId: z.string().min(1), generalId: zFiniteNumber, troopId: zFiniteNumber, troopName: z.string(), @@ -125,11 +134,13 @@ const zMessageRespond = z.object({ const zVacation = z.object({ type: z.literal('vacation'), + userId: z.string().min(1), generalId: zFiniteNumber, }); const zSetMySetting = z.object({ type: z.literal('setMySetting'), + userId: z.string().min(1), generalId: zFiniteNumber, settings: z.object({ tnmt: z.number().int().optional(), @@ -146,25 +157,30 @@ const zSetMySetting = z.object({ const zDropItem = z.object({ type: z.literal('dropItem'), + userId: z.string().min(1), generalId: zFiniteNumber, - itemType: z.string().min(1), + itemType: z.enum(['horse', 'weapon', 'book', 'item']), }); const zChangePermission = z.object({ type: z.literal('changePermission'), + userId: z.string().min(1), generalId: zFiniteNumber, isAmbassador: z.boolean(), - targetGeneralIds: z.array(zFiniteNumber).min(1), + // Ref uses an empty selection to clear every holder of the role. + targetGeneralIds: z.array(zFiniteNumber), }); const zKick = z.object({ type: z.literal('kick'), + userId: z.string().min(1), generalId: zFiniteNumber, destGeneralId: zFiniteNumber, }); const zAppoint = z.object({ type: z.literal('appoint'), + userId: z.string().min(1), generalId: zFiniteNumber, destGeneralId: zFiniteNumber, destCityId: zFiniteNumber, @@ -198,17 +214,42 @@ const zTournamentReward = z.object({ const zVoteReward = z.object({ type: z.literal('voteReward'), + userId: z.string().min(1), voteId: zFiniteNumber, generalId: zFiniteNumber, selection: z.array(zFiniteNumber.int()).min(1), acceptedGameTick: zSafeInteger.optional(), }); -const zSetNationMeta = z.object({ - type: z.literal('setNationMeta'), +const zSetNationSetting = z.object({ + type: z.literal('setNationSetting'), + userId: z.string().min(1), + generalId: zFiniteNumber, nationId: zFiniteNumber, - updates: zRecord, - expectedUpdatedAt: z.string().optional(), + mutation: z.discriminatedUnion('kind', [ + // Ref validates required content before HTML purification. A raw, + // non-empty value can therefore become an empty persisted string. + z.object({ kind: z.literal('notice'), message: zBoundedCodePointText(16_384) }), + z.object({ kind: z.literal('scoutMessage'), message: zBoundedCodePointText(1_000) }), + z.object({ kind: z.literal('rate'), amount: z.number().int().min(5).max(30) }), + z.object({ kind: z.literal('bill'), amount: z.number().int().min(20).max(200) }), + z.object({ kind: z.literal('secretLimit'), amount: z.number().int().min(1).max(99) }), + z.object({ kind: z.literal('blockWar'), value: z.boolean() }), + z.object({ kind: z.literal('blockScout'), value: z.boolean() }), + ]), +}); + +const zSetNpcPolicy = z.object({ + type: z.literal('setNpcPolicy'), + userId: z.string().min(1), + generalId: zFiniteNumber, + nationId: zFiniteNumber, + expectedUpdatedAt: z.string().nullable(), + mutation: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('nationPolicy'), values: zRecord }), + z.object({ kind: z.literal('nationPriority'), priority: z.array(z.string()) }), + z.object({ kind: z.literal('generalPriority'), priority: z.array(z.string()) }), + ]), }); const zAdjustGeneralResources = z.object({ @@ -640,8 +681,16 @@ const normalizeVoteReward: CommandNormalizer<'voteReward'> = (envelope) => { return { ...command, requestId: envelope.requestId }; }; -const normalizeSetNationMeta: CommandNormalizer<'setNationMeta'> = (envelope) => { - const command = parseWith(zSetNationMeta, envelope.command); +const normalizeSetNationSetting: CommandNormalizer<'setNationSetting'> = (envelope) => { + const command = parseWith(zSetNationSetting, envelope.command); + if (!command) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + +const normalizeSetNpcPolicy: CommandNormalizer<'setNpcPolicy'> = (envelope) => { + const command = parseWith(zSetNpcPolicy, envelope.command); if (!command) { return null; } @@ -804,7 +853,8 @@ const normalizers: CommandNormalizerMap = { tournamentBettingPayout: normalizeTournamentBettingPayout, tournamentReward: normalizeTournamentReward, voteReward: normalizeVoteReward, - setNationMeta: normalizeSetNationMeta, + setNationSetting: normalizeSetNationSetting, + setNpcPolicy: normalizeSetNpcPolicy, adjustGeneralResources: normalizeAdjustGeneralResources, adjustGeneralMeta: normalizeAdjustGeneralMeta, tournamentMatchResult: normalizeTournamentMatchResult, diff --git a/app/game-engine/src/turn/nationSettingMutation.ts b/app/game-engine/src/turn/nationSettingMutation.ts new file mode 100644 index 00000000..f2cb879b --- /dev/null +++ b/app/game-engine/src/turn/nationSettingMutation.ts @@ -0,0 +1,171 @@ +import { createHash } from 'node:crypto'; + +import { asRecord, formatServerDateTime, type TurnDaemonCommand, type TurnDaemonCommandResult } from '@sammo-ts/common'; +import { resolveTroopSecretPermission } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; + +type SetNationSettingCommand = Extract; +type SetNationSettingResult = Extract; + +const MAX_AVAILABLE_WAR_SETTING_COUNT = 10; + +const reject = ( + code: Extract['code'], + reason: string, + nationId?: number +): SetNationSettingResult => ({ + type: 'setNationSetting', + ok: false, + code, + reason, + ...(nationId === undefined ? {} : { nationId }), +}); + +const readInteger = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.floor(value); + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.floor(parsed) : null; + } + return null; +}; + +const readWarSettingRemain = (meta: Record): number => { + const legacy = readInteger(meta.available_war_setting_cnt); + const migrated = readInteger(meta.availableWarSettingCnt); + // Ref treats an absent counter as zero. The monthly refill creates it; + // a missing legacy migration value must not grant ten extra changes. + const value = legacy ?? migrated ?? 0; + return Math.max(0, Math.min(MAX_AVAILABLE_WAR_SETTING_COUNT, value)); +}; + +const isLegacyTruthy = (value: unknown): boolean => { + if (value === undefined || value === null || value === false || value === 0 || value === '' || value === '0') { + return false; + } + if (Array.isArray(value)) { + return value.length > 0; + } + return true; +}; + +const buildRevision = (acceptedAt: Date, requestId: string): string => { + const suffix = createHash('sha256').update(requestId).digest('hex').slice(0, 16); + return `${acceptedAt.toISOString()}#${suffix}`; +}; + +export const applyNationSettingMutation = (options: { + world: InMemoryTurnWorld; + command: SetNationSettingCommand; + acceptedAt: Date; +}): SetNationSettingResult => { + const { world, command, acceptedAt } = options; + const actor = world.getGeneralById(command.generalId); + if (!actor) { + return reject('NOT_FOUND', '장수 정보를 찾을 수 없습니다.'); + } + if (actor.userId !== command.userId) { + return reject('FORBIDDEN', '인증된 사용자와 장수의 소유자가 일치하지 않습니다.'); + } + if (actor.nationId <= 0 || actor.nationId !== command.nationId) { + return reject('PRECONDITION_FAILED', '국가에 소속되어있지 않거나 소속 국가가 변경되었습니다.'); + } + + const nation = world.getNationById(command.nationId); + if (!nation) { + return reject('NOT_FOUND', '국가 정보를 찾을 수 없습니다.', command.nationId); + } + const permission = resolveTroopSecretPermission(actor, nation.meta, false); + if (permission < 0 || (actor.officerLevel < 5 && permission !== 4)) { + return reject('FORBIDDEN', '권한이 부족합니다.', command.nationId); + } + + const currentMeta = asRecord(nation.meta); + let updates: Record; + let availableCnt: number | undefined; + switch (command.mutation.kind) { + case 'notice': { + const message = command.mutation.message; + if (Array.from(message).length > 16_384) { + return reject('BAD_REQUEST', '올바른 국가 방침을 입력해주세요.', command.nationId); + } + updates = { + notice: message, + nationNotice: { + date: formatServerDateTime(world.getGameNow(acceptedAt)), + msg: message, + author: actor.name, + authorID: actor.id, + }, + }; + break; + } + case 'scoutMessage': { + const message = command.mutation.message; + if (Array.from(message).length > 1_000) { + return reject('BAD_REQUEST', '올바른 임관 권유문을 입력해주세요.', command.nationId); + } + updates = { infoText: message }; + break; + } + case 'rate': + if (!Number.isInteger(command.mutation.amount) || command.mutation.amount < 5 || command.mutation.amount > 30) { + return reject('BAD_REQUEST', '올바른 세율을 입력해주세요.', command.nationId); + } + updates = { rate: command.mutation.amount }; + break; + case 'bill': + if ( + !Number.isInteger(command.mutation.amount) || + command.mutation.amount < 20 || + command.mutation.amount > 200 + ) { + return reject('BAD_REQUEST', '올바른 지급률을 입력해주세요.', command.nationId); + } + updates = { bill: command.mutation.amount }; + break; + case 'secretLimit': + if (!Number.isInteger(command.mutation.amount) || command.mutation.amount < 1 || command.mutation.amount > 99) { + return reject('BAD_REQUEST', '올바른 기밀 공개 기준을 입력해주세요.', command.nationId); + } + updates = { secretlimit: command.mutation.amount }; + break; + case 'blockWar': { + const remain = readWarSettingRemain(currentMeta); + if (remain <= 0) { + return reject('BAD_REQUEST', '잔여 횟수가 부족합니다.', command.nationId); + } + availableCnt = remain - 1; + updates = { + war: command.mutation.value ? 1 : 0, + available_war_setting_cnt: availableCnt, + }; + break; + } + case 'blockScout': + if (isLegacyTruthy(asRecord(world.getState().meta).block_change_scout)) { + return reject('FORBIDDEN', '임관 설정을 바꿀 수 없도록 설정되어 있습니다.', command.nationId); + } + updates = { scout: command.mutation.value ? 1 : 0 }; + break; + } + + const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`); + world.updateNation(command.nationId, { + meta: { + ...nation.meta, + ...updates, + _updatedAt: updatedAt, + }, + }); + return { + type: 'setNationSetting', + ok: true, + nationId: command.nationId, + updatedAt, + ...(availableCnt === undefined ? {} : { availableCnt }), + }; +}; diff --git a/app/game-engine/src/turn/npcPolicyMutation.ts b/app/game-engine/src/turn/npcPolicyMutation.ts new file mode 100644 index 00000000..0185f585 --- /dev/null +++ b/app/game-engine/src/turn/npcPolicyMutation.ts @@ -0,0 +1,398 @@ +import { createHash } from 'node:crypto'; + +import { + asRecord, + formatServerDateTime, + isRecord, + type TurnDaemonCommand, + type TurnDaemonCommandResult, +} from '@sammo-ts/common'; +import { resolveTroopSecretPermission } from '@sammo-ts/logic'; + +import type { InMemoryTurnWorld } from './inMemoryWorld.js'; + +export type NationPolicy = { + reqNationGold: number; + reqNationRice: number; + CombatForce: Record; + SupportForce: number[]; + DevelopForce: number[]; + reqHumanWarUrgentGold: number; + reqHumanWarUrgentRice: number; + reqHumanWarRecommandGold: number; + reqHumanWarRecommandRice: number; + reqHumanDevelGold: number; + reqHumanDevelRice: number; + reqNPCWarGold: number; + reqNPCWarRice: number; + reqNPCDevelGold: number; + reqNPCDevelRice: number; + minimumResourceActionAmount: number; + maximumResourceActionAmount: number; + minNPCWarLeadership: number; + minWarCrew: number; + minNPCRecruitCityPopulation: number; + safeRecruitCityPopulationRatio: number; + properWarTrainAtmos: number; + cureThreshold: number; +}; + +export const DEFAULT_NATION_PRIORITY = [ + '불가침제의', + '선전포고', + '천도', + '유저장긴급포상', + '부대전방발령', + '유저장구출발령', + '유저장후방발령', + '부대유저장후방발령', + '유저장전방발령', + '유저장포상', + '부대구출발령', + '부대후방발령', + 'NPC긴급포상', + 'NPC구출발령', + 'NPC후방발령', + 'NPC포상', + 'NPC전방발령', + '유저장내정발령', + 'NPC내정발령', + 'NPC몰수', +] as const; + +export const DEFAULT_GENERAL_PRIORITY = [ + 'NPC사망대비', + '귀환', + '금쌀구매', + '출병', + '긴급내정', + '전투준비', + '전방워프', + 'NPC헌납', + '징병', + '후방워프', + '전쟁내정', + '소집해제', + '일반내정', + '내정워프', +] as const; + +export const DEFAULT_NATION_POLICY: NationPolicy = { + reqNationGold: 10000, + reqNationRice: 12000, + CombatForce: {}, + SupportForce: [], + DevelopForce: [], + reqHumanWarUrgentGold: 0, + reqHumanWarUrgentRice: 0, + reqHumanWarRecommandGold: 0, + reqHumanWarRecommandRice: 0, + reqHumanDevelGold: 10000, + reqHumanDevelRice: 10000, + reqNPCWarGold: 0, + reqNPCWarRice: 0, + reqNPCDevelGold: 0, + reqNPCDevelRice: 500, + minimumResourceActionAmount: 1000, + maximumResourceActionAmount: 10000, + minNPCWarLeadership: 40, + minWarCrew: 1500, + minNPCRecruitCityPopulation: 50000, + safeRecruitCityPopulationRatio: 0.5, + properWarTrainAtmos: 90, + cureThreshold: 10, +}; + +const NATION_POLICY_KEYS = new Set(Object.keys(DEFAULT_NATION_POLICY) as Array); + +const INTEGER_POLICY_KEYS = [ + 'reqNationGold', + 'reqNationRice', + 'reqHumanWarUrgentGold', + 'reqHumanWarUrgentRice', + 'reqHumanWarRecommandGold', + 'reqHumanWarRecommandRice', + 'reqHumanDevelGold', + 'reqHumanDevelRice', + 'reqNPCWarGold', + 'reqNPCWarRice', + 'reqNPCDevelGold', + 'reqNPCDevelRice', + 'minimumResourceActionAmount', + 'maximumResourceActionAmount', + 'minNPCWarLeadership', + 'minWarCrew', + 'minNPCRecruitCityPopulation', + 'properWarTrainAtmos', + 'cureThreshold', +] as const satisfies ReadonlyArray; + +const FLOAT_POLICY_KEYS = ['safeRecruitCityPopulationRatio'] as const satisfies ReadonlyArray; +const INTEGER_POLICY_KEY_SET = new Set(INTEGER_POLICY_KEYS); +const FLOAT_POLICY_KEY_SET = new Set(FLOAT_POLICY_KEYS); + +type SetNpcPolicyCommand = Extract; +type SetNpcPolicyResult = Extract; + +const reject = ( + code: Extract['code'], + reason: string, + extra: Pick, 'nationId' | 'currentUpdatedAt'> = {} +): SetNpcPolicyResult => ({ type: 'setNpcPolicy', ok: false, code, reason, ...extra }); + +const buildRevision = (acceptedAt: Date, requestId: string): string => { + const suffix = createHash('sha256').update(requestId).digest('hex').slice(0, 16); + return `${acceptedAt.toISOString()}#${suffix}`; +}; + +const validateGeneralPriority = (priority: readonly string[]): string | null => { + const mustHave = new Set(['출병', '일반내정']); + const orderMap = new Map(); + + for (const item of priority) { + if (!DEFAULT_GENERAL_PRIORITY.includes(item as (typeof DEFAULT_GENERAL_PRIORITY)[number])) { + return `${item}은 올바른 명령이 아닙니다.`; + } + mustHave.delete(item); + // Ref uses the count of distinct keys at each assignment. Updating an + // existing key therefore advances it after the currently known keys. + orderMap.set(item, orderMap.size); + } + + const sortieIndex = orderMap.get('출병'); + const domesticIndex = orderMap.get('일반내정'); + if (sortieIndex !== undefined && domesticIndex !== undefined && sortieIndex > domesticIndex) { + return '출병 명령은 일반내정 명령보다 먼저여야 합니다.'; + } + if (mustHave.size > 0) { + return `${mustHave.values().next().value}은 항상 사용해야 합니다.`; + } + return null; +}; + +const applyNationPolicyValues = ( + world: InMemoryTurnWorld, + nationId: number, + currentValues: Record, + values: Record +): { values?: Record; error?: string } => { + if (Object.keys(values).length === 0) { + return { error: '올바른 입력이 아닙니다.' }; + } + + for (const key of Object.keys(values)) { + if (!NATION_POLICY_KEYS.has(key as keyof NationPolicy)) { + return { error: `${key}는 올바른 정책값이 아닙니다.` }; + } + } + + // Ref persists only the supplied delta on top of the existing nation + // overrides. Materialising defaults here would freeze later server-policy + // changes and is not equivalent to j_set_npc_control.php. + const nextValues = { ...currentValues }; + for (const key of INTEGER_POLICY_KEYS) { + if (!Object.hasOwn(values, key)) { + continue; + } + const value = values[key]; + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) { + return { error: `${key}는 올바른 값이 아닙니다.` }; + } + nextValues[key] = Math.max(0, value); + } + for (const key of FLOAT_POLICY_KEYS) { + if (!Object.hasOwn(values, key)) { + continue; + } + const value = values[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + return { error: `${key}는 올바른 값이 아닙니다.` }; + } + // Ref clamps negative integers but deliberately leaves floating-point + // ratios unchanged. + nextValues[key] = value; + } + + const troopIds = new Set( + world + .listTroops() + .filter((troop) => troop.nationId === nationId) + .map((troop) => troop.id) + ); + const assigned = new Set(); + + if (Object.hasOwn(values, 'CombatForce')) { + const rawCombat = values.CombatForce; + if (!isRecord(rawCombat)) { + return { error: 'CombatForce는 올바른 정책값이 아닙니다.' }; + } + for (const [rawLeaderId, rawTarget] of Object.entries(rawCombat)) { + const leaderId = Number(rawLeaderId); + if (!Number.isSafeInteger(leaderId) || !troopIds.has(leaderId)) { + return { error: `${rawLeaderId}는 국가의 부대가 아닙니다.` }; + } + if (assigned.has(leaderId)) { + return { error: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.` }; + } + if (!Array.isArray(rawTarget) || rawTarget.length !== 2) { + return { error: `${leaderId}의 입력양식이 올바르지 않습니다.` }; + } + // Ref j_set_npc_control.php accidentally destructures the complete + // troop-role cache instead of this rawTarget. Positive troop IDs + // therefore leave indexes 0/1 undefined and every non-empty + // CombatForce is rejected with this observable empty-city error. + // Preserve that legacy bug until a separately approved contract + // change fixes Ref and Core together. + return { error: `${leaderId}의 도시 , 가 올바른 도시 번호가 아닙니다.` }; + } + nextValues.CombatForce = {}; + } + + for (const key of ['SupportForce', 'DevelopForce'] as const) { + if (!Object.hasOwn(values, key)) { + continue; + } + const rawList = values[key]; + if (!Array.isArray(rawList)) { + return { error: `${key}는 올바른 정책값이 아닙니다.` }; + } + const list: number[] = []; + for (const rawLeaderId of rawList) { + const leaderId = Number(rawLeaderId); + if (!Number.isSafeInteger(leaderId) || !troopIds.has(leaderId)) { + return { error: `${String(rawLeaderId)}는 국가의 부대가 아닙니다.` }; + } + if (assigned.has(leaderId)) { + return { error: `부대(${leaderId})는 하나의 역할만 지정할 수 있습니다.` }; + } + assigned.add(leaderId); + list.push(leaderId); + } + nextValues[key] = list; + } + + // Numeric keys have already been handled. The three role keys are handled + // above, leaving no accepted policy key unprocessed. + for (const [key, value] of Object.entries(values)) { + if ( + !INTEGER_POLICY_KEY_SET.has(key) && + !FLOAT_POLICY_KEY_SET.has(key) && + !['CombatForce', 'SupportForce', 'DevelopForce'].includes(key) + ) { + nextValues[key] = value; + } + } + return { values: nextValues }; +}; + +export const applyNpcPolicyMutation = (options: { + world: InMemoryTurnWorld; + command: SetNpcPolicyCommand; + acceptedAt: Date; +}): SetNpcPolicyResult => { + const { world, command, acceptedAt } = options; + const actor = world.getGeneralById(command.generalId); + if (!actor) { + return reject('NOT_FOUND', '장수 정보를 찾을 수 없습니다.'); + } + if (actor.userId !== command.userId) { + return reject('FORBIDDEN', '인증된 사용자와 장수의 소유자가 일치하지 않습니다.'); + } + if (actor.nationId <= 0 || actor.nationId !== command.nationId) { + return reject('PRECONDITION_FAILED', '국가에 소속되어있지 않거나 소속 국가가 변경되었습니다.'); + } + + const nation = world.getNationById(command.nationId); + if (!nation) { + return reject('NOT_FOUND', '국가 정보를 찾을 수 없습니다.', { nationId: command.nationId }); + } + if (resolveTroopSecretPermission(actor, nation.meta, true) < 3) { + return reject('FORBIDDEN', '권한이 부족합니다. 군주, 외교권자, 조언자가 아닙니다.', { + nationId: command.nationId, + }); + } + + const nationMeta = asRecord(nation.meta); + const currentUpdatedAt = + typeof nationMeta._npcPolicyUpdatedAt === 'string' + ? nationMeta._npcPolicyUpdatedAt + : typeof nationMeta._updatedAt === 'string' + ? nationMeta._updatedAt + : null; + if (command.expectedUpdatedAt !== currentUpdatedAt) { + return reject('CONFLICT', '다른 사용자가 정책을 변경했습니다. 재시도하거나 현재 상태로 갱신해주세요.', { + nationId: command.nationId, + currentUpdatedAt, + }); + } + + const gameNow = formatServerDateTime(world.getGameNow(acceptedAt)); + let updates: Record; + if (command.mutation.kind === 'nationPolicy') { + const policyRoot = asRecord(nationMeta.npc_nation_policy); + const applied = applyNationPolicyValues( + world, + command.nationId, + asRecord(policyRoot.values), + command.mutation.values + ); + if (!applied.values) { + return reject('BAD_REQUEST', applied.error ?? '올바른 입력이 아닙니다.', { nationId: command.nationId }); + } + updates = { + npc_nation_policy: { + ...policyRoot, + values: applied.values, + valueSetter: actor.name, + valueSetTime: gameNow, + }, + }; + } else if (command.mutation.kind === 'nationPriority') { + if (command.mutation.priority.length === 0) { + return reject('BAD_REQUEST', '올바른 입력이 아닙니다.', { nationId: command.nationId }); + } + for (const item of command.mutation.priority) { + if (!DEFAULT_NATION_PRIORITY.includes(item as (typeof DEFAULT_NATION_PRIORITY)[number])) { + return reject('BAD_REQUEST', `${item}은 올바른 명령이 아닙니다.`, { nationId: command.nationId }); + } + } + const policyRoot = asRecord(nationMeta.npc_nation_policy); + updates = { + npc_nation_policy: { + ...policyRoot, + priority: [...command.mutation.priority], + prioritySetter: actor.name, + prioritySetTime: gameNow, + }, + }; + } else { + const validationError = validateGeneralPriority(command.mutation.priority); + if (validationError) { + return reject('BAD_REQUEST', validationError, { nationId: command.nationId }); + } + const policyRoot = asRecord(nationMeta.npc_general_policy); + updates = { + npc_general_policy: { + ...policyRoot, + priority: [...command.mutation.priority], + prioritySetter: actor.name, + prioritySetTime: gameNow, + }, + }; + } + + // input_event timestamps have millisecond precision, so two independent + // accepted commands can share the same time. Include the durable request + // identity to keep the strict CAS token unique. + const updatedAt = buildRevision(acceptedAt, command.requestId ?? `${command.type}:${command.generalId}`); + world.updateNation(command.nationId, { + meta: { + ...nation.meta, + ...updates, + // Keep the policy CAS independent from notice/tax/scout settings. + // The legacy shared _updatedAt remains a one-time migration fallback. + _npcPolicyUpdatedAt: updatedAt, + }, + }); + return { type: 'setNpcPolicy', ok: true, nationId: command.nationId, updatedAt }; +}; diff --git a/app/game-engine/src/turn/oldNationArchive.ts b/app/game-engine/src/turn/oldNationArchive.ts index 6a96a930..56518878 100644 --- a/app/game-engine/src/turn/oldNationArchive.ts +++ b/app/game-engine/src/turn/oldNationArchive.ts @@ -9,6 +9,11 @@ const readNumber = (value: unknown): number => { const readTextArray = (value: unknown): string[] => Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []; +const readArchiveText = (values: readonly unknown[], fallback: string | null): string | null => { + const value = values.find((candidate) => candidate !== undefined && candidate !== null); + return value === undefined ? fallback : String(value); +}; + export const buildOldNationArchiveData = (options: { nation: Nation; generalIds: readonly number[]; @@ -22,6 +27,7 @@ export const buildOldNationArchiveData = (options: { ...maxPower, }; const maxCities = readTextArray(maxPower.maxCities); + const nationNotice = asRecord(meta.nationNotice); return { ...nation, @@ -34,5 +40,7 @@ export const buildOldNationArchiveData = (options: { aux, generals: [...options.generalIds], history: [...options.history], + msg: readArchiveText([meta.notice, nationNotice.msg, meta.msg], ''), + scout_msg: readArchiveText([meta.infoText, meta.scout_msg], null), }; }; diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 0927be04..57be4490 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -2541,7 +2541,7 @@ export const createImmediateGeneralActionExecutor = async (options: { const failureText = definition.formatConstraintFailure?.(reason, constraintCtx, args, view) ?? `${reason} ${definition.name} 실패.`; - if (input.actionKey === 'che_접경귀환' || input.actionKey === 'che_등용수락') { + if (input.actionKey === 'che_접경귀환') { options.world.pushLog(createGeneralActionLog(general.id, failureText), general.turnTime); } return { ok: false, reason: failureText }; @@ -2671,10 +2671,16 @@ export const createImmediateGeneralActionExecutor = async (options: { nextGeneral = applyLegacyGeneralProgression( { ...nextGeneral, - lastTurn: { - command: definition.name, - arg: extractArgsRecord(args), - }, + // Ref's recruitment-letter acceptance is an immediate + // side action and never replaces the receiver's + // reserved-command repetition state. + lastTurn: + input.actionKey === 'che_등용수락' + ? general.lastTurn + : { + command: definition.name, + arg: extractArgsRecord(args), + }, }, general, input.actionKey, diff --git a/app/game-engine/src/turn/turnDaemon.ts b/app/game-engine/src/turn/turnDaemon.ts index d1a5c43a..c6d3cc69 100644 --- a/app/game-engine/src/turn/turnDaemon.ts +++ b/app/game-engine/src/turn/turnDaemon.ts @@ -962,6 +962,7 @@ const createTurnDaemonRuntimeWithLease = async ( scenarioMeta: snapshot.scenarioMeta, map: snapshot.map, commandProfile, + generalActionModules: monthlyActionModules.general, getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys, auctionFinalizer: auctionFinalizer ?? undefined, auctionBidder: auctionBidder ?? undefined, diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index 96ebb9c9..8b6db860 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -496,15 +496,12 @@ export const persistUnificationFinalization = async ( const cityCount = cities.filter((city) => city.nationId === input.winnerNationId).length; const totalPop = cities.reduce((sum, city) => sum + city.population, 0); const totalMaxPop = cities.reduce((sum, city) => sum + city.populationMax, 0); - const winnerMeta = asRecord(winner.meta); const winnerData = { ...buildOldNationArchiveData({ nation: winner, generalIds: winnerGenerals.map((general) => general.id), history: nationHistory, }), - msg: String(asRecord(winnerMeta.nationNotice).msg ?? winnerMeta.msg ?? ''), - scout_msg: String(winnerMeta.scout_msg ?? ''), generationKey: input.generationKey, }; await transaction.oldNation.upsert({ diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 1aeb2647..dcfb036a 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -22,20 +22,24 @@ import { buildVoteUniqueSeed, countOccupiedUniqueItems, createItemModuleRegistry, + GeneralActionPipeline, isDefenceTrainPenaltyWaivedByScenarioEffect, isValidTroopNameWidth, loadItemModules, normalizeTroopName, resolveTroopSecretPermission, + resolveMessageTargetIcon, + type GeneralActionModule, resolveUniqueConfig, rollUniqueLottery, type ItemModule, + type LogEntryDraft, type MapDefinition, type ScenarioMeta, type TriggerValue, type TurnCommandProfile, } from '@sammo-ts/logic'; -import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; +import { round as roundLegacyInteger, simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { cloneItemInventory, ensureItemInventory, @@ -45,7 +49,12 @@ import { import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryReservedTurnStore } from './reservedTurnStore.js'; import type { TurnGeneral } from './types.js'; -import { createImmediateGeneralActionExecutor, type ImmediateGeneralActionExecutor } from './reservedTurnHandler.js'; +import { + applyLegacyGeneralProgression, + createImmediateGeneralActionExecutor, + type ImmediateGeneralActionExecutor, +} from './reservedTurnHandler.js'; +import { buildCommandEnv } from './reservedTurnCommands.js'; import { openAuction } from '../auction/opener.js'; import { hasScenarioStaticEventHandler, @@ -63,6 +72,8 @@ import { NpcPossessionError, possessNpcGeneral } from './npcPossessionService.js import { buildPrestartDeleteAfter, formatPrestartDeleteAfter, readPrestartDeleteAfter } from './prestartDeletion.js'; import { respondToActionableMessage } from './actionableMessageResponse.js'; import { executeInheritanceAction } from './inheritanceActionService.js'; +import { applyNpcPolicyMutation } from './npcPolicyMutation.js'; +import { applyNationSettingMutation } from './nationSettingMutation.js'; let itemRegistryPromise: Promise> | null = null; @@ -140,6 +151,7 @@ interface CommandHandlerContext { tournamentRewardFinalizer?: TournamentRewardFinalizer; getImmediateGeneralActionExecutor?: () => Promise; reservedTurns?: InMemoryReservedTurnStore; + generalActionModules?: ReadonlyArray; loadArchivedNationMaxId?: (serverId: string) => Promise; } @@ -150,6 +162,72 @@ const requireCommandDatabase = (ctx: CommandHandlerContext): DatabaseClient => { return ctx.commandDb as unknown as DatabaseClient; }; +const ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST = [ + 'troopCreate', + 'troopJoin', + 'troopExit', + 'troopKick', + 'troopRename', + 'vacation', + 'setMySetting', + 'dropItem', + 'auctionOpen', + 'auctionBid', + 'changePermission', + 'kick', + 'appoint', + 'voteReward', +] as const satisfies readonly TurnDaemonCommand['type'][]; + +type ActorBoundGeneralCommandType = (typeof ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST)[number]; +const ACTOR_BOUND_GENERAL_COMMAND_TYPES = new Set(ACTOR_BOUND_GENERAL_COMMAND_TYPE_LIST); + +type ActorBoundGeneralCommand = Extract; + +const isActorBoundGeneralCommand = (command: TurnDaemonCommand): command is ActorBoundGeneralCommand => + ACTOR_BOUND_GENERAL_COMMAND_TYPES.has(command.type as ActorBoundGeneralCommandType); + +const rejectActorBoundGeneralCommand = ( + command: ActorBoundGeneralCommand, + reason: string +): TurnDaemonCommandResult => ({ + type: 'commandRejected', + ok: false, + commandType: command.type, + reason, +}); + +const validateActorBoundGeneralCommand = async ( + ctx: CommandHandlerContext, + command: ActorBoundGeneralCommand +): Promise => { + if (!ctx.commandDb) { + return null; + } + if (!command.requestId) { + return rejectActorBoundGeneralCommand(command, '인증 명령의 요청 ID가 없습니다.'); + } + + const event = await ctx.commandDb.inputEvent.findUnique({ + where: { requestId: command.requestId }, + select: { actorUserId: true, target: true, eventType: true }, + }); + if ( + !event || + event.actorUserId !== command.userId || + event.target !== 'ENGINE' || + event.eventType !== command.type + ) { + return rejectActorBoundGeneralCommand(command, '인증 명령의 입력 이벤트가 일치하지 않습니다.'); + } + + const general = ctx.world.getGeneralById(command.generalId); + if (!general || general.userId !== command.userId) { + return rejectActorBoundGeneralCommand(command, '명령 수행 장수의 현재 소유자가 일치하지 않습니다.'); + } + return null; +}; + const resolveCommandAcceptedAt = async ( db: DatabaseClient, command: Extract< @@ -164,7 +242,9 @@ const resolveCommandAcceptedAt = async ( | 'selectPoolCreate' | 'selectPoolReselect' | 'adjustGeneralIcon' - | 'inheritanceAction'; + | 'inheritanceAction' + | 'setNationSetting' + | 'setNpcPolicy'; } > ): Promise => { @@ -504,49 +584,22 @@ interface TournamentRewardFinalizer { ): Promise; } -async function handleSetNationMeta( +async function handleSetNationSetting( ctx: CommandHandlerContext, - command: Extract + command: Extract ): Promise { - const { world } = ctx; - const nation = world.getNationById(command.nationId); - if (!nation) { - return { - type: 'setNationMeta', - ok: false, - nationId: command.nationId, - reason: '국가 정보를 찾을 수 없습니다.', - }; - } + const db = requireCommandDatabase(ctx); + const acceptedAt = await resolveCommandAcceptedAt(db, command); + return applyNationSettingMutation({ world: ctx.world, command, acceptedAt }); +} - 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, - }); - return { - type: 'setNationMeta', - ok: true, - nationId: command.nationId, - updatedAt, - }; +async function handleSetNpcPolicy( + ctx: CommandHandlerContext, + command: Extract +): Promise { + const db = requireCommandDatabase(ctx); + const acceptedAt = await resolveCommandAcceptedAt(db, command); + return applyNpcPolicyMutation({ world: ctx.world, command, acceptedAt }); } async function handleAdjustGeneralResources( @@ -1832,10 +1885,15 @@ async function handleDropItem( if (!general) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; } - const slot = (['horse', 'weapon', 'book', 'item'] as const).find((candidate) => candidate === command.itemType); - if (!slot || !general.role.items[slot]) { + const slot = command.itemType; + const itemKey = general.role.items[slot]; + if (!itemKey) { return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템을 가지고 있지 않습니다.' }; } + const item = (await getItemRegistry()).get(itemKey); + if (!item) { + return { type: 'dropItem', ok: false, generalId: command.generalId, reason: '아이템 정보를 찾을 수 없습니다.' }; + } const nextGeneral = { ...general, role: { ...general.role, items: { ...general.role.items } }, @@ -1847,6 +1905,34 @@ async function handleDropItem( role: nextGeneral.role, itemInventory: nextGeneral.itemInventory, }); + + const josaUl = JosaUtil.pick(item.rawName, '을'); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + text: `${item.name}${josaUl} 버렸습니다.`, + generalId: general.id, + meta: {}, + }); + if (!item.buyable) { + const nationName = world.getNationById(general.nationId)?.name ?? '재야'; + const josaYi = JosaUtil.pick(general.name, '이'); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + text: `${general.name}${josaYi} ${item.name}${josaUl} 잃었습니다!`, + meta: {}, + }); + world.pushLog({ + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + text: `【망실】${nationName}${general.name}${josaYi} ${item.name}${josaUl} 잃었습니다!`, + meta: {}, + }); + } return { type: 'dropItem', ok: true, generalId: command.generalId }; } @@ -1975,6 +2061,9 @@ async function handleKick( command: Extract ): Promise { const { world } = ctx; + const operationalAcceptedAt = ctx.commandDb + ? await resolveOperationalAcceptedAt(ctx.commandDb, command) + : new Date(); const general = world.getGeneralById(command.generalId); if (!general) { return { type: 'kick', ok: false, generalId: command.generalId, reason: '장수 정보를 찾을 수 없습니다.' }; @@ -2043,14 +2132,38 @@ async function handleKick( const worldState = world.getState(); const scenarioMeta = asRecord(asRecord(worldState.meta).scenarioMeta); const startYear = readMetaNumber(scenarioMeta, 'startYear', worldState.currentYear); + let nextExperience = target.experience; + let nextDedication = target.dedication; + let applyProgression = false; if (worldState.currentYear > startYear || target.npcState >= 2) { const betray = Math.max(0, readMetaNumber(targetMeta, 'betray', 0)); const maxBetrayCnt = readMetaNumber(config, 'maxBetrayCnt', 9); + const detachedTarget: TurnGeneral = { + ...target, + nationId: 0, + officerLevel: 0, + troopId: 0, + meta: nextMeta, + }; + const pipeline = new GeneralActionPipeline(ctx.generalActionModules ?? []); + const actionContext = { + general: detachedTarget, + nation: null, + worldView: world, + time: { + year: worldState.currentYear, + month: worldState.currentMonth, + startYear, + }, + }; + nextExperience = roundLegacyInteger( + target.experience + pipeline.onCalcStat(actionContext, 'experience', -target.experience * 0.15 * betray) + ); + nextDedication = roundLegacyInteger( + target.dedication + pipeline.onCalcStat(actionContext, 'dedication', -target.dedication * 0.15 * betray) + ); nextMeta.betray = Math.min(maxBetrayCnt, betray + 1); - world.updateGeneral(target.id, { - experience: Math.max(0, Math.floor(target.experience - target.experience * 0.15 * betray)), - dedication: Math.max(0, Math.floor(target.dedication - target.dedication * 0.15 * betray)), - }); + applyProgression = true; } else { nextMeta.makelimit = targetMeta.makelimit ?? 12; } @@ -2070,14 +2183,28 @@ async function handleKick( world.removeTroop(target.id); } - world.updateGeneral(command.destGeneralId, { + const detachedTarget: TurnGeneral = { + ...target, nationId: 0, officerLevel: 0, troopId: 0, gold: Math.min(target.gold, defaultGold), rice: Math.min(target.rice, defaultRice), + experience: nextExperience, + dedication: nextDedication, meta: nextMeta, - }); + }; + const progressionLogs: LogEntryDraft[] = []; + const nextTarget = applyProgression + ? applyLegacyGeneralProgression( + detachedTarget, + target, + 'kick', + buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()), + progressionLogs + ) + : detachedTarget; + world.updateGeneral(command.destGeneralId, nextTarget); const nationMeta = worldState.currentYear >= startYear + 3 ? setOfficerLock(nation.meta, 'chief_set', general.officerLevel) @@ -2098,6 +2225,17 @@ async function handleKick( text: `${target.name}${josaYi} ${nation.name}에서 추방당했습니다.`, meta: {}, }); + world.pushLog({ + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + text: `${nation.name}에서 추방당했습니다.`, + generalId: target.id, + meta: {}, + }); + for (const log of progressionLogs) { + world.pushLog(log); + } world.pushLog({ scope: LogScope.GENERAL, category: LogCategory.HISTORY, @@ -2125,7 +2263,7 @@ async function handleKick( const text = rng.choice([ '날 버리다니... 곧 전장에서 복수해주겠다...', '추방이라... 내가 무얼 잘못했단 말인가...', - '어디 추방해가면서 잘되나 보자... 꼭 복수하겠다.', + '어디 추방해가면서 잘되나 보자... 꼭 복수하겠다...', '인덕이 제일이거늘... 추방이 웬말인가... 저주한다!', '날 추방했으니 그 복수로 적국에 정보를 팔아 넘겨야겠군요. 그럼 이만.', ]); @@ -2135,14 +2273,14 @@ async function handleKick( nationId: nation.id, nationName: nation.name, color: nation.color, - icon: target.picture === null ? '' : String(target.picture), + icon: resolveMessageTargetIcon(target), }; world.queueMessage({ msgType: 'public', src: messageTarget, dest: messageTarget, text, - time: world.getGameNow(new Date()), + time: world.getGameNow(operationalAcceptedAt), validUntil: new Date('9999-12-31T00:00:00.000Z'), option: {}, }); @@ -2873,6 +3011,7 @@ export const createTurnDaemonCommandHandler = (options: { scenarioMeta?: ScenarioMeta; map?: MapDefinition; commandProfile?: TurnCommandProfile; + generalActionModules?: ReadonlyArray; getAdditionalOccupiedUniqueItemKeys?: () => Iterable; auctionFinalizer?: AuctionFinalizer; auctionBidder?: AuctionBidder; @@ -2886,6 +3025,7 @@ export const createTurnDaemonCommandHandler = (options: { auctionBidder: options.auctionBidder, tournamentRewardFinalizer: options.tournamentRewardFinalizer, reservedTurns: options.reservedTurns, + generalActionModules: options.generalActionModules, loadArchivedNationMaxId: options.loadArchivedNationMaxId, getImmediateGeneralActionExecutor: () => { immediateGeneralActionExecutor ??= createImmediateGeneralActionExecutor({ @@ -2961,8 +3101,10 @@ export const createTurnDaemonCommandHandler = (options: { tournamentReward: (command) => handleTournamentReward(ctx, command as Extract), voteReward: (command) => handleVoteReward(ctx, command as Extract), - setNationMeta: (command) => - handleSetNationMeta(ctx, command as Extract), + setNationSetting: (command) => + handleSetNationSetting(ctx, command as Extract), + setNpcPolicy: (command) => + handleSetNpcPolicy(ctx, command as Extract), adjustGeneralResources: (command) => handleAdjustGeneralResources( ctx, @@ -3005,6 +3147,12 @@ export const createTurnDaemonCommandHandler = (options: { } ctx.commandDb = executionContext?.db; try { + if (isActorBoundGeneralCommand(command)) { + const rejected = await validateActorBoundGeneralCommand(ctx, command); + if (rejected) { + return rejected; + } + } return await handler(command); } finally { ctx.commandDb = undefined; diff --git a/app/game-engine/test/actionableMessageResponse.test.ts b/app/game-engine/test/actionableMessageResponse.test.ts index 1e3a5d58..dc579844 100644 --- a/app/game-engine/test/actionableMessageResponse.test.ts +++ b/app/game-engine/test/actionableMessageResponse.test.ts @@ -92,6 +92,7 @@ const buildRow = (action: 'scout' | 'raiseInvader', overrides: Partial { expect(world.peekDirtyState().messages).toHaveLength(0); }); + it('treats legacy truthy used values and an inverted validity interval as invalid scout letters', async () => { + for (const row of [ + buildRow('scout', { option: { action: 'scout', used: 1 } }), + { + ...buildRow('scout'), + validUntil: new Date('0199-12-31T23:59:59.000Z'), + }, + ]) { + const world = buildWorld(); + const { db, updateMany } = buildDb([[row]]); + const executor = buildExecutor(); + + await expect( + respondToActionableMessage({ + db, + world, + executor, + userId: actor.userId!, + generalId: actor.id, + messageId: row.id, + response: true, + }) + ).resolves.toEqual({ ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' }); + expect(executor.execute).not.toHaveBeenCalled(); + expect(updateMany).not.toHaveBeenCalled(); + } + }); + + it("keeps PHP's special string-zero used value false", async () => { + const world = buildWorld(); + const row = buildRow('scout', { option: { action: 'scout', used: '0' } }); + const { db, updateMany } = buildDb([[row], []]); + const executor = buildExecutor(); + + await expect( + respondToActionableMessage({ + db, + world, + executor, + userId: actor.userId!, + generalId: actor.id, + messageId: row.id, + response: true, + }) + ).resolves.toEqual({ ok: true, action: 'scout', reason: 'success' }); + expect(executor.execute).toHaveBeenCalledOnce(); + expect(updateMany).toHaveBeenCalledOnce(); + }); + + it('rejects malformed actionable payloads without throwing inside the daemon transaction', async () => { + const world = buildWorld(); + const row = { ...buildRow('scout'), message: { option: { action: 'scout' }, dest: null } }; + const { db, updateMany } = buildDb([[row]]); + const executor = buildExecutor(); + + await expect( + respondToActionableMessage({ + db, + world, + executor, + userId: actor.userId!, + generalId: actor.id, + messageId: row.id, + response: true, + }) + ).resolves.toEqual({ ok: false, reason: '응답할 수 없는 메시지입니다.' }); + expect(executor.execute).not.toHaveBeenCalled(); + expect(updateMany).not.toHaveBeenCalled(); + }); + it('does not invalidate an invader prompt before validating its receiver', async () => { const world = buildWorld(); const row = { ...buildRow('raiseInvader'), mailbox: 99 }; diff --git a/app/game-engine/test/auctionBidCompatibility.test.ts b/app/game-engine/test/auctionBidCompatibility.test.ts index 502cd818..ea4a9d0a 100644 --- a/app/game-engine/test/auctionBidCompatibility.test.ts +++ b/app/game-engine/test/auctionBidCompatibility.test.ts @@ -110,6 +110,7 @@ const runDelayedResourceBid = async (finishImmediately: boolean) => { const result = await auctionBidder.bid( { type: 'auctionBid', + userId: 'user-7', auctionId: 31, generalId: general.id, amount, @@ -155,6 +156,7 @@ describe('resource auction Ref compatibility', () => { sentAt: '2026-08-23T00:00:00.000Z', command: { type: 'auctionBid', + userId: 'user-7', auctionId: 31, generalId: 7, amount: 500, diff --git a/app/game-engine/test/auctionFinalizerCompatibility.test.ts b/app/game-engine/test/auctionFinalizerCompatibility.test.ts index 12cc6add..d07303a8 100644 --- a/app/game-engine/test/auctionFinalizerCompatibility.test.ts +++ b/app/game-engine/test/auctionFinalizerCompatibility.test.ts @@ -120,6 +120,7 @@ describe('unique auction inheritance log compatibility', () => { const result = await openAuction( { type: 'auctionOpen', + userId: 'user-7', auctionType: 'UNIQUE_ITEM', generalId: general.id, amount: 6_000, diff --git a/app/game-engine/test/authenticatedActorCommand.test.ts b/app/game-engine/test/authenticatedActorCommand.test.ts new file mode 100644 index 00000000..160fef8d --- /dev/null +++ b/app/game-engine/test/authenticatedActorCommand.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { TurnDaemonCommand } from '@sammo-ts/common'; + +import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; + +const buildActorBoundCommands = (userId = 'old-owner'): TurnDaemonCommand[] => [ + { type: 'troopCreate', requestId: 'troopCreate', userId, generalId: 7, troopName: '백마대' }, + { type: 'troopJoin', requestId: 'troopJoin', userId, generalId: 7, troopId: 8 }, + { type: 'troopExit', requestId: 'troopExit', userId, generalId: 7 }, + { type: 'troopKick', requestId: 'troopKick', userId, generalId: 7, troopId: 7, targetGeneralId: 8 }, + { type: 'troopRename', requestId: 'troopRename', userId, generalId: 7, troopId: 7, troopName: '신대' }, + { type: 'vacation', requestId: 'vacation', userId, generalId: 7 }, + { type: 'setMySetting', requestId: 'setMySetting', userId, generalId: 7, settings: { tnmt: 1 } }, + { type: 'dropItem', requestId: 'dropItem', userId, generalId: 7, itemType: 'weapon' }, + { + type: 'auctionOpen', + requestId: 'auctionOpen', + userId, + generalId: 7, + auctionType: 'BUY_RICE', + amount: 1_000, + closeTurnCnt: 3, + startBidAmount: 100, + finishBidAmount: 500, + }, + { type: 'auctionBid', requestId: 'auctionBid', userId, generalId: 7, auctionId: 1, amount: 200 }, + { + type: 'changePermission', + requestId: 'changePermission', + userId, + generalId: 7, + isAmbassador: true, + targetGeneralIds: [], + }, + { type: 'kick', requestId: 'kick', userId, generalId: 7, destGeneralId: 8 }, + { + type: 'appoint', + requestId: 'appoint', + userId, + generalId: 7, + destGeneralId: 8, + destCityId: 1, + officerLevel: 4, + }, + { type: 'voteReward', requestId: 'voteReward', userId, generalId: 7, voteId: 1, selection: [0] }, +]; + +const buildReadOnlyWorld = (ownerUserId: string) => { + const mutation = vi.fn(); + const world = { + getGeneralById: vi.fn(() => ({ id: 7, userId: ownerUserId })), + updateGeneral: mutation, + updateNation: mutation, + createTroop: mutation, + updateTroop: mutation, + removeTroop: mutation, + pushLog: mutation, + queueMessage: mutation, + } as unknown as InMemoryTurnWorld; + return { world, mutation }; +}; + +describe('authenticated actor-bound command registry and execution', () => { + it.each(['horse', 'weapon', 'book', 'item'] as const)( + 'accepts the Ref equipment slot %s for dropItem', + (itemType) => { + expect( + normalizeTurnDaemonCommand({ + requestId: `drop-item:${itemType}`, + sentAt: '2026-08-24T00:00:00.000Z', + command: { type: 'dropItem', userId: 'user-7', generalId: 7, itemType }, + }) + ).toEqual({ + type: 'dropItem', + requestId: `drop-item:${itemType}`, + userId: 'user-7', + generalId: 7, + itemType, + }); + } + ); + + it.each(['armor', '', 0, null])('rejects the invalid dropItem slot %j at the daemon boundary', (itemType) => { + expect( + normalizeTurnDaemonCommand({ + requestId: 'drop-item:invalid-slot', + sentAt: '2026-08-24T00:00:00.000Z', + command: { + type: 'dropItem', + userId: 'user-7', + generalId: 7, + itemType, + } as unknown as TurnDaemonCommand, + }) + ).toBeNull(); + }); + + it('rejects every actor-bound queue payload that omits userId', () => { + for (const command of buildActorBoundCommands()) { + const { + userId: _userId, + requestId: _requestId, + ...payload + } = command as unknown as Record; + expect( + normalizeTurnDaemonCommand({ + requestId: `missing-user:${command.type}`, + sentAt: '2026-08-24T00:00:00.000Z', + command: payload as TurnDaemonCommand, + }), + command.type + ).toBeNull(); + } + }); + + it('rejects all stale-owner commands before any world mutation', async () => { + const commands = buildActorBoundCommands(); + const { world, mutation } = buildReadOnlyWorld('new-owner'); + const eventTypes = new Map(commands.map((command) => [command.requestId, command.type])); + const db = { + inputEvent: { + findUnique: vi.fn(async ({ where }: { where: { requestId: string } }) => ({ + actorUserId: 'old-owner', + target: 'ENGINE', + eventType: eventTypes.get(where.requestId), + })), + }, + }; + const handler = createTurnDaemonCommandHandler({ world }); + + for (const command of commands) { + await expect(handler.handle(command, { db: db as never }), command.type).resolves.toMatchObject({ + type: 'commandRejected', + ok: false, + commandType: command.type, + }); + } + expect(mutation).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing event', null], + ['actor mismatch', { actorUserId: 'other-owner', target: 'ENGINE', eventType: 'vacation' }], + ['target mismatch', { actorUserId: 'old-owner', target: 'API', eventType: 'vacation' }], + ['event type mismatch', { actorUserId: 'old-owner', target: 'ENGINE', eventType: 'dropItem' }], + ])('returns commandRejected for %s without looking up or mutating the general', async (_label, event) => { + const { world, mutation } = buildReadOnlyWorld('old-owner'); + const getGeneralById = world.getGeneralById as ReturnType; + const handler = createTurnDaemonCommandHandler({ world }); + const result = await handler.handle( + { type: 'vacation', requestId: 'vacation', userId: 'old-owner', generalId: 7 }, + { + db: { + inputEvent: { findUnique: vi.fn(async () => event) }, + } as never, + } + ); + + expect(result).toMatchObject({ type: 'commandRejected', ok: false, commandType: 'vacation' }); + expect(getGeneralById).not.toHaveBeenCalled(); + expect(mutation).not.toHaveBeenCalled(); + }); + + it('preserves direct in-memory invocation when no command database is supplied', async () => { + const updateGeneral = vi.fn(); + const world = { + getGeneralById: vi.fn(() => ({ id: 7, userId: 'current-owner', meta: { killturn: 12 } })), + getState: vi.fn(() => ({ meta: { killturn: 24, autorun_user: {} } })), + updateGeneral, + } as unknown as InMemoryTurnWorld; + const handler = createTurnDaemonCommandHandler({ world }); + + await expect(handler.handle({ type: 'vacation', userId: 'different-owner', generalId: 7 })).resolves.toEqual({ + type: 'vacation', + ok: true, + generalId: 7, + }); + expect(updateGeneral).toHaveBeenCalledWith(7, { meta: { killturn: 72 } }); + }); +}); diff --git a/app/game-engine/test/databaseCommandQueue.integration.test.ts b/app/game-engine/test/databaseCommandQueue.integration.test.ts index fce224d2..0ca4a18a 100644 --- a/app/game-engine/test/databaseCommandQueue.integration.test.ts +++ b/app/game-engine/test/databaseCommandQueue.integration.test.ts @@ -1,8 +1,12 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import type { TurnDaemonCommand } from '@sammo-ts/common'; import { createGamePostgresConnector } from '@sammo-ts/infra'; import type { GamePrisma, GamePrismaClient } from '@sammo-ts/infra'; import { DatabaseTurnDaemonCommandQueue } from '../src/lifecycle/databaseCommandQueue.js'; +import type { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); @@ -35,7 +39,8 @@ integration('database command queue', () => { requestId, target: 'ENGINE', eventType: 'vacation', - payload: { type: 'vacation', requestId, generalId: 7 } as GamePrisma.InputJsonValue, + actorUserId: 'user-7', + payload: { type: 'vacation', requestId, userId: 'user-7', generalId: 7 } as GamePrisma.InputJsonValue, }, }); @@ -44,7 +49,7 @@ integration('database command queue', () => { const [firstCommands, secondCommands] = await Promise.all([first.drain(), second.drain()]); const commands = firstCommands.concat(secondCommands); - expect(commands).toEqual([{ type: 'vacation', requestId, generalId: 7 }]); + expect(commands).toEqual([{ type: 'vacation', requestId, userId: 'user-7', generalId: 7 }]); await first.publishCommandResult(requestId, { type: 'vacation', ok: true, generalId: 7 }); const stored = await db.inputEvent.findUniqueOrThrow({ where: { requestId } }); @@ -64,7 +69,13 @@ integration('database command queue', () => { requestId: expiredId, target: 'ENGINE', eventType: 'vacation', - payload: { type: 'vacation', requestId: expiredId, generalId: 8 } as GamePrisma.InputJsonValue, + actorUserId: 'user-8', + payload: { + type: 'vacation', + requestId: expiredId, + userId: 'user-8', + generalId: 8, + } as GamePrisma.InputJsonValue, status: 'PROCESSING', processingAt: new Date(Date.now() - 120_000), lockedBy: 'dead-worker', @@ -74,7 +85,13 @@ integration('database command queue', () => { requestId: activeId, target: 'ENGINE', eventType: 'vacation', - payload: { type: 'vacation', requestId: activeId, generalId: 9 } as GamePrisma.InputJsonValue, + actorUserId: 'user-9', + payload: { + type: 'vacation', + requestId: activeId, + userId: 'user-9', + generalId: 9, + } as GamePrisma.InputJsonValue, status: 'PROCESSING', processingAt: new Date(), lockedBy: 'active-worker', @@ -87,7 +104,7 @@ integration('database command queue', () => { await queue.initialize(); const commands = await queue.drain(); - expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, generalId: 8 }]); + expect(commands).toEqual([{ type: 'vacation', requestId: expiredId, userId: 'user-8', generalId: 8 }]); expect(await db.inputEvent.findUniqueOrThrow({ where: { requestId: activeId } })).toMatchObject({ status: 'PROCESSING', lockedBy: 'active-worker', @@ -101,9 +118,11 @@ integration('database command queue', () => { requestId, target: 'ENGINE', eventType: 'vacation', + actorUserId: 'user-10', payload: { type: 'vacation', requestId, + userId: 'user-10', generalId: 10, } as GamePrisma.InputJsonValue, }, @@ -113,20 +132,16 @@ integration('database command queue', () => { const stale = new DatabaseTurnDaemonCommandQueue(db); for (const attempt of [1, 2, 3]) { await expect(owner.drain()).resolves.toEqual([ - { type: 'vacation', requestId, generalId: 10 }, + { type: 'vacation', requestId, userId: 'user-10', generalId: 10 }, ]); await stale.publishCommandError(requestId, new Error('stale worker failure')); - await expect( - db.inputEvent.findUniqueOrThrow({ where: { requestId } }) - ).resolves.toMatchObject({ + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ status: 'PROCESSING', attempts: attempt, }); await owner.publishCommandError(requestId, new Error('injected command failure')); - await expect( - db.inputEvent.findUniqueOrThrow({ where: { requestId } }) - ).resolves.toMatchObject({ + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ status: attempt < 3 ? 'PENDING' : 'FAILED', attempts: attempt, error: 'injected command failure', @@ -137,4 +152,81 @@ integration('database command queue', () => { await expect(owner.drain()).resolves.toEqual([]); }); + + it('fails an actor-bound payload that omits userId instead of dispatching it', async () => { + const requestId = 'integration:engine:missing-user-id'; + await db.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: 'vacation', + payload: { type: 'vacation', requestId, generalId: 7 } as GamePrisma.InputJsonValue, + }, + }); + + const queue = new DatabaseTurnDaemonCommandQueue(db); + await expect(queue.drain()).resolves.toEqual([]); + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ + status: 'FAILED', + error: 'Invalid command payload for vacation', + }); + }); + + it('stores a stale-owner rejection once and never redispatches the exact durable request', async () => { + const requestId = 'integration:engine:stale-owner-replay'; + const command: TurnDaemonCommand = { + type: 'vacation', + requestId, + userId: 'old-owner', + generalId: 7, + }; + await db.inputEvent.create({ + data: { + requestId, + target: 'ENGINE', + eventType: command.type, + actorUserId: command.userId, + payload: command as GamePrisma.InputJsonValue, + }, + }); + + const mutation = vi.fn(); + const world = { + getGeneralById: vi.fn(() => ({ id: command.generalId, userId: 'new-owner' })), + updateGeneral: mutation, + updateNation: mutation, + createTroop: mutation, + updateTroop: mutation, + removeTroop: mutation, + pushLog: mutation, + queueMessage: mutation, + } as unknown as InMemoryTurnWorld; + const handler = createTurnDaemonCommandHandler({ world }); + const handle = vi.spyOn(handler, 'handle'); + const owner = new DatabaseTurnDaemonCommandQueue(db); + + const claimed = await owner.drain(); + expect(claimed).toEqual([command]); + const result = await db.$transaction((transaction) => handler.handle(claimed[0]!, { db: transaction })); + expect(result).toMatchObject({ + type: 'commandRejected', + ok: false, + commandType: command.type, + }); + await owner.publishCommandResult(requestId, result!); + + await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({ + status: 'SUCCEEDED', + attempts: 1, + actorUserId: command.userId, + eventType: command.type, + payload: command, + result, + lockedBy: null, + leaseUntil: null, + }); + await expect(new DatabaseTurnDaemonCommandQueue(db).drain()).resolves.toEqual([]); + expect(handle).toHaveBeenCalledOnce(); + expect(mutation).not.toHaveBeenCalled(); + }); }); diff --git a/app/game-engine/test/inputEventAtomicity.test.ts b/app/game-engine/test/inputEventAtomicity.test.ts index cfddbf24..de4e9b5c 100644 --- a/app/game-engine/test/inputEventAtomicity.test.ts +++ b/app/game-engine/test/inputEventAtomicity.test.ts @@ -176,6 +176,7 @@ describe('input event atomicity', () => { queue.enqueue({ type: 'auctionBid', requestId: 'event-1', + userId: 'user-7', auctionId: 3, generalId: 7, amount: 1000, @@ -239,7 +240,7 @@ describe('input event atomicity', () => { } ); - queue.enqueue({ type: 'vacation', requestId: 'event-uow', generalId: 7 }); + queue.enqueue({ type: 'vacation', requestId: 'event-uow', userId: 'user-7', generalId: 7 }); const loop = lifecycle.start(); await responded; @@ -304,7 +305,7 @@ describe('input event atomicity', () => { } ); - queue.enqueue({ type: 'vacation', requestId: 'event-2', generalId: 7 }); + queue.enqueue({ type: 'vacation', requestId: 'event-2', userId: 'user-7', generalId: 7 }); const loop = lifecycle.start(); await errorObserved; @@ -374,7 +375,7 @@ describe('input event atomicity', () => { } ); - queue.enqueue({ type: 'vacation', requestId: 'event-3', generalId: 7 }); + queue.enqueue({ type: 'vacation', requestId: 'event-3', userId: 'user-7', generalId: 7 }); const loop = lifecycle.start(); await errorObserved; diff --git a/app/game-engine/test/myInformationCommands.test.ts b/app/game-engine/test/myInformationCommands.test.ts index fd5b7206..f01f2754 100644 --- a/app/game-engine/test/myInformationCommands.test.ts +++ b/app/game-engine/test/myInformationCommands.test.ts @@ -55,7 +55,7 @@ const buildGeneral = (overrides: Partial = {}): TurnGeneral => ({ turnTime: new Date('0185-01-01T00:00:00Z'), recentWarTime: null, role: { - items: { horse: 'che_명마', weapon: null, book: null, item: null }, + items: { horse: 'che_명마_02_조랑', weapon: null, book: null, item: null }, personality: null, specialDomestic: null, specialWar: null, @@ -103,7 +103,21 @@ const buildWorld = ( const snapshot: TurnWorldSnapshot = { generals: [general], cities: [], - nations: [], + nations: [ + { + id: 1, + name: '테스트국', + color: '#111111', + typeCode: 'che_중립', + level: 1, + capitalCityId: null, + chiefGeneralId: 7, + gold: 0, + rice: 0, + power: 0, + meta: {}, + }, + ], troops: [], diplomacy: [], events: [], @@ -216,6 +230,7 @@ describe('my information world commands', () => { await expect( fixture.handler.handle({ type: 'setMySetting', + userId: 'user-7', generalId: 7, settings: { tnmt: 9, @@ -250,6 +265,7 @@ describe('my information world commands', () => { await fixture.handler.handle({ type: 'setMySetting', + userId: 'user-7', generalId: 7, settings: { tnmt: 0, defence_train: 999, use_treatment: 1 }, }); @@ -270,6 +286,7 @@ describe('my information world commands', () => { const fixture = buildWorld(buildGeneral(), { scenarioEffect }); await fixture.handler.handle({ type: 'setMySetting', + userId: 'user-7', generalId: 7, settings: { defence_train: 999 }, }); @@ -279,26 +296,110 @@ describe('my information world commands', () => { it('applies vacation killturn and rejects it in automatic-turn mode', async () => { const allowed = buildWorld(); - await expect(allowed.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ ok: true }); + await expect( + allowed.handler.handle({ type: 'vacation', userId: 'user-7', generalId: 7 }) + ).resolves.toMatchObject({ ok: true }); expect(allowed.world.getGeneralById(7)?.meta.killturn).toBe(72); const blocked = buildWorld(buildGeneral(), { autorunLimit: true }); - await expect(blocked.handler.handle({ type: 'vacation', generalId: 7 })).resolves.toMatchObject({ + await expect( + blocked.handler.handle({ type: 'vacation', userId: 'user-7', generalId: 7 }) + ).resolves.toMatchObject({ ok: false, reason: '자동 턴인 경우에는 휴가 명령이 불가능합니다.', }); expect(blocked.world.getGeneralById(7)?.meta.killturn).toBe(12); }); - it('drops only the authenticated command target slot and rejects an empty slot', async () => { + it('drops a buyable item with only the Ref personal action log', async () => { const fixture = buildWorld(); await expect( - fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'weapon' }) + fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' }) ).resolves.toMatchObject({ ok: false }); await expect( - fixture.handler.handle({ type: 'dropItem', generalId: 7, itemType: 'horse' }) + fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'horse' }) ).resolves.toMatchObject({ ok: true }); expect(fixture.world.getGeneralById(7)?.role.items.horse).toBeNull(); + expect(fixture.world.peekDirtyState().logs).toEqual([ + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + text: '조랑(+2)을 버렸습니다.', + generalId: 7, + meta: {}, + }, + ]); + }); + + it('adds Ref global loss logs when dropping a non-buyable item', async () => { + const fixture = buildWorld( + buildGeneral({ + role: { + items: { horse: null, weapon: null, book: 'che_서적_14_한비자', item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + }) + ); + + await expect( + fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'book' }) + ).resolves.toMatchObject({ ok: true }); + expect(fixture.world.getGeneralById(7)?.role.items.book).toBeNull(); + expect(fixture.world.peekDirtyState().logs).toEqual([ + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + text: '한비자(+14)를 버렸습니다.', + generalId: 7, + meta: {}, + }, + { + scope: LogScope.SYSTEM, + category: LogCategory.SUMMARY, + format: LogFormat.MONTH, + text: '테스트장수가 한비자(+14)를 잃었습니다!', + meta: {}, + }, + { + scope: LogScope.SYSTEM, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + text: '【망실】테스트국테스트장수가 한비자(+14)를 잃었습니다!', + meta: {}, + }, + ]); + }); + + it('drops and logs an item stored under a mismatched equipment slot like Ref', async () => { + const fixture = buildWorld( + buildGeneral({ + role: { + items: { horse: null, weapon: 'che_명마_02_조랑', book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + }) + ); + + await expect( + fixture.handler.handle({ type: 'dropItem', userId: 'user-7', generalId: 7, itemType: 'weapon' }) + ).resolves.toMatchObject({ type: 'dropItem', ok: true, generalId: 7 }); + expect(fixture.world.getGeneralById(7)?.role.items.weapon).toBeNull(); + expect(fixture.world.peekDirtyState().logs).toEqual([ + { + scope: LogScope.GENERAL, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + text: '조랑(+2)을 버렸습니다.', + generalId: 7, + meta: {}, + }, + ]); }); it('executes pre-open uprising through the action stack without advancing the turn clock', async () => { @@ -499,6 +600,7 @@ describe('my information world commands', () => { }); it('loads the internal recruitment acceptance action outside the selectable command profile', async () => { + const originalLastTurn = { command: '전투태세', arg: { term: 3 } }; const recipient = buildGeneral({ id: 8, userId: 'user-8', @@ -506,6 +608,7 @@ describe('my information world commands', () => { nationId: 0, cityId: 1, officerLevel: 0, + lastTurn: originalLastTurn, }); const recruiter = buildGeneral({ id: 9, @@ -566,6 +669,7 @@ describe('my information world commands', () => { nationId: 2, cityId: 2, officerLevel: 1, + lastTurn: originalLastTurn, }); expect(fixture.world.getGeneralById(recruiter.id)).toMatchObject({ experience: recruiter.experience + 100, @@ -592,6 +696,75 @@ describe('my information world commands', () => { ]); }); + it('accepts a recruitment letter after the recruiter was deleted and preserves the reserved command', async () => { + const deletedRecruiterId = 99; + const originalLastTurn = { command: '내정 특기 초기화', arg: { phase: 2 } }; + const recipient = buildGeneral({ + id: 8, + userId: 'user-8', + name: '재야장수', + nationId: 0, + cityId: 1, + officerLevel: 0, + lastTurn: originalLastTurn, + }); + const map = { + id: 'test', + name: 'test', + cities: [buildMapCity(1, [2]), buildMapCity(2, [1])], + }; + const fixture = buildImmediateActionWorld({ + general: recipient, + cities: [ + { id: 1, name: '낙양', nationId: 0, supplyState: 1, meta: {} }, + { id: 2, name: '장안', nationId: 2, supplyState: 1, meta: {} }, + ] as TurnWorldSnapshot['cities'], + nations: [ + { + id: 2, + name: '등용국', + color: '#222222', + typeCode: 'che_중립', + level: 1, + capitalCityId: 2, + chiefGeneralId: deletedRecruiterId, + gold: 0, + rice: 0, + power: 0, + meta: { gennum: 1 }, + }, + ] as TurnWorldSnapshot['nations'], + map, + }); + const executor = await createImmediateGeneralActionExecutor({ + world: fixture.world, + reservedTurns: fixture.reservedTurns, + scenarioMeta: fixture.scenarioMeta, + map, + commandProfile: { general: ['che_등용'], nation: [] }, + }); + + await expect( + executor.execute({ + actionKey: 'che_등용수락', + generalId: recipient.id, + rng: new RandUtil(new LiteHashDRBG('accept-deleted-recruiter-letter')), + args: { destNationId: 2, destGeneralId: deletedRecruiterId }, + }) + ).resolves.toEqual({ ok: true }); + + expect(fixture.world.getGeneralById(recipient.id)).toMatchObject({ + nationId: 2, + cityId: 2, + officerLevel: 1, + lastTurn: originalLastTurn, + }); + expect(fixture.world.getNationById(2)?.meta.gennum).toBe(2); + expect(fixture.world.peekDirtyState().logs).not.toEqual( + expect.arrayContaining([expect.objectContaining({ generalId: deletedRecruiterId })]) + ); + }); + it('preserves the Ref uprising precheck order and messages after the game starts', async () => { const general = buildGeneral({ nationId: 1, cityId: 1 }); const fixture = buildImmediateActionWorld({ diff --git a/app/game-engine/test/nationPersonnelManagement.test.ts b/app/game-engine/test/nationPersonnelManagement.test.ts index 11391432..b8dd09cf 100644 --- a/app/game-engine/test/nationPersonnelManagement.test.ts +++ b/app/game-engine/test/nationPersonnelManagement.test.ts @@ -1,10 +1,17 @@ import { describe, expect, it } from 'vitest'; -import type { TriggerValue, TurnSchedule } from '@sammo-ts/logic'; +import { + loadActionModuleBundle, + LogFormat, + type GeneralActionModule, + type TriggerValue, + type TurnSchedule, +} from '@sammo-ts/logic'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; +import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js'; const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; @@ -25,7 +32,7 @@ const buildGeneral = (id: number, overrides: Partial = {}): TurnGen specialWar: null, }, triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, - meta: { killturn: 12, belong: 5, permission: 'normal' }, + meta: { killturn: 12, belong: 5, permission: 'normal', explevel: 10, dedlevel: 5 }, penalty: {}, officerLevel: 1, experience: 1_000, @@ -48,6 +55,11 @@ const buildWorld = (options: { cityMeta?: Record; currentYear?: number; scenarioConst?: Record; + generalActionModules?: ReadonlyArray; + clock?: Pick< + TurnWorldState, + 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick' + >; }) => { const state: TurnWorldState = { id: 1, @@ -56,6 +68,7 @@ const buildWorld = (options: { tickSeconds: 600, lastTurnTime: new Date('0185-01-01T00:00:00Z'), meta: { killturn: 24, scenarioMeta: { startYear: 180 } }, + ...options.clock, }; const snapshot: TurnWorldSnapshot = { generals: options.generals ?? [ @@ -129,14 +142,24 @@ const buildWorld = (options: { }, }; const world = new InMemoryTurnWorld(state, snapshot, { schedule }); - return { world, handler: createTurnDaemonCommandHandler({ world }) }; + return { + world, + handler: createTurnDaemonCommandHandler({ world, generalActionModules: options.generalActionModules }), + }; }; describe('nation personnel world commands', () => { it('allows any unlocked head officer to appoint and preserves legacy officer state', async () => { const { world, handler } = buildWorld({}); await expect( - handler.handle({ type: 'appoint', generalId: 2, destGeneralId: 3, destCityId: 0, officerLevel: 9 }) + handler.handle({ + type: 'appoint', + userId: 'user-2', + generalId: 2, + destGeneralId: 3, + destCityId: 0, + officerLevel: 9, + }) ).resolves.toMatchObject({ ok: true }); expect(world.getGeneralById(3)).toMatchObject({ officerLevel: 9, @@ -154,6 +177,7 @@ describe('nation personnel world commands', () => { await expect( fixture.handler.handle({ type: 'appoint', + userId: 'user-3', generalId: 3, destGeneralId: 2, destCityId: 0, @@ -163,6 +187,7 @@ describe('nation personnel world commands', () => { await expect( fixture.handler.handle({ type: 'appoint', + userId: 'user-2', generalId: 2, destGeneralId: 3, destCityId: 0, @@ -175,6 +200,7 @@ describe('nation personnel world commands', () => { await expect( locked.handler.handle({ type: 'appoint', + userId: 'user-2', generalId: 2, destGeneralId: 3, destCityId: 0, @@ -196,6 +222,7 @@ describe('nation personnel world commands', () => { await expect( fixture.handler.handle({ type: 'appoint', + userId: 'user-2', generalId: 2, destGeneralId: 3, destCityId: 1, @@ -215,6 +242,7 @@ describe('nation personnel world commands', () => { await expect( locked.handler.handle({ type: 'appoint', + userId: 'user-2', generalId: 2, destGeneralId: 3, destCityId: 1, @@ -237,6 +265,7 @@ describe('nation personnel world commands', () => { await expect( fixture.handler.handle({ type: 'changePermission', + userId: 'user-2', generalId: 2, isAmbassador: true, targetGeneralIds: [3], @@ -245,6 +274,7 @@ describe('nation personnel world commands', () => { await expect( fixture.handler.handle({ type: 'changePermission', + userId: 'user-1', generalId: 1, isAmbassador: true, targetGeneralIds: [3, 5, 6], @@ -254,6 +284,7 @@ describe('nation personnel world commands', () => { await expect( fixture.handler.handle({ type: 'changePermission', + userId: 'user-1', generalId: 1, isAmbassador: true, targetGeneralIds: [2, 3, 4], @@ -263,6 +294,7 @@ describe('nation personnel world commands', () => { await expect( fixture.handler.handle({ type: 'changePermission', + userId: 'user-1', generalId: 1, isAmbassador: true, targetGeneralIds: [2, 3], @@ -271,6 +303,22 @@ describe('nation personnel world commands', () => { expect(fixture.world.getGeneralById(2)?.meta.permission).toBe('ambassador'); expect(fixture.world.getGeneralById(3)?.meta.permission).toBe('ambassador'); expect(fixture.world.getGeneralById(4)?.meta.permission).toBe('normal'); + + const clearCommand = normalizeTurnDaemonCommand({ + requestId: 'clear-ambassadors', + sentAt: '2026-01-01T00:00:00.000Z', + command: { + type: 'changePermission', + userId: 'user-1', + generalId: 1, + isAmbassador: true, + targetGeneralIds: [], + }, + }); + expect(clearCommand).toMatchObject({ type: 'changePermission', targetGeneralIds: [] }); + await expect(fixture.handler.handle(clearCommand!)).resolves.toMatchObject({ ok: true }); + expect(fixture.world.getGeneralById(2)?.meta.permission).toBe('normal'); + expect(fixture.world.getGeneralById(3)?.meta.permission).toBe('normal'); }); it('kicks for an unlocked head officer with resource, troop, permission, and log side effects', async () => { @@ -280,7 +328,14 @@ describe('nation personnel world commands', () => { rice: 3_000, experience: 1_000, dedication: 2_000, - meta: { killturn: 12, permission: 'normal', belong: 8, betray: 1 }, + meta: { + killturn: 12, + permission: 'normal', + belong: 8, + betray: 1, + explevel: 10, + dedlevel: 5, + }, }); const member = buildGeneral(4, { troopId: 3 }); const fixture = buildWorld({ @@ -289,7 +344,9 @@ describe('nation personnel world commands', () => { }); fixture.world.createTroop({ id: 3, nationId: 1, name: '추방대' }); - await expect(fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 })).resolves.toMatchObject({ + await expect( + fixture.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 }) + ).resolves.toMatchObject({ ok: true, }); expect(fixture.world.getGeneralById(3)).toMatchObject({ @@ -309,7 +366,59 @@ describe('nation personnel world commands', () => { rice: 22_000, meta: expect.objectContaining({ gennum: 3 }), }); - expect(fixture.world.peekDirtyState().logs).toHaveLength(2); + expect(fixture.world.peekDirtyState().logs).toEqual([ + expect.objectContaining({ scope: 'SYSTEM', category: 'SUMMARY' }), + expect.objectContaining({ + scope: 'GENERAL', + category: 'ACTION', + format: LogFormat.PLAIN, + generalId: 3, + text: '에서 추방당했습니다.', + }), + expect.objectContaining({ + scope: 'GENERAL', + category: 'ACTION', + text: expect.stringContaining('레벨다운'), + }), + expect.objectContaining({ scope: 'GENERAL', category: 'HISTORY' }), + ]); + }); + + it('applies Ref-ordered personality and item modifiers before legacy INT rounding on kick', async () => { + const modules = (await loadActionModuleBundle()).general; + const target = buildGeneral(3, { + experience: 1_001, + dedication: 2_001, + role: { + items: { horse: null, weapon: null, book: null, item: 'che_명성_구석' }, + personality: 'che_대의', + specialDomestic: null, + specialWar: null, + }, + meta: { + killturn: 12, + permission: 'normal', + belong: 8, + betray: 1, + explevel: 10, + dedlevel: 5, + }, + }); + const fixture = buildWorld({ + generals: [buildGeneral(1, { officerLevel: 12 }), buildGeneral(2, { officerLevel: 5 }), target], + generalActionModules: modules, + }); + + await expect( + fixture.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 }) + ).resolves.toMatchObject({ + ok: true, + }); + expect(fixture.world.getGeneralById(3)).toMatchObject({ + experience: 803, + dedication: 1_701, + meta: expect.objectContaining({ explevel: 8, dedlevel: 5, betray: 2 }), + }); }); it('rejects self, ruler, head officer, and ambassador targets without partial mutation', async () => { @@ -336,7 +445,12 @@ describe('nation personnel world commands', () => { const originalTarget = fixture.world.getGeneralById(testCase.targetId); await expect( - fixture.handler.handle({ type: 'kick', generalId: 2, destGeneralId: testCase.targetId }) + fixture.handler.handle({ + type: 'kick', + userId: 'user-2', + generalId: 2, + destGeneralId: testCase.targetId, + }) ).resolves.toMatchObject({ ok: false, reason: testCase.reason }); expect(fixture.world.getGeneralById(testCase.targetId), testCase.label).toEqual(originalTarget); expect(fixture.world.getGeneralById(2)?.meta.killturn, testCase.label).toBe(12); @@ -354,7 +468,7 @@ describe('nation personnel world commands', () => { buildGeneral(3, { meta: { killturn: 12, belong: 8, permission: 'normal', betray: 1 } }), ], }); - await early.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 }); + await early.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 }); expect(early.world.getGeneralById(3)).toMatchObject({ experience: 850, dedication: 1_700, @@ -372,12 +486,54 @@ describe('nation personnel world commands', () => { buildGeneral(3, { npcState: 2 }), ], }); - await npc.handler.handle({ type: 'kick', generalId: 2, destGeneralId: 3 }); + await npc.handler.handle({ type: 'kick', userId: 'user-2', generalId: 2, destGeneralId: 3 }); expect(npc.world.peekDirtyState().messages).toHaveLength(1); expect(npc.world.peekDirtyState().messages[0]).toMatchObject({ msgType: 'public', - src: { generalId: 3, nationId: 1 }, - dest: { generalId: 3, nationId: 1 }, + src: { generalId: 3, nationId: 1, icon: 'https://sam-image.hided.net/icons/default.jpg' }, + dest: { generalId: 3, nationId: 1, icon: 'https://sam-image.hided.net/icons/default.jpg' }, }); }); + + it('timestamps a queued NPC kick message from the durable accepted instant', async () => { + const acceptedAt = new Date('2026-01-01T00:10:00.000Z'); + const fixture = buildWorld({ + currentYear: 185, + scenarioConst: { npcBanMessageProb: 1 }, + clock: { + clockBaseTime: new Date('0185-01-01T00:00:00.000Z'), + clockTick: 0, + clockMode: 'realtime', + clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'), + lastTurnTick: 0, + }, + generals: [ + buildGeneral(1, { officerLevel: 12 }), + buildGeneral(2, { officerLevel: 5 }), + buildGeneral(3, { npcState: 2 }), + ], + }); + const command = { + type: 'kick' as const, + requestId: 'kick-accepted-time', + userId: 'user-2', + generalId: 2, + destGeneralId: 3, + }; + const db = { + inputEvent: { + findUnique: async () => ({ + createdAt: acceptedAt, + actorUserId: command.userId, + target: 'ENGINE', + eventType: command.type, + }), + }, + }; + + await expect(fixture.handler.handle(command, { db: db as never })).resolves.toMatchObject({ ok: true }); + expect(fixture.world.peekDirtyState().messages[0]?.time).toEqual( + new Date('0185-01-01T00:10:00.000Z') + ); + }); }); diff --git a/app/game-engine/test/nationSettingMutation.test.ts b/app/game-engine/test/nationSettingMutation.test.ts new file mode 100644 index 00000000..e28e12df --- /dev/null +++ b/app/game-engine/test/nationSettingMutation.test.ts @@ -0,0 +1,424 @@ +import { describe, expect, it } from 'vitest'; + +import type { TurnDaemonCommand } from '@sammo-ts/common'; +import type { TurnSchedule } from '@sammo-ts/logic'; + +import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js'; +import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { applyNationSettingMutation } from '../src/turn/nationSettingMutation.js'; +import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; + +type SetNationSettingCommand = Extract; +type NationSettingMutation = SetNationSettingCommand['mutation']; + +const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; +const acceptedAt = new Date('2026-02-03T04:05:06.000Z'); + +const general: TurnGeneral = { + id: 1, + userId: 'owner-1', + name: '테스트군주', + nationId: 1, + cityId: 1, + troopId: 0, + stats: { leadership: 75, strength: 40, intelligence: 70 }, + turnTime: new Date('0185-01-01T00:00:00.000Z'), + recentWarTime: null, + role: { + items: { horse: null, weapon: null, book: null, item: null }, + personality: null, + specialDomestic: null, + specialWar: null, + }, + triggerState: { flags: {}, counters: {}, modifiers: {}, meta: {} }, + meta: { killturn: 24 }, + penalty: {}, + officerLevel: 12, + experience: 0, + dedication: 0, + injury: 0, + gold: 1_000, + rice: 1_000, + crew: 0, + crewTypeId: 1100, + train: 0, + atmos: 0, + age: 30, + npcState: 0, +}; + +const snapshot: TurnWorldSnapshot = { + generals: [general], + cities: [ + { + id: 1, + name: '허창', + nationId: 1, + level: 7, + state: 0, + population: 100_000, + populationMax: 200_000, + agriculture: 1_000, + agricultureMax: 2_000, + commerce: 1_000, + commerceMax: 2_000, + security: 1_000, + securityMax: 2_000, + supplyState: 1, + frontState: 0, + defence: 1_000, + defenceMax: 2_000, + wall: 1_000, + wallMax: 2_000, + meta: {}, + }, + ], + nations: [ + { + id: 1, + name: '위', + color: '#777777', + capitalCityId: 1, + chiefGeneralId: 1, + gold: 10_000, + rice: 20_000, + power: 0, + level: 3, + typeCode: 'che_법가', + meta: { tech: 3_000, preserved: 'yes' }, + }, + ], + troops: [], + diplomacy: [], + events: [], + initialEvents: [], + scenarioConfig: { + stat: { total: 300, min: 10, max: 80, npcTotal: 150, npcMax: 75, npcMin: 10, chiefMin: 65 }, + iconPath: '', + map: {}, + const: {}, + environment: { mapName: 'test', unitSet: 'basic' }, + }, + scenarioMeta: { + title: 'test', + startYear: 180, + life: null, + fiction: null, + history: [], + ignoreDefaultEvents: false, + }, + map: { + id: 'test', + name: 'test', + cities: [], + defaults: { trust: 50, trade: 100, supplyState: 1, frontState: 0 }, + }, +}; + +const state: TurnWorldState = { + id: 1, + currentYear: 185, + currentMonth: 1, + tickSeconds: 600, + lastTurnTime: new Date('0185-01-01T00:00:00.000Z'), + clockBaseTime: new Date('0185-01-01T00:00:00.000Z'), + clockTick: 0, + clockMode: 'manual', + clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'), + lastTurnTick: 0, + meta: { killturn: 24 }, +}; + +const createWorld = (options?: { + general?: Partial; + nationMeta?: TurnWorldSnapshot['nations'][number]['meta']; + worldMeta?: Record; +}): InMemoryTurnWorld => { + const nextSnapshot = structuredClone(snapshot); + nextSnapshot.generals[0] = { ...nextSnapshot.generals[0]!, ...options?.general }; + nextSnapshot.nations[0]!.meta = { + ...nextSnapshot.nations[0]!.meta, + ...options?.nationMeta, + }; + return new InMemoryTurnWorld( + { + ...state, + meta: { ...state.meta, ...options?.worldMeta }, + }, + nextSnapshot, + { schedule } + ); +}; + +const command = ( + mutation: NationSettingMutation, + overrides?: Partial> +): SetNationSettingCommand => ({ + type: 'setNationSetting', + requestId: 'nation-setting-test', + userId: 'owner-1', + generalId: 1, + nationId: 1, + mutation, + ...overrides, +}); + +describe('nation setting mutation', () => { + it('keeps raw required validation at the API boundary and only enforces code-point length durably', () => { + const normalizeNotice = (message: string) => + normalizeTurnDaemonCommand({ + requestId: 'nation-setting-text-boundary', + sentAt: acceptedAt.toISOString(), + command: command({ kind: 'notice', message }), + }); + + expect(normalizeNotice('')).toMatchObject({ + type: 'setNationSetting', + mutation: { kind: 'notice', message: '' }, + }); + expect(normalizeNotice(' \t\n\v\0')).toMatchObject({ + type: 'setNationSetting', + mutation: { kind: 'notice', message: ' \t\n\v\0' }, + }); + expect(normalizeNotice(' ')).toMatchObject({ + type: 'setNationSetting', + mutation: { kind: 'notice', message: ' ' }, + }); + expect(normalizeNotice('😀'.repeat(16_384))).not.toBeNull(); + expect(normalizeNotice('😀'.repeat(16_385))).toBeNull(); + expect( + normalizeTurnDaemonCommand({ + requestId: 'nation-setting-scout-text-boundary', + sentAt: acceptedAt.toISOString(), + command: command({ kind: 'scoutMessage', message: '😀'.repeat(1_000) }), + }) + ).not.toBeNull(); + expect( + normalizeTurnDaemonCommand({ + requestId: 'nation-setting-scout-text-overflow', + sentAt: acceptedAt.toISOString(), + command: command({ kind: 'scoutMessage', message: '😀'.repeat(1_001) }), + }) + ).toBeNull(); + }); + + it.each([ + ['notice', 'notice', 'nationNotice'], + ['scoutMessage', 'infoText', null], + ] as const)('stores an empty sanitized %s string like Ref', (kind, metaKey, structuredMetaKey) => { + const normalized = normalizeTurnDaemonCommand({ + requestId: `nation-setting-empty-${kind}`, + sentAt: acceptedAt.toISOString(), + command: command({ kind, message: '' }), + }); + expect(normalized).not.toBeNull(); + if (!normalized || normalized.type !== 'setNationSetting') { + throw new Error('setNationSetting normalization failed'); + } + + const world = createWorld(); + expect(applyNationSettingMutation({ world, command: normalized, acceptedAt })).toMatchObject({ + type: 'setNationSetting', + ok: true, + }); + expect(world.getNationById(1)?.meta[metaKey]).toBe(''); + if (structuredMetaKey) { + expect(world.getNationById(1)?.meta[structuredMetaKey]).toMatchObject({ msg: '' }); + } + }); + + it('rechecks owner, nation, and permission at execution time without mutating nation metadata on rejection', () => { + const cases: Array<{ + name: string; + world: InMemoryTurnWorld; + command: SetNationSettingCommand; + code: 'FORBIDDEN' | 'PRECONDITION_FAILED'; + }> = [ + { + name: 'owner changed', + world: createWorld(), + command: command({ kind: 'rate', amount: 20 }, { userId: 'other-owner' }), + code: 'FORBIDDEN', + }, + { + name: 'nation changed', + world: createWorld({ general: { nationId: 2 } }), + command: command({ kind: 'rate', amount: 20 }), + code: 'PRECONDITION_FAILED', + }, + { + name: 'permission revoked', + world: createWorld({ general: { officerLevel: 2 } }), + command: command({ kind: 'rate', amount: 20 }), + code: 'FORBIDDEN', + }, + ]; + + for (const testCase of cases) { + const before = structuredClone(testCase.world.getNationById(1)?.meta); + expect( + applyNationSettingMutation({ + world: testCase.world, + command: testCase.command, + acceptedAt, + }), + testCase.name + ).toMatchObject({ type: 'setNationSetting', ok: false, code: testCase.code }); + expect(testCase.world.getNationById(1)?.meta, testCase.name).toEqual(before); + } + }); + + it('preserves the special editable-permission rules for high officers and low ambassadors', () => { + const highOfficer = createWorld({ + general: { officerLevel: 5, penalty: { noChief: true } }, + }); + expect( + applyNationSettingMutation({ + world: highOfficer, + command: command({ kind: 'rate', amount: 20 }, { requestId: 'high-officer' }), + acceptedAt, + }) + ).toMatchObject({ type: 'setNationSetting', ok: true }); + expect(highOfficer.getNationById(1)?.meta).toMatchObject({ rate: 20, preserved: 'yes' }); + + const lowAmbassador = createWorld({ + general: { officerLevel: 2, meta: { killturn: 24, permission: 'ambassador' } }, + }); + expect( + applyNationSettingMutation({ + world: lowAmbassador, + command: command({ kind: 'bill', amount: 100 }, { requestId: 'low-ambassador' }), + acceptedAt, + }) + ).toMatchObject({ type: 'setNationSetting', ok: true }); + expect(lowAmbassador.getNationById(1)?.meta).toMatchObject({ bill: 100, preserved: 'yes' }); + }); + + it('stores the notice text and author snapshot at logical game time', () => { + const world = createWorld(); + const result = applyNationSettingMutation({ + world, + command: command({ kind: 'notice', message: '새 국가 방침' }, { requestId: 'notice-logical-time' }), + acceptedAt, + }); + + expect(result).toMatchObject({ + type: 'setNationSetting', + ok: true, + nationId: 1, + updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/), + }); + expect(world.getNationById(1)?.meta).toMatchObject({ + preserved: 'yes', + notice: '새 국가 방침', + nationNotice: { + date: '0185-01-01 09:00:00', + msg: '새 국가 방침', + author: '테스트군주', + authorID: 1, + }, + _updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/), + }); + }); + + it('treats an absent war-setting counter as zero and leaves metadata unchanged', () => { + const world = createWorld(); + const before = structuredClone(world.getNationById(1)?.meta); + + expect( + applyNationSettingMutation({ + world, + command: command({ kind: 'blockWar', value: true }), + acceptedAt, + }) + ).toEqual({ + type: 'setNationSetting', + ok: false, + code: 'BAD_REQUEST', + reason: '잔여 횟수가 부족합니다.', + nationId: 1, + }); + expect(world.getNationById(1)?.meta).toEqual(before); + }); + + it('consumes the current war-setting counter sequentially and rejects after exhaustion', () => { + const world = createWorld({ nationMeta: { available_war_setting_cnt: 2 } }); + + expect( + applyNationSettingMutation({ + world, + command: command({ kind: 'blockWar', value: true }, { requestId: 'block-war-1' }), + acceptedAt, + }) + ).toMatchObject({ type: 'setNationSetting', ok: true, availableCnt: 1 }); + expect(world.getNationById(1)?.meta).toMatchObject({ war: 1, available_war_setting_cnt: 1 }); + + expect( + applyNationSettingMutation({ + world, + command: command({ kind: 'blockWar', value: false }, { requestId: 'block-war-2' }), + acceptedAt, + }) + ).toMatchObject({ type: 'setNationSetting', ok: true, availableCnt: 0 }); + expect(world.getNationById(1)?.meta).toMatchObject({ war: 0, available_war_setting_cnt: 0 }); + + const beforeRejected = structuredClone(world.getNationById(1)?.meta); + expect( + applyNationSettingMutation({ + world, + command: command({ kind: 'blockWar', value: true }, { requestId: 'block-war-3' }), + acceptedAt, + }) + ).toMatchObject({ type: 'setNationSetting', ok: false, code: 'BAD_REQUEST' }); + expect(world.getNationById(1)?.meta).toEqual(beforeRejected); + }); + + it.each([ + ['missing', undefined], + ['null', null], + ['false', false], + ['zero', 0], + ['empty string', ''], + ['string zero', '0'], + ['empty array', []], + ])('allows scout changes when the legacy lock value is falsey: %s', (_name, lockValue) => { + const world = createWorld({ + worldMeta: lockValue === undefined ? {} : { block_change_scout: lockValue }, + }); + + expect( + applyNationSettingMutation({ + world, + command: command({ kind: 'blockScout', value: true }), + acceptedAt, + }) + ).toMatchObject({ type: 'setNationSetting', ok: true }); + expect(world.getNationById(1)?.meta).toMatchObject({ scout: 1, preserved: 'yes' }); + }); + + it.each([ + ['true', true], + ['one', 1], + ['string one', '1'], + ['non-empty array', [0]], + ['object', {}], + ])('rejects scout changes without mutation when the legacy lock value is truthy: %s', (_name, lockValue) => { + const world = createWorld({ worldMeta: { block_change_scout: lockValue } }); + const before = structuredClone(world.getNationById(1)?.meta); + + expect( + applyNationSettingMutation({ + world, + command: command({ kind: 'blockScout', value: true }), + acceptedAt, + }) + ).toEqual({ + type: 'setNationSetting', + ok: false, + code: 'FORBIDDEN', + reason: '임관 설정을 바꿀 수 없도록 설정되어 있습니다.', + nationId: 1, + }); + expect(world.getNationById(1)?.meta).toEqual(before); + }); +}); diff --git a/app/game-engine/test/npcPolicyLifecycle.test.ts b/app/game-engine/test/npcPolicyLifecycle.test.ts index 18ca5afe..009eb8e6 100644 --- a/app/game-engine/test/npcPolicyLifecycle.test.ts +++ b/app/game-engine/test/npcPolicyLifecycle.test.ts @@ -6,7 +6,7 @@ import { asRecord } from '@sammo-ts/common'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; import { AutorunNationPolicy } from '../src/turn/ai/policies.js'; import type { TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; -import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; +import { applyNpcPolicyMutation } from '../src/turn/npcPolicyMutation.js'; const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] }; const general: TurnGeneral = { @@ -83,7 +83,7 @@ const snapshot: TurnWorldSnapshot = { meta: { tech: 3_000, preserved: 'yes', _updatedAt: '2026-01-01T00:00:00.000Z' }, }, ], - troops: [], + troops: [{ id: 101, nationId: 1, name: '선봉부대' }], diplomacy: [], events: [], initialEvents: [], @@ -176,28 +176,71 @@ const unitSet: UnitSetDefinition = { }; describe('NPC policy lifecycle', () => { - it('applies one CAS-protected metadata command and the next AI instance consumes it without scheduler changes', async () => { + it('applies CAS-protected semantic policy changes and the next AI instance consumes them without scheduler changes', () => { const world = new InMemoryTurnWorld(state, snapshot, { schedule }); - const handler = createTurnDaemonCommandHandler({ world }); - const updates = { + const first = applyNpcPolicyMutation({ + world, + acceptedAt: new Date('2026-02-03T04:05:06.000Z'), + command: { + type: 'setNpcPolicy', + userId: 'owner-1', + generalId: 1, + nationId: 1, + requestId: 'npc-policy-values', + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + mutation: { kind: 'nationPolicy', values: { reqNationGold: 4_321 } }, + }, + }); + expect(first).toMatchObject({ + type: 'setNpcPolicy', + ok: true, + nationId: 1, + updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/), + }); + if (!first.ok) { + throw new Error(first.reason); + } + + world.updateNation(1, { + meta: { + ...world.getNationById(1)!.meta, + _updatedAt: '2026-02-03T04:05:06.500Z#unrelated-setting', + }, + }); + + const second = applyNpcPolicyMutation({ + world, + acceptedAt: new Date('2026-02-03T04:05:07.000Z'), + command: { + type: 'setNpcPolicy', + userId: 'owner-1', + generalId: 1, + nationId: 1, + requestId: 'npc-policy-priority', + expectedUpdatedAt: first.updatedAt, + mutation: { kind: 'nationPriority', priority: ['천도'] }, + }, + }); + expect(second).toMatchObject({ + type: 'setNpcPolicy', + ok: true, + nationId: 1, + updatedAt: expect.stringMatching(/^2026-02-03T04:05:07\.000Z#[0-9a-f]{16}$/), + }); + if (!second.ok) { + throw new Error(second.reason); + } + + const nation = world.getNationById(1)!; + expect(nation.meta).toMatchObject({ + preserved: 'yes', npc_nation_policy: { values: { reqNationGold: 4_321 }, priority: ['천도'], - valueSetter: '정책담당', + valueSetter: 'NPC군주', + prioritySetter: 'NPC군주', }, - }; - - await expect( - handler.handle({ - type: 'setNationMeta', - nationId: 1, - updates, - expectedUpdatedAt: '2026-01-01T00:00:00.000Z', - }) - ).resolves.toMatchObject({ type: 'setNationMeta', ok: true, nationId: 1 }); - - const nation = world.getNationById(1)!; - expect(nation.meta).toMatchObject({ preserved: 'yes', npc_nation_policy: updates.npc_nation_policy }); + }); const policy = new AutorunNationPolicy({ general: world.getGeneralById(1)!, aiOptions: null, @@ -214,17 +257,214 @@ describe('NPC policy lifecycle', () => { expect(policy.reqNpcWarGold).toBe(3_900); expect(policy.reqNpcWarRice).toBe(3_900); - await expect( - handler.handle({ - type: 'setNationMeta', - nationId: 1, - updates: { npc_nation_policy: { values: { reqNationGold: 9_999 } } }, - expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + const beforeConflict = structuredClone(world.getNationById(1)?.meta); + expect( + applyNpcPolicyMutation({ + world, + acceptedAt: new Date('2026-02-03T04:05:08.000Z'), + command: { + type: 'setNpcPolicy', + userId: 'owner-1', + generalId: 1, + nationId: 1, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + mutation: { kind: 'nationPolicy', values: { reqNationGold: 9_999 } }, + }, }) - ).resolves.toMatchObject({ type: 'setNationMeta', ok: false, reason: 'CONFLICT' }); + ).toMatchObject({ + type: 'setNpcPolicy', + ok: false, + code: 'CONFLICT', + currentUpdatedAt: second.updatedAt, + }); + expect(world.getNationById(1)?.meta).toEqual(beforeConflict); expect(asRecord(asRecord(world.getNationById(1)?.meta).npc_nation_policy).values).toEqual({ reqNationGold: 4_321, }); expect(world.getState()).toMatchObject({ currentYear: 185, currentMonth: 1, tickSeconds: 600 }); }); + + it('requires an exact nullable revision when policy metadata has never been versioned', () => { + const noRevisionSnapshot = structuredClone(snapshot); + delete noRevisionSnapshot.nations[0]!.meta._npcPolicyUpdatedAt; + delete noRevisionSnapshot.nations[0]!.meta._updatedAt; + const world = new InMemoryTurnWorld(state, noRevisionSnapshot, { schedule }); + const initialMeta = structuredClone(world.getNationById(1)?.meta); + + expect( + applyNpcPolicyMutation({ + world, + acceptedAt: new Date('2026-02-03T04:05:06.000Z'), + command: { + type: 'setNpcPolicy', + userId: 'owner-1', + generalId: 1, + nationId: 1, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + mutation: { kind: 'nationPriority', priority: ['천도'] }, + }, + }) + ).toMatchObject({ type: 'setNpcPolicy', ok: false, code: 'CONFLICT', currentUpdatedAt: null }); + expect(world.getNationById(1)?.meta).toEqual(initialMeta); + + const accepted = applyNpcPolicyMutation({ + world, + acceptedAt: new Date('2026-02-03T04:05:07.000Z'), + command: { + type: 'setNpcPolicy', + requestId: 'initial-null-revision', + userId: 'owner-1', + generalId: 1, + nationId: 1, + expectedUpdatedAt: null, + mutation: { kind: 'nationPriority', priority: ['천도'] }, + }, + }); + expect(accepted).toMatchObject({ + type: 'setNpcPolicy', + ok: true, + nationId: 1, + updatedAt: expect.stringMatching(/^2026-02-03T04:05:07\.000Z#[0-9a-f]{16}$/), + }); + if (!accepted.ok) { + throw new Error(accepted.reason); + } + expect(world.getNationById(1)?.meta).toMatchObject({ + preserved: 'yes', + _npcPolicyUpdatedAt: accepted.updatedAt, + npc_nation_policy: { priority: ['천도'] }, + }); + }); + + it('validates and merges policy intent against current ENGINE state without materialising defaults', () => { + const world = new InMemoryTurnWorld( + { + ...state, + clockBaseTime: new Date('0185-01-01T00:00:00.000Z'), + clockTick: 0, + clockMode: 'manual', + clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'), + lastTurnTick: 0, + }, + snapshot, + { schedule } + ); + world.updateNation(1, { + meta: { + ...world.getNationById(1)!.meta, + npc_nation_policy: { values: { reqNationRice: 456 }, preserved: 'root' }, + }, + }); + + const result = applyNpcPolicyMutation({ + world, + acceptedAt: new Date('2026-02-03T04:05:06.000Z'), + command: { + type: 'setNpcPolicy', + userId: 'owner-1', + generalId: 1, + nationId: 1, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + mutation: { + kind: 'nationPolicy', + values: { + reqNationGold: -100, + safeRecruitCityPopulationRatio: -0.5, + CombatForce: {}, + SupportForce: [101], + }, + }, + }, + }); + + expect(result).toMatchObject({ + type: 'setNpcPolicy', + ok: true, + nationId: 1, + updatedAt: expect.stringMatching(/^2026-02-03T04:05:06\.000Z#[0-9a-f]{16}$/), + }); + expect(asRecord(world.getNationById(1)?.meta).npc_nation_policy).toEqual({ + values: { + reqNationRice: 456, + reqNationGold: 0, + safeRecruitCityPopulationRatio: -0.5, + CombatForce: {}, + SupportForce: [101], + }, + preserved: 'root', + valueSetter: 'NPC군주', + valueSetTime: '0185-01-01 09:00:00', + }); + }); + + it('rejects stale authority, empty input, malformed combat targets, and lost CAS inside ENGINE', () => { + const world = new InMemoryTurnWorld(state, snapshot, { schedule }); + const baseCommand = { + type: 'setNpcPolicy' as const, + userId: 'owner-1', + generalId: 1, + nationId: 1, + expectedUpdatedAt: '2026-01-01T00:00:00.000Z', + }; + const acceptedAt = new Date('2026-02-03T04:05:06.000Z'); + + expect( + applyNpcPolicyMutation({ + world, + acceptedAt, + command: { ...baseCommand, mutation: { kind: 'nationPolicy', values: {} } }, + }) + ).toMatchObject({ ok: false, code: 'BAD_REQUEST' }); + expect( + applyNpcPolicyMutation({ + world, + acceptedAt, + command: { + ...baseCommand, + mutation: { kind: 'nationPolicy', values: { CombatForce: { 101: [1, 1, 1] } } }, + }, + }) + ).toMatchObject({ ok: false, code: 'BAD_REQUEST', reason: '101의 입력양식이 올바르지 않습니다.' }); + expect( + applyNpcPolicyMutation({ + world, + acceptedAt, + command: { + ...baseCommand, + mutation: { kind: 'nationPolicy', values: { CombatForce: { 101: [1, 1] } } }, + }, + }) + ).toMatchObject({ + ok: false, + code: 'BAD_REQUEST', + reason: '101의 도시 , 가 올바른 도시 번호가 아닙니다.', + }); + expect( + applyNpcPolicyMutation({ + world, + acceptedAt, + command: { ...baseCommand, mutation: { kind: 'nationPriority', priority: [] } }, + }) + ).toMatchObject({ ok: false, code: 'BAD_REQUEST' }); + expect( + applyNpcPolicyMutation({ + world, + acceptedAt, + command: { + ...baseCommand, + expectedUpdatedAt: '1999-01-01T00:00:00.000Z', + mutation: { kind: 'nationPriority', priority: ['천도'] }, + }, + }) + ).toMatchObject({ ok: false, code: 'CONFLICT' }); + + world.updateGeneral(1, { officerLevel: 2 }); + expect( + applyNpcPolicyMutation({ + world, + acceptedAt, + command: { ...baseCommand, mutation: { kind: 'nationPriority', priority: ['천도'] } }, + }) + ).toMatchObject({ ok: false, code: 'FORBIDDEN' }); + }); }); diff --git a/app/game-engine/test/oldNationArchive.test.ts b/app/game-engine/test/oldNationArchive.test.ts index b739e6d2..8c6792f2 100644 --- a/app/game-engine/test/oldNationArchive.test.ts +++ b/app/game-engine/test/oldNationArchive.test.ts @@ -36,6 +36,67 @@ describe('old nation archive data', () => { aux: { legacy: 'preserved', maxPower: 20_000, maxCrew: 80_000, maxCities: ['허창'] }, generals: [7, 8], history: ['위가 멸망'], + msg: '', + scout_msg: null, }); }); + + it('prefers current notice fields and preserves empty strings', () => { + const nation: Nation = { + id: 2, + name: '위', + color: '#0000ff', + capitalCityId: 3, + chiefGeneralId: 7, + gold: 1_000, + rice: 2_000, + power: 8_000, + level: 5, + typeCode: 'che_법가', + meta: { + notice: '', + infoText: '', + nationNotice: { msg: 'legacy notice' }, + msg: 'legacy flat notice', + scout_msg: 'legacy scout message', + }, + }; + + expect(buildOldNationArchiveData({ nation, generalIds: [], history: [] })).toMatchObject({ + msg: '', + scout_msg: '', + }); + }); + + it('falls back to both legacy notice shapes and legacy scout text', () => { + const baseNation: Nation = { + id: 2, + name: '위', + color: '#0000ff', + capitalCityId: 3, + chiefGeneralId: 7, + gold: 1_000, + rice: 2_000, + power: 8_000, + level: 5, + typeCode: 'che_법가', + meta: { + nationNotice: { msg: 'legacy notice' }, + msg: 'legacy flat notice', + scout_msg: 'legacy scout message', + }, + }; + + expect(buildOldNationArchiveData({ nation: baseNation, generalIds: [], history: [] })).toMatchObject({ + msg: 'legacy notice', + scout_msg: 'legacy scout message', + }); + expect( + buildOldNationArchiveData({ + nation: { ...baseNation, meta: { msg: 'legacy flat notice' } }, + generalIds: [], + history: [], + }) + ).toMatchObject({ msg: 'legacy flat notice', scout_msg: null }); + }); }); diff --git a/app/game-engine/test/troopManagement.test.ts b/app/game-engine/test/troopManagement.test.ts index ce6e1558..db3bb1fd 100644 --- a/app/game-engine/test/troopManagement.test.ts +++ b/app/game-engine/test/troopManagement.test.ts @@ -10,6 +10,7 @@ const schedule: TurnSchedule = { entries: [{ startMinute: 0, tickMinutes: 10 }] const buildGeneral = (id: number, overrides: Partial = {}): TurnGeneral => ({ id, + userId: `user-${id}`, name: `장수${id}`, nationId: 1, cityId: 1, @@ -132,7 +133,9 @@ describe('troop management world commands', () => { }); const handler = createTurnDaemonCommandHandler({ world }); - await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toEqual({ + await expect( + handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 }) + ).resolves.toEqual({ type: 'troopJoin', ok: true, generalId: 1, @@ -159,7 +162,9 @@ describe('troop management world commands', () => { }); const handler = createTurnDaemonCommandHandler({ world }); - await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({ + await expect( + handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 }) + ).resolves.toMatchObject({ ok: true, }); expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 1 }); @@ -177,7 +182,9 @@ describe('troop management world commands', () => { }); const handler = createTurnDaemonCommandHandler({ world }); - await expect(handler.handle({ type: 'troopJoin', generalId: 1, troopId: 2 })).resolves.toMatchObject({ + await expect( + handler.handle({ type: 'troopJoin', userId: 'user-1', generalId: 1, troopId: 2 }) + ).resolves.toMatchObject({ ok: true, }); expect(world.getGeneralById(1)).toMatchObject({ troopId: 2, cityId: 2 }); @@ -188,7 +195,9 @@ describe('troop management world commands', () => { const world = buildWorld({}); const handler = createTurnDaemonCommandHandler({ world }); - await expect(handler.handle({ type: 'troopCreate', generalId: 1, troopName: ' 백마대 ' })).resolves.toEqual({ + await expect( + handler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: ' 백마대 ' }) + ).resolves.toEqual({ type: 'troopCreate', ok: true, generalId: 1, @@ -202,7 +211,12 @@ describe('troop management world commands', () => { const escapedWorld = buildWorld({ generals: [buildGeneral(2)] }); const escapedHandler = createTurnDaemonCommandHandler({ world: escapedWorld }); await expect( - escapedHandler.handle({ type: 'troopCreate', generalId: 2, troopName: '<백마대>' }) + escapedHandler.handle({ + type: 'troopCreate', + userId: 'user-2', + generalId: 2, + troopName: '<백마대>', + }) ).resolves.toMatchObject({ ok: true, troopName: '<백마대>' }); }); @@ -213,13 +227,13 @@ describe('troop management world commands', () => { }); const assignedHandler = createTurnDaemonCommandHandler({ world: assigned }); await expect( - assignedHandler.handle({ type: 'troopCreate', generalId: 1, troopName: '신규대' }) + assignedHandler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: '신규대' }) ).resolves.toMatchObject({ ok: false, reason: '이미 부대에 소속되어 있습니다.' }); const blank = buildWorld({}); const blankHandler = createTurnDaemonCommandHandler({ world: blank }); await expect( - blankHandler.handle({ type: 'troopCreate', generalId: 1, troopName: ' ' }) + blankHandler.handle({ type: 'troopCreate', userId: 'user-1', generalId: 1, troopName: ' ' }) ).resolves.toMatchObject({ ok: false, reason: '부대 이름이 없습니다.', @@ -244,6 +258,7 @@ describe('troop management world commands', () => { await expect( forbiddenHandler.handle({ type: 'troopKick', + userId: 'user-2', generalId: 2, troopId: 1, targetGeneralId: 3, @@ -256,6 +271,7 @@ describe('troop management world commands', () => { await expect( allowedHandler.handle({ type: 'troopKick', + userId: 'user-1', generalId: 1, troopId: 1, targetGeneralId: 3, @@ -266,6 +282,7 @@ describe('troop management world commands', () => { await expect( allowedHandler.handle({ type: 'troopKick', + userId: 'user-1', generalId: 1, troopId: 1, targetGeneralId: 1, @@ -280,7 +297,13 @@ describe('troop management world commands', () => { }); const leaderHandler = createTurnDaemonCommandHandler({ world: leaderWorld }); await expect( - leaderHandler.handle({ type: 'troopRename', generalId: 1, troopId: 1, troopName: '신대' }) + leaderHandler.handle({ + type: 'troopRename', + userId: 'user-1', + generalId: 1, + troopId: 1, + troopName: '신대', + }) ).resolves.toMatchObject({ ok: true, troopName: '신대' }); const managerWorld = buildWorld({ @@ -292,7 +315,13 @@ describe('troop management world commands', () => { }); const managerHandler = createTurnDaemonCommandHandler({ world: managerWorld }); await expect( - managerHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' }) + managerHandler.handle({ + type: 'troopRename', + userId: 'user-2', + generalId: 2, + troopId: 1, + troopName: '신대', + }) ).resolves.toMatchObject({ ok: true, troopName: '신대' }); const penalizedWorld = buildWorld({ @@ -307,7 +336,13 @@ describe('troop management world commands', () => { }); const penalizedHandler = createTurnDaemonCommandHandler({ world: penalizedWorld }); await expect( - penalizedHandler.handle({ type: 'troopRename', generalId: 2, troopId: 1, troopName: '신대' }) + penalizedHandler.handle({ + type: 'troopRename', + userId: 'user-2', + generalId: 2, + troopId: 1, + troopName: '신대', + }) ).resolves.toMatchObject({ ok: false, reason: '권한이 부족합니다.' }); expect(penalizedWorld.getTroopById(1)?.name).toBe('구대'); }); diff --git a/app/game-engine/test/unificationFinalization.integration.test.ts b/app/game-engine/test/unificationFinalization.integration.test.ts index 6b3308b3..fd18c1e9 100644 --- a/app/game-engine/test/unificationFinalization.integration.test.ts +++ b/app/game-engine/test/unificationFinalization.integration.test.ts @@ -80,6 +80,8 @@ integration('unification finalization transaction', () => { meta: { power: 3_000, max_power: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] }, + notice: '통일 공지', + infoText: '통일 임관 안내', }, }, }); @@ -268,6 +270,7 @@ integration('unification finalization transaction', () => { await expect( bidder.bid({ type: 'auctionBid', + userId, auctionId: uniqueAuction.id, generalId: fixtureId, amount: 30, @@ -277,6 +280,7 @@ integration('unification finalization transaction', () => { await expect( bidder.bid({ type: 'auctionBid', + userId, auctionId: uniqueAuction.id, generalId: fixtureId, amount: 50, @@ -404,6 +408,8 @@ integration('unification finalization transaction', () => { maxCities: ['원자도시'], aux: { maxPower: 3_500, maxCrew: 400, maxCities: ['원자도시'] }, generals: [fixtureId], + msg: '통일 공지', + scout_msg: '통일 임관 안내', }); expect(legacyOfficerPicture.length).toBeGreaterThan(32); expect(await db.emperor.findFirstOrThrow({ where: { serverId } })).toMatchObject({ diff --git a/app/game-engine/test/unificationHandler.test.ts b/app/game-engine/test/unificationHandler.test.ts index 79a7a3e6..aabd524f 100644 --- a/app/game-engine/test/unificationHandler.test.ts +++ b/app/game-engine/test/unificationHandler.test.ts @@ -245,10 +245,16 @@ describe('unification handler', () => { auctionBidder: { bid }, }); await expect( - commands.handle({ type: 'auctionBid', auctionId: 77, generalId: 1, amount: 100 }) + commands.handle({ type: 'auctionBid', userId: 'user-1', auctionId: 77, generalId: 1, amount: 100 }) ).resolves.toMatchObject({ ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' }); await expect( - commands.handle({ type: 'auctionOpen', auctionType: 'UNIQUE_ITEM', generalId: 1, amount: 100 }) + commands.handle({ + type: 'auctionOpen', + userId: 'user-1', + auctionType: 'UNIQUE_ITEM', + generalId: 1, + amount: 100, + }) ).resolves.toMatchObject({ ok: false, reason: '천하통일 후에는 경매를 이용할 수 없습니다.' }); expect(bid).not.toHaveBeenCalled(); }); diff --git a/app/game-engine/test/unificationPersistence.test.ts b/app/game-engine/test/unificationPersistence.test.ts index f1b89d1c..17c95a1f 100644 --- a/app/game-engine/test/unificationPersistence.test.ts +++ b/app/game-engine/test/unificationPersistence.test.ts @@ -63,7 +63,7 @@ const buildWorld = (): InMemoryTurnWorld => { power: 3000, level: 1, typeCode: 'test', - meta: {}, + meta: { notice: '통일 공지', infoText: '통일 임관 안내' }, }; const city: City = { id: 1, @@ -184,6 +184,7 @@ describe('persistUnificationFinalization', () => { const gameHistoryUpdate = vi.fn().mockResolvedValue({}); const emperorCreate = vi.fn().mockResolvedValue({}); const oldGeneralUpsert = vi.fn().mockResolvedValue({}); + const oldNationUpsert = vi.fn().mockResolvedValue({}); const transaction = Object.assign({} as GamePrisma.TransactionClient, { $executeRaw: vi.fn().mockResolvedValue(1), $queryRaw: vi.fn().mockResolvedValue([]), @@ -233,7 +234,7 @@ describe('persistUnificationFinalization', () => { ), }, oldNation: { - upsert: vi.fn().mockResolvedValue({}), + upsert: oldNationUpsert, findMany: vi.fn().mockResolvedValue([]), }, oldGeneral: { upsert: oldGeneralUpsert }, @@ -282,6 +283,14 @@ describe('persistUnificationFinalization', () => { expect(gameHistoryUpdate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ winnerNation: 1 }) }) ); + expect(oldNationUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + nation: 1, + data: expect.objectContaining({ msg: '통일 공지', scout_msg: '통일 임관 안내' }), + }), + }) + ); expect(emperorCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ diff --git a/app/game-engine/test/voteReward.test.ts b/app/game-engine/test/voteReward.test.ts index ec8be31c..a869b067 100644 --- a/app/game-engine/test/voteReward.test.ts +++ b/app/game-engine/test/voteReward.test.ts @@ -18,6 +18,7 @@ import { createTurnDaemonCommandHandler, hasVotePollDeadlinePassed } from '../sr const buildGeneral = (id: number): TurnGeneral => ({ id, + userId: `user-${id}`, name: `General_${id}`, nationId: 1, cityId: 1, @@ -46,6 +47,12 @@ const buildGeneral = (id: number): TurnGeneral => ({ npcState: 0, }); +const actorBindingDb = (userId = 'user-1') => ({ + inputEvent: { + findUnique: async () => ({ actorUserId: userId, target: 'ENGINE', eventType: 'voteReward' }), + }, +}); + describe('voteReward command', () => { it('keeps the wall-time fallback open at exact deadline equality', () => { const deadline = new Date('0180-01-01T00:00:00.000Z'); @@ -67,6 +74,7 @@ describe('voteReward command', () => { sentAt: '2026-08-23T00:00:00.000Z', command: { type: 'voteReward', + userId: 'user-1', voteId: 1, generalId: 1, selection: [0], @@ -224,6 +232,7 @@ describe('voteReward command', () => { let voteQueryCount = 0; let voteInsertQuery: { strings: readonly string[]; values: readonly unknown[] } | undefined; const commandDb = { + ...actorBindingDb(), auction: { findMany: async () => [], }, @@ -249,6 +258,8 @@ describe('voteReward command', () => { const handler = createTurnDaemonCommandHandler({ world }); const command = { type: 'voteReward' as const, + requestId: 'vote-reward-1', + userId: 'user-1', voteId: 1, generalId: 1, selection: [0], @@ -269,9 +280,7 @@ describe('voteReward command', () => { expect( voteInsertQuery?.values.filter( (value) => - value instanceof Date && - value.getTime() >= writerWindowStart && - value.getTime() <= writerWindowEnd + value instanceof Date && value.getTime() >= writerWindowStart && value.getTime() <= writerWindowEnd ) ).toHaveLength(1); @@ -311,6 +320,7 @@ describe('voteReward command', () => { const duplicateHandler = createTurnDaemonCommandHandler({ world: duplicateWorld }); const duplicateResult = await duplicateHandler.handle(command, { db: { + ...actorBindingDb(), auction: { findMany: async () => [] }, $queryRaw: async (query: { strings: readonly string[] }) => { const text = query.strings.join(' '); @@ -346,6 +356,7 @@ describe('voteReward command', () => { const mismatchHandler = createTurnDaemonCommandHandler({ world: mismatchWorld }); const mismatchResult = await mismatchHandler.handle(command, { db: { + ...actorBindingDb(), auction: { findMany: async () => [] }, $queryRaw: async (query: { strings: readonly string[] }) => { const text = query.strings.join(' '); @@ -383,6 +394,7 @@ describe('voteReward command', () => { const { acceptedGameTick: _acceptedGameTick, ...legacyLateCommand } = command; const legacyLateResult = await legacyLateHandler.handle(legacyLateCommand, { db: { + ...actorBindingDb(), $queryRaw: async (query: { strings: readonly string[] }) => query.strings.join(' ').includes('SELECT options') ? [ @@ -456,6 +468,7 @@ describe('voteReward command', () => { }); const handler = createTurnDaemonCommandHandler({ world }); const commandDb = { + ...actorBindingDb(), auction: { findMany: async () => [{ targetCode: 'che_무기_12_칠성검' }], }, @@ -476,6 +489,8 @@ describe('voteReward command', () => { const result = await handler.handle( { type: 'voteReward', + requestId: 'vote-reward-occupied', + userId: 'user-1', voteId: 1, generalId: 1, selection: [0], diff --git a/app/game-engine/tsdown.config.ts b/app/game-engine/tsdown.config.ts index 3713de76..3e102d24 100644 --- a/app/game-engine/tsdown.config.ts +++ b/app/game-engine/tsdown.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ 'turn/monthlyDisasterAction': 'src/turn/monthlyDisasterAction.ts', 'turn/monthlyEventHandler': 'src/turn/monthlyEventHandler.ts', 'turn/monthlyNationBettingAction': 'src/turn/monthlyNationBettingAction.ts', + 'turn/npcPolicyMutation': 'src/turn/npcPolicyMutation.ts', 'turn/npcPossessionService': 'src/turn/npcPossessionService.ts', 'turn/rankData': 'src/turn/rankData.ts', 'turn/reservedTurnHandler': 'src/turn/reservedTurnHandler.ts', diff --git a/docs/architecture/game-api-daemon-procedure-inventory.md b/docs/architecture/game-api-daemon-procedure-inventory.md index 81998e02..5e0353fe 100644 --- a/docs/architecture/game-api-daemon-procedure-inventory.md +++ b/docs/architecture/game-api-daemon-procedure-inventory.md @@ -23,7 +23,7 @@ transaction을 만든다. `engineAuthedProcedure`, `accessEngineAuthedProcedure` - **ENGINE 소유 + 불필요한 API outer**: gameplay durable mutation은 ENGINE이 전부 소유하지만 route가 아직 API `input_event`/journal transaction에 감싸여 있다. -현재 합계는 **ENGINE 전환 26 + 혼합 13 + 기존 ENGINE 9 + 불필요한 API +현재 합계는 **ENGINE 전환 36 + 혼합 3 + 기존 ENGINE 9 + 불필요한 API outer 1 = 49**다. ENGINE 전환 route의 explicit `requestId`는 각 route가 기존에 사용하던 HTTP 요청 @@ -45,19 +45,19 @@ selection-pool create/reselect는 client request ID가 있을 때 | `inherit.openUniqueAuction` | `engineAuthedProcedure`; world/general/minimum bid 조기 validation (`app/game-api/src/router/inherit/index.ts:389-428`) | 공통 `auctionOpen` ENGINE handler가 mutation을 소유하고 API는 Redis timer index만 갱신 (`app/game-api/src/auction/open.ts:22-51`) | | `join.getSelectionPool` | `engineAuthedProcedure`; actor와 accepted game time만 전달 (`app/game-api/src/router/join/index.ts:390-406`) | `selectPoolReserve` ENGINE handler가 world/DB 상태에서 예약을 재검증하고 저장 (`app/game-engine/src/turn/worldCommandHandler.ts:401-437`) | | `inherit.buyHiddenBuff`, `inherit.setNextSpecialWar`, `inherit.resetSpecialWar`, `inherit.resetTurnTime`, `inherit.resetStat`, `inherit.buyRandomUnique`, `inherit.checkOwner` | 모두 `engineAuthedProcedure`; 인증 user ID와 action 입력만 전달 (`app/game-api/src/router/inherit/index.ts:107-124`, `:301-388`, `:429-445`) | `inheritanceAction` ENGINE handler가 general/user 소유권, 통일 상태, 잔액, 대상, RNG, general patch, inheritance point/log/message를 한 transaction에서 처리 (`app/game-engine/src/turn/worldCommandHandler.ts:807-818`, `app/game-engine/src/turn/inheritanceActionService.ts:313-669`) | +| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `engineAuthedProcedure`; API는 인증 actor, 현재 국가와 조기 권한만 읽고 semantic mutation을 전달 (`app/game-api/src/router/nation/endpoints/setNotice.ts`, `setScoutMsg.ts`, `setSecretLimit.ts`, `setRate.ts`, `setBlockWar.ts`, `setBill.ts`, `setBlockScout.ts`) | `setNationSetting`이 실행 시점 owner/nation/직책·permission을 다시 검사한다. 전쟁 설정 잔여 횟수 차감과 임관 잠금 검사를 현재 ENGINE state에서 수행하고, 공지는 logical game time과 author snapshot을 국가 meta와 같은 transaction에 저장한다 (`app/game-engine/src/turn/nationSettingMutation.ts`, `worldCommandHandler.ts`). | +| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessEngineAuthedInputProcedure`; API는 현재 화면값과 unit-set 기반 입력 보조만 수행하고 actor-bound semantic delta와 명시적 nullable revision을 전달 (`app/game-api/src/router/npc/index.ts`) | `setNpcPolicy`가 실행 시점 owner/nation/permission, strict CAS, 현재 troop/city membership, priority와 numeric policy를 검증하고 logical setter snapshot과 delta만 반영한다 (`app/game-engine/src/turn/npcPolicyMutation.ts`, `worldCommandHandler.ts`). | -합계 **26개 route**다. +합계 **36개 route**다. ## 혼합 또는 validation 이관이 먼저 필요한 route | route | 현재 procedure / outer transaction | 보류 근거 | | --- | --- | --- | | `messages.respond` | `authedProcedure`, action별 분기 (`app/game-api/src/router/messages/index.ts:318-372`) | `scout`/`raiseInvader`는 `messageRespond` ENGINE command가 처리하지만 `noAggression`/`cancelNA`/`stopWar`는 API transaction의 `respondToDiplomaticMessage` 경로가 처리한다. route 전체를 ENGINE-owned로 보지 않는다. | -| `nation.setNotice`, `nation.setScoutMsg`, `nation.setSecretLimit`, `nation.setRate`, `nation.setBlockWar`, `nation.setBill`, `nation.setBlockScout` | `authedProcedure`, outer 있음 (`app/game-api/src/router/nation/endpoints/setNotice.ts:11`, `setScoutMsg.ts:11`, `setSecretLimit.ts:10`, `setRate.ts:10`, `setBlockWar.ts:10`, `setBill.ts:10`, `setBlockScout.ts:10`) | API가 actor 권한과 nation meta를 읽어 full metadata patch를 합성한다. `setNationMeta`는 `_updatedAt` CAS만 검사하므로 actor/permission과 합성 의미를 ENGINE으로 옮겨야 한다 (`app/game-api/src/router/nation/shared.ts:431-458`, `app/game-engine/src/turn/worldCommandHandler.ts:499-542`). | -| `npc.setNationPolicy`, `npc.setNationPriority`, `npc.setGeneralPriority` | `accessAuthedInputProcedure`, outer 있음 (`app/game-api/src/router/npc/index.ts:545-812`) | API가 nation/general/world를 읽어 권한, unit-set 기반 기본값과 full policy object를 합성한 뒤 `setNationMeta` CAS를 사용한다. ENGINE은 권한/합성 의미를 소유하지 않는다. | | `tournament.join`, `tournament.placeBet` | `authedProcedure`, outer 있음 (`app/game-api/src/router/tournament/index.ts:393-458`, `:515-620`) | PostgreSQL ENGINE resource/meta 명령과 Redis-owned participants/bets를 결합하고 실패 시 보상 ENGINE 명령을 보낸다. 하나의 DB transaction이 아니며 durable saga/Redis atomic revision이 필요하다. | -합계 **13개 route**다. +합계 **3개 route**다. ## 기존에 API outer transaction이 없던 ENGINE route @@ -84,6 +84,12 @@ selection-pool create/reselect는 client request ID가 있을 때 `$transaction`을 호출하지 않고 기존 형식의 stable ENGINE request ID를 전달한다. - `app/game-api/test/nationPersonnelRouter.test.ts`: nation personnel route의 동일 계약을 검증한다. +- `app/game-api/test/nationSettingRouter.test.ts`, `nationHtmlRouter.test.ts`, + `scoutBlockRouter.test.ts`: 일곱 국가 설정이 API outer transaction 없이 인증 actor와 + semantic intent를 전달하고 ENGINE 거절을 그대로 매핑하는지 검증한다. +- `app/game-api/test/npcPolicyRouter.test.ts`, + `app/game-engine/test/npcPolicyLifecycle.test.ts`: NPC policy의 명시적 nullable CAS, + 실행 시점 권한과 delta merge/replay 경계를 검증한다. - `app/game-api/test/troopRouter.test.ts`: troop mutation의 동일 계약을 검증한다. - `app/game-api/test/auctionRouter.test.ts`: auction mutation이 API transaction 없이 daemon command와 Redis timer projection을 완료하는 계약을 검증한다. @@ -94,7 +100,8 @@ selection-pool create/reselect는 client request ID가 있을 때 validation과 ENGINE의 vote insert/reward/idempotency 경계를 검증한다. API outer/journal 제거는 아직 검증 대상이 아니다. - raw inventory 재검색: `rg -n "requestCommand\\(" app/game-api/src/router`와 - `openAuctionWithDaemon`, `requestInheritanceAction`, `updateNationMeta`, + `openAuctionWithDaemon`, `requestInheritanceAction`, `updateNationSetting`, + `requestNpcPolicyMutation`, `adjustAccountIconForUser`, `requestImmediateAction`, `requestJoinCreateCommand`, `requestNpcPossessionCommand` caller 검색을 함께 실행해 helper 경유 route를 놓치지 않는다. diff --git a/docs/architecture/game-api-direct-mutation-journal-inventory.md b/docs/architecture/game-api-direct-mutation-journal-inventory.md index 967d4efc..fd2f478a 100644 --- a/docs/architecture/game-api-direct-mutation-journal-inventory.md +++ b/docs/architecture/game-api-direct-mutation-journal-inventory.md @@ -33,10 +33,10 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만 | 분류 | 수 | route | | --- | ---: | --- | -| durable journal | 23 | `betting.bet`; `inherit.checkOwner`; `messages.delete`, `messages.respond`, `messages.send`; `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `turns.repeatGeneral`, `turns.setGeneral`, `turns.setGeneralBulk`, `turns.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` | +| durable journal | 13 | `betting.bet`; `inherit.checkOwner`; `messages.delete`, `messages.respond`, `messages.send`; `turns.repeatGeneral`, `turns.setGeneral`, `turns.setGeneralBulk`, `turns.shiftGeneral`; `vote.closePoll`, `vote.createPoll`, `vote.submitVote`, `vote.updatePoll` | | separate access journal | 1 | `public.recordAccess` | | explicit no realtime consumer | 14 | `board.writeArticle`, `board.writeComment`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `join.getSelectionPool`, `join.listPossessCandidates`; `messages.readLatest`; `turns.repeatNation`, `turns.setNation`, `turns.setNationBulk`, `turns.shiftNation`; `vote.addComment` | -| engine owned | 27 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` | +| engine owned | 37 | `auction.bidBuyRice`, `auction.bidSellRice`, `auction.bidUnique`, `auction.openBuyRice`, `auction.openSellRice`, `auction.openUnique`; `general.adjustIcon`, `general.buildNationCandidate`, `general.dieOnPrestart`, `general.dropItem`, `general.ensureDieOnPrestartStatus`, `general.instantRetreat`, `general.setMySetting`, `general.vacation`; `inherit.openUniqueAuction`; `join.createGeneral`, `join.possessGeneral`, `join.reselectPoolGeneral`, `join.selectPoolGeneral`; `nation.appoint`, `nation.changePermission`, `nation.kick`, `nation.setBill`, `nation.setBlockScout`, `nation.setBlockWar`, `nation.setNotice`, `nation.setRate`, `nation.setScoutMsg`, `nation.setSecretLimit`; `npc.setGeneralPriority`, `npc.setNationPolicy`, `npc.setNationPriority`; `troop.create`, `troop.exit`, `troop.join`, `troop.kick`, `troop.rename` | | mixed saga | 9 | `inherit.buyHiddenBuff`, `inherit.buyRandomUnique`, `inherit.resetSpecialWar`, `inherit.resetStat`, `inherit.resetTurnTime`, `inherit.setNextSpecialWar`; `tournament.cancel`, `tournament.join`, `tournament.placeBet` | | Redis projection | 6 | `tournament.patchState`, `tournament.seedParticipants`, `tournament.setBettingEntries`, `tournament.setMatches`, `tournament.setParticipants`, `tournament.setState` | | operational | 3 | `turnDaemon.pause`, `turnDaemon.resume`, `turnDaemon.run` | @@ -55,7 +55,6 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만 | `messages.send` | 생성된 수신/송신 복사본의 `messages.mailbox:` | 해당 mailbox viewer에게 ID 없는 `messagesInvalidated` | 기존 pre-commit Redis `messageCreated`를 제거했다. outbox publish 뒤에도 browser에는 mailbox/message/sender/time/revision이 노출되지 않는다. | | `messages.delete` | 실제로 만료한 송신/수신 mailbox | 동일 | sender copy만 지우는 수동 외교 메시지는 그 mailbox만 표시한다. | | `messages.respond` | 영향 mailbox, `records.general`, 실제 외교 변경 국가의 `nation.content`, front-state patch 도시의 `city.content`, 필요 시 `map.world`, transitive aggregate용 `dashboard.global` | mailbox boolean 및 해당 dashboard slice | 실패 로그도 commit되면 actor 개인 기록을 표시한다. 외교 수락이 실제 diplomacy/city/nation dependency를 바꿀 때만 broad source key를 표시한다. | -| nation metadata 7개 및 NPC policy 3개 | `nation.content:`, `dashboard.global:0`; notice만 추가로 `front.nation:` | 해당 국가 context/command/board, notice front status | `updateNationMeta()` 성공 뒤 API 표식을 사용하고, ENGINE 소유 transaction도 nation 변화와 `dashboard.global`을 함께 기록한다. 두 input-event의 업무 원자성은 기존 saga지만 source coverage는 ENGINE commit으로 보존된다. | | general reserved turn 4개 | `reserved.general:`, `dashboard.global:0` | 본인 reserved-turn slice | queue row와 CAS revision을 쓴 같은 API transaction에서 표시한다. global key는 troop leader 첫 예약턴에 의존하는 다른 장수 context를 위한 source-only 표식이다. nation reserved turns는 main SSE consumer가 없어 명시적 no-op이다. | | vote 4개 | 기존 `front.general`/`front.global` | front-status boolean | vote producer 작업에서 pre-commit publish를 journal로 이미 치환했다. 댓글은 active survey 제목을 바꾸지 않아 별도 화면 no-op이다. | | `public.recordAccess` | `access.general:` | 없음 | Ref 순서상 gameplay transaction 밖의 별도 access transaction에 저장한다. | @@ -81,9 +80,9 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti ## 남은 업무 원자성 gap과 coverage 판정 -1. nation metadata/NPC policy의 API input-event와 ENGINE input-event는 여전히 하나의 - 업무 transaction이 아니다. 다만 실제 state 소유 ENGINE commit이 precise entity와 - `dashboard.global`을 기록하므로 revision-first source coverage는 빠지지 않는다. +1. 국가 설정 7개와 NPC policy 3개는 API outer input-event를 제거했다. semantic ENGINE + command가 actor 권한, 현재 state와 mutation을 검증하고 국가 저장·ENGINE input-event· + `nation.content`/`front.nation`/`dashboard.global` journal을 한 transaction에서 commit한다. 2. inheritance/tournament command 일부는 보상 가능한 saga다. tournament payload와 profile source revision 자체는 API store, 월 자동 개막, runtime clock shift 모두 공통 Lua writer 한 번으로 원자화했다. diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index 73ab7a1c..208b877a 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -130,17 +130,25 @@ export type TurnDaemonCommand = deltaMinutes: number; } | { type: 'getStatus'; requestId?: string } - | { type: 'troopCreate'; requestId?: string; generalId: number; troopName: string } - | { type: 'troopJoin'; requestId?: string; generalId: number; troopId: number } - | { type: 'troopExit'; requestId?: string; generalId: number } + | { type: 'troopCreate'; requestId?: string; userId: string; generalId: number; troopName: string } + | { type: 'troopJoin'; requestId?: string; userId: string; generalId: number; troopId: number } + | { type: 'troopExit'; requestId?: string; userId: string; generalId: number } | { type: 'troopKick'; requestId?: string; + userId: string; generalId: number; troopId: number; targetGeneralId: number; } - | { type: 'troopRename'; requestId?: string; generalId: number; troopId: number; troopName: string } + | { + type: 'troopRename'; + requestId?: string; + userId: string; + generalId: number; + troopId: number; + troopName: string; + } | { type: 'ensureDieOnPrestartStatus'; requestId?: string; userId: string; generalId: number } | { type: 'dieOnPrestart'; requestId?: string; userId: string; generalId: number } | { type: 'buildNationCandidate'; requestId?: string; userId: string; generalId: number } @@ -153,10 +161,11 @@ export type TurnDaemonCommand = messageId: number; response: boolean; } - | { type: 'vacation'; requestId?: string; generalId: number } + | { type: 'vacation'; requestId?: string; userId: string; generalId: number } | { type: 'setMySetting'; requestId?: string; + userId: string; generalId: number; settings: { tnmt?: number; @@ -170,7 +179,13 @@ export type TurnDaemonCommand = use_auto_nation_capital?: number; }; } - | { type: 'dropItem'; requestId?: string; generalId: number; itemType: string } + | { + type: 'dropItem'; + requestId?: string; + userId: string; + generalId: number; + itemType: 'horse' | 'weapon' | 'book' | 'item'; + } | { type: 'auctionFinalize'; requestId?: string; @@ -181,6 +196,7 @@ export type TurnDaemonCommand = | { type: 'auctionOpen'; requestId?: string; + userId: string; generalId: number; auctionType: 'BUY_RICE' | 'SELL_RICE' | 'UNIQUE_ITEM'; amount: number; @@ -192,14 +208,16 @@ export type TurnDaemonCommand = | { type: 'changePermission'; requestId?: string; + userId: string; generalId: number; isAmbassador: boolean; targetGeneralIds: number[]; } - | { type: 'kick'; requestId?: string; generalId: number; destGeneralId: number } + | { type: 'kick'; requestId?: string; userId: string; generalId: number; destGeneralId: number } | { type: 'appoint'; requestId?: string; + userId: string; generalId: number; destGeneralId: number; destCityId: number; @@ -239,17 +257,38 @@ export type TurnDaemonCommand = | { type: 'voteReward'; requestId?: string; + userId: string; voteId: number; generalId: number; selection: number[]; acceptedGameTick?: number; } | { - type: 'setNationMeta'; + type: 'setNationSetting'; requestId?: string; + userId: string; + generalId: number; nationId: number; - updates: Record; - expectedUpdatedAt?: string; + mutation: + | { kind: 'notice'; message: string } + | { kind: 'scoutMessage'; message: string } + | { kind: 'rate'; amount: number } + | { kind: 'bill'; amount: number } + | { kind: 'secretLimit'; amount: number } + | { kind: 'blockWar'; value: boolean } + | { kind: 'blockScout'; value: boolean }; + } + | { + type: 'setNpcPolicy'; + requestId?: string; + userId: string; + generalId: number; + nationId: number; + expectedUpdatedAt: string | null; + mutation: + | { kind: 'nationPolicy'; values: Record } + | { kind: 'nationPriority'; priority: string[] } + | { kind: 'generalPriority'; priority: string[] }; } | { type: 'adjustGeneralResources'; @@ -377,6 +416,7 @@ export type TurnDaemonCommand = | { type: 'auctionBid'; requestId?: string; + userId: string; auctionId: number; generalId: number; amount: number; @@ -605,17 +645,33 @@ export type TurnDaemonCommandResult = reason: string; } | { - type: 'setNationMeta'; + type: 'setNationSetting'; + ok: true; + nationId: number; + updatedAt: string; + availableCnt?: number; + } + | { + type: 'setNationSetting'; + ok: false; + code: 'BAD_REQUEST' | 'FORBIDDEN' | 'NOT_FOUND' | 'PRECONDITION_FAILED'; + nationId?: number; + reason: string; + currentUpdatedAt?: string | null; + } + | { + type: 'setNpcPolicy'; ok: true; nationId: number; updatedAt: string; } | { - type: 'setNationMeta'; + type: 'setNpcPolicy'; ok: false; - nationId: number; + code: 'BAD_REQUEST' | 'FORBIDDEN' | 'NOT_FOUND' | 'PRECONDITION_FAILED' | 'CONFLICT'; reason: string; - currentUpdatedAt?: string; + nationId?: number; + currentUpdatedAt?: string | null; } | { type: 'adjustGeneralResources'; diff --git a/packages/logic/src/actions/turn/general/che_등용수락.ts b/packages/logic/src/actions/turn/general/che_등용수락.ts index 4a2ebb1a..04c501ee 100644 --- a/packages/logic/src/actions/turn/general/che_등용수락.ts +++ b/packages/logic/src/actions/turn/general/che_등용수락.ts @@ -46,10 +46,10 @@ export interface AcceptScoutResolveContext< const differentDestGeneral = (destGeneralId: number): Constraint => ({ name: 'differentDestGeneral', - requires: (ctx) => [ - { kind: 'general', id: ctx.actorId }, - { kind: 'destGeneral', id: destGeneralId }, - ], + // Ref keeps only the recruiter id/nation snapshot in the letter. The + // recruiter can be deleted before the receiver accepts it, so this + // self-check must not turn the missing live recruiter into a dependency. + requires: (ctx) => [{ kind: 'general', id: ctx.actorId }], test: (ctx) => ctx.actorId === destGeneralId ? { kind: 'deny', reason: '본인의 등용장을 수락할 수 없습니다.' } @@ -74,7 +74,6 @@ export class ActionResolver< const effects: GeneralActionEffect[] = []; if (!destNation) throw new Error('Target nation not found.'); - if (!destGeneral) throw new Error('Recruiter not found.'); // 1. Logs const destNationName = destNation.name; @@ -96,79 +95,102 @@ export class ActionResolver< format: LogFormat.MONTH, }); - // 2. Recruiter Rewards - const recruiterExperience = destGeneral.experience + 100; - const recruiterDedication = destGeneral.dedication + 100; + // 2. Recruiter Rewards. Ref resolves a deleted recruiter as a dummy + // general: joining still succeeds, while recruiter-only rewards and + // logs are discarded. const belong = readMetaNumberFromUnknown(general.meta, 'belong') ?? 0; const maxBelong = readMetaNumberFromUnknown(general.meta, 'max_belong') ?? 0; - const recruiterExpLevel = Math.max( - 0, - Math.min( - this.env.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL, - recruiterExperience < 1_000 - ? Math.trunc(recruiterExperience / 100) - : Math.trunc(Math.sqrt(recruiterExperience / 10)) - ) - ); - const recruiterDedicationLevel = Math.max( - 0, - Math.min(this.env.maxDedicationLevel ?? 30, Math.ceil(Math.sqrt(recruiterDedication) / 10)) - ); - const previousRecruiterExpLevel = typeof destGeneral.meta.explevel === 'number' ? destGeneral.meta.explevel : 0; - const previousRecruiterDedicationLevel = - typeof destGeneral.meta.dedlevel === 'number' ? destGeneral.meta.dedlevel : 0; - effects.push( - createGeneralPatchEffect( - { - experience: recruiterExperience, - dedication: recruiterDedication, - meta: { - ...destGeneral.meta, - explevel: recruiterExpLevel, - dedlevel: recruiterDedicationLevel, - }, - }, - destGeneral.id - ) - ); - if (recruiterExpLevel !== previousRecruiterExpLevel) { - const josaRo = JosaUtil.pick(String(recruiterExpLevel), '로'); - effects.push( - createLogEffect( - recruiterExpLevel > previousRecruiterExpLevel - ? `Lv ${recruiterExpLevel}${josaRo} 레벨업!` - : `Lv ${recruiterExpLevel}${josaRo} 레벨다운!`, - { - scope: LogScope.GENERAL, - generalId: destGeneral.id, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - legacyFlushGroup: 1, - } + if (destGeneral) { + const recruiterExperience = destGeneral.experience + 100; + const recruiterDedication = destGeneral.dedication + 100; + const recruiterExpLevel = Math.max( + 0, + Math.min( + this.env.maxStatLevel ?? LEGACY_DEFAULT_MAX_LEVEL, + recruiterExperience < 1_000 + ? Math.trunc(recruiterExperience / 100) + : Math.trunc(Math.sqrt(recruiterExperience / 10)) ) ); - } - if (recruiterDedicationLevel !== previousRecruiterDedicationLevel) { - const maxDedicationLevel = this.env.maxDedicationLevel ?? 30; - const dedicationLevelText = - recruiterDedicationLevel === 0 ? '무품관' : `${maxDedicationLevel - recruiterDedicationLevel + 1}품관`; - const billText = new Intl.NumberFormat('en-US').format(recruiterDedicationLevel * 200 + 400); - const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로'); - const josaRoBill = JosaUtil.pick(billText, '로'); + const recruiterDedicationLevel = Math.max( + 0, + Math.min(this.env.maxDedicationLevel ?? 30, Math.ceil(Math.sqrt(recruiterDedication) / 10)) + ); + const previousRecruiterExpLevel = + typeof destGeneral.meta.explevel === 'number' ? destGeneral.meta.explevel : 0; + const previousRecruiterDedicationLevel = + typeof destGeneral.meta.dedlevel === 'number' ? destGeneral.meta.dedlevel : 0; effects.push( - createLogEffect( - recruiterDedicationLevel > previousRecruiterDedicationLevel - ? `${dedicationLevelText}${josaRoDedication} 승급하여 봉록이 ${billText}${josaRoBill} 상승했습니다!` - : `${dedicationLevelText}${josaRoDedication} 강등되어 봉록이 ${billText}${josaRoBill} 하락했습니다!`, + createGeneralPatchEffect( { - scope: LogScope.GENERAL, - generalId: destGeneral.id, - category: LogCategory.ACTION, - format: LogFormat.PLAIN, - legacyFlushGroup: 1, - } + experience: recruiterExperience, + dedication: recruiterDedication, + meta: { + ...destGeneral.meta, + explevel: recruiterExpLevel, + dedlevel: recruiterDedicationLevel, + }, + }, + destGeneral.id ) ); + if (recruiterExpLevel !== previousRecruiterExpLevel) { + const recruiterLevelJosaRo = JosaUtil.pick(String(recruiterExpLevel), '로'); + effects.push( + createLogEffect( + recruiterExpLevel > previousRecruiterExpLevel + ? `Lv ${recruiterExpLevel}${recruiterLevelJosaRo} 레벨업!` + : `Lv ${recruiterExpLevel}${recruiterLevelJosaRo} 레벨다운!`, + { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + legacyFlushGroup: 1, + } + ) + ); + } + if (recruiterDedicationLevel !== previousRecruiterDedicationLevel) { + const maxDedicationLevel = this.env.maxDedicationLevel ?? 30; + const dedicationLevelText = + recruiterDedicationLevel === 0 + ? '무품관' + : `${maxDedicationLevel - recruiterDedicationLevel + 1}품관`; + const billText = new Intl.NumberFormat('en-US').format(recruiterDedicationLevel * 200 + 400); + const josaRoDedication = JosaUtil.pick(dedicationLevelText, '로'); + const josaRoBill = JosaUtil.pick(billText, '로'); + effects.push( + createLogEffect( + recruiterDedicationLevel > previousRecruiterDedicationLevel + ? `${dedicationLevelText}${josaRoDedication} 승급하여 봉록이 ${billText}${josaRoBill} 상승했습니다!` + : `${dedicationLevelText}${josaRoDedication} 강등되어 봉록이 ${billText}${josaRoBill} 하락했습니다!`, + { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.ACTION, + format: LogFormat.PLAIN, + legacyFlushGroup: 1, + } + ) + ); + } + effects.push( + createLogEffect(`${generalName} 등용에 성공했습니다.`, { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.ACTION, + format: LogFormat.MONTH, + legacyFlushGroup: 1, + }), + createLogEffect(`${generalName} 등용에 성공`, { + scope: LogScope.GENERAL, + generalId: destGeneral.id, + category: LogCategory.HISTORY, + format: LogFormat.YEAR_MONTH, + legacyFlushGroup: 1, + }) + ); } // 3. Betrayal Logic @@ -285,22 +307,6 @@ export class ActionResolver< category: LogCategory.HISTORY, format: LogFormat.YEAR_MONTH, }); - effects.push( - createLogEffect(`${generalName} 등용에 성공했습니다.`, { - scope: LogScope.GENERAL, - generalId: destGeneral.id, - category: LogCategory.ACTION, - format: LogFormat.MONTH, - legacyFlushGroup: 1, - }), - createLogEffect(`${generalName} 등용에 성공`, { - scope: LogScope.GENERAL, - generalId: destGeneral.id, - category: LogCategory.HISTORY, - format: LogFormat.YEAR_MONTH, - legacyFlushGroup: 1, - }) - ); const deletedTroopIds: number[] = []; if (general.troopId === general.id) { diff --git a/packages/logic/src/war/aftermath.ts b/packages/logic/src/war/aftermath.ts index a405cc8b..2354a1de 100644 --- a/packages/logic/src/war/aftermath.ts +++ b/packages/logic/src/war/aftermath.ts @@ -81,6 +81,14 @@ const findReport = (reports: WarUnitReport[], predicate: (report: WarUnitReport) const getDeadCounter = (city: City): number => getMetaNumber(city.meta, META_DEAD, 0); +const isAssignedToOfficerCity = ( + general: General, + cityId: number +): boolean => + ['officerCity', 'officer_city', 'officerCityId'].some( + (key) => getMetaNumber(general.meta, key, Number.NaN) === cityId + ); + // REF-COMPAT:BEGIN ref-dead-split-int-binding const increaseDeadCounter = (city: City, delta: number): void => { // Ref binds each `dead + %i` increment as an integer before MariaDB adds @@ -452,6 +460,21 @@ const resolveConquerCity = ( affectedNations.add(attackerNation); } + if (!nationCollapsed) { + // Ref updates every row assigned to the captured city without filtering + // by nation, current city, or existing officer level. + for (const general of generals) { + if (!isAssignedToOfficerCity(general, defenderCity.id)) { + continue; + } + general.officerLevel = 1; + general.meta.officerCity = 0; + general.meta.officer_city = 0; + general.meta.officerCityId = 0; + affectedGenerals.add(general); + } + } + // 수도 함락 시 수도 이전 및 내부 사기/자원 페널티. if (!nationCollapsed && defenderNation && defenderNation.capitalCityId === defenderCity.id) { const nextCapital = findNextCapital(cities, defenderNationId, defenderCity.id, input.map); @@ -528,6 +551,7 @@ const resolveConquerCity = ( defenderCity.supplyState = 1; defenderCity.frontState = 0; defenderCity.meta.term = 0; + defenderCity.meta.officer_set = 0; defenderCity.agriculture = round(defenderCity.agriculture * 0.7); defenderCity.commerce = round(defenderCity.commerce * 0.7); defenderCity.security = round(defenderCity.security * 0.7); diff --git a/packages/logic/test/warAftermath.test.ts b/packages/logic/test/warAftermath.test.ts index e74f241d..d61fe7db 100644 --- a/packages/logic/test/warAftermath.test.ts +++ b/packages/logic/test/warAftermath.test.ts @@ -323,6 +323,76 @@ describe('war aftermath', () => { expect(defenderCity.meta.dead).toBe(111); }); + it('clears every captured-city officer assignment when the defending nation survives', () => { + const attackerNation = buildNation(1); + const defenderNation = buildNation(2); + const foreignNation = buildNation(3); + defenderNation.capitalCityId = 3; + const attackerCity = buildCity(1, 1); + const defenderCity = buildCity(2, 2); + const defenderCapital = buildCity(3, 2); + const foreignCity = buildCity(4, 3); + defenderCity.meta.officer_set = 7; + const attacker = buildGeneral(1, 1, 1); + const camelAssigned = buildGeneral(2, 2, 3); + camelAssigned.officerLevel = 4; + camelAssigned.meta.officerCity = defenderCity.id; + const snakeAssigned = buildGeneral(3, 1, 1); + snakeAssigned.officerLevel = 10; + snakeAssigned.meta.officer_city = defenderCity.id; + const idAssigned = buildGeneral(4, 3, 4); + idAssigned.officerLevel = 0; + idAssigned.meta.officerCityId = defenderCity.id; + const unrelated = buildGeneral(5, 2, 2); + unrelated.officerLevel = 3; + unrelated.meta.officerCity = defenderCapital.id; + unrelated.meta.officer_city = defenderCapital.id; + unrelated.meta.officerCityId = defenderCapital.id; + + const outcome = resolveWarAftermath({ + battle: { + attacker, + defenders: [], + defenderCity, + logs: [], + conquered: true, + reports: [], + }, + attackerNation, + defenderNation, + attackerCity, + defenderCity, + nations: [attackerNation, defenderNation, foreignNation], + cities: [attackerCity, defenderCity, defenderCapital, foreignCity], + generals: [attacker, camelAssigned, snakeAssigned, idAssigned, unrelated], + unitSet: buildUnitSet(), + map: DEFAULT_MAP, + config: buildConfig(), + time: { year: 200, month: 1, startYear: 180 }, + messageTime: MESSAGE_TIME, + }); + + expect(outcome.conquest?.nationCollapsed).toBe(false); + expect(defenderCity.meta.officer_set).toBe(0); + for (const assigned of [camelAssigned, snakeAssigned, idAssigned]) { + expect(assigned).toMatchObject({ + officerLevel: 1, + meta: { officerCity: 0, officer_city: 0, officerCityId: 0 }, + }); + } + expect(unrelated).toMatchObject({ + officerLevel: 3, + meta: { + officerCity: defenderCapital.id, + officer_city: defenderCapital.id, + officerCityId: defenderCapital.id, + }, + }); + expect(outcome.generals.map((general) => general.id)).toEqual( + expect.arrayContaining([attacker.id, camelAssigned.id, snakeAssigned.id, idAssigned.id]) + ); + }); + it('logs emergency relocation when a surviving nation loses its capital', () => { const attackerNation = buildNation(1); const defenderNation = buildNation(2);