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'; 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';
@@ -8,29 +8,44 @@ import {
type ProcessDefinition, type ProcessDefinition,
} from './processManager.js'; } 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 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 withTimeout = <T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> =>
const pm2 = loadPm2(); new Promise<T>((resolve, reject) => {
await new Promise<void>((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
pm2.connect((error) => { timer.unref();
if (error) { promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error); reject(error);
return;
} }
resolve(); );
});
}); });
try {
return await handler(pm2);
} finally {
pm2.disconnect();
}
};
export const buildPm2StartOptions = (definition: ProcessDefinition) => ({ export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
name: definition.name, name: definition.name,
@@ -47,8 +62,52 @@ export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
}); });
export class Pm2ProcessManager implements ProcessManager { 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[]> { async list(): Promise<ManagedProcessInfo[]> {
return withPm2( return this.withPm2(
'PM2 list',
this.listTimeoutMs,
(pm2) => (pm2) =>
new Promise<ManagedProcessInfo[]>((resolve, reject) => { new Promise<ManagedProcessInfo[]>((resolve, reject) => {
pm2.list((error, list) => { pm2.list((error, list) => {
@@ -72,7 +131,9 @@ export class Pm2ProcessManager implements ProcessManager {
} }
async start(definition: ProcessDefinition): Promise<void> { async start(definition: ProcessDefinition): Promise<void> {
await withPm2( await this.withPm2(
`PM2 start ${definition.name}`,
this.mutationTimeoutMs,
(pm2) => (pm2) =>
new Promise<void>((resolve, reject) => { new Promise<void>((resolve, reject) => {
pm2.list((listError, list) => { pm2.list((listError, list) => {
@@ -100,7 +161,9 @@ export class Pm2ProcessManager implements ProcessManager {
} }
async stop(name: string): Promise<void> { async stop(name: string): Promise<void> {
await withPm2( await this.withPm2(
`PM2 stop ${name}`,
this.mutationTimeoutMs,
(pm2) => (pm2) =>
new Promise<void>((resolve, reject) => { new Promise<void>((resolve, reject) => {
pm2.stop(name, (error) => { pm2.stop(name, (error) => {
@@ -115,7 +178,9 @@ export class Pm2ProcessManager implements ProcessManager {
} }
async delete(name: string): Promise<void> { async delete(name: string): Promise<void> {
await withPm2( await this.withPm2(
`PM2 delete ${name}`,
this.mutationTimeoutMs,
(pm2) => (pm2) =>
new Promise<void>((resolve, reject) => { new Promise<void>((resolve, reject) => {
pm2.delete(name, (error) => { 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', () => { describe('buildPm2StartOptions', () => {
it('enforces bounded restart policy and strips inherited PM2 identity at the PM2 boundary', () => { 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'); 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)); 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);
});
});
+10
View File
@@ -128,3 +128,13 @@ README에 정의된 경로에서 다음 순서로 확인하며, `down --volumes`
기능이 어긋날 수 있으므로, 일반 배포의 manifest protocol 검사를 우회하지 기능이 어긋날 수 있으므로, 일반 배포의 manifest protocol 검사를 우회하지
마세요. Self-upgrade CLI만 다음 protocol을 허용하며 schema head와 component는 마세요. 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`는 같은 보호 제거됩니다. Profile 관리자 API의 `admin.profiles.cleanupWorkspaces`는 같은 보호
규칙을 사용하므로 진행 중인 build/operation이 있으면 전체 정리를 보류합니다. 규칙을 사용하므로 진행 중인 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 배포
버전 업데이트 화면에서 profile의 branch 또는 commit을 선택합니다. Branch는 worker가 버전 업데이트 화면에서 profile의 branch 또는 commit을 선택합니다. Branch는 worker가
@@ -103,6 +112,14 @@ queue에서 기다리고, Gateway 릴리스가 실행 중이면 새 profile 작
Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중단하지 않는 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 유지 배포
`DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다. `DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
+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, []);
});