merge: 최신 main을 프로필 큐 자동 복구에 통합

This commit is contained in:
2026-08-21 02:04:06 +00:00
40 changed files with 377 additions and 59 deletions
@@ -1,4 +1,4 @@
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import type { CommandOption, CommandTable } from './types'; import type { CommandOption, CommandTable } from './types';
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
export type GeneralBattleSummaryData = { export type GeneralBattleSummaryData = {
available?: boolean; available?: boolean;
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed } from 'vue'; import { computed } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus'; import { resolveTournamentStageName } from '../../utils/tournamentStatus';
@@ -5,12 +5,8 @@ import {
MESSAGE_MAILBOX_PUBLIC, MESSAGE_MAILBOX_PUBLIC,
type MessageType, type MessageType,
} from '@sammo-ts/logic/messages/message.js'; } from '@sammo-ts/logic/messages/message.js';
import { import { applyReadModelDelta, cloneReadModelJson } from '@sammo-ts/common/realtime/delta';
applyReadModelDelta, import type { PublicRealtimeEvent, RealtimeReadModelInvalidation } from '@sammo-ts/common/realtime/types';
cloneReadModelJson,
type PublicRealtimeEvent,
type RealtimeReadModelInvalidation,
} from '@sammo-ts/common';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { useMapViewerStore } from './mapViewer'; import { useMapViewerStore } from './mapViewer';
import { useSessionStore } from './session'; import { useSessionStore } from './session';
@@ -5,7 +5,7 @@ import {
type RealtimeReadModelChanges, type RealtimeReadModelChanges,
type RealtimeReadModelInvalidation, type RealtimeReadModelInvalidation,
type RealtimeViewerIdentity, type RealtimeViewerIdentity,
} from '@sammo-ts/common'; } from '@sammo-ts/common/realtime/types';
export type DashboardReadModelIdentity = RealtimeViewerIdentity; export type DashboardReadModelIdentity = RealtimeViewerIdentity;
export type DashboardRefreshPlan = RealtimeReadModelInvalidation; export type DashboardRefreshPlan = RealtimeReadModelInvalidation;
+1 -1
View File
@@ -1,3 +1,3 @@
import { formatLegacyLogHtml } from '@sammo-ts/common'; import { formatLegacyLogHtml } from '@sammo-ts/common/logging/formatLegacyLogHtml';
export const formatLog = (text?: string): string => formatLegacyLogHtml(text); export const formatLog = (text?: string): string => formatLegacyLogHtml(text);
+1 -1
View File
@@ -1,4 +1,4 @@
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common'; import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/gateway-api'; import type { AppRouter } from '@sammo-ts/gateway-api';
@@ -1,4 +1,4 @@
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
export const formatSeoulDateTime = (value: string | Date): string => formatServerDateTime(value); export const formatSeoulDateTime = (value: string | Date): string => formatServerDateTime(value);
+2 -1
View File
@@ -1,4 +1,5 @@
import { REALTIME_ACCESS_GRANT_HEADER, trpcJsonBodyHttpClientOptions } from '@sammo-ts/common'; import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport';
import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common/realtime/types';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/game-api'; import type { AppRouter } from '@sammo-ts/game-api';
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant'; import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue'; import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'; import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
+2 -1
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime, JosaUtil } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig'; import type { RuntimeNavigationConfig } from '@sammo-ts/common/navigation/menuConfig';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
@@ -2,7 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { JosaUtil } from '@sammo-ts/common'; import { JosaUtil } from '@sammo-ts/common/util/JosaUtil';
import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue'; import PersonnelSelectionDialog from '../components/personnel/PersonnelSelectionDialog.vue';
import { useGameFeedback } from '../composables/useGameFeedback'; import { useGameFeedback } from '../composables/useGameFeedback';
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>; type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
@@ -1,7 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { isLegacyArchiveProfile, type LegacyArchiveProfile } from '@sammo-ts/common'; import {
isLegacyArchiveProfile,
type LegacyArchiveProfile,
} from '@sammo-ts/common/legacyArchive/ArchivedGeneralSnapshot';
import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue';
import GeneralBattleSummary, { type GeneralBattleSummaryData } from '../components/main/GeneralBattleSummary.vue'; import GeneralBattleSummary, { type GeneralBattleSummaryData } from '../components/main/GeneralBattleSummary.vue';
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue'; import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -240,6 +240,112 @@ test('desktop administrator sidebar follows the navbar away and then sticks to t
await writeFile(testInfo.outputPath('admin-sidebar-scroll-geometry.json'), JSON.stringify(measurements, null, 2)); await writeFile(testInfo.outputPath('admin-sidebar-scroll-geometry.json'), JSON.stringify(measurements, null, 2));
}); });
test('keeps mobile account actions on clean rows for users and administrators', async ({ browser }, testInfo) => {
const measureAccountActions = async (page: Page) => {
const actions = page.locator('.account-actions');
await expect(actions).toBeVisible();
return actions.evaluate((element) => {
const rect = (target: Element) => {
const value = target.getBoundingClientRect();
return {
top: value.top,
right: value.right,
bottom: value.bottom,
left: value.left,
width: value.width,
height: value.height,
};
};
const items = Array.from(element.children).map((child) => ({
...rect(child),
clientWidth: (child as HTMLElement).clientWidth,
scrollWidth: (child as HTMLElement).scrollWidth,
whiteSpace: getComputedStyle(child).whiteSpace,
}));
return {
container: rect(element),
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
items,
};
});
};
const userContext = await browser.newContext({ viewport: { width: 500, height: 900 } });
const userPage = await userContext.newPage();
await installGatewayFixture(userPage, []);
await userPage.goto('lobby');
const userGeometry = await measureAccountActions(userPage);
expect(userGeometry.documentWidth).toBe(userGeometry.viewportWidth);
expect(userGeometry.items).toHaveLength(2);
expect(userGeometry.items[0]?.top).toBeCloseTo(userGeometry.items[1]?.top ?? 0, 0);
expect(userGeometry.items[0]?.bottom).toBeCloseTo(userGeometry.items[1]?.bottom ?? 0, 0);
expect(userGeometry.items[0]?.width).toBeCloseTo(userGeometry.items[1]?.width ?? 0, 0);
expect(userGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
await userPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-user-500px.png'), fullPage: true });
await userContext.close();
const adminContext = await browser.newContext({ viewport: { width: 500, height: 900 } });
const adminPage = await adminContext.newPage();
await installGatewayFixture(adminPage, ['superuser']);
await adminPage.goto('lobby');
const adminGeometry = await measureAccountActions(adminPage);
expect(adminGeometry.documentWidth).toBe(adminGeometry.viewportWidth);
expect(adminGeometry.items).toHaveLength(3);
const [account, logout, admin] = adminGeometry.items;
expect(account?.top).toBeCloseTo(logout?.top ?? 0, 0);
expect(account?.bottom).toBeCloseTo(logout?.bottom ?? 0, 0);
expect(account?.width).toBeCloseTo(logout?.width ?? 0, 0);
expect(admin?.top).toBeCloseTo((logout?.bottom ?? 0) + 16, 0);
expect(admin?.left).toBeCloseTo(account?.left ?? 0, 0);
expect(admin?.right).toBeCloseTo(logout?.right ?? 0, 0);
expect(adminGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
expect(account?.whiteSpace).toBe('nowrap');
expect(admin?.whiteSpace).toBe('nowrap');
const adminLink = adminPage.getByRole('link', { name: '관리자 페이지' });
const baseBackground = await adminLink.evaluate((element) => getComputedStyle(element).backgroundColor);
await adminLink.hover();
await expect.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(
baseBackground
);
await adminLink.focus();
await expect(adminLink).toBeFocused();
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-500px.png'), fullPage: true });
await adminPage.setViewportSize({ width: 390, height: 844 });
const adminNarrowGeometry = await measureAccountActions(adminPage);
expect(adminNarrowGeometry.documentWidth).toBe(adminNarrowGeometry.viewportWidth);
expect(adminNarrowGeometry.items[0]?.top).toBeCloseTo(adminNarrowGeometry.items[1]?.top ?? 0, 0);
expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo(
(adminNarrowGeometry.items[1]?.bottom ?? 0) + 16,
0
);
expect(adminNarrowGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-390px.png'), fullPage: true });
await adminPage.setViewportSize({ width: 360, height: 800 });
const adminSmallGeometry = await measureAccountActions(adminPage);
expect(adminSmallGeometry.documentWidth).toBe(adminSmallGeometry.viewportWidth);
expect(adminSmallGeometry.items[1]?.top).toBeCloseTo((adminSmallGeometry.items[0]?.bottom ?? 0) + 16, 0);
expect(adminSmallGeometry.items[2]?.top).toBeCloseTo((adminSmallGeometry.items[1]?.bottom ?? 0) + 16, 0);
expect(adminSmallGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
await writeFile(
testInfo.outputPath('gateway-account-actions-geometry.json'),
JSON.stringify(
{
user: userGeometry,
admin: adminGeometry,
adminNarrow: adminNarrowGeometry,
adminSmall: adminSmallGeometry,
},
null,
2
)
);
await adminContext.close();
});
test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => { test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => {
await installGatewayFixture(page, ['superuser']); await installGatewayFixture(page, ['superuser']);
@@ -303,6 +303,43 @@ test('exchanges the gateway token before loading authenticated lobby general dat
}); });
}); });
test('shows the Ref scenario title instead of the stored scenario code for an active profile', async ({
page,
}, testInfo) => {
await installFixture(page, {
scenarioTitle: '【가상모드27-b】 아시아 명장전(비급)',
});
await page.setViewportSize({ width: 1365, height: 900 });
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
const info = row.locator('.profile-info-cell');
const title = row.getByTestId('profile-scenario-title');
await expect(title).toHaveText('【가상모드27-b】 아시아 명장전(비급)');
await expect(info).not.toContainText('903');
await expect(title).toHaveCSS('color', 'oklch(0.75 0.183 55.934)');
await page.screenshot({ path: testInfo.outputPath('gateway-active-scenario-title-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await title.scrollIntoViewIfNeeded();
const mobileGeometry = await info.evaluate((element) => {
const titleElement = element.querySelector('[data-testid="profile-scenario-title"]');
if (!titleElement) throw new Error('expected scenario title');
const infoRect = element.getBoundingClientRect();
const titleRect = titleElement.getBoundingClientRect();
return {
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
info: infoRect.toJSON(),
title: titleRect.toJSON(),
};
});
expect(mobileGeometry.documentWidth).toBe(mobileGeometry.viewportWidth);
expect(mobileGeometry.title.left).toBeGreaterThanOrEqual(mobileGeometry.info.left);
expect(mobileGeometry.title.right).toBeLessThanOrEqual(mobileGeometry.info.right);
await page.screenshot({ path: testInfo.outputPath('gateway-active-scenario-title-mobile.png'), fullPage: true });
});
test('copies the complete preopen announcement and reveals autorun details without changing layout', async ({ test('copies the complete preopen announcement and reveals autorun details without changing layout', async ({
page, page,
}, testInfo) => { }, testInfo) => {
+9 -8
View File
@@ -1,12 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import LobbyView from '../views/LobbyView.vue'; const HomeView = () => import('../views/HomeView.vue');
import AdminOverviewView from '../views/AdminOverviewView.vue'; const LobbyView = () => import('../views/LobbyView.vue');
import AdminView from '../views/AdminView.vue'; const AdminOverviewView = () => import('../views/AdminOverviewView.vue');
import ServerOperationsView from '../views/ServerOperationsView.vue'; const AdminView = () => import('../views/AdminView.vue');
import AccountView from '../views/AccountView.vue'; const ServerOperationsView = () => import('../views/ServerOperationsView.vue');
import OAuthCallbackView from '../views/OAuthCallbackView.vue'; const AccountView = () => import('../views/AccountView.vue');
import SignupView from '../views/SignupView.vue'; const OAuthCallbackView = () => import('../views/OAuthCallbackView.vue');
const SignupView = () => import('../views/SignupView.vue');
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
+1 -1
View File
@@ -1,3 +1,3 @@
import { formatLegacyLogHtml } from '@sammo-ts/common'; import { formatLegacyLogHtml } from '@sammo-ts/common/logging/formatLegacyLogHtml';
export const formatLog = (text: string): string => formatLegacyLogHtml(text); export const formatLog = (text: string): string => formatLegacyLogHtml(text);
+2 -4
View File
@@ -1,4 +1,4 @@
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common'; import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport';
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { appRouter } from '@sammo-ts/game-api'; import type { appRouter } from '@sammo-ts/game-api';
@@ -9,9 +9,7 @@ const resolveProfileUrl = (template: string, profile: string): string =>
export const createGameTrpc = (profile: string, port: number, gameToken?: string) => { export const createGameTrpc = (profile: string, port: number, gameToken?: string) => {
const urlTemplate = import.meta.env.VITE_GAME_API_URL_TEMPLATE; const urlTemplate = import.meta.env.VITE_GAME_API_URL_TEMPLATE;
const url = urlTemplate const url = urlTemplate ? resolveProfileUrl(urlTemplate, profile) : `http://localhost:${port}/api/trpc`;
? resolveProfileUrl(urlTemplate, profile)
: `http://localhost:${port}/api/trpc`;
return createTRPCProxyClient<GameRouter>({ return createTRPCProxyClient<GameRouter>({
links: [ links: [
httpBatchLink({ httpBatchLink({
+1 -1
View File
@@ -1,4 +1,4 @@
import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common'; import { trpcJsonBodyHttpClientOptions } from '@sammo-ts/common/http/trpcTransport';
import { createTRPCProxyClient, httpBatchLink, httpLink } from '@trpc/client'; import { createTRPCProxyClient, httpBatchLink, httpLink } from '@trpc/client';
import type { AppRouter } from '@sammo-ts/gateway-api'; import type { AppRouter } from '@sammo-ts/gateway-api';
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
+2 -3
View File
@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
formatServerDateTime, formatServerDateTime,
gatewayProfileCapabilities,
serverDateTimeInputToIso, serverDateTimeInputToIso,
toServerDateTimeInputValue, toServerDateTimeInputValue,
type GatewayProfileStatus, } from '@sammo-ts/common/time/ServerDateTime';
} from '@sammo-ts/common'; import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common/gateway/profileStatus';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue'; import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
+1 -1
View File
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import type { inferRouterOutputs } from '@trpc/server'; import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@sammo-ts/gateway-api'; import type { AppRouter } from '@sammo-ts/gateway-api';
import { gatewayProfileCapabilities } from '@sammo-ts/common'; import { gatewayProfileCapabilities } from '@sammo-ts/common/gateway/profileStatus';
import MapPreview from '../components/MapPreview.vue'; import MapPreview from '../components/MapPreview.vue';
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue'; import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
+60 -9
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common'; import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'; import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import type { inferRouterOutputs } from '@trpc/server'; import type { inferRouterOutputs } from '@trpc/server';
@@ -108,6 +108,8 @@ const formatGraceEndsAt = (value: string | null | undefined): string => formatSe
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info); const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
const formatAnnouncementDate = (value: string | null | undefined): string => const formatAnnouncementDate = (value: string | null | undefined): string =>
formatServerDateTime(value, { fallback: '-' }); formatServerDateTime(value, { fallback: '-' });
const profileScenarioTitle = (profileName: string): string =>
profileDetails.value[profileName]?.scenarioTitle.trim() || '-';
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가'; const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
const autorunDetailText = (info: LobbyInfo): string => { const autorunDetailText = (info: LobbyInfo): string => {
const autorun = info.autorunUser; const autorun = info.autorunUser;
@@ -527,10 +529,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
- -
</div> </div>
<div data-testid="profile-scenario-announcement"> <div data-testid="profile-scenario-announcement">
<span class="text-orange-400">{{ <span
profileDetails[profile.profileName]?.scenarioTitle || class="text-orange-400"
profile.scenario data-testid="profile-scenario-title"
}}</span >{{ profileScenarioTitle(profile.profileName) }}</span
>{{ ' ' }} >{{ ' ' }}
<span class="text-green-400"> <span class="text-green-400">
{{ profileDetails[profile.profileName]?.turnTerm }}분 턴 서버 {{ profileDetails[profile.profileName]?.turnTerm }}분 턴 서버
@@ -542,7 +544,8 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
서기 {{ profileDetails[profile.profileName]?.year }}년 서기 {{ profileDetails[profile.profileName]?.year }}년
{{ profileDetails[profile.profileName]?.month }}월 (<span {{ profileDetails[profile.profileName]?.month }}월 (<span
class="text-orange-400" class="text-orange-400"
>{{ profile.scenario }}</span data-testid="profile-scenario-title"
>{{ profileScenarioTitle(profile.profileName) }}</span
>) >)
</div> </div>
<div class="text-zinc-400"> <div class="text-zinc-400">
@@ -823,10 +826,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
> >
계 정 관 리 계 정 관 리
</div> </div>
<div class="p-6 flex justify-center space-x-4"> <div class="account-actions p-6">
<RouterLink <RouterLink
to="/account" to="/account"
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors" class="account-navigation-button bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
> >
비밀번호 & 전콘 & 탈퇴 비밀번호 & 전콘 & 탈퇴
</RouterLink> </RouterLink>
@@ -841,7 +844,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
<RouterLink <RouterLink
v-if="canAccessAdmin" v-if="canAccessAdmin"
to="/admin" to="/admin"
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors" class="account-navigation-button account-admin-button bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
> >
관리자 페이지 관리자 페이지
</RouterLink> </RouterLink>
@@ -855,6 +858,20 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</template> </template>
<style scoped> <style scoped>
.account-actions {
display: flex;
justify-content: center;
gap: 16px;
}
.account-navigation-button {
display: flex;
box-sizing: border-box;
align-items: center;
justify-content: center;
text-align: center;
}
.legacy-logout-button { .legacy-logout-button {
box-sizing: border-box; box-sizing: border-box;
width: 200px; width: 200px;
@@ -1091,4 +1108,38 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
cursor: default; cursor: default;
opacity: 0.65; opacity: 0.65;
} }
@media (max-width: 640px) {
.account-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.account-actions > * {
width: 100%;
min-width: 0;
}
.account-navigation-button {
height: 48px;
padding: 10px 4px;
font-size: clamp(12px, 3.2vw, 16px);
line-height: 24px;
white-space: nowrap;
}
.account-admin-button {
grid-column: 1 / -1;
}
}
@media (max-width: 360px) {
.account-actions {
grid-template-columns: minmax(0, 1fr);
}
.account-admin-button {
grid-column: auto;
}
}
</style> </style>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common'; import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common/time/ServerDateTime';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import CompactHelp from '../components/CompactHelp.vue'; import CompactHelp from '../components/CompactHelp.vue';
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { describe, it } from 'node:test';
const routerSourcePath = path.resolve(import.meta.dirname, '../src/router/index.ts');
void describe('gateway route loading contract', () => {
void it('loads view components through route-level dynamic imports', async () => {
const source = await readFile(routerSourcePath, 'utf8');
const staticViewImports = [...source.matchAll(/^import\s+.+\s+from\s+['"]\.\.\/views\//gm)];
const lazyViewComponents = new Set(
[...source.matchAll(/^const\s+([A-Z][A-Za-z0-9]+View)\s*=\s*\(\)\s*=>\s*import\(['"]\.\.\/views\//gm)].map(
(match) => match[1]
)
);
const routedViewComponents = new Set(
[...source.matchAll(/^\s*component:\s*([A-Z][A-Za-z0-9]+View),?$/gm)].map((match) => match[1])
);
assert.deepEqual(staticViewImports, []);
assert.deepEqual(lazyViewComponents, routedViewComponents);
assert.ok(lazyViewComponents.size >= 8);
});
});
+32
View File
@@ -22,9 +22,41 @@
"types": "./dist/auth/gameSessionTransfer.d.ts", "types": "./dist/auth/gameSessionTransfer.d.ts",
"default": "./dist/auth/gameSessionTransfer.js" "default": "./dist/auth/gameSessionTransfer.js"
}, },
"./gateway/profileStatus": {
"types": "./dist/gateway/profileStatus.d.ts",
"default": "./dist/gateway/profileStatus.js"
},
"./http/trpcTransport": {
"types": "./dist/http/trpcTransport.d.ts",
"default": "./dist/http/trpcTransport.js"
},
"./legacyArchive/ArchivedGeneralSnapshot": {
"types": "./dist/legacyArchive/ArchivedGeneralSnapshot.d.ts",
"default": "./dist/legacyArchive/ArchivedGeneralSnapshot.js"
},
"./logging/formatLegacyLogHtml": {
"types": "./dist/logging/formatLegacyLogHtml.d.ts",
"default": "./dist/logging/formatLegacyLogHtml.js"
},
"./navigation/menuConfig": { "./navigation/menuConfig": {
"types": "./dist/navigation/menuConfig.d.ts", "types": "./dist/navigation/menuConfig.d.ts",
"default": "./dist/navigation/menuConfig.js" "default": "./dist/navigation/menuConfig.js"
},
"./realtime/delta": {
"types": "./dist/realtime/delta.d.ts",
"default": "./dist/realtime/delta.js"
},
"./realtime/types": {
"types": "./dist/realtime/types.d.ts",
"default": "./dist/realtime/types.js"
},
"./time/ServerDateTime": {
"types": "./dist/time/ServerDateTime.d.ts",
"default": "./dist/time/ServerDateTime.js"
},
"./util/JosaUtil": {
"types": "./dist/util/JosaUtil.d.ts",
"default": "./dist/util/JosaUtil.js"
} }
}, },
"scripts": { "scripts": {
+8
View File
@@ -6,7 +6,15 @@ export default defineConfig({
'auth/gameToken': 'src/auth/gameToken.ts', 'auth/gameToken': 'src/auth/gameToken.ts',
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts', 'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
'auth/sanctions': 'src/auth/sanctions.ts', 'auth/sanctions': 'src/auth/sanctions.ts',
'gateway/profileStatus': 'src/gateway/profileStatus.ts',
'http/trpcTransport': 'src/http/trpcTransport.ts',
'legacyArchive/ArchivedGeneralSnapshot': 'src/legacyArchive/ArchivedGeneralSnapshot.ts',
'logging/formatLegacyLogHtml': 'src/logging/formatLegacyLogHtml.ts',
'navigation/menuConfig': 'src/navigation/menuConfig.ts', 'navigation/menuConfig': 'src/navigation/menuConfig.ts',
'realtime/delta': 'src/realtime/delta.ts',
'realtime/types': 'src/realtime/types.ts',
'time/ServerDateTime': 'src/time/ServerDateTime.ts',
'util/JosaUtil': 'src/util/JosaUtil.ts',
}, },
format: 'es', format: 'es',
outDir: 'dist', outDir: 'dist',
+25 -1
View File
@@ -67,6 +67,10 @@ const persistenceModules = new Set([
]); ]);
const backendPackages = new Set(['@sammo-ts/game-api', '@sammo-ts/gateway-api']); const backendPackages = new Set(['@sammo-ts/game-api', '@sammo-ts/gateway-api']);
const frontendRuntimeRootBarrels = new Set(['@sammo-ts/common', '@sammo-ts/logic']);
const frontendRuntimeRootAllowlist = new Map([
['app/game-frontend/src/workers/battleSimulator.worker.ts', new Set(['@sammo-ts/logic'])],
]);
const nodeBuiltins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); const nodeBuiltins = new Set(builtinModules.flatMap((name) => [name, `node:${name}`]));
const isPersistenceModule = (specifier) => const isPersistenceModule = (specifier) =>
@@ -101,7 +105,19 @@ export const extractImports = (source) => {
const dynamicPattern = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g; const dynamicPattern = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
let match; let match;
while ((match = fromPattern.exec(source))) { while ((match = fromPattern.exec(source))) {
imports.push({ specifier: match[3], typeOnly: Boolean(match[1]), clause: match[2] }); const clause = match[2].trim();
const namedElements =
clause.startsWith('{') && clause.endsWith('}')
? clause
.slice(1, -1)
.split(',')
.map((element) => element.trim())
.filter(Boolean)
: [];
const typeOnly =
Boolean(match[1]) ||
(namedElements.length > 0 && namedElements.every((element) => element.startsWith('type ')));
imports.push({ specifier: match[3], typeOnly, clause: match[2] });
} }
while ((match = sideEffectPattern.exec(source))) { while ((match = sideEffectPattern.exec(source))) {
imports.push({ specifier: match[1], typeOnly: false, clause: '' }); imports.push({ specifier: match[1], typeOnly: false, clause: '' });
@@ -130,6 +146,14 @@ export const checkSource = ({ source, relativePath, zone }) => {
if (zone.frontend && packageName && backendPackages.has(packageName) && !imported.typeOnly) { if (zone.frontend && packageName && backendPackages.has(packageName) && !imported.typeOnly) {
violations.push(`${relativePath}: frontend may reference ${packageName} only through import type`); violations.push(`${relativePath}: frontend may reference ${packageName} only through import type`);
} }
if (
zone.frontend &&
frontendRuntimeRootBarrels.has(imported.specifier) &&
!imported.typeOnly &&
!frontendRuntimeRootAllowlist.get(relativePath)?.has(imported.specifier)
) {
violations.push(`${relativePath}: frontend runtime must import a leaf subpath from ${imported.specifier}`);
}
if ( if (
(zone.name === 'game-api' || zone.name === 'gateway-api') && (zone.name === 'game-api' || zone.name === 'gateway-api') &&
imported.specifier === '@sammo-ts/game-engine' imported.specifier === '@sammo-ts/game-engine'
+36
View File
@@ -11,6 +11,10 @@ test('extracts multiline and type-only package imports', () => {
extractImports("import {\n GamePrisma,\n type DatabaseClient\n} from '@sammo-ts/infra';")[0]?.specifier, extractImports("import {\n GamePrisma,\n type DatabaseClient\n} from '@sammo-ts/infra';")[0]?.specifier,
'@sammo-ts/infra' '@sammo-ts/infra'
); );
assert.equal(
extractImports("import { type AppRouter, type Profile } from '@sammo-ts/gateway-api';")[0]?.typeOnly,
true
);
}); });
test('rejects infrastructure access and process diagnostics in logic', () => { test('rejects infrastructure access and process diagnostics in logic', () => {
@@ -51,3 +55,35 @@ test('rejects prefixed and bare Node builtins in frontend source', () => {
'app/game-frontend/src/example.ts: frontend production code may not import node:fs', 'app/game-frontend/src/example.ts: frontend production code may not import node:fs',
]); ]);
}); });
test('requires frontend common and logic runtime imports to use leaf subpaths', () => {
const zone = {
name: 'game-frontend',
allowed: ['@sammo-ts/common', '@sammo-ts/logic'],
frontend: true,
};
const violations = checkSource({
source: [
"import { formatServerDateTime } from '@sammo-ts/common';",
"import { MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts/logic';",
"import type { MessageType } from '@sammo-ts/logic';",
].join('\n'),
relativePath: 'app/game-frontend/src/example.ts',
zone,
});
assert.deepEqual(violations, [
'app/game-frontend/src/example.ts: frontend runtime must import a leaf subpath from @sammo-ts/common',
'app/game-frontend/src/example.ts: frontend runtime must import a leaf subpath from @sammo-ts/logic',
]);
});
test('allows the isolated battle simulator worker to load the logic runtime root', () => {
const violations = checkSource({
source: "const loadProcessor = () => import('@sammo-ts/logic');",
relativePath: 'app/game-frontend/src/workers/battleSimulator.worker.ts',
zone: { name: 'game-frontend', allowed: ['@sammo-ts/logic'], frontend: true },
});
assert.deepEqual(violations, []);
});