merge: 사령턴 대상 선택 보강을 main에 반영
This commit is contained in:
@@ -35,7 +35,11 @@ import {
|
||||
} from '../../turns/reservedTurns.js';
|
||||
import { getOwnedGeneral } from '../shared/general.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
|
||||
.number()
|
||||
@@ -159,52 +163,125 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
const moduleBundlePromise = environmentPromise.then((environment) =>
|
||||
loadActionModuleBundle(environment.unitSet, environment.scenarioEffect)
|
||||
);
|
||||
const [city, nation, nationGenerals, cities, nations, generals, environment, traits, moduleBundle, map] =
|
||||
await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.general.findMany({
|
||||
where: { nationId: general.nationId },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ctx.db.city.findMany({ orderBy: { id: 'asc' } }),
|
||||
ctx.db.nation.findMany({
|
||||
select: { id: true, name: true, color: true },
|
||||
orderBy: { id: 'asc' },
|
||||
}),
|
||||
ctx.db.general.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nationId: true,
|
||||
cityId: true,
|
||||
npcState: true,
|
||||
officerLevel: true,
|
||||
},
|
||||
orderBy: [{ npcState: 'asc' }, { name: 'asc' }, { id: 'asc' }],
|
||||
}),
|
||||
environmentPromise,
|
||||
loadBattleSimTraitOptions(),
|
||||
moduleBundlePromise,
|
||||
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
||||
]);
|
||||
const [
|
||||
city,
|
||||
nation,
|
||||
nationGenerals,
|
||||
cities,
|
||||
nations,
|
||||
generals,
|
||||
diplomacy,
|
||||
troops,
|
||||
environment,
|
||||
traits,
|
||||
moduleBundle,
|
||||
map,
|
||||
] = await Promise.all([
|
||||
general.cityId > 0
|
||||
? ctx.db.city.findUnique({
|
||||
where: { id: general.cityId },
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.nation.findUnique({
|
||||
where: { id: general.nationId },
|
||||
})
|
||||
: null,
|
||||
general.nationId > 0
|
||||
? ctx.db.general.findMany({
|
||||
where: { nationId: general.nationId },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ctx.db.city.findMany({ orderBy: { id: 'asc' } }),
|
||||
ctx.db.nation.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true,
|
||||
capitalCityId: true,
|
||||
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 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({
|
||||
actorId: general.id,
|
||||
actorNationId: general.nationId,
|
||||
generals,
|
||||
nationNames: new Map(nations.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'] = {
|
||||
horse: [{ value: 'None', label: '판매/해제' }],
|
||||
@@ -234,11 +311,8 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${nationById.get(entry.nationId)?.name ?? '무주'})`,
|
||||
})),
|
||||
nations: nations.map((entry) => ({
|
||||
value: entry.id,
|
||||
label: entry.name,
|
||||
color: entry.color,
|
||||
})),
|
||||
nations: nationTargetOptions.nations,
|
||||
nationTargets: nationTargetOptions.nationTargets,
|
||||
generals: generalTargetOptions.generals,
|
||||
generalTargets: generalTargetOptions.generalTargets,
|
||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||
@@ -273,6 +347,10 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
||||
unitSet: environment.unitSet,
|
||||
generalActionModules: moduleBundle.general,
|
||||
}),
|
||||
amountPresets: buildRefAmountPresets(
|
||||
nation?.level ?? 0,
|
||||
readGeneralMetaNumber(asRecord(asRecord(worldState.config).const), 'maxResourceActionAmount') ?? 10_000
|
||||
),
|
||||
context: {
|
||||
actorGold: general.gold,
|
||||
actorRice: general.rice,
|
||||
|
||||
@@ -16,6 +16,19 @@ export interface TurnCommandOption {
|
||||
label: string;
|
||||
color?: 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 {
|
||||
@@ -70,6 +83,7 @@ export interface TurnCommandInputField {
|
||||
export interface TurnCommandInputOptions {
|
||||
cities: TurnCommandOption[];
|
||||
nations: TurnCommandOption[];
|
||||
nationTargets?: Record<string, TurnCommandOption[]>;
|
||||
generals: TurnCommandOption[];
|
||||
generalTargets?: Record<string, TurnCommandOption[]>;
|
||||
crewTypes: TurnCommandOption[];
|
||||
@@ -78,6 +92,7 @@ export interface TurnCommandInputOptions {
|
||||
colors: TurnCommandOption[];
|
||||
items: Record<string, TurnCommandOption[]>;
|
||||
recruitment: TurnCommandRecruitmentInfo | null;
|
||||
amountPresets?: Record<string, TurnCommandAmountPreset>;
|
||||
context?: {
|
||||
actorGold: number;
|
||||
actorRice: number;
|
||||
|
||||
@@ -770,6 +770,7 @@ export const buildTurnCommandTable = async (options: {
|
||||
inputOptions: options.inputOptions ?? {
|
||||
cities: [],
|
||||
nations: [],
|
||||
nationTargets: {},
|
||||
generals: [],
|
||||
generalTargets: {},
|
||||
crewTypes: [],
|
||||
@@ -778,6 +779,7 @@ export const buildTurnCommandTable = async (options: {
|
||||
colors: [],
|
||||
items: {},
|
||||
recruitment: null,
|
||||
amountPresets: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
id: number;
|
||||
@@ -7,6 +9,27 @@ export interface GeneralTargetSource {
|
||||
cityId: number;
|
||||
npcState: 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 {
|
||||
@@ -14,6 +37,11 @@ export interface RefGeneralTargetOptions {
|
||||
generalTargets: Record<string, TurnCommandOption[]>;
|
||||
}
|
||||
|
||||
export interface RefNationTargetOptions {
|
||||
nations: TurnCommandOption[];
|
||||
nationTargets: Record<string, TurnCommandOption[]>;
|
||||
}
|
||||
|
||||
const SAME_NATION_GENERAL_COMMANDS = ['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[];
|
||||
nationNames: ReadonlyMap<number, string>;
|
||||
cityNames: ReadonlyMap<number, string>;
|
||||
troopNames?: ReadonlyMap<number, string>;
|
||||
}): RefGeneralTargetOptions => {
|
||||
const toOption = (entry: GeneralTargetSource): TurnCommandOption => ({
|
||||
value: entry.id,
|
||||
label: `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${
|
||||
options.cityNames.get(entry.cityId) ?? '재야'
|
||||
})`,
|
||||
});
|
||||
const toOption = (entry: GeneralTargetSource, action?: string): TurnCommandOption => {
|
||||
const troopName = entry.troopId ? options.troopNames?.get(entry.troopId) : undefined;
|
||||
const isTroopMember = Boolean(entry.troopId && entry.troopId !== entry.id);
|
||||
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[] =>
|
||||
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[]> = {};
|
||||
for (const action of SAME_NATION_GENERAL_COMMANDS) generalTargets[action] = sameNation;
|
||||
for (const action of SAME_NATION_NATION_COMMANDS) generalTargets[action] = sameNation;
|
||||
for (const action of SAME_NATION_GENERAL_COMMANDS) {
|
||||
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(
|
||||
(entry) => entry.nationId !== 0 && entry.nationId === options.actorNationId && entry.id !== options.actorId
|
||||
@@ -53,3 +115,124 @@ export const buildRefGeneralTargetOptions = (options: {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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 => ({
|
||||
id: 1,
|
||||
@@ -48,4 +53,132 @@ describe('Ref command general targets', () => {
|
||||
expect(ids('che_장수대상임관')).toEqual([2, 3, 4, 5]);
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,21 +48,184 @@ const inputOptions = {
|
||||
nations: [
|
||||
{ value: 1, label: '아국', color: '#008000' },
|
||||
{ value: 2, label: '적국', color: '#800000', description: '수도 허창' },
|
||||
{ value: 3, label: '불가침국', color: '#000080', description: '수도 단양' },
|
||||
],
|
||||
nationTargets: {
|
||||
che_물자원조: [
|
||||
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 원조 대상 · 교역' },
|
||||
{
|
||||
value: 3,
|
||||
label: '불가침국',
|
||||
color: '#000080',
|
||||
availableNow: true,
|
||||
description: '현재 원조 대상 · 불가침 12턴',
|
||||
},
|
||||
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
||||
],
|
||||
che_불가침제의: [
|
||||
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 제의 가능 · 교역' },
|
||||
{
|
||||
value: 3,
|
||||
label: '불가침국',
|
||||
color: '#000080',
|
||||
availableNow: true,
|
||||
description: '현재 제의 가능 · 불가침 12턴',
|
||||
},
|
||||
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
||||
],
|
||||
che_선전포고: [
|
||||
{ value: 2, label: '적국', color: '#800000', availableNow: true, description: '현재 선전포고 가능 · 교역' },
|
||||
{
|
||||
value: 3,
|
||||
label: '불가침국',
|
||||
color: '#000080',
|
||||
availableNow: false,
|
||||
description: '현재 외교 관계에서는 선전포고 불가',
|
||||
},
|
||||
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
||||
],
|
||||
che_종전제의: [
|
||||
{
|
||||
value: 2,
|
||||
label: '적국',
|
||||
color: '#800000',
|
||||
availableNow: true,
|
||||
description: '현재 종전 제의 가능 · 전쟁 6턴',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '불가침국',
|
||||
color: '#000080',
|
||||
availableNow: false,
|
||||
description: '전쟁·선포 중인 국가가 아닙니다.',
|
||||
},
|
||||
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
||||
],
|
||||
che_불가침파기제의: [
|
||||
{
|
||||
value: 3,
|
||||
label: '불가침국',
|
||||
color: '#000080',
|
||||
availableNow: true,
|
||||
description: '현재 불가침 파기 제의 가능 · 불가침 12턴',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '적국',
|
||||
color: '#800000',
|
||||
availableNow: false,
|
||||
description: '불가침 중인 국가가 아닙니다.',
|
||||
},
|
||||
{ value: 1, label: '아국', color: '#008000', availableNow: false, description: '아국은 대상이 아닙니다.' },
|
||||
],
|
||||
},
|
||||
generals: [
|
||||
{ value: 1, label: '장수 (아국 · 업)' },
|
||||
{ value: 2, label: '관우 (아국 · 업)' },
|
||||
],
|
||||
generalTargets: {
|
||||
che_포상: [
|
||||
{ value: 1, label: '장수 (아국 · 업)' },
|
||||
{ value: 2, label: '관우 (아국 · 업)' },
|
||||
{ value: 3, label: '여포NPC (아국 · 업)' },
|
||||
{
|
||||
value: 1,
|
||||
label: '장수 (아국 · 업)',
|
||||
gold: 5000,
|
||||
rice: 400,
|
||||
crew: 500,
|
||||
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '관우 (아국 · 업)',
|
||||
gold: 100,
|
||||
rice: 4000,
|
||||
crew: 1200,
|
||||
troopId: 2,
|
||||
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '여포NPC (아국 · 업)',
|
||||
gold: 3000,
|
||||
rice: 500,
|
||||
crew: 1500,
|
||||
troopId: 2,
|
||||
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
|
||||
},
|
||||
],
|
||||
che_몰수: [
|
||||
{ value: 1, label: '장수 (아국 · 업)' },
|
||||
{ value: 2, label: '관우 (아국 · 업)' },
|
||||
{ value: 3, label: '여포NPC (아국 · 업)' },
|
||||
{
|
||||
value: 1,
|
||||
label: '장수 (아국 · 업)',
|
||||
gold: 5000,
|
||||
rice: 400,
|
||||
crew: 500,
|
||||
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '관우 (아국 · 업)',
|
||||
gold: 100,
|
||||
rice: 4000,
|
||||
crew: 1200,
|
||||
troopId: 2,
|
||||
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '여포NPC (아국 · 업)',
|
||||
gold: 3000,
|
||||
rice: 500,
|
||||
crew: 1500,
|
||||
troopId: 2,
|
||||
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
|
||||
},
|
||||
],
|
||||
che_발령: [
|
||||
{
|
||||
value: 1,
|
||||
label: '장수 (아국 · 업)',
|
||||
crew: 500,
|
||||
description: '금 5,000 · 쌀 400 · 병력 500 · 탑승 부대 없음',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '관우 (아국 · 업)',
|
||||
crew: 1200,
|
||||
troopId: 2,
|
||||
description: '금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '여포NPC (아국 · 업)',
|
||||
crew: 1500,
|
||||
troopId: 2,
|
||||
description: '금 3,000 · 쌀 500 · 병력 1,500 · 탑승 부대 청룡대',
|
||||
},
|
||||
],
|
||||
che_부대탈퇴지시: [
|
||||
{
|
||||
value: 3,
|
||||
label: '여포NPC (아국 · 업)',
|
||||
availableNow: true,
|
||||
crew: 1500,
|
||||
troopId: 2,
|
||||
description: '현재 탈퇴 지시 가능 · 병력 1,500 · 탑승 부대 청룡대',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: '장수 (아국 · 업)',
|
||||
availableNow: false,
|
||||
crew: 500,
|
||||
description: '현재 탈퇴 지시 불가 · 탑승 부대 없음',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '관우 (아국 · 업)',
|
||||
availableNow: false,
|
||||
crew: 1200,
|
||||
troopId: 2,
|
||||
description: '현재 탈퇴 지시 불가 · 탑승 부대 청룡대 (부대장)',
|
||||
},
|
||||
],
|
||||
},
|
||||
crewTypes: [{ value: 1100, label: '보병' }],
|
||||
@@ -115,6 +278,11 @@ const inputOptions = {
|
||||
},
|
||||
],
|
||||
},
|
||||
amountPresets: {
|
||||
che_포상: { values: [100, 500, 1000, 5000, 10000], defaultValue: 1000, min: 100, max: 10000, step: 1 },
|
||||
che_몰수: { values: [100, 500, 1000, 5000, 10000], defaultValue: 1000, min: 100, max: 10000, step: 1 },
|
||||
che_물자원조: { values: [10000, 20000, 30000], defaultValue: 1000, min: 1000, max: 30000, step: 10 },
|
||||
},
|
||||
context: {
|
||||
actorGold: 1000,
|
||||
actorRice: 1000,
|
||||
@@ -124,6 +292,40 @@ const inputOptions = {
|
||||
nationLevel: 1,
|
||||
},
|
||||
};
|
||||
const buildCityCommand = (key: string, name: string) => ({
|
||||
key,
|
||||
name,
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
status: 'needsInput',
|
||||
inputFields: [{ key: 'destCityId', label: '대상 도시', kind: 'select', required: true, optionSource: 'cities' }],
|
||||
});
|
||||
const buildNationCommand = (key: string, name: string) => ({
|
||||
key,
|
||||
name,
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
status: 'needsInput',
|
||||
inputFields: [{ key: 'destNationId', label: '대상 국가', kind: 'select', required: true, optionSource: 'nations' }],
|
||||
});
|
||||
const buildGeneralCommand = (key: string, name: string) => ({
|
||||
key,
|
||||
name,
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
status: 'needsInput',
|
||||
inputFields: [
|
||||
{ key: 'destGeneralId', label: '대상 장수', kind: 'select', required: true, optionSource: 'generals' },
|
||||
],
|
||||
});
|
||||
const buildSimpleCommand = (key: string, name: string) => ({
|
||||
key,
|
||||
name,
|
||||
reqArg: false,
|
||||
possible: true,
|
||||
status: 'available',
|
||||
inputFields: [],
|
||||
});
|
||||
const commandTable = {
|
||||
general: [
|
||||
{
|
||||
@@ -221,6 +423,13 @@ const commandTable = {
|
||||
{
|
||||
category: '인사',
|
||||
values: [
|
||||
{
|
||||
...buildGeneralCommand('che_발령', '발령'),
|
||||
inputFields: [
|
||||
...buildGeneralCommand('che_발령', '발령').inputFields,
|
||||
...buildCityCommand('che_발령', '발령').inputFields,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'che_포상',
|
||||
name: '포상',
|
||||
@@ -239,27 +448,64 @@ const commandTable = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'che_몰수',
|
||||
name: '몰수',
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
status: 'needsInput',
|
||||
inputFields: [
|
||||
{ key: 'isGold', label: '물자', kind: 'boolean', required: true },
|
||||
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
|
||||
...buildGeneralCommand('che_몰수', '몰수').inputFields,
|
||||
],
|
||||
},
|
||||
buildGeneralCommand('che_부대탈퇴지시', '부대 탈퇴 지시'),
|
||||
],
|
||||
},
|
||||
{
|
||||
category: '외교',
|
||||
values: [
|
||||
{
|
||||
key: 'che_선전포고',
|
||||
name: '선전포고',
|
||||
key: 'che_물자원조',
|
||||
name: '원조',
|
||||
reqArg: true,
|
||||
possible: true,
|
||||
status: 'needsInput',
|
||||
inputFields: [
|
||||
...buildNationCommand('che_물자원조', '원조').inputFields,
|
||||
{
|
||||
key: 'destNationId',
|
||||
label: '대상 국가',
|
||||
kind: 'select',
|
||||
key: 'amountList',
|
||||
label: '지원 물자',
|
||||
kind: 'numberTuple',
|
||||
required: true,
|
||||
optionSource: 'nations',
|
||||
min: 0,
|
||||
step: 1,
|
||||
tupleLabels: ['금', '쌀'],
|
||||
},
|
||||
],
|
||||
},
|
||||
buildNationCommand('che_불가침제의', '불가침 제의'),
|
||||
buildNationCommand('che_선전포고', '선전포고'),
|
||||
buildNationCommand('che_종전제의', '종전 제의'),
|
||||
buildNationCommand('che_불가침파기제의', '불가침 파기 제의'),
|
||||
],
|
||||
},
|
||||
{
|
||||
category: '특수',
|
||||
values: [
|
||||
buildCityCommand('che_초토화', '초토화'),
|
||||
buildCityCommand('che_천도', '천도'),
|
||||
buildSimpleCommand('che_증축', '증축'),
|
||||
buildSimpleCommand('che_감축', '감축'),
|
||||
],
|
||||
},
|
||||
{
|
||||
category: '전략',
|
||||
values: [
|
||||
buildCityCommand('che_백성동원', '백성동원'),
|
||||
buildCityCommand('che_수몰', '수몰'),
|
||||
buildCityCommand('che_허보', '허보'),
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -330,14 +576,6 @@ const fourArmRecruitmentCommandTable = {
|
||||
},
|
||||
},
|
||||
};
|
||||
const buildSimpleCommand = (key: string, name: string) => ({
|
||||
key,
|
||||
name,
|
||||
reqArg: false,
|
||||
possible: true,
|
||||
status: 'available',
|
||||
inputFields: [],
|
||||
});
|
||||
const refChiefCommandTable = {
|
||||
general: [],
|
||||
nation: [
|
||||
@@ -1063,13 +1301,13 @@ test('enters general and nation command arguments and sends exact values', async
|
||||
await chiefPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||
await chiefPicker.getByRole('button', { name: /포상/ }).click();
|
||||
const chiefForm = chiefPicker.getByTestId('command-argument-form');
|
||||
await chiefForm.getByRole('button', { name: '쌀' }).click();
|
||||
await chiefForm.getByRole('button', { name: '쌀', exact: true }).click();
|
||||
await chiefForm.locator('input[type=number]').fill('300');
|
||||
const chiefTarget = chiefForm.locator('select');
|
||||
const chiefTarget = chiefForm.locator('#command-arg-destGeneralId');
|
||||
await expect(chiefTarget.locator('option')).toHaveText([
|
||||
'장수 (아국 · 업)',
|
||||
'관우 (아국 · 업)',
|
||||
'여포NPC (아국 · 업)',
|
||||
'관우 (아국 · 업)',
|
||||
]);
|
||||
await chiefTarget.selectOption('3');
|
||||
const geometry = await chiefForm.evaluate((element) => {
|
||||
@@ -1332,9 +1570,9 @@ test('keeps arbitrary direct recruitment and mercenary amounts for all four arms
|
||||
await row.getByRole('button', { name: entry.command, exact: true }).click();
|
||||
|
||||
await expect(picker).toHaveCount(0);
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText(
|
||||
`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)
|
||||
).toHaveText(`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`);
|
||||
}
|
||||
|
||||
const refreshResponse = page.waitForResponse((apiResponse) =>
|
||||
@@ -1344,9 +1582,9 @@ test('keeps arbitrary direct recruitment and mercenary amounts for all four arms
|
||||
await refreshResponse;
|
||||
|
||||
for (const entry of entries) {
|
||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)).toHaveText(
|
||||
`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-command-scope="general"] .action-column > div').nth(entry.turn - 1)
|
||||
).toHaveText(`【${entry.name}】 ${entry.savedAmount}명 ${entry.command}`);
|
||||
}
|
||||
await page
|
||||
.locator('[data-command-scope="general"]')
|
||||
@@ -1379,6 +1617,214 @@ test('uses the map to choose a nation target in the chief command window', async
|
||||
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('shows city or capital maps for every requested chief command', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
const cases = [
|
||||
{ category: '인사', action: '발령', mode: 'city' },
|
||||
{ category: '특수', action: '초토화', mode: 'city' },
|
||||
{ category: '특수', action: '천도', mode: 'city' },
|
||||
{ category: '특수', action: '증축', mode: 'capital' },
|
||||
{ category: '특수', action: '감축', mode: 'capital' },
|
||||
{ category: '전략', action: '수몰', mode: 'city' },
|
||||
{ category: '전략', action: '허보', mode: 'city' },
|
||||
{ category: '전략', action: '백성동원', mode: 'city' },
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: new RegExp(`^(?:국가:)?${entry.category}$`) }).click();
|
||||
await picker.getByRole('button', { name: new RegExp(entry.action) }).click();
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
const map = form.getByTestId('command-argument-map');
|
||||
await expect(map, `${entry.action} 지도`).toBeVisible();
|
||||
await expect(form.getByTestId('command-argument-guidance')).toBeVisible();
|
||||
|
||||
if (entry.mode === 'capital') {
|
||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 수도업');
|
||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 수도');
|
||||
await expect(map.locator('.city-base').first()).toHaveJSProperty('tagName', 'DIV');
|
||||
expect(
|
||||
await map
|
||||
.locator('.city-base')
|
||||
.first()
|
||||
.evaluate((node) => getComputedStyle(node).cursor)
|
||||
).toBe('default');
|
||||
} else {
|
||||
await map.locator('.city-base').nth(1).click();
|
||||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
||||
}
|
||||
}
|
||||
|
||||
await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('prioritizes current nation targets while preserving every choice', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
const cases = [
|
||||
{ action: '원조', first: '적국' },
|
||||
{ action: '불가침 제의', first: '적국' },
|
||||
{ action: '선전포고', first: '적국' },
|
||||
{ action: '종전 제의', first: '적국' },
|
||||
{ action: '불가침 파기 제의', first: '불가침국' },
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: new RegExp(entry.action) }).click();
|
||||
const form = picker.getByTestId('command-argument-form');
|
||||
const targetList = form.getByTestId('nation-target-list');
|
||||
const targets = targetList.locator('.target-option');
|
||||
await expect(targets).toHaveCount(3);
|
||||
await expect(targets.first().locator('strong')).toHaveText(entry.first);
|
||||
await expect(targets.first().locator('.target-state')).toHaveText('우선 대상');
|
||||
await expect(targets.last().locator('.target-state')).toHaveText('현재 불가');
|
||||
await expect(form.locator('#command-arg-destNationId')).toHaveValue(entry.first === '불가침국' ? '3' : '2');
|
||||
await expect(form.getByTestId('command-argument-map')).toBeVisible();
|
||||
}
|
||||
|
||||
const form = page.getByTestId('command-picker').getByTestId('command-argument-form');
|
||||
const targets = form.getByTestId('nation-target-list').locator('.target-option');
|
||||
await targets.filter({ hasText: '적국' }).click();
|
||||
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||||
await expect(targets.filter({ hasText: '적국' })).toHaveClass(/selected/);
|
||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창');
|
||||
await page.screenshot({ path: test.info().outputPath('chief-nation-target-priority.png'), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
const mobilePicker = page.getByTestId('command-picker');
|
||||
await mobilePicker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||||
await mobilePicker.getByRole('button', { name: /불가침 파기 제의/ }).click();
|
||||
const mobileTargets = mobilePicker.getByTestId('nation-target-list').locator('.target-option');
|
||||
await mobileTargets.nth(1).hover();
|
||||
expect(await mobileTargets.nth(1).evaluate((element) => getComputedStyle(element).cursor)).toBe('pointer');
|
||||
await mobileTargets.nth(1).focus();
|
||||
await expect(mobileTargets.nth(1)).toBeFocused();
|
||||
const mobileGeometry = await mobilePicker.evaluate((element) => {
|
||||
const list = element.querySelector<HTMLElement>('[data-testid="nation-target-list"]')!;
|
||||
const cards = Array.from(list.querySelectorAll<HTMLElement>('.target-option'));
|
||||
const listRect = list.getBoundingClientRect();
|
||||
return {
|
||||
pickerWidth: element.getBoundingClientRect().width,
|
||||
pickerOverflow: element.scrollWidth - element.clientWidth,
|
||||
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
cardsInsideList: cards.every((card) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
return rect.left >= listRect.left && rect.right <= listRect.right;
|
||||
}),
|
||||
};
|
||||
});
|
||||
expect(mobileGeometry).toEqual({
|
||||
pickerWidth: 500,
|
||||
pickerOverflow: 0,
|
||||
documentOverflow: 0,
|
||||
cardsInsideList: true,
|
||||
});
|
||||
await page.screenshot({ path: test.info().outputPath('chief-nation-target-priority-mobile.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('offers Ref amount presets and rich, command-specific general lists', async ({ page }) => {
|
||||
const requests = await install(page);
|
||||
await page.setViewportSize({ width: 1200, height: 900 });
|
||||
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||
let picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /포상/ }).click();
|
||||
let form = picker.getByTestId('command-argument-form');
|
||||
const amount = form.locator('#command-arg-amount');
|
||||
const amountPreset = form.getByRole('combobox', { name: '금액 프리셋' });
|
||||
await expect(amount).toHaveValue('1000');
|
||||
await expect(amountPreset.locator('option')).toHaveText(['프리셋', '100', '500', '1,000', '5,000', '10,000']);
|
||||
await amountPreset.selectOption('5000');
|
||||
await expect(amount).toHaveValue('5000');
|
||||
await amount.fill('1375');
|
||||
await expect(amount).toHaveValue('1375');
|
||||
|
||||
let generalList = form.getByTestId('general-target-list');
|
||||
await expect(generalList.locator('.target-option strong')).toHaveText([
|
||||
'관우 (아국 · 업)',
|
||||
'여포NPC (아국 · 업)',
|
||||
'장수 (아국 · 업)',
|
||||
]);
|
||||
await expect(generalList).toContainText('금 100 · 쌀 4,000 · 병력 1,200 · 탑승 부대 청룡대 (부대장)');
|
||||
await form.getByRole('button', { name: '쌀', exact: true }).click();
|
||||
await expect(generalList.locator('.target-option strong')).toHaveText([
|
||||
'장수 (아국 · 업)',
|
||||
'여포NPC (아국 · 업)',
|
||||
'관우 (아국 · 업)',
|
||||
]);
|
||||
await generalList.locator('.target-option').filter({ hasText: '여포NPC' }).click();
|
||||
const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await awardResponse;
|
||||
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click();
|
||||
picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /몰수/ }).click();
|
||||
form = picker.getByTestId('command-argument-form');
|
||||
generalList = form.getByTestId('general-target-list');
|
||||
await expect(generalList.locator('.target-option strong')).toHaveText([
|
||||
'장수 (아국 · 업)',
|
||||
'여포NPC (아국 · 업)',
|
||||
'관우 (아국 · 업)',
|
||||
]);
|
||||
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click();
|
||||
picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /발령/ }).click();
|
||||
form = picker.getByTestId('command-argument-form');
|
||||
await expect(form.getByTestId('general-target-list')).toContainText('병력 1,200 · 탑승 부대 청룡대 (부대장)');
|
||||
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '4턴 명령 입력', exact: true }).click();
|
||||
picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /부대 탈퇴 지시/ }).click();
|
||||
form = picker.getByTestId('command-argument-form');
|
||||
generalList = form.getByTestId('general-target-list');
|
||||
await expect(generalList.locator('.target-option strong').first()).toHaveText('여포NPC (아국 · 업)');
|
||||
await expect(generalList.locator('.target-option').first().locator('.target-state')).toHaveText('우선 대상');
|
||||
await expect(generalList.locator('.target-option').nth(1).locator('.target-state')).toHaveText('현재 불가');
|
||||
|
||||
await page.goto('/che/chief-center');
|
||||
await page.getByRole('button', { name: '5턴 명령 입력', exact: true }).click();
|
||||
picker = page.getByTestId('command-picker');
|
||||
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||||
await picker.getByRole('button', { name: /원조/ }).click();
|
||||
form = picker.getByTestId('command-argument-form');
|
||||
const tupleInputs = form.locator('.tuple-options input[type=number]');
|
||||
await expect(tupleInputs.nth(0)).toHaveValue('1000');
|
||||
await expect(tupleInputs.nth(1)).toHaveValue('1000');
|
||||
await form.getByRole('combobox', { name: '금 금액 프리셋' }).selectOption('20000');
|
||||
await tupleInputs.nth(1).fill('1370');
|
||||
await expect(tupleInputs.nth(0)).toHaveValue('20000');
|
||||
await expect(tupleInputs.nth(1)).toHaveValue('1370');
|
||||
const aidResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk'));
|
||||
await picker.getByRole('button', { name: '입력', exact: true }).click();
|
||||
await aidResponse;
|
||||
|
||||
const serialized = JSON.stringify(requests);
|
||||
expect(serialized).toContain('"amount":1375');
|
||||
expect(serialized).toContain('"destGeneralId":3');
|
||||
expect(serialized).toContain('"amountList":[20000,1370]');
|
||||
await page.screenshot({ path: test.info().outputPath('chief-ref-guidance-controls.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => {
|
||||
await install(page);
|
||||
await page.setViewportSize({ width: 500, height: 900 });
|
||||
@@ -1475,7 +1921,10 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation
|
||||
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('visible');
|
||||
expect((await tooltipStyle()).content).toContain('자율 행동: 200年 3月');
|
||||
expect(await editor.evaluate((element) => element.getBoundingClientRect().height)).toBe(heightBeforeHover);
|
||||
await page.screenshot({ path: test.info().outputPath('command-brief-autorun-hover-desktop-1200.png'), fullPage: true });
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath('command-brief-autorun-hover-desktop-1200.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
await page.mouse.move(0, 0);
|
||||
await expect.poll(async () => (await tooltipStyle()).visibility).toBe('hidden');
|
||||
|
||||
@@ -1507,7 +1956,10 @@ test('keeps Ref command briefs and autonomous-action state after a turn mutation
|
||||
expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0);
|
||||
expect(desktopGeometry.controlPadOffset).toBe(0);
|
||||
expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(20);
|
||||
await page.screenshot({ path: test.info().outputPath('command-brief-autorun-focus-desktop-1200.png'), fullPage: true });
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath('command-brief-autorun-focus-desktop-1200.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
const mobilePage = await context.newPage();
|
||||
await install(mobilePage);
|
||||
|
||||
@@ -251,7 +251,8 @@ const selectCommand = (commandKey: string) => {
|
||||
selectedCommand.value = command;
|
||||
commandArgs.value = {};
|
||||
commandArgsValid.value = !command.reqArg;
|
||||
if (!command.reqArg) submitCommand();
|
||||
const needsInformationalConfirmation = commandArgumentPresentation(command.key).mapTarget === 'capital';
|
||||
if (!command.reqArg && !needsInformationalConfirmation) submitCommand();
|
||||
};
|
||||
const submitCommand = () => {
|
||||
const command = selectedCommand.value;
|
||||
@@ -737,7 +738,11 @@ const clickOutsideMenu = (event: Event) => {
|
||||
@submit="submitCommand"
|
||||
/>
|
||||
<CommandArgumentForm
|
||||
v-else-if="selectedCommand.reqArg && props.commandTable"
|
||||
v-else-if="
|
||||
props.commandTable &&
|
||||
(selectedCommand.reqArg ||
|
||||
commandArgumentPresentation(selectedCommand.key).mapTarget === 'capital')
|
||||
"
|
||||
:command-key="selectedCommand.key"
|
||||
:fields="selectedCommand.inputFields"
|
||||
:options="props.commandTable.inputOptions"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export type CommandArgumentPresentation = {
|
||||
lines: string[];
|
||||
mapTarget?: 'city' | 'nation';
|
||||
mapTarget?: 'city' | 'nation' | 'capital';
|
||||
};
|
||||
|
||||
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
|
||||
const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' });
|
||||
const capitalTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'capital' });
|
||||
|
||||
// Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다.
|
||||
// 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다.
|
||||
@@ -33,6 +34,14 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
|
||||
]),
|
||||
cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']),
|
||||
che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']),
|
||||
che_증축: capitalTarget([
|
||||
'현재 수도를 증축해 인구·내정·성벽의 최대치를 높입니다.',
|
||||
'지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.',
|
||||
]),
|
||||
che_감축: capitalTarget([
|
||||
'현재 수도를 감축해 인구·내정·성벽의 최대치를 낮추고 국고를 회수합니다.',
|
||||
'지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.',
|
||||
]),
|
||||
|
||||
che_선전포고: nationTarget([
|
||||
'선택한 국가에 선전포고합니다.',
|
||||
|
||||
@@ -3,6 +3,19 @@ export type CommandOption = {
|
||||
label: string;
|
||||
color?: string;
|
||||
description?: string;
|
||||
availableNow?: boolean;
|
||||
gold?: number;
|
||||
rice?: number;
|
||||
crew?: number;
|
||||
troopId?: number;
|
||||
};
|
||||
|
||||
export type CommandAmountPreset = {
|
||||
values: number[];
|
||||
defaultValue: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
};
|
||||
|
||||
export type CommandMapData = {
|
||||
@@ -94,6 +107,7 @@ export type CommandTable = {
|
||||
inputOptions: {
|
||||
cities: CommandOption[];
|
||||
nations: CommandOption[];
|
||||
nationTargets?: Record<string, CommandOption[]>;
|
||||
generals: CommandOption[];
|
||||
generalTargets?: Record<string, CommandOption[]>;
|
||||
crewTypes: CommandOption[];
|
||||
@@ -102,6 +116,7 @@ export type CommandTable = {
|
||||
colors: CommandOption[];
|
||||
items: Record<string, CommandOption[]>;
|
||||
recruitment: RecruitmentInfo | null;
|
||||
amountPresets?: Record<string, CommandAmountPreset>;
|
||||
context?: CommandInputContext;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,11 +30,31 @@ const values = reactive<Record<string, unknown>>({});
|
||||
const presentation = computed(() => commandArgumentPresentation(props.commandKey));
|
||||
const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden'));
|
||||
|
||||
const amountPreset = computed(() => props.options.amountPresets?.[props.commandKey]);
|
||||
|
||||
const sortGeneralOptions = (options: CommandOption[]): CommandOption[] => {
|
||||
const result = [...options];
|
||||
const resourceKey = values.isGold === false ? 'rice' : 'gold';
|
||||
if (props.commandKey === 'che_포상') {
|
||||
return result.sort((left, right) => (left[resourceKey] ?? 0) - (right[resourceKey] ?? 0));
|
||||
}
|
||||
if (props.commandKey === 'che_몰수') {
|
||||
return result.sort((left, right) => (right[resourceKey] ?? 0) - (left[resourceKey] ?? 0));
|
||||
}
|
||||
if (props.commandKey === 'che_부대탈퇴지시') {
|
||||
return result.sort((left, right) => Number(right.availableNow) - Number(left.availableNow));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const optionsFor = (field: CommandInputField): CommandOption[] => {
|
||||
if (field.options) return field.options;
|
||||
if (!field.optionSource) return [];
|
||||
if (field.optionSource === 'generals') {
|
||||
return props.options.generalTargets?.[props.commandKey] ?? props.options.generals;
|
||||
return sortGeneralOptions(props.options.generalTargets?.[props.commandKey] ?? props.options.generals);
|
||||
}
|
||||
if (field.optionSource === 'nations') {
|
||||
return props.options.nationTargets?.[props.commandKey] ?? props.options.nations;
|
||||
}
|
||||
if (field.optionSource === 'items') {
|
||||
return props.options.items[String(values.itemType ?? '')] ?? [];
|
||||
@@ -45,10 +65,16 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
|
||||
const defaultValue = (field: CommandInputField): unknown => {
|
||||
if (field.kind === 'hidden') return field.constValue;
|
||||
if (field.kind === 'boolean') return true;
|
||||
if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0];
|
||||
if (field.kind === 'number') return field.min ?? 0;
|
||||
if (field.kind === 'numberTuple') {
|
||||
const value = amountPreset.value?.defaultValue ?? field.min ?? 0;
|
||||
return [value, value];
|
||||
}
|
||||
if (field.kind === 'number') return amountPreset.value?.defaultValue ?? field.min ?? 0;
|
||||
if (field.kind === 'select') {
|
||||
const options = optionsFor(field);
|
||||
const commandSpecificNationTargets =
|
||||
field.optionSource === 'nations' ? props.options.nationTargets?.[props.commandKey] : undefined;
|
||||
if (commandSpecificNationTargets?.length) return commandSpecificNationTargets[0]?.value ?? '';
|
||||
const mapDefault =
|
||||
field.optionSource === 'cities' && (field.key === 'destCityId' || field.key === 'destCityID')
|
||||
? props.mapData?.myCity
|
||||
@@ -96,7 +122,8 @@ const showMap = computed(
|
||||
() =>
|
||||
Boolean(props.mapData && props.mapLayout) &&
|
||||
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
|
||||
(presentation.value.mapTarget === 'nation' && nationTargetField.value))
|
||||
(presentation.value.mapTarget === 'nation' && nationTargetField.value) ||
|
||||
presentation.value.mapTarget === 'capital')
|
||||
);
|
||||
const mapSelectedCityId = computed<number | null>(() => {
|
||||
if (!props.mapData) return null;
|
||||
@@ -107,7 +134,15 @@ const mapSelectedCityId = computed<number | null>(() => {
|
||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||
const value = values[nationTargetField.value.key];
|
||||
if (typeof value !== 'number') return null;
|
||||
return props.mapData.cityList.find((entry) => entry[3] === value)?.[0] ?? null;
|
||||
return (
|
||||
props.mapData.nationList.find((entry) => entry[0] === value)?.[3] ??
|
||||
props.mapData.cityList.find((entry) => entry[3] === value)?.[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
if (presentation.value.mapTarget === 'capital') {
|
||||
const myNation = props.mapData.myNation;
|
||||
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -129,6 +164,11 @@ const selectedMapTargetName = computed(() => {
|
||||
if (typeof nationId !== 'number') return '-';
|
||||
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
|
||||
}
|
||||
if (presentation.value.mapTarget === 'capital') {
|
||||
const cityId = mapSelectedCityId.value;
|
||||
if (!cityId) return '-';
|
||||
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
|
||||
}
|
||||
return '-';
|
||||
});
|
||||
|
||||
@@ -181,6 +221,14 @@ const mapTargetSummary = computed(() => {
|
||||
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
|
||||
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}개`;
|
||||
}
|
||||
if (presentation.value.mapTarget === 'capital' && mapSelectedCityId.value) {
|
||||
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
||||
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
||||
if (!city) return '';
|
||||
return `${city.name} · ${props.mapLayout.regionMap[dynamic?.[4] ?? city.region]} · ${
|
||||
props.mapLayout.levelMap[dynamic?.[1] ?? city.level]
|
||||
} · 현재 수도`;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
@@ -225,6 +273,35 @@ const setTupleValue = (field: CommandInputField, index: number, rawValue: string
|
||||
values[field.key] = tuple;
|
||||
};
|
||||
|
||||
const setNumberPreset = (field: CommandInputField, rawValue: string, tupleIndex?: number) => {
|
||||
if (!rawValue) return;
|
||||
if (tupleIndex === undefined) {
|
||||
values[field.key] = Number(rawValue);
|
||||
return;
|
||||
}
|
||||
setTupleValue(field, tupleIndex, rawValue);
|
||||
};
|
||||
|
||||
const effectiveMin = (field: CommandInputField): number | undefined => amountPreset.value?.min ?? field.min;
|
||||
const effectiveMax = (field: CommandInputField): number | undefined => amountPreset.value?.max ?? field.max;
|
||||
const effectiveStep = (field: CommandInputField): number | undefined => amountPreset.value?.step ?? field.step;
|
||||
|
||||
const OPTION_CARD_COMMANDS = new Set([
|
||||
'che_물자원조',
|
||||
'che_불가침제의',
|
||||
'che_선전포고',
|
||||
'che_종전제의',
|
||||
'che_불가침파기제의',
|
||||
'che_포상',
|
||||
'che_발령',
|
||||
'che_몰수',
|
||||
'che_부대탈퇴지시',
|
||||
]);
|
||||
const showOptionCards = (field: CommandInputField): boolean =>
|
||||
field.kind === 'select' &&
|
||||
Boolean(field.optionSource && ['nations', 'generals'].includes(field.optionSource)) &&
|
||||
OPTION_CARD_COMMANDS.has(props.commandKey);
|
||||
|
||||
const isValid = computed(() =>
|
||||
props.fields.every((field) => {
|
||||
const value = values[field.key];
|
||||
@@ -237,14 +314,18 @@ const isValid = computed(() =>
|
||||
);
|
||||
}
|
||||
if (field.kind === 'number') {
|
||||
const min = effectiveMin(field);
|
||||
const max = effectiveMax(field);
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
(field.min === undefined || value >= field.min) &&
|
||||
(field.max === undefined || value <= field.max)
|
||||
(min === undefined || value >= min) &&
|
||||
(max === undefined || value <= max)
|
||||
);
|
||||
}
|
||||
if (field.kind === 'numberTuple') {
|
||||
const min = effectiveMin(field);
|
||||
const max = effectiveMax(field);
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length === 2 &&
|
||||
@@ -252,8 +333,8 @@ const isValid = computed(() =>
|
||||
(entry) =>
|
||||
typeof entry === 'number' &&
|
||||
Number.isFinite(entry) &&
|
||||
(field.min === undefined || entry >= field.min) &&
|
||||
(field.max === undefined || entry <= field.max)
|
||||
(min === undefined || entry >= min) &&
|
||||
(max === undefined || entry <= max)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -274,7 +355,11 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form">
|
||||
<div
|
||||
v-if="props.fields.length || showMap || presentation.lines.length"
|
||||
class="command-argument-form"
|
||||
data-testid="command-argument-form"
|
||||
>
|
||||
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
|
||||
<MapViewer
|
||||
:map-data="props.mapData ?? null"
|
||||
@@ -284,18 +369,24 @@ watch(
|
||||
:detail-mode="true"
|
||||
:fit-container="true"
|
||||
:show-current-city-marker="true"
|
||||
:readonly="presentation.mapTarget === 'capital'"
|
||||
@select-city="selectMapCity"
|
||||
/>
|
||||
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
||||
<small v-if="presentation.mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
|
||||
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
||||
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
|
||||
<span class="current-city-status">
|
||||
<span v-if="presentation.mapTarget !== 'capital'" class="current-city-status">
|
||||
<span class="status-key">현재 도시</span>
|
||||
<strong>{{ currentCityName }}</strong>
|
||||
</span>
|
||||
<span aria-hidden="true">→</span>
|
||||
<span v-if="presentation.mapTarget !== 'capital'" aria-hidden="true">→</span>
|
||||
<span class="selected-target-status">
|
||||
<span class="status-key">{{
|
||||
presentation.mapTarget === 'nation' ? '선택 국가' : '선택 도시'
|
||||
presentation.mapTarget === 'nation'
|
||||
? '선택 국가'
|
||||
: presentation.mapTarget === 'capital'
|
||||
? '현재 수도'
|
||||
: '선택 도시'
|
||||
}}</span>
|
||||
<strong>{{ selectedMapTargetName }}</strong>
|
||||
</span>
|
||||
@@ -320,16 +411,29 @@ watch(
|
||||
:maxlength="field.max"
|
||||
@input="values[field.key] = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<input
|
||||
v-else-if="field.kind === 'number'"
|
||||
:id="`command-arg-${field.key}`"
|
||||
type="number"
|
||||
:value="Number(values[field.key] ?? 0)"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step"
|
||||
@input="values[field.key] = Number(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div v-else-if="field.kind === 'number'" class="number-options">
|
||||
<input
|
||||
:id="`command-arg-${field.key}`"
|
||||
type="number"
|
||||
:value="Number(values[field.key] ?? 0)"
|
||||
:min="effectiveMin(field)"
|
||||
:max="effectiveMax(field)"
|
||||
:step="effectiveStep(field)"
|
||||
@input="values[field.key] = Number(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
v-if="amountPreset"
|
||||
aria-label="금액 프리셋"
|
||||
class="amount-preset"
|
||||
value=""
|
||||
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="" disabled>프리셋</option>
|
||||
<option v-for="preset in amountPreset.values" :key="preset" :value="preset">
|
||||
{{ preset.toLocaleString() }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<select
|
||||
v-else-if="field.kind === 'select'"
|
||||
:id="`command-arg-${field.key}`"
|
||||
@@ -362,11 +466,23 @@ watch(
|
||||
<input
|
||||
type="number"
|
||||
:value="(values[field.key] as number[] | undefined)?.[index] ?? 0"
|
||||
:min="field.min"
|
||||
:max="field.max"
|
||||
:step="field.step"
|
||||
:min="effectiveMin(field)"
|
||||
:max="effectiveMax(field)"
|
||||
:step="effectiveStep(field)"
|
||||
@input="setTupleValue(field, index, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
v-if="amountPreset"
|
||||
:aria-label="`${tupleLabel} 금액 프리셋`"
|
||||
class="amount-preset"
|
||||
value=""
|
||||
@change="setNumberPreset(field, ($event.target as HTMLSelectElement).value, index)"
|
||||
>
|
||||
<option value="" disabled>프리셋</option>
|
||||
<option v-for="preset in amountPreset.values" :key="preset" :value="preset">
|
||||
{{ preset.toLocaleString() }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
@@ -384,6 +500,30 @@ watch(
|
||||
/>
|
||||
<span>{{ selectedOptionFor(field)?.description }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="showOptionCards(field)"
|
||||
class="target-option-list"
|
||||
:data-testid="field.optionSource === 'nations' ? 'nation-target-list' : 'general-target-list'"
|
||||
>
|
||||
<button
|
||||
v-for="option in optionsFor(field)"
|
||||
:key="String(option.value)"
|
||||
type="button"
|
||||
class="target-option"
|
||||
:class="{
|
||||
selected: option.value === values[field.key],
|
||||
unavailable: option.availableNow === false,
|
||||
}"
|
||||
@click="setSelectValue(field, String(option.value))"
|
||||
>
|
||||
<span v-if="option.color" class="option-color" :style="{ backgroundColor: option.color }" />
|
||||
<strong>{{ option.label }}</strong>
|
||||
<span class="target-state">{{
|
||||
option.availableNow === false ? '현재 불가' : option.availableNow ? '우선 대상' : '대상'
|
||||
}}</span>
|
||||
<small>{{ option.description }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
||||
</div>
|
||||
@@ -484,6 +624,74 @@ watch(
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.target-option-list {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
max-height: 230px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid rgba(201, 164, 90, 0.2);
|
||||
}
|
||||
|
||||
.target-option {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, auto) max-content;
|
||||
align-items: center;
|
||||
gap: 3px 7px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(201, 164, 90, 0.16);
|
||||
padding: 6px 8px;
|
||||
background: rgba(7, 9, 12, 0.82);
|
||||
color: #e8ddc4;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.target-option:hover,
|
||||
.target-option:focus-visible {
|
||||
background: rgba(201, 164, 90, 0.13);
|
||||
outline: 1px solid rgba(201, 164, 90, 0.6);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.target-option.selected {
|
||||
background: rgba(201, 164, 90, 0.2);
|
||||
box-shadow: inset 3px 0 #e0bc6d;
|
||||
}
|
||||
|
||||
.target-option.unavailable {
|
||||
color: rgba(232, 221, 196, 0.58);
|
||||
}
|
||||
|
||||
.target-option > strong {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.target-option > .option-color + strong {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.target-option > strong:first-child {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
.target-state {
|
||||
grid-column: 3;
|
||||
color: #aee6a7;
|
||||
font-size: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.target-option.unavailable .target-state {
|
||||
color: #e9a29a;
|
||||
}
|
||||
|
||||
.target-option small {
|
||||
grid-column: 1 / -1;
|
||||
color: inherit;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.option-color {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -511,6 +719,19 @@ select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.number-options {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(90px, 0.45fr);
|
||||
gap: 5px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.number-options input,
|
||||
.number-options select {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.boolean-options,
|
||||
.tuple-options {
|
||||
display: flex;
|
||||
@@ -531,17 +752,34 @@ select {
|
||||
}
|
||||
|
||||
.tuple-options label {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(68px, 1fr) minmax(82px, 0.7fr);
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tuple-options input {
|
||||
width: 80px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tuple-options .amount-preset {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.tuple-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tuple-options label {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.argument-error {
|
||||
padding: 5px 8px;
|
||||
color: #ff9a8f;
|
||||
|
||||
@@ -22,6 +22,7 @@ const props = defineProps<{
|
||||
showName: boolean;
|
||||
mapScale: number;
|
||||
selectOnly?: boolean;
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -33,18 +34,27 @@ const emit = defineEmits<{
|
||||
const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
|
||||
const stateSize = computed(() => 8 * props.mapScale);
|
||||
const stateOffset = computed(() => -6 * props.mapScale);
|
||||
const selectCity = () => emit('select', props.city.id);
|
||||
const selectCity = () => {
|
||||
if (!props.readonly) emit('select', props.city.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.selectOnly ? 'button' : RouterLink"
|
||||
:is="props.readonly ? 'div' : props.selectOnly ? 'button' : RouterLink"
|
||||
class="map-city"
|
||||
:type="props.selectOnly ? 'button' : undefined"
|
||||
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
|
||||
:type="!props.readonly && props.selectOnly ? 'button' : undefined"
|
||||
:to="
|
||||
props.selectOnly || props.readonly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }
|
||||
"
|
||||
:class="[
|
||||
`state-${props.city.stateClass}`,
|
||||
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
|
||||
{
|
||||
mine: props.city.isMyCity,
|
||||
selected: props.city.selected,
|
||||
'supply-off': !props.city.supply,
|
||||
readonly: props.readonly,
|
||||
},
|
||||
]"
|
||||
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
|
||||
@mouseenter="emit('hover', props.city.id)"
|
||||
@@ -86,6 +96,10 @@ const selectCity = () => emit('select', props.city.id);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.map-city.readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.city-dot {
|
||||
border: 1px solid rgba(232, 221, 196, 0.6);
|
||||
display: flex;
|
||||
|
||||
@@ -48,6 +48,7 @@ const props = defineProps<{
|
||||
themeName: string;
|
||||
mapScale: number;
|
||||
selectOnly?: boolean;
|
||||
readonly?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -143,7 +144,9 @@ const capitalIconStyle = computed(() => ({
|
||||
height: `${10 * props.mapScale}px`,
|
||||
}));
|
||||
|
||||
const selectCity = () => emit('select', props.city.id);
|
||||
const selectCity = () => {
|
||||
if (!props.readonly) emit('select', props.city.id);
|
||||
};
|
||||
|
||||
const cityStateStyle = computed(() => ({
|
||||
width: `${12 * props.mapScale}px`,
|
||||
@@ -154,11 +157,20 @@ const cityStateStyle = computed(() => ({
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.selectOnly ? 'button' : RouterLink"
|
||||
:is="props.readonly ? 'div' : props.selectOnly ? 'button' : RouterLink"
|
||||
class="city-base"
|
||||
:type="props.selectOnly ? 'button' : undefined"
|
||||
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
|
||||
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
|
||||
:type="!props.readonly && props.selectOnly ? 'button' : undefined"
|
||||
:to="
|
||||
props.selectOnly || props.readonly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }
|
||||
"
|
||||
:class="[
|
||||
{
|
||||
mine: props.city.isMyCity,
|
||||
selected: props.city.selected,
|
||||
'supply-off': !props.city.supply,
|
||||
readonly: props.readonly,
|
||||
},
|
||||
]"
|
||||
:style="cityBaseStyle"
|
||||
@mouseenter="emit('hover', props.city.id)"
|
||||
@mouseleave="emit('leave')"
|
||||
@@ -195,6 +207,10 @@ const cityStateStyle = computed(() => ({
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.city-base.readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.city-bg {
|
||||
position: absolute;
|
||||
background-position: center;
|
||||
|
||||
@@ -72,6 +72,7 @@ const props = withDefaults(
|
||||
detailMode?: boolean;
|
||||
fitContainer?: boolean;
|
||||
showCurrentCityMarker?: boolean;
|
||||
readonly?: boolean;
|
||||
}>(),
|
||||
{
|
||||
// Vue casts an absent Boolean prop to false unless undefined is an explicit default.
|
||||
@@ -404,6 +405,7 @@ const setHoveredCity = (cityId: number | null) => {
|
||||
};
|
||||
|
||||
const selectCity = (cityId: number) => {
|
||||
if (props.readonly) return;
|
||||
emit('select-city', cityId);
|
||||
if (props.selectedCityId === undefined) {
|
||||
mapStore.setSelectedCity(cityId);
|
||||
@@ -443,6 +445,7 @@ const selectCity = (cityId: number) => {
|
||||
:map-scale="mapScale"
|
||||
:show-name="showCityName"
|
||||
:select-only="props.selectedCityId !== undefined"
|
||||
:readonly="props.readonly"
|
||||
v-bind="detailProps"
|
||||
@hover="setHoveredCity"
|
||||
@leave="setHoveredCity(null)"
|
||||
|
||||
Reference in New Issue
Block a user