merge: 메인 예약턴 분류를 Ref 순서로 복원
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
|||||||
GeneralItemSlots,
|
GeneralItemSlots,
|
||||||
GeneralActionDefinition,
|
GeneralActionDefinition,
|
||||||
GeneralTurnCommandSpec,
|
GeneralTurnCommandSpec,
|
||||||
|
GeneralTurnCommandKey,
|
||||||
MapDefinition,
|
MapDefinition,
|
||||||
Nation,
|
Nation,
|
||||||
NationTurnCommandSpec,
|
NationTurnCommandSpec,
|
||||||
@@ -72,6 +73,83 @@ interface CommandEntry {
|
|||||||
evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore;
|
evaluate?: (ctx: ConstraintContext, view: StateView) => AvailabilityCore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REF_GENERAL_COMMAND_GROUPS = [
|
||||||
|
{
|
||||||
|
category: '개인',
|
||||||
|
commands: [
|
||||||
|
'휴식',
|
||||||
|
'che_요양',
|
||||||
|
'che_단련',
|
||||||
|
'che_숙련전환',
|
||||||
|
'che_견문',
|
||||||
|
'che_은퇴',
|
||||||
|
'che_장비매매',
|
||||||
|
'che_군량매매',
|
||||||
|
'che_내정특기초기화',
|
||||||
|
'che_전투특기초기화',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '내정',
|
||||||
|
commands: [
|
||||||
|
'che_농지개간',
|
||||||
|
'che_상업투자',
|
||||||
|
'che_기술연구',
|
||||||
|
'che_수비강화',
|
||||||
|
'che_성벽보수',
|
||||||
|
'che_치안강화',
|
||||||
|
'che_정착장려',
|
||||||
|
'che_주민선정',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '군사',
|
||||||
|
commands: [
|
||||||
|
'che_징병',
|
||||||
|
'che_모병',
|
||||||
|
'che_훈련',
|
||||||
|
'che_사기진작',
|
||||||
|
'che_출병',
|
||||||
|
'che_집합',
|
||||||
|
'che_소집해제',
|
||||||
|
'che_첩보',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '인사',
|
||||||
|
commands: [
|
||||||
|
'che_이동',
|
||||||
|
'che_강행',
|
||||||
|
'che_인재탐색',
|
||||||
|
'che_등용',
|
||||||
|
'che_귀환',
|
||||||
|
'che_임관',
|
||||||
|
'che_랜덤임관',
|
||||||
|
'che_장수대상임관',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '계략',
|
||||||
|
commands: ['che_선동', 'che_탈취', 'che_파괴', 'che_화계'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: '국가',
|
||||||
|
commands: ['che_증여', 'che_헌납', 'che_물자조달', 'che_하야', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
||||||
|
},
|
||||||
|
] as const satisfies ReadonlyArray<{
|
||||||
|
category: string;
|
||||||
|
commands: ReadonlyArray<GeneralTurnCommandKey>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const REF_GENERAL_CATEGORY_ORDER = new Map<string, number>(
|
||||||
|
REF_GENERAL_COMMAND_GROUPS.map(({ category }, index) => [category, index] as const)
|
||||||
|
);
|
||||||
|
const REF_GENERAL_COMMAND_POSITION = new Map<string, { category: string; index: number }>(
|
||||||
|
REF_GENERAL_COMMAND_GROUPS.flatMap(({ category, commands }) =>
|
||||||
|
commands.map((command, index) => [command, { category, index }] as const)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const INPUT_REQUIREMENT_KINDS = new Set<RequirementKey['kind']>([
|
const INPUT_REQUIREMENT_KINDS = new Set<RequirementKey['kind']>([
|
||||||
'destGeneral',
|
'destGeneral',
|
||||||
'destCity',
|
'destCity',
|
||||||
@@ -567,6 +645,26 @@ const buildGroups = (entries: CommandEntry[], ctx: ConstraintContext, view: Stat
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const projectRefGeneralCommandGroups = (entries: CommandEntry[]): CommandEntry[] =>
|
||||||
|
entries
|
||||||
|
.map((entry, profileIndex) => {
|
||||||
|
const refPosition = REF_GENERAL_COMMAND_POSITION.get(entry.definition.key as GeneralTurnCommandKey);
|
||||||
|
return {
|
||||||
|
entry: refPosition ? { ...entry, category: refPosition.category } : entry,
|
||||||
|
categoryIndex:
|
||||||
|
REF_GENERAL_CATEGORY_ORDER.get(refPosition?.category ?? entry.category) ?? Number.MAX_SAFE_INTEGER,
|
||||||
|
commandIndex: refPosition?.index ?? profileIndex,
|
||||||
|
profileIndex,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.categoryIndex - right.categoryIndex ||
|
||||||
|
left.commandIndex - right.commandIndex ||
|
||||||
|
left.profileIndex - right.profileIndex
|
||||||
|
)
|
||||||
|
.map(({ entry }) => entry);
|
||||||
|
|
||||||
export const buildTurnCommandTable = async (options: {
|
export const buildTurnCommandTable = async (options: {
|
||||||
worldState: WorldStateRow;
|
worldState: WorldStateRow;
|
||||||
general: GeneralRow;
|
general: GeneralRow;
|
||||||
@@ -598,7 +696,7 @@ export const buildTurnCommandTable = async (options: {
|
|||||||
const nationEntries = buildEntries(env, nationSpecs);
|
const nationEntries = buildEntries(env, nationSpecs);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
general: buildGroups(generalEntries, ctx, view),
|
general: buildGroups(projectRefGeneralCommandGroups(generalEntries), ctx, view),
|
||||||
nation: buildGroups(nationEntries, ctx, view),
|
nation: buildGroups(nationEntries, ctx, view),
|
||||||
inputOptions: options.inputOptions ?? {
|
inputOptions: options.inputOptions ?? {
|
||||||
cities: [],
|
cities: [],
|
||||||
|
|||||||
@@ -100,6 +100,47 @@ const buildNation = (): NationRow =>
|
|||||||
}) as unknown as NationRow;
|
}) as unknown as NationRow;
|
||||||
|
|
||||||
describe('buildTurnCommandTable', () => {
|
describe('buildTurnCommandTable', () => {
|
||||||
|
it('projects the main reserved-turn categories and command order from Ref', async () => {
|
||||||
|
const table = await buildTurnCommandTable({
|
||||||
|
worldState: buildWorldState(),
|
||||||
|
general: buildGeneral(),
|
||||||
|
city: buildCity(),
|
||||||
|
nation: buildNation(),
|
||||||
|
nationGenerals: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(table.general.map(({ category }) => category)).toEqual(['개인', '내정', '군사', '인사', '계략', '국가']);
|
||||||
|
expect(
|
||||||
|
Object.fromEntries(table.general.map(({ category, values }) => [category, values.map(({ key }) => key)]))
|
||||||
|
).toEqual({
|
||||||
|
개인: [
|
||||||
|
'휴식',
|
||||||
|
'che_요양',
|
||||||
|
'che_단련',
|
||||||
|
'che_숙련전환',
|
||||||
|
'che_견문',
|
||||||
|
'che_장비매매',
|
||||||
|
'che_군량매매',
|
||||||
|
'che_내정특기초기화',
|
||||||
|
'che_전투특기초기화',
|
||||||
|
],
|
||||||
|
내정: [
|
||||||
|
'che_농지개간',
|
||||||
|
'che_상업투자',
|
||||||
|
'che_기술연구',
|
||||||
|
'che_수비강화',
|
||||||
|
'che_성벽보수',
|
||||||
|
'che_치안강화',
|
||||||
|
'che_정착장려',
|
||||||
|
'che_주민선정',
|
||||||
|
],
|
||||||
|
군사: ['che_징병', 'che_모병', 'che_훈련', 'che_사기진작', 'che_출병', 'che_집합', 'che_소집해제'],
|
||||||
|
인사: ['che_이동', 'che_인재탐색', 'che_귀환', 'che_임관', 'che_랜덤임관'],
|
||||||
|
계략: ['che_화계'],
|
||||||
|
국가: ['che_증여', 'che_헌납', 'che_물자조달', 'che_거병', 'che_건국', 'che_선양', 'che_해산'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('uses min-condition constraints for availability', async () => {
|
it('uses min-condition constraints for availability', async () => {
|
||||||
const table = await buildTurnCommandTable({
|
const table = await buildTurnCommandTable({
|
||||||
worldState: buildWorldState(),
|
worldState: buildWorldState(),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type NavigationFixture = {
|
|||||||
forceSnapshotCalls?: number;
|
forceSnapshotCalls?: number;
|
||||||
refreshDelayMs?: number;
|
refreshDelayMs?: number;
|
||||||
largeCommandTable?: boolean;
|
largeCommandTable?: boolean;
|
||||||
|
refCommandCategories?: boolean;
|
||||||
currentYear?: number;
|
currentYear?: number;
|
||||||
currentMonth?: number;
|
currentMonth?: number;
|
||||||
scenarioTitle?: string;
|
scenarioTitle?: string;
|
||||||
@@ -108,32 +109,48 @@ const emitReadModelInvalidation = (page: Page, invalidation: ReturnType<typeof r
|
|||||||
);
|
);
|
||||||
}, invalidation);
|
}, invalidation);
|
||||||
|
|
||||||
const commandTableFixture = (large: boolean, blockedCount = 0) => ({
|
const refCommandCategoryFixture = ['개인', '내정', '군사', '인사', '계략', '국가'].map((category, index) => ({
|
||||||
general: large
|
category,
|
||||||
? ['내정', '군사', '계략'].map((category, categoryIndex) => ({
|
values: [
|
||||||
category,
|
{
|
||||||
values: Array.from({ length: 16 }, (_, localIndex) => {
|
key: `ref-command-${index}`,
|
||||||
const index = categoryIndex * 16 + localIndex;
|
name: category === '계략' ? '화계' : `${category} 명령`,
|
||||||
return {
|
reqArg: false,
|
||||||
key: `command-${index}`,
|
possible: true,
|
||||||
name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`,
|
status: 'available' as const,
|
||||||
reqArg: index % 2 === 0,
|
inputFields: [],
|
||||||
possible: index >= blockedCount,
|
},
|
||||||
status: index >= blockedCount ? 'available' : 'blocked',
|
],
|
||||||
inputFields: [
|
}));
|
||||||
{
|
|
||||||
key: 'amount',
|
const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false) => ({
|
||||||
label: '수량',
|
general: refCategories
|
||||||
kind: 'number',
|
? refCommandCategoryFixture
|
||||||
required: true,
|
: large
|
||||||
min: 1,
|
? ['내정', '군사', '계략'].map((category, categoryIndex) => ({
|
||||||
max: 10_000,
|
category,
|
||||||
},
|
values: Array.from({ length: 16 }, (_, localIndex) => {
|
||||||
],
|
const index = categoryIndex * 16 + localIndex;
|
||||||
};
|
return {
|
||||||
}),
|
key: `command-${index}`,
|
||||||
}))
|
name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`,
|
||||||
: [],
|
reqArg: index % 2 === 0,
|
||||||
|
possible: index >= blockedCount,
|
||||||
|
status: index >= blockedCount ? 'available' : 'blocked',
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
label: '수량',
|
||||||
|
kind: 'number',
|
||||||
|
required: true,
|
||||||
|
min: 1,
|
||||||
|
max: 10_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
nation: [],
|
nation: [],
|
||||||
inputOptions: {
|
inputOptions: {
|
||||||
cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })),
|
cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })),
|
||||||
@@ -371,7 +388,11 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
|||||||
? {
|
? {
|
||||||
kind: 'snapshot' as const,
|
kind: 'snapshot' as const,
|
||||||
revision: currentCommandTableRevision,
|
revision: currentCommandTableRevision,
|
||||||
data: commandTableFixture(state.largeCommandTable === true, state.commandBlockedCount),
|
data: commandTableFixture(
|
||||||
|
state.largeCommandTable === true,
|
||||||
|
state.commandBlockedCount,
|
||||||
|
state.refCommandCategories === true
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: input.known.commandTable === currentCommandTableRevision
|
: input.known.commandTable === currentCommandTableRevision
|
||||||
? { kind: 'unchanged' as const, revision: currentCommandTableRevision }
|
? { kind: 'unchanged' as const, revision: currentCommandTableRevision }
|
||||||
@@ -953,6 +974,57 @@ test('pure NPC message senders are not rendered as reply targets', async ({ page
|
|||||||
await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`);
|
await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('main reserved-turn picker renders the Ref general category order', async ({ page }) => {
|
||||||
|
const state: NavigationFixture = {
|
||||||
|
officerLevel: 1,
|
||||||
|
permission: 0,
|
||||||
|
nationLevel: 1,
|
||||||
|
stage: 0,
|
||||||
|
npcMode: 1,
|
||||||
|
generalMeCalls: 0,
|
||||||
|
operations: [],
|
||||||
|
refCommandCategories: true,
|
||||||
|
reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })),
|
||||||
|
};
|
||||||
|
await installFixture(page, state);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await waitForMain(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
const picker = page.getByTestId('command-picker');
|
||||||
|
const categoryButtons = picker.locator('.category-btn');
|
||||||
|
await expect(categoryButtons).toHaveText(['개인', '내정', '군사', '인사', '계략', '국가']);
|
||||||
|
|
||||||
|
const desktopGeometry = await picker.evaluate((element) => {
|
||||||
|
const categories = element.querySelector<HTMLElement>('.category-list');
|
||||||
|
if (!categories) throw new Error('command category list is missing');
|
||||||
|
const buttons = [...categories.querySelectorAll<HTMLElement>('.category-btn')];
|
||||||
|
return {
|
||||||
|
columns: getComputedStyle(categories).gridTemplateColumns,
|
||||||
|
rows: new Set(buttons.map((button) => button.getBoundingClientRect().y)).size,
|
||||||
|
horizontalOverflow: element.scrollWidth - element.clientWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(desktopGeometry.columns.split(' ')).toHaveLength(3);
|
||||||
|
expect(desktopGeometry.rows).toBe(2);
|
||||||
|
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||||
|
|
||||||
|
const strategyCategory = picker.getByRole('button', { name: '계략', exact: true });
|
||||||
|
await strategyCategory.hover();
|
||||||
|
await strategyCategory.focus();
|
||||||
|
await expect(strategyCategory).toBeFocused();
|
||||||
|
await strategyCategory.click();
|
||||||
|
await expect(strategyCategory).toHaveClass(/active/);
|
||||||
|
await expect(picker.locator('.command-item')).toHaveText(['화계']);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
const mobilePicker = page.getByTestId('command-picker');
|
||||||
|
await expect(mobilePicker.locator('.category-btn')).toHaveText(['개인', '내정', '군사', '인사', '계략', '국가']);
|
||||||
|
expect(await mobilePicker.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0);
|
||||||
|
await persistArtifact(page, `${basePath.slice(1)}-main-reserved-ref-categories-mobile-500`);
|
||||||
|
});
|
||||||
|
|
||||||
test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => {
|
test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => {
|
||||||
const state: NavigationFixture = {
|
const state: NavigationFixture = {
|
||||||
officerLevel: 1,
|
officerLevel: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user