diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 64b9af37..eacd4db2 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -175,6 +175,7 @@ const inputOptions = { { value: 3, label: '여포NPC (업)', + npcState: 2, gold: 3000, rice: 500, crew: 1500, @@ -199,6 +200,7 @@ const inputOptions = { { value: 3, label: '여포NPC (청룡대 · 업)', + npcState: 2, crew: 1500, troopId: 2, description: '금 3,000 · 쌀 500 · 병력 1,500', @@ -208,6 +210,7 @@ const inputOptions = { { value: 3, label: '여포NPC (아국 · 업)', + npcState: 2, availableNow: true, crew: 1500, troopId: 2, @@ -2513,10 +2516,10 @@ test('offers Ref amount presets and rich, command-specific general lists', async let generalList = form.getByTestId('general-target-list'); await expect(form.locator('#command-arg-destGeneralId option')).toHaveText([ '관우 (업)', - '여포NPC (업)', '장수 (업)', + '여포NPC (업)', ]); - await expect(generalList.locator('.target-option strong')).toHaveText(['관우 (업)', '여포NPC (업)', '장수 (업)']); + await expect(generalList.locator('.target-option strong')).toHaveText(['관우 (업)', '장수 (업)', '여포NPC (업)']); await expect(generalList.locator('.target-option').filter({ hasText: '여포NPC' }).locator('strong')).toHaveCSS( 'color', 'rgb(0, 255, 255)' @@ -2525,7 +2528,7 @@ test('offers Ref amount presets and rich, command-specific general lists', async await expect(generalList).not.toContainText('아국'); await expect(generalList).not.toContainText('탑승 부대'); await form.getByRole('button', { name: '쌀', exact: true }).click(); - await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '여포NPC (업)', '관우 (업)']); + await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']); await generalList.locator('.target-option').filter({ hasText: '여포NPC' }).click(); const awardResponse = page.waitForResponse((response) => response.url().includes('turns.reserved.setNationBulk')); await picker.getByRole('button', { name: '입력', exact: true }).click(); @@ -2540,10 +2543,10 @@ test('offers Ref amount presets and rich, command-specific general lists', async generalList = form.getByTestId('general-target-list'); await expect(form.locator('#command-arg-destGeneralId option')).toHaveText([ '장수 (업)', - '여포NPC (업)', '관우 (업)', + '여포NPC (업)', ]); - await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '여포NPC (업)', '관우 (업)']); + await expect(generalList.locator('.target-option strong')).toHaveText(['장수 (업)', '관우 (업)', '여포NPC (업)']); await expect(generalList).not.toContainText('아국'); await expect(generalList).not.toContainText('탑승 부대'); await page.goto('/che/chief-center'); @@ -2574,9 +2577,13 @@ test('offers Ref amount presets and rich, command-specific general lists', async await picker.getByRole('button', { name: /부대 탈퇴 지시/ }).click(); form = picker.getByTestId('command-argument-form'); generalList = form.getByTestId('general-target-list'); - await expect(generalList.locator('.target-option strong').first()).toHaveText('여포NPC (아국 · 업)'); - await expect(generalList.locator('.target-option').first().locator('.target-state')).toHaveText('우선 대상'); - await expect(generalList.locator('.target-option').nth(1).locator('.target-state')).toHaveText('현재 불가'); + await expect(generalList.locator('.target-option strong')).toHaveText([ + '장수 (아국 · 업)', + '관우 (아국 · 업)', + '여포NPC (아국 · 업)', + ]); + await expect(generalList.locator('.target-option').first().locator('.target-state')).toHaveText('현재 불가'); + await expect(generalList.locator('.target-option').nth(2).locator('.target-state')).toHaveText('우선 대상'); await page.goto('/che/chief-center'); await page.getByRole('button', { name: '5턴 명령 입력', exact: true }).click(); @@ -2603,8 +2610,8 @@ test('offers Ref amount presets and rich, command-specific general lists', async await page.setViewportSize({ width: 500, height: 900 }); const mobilePersonnelCases = [ - { turn: 5, command: '포상', labels: ['관우 (업)', '여포NPC (업)', '장수 (업)'] }, - { turn: 6, command: '몰수', labels: ['장수 (업)', '여포NPC (업)', '관우 (업)'] }, + { turn: 5, command: '포상', labels: ['관우 (업)', '장수 (업)', '여포NPC (업)'] }, + { turn: 6, command: '몰수', labels: ['장수 (업)', '관우 (업)', '여포NPC (업)'] }, { turn: 7, command: '발령', diff --git a/app/game-frontend/src/components/command/commandGeneralOptions.ts b/app/game-frontend/src/components/command/commandGeneralOptions.ts new file mode 100644 index 00000000..5b4b32ab --- /dev/null +++ b/app/game-frontend/src/components/command/commandGeneralOptions.ts @@ -0,0 +1,33 @@ +import type { CommandOption } from './types'; + +const npcStateOf = (option: CommandOption): number => option.npcState ?? 0; + +/** 장수 선택지는 유저장부터 NPC 종류순으로 묶고, 명령별 우선순위는 같은 종류 안에서만 적용한다. */ +export const sortCommandGeneralOptions = ( + commandKey: string, + options: readonly CommandOption[], + isGold: boolean +): CommandOption[] => { + const resourceKey = isGold ? 'gold' : 'rice'; + return options + .map((option, index) => ({ option, index })) + .sort((left, right) => { + const typeOrder = npcStateOf(left.option) - npcStateOf(right.option); + if (typeOrder !== 0) return typeOrder; + + if (commandKey === 'che_포상') { + const resourceOrder = (left.option[resourceKey] ?? 0) - (right.option[resourceKey] ?? 0); + if (resourceOrder !== 0) return resourceOrder; + } + if (commandKey === 'che_몰수') { + const resourceOrder = (right.option[resourceKey] ?? 0) - (left.option[resourceKey] ?? 0); + if (resourceOrder !== 0) return resourceOrder; + } + if (commandKey === 'che_부대탈퇴지시') { + const availabilityOrder = Number(right.option.availableNow) - Number(left.option.availableNow); + if (availabilityOrder !== 0) return availabilityOrder; + } + return left.index - right.index; + }) + .map(({ option }) => option); +}; diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index 87992a66..17c9bb34 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -4,6 +4,7 @@ import MapViewer from './MapViewer.vue'; import NationColorSelect from './NationColorSelect.vue'; import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation'; import { commandCityOptions } from '../command/commandArgumentOptions'; +import { sortCommandGeneralOptions } from '../command/commandGeneralOptions'; import { commandArgumentFieldContract, shouldPreserveCommandArgumentValue, @@ -44,18 +45,7 @@ const visibleFields = computed(() => props.fields.filter((entry) => entry.kind ! const amountPreset = computed(() => props.options.amountPresets?.[props.commandKey]); const sortGeneralOptions = (options: CommandOption[]): CommandOption[] => { - const result = [...options]; - const resourceKey = values.isGold === false ? 'rice' : 'gold'; - if (props.commandKey === 'che_포상') { - return result.sort((left, right) => (left[resourceKey] ?? 0) - (right[resourceKey] ?? 0)); - } - if (props.commandKey === 'che_몰수') { - return result.sort((left, right) => (right[resourceKey] ?? 0) - (left[resourceKey] ?? 0)); - } - if (props.commandKey === 'che_부대탈퇴지시') { - return result.sort((left, right) => Number(right.availableNow) - Number(left.availableNow)); - } - return result; + return sortCommandGeneralOptions(props.commandKey, options, values.isGold !== false); }; const optionsFor = (field: CommandInputField): CommandOption[] => { diff --git a/app/game-frontend/test/commandGeneralOptions.test.ts b/app/game-frontend/test/commandGeneralOptions.test.ts new file mode 100644 index 00000000..97de83a8 --- /dev/null +++ b/app/game-frontend/test/commandGeneralOptions.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { sortCommandGeneralOptions } from '../src/components/command/commandGeneralOptions.ts'; +import type { CommandOption } from '../src/components/command/types.ts'; + +const option = (value: number, npcState: number | undefined, fields: Partial = {}): CommandOption => ({ + value, + label: String(value), + npcState, + ...fields, +}); + +void test('puts user generals before possessed and NPC types for general target commands', () => { + const options = [option(4, 3), option(3, 2), option(2, 1), option(1, 0), option(5, undefined)]; + + assert.deepEqual( + sortCommandGeneralOptions('che_발령', options, true).map(({ value }) => value), + [1, 5, 2, 3, 4] + ); + assert.deepEqual( + options.map(({ value }) => value), + [4, 3, 2, 1, 5] + ); +}); + +void test('keeps reward and confiscation resource order inside each general type', () => { + const options = [ + option(3, 2, { gold: 300, rice: 500 }), + option(1, 0, { gold: 500, rice: 100 }), + option(4, 2, { gold: 100, rice: 900 }), + option(2, 0, { gold: 200, rice: 400 }), + ]; + + assert.deepEqual( + sortCommandGeneralOptions('che_포상', options, true).map(({ value }) => value), + [2, 1, 4, 3] + ); + assert.deepEqual( + sortCommandGeneralOptions('che_포상', options, false).map(({ value }) => value), + [1, 2, 3, 4] + ); + assert.deepEqual( + sortCommandGeneralOptions('che_몰수', options, true).map(({ value }) => value), + [1, 2, 3, 4] + ); +}); + +void test('keeps troop-exit availability order inside each general type', () => { + const options = [ + option(3, 2, { availableNow: true }), + option(1, 0, { availableNow: false }), + option(4, 2, { availableNow: false }), + option(2, 0, { availableNow: true }), + ]; + + assert.deepEqual( + sortCommandGeneralOptions('che_부대탈퇴지시', options, true).map(({ value }) => value), + [2, 1, 3, 4] + ); +});