From 604e8fdaf266d3cbb3402a69f5a04d3671c6dcb0 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 16:30:33 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=99=B8=EA=B5=90=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=99=80=20=EC=88=98=EB=87=8C=20=EC=98=88=EC=95=BD=EC=9D=98=20?= =?UTF-8?q?Ref=20=EA=B3=84=EC=95=BD=EC=9D=84=20=EB=B3=B5=EC=9B=90=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/messages/store.ts | 6 + app/game-api/src/messages/targets.ts | 4 +- app/game-api/src/router/diplomacy/index.ts | 183 ++++++- app/game-api/src/router/turns/index.ts | 86 ++- ...lomacyDocumentMessages.integration.test.ts | 516 ++++++++++++++++++ app/game-api/test/diplomacyRouter.test.ts | 14 +- app/game-api/test/router.test.ts | 215 +++++++- 7 files changed, 999 insertions(+), 25 deletions(-) create mode 100644 app/game-api/test/diplomacyDocumentMessages.integration.test.ts diff --git a/app/game-api/src/messages/store.ts b/app/game-api/src/messages/store.ts index cdf664c4..c93f83ef 100644 --- a/app/game-api/src/messages/store.ts +++ b/app/game-api/src/messages/store.ts @@ -1,4 +1,5 @@ import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra'; +import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic'; import type { DatabaseClient } from '../context.js'; @@ -63,6 +64,11 @@ const toMessageView = (row: MessageRow): MessageView => { export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise => { const gameTime = await loadCurrentGameTime(db); const toTickOrNull = (date: Date): bigint | null => { + // Ref represents its unlimited 9999-12-31 message lifetime with the + // largest safe game tick instead of falling back to a wall-clock-only row. + if (date.getUTCFullYear() >= 9000) { + return BigInt(MAX_SAFE_GAME_TICK); + } try { const tick = gameTime.dateToTick(date); return tick === null ? null : BigInt(tick); diff --git a/app/game-api/src/messages/targets.ts b/app/game-api/src/messages/targets.ts index 699324a1..7990f85f 100644 --- a/app/game-api/src/messages/targets.ts +++ b/app/game-api/src/messages/targets.ts @@ -1,4 +1,4 @@ -import type { MessageTarget } from '@sammo-ts/logic'; +import { resolveMessageTargetIcon, type MessageTarget } from '@sammo-ts/logic'; import type { DatabaseClient, GeneralRow } from '../context.js'; @@ -42,5 +42,5 @@ export const buildNationTarget = (nationId: number, nationName: string, color: s nationId, nationName, color, - icon: '', + icon: resolveMessageTargetIcon(null), }); diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index 8aa53cf2..2c7d4614 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -1,15 +1,95 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { asRecord } from '@sammo-ts/common'; +import { asRecord, JosaUtil } from '@sammo-ts/common'; import type { GamePrisma } from '@sammo-ts/infra'; +import { + MESSAGE_MAILBOX_NATIONAL_BASE, + resolveMessageTargetIcon, + sendMessage, + type MessageDraft, + type MessageRecordDraft, + type MessageTarget, +} from '@sammo-ts/logic'; +import type { GameApiContext, GeneralRow, NationRow } from '../../context.js'; +import { insertMessage } from '../../messages/store.js'; 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'; +const DIPLOMACY_MESSAGE_VALID_UNTIL = new Date('9999-12-31T00:00:00.000Z'); + +type DiplomacyNation = Pick; + +const buildActorTarget = (general: GeneralRow, nation: DiplomacyNation): MessageTarget => ({ + generalId: general.id, + generalName: general.name, + nationId: nation.id, + nationName: nation.name, + color: nation.color, + icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }), +}); + +const buildNationTarget = (nation: DiplomacyNation): MessageTarget => ({ + generalId: 0, + generalName: '', + nationId: nation.id, + nationName: nation.name, + color: nation.color, + icon: resolveMessageTargetIcon(null), +}); + +const loadLetterNations = async ( + ctx: Pick, + srcNationId: number, + destNationId: number +): Promise<{ srcNation: DiplomacyNation; destNation: DiplomacyNation }> => { + const nations = await ctx.db.nation.findMany({ + where: { id: { in: [srcNationId, destNationId] } }, + select: { id: true, name: true, color: true }, + }); + const srcNation = nations.find((nation) => nation.id === srcNationId); + const destNation = nations.find((nation) => nation.id === destNationId); + if (!srcNation || !destNation) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 국가입니다.' }); + } + return { srcNation, destNation }; +}; + +const sendDocumentNotice = async (options: { + ctx: Pick; + src: MessageTarget; + dest: MessageTarget; + text: string; + time: Date; + includeNational?: boolean; +}): Promise => { + const store = { + insertMessage: (draft: MessageRecordDraft) => insertMessage(options.ctx.db, draft), + }; + const draft = { + msgType: 'diplomacy', + src: options.src, + dest: options.dest, + text: options.text, + time: options.time, + validUntil: DIPLOMACY_MESSAGE_VALID_UNTIL, + option: { deletable: false }, + } satisfies MessageDraft; + + // Ref는 외교 사본을 먼저, 응답 때만 같은 문구의 국가 사본을 뒤이어 보낸다. + await sendMessage(store, draft); + if (options.includeNational) { + await sendMessage(store, { ...draft, msgType: 'national' }); + } + + options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId); + options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId); +}; + const resolvePermissionLevel = async (ctx: Parameters[0], nationId: number) => { const nation = await ctx.db.nation.findUnique({ where: { id: nationId }, @@ -211,13 +291,15 @@ export const diplomacyRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '올바르지 않은 국가입니다.' }); } + const srcTarget = buildActorTarget(me, srcNation); + const destTarget = buildNationTarget(destNation); const aux = { src: { nationName: srcNation.name, nationColor: srcNation.color, generalId: me.id, generalName: me.name, - generalIcon: null, + generalIcon: srcTarget.icon, }, dest: { nationName: destNation.name, @@ -240,6 +322,19 @@ export const diplomacyRouter = router({ }, }); + const letterIdText = String(created.id); + const josaYi = JosaUtil.pick(letterIdText, '이'); + const text = prevId + ? `문서 #${prevId}의 새로운 외교 문서 #${letterIdText}${josaYi} 준비되었습니다. 외교부에서 확인해주세요.` + : `새로운 외교 문서 #${letterIdText}${josaYi} 준비되었습니다. 외교부에서 확인해주세요.`; + await sendDocumentNotice({ + ctx, + src: srcTarget, + dest: destTarget, + text, + time: letterDate, + }); + return { id: created.id }; }), respondLetter: accessAuthedInputProcedure( @@ -269,12 +364,21 @@ export const diplomacyRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' }); } + const { srcNation, destNation } = await loadLetterNations( + ctx, + letter.srcNationId, + letter.destNationId + ); + const messageSrc = buildActorTarget(me, destNation); + const messageDest = buildNationTarget(srcNation); + const messageTime = (await loadCurrentGameTime(ctx.db)).now; const aux = asRecord(letter.aux); + let messageText: string; if (input.agree) { const dest = asRecord(aux.dest); dest.generalId = me.id; dest.generalName = me.name; - dest.generalIcon = null; + dest.generalIcon = messageSrc.icon; aux.dest = dest; await ctx.db.diplomacyLetter.update({ @@ -289,7 +393,7 @@ export const diplomacyRouter = router({ let prevId = letter.prevId; while (prevId) { const prevLetter = await ctx.db.diplomacyLetter.findFirst({ where: { id: prevId } }); - if (!prevLetter || prevLetter.state === 'CANCELLED') { + if (!prevLetter) { break; } await ctx.db.diplomacyLetter.update({ @@ -298,6 +402,7 @@ export const diplomacyRouter = router({ }); prevId = prevLetter.prevId; } + messageText = `외교 서신( #${letter.id})이 승인되었습니다.`; } else { aux.reason = { who: me.id, @@ -308,8 +413,21 @@ export const diplomacyRouter = router({ where: { id: letter.id }, data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue }, }); + messageText = `외교 서신(#${letter.id})이 거부되었습니다.`; + if (input.reason && input.reason !== '0') { + messageText += ` 이유 : ${input.reason}`; + } } + await sendDocumentNotice({ + ctx, + src: messageSrc, + dest: messageDest, + text: messageText, + time: messageTime, + includeNational: true, + }); + return { ok: true }; }), rollbackLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })) @@ -333,6 +451,14 @@ export const diplomacyRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: '서신이 없습니다.' }); } + const { srcNation, destNation } = await loadLetterNations( + ctx, + letter.srcNationId, + letter.destNationId + ); + const messageSrc = buildActorTarget(me, srcNation); + const messageDest = buildNationTarget(destNation); + const messageTime = (await loadCurrentGameTime(ctx.db)).now; const aux = asRecord(letter.aux); aux.reason = { who: me.id, @@ -345,6 +471,14 @@ export const diplomacyRouter = router({ data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue }, }); + await sendDocumentNotice({ + ctx, + src: messageSrc, + dest: messageDest, + text: `외교 서신(#${letter.id})이 회수되었습니다.`, + time: messageTime, + }); + return { ok: true }; }), destroyLetter: accessAuthedInputProcedure(z.object({ letterId: z.number().int().positive() })) @@ -376,24 +510,43 @@ export const diplomacyRouter = router({ throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 파기 신청을 했습니다.' }); } + const { srcNation, destNation } = await loadLetterNations( + ctx, + letter.srcNationId, + letter.destNationId + ); + const actorNation = letter.srcNationId === me.nationId ? srcNation : destNation; + const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation; + const messageSrc = buildActorTarget(me, actorNation); + const messageDest = buildNationTarget(otherNation); + const messageTime = (await loadCurrentGameTime(ctx.db)).now; + let resultState: 'ACTIVATED' | 'CANCELLED'; + let messageText: string; + if (stateOpt && stateOpt !== myStateOpt) { - aux.reason = { - who: me.id, - action: 'destroy', - reason: '파기', - }; await ctx.db.diplomacyLetter.update({ where: { id: letter.id }, data: { state: 'CANCELLED', aux: aux as GamePrisma.InputJsonValue }, }); - return { state: 'CANCELLED' }; + resultState = 'CANCELLED'; + messageText = `외교 서신(#${letter.id})을 파기했습니다.`; + } else { + aux.state_opt = myStateOpt; + await ctx.db.diplomacyLetter.update({ + where: { id: letter.id }, + data: { aux: aux as GamePrisma.InputJsonValue }, + }); + resultState = 'ACTIVATED'; + messageText = `외교 서신(#${letter.id})을 파기 요청합니다.`; } - aux.state_opt = myStateOpt; - await ctx.db.diplomacyLetter.update({ - where: { id: letter.id }, - data: { aux: aux as GamePrisma.InputJsonValue }, + await sendDocumentNotice({ + ctx, + src: messageSrc, + dest: messageDest, + text: messageText, + time: messageTime, }); - return { state: 'ACTIVATED' }; + return { state: resultState }; }), }); diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 7622c507..93ba0ae5 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { loadActionModuleBundle } from '@sammo-ts/logic'; import { asRecord } from '@sammo-ts/common'; +import { GamePrisma } from '@sammo-ts/infra'; import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js'; import { buildBattleSimEnvironment } from '../../battleSim/environment.js'; @@ -23,6 +24,7 @@ import { MAX_GENERAL_TURNS, MAX_NATION_TURNS, ReservedTurnRevisionConflictError, + type ReservedTurnUpdate, expandGeneralTurnIndices, getGeneralTurnSnapshot, getNationTurnSnapshot, @@ -143,6 +145,68 @@ const readGeneralMetaNumber = (meta: unknown, key: string): number | null => { return null; }; +const assertNationTurnInputAllowed = (general: GeneralRow): void => { + if (!Object.prototype.hasOwnProperty.call(asRecord(general.penalty), 'noChiefTurnInput')) { + return; + } + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '수뇌 턴 입력 불가능', + }); +}; + +const refillNationTurnInputKillturn = async ( + ctx: GameApiContext, + general: GeneralRow, + worldState: WorldStateRow +): Promise => { + if (general.npcState >= 2) { + return false; + } + + const worldKillturn = readGeneralMetaNumber(worldState.meta, 'killturn'); + if (worldKillturn === null) { + return false; + } + const currentKillturn = readGeneralMetaNumber(general.meta, 'killturn'); + const nextKillturn = Math.max(currentKillturn ?? 0, worldKillturn); + if (currentKillturn !== null && nextKillturn === currentKillturn) { + return false; + } + + const updated = await ctx.db.$queryRaw>( + GamePrisma.sql` + UPDATE general + SET meta = jsonb_set( + CASE + WHEN jsonb_typeof(meta) = 'object' THEN meta + ELSE '{}'::jsonb + END, + '{killturn}', + to_jsonb( + GREATEST( + CASE + WHEN jsonb_typeof(meta->'killturn') = 'number' + THEN (meta->>'killturn')::double precision + ELSE 0::double precision + END, + ${nextKillturn}::double precision + ) + ), + true + ) + WHERE id = ${general.id} + AND npc_state < 2 + AND ( + jsonb_typeof(meta->'killturn') IS DISTINCT FROM 'number' + OR (meta->>'killturn')::double precision < ${nextKillturn} + ) + RETURNING id + ` + ); + return updated.length > 0; +}; + const assertReservedTurnPermission = async ( worldState: WorldStateRow, general: GeneralRow, @@ -535,6 +599,7 @@ export const turnsRouter = router({ const args = await parseCommandArgs('nation', input.action, input.args); const worldState = await getReservationWorldState(ctx); await assertScenarioCommandAvailable('nation', input.action, worldState); + assertNationTurnInputAllowed(general); await assertReservedTurnPermission(worldState, general, 'nation', input.action, args); const snapshot = await mutateReservedTurns(() => @@ -548,6 +613,10 @@ export const turnsRouter = router({ input.expectedRevision ) ); + if (await refillNationTurnInputKillturn(ctx, general, worldState)) { + ctx.changeJournal?.mark('general.content', general.id); + ctx.changeJournal?.mark('dashboard.global'); + } return { ok: true, ...snapshot }; }), shiftNation: authedProcedure @@ -639,17 +708,18 @@ export const turnsRouter = router({ message: 'General is not an officer.', }); } - const updates = await Promise.all( - input.entries.map(async (entry) => ({ + const worldState = await getReservationWorldState(ctx); + const updates: ReservedTurnUpdate[] = []; + for (const entry of input.entries) { + const update = { turnIndices: entry.turnList, action: entry.action, args: await parseCommandArgs('nation', entry.action, entry.args), - })) - ); - const worldState = await getReservationWorldState(ctx); - for (const update of updates) { + }; await assertScenarioCommandAvailable('nation', update.action, worldState); + assertNationTurnInputAllowed(general); await assertReservedTurnPermission(worldState, general, 'nation', update.action, update.args); + updates.push(update); } const snapshot = await mutateReservedTurns(() => setNationTurnsAtCurrentPositions( @@ -660,6 +730,10 @@ export const turnsRouter = router({ input.expectedRevision ) ); + if (await refillNationTurnInputKillturn(ctx, general, worldState)) { + ctx.changeJournal?.mark('general.content', general.id); + ctx.changeJournal?.mark('dashboard.global'); + } return { ok: true, ...snapshot }; }), }), diff --git a/app/game-api/test/diplomacyDocumentMessages.integration.test.ts b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts new file mode 100644 index 00000000..fcfd10ba --- /dev/null +++ b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts @@ -0,0 +1,516 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { JosaUtil } from '@sammo-ts/common'; +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra'; +import { + MESSAGE_MAILBOX_NATIONAL_BASE, + type MessagePayload, + type MessageTarget, + type MessageType, +} from '@sammo-ts/logic'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { GameApiContext } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { appRouter } from '../src/router.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); + +const fixtureNationId = 841; +const foreignNationId = fixtureNationId + 1; +const fixtureGeneralId = 8_864_243; +const foreignGeneralId = fixtureGeneralId + 1; +const fixtureWorldStateId = -8_864_241; +const fixtureUserId = 'diplomacy-document-message-src-user'; +const foreignUserId = 'diplomacy-document-message-dest-user'; +const requestPrefix = 'integration:diplomacy-document-message'; +const fixtureMailboxes = [ + MESSAGE_MAILBOX_NATIONAL_BASE + fixtureNationId, + MESSAGE_MAILBOX_NATIONAL_BASE + foreignNationId, +] as const; +const clockBaseTime = new Date('0208-04-05T06:07:08.000Z'); +const logicalGameTime = new Date('0208-04-05T06:17:08.000Z'); +const logicalGameTick = 36_000_000n; + +const buildAuth = (userId: string, sessionSuffix: string): GameSessionTokenPayload => ({ + version: 1, + profile: 'che:diplomacy-document-message', + issuedAt: '2026-08-24T00:00:00.000Z', + expiresAt: '2027-08-24T00:00:00.000Z', + sessionId: `diplomacy-document-message-${sessionSuffix}`, + user: { + id: userId, + username: userId, + displayName: userId, + roles: ['user'], + }, + sanctions: {}, +}); + +const fixtureAuth = buildAuth(fixtureUserId, 'src'); +const foreignAuth = buildAuth(foreignUserId, 'dest'); + +const isFixtureOutboxPayload = (payload: unknown): boolean => { + if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false; + const changes = (payload as { changes?: unknown }).changes; + return ( + Array.isArray(changes) && + changes.some( + (change) => + Array.isArray(change) && + change[0] === 'messages.mailbox' && + fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number]) + ) + ); +}; + +integration('diplomacy document message persistence', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + + const deleteFixtureOutboxes = async (): Promise => { + const rows = await db.readModelOutbox.findMany({ select: { id: true, payload: true } }); + const ids = rows.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id); + if (ids.length > 0) { + await db.readModelOutbox.deleteMany({ where: { id: { in: ids } } }); + } + }; + + const cleanupRouteState = async (): Promise => { + await db.message.deleteMany({ where: { mailbox: { in: [...fixtureMailboxes] } } }); + await db.diplomacyLetter.deleteMany({ + where: { + OR: [ + { srcNationId: { in: [fixtureNationId, foreignNationId] } }, + { destNationId: { in: [fixtureNationId, foreignNationId] } }, + ], + }, + }); + await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); + await deleteFixtureOutboxes(); + await db.readModelRevision.deleteMany({ + where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, + }); + }; + + const cleanupFixture = async (): Promise => { + await cleanupRouteState(); + await db.general.deleteMany({ where: { id: { in: [fixtureGeneralId, foreignGeneralId] } } }); + await db.nation.deleteMany({ where: { id: { in: [fixtureNationId, foreignNationId] } } }); + await db.worldState.deleteMany({ where: { id: fixtureWorldStateId } }); + }; + + const buildContext = ( + requestId: string, + auth: GameSessionTokenPayload, + database: GameApiContext['db'] = db + ): GameApiContext => { + const redisClient = { + get: async () => null, + set: async () => null, + publish: async () => 0, + } as unknown as RedisConnector['client']; + return { + requestId: `${requestPrefix}:${requestId}`, + db: database, + redis: redisClient, + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile: { + id: 'che', + scenario: 'diplomacy-document-message', + name: 'che:diplomacy-document-message', + }, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + auth, + accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:diplomacy-document-message'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'diplomacy-document-message-secret', + }; + }; + + const createLetter = async ( + options: { + state?: 'PROPOSED' | 'ACTIVATED'; + srcNationId?: number; + destNationId?: number; + srcSignerId?: number; + destSignerId?: number | null; + } = {} + ) => { + const srcNationId = options.srcNationId ?? fixtureNationId; + const destNationId = options.destNationId ?? foreignNationId; + const srcSignerId = options.srcSignerId ?? fixtureGeneralId; + const state = options.state ?? 'PROPOSED'; + return db.diplomacyLetter.create({ + data: { + srcNationId, + destNationId, + state, + textBrief: '통합 외교문서', + textDetail: '통합 외교문서 상세', + date: logicalGameTime, + srcSignerId, + destSignerId: + options.destSignerId === undefined + ? state === 'ACTIVATED' + ? foreignGeneralId + : null + : options.destSignerId, + aux: { + src: { + nationName: srcNationId === fixtureNationId ? '원민국' : '상대국', + nationColor: srcNationId === fixtureNationId ? '#123456' : '#654321', + generalId: srcSignerId, + generalName: srcSignerId === fixtureGeneralId ? '원민수뇌' : '상대수뇌', + }, + dest: { + nationName: destNationId === fixtureNationId ? '원민국' : '상대국', + nationColor: destNationId === fixtureNationId ? '#123456' : '#654321', + }, + }, + }, + }); + }; + + const expectInputEvent = async (requestId: string, route: string, actorUserId: string): Promise => { + await expect( + db.inputEvent.findUniqueOrThrow({ + where: { requestId: `${requestPrefix}:${requestId}:diplomacy.${route}` }, + }) + ).resolves.toMatchObject({ + target: 'API', + eventType: `diplomacy.${route}`, + actorUserId, + status: 'SUCCEEDED', + attempts: 1, + }); + }; + + const expectNoticeCopies = async (options: { + text: string; + types: readonly MessageType[]; + src: Pick; + dest: Pick; + }): Promise => { + const allRows = await db.message.findMany({ + where: { mailbox: { in: [...fixtureMailboxes] } }, + orderBy: { id: 'asc' }, + }); + const srcMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId; + const destMailbox = MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId; + + for (const type of options.types) { + const rows = allRows.filter((row) => { + const payload = row.message as unknown as MessagePayload; + return row.type === type && payload.text === options.text; + }); + expect(rows, `${type}: ${options.text}`).toHaveLength(2); + expect(rows.map(({ mailbox }) => mailbox).sort((left, right) => left - right)).toEqual( + [srcMailbox, destMailbox].sort((left, right) => left - right) + ); + + const receiver = rows.find(({ mailbox }) => mailbox === destMailbox); + const sender = rows.find(({ mailbox }) => mailbox === srcMailbox); + expect(receiver).toBeDefined(); + expect(sender).toBeDefined(); + + for (const row of rows) { + const payload = row.message as unknown as MessagePayload; + expect(row).toMatchObject({ + type, + src: srcMailbox, + dest: destMailbox, + time: logicalGameTime, + timeTick: logicalGameTick, + }); + expect(payload).toMatchObject({ + src: options.src, + dest: options.dest, + text: options.text, + option: { deletable: false }, + }); + expect(payload.option?.deletable).toBe(false); + } + + const receiverPayload = receiver?.message as unknown as MessagePayload; + const senderPayload = sender?.message as unknown as MessagePayload; + expect(receiverPayload.option).toEqual({ deletable: false }); + expect(senderPayload.option).toMatchObject({ + deletable: false, + receiverMessageID: receiver?.id, + }); + } + }; + + const buildRollbackDatabase = (failure: Error): GameApiContext['db'] => { + const database = db as unknown as GameApiContext['db']; + return new Proxy(database, { + get(target, property) { + if (property === '$transaction') { + return async (callback: (transaction: GameApiContext['db']) => Promise) => + db.$transaction(async (transaction) => { + await callback(transaction as unknown as GameApiContext['db']); + throw failure; + }); + } + return Reflect.get(target, property, target); + }, + }); + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await cleanupFixture(); + await db.worldState.create({ + data: { + id: fixtureWorldStateId, + scenarioCode: 'diplomacy-document-message', + currentYear: 208, + currentMonth: 4, + tickSeconds: 600, + clockBaseTime, + clockTick: logicalGameTick, + clockMode: 'manual', + clockWallAnchor: new Date('2026-08-24T00:00:00.000Z'), + config: {}, + meta: {}, + }, + }); + await db.nation.createMany({ + data: [ + { id: fixtureNationId, name: '원민국', color: '#123456' }, + { id: foreignNationId, name: '상대국', color: '#654321' }, + ], + }); + await db.general.createMany({ + data: [ + { + id: fixtureGeneralId, + userId: fixtureUserId, + name: '원민수뇌', + nationId: fixtureNationId, + officerLevel: 12, + picture: 'src.png', + imageServer: 0, + turnTime: logicalGameTime, + }, + { + id: foreignGeneralId, + userId: foreignUserId, + name: '상대수뇌', + nationId: foreignNationId, + officerLevel: 12, + picture: 'dest.png', + imageServer: 0, + turnTime: logicalGameTime, + }, + ], + }); + }); + + beforeEach(cleanupRouteState); + afterEach(cleanupRouteState); + + afterAll(async () => { + await cleanupFixture(); + await closeDb?.(); + }); + + it('stores only diplomacy receiver/sender copies for new and chained documents', async () => { + const firstRequestId = 'send-first'; + const first = await appRouter.createCaller(buildContext(firstRequestId, fixtureAuth)).diplomacy.sendLetter({ + destNationId: foreignNationId, + brief: '첫 외교문서', + detail: '첫 외교문서 상세', + }); + const firstText = `새로운 외교 문서 #${first.id}${JosaUtil.pick(String(first.id), '이')} 준비되었습니다. 외교부에서 확인해주세요.`; + await expectNoticeCopies({ + text: firstText, + types: ['diplomacy'], + src: { + generalId: fixtureGeneralId, + generalName: '원민수뇌', + nationId: fixtureNationId, + nationName: '원민국', + }, + dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' }, + }); + await expectInputEvent(firstRequestId, 'sendLetter', fixtureUserId); + + const chainedRequestId = 'send-chained'; + const chained = await appRouter.createCaller(buildContext(chainedRequestId, fixtureAuth)).diplomacy.sendLetter({ + destNationId: foreignNationId, + prevId: first.id, + brief: '후속 외교문서', + detail: '후속 외교문서 상세', + }); + const chainedText = `문서 #${first.id}의 새로운 외교 문서 #${chained.id}${JosaUtil.pick(String(chained.id), '이')} 준비되었습니다. 외교부에서 확인해주세요.`; + await expectNoticeCopies({ + text: chainedText, + types: ['diplomacy'], + src: { + generalId: fixtureGeneralId, + generalName: '원민수뇌', + nationId: fixtureNationId, + nationName: '원민국', + }, + dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' }, + }); + expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(4); + await expectInputEvent(chainedRequestId, 'sendLetter', fixtureUserId); + }); + + it('stores diplomacy and national copies for both approval and rejection responses', async () => { + const approved = await createLetter(); + const approveRequestId = 'respond-approve'; + await expect( + appRouter + .createCaller(buildContext(approveRequestId, foreignAuth)) + .diplomacy.respondLetter({ letterId: approved.id, agree: true }) + ).resolves.toEqual({ ok: true }); + await expectNoticeCopies({ + text: `외교 서신( #${approved.id})이 승인되었습니다.`, + types: ['diplomacy', 'national'], + src: { + generalId: foreignGeneralId, + generalName: '상대수뇌', + nationId: foreignNationId, + nationName: '상대국', + }, + dest: { generalId: 0, generalName: '', nationId: fixtureNationId, nationName: '원민국' }, + }); + await expectInputEvent(approveRequestId, 'respondLetter', foreignUserId); + + const rejected = await createLetter(); + const rejectRequestId = 'respond-reject'; + await expect( + appRouter.createCaller(buildContext(rejectRequestId, foreignAuth)).diplomacy.respondLetter({ + letterId: rejected.id, + agree: false, + reason: '조건 불충족', + }) + ).resolves.toEqual({ ok: true }); + await expectNoticeCopies({ + text: `외교 서신(#${rejected.id})이 거부되었습니다. 이유 : 조건 불충족`, + types: ['diplomacy', 'national'], + src: { + generalId: foreignGeneralId, + generalName: '상대수뇌', + nationId: foreignNationId, + nationName: '상대국', + }, + dest: { generalId: 0, generalName: '', nationId: fixtureNationId, nationName: '원민국' }, + }); + expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(8); + await expectInputEvent(rejectRequestId, 'respondLetter', foreignUserId); + }); + + it('stores diplomacy copies when the sender rolls a proposed document back', async () => { + const letter = await createLetter(); + const requestId = 'rollback'; + await expect( + appRouter + .createCaller(buildContext(requestId, fixtureAuth)) + .diplomacy.rollbackLetter({ letterId: letter.id }) + ).resolves.toEqual({ ok: true }); + await expectNoticeCopies({ + text: `외교 서신(#${letter.id})이 회수되었습니다.`, + types: ['diplomacy'], + src: { + generalId: fixtureGeneralId, + generalName: '원민수뇌', + nationId: fixtureNationId, + nationName: '원민국', + }, + dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' }, + }); + expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(2); + await expectInputEvent(requestId, 'rollbackLetter', fixtureUserId); + }); + + it('stores actor-directed diplomacy copies for the first and second destroy phases', async () => { + const letter = await createLetter({ state: 'ACTIVATED' }); + const requestRequestId = 'destroy-request'; + await expect( + appRouter + .createCaller(buildContext(requestRequestId, fixtureAuth)) + .diplomacy.destroyLetter({ letterId: letter.id }) + ).resolves.toEqual({ state: 'ACTIVATED' }); + await expectNoticeCopies({ + text: `외교 서신(#${letter.id})을 파기 요청합니다.`, + types: ['diplomacy'], + src: { + generalId: fixtureGeneralId, + generalName: '원민수뇌', + nationId: fixtureNationId, + nationName: '원민국', + }, + dest: { generalId: 0, generalName: '', nationId: foreignNationId, nationName: '상대국' }, + }); + await expectInputEvent(requestRequestId, 'destroyLetter', fixtureUserId); + + const completeRequestId = 'destroy-complete'; + await expect( + appRouter + .createCaller(buildContext(completeRequestId, foreignAuth)) + .diplomacy.destroyLetter({ letterId: letter.id }) + ).resolves.toEqual({ state: 'CANCELLED' }); + await expectNoticeCopies({ + text: `외교 서신(#${letter.id})을 파기했습니다.`, + types: ['diplomacy'], + src: { + generalId: foreignGeneralId, + generalName: '상대수뇌', + nationId: foreignNationId, + nationName: '상대국', + }, + dest: { generalId: 0, generalName: '', nationId: fixtureNationId, nationName: '원민국' }, + }); + expect(await db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).toBe(4); + await expectInputEvent(completeRequestId, 'destroyLetter', foreignUserId); + }); + + it('rolls letter and message writes back together while retaining the failed API input event', async () => { + const failure = new Error('injected diplomacy message transaction rollback'); + const requestId = 'send-rollback'; + const rollbackDb = buildRollbackDatabase(failure); + + await expect( + appRouter.createCaller(buildContext(requestId, fixtureAuth, rollbackDb)).diplomacy.sendLetter({ + destNationId: foreignNationId, + brief: 'rollback 외교문서', + detail: 'rollback 외교문서 상세', + }) + ).rejects.toThrow(failure.message); + + await expect(db.diplomacyLetter.findFirst({ where: { textBrief: 'rollback 외교문서' } })).resolves.toBeNull(); + await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0); + await expect( + db.readModelRevision.count({ + where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, + }) + ).resolves.toBe(0); + await expect( + db.inputEvent.findUniqueOrThrow({ + where: { requestId: `${requestPrefix}:${requestId}:diplomacy.sendLetter` }, + }) + ).resolves.toMatchObject({ + target: 'API', + eventType: 'diplomacy.sendLetter', + actorUserId: fixtureUserId, + status: 'FAILED', + attempts: 1, + error: failure.message, + }); + }); +}); diff --git a/app/game-api/test/diplomacyRouter.test.ts b/app/game-api/test/diplomacyRouter.test.ts index a595b3a7..935ba208 100644 --- a/app/game-api/test/diplomacyRouter.test.ts +++ b/app/game-api/test/diplomacyRouter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { RedisConnector } from '@sammo-ts/infra'; @@ -88,7 +89,10 @@ const storedLetter = { const buildContext = (officerLevel = 12, letter: Record = storedLetter) => { const create = vi.fn(async () => ({ id: 9 })); + let messageId = 100; + const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]); const db = { + $queryRaw: queryRaw, general: { findFirst: vi.fn(async () => buildGeneral(officerLevel)), findMany: vi.fn(async () => [ @@ -136,7 +140,7 @@ const buildContext = (officerLevel = 12, letter: Record = store flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; - return { caller: appRouter.createCaller(context), create }; + return { caller: appRouter.createCaller(context), create, queryRaw }; }; describe('diplomacy HTML API boundary', () => { @@ -158,6 +162,14 @@ describe('diplomacy HTML API boundary', () => { date: new Date('0185-01-01T00:00:00.000Z'), }), }); + expect(fixture.queryRaw).toHaveBeenCalledTimes(2); + expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toEqual( + expect.arrayContaining([9002, 'diplomacy', 9001, 9002]) + ); + expect(fixture.queryRaw.mock.calls[1]?.slice(1)).toEqual(expect.arrayContaining([9001, 'diplomacy'])); + expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toContain(BigInt(MAX_SAFE_GAME_TICK)); + expect(fixture.queryRaw.mock.calls[0]?.find((value) => typeof value === 'string' && value.includes('text'))) + .toContain('새로운 외교 문서 #9가 준비되었습니다. 외교부에서 확인해주세요.'); }); it('purifies legacy stored rows on every read while preserving secret redaction', async () => { diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 687c02dc..7634ddc2 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -24,7 +24,7 @@ const profile: GameProfile = { name: 'che:default', }; -const buildWorldState = (joinMode = 'full'): WorldStateRow => +const buildWorldState = (joinMode = 'full', killturn?: number): WorldStateRow => ({ id: 1, scenarioCode: 'default', @@ -39,6 +39,7 @@ const buildWorldState = (joinMode = 'full'): WorldStateRow => scenarioMeta: { startYear: 180, }, + ...(killturn === undefined ? {} : { killturn }), }, updatedAt: new Date('2026-01-01T00:00:00Z'), }) as unknown as WorldStateRow; @@ -114,6 +115,7 @@ const buildContext = (options?: { nationTurns?: NationTurnRow[]; generalTurnWrites?: unknown[]; nationTurnWrites?: unknown[]; + generalUpdates?: unknown[]; auth?: GameSessionTokenPayload | null; currentAccountIcon?: unknown; accountIconGet?: (userId: string) => Promise; @@ -128,6 +130,10 @@ const buildContext = (options?: { let generalTurnRevision: number | undefined; let nationTurnRevision: number | undefined; const db = { + $queryRaw: async (query: unknown) => { + options?.generalUpdates?.push(query); + return options?.generalUpdates ? [{ id: options.general?.id ?? 0 }] : []; + }, worldState: { findFirst: async () => { if (options?.worldStateReads) { @@ -1106,6 +1112,213 @@ describe('appRouter', () => { expect(nationWrites).toHaveLength(2); }); + it('preserves Ref nation-turn penalty key semantics and validation priority', async () => { + const general = buildGeneralRow({ + id: 22, + nationId: 3, + officerLevel: 12, + penalty: { noChiefTurnInput: 0 }, + meta: { killturn: 3 }, + }); + + const malformedWrites: unknown[] = []; + const malformedUpdates: unknown[] = []; + const malformedCaller = appRouter.createCaller( + buildContext({ + state: buildWorldState('full', 12), + general, + nationTurnWrites: malformedWrites, + generalUpdates: malformedUpdates, + }) + ); + await expect( + malformedCaller.turns.reserved.setNation({ + generalId: general.id, + turnIndex: 0, + action: 'che_포상', + args: { isGold: true, amount: '1', destGeneralId: 7 }, + expectedRevision: 0, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(malformedWrites).toHaveLength(0); + expect(malformedUpdates).toHaveLength(0); + + const singleWrites: unknown[] = []; + const singleUpdates: unknown[] = []; + const singleJournal = new ChangeJournal(); + const singleCaller = appRouter.createCaller( + buildContext({ + state: buildWorldState('full', 12), + general, + nationTurnWrites: singleWrites, + generalUpdates: singleUpdates, + changeJournal: singleJournal, + }) + ); + await expect( + singleCaller.turns.reserved.setNation({ + generalId: general.id, + turnIndex: 0, + action: '휴식', + expectedRevision: 0, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '수뇌 턴 입력 불가능', + }); + expect(singleWrites).toHaveLength(0); + expect(singleUpdates).toHaveLength(0); + expect(singleJournal.snapshot()).toEqual([]); + + const bulkWrites: unknown[] = []; + const bulkUpdates: unknown[] = []; + const bulkCaller = appRouter.createCaller( + buildContext({ + state: buildWorldState('full', 12), + general, + nationTurnWrites: bulkWrites, + generalUpdates: bulkUpdates, + }) + ); + await expect( + bulkCaller.turns.reserved.setNationBulk({ + generalId: general.id, + entries: [ + { turnList: [0], action: '휴식' }, + { turnList: [1], action: 'not-a-command' }, + ], + expectedRevision: 0, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: '수뇌 턴 입력 불가능', + }); + expect(bulkWrites).toHaveLength(0); + expect(bulkUpdates).toHaveLength(0); + }); + + it('refills user killturn after a successful nation reservation and invalidates its readers', async () => { + const general = buildGeneralRow({ + id: 23, + nationId: 3, + officerLevel: 12, + npcState: 0, + meta: { killturn: 3, marker: 'kept' }, + }); + const nationWrites: unknown[] = []; + const generalUpdates: unknown[] = []; + const changeJournal = new ChangeJournal(); + const caller = appRouter.createCaller( + buildContext({ + state: buildWorldState('full', 12), + general, + nationTurnWrites: nationWrites, + generalUpdates, + changeJournal, + }) + ); + + await expect( + caller.turns.reserved.setNation({ + generalId: general.id, + turnIndex: 0, + action: '휴식', + expectedRevision: 0, + }) + ).resolves.toMatchObject({ ok: true }); + + expect(nationWrites).toHaveLength(1); + expect(generalUpdates).toHaveLength(1); + expect(generalUpdates[0]).toMatchObject({ values: [12, general.id, 12] }); + expect(changeJournal.snapshot()).toEqual([ + { domain: 'dashboard.global', entityId: 0 }, + { domain: 'general.content', entityId: general.id }, + ]); + }); + + it('refills killturn once after all nation bulk entries succeed', async () => { + const general = buildGeneralRow({ + id: 24, + nationId: 3, + officerLevel: 12, + npcState: 1, + meta: { killturn: 2 }, + }); + const nationWrites: unknown[] = []; + const generalUpdates: unknown[] = []; + const caller = appRouter.createCaller( + buildContext({ + state: buildWorldState('full', 12), + general, + nationTurnWrites: nationWrites, + generalUpdates, + }) + ); + + await expect( + caller.turns.reserved.setNationBulk({ + generalId: general.id, + entries: [ + { turnList: [0], action: '휴식' }, + { + turnList: [1], + action: 'che_포상', + args: { isGold: true, amount: 1, destGeneralId: 7 }, + }, + ], + expectedRevision: 0, + }) + ).resolves.toMatchObject({ ok: true }); + + expect(nationWrites).toHaveLength(1); + expect(generalUpdates).toHaveLength(1); + expect(generalUpdates[0]).toMatchObject({ values: [12, general.id, 12] }); + }); + + it.each([ + { + label: '자동 장수', + npcState: 2, + currentKillturn: 3, + worldKillturn: 12, + }, + { + label: '세계 기본보다 삭턴이 많은 유저 장수', + npcState: 0, + currentKillturn: 20, + worldKillturn: 12, + }, + ])('$label nation reservation does not change killturn', async ({ npcState, currentKillturn, worldKillturn }) => { + const general = buildGeneralRow({ + id: 25 + npcState, + nationId: 3, + officerLevel: 12, + npcState, + meta: { killturn: currentKillturn }, + }); + const generalUpdates: unknown[] = []; + const changeJournal = new ChangeJournal(); + const caller = appRouter.createCaller( + buildContext({ + state: buildWorldState('full', worldKillturn), + general, + generalUpdates, + changeJournal, + }) + ); + + await expect( + caller.turns.reserved.setNation({ + generalId: general.id, + turnIndex: 0, + action: '휴식', + expectedRevision: 0, + }) + ).resolves.toMatchObject({ ok: true }); + expect(generalUpdates).toHaveLength(0); + expect(changeJournal.snapshot()).toEqual([]); + }); + it('enforces only legacy reservation permissions without applying full execution constraints', async () => { const general = buildGeneralRow({ id: 19 }); const allowedWrites: unknown[] = [];