diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 9eb5742f..4346c992 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -7,12 +7,12 @@ import { ItemLoader, isItemKey, loadWarTraitModules, + sendMessage, WarTraitLoader, WAR_TRAIT_KEYS, isWarTraitKey, } from '@sammo-ts/logic'; -import type { InheritBuffType } from '@sammo-ts/logic'; -import type { ItemSlot } from '@sammo-ts/logic'; +import type { InheritBuffType, ItemSlot, MessageDraft, MessageRecordDraft } from '@sammo-ts/logic'; import { simpleSerialize } from '@sammo-ts/logic/war/utils.js'; import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { @@ -28,6 +28,9 @@ import { } from '../../services/inheritance.js'; import type { GameApiContext, WorldStateRow } from '../../context.js'; import { openAuctionWithDaemon } from '../../auction/open.js'; +import { buildTargetFromGeneral } from '../../messages/targets.js'; +import { insertMessage } from '../../messages/store.js'; +import { loadCurrentGameTime } from '../../services/gameClock.js'; const BUFF_KEYS: InheritBuffType[] = [ 'warAvoidRatio', @@ -881,11 +884,8 @@ export const inheritRouter = router({ } const [general, target] = await Promise.all([ - ctx.db.general.findFirst({ where: { userId }, select: { id: true } }), - ctx.db.general.findUnique({ - where: { id: input.targetGeneralId }, - select: { id: true, name: true, userId: true, meta: true }, - }), + ctx.db.general.findFirst({ where: { userId } }), + ctx.db.general.findUnique({ where: { id: input.targetGeneralId } }), ]); if (!general) { throw new TRPCError({ code: 'PRECONDITION_FAILED', message: '장수가 존재하지 않습니다.' }); @@ -909,6 +909,42 @@ export const inheritRouter = router({ worldState.currentMonth, `${inheritConst.inheritCheckOwnerPoint} 포인트로 장수 소유자 확인` ); + + const [generalTarget, checkedTarget, gameTime] = await Promise.all([ + buildTargetFromGeneral(ctx.db, general), + buildTargetFromGeneral(ctx.db, target), + loadCurrentGameTime(ctx.db), + ]); + const systemTarget: MessageDraft['src'] = { + generalId: 0, + generalName: '', + nationId: 0, + nationName: 'System', + color: '#000000', + icon: '', + }; + const validUntil = new Date('9999-12-31T00:00:00.000Z'); + const sendSystemPrivateMessage = async (dest: MessageDraft['dest'], text: string): Promise => { + await sendMessage( + { + insertMessage: (draft: MessageRecordDraft) => insertMessage(ctx.db, draft), + }, + { + msgType: 'private', + src: systemTarget, + dest, + text, + time: gameTime.now, + validUntil, + option: {}, + }, + { sendDestOnly: true } + ); + ctx.changeJournal?.mark('messages.mailbox', dest.generalId); + }; + + await sendSystemPrivateMessage(generalTarget, `${target.name}의 소유자는 ${ownerName} 입니다.`); + await sendSystemPrivateMessage(checkedTarget, '소유자명이 누군가에 의해 확인되었습니다.'); return { ok: true, ownerName, targetName: target.name }; }), }); diff --git a/app/game-api/test/directMutationJournalInventory.test.ts b/app/game-api/test/directMutationJournalInventory.test.ts index 9789fa10..b24800a6 100644 --- a/app/game-api/test/directMutationJournalInventory.test.ts +++ b/app/game-api/test/directMutationJournalInventory.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'; const classifications = { durableJournal: [ 'betting.bet', + 'inherit.checkOwner', 'messages.delete', 'messages.respond', 'messages.send', @@ -36,7 +37,6 @@ const classifications = { 'diplomacy.respondLetter', 'diplomacy.rollbackLetter', 'diplomacy.sendLetter', - 'inherit.checkOwner', 'join.getSelectionPool', 'join.listPossessCandidates', 'messages.readLatest', diff --git a/app/game-api/test/inheritOwnerMessages.integration.test.ts b/app/game-api/test/inheritOwnerMessages.integration.test.ts new file mode 100644 index 00000000..930a062f --- /dev/null +++ b/app/game-api/test/inheritOwnerMessages.integration.test.ts @@ -0,0 +1,228 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; +import { createGamePostgresConnector, type GamePrismaClient, type RedisConnector } from '@sammo-ts/infra'; + +import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; +import { InMemoryBattleSimTransport } from '../src/battleSim/inMemoryTransport.js'; +import type { GameApiContext } from '../src/context.js'; +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { InMemoryFlushStore } from '../src/auth/flushStore.js'; +import { appRouter } from '../src/router.js'; + +const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const actorGeneralId = 8_701; +const checkedGeneralId = 8_702; +const actorNationId = 871; +const checkedNationId = 872; +const actorUserId = 'inherit-owner-message-actor'; +const checkedUserId = 'inherit-owner-message-checked'; + +const auth: GameSessionTokenPayload = { + version: 1, + profile: 'che:inherit-owner-message', + issuedAt: '2026-08-19T00:00:00.000Z', + expiresAt: '2026-08-20T00:00:00.000Z', + sessionId: 'inherit-owner-message-session', + user: { + id: actorUserId, + username: actorUserId, + displayName: '확인자 계정', + roles: ['user'], + }, + sanctions: {}, +}; + +const hasMailboxChange = (payload: unknown): boolean => { + if (!payload || typeof payload !== 'object' || !('changes' in payload)) return false; + const changes = (payload as { changes?: unknown }).changes; + if (!Array.isArray(changes)) return false; + const mailboxes = new Set([actorGeneralId, checkedGeneralId]); + return changes.some( + (change) => + Array.isArray(change) && + change[0] === 'messages.mailbox' && + typeof change[1] === 'number' && + mailboxes.has(change[1]) + ); +}; + +integration('inherit owner lookup private messages', () => { + let db: GamePrismaClient; + let closeDb: (() => Promise) | undefined; + let worldStateId: number; + + const buildContext = (requestId: string): GameApiContext => { + const redisClient = { + get: async () => null, + set: async () => null, + }; + return { + requestId, + db, + redis: redisClient as unknown as RedisConnector['client'], + turnDaemon: new InMemoryTurnDaemonTransport(), + battleSim: new InMemoryBattleSimTransport(), + profile: { id: 'che', scenario: 'inherit-owner-message', name: 'che:inherit-owner-message' }, + uploadDir: 'uploads', + uploadPath: '/uploads', + uploadPublicUrl: null, + auth, + accessTokenStore: new RedisAccessTokenStore(redisClient, 'che:inherit-owner-message'), + flushStore: new InMemoryFlushStore(), + gameTokenSecret: 'test-secret', + readModelOutbox: { wake: vi.fn() }, + }; + }; + + beforeAll(async () => { + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + + await db.inputEvent.deleteMany({ where: { actorUserId } }); + await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } }); + await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } }); + await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } }); + await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } }); + await db.readModelRevision.deleteMany({ + where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } }, + }); + + await db.nation.createMany({ + data: [ + { id: actorNationId, name: '확인국', color: '#123456', level: 2 }, + { id: checkedNationId, name: '피확인국', color: '#654321', level: 3 }, + ], + }); + await db.general.createMany({ + data: [ + { + id: actorGeneralId, + userId: actorUserId, + name: '확인장수', + nationId: actorNationId, + cityId: 1, + npcState: 0, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { ownerName: '확인자 계정' }, + }, + { + id: checkedGeneralId, + userId: checkedUserId, + name: '피확인장수', + nationId: checkedNationId, + cityId: 1, + npcState: 0, + turnTime: new Date('0200-01-01T00:00:00.000Z'), + meta: { ownerName: '피확인 계정' }, + }, + ], + }); + await db.inheritancePoint.create({ + data: { userId: actorUserId, key: 'previous', value: 1_500 }, + }); + const world = await db.worldState.create({ + data: { + scenarioCode: 'inherit-owner-message', + currentYear: 200, + currentMonth: 4, + tickSeconds: 600, + config: { const: { inheritCheckOwnerPoint: 1_000 } }, + meta: { isUnited: 0 }, + }, + }); + worldStateId = world.id; + }); + + afterAll(async () => { + const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } }); + const outboxIds = outboxes.filter(({ payload }) => hasMailboxChange(payload)).map(({ id }) => id); + if (outboxIds.length > 0) { + await db.readModelOutbox.deleteMany({ where: { id: { in: outboxIds } } }); + } + await db.inputEvent.deleteMany({ where: { actorUserId } }); + await db.message.deleteMany({ where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } } }); + await db.inheritanceLog.deleteMany({ where: { userId: actorUserId } }); + await db.inheritancePoint.deleteMany({ where: { userId: actorUserId } }); + await db.general.deleteMany({ where: { id: { in: [actorGeneralId, checkedGeneralId] } } }); + await db.nation.deleteMany({ where: { id: { in: [actorNationId, checkedNationId] } } }); + await db.readModelRevision.deleteMany({ + where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } }, + }); + await db.worldState.delete({ where: { id: worldStateId } }); + await closeDb?.(); + }); + + it('commits the point charge, log, and both Ref-compatible private messages', async () => { + const requestId = 'integration:inherit-owner-message:success'; + await expect( + appRouter.createCaller(buildContext(requestId)).inherit.checkOwner({ targetGeneralId: checkedGeneralId }) + ).resolves.toEqual({ + ok: true, + ownerName: '피확인 계정', + targetName: '피확인장수', + }); + + await expect( + db.inheritancePoint.findUniqueOrThrow({ + where: { userId_key: { userId: actorUserId, key: 'previous' } }, + }) + ).resolves.toMatchObject({ value: 500 }); + await expect(db.inheritanceLog.findMany({ where: { userId: actorUserId } })).resolves.toEqual([ + expect.objectContaining({ + year: 200, + month: 4, + text: '1000 포인트로 장수 소유자 확인', + }), + ]); + + const messages = await db.message.findMany({ + where: { mailbox: { in: [actorGeneralId, checkedGeneralId] } }, + orderBy: { mailbox: 'asc' }, + }); + expect(messages).toHaveLength(2); + expect( + messages.map(({ mailbox, type, src, dest, message }) => ({ mailbox, type, src, dest, message })) + ).toEqual([ + { + mailbox: actorGeneralId, + type: 'private', + src: 0, + dest: actorGeneralId, + message: expect.objectContaining({ + src: expect.objectContaining({ generalId: 0, nationName: 'System' }), + dest: expect.objectContaining({ generalId: actorGeneralId, generalName: '확인장수' }), + text: '피확인장수의 소유자는 피확인 계정 입니다.', + }), + }, + { + mailbox: checkedGeneralId, + type: 'private', + src: 0, + dest: checkedGeneralId, + message: expect.objectContaining({ + src: expect.objectContaining({ generalId: 0, nationName: 'System' }), + dest: expect.objectContaining({ generalId: checkedGeneralId, generalName: '피확인장수' }), + text: '소유자명이 누군가에 의해 확인되었습니다.', + }), + }, + ]); + + await expect( + db.inputEvent.findUniqueOrThrow({ where: { requestId: `${requestId}:inherit.checkOwner` } }) + ).resolves.toMatchObject({ status: 'SUCCEEDED', actorUserId }); + await expect( + db.readModelRevision.findMany({ + where: { domain: 'messages.mailbox', entityId: { in: [actorGeneralId, checkedGeneralId] } }, + orderBy: { entityId: 'asc' }, + }) + ).resolves.toEqual([ + expect.objectContaining({ domain: 'messages.mailbox', entityId: actorGeneralId, revision: 1n }), + expect.objectContaining({ domain: 'messages.mailbox', entityId: checkedGeneralId, revision: 1n }), + ]); + }); +}); diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index 5860eec8..f3d473f7 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; +import { ChangeJournal } from '@sammo-ts/common'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import type { RedisConnector } from '@sammo-ts/infra'; +import type { MessagePayload } from '@sammo-ts/logic'; import { RedisAccessTokenStore } from '../src/auth/accessTokenStore.js'; import { InMemoryFlushStore } from '../src/auth/flushStore.js'; @@ -91,6 +93,14 @@ const worldState = { updatedAt: new Date('2026-07-26T00:00:00Z'), }; +interface CapturedMessage { + mailbox: number; + type: string; + src: number; + dest: number; + payload: MessagePayload; +} + const buildContext = (options: { auth?: GameSessionTokenPayload | null; general?: GeneralRow | null; @@ -123,8 +133,30 @@ const buildContext = (options: { const: options.configConst, }, }; + const messageRows: CapturedMessage[] = []; + const queryRaw = vi.fn(async (query: unknown, ...values: unknown[]) => { + const queryStrings = Array.isArray(query) + ? query.map(String) + : ((query as { strings?: readonly string[] } | null)?.strings ?? []); + const sql = queryStrings.join(' '); + if (sql.includes('INSERT INTO message')) { + const payload = JSON.parse(String(values[8])) as MessagePayload; + messageRows.push({ + mailbox: Number(values[0]), + type: String(values[1]), + src: Number(values[2]), + dest: Number(values[3]), + payload, + }); + return [{ id: 100 + messageRows.length }]; + } + if (sql.includes('FROM inheritance_point')) { + return [{ value: options.inheritancePoint ?? 10_000 }]; + } + throw new Error(`Unexpected raw query in inherit router fixture: ${sql}`); + }); const db = { - $queryRaw: vi.fn(async () => [{ value: options.inheritancePoint ?? 10_000 }]), + $queryRaw: queryRaw, worldState: { findFirst: vi.fn(async () => activeWorldState), }, @@ -137,6 +169,11 @@ const buildContext = (options: { target?.id === where.id ? target : null ), }, + nation: { + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => + where.id === 1 ? { id: 1, name: '촉', color: '#ff0000' } : null + ), + }, inheritancePoint: { upsert: pointUpsert, }, @@ -156,8 +193,10 @@ const buildContext = (options: { }, 'che:default' ); + const changeJournal = new ChangeJournal(); const context: GameApiContext = { db: db as unknown as DatabaseClient, + changeJournal, redis: {} as RedisConnector['client'], turnDaemon: { requestCommand } as unknown as TurnDaemonTransport, battleSim: {} as GameApiContext['battleSim'], @@ -170,7 +209,16 @@ const buildContext = (options: { flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; - return { context, requestCommand, pointUpsert, logCreate, findMany, inheritanceLogFindMany }; + return { + context, + requestCommand, + pointUpsert, + logCreate, + findMany, + inheritanceLogFindMany, + messageRows, + changeJournal, + }; }; describe('inherit router actor and permission boundaries', () => { @@ -182,6 +230,10 @@ describe('inherit router actor and permission boundaries', () => { await expect(caller.inherit.buyHiddenBuff({ type: 'warAvoidRatio', level: 1 })).rejects.toMatchObject({ code: 'UNAUTHORIZED', }); + await expect(caller.inherit.checkOwner({ targetGeneralId: 8 })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + expect(fixture.messageRows).toHaveLength(0); expect(fixture.requestCommand).not.toHaveBeenCalled(); }); @@ -476,6 +528,68 @@ describe('inherit router actor and permission boundaries', () => { text: '1000 포인트로 장수 소유자 확인', }, }); + expect(fixture.messageRows).toHaveLength(2); + expect(fixture.messageRows).toEqual([ + expect.objectContaining({ + mailbox: 7, + type: 'private', + src: 0, + dest: 7, + payload: expect.objectContaining({ + src: expect.objectContaining({ generalId: 0, nationName: 'System' }), + dest: expect.objectContaining({ generalId: 7, generalName: '유비' }), + text: '조조의 소유자는 위유저 입니다.', + }), + }), + expect.objectContaining({ + mailbox: 8, + type: 'private', + src: 0, + dest: 8, + payload: expect.objectContaining({ + src: expect.objectContaining({ generalId: 0, nationName: 'System' }), + dest: expect.objectContaining({ generalId: 8, generalName: '조조' }), + text: '소유자명이 누군가에 의해 확인되었습니다.', + }), + }), + ]); + expect(fixture.changeJournal.snapshot()).toEqual([ + { domain: 'messages.mailbox', entityId: 7 }, + { domain: 'messages.mailbox', entityId: 8 }, + ]); expect(fixture.requestCommand).not.toHaveBeenCalled(); }); + + it('does not charge or send messages when the owner lookup target is the actor', async () => { + const fixture = buildContext({ + inheritancePoint: 1_500, + target: buildGeneral({ id: 7, userId: 'user-1', name: '유비' }), + }); + + await expect( + appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 7 }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '자신의 정보는 확인할 수 없습니다.', + }); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); + expect(fixture.messageRows).toHaveLength(0); + expect(fixture.changeJournal.snapshot()).toEqual([]); + }); + + it('does not charge or send messages when inheritance points are insufficient', async () => { + const fixture = buildContext({ inheritancePoint: 999 }); + + await expect( + appRouter.createCaller(fixture.context).inherit.checkOwner({ targetGeneralId: 8 }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '유산 포인트가 부족합니다.', + }); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); + expect(fixture.messageRows).toHaveLength(0); + expect(fixture.changeJournal.snapshot()).toEqual([]); + }); }); diff --git a/docs/architecture/game-api-direct-mutation-journal-inventory.md b/docs/architecture/game-api-direct-mutation-journal-inventory.md index dae51063..967d4efc 100644 --- a/docs/architecture/game-api-direct-mutation-journal-inventory.md +++ b/docs/architecture/game-api-direct-mutation-journal-inventory.md @@ -33,9 +33,9 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만 | 분류 | 수 | route | | --- | ---: | --- | -| durable journal | 22 | `betting.bet`; `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 | 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` | | separate access journal | 1 | `public.recordAccess` | -| explicit no realtime consumer | 15 | `board.writeArticle`, `board.writeComment`; `diplomacy.destroyLetter`, `diplomacy.respondLetter`, `diplomacy.rollbackLetter`, `diplomacy.sendLetter`; `inherit.checkOwner`; `join.getSelectionPool`, `join.listPossessCandidates`; `messages.readLatest`; `turns.repeatNation`, `turns.setNation`, `turns.setNationBulk`, `turns.shiftNation`; `vote.addComment` | +| 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` | | 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` | @@ -51,6 +51,7 @@ writer reconciliation을 포함한다. rolling deployment가 끝난 뒤에만 | writer | durable key | public wake-up | 근거와 경계 | | --- | --- | --- | --- | | `betting.bet` | `general.content:`, `betting:0` | 없음 | 본인 베팅/유산 지출과 베팅 aggregate source가 바뀐다. `betting`은 현재 별도 화면 source이고 main dashboard fan-out을 만들지 않는다. | +| `inherit.checkOwner` | 확인자·확인 대상의 `messages.mailbox:` | 두 장수 mailbox viewer에게 ID 없는 `messagesInvalidated` | Ref처럼 확인 결과와 피확인 알림을 시스템 개인 메시지로 저장하며 포인트 차감·유산 로그·두 메시지·journal을 한 API input-event transaction에서 commit한다. | | `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를 표시한다. | @@ -72,8 +73,8 @@ public dashboard event로 내보내지 않는다. browser wake-up은 정밀 enti 전쟁/불가침 상태를 실제 변경하는 `messages.respond`와 구분한다. - `messages.readLatest`는 본인의 읽음 cursor다. 요청한 tab이 이미 최신 cursor를 알고 있으므로 자기 자신에게 다시 wake-up을 보내지 않는다. -- nation reserved turn, selection-pool reservation, possession 후보, inheritance owner - 확인은 각각 전용 화면/request response가 최신 상태를 소유한다. +- nation reserved turn, selection-pool reservation과 possession 후보는 각각 전용 + 화면/request response가 최신 상태를 소유한다. - image upload는 외부 content store write이며 game PostgreSQL read model이 아니다. - battle simulation 준비와 서버 fallback은 호환상 mutation transport를 쓰지만 read-only 계산이며 input event transaction을 열지 않는다.