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(() => {
{{ errorMessage }}
-
+
-
-
-