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);
@@ -251,7 +251,8 @@ const selectCommand = (commandKey: string) => {
selectedCommand.value = command;
commandArgs.value = {};
commandArgsValid.value = !command.reqArg;
if (!command.reqArg) submitCommand();
const needsInformationalConfirmation = commandArgumentPresentation(command.key).mapTarget === 'capital';
if (!command.reqArg && !needsInformationalConfirmation) submitCommand();
};
const submitCommand = () => {
const command = selectedCommand.value;
@@ -737,7 +738,11 @@ const clickOutsideMenu = (event: Event) => {
@submit="submitCommand"
/>
<CommandArgumentForm
v-else-if="selectedCommand.reqArg && props.commandTable"
v-else-if="
props.commandTable &&
(selectedCommand.reqArg ||
commandArgumentPresentation(selectedCommand.key).mapTarget === 'capital')
"
:command-key="selectedCommand.key"
:fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions"
@@ -1,10 +1,11 @@
export type CommandArgumentPresentation = {
lines: string[];
mapTarget?: 'city' | 'nation';
mapTarget?: 'city' | 'nation' | 'capital';
};
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' });
const capitalTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'capital' });
// Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다.
// 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다.
@@ -33,6 +34,14 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
]),
cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']),
che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']),
che_증축: capitalTarget([
'현재 수도를 증축해 인구·내정·성벽의 최대치를 높입니다.',
'지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.',
]),
che_감축: capitalTarget([
'현재 수도를 감축해 인구·내정·성벽의 최대치를 낮추고 국고를 회수합니다.',
'지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.',
]),
che_선전포고: nationTarget([
'선택한 국가에 선전포고합니다.',
@@ -3,6 +3,19 @@ export type CommandOption = {
label: string;
color?: string;
description?: string;
availableNow?: boolean;
gold?: number;
rice?: number;
crew?: number;
troopId?: number;
};
export type CommandAmountPreset = {
values: number[];
defaultValue: number;
min: number;
max: number;
step: number;
};
export type CommandMapData = {
@@ -94,6 +107,7 @@ export type CommandTable = {
inputOptions: {
cities: CommandOption[];
nations: CommandOption[];
nationTargets?: Record<string, CommandOption[]>;
generals: CommandOption[];
generalTargets?: Record<string, CommandOption[]>;
crewTypes: CommandOption[];
@@ -102,6 +116,7 @@ export type CommandTable = {
colors: CommandOption[];
items: Record<string, CommandOption[]>;
recruitment: RecruitmentInfo | null;
amountPresets?: Record<string, CommandAmountPreset>;
context?: CommandInputContext;
};
};
@@ -30,11 +30,31 @@ const values = reactive<Record<string, unknown>>({});
const presentation = computed(() => commandArgumentPresentation(props.commandKey));
const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden'));
const amountPreset = computed(() => props.options.amountPresets?.[props.commandKey]);
const sortGeneralOptions = (options: CommandOption[]): CommandOption[] => {
const result = [...options];
const resourceKey = values.isGold === false ? 'rice' : 'gold';
if (props.commandKey === 'che_포상') {
return result.sort((left, right) => (left[resourceKey] ?? 0) - (right[resourceKey] ?? 0));
}
if (props.commandKey === 'che_몰수') {
return result.sort((left, right) => (right[resourceKey] ?? 0) - (left[resourceKey] ?? 0));
}
if (props.commandKey === 'che_부대탈퇴지시') {
return result.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
}
return result;
};
const optionsFor = (field: CommandInputField): CommandOption[] => {
if (field.options) return field.options;
if (!field.optionSource) return [];
if (field.optionSource === 'generals') {
return props.options.generalTargets?.[props.commandKey] ?? props.options.generals;
return sortGeneralOptions(props.options.generalTargets?.[props.commandKey] ?? props.options.generals);
}
if (field.optionSource === 'nations') {
return props.options.nationTargets?.[props.commandKey] ?? props.options.nations;
}
if (field.optionSource === 'items') {
return props.options.items[String(values.itemType ?? '')] ?? [];
@@ -45,10 +65,16 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
const defaultValue = (field: CommandInputField): unknown => {
if (field.kind === 'hidden') return field.constValue;
if (field.kind === 'boolean') return true;
if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0];
if (field.kind === 'number') return field.min ?? 0;
if (field.kind === 'numberTuple') {
const value = amountPreset.value?.defaultValue ?? field.min ?? 0;
return [value, value];
}
if (field.kind === 'number') return amountPreset.value?.defaultValue ?? field.min ?? 0;
if (field.kind === 'select') {
const options = optionsFor(field);
const commandSpecificNationTargets =
field.optionSource === 'nations' ? props.options.nationTargets?.[props.commandKey] : undefined;
if (commandSpecificNationTargets?.length) return commandSpecificNationTargets[0]?.value ?? '';
const mapDefault =
field.optionSource === 'cities' && (field.key === 'destCityId' || field.key === 'destCityID')
? props.mapData?.myCity
@@ -96,7 +122,8 @@ const showMap = computed(
() =>
Boolean(props.mapData && props.mapLayout) &&
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
(presentation.value.mapTarget === 'nation' && nationTargetField.value))
(presentation.value.mapTarget === 'nation' && nationTargetField.value) ||
presentation.value.mapTarget === 'capital')
);
const mapSelectedCityId = computed<number | null>(() => {
if (!props.mapData) return null;
@@ -107,7 +134,15 @@ const mapSelectedCityId = computed<number | null>(() => {
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return null;
return props.mapData.cityList.find((entry) => entry[3] === value)?.[0] ?? null;
return (
props.mapData.nationList.find((entry) => entry[0] === value)?.[3] ??
props.mapData.cityList.find((entry) => entry[3] === value)?.[0] ??
null
);
}
if (presentation.value.mapTarget === 'capital') {
const myNation = props.mapData.myNation;
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
}
return null;
});
@@ -129,6 +164,11 @@ const selectedMapTargetName = computed(() => {
if (typeof nationId !== 'number') return '-';
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
}
if (presentation.value.mapTarget === 'capital') {
const cityId = mapSelectedCityId.value;
if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
}
return '-';
});
@@ -181,6 +221,14 @@ const mapTargetSummary = computed(() => {
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}`;
}
if (presentation.value.mapTarget === 'capital' && mapSelectedCityId.value) {
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return '';
return `${city.name} · ${props.mapLayout.regionMap[dynamic?.[4] ?? city.region]} · ${
props.mapLayout.levelMap[dynamic?.[1] ?? city.level]
} · 현재 수도`;
}
return '';
});
@@ -225,6 +273,35 @@ const setTupleValue = (field: CommandInputField, index: number, rawValue: string
values[field.key] = tuple;
};
const setNumberPreset = (field: CommandInputField, rawValue: string, tupleIndex?: number) => {
if (!rawValue) return;
if (tupleIndex === undefined) {
values[field.key] = Number(rawValue);
return;
}
setTupleValue(field, tupleIndex, rawValue);
};
const effectiveMin = (field: CommandInputField): number | undefined => amountPreset.value?.min ?? field.min;
const effectiveMax = (field: CommandInputField): number | undefined => amountPreset.value?.max ?? field.max;
const effectiveStep = (field: CommandInputField): number | undefined => amountPreset.value?.step ?? field.step;
const OPTION_CARD_COMMANDS = new Set([
'che_물자원조',
'che_불가침제의',
'che_선전포고',
'che_종전제의',
'che_불가침파기제의',
'che_포상',
'che_발령',
'che_몰수',
'che_부대탈퇴지시',
]);
const showOptionCards = (field: CommandInputField): boolean =>
field.kind === 'select' &&
Boolean(field.optionSource && ['nations', 'generals'].includes(field.optionSource)) &&
OPTION_CARD_COMMANDS.has(props.commandKey);
const isValid = computed(() =>
props.fields.every((field) => {
const value = values[field.key];
@@ -237,14 +314,18 @@ const isValid = computed(() =>
);
}
if (field.kind === 'number') {
const min = effectiveMin(field);
const max = effectiveMax(field);
return (
typeof value === 'number' &&
Number.isFinite(value) &&
(field.min === undefined || value >= field.min) &&
(field.max === undefined || value <= field.max)
(min === undefined || value >= min) &&
(max === undefined || value <= max)
);
}
if (field.kind === 'numberTuple') {
const min = effectiveMin(field);
const max = effectiveMax(field);
return (
Array.isArray(value) &&
value.length === 2 &&
@@ -252,8 +333,8 @@ const isValid = computed(() =>
(entry) =>
typeof entry === 'number' &&
Number.isFinite(entry) &&
(field.min === undefined || entry >= field.min) &&
(field.max === undefined || entry <= field.max)
(min === undefined || entry >= min) &&
(max === undefined || entry <= max)
)
);
}
@@ -274,7 +355,11 @@ watch(
</script>
<template>
<div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form">
<div
v-if="props.fields.length || showMap || presentation.lines.length"
class="command-argument-form"
data-testid="command-argument-form"
>
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
<MapViewer
:map-data="props.mapData ?? null"
@@ -284,18 +369,24 @@ watch(
:detail-mode="true"
:fit-container="true"
:show-current-city-marker="true"
:readonly="presentation.mapTarget === 'capital'"
@select-city="selectMapCity"
/>
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<small v-if="presentation.mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
<span class="current-city-status">
<span v-if="presentation.mapTarget !== 'capital'" class="current-city-status">
<span class="status-key">현재 도시</span>
<strong>{{ currentCityName }}</strong>
</span>
<span aria-hidden="true"></span>
<span v-if="presentation.mapTarget !== 'capital'" aria-hidden="true"></span>
<span class="selected-target-status">
<span class="status-key">{{
presentation.mapTarget === 'nation' ? '선택 국가' : '선택 도시'
presentation.mapTarget === 'nation'
? '선택 국가'
: presentation.mapTarget === 'capital'
? '현재 수도'
: '선택 도시'
}}</span>
<strong>{{ selectedMapTargetName }}</strong>
</span>
@@ -320,16 +411,29 @@ watch(
:maxlength="field.max"
@input="values[field.key] = ($event.target as HTMLInputElement).value"
/>
<input
v-else-if="field.kind === 'number'"
:id="`command-arg-${field.key}`"
type="number"
:value="Number(values[field.key] ?? 0)"
:min="field.min"
:max="field.max"
:step="field.step"
@input="values[field.key] = Number(($event.target as HTMLInputElement).value)"
/>
<div v-else-if="field.kind === 'number'" class="number-options">
<input
:id="`command-arg-${field.key}`"
type="number"
:value="Number(values[field.key] ?? 0)"
:min="effectiveMin(field)"
:max="effectiveMax(field)"
:step="effectiveStep(field)"
@input="values[field.key] = Number(($event.target as HTMLInputElement).value)"
/>
<select
v-if="amountPreset"
aria-label="금액 프리셋"
class="amount-preset"
value=""
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value)"
>
<option value="" disabled>프리셋</option>
<option v-for="preset in amountPreset.values" :key="preset" :value="preset">
{{ preset.toLocaleString() }}
</option>
</select>
</div>
<select
v-else-if="field.kind === 'select'"
:id="`command-arg-${field.key}`"
@@ -362,11 +466,23 @@ watch(
<input
type="number"
:value="(values[field.key] as number[] | undefined)?.[index] ?? 0"
:min="field.min"
:max="field.max"
:step="field.step"
:min="effectiveMin(field)"
:max="effectiveMax(field)"
:step="effectiveStep(field)"
@input="setTupleValue(field, index, ($event.target as HTMLInputElement).value)"
/>
<select
v-if="amountPreset"
:aria-label="`${tupleLabel} 금액 프리셋`"
class="amount-preset"
value=""
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value, index)"
>
<option value="" disabled>프리셋</option>
<option v-for="preset in amountPreset.values" :key="preset" :value="preset">
{{ preset.toLocaleString() }}
</option>
</select>
</label>
</div>
<div
@@ -384,6 +500,30 @@ watch(
/>
<span>{{ selectedOptionFor(field)?.description }}</span>
</div>
<div
v-if="showOptionCards(field)"
class="target-option-list"
:data-testid="field.optionSource === 'nations' ? 'nation-target-list' : 'general-target-list'"
>
<button
v-for="option in optionsFor(field)"
:key="String(option.value)"
type="button"
class="target-option"
:class="{
selected: option.value === values[field.key],
unavailable: option.availableNow === false,
}"
@click="setSelectValue(field, String(option.value))"
>
<span v-if="option.color" class="option-color" :style="{ backgroundColor: option.color }" />
<strong>{{ option.label }}</strong>
<span class="target-state">{{
option.availableNow === false ? '현재 불가' : option.availableNow ? '우선 대상' : '대상'
}}</span>
<small>{{ option.description }}</small>
</button>
</div>
</div>
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
</div>
@@ -484,6 +624,74 @@ watch(
line-height: 1.35;
}
.target-option-list {
grid-column: 1 / -1;
display: grid;
max-height: 230px;
overflow-y: auto;
border-top: 1px solid rgba(201, 164, 90, 0.2);
}
.target-option {
display: grid;
grid-template-columns: 18px minmax(0, auto) max-content;
align-items: center;
gap: 3px 7px;
border: 0;
border-bottom: 1px solid rgba(201, 164, 90, 0.16);
padding: 6px 8px;
background: rgba(7, 9, 12, 0.82);
color: #e8ddc4;
font: inherit;
text-align: left;
cursor: pointer;
}
.target-option:hover,
.target-option:focus-visible {
background: rgba(201, 164, 90, 0.13);
outline: 1px solid rgba(201, 164, 90, 0.6);
outline-offset: -1px;
}
.target-option.selected {
background: rgba(201, 164, 90, 0.2);
box-shadow: inset 3px 0 #e0bc6d;
}
.target-option.unavailable {
color: rgba(232, 221, 196, 0.58);
}
.target-option > strong {
grid-column: 2;
}
.target-option > .option-color + strong {
grid-column: 2;
}
.target-option > strong:first-child {
grid-column: 1 / 3;
}
.target-state {
grid-column: 3;
color: #aee6a7;
font-size: 10px;
text-align: right;
}
.target-option.unavailable .target-state {
color: #e9a29a;
}
.target-option small {
grid-column: 1 / -1;
color: inherit;
line-height: 1.35;
}
.option-color {
width: 18px;
height: 18px;
@@ -511,6 +719,19 @@ select {
font: inherit;
}
.number-options {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(90px, 0.45fr);
gap: 5px;
padding-right: 6px;
}
.number-options input,
.number-options select {
width: 100%;
margin-right: 0;
}
.boolean-options,
.tuple-options {
display: flex;
@@ -531,17 +752,34 @@ select {
}
.tuple-options label {
display: flex;
display: grid;
grid-template-columns: max-content minmax(68px, 1fr) minmax(82px, 0.7fr);
align-items: center;
gap: 4px;
min-width: 0;
}
.tuple-options input {
width: 80px;
width: 100%;
margin: 0;
}
.tuple-options .amount-preset {
min-width: 0;
width: 100%;
margin: 0;
}
@media (max-width: 520px) {
.tuple-options {
flex-direction: column;
}
.tuple-options label {
width: 100%;
}
}
.argument-error {
padding: 5px 8px;
color: #ff9a8f;
@@ -22,6 +22,7 @@ const props = defineProps<{
showName: boolean;
mapScale: number;
selectOnly?: boolean;
readonly?: boolean;
}>();
const emit = defineEmits<{
@@ -33,18 +34,27 @@ const emit = defineEmits<{
const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
const stateSize = computed(() => 8 * props.mapScale);
const stateOffset = computed(() => -6 * props.mapScale);
const selectCity = () => emit('select', props.city.id);
const selectCity = () => {
if (!props.readonly) emit('select', props.city.id);
};
</script>
<template>
<component
:is="props.selectOnly ? 'button' : RouterLink"
:is="props.readonly ? 'div' : props.selectOnly ? 'button' : RouterLink"
class="map-city"
:type="props.selectOnly ? 'button' : undefined"
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
:type="!props.readonly && props.selectOnly ? 'button' : undefined"
:to="
props.selectOnly || props.readonly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }
"
:class="[
`state-${props.city.stateClass}`,
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
{
mine: props.city.isMyCity,
selected: props.city.selected,
'supply-off': !props.city.supply,
readonly: props.readonly,
},
]"
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)"
@@ -86,6 +96,10 @@ const selectCity = () => emit('select', props.city.id);
background: transparent;
}
.map-city.readonly {
cursor: default;
}
.city-dot {
border: 1px solid rgba(232, 221, 196, 0.6);
display: flex;
@@ -48,6 +48,7 @@ const props = defineProps<{
themeName: string;
mapScale: number;
selectOnly?: boolean;
readonly?: boolean;
}>();
const emit = defineEmits<{
@@ -143,7 +144,9 @@ const capitalIconStyle = computed(() => ({
height: `${10 * props.mapScale}px`,
}));
const selectCity = () => emit('select', props.city.id);
const selectCity = () => {
if (!props.readonly) emit('select', props.city.id);
};
const cityStateStyle = computed(() => ({
width: `${12 * props.mapScale}px`,
@@ -154,11 +157,20 @@ const cityStateStyle = computed(() => ({
<template>
<component
:is="props.selectOnly ? 'button' : RouterLink"
:is="props.readonly ? 'div' : props.selectOnly ? 'button' : RouterLink"
class="city-base"
:type="props.selectOnly ? 'button' : undefined"
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
:type="!props.readonly && props.selectOnly ? 'button' : undefined"
:to="
props.selectOnly || props.readonly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }
"
:class="[
{
mine: props.city.isMyCity,
selected: props.city.selected,
'supply-off': !props.city.supply,
readonly: props.readonly,
},
]"
:style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@@ -195,6 +207,10 @@ const cityStateStyle = computed(() => ({
background: transparent;
}
.city-base.readonly {
cursor: default;
}
.city-bg {
position: absolute;
background-position: center;
@@ -72,6 +72,7 @@ const props = withDefaults(
detailMode?: boolean;
fitContainer?: boolean;
showCurrentCityMarker?: boolean;
readonly?: boolean;
}>(),
{
// Vue casts an absent Boolean prop to false unless undefined is an explicit default.
@@ -404,6 +405,7 @@ const setHoveredCity = (cityId: number | null) => {
};
const selectCity = (cityId: number) => {
if (props.readonly) return;
emit('select-city', cityId);
if (props.selectedCityId === undefined) {
mapStore.setSelectedCity(cityId);
@@ -443,6 +445,7 @@ const selectCity = (cityId: number) => {
:map-scale="mapScale"
:show-name="showCityName"
:select-only="props.selectedCityId !== undefined"
:readonly="props.readonly"
v-bind="detailProps"
@hover="setHoveredCity"
@leave="setHoveredCity(null)"