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
+122 -44
View File
@@ -35,7 +35,11 @@ import {
} from '../../turns/reservedTurns.js'; } from '../../turns/reservedTurns.js';
import { getOwnedGeneral } from '../shared/general.js'; import { getOwnedGeneral } from '../shared/general.js';
import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js'; import type { GameApiContext, GeneralRow, WorldStateRow } from '../../context.js';
import { buildRefGeneralTargetOptions } from '../../turns/commandTargets.js'; import {
buildRefAmountPresets,
buildRefGeneralTargetOptions,
buildRefNationTargetOptions,
} from '../../turns/commandTargets.js';
const zPushAmount = z const zPushAmount = z
.number() .number()
@@ -159,52 +163,125 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
const moduleBundlePromise = environmentPromise.then((environment) => const moduleBundlePromise = environmentPromise.then((environment) =>
loadActionModuleBundle(environment.unitSet, environment.scenarioEffect) loadActionModuleBundle(environment.unitSet, environment.scenarioEffect)
); );
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, moduleBundle, map] = const [
await Promise.all([ city,
general.cityId > 0 nation,
? ctx.db.city.findUnique({ nationGenerals,
where: { id: general.cityId }, cities,
}) nations,
: null, generals,
general.nationId > 0 diplomacy,
? ctx.db.nation.findUnique({ troops,
where: { id: general.nationId }, environment,
}) traits,
: null, moduleBundle,
general.nationId > 0 map,
? ctx.db.general.findMany({ ] = await Promise.all([
where: { nationId: general.nationId }, general.cityId > 0
}) ? ctx.db.city.findUnique({
: Promise.resolve(null), where: { id: general.cityId },
ctx.db.city.findMany({ orderBy: { id: 'asc' } }), })
ctx.db.nation.findMany({ : null,
select: { id: true, name: true, color: true }, general.nationId > 0
orderBy: { id: 'asc' }, ? ctx.db.nation.findUnique({
}), where: { id: general.nationId },
ctx.db.general.findMany({ })
select: { : null,
id: true, general.nationId > 0
name: true, ? ctx.db.general.findMany({
nationId: true, where: { nationId: general.nationId },
cityId: true, })
npcState: true, : Promise.resolve(null),
officerLevel: true, ctx.db.city.findMany({ orderBy: { id: 'asc' } }),
}, ctx.db.nation.findMany({
orderBy: [{ npcState: 'asc' }, { name: 'asc' }, { id: 'asc' }], select: {
}), id: true,
environmentPromise, name: true,
loadBattleSimTraitOptions(), color: true,
moduleBundlePromise, capitalCityId: true,
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)), level: true,
]); meta: true,
},
orderBy: { id: 'asc' },
}),
ctx.db.general.findMany({
select: {
id: true,
name: true,
nationId: true,
cityId: true,
npcState: true,
officerLevel: true,
gold: true,
rice: true,
crew: true,
train: true,
atmos: true,
troopId: true,
},
orderBy: [{ npcState: 'asc' }, { name: 'asc' }, { id: 'asc' }],
}),
general.nationId > 0
? ctx.db.diplomacy.findMany({ where: { srcNationId: general.nationId } })
: Promise.resolve([]),
general.nationId > 0
? ctx.db.troop.findMany({ where: { nationId: general.nationId }, orderBy: { troopLeaderId: 'asc' } })
: Promise.resolve([]),
environmentPromise,
loadBattleSimTraitOptions(),
moduleBundlePromise,
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
]);
const nationById = new Map(nations.map((entry) => [entry.id, entry])); const nationById = new Map(nations.map((entry) => [entry.id, entry]));
const cityById = new Map(cities.map((entry) => [entry.id, entry]));
const generalCountByNation = new Map<number, number>();
for (const entry of generals) {
generalCountByNation.set(entry.nationId, (generalCountByNation.get(entry.nationId) ?? 0) + 1);
}
const cityCountByNation = new Map<number, number>();
for (const entry of cities) {
cityCountByNation.set(entry.nationId, (cityCountByNation.get(entry.nationId) ?? 0) + 1);
}
const diplomacyByNation = new Map(diplomacy.map((entry) => [entry.destNationId, entry]));
const adjacentNationIds = new Set<number>();
const actorCityIds = new Set(
cities.filter((entry) => entry.nationId === general.nationId).map((entry) => entry.id)
);
for (const mapCity of map.cities) {
if (!actorCityIds.has(mapCity.id)) continue;
for (const adjacentId of mapCity.connections) {
const adjacentNationId = cityById.get(adjacentId)?.nationId;
if (adjacentNationId && adjacentNationId !== general.nationId) adjacentNationIds.add(adjacentNationId);
}
}
const nationTargetOptions = buildRefNationTargetOptions({
actorNationId: general.nationId,
nations: nations.map((entry) => {
const relation = diplomacyByNation.get(entry.id);
return {
id: entry.id,
name: entry.name,
color: entry.color,
capitalName: entry.capitalCityId ? (cityById.get(entry.capitalCityId)?.name ?? '-') : '-',
level: entry.level,
power: readGeneralMetaNumber(entry.meta, 'power') ?? 0,
generalCount: generalCountByNation.get(entry.id) ?? 0,
cityCount: cityCountByNation.get(entry.id) ?? 0,
diplomacyState: relation?.stateCode ?? 2,
diplomacyTerm: relation?.term ?? 0,
adjacent: adjacentNationIds.has(entry.id),
diplomacyRestricted: (readGeneralMetaNumber(entry.meta, 'surlimit') ?? 0) !== 0,
};
}),
});
const generalTargetOptions = buildRefGeneralTargetOptions({ const generalTargetOptions = buildRefGeneralTargetOptions({
actorId: general.id, actorId: general.id,
actorNationId: general.nationId, actorNationId: general.nationId,
generals, generals,
nationNames: new Map(nations.map((entry) => [entry.id, entry.name])), nationNames: new Map(nations.map((entry) => [entry.id, entry.name])),
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])), cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
}); });
const items: TurnCommandInputOptions['items'] = { const items: TurnCommandInputOptions['items'] = {
horse: [{ value: 'None', label: '판매/해제' }], horse: [{ value: 'None', label: '판매/해제' }],
@@ -234,11 +311,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
value: entry.id, value: entry.id,
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`, label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`,
})), })),
nations: nations.map((entry) => ({ nations: nationTargetOptions.nations,
value: entry.id, nationTargets: nationTargetOptions.nationTargets,
label: entry.name,
color: entry.color,
})),
generals: generalTargetOptions.generals, generals: generalTargetOptions.generals,
generalTargets: generalTargetOptions.generalTargets, generalTargets: generalTargetOptions.generalTargets,
crewTypes: (environment.unitSet.crewTypes ?? []) crewTypes: (environment.unitSet.crewTypes ?? [])
@@ -273,6 +347,10 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
unitSet: environment.unitSet, unitSet: environment.unitSet,
generalActionModules: moduleBundle.general, generalActionModules: moduleBundle.general,
}), }),
amountPresets: buildRefAmountPresets(
nation?.level ?? 0,
readGeneralMetaNumber(asRecord(asRecord(worldState.config).const), 'maxResourceActionAmount') ?? 10_000
),
context: { context: {
actorGold: general.gold, actorGold: general.gold,
actorRice: general.rice, actorRice: general.rice,
+15
View File
@@ -16,6 +16,19 @@ export interface TurnCommandOption {
label: string; label: string;
color?: string; color?: string;
description?: string; description?: string;
availableNow?: boolean;
gold?: number;
rice?: number;
crew?: number;
troopId?: number;
}
export interface TurnCommandAmountPreset {
values: number[];
defaultValue: number;
min: number;
max: number;
step: number;
} }
export interface TurnCommandRecruitmentCrewType { export interface TurnCommandRecruitmentCrewType {
@@ -70,6 +83,7 @@ export interface TurnCommandInputField {
export interface TurnCommandInputOptions { export interface TurnCommandInputOptions {
cities: TurnCommandOption[]; cities: TurnCommandOption[];
nations: TurnCommandOption[]; nations: TurnCommandOption[];
nationTargets?: Record<string, TurnCommandOption[]>;
generals: TurnCommandOption[]; generals: TurnCommandOption[];
generalTargets?: Record<string, TurnCommandOption[]>; generalTargets?: Record<string, TurnCommandOption[]>;
crewTypes: TurnCommandOption[]; crewTypes: TurnCommandOption[];
@@ -78,6 +92,7 @@ export interface TurnCommandInputOptions {
colors: TurnCommandOption[]; colors: TurnCommandOption[];
items: Record<string, TurnCommandOption[]>; items: Record<string, TurnCommandOption[]>;
recruitment: TurnCommandRecruitmentInfo | null; recruitment: TurnCommandRecruitmentInfo | null;
amountPresets?: Record<string, TurnCommandAmountPreset>;
context?: { context?: {
actorGold: number; actorGold: number;
actorRice: number; actorRice: number;
+2
View File
@@ -770,6 +770,7 @@ export const buildTurnCommandTable = async (options: {
inputOptions: options.inputOptions ?? { inputOptions: options.inputOptions ?? {
cities: [], cities: [],
nations: [], nations: [],
nationTargets: {},
generals: [], generals: [],
generalTargets: {}, generalTargets: {},
crewTypes: [], crewTypes: [],
@@ -778,6 +779,7 @@ export const buildTurnCommandTable = async (options: {
colors: [], colors: [],
items: {}, items: {},
recruitment: null, recruitment: null,
amountPresets: {},
}, },
}; };
}; };
+194 -11
View File
@@ -1,4 +1,6 @@
import type { TurnCommandOption } from './commandInput.js'; import { DIPLOMACY_STATE } from '@sammo-ts/logic';
import type { TurnCommandAmountPreset, TurnCommandOption } from './commandInput.js';
export interface GeneralTargetSource { export interface GeneralTargetSource {
id: number; id: number;
@@ -7,6 +9,27 @@ export interface GeneralTargetSource {
cityId: number; cityId: number;
npcState: number; npcState: number;
officerLevel: number; officerLevel: number;
gold?: number;
rice?: number;
crew?: number;
train?: number;
atmos?: number;
troopId?: number;
}
export interface NationTargetSource {
id: number;
name: string;
color: string;
capitalName: string;
level: number;
power: number;
generalCount: number;
cityCount: number;
diplomacyState: number;
diplomacyTerm: number;
adjacent: boolean;
diplomacyRestricted?: boolean;
} }
export interface RefGeneralTargetOptions { export interface RefGeneralTargetOptions {
@@ -14,6 +37,11 @@ export interface RefGeneralTargetOptions {
generalTargets: Record<string, TurnCommandOption[]>; generalTargets: Record<string, TurnCommandOption[]>;
} }
export interface RefNationTargetOptions {
nations: TurnCommandOption[];
nationTargets: Record<string, TurnCommandOption[]>;
}
const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const; const SAME_NATION_GENERAL_COMMANDS = ['che_증여'] as const;
const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const; const SAME_NATION_NATION_COMMANDS = ['che_발령', 'che_포상', 'che_몰수', 'che_부대탈퇴지시'] as const;
@@ -24,20 +52,54 @@ export const buildRefGeneralTargetOptions = (options: {
generals: readonly GeneralTargetSource[]; generals: readonly GeneralTargetSource[];
nationNames: ReadonlyMap<number, string>; nationNames: ReadonlyMap<number, string>;
cityNames: ReadonlyMap<number, string>; cityNames: ReadonlyMap<number, string>;
troopNames?: ReadonlyMap<number, string>;
}): RefGeneralTargetOptions => { }): RefGeneralTargetOptions => {
const toOption = (entry: GeneralTargetSource): TurnCommandOption => ({ const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => {
value: entry.id, const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined;
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${ const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id);
options.cityNames.get(entry.cityId) ?? '재야' const isTroopExit = action === 'che_부대탈퇴지시';
})`, const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
}); const details = [
entry.gold === undefined ? null : `${entry.gold.toLocaleString()}`,
entry.rice === undefined ? null : `${entry.rice.toLocaleString()}`,
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
entry.troopId
? `탑승 부대 ${troopName ?? `#${entry.troopId}`}${entry.troopId === entry.id ? ' (부대장)' : ''}`
: '탑승 부대 없음',
].filter((value): value is string => Boolean(value));
if (isTroopExit) {
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
}
return {
value: entry.id,
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
options.cityNames.get(entry.cityId) ?? '재야'
})`,
description: details.join(' · '),
...(availableNow === undefined ? {} : { availableNow }),
...(entry.gold === undefined ? {} : { gold: entry.gold }),
...(entry.rice === undefined ? {} : { rice: entry.rice }),
...(entry.crew === undefined ? {} : { crew: entry.crew }),
...(entry.troopId === undefined ? {} : { troopId: entry.troopId }),
};
};
const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] => const project = (predicate: (entry: GeneralTargetSource) => boolean): TurnCommandOption[] =>
options.generals.filter(predicate).map(toOption); options.generals.filter(predicate).map((entry) => toOption(entry));
const sameNation = project((entry) => entry.nationId === options.actorNationId);
const generalTargets: Record<string, TurnCommandOption[]> = {}; const generalTargets: Record<string, TurnCommandOption[]> = {};
for (const action of SAME_NATION_GENERAL_COMMANDS) generalTargets[action] = sameNation; for (const action of SAME_NATION_GENERAL_COMMANDS) {
for (const action of SAME_NATION_NATION_COMMANDS) generalTargets[action] = sameNation; generalTargets[action] = options.generals
.filter((entry) => entry.nationId === options.actorNationId)
.map((entry) => toOption(entry, action));
}
for (const action of SAME_NATION_NATION_COMMANDS) {
generalTargets[action] = options.generals
.filter((entry) => entry.nationId === options.actorNationId)
.map((entry) => toOption(entry, action))
.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
}
generalTargets.che_선양 = project( generalTargets.che_선양 = project(
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId (entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId
@@ -53,3 +115,124 @@ export const buildRefGeneralTargetOptions = (options: {
generalTargets, generalTargets,
}; };
}; };
const DIPLOMACY_LABELS: Record<number, string> = {
[DIPLOMACY_STATE.WAR]: '전쟁',
[DIPLOMACY_STATE.DECLARATION]: '선포',
[DIPLOMACY_STATE.TRADE]: '교역',
[DIPLOMACY_STATE.NON_AGGRESSION]: '불가침',
};
const NATION_TARGET_COMMANDS = [
'che_물자원조',
'che_불가침제의',
'che_선전포고',
'che_종전제의',
'che_불가침파기제의',
] as const;
const nationAvailability = (
action: (typeof NATION_TARGET_COMMANDS)[number],
actorNationId: number,
target: NationTargetSource
): { available: boolean; reason: string } => {
if (target.id === actorNationId) return { available: false, reason: '아국은 대상이 아닙니다.' };
if (action === 'che_물자원조') {
return target.diplomacyRestricted
? { available: false, reason: '상대국이 외교제한 중입니다.' }
: { available: true, reason: '현재 원조 대상' };
}
if (action === 'che_불가침제의') {
const available = ![DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION].includes(target.diplomacyState as 0 | 1);
return { available, reason: available ? '현재 제의 가능' : '교전·선포 중에는 제의 불가' };
}
if (action === 'che_선전포고') {
if (!target.adjacent) return { available: false, reason: '인접 국가가 아닙니다.' };
const available = ![DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION, DIPLOMACY_STATE.NON_AGGRESSION].includes(
target.diplomacyState as 0 | 1 | 7
);
return { available, reason: available ? '현재 선전포고 가능' : '현재 외교 관계에서는 선전포고 불가' };
}
if (action === 'che_종전제의') {
const available = [DIPLOMACY_STATE.WAR, DIPLOMACY_STATE.DECLARATION].includes(target.diplomacyState as 0 | 1);
return { available, reason: available ? '현재 종전 제의 가능' : '전쟁·선포 중인 국가가 아닙니다.' };
}
const available = target.diplomacyState === DIPLOMACY_STATE.NON_AGGRESSION;
return { available, reason: available ? '현재 불가침 파기 제의 가능' : '불가침 중인 국가가 아닙니다.' };
};
/** 사령턴 외교 대상은 현재 명령에 맞는 국가부터 보이되, 예약 자체는 모든 대상을 유지한다. */
export const buildRefNationTargetOptions = (options: {
actorNationId: number;
nations: readonly NationTargetSource[];
}): RefNationTargetOptions => {
const baseOptions = options.nations.map<TurnCommandOption>((entry) => ({
value: entry.id,
label: entry.name,
color: entry.color,
description: `수도 ${entry.capitalName} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`,
}));
const nationTargets: Record<string, TurnCommandOption[]> = {};
for (const action of NATION_TARGET_COMMANDS) {
nationTargets[action] = options.nations
.map((entry) => {
const availability = nationAvailability(action, options.actorNationId, entry);
const relation = DIPLOMACY_LABELS[entry.diplomacyState] ?? `관계 ${entry.diplomacyState}`;
const term = entry.diplomacyTerm > 0 ? ` ${entry.diplomacyTerm}` : '';
return {
value: entry.id,
label: entry.name,
color: entry.color,
availableNow: availability.available,
description: `${availability.reason} · ${relation}${term} · 수도 ${entry.capitalName} · 국력 ${entry.power.toLocaleString()} · 도시 ${entry.cityCount.toLocaleString()} · 장수 ${entry.generalCount.toLocaleString()}`,
power: entry.power,
} as TurnCommandOption & { power: number };
})
.sort(
(left, right) =>
Number(right.availableNow) - Number(left.availableNow) ||
right.power - left.power ||
Number(left.value) - Number(right.value)
)
.map(({ power: _power, ...entry }) => entry);
}
return { nations: baseOptions, nationTargets };
};
const RESOURCE_ACTION_GUIDE = [
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1200, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7000, 8000, 9000,
10000,
];
/** Ref SelectAmount의 dropdown 값을 공통 예약 입력 DTO로 옮긴다. */
export const buildRefAmountPresets = (
nationLevel: number,
maxResourceActionAmount: number
): Record<string, TurnCommandAmountPreset> => {
const resourceMax = maxResourceActionAmount > 0 ? maxResourceActionAmount : 10_000;
const resourceValues = RESOURCE_ACTION_GUIDE.filter((value) => value <= resourceMax);
if (!resourceValues.includes(resourceMax)) resourceValues.push(resourceMax);
const resourcePreset: TurnCommandAmountPreset = {
values: resourceValues,
defaultValue: Math.min(1000, resourceMax),
min: Math.min(100, resourceMax),
max: resourceMax,
step: 1,
};
const aidMax = Math.max(10_000, Math.max(1, nationLevel) * 10_000);
const aidPreset: TurnCommandAmountPreset = {
values: Array.from({ length: Math.max(1, nationLevel) }, (_, index) => (index + 1) * 10_000),
defaultValue: Math.min(1000, aidMax),
min: 1000,
max: aidMax,
step: 10,
};
return {
che_증여: resourcePreset,
che_헌납: resourcePreset,
che_군량매매: resourcePreset,
che_포상: resourcePreset,
che_몰수: resourcePreset,
che_물자원조: aidPreset,
};
};
+134 -1
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildRefGeneralTargetOptions, type GeneralTargetSource } from '../src/turns/commandTargets.js'; import {
buildRefAmountPresets,
buildRefGeneralTargetOptions,
buildRefNationTargetOptions,
type GeneralTargetSource,
} from '../src/turns/commandTargets.js';
const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({ const general = (overrides: Partial<GeneralTargetSource>): GeneralTargetSource => ({
id: 1, id: 1,
@@ -48,4 +53,132 @@ describe('Ref command general targets', () => {
expect(ids('che_장수대상임관')).toEqual([2, 3, 4, 5]); expect(ids('che_장수대상임관')).toEqual([2, 3, 4, 5]);
expect(result.generals.map((entry) => entry.value)).toEqual([1, 2, 4]); expect(result.generals.map((entry) => entry.value)).toEqual([1, 2, 4]);
}); });
it('adds resource, crew, and troop details and puts actual troop members first for kick orders', () => {
const detailed = buildRefGeneralTargetOptions({
actorId: 1,
actorNationId: 1,
generals: [
general({ id: 1, name: '본인', gold: 5000, rice: 4000, crew: 1000, troopId: 0 }),
general({
id: 2,
name: '부대원',
gold: 100,
rice: 200,
crew: 900,
train: 80,
atmos: 70,
troopId: 3,
}),
general({ id: 3, name: '부대장', npcState: 2, gold: 300, rice: 400, crew: 800, troopId: 3 }),
],
nationNames: new Map([[1, '아국']]),
cityNames: new Map([[10, '업']]),
troopNames: new Map([[3, '청룡대']]),
});
expect(detailed.generalTargets.che_부대탈퇴지시?.map((entry) => entry.value)).toEqual([2, 1, 3]);
expect(detailed.generalTargets.che_부대탈퇴지시?.[0]).toMatchObject({
availableNow: true,
gold: 100,
rice: 200,
crew: 900,
troopId: 3,
description: expect.stringContaining('탑승 부대 청룡대'),
});
expect(detailed.generalTargets.che_포상?.[0]?.description).toContain('금 5,000 · 쌀 4,000 · 병력 1,000');
});
});
describe('Ref nation target guidance', () => {
const nations = [
{
id: 1,
name: '아국',
color: '#008000',
capitalName: '업',
level: 3,
power: 1000,
generalCount: 5,
cityCount: 2,
diplomacyState: 7,
diplomacyTerm: 0,
adjacent: false,
},
{
id: 2,
name: '교역국',
color: '#800000',
capitalName: '허창',
level: 2,
power: 800,
generalCount: 4,
cityCount: 2,
diplomacyState: 2,
diplomacyTerm: 0,
adjacent: true,
diplomacyRestricted: true,
},
{
id: 3,
name: '불가침국',
color: '#000080',
capitalName: '건업',
level: 2,
power: 900,
generalCount: 3,
cityCount: 1,
diplomacyState: 7,
diplomacyTerm: 12,
adjacent: false,
},
{
id: 4,
name: '전쟁국',
color: '#ff0000',
capitalName: '성도',
level: 1,
power: 500,
generalCount: 2,
cityCount: 1,
diplomacyState: 0,
diplomacyTerm: 6,
adjacent: true,
},
];
it('sorts the currently relevant relation first for each diplomacy command', () => {
const result = buildRefNationTargetOptions({ actorNationId: 1, nations });
expect(result.nationTargets.che_선전포고?.map((entry) => entry.value)).toEqual([2, 1, 3, 4]);
expect(result.nationTargets.che_종전제의?.[0]).toMatchObject({ value: 4, availableNow: true });
expect(result.nationTargets.che_불가침파기제의?.[0]).toMatchObject({ value: 3, availableNow: true });
expect(result.nationTargets.che_물자원조?.map((entry) => entry.value)).toEqual([3, 4, 1, 2]);
expect(result.nationTargets.che_물자원조?.at(-1)?.description).toContain('외교제한');
expect(result.nationTargets.che_불가침파기제의?.[0]?.description).toContain('불가침 12턴');
expect(result.nationTargets.che_불가침파기제의?.at(-1)).toMatchObject({ value: 4, availableNow: false });
});
});
describe('Ref amount presets', () => {
it('keeps the exact reward/seizure guide and the nation-level aid guide', () => {
const result = buildRefAmountPresets(3, 10_000);
expect(result.che_포상).toEqual({
values: [
100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1200, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7000,
8000, 9000, 10000,
],
defaultValue: 1000,
min: 100,
max: 10_000,
step: 1,
});
expect(result.che_몰수).toEqual(result.che_포상);
expect(result.che_물자원조).toEqual({
values: [10_000, 20_000, 30_000],
defaultValue: 1000,
min: 1000,
max: 30_000,
step: 10,
});
});
}); });
+483 -31
View File
@@ -48,21 +48,184 @@ const inputOptions = {
nations: [ nations: [
{ value: 1, label: '아국', color: '#008000' }, { value: 1, label: '아국', color: '#008000' },
{ value: 2, label: '적국', color: '#800000', description: '수도 허창' }, { 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: [ generals: [
{ value: 1, label: '장수 (아국 · 업)' }, { value: 1, label: '장수 (아국 · 업)' },
{ value: 2, label: '관우 (아국 · 업)' }, { value: 2, label: '관우 (아국 · 업)' },
], ],
generalTargets: { generalTargets: {
che_포상: [ che_포상: [
{ value: 1, label: '장수 (아국 · 업)' }, {
{ value: 2, label: '관우 (아국 · 업)' }, value: 1,
{ value: 3, label: '여포NPC (아국 · 업)' }, 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_몰수: [ che_몰수: [
{ value: 1, label: '장수 (아국 · 업)' }, {
{ value: 2, label: '관우 (아국 · 업)' }, value: 1,
{ value: 3, label: '여포NPC (아국 · 업)' }, 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: '보병' }], 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: { context: {
actorGold: 1000, actorGold: 1000,
actorRice: 1000, actorRice: 1000,
@@ -124,6 +292,40 @@ const inputOptions = {
nationLevel: 1, 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 = { const commandTable = {
general: [ general: [
{ {
@@ -221,6 +423,13 @@ const commandTable = {
{ {
category: '인사', category: '인사',
values: [ values: [
{
...buildGeneralCommand('che_발령', '발령'),
inputFields: [
...buildGeneralCommand('che_발령', '발령').inputFields,
...buildCityCommand('che_발령', '발령').inputFields,
],
},
{ {
key: 'che_포상', key: 'che_포상',
name: '포상', 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: '외교', category: '외교',
values: [ values: [
{ {
key: 'che_선전포고', key: 'che_물자원조',
name: '선전포고', name: '원조',
reqArg: true, reqArg: true,
possible: true, possible: true,
status: 'needsInput', status: 'needsInput',
inputFields: [ inputFields: [
...buildNationCommand('che_물자원조', '원조').inputFields,
{ {
key: 'destNationId', key: 'amountList',
label: '대상 국가', label: '지원 물자',
kind: 'select', kind: 'numberTuple',
required: true, 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 = { const refChiefCommandTable = {
general: [], general: [],
nation: [ 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: /^(?:국가:)?인사$/, exact: true }).click();
await chiefPicker.getByRole('button', { name: /포상/ }).click(); await chiefPicker.getByRole('button', { name: /포상/ }).click();
const chiefForm = chiefPicker.getByTestId('command-argument-form'); 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'); 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([ await expect(chiefTarget.locator('option')).toHaveText([
'장수 (아국 · 업)', '장수 (아국 · 업)',
'관우 (아국 · 업)',
'여포NPC (아국 · 업)', '여포NPC (아국 · 업)',
'관우 (아국 · 업)',
]); ]);
await chiefTarget.selectOption('3'); await chiefTarget.selectOption('3');
const geometry = await chiefForm.evaluate((element) => { 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 row.getByRole('button', { name: entry.command, exact: true }).click();
await expect(picker).toHaveCount(0); await expect(picker).toHaveCount(0);
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText( await expect(
`${entry.name}${entry.savedAmount}${entry.command}` 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) => const refreshResponse = page.waitForResponse((apiResponse) =>
@@ -1344,9 +1582,9 @@ test('keeps arbitrary direct recruitment and mercenary amounts for all four arms
await refreshResponse; await refreshResponse;
for (const entry of entries) { for (const entry of entries) {
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText( await expect(
`${entry.name}${entry.savedAmount}${entry.command}` page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)
); ).toHaveText(`${entry.name}${entry.savedAmount}${entry.command}`);
} }
await page await page
.locator('[data-command-scope="general"]') .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 }); 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 }) => { test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
await install(page); await install(page);
await page.setViewportSize({ width: 500, height: 900 }); 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'); await expect.poll(async () => (await tooltipStyle()).visibility).toBe('visible');
expect((await tooltipStyle()).content).toContain('자율 행동: 200年 3月'); expect((await tooltipStyle()).content).toContain('자율 행동: 200年 3月');
expect(await editor.evaluate((element) => element.getBoundingClientRect().height)).toBe(heightBeforeHover); 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 page.mouse.move(0, 0);
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('hidden'); 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.horizontalOverflow).toBeLessThanOrEqual(0);
expect(desktopGeometry.controlPadOffset).toBe(0); expect(desktopGeometry.controlPadOffset).toBe(0);
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(20); 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(); const mobilePage = await context.newPage();
await install(mobilePage); await install(mobilePage);
@@ -251,7 +251,8 @@ const selectCommand = (commandKey: string) => {
selectedCommand.value = command; selectedCommand.value = command;
commandArgs.value = {}; commandArgs.value = {};
commandArgsValid.value = !command.reqArg; commandArgsValid.value = !command.reqArg;
if (!command.reqArg) submitCommand(); const needsInformationalConfirmation = commandArgumentPresentation(command.key).mapTarget === 'capital';
if (!command.reqArg && !needsInformationalConfirmation) submitCommand();
}; };
const submitCommand = () => { const submitCommand = () => {
const command = selectedCommand.value; const command = selectedCommand.value;
@@ -737,7 +738,11 @@ const clickOutsideMenu = (event: Event) => {
@submit="submitCommand" @submit="submitCommand"
/> />
<CommandArgumentForm <CommandArgumentForm
v-else-if="selectedCommand.reqArg && props.commandTable" v-else-if="
props.commandTable &&
(selectedCommand.reqArg ||
commandArgumentPresentation(selectedCommand.key).mapTarget === 'capital')
"
:command-key="selectedCommand.key" :command-key="selectedCommand.key"
:fields="selectedCommand.inputFields" :fields="selectedCommand.inputFields"
:options="props.commandTable.inputOptions" :options="props.commandTable.inputOptions"
@@ -1,10 +1,11 @@
export type CommandArgumentPresentation = { export type CommandArgumentPresentation = {
lines: string[]; lines: string[];
mapTarget?: 'city' | 'nation'; mapTarget?: 'city' | 'nation' | 'capital';
}; };
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' }); const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' }); const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' });
const capitalTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'capital' });
// Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다. // Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다.
// 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다. // 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다.
@@ -33,6 +34,14 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
]), ]),
cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']), cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']),
che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']), che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']),
che_증축: capitalTarget([
'현재 수도를 증축해 인구·내정·성벽의 최대치를 높입니다.',
'지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.',
]),
che_감축: capitalTarget([
'현재 수도를 감축해 인구·내정·성벽의 최대치를 낮추고 국고를 회수합니다.',
'지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.',
]),
che_선전포고: nationTarget([ che_선전포고: nationTarget([
'선택한 국가에 선전포고합니다.', '선택한 국가에 선전포고합니다.',
@@ -3,6 +3,19 @@ export type CommandOption = {
label: string; label: string;
color?: string; color?: string;
description?: 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 = { export type CommandMapData = {
@@ -94,6 +107,7 @@ export type CommandTable = {
inputOptions: { inputOptions: {
cities: CommandOption[]; cities: CommandOption[];
nations: CommandOption[]; nations: CommandOption[];
nationTargets?: Record<string, CommandOption[]>;
generals: CommandOption[]; generals: CommandOption[];
generalTargets?: Record<string, CommandOption[]>; generalTargets?: Record<string, CommandOption[]>;
crewTypes: CommandOption[]; crewTypes: CommandOption[];
@@ -102,6 +116,7 @@ export type CommandTable = {
colors: CommandOption[]; colors: CommandOption[];
items: Record<string, CommandOption[]>; items: Record<string, CommandOption[]>;
recruitment: RecruitmentInfo | null; recruitment: RecruitmentInfo | null;
amountPresets?: Record<string, CommandAmountPreset>;
context?: CommandInputContext; context?: CommandInputContext;
}; };
}; };
@@ -30,11 +30,31 @@ const values = reactive<Record<string, unknown>>({});
const presentation = computed(() => commandArgumentPresentation(props.commandKey)); const presentation = computed(() => commandArgumentPresentation(props.commandKey));
const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden')); 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[] => { const optionsFor = (field: CommandInputField): CommandOption[] => {
if (field.options) return field.options; if (field.options) return field.options;
if (!field.optionSource) return []; if (!field.optionSource) return [];
if (field.optionSource === 'generals') { 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') { if (field.optionSource === 'items') {
return props.options.items[String(values.itemType ?? '')] ?? []; return props.options.items[String(values.itemType ?? '')] ?? [];
@@ -45,10 +65,16 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
const defaultValue = (field: CommandInputField): unknown => { const defaultValue = (field: CommandInputField): unknown => {
if (field.kind === 'hidden') return field.constValue; if (field.kind === 'hidden') return field.constValue;
if (field.kind === 'boolean') return true; if (field.kind === 'boolean') return true;
if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0]; if (field.kind === 'numberTuple') {
if (field.kind === 'number') return field.min ?? 0; 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') { if (field.kind === 'select') {
const options = optionsFor(field); const options = optionsFor(field);
const commandSpecificNationTargets =
field.optionSource === 'nations' ? props.options.nationTargets?.[props.commandKey] : undefined;
if (commandSpecificNationTargets?.length) return commandSpecificNationTargets[0]?.value ?? '';
const mapDefault = const mapDefault =
field.optionSource === 'cities' && (field.key === 'destCityId' || field.key === 'destCityID') field.optionSource === 'cities' && (field.key === 'destCityId' || field.key === 'destCityID')
? props.mapData?.myCity ? props.mapData?.myCity
@@ -96,7 +122,8 @@ const showMap = computed(
() => () =>
Boolean(props.mapData && props.mapLayout) && Boolean(props.mapData && props.mapLayout) &&
((presentation.value.mapTarget === 'city' && cityTargetField.value) || ((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>(() => { const mapSelectedCityId = computed<number | null>(() => {
if (!props.mapData) return null; if (!props.mapData) return null;
@@ -107,7 +134,15 @@ const mapSelectedCityId = computed<number | null>(() => {
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) { if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key]; const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return null; 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; return null;
}); });
@@ -129,6 +164,11 @@ const selectedMapTargetName = computed(() => {
if (typeof nationId !== 'number') return '-'; if (typeof nationId !== 'number') return '-';
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-'; 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 '-'; return '-';
}); });
@@ -181,6 +221,14 @@ const mapTargetSummary = computed(() => {
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length; const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}`; 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 ''; return '';
}); });
@@ -225,6 +273,35 @@ const setTupleValue = (field: CommandInputField, index: number, rawValue: string
values[field.key] = tuple; 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(() => const isValid = computed(() =>
props.fields.every((field) => { props.fields.every((field) => {
const value = values[field.key]; const value = values[field.key];
@@ -237,14 +314,18 @@ const isValid = computed(() =>
); );
} }
if (field.kind === 'number') { if (field.kind === 'number') {
const min = effectiveMin(field);
const max = effectiveMax(field);
return ( return (
typeof value === 'number' && typeof value === 'number' &&
Number.isFinite(value) && Number.isFinite(value) &&
(field.min === undefined || value >= field.min) && (min === undefined || value >= min) &&
(field.max === undefined || value <= field.max) (max === undefined || value <= max)
); );
} }
if (field.kind === 'numberTuple') { if (field.kind === 'numberTuple') {
const min = effectiveMin(field);
const max = effectiveMax(field);
return ( return (
Array.isArray(value) && Array.isArray(value) &&
value.length === 2 && value.length === 2 &&
@@ -252,8 +333,8 @@ const isValid = computed(() =>
(entry) => (entry) =>
typeof entry === 'number' && typeof entry === 'number' &&
Number.isFinite(entry) && Number.isFinite(entry) &&
(field.min === undefined || entry >= field.min) && (min === undefined || entry >= min) &&
(field.max === undefined || entry <= field.max) (max === undefined || entry <= max)
) )
); );
} }
@@ -274,7 +355,11 @@ watch(
</script> </script>
<template> <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"> <div v-if="showMap" class="command-map" data-testid="command-argument-map">
<MapViewer <MapViewer
:map-data="props.mapData ?? null" :map-data="props.mapData ?? null"
@@ -284,18 +369,24 @@ watch(
:detail-mode="true" :detail-mode="true"
:fit-container="true" :fit-container="true"
:show-current-city-marker="true" :show-current-city-marker="true"
:readonly="presentation.mapTarget === 'capital'"
@select-city="selectMapCity" @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"> <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> <span class="status-key">현재 도시</span>
<strong>{{ currentCityName }}</strong> <strong>{{ currentCityName }}</strong>
</span> </span>
<span aria-hidden="true"></span> <span v-if="presentation.mapTarget !== 'capital'" aria-hidden="true"></span>
<span class="selected-target-status"> <span class="selected-target-status">
<span class="status-key">{{ <span class="status-key">{{
presentation.mapTarget === 'nation' ? '선택 국가' : '선택 도시' presentation.mapTarget === 'nation'
? '선택 국가'
: presentation.mapTarget === 'capital'
? '현재 수도'
: '선택 도시'
}}</span> }}</span>
<strong>{{ selectedMapTargetName }}</strong> <strong>{{ selectedMapTargetName }}</strong>
</span> </span>
@@ -320,16 +411,29 @@ watch(
:maxlength="field.max" :maxlength="field.max"
@input="values[field.key] = ($event.target as HTMLInputElement).value" @input="values[field.key] = ($event.target as HTMLInputElement).value"
/> />
<input <div v-else-if="field.kind === 'number'" class="number-options">
v-else-if="field.kind === 'number'" <input
:id="`command-arg-${field.key}`" :id="`command-arg-${field.key}`"
type="number" type="number"
:value="Number(values[field.key] ?? 0)" :value="Number(values[field.key] ?? 0)"
:min="field.min" :min="effectiveMin(field)"
:max="field.max" :max="effectiveMax(field)"
:step="field.step" :step="effectiveStep(field)"
@input="values[field.key] = Number(($event.target as HTMLInputElement).value)" @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 <select
v-else-if="field.kind === 'select'" v-else-if="field.kind === 'select'"
:id="`command-arg-${field.key}`" :id="`command-arg-${field.key}`"
@@ -362,11 +466,23 @@ watch(
<input <input
type="number" type="number"
:value="(values[field.key] as number[] | undefined)?.[index] ?? 0" :value="(values[field.key] as number[] | undefined)?.[index] ?? 0"
:min="field.min" :min="effectiveMin(field)"
:max="field.max" :max="effectiveMax(field)"
:step="field.step" :step="effectiveStep(field)"
@input="setTupleValue(field, index, ($event.target as HTMLInputElement).value)" @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> </label>
</div> </div>
<div <div
@@ -384,6 +500,30 @@ watch(
/> />
<span>{{ selectedOptionFor(field)?.description }}</span> <span>{{ selectedOptionFor(field)?.description }}</span>
</div> </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>
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div> <div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
</div> </div>
@@ -484,6 +624,74 @@ watch(
line-height: 1.35; 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 { .option-color {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -511,6 +719,19 @@ select {
font: inherit; 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, .boolean-options,
.tuple-options { .tuple-options {
display: flex; display: flex;
@@ -531,17 +752,34 @@ select {
} }
.tuple-options label { .tuple-options label {
display: flex; display: grid;
grid-template-columns: max-content minmax(68px, 1fr) minmax(82px, 0.7fr);
align-items: center; align-items: center;
gap: 4px; gap: 4px;
min-width: 0; min-width: 0;
} }
.tuple-options input { .tuple-options input {
width: 80px; width: 100%;
margin: 0; 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 { .argument-error {
padding: 5px 8px; padding: 5px 8px;
color: #ff9a8f; color: #ff9a8f;
@@ -22,6 +22,7 @@ const props = defineProps<{
showName: boolean; showName: boolean;
mapScale: number; mapScale: number;
selectOnly?: boolean; selectOnly?: boolean;
readonly?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -33,18 +34,27 @@ const emit = defineEmits<{
const size = computed(() => (6 + props.city.level * 2) * props.mapScale); const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
const stateSize = computed(() => 8 * props.mapScale); const stateSize = computed(() => 8 * props.mapScale);
const stateOffset = computed(() => -6 * 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> </script>
<template> <template>
<component <component
:is="props.selectOnly ? 'button' : RouterLink" :is="props.readonly ? 'div' : props.selectOnly ? 'button' : RouterLink"
class="map-city" class="map-city"
:type="props.selectOnly ? 'button' : undefined" :type="!props.readonly && props.selectOnly ? 'button' : undefined"
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }" :to="
props.selectOnly || props.readonly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }
"
:class="[ :class="[
`state-${props.city.stateClass}`, `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` }" :style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)" @mouseenter="emit('hover', props.city.id)"
@@ -86,6 +96,10 @@ const selectCity = () => emit('select', props.city.id);
background: transparent; background: transparent;
} }
.map-city.readonly {
cursor: default;
}
.city-dot { .city-dot {
border: 1px solid rgba(232, 221, 196, 0.6); border: 1px solid rgba(232, 221, 196, 0.6);
display: flex; display: flex;
@@ -48,6 +48,7 @@ const props = defineProps<{
themeName: string; themeName: string;
mapScale: number; mapScale: number;
selectOnly?: boolean; selectOnly?: boolean;
readonly?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -143,7 +144,9 @@ const capitalIconStyle = computed(() => ({
height: `${10 * props.mapScale}px`, 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(() => ({ const cityStateStyle = computed(() => ({
width: `${12 * props.mapScale}px`, width: `${12 * props.mapScale}px`,
@@ -154,11 +157,20 @@ const cityStateStyle = computed(() => ({
<template> <template>
<component <component
:is="props.selectOnly ? 'button' : RouterLink" :is="props.readonly ? 'div' : props.selectOnly ? 'button' : RouterLink"
class="city-base" class="city-base"
:type="props.selectOnly ? 'button' : undefined" :type="!props.readonly && props.selectOnly ? 'button' : undefined"
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }" :to="
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]" 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" :style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)" @mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')" @mouseleave="emit('leave')"
@@ -195,6 +207,10 @@ const cityStateStyle = computed(() => ({
background: transparent; background: transparent;
} }
.city-base.readonly {
cursor: default;
}
.city-bg { .city-bg {
position: absolute; position: absolute;
background-position: center; background-position: center;
@@ -72,6 +72,7 @@ const props = withDefaults(
detailMode?: boolean; detailMode?: boolean;
fitContainer?: boolean; fitContainer?: boolean;
showCurrentCityMarker?: boolean; showCurrentCityMarker?: boolean;
readonly?: boolean;
}>(), }>(),
{ {
// Vue casts an absent Boolean prop to false unless undefined is an explicit default. // 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) => { const selectCity = (cityId: number) => {
if (props.readonly) return;
emit('select-city', cityId); emit('select-city', cityId);
if (props.selectedCityId === undefined) { if (props.selectedCityId === undefined) {
mapStore.setSelectedCity(cityId); mapStore.setSelectedCity(cityId);
@@ -443,6 +445,7 @@ const selectCity = (cityId: number) => {
:map-scale="mapScale" :map-scale="mapScale"
:show-name="showCityName" :show-name="showCityName"
:select-only="props.selectedCityId !== undefined" :select-only="props.selectedCityId !== undefined"
:readonly="props.readonly"
v-bind="detailProps" v-bind="detailProps"
@hover="setHoveredCity" @hover="setHoveredCity"
@leave="setHoveredCity(null)" @leave="setHoveredCity(null)"