diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index fb10f760..c2400bb9 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -32,6 +32,8 @@ type NavigationFixture = { refreshDelayMs?: number; largeCommandTable?: boolean; reservedTurns?: Array<{ index: number; action: string; args: Record }>; + messages?: unknown; + messageContacts?: unknown; dashboardResponses?: Array<{ bytes: number; contextKind: string | null; @@ -405,8 +407,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => { if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { return response({ turns: state.reservedTurns ?? [], revision: 0 }); } - if (operation === 'messages.getRecent') return response(emptyMessages(state.permission)); - if (operation === 'messages.getContacts') return response({ nation: [] }); + if (operation === 'messages.getRecent') { + return response(state.messages ?? emptyMessages(state.permission)); + } + if (operation === 'messages.getContacts') return response(state.messageContacts ?? { nation: [] }); if (operation === 'general.getRecentRecords') { return response({ global: [{ id: 3, text: '장수 동향 기록' }], @@ -617,7 +621,23 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/); const gameInfoButton = global.locator('[data-menu-id="game-info"]'); + const bettingButton = global.locator('[data-navigation-id="nation-betting"]'); + await expect + .poll(() => + bettingButton.evaluate((element) => ({ + backgroundColor: getComputedStyle(element).backgroundColor, + backgroundImage: getComputedStyle(element).backgroundImage, + })) + ) + .toEqual({ backgroundColor: 'rgb(0, 88, 44)', backgroundImage: 'none' }); + await bettingButton.hover(); + await expect + .poll(() => bettingButton.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe('rgb(0, 88, 44)'); await gameInfoButton.focus(); + await expect + .poll(() => gameInfoButton.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe('rgb(0, 88, 44)'); await gameInfoButton.press('Enter'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); await expect(global.locator('#global-menu-game-info')).toBeVisible(); @@ -631,6 +651,74 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); +test('pure NPC message senders are not rendered as reply targets', async ({ page }) => { + const target = (generalId: number, generalName: string) => ({ + generalId, + generalName, + nationId: 1, + nationName: '위', + color: '#008000', + icon: '', + }); + const messages = { + ...emptyMessages(0), + public: [ + { + id: 102, + text: 'NPC 메시지', + time: '2026-08-12 12:00:00', + msgType: 'public', + src: target(22, '순수NPC'), + dest: null, + option: {}, + }, + { + id: 101, + text: '유저 메시지', + time: '2026-08-12 11:59:00', + msgType: 'public', + src: target(21, '유저장수'), + dest: null, + option: {}, + }, + ], + }; + const state: NavigationFixture = { + officerLevel: 1, + permission: 0, + nationLevel: 1, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + messages, + messageContacts: { + nation: [ + { + nationId: 1, + mailbox: 9001, + name: '위', + color: '#008000', + general: [[21, '유저장수', 0]], + }, + ], + }, + }; + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + + const npcMessage = page.locator('.desktop-message-panel .msg-plate[data-id="102"]'); + const userMessage = page.locator('.desktop-message-panel .msg-plate[data-id="101"]'); + await expect(npcMessage.locator('.msg-header')).toContainText('순수NPC:위'); + await expect(npcMessage.locator('.msg-header')).not.toContainText('↩'); + await expect(npcMessage.getByRole('button', { name: /순수NPC/ })).toHaveCount(0); + await expect(userMessage.getByRole('button', { name: /유저장수:위.*↩/ })).toBeVisible(); + await userMessage.getByRole('button', { name: /유저장수:위.*↩/ }).click(); + await expect(page.locator('.desktop-message-panel #mailbox_list')).toHaveValue('21'); + await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`); +}); + test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, @@ -925,6 +1013,15 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await page.setViewportSize({ width: 500, height: 900 }); + await expect + .poll(() => + page + .locator('.main-global-menu') + .first() + .locator('[data-navigation-id="nation-betting"]') + .evaluate((element) => getComputedStyle(element).backgroundColor) + ) + .toBe('rgb(0, 88, 44)'); const documentGeometry = await page.locator('.main-page').evaluate((element) => { const rect = element.getBoundingClientRect(); return { diff --git a/app/game-frontend/src/components/main/MainGlobalMenu.vue b/app/game-frontend/src/components/main/MainGlobalMenu.vue index 2f37254a..a149751d 100644 --- a/app/game-frontend/src/components/main/MainGlobalMenu.vue +++ b/app/game-frontend/src/components/main/MainGlobalMenu.vue @@ -115,6 +115,33 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props min-width: 0; } +.main-global-menu > :deep(.main-menu-link), +.main-menu-popup > .main-menu-button, +.main-menu-split > :deep(.main-menu-link), +.main-menu-split > .main-menu-split__toggle { + border-color: var(--sammo-button-navigation-border); + background-color: var(--sammo-button-navigation-bg); + background-image: none; +} + +.main-global-menu > :deep(.main-menu-link:hover), +.main-global-menu > :deep(.main-menu-link:focus-visible), +.main-global-menu > :deep(.main-menu-link:active), +.main-menu-popup > .main-menu-button:hover, +.main-menu-popup > .main-menu-button:focus-visible, +.main-menu-popup > .main-menu-button:active, +.main-menu-popup > .main-menu-button[aria-expanded='true'], +.main-menu-split > :deep(.main-menu-link:hover), +.main-menu-split > :deep(.main-menu-link:focus-visible), +.main-menu-split > :deep(.main-menu-link:active), +.main-menu-split > .main-menu-split__toggle:hover, +.main-menu-split > .main-menu-split__toggle:focus-visible, +.main-menu-split > .main-menu-split__toggle:active, +.main-menu-split > .main-menu-split__toggle[aria-expanded='true'] { + border-color: var(--sammo-button-navigation-border); + background-color: var(--sammo-button-navigation-bg); +} + .main-menu-popup > .main-menu-button, .main-menu-split > :deep(.main-menu-link) { width: 100%; diff --git a/app/game-frontend/src/components/main/MessagePanel.vue b/app/game-frontend/src/components/main/MessagePanel.vue index f39cf2cc..c51f378f 100644 --- a/app/game-frontend/src/components/main/MessagePanel.vue +++ b/app/game-frontend/src/components/main/MessagePanel.vue @@ -87,6 +87,13 @@ const bucket = (type: MessageType): MessageEntry[] => props.messages?.[type] ?? const visibleMessages = (type: MessageType): MessageEntry[] => bucket(type).slice(0, visibleLimits[type]); const permission = computed(() => props.messages?.permission ?? -1); +const replyableGeneralIds = computed(() => + props.mailboxGroups.flatMap((group) => + group.options + .filter((option) => !option.disabled && option.value > 0 && option.value < 9000) + .map((option) => option.value) + ) +); const setMailbox = (value: string) => { const parsed = Number(value); @@ -239,6 +246,7 @@ const forwardResponse = (messageId: number, response: boolean) => { :nation-id="nationId" :permission="permission" :can-respond-diplomacy="canRespondDiplomacy" + :replyable-general-ids="replyableGeneralIds" @set-target="setReplyTarget" @delete="emit('delete', $event)" @respond="forwardResponse" diff --git a/app/game-frontend/src/components/main/MessagePlate.vue b/app/game-frontend/src/components/main/MessagePlate.vue index 228fa5f6..91dcc835 100644 --- a/app/game-frontend/src/components/main/MessagePlate.vue +++ b/app/game-frontend/src/components/main/MessagePlate.vue @@ -29,6 +29,7 @@ const props = defineProps<{ nationId: number; permission: number; canRespondDiplomacy: boolean; + replyableGeneralIds: number[]; }>(); const emit = defineEmits<{ @@ -107,6 +108,7 @@ const isBright = (color: string): boolean => { }; const iconUrl = computed(() => resolveMessageGeneralIconUrl(props.message.src.icon)); +const canReplyToGeneral = (target: MessageTarget): boolean => props.replyableGeneralIds.includes(target.generalId); const targetClass = (target: MessageTarget) => ({ 'msg-target': true, @@ -167,6 +169,7 @@ onBeforeUnmount(() => { > + + {{ destination.generalName }}:{{ destination.nationName }} + + + {{ message.src.generalName }}:{{ message.src.nationName }} + {{ message.src.generalName }}