merge: 최신 main을 0.1.0 사전점검에 통합
This commit is contained in:
@@ -202,6 +202,7 @@ pnpm docs:preview
|
||||
- [아키텍처 개요](docs/architecture/overview.md)
|
||||
- [런타임 아키텍처](docs/architecture/runtime.md)
|
||||
- [릴리스 운영 매뉴얼](docs/release-operations.md)
|
||||
- [Gateway와 게임 공통 메뉴 설정](docs/runtime-navigation.md)
|
||||
- [차등 검증](docs/architecture/turn-state-differential-testing.md)
|
||||
- [Caddy prefix 계약](docs/e2e-caddy-routing.md)
|
||||
- [레거시 DB 이관](docs/legacy-db-migration.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { asRecord } from '@sammo-ts/common';
|
||||
import { asNumber, asRecord } from '@sammo-ts/common';
|
||||
|
||||
import { zWorldStateConfig, zWorldStateMeta } from '../../context.js';
|
||||
import { isSelectionPoolWorld, resolveSelectionMaxGeneral } from '@sammo-ts/game-engine/turn/selectPoolService.js';
|
||||
@@ -26,7 +26,14 @@ export const lobbyRouter = router({
|
||||
const userCnt = await ctx.db.general.count({ where: { npcState: { lt: 2 } } });
|
||||
const npcCnt = await ctx.db.general.count({ where: { npcState: { gte: 2 } } });
|
||||
const nationCnt = await ctx.db.nation.count({ where: { level: { gt: 0 } } });
|
||||
const rawConfig = asRecord(rawWorldState.config);
|
||||
const scenarioTitle = asRecord(asRecord(rawWorldState.meta).scenarioMeta).title;
|
||||
const autorunUser = worldState.meta.autorun_user;
|
||||
const autorunOptions = autorunUser?.options
|
||||
? Object.entries(autorunUser.options)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([option]) => option)
|
||||
: [];
|
||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||
|
||||
let myGeneral = null;
|
||||
@@ -55,10 +62,20 @@ export const lobbyRouter = router({
|
||||
fictionMode: worldState.config.fictionMode ?? '사실',
|
||||
starttime: worldState.meta.starttime ?? '',
|
||||
opentime: worldState.meta.opentime ?? '',
|
||||
preopenAt: worldState.meta.preopenAt ?? '',
|
||||
turntime: worldState.meta.turntime ?? '',
|
||||
serverTime: gameTime.now.toISOString(),
|
||||
clockMode: gameTime.mode ?? 'realtime',
|
||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||
npcMode: worldState.config.npcMode ?? 0,
|
||||
defaultStatTotal: asNumber(asRecord(rawConfig.stat).total, 165),
|
||||
autorunUser:
|
||||
autorunUser?.limit_minutes && autorunUser.limit_minutes > 0 && autorunOptions.length > 0
|
||||
? {
|
||||
limitMinutes: autorunUser.limit_minutes,
|
||||
options: autorunOptions,
|
||||
}
|
||||
: null,
|
||||
isUnited: worldState.meta.isunited ?? worldState.meta.isUnited ?? 0,
|
||||
selectionPoolEnabled: isSelectionPoolWorld(rawWorldState),
|
||||
npcPossessionEnabled: worldState.config.npcMode === 1,
|
||||
|
||||
@@ -10,7 +10,8 @@ const buildContext = (
|
||||
tick?: bigint;
|
||||
mode?: string;
|
||||
wallAnchor?: Date;
|
||||
} = {}
|
||||
} = {},
|
||||
config: Record<string, unknown> = {}
|
||||
): GameApiContext =>
|
||||
({
|
||||
auth: null,
|
||||
@@ -22,7 +23,7 @@ const buildContext = (
|
||||
currentYear: 200,
|
||||
currentMonth: 1,
|
||||
tickSeconds: 3_600,
|
||||
config: {},
|
||||
config,
|
||||
meta,
|
||||
clockBaseTime: clock.baseTime ?? null,
|
||||
clockTick: clock.tick ?? null,
|
||||
@@ -67,4 +68,48 @@ describe('lobby season state', () => {
|
||||
expect(result.serverTime).toBe('2026-08-15T02:00:00.000Z');
|
||||
expect(result.clockMode).toBe('manual');
|
||||
});
|
||||
|
||||
it('projects the Ref-compatible opening announcement settings without exposing disabled autorun options', async () => {
|
||||
const result = await appRouter
|
||||
.createCaller(
|
||||
buildContext(
|
||||
{
|
||||
preopenAt: '2026-08-19 22:00:00',
|
||||
opentime: '2026-08-19 23:00:00',
|
||||
scenarioMeta: { title: '【가상모드27-b】 아시아 명장전(비급)' },
|
||||
autorun_user: {
|
||||
limit_minutes: 1_440,
|
||||
options: {
|
||||
develop: true,
|
||||
warp: true,
|
||||
recruit: false,
|
||||
recruit_high: true,
|
||||
train: true,
|
||||
battle: true,
|
||||
chief: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{},
|
||||
{
|
||||
fictionMode: '가상',
|
||||
npcMode: 0,
|
||||
stat: { total: 310, min: 10, max: 110 },
|
||||
}
|
||||
)
|
||||
)
|
||||
.lobby.info();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
preopenAt: '2026-08-19 22:00:00',
|
||||
opentime: '2026-08-19 23:00:00',
|
||||
scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)',
|
||||
npcMode: 0,
|
||||
defaultStatTotal: 310,
|
||||
autorunUser: {
|
||||
limitMinutes: 1_440,
|
||||
options: ['develop', 'warp', 'recruit_high', 'train', 'battle', 'chief'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const runtimeNavigation = JSON.parse(
|
||||
await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8')
|
||||
) as unknown;
|
||||
const errorResponse = (path: string, message: string) => ({
|
||||
error: {
|
||||
message,
|
||||
@@ -359,11 +362,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
await page.route('**/events**', async (route) => {
|
||||
await route.abort();
|
||||
});
|
||||
await page.route('**/gateway/api/navigation', async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(runtimeNavigation) });
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const operations = operationNames(route);
|
||||
const results = operations.map((operation) =>
|
||||
operation === 'me'
|
||||
? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' })
|
||||
: operation === 'navigation.get'
|
||||
? response(runtimeNavigation)
|
||||
: response({ ok: true })
|
||||
);
|
||||
await route.fulfill({
|
||||
@@ -924,7 +932,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
);
|
||||
await expect(global.locator('[data-navigation-id="nation-list"]')).toHaveAttribute('target', '_blank');
|
||||
await expect(global.locator('[data-navigation-id="board-community"]')).toHaveAttribute('href', '/xe/community');
|
||||
await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(global.locator('[data-navigation-id="official-chat"]')).toHaveAttribute('target', '_blank');
|
||||
await expect(global.locator('[data-navigation-id="survey"]')).toHaveClass(/highlight/);
|
||||
await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/);
|
||||
|
||||
@@ -971,6 +979,14 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
|
||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
await gameInfoButton.click();
|
||||
await global.locator('[data-navigation-id="version"]').click();
|
||||
const versionDialog = page.getByRole('dialog', { name: '게임 정보' });
|
||||
await expect(versionDialog).toBeVisible();
|
||||
await expect(versionDialog).toContainText('메인 화면 검증 시나리오');
|
||||
await versionDialog.getByRole('button', { name: '닫기' }).click();
|
||||
await expect(versionDialog).toBeHidden();
|
||||
|
||||
const bottomGlobal = page.locator('[data-menu-position="bottom"]');
|
||||
const bottomGameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]');
|
||||
await bottomGameInfoButton.click();
|
||||
@@ -995,7 +1011,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
|
||||
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
|
||||
});
|
||||
|
||||
test('split buttons keep square inner corners and a single divider in every interaction state', async ({
|
||||
test('nation split buttons keep square inner corners and a single divider in every interaction state', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const state: NavigationFixture = {
|
||||
@@ -1062,14 +1078,8 @@ test('split buttons keep square inner corners and a single divider in every inte
|
||||
for (const width of [1200, 500]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await waitForMain(page);
|
||||
const globalSplit = page.locator('.main-global-menu:visible .main-menu-split').first();
|
||||
const nationSplit = page.locator('.main-nation-menu:visible .nation-menu-split').first();
|
||||
const pairs: Array<[string, Locator, Locator]> = [
|
||||
[
|
||||
'global',
|
||||
globalSplit.locator('[data-navigation-id="board-community"]'),
|
||||
globalSplit.locator('[data-menu-id="boards"]'),
|
||||
],
|
||||
[
|
||||
'nation',
|
||||
nationSplit.locator('[data-navigation-id="auction-resource"]'),
|
||||
@@ -1129,7 +1139,8 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn
|
||||
|
||||
const bottomGlobal = page.locator('[data-menu-position="bottom"]');
|
||||
const gameInfoButton = bottomGlobal.locator('[data-menu-id="game-info"]');
|
||||
await gameInfoButton.click();
|
||||
await gameInfoButton.scrollIntoViewIfNeeded();
|
||||
await gameInfoButton.evaluate((button) => (button as HTMLElement).click());
|
||||
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(bottomGlobal.locator('#global-menu-game-info')).toBeVisible();
|
||||
const geometry = await gameInfoButton.evaluate((button) => {
|
||||
@@ -2418,9 +2429,9 @@ test('all main Lumen button families share the rounded pressed geometry', async
|
||||
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
|
||||
],
|
||||
[
|
||||
'게임정보',
|
||||
'게임 정보',
|
||||
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
|
||||
name: '게임정보',
|
||||
name: '게임 정보',
|
||||
exact: true,
|
||||
}),
|
||||
],
|
||||
@@ -2566,7 +2577,7 @@ test('mobile main Lumen button families keep the same state geometry without ove
|
||||
const controls = [
|
||||
page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'),
|
||||
page.locator('.main-global-menu[data-menu-position="top"]').getByRole('button', {
|
||||
name: '게임정보',
|
||||
name: '게임 정보',
|
||||
exact: true,
|
||||
}),
|
||||
page.locator('.layout-mobile [data-navigation-id="meeting"]'),
|
||||
|
||||
@@ -12,7 +12,8 @@ const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/';
|
||||
const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||
const frontendEnv =
|
||||
`VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` +
|
||||
`VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl}`;
|
||||
`VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} ` +
|
||||
'VITE_GATEWAY_API_URL=/gateway/api/trpc';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
|
||||
@@ -5,17 +5,23 @@ import {
|
||||
buildGlobalNavigation,
|
||||
isNavigationConfigured,
|
||||
type MainNavigationLink as MainNavigationLinkItem,
|
||||
type MainNavigationEntry,
|
||||
} from './mainNavigation';
|
||||
import { useMenuPopup } from './useMenuPopup';
|
||||
|
||||
const props = defineProps<{
|
||||
npcMode: number;
|
||||
voteActive: boolean;
|
||||
entries?: MainNavigationEntry[];
|
||||
}>();
|
||||
|
||||
const entries = computed(() => buildGlobalNavigation(props.npcMode));
|
||||
const emit = defineEmits<{
|
||||
action: [action: NonNullable<MainNavigationLinkItem['action']>];
|
||||
}>();
|
||||
|
||||
const entries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
|
||||
const { setRoot, openId, close, toggle } = useMenuPopup();
|
||||
const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props.voteActive;
|
||||
const isActive = (link: MainNavigationLinkItem) => link.highlightWhen === 'vote' && props.voteActive;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,6 +33,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
||||
:enabled="isNavigationConfigured(entry)"
|
||||
:active="isActive(entry)"
|
||||
lumen-variant="navigation"
|
||||
@action="emit('action', $event)"
|
||||
/>
|
||||
<div v-else-if="entry.kind === 'group'" class="main-menu-popup">
|
||||
<button
|
||||
@@ -54,6 +61,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
||||
:enabled="isNavigationConfigured(item)"
|
||||
role="menuitem"
|
||||
@navigate="close()"
|
||||
@action="emit('action', $event); close()"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
@@ -65,6 +73,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
||||
:enabled="isNavigationConfigured(entry.main)"
|
||||
:active="isActive(entry.main)"
|
||||
lumen-variant="navigation"
|
||||
@action="emit('action', $event)"
|
||||
/>
|
||||
<button
|
||||
class="main-menu-button main-menu-split__toggle legacy-split-button__toggle legacy-button legacy-button--navigation"
|
||||
@@ -91,6 +100,7 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props
|
||||
:enabled="isNavigationConfigured(item)"
|
||||
role="menuitem"
|
||||
@navigate="close()"
|
||||
@action="emit('action', $event); close()"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
nationNavigation,
|
||||
quickNavigation,
|
||||
type MainNavigationLink as MainNavigationLinkItem,
|
||||
type MainNavigationEntry,
|
||||
type NationNavigationAccess,
|
||||
type QuickNavigationItem,
|
||||
} from './mainNavigation';
|
||||
@@ -21,6 +22,7 @@ const props = defineProps<{
|
||||
npcMode: number;
|
||||
realtimeEnabled: boolean;
|
||||
refreshing: boolean;
|
||||
entries?: MainNavigationEntry[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -28,10 +30,11 @@ const emit = defineEmits<{
|
||||
toggleRealtime: [];
|
||||
lobby: [];
|
||||
quick: [item: QuickNavigationItem];
|
||||
action: [action: NonNullable<MainNavigationLinkItem['action']>];
|
||||
}>();
|
||||
|
||||
const { setRoot, openId, close, toggle } = useMenuPopup();
|
||||
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode));
|
||||
const globalEntries = computed(() => buildGlobalNavigation(props.npcMode, props.entries));
|
||||
const nationMenuColor = computed(() => props.nationColor || '#000000');
|
||||
const nationMenuTextColor = computed(() => legacyNationTextColor(nationMenuColor.value));
|
||||
const isActive = (link: MainNavigationLinkItem) => link.highlightStage === props.tournamentStage;
|
||||
@@ -40,6 +43,10 @@ const onQuick = (item: QuickNavigationItem) => {
|
||||
close();
|
||||
emit('quick', item);
|
||||
};
|
||||
const onAction = (action: NonNullable<MainNavigationLinkItem['action']>) => {
|
||||
close();
|
||||
emit('action', action);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -73,6 +80,7 @@ const onQuick = (item: QuickNavigationItem) => {
|
||||
compact
|
||||
role="menuitem"
|
||||
@navigate="close()"
|
||||
@action="onAction"
|
||||
/>
|
||||
</li>
|
||||
<template v-else-if="entry.kind === 'group'">
|
||||
@@ -88,6 +96,7 @@ const onQuick = (item: QuickNavigationItem) => {
|
||||
compact
|
||||
role="menuitem"
|
||||
@navigate="close()"
|
||||
@action="onAction"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
@@ -100,6 +109,7 @@ const onQuick = (item: QuickNavigationItem) => {
|
||||
compact
|
||||
role="menuitem"
|
||||
@navigate="close()"
|
||||
@action="onAction"
|
||||
/>
|
||||
</li>
|
||||
<template v-for="item in entry.items" :key="item.id">
|
||||
@@ -111,6 +121,7 @@ const onQuick = (item: QuickNavigationItem) => {
|
||||
compact
|
||||
role="menuitem"
|
||||
@navigate="close()"
|
||||
@action="onAction"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -20,6 +20,7 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [];
|
||||
action: [action: NonNullable<MainNavigationLink['action']>];
|
||||
}>();
|
||||
|
||||
const label = computed(() => (props.compact ? (props.link.compactLabel ?? props.link.label) : props.link.label));
|
||||
@@ -54,6 +55,16 @@ const lumenClasses = computed(() =>
|
||||
>
|
||||
{{ label }}
|
||||
</a>
|
||||
<button
|
||||
v-else-if="enabled && link.action"
|
||||
class="main-menu-link main-menu-link--button"
|
||||
:class="[lumenClasses, { highlight: active }]"
|
||||
type="button"
|
||||
:data-navigation-id="link.id"
|
||||
@click="emit('action', link.action)"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="main-menu-link disabled"
|
||||
@@ -92,6 +103,10 @@ const lumenClasses = computed(() =>
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main-menu-link--button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-menu-link:hover,
|
||||
.main-menu-link:focus-visible,
|
||||
.main-menu-button:hover,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type {
|
||||
RuntimeNavigationConfig,
|
||||
RuntimeNavigationEntry,
|
||||
RuntimeNavigationLink,
|
||||
} from '@sammo-ts/common/navigation/menuConfig';
|
||||
import defaultNavigationJson from '../../../../../resources/navigation.json';
|
||||
|
||||
export type NationAccessRule =
|
||||
'always' | 'meeting' | 'secret' | 'nation-member' | 'nation-established' | 'nation-secret';
|
||||
|
||||
export type MainNavigationLink = {
|
||||
kind: 'link';
|
||||
id: string;
|
||||
label: string;
|
||||
to?: string;
|
||||
href?: string;
|
||||
export type MainNavigationLink = RuntimeNavigationLink & {
|
||||
compactLabel?: string;
|
||||
newTab?: boolean;
|
||||
access?: NationAccessRule;
|
||||
highlightStage?: 1 | 6;
|
||||
unavailableReason?: string;
|
||||
@@ -49,114 +50,26 @@ export interface QuickNavigationItem {
|
||||
selector: string;
|
||||
}
|
||||
|
||||
const configuredExternalLink = (
|
||||
id: string,
|
||||
label: string,
|
||||
value: string | undefined,
|
||||
unavailableReason: string
|
||||
): MainNavigationLink => {
|
||||
const href = value?.trim();
|
||||
return {
|
||||
kind: 'link',
|
||||
id,
|
||||
label,
|
||||
...(href ? { href } : {}),
|
||||
newTab: true,
|
||||
unavailableReason: href ? undefined : unavailableReason,
|
||||
};
|
||||
};
|
||||
const defaultNavigation = defaultNavigationJson as RuntimeNavigationConfig;
|
||||
export const defaultGlobalNavigation = defaultNavigation.game.items as MainNavigationEntry[];
|
||||
|
||||
export const buildGlobalNavigation = (npcMode: number): MainNavigationEntry[] => [
|
||||
{
|
||||
kind: 'link',
|
||||
id: 'nation-betting',
|
||||
label: '천통국 베팅',
|
||||
to: '/nation-betting',
|
||||
},
|
||||
{
|
||||
kind: 'group',
|
||||
id: 'game-info',
|
||||
label: '게임정보',
|
||||
items: [
|
||||
{ kind: 'link', id: 'nation-list', label: '세력일람', to: '/nation-list', newTab: true },
|
||||
{ kind: 'link', id: 'general-list', label: '장수일람', to: '/general-list', newTab: true },
|
||||
{ kind: 'link', id: 'best-general', label: '명장일람', to: '/best-general', newTab: true },
|
||||
{ kind: 'divider', id: 'game-info-divider' },
|
||||
{ kind: 'link', id: 'hall-of-fame', label: '명예의전당', to: '/hall-of-fame', newTab: true },
|
||||
{ kind: 'link', id: 'dynasty', label: '왕조일람', to: '/dynasty', newTab: true },
|
||||
],
|
||||
},
|
||||
{ kind: 'link', id: 'yearbook', label: '연감', to: '/yearbook', newTab: true },
|
||||
{
|
||||
kind: 'split',
|
||||
id: 'boards',
|
||||
main: {
|
||||
kind: 'link',
|
||||
id: 'board-community',
|
||||
label: '게시판',
|
||||
href: import.meta.env.VITE_BOARD_COMMUNITY_URL?.trim() || '/xe/community',
|
||||
newTab: true,
|
||||
},
|
||||
items: [
|
||||
configuredExternalLink(
|
||||
'board-request',
|
||||
'건의/제안',
|
||||
import.meta.env.VITE_BOARD_REQUEST_URL,
|
||||
'건의/제안 게시판 URL이 설정되지 않았습니다.'
|
||||
),
|
||||
configuredExternalLink(
|
||||
'board-tip',
|
||||
'팁/강좌',
|
||||
import.meta.env.VITE_BOARD_TIP_URL,
|
||||
'팁/강좌 게시판 URL이 설정되지 않았습니다.'
|
||||
),
|
||||
{ kind: 'divider', id: 'board-divider' },
|
||||
configuredExternalLink(
|
||||
'board-patch',
|
||||
'패치 내역',
|
||||
import.meta.env.VITE_BOARD_PATCH_URL,
|
||||
'패치 내역 URL이 설정되지 않았습니다.'
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'split',
|
||||
id: 'open-chat',
|
||||
main: configuredExternalLink(
|
||||
'official-chat',
|
||||
'공식 오픈 톡',
|
||||
import.meta.env.VITE_OFFICIAL_CHAT_URL,
|
||||
'공식 오픈톡 URL이 설정되지 않았습니다.'
|
||||
),
|
||||
items: [
|
||||
configuredExternalLink(
|
||||
'casual-chat',
|
||||
'잡담 오픈 톡',
|
||||
import.meta.env.VITE_CASUAL_CHAT_URL,
|
||||
'잡담 오픈톡 URL이 설정되지 않았습니다.'
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'link',
|
||||
id: 'battle-simulator',
|
||||
label: '전투 시뮬레이터',
|
||||
to: '/battle-simulator',
|
||||
newTab: true,
|
||||
},
|
||||
{
|
||||
kind: 'group',
|
||||
id: 'other-info',
|
||||
label: '기타 정보',
|
||||
items: [
|
||||
{ kind: 'link', id: 'traffic', label: '접속량정보', to: '/traffic', newTab: true },
|
||||
...(npcMode > 0
|
||||
? [{ kind: 'link', id: 'npc-list', label: '빙의일람', to: '/npc-list', newTab: true } as const]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{ kind: 'link', id: 'survey', label: '설문조사', to: '/survey', newTab: true },
|
||||
];
|
||||
const isVisible = (link: MainNavigationLink, npcMode: number): boolean =>
|
||||
link.showWhen !== 'npc-enabled' || npcMode > 0;
|
||||
|
||||
export const buildGlobalNavigation = (
|
||||
npcMode: number,
|
||||
source: RuntimeNavigationEntry[] = defaultGlobalNavigation
|
||||
): MainNavigationEntry[] =>
|
||||
source.flatMap((entry): MainNavigationEntry[] => {
|
||||
if (entry.kind === 'link') return isVisible(entry, npcMode) ? [entry] : [];
|
||||
const items = entry.items.filter(
|
||||
(item): item is MainNavigationLink | MainNavigationDivider =>
|
||||
item.kind === 'divider' || isVisible(item, npcMode)
|
||||
);
|
||||
if (entry.kind === 'group') return items.length > 0 ? [{ ...entry, items }] : [];
|
||||
if (!isVisible(entry.main, npcMode)) return [];
|
||||
return items.length > 0 ? [{ ...entry, items }] : [entry.main];
|
||||
});
|
||||
|
||||
export const nationNavigation: MainNavigationEntry[] = [
|
||||
{
|
||||
@@ -336,7 +249,7 @@ export const quickNavigation: Array<QuickNavigationItem | MainNavigationDivider>
|
||||
{ id: 'diplomacy-message', label: '외교', tab: 'messages', selector: '[data-message-type="diplomacy"]' },
|
||||
];
|
||||
|
||||
export const isNavigationConfigured = (link: MainNavigationLink): boolean => Boolean(link.to || link.href);
|
||||
export const isNavigationConfigured = (link: MainNavigationLink): boolean => Boolean(link.to || link.href || link.action);
|
||||
|
||||
export const isNationNavigationEnabled = (link: MainNavigationLink, access: NationNavigationAccess): boolean => {
|
||||
const rule = link.access ?? 'always';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { formatServerDateTime } from '@sammo-ts/common';
|
||||
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
@@ -16,7 +17,12 @@ import MainFrontStatus from '../components/main/MainFrontStatus.vue';
|
||||
import MainGlobalMenu from '../components/main/MainGlobalMenu.vue';
|
||||
import MainNationMenu from '../components/main/MainNationMenu.vue';
|
||||
import MainMobileBottomBar from '../components/main/MainMobileBottomBar.vue';
|
||||
import type { QuickNavigationItem } from '../components/main/mainNavigation';
|
||||
import {
|
||||
defaultGlobalNavigation,
|
||||
type MainNavigationEntry,
|
||||
type MainNavigationLink,
|
||||
type QuickNavigationItem,
|
||||
} from '../components/main/mainNavigation';
|
||||
import { formatLog } from '../utils/formatLog';
|
||||
import { useSessionStore } from '../stores/session';
|
||||
import { useMainDashboardStore } from '../stores/mainDashboard';
|
||||
@@ -30,6 +36,9 @@ const { info: showInfoToast } = useGameFeedback();
|
||||
const isMobile = useMediaQuery('(max-width: 939.98px)');
|
||||
|
||||
const npcMode = ref(0);
|
||||
const globalNavigation = ref<MainNavigationEntry[]>(defaultGlobalNavigation);
|
||||
const versionDialog = ref<HTMLDialogElement | null>(null);
|
||||
const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation');
|
||||
|
||||
const {
|
||||
loading,
|
||||
@@ -95,6 +104,17 @@ onUnmounted(() => {
|
||||
|
||||
onMounted(() => {
|
||||
dashboard.startRealtime();
|
||||
void fetch(navigationUrl, { headers: { Accept: 'application/json' } })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`메뉴 설정 조회 실패: HTTP ${response.status}`);
|
||||
return (await response.json()) as RuntimeNavigationConfig;
|
||||
})
|
||||
.then((config) => {
|
||||
globalNavigation.value = config.game.items;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn('운영 메뉴 설정을 불러오지 못해 기본 메뉴를 사용합니다.', error);
|
||||
});
|
||||
});
|
||||
|
||||
const shiftGeneralTurns = (amount: number) => {
|
||||
@@ -133,6 +153,10 @@ const moveQuick = (item: QuickNavigationItem) => {
|
||||
document.querySelector(item.selector)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
const handleNavigationAction = (action: NonNullable<MainNavigationLink['action']>) => {
|
||||
if (action === 'show-version') versionDialog.value?.showModal();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [session.isReady, session.hasGeneral],
|
||||
([ready, hasGeneral]) => {
|
||||
@@ -146,7 +170,13 @@ watch(
|
||||
|
||||
<template>
|
||||
<main class="game-shell main-page">
|
||||
<MainGlobalMenu data-menu-position="top" :npc-mode="npcMode" :vote-active="voteActive" />
|
||||
<MainGlobalMenu
|
||||
data-menu-position="top"
|
||||
:npc-mode="npcMode"
|
||||
:vote-active="voteActive"
|
||||
:entries="globalNavigation"
|
||||
@action="handleNavigationAction"
|
||||
/>
|
||||
|
||||
<header class="game-shell__header">
|
||||
<h1 class="game-shell__title">
|
||||
@@ -308,6 +338,8 @@ watch(
|
||||
data-menu-position="middle"
|
||||
:npc-mode="npcMode"
|
||||
:vote-active="voteActive"
|
||||
:entries="globalNavigation"
|
||||
@action="handleNavigationAction"
|
||||
/>
|
||||
|
||||
<div class="mobile-panel">
|
||||
@@ -421,6 +453,8 @@ watch(
|
||||
data-menu-position="middle"
|
||||
:npc-mode="npcMode"
|
||||
:vote-active="voteActive"
|
||||
:entries="globalNavigation"
|
||||
@action="handleNavigationAction"
|
||||
/>
|
||||
<MessagePanel
|
||||
class="desktop-message-panel"
|
||||
@@ -449,6 +483,8 @@ watch(
|
||||
data-menu-position="bottom"
|
||||
:npc-mode="npcMode"
|
||||
:vote-active="voteActive"
|
||||
:entries="globalNavigation"
|
||||
@action="handleNavigationAction"
|
||||
/>
|
||||
</main>
|
||||
<div v-if="isMobile" class="main-mobile-bottom-spacer" aria-hidden="true"></div>
|
||||
@@ -460,11 +496,19 @@ watch(
|
||||
:npc-mode="npcMode"
|
||||
:realtime-enabled="realtimeEnabled"
|
||||
:refreshing="refreshing"
|
||||
:entries="globalNavigation"
|
||||
@refresh="requestManualRefresh"
|
||||
@toggle-realtime="dashboard.setRealtimeEnabled(!realtimeEnabled)"
|
||||
@lobby="moveLobby"
|
||||
@quick="moveQuick"
|
||||
@action="handleNavigationAction"
|
||||
/>
|
||||
<dialog ref="versionDialog" class="game-version-dialog" aria-labelledby="game-version-title">
|
||||
<h2 id="game-version-title">게임 정보</h2>
|
||||
<p>{{ lobbyInfo?.scenarioTitle || 'Core2026' }}</p>
|
||||
<p>삼국지 모의전투 Core2026</p>
|
||||
<form method="dialog"><button class="legacy-button legacy-button--navigation" type="submit">닫기</button></form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -475,6 +519,30 @@ button {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.game-version-dialog {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
padding: 18px;
|
||||
background: #202020;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-version-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 65%);
|
||||
}
|
||||
|
||||
.game-version-dialog h2,
|
||||
.game-version-dialog p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.game-version-dialog form {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* Ref's main document does not clip horizontally; the map panel below manages
|
||||
* its own overflow.
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface GatewayApiConfig {
|
||||
orchestratorAdminIntervalMs: number;
|
||||
workspaceRootHint: string;
|
||||
worktreeRoot: string;
|
||||
navigationConfigFile: string | null;
|
||||
defaultNavigationConfigFile: string;
|
||||
}
|
||||
|
||||
export interface GatewayOrchestratorConfig {
|
||||
@@ -70,6 +72,7 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
const publicBaseUrl = env.GATEWAY_PUBLIC_URL ?? kakaoRedirectUri;
|
||||
const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway';
|
||||
const port = parseNumberWithFallback(env.GATEWAY_API_PORT, 13000, 'GATEWAY_API_PORT');
|
||||
const workspaceRootHint = env.GATEWAY_WORKSPACE_ROOT ?? process.cwd();
|
||||
return {
|
||||
host: env.GATEWAY_API_HOST ?? '0.0.0.0',
|
||||
port,
|
||||
@@ -129,9 +132,10 @@ export const resolveGatewayApiConfigFromEnv = (env: NodeJS.ProcessEnv = process.
|
||||
5000,
|
||||
'GATEWAY_ORCHESTRATOR_ADMIN_MS'
|
||||
),
|
||||
workspaceRootHint: env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(),
|
||||
worktreeRoot:
|
||||
env.GATEWAY_WORKTREE_ROOT ?? path.resolve(env.GATEWAY_WORKSPACE_ROOT ?? process.cwd(), '.worktrees'),
|
||||
workspaceRootHint,
|
||||
worktreeRoot: env.GATEWAY_WORKTREE_ROOT ?? path.resolve(workspaceRootHint, '.worktrees'),
|
||||
navigationConfigFile: env.CORE_NAVIGATION_CONFIG_FILE?.trim() || '/srv/data/navigation.json',
|
||||
defaultNavigationConfigFile: path.resolve(workspaceRootHint, 'resources/navigation.json'),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { AdminAuthContext } from './adminAuth.js';
|
||||
import type { PasswordEnvelopeService } from './auth/passwordEnvelope.js';
|
||||
import { createAdminAuditStore, type AdminAuditStore } from './adminAudit.js';
|
||||
import type { UserIconUploadStore } from './account/remoteUserIconStore.js';
|
||||
import path from 'node:path';
|
||||
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
|
||||
|
||||
export interface GatewayApiContext {
|
||||
users: UserRepository;
|
||||
@@ -41,6 +43,7 @@ export interface GatewayApiContext {
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit: AdminAuditStore;
|
||||
adminAuth?: AdminAuthContext;
|
||||
navigationConfig: RuntimeNavigationConfigStore;
|
||||
}
|
||||
|
||||
export const createGatewayApiContext = (options: {
|
||||
@@ -67,6 +70,7 @@ export const createGatewayApiContext = (options: {
|
||||
requestHeaders?: Record<string, string | string[] | undefined>;
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit?: AdminAuditStore;
|
||||
navigationConfig?: RuntimeNavigationConfigStore;
|
||||
}): GatewayApiContext => ({
|
||||
users: options.users,
|
||||
sessions: options.sessions,
|
||||
@@ -91,4 +95,7 @@ export const createGatewayApiContext = (options: {
|
||||
requestHeaders: options.requestHeaders ?? {},
|
||||
prisma: options.prisma,
|
||||
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
|
||||
navigationConfig:
|
||||
options.navigationConfig ??
|
||||
new RuntimeNavigationConfigStore(null, path.resolve(process.cwd(), 'resources/navigation.json')),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zId = z.string().min(1).max(80).regex(/^[a-z0-9][a-z0-9-]*$/u);
|
||||
const zLabel = z.string().min(1).max(80);
|
||||
const zInternalPath = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.refine((value) => value.startsWith('/') && !value.startsWith('//'), '내부 경로는 /로 시작해야 합니다.');
|
||||
const zExternalHref = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.refine(
|
||||
(value) =>
|
||||
(value.startsWith('/') && !value.startsWith('///')) ||
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('http://'),
|
||||
'링크는 /, //, https:// 또는 http://로 시작해야 합니다.'
|
||||
);
|
||||
|
||||
const zNavigationLink = z
|
||||
.object({
|
||||
kind: z.literal('link'),
|
||||
id: zId,
|
||||
label: zLabel,
|
||||
to: zInternalPath.optional(),
|
||||
href: zExternalHref.optional(),
|
||||
action: z.literal('show-version').optional(),
|
||||
newTab: z.boolean().optional(),
|
||||
showWhen: z.enum(['always', 'npc-enabled']).optional(),
|
||||
highlightWhen: z.enum(['nation-betting', 'vote']).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
const destinations = [value.to, value.href, value.action].filter(Boolean);
|
||||
if (destinations.length !== 1) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
message: '메뉴 링크에는 to, href, action 중 하나만 필요합니다.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const zNavigationDivider = z.object({ kind: z.literal('divider'), id: zId }).strict();
|
||||
const zNavigationChild = z.union([zNavigationLink, zNavigationDivider]);
|
||||
const zNavigationEntry = z.union([
|
||||
zNavigationLink,
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('group'),
|
||||
id: zId,
|
||||
label: zLabel,
|
||||
items: z.array(zNavigationChild).min(1).max(30),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('split'),
|
||||
id: zId,
|
||||
main: zNavigationLink,
|
||||
items: z.array(zNavigationChild).min(1).max(30),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export const zRuntimeNavigationConfig: z.ZodType<RuntimeNavigationConfig> = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
gateway: z
|
||||
.object({
|
||||
brand: z.object({ label: zLabel, to: zInternalPath }).strict(),
|
||||
items: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: zId,
|
||||
label: zLabel,
|
||||
href: zExternalHref,
|
||||
newTab: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(30),
|
||||
})
|
||||
.strict(),
|
||||
game: z.object({ items: z.array(zNavigationEntry).min(1).max(20) }).strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export class RuntimeNavigationConfigStore {
|
||||
constructor(
|
||||
private readonly overridePath: string | null,
|
||||
private readonly defaultPath: string
|
||||
) {}
|
||||
|
||||
async get(): Promise<RuntimeNavigationConfig> {
|
||||
const configPath = await this.resolveConfigPath();
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(await fs.readFile(configPath, 'utf8')) as unknown;
|
||||
} catch (error) {
|
||||
throw new Error(`메뉴 설정 파일을 읽지 못했습니다: ${configPath}`, { cause: error });
|
||||
}
|
||||
const parsed = zRuntimeNavigationConfig.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new Error(`메뉴 설정 파일이 올바르지 않습니다: ${configPath}: ${parsed.error.message}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
private async resolveConfigPath(): Promise<string> {
|
||||
if (!this.overridePath) return this.defaultPath;
|
||||
try {
|
||||
await fs.access(this.overridePath);
|
||||
return this.overridePath;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && 'code' in error ? error.code : undefined;
|
||||
if (code === 'ENOENT') return this.defaultPath;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,9 @@ const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verif
|
||||
};
|
||||
|
||||
export const appRouter = router({
|
||||
navigation: router({
|
||||
get: procedure.query(({ ctx }) => ctx.navigationConfig.get()),
|
||||
}),
|
||||
health: router({
|
||||
ping: procedure.query(() => ({
|
||||
ok: true,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { registerProfileStatusInternalRoute } from './lobby/profileStatusInterna
|
||||
import { installGatewayShutdownController } from './lifecycle/shutdownController.js';
|
||||
import { RemoteUserIconStore } from './account/remoteUserIconStore.js';
|
||||
import { gatewayFastifyRouterOptions } from './fastifyOptions.js';
|
||||
import { RuntimeNavigationConfigStore } from './navigation/runtimeNavigationConfig.js';
|
||||
|
||||
export const createGatewayApiServer = async () => {
|
||||
const config = resolveGatewayApiConfigFromEnv();
|
||||
@@ -80,6 +81,10 @@ export const createGatewayApiServer = async () => {
|
||||
);
|
||||
const releases = createGatewayReleaseRepository(postgres.prisma as GatewayPrismaClient);
|
||||
const profileStatus = new RepositoryProfileStatusService(profiles, orchestrator);
|
||||
const navigationConfig = new RuntimeNavigationConfigStore(
|
||||
config.navigationConfigFile,
|
||||
config.defaultNavigationConfigFile
|
||||
);
|
||||
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
@@ -104,6 +109,10 @@ export const createGatewayApiServer = async () => {
|
||||
profiles,
|
||||
secret: config.gameTokenSecret,
|
||||
});
|
||||
app.get(config.trpcPath.replace(/\/trpc\/?$/u, '/navigation'), async (_request, reply) => {
|
||||
void reply.header('Cache-Control', 'no-store');
|
||||
return navigationConfig.get();
|
||||
});
|
||||
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: config.trpcPath,
|
||||
@@ -134,6 +143,7 @@ export const createGatewayApiServer = async () => {
|
||||
profileStatus,
|
||||
requestHeaders: req.headers,
|
||||
prisma: postgres.prisma as GatewayPrismaClient,
|
||||
navigationConfig,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -789,6 +789,30 @@ describe('admin operation API', () => {
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('stores event season zero as the next season number', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
|
||||
);
|
||||
|
||||
await harness.caller.admin.profiles.updateMeta({
|
||||
profileName: 'che:2',
|
||||
patch: { nextSeasonIdx: 0 },
|
||||
reason: 'prepare event season',
|
||||
});
|
||||
expect(harness.updatedMetas.at(-1)).toMatchObject({ nextSeasonIdx: 0 });
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.updateMeta({
|
||||
profileName: 'che:2',
|
||||
patch: { nextSeasonIdx: -1 },
|
||||
reason: 'reject negative season',
|
||||
})
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('does not let a scenario-only operator combine a Git update with reset', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { RuntimeNavigationConfigStore } from '../src/navigation/runtimeNavigationConfig.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
const createTemporaryDirectory = async (): Promise<string> => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'sammo-navigation-'));
|
||||
temporaryDirectories.push(directory);
|
||||
return directory;
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true })));
|
||||
});
|
||||
|
||||
describe('RuntimeNavigationConfigStore', () => {
|
||||
it('운영 override가 없으면 저장소 기본 메뉴를 읽는다', async () => {
|
||||
const store = new RuntimeNavigationConfigStore(
|
||||
'/definitely-missing/navigation.json',
|
||||
path.resolve(import.meta.dirname, '../../../resources/navigation.json')
|
||||
);
|
||||
|
||||
const config = await store.get();
|
||||
|
||||
expect(config.gateway.items.map((item) => item.label)).toEqual([
|
||||
'공지사항',
|
||||
'커뮤니티',
|
||||
'건의/제안/개발',
|
||||
'신고/문의',
|
||||
'자주 묻는 질문',
|
||||
'패치 내역',
|
||||
'Git Repo.',
|
||||
'위키',
|
||||
'공식 오픈 톡',
|
||||
'잡담 오픈 톡',
|
||||
]);
|
||||
expect(config.game.items.map((item) => (item.kind === 'split' ? item.main.label : item.label))).toEqual([
|
||||
'천통국 베팅',
|
||||
'세력일람',
|
||||
'장수일람',
|
||||
'명장일람',
|
||||
'연감',
|
||||
'게임 정보',
|
||||
'커뮤니티',
|
||||
'설문조사',
|
||||
]);
|
||||
});
|
||||
|
||||
it('프로세스를 재시작하지 않아도 운영 JSON 수정이 다음 조회에 반영된다', async () => {
|
||||
const directory = await createTemporaryDirectory();
|
||||
const overridePath = path.join(directory, 'navigation.json');
|
||||
const defaultPath = path.resolve(import.meta.dirname, '../../../resources/navigation.json');
|
||||
const raw = JSON.parse(await fs.readFile(defaultPath, 'utf8')) as {
|
||||
gateway: { items: Array<{ label: string }> };
|
||||
};
|
||||
await fs.writeFile(overridePath, JSON.stringify(raw));
|
||||
const store = new RuntimeNavigationConfigStore(overridePath, defaultPath);
|
||||
|
||||
expect((await store.get()).gateway.items[0]?.label).toBe('공지사항');
|
||||
raw.gateway.items[0]!.label = '운영 공지';
|
||||
await fs.writeFile(overridePath, JSON.stringify(raw));
|
||||
expect((await store.get()).gateway.items[0]?.label).toBe('운영 공지');
|
||||
});
|
||||
|
||||
it('실행 가능한 스크립트 URL과 목적지가 없는 링크를 거부한다', async () => {
|
||||
const directory = await createTemporaryDirectory();
|
||||
const overridePath = path.join(directory, 'navigation.json');
|
||||
const invalid = {
|
||||
version: 1,
|
||||
gateway: {
|
||||
brand: { label: '삼국지 모의전투 HiDCHe', to: '/' },
|
||||
items: [{ id: 'unsafe', label: '위험', href: 'javascript:alert(1)' }],
|
||||
},
|
||||
game: { items: [{ kind: 'link', id: 'empty', label: '빈 링크' }] },
|
||||
};
|
||||
await fs.writeFile(overridePath, JSON.stringify(invalid));
|
||||
const store = new RuntimeNavigationConfigStore(overridePath, overridePath);
|
||||
|
||||
await expect(store.get()).rejects.toThrow('메뉴 설정 파일이 올바르지 않습니다');
|
||||
});
|
||||
});
|
||||
@@ -176,7 +176,7 @@ test('desktop administrator sidebar follows the navbar away and then sticks to t
|
||||
backgroundColor: string;
|
||||
}> = [];
|
||||
|
||||
for (const scrollY of [0, 20, 55, 56, 120]) {
|
||||
for (const scrollY of [0, 20, 75, 76, 140]) {
|
||||
await page.evaluate((top) => window.scrollTo(0, top), scrollY);
|
||||
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(scrollY);
|
||||
|
||||
@@ -192,16 +192,16 @@ test('desktop administrator sidebar follows the navbar away and then sticks to t
|
||||
backgroundColor: style.backgroundColor,
|
||||
};
|
||||
});
|
||||
expect(geometry.top).toBeCloseTo(Math.max(0, 56 - scrollY), 0);
|
||||
expect(geometry.top).toBeCloseTo(Math.max(0, 76 - scrollY), 0);
|
||||
expect(geometry.position).toBe('sticky');
|
||||
expect(geometry.backgroundColor).toBe('rgb(17, 17, 19)');
|
||||
measurements.push({ scrollY, ...geometry });
|
||||
|
||||
if (scrollY >= 56) {
|
||||
if (scrollY >= 76) {
|
||||
expect(geometry.bottom).toBeCloseTo(geometry.viewportHeight, 0);
|
||||
}
|
||||
|
||||
if (scrollY === 20 || scrollY === 56) {
|
||||
if (scrollY === 20 || scrollY === 76) {
|
||||
await page.screenshot({ path: testInfo.outputPath(`admin-sidebar-scroll-${scrollY}.png`) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,18 @@ type LobbyFixtureOptions = {
|
||||
isUnited?: number;
|
||||
starttime?: string;
|
||||
opentime?: string;
|
||||
preopenAt?: string;
|
||||
turntime?: string;
|
||||
turnTerm?: number;
|
||||
scenarioTitle?: string;
|
||||
npcMode?: number;
|
||||
defaultStatTotal?: number;
|
||||
korName?: string;
|
||||
otherTextInfo?: string;
|
||||
autorunUser?: {
|
||||
limitMinutes: number;
|
||||
options: string[];
|
||||
} | null;
|
||||
lobbyBundleFailures?: number;
|
||||
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
|
||||
includeStoppedProfile?: boolean;
|
||||
@@ -78,7 +89,15 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
||||
isUnited = 0,
|
||||
starttime = '2026-07-30 00:00:00',
|
||||
opentime = '2026-07-30 00:00:00',
|
||||
preopenAt = '',
|
||||
turntime = '2026-07-30 00:05:00',
|
||||
turnTerm = 5,
|
||||
scenarioTitle = '',
|
||||
npcMode = 0,
|
||||
defaultStatTotal = 165,
|
||||
korName = 'hwe',
|
||||
otherTextInfo = '',
|
||||
autorunUser = null,
|
||||
lobbyBundleFailures = 0,
|
||||
profileStatus = 'RUNNING',
|
||||
includeStoppedProfile = false,
|
||||
@@ -134,7 +153,7 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
||||
battleSimRunning: true,
|
||||
tournamentRunning: true,
|
||||
},
|
||||
korName: 'hwe',
|
||||
korName,
|
||||
color: '#ffffff',
|
||||
localAccountPolicy: {
|
||||
accessAllowed: true,
|
||||
@@ -223,12 +242,17 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
||||
maxUserCnt,
|
||||
npcCnt: 0,
|
||||
nationCnt,
|
||||
turnTerm: 5,
|
||||
turnTerm,
|
||||
fictionMode: '가상',
|
||||
starttime,
|
||||
opentime,
|
||||
preopenAt,
|
||||
turntime,
|
||||
otherTextInfo: '',
|
||||
otherTextInfo,
|
||||
scenarioTitle,
|
||||
npcMode,
|
||||
defaultStatTotal,
|
||||
autorunUser,
|
||||
isUnited,
|
||||
selectionPoolEnabled,
|
||||
npcPossessionEnabled,
|
||||
@@ -279,6 +303,118 @@ test('exchanges the gateway token before loading authenticated lobby general dat
|
||||
});
|
||||
});
|
||||
|
||||
test('copies the complete preopen announcement and reveals autorun details without changing layout', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await installFixture(page, {
|
||||
roles: ['superuser'],
|
||||
kakaoVerified: false,
|
||||
profileStatus: 'PREOPEN',
|
||||
korName: '훼',
|
||||
specialAccess: {
|
||||
kind: 'OPERATOR',
|
||||
grantId: null,
|
||||
expiresAt: null,
|
||||
allowsGeneralCreation: true,
|
||||
},
|
||||
preopenAt: '2026-08-19 22:00:00',
|
||||
opentime: '2026-08-19 23:00:00',
|
||||
starttime: '2026-08-19 23:00:00',
|
||||
turnTerm: 1,
|
||||
scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)',
|
||||
npcMode: 0,
|
||||
defaultStatTotal: 310,
|
||||
autorunUser: {
|
||||
limitMinutes: 1_440,
|
||||
options: ['develop', 'warp', 'recruit_high', 'train', 'battle', 'chief'],
|
||||
},
|
||||
});
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
|
||||
await page.goto('lobby');
|
||||
const row = page.locator('tbody tr').filter({ hasText: '훼섭' });
|
||||
const serverName = row.locator('.profile-server-cell .font-bold');
|
||||
const settings = row.locator('.profile-announcement-settings');
|
||||
const autorun = row.locator('.copyable-autorun');
|
||||
const detail = row.locator('.copyable-autorun-detail');
|
||||
await expect(row.getByTestId('profile-preopen-at')).toHaveText('- 가오픈 일시 : 2026-08-19 22:00:00 -');
|
||||
await expect(row.getByTestId('profile-open-at')).toHaveText('- 오픈 일시 : 2026-08-19 23:00:00 -');
|
||||
await expect(row.getByTestId('profile-scenario-announcement')).toHaveText(
|
||||
'【가상모드27-b】 아시아 명장전(비급) 1분 턴 서버'
|
||||
);
|
||||
const settingsText = (await settings.textContent())?.replace(/\s+/g, ' ').trim();
|
||||
expect(settingsText).toBe(
|
||||
'(상성 설정:가상), (빙의 여부:불가), (최대 스탯:310), ' +
|
||||
'(기타 설정:자율행동[내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효])'
|
||||
);
|
||||
await expect(detail).toHaveCSS('font-size', '0px');
|
||||
await expect(detail).toHaveCSS('color', 'rgba(0, 0, 0, 0)');
|
||||
await expect(page.getByText('특수 접근 · OPERATOR')).toHaveCount(0);
|
||||
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
|
||||
|
||||
const baseGeometry = await row.evaluate((element) => ({
|
||||
row: element.getBoundingClientRect().toJSON(),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
await autorun.hover();
|
||||
await expect(detail).toBeVisible();
|
||||
await expect(detail).toContainText('내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효');
|
||||
const hoverGeometry = await row.evaluate((element) => ({
|
||||
row: element.getBoundingClientRect().toJSON(),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(hoverGeometry).toEqual(baseGeometry);
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-autorun-announcement-hover.png'), fullPage: true });
|
||||
|
||||
await autorun.focus();
|
||||
await expect(autorun).toBeFocused();
|
||||
await expect(detail).toBeVisible();
|
||||
await expect(autorun).toHaveCSS('outline-width', '2px');
|
||||
|
||||
await page.mouse.click(8, 8);
|
||||
const start = await serverName.boundingBox();
|
||||
const end = await settings.boundingBox();
|
||||
if (!start || !end) throw new Error('expected announcement selection geometry');
|
||||
await page.mouse.move(start.x + 1, start.y + start.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x + end.width - 1, end.y + end.height / 2, { steps: 24 });
|
||||
await page.mouse.up();
|
||||
const selectedText = await page.evaluate(() => window.getSelection()?.toString() ?? '');
|
||||
const compactSelection = selectedText.replace(/\s+/g, ' ').trim();
|
||||
expect(compactSelection).toContain(
|
||||
'훼섭 - 가오픈 일시 : 2026-08-19 22:00:00 - - 오픈 일시 : 2026-08-19 23:00:00 - ' +
|
||||
'【가상모드27-b】 아시아 명장전(비급) 1분 턴 서버 ' +
|
||||
'(상성 설정:가상), (빙의 여부:불가), (최대 스탯:310), ' +
|
||||
'(기타 설정:자율행동[내정, 순간이동, 모병, 훈련/사기진작, 출병, 사령턴, 24시간 유효])'
|
||||
);
|
||||
expect(compactSelection).not.toContain('OPERATOR');
|
||||
|
||||
await page.evaluate(() => window.getSelection()?.removeAllRanges());
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await autorun.scrollIntoViewIfNeeded();
|
||||
const mobileBase = await row.evaluate((element) => ({
|
||||
row: element.getBoundingClientRect().toJSON(),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
await autorun.hover();
|
||||
await expect(detail).toBeVisible();
|
||||
const mobileHover = await row.evaluate((element) => {
|
||||
const tooltip = element.querySelector('.copyable-autorun-detail');
|
||||
if (!tooltip) throw new Error('expected autorun tooltip');
|
||||
return {
|
||||
row: element.getBoundingClientRect().toJSON(),
|
||||
tooltip: tooltip.getBoundingClientRect().toJSON(),
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: window.innerWidth,
|
||||
};
|
||||
});
|
||||
expect(mobileHover.row).toEqual(mobileBase.row);
|
||||
expect(mobileHover.documentWidth).toBe(mobileHover.viewportWidth);
|
||||
expect(mobileHover.tooltip.left).toBeGreaterThanOrEqual(8);
|
||||
expect(mobileHover.tooltip.right).toBeLessThanOrEqual(mobileHover.viewportWidth - 8);
|
||||
await page.screenshot({ path: testInfo.outputPath('gateway-autorun-announcement-mobile.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('loads and labels a PAUSED profile whose runtime remains available', async ({ page }, testInfo) => {
|
||||
const gameOperations = await installFixture(page, { profileStatus: 'PAUSED' });
|
||||
await page.setViewportSize({ width: 1365, height: 900 });
|
||||
@@ -484,7 +620,7 @@ test('hides the Kakao verification banner for operator special access', async ({
|
||||
});
|
||||
|
||||
await page.goto('lobby');
|
||||
await expect(page.getByText('특수 접근 · OPERATOR')).toBeVisible();
|
||||
await expect(page.getByText('특수 접근 · OPERATOR')).toHaveCount(0);
|
||||
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -502,7 +638,7 @@ test('hides the Kakao verification banner when a grant removes the remaining ver
|
||||
});
|
||||
|
||||
await page.goto('lobby');
|
||||
await expect(page.getByText('특수 접근 · RECOVERY')).toBeVisible();
|
||||
await expect(page.getByText('특수 접근 · RECOVERY')).toHaveCount(0);
|
||||
await expect(page.getByText('카카오 인증이 필요합니다.')).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const port = Number(process.env.PLAYWRIGHT_GATEWAY_FRONTEND_PORT ?? 15130);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
@@ -19,6 +20,7 @@ export default defineConfig({
|
||||
'kakao-otp.spec.ts',
|
||||
'kakao-account-recovery.spec.ts',
|
||||
'public-map-tabs.spec.ts',
|
||||
'runtime-navigation.spec.ts',
|
||||
],
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
@@ -29,7 +31,7 @@ export default defineConfig({
|
||||
reporter: [['list']],
|
||||
outputDir: resolve(repositoryRoot, 'test-results/server-operations'),
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:15130/gateway/',
|
||||
baseURL: `http://127.0.0.1:${port}/gateway/`,
|
||||
...devices['Desktop Chrome'],
|
||||
deviceScaleFactor: 1,
|
||||
colorScheme: 'dark',
|
||||
@@ -38,9 +40,9 @@ export default defineConfig({
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
"export VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc'; pnpm --filter @sammo-ts/gateway-frontend build && pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port 15130",
|
||||
`export VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc'; pnpm --filter @sammo-ts/gateway-frontend build && pnpm --filter @sammo-ts/gateway-frontend preview --host 127.0.0.1 --port ${port}`,
|
||||
cwd: repositoryRoot,
|
||||
url: 'http://127.0.0.1:15130/gateway/',
|
||||
url: `http://127.0.0.1:${port}/gateway/`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const defaultNavigation = JSON.parse(
|
||||
await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8')
|
||||
) as {
|
||||
gateway: { items: Array<{ id: string; label: string }> };
|
||||
};
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
const installGatewayFixture = async (page: Page, navigation: unknown = defaultNavigation) => {
|
||||
await page.route('**/gateway/api/navigation', async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(navigation) });
|
||||
});
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const operations = operationNames(route);
|
||||
const results = operations.map((operation) => {
|
||||
if (operation === 'navigation.get') return response(navigation);
|
||||
if (operation === 'me' || operation === 'lobby.notice') return response(null);
|
||||
if (operation === 'lobby.profiles') return response([]);
|
||||
return response({ ok: true });
|
||||
});
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(operations.length === 1 ? results[0] : results),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
test('Gateway 상단 메뉴가 PHP 항목과 desktop geometry를 따른다', async ({ page }) => {
|
||||
await installGatewayFixture(page);
|
||||
await page.setViewportSize({ width: 1365, height: 900 });
|
||||
await page.goto('./');
|
||||
|
||||
const navigation = page.locator('#gateway-navigation');
|
||||
await expect(navigation.locator('a')).toHaveText(defaultNavigation.gateway.items.map((item) => item.label));
|
||||
await expect(page.locator('.gateway-navbar')).toHaveCSS('height', '76px');
|
||||
await expect(page.locator('.gateway-navbar')).toHaveCSS('padding', '16px 0px');
|
||||
await expect(navigation.locator('a').first()).toHaveCSS('font-size', '16px');
|
||||
await expect(navigation.locator('a').first()).toHaveCSS('padding', '8px');
|
||||
|
||||
await navigation.locator('a').first().hover();
|
||||
await expect(navigation.locator('a').first()).toHaveCSS('color', 'rgb(255, 255, 255)');
|
||||
});
|
||||
|
||||
test('Gateway 모바일 접이식 메뉴가 PHP 40px 행과 전체 너비를 따른다', async ({ page }) => {
|
||||
await installGatewayFixture(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto('./');
|
||||
|
||||
await page.getByRole('button', { name: '메뉴 열기' }).click();
|
||||
const links = page.locator('#gateway-navigation a');
|
||||
await expect(links).toHaveCount(10);
|
||||
const first = await links.first().boundingBox();
|
||||
expect(first).toMatchObject({ x: 1, y: 56, width: 388, height: 40 });
|
||||
await links.first().focus();
|
||||
await expect(links.first()).toBeFocused();
|
||||
await expect(links.first()).toHaveCSS('color', 'rgb(255, 255, 255)');
|
||||
});
|
||||
|
||||
test('JSON 응답을 바꾸면 frontend 재빌드 없이 다음 로드에 반영된다', async ({ page }) => {
|
||||
const changed = structuredClone(defaultNavigation);
|
||||
changed.gateway.items[0]!.label = '운영 공지';
|
||||
await installGatewayFixture(page, changed);
|
||||
await page.goto('./');
|
||||
|
||||
await expect(page.locator('[data-navigation-id="notice"]')).toHaveText('운영 공지');
|
||||
});
|
||||
@@ -1040,6 +1040,33 @@ test('edits server reset defaults through profile metadata settings', async ({ p
|
||||
expect(request).toContain('"npcMode":2');
|
||||
});
|
||||
|
||||
test('stores event season zero from server metadata settings', async ({ page }) => {
|
||||
const state: FixtureState = { operations: [], gatewayOperations: [], runtimeRunning: true, requestBodies: [] };
|
||||
await installFixture(page, state);
|
||||
|
||||
await page.goto('admin/servers/che%3Adefault');
|
||||
const nextSeasonInput = page.getByTestId('next-season-idx');
|
||||
await nextSeasonInput.fill('0');
|
||||
await expect(nextSeasonInput).toHaveValue('0');
|
||||
expect(
|
||||
await nextSeasonInput.evaluate((element: HTMLInputElement) => ({
|
||||
valid: element.validity.valid,
|
||||
valueAsNumber: element.valueAsNumber,
|
||||
}))
|
||||
).toEqual({ valid: true, valueAsNumber: 0 });
|
||||
await page.getByPlaceholder('변경 사유 (필수)').fill('prepare event season');
|
||||
await page.getByRole('button', { name: '메타 저장' }).click();
|
||||
|
||||
await expect
|
||||
.poll(() => state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta'))
|
||||
.toBeTruthy();
|
||||
const request = JSON.stringify(
|
||||
state.requestBodies.find((entry) => entry.operation === 'admin.profiles.updateMeta')?.body
|
||||
);
|
||||
expect(request).toContain('"nextSeasonIdx":0');
|
||||
await expect(page.getByTestId('action-toast').filter({ hasText: '메타 저장 완료' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows a dismissible error toast when profile metadata persistence fails', async ({ page }, testInfo) => {
|
||||
const state: FixtureState = {
|
||||
operations: [],
|
||||
|
||||
@@ -207,10 +207,10 @@ onMounted(async () => {
|
||||
.admin-shell {
|
||||
display: grid;
|
||||
width: min(1480px, 100%);
|
||||
min-height: calc(100vh - 56px);
|
||||
min-height: calc(100vh - 76px);
|
||||
margin: 0 auto;
|
||||
grid-template-columns: 244px minmax(0, 1fr);
|
||||
padding-top: 56px;
|
||||
padding-top: 76px;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
@@ -392,13 +392,13 @@ onMounted(async () => {
|
||||
@media (max-width: 860px) {
|
||||
.admin-shell {
|
||||
display: block;
|
||||
padding-top: 72px;
|
||||
padding-top: 92px;
|
||||
}
|
||||
|
||||
.admin-menu-button {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: 72px;
|
||||
top: 92px;
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
display: flex;
|
||||
@@ -416,7 +416,7 @@ onMounted(async () => {
|
||||
.admin-sidebar {
|
||||
position: absolute;
|
||||
z-index: 19;
|
||||
top: 124px;
|
||||
top: 144px;
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
display: none;
|
||||
|
||||
@@ -1,15 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import defaultNavigationJson from '../../../../resources/navigation.json';
|
||||
|
||||
const menuOpen = ref(false);
|
||||
const appBase = import.meta.env.BASE_URL;
|
||||
const defaultNavigation = defaultNavigationJson as RuntimeNavigationConfig;
|
||||
const navigation = ref(defaultNavigation.gateway);
|
||||
const navigationUrl = (import.meta.env.VITE_GATEWAY_API_URL ?? '/api/trpc').replace(/\/trpc\/?$/u, '/navigation');
|
||||
|
||||
onMounted(() => {
|
||||
void fetch(navigationUrl, { headers: { Accept: 'application/json' } })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) throw new Error(`메뉴 설정 조회 실패: HTTP ${response.status}`);
|
||||
return (await response.json()) as RuntimeNavigationConfig;
|
||||
})
|
||||
.then((config) => {
|
||||
navigation.value = config.gateway;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.warn('운영 메뉴 설정을 불러오지 못해 기본 메뉴를 사용합니다.', error);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="gateway-layout">
|
||||
<header class="gateway-navbar">
|
||||
<div class="navbar-inner">
|
||||
<RouterLink class="navbar-brand" to="/">삼국지 모의전투 HiDCHe</RouterLink>
|
||||
<RouterLink class="navbar-brand" :to="navigation.brand.to">{{ navigation.brand.label }}</RouterLink>
|
||||
<button
|
||||
class="navbar-toggler"
|
||||
type="button"
|
||||
@@ -21,12 +40,16 @@ const appBase = import.meta.env.BASE_URL;
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
<nav id="gateway-navigation" :class="{ open: menuOpen }">
|
||||
<a href="/bbs/board" target="_blank" rel="noreferrer">삼모게시판</a>
|
||||
<a href="/bbs/tip" target="_blank" rel="noreferrer">팁/강좌</a>
|
||||
<a href="/bbs/news" target="_blank" rel="noreferrer">삼국 일보</a>
|
||||
<a href="/bbs/history2" target="_blank" rel="noreferrer">개인 열전</a>
|
||||
<a href="/bbs/history3" target="_blank" rel="noreferrer">국가 열전</a>
|
||||
<a href="/bbs/patch" target="_blank" rel="noreferrer">패치 내역</a>
|
||||
<a
|
||||
v-for="item in navigation.items"
|
||||
:key="item.id"
|
||||
:href="item.href"
|
||||
:target="item.newTab ? '_blank' : undefined"
|
||||
:rel="item.newTab ? 'noopener noreferrer' : undefined"
|
||||
:data-navigation-id="item.id"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -66,14 +89,16 @@ const appBase = import.meta.env.BASE_URL;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
min-height: 56px;
|
||||
border-bottom: 1px solid #222;
|
||||
box-sizing: border-box;
|
||||
height: 76px;
|
||||
border: 0;
|
||||
padding: 16px 0;
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
display: flex;
|
||||
min-height: 56px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
padding: 0 1px;
|
||||
}
|
||||
@@ -90,13 +115,16 @@ const appBase = import.meta.env.BASE_URL;
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
nav a {
|
||||
color: rgb(255 255 255 / 55%);
|
||||
font-size: 14px;
|
||||
padding: 8px;
|
||||
color: rgb(255 255 255 / 60%);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -108,12 +136,12 @@ nav a:focus {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
width: 56px;
|
||||
height: 42px;
|
||||
height: 40px;
|
||||
margin-left: auto;
|
||||
border: 1px solid rgb(255 255 255 / 15%);
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgb(255 255 255 / 10%);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
padding: 8px 12px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.navbar-toggler span {
|
||||
@@ -140,10 +168,9 @@ footer a {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media (max-width: 759px) {
|
||||
@media (max-width: 991.98px) {
|
||||
.navbar-inner {
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 1px;
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
@@ -151,12 +178,17 @@ footer a {
|
||||
}
|
||||
|
||||
nav {
|
||||
position: absolute;
|
||||
top: 56px;
|
||||
right: 1px;
|
||||
left: 1px;
|
||||
display: none;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
padding: 8px 12px;
|
||||
padding: 0;
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
nav.open {
|
||||
@@ -165,7 +197,9 @@ footer a {
|
||||
|
||||
nav a {
|
||||
width: 100%;
|
||||
padding: 7px 0;
|
||||
padding: 8px 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -407,7 +407,7 @@ const profileEdits = ref<
|
||||
color: string;
|
||||
inGameNotice: string;
|
||||
profileImageUrl: string;
|
||||
nextSeasonIdx: string;
|
||||
nextSeasonIdx: string | number;
|
||||
localAccountAccessGraceDays: string;
|
||||
localAccountGeneralCreationGraceDays: string;
|
||||
resetDefaults: ProfileResetDefaults;
|
||||
@@ -770,7 +770,10 @@ const updateProfileMeta = async (profileName: string) => {
|
||||
if (!edit) {
|
||||
return;
|
||||
}
|
||||
const nextSeasonRaw = edit.nextSeasonIdx.trim();
|
||||
// Vue casts non-empty values from type="number" inputs to numbers even when
|
||||
// the buffer was initialized with a string. Normalize both runtime shapes so
|
||||
// event season 0 reaches the metadata mutation instead of throwing on trim().
|
||||
const nextSeasonRaw = String(edit.nextSeasonIdx).trim();
|
||||
const nextSeasonIdx = nextSeasonRaw === '' ? null : Number(nextSeasonRaw);
|
||||
if (nextSeasonIdx !== null && (!Number.isFinite(nextSeasonIdx) || nextSeasonIdx < 0)) {
|
||||
profileActionStatus.value = {
|
||||
@@ -2229,6 +2232,7 @@ onMounted(() => {
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
data-testid="next-season-idx"
|
||||
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
|
||||
placeholder="예: 12"
|
||||
/>
|
||||
|
||||
@@ -106,6 +106,34 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
|
||||
|
||||
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
|
||||
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
|
||||
const formatAnnouncementDate = (value: string | null | undefined): string =>
|
||||
formatServerDateTime(value, { fallback: '-' });
|
||||
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
|
||||
const autorunDetailText = (info: LobbyInfo): string => {
|
||||
const autorun = info.autorunUser;
|
||||
if (!autorun) return '';
|
||||
|
||||
const enabled = new Set(autorun.options);
|
||||
const labels: string[] = [];
|
||||
if (enabled.has('develop')) labels.push('내정');
|
||||
if (enabled.has('warp')) labels.push('순간이동');
|
||||
if (enabled.has('recruit_high')) labels.push('모병');
|
||||
else if (enabled.has('recruit')) labels.push('징병');
|
||||
if (enabled.has('train')) labels.push('훈련/사기진작');
|
||||
if (enabled.has('battle')) labels.push('출병');
|
||||
if (enabled.has('chief')) labels.push('사령턴');
|
||||
|
||||
const limit =
|
||||
autorun.limitMinutes >= 43_200
|
||||
? '항상 유효'
|
||||
: autorun.limitMinutes % 60 === 0
|
||||
? `${autorun.limitMinutes / 60}시간 유효`
|
||||
: `${autorun.limitMinutes}분 유효`;
|
||||
labels.push(limit);
|
||||
return labels.join(', ');
|
||||
};
|
||||
const autorunTooltipId = (profileName: string): string =>
|
||||
`profile-autorun-${profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible;
|
||||
const unavailableProfileText = (profile: LobbyProfile): string => {
|
||||
if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -';
|
||||
@@ -455,13 +483,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
턴 일시정지 · 조회/예약턴 가능
|
||||
</div>
|
||||
<div
|
||||
v-if="profile.localAccountPolicy?.specialAccess"
|
||||
class="mt-2 text-xs text-emerald-300"
|
||||
>
|
||||
특수 접근 · {{ profile.localAccountPolicy.specialAccess.kind }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
v-if="
|
||||
profile.localAccountPolicy?.requiresKakaoVerification &&
|
||||
!profile.localAccountPolicy.canCreateGeneral
|
||||
"
|
||||
@@ -481,27 +503,91 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
<td class="profile-info-cell px-4 py-4 border-r border-zinc-800">
|
||||
<template v-if="profileDetails[profile.profileName]">
|
||||
<div class="space-y-1">
|
||||
<div>
|
||||
서기 {{ profileDetails[profile.profileName]?.year }}년
|
||||
{{ profileDetails[profile.profileName]?.month }}월 (<span
|
||||
class="text-orange-400"
|
||||
>{{ profile.scenario }}</span
|
||||
>)
|
||||
</div>
|
||||
<div class="text-zinc-400">
|
||||
유저 : {{ profileDetails[profile.profileName]?.userCnt }} /
|
||||
{{ profileDetails[profile.profileName]?.maxUserCnt }}명
|
||||
<span class="text-cyan-400 ml-2"
|
||||
>NPC : {{ profileDetails[profile.profileName]?.npcCnt }}명</span
|
||||
<template v-if="profile.status === 'PREOPEN'">
|
||||
<div
|
||||
v-if="profileDetails[profile.profileName]?.preopenAt"
|
||||
data-testid="profile-preopen-at"
|
||||
>
|
||||
<span class="text-green-400 ml-2"
|
||||
>({{ profileDetails[profile.profileName]?.turnTerm }}분 턴
|
||||
서버)</span
|
||||
>
|
||||
</div>
|
||||
<div class="text-xs text-zinc-500">
|
||||
- 가오픈 일시 :
|
||||
{{
|
||||
formatAnnouncementDate(
|
||||
profileDetails[profile.profileName]?.preopenAt
|
||||
)
|
||||
}}
|
||||
-
|
||||
</div>
|
||||
<div data-testid="profile-open-at">
|
||||
- 오픈 일시 :
|
||||
{{
|
||||
formatAnnouncementDate(
|
||||
profileDetails[profile.profileName]?.opentime ||
|
||||
profileDetails[profile.profileName]?.starttime
|
||||
)
|
||||
}}
|
||||
-
|
||||
</div>
|
||||
<div data-testid="profile-scenario-announcement">
|
||||
<span class="text-orange-400">{{
|
||||
profileDetails[profile.profileName]?.scenarioTitle ||
|
||||
profile.scenario
|
||||
}}</span
|
||||
>{{ ' ' }}
|
||||
<span class="text-green-400">
|
||||
{{ profileDetails[profile.profileName]?.turnTerm }}분 턴 서버
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>
|
||||
서기 {{ profileDetails[profile.profileName]?.year }}년
|
||||
{{ profileDetails[profile.profileName]?.month }}월 (<span
|
||||
class="text-orange-400"
|
||||
>{{ profile.scenario }}</span
|
||||
>)
|
||||
</div>
|
||||
<div class="text-zinc-400">
|
||||
유저 : {{ profileDetails[profile.profileName]?.userCnt }} /
|
||||
{{ profileDetails[profile.profileName]?.maxUserCnt }}명
|
||||
<span class="text-cyan-400 ml-2"
|
||||
>NPC : {{ profileDetails[profile.profileName]?.npcCnt }}명</span
|
||||
>
|
||||
<span class="text-green-400 ml-2"
|
||||
>({{ profileDetails[profile.profileName]?.turnTerm }}분 턴
|
||||
서버)</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<div class="profile-announcement-settings text-xs text-zinc-500">
|
||||
(상성 설정:{{ profileDetails[profile.profileName]?.fictionMode }}),
|
||||
(기타 설정:{{ profileDetails[profile.profileName]?.otherTextInfo }})
|
||||
<template v-if="profile.status === 'PREOPEN'">
|
||||
(빙의 여부:{{
|
||||
npcModeText(profileDetails[profile.profileName]?.npcMode ?? 0)
|
||||
}}), (최대 스탯:{{
|
||||
profileDetails[profile.profileName]?.defaultStatTotal
|
||||
}}),
|
||||
</template>
|
||||
(기타 설정:<template
|
||||
v-if="profileDetails[profile.profileName]?.otherTextInfo"
|
||||
>{{ profileDetails[profile.profileName]?.otherTextInfo
|
||||
}}<template v-if="profileDetails[profile.profileName]?.autorunUser"
|
||||
>,
|
||||
</template></template
|
||||
><span
|
||||
v-if="profileDetails[profile.profileName]?.autorunUser"
|
||||
class="copyable-autorun"
|
||||
tabindex="0"
|
||||
:aria-describedby="autorunTooltipId(profile.profileName)"
|
||||
>자율행동<span
|
||||
:id="autorunTooltipId(profile.profileName)"
|
||||
class="copyable-autorun-detail"
|
||||
role="tooltip"
|
||||
><span class="copyable-autorun-bracket">[</span
|
||||
><span>{{
|
||||
autorunDetailText(profileDetails[profile.profileName]!)
|
||||
}}</span
|
||||
><span class="copyable-autorun-bracket">]</span></span
|
||||
></span
|
||||
>)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -793,6 +879,68 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.season-status {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.copyable-autorun {
|
||||
position: relative;
|
||||
cursor: help;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.copyable-autorun-detail {
|
||||
display: inline;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.copyable-autorun-bracket {
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.copyable-autorun:hover .copyable-autorun-detail,
|
||||
.copyable-autorun:focus-visible .copyable-autorun-detail {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
right: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #52525b;
|
||||
border-radius: 4px;
|
||||
background: #18181b;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 45%);
|
||||
color: #f4f4f5;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.copyable-autorun:focus-visible {
|
||||
border-radius: 2px;
|
||||
outline: 2px solid #fdba74;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.copyable-autorun:hover .copyable-autorun-detail,
|
||||
.copyable-autorun:focus-visible .copyable-autorun-detail {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.map-preview-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -76,6 +76,11 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
메타가 없거나 유효하지 않으면 기존 시스템 기본값을 사용합니다. 시나리오와
|
||||
예약·가오픈·정식 오픈 시각은 매 실행마다 선택하므로 서버 기본값에 포함하지
|
||||
않습니다.
|
||||
- 서버 상태의 `다음 시즌 번호`는 위 리셋 옵션과 별도의
|
||||
`GatewayProfile.meta.nextSeasonIdx`이며 0 이상의 정수를 허용합니다. 이벤트
|
||||
기수는 0을 저장한 뒤 RESET하고, 이벤트 종료 뒤 정상 기수 번호를 다시 저장한
|
||||
다음 RESET합니다. 빈 값은 강제 번호를 해제하여 기존 게임의 season 또는 신규
|
||||
기본값 1을 사용한다는 뜻입니다.
|
||||
- 같은 화면의 `실행 중 게임 옵션`은 리셋 기본값과 별개로 현재 기수 DB의 턴
|
||||
간격, 장수 생성 제한, 유저 자동턴 제한·동작을 읽어 표시합니다. 세 값은
|
||||
`admin.profiles.runtime:<name>` 권한과 3자 이상의 사유가 있을 때 하나의
|
||||
@@ -96,7 +101,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
|
||||
| `admin.profiles.settings:<name>` | 표시 정보·리셋 기본 옵션·Kakao 미인증 접근/장수 생성 유예 |
|
||||
| `admin.profiles.deploy:<name>` | DB를 유지하는 Git 버전 업데이트, 초기화와 새 버전 결합 |
|
||||
| `admin.scenarios.reset:<name>` | 현재 배포 버전으로 시나리오 초기화 |
|
||||
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
|
||||
| `admin.games.cancel:<name>` | 진행 게임 취소, 기록 옵션과 유산 포인트 보전율 확정 |
|
||||
| `admin.reset.schedule:<name>` | 허용된 시나리오 초기화를 미래 시각에 예약 |
|
||||
| `admin.releases.manage` | profile과 분리된 Gateway control plane 배포·rollback |
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ features:
|
||||
[시간과 턴](./user/time-and-turns.md)과
|
||||
[커맨드 목록](./user/command-catalog.generated.md)을 확인해 주세요. Profile과
|
||||
Gateway 배포는 [릴리스 운영 매뉴얼](./release-operations.md)을 따라 주세요.
|
||||
[Gateway와 게임 공통 메뉴 설정](./runtime-navigation.md)은 코드 재빌드 없이
|
||||
상단 링크와 dropdown을 바꾸는 JSON 형식과 복구 경계를 설명합니다.
|
||||
관리자 화면의 메뉴와 권한·운영 경계는
|
||||
[관리자 콘솔](./admin-console.md)에서 확인할 수 있습니다.
|
||||
게임 진행 시각과 운영 벽시계의 경계는
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Gateway와 게임 공통 메뉴 설정
|
||||
|
||||
Gateway 상단 메뉴와 profile 게임 화면의 공통 메뉴는 하나의 JSON 설정을
|
||||
공유합니다. 저장소 기본값은 `resources/navigation.json`이고 운영 runtime은
|
||||
`CORE_NAVIGATION_CONFIG_FILE`이 가리키는 파일을 우선합니다. Docker 운영 구성의
|
||||
기본 경로는 영속 volume 안의 `/srv/data/navigation.json`입니다.
|
||||
|
||||
## 반영 경계
|
||||
|
||||
`GET /gateway/api/navigation`과 `navigation.get`은 인증 없이 현재 파일을 요청마다
|
||||
읽고 schema를 검증합니다.
|
||||
Gateway와 게임 frontend는 화면을 처음 열 때 이를 조회하므로 JSON을 저장한 뒤
|
||||
브라우저를 새로고침하면 frontend 재빌드나 profile DB 초기화 없이 반영됩니다.
|
||||
이미 열린 화면을 서버가 강제로 바꾸지는 않습니다.
|
||||
|
||||
운영 파일이 아직 없으면 저장소 기본값을 사용합니다. Docker entrypoint는 최초
|
||||
기동 때만 저장소 기본값을 영속 경로로 복사하고, 이미 존재하는 운영 파일은 배포나
|
||||
container 재생성 때 덮어쓰지 않습니다. 파일을 읽을 수 없거나 schema가 틀리면
|
||||
API는 오류를 반환하고 frontend는 빌드에 포함된 안전한 기본 메뉴를 표시합니다.
|
||||
|
||||
## 편집 형식
|
||||
|
||||
최상위 `version`은 현재 `1`입니다.
|
||||
|
||||
- `gateway.brand`: Gateway 브랜드 문구와 내부 `to`
|
||||
- `gateway.items`: `id`, `label`, `href`, 선택적인 `newTab`
|
||||
- `game.items`: `link`, `group`, `split` 항목
|
||||
- `link`: `to`, `href`, `action` 가운데 정확히 하나만 사용
|
||||
- `divider`: dropdown 구분선이며 고유한 `id`만 사용
|
||||
- `showWhen: npc-enabled`: NPC 모드에서만 노출
|
||||
- `highlightWhen: nation-betting|vote`: 해당 실시간 상태일 때 기존 강조색 적용
|
||||
- `action: show-version`: 현재 지원하는 유일한 로컬 동작으로 버전 정보 dialog 표시
|
||||
|
||||
`to`는 profile base path를 보존하는 Vue Router 내부 경로입니다. `/xe`, `/wiki`
|
||||
같이 Caddy가 소유한 외부 경로는 `href`를 사용합니다. URL은 `/`, `//`,
|
||||
`https://`, `http://`로 시작하는 값만 허용하며 `javascript:` 같은 실행 URL은
|
||||
거부합니다. 브라우저 식별과 자동 검증에 쓰이는 `id`는 영문 소문자, 숫자와
|
||||
하이픈만 사용합니다.
|
||||
|
||||
## 운영 변경과 복구
|
||||
|
||||
1. `/srv/data/navigation.json`을 별도 위치에 복사해 되돌릴 파일을 확보합니다.
|
||||
2. 임시 파일에서 편집하고 `jq empty`로 JSON 문법을 확인합니다.
|
||||
3. 임시 파일을 운영 경로로 같은 filesystem 안에서 교체합니다.
|
||||
4. `GET /gateway/api/navigation`이 성공하는지 확인합니다.
|
||||
5. Gateway desktop/mobile과 실제 profile 화면을 새로고침해 순서, 링크,
|
||||
dropdown과 hover/focus를 확인합니다.
|
||||
|
||||
API 검증이 실패하면 직전 복사본을 원래 경로로 되돌립니다. 저장소 기본값으로
|
||||
완전히 복구하려면 현재 배포 commit의 `resources/navigation.json`을 운영 경로에
|
||||
복사합니다. 이 작업은 PostgreSQL, Redis, profile release나 현재 시즌을 변경하지
|
||||
않습니다.
|
||||
|
||||
개발 검증은 다음 명령을 사용합니다.
|
||||
|
||||
```sh
|
||||
pnpm --filter @sammo-ts/gateway-api test -- runtimeNavigationConfig.test.ts
|
||||
pnpm --filter @sammo-ts/gateway-frontend test:e2e:operations --grep 'Gateway 상단 메뉴|Gateway 모바일|JSON 응답'
|
||||
```
|
||||
@@ -21,6 +21,10 @@
|
||||
"./auth/gameSessionTransfer": {
|
||||
"types": "./dist/auth/gameSessionTransfer.d.ts",
|
||||
"default": "./dist/auth/gameSessionTransfer.js"
|
||||
},
|
||||
"./navigation/menuConfig": {
|
||||
"types": "./dist/navigation/menuConfig.d.ts",
|
||||
"default": "./dist/navigation/menuConfig.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export type RuntimeNavigationAction = 'show-version';
|
||||
export type RuntimeNavigationVisibility = 'always' | 'npc-enabled';
|
||||
export type RuntimeNavigationHighlight = 'nation-betting' | 'vote';
|
||||
|
||||
export interface RuntimeNavigationLink {
|
||||
kind: 'link';
|
||||
id: string;
|
||||
label: string;
|
||||
to?: string;
|
||||
href?: string;
|
||||
action?: RuntimeNavigationAction;
|
||||
newTab?: boolean;
|
||||
showWhen?: RuntimeNavigationVisibility;
|
||||
highlightWhen?: RuntimeNavigationHighlight;
|
||||
}
|
||||
|
||||
export interface RuntimeNavigationDivider {
|
||||
kind: 'divider';
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface RuntimeNavigationGroup {
|
||||
kind: 'group';
|
||||
id: string;
|
||||
label: string;
|
||||
items: Array<RuntimeNavigationLink | RuntimeNavigationDivider>;
|
||||
}
|
||||
|
||||
export interface RuntimeNavigationSplit {
|
||||
kind: 'split';
|
||||
id: string;
|
||||
main: RuntimeNavigationLink;
|
||||
items: Array<RuntimeNavigationLink | RuntimeNavigationDivider>;
|
||||
}
|
||||
|
||||
export type RuntimeNavigationEntry = RuntimeNavigationLink | RuntimeNavigationGroup | RuntimeNavigationSplit;
|
||||
|
||||
export interface GatewayNavigationLink {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimeNavigationConfig {
|
||||
version: 1;
|
||||
gateway: {
|
||||
brand: {
|
||||
label: string;
|
||||
to: string;
|
||||
};
|
||||
items: GatewayNavigationLink[];
|
||||
};
|
||||
game: {
|
||||
items: RuntimeNavigationEntry[];
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
||||
'auth/gameToken': 'src/auth/gameToken.ts',
|
||||
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
|
||||
'auth/sanctions': 'src/auth/sanctions.ts',
|
||||
'navigation/menuConfig': 'src/navigation/menuConfig.ts',
|
||||
},
|
||||
format: 'es',
|
||||
outDir: 'dist',
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"version": 1,
|
||||
"gateway": {
|
||||
"brand": {
|
||||
"label": "삼국지 모의전투 HiDCHe",
|
||||
"to": "/"
|
||||
},
|
||||
"items": [
|
||||
{ "id": "notice", "label": "공지사항", "href": "/xe/", "newTab": true },
|
||||
{ "id": "community", "label": "커뮤니티", "href": "/xe/community", "newTab": true },
|
||||
{ "id": "development", "label": "건의/제안/개발", "href": "/xe/devel", "newTab": true },
|
||||
{ "id": "report", "label": "신고/문의", "href": "/xe/report", "newTab": true },
|
||||
{ "id": "faq", "label": "자주 묻는 질문", "href": "/xe/faq", "newTab": true },
|
||||
{ "id": "patch", "label": "패치 내역", "href": "/wiki/개발/패치_내역", "newTab": true },
|
||||
{ "id": "repository", "label": "Git Repo.", "href": "//gitea.hided.net/devsam/core", "newTab": true },
|
||||
{ "id": "wiki", "label": "위키", "href": "/wiki", "newTab": true },
|
||||
{ "id": "official-chat", "label": "공식 오픈 톡", "href": "https://open.kakao.com/o/gR82obT", "newTab": true },
|
||||
{ "id": "casual-chat", "label": "잡담 오픈 톡", "href": "https://open.kakao.com/o/g9ZWe5K", "newTab": true }
|
||||
]
|
||||
},
|
||||
"game": {
|
||||
"items": [
|
||||
{
|
||||
"kind": "link",
|
||||
"id": "nation-betting",
|
||||
"label": "천통국 베팅",
|
||||
"to": "/nation-betting",
|
||||
"highlightWhen": "nation-betting"
|
||||
},
|
||||
{ "kind": "link", "id": "nation-list", "label": "세력일람", "to": "/nation-list", "newTab": true },
|
||||
{ "kind": "link", "id": "general-list", "label": "장수일람", "to": "/general-list", "newTab": true },
|
||||
{ "kind": "link", "id": "best-general", "label": "명장일람", "to": "/best-general", "newTab": true },
|
||||
{ "kind": "link", "id": "yearbook", "label": "연감", "to": "/yearbook", "newTab": true },
|
||||
{
|
||||
"kind": "group",
|
||||
"id": "game-info",
|
||||
"label": "게임 정보",
|
||||
"items": [
|
||||
{ "kind": "link", "id": "battle-simulator", "label": "전투 시뮬레이터", "to": "/battle-simulator", "newTab": true },
|
||||
{ "kind": "link", "id": "hall-of-fame", "label": "명예의전당", "to": "/hall-of-fame", "newTab": true },
|
||||
{ "kind": "link", "id": "dynasty", "label": "왕조일람", "to": "/dynasty", "newTab": true },
|
||||
{ "kind": "link", "id": "traffic", "label": "접속량정보", "to": "/traffic", "newTab": true },
|
||||
{ "kind": "link", "id": "npc-list", "label": "빙의일람", "to": "/npc-list", "newTab": true, "showWhen": "npc-enabled" },
|
||||
{ "kind": "divider", "id": "game-info-reference-divider" },
|
||||
{ "kind": "link", "id": "patch", "label": "패치 내역", "href": "/wiki/개발/패치_내역", "newTab": true },
|
||||
{ "kind": "link", "id": "repository", "label": "코드 저장소", "href": "//storage.hided.net/gitea/devsam/core", "newTab": true },
|
||||
{ "kind": "divider", "id": "game-info-version-divider" },
|
||||
{ "kind": "link", "id": "version", "label": "정보", "action": "show-version" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "group",
|
||||
"id": "community",
|
||||
"label": "커뮤니티",
|
||||
"items": [
|
||||
{ "kind": "link", "id": "board-community", "label": "게시판", "href": "/xe/community", "newTab": true },
|
||||
{ "kind": "link", "id": "board-request", "label": "건의/제안", "href": "/xe/devel", "newTab": true },
|
||||
{ "kind": "link", "id": "wiki", "label": "위키", "href": "/wiki", "newTab": true },
|
||||
{ "kind": "divider", "id": "community-chat-divider" },
|
||||
{ "kind": "link", "id": "official-chat", "label": "공식 오픈 톡", "href": "https://open.kakao.com/o/gR82obT", "newTab": true },
|
||||
{ "kind": "link", "id": "casual-chat", "label": "잡담 오픈 톡", "href": "https://open.kakao.com/o/g9ZWe5K", "newTab": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "link",
|
||||
"id": "survey",
|
||||
"label": "설문조사",
|
||||
"to": "/survey",
|
||||
"newTab": true,
|
||||
"highlightWhen": "vote"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user