From 6b4e4e490c9f5f2fb42812e39bdb0c00b38372b1 Mon Sep 17 00:00:00 2001
From: hided62
Date: Fri, 21 Aug 2026 17:11:40 +0000
Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=82=B4=EC=A0=95=EB=B3=B4?=
=?UTF-8?q?=EC=99=80=20=EA=B0=90=EC=B0=B0=EB=B6=80=EC=97=90=20=EA=B0=9C?=
=?UTF-8?q?=EC=9D=B8=20=EB=B4=89=EA=B8=89=EC=9D=84=20=ED=91=9C=EC=8B=9C?=
=?UTF-8?q?=ED=95=9C=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
공헌 등급에서 계산한 개인 봉급을 두 read model에 투영하고 공통 요약 첫 행에 배치한다. 전투·계략·사관 행과 봉급 snapshot이 없는 과거 플레이의 빈 slot은 유지한다.
---
app/game-api/src/router/general/index.ts | 3 ++-
.../src/router/nation/endpoints/getBattleCenter.ts | 3 ++-
app/game-api/test/inGameMenuPermissions.test.ts | 3 +++
app/game-frontend/e2e/inGameMenus.spec.ts | 9 +++++++--
.../src/components/main/GeneralBattleSummary.vue | 10 ++++++++--
app/game-frontend/src/views/BattleCenterView.vue | 1 +
app/game-frontend/src/views/MyPageView.vue | 1 +
7 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts
index 29f39cbb..b07c1550 100644
--- a/app/game-api/src/router/general/index.ts
+++ b/app/game-api/src/router/general/index.ts
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
-import { LogCategory, LogScope } from '@sammo-ts/logic';
+import { getBillByLevel, LogCategory, LogScope } from '@sammo-ts/logic';
import { asRecord, type RankDataType } from '@sammo-ts/common';
import type { GameApiContext } from '../../context.js';
@@ -517,6 +517,7 @@ export const getGeneralContext = async (ctx: GameApiContext) => {
injury: general.injury,
experience: general.experience,
dedication: general.dedication,
+ bill: getBillByLevel(dedicationLevel),
age: general.age,
retirementYear,
turnTime: general.turnTime.toISOString(),
diff --git a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts
index 0601df8a..502adbca 100644
--- a/app/game-api/src/router/nation/endpoints/getBattleCenter.ts
+++ b/app/game-api/src/router/nation/endpoints/getBattleCenter.ts
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { asRecord, type RankDataType } from '@sammo-ts/common';
-import { LogCategory } from '@sammo-ts/logic';
+import { getBillByLevel, LogCategory } from '@sammo-ts/logic';
import { accessAuthedProcedure } from '../../../trpc.js';
import {
@@ -201,6 +201,7 @@ export const getBattleCenter = accessAuthedProcedure.query(async ({ ctx }) => {
},
experience: general.experience,
dedication: general.dedication,
+ bill: getBillByLevel(dedicationLevel),
injury: general.injury,
gold: general.gold,
rice: general.rice,
diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts
index 4ed4e01f..9cd5752f 100644
--- a/app/game-api/test/inGameMenuPermissions.test.ts
+++ b/app/game-api/test/inGameMenuPermissions.test.ts
@@ -297,6 +297,7 @@ describe('in-game my information ownership', () => {
statUpgradeLimit: 20,
dex: [350, 1_375, 3_500, 7_125, 12_650],
},
+ bill: 1_000,
},
city: {
population: 322_886,
@@ -433,6 +434,7 @@ describe('in-game my information ownership', () => {
stats: { attack: 100, defence: 150, speed: 7, avoid: 10, magicCoef: 0, cost: 9, rice: 9 },
},
progression: { experienceLevel: 4, dedicationLevel: 2, dedicationText: '29품관' },
+ bill: 800,
itemNames: { horse: '노새(+3)' },
itemInfo: { horse: '통솔 +3' },
},
@@ -855,6 +857,7 @@ describe('battle-center general and user permissions', () => {
statUpgradeLimit: 20,
dex: [0, 0, 0, 0, 0],
},
+ bill: 600,
serviceYears: 3,
battleStats: {
kills: 5,
diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts
index cb5265b8..5c5a86e8 100644
--- a/app/game-frontend/e2e/inGameMenus.spec.ts
+++ b/app/game-frontend/e2e/inGameMenus.spec.ts
@@ -112,6 +112,7 @@ const myGeneral = (state: FixtureState) => ({
injury: 0,
experience: 100,
dedication: 200,
+ bill: 800,
age: 30,
turnTime: '2026-01-01 00:10:00',
recentWar: '2026-01-01 00:00:00',
@@ -296,6 +297,7 @@ const battleCenter = (state: FixtureState) => ({
stats: { leadership: 70, strength: 60, intelligence: 50 },
experience: 100,
dedication: 200,
+ bill: 800,
injury: 0,
gold: 1_000,
rice: 2_000,
@@ -334,6 +336,7 @@ const battleCenter = (state: FixtureState) => ({
stats: { leadership: 50, strength: 50, intelligence: 50 },
experience: 0,
dedication: 0,
+ bill: 400,
injury: 0,
gold: 500,
rice: 500,
@@ -1245,6 +1248,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await expect(page.locator('.general-table')).toContainText('병종보병');
await expect(page.locator('.general-table')).toContainText('삭턴6 턴');
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
+ await expect(page.locator('.battle-general-extra')).toContainText('봉급800');
await expect(page.locator('.battle-general-extra')).toContainText('전투8회');
await expect(page.locator('.battle-general-extra')).toContainText('계략12');
await expect(page.locator('.battle-general-extra')).toContainText('사관4년');
@@ -1255,7 +1259,7 @@ test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-ide
await expect(page.locator('.battle-general-extra__recent-value')).toHaveText('01-01 00:00');
await expect(page.locator('.legacy-general-details')).toHaveCount(0);
expect(await readGeneralSummaryRows(page.locator('.battle-general-extra'))).toEqual([
- ['명성', '계급', ''],
+ ['명성', '계급', '봉급'],
['전투', '계략', '사관'],
['승률', '승리', '패배'],
['살상률', '사살', '피살'],
@@ -2039,6 +2043,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.battle-general-name')).toContainText('간의대부');
await expect(page.locator('.battle-general-name')).toContainText('건강');
await expect(page.locator('.battle-general-extra')).toContainText('계급29품관');
+ await expect(page.locator('.battle-general-extra')).toContainText('봉급800');
await expect(page.locator('.battle-general-card')).toContainText('병종보병');
await expect(page.locator('.battle-general-card')).not.toContainText('che_');
await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', '');
@@ -2049,7 +2054,7 @@ test('감찰부 keeps the selector interaction and shows the permission error pa
await expect(page.locator('.battle-general-extra')).toContainText('승률62.50%');
await expect(page.locator('.battle-general-extra')).toContainText('살상률181.84%');
expect(await readGeneralSummaryRows(page.locator('.battle-general-extra'))).toEqual([
- ['명성', '계급', ''],
+ ['명성', '계급', '봉급'],
['전투', '계략', '사관'],
['승률', '승리', '패배'],
['살상률', '사살', '피살'],
diff --git a/app/game-frontend/src/components/main/GeneralBattleSummary.vue b/app/game-frontend/src/components/main/GeneralBattleSummary.vue
index 7b2ec503..b7b97216 100644
--- a/app/game-frontend/src/components/main/GeneralBattleSummary.vue
+++ b/app/game-frontend/src/components/main/GeneralBattleSummary.vue
@@ -7,6 +7,7 @@ export type GeneralBattleSummaryData = {
available?: boolean;
experience?: number | null;
dedicationText?: string | null;
+ bill?: number | null;
warnum?: number | null;
wins?: number | null;
losses?: number | null;
@@ -62,8 +63,13 @@ const killRate = computed(() => {
명성{{ numberText(summary.experience) }} 계급{{ summary.dedicationText || '-' }}
-
-
+
+ 봉급{{ numberText(summary.bill) }}
+
+
+
+
+
전투{{ numberText(summary.warnum) }}회
계략{{ numberText(summary.strategies) }}
diff --git a/app/game-frontend/src/views/BattleCenterView.vue b/app/game-frontend/src/views/BattleCenterView.vue
index 072b9abb..9ecf1ad8 100644
--- a/app/game-frontend/src/views/BattleCenterView.vue
+++ b/app/game-frontend/src/views/BattleCenterView.vue
@@ -291,6 +291,7 @@ onMounted(() => {
available: true,
experience: selectedGeneral.experience,
dedicationText: selectedGeneral.progression.dedicationText,
+ bill: selectedGeneral.bill,
warnum: selectedGeneral.warnum,
wins: selectedGeneral.battleStats.kills,
losses: selectedGeneral.battleStats.deaths,
diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue
index cddf5454..a69960aa 100644
--- a/app/game-frontend/src/views/MyPageView.vue
+++ b/app/game-frontend/src/views/MyPageView.vue
@@ -421,6 +421,7 @@ onMounted(() => {
available: true,
experience: data.general.experience,
dedicationText: data.general.progression?.dedicationText,
+ bill: data.general.bill,
warnum: data.general.records.battles,
wins: data.general.records.wins,
losses: data.general.records.losses,
From 4a61d7ed9277524e5409ab80b77d88d136ce2a47 Mon Sep 17 00:00:00 2001
From: hided62
Date: Fri, 21 Aug 2026 17:13:32 +0000
Subject: [PATCH 2/5] =?UTF-8?q?fix(game-ui):=20=ED=88=B4=ED=8C=81=20?=
=?UTF-8?q?=EC=A0=90=EC=84=A0=20=EC=9E=A5=EC=8B=9D=EC=9D=84=20=EC=A0=9C?=
=?UTF-8?q?=EA=B1=B0=ED=95=9C=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
툴팁의 hover와 키보드 focus 동작은 유지하면서 게임 정보에 반복되던 점선·파선 밑줄을 제거한다. 실제 Chromium에서 내정보, 장수 일람, 국가 전략, NPC 선택 흐름을 검증한다.
---
app/game-frontend/e2e/directoryLists.spec.ts | 4 ++++
app/game-frontend/e2e/inGameMenus.spec.ts | 1 +
app/game-frontend/e2e/mainNavigation.spec.ts | 1 +
app/game-frontend/e2e/npcPossession.spec.ts | 1 +
.../src/components/directory/DirectoryTooltip.vue | 2 --
app/game-frontend/src/components/main/NationBasicCard.vue | 1 -
app/game-frontend/src/components/ui/RichTooltip.vue | 2 --
app/game-frontend/src/views/JoinView.vue | 1 -
docs/frontend-css-architecture.md | 5 ++++-
9 files changed, 11 insertions(+), 7 deletions(-)
diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts
index bcd7b16b..06edbb51 100644
--- a/app/game-frontend/e2e/directoryLists.spec.ts
+++ b/app/game-frontend/e2e/directoryLists.spec.ts
@@ -629,6 +629,10 @@ test('nation directory reuses only the public general-directory row on hover and
await expect(preview.locator('[data-general-card-id="10"]')).toContainText('대담');
await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toContainText('상재');
await expect(preview.locator('[data-directory-tooltip="card-special-war-10"]')).toContainText('귀모');
+ await expect(preview.locator('[data-directory-tooltip="card-special-domestic-10"]')).toHaveCSS(
+ 'text-decoration-line',
+ 'none'
+ );
await expect(preview).not.toContainText('user-');
await expect(preview).not.toContainText('secret');
diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts
index cb5265b8..4172e7c6 100644
--- a/app/game-frontend/e2e/inGameMenus.spec.ts
+++ b/app/game-frontend/e2e/inGameMenus.spec.ts
@@ -1429,6 +1429,7 @@ test('내 정보 항목과 국가 성향은 HTML 리치 툴팁을 마우스와
const showWithMouse = async (testId: string, expectedTexts: readonly string[]) => {
const trigger = page.locator(`[data-rich-tooltip="${testId}"]`);
await expect(trigger).toHaveAttribute('tabindex', '0');
+ await expect(trigger).toHaveCSS('text-decoration-line', 'none');
await trigger.hover();
await expect(visibleTooltip).toHaveCount(1);
await expect(visibleTooltip).toHaveAttribute('role', 'tooltip');
diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts
index 2114ca10..33de76ef 100644
--- a/app/game-frontend/e2e/mainNavigation.spec.ts
+++ b/app/game-frontend/e2e/mainNavigation.spec.ts
@@ -2106,6 +2106,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
expect(Math.max(...nationRowHeights) - Math.min(...nationRowHeights)).toBeLessThanOrEqual(0.01);
const strategicCell = nationCard.locator('.strategic');
const strategicTooltip = strategicCell.getByRole('tooltip');
+ await expect(strategicCell).toHaveCSS('text-decoration-line', 'none');
await expect(strategicTooltip).toBeHidden();
await strategicCell.hover();
await expect(strategicTooltip).toBeVisible();
diff --git a/app/game-frontend/e2e/npcPossession.spec.ts b/app/game-frontend/e2e/npcPossession.spec.ts
index 8a035639..975e93a7 100644
--- a/app/game-frontend/e2e/npcPossession.spec.ts
+++ b/app/game-frontend/e2e/npcPossession.spec.ts
@@ -266,6 +266,7 @@ test('renders Ref-shaped token cards, preserves keep cooldown and retries posses
});
const tooltip = page.locator('.npc-tooltip').first();
const tooltipPopup = tooltip.getByRole('tooltip');
+ await expect(tooltip).toHaveCSS('text-decoration-line', 'none');
await expect(tooltipPopup).toBeHidden();
await tooltip.hover();
await expect(tooltipPopup).toBeVisible();
diff --git a/app/game-frontend/src/components/directory/DirectoryTooltip.vue b/app/game-frontend/src/components/directory/DirectoryTooltip.vue
index 9106f8e3..9110bbc4 100644
--- a/app/game-frontend/src/components/directory/DirectoryTooltip.vue
+++ b/app/game-frontend/src/components/directory/DirectoryTooltip.vue
@@ -29,8 +29,6 @@ defineProps<{
}
.directory-tooltip--enabled {
cursor: help;
- text-decoration: underline dotted rgb(150 210 255 / 85%);
- text-underline-offset: 2px;
}
.directory-tooltip--enabled:focus-visible {
border-radius: 2px;
diff --git a/app/game-frontend/src/components/main/NationBasicCard.vue b/app/game-frontend/src/components/main/NationBasicCard.vue
index 478df508..8435b3b5 100644
--- a/app/game-frontend/src/components/main/NationBasicCard.vue
+++ b/app/game-frontend/src/components/main/NationBasicCard.vue
@@ -284,7 +284,6 @@ const displayChiefName = (chief: NationChief | undefined): string => {
.strategic.has-tooltip {
overflow: visible;
- text-decoration: underline dashed red;
}
.cooldown-tooltip {
diff --git a/app/game-frontend/src/components/ui/RichTooltip.vue b/app/game-frontend/src/components/ui/RichTooltip.vue
index 96edc160..c3d8901f 100644
--- a/app/game-frontend/src/components/ui/RichTooltip.vue
+++ b/app/game-frontend/src/components/ui/RichTooltip.vue
@@ -97,8 +97,6 @@ watch(
.rich-tooltip-trigger--enabled {
cursor: help;
- text-decoration: underline dotted rgb(150 210 255 / 85%);
- text-underline-offset: 2px;
}
.rich-tooltip-trigger--enabled:focus-visible {
diff --git a/app/game-frontend/src/views/JoinView.vue b/app/game-frontend/src/views/JoinView.vue
index e3b3b64e..2045c02d 100644
--- a/app/game-frontend/src/views/JoinView.vue
+++ b/app/game-frontend/src/views/JoinView.vue
@@ -1733,7 +1733,6 @@ onUnmounted(() => {
.npc-tooltip {
position: relative;
cursor: help;
- text-decoration: underline dotted;
}
.npc-tooltip [role='tooltip'] {
diff --git a/docs/frontend-css-architecture.md b/docs/frontend-css-architecture.md
index ef33e083..3a76918b 100644
--- a/docs/frontend-css-architecture.md
+++ b/docs/frontend-css-architecture.md
@@ -36,7 +36,10 @@ available sort keys and placement. NPC·암행부·세력도시는 Ref의 고정
`내림차순 → 오름차순 → 해제`를 순환하고, 여러 열의 방향과 우선순위를 scoped
SFC indicator로 표시합니다. 장수 일람의 성격·특기·부상 설명은 많은 행에서
eager popup instance를 만들지 않는 `DirectoryTooltip.vue` scoped CSS가 소유하며,
-mobile에서는 viewport 가장자리 8px 안의 고정 설명판으로 전환합니다.
+mobile에서는 viewport 가장자리 8px 안의 고정 설명판으로 전환합니다. 게임 내
+tooltip trigger는 hover/focus 동작과 `cursor: help`를 유지하되, tooltip이 있는 모든
+텍스트에 점선 밑줄을 반복하지 않습니다. keyboard `focus-visible` outline은 별도의
+접근성 상태로 유지합니다.
## Button composition
From d71f16e56b93bd2e725c85ce294cc54e9b49a6e6 Mon Sep 17 00:00:00 2001
From: hided62
Date: Fri, 21 Aug 2026 17:15:16 +0000
Subject: [PATCH 3/5] =?UTF-8?q?fix(game-ui):=20=EA=B5=AD=EA=B0=80=20?=
=?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=8E=B8=EC=A7=91=EA=B8=B0=20?=
=?UTF-8?q?=EC=A0=95=EB=A0=AC=EA=B3=BC=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20?=
=?UTF-8?q?=EB=B0=94=EB=A1=9C=EC=9E=A1=EB=8A=94=EB=8B=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/game-api/test/nationHtml.test.ts | 3 +-
app/game-frontend/e2e/nationOffices.spec.ts | 76 ++++++-
.../src/components/ui/LegacyHtmlEditor.vue | 198 ++++++++++++++++--
.../src/views/NationStratFinanView.vue | 47 ++++-
4 files changed, 301 insertions(+), 23 deletions(-)
diff --git a/app/game-api/test/nationHtml.test.ts b/app/game-api/test/nationHtml.test.ts
index 2dbd1f73..6dae4b9c 100644
--- a/app/game-api/test/nationHtml.test.ts
+++ b/app/game-api/test/nationHtml.test.ts
@@ -73,7 +73,7 @@ describe('nation HTML purification', () => {
const source = [
'',
'방침',
- '
',
+ '
',
].join('');
const clean = purifyNationHtml(source);
@@ -83,6 +83,7 @@ describe('nation HTML purification', () => {
'style="font-family:Pretendard, sans-serif;font-size:22px;color:#123456;background-color:#fedcba"'
);
expect(clean).toContain('
');
+ expect(clean).toContain('class="custom-image-align-right"');
expect(clean).toContain(
'src="https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp"'
);
diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts
index 9b0c8865..acf920c4 100644
--- a/app/game-frontend/e2e/nationOffices.spec.ts
+++ b/app/game-frontend/e2e/nationOffices.spec.ts
@@ -624,6 +624,21 @@ test('finance editor preserves Ref formatting controls and uploads images throug
expect(await editorFrame.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
expect(await editor.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
+ await editor.fill('첫 번째 항목');
+ await page.getByRole('button', { name: '번호 목록' }).click();
+ await expect(editor.locator('ol')).toHaveCSS('list-style-type', 'decimal');
+ await expect(editor.locator('li')).toContainText('첫 번째 항목');
+ await screenshot(page, 'core-finance-numbered-list-desktop.png');
+ await page.getByRole('button', { name: '번호 목록' }).click();
+
+ const alignmentIcons = await page
+ .getByRole('toolbar', { name: '서식' })
+ .getByRole('button', { name: /^(왼쪽|가운데|오른쪽) 정렬$/ })
+ .locator('svg')
+ .evaluateAll((icons) => icons.map((icon) => icon.innerHTML));
+ expect(alignmentIcons).toHaveLength(3);
+ expect(new Set(alignmentIcons).size).toBe(3);
+
await editor.fill('서식 검증');
await editor.press('Control+A');
await page.getByRole('combobox', { name: '글꼴', exact: true }).selectOption('Gungsuh, serif');
@@ -661,11 +676,27 @@ test('finance editor preserves Ref formatting controls and uploads images throug
await expect.poll(() => state.uploadDataUrl).toMatch(/^data:image\/png;base64,/);
await expect(editor.locator('hr')).toHaveCount(1);
- await expect(editor.locator('img')).toHaveAttribute(
+ const uploadedImage = editor.locator('img');
+ await expect(uploadedImage).toHaveAttribute(
'src',
'https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp'
);
+ await uploadedImage.click();
+ const imageToolbar = page.getByRole('toolbar', { name: '이미지 정렬' });
+ await expect(imageToolbar).toBeVisible();
+ await expect(imageToolbar.getByRole('button')).toHaveCount(5);
+ await screenshot(page, 'core-finance-image-toolbar-desktop.png');
+ await imageToolbar.getByRole('button', { name: '이미지 오른쪽 정렬' }).click();
+ await expect(uploadedImage).toHaveClass(/custom-image-align-right/);
+ await expect(uploadedImage).toHaveCSS('display', 'block');
+ await expect(uploadedImage).toHaveCSS('margin-right', '0px');
+
+ await page.getByRole('toolbar', { name: '서식' }).getByRole('button', { name: '가운데 정렬' }).click();
+ await expect(uploadedImage).toHaveClass(/custom-image-align-center/);
+ await uploadedImage.click();
+ await imageToolbar.getByRole('button', { name: '이미지 오른쪽 정렬' }).click();
+
const imageButton = page.getByRole('button', { name: '이미지', exact: true });
await imageButton.hover();
await expect(imageButton).toHaveCSS('border-color', 'rgb(157, 200, 240)');
@@ -691,11 +722,54 @@ test('finance editor preserves Ref formatting controls and uploads images throug
expect(state.noticeMutationInput).toContain('background-color: rgb(254, 220, 186)');
expect(state.noticeMutationInput).toContain('text-align: center');
expect(state.noticeMutationInput).toContain('
');
+ expect(state.noticeMutationInput).toContain('class="custom-image-align-right"');
expect(state.noticeMutationInput).toContain(
'https://sam-image.hided.net/uploads/core2026/0123456789abcdef0123456789abcdef.webp'
);
});
+test('recruitment editor keeps the Ref 870px content width in desktop and scales it to 500px on mobile', async ({
+ page,
+}) => {
+ await installFixture(page, { role: 'head', rate: 20 });
+ await page.setViewportSize({ width: 1000, height: 900 });
+ await gotoOffice(page, 'nation/finance');
+ await page.getByRole('button', { name: '임관 권유문 수정' }).click();
+
+ const form = page.locator('#scout-message-form');
+ const editorFrame = form.locator('.legacy-html-editor');
+ const desktop = await form.evaluate((element) => {
+ const frame = element.querySelector('.legacy-html-editor')!;
+ const formRect = element.getBoundingClientRect();
+ const frameRect = frame.getBoundingClientRect();
+ return {
+ formWidth: formRect.width,
+ frameWidth: frameRect.width,
+ leftInset: frameRect.left - formRect.left,
+ rightInset: formRect.right - frameRect.right,
+ };
+ });
+ expect(desktop.formWidth).toBe(1000);
+ expect(desktop.frameWidth).toBe(870);
+ expect(desktop.leftInset).toBe(130);
+ expect(desktop.rightInset).toBe(0);
+
+ await page.setViewportSize({ width: 500, height: 900 });
+ const mobile = await editorFrame.evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ return {
+ visualWidth: rect.width,
+ sourceWidth: element.clientWidth,
+ toolbarWidth: element.querySelector('[role="toolbar"]')!.getBoundingClientRect().width,
+ };
+ });
+ expect(mobile.sourceWidth).toBe(870);
+ expect(mobile.visualWidth).toBeCloseTo(500, 1);
+ expect(mobile.toolbarWidth).toBeCloseTo(500, 1);
+ expect(await page.locator('.page-finance').evaluate((element) => element.scrollWidth)).toBeLessThanOrEqual(500);
+ await screenshot(page, 'core-finance-scout-editor-mobile.png');
+});
+
test('finance adopts the server-purified notice and scout message before rendering the saved preview', async ({
page,
}) => {
diff --git a/app/game-frontend/src/components/ui/LegacyHtmlEditor.vue b/app/game-frontend/src/components/ui/LegacyHtmlEditor.vue
index 199866e7..9f68d68e 100644
--- a/app/game-frontend/src/components/ui/LegacyHtmlEditor.vue
+++ b/app/game-frontend/src/components/ui/LegacyHtmlEditor.vue
@@ -1,16 +1,17 @@
-
-
- 동작 시각: {{ lastExecutedStatus }}
+
+
+ 현재 시각: {{ currentServerTime }}
@@ -120,6 +195,10 @@ const lastExecutedStatus = computed(() =>
color: magenta;
}
+.execution-status--stale {
+ color: magenta;
+}
+
.vote-label {
color: cyan;
}
diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts
index 08134d0d..1a83d38f 100644
--- a/app/game-frontend/src/stores/mainDashboard.ts
+++ b/app/game-frontend/src/stores/mainDashboard.ts
@@ -21,6 +21,7 @@ import {
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
+import { markGameServerContact } from '../utils/gameServerActivity';
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
@@ -1097,10 +1098,12 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
onPayload: (message) => {
if (!isRealtimeParticipant()) return;
if (message.kind === 'patch') {
+ markGameServerContact();
applyDashboardPatch(message.patch);
return;
}
realtimeStatus.value = message.status;
+ if (message.status === 'connected') markGameServerContact();
},
});
realtimeCoordinator.start();
@@ -1153,6 +1156,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
realtimeSource = source;
source.addEventListener('open', () => {
+ markGameServerContact();
realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
});
@@ -1166,6 +1170,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!payload || payload.type !== 'readModelInvalidated') {
return;
}
+ markGameServerContact();
readModelRefreshQueue.request(payload.invalidation, payload.refreshGrant);
});
source.addEventListener('messagesInvalidated', (event) => {
@@ -1174,6 +1179,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
if (!payload || payload.type !== 'messagesInvalidated') {
return;
}
+ markGameServerContact();
void refreshMessages(payload.refreshGrant);
});
@@ -1182,14 +1188,17 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
for (const legacyEventType of ['turnCompleted', 'readModelChanged'] as const) {
source.addEventListener(legacyEventType, () => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
+ markGameServerContact();
realtimeRefreshQueue.request();
});
}
source.addEventListener('messageCreated', () => {
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
+ markGameServerContact();
void refreshMessages();
});
source.addEventListener('ping', () => {
+ markGameServerContact();
if (realtimeEnabled.value) {
realtimeStatus.value = 'connected';
realtimeCoordinator?.postFromLeader({ kind: 'status', status: 'connected' });
diff --git a/app/game-frontend/src/utils/gameServerActivity.ts b/app/game-frontend/src/utils/gameServerActivity.ts
new file mode 100644
index 00000000..cebcb47e
--- /dev/null
+++ b/app/game-frontend/src/utils/gameServerActivity.ts
@@ -0,0 +1,34 @@
+import { readonly, ref, type Ref } from 'vue';
+
+export const GAME_SERVER_ACTIVITY_FRESHNESS_MS = 45_000;
+
+export type GameServerActivityTracker = {
+ lastContactAt: Readonly[>;
+ markContact: (contactAt?: number) => void;
+};
+
+export const createGameServerActivityTracker = (): GameServerActivityTracker => {
+ const lastContactAt = ref(null);
+
+ return {
+ lastContactAt: readonly(lastContactAt),
+ markContact(contactAt = Date.now()) {
+ if (!Number.isFinite(contactAt)) return;
+ lastContactAt.value = contactAt;
+ },
+ };
+};
+
+export const isRecentGameServerActivity = (
+ lastContactAt: number | null,
+ now = Date.now(),
+ freshnessMs = GAME_SERVER_ACTIVITY_FRESHNESS_MS
+): boolean =>
+ lastContactAt !== null &&
+ Number.isFinite(lastContactAt) &&
+ Number.isFinite(now) &&
+ Math.max(0, now - lastContactAt) <= freshnessMs;
+
+export const gameServerActivity = createGameServerActivityTracker();
+
+export const markGameServerContact = (contactAt = Date.now()) => gameServerActivity.markContact(contactAt);
diff --git a/app/game-frontend/src/utils/serverClockProjection.ts b/app/game-frontend/src/utils/serverClockProjection.ts
new file mode 100644
index 00000000..3376b9cb
--- /dev/null
+++ b/app/game-frontend/src/utils/serverClockProjection.ts
@@ -0,0 +1,67 @@
+export type ServerClockProjectionInput = {
+ serverTime?: string;
+ serverWallTime?: string;
+ clockMode?: 'realtime' | 'manual';
+ clockRunning?: boolean;
+ clockStartsAt?: string | null;
+};
+
+export type SampledServerClock = {
+ serverTimeMs: number;
+ sampledClientTimeMs: number;
+ clockMode: 'realtime' | 'manual';
+ startDelayMs: number | null;
+};
+
+const parseInstant = (value?: string | null): number | null => {
+ if (!value) return null;
+ const parsed = new Date(value).getTime();
+ return Number.isFinite(parsed) ? parsed : null;
+};
+
+export const sampleServerClock = (
+ input: ServerClockProjectionInput,
+ sampledClientTimeMs = Date.now()
+): SampledServerClock | null => {
+ const serverTimeMs = parseInstant(input.serverTime);
+ if (serverTimeMs === null) return null;
+
+ let startDelayMs: number | null;
+ if (input.clockMode === 'manual') {
+ startDelayMs = null;
+ } else if (input.clockRunning !== false) {
+ startDelayMs = 0;
+ } else {
+ const serverWallTimeMs = parseInstant(input.serverWallTime);
+ const clockStartsAtMs = parseInstant(input.clockStartsAt);
+ startDelayMs =
+ serverWallTimeMs !== null && clockStartsAtMs !== null
+ ? Math.max(0, clockStartsAtMs - serverWallTimeMs)
+ : null;
+ }
+
+ return {
+ serverTimeMs,
+ sampledClientTimeMs,
+ clockMode: input.clockMode ?? 'realtime',
+ startDelayMs,
+ };
+};
+
+export const projectServerClock = (sample: SampledServerClock, clientTimeMs = Date.now()) => {
+ const clientElapsedMs = Math.max(0, clientTimeMs - sample.sampledClientTimeMs);
+ const elapsedGameMs =
+ sample.clockMode === 'manual' || sample.startDelayMs === null
+ ? 0
+ : Math.max(0, clientElapsedMs - sample.startDelayMs);
+
+ return {
+ clientElapsedMs,
+ time: new Date(sample.serverTimeMs + elapsedGameMs),
+ };
+};
+
+export const millisecondsUntilNextMinute = (time: Date): number => {
+ const remainder = ((time.getTime() % 60_000) + 60_000) % 60_000;
+ return remainder === 0 ? 60_000 : 60_000 - remainder;
+};
diff --git a/app/game-frontend/src/utils/trpc.ts b/app/game-frontend/src/utils/trpc.ts
index 899042aa..bd291ee4 100644
--- a/app/game-frontend/src/utils/trpc.ts
+++ b/app/game-frontend/src/utils/trpc.ts
@@ -3,6 +3,7 @@ import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common/realtime/types';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/game-api';
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
+import { markGameServerContact } from './gameServerActivity';
const getGameToken = (): string | null => {
if (typeof window === 'undefined') {
@@ -17,6 +18,11 @@ export const trpc = createTRPCProxyClient({
httpBatchLink({
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
...trpcJsonBodyHttpClientOptions,
+ async fetch(input, init) {
+ const result = await globalThis.fetch(input, init);
+ markGameServerContact();
+ return result;
+ },
headers({ opList }) {
const token = getGameToken();
const refreshGrant = resolveBatchRealtimeAccessGrant(opList);
diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue
index 1b6618eb..f12162fe 100644
--- a/app/game-frontend/src/views/MainView.vue
+++ b/app/game-frontend/src/views/MainView.vue
@@ -243,7 +243,15 @@ watch(
]
-
+