feat: enrich command option dialogs with Ref context
This commit is contained in:
@@ -89,6 +89,24 @@ const getReservationWorldState = async (ctx: GameApiContext): Promise<WorldState
|
|||||||
return worldState;
|
return worldState;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const plainLegacyInfo = (value: string): string =>
|
||||||
|
value
|
||||||
|
.replace(/<br\s*\/?>/giu, ' · ')
|
||||||
|
.replace(/<[^>]+>/gu, '')
|
||||||
|
.replace(/\s+/gu, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const readGeneralMetaNumber = (meta: unknown, key: string): number | null => {
|
||||||
|
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null;
|
||||||
|
const value = (meta as Record<string, unknown>)[key];
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const assertReservedTurnPermission = async (
|
const assertReservedTurnPermission = async (
|
||||||
worldState: WorldStateRow,
|
worldState: WorldStateRow,
|
||||||
general: GeneralRow,
|
general: GeneralRow,
|
||||||
@@ -168,7 +186,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
};
|
};
|
||||||
for (const item of itemModules) {
|
for (const item of itemModules) {
|
||||||
if (item.buyable) {
|
if (item.buyable) {
|
||||||
items[item.slot].push({ value: item.key, label: item.name });
|
const cost = item.cost ?? 0;
|
||||||
|
const currentSecurity = city?.security ?? 0;
|
||||||
|
const availability =
|
||||||
|
currentSecurity < item.reqSecu
|
||||||
|
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
|
||||||
|
: general.gold < cost
|
||||||
|
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
|
||||||
|
: '현재 구입 가능';
|
||||||
|
items[item.slot].push({
|
||||||
|
value: item.key,
|
||||||
|
label: item.name,
|
||||||
|
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const inputOptions: TurnCommandInputOptions = {
|
const inputOptions: TurnCommandInputOptions = {
|
||||||
@@ -190,17 +220,31 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
crewTypes: (environment.unitSet.crewTypes ?? [])
|
crewTypes: (environment.unitSet.crewTypes ?? [])
|
||||||
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
.filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible'))
|
||||||
.map((entry) => ({ value: entry.id, label: entry.name })),
|
.map((entry) => ({ value: entry.id, label: entry.name })),
|
||||||
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({
|
armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => {
|
||||||
value: Number(value),
|
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
|
||||||
label,
|
return {
|
||||||
|
value: Number(value),
|
||||||
|
label,
|
||||||
|
...(dexterity === null ? {} : { description: `현재 숙련 ${dexterity.toLocaleString()}` }),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
nationTypes: traits.nationTypes.map((entry) => ({
|
||||||
|
value: entry.key,
|
||||||
|
label: entry.name,
|
||||||
|
description: plainLegacyInfo(entry.info),
|
||||||
})),
|
})),
|
||||||
nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })),
|
|
||||||
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({
|
||||||
value: index,
|
value: index,
|
||||||
label: `색상 ${index + 1}`,
|
label: `색상 ${index + 1}`,
|
||||||
color,
|
color,
|
||||||
})),
|
})),
|
||||||
items,
|
items,
|
||||||
|
context: {
|
||||||
|
actorGold: general.gold,
|
||||||
|
actorRice: general.rice,
|
||||||
|
...(city ? { citySecurity: city.security } : {}),
|
||||||
|
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return buildTurnCommandTable({
|
return buildTurnCommandTable({
|
||||||
|
|||||||
@@ -15,17 +15,11 @@ export interface TurnCommandOption {
|
|||||||
value: TurnCommandOptionValue;
|
value: TurnCommandOptionValue;
|
||||||
label: string;
|
label: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TurnCommandOptionSource =
|
export type TurnCommandOptionSource =
|
||||||
| 'cities'
|
'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
|
||||||
| 'nations'
|
|
||||||
| 'generals'
|
|
||||||
| 'crewTypes'
|
|
||||||
| 'armTypes'
|
|
||||||
| 'nationTypes'
|
|
||||||
| 'colors'
|
|
||||||
| 'items';
|
|
||||||
|
|
||||||
export interface TurnCommandInputField {
|
export interface TurnCommandInputField {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -50,14 +44,50 @@ export interface TurnCommandInputOptions {
|
|||||||
nationTypes: TurnCommandOption[];
|
nationTypes: TurnCommandOption[];
|
||||||
colors: TurnCommandOption[];
|
colors: TurnCommandOption[];
|
||||||
items: Record<string, TurnCommandOption[]>;
|
items: Record<string, TurnCommandOption[]>;
|
||||||
|
context?: {
|
||||||
|
actorGold: number;
|
||||||
|
actorRice: number;
|
||||||
|
citySecurity?: number;
|
||||||
|
nationGold?: number;
|
||||||
|
nationRice?: number;
|
||||||
|
nationLevel?: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
|
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
|
||||||
export const TURN_COMMAND_NATION_COLORS = [
|
export const TURN_COMMAND_NATION_COLORS = [
|
||||||
'#FF0000', '#800000', '#A0522D', '#FF6347', '#FFA500', '#FFDAB9', '#FFD700', '#FFFF00',
|
'#FF0000',
|
||||||
'#7CFC00', '#00FF00', '#808000', '#008000', '#2E8B57', '#008080', '#20B2AA', '#6495ED',
|
'#800000',
|
||||||
'#7FFFD4', '#AFEEEE', '#87CEEB', '#00FFFF', '#00BFFF', '#0000FF', '#000080', '#483D8B',
|
'#A0522D',
|
||||||
'#7B68EE', '#BA55D3', '#800080', '#FF00FF', '#FFC0CB', '#F5F5DC', '#E0FFFF', '#FFFFFF',
|
'#FF6347',
|
||||||
|
'#FFA500',
|
||||||
|
'#FFDAB9',
|
||||||
|
'#FFD700',
|
||||||
|
'#FFFF00',
|
||||||
|
'#7CFC00',
|
||||||
|
'#00FF00',
|
||||||
|
'#808000',
|
||||||
|
'#008000',
|
||||||
|
'#2E8B57',
|
||||||
|
'#008080',
|
||||||
|
'#20B2AA',
|
||||||
|
'#6495ED',
|
||||||
|
'#7FFFD4',
|
||||||
|
'#AFEEEE',
|
||||||
|
'#87CEEB',
|
||||||
|
'#00FFFF',
|
||||||
|
'#00BFFF',
|
||||||
|
'#0000FF',
|
||||||
|
'#000080',
|
||||||
|
'#483D8B',
|
||||||
|
'#7B68EE',
|
||||||
|
'#BA55D3',
|
||||||
|
'#800080',
|
||||||
|
'#FF00FF',
|
||||||
|
'#FFC0CB',
|
||||||
|
'#F5F5DC',
|
||||||
|
'#E0FFFF',
|
||||||
|
'#FFFFFF',
|
||||||
'#A9A9A9',
|
'#A9A9A9',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ const operations = (route: Route) =>
|
|||||||
const inputOptions = {
|
const inputOptions = {
|
||||||
cities: [
|
cities: [
|
||||||
{ value: 1, label: '업 (아국)' },
|
{ value: 1, label: '업 (아국)' },
|
||||||
{ value: 2, label: '허창 (적국)' },
|
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
|
||||||
],
|
],
|
||||||
nations: [
|
nations: [
|
||||||
{ value: 1, label: '아국', color: '#008000' },
|
{ value: 1, label: '아국', color: '#008000' },
|
||||||
{ value: 2, label: '적국', color: '#800000' },
|
{ value: 2, label: '적국', color: '#800000', description: '수도 허창' },
|
||||||
],
|
],
|
||||||
generals: [
|
generals: [
|
||||||
{ value: 1, label: '장수 (아국 · 업)' },
|
{ value: 1, label: '장수 (아국 · 업)' },
|
||||||
@@ -30,6 +30,14 @@ const inputOptions = {
|
|||||||
nationTypes: [{ value: 'che_중립', label: '중립' }],
|
nationTypes: [{ value: 'che_중립', label: '중립' }],
|
||||||
colors: [{ value: 0, label: '색상 1', color: '#ff0000' }],
|
colors: [{ value: 0, label: '색상 1', color: '#ff0000' }],
|
||||||
items: { horse: [{ value: 'None', label: '판매/해제' }] },
|
items: { horse: [{ value: 'None', label: '판매/해제' }] },
|
||||||
|
context: {
|
||||||
|
actorGold: 1000,
|
||||||
|
actorRice: 1000,
|
||||||
|
citySecurity: 500,
|
||||||
|
nationGold: 5000,
|
||||||
|
nationRice: 6000,
|
||||||
|
nationLevel: 1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const commandTable = {
|
const commandTable = {
|
||||||
general: [
|
general: [
|
||||||
@@ -53,6 +61,23 @@ const commandTable = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'che_징병',
|
||||||
|
name: '징병',
|
||||||
|
reqArg: true,
|
||||||
|
possible: true,
|
||||||
|
status: 'needsInput',
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'crewType',
|
||||||
|
label: '병종',
|
||||||
|
kind: 'select',
|
||||||
|
required: true,
|
||||||
|
optionSource: 'crewTypes',
|
||||||
|
},
|
||||||
|
{ key: 'amount', label: '수량', kind: 'number', required: true, min: 0, step: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -80,6 +105,27 @@ const commandTable = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
category: '외교',
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
key: 'che_선전포고',
|
||||||
|
name: '선전포고',
|
||||||
|
reqArg: true,
|
||||||
|
possible: true,
|
||||||
|
status: 'needsInput',
|
||||||
|
inputFields: [
|
||||||
|
{
|
||||||
|
key: 'destNationId',
|
||||||
|
label: '대상 국가',
|
||||||
|
kind: 'select',
|
||||||
|
required: true,
|
||||||
|
optionSource: 'nations',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
inputOptions,
|
inputOptions,
|
||||||
};
|
};
|
||||||
@@ -143,12 +189,25 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
const names = operations(route);
|
const names = operations(route);
|
||||||
const body = route.request().postDataJSON();
|
const body = route.request().postDataJSON();
|
||||||
const results = names.map((name) => {
|
const results = names.map((name) => {
|
||||||
|
if (name === 'dashboard.getContextBundleDelta')
|
||||||
|
return response({
|
||||||
|
context: { kind: 'snapshot', revision: 'context-v1', data: generalContext },
|
||||||
|
commandTable: { kind: 'snapshot', revision: 'commands-v1', data: commandTable },
|
||||||
|
boardAccess: {
|
||||||
|
kind: 'snapshot',
|
||||||
|
revision: 'board-v1',
|
||||||
|
data: { permission: 0, canMeeting: false, canSecret: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
if (name === 'general.me') return response(generalContext);
|
if (name === 'general.me') return response(generalContext);
|
||||||
if (name === 'world.getMapLayout')
|
if (name === 'world.getMapLayout')
|
||||||
return response({
|
return response({
|
||||||
mapName: 'che',
|
mapName: 'che',
|
||||||
cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [] }],
|
cityList: [
|
||||||
regionMap: { 1: '하북' },
|
{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [2] },
|
||||||
|
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
|
||||||
|
],
|
||||||
|
regionMap: { 1: '하북', 2: '예주' },
|
||||||
levelMap: { 8: '특' },
|
levelMap: { 8: '특' },
|
||||||
});
|
});
|
||||||
if (name === 'auth.status') return response({ ok: true });
|
if (name === 'auth.status') return response({ ok: true });
|
||||||
@@ -171,8 +230,14 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
startYear: 180,
|
startYear: 180,
|
||||||
year: 200,
|
year: 200,
|
||||||
month: 1,
|
month: 1,
|
||||||
cityList: [[1, 8, 0, 1, 1, 1]],
|
cityList: [
|
||||||
nationList: [[1, '아국', '#008000', 1]],
|
[1, 8, 0, 1, 1, 1],
|
||||||
|
[2, 7, 40, 2, 2, 1],
|
||||||
|
],
|
||||||
|
nationList: [
|
||||||
|
[1, '아국', '#008000', 1],
|
||||||
|
[2, '적국', '#800000', 2],
|
||||||
|
],
|
||||||
spyList: {},
|
spyList: {},
|
||||||
shownByGeneralList: [],
|
shownByGeneralList: [],
|
||||||
myCity: 1,
|
myCity: 1,
|
||||||
@@ -241,13 +306,35 @@ const install = async (page: Page, rejectGeneral = false) => {
|
|||||||
|
|
||||||
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
|
test('enters general and nation command arguments and sends exact values', async ({ page }) => {
|
||||||
const requests = await install(page);
|
const requests = await install(page);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
|
||||||
const form = page.getByTestId('command-argument-form');
|
const form = page.getByTestId('command-argument-form');
|
||||||
await expect(form).toBeVisible();
|
await expect(form).toBeVisible();
|
||||||
await form.locator('select').selectOption('2');
|
await expect(form.getByTestId('command-argument-map')).toBeVisible();
|
||||||
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.');
|
||||||
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸');
|
||||||
|
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click();
|
||||||
|
await expect(form.locator('select')).toHaveValue('2');
|
||||||
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸');
|
||||||
|
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).hover();
|
||||||
|
expect(
|
||||||
|
await form
|
||||||
|
.getByTestId('command-argument-map')
|
||||||
|
.locator('.map-city')
|
||||||
|
.nth(1)
|
||||||
|
.evaluate((element) => getComputedStyle(element).cursor)
|
||||||
|
).toBe('pointer');
|
||||||
|
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).focus();
|
||||||
|
await expect(form.getByTestId('command-argument-map').locator('.map-city').nth(1)).toBeFocused();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => {
|
||||||
|
const area = element.querySelector<HTMLElement>('.map-area')!;
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
return { width: rect.width, height: rect.height };
|
||||||
|
});
|
||||||
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
|
await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click();
|
||||||
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계');
|
await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계');
|
||||||
|
|
||||||
@@ -279,12 +366,74 @@ test('enters general and nation command arguments and sends exact values', async
|
|||||||
expect(JSON.stringify(requests)).toContain('"amount":300');
|
expect(JSON.stringify(requests)).toContain('"amount":300');
|
||||||
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
|
expect(JSON.stringify(requests)).toContain('"destGeneralId":2');
|
||||||
|
|
||||||
|
expect(mapGeometry.width).toBeGreaterThan(650);
|
||||||
|
expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2);
|
||||||
|
|
||||||
expect(geometry.width).toBeGreaterThan(200);
|
expect(geometry.width).toBeGreaterThan(200);
|
||||||
expect(geometry.rowHeight).toBeGreaterThanOrEqual(34);
|
expect(geometry.rowHeight).toBeGreaterThanOrEqual(34);
|
||||||
expect(geometry.borderStyle).toBe('solid');
|
expect(geometry.borderStyle).toBe('solid');
|
||||||
expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10);
|
expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('uses the map to choose a nation target in the chief command window', async ({ page }) => {
|
||||||
|
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();
|
||||||
|
const picker = page.getByTestId('command-picker');
|
||||||
|
await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click();
|
||||||
|
await picker.getByRole('button', { name: /선전포고/ }).click();
|
||||||
|
const form = picker.getByTestId('command-argument-form');
|
||||||
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한');
|
||||||
|
await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click();
|
||||||
|
await expect(form.locator('select')).toHaveValue('2');
|
||||||
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개');
|
||||||
|
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
||||||
|
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves the separately scoped recruitment argument window unchanged', async ({ page }) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
const picker = page.getByTestId('command-picker');
|
||||||
|
await picker.getByRole('button', { name: /징병/ }).click();
|
||||||
|
await expect(picker.getByTestId('command-argument-form')).toBeVisible();
|
||||||
|
await expect(picker.getByTestId('command-argument-guidance')).toHaveCount(0);
|
||||||
|
await expect(picker.getByTestId('command-argument-map')).toHaveCount(0);
|
||||||
|
expect((await picker.boundingBox())?.width).toBeLessThan(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 });
|
||||||
|
await page.goto('/');
|
||||||
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
const picker = page.getByTestId('command-picker');
|
||||||
|
await picker.getByRole('button', { name: /화계/ }).click();
|
||||||
|
const geometry = await picker.evaluate((element) => {
|
||||||
|
const map = element.querySelector<HTMLElement>('[data-testid="command-argument-map"] .map-area')!;
|
||||||
|
const pickerRect = element.getBoundingClientRect();
|
||||||
|
const mapRect = map.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
pickerX: pickerRect.x,
|
||||||
|
pickerRight: pickerRect.right,
|
||||||
|
pickerWidth: pickerRect.width,
|
||||||
|
pickerScrollWidth: element.scrollWidth,
|
||||||
|
mapWidth: mapRect.width,
|
||||||
|
mapHeight: mapRect.height,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(geometry.pickerX).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(geometry.pickerRight).toBeLessThanOrEqual(500);
|
||||||
|
expect(geometry.pickerWidth).toBeGreaterThanOrEqual(488);
|
||||||
|
expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth);
|
||||||
|
expect(geometry.mapWidth).toBeGreaterThan(470);
|
||||||
|
expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2);
|
||||||
|
await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('keeps the entered command visible and reports a server validation error', async ({ page }) => {
|
test('keeps the entered command visible and reports a server validation error', async ({ page }) => {
|
||||||
await install(page, true);
|
await install(page, true);
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
@@ -333,7 +482,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo
|
|||||||
await expect.poll(() => page.evaluate(() => localStorage.getItem('core2026:general:1:clipboard'))).not.toBeNull();
|
await expect.poll(() => page.evaluate(() => localStorage.getItem('core2026:general:1:clipboard'))).not.toBeNull();
|
||||||
await editor.locator('details.range-menu > summary').click();
|
await editor.locator('details.range-menu > summary').click();
|
||||||
await editor.getByRole('button', { name: '모든턴', exact: true }).click();
|
await editor.getByRole('button', { name: '모든턴', exact: true }).click();
|
||||||
await expect(editor.locator('.index-column > button.selected')).toHaveCount(14);
|
await expect(editor.locator('.index-column > button.selected')).toHaveCount(15);
|
||||||
await editor.locator('details.selected-menu > summary').click();
|
await editor.locator('details.selected-menu > summary').click();
|
||||||
await editor.getByRole('button', { name: '붙여넣기', exact: true }).click();
|
await editor.getByRole('button', { name: '붙여넣기', exact: true }).click();
|
||||||
await expect(editor.locator('.action-column > div').nth(5)).toHaveText('화계');
|
await expect(editor.locator('.action-column > div').nth(5)).toHaveText('화계');
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
||||||
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types';
|
import type {
|
||||||
|
CommandMapData,
|
||||||
|
CommandMapLayout,
|
||||||
|
CommandPatternEntry,
|
||||||
|
CommandTable,
|
||||||
|
ReservedCommandRow,
|
||||||
|
} from '../command/types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
officerLevelText: string;
|
officerLevelText: string;
|
||||||
@@ -13,6 +19,8 @@ const props = defineProps<{
|
|||||||
generalId: number;
|
generalId: number;
|
||||||
officerLevel: number;
|
officerLevel: number;
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
|
mapData?: CommandMapData | null;
|
||||||
|
mapLayout?: CommandMapLayout | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
|
const commandRows = computed(() => props.rows.map((row) => ({ ...row, action: row.actionCode ?? row.action })));
|
||||||
@@ -37,6 +45,8 @@ const emit = defineEmits<{
|
|||||||
:title="props.officerLevelText"
|
:title="props.officerLevelText"
|
||||||
:name="props.name"
|
:name="props.name"
|
||||||
:current-time="props.rows[0]?.time"
|
:current-time="props.rows[0]?.time"
|
||||||
|
:map-data="props.mapData"
|
||||||
|
:map-layout="props.mapLayout"
|
||||||
@reserve-bulk="emit('reserve-bulk', $event)"
|
@reserve-bulk="emit('reserve-bulk', $event)"
|
||||||
@shift="emit('shift', $event)"
|
@shift="emit('shift', $event)"
|
||||||
@repeat="emit('repeat', $event)"
|
@repeat="emit('repeat', $event)"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
|
import { computed, onMounted, ref, shallowRef, watch } from 'vue';
|
||||||
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
|
import CommandArgumentForm from '../main/CommandArgumentForm.vue';
|
||||||
import CommandSelectForm from '../main/CommandSelectForm.vue';
|
import CommandSelectForm from '../main/CommandSelectForm.vue';
|
||||||
|
import { commandArgumentPresentation } from './commandArgumentPresentation';
|
||||||
import DragSelect from './DragSelect.vue';
|
import DragSelect from './DragSelect.vue';
|
||||||
import {
|
import {
|
||||||
amplifyPattern,
|
amplifyPattern,
|
||||||
@@ -11,7 +12,14 @@ import {
|
|||||||
normalizedSelection,
|
normalizedSelection,
|
||||||
selectStep,
|
selectStep,
|
||||||
} from './commandQueue';
|
} from './commandQueue';
|
||||||
import type { CommandAvailability, CommandPatternEntry, CommandTable, ReservedCommandRow } from './types';
|
import type {
|
||||||
|
CommandAvailability,
|
||||||
|
CommandMapData,
|
||||||
|
CommandMapLayout,
|
||||||
|
CommandPatternEntry,
|
||||||
|
CommandTable,
|
||||||
|
ReservedCommandRow,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -26,8 +34,19 @@ const props = withDefaults(
|
|||||||
title?: string;
|
title?: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
currentTime?: string;
|
currentTime?: string;
|
||||||
|
mapData?: CommandMapData | null;
|
||||||
|
mapLayout?: CommandMapLayout | null;
|
||||||
}>(),
|
}>(),
|
||||||
{ maxPushTurn: 6, compact: false, mobile: false, title: '', name: null, currentTime: '--:--' }
|
{
|
||||||
|
maxPushTurn: 6,
|
||||||
|
compact: false,
|
||||||
|
mobile: false,
|
||||||
|
title: '',
|
||||||
|
name: null,
|
||||||
|
currentTime: '--:--',
|
||||||
|
mapData: null,
|
||||||
|
mapLayout: null,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -238,7 +257,15 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
<template>
|
<template>
|
||||||
<article
|
<article
|
||||||
class="reserved-command-editor"
|
class="reserved-command-editor"
|
||||||
:class="{ compact: props.compact, mobile: props.mobile, 'edit-mode': editMode, 'picker-open': pickerOpen }"
|
:class="{
|
||||||
|
compact: props.compact,
|
||||||
|
mobile: props.mobile,
|
||||||
|
'edit-mode': editMode,
|
||||||
|
'picker-open': pickerOpen,
|
||||||
|
'argument-expanded': Boolean(
|
||||||
|
selectedCommand?.reqArg && commandArgumentPresentation(selectedCommand.key).lines.length
|
||||||
|
),
|
||||||
|
}"
|
||||||
:data-command-scope="props.scope"
|
:data-command-scope="props.scope"
|
||||||
>
|
>
|
||||||
<header v-if="props.compact && !props.mobile" class="identity legacy-bg1">
|
<header v-if="props.compact && !props.mobile" class="identity legacy-bg1">
|
||||||
@@ -589,6 +616,8 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
:command-key="selectedCommand.key"
|
:command-key="selectedCommand.key"
|
||||||
:fields="selectedCommand.inputFields"
|
:fields="selectedCommand.inputFields"
|
||||||
:options="props.commandTable.inputOptions"
|
:options="props.commandTable.inputOptions"
|
||||||
|
:map-data="props.mapData"
|
||||||
|
:map-layout="props.mapLayout"
|
||||||
@update:args="commandArgs = $event"
|
@update:args="commandArgs = $event"
|
||||||
@update:valid="commandArgsValid = $event"
|
@update:valid="commandArgsValid = $event"
|
||||||
/>
|
/>
|
||||||
@@ -902,6 +931,11 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1025px) {
|
@media (min-width: 1025px) {
|
||||||
|
.argument-expanded:not(.compact) .command-picker {
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
width: 700px;
|
||||||
|
}
|
||||||
.compact:not(.mobile) .command-picker {
|
.compact:not(.mobile) .command-picker {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
@@ -910,6 +944,13 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
left: calc(50% - 476px);
|
left: calc(50% - 476px);
|
||||||
width: 238px;
|
width: 238px;
|
||||||
}
|
}
|
||||||
|
.compact.argument-expanded:not(.mobile) .command-picker {
|
||||||
|
left: calc(50% - 350px);
|
||||||
|
width: 700px;
|
||||||
|
height: auto;
|
||||||
|
max-height: calc(100vh - 104px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile.compact .editor-layout {
|
.mobile.compact .editor-layout {
|
||||||
@@ -945,6 +986,16 @@ const clickOutsideMenu = (event: Event) => {
|
|||||||
width: 370px;
|
width: 370px;
|
||||||
height: 327px;
|
height: 327px;
|
||||||
}
|
}
|
||||||
|
.mobile.compact.argument-expanded .command-picker {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
margin-top: -330px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
.mobile.compact .advanced-actions {
|
.mobile.compact .advanced-actions {
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
export type CommandArgumentPresentation = {
|
||||||
|
lines: string[];
|
||||||
|
mapTarget?: 'city' | 'nation';
|
||||||
|
};
|
||||||
|
|
||||||
|
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
|
||||||
|
const nationTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'nation' });
|
||||||
|
|
||||||
|
// Ref hwe/ts/processing의 명령별 안내를 예약 명령 옵션창에 맞게 옮긴다.
|
||||||
|
// 징병/모병은 별도 이관 범위이므로 이 표에 넣지 않는다.
|
||||||
|
const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
|
||||||
|
che_강행: cityTarget(['선택한 도시로 강행합니다.', '최대 3칸 안의 도시만 선택할 수 있습니다.']),
|
||||||
|
che_이동: cityTarget(['선택한 도시로 이동합니다.', '인접한 도시로만 이동할 수 있습니다.']),
|
||||||
|
che_출병: cityTarget([
|
||||||
|
'선택한 도시를 향해 침공합니다.',
|
||||||
|
'침공 경로에 적군 도시가 있으면 그 도시에서 전투를 벌입니다.',
|
||||||
|
]),
|
||||||
|
che_첩보: cityTarget(['선택한 도시에 첩보를 실행합니다.', '인접 도시에서는 더 많은 정보를 얻습니다.']),
|
||||||
|
che_화계: cityTarget(['선택한 도시에 화계를 실행합니다.']),
|
||||||
|
che_탈취: cityTarget(['선택한 도시에 탈취를 실행합니다.']),
|
||||||
|
che_파괴: cityTarget(['선택한 도시에 파괴를 실행합니다.']),
|
||||||
|
che_선동: cityTarget(['선택한 도시에 선동을 실행합니다.']),
|
||||||
|
che_수몰: cityTarget(['선택한 도시에 수몰을 발동합니다.', '전쟁 중인 상대국 도시만 대상이 됩니다.']),
|
||||||
|
che_백성동원: cityTarget(['선택한 도시에 백성을 동원해 성벽을 쌓습니다.', '아국 도시만 대상이 됩니다.']),
|
||||||
|
che_천도: cityTarget([
|
||||||
|
'선택한 도시로 수도를 옮깁니다.',
|
||||||
|
'현재 수도에서 연결된 도시만 가능하며 1 + 2 × 거리만큼의 턴이 필요합니다.',
|
||||||
|
]),
|
||||||
|
che_허보: cityTarget(['선택한 도시에 허보를 발동합니다.', '선포 또는 전쟁 중인 상대국 도시만 대상이 됩니다.']),
|
||||||
|
che_초토화: cityTarget([
|
||||||
|
'선택한 도시를 초토화해 공백지로 만듭니다.',
|
||||||
|
'인구와 내정 상태에 따라 국고를 확보하고, 수뇌 명성과 모든 장수의 배신 수치에 영향을 줍니다.',
|
||||||
|
]),
|
||||||
|
cr_인구이동: cityTarget(['현재 도시의 인구를 선택한 인접 도시로 이동합니다.']),
|
||||||
|
che_발령: cityTarget(['선택한 도시로 아국 장수를 발령합니다.', '아국 도시만 대상이 됩니다.']),
|
||||||
|
|
||||||
|
che_선전포고: nationTarget([
|
||||||
|
'선택한 국가에 선전포고합니다.',
|
||||||
|
'고립되지 않은 아국 도시와 인접한 국가에만 가능하며 초반 제한의 영향을 받습니다.',
|
||||||
|
]),
|
||||||
|
che_급습: nationTarget(['선택한 국가에 급습을 발동합니다.', '선포 또는 전쟁 중인 상대국만 대상이 됩니다.']),
|
||||||
|
che_불가침파기제의: nationTarget(['불가침 중인 국가에 조약 파기를 제의합니다.']),
|
||||||
|
che_이호경식: nationTarget(['선택한 국가에 이호경식을 발동합니다.', '선포 또는 전쟁 중인 상대국만 대상이 됩니다.']),
|
||||||
|
che_종전제의: nationTarget(['전쟁 중인 국가에 종전을 제의합니다.']),
|
||||||
|
che_불가침제의: nationTarget([
|
||||||
|
'선택한 국가에 불가침을 제의합니다.',
|
||||||
|
'불가침 기한 다음 달부터 다시 선전포고할 수 있습니다.',
|
||||||
|
]),
|
||||||
|
che_피장파장: nationTarget([
|
||||||
|
'선택한 국가가 지정한 전략을 일정 턴 동안 사용하지 못하게 합니다.',
|
||||||
|
'아국에도 지정 전략의 재사용 제한이 생깁니다.',
|
||||||
|
]),
|
||||||
|
che_물자원조: nationTarget(['타국에 금과 쌀을 원조합니다.', '국가 작위에 따라 보낼 수 있는 금액이 제한됩니다.']),
|
||||||
|
|
||||||
|
che_증여: { lines: ['자신의 금이나 쌀을 선택한 장수에게 증여합니다.'] },
|
||||||
|
che_헌납: { lines: ['자신의 금이나 쌀을 국가 재산으로 헌납합니다.'] },
|
||||||
|
che_군량매매: { lines: ['자신의 군량을 사거나 팝니다.'] },
|
||||||
|
che_몰수: { lines: ['선택한 장수의 금이나 쌀을 몰수해 국가 재산으로 귀속합니다.'] },
|
||||||
|
che_포상: { lines: ['국고에서 선택한 장수에게 금이나 쌀을 지급합니다.'] },
|
||||||
|
che_부대탈퇴지시: { lines: ['선택한 장수에게 부대 탈퇴를 지시합니다.', '현재 부대원인 장수만 대상이 됩니다.'] },
|
||||||
|
che_등용: { lines: ['재야 또는 타국 장수에게 등용 서신을 보냅니다.', '서신은 개인 메시지로 전달됩니다.'] },
|
||||||
|
che_선양: { lines: ['군주의 자리를 선택한 아국 장수에게 물려줍니다.'] },
|
||||||
|
che_임관: {
|
||||||
|
lines: [
|
||||||
|
'선택한 국가에 임관하고 군주의 위치로 이동합니다.',
|
||||||
|
'이미 임관하거나 등용되었던 국가는 선택할 수 없습니다.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
che_장수대상임관: {
|
||||||
|
lines: ['선택한 장수를 따라 그 장수의 국가에 임관하고 군주의 위치로 이동합니다.'],
|
||||||
|
},
|
||||||
|
che_숙련전환: {
|
||||||
|
lines: ['선택한 병과 숙련을 40% 줄이고, 줄어든 숙련의 90%를 다른 병과 숙련으로 전환합니다.'],
|
||||||
|
},
|
||||||
|
che_장비매매: { lines: ['장비를 구입하거나 매각합니다.', '가격과 요구 치안, 장비 효과를 확인한 뒤 선택하세요.'] },
|
||||||
|
che_건국: {
|
||||||
|
lines: ['현재 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
|
||||||
|
},
|
||||||
|
che_무작위건국: {
|
||||||
|
lines: ['무작위 공백 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
|
||||||
|
},
|
||||||
|
cr_건국: { lines: ['현재 도시에서 규모 제한 없이 나라를 세웁니다.', '국가 성향별 장단점을 확인하세요.'] },
|
||||||
|
che_국기변경: { lines: ['국기의 색상을 변경합니다.', '이 명령은 한 번만 실행할 수 있습니다.'] },
|
||||||
|
che_국호변경: { lines: ['국가 이름을 변경합니다.', '황제가 된 뒤 한 번만 실행할 수 있습니다.'] },
|
||||||
|
che_등용수락: { lines: ['도착한 등용 제의에 응할 행동을 선택합니다.'] },
|
||||||
|
che_NPC능동: { lines: ['NPC 장수의 능동 행동 방식을 선택합니다.'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
|
||||||
|
PRESENTATIONS[commandKey] ?? { lines: [] };
|
||||||
|
|
||||||
|
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
|
||||||
@@ -1,4 +1,36 @@
|
|||||||
export type CommandOption = { value: string | number; label: string; color?: string };
|
export type CommandOption = {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
color?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandMapData = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
startYear: number;
|
||||||
|
techLevelLimit?: { maxLevel: number; initialLevel: number; increaseYears: number };
|
||||||
|
cityList: [number, number, number, number, number, number][];
|
||||||
|
nationList: [number, string, string, number][];
|
||||||
|
myCity?: number | null;
|
||||||
|
myNation?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandMapLayout = {
|
||||||
|
mapName: string;
|
||||||
|
cityList: Array<{ id: number; name: string; level: number; region: number; x: number; y: number; path: number[] }>;
|
||||||
|
regionMap: Record<number, string>;
|
||||||
|
levelMap: Record<number, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommandInputContext = {
|
||||||
|
actorGold: number;
|
||||||
|
actorRice: number;
|
||||||
|
citySecurity?: number;
|
||||||
|
nationGold?: number;
|
||||||
|
nationRice?: number;
|
||||||
|
nationLevel?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type CommandInputField = {
|
export type CommandInputField = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -38,6 +70,7 @@ export type CommandTable = {
|
|||||||
nationTypes: CommandOption[];
|
nationTypes: CommandOption[];
|
||||||
colors: CommandOption[];
|
colors: CommandOption[];
|
||||||
items: Record<string, CommandOption[]>;
|
items: Record<string, CommandOption[]>;
|
||||||
|
context?: CommandInputContext;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, watch } from 'vue';
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import MapViewer from './MapViewer.vue';
|
||||||
|
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
|
||||||
|
import type {
|
||||||
|
CommandInputContext,
|
||||||
|
CommandInputField,
|
||||||
|
CommandMapData,
|
||||||
|
CommandMapLayout,
|
||||||
|
CommandOption,
|
||||||
|
CommandTable,
|
||||||
|
} from '../command/types';
|
||||||
|
|
||||||
type OptionValue = string | number;
|
type CommandInputOptions = CommandTable['inputOptions'];
|
||||||
interface CommandOption {
|
|
||||||
value: OptionValue;
|
|
||||||
label: string;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
interface CommandInputField {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
|
|
||||||
required: boolean;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step?: number;
|
|
||||||
constValue?: OptionValue;
|
|
||||||
options?: CommandOption[];
|
|
||||||
optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
|
|
||||||
tupleLabels?: string[];
|
|
||||||
}
|
|
||||||
interface CommandInputOptions {
|
|
||||||
cities: CommandOption[];
|
|
||||||
nations: CommandOption[];
|
|
||||||
generals: CommandOption[];
|
|
||||||
crewTypes: CommandOption[];
|
|
||||||
armTypes: CommandOption[];
|
|
||||||
nationTypes: CommandOption[];
|
|
||||||
colors: CommandOption[];
|
|
||||||
items: Record<string, CommandOption[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
commandKey: string;
|
commandKey: string;
|
||||||
fields: CommandInputField[];
|
fields: CommandInputField[];
|
||||||
options: CommandInputOptions;
|
options: CommandInputOptions;
|
||||||
|
mapData?: CommandMapData | null;
|
||||||
|
mapLayout?: CommandMapLayout | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -43,6 +27,8 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const values = reactive<Record<string, unknown>>({});
|
const values = reactive<Record<string, unknown>>({});
|
||||||
|
const presentation = computed(() => commandArgumentPresentation(props.commandKey));
|
||||||
|
const visibleFields = computed(() => props.fields.filter((entry) => entry.kind !== 'hidden'));
|
||||||
|
|
||||||
const optionsFor = (field: CommandInputField): CommandOption[] => {
|
const optionsFor = (field: CommandInputField): CommandOption[] => {
|
||||||
if (field.options) return field.options;
|
if (field.options) return field.options;
|
||||||
@@ -58,7 +44,16 @@ const defaultValue = (field: CommandInputField): unknown => {
|
|||||||
if (field.kind === 'boolean') return true;
|
if (field.kind === 'boolean') return true;
|
||||||
if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0];
|
if (field.kind === 'numberTuple') return [field.min ?? 0, field.min ?? 0];
|
||||||
if (field.kind === 'number') return field.min ?? 0;
|
if (field.kind === 'number') return field.min ?? 0;
|
||||||
if (field.kind === 'select') return optionsFor(field)[0]?.value ?? '';
|
if (field.kind === 'select') {
|
||||||
|
const options = optionsFor(field);
|
||||||
|
const mapDefault =
|
||||||
|
field.optionSource === 'cities' && (field.key === 'destCityId' || field.key === 'destCityID')
|
||||||
|
? props.mapData?.myCity
|
||||||
|
: field.optionSource === 'nations' && field.key === 'destNationId'
|
||||||
|
? props.mapData?.myNation
|
||||||
|
: null;
|
||||||
|
return options.find((option) => option.value === mapDefault)?.value ?? options[0]?.value ?? '';
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,6 +73,129 @@ const setSelectValue = (field: CommandInputField, rawValue: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedOptionFor = (field: CommandInputField): CommandOption | undefined =>
|
||||||
|
optionsFor(field).find((entry) => entry.value === values[field.key]);
|
||||||
|
|
||||||
|
const cityTargetField = computed(() =>
|
||||||
|
props.fields.find(
|
||||||
|
(field) =>
|
||||||
|
field.kind === 'select' &&
|
||||||
|
field.optionSource === 'cities' &&
|
||||||
|
(field.key === 'destCityId' || field.key === 'destCityID')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const nationTargetField = computed(() =>
|
||||||
|
props.fields.find(
|
||||||
|
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const showMap = computed(
|
||||||
|
() =>
|
||||||
|
Boolean(props.mapData && props.mapLayout) &&
|
||||||
|
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
|
||||||
|
(presentation.value.mapTarget === 'nation' && nationTargetField.value))
|
||||||
|
);
|
||||||
|
const mapSelectedCityId = computed<number | null>(() => {
|
||||||
|
if (!props.mapData) return null;
|
||||||
|
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
|
||||||
|
const value = values[cityTargetField.value.key];
|
||||||
|
return typeof value === 'number' ? value : 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 null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const distanceFromMyCity = (destination: number): number | null => {
|
||||||
|
const start = props.mapData?.myCity;
|
||||||
|
if (!start || !props.mapLayout) return null;
|
||||||
|
if (start === destination) return 0;
|
||||||
|
const paths = new Map(props.mapLayout.cityList.map((city) => [city.id, city.path]));
|
||||||
|
const visited = new Set<number>([start]);
|
||||||
|
let frontier = [start];
|
||||||
|
for (let distance = 1; frontier.length; distance += 1) {
|
||||||
|
const next: number[] = [];
|
||||||
|
for (const cityId of frontier) {
|
||||||
|
for (const adjacentId of paths.get(cityId) ?? []) {
|
||||||
|
if (visited.has(adjacentId)) continue;
|
||||||
|
if (adjacentId === destination) return distance;
|
||||||
|
visited.add(adjacentId);
|
||||||
|
next.push(adjacentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapTargetSummary = computed(() => {
|
||||||
|
if (!props.mapData || !props.mapLayout) return '';
|
||||||
|
if (presentation.value.mapTarget === 'city' && mapSelectedCityId.value) {
|
||||||
|
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
||||||
|
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
||||||
|
if (!city) return '';
|
||||||
|
const nation = props.mapData.nationList.find((entry) => entry[0] === dynamic?.[3]);
|
||||||
|
const distance = distanceFromMyCity(city.id);
|
||||||
|
return [
|
||||||
|
city.name,
|
||||||
|
nation?.[1] ?? '무주',
|
||||||
|
props.mapLayout.regionMap[dynamic?.[4] ?? city.region],
|
||||||
|
props.mapLayout.levelMap[dynamic?.[1] ?? city.level],
|
||||||
|
distance === null ? null : `현재 도시에서 ${distance}칸`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||||
|
const value = values[nationTargetField.value.key];
|
||||||
|
if (typeof value !== 'number') return '';
|
||||||
|
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
|
||||||
|
if (!nation) return '';
|
||||||
|
const capital = props.mapLayout.cityList.find((entry) => entry.id === nation[3]);
|
||||||
|
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
|
||||||
|
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}개`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectMapCity = (cityId: number) => {
|
||||||
|
if (!props.mapData) return;
|
||||||
|
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
|
||||||
|
setSelectValue(cityTargetField.value, String(cityId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
||||||
|
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
|
||||||
|
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resourceSummary = computed(() => {
|
||||||
|
const context: CommandInputContext | undefined = props.options.context;
|
||||||
|
if (!context) return [];
|
||||||
|
const result: string[] = [];
|
||||||
|
const usesActorResources = new Set(['che_증여', 'che_헌납', 'che_군량매매', 'che_장비매매']);
|
||||||
|
const usesNationResources = new Set(['che_몰수', 'che_포상', 'che_물자원조']);
|
||||||
|
if (usesActorResources.has(props.commandKey)) {
|
||||||
|
result.push(
|
||||||
|
`현재 자금 ${context.actorGold.toLocaleString()}`,
|
||||||
|
`현재 군량 ${context.actorRice.toLocaleString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (props.commandKey === 'che_장비매매' && context.citySecurity !== undefined) {
|
||||||
|
result.push(`현재 도시 치안 ${context.citySecurity.toLocaleString()}`);
|
||||||
|
}
|
||||||
|
if (usesNationResources.has(props.commandKey)) {
|
||||||
|
if (context.nationGold !== undefined) result.push(`국고 ${context.nationGold.toLocaleString()}`);
|
||||||
|
if (context.nationRice !== undefined) result.push(`국가 군량 ${context.nationRice.toLocaleString()}`);
|
||||||
|
if (context.nationLevel !== undefined) result.push(`국가 작위 ${context.nationLevel}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
const setTupleValue = (field: CommandInputField, index: number, rawValue: string) => {
|
const setTupleValue = (field: CommandInputField, index: number, rawValue: string) => {
|
||||||
const tuple = Array.isArray(values[field.key]) ? [...(values[field.key] as unknown[])] : [0, 0];
|
const tuple = Array.isArray(values[field.key]) ? [...(values[field.key] as unknown[])] : [0, 0];
|
||||||
tuple[index] = Number(rawValue);
|
tuple[index] = Number(rawValue);
|
||||||
@@ -89,17 +207,32 @@ const isValid = computed(() =>
|
|||||||
const value = values[field.key];
|
const value = values[field.key];
|
||||||
if (field.kind === 'text') {
|
if (field.kind === 'text') {
|
||||||
const length = typeof value === 'string' ? value.trim().length : 0;
|
const length = typeof value === 'string' ? value.trim().length : 0;
|
||||||
return (!field.required || length > 0) && (field.min === undefined || length >= field.min) &&
|
return (
|
||||||
(field.max === undefined || length <= field.max);
|
(!field.required || length > 0) &&
|
||||||
|
(field.min === undefined || length >= field.min) &&
|
||||||
|
(field.max === undefined || length <= field.max)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (field.kind === 'number') {
|
if (field.kind === 'number') {
|
||||||
return typeof value === 'number' && Number.isFinite(value) &&
|
return (
|
||||||
(field.min === undefined || value >= field.min) && (field.max === undefined || value <= field.max);
|
typeof value === 'number' &&
|
||||||
|
Number.isFinite(value) &&
|
||||||
|
(field.min === undefined || value >= field.min) &&
|
||||||
|
(field.max === undefined || value <= field.max)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (field.kind === 'numberTuple') {
|
if (field.kind === 'numberTuple') {
|
||||||
return Array.isArray(value) && value.length === 2 &&
|
return (
|
||||||
value.every((entry) => typeof entry === 'number' && Number.isFinite(entry) &&
|
Array.isArray(value) &&
|
||||||
(field.min === undefined || entry >= field.min) && (field.max === undefined || entry <= field.max));
|
value.length === 2 &&
|
||||||
|
value.every(
|
||||||
|
(entry) =>
|
||||||
|
typeof entry === 'number' &&
|
||||||
|
Number.isFinite(entry) &&
|
||||||
|
(field.min === undefined || entry >= field.min) &&
|
||||||
|
(field.max === undefined || entry <= field.max)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (field.kind === 'select') return optionsFor(field).some((option) => option.value === value);
|
if (field.kind === 'select') return optionsFor(field).some((option) => option.value === value);
|
||||||
return value !== undefined;
|
return value !== undefined;
|
||||||
@@ -119,11 +252,28 @@ watch(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form">
|
<div v-if="props.fields.length" class="command-argument-form" data-testid="command-argument-form">
|
||||||
<div
|
<div v-if="showMap" class="command-map" data-testid="command-argument-map">
|
||||||
v-for="field in props.fields.filter((entry) => entry.kind !== 'hidden')"
|
<MapViewer
|
||||||
:key="field.key"
|
:map-data="props.mapData ?? null"
|
||||||
class="argument-row"
|
:map-layout="props.mapLayout ?? null"
|
||||||
>
|
:loading="false"
|
||||||
|
:selected-city-id="mapSelectedCityId"
|
||||||
|
:detail-mode="false"
|
||||||
|
:fit-container="true"
|
||||||
|
@select-city="selectMapCity"
|
||||||
|
/>
|
||||||
|
<small>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
||||||
|
<div v-if="mapTargetSummary" class="map-target-summary" data-testid="command-map-target-summary">
|
||||||
|
{{ mapTargetSummary }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="presentation.lines.length" class="command-guidance" data-testid="command-argument-guidance">
|
||||||
|
<div v-for="line in presentation.lines" :key="line">{{ line }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="resourceSummary.length" class="resource-summary" data-testid="command-resource-summary">
|
||||||
|
<span v-for="entry in resourceSummary" :key="entry">{{ entry }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
|
||||||
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
|
<label :for="`command-arg-${field.key}`">{{ field.label }}</label>
|
||||||
<input
|
<input
|
||||||
v-if="field.kind === 'text'"
|
v-if="field.kind === 'text'"
|
||||||
@@ -182,6 +332,21 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
field.kind === 'select' &&
|
||||||
|
(selectedOptionFor(field)?.description || selectedOptionFor(field)?.color)
|
||||||
|
"
|
||||||
|
class="option-detail"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="selectedOptionFor(field)?.color"
|
||||||
|
class="option-color"
|
||||||
|
:style="{ backgroundColor: selectedOptionFor(field)?.color }"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{{ selectedOptionFor(field)?.description }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +358,43 @@ watch(
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.command-map {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-map small {
|
||||||
|
display: block;
|
||||||
|
padding: 5px 8px;
|
||||||
|
color: rgba(232, 221, 196, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-target-summary {
|
||||||
|
padding: 0 8px 6px;
|
||||||
|
color: #f1d89a;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-guidance {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid rgba(201, 164, 90, 0.35);
|
||||||
|
background: #191919;
|
||||||
|
color: #eee;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px 14px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid rgba(201, 164, 90, 0.25);
|
||||||
|
color: #f1d89a;
|
||||||
|
}
|
||||||
|
|
||||||
.argument-row {
|
.argument-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(76px, 0.36fr) 1fr;
|
grid-template-columns: minmax(76px, 0.36fr) 1fr;
|
||||||
@@ -200,6 +402,23 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.option-detail {
|
||||||
|
grid-column: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 6px 6px 0;
|
||||||
|
color: rgba(232, 221, 196, 0.74);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-color {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
.argument-row:nth-child(odd) {
|
.argument-row:nth-child(odd) {
|
||||||
background: rgba(255, 255, 255, 0.035);
|
background: rgba(255, 255, 255, 0.035);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { addMinutes } from 'date-fns';
|
import { addMinutes } from 'date-fns';
|
||||||
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
import ReservedCommandEditor from '../command/ReservedCommandEditor.vue';
|
||||||
import type { CommandPatternEntry, CommandTable, ReservedCommandRow } from '../command/types';
|
import type {
|
||||||
|
CommandMapData,
|
||||||
|
CommandMapLayout,
|
||||||
|
CommandPatternEntry,
|
||||||
|
CommandTable,
|
||||||
|
ReservedCommandRow,
|
||||||
|
} from '../command/types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
commandTable: CommandTable | null;
|
commandTable: CommandTable | null;
|
||||||
@@ -14,6 +20,8 @@ const props = defineProps<{
|
|||||||
turnTermMinutes?: number;
|
turnTermMinutes?: number;
|
||||||
autorunLimit?: number | null;
|
autorunLimit?: number | null;
|
||||||
storageKey?: string;
|
storageKey?: string;
|
||||||
|
mapData?: CommandMapData | null;
|
||||||
|
mapLayout?: CommandMapLayout | null;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -63,6 +71,8 @@ const rows = computed<ReservedCommandRow[]>(() => {
|
|||||||
:loading="props.loading"
|
:loading="props.loading"
|
||||||
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
|
:storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`"
|
||||||
:current-time="rows[0]?.time"
|
:current-time="rows[0]?.time"
|
||||||
|
:map-data="props.mapData"
|
||||||
|
:map-layout="props.mapLayout"
|
||||||
@reserve-bulk="emit('set-general-turns', $event)"
|
@reserve-bulk="emit('set-general-turns', $event)"
|
||||||
@shift="emit('shift-general-turns', $event)"
|
@shift="emit('shift-general-turns', $event)"
|
||||||
@repeat="emit('repeat-general-turns', $event)"
|
@repeat="emit('repeat-general-turns', $event)"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
interface MapCityView {
|
interface MapCityView {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,6 +21,7 @@ const props = defineProps<{
|
|||||||
city: MapCityView;
|
city: MapCityView;
|
||||||
showName: boolean;
|
showName: boolean;
|
||||||
mapScale: number;
|
mapScale: number;
|
||||||
|
selectOnly?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -31,12 +33,15 @@ const emit = defineEmits<{
|
|||||||
const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
|
const size = computed(() => (6 + props.city.level * 2) * props.mapScale);
|
||||||
const stateSize = computed(() => 8 * props.mapScale);
|
const stateSize = computed(() => 8 * props.mapScale);
|
||||||
const stateOffset = computed(() => -6 * props.mapScale);
|
const stateOffset = computed(() => -6 * props.mapScale);
|
||||||
|
const selectCity = () => emit('select', props.city.id);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink
|
<component
|
||||||
|
:is="props.selectOnly ? 'button' : RouterLink"
|
||||||
class="map-city"
|
class="map-city"
|
||||||
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
:type="props.selectOnly ? 'button' : undefined"
|
||||||
|
:to="props.selectOnly ? undefined : { name: 'current-city', query: { cityId: props.city.id } }"
|
||||||
:class="[
|
:class="[
|
||||||
`state-${props.city.stateClass}`,
|
`state-${props.city.stateClass}`,
|
||||||
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
|
{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply },
|
||||||
@@ -44,7 +49,7 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
|
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
|
||||||
@mouseenter="emit('hover', props.city.id)"
|
@mouseenter="emit('hover', props.city.id)"
|
||||||
@mouseleave="emit('leave')"
|
@mouseleave="emit('leave')"
|
||||||
@click.stop="emit('select', props.city.id)"
|
@click.stop="selectCity"
|
||||||
>
|
>
|
||||||
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
||||||
<span v-if="props.city.isCapital" class="capital" />
|
<span v-if="props.city.isCapital" class="capital" />
|
||||||
@@ -61,7 +66,7 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
|
<div v-if="props.showName" class="city-name">{{ props.city.name }}</div>
|
||||||
</RouterLink>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -76,6 +81,9 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
color: rgba(232, 221, 196, 0.8);
|
color: rgba(232, 221, 196, 0.8);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-dot {
|
.city-dot {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets';
|
import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets';
|
||||||
|
|
||||||
interface MapCityView {
|
interface MapCityView {
|
||||||
@@ -46,6 +47,7 @@ const props = defineProps<{
|
|||||||
imageBaseUrl: string;
|
imageBaseUrl: string;
|
||||||
themeName: string;
|
themeName: string;
|
||||||
mapScale: number;
|
mapScale: number;
|
||||||
|
selectOnly?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -141,6 +143,8 @@ const capitalIconStyle = computed(() => ({
|
|||||||
height: `${10 * props.mapScale}px`,
|
height: `${10 * props.mapScale}px`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const selectCity = () => emit('select', props.city.id);
|
||||||
|
|
||||||
const cityStateStyle = computed(() => ({
|
const cityStateStyle = computed(() => ({
|
||||||
width: `${12 * props.mapScale}px`,
|
width: `${12 * props.mapScale}px`,
|
||||||
height: `${12 * props.mapScale}px`,
|
height: `${12 * props.mapScale}px`,
|
||||||
@@ -149,14 +153,16 @@ const cityStateStyle = computed(() => ({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink
|
<component
|
||||||
|
:is="props.selectOnly ? 'button' : RouterLink"
|
||||||
class="city-base"
|
class="city-base"
|
||||||
:to="{ name: 'current-city', query: { cityId: props.city.id } }"
|
: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 }]"
|
:class="[{ mine: props.city.isMyCity, selected: props.city.selected, 'supply-off': !props.city.supply }]"
|
||||||
:style="cityBaseStyle"
|
:style="cityBaseStyle"
|
||||||
@mouseenter="emit('hover', props.city.id)"
|
@mouseenter="emit('hover', props.city.id)"
|
||||||
@mouseleave="emit('leave')"
|
@mouseleave="emit('leave')"
|
||||||
@click.stop="emit('select', props.city.id)"
|
@click.stop="selectCity"
|
||||||
>
|
>
|
||||||
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
|
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
|
||||||
<div class="city-img" :style="cityIconStyle">
|
<div class="city-img" :style="cityIconStyle">
|
||||||
@@ -173,7 +179,7 @@ const cityStateStyle = computed(() => ({
|
|||||||
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
|
<div v-if="stateIcon" class="city-state" :style="cityStateStyle">
|
||||||
<img :src="stateIcon" />
|
<img :src="stateIcon" />
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -184,6 +190,9 @@ const cityStateStyle = computed(() => ({
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: auto;
|
cursor: auto;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.city-bg {
|
.city-bg {
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ const props = defineProps<{
|
|||||||
mapData: MapSummary | null;
|
mapData: MapSummary | null;
|
||||||
mapLayout: MapLayout | null;
|
mapLayout: MapLayout | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
selectedCityId?: number | null;
|
||||||
|
detailMode?: boolean;
|
||||||
|
fitContainer?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'select-city', cityId: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const BASE_MAP_WIDTH = 700;
|
const BASE_MAP_WIDTH = 700;
|
||||||
@@ -75,7 +82,12 @@ const SMALL_MAP_SCALE = 5 / 7;
|
|||||||
|
|
||||||
const isWide = useMediaQuery('(min-width: 1024px)');
|
const isWide = useMediaQuery('(min-width: 1024px)');
|
||||||
const mapStore = useMapViewerStore();
|
const mapStore = useMapViewerStore();
|
||||||
const { showCityName, detailMode, hoveredCityId, selectedCityId } = storeToRefs(mapStore);
|
const {
|
||||||
|
showCityName,
|
||||||
|
detailMode: storeDetailMode,
|
||||||
|
hoveredCityId,
|
||||||
|
selectedCityId: storeSelectedCityId,
|
||||||
|
} = storeToRefs(mapStore);
|
||||||
|
|
||||||
const mapArea = ref<HTMLElement | null>(null);
|
const mapArea = ref<HTMLElement | null>(null);
|
||||||
const mapBody = ref<HTMLElement | null>(null);
|
const mapBody = ref<HTMLElement | null>(null);
|
||||||
@@ -140,15 +152,20 @@ const dynamicCityById = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const mapScale = computed(() => {
|
const mapScale = computed(() => {
|
||||||
if (isWide.value) {
|
if (isWide.value && !props.fitContainer) {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
if (mapBodyWidth.value <= 0) {
|
if (mapBodyWidth.value <= 0) {
|
||||||
return SMALL_MAP_SCALE;
|
return SMALL_MAP_SCALE;
|
||||||
}
|
}
|
||||||
return Math.min(SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
|
return Math.min(props.fitContainer ? 1 : SMALL_MAP_SCALE, mapBodyWidth.value / BASE_MAP_WIDTH);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const effectiveDetailMode = computed(() => props.detailMode ?? storeDetailMode.value);
|
||||||
|
const effectiveSelectedCityId = computed(() =>
|
||||||
|
props.selectedCityId === undefined ? storeSelectedCityId.value : props.selectedCityId
|
||||||
|
);
|
||||||
|
|
||||||
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
|
const mapWidth = computed(() => `${BASE_MAP_WIDTH * mapScale.value}px`);
|
||||||
|
|
||||||
const mapHeight = computed(() => `${BASE_MAP_HEIGHT * mapScale.value}px`);
|
const mapHeight = computed(() => `${BASE_MAP_HEIGHT * mapScale.value}px`);
|
||||||
@@ -185,7 +202,7 @@ const cityViews = computed<CityView[]>(() => {
|
|||||||
y,
|
y,
|
||||||
isCapital: nation?.capitalCityId === layoutCity.id,
|
isCapital: nation?.capitalCityId === layoutCity.id,
|
||||||
isMyCity: props.mapData?.myCity === layoutCity.id,
|
isMyCity: props.mapData?.myCity === layoutCity.id,
|
||||||
selected: selectedCityId.value === layoutCity.id,
|
selected: effectiveSelectedCityId.value === layoutCity.id,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -258,7 +275,7 @@ const titleTooltipLines = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const titleBandStyle = computed(() =>
|
const titleBandStyle = computed(() =>
|
||||||
detailMode.value
|
effectiveDetailMode.value
|
||||||
? {
|
? {
|
||||||
backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`,
|
backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`,
|
||||||
}
|
}
|
||||||
@@ -266,7 +283,7 @@ const titleBandStyle = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const titleTextStyle = computed(() =>
|
const titleTextStyle = computed(() =>
|
||||||
detailMode.value
|
effectiveDetailMode.value
|
||||||
? {
|
? {
|
||||||
color: titleColor.value,
|
color: titleColor.value,
|
||||||
backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`,
|
backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`,
|
||||||
@@ -327,7 +344,7 @@ const mapRoadStyle = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const detailProps = computed(() =>
|
const detailProps = computed(() =>
|
||||||
detailMode.value
|
effectiveDetailMode.value
|
||||||
? {
|
? {
|
||||||
imageBaseUrl: assetBaseUrl.value,
|
imageBaseUrl: assetBaseUrl.value,
|
||||||
themeName: mapTheme.value,
|
themeName: mapTheme.value,
|
||||||
@@ -365,7 +382,10 @@ const setHoveredCity = (cityId: number | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectCity = (cityId: number) => {
|
const selectCity = (cityId: number) => {
|
||||||
mapStore.setSelectedCity(cityId);
|
emit('select-city', cityId);
|
||||||
|
if (props.selectedCityId === undefined) {
|
||||||
|
mapStore.setSelectedCity(cityId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -394,12 +414,13 @@ const selectCity = (cityId: number) => {
|
|||||||
<div class="map-layer map-bglayer2" />
|
<div class="map-layer map-bglayer2" />
|
||||||
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
|
<div v-if="mapRoadImage" class="map-layer map-bgroad" :style="mapRoadStyle" />
|
||||||
<component
|
<component
|
||||||
:is="detailMode ? MapCityDetail : MapCityBasic"
|
:is="effectiveDetailMode ? MapCityDetail : MapCityBasic"
|
||||||
v-for="city in cityViews"
|
v-for="city in cityViews"
|
||||||
:key="city.id"
|
:key="city.id"
|
||||||
:city="city"
|
:city="city"
|
||||||
:map-scale="mapScale"
|
:map-scale="mapScale"
|
||||||
:show-name="showCityName"
|
:show-name="showCityName"
|
||||||
|
:select-only="props.selectedCityId !== undefined"
|
||||||
v-bind="detailProps"
|
v-bind="detailProps"
|
||||||
@hover="setHoveredCity"
|
@hover="setHoveredCity"
|
||||||
@leave="setHoveredCity(null)"
|
@leave="setHoveredCity(null)"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue';
|
|||||||
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
|
import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { formatOfficerLevelText } from '../utils/nationFormat';
|
import { formatOfficerLevelText } from '../utils/nationFormat';
|
||||||
import type { CommandPatternEntry } from '../components/command/types';
|
import type { CommandMapData, CommandMapLayout, CommandPatternEntry, CommandTable } from '../components/command/types';
|
||||||
|
|
||||||
type ChiefTurn = {
|
type ChiefTurn = {
|
||||||
index: number;
|
index: number;
|
||||||
@@ -43,49 +43,6 @@ type ChiefCenterResponse = {
|
|||||||
chiefs: ChiefEntry[];
|
chiefs: ChiefEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CommandAvailability = {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
reqArg: boolean;
|
|
||||||
status: 'available' | 'blocked' | 'needsInput' | 'unknown';
|
|
||||||
possible: boolean;
|
|
||||||
reason?: string;
|
|
||||||
inputFields: Array<{
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden';
|
|
||||||
required: boolean;
|
|
||||||
min?: number;
|
|
||||||
max?: number;
|
|
||||||
step?: number;
|
|
||||||
constValue?: string | number;
|
|
||||||
options?: Array<{ value: string | number; label: string; color?: string }>;
|
|
||||||
optionSource?:
|
|
||||||
'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items';
|
|
||||||
tupleLabels?: string[];
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CommandGroup = {
|
|
||||||
category: string;
|
|
||||||
values: CommandAvailability[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type CommandTable = {
|
|
||||||
general: CommandGroup[];
|
|
||||||
nation: CommandGroup[];
|
|
||||||
inputOptions: {
|
|
||||||
cities: Array<{ value: string | number; label: string; color?: string }>;
|
|
||||||
nations: Array<{ value: string | number; label: string; color?: string }>;
|
|
||||||
generals: Array<{ value: string | number; label: string; color?: string }>;
|
|
||||||
crewTypes: Array<{ value: string | number; label: string; color?: string }>;
|
|
||||||
armTypes: Array<{ value: string | number; label: string; color?: string }>;
|
|
||||||
nationTypes: Array<{ value: string | number; label: string; color?: string }>;
|
|
||||||
colors: Array<{ value: string | number; label: string; color?: string }>;
|
|
||||||
items: Record<string, Array<{ value: string | number; label: string; color?: string }>>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const chiefApi = trpc as unknown as {
|
const chiefApi = trpc as unknown as {
|
||||||
nation: {
|
nation: {
|
||||||
getChiefCenter: {
|
getChiefCenter: {
|
||||||
@@ -97,6 +54,10 @@ const chiefApi = trpc as unknown as {
|
|||||||
query: (input: { generalId: number }) => Promise<CommandTable>;
|
query: (input: { generalId: number }) => Promise<CommandTable>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
world: {
|
||||||
|
getMap: { query: () => Promise<CommandMapData> };
|
||||||
|
getMapLayout: { query: () => Promise<CommandMapLayout> };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type TurnRow = {
|
type TurnRow = {
|
||||||
@@ -114,6 +75,8 @@ const commandLoading = ref(false);
|
|||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const data = ref<ChiefCenterResponse | null>(null);
|
const data = ref<ChiefCenterResponse | null>(null);
|
||||||
const commandTable = ref<CommandTable | null>(null);
|
const commandTable = ref<CommandTable | null>(null);
|
||||||
|
const worldMap = ref<CommandMapData | null>(null);
|
||||||
|
const mapLayout = ref<CommandMapLayout | null>(null);
|
||||||
|
|
||||||
const selectedChiefLevel = ref<number | null>(null);
|
const selectedChiefLevel = ref<number | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -152,7 +115,14 @@ const loadCommandTable = async (generalId: number) => {
|
|||||||
}
|
}
|
||||||
commandLoading.value = true;
|
commandLoading.value = true;
|
||||||
try {
|
try {
|
||||||
commandTable.value = await chiefApi.turns.getCommandTable.query({ generalId });
|
const [nextCommandTable, nextWorldMap, nextMapLayout] = await Promise.all([
|
||||||
|
chiefApi.turns.getCommandTable.query({ generalId }),
|
||||||
|
chiefApi.world.getMap.query().catch(() => null),
|
||||||
|
chiefApi.world.getMapLayout.query().catch(() => null),
|
||||||
|
]);
|
||||||
|
commandTable.value = nextCommandTable;
|
||||||
|
worldMap.value = nextWorldMap;
|
||||||
|
mapLayout.value = nextMapLayout;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = resolveErrorMessage(err);
|
error.value = resolveErrorMessage(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -360,6 +330,8 @@ const repeatTurns = async (amount: number) => {
|
|||||||
:general-id="data.me.id"
|
:general-id="data.me.id"
|
||||||
:officer-level="selectedChief.officerLevel"
|
:officer-level="selectedChief.officerLevel"
|
||||||
:mobile="true"
|
:mobile="true"
|
||||||
|
:map-data="worldMap"
|
||||||
|
:map-layout="mapLayout"
|
||||||
@reserve-bulk="reserveTurns"
|
@reserve-bulk="reserveTurns"
|
||||||
@shift="shiftTurns"
|
@shift="shiftTurns"
|
||||||
@repeat="repeatTurns"
|
@repeat="repeatTurns"
|
||||||
@@ -420,6 +392,8 @@ const repeatTurns = async (amount: number) => {
|
|||||||
:loading="commandLoading"
|
:loading="commandLoading"
|
||||||
:general-id="data.me.id"
|
:general-id="data.me.id"
|
||||||
:officer-level="chief.officerLevel"
|
:officer-level="chief.officerLevel"
|
||||||
|
:map-data="worldMap"
|
||||||
|
:map-layout="mapLayout"
|
||||||
@reserve-bulk="reserveTurns"
|
@reserve-bulk="reserveTurns"
|
||||||
@shift="shiftTurns"
|
@shift="shiftTurns"
|
||||||
@repeat="repeatTurns"
|
@repeat="repeatTurns"
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ watch(
|
|||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
:autorun-limit="reservedGeneralAutorunLimit"
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
|
:map-data="worldMap"
|
||||||
|
:map-layout="mapLayout"
|
||||||
@set-general-turns="reserveGeneralTurns"
|
@set-general-turns="reserveGeneralTurns"
|
||||||
@shift-general-turns="shiftGeneralTurns"
|
@shift-general-turns="shiftGeneralTurns"
|
||||||
@repeat-general-turns="repeatGeneralTurns"
|
@repeat-general-turns="repeatGeneralTurns"
|
||||||
@@ -344,6 +346,8 @@ watch(
|
|||||||
:current-month="lobbyInfo?.month"
|
:current-month="lobbyInfo?.month"
|
||||||
:turn-term-minutes="lobbyInfo?.turnTerm"
|
:turn-term-minutes="lobbyInfo?.turnTerm"
|
||||||
:autorun-limit="reservedGeneralAutorunLimit"
|
:autorun-limit="reservedGeneralAutorunLimit"
|
||||||
|
:map-data="worldMap"
|
||||||
|
:map-layout="mapLayout"
|
||||||
@set-general-turns="reserveGeneralTurns"
|
@set-general-turns="reserveGeneralTurns"
|
||||||
@shift-general-turns="shiftGeneralTurns"
|
@shift-general-turns="shiftGeneralTurns"
|
||||||
@repeat-general-turns="repeatGeneralTurns"
|
@repeat-general-turns="repeatGeneralTurns"
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
commandArgumentPresentation,
|
||||||
|
presentedCommandKeys,
|
||||||
|
} from '../src/components/command/commandArgumentPresentation.ts';
|
||||||
|
|
||||||
|
const cityCommands = [
|
||||||
|
'che_강행',
|
||||||
|
'che_이동',
|
||||||
|
'che_출병',
|
||||||
|
'che_첩보',
|
||||||
|
'che_화계',
|
||||||
|
'che_탈취',
|
||||||
|
'che_파괴',
|
||||||
|
'che_선동',
|
||||||
|
'che_수몰',
|
||||||
|
'che_백성동원',
|
||||||
|
'che_천도',
|
||||||
|
'che_허보',
|
||||||
|
'che_초토화',
|
||||||
|
'cr_인구이동',
|
||||||
|
'che_발령',
|
||||||
|
];
|
||||||
|
|
||||||
|
const nationCommands = [
|
||||||
|
'che_선전포고',
|
||||||
|
'che_급습',
|
||||||
|
'che_불가침파기제의',
|
||||||
|
'che_이호경식',
|
||||||
|
'che_종전제의',
|
||||||
|
'che_불가침제의',
|
||||||
|
'che_피장파장',
|
||||||
|
'che_물자원조',
|
||||||
|
];
|
||||||
|
|
||||||
|
const otherArgumentCommands = [
|
||||||
|
'che_증여',
|
||||||
|
'che_헌납',
|
||||||
|
'che_군량매매',
|
||||||
|
'che_몰수',
|
||||||
|
'che_포상',
|
||||||
|
'che_부대탈퇴지시',
|
||||||
|
'che_등용',
|
||||||
|
'che_선양',
|
||||||
|
'che_임관',
|
||||||
|
'che_장수대상임관',
|
||||||
|
'che_숙련전환',
|
||||||
|
'che_장비매매',
|
||||||
|
'che_건국',
|
||||||
|
'che_무작위건국',
|
||||||
|
'cr_건국',
|
||||||
|
'che_국기변경',
|
||||||
|
'che_국호변경',
|
||||||
|
'che_등용수락',
|
||||||
|
'che_NPC능동',
|
||||||
|
];
|
||||||
|
|
||||||
|
void test('provides Ref-level guidance for every in-scope argument command', () => {
|
||||||
|
const expected = [...cityCommands, ...nationCommands, ...otherArgumentCommands].sort();
|
||||||
|
assert.deepEqual(presentedCommandKeys().sort(), expected);
|
||||||
|
for (const commandKey of expected) {
|
||||||
|
assert.ok(commandArgumentPresentation(commandKey).lines.join(' ').length >= 12, commandKey);
|
||||||
|
}
|
||||||
|
assert.ok(!presentedCommandKeys().includes('che_징병'));
|
||||||
|
assert.ok(!presentedCommandKeys().includes('che_모병'));
|
||||||
|
assert.deepEqual(commandArgumentPresentation('che_징병'), { lines: [] });
|
||||||
|
assert.deepEqual(commandArgumentPresentation('che_모병'), { lines: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('marks the same city and nation target families that Ref renders with a map', () => {
|
||||||
|
for (const commandKey of cityCommands) {
|
||||||
|
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'city', commandKey);
|
||||||
|
}
|
||||||
|
for (const commandKey of nationCommands) {
|
||||||
|
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'nation', commandKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user