도시 대상 명령 입력에 Ref 거리별 도시 목록 복원
Ref ProcessCity의 `N칸 떨어진 도시:` 목록(CitiesBasedOnDistance)을 전체화면 인자 입력에 되살린다. 이동·출병 1칸, 강행·첩보·화계·탈취·파괴·선동 3칸을 map layout path의 BFS로 Ref searchDistance와 같은 순서로 나열하고, 거리별 magenta/orange/yellow 밑줄 도시를 누르면 대상 도시를 고른다. che 지도 94개 도시 결과를 Ref PHP 원문 실행 fixture와 비교하는 unit test와 1200/390px e2e를 추가한다. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2032,7 +2032,10 @@ test('enters general and nation command arguments and sends exact values', async
|
||||
const rect = area.getBoundingClientRect();
|
||||
return { width: rect.width, height: rect.height };
|
||||
});
|
||||
await page.getByTestId('command-picker').getByRole('button', { name: / 입력$/ }).click();
|
||||
await page
|
||||
.getByTestId('command-picker')
|
||||
.getByRole('button', { name: / 입력$/ })
|
||||
.click();
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText(
|
||||
'【허창】에 화계실행'
|
||||
);
|
||||
@@ -3495,7 +3498,10 @@ test('keeps the shared main and chief shell geometry and interaction states', as
|
||||
await chiefArgumentForm.getByRole('button', { name: '쌀' }).click();
|
||||
await chiefArgumentForm.locator('input[type=number]').fill('300');
|
||||
await chiefArgumentForm.locator('select').selectOption('2');
|
||||
await page.getByTestId('command-picker').getByRole('button', { name: / 입력$/ }).click();
|
||||
await page
|
||||
.getByTestId('command-picker')
|
||||
.getByRole('button', { name: / 입력$/ })
|
||||
.click();
|
||||
await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText(
|
||||
'【관우】 쌀 300 포상'
|
||||
);
|
||||
@@ -4130,8 +4136,89 @@ test('opens chief argument input as the same full-screen overlay with selected t
|
||||
expect(geometry.overlay).toMatchObject({ x: 0, y: 0, width: 1200, height: 900 });
|
||||
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
|
||||
await expect(overlay.getByTestId('command-argument-map')).toBeVisible();
|
||||
// Ref che_발령.vue는 distanceList를 받지만 표시하지 않는다.
|
||||
await expect(overlay.getByTestId('city-distance-list')).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath('chief-argument-overlay-1200.png') });
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId('command-picker')).toHaveCount(0);
|
||||
expect(page.url()).toBe(mainUrl);
|
||||
});
|
||||
|
||||
// Ref ProcessCity + CitiesBasedOnDistance: 화계 계열·강행·첩보는 3칸, 이동·출병은 1칸까지 현재 도시 기준
|
||||
// 거리별 도시를 magenta/orange/yellow 밑줄 글자로 보여 주고, 누르면 대상 도시로 고른다.
|
||||
for (const viewport of [
|
||||
{ width: 1200, height: 900 },
|
||||
{ width: 390, height: 844 },
|
||||
] as const) {
|
||||
test(`lists Ref-style cities by distance for city target commands at ${viewport.width}px`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const requests = await install(page);
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(gamePath('/'));
|
||||
const editor = page.locator('[data-command-scope="general"]:visible');
|
||||
const openCommand = async (turn: number, category: string, name: string) => {
|
||||
await editor.getByRole('button', { name: `${turn}턴 명령 입력`, exact: true }).click();
|
||||
const listPicker = page.getByTestId('command-picker');
|
||||
await listPicker.getByRole('button', { name: category, exact: true }).click();
|
||||
await listPicker.getByRole('button', { name, exact: true }).click();
|
||||
const overlay = page.getByRole('dialog', { name: `${name} ${turn}턴 명령 입력` });
|
||||
await expect(overlay).toBeVisible();
|
||||
return overlay;
|
||||
};
|
||||
|
||||
let overlay = await openCommand(1, '계략', '화계');
|
||||
const list = overlay.getByTestId('city-distance-list');
|
||||
await expect(list).toBeVisible();
|
||||
await expect(list.locator('.city-distance-row')).toHaveText([
|
||||
'1칸 떨어진 도시: 허창',
|
||||
'2칸 떨어진 도시:',
|
||||
'3칸 떨어진 도시:',
|
||||
]);
|
||||
const link = list.getByRole('button', { name: '허창', exact: true });
|
||||
const style = await link.evaluate((element) => {
|
||||
const computed = getComputedStyle(element);
|
||||
return {
|
||||
color: computed.color,
|
||||
textDecorationLine: computed.textDecorationLine,
|
||||
fontWeight: computed.fontWeight,
|
||||
};
|
||||
});
|
||||
expect(style).toEqual({ color: 'rgb(255, 0, 255)', textDecorationLine: 'underline', fontWeight: '400' });
|
||||
await expect(link).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await link.click();
|
||||
await expect(link).toHaveAttribute('aria-pressed', 'true');
|
||||
await expect(overlay.getByTestId('command-argument-form').locator('select')).toHaveValue('2');
|
||||
await expect(overlay.getByTestId('command-map-selection-status')).toContainText('허창');
|
||||
const geometry = await overlay.evaluate((element) => {
|
||||
const fields = element.querySelector<HTMLElement>('.argument-fields')!.getBoundingClientRect();
|
||||
const distance = element.querySelector<HTMLElement>('[data-testid="city-distance-list"]')!;
|
||||
const rect = distance.getBoundingClientRect();
|
||||
return {
|
||||
fields: fields.toJSON(),
|
||||
list: rect.toJSON(),
|
||||
fontSize: getComputedStyle(distance).fontSize,
|
||||
scrollWidth: element.scrollWidth,
|
||||
clientWidth: element.clientWidth,
|
||||
};
|
||||
});
|
||||
await writeFile(testInfo.outputPath(`city-distance-${viewport.width}.json`), JSON.stringify(geometry, null, 2));
|
||||
await page.screenshot({ path: testInfo.outputPath(`city-distance-${viewport.width}.png`) });
|
||||
expect(geometry.fontSize).toBe('14px');
|
||||
expect(geometry.list.x).toBeGreaterThanOrEqual(geometry.fields.x);
|
||||
expect(geometry.list.right).toBeLessThanOrEqual(geometry.fields.right);
|
||||
expect(geometry.scrollWidth).toBe(geometry.clientWidth);
|
||||
|
||||
await overlay.getByRole('button', { name: '화계 입력', exact: true }).click();
|
||||
await expect
|
||||
.poll(() => JSON.stringify(requests))
|
||||
.toContain('"turnList":[0],"action":"che_화계","args":{"destCityId":2}');
|
||||
|
||||
overlay = await openCommand(2, '군사', '출병');
|
||||
await expect(overlay.getByTestId('city-distance-list').locator('.city-distance-row')).toHaveText([
|
||||
'1칸 떨어진 도시: 허창',
|
||||
]);
|
||||
await overlay.getByRole('button', { name: '명령 취소', exact: true }).click();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CommandMapLayout } from './types';
|
||||
|
||||
// Ref `v_processing.php`의 ProcessCity는 명령별 `JSCitiesBasedOnDistance(현재 도시, N)` 결과를
|
||||
// `CitiesBasedOnDistance.vue`로 `N칸 떨어진 도시:` 줄에 보여 준다. 사령턴 도시 명령(천도·수몰 등)은
|
||||
// 빈 목록을 보내 줄이 없고, 발령·인구이동은 값을 보내지만 화면에 쓰지 않는다.
|
||||
export const COMMAND_CITY_DISTANCE_RANGE: Readonly<Record<string, number>> = {
|
||||
che_이동: 1,
|
||||
che_출병: 1,
|
||||
che_강행: 3,
|
||||
che_첩보: 3,
|
||||
che_화계: 3,
|
||||
che_탈취: 3,
|
||||
che_파괴: 3,
|
||||
che_선동: 3,
|
||||
};
|
||||
|
||||
export type CityDistanceGroup = {
|
||||
distance: number;
|
||||
cityIds: number[];
|
||||
};
|
||||
|
||||
type CityPathSource = Pick<CommandMapLayout['cityList'][number], 'id' | 'path'>;
|
||||
|
||||
/**
|
||||
* Ref `searchDistance($from, $maxDist, true)`와 같은 BFS 순서로 1칸부터 maxDistance칸까지의 도시를 모은다.
|
||||
* 같은 거리 안의 순서는 앞 거리 도시 순서와 각 도시 연결 목록 순서를 따른다. 빈 거리도 Ref처럼 남긴다.
|
||||
*/
|
||||
export const citiesBasedOnDistance = (
|
||||
startCityId: number,
|
||||
cityList: readonly CityPathSource[],
|
||||
maxDistance: number
|
||||
): CityDistanceGroup[] => {
|
||||
const paths = new Map(cityList.map((city) => [city.id, city.path]));
|
||||
const visited = new Set<number>([startCityId]);
|
||||
const groups: CityDistanceGroup[] = [];
|
||||
let frontier = [startCityId];
|
||||
for (let distance = 1; distance <= maxDistance; distance += 1) {
|
||||
const next: number[] = [];
|
||||
for (const cityId of frontier) {
|
||||
for (const adjacentId of paths.get(cityId) ?? []) {
|
||||
if (visited.has(adjacentId)) continue;
|
||||
visited.add(adjacentId);
|
||||
next.push(adjacentId);
|
||||
}
|
||||
}
|
||||
groups.push({ distance, cityIds: next });
|
||||
frontier = next;
|
||||
}
|
||||
return groups;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import MapViewer from './MapViewer.vue';
|
||||
import NationColorSelect from './NationColorSelect.vue';
|
||||
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
|
||||
import { commandCityOptions } from '../command/commandArgumentOptions';
|
||||
import { COMMAND_CITY_DISTANCE_RANGE, citiesBasedOnDistance } from '../command/commandCityDistance';
|
||||
import { sortCommandGeneralOptions } from '../command/commandGeneralOptions';
|
||||
import {
|
||||
commandArgumentFieldContract,
|
||||
@@ -309,6 +310,23 @@ const distanceFromMyCity = (destination: number): number | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// Ref CitiesBasedOnDistance.vue의 거리별 글자색이다. 도시 이름을 누르면 대상 도시로 고른다.
|
||||
const CITY_DISTANCE_COLORS: Readonly<Record<number, string>> = { 1: 'magenta', 2: 'orange', 3: 'yellow' };
|
||||
const cityDistanceGroups = computed(() => {
|
||||
const range = COMMAND_CITY_DISTANCE_RANGE[props.commandKey];
|
||||
const start = props.mapData?.myCity;
|
||||
const layout = props.mapLayout;
|
||||
const field = cityTargetField.value;
|
||||
if (!range || !start || !layout || !field) return [];
|
||||
const names = new Map(layout.cityList.map((city) => [city.id, city.name]));
|
||||
const selectable = new Set(optionsFor(field).map((option) => option.value));
|
||||
return citiesBasedOnDistance(start, layout.cityList, range).map((group) => ({
|
||||
distance: group.distance,
|
||||
color: CITY_DISTANCE_COLORS[group.distance],
|
||||
cities: group.cityIds.map((id) => ({ id, name: names.get(id) ?? String(id), selectable: selectable.has(id) })),
|
||||
}));
|
||||
});
|
||||
|
||||
const mapTargetSummary = computed(() => {
|
||||
if (!props.mapData || !props.mapLayout) return '';
|
||||
if (mapTarget.value === 'city' && mapSelectedCityId.value) {
|
||||
@@ -623,6 +641,34 @@ watch(
|
||||
/>
|
||||
<span>{{ commandTargetDescription(commandKey, selectedOptionFor(field)) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.key === cityTargetField?.key && cityDistanceGroups.length"
|
||||
class="city-distance-list"
|
||||
data-testid="city-distance-list"
|
||||
>
|
||||
<div
|
||||
v-for="group in cityDistanceGroups"
|
||||
:key="group.distance"
|
||||
class="city-distance-row"
|
||||
:data-distance="group.distance"
|
||||
>
|
||||
{{ group.distance }}칸 떨어진 도시:
|
||||
<template v-for="(city, index) in group.cities" :key="city.id"
|
||||
><template v-if="index !== 0"> , </template
|
||||
><button
|
||||
type="button"
|
||||
class="city-distance-link"
|
||||
:class="{ selected: city.id === values[field.key] }"
|
||||
:style="{ color: group.color }"
|
||||
:disabled="!city.selectable"
|
||||
:aria-pressed="city.id === values[field.key]"
|
||||
@click="setSelectValue(field, String(city.id))"
|
||||
>
|
||||
{{ city.name }}
|
||||
</button></template
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="searchEnabled && isSearchable(field)"
|
||||
class="target-search"
|
||||
@@ -721,6 +767,41 @@ small {
|
||||
.target-search small {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.city-distance-list {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid rgba(201, 164, 90, 0.2);
|
||||
color: #e8ddc4;
|
||||
font-size: var(--sammo-font-size-normal);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.city-distance-link {
|
||||
display: inline;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.city-distance-link.selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.city-distance-link:focus-visible {
|
||||
outline: 1px solid currentColor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.city-distance-link:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.target-search-empty {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
import { COMMAND_CITY_DISTANCE_RANGE, citiesBasedOnDistance } from '../src/components/command/commandCityDistance.ts';
|
||||
|
||||
type MapDefinition = { cities: Array<{ id: number; connections: number[] }> };
|
||||
|
||||
const readJson = <T>(path: string): T => JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as T;
|
||||
|
||||
// Ref `hwe/func.php` searchDistance와 `func_legacy.php` JSCitiesBasedOnDistance 원문을
|
||||
// che 지도 CityConst로 실행한 결과(2026-09-23, 생성 절차는 report 참고)다.
|
||||
const refDistances = readJson<Record<string, Record<string, number[]>>>(
|
||||
'./fixtures/ref-cities-based-on-distance-che.json'
|
||||
);
|
||||
const cheMap = readJson<MapDefinition>('../../../resources/map/map_che.json');
|
||||
const cityList = cheMap.cities.map((city) => ({ id: city.id, path: city.connections }));
|
||||
|
||||
void test('matches Ref JSCitiesBasedOnDistance order for every che city up to 3 distance', () => {
|
||||
assert.equal(Object.keys(refDistances).length, cityList.length);
|
||||
for (const city of cityList) {
|
||||
const expected = refDistances[String(city.id)];
|
||||
assert.ok(expected, `Ref fixture has city ${city.id}`);
|
||||
const actual = citiesBasedOnDistance(city.id, cityList, 3);
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(actual.map((group) => [String(group.distance), group.cityIds])),
|
||||
expected,
|
||||
`city ${city.id}`
|
||||
);
|
||||
assert.deepEqual(citiesBasedOnDistance(city.id, cityList, 1), [{ distance: 1, cityIds: expected['1'] }]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('keeps empty distance rows like Ref when the graph ends early', () => {
|
||||
const tiny = [
|
||||
{ id: 1, path: [2] },
|
||||
{ id: 2, path: [1] },
|
||||
];
|
||||
assert.deepEqual(citiesBasedOnDistance(1, tiny, 3), [
|
||||
{ distance: 1, cityIds: [2] },
|
||||
{ distance: 2, cityIds: [] },
|
||||
{ distance: 3, cityIds: [] },
|
||||
]);
|
||||
assert.deepEqual(citiesBasedOnDistance(99, tiny, 1), [{ distance: 1, cityIds: [] }]);
|
||||
});
|
||||
|
||||
void test('uses the Ref ProcessCity distance for each general city command', () => {
|
||||
assert.deepEqual(COMMAND_CITY_DISTANCE_RANGE, {
|
||||
che_이동: 1,
|
||||
che_출병: 1,
|
||||
che_강행: 3,
|
||||
che_첩보: 3,
|
||||
che_화계: 3,
|
||||
che_탈취: 3,
|
||||
che_파괴: 3,
|
||||
che_선동: 3,
|
||||
});
|
||||
for (const nationCommand of ['che_천도', 'che_수몰', 'che_허보', 'che_초토화', 'che_백성동원', 'che_발령']) {
|
||||
assert.equal(Object.hasOwn(COMMAND_CITY_DISTANCE_RANGE, nationCommand), false);
|
||||
}
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user