feat(game-ui): 사령턴 대상 지도를 인자 계약에 연결

도시·국가 선택 필드에서 지도 대상을 자동 판별하고 발령을 제외한 공개 사령턴 13종의 지도 선택과 보조 정보를 Chromium 회귀로 고정한다.
This commit is contained in:
2026-08-21 01:06:06 +00:00
parent 7b14585f0d
commit b0ab73e08b
5 changed files with 167 additions and 67 deletions
@@ -1,6 +1,10 @@
import type { CommandInputField } from './types';
export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital';
export type CommandArgumentPresentation = {
lines: string[];
mapTarget?: 'city' | 'nation' | 'capital';
mapTarget?: CommandArgumentMapTarget;
};
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
@@ -98,4 +102,18 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
PRESENTATIONS[commandKey] ?? { lines: [] };
/**
* 대상 지도는 명령명 목록이 아니라 API가 내린 실제 인자 계약을 우선한다.
* 인자가 없는 증축·감축의 수도 확인 지도만 presentation의 명시적 target을 사용한다.
*/
export const resolveCommandArgumentMapTarget = (
commandKey: string,
fields: readonly CommandInputField[]
): CommandArgumentMapTarget | undefined => {
const selectableTargets = fields.filter((field) => field.kind === 'select');
if (selectableTargets.some((field) => field.optionSource === 'cities')) return 'city';
if (selectableTargets.some((field) => field.optionSource === 'nations')) return 'nation';
return commandArgumentPresentation(commandKey).mapTarget;
};
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, reactive, watch, type CSSProperties } from 'vue';
import MapViewer from './MapViewer.vue';
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
import {
commandArgumentFieldContract,
shouldPreserveCommandArgumentValue,
@@ -104,12 +104,7 @@ const synchronizeValues = () => {
for (const field of props.fields) {
const preserve =
!commandChanged &&
shouldPreserveCommandArgumentValue(
field,
previousFieldContracts.get(field.key),
values,
optionsFor(field)
);
shouldPreserveCommandArgumentValue(field, previousFieldContracts.get(field.key), values, optionsFor(field));
if (!preserve) values[field.key] = defaultValue(field);
}
const itemCodeField = props.fields.find((field) => field.key === 'itemCode');
@@ -162,20 +157,15 @@ const nationTargetField = computed(() =>
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
)
);
const showMap = computed(
() =>
Boolean(props.mapData && props.mapLayout) &&
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
(presentation.value.mapTarget === 'nation' && nationTargetField.value) ||
presentation.value.mapTarget === 'capital')
);
const mapTarget = computed(() => resolveCommandArgumentMapTarget(props.commandKey, props.fields));
const showMap = computed(() => Boolean(props.mapData && props.mapLayout && mapTarget.value));
const mapSelectedCityId = computed<number | null>(() => {
if (!props.mapData) return null;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
if (mapTarget.value === 'city' && cityTargetField.value) {
const value = values[cityTargetField.value.key];
return typeof value === 'number' ? value : null;
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
if (mapTarget.value === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return null;
return (
@@ -184,7 +174,7 @@ const mapSelectedCityId = computed<number | null>(() => {
null
);
}
if (presentation.value.mapTarget === 'capital') {
if (mapTarget.value === 'capital') {
const myNation = props.mapData.myNation;
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
}
@@ -198,17 +188,17 @@ const currentCityName = computed(() => {
});
const selectedMapTargetName = computed(() => {
if (presentation.value.mapTarget === 'city') {
if (mapTarget.value === 'city') {
const cityId = mapSelectedCityId.value;
if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
if (mapTarget.value === 'nation' && nationTargetField.value) {
const nationId = values[nationTargetField.value.key];
if (typeof nationId !== 'number') return '-';
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
}
if (presentation.value.mapTarget === 'capital') {
if (mapTarget.value === 'capital') {
const cityId = mapSelectedCityId.value;
if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
@@ -240,7 +230,7 @@ const distanceFromMyCity = (destination: number): number | null => {
const mapTargetSummary = computed(() => {
if (!props.mapData || !props.mapLayout) return '';
if (presentation.value.mapTarget === 'city' && mapSelectedCityId.value) {
if (mapTarget.value === 'city' && 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 '';
@@ -256,7 +246,7 @@ const mapTargetSummary = computed(() => {
.filter(Boolean)
.join(' · ');
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
if (mapTarget.value === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return '';
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
@@ -265,7 +255,7 @@ 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) {
if (mapTarget.value === '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 '';
@@ -278,11 +268,11 @@ const mapTargetSummary = computed(() => {
const selectMapCity = (cityId: number) => {
if (!props.mapData) return;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
if (mapTarget.value === 'city' && cityTargetField.value) {
setSelectValue(cityTargetField.value, String(cityId));
return;
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
if (mapTarget.value === 'nation' && nationTargetField.value) {
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
}
@@ -417,24 +407,20 @@ watch(
:detail-mode="true"
:fit-container="true"
:show-current-city-marker="true"
:readonly="presentation.mapTarget === 'capital'"
:readonly="mapTarget === 'capital'"
@select-city="selectMapCity"
/>
<small v-if="presentation.mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
<small v-if="mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
<span v-if="presentation.mapTarget !== 'capital'" class="current-city-status">
<span v-if="mapTarget !== 'capital'" class="current-city-status">
<span class="status-key">현재 도시</span>
<strong>{{ currentCityName }}</strong>
</span>
<span v-if="presentation.mapTarget !== 'capital'" aria-hidden="true"></span>
<span v-if="mapTarget !== 'capital'" aria-hidden="true"></span>
<span class="selected-target-status">
<span class="status-key">{{
presentation.mapTarget === 'nation'
? '선택 국가'
: presentation.mapTarget === 'capital'
? '현재 수도'
: '선택 도시'
mapTarget === 'nation' ? '선택 국가' : mapTarget === 'capital' ? '현재 수도' : '선택 도시'
}}</span>
<strong>{{ selectedMapTargetName }}</strong>
</span>