merge: 최신 main을 사령부 턴 요약 변경에 통합
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user