merge: 최신 main을 장비 매매 범위 교정에 통합
This commit is contained in:
@@ -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,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,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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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,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';
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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,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 { 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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,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,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,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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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는 유지합니다.
|
||||||
|
|||||||
@@ -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 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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, []);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user