diff --git a/app/game-api/test/publicRealtimeEvent.test.ts b/app/game-api/test/publicRealtimeEvent.test.ts index 8867d5aa..4c155edc 100644 --- a/app/game-api/test/publicRealtimeEvent.test.ts +++ b/app/game-api/test/publicRealtimeEvent.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { resolveNationPermission } from '../src/router/nation/shared.js'; import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common'; import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic'; @@ -288,3 +289,28 @@ describe('public realtime event privacy boundary', () => { }); }); }); + +describe('diplomatic notification recipient roles', () => { + it.each([ + ['군주', 12, 'normal', {}, true], + ['외교권자', 1, 'ambassador', {}, true], + ['조언자', 1, 'auditor', {}, true], + ['일반 장수', 1, 'normal', {}, false], + ['수뇌', 11, 'normal', {}, false], + ['외교 금지', 1, 'ambassador', { noAmbassador: 1 }, false], + ] as const)( + '%s receives only eligible nation diplomacy invalidations', + (_, officerLevel, permission, penalty, allowed) => { + const canReadDiplomacy = + resolveNationPermission({ nationId: 2, officerLevel, meta: { permission }, penalty }, {}, false) >= 3; + expect(canReadDiplomacy).toBe(allowed); + const event: RealtimeEvent = { type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [9002] }; + expect(toPublicRealtimeEvent(event, [{ ...viewer, canReadDiplomacy }])).toEqual( + allowed ? { type: 'messagesInvalidated', refreshGrant } : null + ); + expect( + toPublicRealtimeEvent({ ...event, diplomacyMailboxes: [9003] }, [{ ...viewer, canReadDiplomacy }]) + ).toBeNull(); + } + ); +}); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 447b360a..73edc0d5 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -6418,3 +6418,128 @@ for (const viewport of [ await page.screenshot({ path: testInfo.outputPath('npc-message-icons.png'), fullPage: true }); }); } + +for (const role of [ + { name: '군주', officerLevel: 12, permission: 4 }, + { name: '외교권자', officerLevel: 1, permission: 4 }, + { name: '조언자', officerLevel: 1, permission: 3 }, + { name: '일반 장수', officerLevel: 1, permission: 0 }, +]) { + for (const width of [1200, 390]) { + test(`diplomacy arrival notice ${role.name} ${width}`, async ({ page }, testInfo) => { + const state: NavigationFixture = { + ...role, + nationLevel: 3, + stage: 0, + npcMode: 1, + latestVote: null, + generalMeCalls: 0, + operations: [], + messages: { ...emptyMessages(role.permission), nationId: 1 }, + }; + await installRealtimeHarness(page); + await installFixture(page, state); + await page.setViewportSize({ width, height: 900 }); + await waitForMain(page); + await waitForMainRealtime(page); + const notice = page.getByTestId('diplomacy-message-notice'); + const message = (id: number, sourceNation = 2, option: Record | null = null) => ({ + ...privateMessage(id, 9), + msgType: 'diplomacy', + src: { ...privateMessage(id, 9).src, nationId: sourceNation }, + text: id === 50 ? '외교 문서가 도착했습니다.' : '불가침 제의 서신', + option, + }); + state.messages = { ...emptyMessages(role.permission), nationId: 1, diplomacy: [message(49, 1)] }; + await emitMessagesInvalidation(page); + await expect(notice).toHaveCount(0); + for (const id of [50, 51]) { + state.messages = { + ...emptyMessages(role.permission), + nationId: 1, + diplomacy: [message(id, 2, id === 51 ? { action: 'noAggression' } : null)], + }; + const before = state.operations.filter((op) => op === 'messages.getRecent').length; + await emitMessagesInvalidation(page); + await expect + .poll(() => state.operations.filter((op) => op === 'messages.getRecent').length) + .toBeGreaterThan(before); + if (role.permission < 3) { + await expect(notice).toHaveCount(0); + continue; + } + await expect(notice).toContainText('새로운 외교 메시지가 도착했습니다.'); + await emitMessagesInvalidation(page); + await expect(notice).toHaveCount(1); + const titleVisible = await notice.locator('strong').evaluate((el) => { + const r = el.getBoundingClientRect(); + const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); + return hit === el || el.contains(hit); + }); + expect(titleVisible).toBe(true); + + await testInfo.attach(`diplomacy-${id}.png`, { + body: await notice.screenshot(), + contentType: 'image/png', + }); + const geometry = await notice.evaluate((el) => { + const r = el.getBoundingClientRect(); + return { + x: r.x, + y: r.y, + width: r.width, + height: r.height, + font: getComputedStyle(el).font, + html: el.outerHTML, + }; + }); + expect(geometry.x).toBeGreaterThanOrEqual(0); + expect(geometry.x + geometry.width).toBeLessThanOrEqual(width); + await testInfo.attach(`diplomacy-${id}.json`, { + body: JSON.stringify(geometry), + contentType: 'application/json', + }); + await notice.getByRole('button', { name: id === 50 ? '이미읽음' : '보러가기' }).click(); + await expect(notice).toHaveCount(0); + await expect + .poll(() => + state.trpcRequests?.some( + ({ operations, body }) => + operations.includes('messages.readLatest') && + JSON.stringify(body).includes('"type":"diplomacy"') && + JSON.stringify(body).includes(`"messageId":${id}`) + ) + ) + .toBe(true); + } + if (role.permission >= 3) { + state.messages = { + ...emptyMessages(role.permission), + nationId: 1, + private: [privateMessage(60, 9)], + diplomacy: [message(60)], + }; + await emitMessagesInvalidation(page); + await expect(notice).toBeVisible(); + const privateNotice = page.getByTestId('private-message-notice'); + await expect(privateNotice).toBeVisible(); + const first = await privateNotice.boundingBox(); + const second = await notice.boundingBox(); + expect(second!.y).toBeGreaterThan(first!.y + first!.height); + await notice.getByRole('button', { name: '외교 메시지 알림 닫기' }).click(); + await emitMessagesInvalidation(page); + await expect(notice).toHaveCount(0); + state.messages = { ...emptyMessages(role.permission), nationId: 1, diplomacy: [message(61)] }; + await emitMessagesInvalidation(page); + await expect(notice).toBeVisible(); + state.messages = { + ...emptyMessages(2), + nationId: 1, + diplomacy: [message(61, 2, { permissionRedacted: true })], + }; + await emitMessagesInvalidation(page); + await expect(notice).toHaveCount(0); + } + }); + } +} diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 6d6bb582..c7dbf239 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -130,6 +130,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const tournamentType = ref(null); const surveyNotice = ref | null>(null); const privateMessageNotice = ref(null); + const diplomacyMessageNotice = ref(null); + let dismissedDiplomacyMessageId = 0; let dismissedPrivateMessageId = 0; let lastGeneralRecordId = 0; let lastWorldHistoryId = 0; @@ -351,6 +353,39 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { privateMessageNotice.value = null; }; + const reconcileDiplomacyMessageNotice = (nextMessages: MessageBundle | null) => { + const viewerGeneralId = general.value?.id; + if (!nextMessages || !viewerGeneralId || nextMessages.permission < 3) { + diplomacyMessageNotice.value = null; + return; + } + const newestIncomingId = nextMessages.diplomacy + .filter( + (message) => + message.src.nationId !== nextMessages.nationId && + !message.option?.invalid && + !message.option?.permissionRedacted + ) + .reduce((latest, message) => Math.max(latest, message.id), 0); + const latestReadId = nextMessages.latestRead.diplomacy; + if (dismissedDiplomacyMessageId <= latestReadId) dismissedDiplomacyMessageId = 0; + if (newestIncomingId <= latestReadId || newestIncomingId <= dismissedDiplomacyMessageId) { + diplomacyMessageNotice.value = null; + return; + } + if (diplomacyMessageNotice.value?.messageId !== newestIncomingId) { + diplomacyMessageNotice.value = { messageId: newestIncomingId }; + } + }; + + const dismissDiplomacyMessageNotice = () => { + dismissedDiplomacyMessageId = Math.max( + dismissedDiplomacyMessageId, + diplomacyMessageNotice.value?.messageId ?? 0 + ); + diplomacyMessageNotice.value = null; + }; + const mergeRecentRecords = (current: RecentRecord[], incoming: RecentRecord[]): RecentRecord[] => { const merged = new Map(current.map((entry) => [entry.id, entry])); for (const entry of incoming) { @@ -370,6 +405,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { surveyNotice.value = null; privateMessageNotice.value = null; dismissedPrivateMessageId = 0; + diplomacyMessageNotice.value = null; + dismissedDiplomacyMessageId = 0; }; const applyRecentRecords = (records: Awaited>) => { @@ -448,10 +485,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (patch.messages !== undefined) { messages.value = structurallyShare(messages.value, patch.messages); reconcilePrivateMessageNotice(messages.value); + reconcileDiplomacyMessageNotice(messages.value); } else if (patch.contextSnapshot !== undefined || patch.general !== undefined) { // Main data is fetched concurrently, so the message bundle can arrive // before the authenticated general context needed to identify senders. reconcilePrivateMessageNotice(messages.value); + reconcileDiplomacyMessageNotice(messages.value); } if (patch.messageContacts !== undefined) { messageContacts.value = structurallyShare(messageContacts.value, patch.messageContacts); @@ -688,6 +727,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { worldMap.value = structurallyShare(worldMap.value, map); messages.value = structurallyShare(messages.value, messageData); reconcilePrivateMessageNotice(messages.value); + reconcileDiplomacyMessageNotice(messages.value); messageContacts.value = structurallyShare(messageContacts.value, contacts); reservedGeneralTurns.value = structurallyShare( reservedGeneralTurns.value, @@ -960,6 +1000,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }, }; reconcilePrivateMessageNotice(messages.value); + reconcileDiplomacyMessageNotice(messages.value); } return true; } catch (err) { @@ -974,6 +1015,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { return readLatestMessage('private', messageId); }; + const acknowledgeDiplomacyMessageNotice = async (): Promise => { + const messageId = diplomacyMessageNotice.value?.messageId; + if (!messageId) return false; + return readLatestMessage('diplomacy', messageId); + }; + const deleteMessage = async (messageId: number) => { const id = generalId.value; if (!id) { @@ -1399,6 +1446,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { tournamentType, surveyNotice, privateMessageNotice, + diplomacyMessageNotice, messageDraftText, targetMailbox, mailboxGroups, @@ -1409,6 +1457,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { stopRealtime, dismissSurveyNotice, dismissPrivateMessageNotice, + dismissDiplomacyMessageNotice, + acknowledgeDiplomacyMessageNotice, acknowledgePrivateMessageNotice, loadMainData, refreshMessages, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index ea458582..249630f5 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -89,6 +89,7 @@ const { tournamentType, surveyNotice, privateMessageNotice, + diplomacyMessageNotice, messageDraftText, targetMailbox, mailboxGroups, @@ -168,6 +169,7 @@ const formatRecord = (entry: { text: string; createdAt?: string | Date }, append }; let surveyNoticeTimer: ReturnType | null = null; +let diplomacyMessageNoticeTimer: ReturnType | null = null; let privateMessageNoticeTimer: ReturnType | null = null; watch(surveyNotice, (notice) => { if (surveyNoticeTimer) { @@ -187,6 +189,15 @@ watch(privateMessageNotice, (notice) => { privateMessageNoticeTimer = setTimeout(() => dashboard.dismissPrivateMessageNotice(), 10 * 60_000); } }); +watch(diplomacyMessageNotice, (notice) => { + if (diplomacyMessageNoticeTimer) { + clearTimeout(diplomacyMessageNoticeTimer); + diplomacyMessageNoticeTimer = null; + } + if (notice) { + diplomacyMessageNoticeTimer = setTimeout(() => dashboard.dismissDiplomacyMessageNotice(), 10 * 60_000); + } +}); onUnmounted(() => { if (surveyNoticeTimer) { clearTimeout(surveyNoticeTimer); @@ -194,6 +205,7 @@ onUnmounted(() => { if (privateMessageNoticeTimer) { clearTimeout(privateMessageNoticeTimer); } + if (diplomacyMessageNoticeTimer) clearTimeout(diplomacyMessageNoticeTimer); dashboard.stopRealtime(); window.removeEventListener('storage', handleMobilePanelStorage); document.removeEventListener(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT, reloadMobilePanelOrder); @@ -253,6 +265,13 @@ const acknowledgePrivateMessageNotice = async (moveToMessage: boolean) => { document.querySelector('.PrivateTalk > .stickyAnchor')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; +const acknowledgeDiplomacyMessageNotice = async (moveToMessage: boolean) => { + if (!(await dashboard.acknowledgeDiplomacyMessageNotice())) return; + if (!moveToMessage) return; + await nextTick(); + document.querySelector('.DiplomacyTalk > .stickyAnchor')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); +}; + const handleNavigationAction = (action: NonNullable) => { if (action === 'show-version') versionDialog.value?.showModal(); }; @@ -349,6 +368,32 @@ watch( + +