fix(game-frontend): match ref menu and NPC reply affordance

This commit is contained in:
2026-08-12 15:45:48 +00:00
parent 0450cb518e
commit c04a58ba04
4 changed files with 152 additions and 3 deletions
+99 -2
View File
@@ -32,6 +32,8 @@ type NavigationFixture = {
refreshDelayMs?: number;
largeCommandTable?: boolean;
reservedTurns?: Array<{ index: number; action: string; args: Record<string, unknown> }>;
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 {
@@ -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%;
@@ -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"
@@ -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(() => {
>
<span class="msg-from-to"></span>
<button
v-if="canReplyToGeneral(destination)"
:class="targetClass(destination)"
:style="{ backgroundColor: destination.color }"
type="button"
@@ -174,9 +177,13 @@ onBeforeUnmount(() => {
>
{{ destination.generalName }}:{{ destination.nationName }} |
</button>
<span v-else :class="targetClass(destination)" :style="{ backgroundColor: destination.color }">
{{ destination.generalName }}:{{ destination.nationName }}
</span>
</template>
<template v-else>
<button
v-if="canReplyToGeneral(message.src)"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@@ -184,6 +191,9 @@ onBeforeUnmount(() => {
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}:{{ message.src.nationName }}
</span>
<span class="msg-from-to"></span>
<span :class="targetClass(destination)" :style="{ backgroundColor: destination.color }"
></span
@@ -241,7 +251,7 @@ onBeforeUnmount(() => {
</template>
<button
v-else-if="message.src.generalId !== generalId"
v-else-if="message.src.generalId !== generalId && canReplyToGeneral(message.src)"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
type="button"
@@ -249,6 +259,13 @@ onBeforeUnmount(() => {
>
{{ message.src.generalName }}:{{ message.src.nationName }} |
</button>
<span
v-else-if="message.src.generalId !== generalId"
:class="targetClass(message.src)"
:style="{ backgroundColor: message.src.color }"
>
{{ message.src.generalName }}:{{ message.src.nationName }}
</span>
<span v-else :class="targetClass(message.src)" :style="{ backgroundColor: message.src.color }">
{{ message.src.generalName }}
</span>