도시 대상 명령 입력에 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:
2026-09-23 14:35:40 +00:00
co-authored by Claude Opus 5.5
parent b90dd4499d
commit 0a9c47eddf
5 changed files with 282 additions and 2 deletions
@@ -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;
}