merge: 최신 원격 main을 Vue 드래그 리팩터링에 통합

This commit is contained in:
2026-08-21 02:21:51 +00:00
18 changed files with 691 additions and 53 deletions
+7 -23
View File
@@ -13,6 +13,7 @@ import {
} from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import {
buildEquipmentTradeItemOptions,
parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS,
type TurnCommandInputOptions,
@@ -283,29 +284,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
});
const items: TurnCommandInputOptions['items'] = {
horse: [{ value: 'None', label: '판매/해제' }],
weapon: [{ value: 'None', label: '판매/해제' }],
book: [{ value: 'None', label: '판매/해제' }],
item: [{ value: 'None', label: '판매/해제' }],
};
for (const item of moduleBundle.itemModules) {
if (item.buyable) {
const cost = item.cost ?? 0;
const currentSecurity = city?.security ?? 0;
const availability =
currentSecurity < item.reqSecu
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
: general.gold < cost
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
: '현재 구입 가능';
items[item.slot].push({
value: item.key,
label: item.name,
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
});
}
}
const items = buildEquipmentTradeItemOptions({
configConst: asRecord(asRecord(worldState.config).const),
itemModules: moduleBundle.itemModules,
currentSecurity: city?.security ?? 0,
generalGold: general.gold,
});
const inputOptions: TurnCommandInputOptions = {
cities: cities.map((entry) => ({
value: entry.id,
+45
View File
@@ -5,6 +5,8 @@ import {
type NationTurnCommandSpec,
} from '@sammo-ts/logic';
import { asRecord, isRecord } from '@sammo-ts/common';
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { z } from 'zod';
import { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
@@ -103,6 +105,49 @@ export interface TurnCommandInputOptions {
};
}
type EquipmentTradeItemModule = Pick<ItemModule, 'key' | 'slot' | 'name' | 'info' | 'cost' | 'reqSecu' | 'buyable'>;
const plainLegacyInfo = (value: string): string =>
value
.replace(/<br\s*\/?>/giu, ' · ')
.replace(/<[^>]+>/gu, '')
.replace(/\s+/gu, ' ')
.trim();
export const buildEquipmentTradeItemOptions = (options: {
configConst: Record<string, unknown>;
itemModules: readonly EquipmentTradeItemModule[];
currentSecurity: number;
generalGold: number;
}): TurnCommandInputOptions['items'] => {
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
const items: TurnCommandInputOptions['items'] = {
horse: [{ value: 'None', label: '판매/해제' }],
weapon: [{ value: 'None', label: '판매/해제' }],
book: [{ value: 'None', label: '판매/해제' }],
item: [{ value: 'None', label: '판매/해제' }],
};
for (const item of options.itemModules) {
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
continue;
}
const cost = item.cost ?? 0;
const availability =
options.currentSecurity < item.reqSecu
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
: options.generalGold < cost
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
: '현재 구입 가능';
items[item.slot].push({
value: item.key,
label: item.name,
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
});
}
return items;
};
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
export const TURN_COMMAND_NATION_COLORS = [
'#FF0000',
+49 -1
View File
@@ -6,7 +6,24 @@ import {
} from '@sammo-ts/logic';
import { describe, expect, it } from 'vitest';
import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js';
import {
buildEquipmentTradeItemOptions,
buildTurnCommandInputFields,
parseReservedTurnArgs,
} from '../src/turns/commandInput.js';
const buildShopItem = (key: string, name: string) => ({
key,
rawName: name,
name,
info: `${name}<br>설명`,
slot: 'item' as const,
cost: 100,
buyable: true,
consumable: false,
reqSecu: 3000,
unique: false,
});
describe('turn command argument input', () => {
it('builds supported fields for every argument-bearing command module', async () => {
@@ -80,4 +97,35 @@ describe('turn command argument input', () => {
});
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
});
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
const items = buildEquipmentTradeItemOptions({
configConst: {},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
currentSecurity: 5000,
generalGold: 1000,
});
expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']);
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
});
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
const items = buildEquipmentTradeItemOptions({
configConst: {
allItems: {
item: {
che_치료_환약: 1,
event_전투특기_격노: 0,
},
},
},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
currentSecurity: 2000,
generalGold: 50,
});
expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']);
expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요');
});
});
@@ -15,6 +15,7 @@ import {
loadActionModuleBundle,
} from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { createRuntimeTrace } from './runtimeTrace.js';
// legacy GameConstBase 기본값
@@ -146,6 +147,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
['maxResourceActionAmount'],
DEFAULT_MAX_RESOURCE_ACTION_AMOUNT
),
purchasableItemKeys: resolveLegacyPurchasableItemKeys(constValues),
};
};
@@ -4,6 +4,7 @@ import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
type LoadedScenario = Awaited<ReturnType<typeof loadScenarioDefinitionById>>;
@@ -77,4 +78,18 @@ describe('tracked scenario resources', () => {
expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4);
expect(readItemSlot(composedAddon, 'horse').che_명마_07_백마).toBe(2);
});
it('projects ordinary and explicit secret-item scenario pools into command execution', async () => {
const [ordinaryBlank, secretScenario] = await Promise.all(
[1, 2701].map((scenarioId) => loadScenarioDefinitionById(scenarioId))
);
const ordinaryKeys = buildCommandEnv(ordinaryBlank.config).purchasableItemKeys;
const secretScenarioKeys = buildCommandEnv(secretScenario.config).purchasableItemKeys;
expect(ordinaryKeys?.size).toBe(24);
expect(ordinaryKeys?.has('che_치료_환약')).toBe(true);
expect([...ordinaryKeys!].filter((key) => key.startsWith('event_전투특기_'))).toEqual([]);
expect([...secretScenarioKeys!].filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20);
expect(secretScenarioKeys?.has('event_전투특기_격노')).toBe(true);
});
});
+58 -2
View File
@@ -48,6 +48,7 @@ type NavigationFixture = {
accessLimitAfterCalls?: number;
largeCommandTable?: boolean;
draftCommandTable?: boolean;
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>;
refCommandCategories?: boolean;
currentYear?: number;
currentMonth?: number;
@@ -235,6 +236,7 @@ const draftCommandGroups = [
options: [
{ value: 'horse', label: '명마' },
{ value: 'weapon', label: '무기' },
{ value: 'item', label: '도구' },
],
},
{
@@ -250,7 +252,13 @@ const draftCommandGroups = [
},
];
const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false, draftCommands = false) => ({
const commandTableFixture = (
large: boolean,
blockedCount = 0,
refCategories = false,
draftCommands = false,
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>
) => ({
general: draftCommands
? draftCommandGroups
: refCategories
@@ -309,6 +317,7 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f
{ value: 'None', label: '없음' },
{ value: '청룡언월도', label: '청룡언월도' },
],
item: equipmentItemOptions ?? [{ value: 'None', label: '없음' }],
}
: {},
context: draftCommands
@@ -595,7 +604,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
state.largeCommandTable === true,
state.commandBlockedCount,
state.refCommandCategories === true,
state.draftCommandTable === true
state.draftCommandTable === true,
state.equipmentItemOptions
),
}
: input.known.commandTable === currentCommandTableRevision
@@ -3455,6 +3465,52 @@ for (const viewport of [
});
}
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
] as const) {
test(`renders only scenario-scoped equipment items on ${viewport.name}`, async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
draftCommandTable: true,
equipmentItemOptions: [
{ value: 'None', label: '판매/해제' },
{
value: 'che_치료_환약',
label: '환약',
description: '현재 구입 가능 · 가격 100 · 부상 회복',
},
],
reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
};
await installFixture(page, state);
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await waitForMain(page);
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: '국가', exact: true }).click();
await picker.getByRole('button', { name: '장비 매매', exact: true }).click();
await picker.getByLabel('장비 종류', { exact: true }).selectOption('item');
const itemSelect = picker.getByLabel('장비', { exact: true });
await expect(itemSelect.locator('option')).toHaveText(['판매/해제', '환약']);
await expect(itemSelect.locator('option', { hasText: '비급' })).toHaveCount(0);
await itemSelect.selectOption('che_치료_환약');
await expect(picker).toContainText('현재 구입 가능 · 가격 100 · 부상 회복');
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeLessThanOrEqual(viewport.width);
await picker.screenshot({ path: test.info().outputPath(`scenario-item-shop-${viewport.name}.png`) });
});
}
test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({
page,
}) => {
@@ -8,29 +8,44 @@ import {
type ProcessDefinition,
} from './processManager.js';
type Pm2Module = typeof Pm2;
export interface Pm2Client {
connect(callback: (error?: Error) => void): void;
disconnect(): void;
list(callback: (error: Error | null, list?: Pm2.ProcessDescription[]) => void): void;
start(options: Pm2.StartOptions, callback: (error?: Error) => void): void;
stop(name: string, callback: (error?: Error) => void): void;
delete(name: string, callback: (error?: Error) => void): void;
}
export interface Pm2ProcessManagerOptions {
loadPm2?: () => Pm2Client;
connectTimeoutMs?: number;
listTimeoutMs?: number;
mutationTimeoutMs?: number;
}
const require = createRequire(import.meta.url);
const loadPm2 = (): Pm2Module => require('pm2') as Pm2Module;
const loadPm2 = (): Pm2Client => require('pm2') as Pm2Client;
const DEFAULT_PM2_CONNECT_TIMEOUT_MS = 5_000;
const DEFAULT_PM2_LIST_TIMEOUT_MS = 5_000;
const DEFAULT_PM2_MUTATION_TIMEOUT_MS = 30_000;
const withPm2 = async <T>(handler: (pm2: Pm2Module) => Promise<T>): Promise<T> => {
const pm2 = loadPm2();
await new Promise<void>((resolve, reject) => {
pm2.connect((error) => {
if (error) {
const withTimeout = <T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms.`)), timeoutMs);
timer.unref();
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error);
return;
}
resolve();
});
);
});
try {
return await handler(pm2);
} finally {
pm2.disconnect();
}
};
export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
name: definition.name,
@@ -47,8 +62,52 @@ export const buildPm2StartOptions = (definition: ProcessDefinition) => ({
});
export class Pm2ProcessManager implements ProcessManager {
private readonly loadPm2: () => Pm2Client;
private readonly connectTimeoutMs: number;
private readonly listTimeoutMs: number;
private readonly mutationTimeoutMs: number;
private sessionTail: Promise<void> = Promise.resolve();
constructor(options: Pm2ProcessManagerOptions = {}) {
this.loadPm2 = options.loadPm2 ?? loadPm2;
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_PM2_CONNECT_TIMEOUT_MS;
this.listTimeoutMs = options.listTimeoutMs ?? DEFAULT_PM2_LIST_TIMEOUT_MS;
this.mutationTimeoutMs = options.mutationTimeoutMs ?? DEFAULT_PM2_MUTATION_TIMEOUT_MS;
}
private withPm2<T>(label: string, timeoutMs: number, handler: (pm2: Pm2Client) => Promise<T>): Promise<T> {
const task = this.sessionTail.then(async () => {
const pm2 = this.loadPm2();
try {
await withTimeout(
new Promise<void>((resolve, reject) => {
pm2.connect((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
}),
this.connectTimeoutMs,
'PM2 connect'
);
return await withTimeout(handler(pm2), timeoutMs, label);
} finally {
pm2.disconnect();
}
});
this.sessionTail = task.then(
() => undefined,
() => undefined
);
return task;
}
async list(): Promise<ManagedProcessInfo[]> {
return withPm2(
return this.withPm2(
'PM2 list',
this.listTimeoutMs,
(pm2) =>
new Promise<ManagedProcessInfo[]>((resolve, reject) => {
pm2.list((error, list) => {
@@ -72,7 +131,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async start(definition: ProcessDefinition): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 start ${definition.name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.list((listError, list) => {
@@ -100,7 +161,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async stop(name: string): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 stop ${name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.stop(name, (error) => {
@@ -115,7 +178,9 @@ export class Pm2ProcessManager implements ProcessManager {
}
async delete(name: string): Promise<void> {
await withPm2(
await this.withPm2(
`PM2 delete ${name}`,
this.mutationTimeoutMs,
(pm2) =>
new Promise<void>((resolve, reject) => {
pm2.delete(name, (error) => {
+121 -2
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { buildPm2StartOptions } from '../src/orchestrator/pm2ProcessManager.js';
import {
buildPm2StartOptions,
Pm2ProcessManager,
type Pm2Client,
} from '../src/orchestrator/pm2ProcessManager.js';
describe('buildPm2StartOptions', () => {
it('enforces bounded restart policy and strips inherited PM2 identity at the PM2 boundary', () => {
@@ -56,3 +60,118 @@ describe('buildPm2StartOptions', () => {
expect(options.env).not.toHaveProperty('args');
});
});
describe('Pm2ProcessManager session recovery', () => {
it('serializes concurrent PM2 sessions so one disconnect cannot interrupt another request', async () => {
const events: string[] = [];
let listCall = 0;
let releaseFirstList: (() => void) | undefined;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
events.push('connect');
callback();
},
disconnect() {
events.push('disconnect');
},
list(callback: Parameters<Pm2Client['list']>[0]) {
listCall += 1;
const currentCall = listCall;
events.push(`list:${currentCall}`);
if (currentCall === 1) {
releaseFirstList = () => callback(null, []);
return;
}
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({ loadPm2: () => pm2 });
const first = manager.list();
const second = manager.list();
await vi.waitFor(() => expect(events).toEqual(['connect', 'list:1']));
releaseFirstList?.();
await expect(Promise.all([first, second])).resolves.toEqual([[], []]);
expect(events).toEqual(['connect', 'list:1', 'disconnect', 'connect', 'list:2', 'disconnect']);
});
it('times out a lost PM2 callback and lets the next queued session proceed', async () => {
let listCall = 0;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
callback();
},
disconnect() {},
list(callback: Parameters<Pm2Client['list']>[0]) {
listCall += 1;
if (listCall === 1) {
return;
}
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({
loadPm2: () => pm2,
listTimeoutMs: 10,
});
await expect(manager.list()).rejects.toThrow('PM2 list timed out after 10ms.');
await expect(manager.list()).resolves.toEqual([]);
});
it('disconnects a timed-out PM2 connection before releasing the serialized session', async () => {
let connectCall = 0;
let disconnectCall = 0;
const pm2 = {
connect(callback: Parameters<Pm2Client['connect']>[0]) {
connectCall += 1;
if (connectCall > 1) {
callback();
}
},
disconnect() {
disconnectCall += 1;
},
list(callback: Parameters<Pm2Client['list']>[0]) {
callback(null, []);
},
start() {
throw new Error('unused');
},
stop() {
throw new Error('unused');
},
delete() {
throw new Error('unused');
},
} satisfies Pm2Client;
const manager = new Pm2ProcessManager({
loadPm2: () => pm2,
connectTimeoutMs: 10,
});
await expect(manager.list()).rejects.toThrow('PM2 connect timed out after 10ms.');
expect(disconnectCall).toBe(1);
await expect(manager.list()).resolves.toEqual([]);
expect(disconnectCall).toBe(2);
});
});
@@ -240,6 +240,112 @@ test('desktop administrator sidebar follows the navbar away and then sticks to t
await writeFile(testInfo.outputPath('admin-sidebar-scroll-geometry.json'), JSON.stringify(measurements, null, 2));
});
test('keeps mobile account actions on clean rows for users and administrators', async ({ browser }, testInfo) => {
const measureAccountActions = async (page: Page) => {
const actions = page.locator('.account-actions');
await expect(actions).toBeVisible();
return actions.evaluate((element) => {
const rect = (target: Element) => {
const value = target.getBoundingClientRect();
return {
top: value.top,
right: value.right,
bottom: value.bottom,
left: value.left,
width: value.width,
height: value.height,
};
};
const items = Array.from(element.children).map((child) => ({
...rect(child),
clientWidth: (child as HTMLElement).clientWidth,
scrollWidth: (child as HTMLElement).scrollWidth,
whiteSpace: getComputedStyle(child).whiteSpace,
}));
return {
container: rect(element),
documentWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
items,
};
});
};
const userContext = await browser.newContext({ viewport: { width: 500, height: 900 } });
const userPage = await userContext.newPage();
await installGatewayFixture(userPage, []);
await userPage.goto('lobby');
const userGeometry = await measureAccountActions(userPage);
expect(userGeometry.documentWidth).toBe(userGeometry.viewportWidth);
expect(userGeometry.items).toHaveLength(2);
expect(userGeometry.items[0]?.top).toBeCloseTo(userGeometry.items[1]?.top ?? 0, 0);
expect(userGeometry.items[0]?.bottom).toBeCloseTo(userGeometry.items[1]?.bottom ?? 0, 0);
expect(userGeometry.items[0]?.width).toBeCloseTo(userGeometry.items[1]?.width ?? 0, 0);
expect(userGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
await userPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-user-500px.png'), fullPage: true });
await userContext.close();
const adminContext = await browser.newContext({ viewport: { width: 500, height: 900 } });
const adminPage = await adminContext.newPage();
await installGatewayFixture(adminPage, ['superuser']);
await adminPage.goto('lobby');
const adminGeometry = await measureAccountActions(adminPage);
expect(adminGeometry.documentWidth).toBe(adminGeometry.viewportWidth);
expect(adminGeometry.items).toHaveLength(3);
const [account, logout, admin] = adminGeometry.items;
expect(account?.top).toBeCloseTo(logout?.top ?? 0, 0);
expect(account?.bottom).toBeCloseTo(logout?.bottom ?? 0, 0);
expect(account?.width).toBeCloseTo(logout?.width ?? 0, 0);
expect(admin?.top).toBeCloseTo((logout?.bottom ?? 0) + 16, 0);
expect(admin?.left).toBeCloseTo(account?.left ?? 0, 0);
expect(admin?.right).toBeCloseTo(logout?.right ?? 0, 0);
expect(adminGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
expect(account?.whiteSpace).toBe('nowrap');
expect(admin?.whiteSpace).toBe('nowrap');
const adminLink = adminPage.getByRole('link', { name: '관리자 페이지' });
const baseBackground = await adminLink.evaluate((element) => getComputedStyle(element).backgroundColor);
await adminLink.hover();
await expect.poll(() => adminLink.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(
baseBackground
);
await adminLink.focus();
await expect(adminLink).toBeFocused();
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-500px.png'), fullPage: true });
await adminPage.setViewportSize({ width: 390, height: 844 });
const adminNarrowGeometry = await measureAccountActions(adminPage);
expect(adminNarrowGeometry.documentWidth).toBe(adminNarrowGeometry.viewportWidth);
expect(adminNarrowGeometry.items[0]?.top).toBeCloseTo(adminNarrowGeometry.items[1]?.top ?? 0, 0);
expect(adminNarrowGeometry.items[2]?.top).toBeCloseTo(
(adminNarrowGeometry.items[1]?.bottom ?? 0) + 16,
0
);
expect(adminNarrowGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
await adminPage.screenshot({ path: testInfo.outputPath('gateway-account-actions-admin-390px.png'), fullPage: true });
await adminPage.setViewportSize({ width: 360, height: 800 });
const adminSmallGeometry = await measureAccountActions(adminPage);
expect(adminSmallGeometry.documentWidth).toBe(adminSmallGeometry.viewportWidth);
expect(adminSmallGeometry.items[1]?.top).toBeCloseTo((adminSmallGeometry.items[0]?.bottom ?? 0) + 16, 0);
expect(adminSmallGeometry.items[2]?.top).toBeCloseTo((adminSmallGeometry.items[1]?.bottom ?? 0) + 16, 0);
expect(adminSmallGeometry.items.every(({ scrollWidth, clientWidth }) => scrollWidth <= clientWidth)).toBe(true);
await writeFile(
testInfo.outputPath('gateway-account-actions-geometry.json'),
JSON.stringify(
{
user: userGeometry,
admin: adminGeometry,
adminNarrow: adminNarrowGeometry,
adminSmall: adminSmallGeometry,
},
null,
2
)
);
await adminContext.close();
});
test('legacy server operations URL keeps query parameters and redirects to the server list', async ({ page }) => {
await installGatewayFixture(page, ['superuser']);
+51 -3
View File
@@ -826,10 +826,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
>
계 정 관 리
</div>
<div class="p-6 flex justify-center space-x-4">
<div class="account-actions p-6">
<RouterLink
to="/account"
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
class="account-navigation-button bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
>
비밀번호 & 전콘 & 탈퇴
</RouterLink>
@@ -844,7 +844,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
<RouterLink
v-if="canAccessAdmin"
to="/admin"
class="bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
class="account-navigation-button account-admin-button bg-zinc-800 hover:bg-zinc-700 text-white px-6 py-2 rounded border border-zinc-700 transition-colors"
>
관리자 페이지
</RouterLink>
@@ -858,6 +858,20 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
</template>
<style scoped>
.account-actions {
display: flex;
justify-content: center;
gap: 16px;
}
.account-navigation-button {
display: flex;
box-sizing: border-box;
align-items: center;
justify-content: center;
text-align: center;
}
.legacy-logout-button {
box-sizing: border-box;
width: 200px;
@@ -1094,4 +1108,38 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
cursor: default;
opacity: 0.65;
}
@media (max-width: 640px) {
.account-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.account-actions > * {
width: 100%;
min-width: 0;
}
.account-navigation-button {
height: 48px;
padding: 10px 4px;
font-size: clamp(12px, 3.2vw, 16px);
line-height: 24px;
white-space: nowrap;
}
.account-admin-button {
grid-column: 1 / -1;
}
}
@media (max-width: 360px) {
.account-actions {
grid-template-columns: minmax(0, 1fr);
}
.account-admin-button {
grid-column: auto;
}
}
</style>
+10
View File
@@ -128,3 +128,13 @@ README에 정의된 경로에서 다음 순서로 확인하며, `down --volumes`
기능이 어긋날 수 있으므로, 일반 배포의 manifest protocol 검사를 우회하지
마세요. Self-upgrade CLI만 다음 protocol을 허용하며 schema head와 component는
동일하게 검증합니다.
Gateway 릴리스가 terminal인데 후속 profile 작업이 `QUEUED`, `attempts=0`에서
두 poll 주기 이상 움직이지 않으면 build process를 종료할 문제가 아니라 profile
orchestrator poll 자체를 조사합니다. Gateway 전환 직후 reconcile과 worktree cleanup이
겹쳐도 PM2 Node client session은 직렬화되며, `connect/list` callback이 5초 안에 오지
않으면 해당 scheduled task를 실패시켜 다음 poll로 자동 복구합니다. 이 timeout 전후에
PM2 mutation을 수동 반복하지 말고 active Gateway release row가 없는지, orchestrator
started 로그와 profile operation attempts가 그대로인지 먼저 확인합니다. 제한 복구가
필요하면 현재 PM2 definition의 `sammo:gateway-orchestrator`만 재시작하고 Gateway
API/frontend, release-controller, profile daemon과 container는 유지합니다.
@@ -71,6 +71,15 @@ Core의 `extends`는 이 중복 값을 소스에서 재사용하기 위한 합
따라서 일반 공백지 시나리오에는
`extensions/items/buyable-war-special-uniques.json`이 암묵적으로 적용되지 않습니다.
장비 매매의 구매 목록과 실행 검증도 같은 경계를 사용합니다. 합성된
`const.allItems`에서 수량이 `0` 이하이고 item module이 `buyable`인 항목만 구매할
수 있습니다. `allItems`가 생략되었거나 빈 객체 또는 과거 문자열 `"{}"`이면 Ref
`GameConstBase`의 기본 구매 가능 장비 24종(부위별 6종)을 복원합니다. 반대로
`allItems`가 명시된 시나리오는 그 목록에 없는 전역 item module을 UI 선택지에
노출하지 않고, 조작된 예약 명령으로도 구매하지 못합니다. 유니크 장비의 판매와
로그 표시에는 전체 item catalog가 계속 필요하므로 구매 허용 목록과 catalog 자체를
분리합니다.
공백지 중 `scenario_902`(천지비급), `scenario_910`(거울세계),
`scenario_912`(다병종), `scenario_913`(무한대흥)은 Ref 자체가 전투 특기 아이템
풀을 직접 정의합니다. 이 네 시나리오는 최신 공통 확장과 항목 또는 유니크 수량이
+17
View File
@@ -92,6 +92,15 @@ Profile orchestrator와 Gateway release-controller는 서로 다른 worktree roo
제거됩니다. Profile 관리자 API의 `admin.profiles.cleanupWorkspaces`는 같은 보호
규칙을 사용하므로 진행 중인 build/operation이 있으면 전체 정리를 보류합니다.
PM2의 Node client는 한 process 안에서 공유 connection을 사용합니다. Profile
orchestrator가 시작될 때 reconcile과 worktree 정리가 동시에 `connect/list/disconnect`
호출해 한 callback이 사라지면, PM2에는 orchestrator가 `online`으로 보이면서도 정리
flag가 풀리지 않아 profile operation poll이 계속 대기할 수 있습니다. 제품 경계에서는
PM2 session을 직렬화하고 `connect/list`를 5초, start/stop/delete를 30초로 제한합니다.
Timeout은 scheduled task 오류로 끝나 정리 flag를 해제하고 다음 5초 operation poll이
queue를 다시 claim하게 합니다. PM2 mutation이 timeout된 경우에는 같은 mutation을 즉시
직접 반복하지 않고 실제 process 목록과 operation terminal 상태를 먼저 재조회합니다.
## Profile 배포
버전 업데이트 화면에서 profile의 branch 또는 commit을 선택합니다. Branch는 worker가
@@ -103,6 +112,14 @@ queue에서 기다리고, Gateway 릴리스가 실행 중이면 새 profile 작
Gateway process 전환이 진행 중인 profile migration·seed 실행자를 중단하지 않는
운영 계약입니다.
상대 Gateway 릴리스가 terminal인데도 profile 작업이 두 번의 poll 주기 이상
`QUEUED`, `attempts=0`이면 단순 build 지연이 아닙니다. Gateway orchestrator의 PM2
상태뿐 아니라 최근 started 로그, 활성 release row와 profile operation row를 함께
확인합니다. DB에 활성 release가 없고 API/frontend/profile runtime이 정상인 경우에만
현재 definition의 `sammo:gateway-orchestrator` 한 process를 재시작해 queue poll을
복구할 수 있습니다. Container, release-controller와 game daemon은 함께 재시작하지
않습니다.
### DB 유지 배포
`DB 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
@@ -61,6 +61,7 @@ export interface TurnCommandEnv {
npcSeizureMessageProb?: number;
maxResourceActionAmount: number;
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
purchasableItemKeys?: ReadonlySet<string>;
generalActionModules?: RefOrderedActionStack<GeneralActionModule>;
warActionModules?: RefOrderedActionStack<WarActionModule>;
nationTraitModules?: Array<NationTraitModule>;
@@ -79,7 +79,11 @@ export class ActionDefinition<
if (!item) {
return null;
}
if (item.slot !== itemType || !item.buyable) {
if (
item.slot !== itemType ||
!item.buyable ||
(this.env.purchasableItemKeys !== undefined && !this.env.purchasableItemKeys.has(itemCode))
) {
return null;
}
return args;
@@ -113,6 +113,56 @@ const LEGACY_UNIQUE_ITEM_KEYS: Readonly<Record<ItemModule['slot'], readonly stri
],
};
const LEGACY_DEFAULT_BUYABLE_ITEM_KEYS: Readonly<Record<ItemModule['slot'], readonly string[]>> = {
horse: [
'che_명마_01_노기',
'che_명마_02_조랑',
'che_명마_03_노새',
'che_명마_04_나귀',
'che_명마_05_갈색마',
'che_명마_06_흑색마',
],
weapon: [
'che_무기_01_단도',
'che_무기_02_단궁',
'che_무기_03_단극',
'che_무기_04_목검',
'che_무기_05_죽창',
'che_무기_06_소부',
],
book: [
'che_서적_01_효경전',
'che_서적_02_회남자',
'che_서적_03_변도론',
'che_서적_04_건상역주',
'che_서적_05_여씨춘추',
'che_서적_06_사민월령',
],
item: ['che_치료_환약', 'che_저격_수극', 'che_사기_탁주', 'che_훈련_청주', 'che_계략_이추', 'che_계략_향낭'],
};
/**
* Ref 장비 매매는 GameConst::$allItems에 있고 수량이 0 이하인 구매 가능 아이템만
* 노출합니다. 생략/옛 문자열 빈 객체는 GameConstBase의 기본 24종으로 복원합니다.
*/
export const resolveLegacyPurchasableItemKeys = (configConst: Record<string, unknown>): ReadonlySet<string> => {
const { allItems } = resolveUniqueConfig(configConst);
const hasExplicitPool = Object.values(allItems).some((entries) => Object.keys(entries ?? {}).length > 0);
if (!hasExplicitPool) {
return new Set(Object.values(LEGACY_DEFAULT_BUYABLE_ITEM_KEYS).flat());
}
const result = new Set<string>();
for (const entries of Object.values(allItems)) {
for (const [itemKey, count] of Object.entries(entries ?? {})) {
if (count <= 0) {
result.add(itemKey);
}
}
}
return result;
};
export const buildLegacyDefaultUniqueItemPool = (itemRegistry: Map<string, ItemModule>): UniqueItemPool => {
const pool: UniqueItemPool = { horse: {}, weapon: {}, book: {}, item: {} };
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
@@ -252,6 +252,37 @@ describe('typed item lifecycle events', () => {
});
});
it('시나리오 상점 목록에 없는 전역 구매 가능 아이템을 거부한다', () => {
const itemKey = 'event_전투특기_격노';
const catalog: Record<string, TurnCommandItemCatalogEntry> = {
[itemKey]: {
slot: 'item',
name: '격노의 비급',
rawName: '격노의 비급',
cost: 100,
reqSecu: 0,
buyable: true,
unique: false,
},
};
const denied = new TradeItemAction({
...BASE_ENV,
itemCatalog: catalog,
purchasableItemKeys: new Set(),
});
const allowed = new TradeItemAction({
...BASE_ENV,
itemCatalog: catalog,
purchasableItemKeys: new Set([itemKey]),
});
expect(denied.parseArgs({ itemType: 'item', itemCode: itemKey })).toBeNull();
expect(allowed.parseArgs({ itemType: 'item', itemCode: itemKey })).toEqual({
itemType: 'item',
itemCode: itemKey,
});
});
it('계략 성공 capability만 소비하며 typed 결과로 소비 item을 반환한다', () => {
const general = makeGeneral('che_계략_이추');
const itemModules = createItemActionModules(createItemModuleRegistry([strategyItemModule]));
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import { resolveLegacyCompatibleUniqueConfig } from '../src/rewards/legacyUniqueItemPool.js';
import {
resolveLegacyCompatibleUniqueConfig,
resolveLegacyPurchasableItemKeys,
} from '../src/rewards/legacyUniqueItemPool.js';
describe('legacy-compatible unique item pool', () => {
it.each([undefined, {}, '{}'] as const)('restores the Ref default pool when allItems is %j', async (allItems) => {
@@ -27,4 +30,29 @@ describe('legacy-compatible unique item pool', () => {
},
});
});
it.each([undefined, {}, '{}'] as const)('restores the Ref default shop items when allItems is %j', (allItems) => {
const keys = resolveLegacyPurchasableItemKeys(allItems === undefined ? {} : { allItems });
expect(keys.size).toBe(24);
expect(keys.has('che_명마_01_노기')).toBe(true);
expect(keys.has('che_치료_환약')).toBe(true);
expect(keys.has('event_전투특기_격노')).toBe(false);
});
it('uses only non-limited entries from an explicit scenario shop pool', () => {
const keys = resolveLegacyPurchasableItemKeys({
allItems: {
weapon: {
che_무기_01_단도: 0,
che_무기_12_칠성검: 2,
},
item: {
event_전투특기_격노: 0,
},
},
});
expect([...keys]).toEqual(['che_무기_01_단도', 'event_전투특기_격노']);
});
});