merge: 최신 main을 장비 매매 범위 교정에 통합

This commit is contained in:
2026-08-21 02:12:22 +00:00
44 changed files with 610 additions and 81 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';
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { formatServerDateTime } from '@sammo-ts/common';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
export type GeneralBattleSummaryData = {
available?: boolean;
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { computed } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus';
@@ -5,12 +5,8 @@ import {
MESSAGE_MAILBOX_PUBLIC,
type MessageType,
} from '@sammo-ts/logic/messages/message.js';
import {
applyReadModelDelta,
cloneReadModelJson,
type PublicRealtimeEvent,
type RealtimeReadModelInvalidation,
} from '@sammo-ts/common';
import { applyReadModelDelta, cloneReadModelJson } from '@sammo-ts/common/realtime/delta';
import type { PublicRealtimeEvent, RealtimeReadModelInvalidation } from '@sammo-ts/common/realtime/types';
import { trpc } from '../utils/trpc';
import { useMapViewerStore } from './mapViewer';
import { useSessionStore } from './session';
@@ -5,7 +5,7 @@ import {
type RealtimeReadModelChanges,
type RealtimeReadModelInvalidation,
type RealtimeViewerIdentity,
} from '@sammo-ts/common';
} from '@sammo-ts/common/realtime/types';
export type DashboardReadModelIdentity = RealtimeViewerIdentity;
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);
+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 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);
+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 type { AppRouter } from '@sammo-ts/game-api';
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
+1 -1
View File
@@ -1,5 +1,5 @@
<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 { useRoute } from 'vue-router';
@@ -1,5 +1,5 @@
<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 { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
+1 -1
View File
@@ -1,5 +1,5 @@
<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 TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
+1 -1
View File
@@ -1,5 +1,5 @@
<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 { useRoute, useRouter } from 'vue-router';
@@ -1,5 +1,5 @@
<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 { useRoute, useRouter } from 'vue-router';
+2 -1
View File
@@ -1,5 +1,6 @@
<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 { trpc } from '../utils/trpc';
+1 -1
View File
@@ -1,5 +1,5 @@
<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 { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
@@ -1,5 +1,5 @@
<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 { useRouter } from 'vue-router';
import { useGameFeedback } from '../composables/useGameFeedback';
@@ -2,7 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue';
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 { useGameFeedback } from '../composables/useGameFeedback';
@@ -1,5 +1,5 @@
<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 { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
@@ -1,7 +1,10 @@
<script setup lang="ts">
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 GeneralBattleSummary, { type GeneralBattleSummaryData } from '../components/main/GeneralBattleSummary.vue';
+1 -1
View File
@@ -1,5 +1,5 @@
<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 { useRouter } from 'vue-router';
@@ -1,5 +1,5 @@
<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 TournamentBracket from '../components/tournament/TournamentBracket.vue';
import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue';
+1 -1
View File
@@ -1,5 +1,5 @@
<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 { useRouter } from 'vue-router';
+1 -1
View File
@@ -1,5 +1,5 @@
<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 { useRouter } from 'vue-router';
@@ -8,29 +8,44 @@ import {
type ProcessDefinition,
} from './processManager.js';
type Pm2Module = typeof Pm2;
export interface Pm2Client {
connect(callback: (error?: Error) => void): void;
disconnect(): void;
list(callback: (error: Error | null, list?: Pm2.ProcessDescription[]) => void): void;
start(options: Pm2.StartOptions, callback: (error?: Error) => void): void;
stop(name: string, callback: (error?: Error) => void): void;
delete(name: string, callback: (error?: Error) => void): void;
}
export interface Pm2ProcessManagerOptions {
loadPm2?: () => Pm2Client;
connectTimeoutMs?: number;
listTimeoutMs?: number;
mutationTimeoutMs?: number;
}
const require = createRequire(import.meta.url);
const loadPm2 = (): Pm2Module => require('pm2') as Pm2Module;
const loadPm2 = (): Pm2Client => require('pm2') as Pm2Client;
const DEFAULT_PM2_CONNECT_TIMEOUT_MS = 5_000;
const DEFAULT_PM2_LIST_TIMEOUT_MS = 5_000;
const DEFAULT_PM2_MUTATION_TIMEOUT_MS = 30_000;
const withPm2 = async <T>(handler: (pm2: Pm2Module) => Promise<T>): Promise<T> => {
const pm2 = loadPm2();
await new Promise<void>((resolve, reject) => {
pm2.connect((error) => {
if (error) {
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
timer.unref();
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error);
return;
}
resolve();
});
);
});
try {
return await handler(pm2);
} finally {
pm2.disconnect();
}
};
export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
name: definition.name,
@@ -47,8 +62,52 @@ export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
});
export class Pm2ProcessManager implements ProcessManager {
private readonly loadPm2: () => Pm2Client;
private readonly connectTimeoutMs: number;
private readonly listTimeoutMs: number;
private readonly mutationTimeoutMs: number;
private sessionTail: Promise<void> = Promise.resolve();
constructor(options: Pm2ProcessManagerOptions = {}) {
this.loadPm2 = options.loadPm2 ?? loadPm2;
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_PM2_CONNECT_TIMEOUT_MS;
this.listTimeoutMs = options.listTimeoutMs ?? DEFAULT_PM2_LIST_TIMEOUT_MS;
this.mutationTimeoutMs = options.mutationTimeoutMs ?? DEFAULT_PM2_MUTATION_TIMEOUT_MS;
}
private withPm2<T>(label: string, timeoutMs: number, handler: (pm2: Pm2Client) => Promise<T>): Promise<T> {
const task = this.sessionTail.then(async () => {
const pm2 = this.loadPm2();
try {
await withTimeout(
new Promise<void>((resolve, reject) => {
pm2.connect((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
}),
this.connectTimeoutMs,
'PM2 connect'
);
return await withTimeout(handler(pm2), timeoutMs, label);
} finally {
pm2.disconnect();
}
});
this.sessionTail = task.then(
() => undefined,
() => undefined
);
return task;
}
async list(): Promise<ManagedProcessInfo[]> {
return withPm2(
return this.withPm2(
'PM2 list',
this.listTimeoutMs,
(pm2) =>
new Promise<ManagedProcessInfo[]>((resolve, reject) => {
pm2.list((error, list) => {
@@ -72,7 +131,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async start(definition: ProcessDefinition): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 start ${definition.name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.list((listError, list) => {
@@ -100,7 +161,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async stop(name: string): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 stop ${name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.stop(name, (error) => {
@@ -115,7 +178,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async delete(name: string): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 delete ${name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.delete(name, (error) => {
+121 -2
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { buildPm2StartOptions } from '../src/orchestrator/pm2ProcessManager.js';
import {
buildPm2StartOptions,
Pm2ProcessManager,
type Pm2Client,
} from '../src/orchestrator/pm2ProcessManager.js';
describe('buildPm2StartOptions', () => {
it('enforces bounded restart policy and strips inherited PM2 identity at the PM2 boundary', () => {
@@ -56,3 +60,118 @@ describe('buildPm2StartOptions', () => {
expect(options.env).not.toHaveProperty('args');
});
});
describe('Pm2ProcessManager session recovery', () => {
it('serializes concurrent PM2 sessions so one disconnect cannot interrupt another request', async () => {
const events: string[] = [];
let listCall = 0;
let releaseFirstList: (() => void) | undefined;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
events.push('connect');
callback();
},
disconnect() {
events.push('disconnect');
},
list(callback: Parameters<Pm2Client['list']>[0]) {
listCall += 1;
const currentCall = listCall;
events.push(`list:${currentCall}`);
if (currentCall === 1) {
releaseFirstList = () => callback(null, []);
return;
}
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({ loadPm2: () => pm2 });
const first = manager.list();
const second = manager.list();
await vi.waitFor(() => expect(events).toEqual(['connect', 'list:1']));
releaseFirstList?.();
await expect(Promise.all([first, second])).resolves.toEqual([[], []]);
expect(events).toEqual(['connect', 'list:1', 'disconnect', 'connect', 'list:2', 'disconnect']);
});
it('times out a lost PM2 callback and lets the next queued session proceed', async () => {
let listCall = 0;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
callback();
},
disconnect() {},
list(callback: Parameters<Pm2Client['list']>[0]) {
listCall += 1;
if (listCall === 1) {
return;
}
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({
loadPm2: () => pm2,
listTimeoutMs: 10,
});
await expect(manager.list()).rejects.toThrow('PM2 list timed out after 10ms.');
await expect(manager.list()).resolves.toEqual([]);
});
it('disconnects a timed-out PM2 connection before releasing the serialized session', async () => {
let connectCall = 0;
let disconnectCall = 0;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
connectCall += 1;
if (connectCall > 1) {
callback();
}
},
disconnect() {
disconnectCall += 1;
},
list(callback: Parameters<Pm2Client['list']>[0]) {
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({
loadPm2: () => pm2,
connectTimeoutMs: 10,
});
await expect(manager.list()).rejects.toThrow('PM2 connect timed out after 10ms.');
expect(disconnectCall).toBe(1);
await expect(manager.list()).resolves.toEqual([]);
expect(disconnectCall).toBe(2);
});
});
@@ -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));
});
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 }) => {
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 ({
page,
}, testInfo) => {
+9 -8
View File
@@ -1,12 +1,13 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import LobbyView from '../views/LobbyView.vue';
import AdminOverviewView from '../views/AdminOverviewView.vue';
import AdminView from '../views/AdminView.vue';
import ServerOperationsView from '../views/ServerOperationsView.vue';
import AccountView from '../views/AccountView.vue';
import OAuthCallbackView from '../views/OAuthCallbackView.vue';
import SignupView from '../views/SignupView.vue';
const HomeView = () => import('../views/HomeView.vue');
const LobbyView = () => import('../views/LobbyView.vue');
const AdminOverviewView = () => import('../views/AdminOverviewView.vue');
const AdminView = () => import('../views/AdminView.vue');
const ServerOperationsView = () => import('../views/ServerOperationsView.vue');
const AccountView = () => import('../views/AccountView.vue');
const OAuthCallbackView = () => import('../views/OAuthCallbackView.vue');
const SignupView = () => import('../views/SignupView.vue');
const router = createRouter({
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);
+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 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) => {
const urlTemplate = import.meta.env.VITE_GAME_API_URL_TEMPLATE;
const url = urlTemplate
? resolveProfileUrl(urlTemplate, profile)
: `http://localhost:${port}/api/trpc`;
const url = urlTemplate ? resolveProfileUrl(urlTemplate, profile) : `http://localhost:${port}/api/trpc`;
return createTRPCProxyClient<GameRouter>({
links: [
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 type { AppRouter } from '@sammo-ts/gateway-api';
@@ -1,5 +1,5 @@
<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 { useRouter } from 'vue-router';
+2 -3
View File
@@ -1,11 +1,10 @@
<script setup lang="ts">
import {
formatServerDateTime,
gatewayProfileCapabilities,
serverDateTimeInputToIso,
toServerDateTimeInputValue,
type GatewayProfileStatus,
} from '@sammo-ts/common';
} from '@sammo-ts/common/time/ServerDateTime';
import { gatewayProfileCapabilities, type GatewayProfileStatus } from '@sammo-ts/common/gateway/profileStatus';
import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.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 type { inferRouterOutputs } from '@trpc/server';
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 KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
+60 -9
View File
@@ -1,5 +1,5 @@
<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 { useRouter } from 'vue-router';
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 formatAnnouncementDate = (value: string | null | undefined): string =>
formatServerDateTime(value, { fallback: '-' });
const profileScenarioTitle = (profileName: string): string =>
profileDetails.value[profileName]?.scenarioTitle.trim() || '-';
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
const autorunDetailText = (info: LobbyInfo): string => {
const autorun = info.autorunUser;
@@ -527,10 +529,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
-
</div>
<div data-testid="profile-scenario-announcement">
<span class="text-orange-400">{{
profileDetails[profile.profileName]?.scenarioTitle ||
profile.scenario
}}</span
<span
class="text-orange-400"
data-testid="profile-scenario-title"
>{{ profileScenarioTitle(profile.profileName) }}</span
>{{ ' ' }}
<span class="text-green-400">
{{ profileDetails[profile.profileName]?.turnTerm }}분 턴 서버
@@ -542,7 +544,8 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
서기 {{ profileDetails[profile.profileName]?.year }}년
{{ profileDetails[profile.profileName]?.month }}월 (<span
class="text-orange-400"
>{{ profile.scenario }}</span
data-testid="profile-scenario-title"
>{{ profileScenarioTitle(profile.profileName) }}</span
>)
</div>
<div class="text-zinc-400">
@@ -823,10 +826,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
>
계 정 관 리
</div>
<div class="p-6 flex justify-center space-x-4">
<div class="account-actions p-6">
<RouterLink
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>
@@ -841,7 +844,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
<RouterLink
v-if="canAccessAdmin"
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>
@@ -855,6 +858,20 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</template>
<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 {
box-sizing: border-box;
width: 200px;
@@ -1091,4 +1108,38 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
cursor: default;
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>
@@ -1,5 +1,5 @@
<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 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);
});
});
+10
View File
@@ -128,3 +128,13 @@ README에 정의된 경로에서 다음 순서로 확인하며, `down --volumes`
기능이 어긋날 수 있으므로, 일반 배포의 manifest protocol 검사를 우회하지
마세요. Self-upgrade CLI만 다음 protocol을 허용하며 schema head와 component는
동일하게 검증합니다.
Gateway 릴리스가 terminal인데 후속 profile 작업이 `QUEUED`, `attempts=0`에서
두 poll 주기 이상 움직이지 않으면 build process를 종료할 문제가 아니라 profile
orchestrator poll 자체를 조사합니다. Gateway 전환 직후 reconcile과 worktree cleanup이
겹쳐도 PM2 Node client session은 직렬화되며, `connect/list` callback이 5초 안에 오지
않으면 해당 scheduled task를 실패시켜 다음 poll로 자동 복구합니다. 이 timeout 전후에
PM2 mutation을 수동 반복하지 말고 active Gateway release row가 없는지, orchestrator
started 로그와 profile operation attempts가 그대로인지 먼저 확인합니다. 제한 복구가
필요하면 현재 PM2 definition의 `sammo:gateway-orchestrator`만 재시작하고 Gateway
API/frontend, release-controller, profile daemon과 container는 유지합니다.
+17
View File
@@ -92,6 +92,15 @@ Profile orchestrator와 Gateway release-controller는 서로 다른 worktree roo
제거됩니다. Profile 관리자 API의 `admin.profiles.cleanupWorkspaces`는 같은 보호
규칙을 사용하므로 진행 중인 build/operation이 있으면 전체 정리를 보류합니다.
PM2의 Node client는 한 process 안에서 공유 connection을 사용합니다. Profile
orchestrator가 시작될 때 reconcile과 worktree 정리가 동시에 `connect/list/disconnect`
호출해 한 callback이 사라지면, PM2에는 orchestrator가 `online`으로 보이면서도 정리
flag가 풀리지 않아 profile operation poll이 계속 대기할 수 있습니다. 제품 경계에서는
PM2 session을 직렬화하고 `connect/list`를 5초, start/stop/delete를 30초로 제한합니다.
Timeout은 scheduled task 오류로 끝나 정리 flag를 해제하고 다음 5초 operation poll이
queue를 다시 claim하게 합니다. PM2 mutation이 timeout된 경우에는 같은 mutation을 즉시
직접 반복하지 않고 실제 process 목록과 operation terminal 상태를 먼저 재조회합니다.
## Profile 배포
버전 업데이트 화면에서 profile의 branch 또는 commit을 선택합니다. Branch는 worker가
@@ -103,6 +112,14 @@ queue에서 기다리고, Gateway 릴리스가 실행 중이면 새 profile 작
Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중단하지 않는
운영 계약입니다.
상대 Gateway 릴리스가 terminal인데도 profile 작업이 두 번의 poll 주기 이상
`QUEUED`, `attempts=0`이면 단순 build 지연이 아닙니다. Gateway orchestrator의 PM2
상태뿐 아니라 최근 started 로그, 활성 release row와 profile operation row를 함께
확인합니다. DB에 활성 release가 없고 API/frontend/profile runtime이 정상인 경우에만
현재 definition의 `sammo:gateway-orchestrator` 한 process를 재시작해 queue poll을
복구할 수 있습니다. Container, release-controller와 game daemon은 함께 재시작하지
않습니다.
### DB 유지 배포
`DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
+32
View File
@@ -22,9 +22,41 @@
"types": "./dist/auth/gameSessionTransfer.d.ts",
"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": {
"types": "./dist/navigation/menuConfig.d.ts",
"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": {
+8
View File
@@ -6,7 +6,15 @@ export default defineConfig({
'auth/gameToken': 'src/auth/gameToken.ts',
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.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',
'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',
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 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 isPersistenceModule = (specifier) =>
@@ -101,7 +105,19 @@ export const extractImports = (source) => {
const dynamicPattern = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
let match;
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))) {
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) {
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 (
(zone.name === 'game-api' || zone.name === 'gateway-api') &&
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,
'@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', () => {
@@ -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',
]);
});
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, []);
});