diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index cb1fa847..3397b6c8 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -89,6 +89,24 @@ const getReservationWorldState = async (ctx: GameApiContext): Promise + value + .replace(//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)[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 ( worldState: WorldStateRow, general: GeneralRow, @@ -168,7 +186,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number }; for (const item of itemModules) { 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 = { @@ -190,17 +220,31 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number crewTypes: (environment.unitSet.crewTypes ?? []) .filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible')) .map((entry) => ({ value: entry.id, label: entry.name })), - armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({ - value: Number(value), - label, + armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => { + const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`); + 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) => ({ value: index, label: `색상 ${index + 1}`, color, })), items, + context: { + actorGold: general.gold, + actorRice: general.rice, + ...(city ? { citySecurity: city.security } : {}), + ...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}), + }, }; return buildTurnCommandTable({ diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 75bc1844..6655409f 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -15,17 +15,11 @@ export interface TurnCommandOption { value: TurnCommandOptionValue; label: string; color?: string; + description?: string; } export type TurnCommandOptionSource = - | 'cities' - | 'nations' - | 'generals' - | 'crewTypes' - | 'armTypes' - | 'nationTypes' - | 'colors' - | 'items'; + 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items'; export interface TurnCommandInputField { key: string; @@ -50,14 +44,50 @@ export interface TurnCommandInputOptions { nationTypes: TurnCommandOption[]; colors: TurnCommandOption[]; items: Record; + context?: { + actorGold: number; + actorRice: number; + citySecurity?: number; + nationGold?: number; + nationRice?: number; + nationLevel?: number; + }; } // 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다. export const TURN_COMMAND_NATION_COLORS = [ - '#FF0000', '#800000', '#A0522D', '#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', + '#FF0000', + '#800000', + '#A0522D', + '#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', ] as const; diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index db860ef3..aeca6dec 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -15,11 +15,11 @@ const operations = (route: Route) => const inputOptions = { cities: [ { value: 1, label: '업 (아국)' }, - { value: 2, label: '허창 (적국)' }, + { value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' }, ], nations: [ { value: 1, label: '아국', color: '#008000' }, - { value: 2, label: '적국', color: '#800000' }, + { value: 2, label: '적국', color: '#800000', description: '수도 허창' }, ], generals: [ { value: 1, label: '장수 (아국 · 업)' }, @@ -30,6 +30,14 @@ const inputOptions = { nationTypes: [{ value: 'che_중립', label: '중립' }], colors: [{ value: 0, label: '색상 1', color: '#ff0000' }], items: { horse: [{ value: 'None', label: '판매/해제' }] }, + context: { + actorGold: 1000, + actorRice: 1000, + citySecurity: 500, + nationGold: 5000, + nationRice: 6000, + nationLevel: 1, + }, }; const commandTable = { 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, }; @@ -143,12 +189,25 @@ const install = async (page: Page, rejectGeneral = false) => { const names = operations(route); const body = route.request().postDataJSON(); 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 === 'world.getMapLayout') return response({ mapName: 'che', - cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [] }], - regionMap: { 1: '하북' }, + cityList: [ + { 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: '특' }, }); if (name === 'auth.status') return response({ ok: true }); @@ -171,8 +230,14 @@ const install = async (page: Page, rejectGeneral = false) => { startYear: 180, year: 200, month: 1, - cityList: [[1, 8, 0, 1, 1, 1]], - nationList: [[1, '아국', '#008000', 1]], + cityList: [ + [1, 8, 0, 1, 1, 1], + [2, 7, 40, 2, 2, 1], + ], + nationList: [ + [1, '아국', '#008000', 1], + [2, '적국', '#800000', 2], + ], spyList: {}, shownByGeneralList: [], 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 }) => { const requests = await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('/'); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click(); const form = page.getByTestId('command-argument-form'); 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('.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 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('"destGeneralId":2'); + expect(mapGeometry.width).toBeGreaterThan(650); + expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2); + expect(geometry.width).toBeGreaterThan(200); expect(geometry.rowHeight).toBeGreaterThanOrEqual(34); expect(geometry.borderStyle).toBe('solid'); 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('[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 }) => { await install(page, true); 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 editor.locator('details.range-menu > summary').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.getByRole('button', { name: '붙여넣기', exact: true }).click(); await expect(editor.locator('.action-column > div').nth(5)).toHaveText('화계'); diff --git a/app/game-frontend/src/components/chief/ChiefCommandEditor.vue b/app/game-frontend/src/components/chief/ChiefCommandEditor.vue index 5beb9fd1..dcb8b051 100644 --- a/app/game-frontend/src/components/chief/ChiefCommandEditor.vue +++ b/app/game-frontend/src/components/chief/ChiefCommandEditor.vue @@ -1,7 +1,13 @@