diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index b315126b..d371248c 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -415,6 +415,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number item: general.itemCode, }, }); + const dexterityByArmType: Record = {}; const inputOptions: TurnCommandInputOptions = { cities: cities.map((entry) => ({ value: entry.id, @@ -432,6 +433,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number .map((entry) => ({ value: entry.id, label: entry.name })), armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => { const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`); + if (dexterity !== null) dexterityByArmType[value] = dexterity; return { value: Number(value), label, @@ -468,6 +470,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number actorRice: general.rice, ...(city ? { citySecurity: city.security } : {}), ...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}), + dexterity: dexterityByArmType, }, }; diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 7a536bed..723e5e68 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -130,6 +130,8 @@ export interface TurnCommandInputOptions { nationGold?: number; nationRice?: number; nationLevel?: number; + /** 숙련전환 전/후 미리보기용 본인 병과별 숙련(`dex`). */ + dexterity?: Record; }; } @@ -164,6 +166,8 @@ export const buildEquipmentTradeItemOptions = (options: { { value: 'None', label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`, + // Ref che_장비매매.vue: 소유하지 않은 종류의 판매는 붉은색 `(불가)`로 표시한다. + ...(ownedItem ? {} : { availableNow: false }), description: ownedItem ? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금` : ownedCode && ownedCode !== 'None' @@ -190,6 +194,7 @@ export const buildEquipmentTradeItemOptions = (options: { items[item.slot].push({ value: item.key, label: item.name, + availableNow: options.currentSecurity >= item.reqSecu && options.generalGold >= cost, description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`, }); } @@ -242,8 +247,9 @@ const FIELD_LABELS: Record = { nationName: '국가명', nationType: '국가 성향', colorType: '국기 색상', - srcArmType: '기존 병과', - destArmType: '변경 병과', + // 숙련전환 전용. Ref che_숙련전환.vue의 '감소 대상 숙련 :'·'전환 대상 숙련 :'. + srcArmType: '감소 대상 숙련', + destArmType: '전환 대상 숙련', itemType: '장비 종류', itemCode: '장비', crewType: '병종', diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index 92ac1c19..48dd9b83 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -71,6 +71,11 @@ describe('turn command argument input', () => { ], }, ]); + // Ref che_숙련전환.vue: '감소 대상 숙련 :', '전환 대상 숙련 :'. + expect(fields.find((entry) => entry.key === 'che_숙련전환')?.fields).toMatchObject([ + { key: 'srcArmType', label: '감소 대상 숙련', kind: 'select', optionSource: 'armTypes' }, + { key: 'destArmType', label: '전환 대상 숙련', kind: 'select', optionSource: 'armTypes' }, + ]); expect( nationFields @@ -339,6 +344,9 @@ describe('turn command argument input', () => { 'che_계략_향낭', ]); expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명'); + expect(items.item[1]?.availableNow).toBe(true); + // Ref che_장비매매.vue: 소유하지 않은 종류의 판매는 불가로 표시한다. + expect(items.item[0]?.availableNow).toBe(false); }); it('shows only zero-count buyable items selected by an explicit scenario pool', () => { @@ -359,5 +367,6 @@ describe('turn command argument input', () => { expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']); expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요'); + expect(items.item[1]?.availableNow).toBe(false); }); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index bc952b04..1c5bd571 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -4595,4 +4595,268 @@ for (const width of [1200, 390]) { await writeFile(testInfo.outputPath(`aid-levels-${width}.json`), JSON.stringify(geometry, null, 2)); await picker.screenshot({ path: testInfo.outputPath(`aid-levels-${width}.png`) }); }); + + test(`shows Ref equipment, dex conversion and nation type details at ${width}px`, async ({ page }, testInfo) => { + const requests = await install(page, false, { + ...commandTable, + general: [ + { + category: '개인', + values: [ + { + key: 'che_장비매매', + name: '장비매매', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'itemType', + label: '장비 종류', + kind: 'select', + required: true, + options: [ + { value: 'horse', label: '명마' }, + { value: 'weapon', label: '무기' }, + ], + }, + { key: 'itemCode', label: '장비', kind: 'select', required: true, optionSource: 'items' }, + ], + }, + { + key: 'che_숙련전환', + name: '숙련전환', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'srcArmType', + label: '감소 대상 숙련', + kind: 'select', + required: true, + optionSource: 'armTypes', + }, + { + key: 'destArmType', + label: '전환 대상 숙련', + kind: 'select', + required: true, + optionSource: 'armTypes', + }, + ], + }, + { + key: 'che_건국', + name: '건국', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { key: 'nationName', label: '국가명', kind: 'text', required: true, min: 1, max: 18 }, + { + key: 'nationType', + label: '국가 성향', + kind: 'select', + required: true, + optionSource: 'nationTypes', + }, + { key: 'colorType', label: '국기 색상', kind: 'select', required: true, optionSource: 'colors' }, + ], + }, + ], + }, + ], + inputOptions: { + ...inputOptions, + armTypes: [ + { value: 1, label: '보병' }, + { value: 2, label: '궁병' }, + { value: 3, label: '기병' }, + ], + nationTypes: [ + { value: 'che_도적', label: '도적', description: '계략↑ 금수입↓ 치안↓ 민심↓' }, + { value: 'che_덕가', label: '덕가', description: '치안↑ 인구↑ 민심↑ 쌀수입↓ 수성↓' }, + { value: 'che_묵가', label: '묵가', description: '수성↑ 기술↓' }, + ], + items: { + horse: [ + { value: 'None', label: '노기 판매', description: '소유 물품 판매 · 판매가 500금' }, + { + value: 'che_명마_갈색마', + label: '갈색마', + availableNow: true, + description: '현재 구입 가능 · 가격 1,000 · 통솔 +1', + }, + { + value: 'che_명마_적토마', + label: '적토마', + availableNow: false, + description: '현재 구입 불가: 치안 5,000 필요 · 가격 6,000 · 통솔 +6', + }, + ], + weapon: [ + { + value: 'None', + label: '무기 판매', + availableNow: false, + description: '현재 보유한 장비가 없습니다.', + }, + ], + }, + context: { ...inputOptions.context, citySecurity: 2_000, dexterity: { '1': 1001, '2': 3000, '3': 500_000 } }, + }, + }); + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); + await page.goto(gamePath('/')); + const picker = page.getByTestId('command-picker'); + const form = picker.getByTestId('command-argument-form'); + + // 장비매매: Ref처럼 불가 항목은 붉은색 `(불가)`이고 치안·자금을 함께 보인다. + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + await picker.getByRole('button', { name: /장비매매/ }).click(); + await expect(form.getByTestId('command-argument-guidance')).toContainText( + '현재 구입 불가능한 것은 붉은색으로 표시됩니다.' + ); + await expect(form.getByTestId('command-resource-summary')).toContainText('현재 도시 치안 2,000'); + await expect(form.getByTestId('command-resource-summary')).toContainText('현재 자금'); + const itemType = form.locator('#command-arg-itemType'); + const itemCode = form.locator('#command-arg-itemCode'); + await itemType.selectOption('horse'); + await expect(itemCode.locator('option')).toHaveText(['노기 판매', '갈색마', '적토마 (불가)']); + await expect(itemCode.locator('option').nth(2)).toHaveCSS('color', 'rgb(255, 0, 0)'); + await expect(itemCode.locator('option').nth(1)).not.toHaveCSS('color', 'rgb(255, 0, 0)'); + await itemCode.selectOption('che_명마_적토마'); + await expect(itemCode).toHaveCSS('color', 'rgb(255, 0, 0)'); + await expect(form.locator('.option-detail')).toContainText('치안 5,000 필요'); + await itemType.selectOption('weapon'); + await expect(itemCode.locator('option')).toHaveText(['무기 판매 (불가)']); + await itemType.selectOption('horse'); + await itemCode.selectOption('che_명마_갈색마'); + await picker.screenshot({ path: testInfo.outputPath(`equipment-${width}.png`) }); + await picker.getByRole('button', { name: '장비매매 입력', exact: true }).click(); + await expect + .poll(() => JSON.stringify(requests)) + .toContain('"action":"che_장비매매","args":{"itemType":"horse","itemCode":"che_명마_갈색마"}'); + + // 숙련전환: Ref 라벨·`병과 (등급)`과 감소/전환 대상 전후 두 줄. + await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); + await picker.getByRole('button', { name: /숙련전환/ }).click(); + const src = form.getByLabel('감소 대상 숙련'); + const dest = form.getByLabel('전환 대상 숙련'); + await expect(src.locator('option')).toHaveText(['보병 (F)', '궁병 (F+)', '기병 (S-)']); + await expect(form.getByTestId('dex-conversion-same')).toBeVisible(); + await src.selectOption('3'); + await dest.selectOption('2'); + const preview = form.getByTestId('dex-conversion-preview'); + const previewRows = preview.locator('.dex-conversion-row'); + // cut = trunc(500,000 × 0.4) = 200,000, add = trunc(200,000 × 0.9) = 180,000. + await expect(previewRows.nth(0).locator('> div')).toHaveText([ + '기병', + '[', + 'S-', + '500,000', + ']', + '→', + '[', + 'A-', + '300,000', + ']', + ]); + await expect(previewRows.nth(1).locator('> div')).toHaveText([ + '궁병', + '[', + 'F+', + '3,000', + ']', + '→', + '[', + 'B', + '183,000', + ']', + ]); + await expect(previewRows.nth(0).locator('> div').nth(2)).toHaveCSS('color', 'rgb(255, 99, 71)'); + await expect(previewRows.nth(0).locator('> div').nth(7)).toHaveCSS('color', 'rgb(255, 140, 0)'); + await expect(previewRows.nth(1).locator('> div').nth(2)).toHaveCSS('color', 'rgb(0, 0, 128)'); + await expect(previewRows.nth(1).locator('> div').nth(7)).toHaveCSS('color', 'rgb(50, 205, 50)'); + const dexGeometry = await picker.evaluate((element) => ({ + url: location.href, + viewport: [innerWidth, innerHeight], + overlayOverflow: element.scrollWidth - element.clientWidth, + rows: [...element.querySelectorAll('.dex-conversion-row')].map((row) => ({ + columns: getComputedStyle(row).gridTemplateColumns, + cells: [...row.children].map((cell) => { + const rect = cell.getBoundingClientRect(); + return { text: cell.textContent?.trim(), left: rect.left, right: rect.right, scroll: cell.scrollWidth }; + }), + })), + })); + expect(dexGeometry.overlayOverflow).toBe(0); + for (const row of dexGeometry.rows) { + // 병과명 4em(56px) 칸 안에 이름이 들어가고 칸끼리 겹치지 않는다. + expect(row.columns.split(' ')[0]).toBe('56px'); + for (let index = 1; index < row.cells.length; index += 1) { + expect(row.cells[index]!.left).toBeGreaterThanOrEqual(row.cells[index - 1]!.right - 0.5); + } + } + await writeFile(testInfo.outputPath(`dex-conversion-${width}.json`), JSON.stringify(dexGeometry, null, 2)); + await picker.screenshot({ path: testInfo.outputPath(`dex-conversion-${width}.png`) }); + await picker.getByRole('button', { name: '숙련전환 입력', exact: true }).click(); + await expect + .poll(() => JSON.stringify(requests)) + .toContain('"action":"che_숙련전환","args":{"srcArmType":3,"destArmType":2}'); + + // 건국: Ref 성향표(`- 이름 : 장점, 단점`)를 입력 위에 보이고, 행을 누르면 성향을 고른다(Core 보조). + await page.getByRole('button', { name: '3턴 명령 입력', exact: true }).click(); + await picker.getByRole('button', { name: /^건국/ }).click(); + const typeList = form.getByTestId('nation-type-list'); + const typeRows = typeList.locator('li'); + await expect(typeRows).toHaveText([ + /- 도적\s*: 계략↑,\s*금수입↓ 치안↓ 민심↓/, + /- 덕가\s*: 치안↑ 인구↑ 민심↑,\s*쌀수입↓ 수성↓/, + /- 묵가\s*: 수성↑,\s*기술↓/, + ]); + await expect(typeRows.first().locator('.nation-type-pros')).toHaveCSS('color', 'rgb(0, 255, 255)'); + await expect(typeRows.first().locator('.nation-type-cons')).toHaveCSS('color', 'rgb(255, 0, 255)'); + await expect(typeRows.first()).toHaveClass(/selected/); + await typeRows.nth(1).locator('.nation-type-name').click(); + await expect(form.getByLabel('국가 성향')).toHaveValue('che_덕가'); + await expect(typeRows.nth(1).locator('.nation-type-name')).toHaveAttribute('aria-pressed', 'true'); + const typeGeometry = await picker.evaluate((element) => { + const list = element.querySelector('[data-testid=nation-type-list]')!; + const row = list.querySelector('li')!; + return { + url: location.href, + viewport: [innerWidth, innerHeight], + overlayOverflow: element.scrollWidth - element.clientWidth, + list: list.getBoundingClientRect().toJSON(), + rowWidth: row.getBoundingClientRect().width, + rowHeights: [...list.querySelectorAll('li')].map((node) => node.getBoundingClientRect().height), + lineHeight: Number.parseFloat(getComputedStyle(list).lineHeight), + columns: getComputedStyle(row).gridTemplateColumns.split(' ').map(Number.parseFloat), + listBeforeFields: + list.compareDocumentPosition(element.querySelector('#command-arg-nationType')!) & + Node.DOCUMENT_POSITION_FOLLOWING, + }; + }); + expect(typeGeometry.overlayOverflow).toBe(0); + expect(typeGeometry.listBeforeFields).toBeTruthy(); + if (width >= 992) { + // Ref bootstrap: lg(992px) 이상 col-lg-1/2/2. + const [first, second, third] = typeGeometry.columns; + const unit = typeGeometry.rowWidth / 12; + expect(first).toBeCloseTo(unit, 0); + expect(second).toBeCloseTo(unit * 2, 0); + expect(third).toBeCloseTo(unit * 2, 0); + } + // 390px은 Ref 최소 폭보다 좁아 내용 폭 열을 쓴다. 어느 폭에서도 한 성향이 한 줄이다. + for (const height of typeGeometry.rowHeights) { + expect(height).toBeLessThan(typeGeometry.lineHeight * 1.5); + } + await writeFile(testInfo.outputPath(`nation-types-${width}.json`), JSON.stringify(typeGeometry, null, 2)); + await picker.screenshot({ path: testInfo.outputPath(`nation-types-${width}.png`) }); + await form.getByLabel('국가명').fill('신국'); + await picker.getByRole('button', { name: '건국 입력', exact: true }).click(); + await expect.poll(() => JSON.stringify(requests)).toContain('"nationType":"che_덕가"'); + }); } diff --git a/app/game-frontend/src/components/command/commandArgumentPresentation.ts b/app/game-frontend/src/components/command/commandArgumentPresentation.ts index 4ed3ee06..e50e23b4 100644 --- a/app/game-frontend/src/components/command/commandArgumentPresentation.ts +++ b/app/game-frontend/src/components/command/commandArgumentPresentation.ts @@ -78,7 +78,7 @@ const PRESENTATIONS: Record = { che_숙련전환: { lines: ['선택한 병과 숙련을 40% 줄이고, 줄어든 숙련의 90%를 다른 병과 숙련으로 전환합니다.'], }, - che_장비매매: { lines: ['장비를 구입하거나 매각합니다.', '가격과 요구 치안, 장비 효과를 확인한 뒤 선택하세요.'] }, + che_장비매매: { lines: ['장비를 구입하거나 매각합니다.', '현재 구입 불가능한 것은 붉은색으로 표시됩니다.'] }, che_건국: { lines: ['현재 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'], }, diff --git a/app/game-frontend/src/components/command/commandPersonalDetails.ts b/app/game-frontend/src/components/command/commandPersonalDetails.ts new file mode 100644 index 00000000..518feaf7 --- /dev/null +++ b/app/game-frontend/src/components/command/commandPersonalDetails.ts @@ -0,0 +1,64 @@ +import { dexProgress } from '../../utils/legacyProgress.ts'; +import type { CommandOption } from './types'; + +/** Ref che_장비매매.vue: 현재 구입(판매) 불가능한 항목은 붉은색 `(불가)`로 표시한다. */ +export const equipmentOptionText = (option: CommandOption): string => + option.availableNow === false ? `${option.label} (불가)` : option.label; + +export type DexAmountInfo = { amount: number; name: string; color: string }; +export type DexConversionRow = { armName: string; before: DexAmountInfo; after: DexAmountInfo }; + +/** Core che_숙련전환 실행 계수. Ref che_숙련전환 $decreaseCoeff·$convertCoeff와 같다. */ +export const DEX_DECREASE_COEFF = 0.4; +export const DEX_CONVERT_COEFF = 0.9; + +const dexAmountInfo = (amount: number): DexAmountInfo => { + const progress = dexProgress(amount); + return { amount, name: progress.name, color: progress.color }; +}; + +export const dexGradeName = (amount: number | undefined): string | undefined => + amount === undefined ? undefined : dexProgress(amount).name; + +/** + * Ref che_숙련전환.vue의 감소 대상·전환 대상 전/후 두 줄. + * Ref 화면은 소수 그대로 계산한 뒤 내림해 보이지만, 여기서는 실제 실행과 같은 정수 절삭 + * (`cut = trunc(src × 0.4)`, `add = trunc(cut × 0.9)`) 값을 보인다. + * 같은 병과끼리는 예약할 수 없으므로 미리보기를 만들지 않는다. + */ +export const dexConversionPreview = ( + srcArmType: unknown, + destArmType: unknown, + armTypes: readonly CommandOption[], + dexterity: Readonly> | undefined +): DexConversionRow[] | null => { + if (!dexterity || srcArmType === destArmType) return null; + const src = armTypes.find((entry) => entry.value === srcArmType); + const dest = armTypes.find((entry) => entry.value === destArmType); + if (!src || !dest) return null; + const srcDex = dexterity[String(src.value)] ?? 0; + const destDex = dexterity[String(dest.value)] ?? 0; + const cutDex = Math.trunc(srcDex * DEX_DECREASE_COEFF); + const addDex = Math.trunc(cutDex * DEX_CONVERT_COEFF); + return [ + { armName: src.label, before: dexAmountInfo(srcDex), after: dexAmountInfo(srcDex - cutDex) }, + { armName: dest.label, before: dexAmountInfo(destDex), after: dexAmountInfo(destDex + addDex) }, + ]; +}; + +export type NationTypeRow = { value: CommandOption['value']; name: string; pros: string; cons: string }; + +/** + * Ref che_건국.vue 성향표: `- 이름 : 장점, 단점`. + * Core 국가 성향 info는 Ref `$pros`와 `$cons`를 공백으로 이은 문자열이므로 ↑/↓ 항목으로 다시 나눈다. + */ +export const nationTypeRows = (options: readonly CommandOption[]): NationTypeRow[] => + options.map((option) => { + const tokens = (option.description ?? '').split(/\s+/).filter(Boolean); + return { + value: option.value, + name: option.label, + pros: tokens.filter((token) => token.endsWith('↑')).join(' '), + cons: tokens.filter((token) => !token.endsWith('↑')).join(' '), + }; + }); diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index 9215024f..8494fb35 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -61,6 +61,8 @@ export type CommandInputContext = { nationGold?: number; nationRice?: number; nationLevel?: number; + /** 숙련전환 전/후 미리보기용 본인 병과별 숙련. */ + dexterity?: Record; }; export type CommandInputField = { diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index 51552ab2..364bdb5e 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -23,6 +23,12 @@ import { scoutMessageRows, type ScoutMessageRow, } from '../command/commandNationDetails'; +import { + dexConversionPreview, + dexGradeName, + equipmentOptionText, + nationTypeRows, +} from '../command/commandPersonalDetails'; import { commandArgumentFieldContract, shouldPreserveCommandArgumentValue, @@ -259,14 +265,41 @@ const colorOptionStyle = (field: CommandInputField, option?: CommandOption): CSS if (isCounterStrategyField(field) && option && strategyOption(option).remainTurn > 0) { return { color: 'red' }; } + if (field.optionSource === 'items' && option?.availableNow === false) { + return { color: 'red' }; + } return undefined; }; const isCounterStrategyField = (field: CommandInputField): boolean => props.commandKey === 'che_피장파장' && field.key === 'commandType'; const strategyOption = (option: CommandOption) => counterStrategyOption(option, props.options.strategyCooldowns); -const optionText = (field: CommandInputField, option: CommandOption): string => - isCounterStrategyField(field) ? strategyOption(option).label : option.label; +const isDexConversionField = (field: CommandInputField): boolean => + props.commandKey === 'che_숙련전환' && field.optionSource === 'armTypes'; +const optionText = (field: CommandInputField, option: CommandOption): string => { + if (isCounterStrategyField(field)) return strategyOption(option).label; + if (field.optionSource === 'items') return equipmentOptionText(option); + if (isDexConversionField(field)) { + // Ref che_숙련전환.vue: `병과 (등급)`. + const grade = dexGradeName(props.options.context?.dexterity?.[String(option.value)]); + return grade ? `${option.label} (${grade})` : option.label; + } + return option.label; +}; +const dexPreview = computed(() => + props.commandKey === 'che_숙련전환' + ? dexConversionPreview( + values.srcArmType, + values.destArmType, + props.options.armTypes, + props.options.context?.dexterity + ) + : null +); +const nationTypeTable = computed(() => + props.fields.some((field) => field.optionSource === 'nationTypes') ? nationTypeRows(props.options.nationTypes) : [] +); +const nationTypeField = computed(() => props.fields.find((field) => field.optionSource === 'nationTypes')); const cityTargetField = computed(() => props.fields.find( @@ -584,6 +617,24 @@ watch( >: {{ row.amount.toLocaleString() }} +
    +
  • + + : {{ row.pros }}, + {{ row.cons }} +
  • +
+
+
+
{{ row.armName }}
+
[
+
{{ row.before.name }}
+
{{ row.before.amount.toLocaleString() }}
+
]
+
+
[
+
{{ row.after.name }}
+
{{ row.after.amount.toLocaleString() }}
+
]
+
+
+
+ 감소 대상과 전환 대상 숙련이 같습니다. 다른 병과를 고르세요. +
{ + assert.equal(equipmentOptionText({ value: 'che_명마_적토', label: '적토마', availableNow: false }), '적토마 (불가)'); + assert.equal(equipmentOptionText({ value: 'che_명마_노기', label: '노기', availableNow: true }), '노기'); + assert.equal(equipmentOptionText({ value: 'None', label: '명마 판매' }), '명마 판매'); +}); + +void test('dex conversion preview uses the executed integer cut and Ref grade names', () => { + const armTypes = [ + { value: 1, label: '보병' }, + { value: 2, label: '궁병' }, + ]; + const rows = dexConversionPreview(1, 2, armTypes, { '1': 1001, '2': 3000 }); + // cut = trunc(1001 × 0.4) = 400, add = trunc(400 × 0.9) = 360. + assert.deepEqual( + rows?.map((row) => [row.armName, row.before.amount, row.before.name, row.after.amount, row.after.name]), + [ + ['보병', 1001, 'F', 601, 'F'], + ['궁병', 3000, 'F+', 3360, 'F+'], + ] + ); + assert.equal(rows?.[1]?.after.color, 'navy'); + assert.equal(dexConversionPreview(1, 1, armTypes, { '1': 1001 }), null); + assert.equal(dexConversionPreview(1, 2, armTypes, undefined), null); + assert.equal(dexGradeName(3500), 'E-'); + assert.equal(dexGradeName(undefined), undefined); +}); + +void test('nation type rows split Core trait info back into Ref pros and cons', () => { + assert.deepEqual( + nationTypeRows([ + { value: 'che_도적', label: '도적', description: '계략↑ 금수입↓ 치안↓ 민심↓' }, + { value: 'che_덕가', label: '덕가', description: '치안↑ 인구↑ 민심↑ 쌀수입↓ 수성↓' }, + ]), + [ + { value: 'che_도적', name: '도적', pros: '계략↑', cons: '금수입↓ 치안↓ 민심↓' }, + { value: 'che_덕가', name: '덕가', pros: '치안↑ 인구↑ 민심↑', cons: '쌀수입↓ 수성↓' }, + ] + ); +});