merge: 최신 원격 main을 사령부 지도 복구에 통합
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../../turns/commandTable.js';
|
} from '../../turns/commandTable.js';
|
||||||
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
|
||||||
import {
|
import {
|
||||||
|
buildEquipmentTradeItemOptions,
|
||||||
parseReservedTurnArgs,
|
parseReservedTurnArgs,
|
||||||
TURN_COMMAND_NATION_COLORS,
|
TURN_COMMAND_NATION_COLORS,
|
||||||
type TurnCommandInputOptions,
|
type TurnCommandInputOptions,
|
||||||
@@ -283,29 +284,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
|
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
|
||||||
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
|
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
|
||||||
});
|
});
|
||||||
const items: TurnCommandInputOptions['items'] = {
|
const items = buildEquipmentTradeItemOptions({
|
||||||
horse: [{ value: 'None', label: '판매/해제' }],
|
configConst: asRecord(asRecord(worldState.config).const),
|
||||||
weapon: [{ value: 'None', label: '판매/해제' }],
|
itemModules: moduleBundle.itemModules,
|
||||||
book: [{ value: 'None', label: '판매/해제' }],
|
currentSecurity: city?.security ?? 0,
|
||||||
item: [{ value: 'None', label: '판매/해제' }],
|
generalGold: general.gold,
|
||||||
};
|
});
|
||||||
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 inputOptions: TurnCommandInputOptions = {
|
const inputOptions: TurnCommandInputOptions = {
|
||||||
cities: cities.map((entry) => ({
|
cities: cities.map((entry) => ({
|
||||||
value: entry.id,
|
value: entry.id,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
type NationTurnCommandSpec,
|
type NationTurnCommandSpec,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
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 { z } from 'zod';
|
||||||
|
|
||||||
import { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
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 = [
|
export const TURN_COMMAND_NATION_COLORS = [
|
||||||
'#FF0000',
|
'#FF0000',
|
||||||
|
|||||||
@@ -6,7 +6,24 @@ import {
|
|||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('turn command argument input', () => {
|
||||||
it('builds supported fields for every argument-bearing command module', async () => {
|
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');
|
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,
|
loadActionModuleBundle,
|
||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
|
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import { createRuntimeTrace } from './runtimeTrace.js';
|
import { createRuntimeTrace } from './runtimeTrace.js';
|
||||||
|
|
||||||
// legacy GameConstBase 기본값
|
// legacy GameConstBase 기본값
|
||||||
@@ -146,6 +147,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
|
|||||||
['maxResourceActionAmount'],
|
['maxResourceActionAmount'],
|
||||||
DEFAULT_MAX_RESOURCE_ACTION_AMOUNT
|
DEFAULT_MAX_RESOURCE_ACTION_AMOUNT
|
||||||
),
|
),
|
||||||
|
purchasableItemKeys: resolveLegacyPurchasableItemKeys(constValues),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
|
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
|
||||||
|
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
|
||||||
|
|
||||||
type LoadedScenario = Awaited<ReturnType<typeof loadScenarioDefinitionById>>;
|
type LoadedScenario = Awaited<ReturnType<typeof loadScenarioDefinitionById>>;
|
||||||
|
|
||||||
@@ -77,4 +78,18 @@ describe('tracked scenario resources', () => {
|
|||||||
expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4);
|
expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4);
|
||||||
expect(readItemSlot(composedAddon, 'horse').che_명마_07_백마).toBe(2);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -751,7 +751,13 @@ const chiefCenter = {
|
|||||||
npcState: officerLevel === 8 ? 2 : 0,
|
npcState: officerLevel === 8 ? 2 : 0,
|
||||||
turnTime: null,
|
turnTime: null,
|
||||||
revision: 0,
|
revision: 0,
|
||||||
turns: turns(12),
|
turns:
|
||||||
|
officerLevel === 12
|
||||||
|
? [
|
||||||
|
{ index: 0, action: 'che_포상', args: { destGeneralId: 2, isGold: false, amount: 300 } },
|
||||||
|
...turns(11).map((turn) => ({ ...turn, index: turn.index + 1 })),
|
||||||
|
]
|
||||||
|
: turns(12),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1519,6 +1525,36 @@ test('enters general and nation command arguments and sends exact values', async
|
|||||||
expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10);
|
expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows full nation command briefs in every chief card', async ({ page }) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('/che/chief-center');
|
||||||
|
|
||||||
|
const desktopSummary = page.locator('.layout-desktop .chief-card').first().locator('.row-action').first();
|
||||||
|
await expect(desktopSummary).toHaveText('【관우】 쌀 300 포상');
|
||||||
|
await expect(desktopSummary).toHaveAttribute('title', '【관우】 쌀 300 포상');
|
||||||
|
await page.screenshot({ path: test.info().outputPath('chief-card-command-brief-desktop-1200.png'), fullPage: true });
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
const mobileSummary = page.locator('.chief-overview .chief-card').first().locator('.row-action').first();
|
||||||
|
await expect(mobileSummary).toHaveText('【관우】 쌀 300 포상');
|
||||||
|
await expect(mobileSummary).toHaveAttribute('title', '【관우】 쌀 300 포상');
|
||||||
|
|
||||||
|
const geometry = await mobileSummary.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const card = element.closest<HTMLElement>('.chief-card');
|
||||||
|
if (!card) throw new Error('chief card is missing');
|
||||||
|
return {
|
||||||
|
width: rect.width,
|
||||||
|
cardWidth: card.getBoundingClientRect().width,
|
||||||
|
horizontalOverflow: card.scrollWidth - card.clientWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.width).toBeLessThanOrEqual(geometry.cardWidth);
|
||||||
|
expect(geometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||||
|
await page.screenshot({ path: test.info().outputPath('chief-card-command-brief-mobile-500.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('uses a Ref-style full recruitment page without horizontal overflow on desktop or mobile', async ({
|
test('uses a Ref-style full recruitment page without horizontal overflow on desktop or mobile', async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type NavigationFixture = {
|
|||||||
accessLimitAfterCalls?: number;
|
accessLimitAfterCalls?: number;
|
||||||
largeCommandTable?: boolean;
|
largeCommandTable?: boolean;
|
||||||
draftCommandTable?: boolean;
|
draftCommandTable?: boolean;
|
||||||
|
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>;
|
||||||
refCommandCategories?: boolean;
|
refCommandCategories?: boolean;
|
||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
@@ -235,6 +236,7 @@ const draftCommandGroups = [
|
|||||||
options: [
|
options: [
|
||||||
{ value: 'horse', label: '명마' },
|
{ value: 'horse', label: '명마' },
|
||||||
{ value: 'weapon', 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
|
general: draftCommands
|
||||||
? draftCommandGroups
|
? draftCommandGroups
|
||||||
: refCategories
|
: refCategories
|
||||||
@@ -309,6 +317,7 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f
|
|||||||
{ value: 'None', label: '없음' },
|
{ value: 'None', label: '없음' },
|
||||||
{ value: '청룡언월도', label: '청룡언월도' },
|
{ value: '청룡언월도', label: '청룡언월도' },
|
||||||
],
|
],
|
||||||
|
item: equipmentItemOptions ?? [{ value: 'None', label: '없음' }],
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
context: draftCommands
|
context: draftCommands
|
||||||
@@ -595,7 +604,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
state.largeCommandTable === true,
|
state.largeCommandTable === true,
|
||||||
state.commandBlockedCount,
|
state.commandBlockedCount,
|
||||||
state.refCommandCategories === true,
|
state.refCommandCategories === true,
|
||||||
state.draftCommandTable === true
|
state.draftCommandTable === true,
|
||||||
|
state.equipmentItemOptions
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
: input.known.commandTable === currentCommandTableRevision
|
: 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 ({
|
test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const handleClick = () => {
|
|||||||
<div v-for="row in props.rows" :key="row.index" class="chief-row" :class="{ rest: row.isRest }">
|
<div v-for="row in props.rows" :key="row.index" class="chief-row" :class="{ rest: row.isRest }">
|
||||||
<span class="row-index">#{{ row.index + 1 }}</span>
|
<span class="row-index">#{{ row.index + 1 }}</span>
|
||||||
<span class="row-time">{{ row.time }}</span>
|
<span class="row-time">{{ row.time }}</span>
|
||||||
<span class="row-action">{{ row.action }}</span>
|
<span class="row-action" :title="row.action">{{ row.action }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
|
|||||||
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
|
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||||
|
import { formatReservedCommandBrief } from '../components/command/reservedCommandBrief';
|
||||||
import type { CommandMapData, CommandMapLayout, CommandPatternEntry, CommandTable } from '../components/command/types';
|
import type { CommandMapData, CommandMapLayout, CommandPatternEntry, CommandTable } from '../components/command/types';
|
||||||
|
|
||||||
type ChiefTurn = {
|
type ChiefTurn = {
|
||||||
@@ -219,7 +220,10 @@ const buildTurnRows = (chief: ChiefEntry): TurnRow[] => {
|
|||||||
? `${String(turnDate.getUTCHours()).padStart(2, '0')}:${String(turnDate.getUTCMinutes()).padStart(2, '0')}`
|
? `${String(turnDate.getUTCHours()).padStart(2, '0')}:${String(turnDate.getUTCMinutes()).padStart(2, '0')}`
|
||||||
: `${String(turnDate.getUTCMinutes()).padStart(2, '0')}:${String(turnDate.getUTCSeconds()).padStart(2, '0')}`
|
: `${String(turnDate.getUTCMinutes()).padStart(2, '0')}:${String(turnDate.getUTCSeconds()).padStart(2, '0')}`
|
||||||
: '--:--';
|
: '--:--';
|
||||||
const actionLabel = labelMap.get(turn.action) ?? turn.action;
|
const actionLabel =
|
||||||
|
formatReservedCommandBrief('nation', turn.action, turn.args, commandTable.value) ??
|
||||||
|
labelMap.get(turn.action) ??
|
||||||
|
turn.action;
|
||||||
return {
|
return {
|
||||||
index: turn.index,
|
index: turn.index,
|
||||||
time: timeLabel,
|
time: timeLabel,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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는 유지합니다.
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ Core의 `extends`는 이 중복 값을 소스에서 재사용하기 위한 합
|
|||||||
따라서 일반 공백지 시나리오에는
|
따라서 일반 공백지 시나리오에는
|
||||||
`extensions/items/buyable-war-special-uniques.json`이 암묵적으로 적용되지 않습니다.
|
`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_902`(천지비급), `scenario_910`(거울세계),
|
||||||
`scenario_912`(다병종), `scenario_913`(무한대흥)은 Ref 자체가 전투 특기 아이템
|
`scenario_912`(다병종), `scenario_913`(무한대흥)은 Ref 자체가 전투 특기 아이템
|
||||||
풀을 직접 정의합니다. 이 네 시나리오는 최신 공통 확장과 항목 또는 유니크 수량이
|
풀을 직접 정의합니다. 이 네 시나리오는 최신 공통 확장과 항목 또는 유니크 수량이
|
||||||
|
|||||||
@@ -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 유지 배포`는 현재 시즌을 계속 운영하면서 코드를 교체할 때 사용합니다.
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export interface TurnCommandEnv {
|
|||||||
npcSeizureMessageProb?: number;
|
npcSeizureMessageProb?: number;
|
||||||
maxResourceActionAmount: number;
|
maxResourceActionAmount: number;
|
||||||
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
|
||||||
|
purchasableItemKeys?: ReadonlySet<string>;
|
||||||
generalActionModules?: RefOrderedActionStack<GeneralActionModule>;
|
generalActionModules?: RefOrderedActionStack<GeneralActionModule>;
|
||||||
warActionModules?: RefOrderedActionStack<WarActionModule>;
|
warActionModules?: RefOrderedActionStack<WarActionModule>;
|
||||||
nationTraitModules?: Array<NationTraitModule>;
|
nationTraitModules?: Array<NationTraitModule>;
|
||||||
|
|||||||
@@ -79,7 +79,11 @@ export class ActionDefinition<
|
|||||||
if (!item) {
|
if (!item) {
|
||||||
return null;
|
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 null;
|
||||||
}
|
}
|
||||||
return args;
|
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 => {
|
export const buildLegacyDefaultUniqueItemPool = (itemRegistry: Map<string, ItemModule>): UniqueItemPool => {
|
||||||
const pool: UniqueItemPool = { horse: {}, weapon: {}, book: {}, item: {} };
|
const pool: UniqueItemPool = { horse: {}, weapon: {}, book: {}, item: {} };
|
||||||
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
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을 반환한다', () => {
|
it('계략 성공 capability만 소비하며 typed 결과로 소비 item을 반환한다', () => {
|
||||||
const general = makeGeneral('che_계략_이추');
|
const general = makeGeneral('che_계략_이추');
|
||||||
const itemModules = createItemActionModules(createItemModuleRegistry([strategyItemModule]));
|
const itemModules = createItemActionModules(createItemModuleRegistry([strategyItemModule]));
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('legacy-compatible unique item pool', () => {
|
||||||
it.each([undefined, {}, '{}'] as const)('restores the Ref default pool when allItems is %j', async (allItems) => {
|
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_전투특기_격노']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user