diff --git a/app/game-api/src/realtime/outboxWorker.ts b/app/game-api/src/realtime/outboxWorker.ts index 5d937ef9..c54ef73d 100644 --- a/app/game-api/src/realtime/outboxWorker.ts +++ b/app/game-api/src/realtime/outboxWorker.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { + readModelOutboxPayloadToDiplomacyMailboxes, readModelOutboxPayloadToChanges, readModelOutboxPayloadToMessageMailboxes, type ReadModelDomain, @@ -20,6 +21,7 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet = new Set([ 'access.general', 'dashboard.global', 'messages.mailbox', + 'messages.diplomacyMailbox', 'tournament', 'betting', ]); @@ -86,8 +88,9 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup { this.db, async (payload) => { const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload); - if (mailboxes.length > 0) { - await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes); + const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload); + if (mailboxes.length > 0 || diplomacyMailboxes.length > 0) { + await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes, diplomacyMailboxes); } if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) { const changes = readModelOutboxPayloadToChanges(payload); diff --git a/app/game-api/src/realtime/publicEvent.ts b/app/game-api/src/realtime/publicEvent.ts index f358c04d..73dbc145 100644 --- a/app/game-api/src/realtime/publicEvent.ts +++ b/app/game-api/src/realtime/publicEvent.ts @@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => { const seen = new Set(); return identities.filter((identity) => { - const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`; + const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}:${identity.canReadDiplomacy}`; if (seen.has(key)) return false; seen.add(key); return true; @@ -44,6 +44,7 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit const changes = eventChanges(event); if (!changes) return false; const generalId = identity.generalId; + if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true; return [ changes.generalIds, changes.mapGeneralIds ?? changes.generalIds, @@ -69,8 +70,20 @@ export const toPublicRealtimeEvent = ( identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }] ); if (event.type === 'messageCreated' || event.type === 'messagesChanged') { - const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes; - return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity))) + const mailboxes = + event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes; + const diplomacyMailboxes = + event.type === 'messageCreated' + ? event.msgType === 'diplomacy' + ? [event.mailbox] + : [] + : (event.diplomacyMailboxes ?? []); + return viewers.some( + (identity) => + mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) || + (identity.canReadDiplomacy && + diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity))) + ) ? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() } : null; } diff --git a/app/game-api/src/realtime/publisher.ts b/app/game-api/src/realtime/publisher.ts index 4b94386a..63c5932f 100644 --- a/app/game-api/src/realtime/publisher.ts +++ b/app/game-api/src/realtime/publisher.ts @@ -34,11 +34,15 @@ export const publishRealtimeReadModelChanges = async ( export const publishRealtimeMessageChanges = async ( redis: RedisConnector['client'], profileName: string, - mailboxes: readonly number[] + mailboxes: readonly number[], + diplomacyMailboxes: readonly number[] = [] ): Promise => { - if (mailboxes.length === 0) return; + if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return; await publishRealtimeEvent(redis, profileName, { type: 'messagesChanged', mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right), + ...(diplomacyMailboxes.length > 0 + ? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) } + : {}), }); }; diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index da94f7c7..9bcf1e2d 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -86,8 +86,9 @@ const sendDocumentNotice = async (options: { 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 messageDomain = options.includeNational ? 'messages.mailbox' : 'messages.diplomacyMailbox'; + options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId); + options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId); }; const resolvePermissionLevel = async (ctx: Parameters[0], nationId: number) => { diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index 9732befc..842c22b5 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -76,9 +76,14 @@ const hasPenalty = (penalty: unknown, key: string): boolean => { return value === true || value === 1 || value === '1'; }; -const markMessageMailboxes = (ctx: Pick, mailboxes: Iterable): void => { +const markMessageMailboxes = ( + ctx: Pick, + mailboxes: Iterable, + msgType?: MessageType +): void => { + const domain = msgType === 'diplomacy' ? 'messages.diplomacyMailbox' : 'messages.mailbox'; for (const mailbox of mailboxes) { - ctx.changeJournal?.mark('messages.mailbox', mailbox); + ctx.changeJournal?.mark(domain, mailbox); } }; @@ -315,7 +320,11 @@ export const messagesRouter = router({ message.msgType === 'national' ? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId : null; - markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]); + markMessageMailboxes( + ctx, + [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])], + message.msgType + ); return { ok: true, deletedIds }; }), respond: engineAuthedProcedure @@ -333,6 +342,9 @@ export const messagesRouter = router({ throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' }); } const action = message.payload.option?.action; + if (message.payload.option?.invalid === true) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' }); + } if (action === 'scout' || action === 'raiseInvader') { if (!ctx.auth) { throw new TRPCError({ code: 'UNAUTHORIZED' }); @@ -620,7 +632,7 @@ export const messagesRouter = router({ : msgType === 'private' ? general.id : MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId; - markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]); + markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])], msgType); return { msgType, msgId: result.receiverId }; }), diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 2dc4550e..3fdcfe7b 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -38,6 +38,7 @@ import { shouldReloadRealtimeViewerIdentity, toPublicRealtimeEvent, } from './realtime/publicEvent.js'; +import { resolveNationPermission } from './router/nation/shared.js'; import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js'; import { CachedTurnEngineStatus } from './services/turnEngineStatus.js'; @@ -313,11 +314,24 @@ export const createGameApiServer = async () => { const loadViewerIdentity = async (): Promise => { const general = await postgres.prisma.general.findFirst({ where: { userId: auth.user.id, npcState: 0 }, - select: { id: true, cityId: true, nationId: true }, + select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true }, }); - return general - ? { generalId: general.id, cityId: general.cityId, nationId: general.nationId } - : { generalId: null, cityId: null, nationId: null }; + if (!general) { + return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false }; + } + const nation = + general.nationId > 0 + ? await postgres.prisma.nation.findUnique({ + where: { id: general.nationId }, + select: { meta: true }, + }) + : null; + return { + generalId: general.id, + cityId: general.cityId, + nationId: general.nationId, + canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3), + }; }; let viewerIdentity = await loadViewerIdentity(); diff --git a/app/game-api/test/diplomacyDocumentMessages.integration.test.ts b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts index 2cc8ae99..91319879 100644 --- a/app/game-api/test/diplomacyDocumentMessages.integration.test.ts +++ b/app/game-api/test/diplomacyDocumentMessages.integration.test.ts @@ -63,7 +63,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => { changes.some( (change) => Array.isArray(change) && - change[0] === 'messages.mailbox' && + (change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') && fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number]) ) ); @@ -94,7 +94,10 @@ integration('diplomacy document message persistence', () => { await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } }); await deleteFixtureOutboxes(); await db.readModelRevision.deleteMany({ - where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, + where: { + domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] }, + entityId: { in: [...fixtureMailboxes] }, + }, }); }; @@ -575,7 +578,10 @@ integration('diplomacy document message persistence', () => { await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0); await expect( db.readModelRevision.count({ - where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, + where: { + domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] }, + entityId: { in: [...fixtureMailboxes] }, + }, }) ).resolves.toBe(0); await expect( diff --git a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts index 13bf2196..3c58ee52 100644 --- a/app/game-api/test/diplomacyHtmlTransport.integration.test.ts +++ b/app/game-api/test/diplomacyHtmlTransport.integration.test.ts @@ -81,7 +81,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => { changes.some( (change) => Array.isArray(change) && - change[0] === 'messages.mailbox' && + (change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') && fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number]) ) ); @@ -101,7 +101,10 @@ const cleanup = async (): Promise => { }); await db.inputEvent.deleteMany({ where: { actorUserId: userId } }); await db.readModelRevision.deleteMany({ - where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } }, + where: { + domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] }, + entityId: { in: [...fixtureMailboxes] }, + }, }); const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } }); const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id); diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index cde1f34a..7abf4323 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -368,8 +368,8 @@ describe('messages router missing-flow compatibility', () => { expect(result.msgType).toBe('diplomacy'); expect(queryRaw).toHaveBeenCalledTimes(2); expect(changeJournal.snapshot()).toEqual([ - { domain: 'messages.mailbox', entityId: 9000 }, - { domain: 'messages.mailbox', entityId: 9001 }, + { domain: 'messages.diplomacyMailbox', entityId: 9000 }, + { domain: 'messages.diplomacyMailbox', entityId: 9001 }, ]); }); @@ -837,6 +837,7 @@ describe('messages router missing-flow compatibility', () => { proposerCurrentNationId?: number; proposerNationMeta?: Record; diplomacyState?: number; + invalid?: boolean; response?: boolean; cities?: Array<{ id: number; nationId: number; frontState: number }>; }) => { @@ -891,6 +892,7 @@ describe('messages router missing-flow compatibility', () => { text: '외교 제안', option: { action, + ...(options?.invalid ? { invalid: true } : {}), ...(action === 'noAggression' ? { year: 201, month: 2 } : {}), }, }, @@ -1075,6 +1077,21 @@ describe('messages router missing-flow compatibility', () => { }; }; + it('rejects a response to a tombstoned diplomatic prompt before any state mutation', async () => { + const setup = buildDiplomaticContext({ invalid: true }); + + await expect( + setup.caller.messages.respond({ + generalId: setup.actor.id, + messageId: 31, + response: true, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' }); + expect(setup.diplomacyUpdate).not.toHaveBeenCalled(); + expect(setup.messageUpdateMany).not.toHaveBeenCalled(); + expect(setup.requestCommand).not.toHaveBeenCalled(); + }); + it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => { const setup = buildDiplomaticContext(); diff --git a/app/game-api/test/publicRealtimeEvent.test.ts b/app/game-api/test/publicRealtimeEvent.test.ts index a382b985..8867d5aa 100644 --- a/app/game-api/test/publicRealtimeEvent.test.ts +++ b/app/game-api/test/publicRealtimeEvent.test.ts @@ -9,7 +9,7 @@ import { toPublicRealtimeEvent as convertPublicRealtimeEvent, } from '../src/realtime/publicEvent.js'; -const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const; +const viewer = { generalId: 7, cityId: 3, nationId: 2, canReadDiplomacy: true } as const; const refreshGrant = 'opaque-grant'; const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters[1]) => convertPublicRealtimeEvent(event, identities, () => refreshGrant); @@ -198,6 +198,37 @@ describe('public realtime event privacy boundary', () => { expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull(); }); + it('suppresses diplomacy-only wake-ups for viewers without secret-message access', () => { + const mailbox = MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId; + const blockedViewer = { ...viewer, canReadDiplomacy: false }; + expect( + toPublicRealtimeEvent( + { + type: 'messageCreated', + at: '2026-09-04T00:00:00Z', + mailbox, + msgType: 'diplomacy', + messageId: 1, + senderId: 2, + }, + [blockedViewer] + ) + ).toBeNull(); + expect( + toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [ + blockedViewer, + ]) + ).toBeNull(); + expect( + toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [mailbox], diplomacyMailboxes: [mailbox] }, [ + blockedViewer, + ]) + ).toEqual({ type: 'messagesInvalidated', refreshGrant }); + expect( + toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [viewer]) + ).toEqual({ type: 'messagesInvalidated', refreshGrant }); + }); + it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => { const event: RealtimeEvent = { type: 'messagesChanged', @@ -225,6 +256,12 @@ describe('public realtime event privacy boundary', () => { viewer ) ).toBe(false); + expect( + shouldReloadRealtimeViewerIdentity( + turnEvent({ ...createEmptyRealtimeReadModelChanges(), nationIds: [viewer.nationId] }), + viewer + ) + ).toBe(true); }); it('merges previous and committed identities across an ownership transition', () => { diff --git a/app/game-api/test/readModelOutboxWorker.test.ts b/app/game-api/test/readModelOutboxWorker.test.ts index bac68a3f..60aaf67c 100644 --- a/app/game-api/test/readModelOutboxWorker.test.ts +++ b/app/game-api/test/readModelOutboxWorker.test.ts @@ -12,6 +12,7 @@ const payload = ( | 'access.general' | 'dashboard.global' | 'messages.mailbox' + | 'messages.diplomacyMailbox' | 'tournament' | 'betting' ) => ({ @@ -19,7 +20,11 @@ const payload = ( changes: [ [ domain, - domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0, + domain === 'front.general' || domain === 'access.general' + ? 7 + : domain === 'messages.mailbox' || domain === 'messages.diplomacyMailbox' + ? 9999 + : 0, '1', ], ], @@ -114,6 +119,25 @@ describe('ReadModelOutboxWorker', () => { }); }); + it('labels diplomacy-only mailbox wake-ups for viewer permission filtering', async () => { + const fixture = createFixture([{ id: 15n, payload: payload('messages.diplomacyMailbox'), attempts: 1 }]); + const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', { + owner: 'worker-test', + intervalMs: 60_000, + }); + + worker.start(); + await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1)); + await worker.stop(); + + expect(fixture.incr).not.toHaveBeenCalled(); + expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({ + type: 'messagesChanged', + mailboxes: [], + diplomacyMailboxes: [9999], + }); + }); + it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => { let releaseFirst: (() => void) | undefined; const first = new Promise((resolve) => { diff --git a/app/game-engine/src/turn/databaseHooks.ts b/app/game-engine/src/turn/databaseHooks.ts index e6adbdd3..d554fa8d 100644 --- a/app/game-engine/src/turn/databaseHooks.ts +++ b/app/game-engine/src/turn/databaseHooks.ts @@ -1852,6 +1852,7 @@ export const createDatabaseTurnHooks = async ( await persistYearbookSnapshot(prisma, snapshot); } const persistedMessageMailboxes: number[] = []; + const persistedDiplomacyMailboxes: number[] = []; for (const finalization of pendingUnificationFinalizations) { if (options?.profileName && finalization.profileName !== options.profileName) { throw new Error( @@ -1878,7 +1879,11 @@ export const createDatabaseTurnHooks = async ( expiresGameTick, }); await enqueuePrivateMessageWebPush(prisma, draft, id); - persistedMessageMailboxes.push(draft.mailbox); + if (draft.msgType === 'diplomacy') { + persistedDiplomacyMailboxes.push(draft.mailbox); + } else { + persistedMessageMailboxes.push(draft.mailbox); + } return id; }, }, @@ -1968,6 +1973,7 @@ export const createDatabaseTurnHooks = async ( journal.mark('dashboard.global'); } markIds(journal, 'messages.mailbox', uniqueSortedIds(persistedMessageMailboxes)); + markIds(journal, 'messages.diplomacyMailbox', uniqueSortedIds(persistedDiplomacyMailboxes)); markIds(journal, 'access.general', accessScoreResetGeneralIds); if (pendingNationBettingOpens.length > 0 || pendingNationBettingFinishes.length > 0) { journal.mark('betting'); diff --git a/app/game-frontend/src/components/main/MessagePlate.vue b/app/game-frontend/src/components/main/MessagePlate.vue index d102241a..1b565ad4 100644 --- a/app/game-frontend/src/components/main/MessagePlate.vue +++ b/app/game-frontend/src/components/main/MessagePlate.vue @@ -266,9 +266,9 @@ onBeforeUnmount(() => { {{ invalid ? '삭제된 메시지입니다' : message.text }} -
+