diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 9da9d0ab..10c6264c 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -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(); + for (const entry of generals) { + generalCountByNation.set(entry.nationId, (generalCountByNation.get(entry.nationId) ?? 0) + 1); + } + const cityCountByNation = new Map(); + 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(); + 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, diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 401c2852..70661544 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -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; generals: TurnCommandOption[]; generalTargets?: Record; crewTypes: TurnCommandOption[]; @@ -78,6 +92,7 @@ export interface TurnCommandInputOptions { colors: TurnCommandOption[]; items: Record; recruitment: TurnCommandRecruitmentInfo | null; + amountPresets?: Record; context?: { actorGold: number; actorRice: number; diff --git a/app/game-api/src/turns/commandTable.ts b/app/game-api/src/turns/commandTable.ts index 63fed4c8..ab20a5bc 100644 --- a/app/game-api/src/turns/commandTable.ts +++ b/app/game-api/src/turns/commandTable.ts @@ -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: {}, }, }; }; diff --git a/app/game-api/src/turns/commandTargets.ts b/app/game-api/src/turns/commandTargets.ts index 33c71434..44a3556f 100644 --- a/app/game-api/src/turns/commandTargets.ts +++ b/app/game-api/src/turns/commandTargets.ts @@ -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; } +export interface RefNationTargetOptions { + nations: TurnCommandOption[]; + nationTargets: Record; +} + 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; cityNames: ReadonlyMap; + troopNames?: ReadonlyMap; }): 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 = {}; - 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 = { + [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((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 = {}; + 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 => { + 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, + }; +}; diff --git a/app/game-api/test/commandTargets.test.ts b/app/game-api/test/commandTargets.test.ts index ed7104fb..a94fb519 100644 --- a/app/game-api/test/commandTargets.test.ts +++ b/app/game-api/test/commandTargets.test.ts @@ -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 => ({ 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, + }); + }); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 9f5037fd..1ae4f7a4 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -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('[data-testid="nation-target-list"]')!; + const cards = Array.from(list.querySelectorAll('.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); diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index e29f6183..10ac3c95 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -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" /> ({ 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 = { ]), cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']), che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']), + che_증축: capitalTarget([ + '현재 수도를 증축해 인구·내정·성벽의 최대치를 높입니다.', + '지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.', + ]), + che_감축: capitalTarget([ + '현재 수도를 감축해 인구·내정·성벽의 최대치를 낮추고 국고를 회수합니다.', + '지도에는 이번 명령의 대상인 현재 수도가 강조됩니다.', + ]), che_선전포고: nationTarget([ '선택한 국가에 선전포고합니다.', diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index 57521b84..bb881946 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -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; generals: CommandOption[]; generalTargets?: Record; crewTypes: CommandOption[]; @@ -102,6 +116,7 @@ export type CommandTable = { colors: CommandOption[]; items: Record; recruitment: RecruitmentInfo | null; + amountPresets?: Record; context?: CommandInputContext; }; }; diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index f2b0688f..ba7f4191 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -30,11 +30,31 @@ const values = reactive>({}); 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(() => { if (!props.mapData) return null; @@ -107,7 +134,15 @@ const mapSelectedCityId = computed(() => { 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(