From d71f16e56b93bd2e725c85ce294cc54e9b49a6e6 Mon Sep 17 00:00:00 2001
From: hided62
Date: Fri, 21 Aug 2026 17:15:16 +0000
Subject: [PATCH 1/4] =?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(
]
-
+