feat: 사령턴 대상 선택 정보를 보강

도시·수도 지도와 명령별 국가/장수 상세 목록을 추가한다.\nRef 금액 프리셋과 현재 조건 기반 우선 정렬을 예약 입력에 연결한다.
This commit is contained in:
2026-08-18 13:12:24 +00:00
parent e4c4bd946f
commit 7da67da40f
13 changed files with 1292 additions and 129 deletions
+483 -31
View File
@@ -48,21 +48,184 @@ const inputOptions = {
nations: [
{ value: 1, label: '아국', color: '#008000' },
{ value: 2, label: '적국', color: '#800000', description: '수도 허창' },
{ value: 3, label: '불가침국', color: '#000080', description: '수도 단양' },
],
nationTargets: {
che_물자원조: [
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 원조 대상 · 교역' },
{
value: 3,
label: '불가침국',
color: '#000080',
availableNow: true,
description: '현재 원조 대상 · 불가침 12턴',
},
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
],
che_불가침제의: [
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 제의 가능 · 교역' },
{
value: 3,
label: '불가침국',
color: '#000080',
availableNow: true,
description: '현재 제의 가능 · 불가침 12턴',
},
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
],
che_선전포고: [
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 선전포고 가능 · 교역' },
{
value: 3,
label: '불가침국',
color: '#000080',
availableNow: false,
description: '현재 외교 관계에서는 선전포고 불가',
},
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
],
che_종전제의: [
{
value: 2,
label: '적국',
color: '#800000',
availableNow: true,
description: '현재 종전 제의 가능 · 전쟁 6턴',
},
{
value: 3,
label: '불가침국',
color: '#000080',
availableNow: false,
description: '전쟁·선포 중인 국가가 아닙니다.',
},
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
],
che_불가침파기제의: [
{
value: 3,
label: '불가침국',
color: '#000080',
availableNow: true,
description: '현재 불가침 파기 제의 가능 · 불가침 12턴',
},
{
value: 2,
label: '적국',
color: '#800000',
availableNow: false,
description: '불가침 중인 국가가 아닙니다.',
},
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
],
},
generals: [
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
],
generalTargets: {
che_포상: [
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
{ value: 3, label: '여포NPC (아국 · 업)' },
{
value: 1,
label: '장수 (아국 · 업)',
gold: 5000,
rice: 400,
crew: 500,
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
},
{
value: 2,
label: '관우 (아국 · 업)',
gold: 100,
rice: 4000,
crew: 1200,
troopId: 2,
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
},
{
value: 3,
label: '여포NPC (아국 · 업)',
gold: 3000,
rice: 500,
crew: 1500,
troopId: 2,
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
},
],
che_몰수: [
{ value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' },
{ value: 3, label: '여포NPC (아국 · 업)' },
{
value: 1,
label: '장수 (아국 · 업)',
gold: 5000,
rice: 400,
crew: 500,
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
},
{
value: 2,
label: '관우 (아국 · 업)',
gold: 100,
rice: 4000,
crew: 1200,
troopId: 2,
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
},
{
value: 3,
label: '여포NPC (아국 · 업)',
gold: 3000,
rice: 500,
crew: 1500,
troopId: 2,
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
},
],
che_발령: [
{
value: 1,
label: '장수 (아국 · 업)',
crew: 500,
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
},
{
value: 2,
label: '관우 (아국 · 업)',
crew: 1200,
troopId: 2,
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
},
{
value: 3,
label: '여포NPC (아국 · 업)',
crew: 1500,
troopId: 2,
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
},
],
che_부대탈퇴지시: [
{
value: 3,
label: '여포NPC (아국 · 업)',
availableNow: true,
crew: 1500,
troopId: 2,
description: '현재 탈퇴 지시 가능 · 병력 1,500 · 탑승 부대 청룡대',
},
{
value: 1,
label: '장수 (아국 · 업)',
availableNow: false,
crew: 500,
description: '현재 탈퇴 지시 불가 · 탑승 부대 없음',
},
{
value: 2,
label: '관우 (아국 · 업)',
availableNow: false,
crew: 1200,
troopId: 2,
description: '현재 탈퇴 지시 불가 · 탑승 부대 청룡대 (부대장)',
},
],
},
crewTypes: [{ value: 1100, label: '보병' }],
@@ -115,6 +278,11 @@ const inputOptions = {
},
],
},
amountPresets: {
che_포상: { values: [100, 500, 1000, 5000, 10000], defaultValue: 1000, min: 100, max: 10000, step: 1 },
che_몰수: { values: [100, 500, 1000, 5000, 10000], defaultValue: 1000, min: 100, max: 10000, step: 1 },
che_물자원조: { values: [10000, 20000, 30000], defaultValue: 1000, min: 1000, max: 30000, step: 10 },
},
context: {
actorGold: 1000,
actorRice: 1000,
@@ -124,6 +292,40 @@ const inputOptions = {
nationLevel: 1,
},
};
const buildCityCommand = (key: string, name: string) => ({
key,
name,
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [{ key: 'destCityId', label: '대상 도시', kind: 'select', required: true, optionSource: 'cities' }],
});
const buildNationCommand = (key: string, name: string) => ({
key,
name,
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [{ key: 'destNationId', label: '대상 국가', kind: 'select', required: true, optionSource: 'nations' }],
});
const buildGeneralCommand = (key: string, name: string) => ({
key,
name,
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{ key: 'destGeneralId', label: '대상 장수', kind: 'select', required: true, optionSource: 'generals' },
],
});
const buildSimpleCommand = (key: string, name: string) => ({
key,
name,
reqArg: false,
possible: true,
status: 'available',
inputFields: [],
});
const commandTable = {
general: [
{
@@ -221,6 +423,13 @@ const commandTable = {
{
category: '인사',
values: [
{
...buildGeneralCommand('che_발령', '발령'),
inputFields: [
...buildGeneralCommand('che_발령', '발령').inputFields,
...buildCityCommand('che_발령', '발령').inputFields,
],
},
{
key: 'che_포상',
name: '포상',
@@ -239,27 +448,64 @@ const commandTable = {
},
],
},
{
key: 'che_몰수',
name: '몰수',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
{ key: 'isGold', label: '물자', kind: 'boolean', required: true },
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
...buildGeneralCommand('che_몰수', '몰수').inputFields,
],
},
buildGeneralCommand('che_부대탈퇴지시', '부대 탈퇴 지시'),
],
},
{
category: '외교',
values: [
{
key: 'che_선전포고',
name: '선전포고',
key: 'che_물자원조',
name: '원조',
reqArg: true,
possible: true,
status: 'needsInput',
inputFields: [
...buildNationCommand('che_물자원조', '원조').inputFields,
{
key: 'destNationId',
label: '대상 국가',
kind: 'select',
key: 'amountList',
label: '지원 물자',
kind: 'numberTuple',
required: true,
optionSource: 'nations',
min: 0,
step: 1,
tupleLabels: ['금', '쌀'],
},
],
},
buildNationCommand('che_불가침제의', '불가침 제의'),
buildNationCommand('che_선전포고', '선전포고'),
buildNationCommand('che_종전제의', '종전 제의'),
buildNationCommand('che_불가침파기제의', '불가침 파기 제의'),
],
},
{
category: '특수',
values: [
buildCityCommand('che_초토화', '초토화'),
buildCityCommand('che_천도', '천도'),
buildSimpleCommand('che_증축', '증축'),
buildSimpleCommand('che_감축', '감축'),
],
},
{
category: '전략',
values: [
buildCityCommand('che_백성동원', '백성동원'),
buildCityCommand('che_수몰', '수몰'),
buildCityCommand('che_허보', '허보'),
],
},
],
@@ -330,14 +576,6 @@ const fourArmRecruitmentCommandTable = {
},
},
};
const buildSimpleCommand = (key: string, name: string) => ({
key,
name,
reqArg: false,
possible: true,
status: 'available',
inputFields: [],
});
const refChiefCommandTable = {
general: [],
nation: [
@@ -1063,13 +1301,13 @@ test('enters general and nation command arguments and sends exact values', async
await chiefPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await chiefPicker.getByRole('button', { name: /포상/ }).click();
const chiefForm = chiefPicker.getByTestId('command-argument-form');
await chiefForm.getByRole('button', { name: '쌀' }).click();
await chiefForm.getByRole('button', { name: '쌀', exact: true }).click();
await chiefForm.locator('input[type=number]').fill('300');
const chiefTarget = chiefForm.locator('select');
const chiefTarget = chiefForm.locator('#command-arg-destGeneralId');
await expect(chiefTarget.locator('option')).toHaveText([
'장수 (아국 · 업)',
'관우 (아국 · 업)',
'여포NPC (아국 · 업)',
'관우 (아국 · 업)',
]);
await chiefTarget.selectOption('3');
const geometry = await chiefForm.evaluate((element) => {
@@ -1332,9 +1570,9 @@ test('keeps arbitrary direct recruitment and mercenary amounts for all four arms
await row.getByRole('button', { name: entry.command, exact: true }).click();
await expect(picker).toHaveCount(0);
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText(
`${entry.name}${entry.savedAmount}${entry.command}`
);
await expect(
page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)
).toHaveText(`${entry.name}${entry.savedAmount}${entry.command}`);
}
const refreshResponse = page.waitForResponse((apiResponse) =>
@@ -1344,9 +1582,9 @@ test('keeps arbitrary direct recruitment and mercenary amounts for all four arms
await refreshResponse;
for (const entry of entries) {
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText(
`${entry.name}${entry.savedAmount}${entry.command}`
);
await expect(
page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)
).toHaveText(`${entry.name}${entry.savedAmount}${entry.command}`);
}
await page
.locator('[data-command-scope="general"]')
@@ -1379,6 +1617,214 @@ test('uses the map to choose a nation target in the chief command window', async
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
});
test('shows city or capital maps for every requested chief command', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
const cases = [
{ category: '인사', action: '발령', mode: 'city' },
{ category: '특수', action: '초토화', mode: 'city' },
{ category: '특수', action: '천도', mode: 'city' },
{ category: '특수', action: '증축', mode: 'capital' },
{ category: '특수', action: '감축', mode: 'capital' },
{ category: '전략', action: '수몰', mode: 'city' },
{ category: '전략', action: '허보', mode: 'city' },
{ category: '전략', action: '백성동원', mode: 'city' },
];
for (const entry of cases) {
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: new RegExp(`^(?:국가:)?${entry.category}$`) }).click();
await picker.getByRole('button', { name: new RegExp(entry.action) }).click();
const form = picker.getByTestId('command-argument-form');
const map = form.getByTestId('command-argument-map');
await expect(map, `${entry.action} 지도`).toBeVisible();
await expect(form.getByTestId('command-argument-guidance')).toBeVisible();
if (entry.mode === 'capital') {
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 수도업');
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 수도');
await expect(map.locator('.city-base').first()).toHaveJSProperty('tagName', 'DIV');
expect(
await map
.locator('.city-base')
.first()
.evaluate((node) => getComputedStyle(node).cursor)
).toBe('default');
} else {
await map.locator('.city-base').nth(1).click();
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
}
}
await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true });
});
test('prioritizes current nation targets while preserving every choice', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
const cases = [
{ action: '원조', first: '적국' },
{ action: '불가침 제의', first: '적국' },
{ action: '선전포고', first: '적국' },
{ action: '종전 제의', first: '적국' },
{ action: '불가침 파기 제의', first: '불가침국' },
];
for (const entry of cases) {
await page.goto('/che/chief-center');
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: new RegExp(entry.action) }).click();
const form = picker.getByTestId('command-argument-form');
const targetList = form.getByTestId('nation-target-list');
const targets = targetList.locator('.target-option');
await expect(targets).toHaveCount(3);
await expect(targets.first().locator('strong')).toHaveText(entry.first);
await expect(targets.first().locator('.target-state')).toHaveText('우선 대상');
await expect(targets.last().locator('.target-state')).toHaveText('현재 불가');
await expect(form.locator('#command-arg-destNationId')).toHaveValue(entry.first === '불가침국' ? '3' : '2');
await expect(form.getByTestId('command-argument-map')).toBeVisible();
}
const form = page.getByTestId('command-picker').getByTestId('command-argument-form');
const targets = form.getByTestId('nation-target-list').locator('.target-option');
await targets.filter({ hasText: '적국' }).click();
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
await expect(targets.filter({ hasText: '적국' })).toHaveClass(/selected/);
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창');
await page.screenshot({ path: test.info().outputPath('chief-nation-target-priority.png'), fullPage: true });
await page.setViewportSize({ width: 500, height: 900 });
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
const mobilePicker = page.getByTestId('command-picker');
await mobilePicker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
await mobilePicker.getByRole('button', { name: /불가침 파기 제의/ }).click();
const mobileTargets = mobilePicker.getByTestId('nation-target-list').locator('.target-option');
await mobileTargets.nth(1).hover();
expect(await mobileTargets.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
await mobileTargets.nth(1).focus();
await expect(mobileTargets.nth(1)).toBeFocused();
const mobileGeometry = await mobilePicker.evaluate((element) => {
const list = element.querySelector<HTMLElement>('[data-testid="nation-target-list"]')!;
const cards = Array.from(list.querySelectorAll<HTMLElement>('.target-option'));
const listRect = list.getBoundingClientRect();
return {
pickerWidth: element.getBoundingClientRect().width,
pickerOverflow: element.scrollWidth - element.clientWidth,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
cardsInsideList: cards.every((card) => {
const rect = card.getBoundingClientRect();
return rect.left >= listRect.left && rect.right <= listRect.right;
}),
};
});
expect(mobileGeometry).toEqual({
pickerWidth: 500,
pickerOverflow: 0,
documentOverflow: 0,
cardsInsideList: true,
});
await page.screenshot({ path: test.info().outputPath('chief-nation-target-priority-mobile.png'), fullPage: true });
});
test('offers Ref amount presets and rich, command-specific general lists', async ({ page }) => {
const requests = await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
let picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await picker.getByRole('button', { name: /포상/ }).click();
let form = picker.getByTestId('command-argument-form');
const amount = form.locator('#command-arg-amount');
const amountPreset = form.getByRole('combobox', { name: '금액 프리셋' });
await expect(amount).toHaveValue('1000');
await expect(amountPreset.locator('option')).toHaveText(['프리셋', '100', '500', '1,000', '5,000', '10,000']);
await amountPreset.selectOption('5000');
await expect(amount).toHaveValue('5000');
await amount.fill('1375');
await expect(amount).toHaveValue('1375');
let generalList = form.getByTestId('general-target-list');
await expect(generalList.locator('.target-option strong')).toHaveText([
'관우 (아국 · 업)',
'여포NPC (아국 · 업)',
'장수 (아국 · 업)',
]);
await expect(generalList).toContainText('금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)');
await form.getByRole('button', { name: '쌀', exact: true }).click();
await expect(generalList.locator('.target-option strong')).toHaveText([
'장수 (아국 · 업)',
'여포NPC (아국 · 업)',
'관우 (아국 · 업)',
]);
await generalList.locator('.target-option').filter({ hasText: '여포NPC' }).click();
const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
await picker.getByRole('button', { name: '입력', exact: true }).click();
await awardResponse;
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await picker.getByRole('button', { name: /몰수/ }).click();
form = picker.getByTestId('command-argument-form');
generalList = form.getByTestId('general-target-list');
await expect(generalList.locator('.target-option strong')).toHaveText([
'장수 (아국 · 업)',
'여포NPC (아국 · 업)',
'관우 (아국 · 업)',
]);
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await picker.getByRole('button', { name: /발령/ }).click();
form = picker.getByTestId('command-argument-form');
await expect(form.getByTestId('general-target-list')).toContainText('병력 1,200 · 탑승 부대 청룡대 (부대장)');
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
await picker.getByRole('button', { name: /부대 탈퇴 지시/ }).click();
form = picker.getByTestId('command-argument-form');
generalList = form.getByTestId('general-target-list');
await expect(generalList.locator('.target-option strong').first()).toHaveText('여포NPC (아국 · 업)');
await expect(generalList.locator('.target-option').first().locator('.target-state')).toHaveText('우선 대상');
await expect(generalList.locator('.target-option').nth(1).locator('.target-state')).toHaveText('현재 불가');
await page.goto('/che/chief-center');
await page.getByRole('button', { name: '5턴 명령 입력', exact: true }).click();
picker = page.getByTestId('command-picker');
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
await picker.getByRole('button', { name: /원조/ }).click();
form = picker.getByTestId('command-argument-form');
const tupleInputs = form.locator('.tuple-options input[type=number]');
await expect(tupleInputs.nth(0)).toHaveValue('1000');
await expect(tupleInputs.nth(1)).toHaveValue('1000');
await form.getByRole('combobox', { name: '금 금액 프리셋' }).selectOption('20000');
await tupleInputs.nth(1).fill('1370');
await expect(tupleInputs.nth(0)).toHaveValue('20000');
await expect(tupleInputs.nth(1)).toHaveValue('1370');
const aidResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
await picker.getByRole('button', { name: '입력', exact: true }).click();
await aidResponse;
const serialized = JSON.stringify(requests);
expect(serialized).toContain('"amount":1375');
expect(serialized).toContain('"destGeneralId":3');
expect(serialized).toContain('"amountList":[20000,1370]');
await page.screenshot({ path: test.info().outputPath('chief-ref-guidance-controls.png'), fullPage: true });
});
test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 500, height: 900 });
@@ -1475,7 +1921,10 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('visible');
expect((await tooltipStyle()).content).toContain('자율 행동: 200年 3月');
expect(await editor.evaluate((element) => element.getBoundingClientRect().height)).toBe(heightBeforeHover);
await page.screenshot({ path: test.info().outputPath('command-brief-autorun-hover-desktop-1200.png'), fullPage: true });
await page.screenshot({
path: test.info().outputPath('command-brief-autorun-hover-desktop-1200.png'),
fullPage: true,
});
await page.mouse.move(0, 0);
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('hidden');
@@ -1507,7 +1956,10 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
expect(desktopGeometry.controlPadOffset).toBe(0);
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(20);
await page.screenshot({ path: test.info().outputPath('command-brief-autorun-focus-desktop-1200.png'), fullPage: true });
await page.screenshot({
path: test.info().outputPath('command-brief-autorun-focus-desktop-1200.png'),
fullPage: true,
});
const mobilePage = await context.newPage();
await install(mobilePage);