diff --git a/app/game-api/src/router/diplomacy/index.ts b/app/game-api/src/router/diplomacy/index.ts index 843c412..cd08c3b 100644 --- a/app/game-api/src/router/diplomacy/index.ts +++ b/app/game-api/src/router/diplomacy/index.ts @@ -47,11 +47,24 @@ export const diplomacyRouter = router({ }); const nations = await ctx.db.nation.findMany({ - where: { id: { not: me.nationId } }, select: { id: true, name: true, color: true, level: true }, orderBy: { id: 'asc' }, }); + const signerIds = [ + ...new Set( + letters.flatMap((letter) => + letter.destSignerId === null ? [letter.srcSignerId] : [letter.srcSignerId, letter.destSignerId] + ) + ), + ]; + const signers = await ctx.db.general.findMany({ + where: { id: { in: signerIds } }, + select: { id: true, name: true, picture: true, imageServer: true }, + }); + const nationById = new Map(nations.map((nation) => [nation.id, nation])); + const signerById = new Map(signers.map((general) => [general.id, general])); + const result = letters.map((letter) => { const aux = asRecord(letter.aux); const src = asRecord(aux.src); @@ -60,24 +73,32 @@ export const diplomacyRouter = router({ const detail = permission < 3 && letter.textDetail ? '(권한이 부족합니다)' : purifyDiplomacyHtml(letter.textDetail); const reason = asRecord(aux.reason); + const srcNation = nationById.get(letter.srcNationId); + const destNation = nationById.get(letter.destNationId); + const srcSigner = signerById.get(letter.srcSignerId); + const destSigner = letter.destSignerId === null ? undefined : signerById.get(letter.destSignerId); return { id: letter.id, src: { nationId: letter.srcNationId, - nationName: typeof src.nationName === 'string' ? src.nationName : '', - nationColor: typeof src.nationColor === 'string' ? src.nationColor : '', - generalId: typeof src.generalId === 'number' ? src.generalId : null, - generalName: typeof src.generalName === 'string' ? src.generalName : null, + nationName: typeof src.nationName === 'string' ? src.nationName : (srcNation?.name ?? ''), + nationColor: typeof src.nationColor === 'string' ? src.nationColor : (srcNation?.color ?? ''), + generalId: typeof src.generalId === 'number' ? src.generalId : letter.srcSignerId, + generalName: typeof src.generalName === 'string' ? src.generalName : (srcSigner?.name ?? null), generalIcon: typeof src.generalIcon === 'string' ? src.generalIcon : null, + generalPicture: srcSigner?.picture ?? null, + generalImageServer: srcSigner?.imageServer ?? 0, }, dest: { nationId: letter.destNationId, - nationName: typeof dest.nationName === 'string' ? dest.nationName : '', - nationColor: typeof dest.nationColor === 'string' ? dest.nationColor : '', - generalId: typeof dest.generalId === 'number' ? dest.generalId : null, - generalName: typeof dest.generalName === 'string' ? dest.generalName : null, + nationName: typeof dest.nationName === 'string' ? dest.nationName : (destNation?.name ?? ''), + nationColor: typeof dest.nationColor === 'string' ? dest.nationColor : (destNation?.color ?? ''), + generalId: typeof dest.generalId === 'number' ? dest.generalId : letter.destSignerId, + generalName: typeof dest.generalName === 'string' ? dest.generalName : (destSigner?.name ?? null), generalIcon: typeof dest.generalIcon === 'string' ? dest.generalIcon : null, + generalPicture: destSigner?.picture ?? null, + generalImageServer: destSigner?.imageServer ?? 0, }, prevId: letter.prevId, state: mapLetterState(letter.state), @@ -95,7 +116,7 @@ export const diplomacyRouter = router({ return { letters: result, - nations, + nations: nations.filter((nation) => nation.id !== me.nationId), myNationId: me.nationId, permission, }; diff --git a/app/game-api/test/diplomacyRouter.test.ts b/app/game-api/test/diplomacyRouter.test.ts index d0a00bc..ae091f8 100644 --- a/app/game-api/test/diplomacyRouter.test.ts +++ b/app/game-api/test/diplomacyRouter.test.ts @@ -78,32 +78,33 @@ const storedLetter = { textBrief: '

공개

', textDetail: '기밀링크', date: new Date('2026-07-31T00:00:00.000Z'), + srcSignerId: 1, + destSignerId: 2, aux: { src: { nationName: '위', nationColor: '#0000ff', generalId: 1, generalName: '외교담당' }, dest: { nationName: '촉', nationColor: '#ff0000' }, }, }; -const buildContext = (officerLevel = 12) => { +const buildContext = (officerLevel = 12, letter: Record = storedLetter) => { const create = vi.fn(async () => ({ id: 9 })); const db = { general: { findFirst: vi.fn(async () => buildGeneral(officerLevel)), + findMany: vi.fn(async () => [ + { id: 1, name: '현재 송신자', picture: 'src.jpg', imageServer: 0 }, + { id: 2, name: '현재 수신자', picture: 'dest.jpg', imageServer: 0 }, + ]), }, nation: { findUnique: vi.fn(async () => ({ meta: {} })), - findMany: vi.fn(async ({ where }: { where: { id?: { in?: number[]; not?: number } } }) => { - if (where.id?.in) { - return [ - { id: 1, name: '위', color: '#0000ff' }, - { id: 2, name: '촉', color: '#ff0000' }, - ]; - } - return [{ id: 2, name: '촉', color: '#ff0000', level: 5 }]; - }), + findMany: vi.fn(async () => [ + { id: 1, name: '위', color: '#0000ff', level: 5 }, + { id: 2, name: '촉', color: '#ff0000', level: 5 }, + ]), }, diplomacyLetter: { - findMany: vi.fn(async () => [storedLetter]), + findMany: vi.fn(async () => [letter]), findFirst: vi.fn(async () => null), create, }, @@ -163,6 +164,29 @@ describe('diplomacy HTML API boundary', () => { expect(redacted.letters[0]?.detail).toBe('(권한이 부족합니다)'); }); + it('reconstructs imported letters whose snapshot metadata is empty', async () => { + const imported = { ...storedLetter, aux: {} }; + const result = await buildContext(12, imported).caller.diplomacy.getLetters(); + + expect(result.nations).toEqual([{ id: 2, name: '촉', color: '#ff0000', level: 5 }]); + expect(result.letters[0]).toMatchObject({ + src: { + nationName: '위', + nationColor: '#0000ff', + generalId: 1, + generalName: '현재 송신자', + generalPicture: 'src.jpg', + }, + dest: { + nationName: '촉', + nationColor: '#ff0000', + generalId: 2, + generalName: '현재 수신자', + generalPicture: 'dest.jpg', + }, + }); + }); + it('rejects direct send mutations below the Ref diplomacy permission without writing', async () => { const fixture = buildContext(5); await expect( diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index f949baa..6768d9c 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -307,9 +307,9 @@ test('keeps the shared main and chief shell geometry and interaction states', as }); expect(mainGeometry).toEqual({ width: 1000, - padding: '24px', - gap: '16px', - headerWidth: 952, + padding: '0px', + gap: '10px', + headerWidth: 1000, headerGap: '12px', headerBorder: '1px', headerPadding: '12px', @@ -334,7 +334,7 @@ test('keeps the shared main and chief shell geometry and interaction states', as padding: getComputedStyle(element).padding, headerWidth: element.querySelector('.game-shell__header')!.getBoundingClientRect().width, })); - expect(chiefDesktop).toEqual({ width: 1200, padding: '24px', headerWidth: 1152 }); + expect(chiefDesktop).toEqual({ width: 1000, padding: '0px', headerWidth: 1000 }); await page.setViewportSize({ width: 500, height: 900 }); const chiefMobile = await page.locator('.chief-page').evaluate((element) => ({ @@ -342,5 +342,5 @@ test('keeps the shared main and chief shell geometry and interaction states', as padding: getComputedStyle(element).padding, headerWidth: element.querySelector('.game-shell__header')!.getBoundingClientRect().width, })); - expect(chiefMobile).toEqual({ width: 500, padding: '16px', headerWidth: 468 }); + expect(chiefMobile).toEqual({ width: 500, padding: '0px', headerWidth: 500 }); }); diff --git a/app/game-frontend/e2e/diplomacy.spec.ts b/app/game-frontend/e2e/diplomacy.spec.ts index da36ff8..4c9b662 100644 --- a/app/game-frontend/e2e/diplomacy.spec.ts +++ b/app/game-frontend/e2e/diplomacy.spec.ts @@ -102,7 +102,8 @@ for (const viewport of [ await expect(card.locator('li')).toHaveText('서버 정화 기밀문'); await expect(card.getByRole('link', { name: '자료' })).toHaveAttribute('href', 'https://example.com'); await expect(card.getByRole('link', { name: '자료' })).toHaveAttribute('rel', 'noopener noreferrer nofollow'); - await expect(card.locator('script, svg, math, [onerror], [onclick], [style]')).toHaveCount(0); + await expect(card.locator('.letter-text script, .letter-text svg, .letter-text math')).toHaveCount(0); + await expect(card.locator('.letter-text [onerror], .letter-text [onclick], .letter-text [style]')).toHaveCount(0); expect(await page.evaluate(() => (globalThis as Record).__diplomacyXss)).toBeUndefined(); const geometry = await card.evaluate((element) => { @@ -112,6 +113,9 @@ for (const viewport of [ const style = getComputedStyle(text); return { card: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + documentWidth: document.documentElement.scrollWidth, + labelWidth: element.querySelector('.row-label')?.getBoundingClientRect().width, + headerFontSize: getComputedStyle(element.querySelector('h3')!).fontSize, text: { x: textRect.x, y: textRect.y, @@ -122,8 +126,11 @@ for (const viewport of [ }, }; }); - expect(geometry.card.width).toBeGreaterThan(0); - expect(geometry.text.width).toBeGreaterThan(0); + expect(geometry.card.width).toBe(1000); + expect(geometry.documentWidth).toBe(viewport.name === 'mobile' ? 1000 : viewport.width); + expect(geometry.labelWidth).toBe(200); + expect(geometry.headerFontSize).toBe('28px'); + expect(geometry.text.width).toBe(800); if (artifactRoot) { await mkdir(artifactRoot, { recursive: true }); @@ -134,10 +141,10 @@ for (const viewport of [ ); } - const refresh = page.getByRole('button', { name: '수동 갱신' }); - await refresh.focus(); - await expect(refresh).toBeFocused(); - await refresh.hover(); + const send = page.getByRole('button', { name: '전송' }); + await send.focus(); + await expect(send).toBeFocused(); + await send.hover(); await screenshot(page, `diplomacy-html-${basePath.slice(1)}-${viewport.name}.png`); }); } diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 393f09d..2f7d5fd 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -211,7 +211,7 @@ const waitForMain = async (page: Page) => { await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); await expect(page.locator('.main-global-menu').first()).toBeVisible(); await expect(page.locator('.main-nation-menu')).toBeVisible(); - await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(4); + await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3); }; const gridColumnCount = async (page: Page, selector: string) => @@ -331,7 +331,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); -test('the 939/940 boundary and 500px bottom bar match the ref responsive contract', async ({ page }) => { +test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -353,72 +353,33 @@ test('the 939/940 boundary and 500px bottom bar match the ref responsive contrac await expect(page.locator('.layout-mobile')).toBeVisible(); expect(await gridColumnCount(page, '.main-global-menu')).toBe(4); expect(await gridColumnCount(page, '.main-nation-menu')).toBe(5); - await expect(page.locator('.main-mobile-bottom')).toBeVisible(); + await expect(page.locator('.main-mobile-bottom')).toHaveCount(0); await page.setViewportSize({ width: 500, height: 900 }); - const bottomGeometry = await page.locator('.main-mobile-bottom').evaluate((element) => { + const documentGeometry = await page.locator('.main-page').evaluate((element) => { const rect = element.getBoundingClientRect(); return { x: rect.x, width: rect.width, height: rect.height, - bottom: innerHeight - rect.bottom, - buttons: [...element.children].map((child) => child.getBoundingClientRect().width), - position: getComputedStyle(element).position, - zIndex: getComputedStyle(element).zIndex, + scrollWidth: document.documentElement.scrollWidth, }; }); - expect(bottomGeometry).toEqual({ + expect(documentGeometry).toMatchObject({ x: 0, width: 500, - height: 45, - bottom: 0, - buttons: [125, 125, 125, 125], - position: 'fixed', - zIndex: '99', + scrollWidth: 500, }); - - const externalTrigger = page.locator('[data-bottom-menu="global"]'); - await externalTrigger.focus(); - await externalTrigger.press('Enter'); - await expect(externalTrigger).toHaveAttribute('aria-expanded', 'true'); - const popupStyle = await page.locator('#mobile-global-menu').evaluate((element) => { - const style = getComputedStyle(element); - return { - columns: style.columnCount, - overflowY: style.overflowY, - maxHeight: style.maxHeight, - bottom: style.bottom, - }; - }); - expect(popupStyle.columns).toBe('3'); - expect(popupStyle.overflowY).toBe('auto'); - expect(popupStyle.maxHeight).toBe('850px'); - expect(popupStyle.bottom).toBe('47px'); - const globalPopup = page.locator('#mobile-global-menu'); - await expect(globalPopup.getByText('게시판', { exact: true })).toHaveCount(1); - await expect(globalPopup.getByText('공식 오픈 톡', { exact: true })).toHaveCount(1); - await expect(globalPopup.getByText('게임정보', { exact: true })).toHaveCount(1); - await expect(globalPopup.getByText('기타 정보', { exact: true })).toHaveCount(1); - await persistArtifact(page, `${basePath.slice(1)}-mobile-global-open-500`); - await page.keyboard.press('Escape'); - await expect(externalTrigger).toBeFocused(); - - await page.locator('[data-bottom-menu="nation"]').click(); - const nationPopup = page.locator('#mobile-nation-menu'); - await expect(nationPopup.getByText('금/쌀 경매장', { exact: true })).toHaveCount(1); - await expect(nationPopup.getByText('유니크 경매장', { exact: true })).toHaveCount(1); - await page.keyboard.press('Escape'); - - await page.locator('[data-bottom-menu="quick"]').click(); - const quickPopup = page.locator('#mobile-quick-menu'); - for (const heading of ['국가 정보', '동향 정보', '메시지']) { - await expect(quickPopup.locator('.bottom-heading').getByText(heading, { exact: true })).toHaveCount(1); + expect(documentGeometry.height).toBeGreaterThan(900); + for (const selector of [ + '[data-main-target="commands"]', + '[data-main-target="general"]', + '[data-main-target="map"]', + '[data-main-target="world-history"]', + '.mobile-message-panel', + ]) { + await expect(page.locator(selector)).toBeVisible(); } - await expect(quickPopup.locator('.bottom-heading')).toHaveCount(3); - await expect(quickPopup.locator('.bottom-divider')).toHaveCount(3); - await persistArtifact(page, `${basePath.slice(1)}-mobile-quick-open-500`); - await page.keyboard.press('Escape'); await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); }); @@ -459,9 +420,7 @@ test('nation menu presentation follows the server-derived permission matrix', as ); }); -test('mobile quick navigation changes tabs before scrolling, refreshes once, and preserves tokens on lobby return', async ({ - page, -}) => { +test('mobile single document refreshes once and preserves tokens on lobby return', async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -475,35 +434,26 @@ test('mobile quick navigation changes tabs before scrolling, refreshes once, and await page.setViewportSize({ width: 500, height: 900 }); await waitForMain(page); - const cases = [ - ['policy', 'map', '[data-main-target="policy"]'], - ['commands', 'commands', '[data-main-target="commands"]'], - ['nation', 'status', '[data-main-target="nation"]'], - ['general', 'status', '[data-main-target="general"]'], - ['city', 'status', '[data-main-target="city"]'], - ['map', 'map', '[data-main-target="map"]'], - ['global-records', 'world', '[data-main-target="global-records"]'], - ['general-records', 'world', '[data-main-target="general-records"]'], - ['world-history', 'world', '[data-main-target="world-history"]'], - ['public-message', 'messages', '[data-message-type="public"]'], - ['national-message', 'messages', '[data-message-type="national"]'], - ['private-message', 'messages', '[data-message-type="private"]'], - ['diplomacy-message', 'messages', '[data-message-type="diplomacy"]'], - ] as const; - - for (const [id, tab, selector] of cases) { - await page.locator('[data-bottom-menu="quick"]').click(); - await page.locator(`[data-quick-id="${id}"]`).click(); - await expect(page.locator('.mobile-tabs button.active')).toHaveText( - { map: '지도', commands: '명령', status: '상태', world: '동향', messages: '메시지' }[tab] - ); + for (const selector of [ + '[data-main-target="policy"]', + '[data-main-target="commands"]', + '[data-main-target="nation"]', + '[data-main-target="general"]', + '[data-main-target="city"]', + '[data-main-target="map"]', + '[data-main-target="global-records"]', + '[data-main-target="general-records"]', + '[data-main-target="world-history"]', + '[data-message-type="public"]', + '[data-message-type="national"]', + '[data-message-type="private"]', + '[data-message-type="diplomacy"]', + ]) { await expect(page.locator(selector)).toBeVisible(); - const targetTop = await page.locator(selector).evaluate((element) => element.getBoundingClientRect().top); - expect(targetTop).toBeLessThan(855); } const callsBeforeRefresh = state.generalMeCalls; - await page.locator('[data-bottom-menu="refresh"]').click(); + await page.getByRole('button', { name: '갱 신' }).click(); await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeRefresh); await page.evaluate(() => { @@ -512,8 +462,7 @@ test('mobile quick navigation changes tabs before scrolling, refreshes once, and await page.route('**/gateway/', async (route) => { await route.fulfill({ status: 200, contentType: 'text/html', body: 'gateway' }); }); - await page.locator('[data-bottom-menu="quick"]').click(); - await Promise.all([page.waitForURL('**/gateway/'), page.getByRole('menuitem', { name: '로비로' }).click()]); + await Promise.all([page.waitForURL('**/gateway/'), page.getByRole('button', { name: '로비로' }).click()]); expect( await page.evaluate(() => ({ session: localStorage.getItem('sammo-session-token'), diff --git a/app/game-frontend/src/components/chief/ChiefTurnCard.vue b/app/game-frontend/src/components/chief/ChiefTurnCard.vue index c4eb17c..1be9216 100644 --- a/app/game-frontend/src/components/chief/ChiefTurnCard.vue +++ b/app/game-frontend/src/components/chief/ChiefTurnCard.vue @@ -60,12 +60,14 @@ const handleClick = () => { diff --git a/app/game-frontend/src/views/DiplomacyView.vue b/app/game-frontend/src/views/DiplomacyView.vue index 4e2db6a..2851079 100644 --- a/app/game-frontend/src/views/DiplomacyView.vue +++ b/app/game-frontend/src/views/DiplomacyView.vue @@ -7,6 +7,8 @@ import Link from '@tiptap/extension-link'; import Placeholder from '@tiptap/extension-placeholder'; import Underline from '@tiptap/extension-underline'; import { trpc } from '../utils/trpc'; +import { resolveGeneralIconUrl } from '../utils/generalIcon'; +import { formatSeoulDateTime } from '../utils/legacyDateTime'; type DiplomacyResponse = Awaited>; type DiplomacyLetter = DiplomacyResponse['letters'][number]; @@ -191,13 +193,44 @@ const destroyLetter = async (letterId: number) => { const prevOptions = computed(() => data.value?.letters.filter((letter) => letter.state !== 'CANCELLED') ?? []); -const formatDate = (value: string) => new Date(value).toLocaleString('ko-KR'); +const formatDate = (value: string) => formatSeoulDateTime(value); const stateLabelMap: Record = { - PROPOSED: '제안', - ACTIVATED: '승인', - CANCELLED: '종료', - REPLACED: '대체', + PROPOSED: '제안됨', + ACTIVATED: '승인됨', + CANCELLED: '거부됨', + REPLACED: '대체됨', +}; + +const stateOptionLabelMap: Record = { + try_destroy_src: '송신측의 파기 요청', + try_destroy_dest: '수신측의 파기 요청', +}; + +const targetNation = (letter: DiplomacyLetter) => + letter.src.nationId === data.value?.myNationId ? letter.dest : letter.src; + +const isBrightColor = (color: string): boolean => { + const normalized = color.trim().replace(/^#/u, ''); + if (!/^[0-9a-f]{6}$/iu.test(normalized)) return false; + const red = Number.parseInt(normalized.slice(0, 2), 16); + const green = Number.parseInt(normalized.slice(2, 4), 16); + const blue = Number.parseInt(normalized.slice(4, 6), 16); + return red * 0.299 + green * 0.587 + blue * 0.114 > 170; +}; + +const nationStyle = (color: string) => ({ + backgroundColor: color || '#315f86', + color: isBrightColor(color) ? '#000' : '#fff', +}); + +const signerIcon = (signer: DiplomacyLetter['src'] | DiplomacyLetter['dest']): string | null => { + if (signer.generalIcon) return signer.generalIcon; + if (!signer.generalPicture) return null; + return resolveGeneralIconUrl( + { picture: signer.generalPicture, imageServer: signer.generalImageServer }, + { legacyBaseUrl: '/image/general' } + ); }; const toggleHistory = (letterId: number) => { @@ -233,43 +266,41 @@ onBeforeUnmount(() => {