장비매매 불가 표시·숙련전환 전후 미리보기·건국 성향표를 Ref처럼 복원
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -415,6 +415,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
item: general.itemCode,
|
item: general.itemCode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const dexterityByArmType: Record<string, number> = {};
|
||||||
const inputOptions: TurnCommandInputOptions = {
|
const inputOptions: TurnCommandInputOptions = {
|
||||||
cities: cities.map((entry) => ({
|
cities: cities.map((entry) => ({
|
||||||
value: entry.id,
|
value: entry.id,
|
||||||
@@ -432,6 +433,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
.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]) => {
|
||||||
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
|
const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`);
|
||||||
|
if (dexterity !== null) dexterityByArmType[value] = dexterity;
|
||||||
return {
|
return {
|
||||||
value: Number(value),
|
value: Number(value),
|
||||||
label,
|
label,
|
||||||
@@ -468,6 +470,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
actorRice: general.rice,
|
actorRice: general.rice,
|
||||||
...(city ? { citySecurity: city.security } : {}),
|
...(city ? { citySecurity: city.security } : {}),
|
||||||
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
|
...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}),
|
||||||
|
dexterity: dexterityByArmType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export interface TurnCommandInputOptions {
|
|||||||
nationGold?: number;
|
nationGold?: number;
|
||||||
nationRice?: number;
|
nationRice?: number;
|
||||||
nationLevel?: number;
|
nationLevel?: number;
|
||||||
|
/** 숙련전환 전/후 미리보기용 본인 병과별 숙련(`dex<armType>`). */
|
||||||
|
dexterity?: Record<string, number>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +166,8 @@ export const buildEquipmentTradeItemOptions = (options: {
|
|||||||
{
|
{
|
||||||
value: 'None',
|
value: 'None',
|
||||||
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
|
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
|
||||||
|
// Ref che_장비매매.vue: 소유하지 않은 종류의 판매는 붉은색 `(불가)`로 표시한다.
|
||||||
|
...(ownedItem ? {} : { availableNow: false }),
|
||||||
description: ownedItem
|
description: ownedItem
|
||||||
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금`
|
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금`
|
||||||
: ownedCode && ownedCode !== 'None'
|
: ownedCode && ownedCode !== 'None'
|
||||||
@@ -190,6 +194,7 @@ export const buildEquipmentTradeItemOptions = (options: {
|
|||||||
items[item.slot].push({
|
items[item.slot].push({
|
||||||
value: item.key,
|
value: item.key,
|
||||||
label: item.name,
|
label: item.name,
|
||||||
|
availableNow: options.currentSecurity >= item.reqSecu && options.generalGold >= cost,
|
||||||
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
|
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -242,8 +247,9 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
nationName: '국가명',
|
nationName: '국가명',
|
||||||
nationType: '국가 성향',
|
nationType: '국가 성향',
|
||||||
colorType: '국기 색상',
|
colorType: '국기 색상',
|
||||||
srcArmType: '기존 병과',
|
// 숙련전환 전용. Ref che_숙련전환.vue의 '감소 대상 숙련 :'·'전환 대상 숙련 :'.
|
||||||
destArmType: '변경 병과',
|
srcArmType: '감소 대상 숙련',
|
||||||
|
destArmType: '전환 대상 숙련',
|
||||||
itemType: '장비 종류',
|
itemType: '장비 종류',
|
||||||
itemCode: '장비',
|
itemCode: '장비',
|
||||||
crewType: '병종',
|
crewType: '병종',
|
||||||
|
|||||||
@@ -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(
|
expect(
|
||||||
nationFields
|
nationFields
|
||||||
@@ -339,6 +344,9 @@ describe('turn command argument input', () => {
|
|||||||
'che_계략_향낭',
|
'che_계략_향낭',
|
||||||
]);
|
]);
|
||||||
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
|
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', () => {
|
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.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']);
|
||||||
expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요');
|
expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요');
|
||||||
|
expect(items.item[1]?.availableNow).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4595,4 +4595,268 @@ for (const width of [1200, 390]) {
|
|||||||
await writeFile(testInfo.outputPath(`aid-levels-${width}.json`), JSON.stringify(geometry, null, 2));
|
await writeFile(testInfo.outputPath(`aid-levels-${width}.json`), JSON.stringify(geometry, null, 2));
|
||||||
await picker.screenshot({ path: testInfo.outputPath(`aid-levels-${width}.png`) });
|
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_덕가"');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
|
|||||||
che_숙련전환: {
|
che_숙련전환: {
|
||||||
lines: ['선택한 병과 숙련을 40% 줄이고, 줄어든 숙련의 90%를 다른 병과 숙련으로 전환합니다.'],
|
lines: ['선택한 병과 숙련을 40% 줄이고, 줄어든 숙련의 90%를 다른 병과 숙련으로 전환합니다.'],
|
||||||
},
|
},
|
||||||
che_장비매매: { lines: ['장비를 구입하거나 매각합니다.', '가격과 요구 치안, 장비 효과를 확인한 뒤 선택하세요.'] },
|
che_장비매매: { lines: ['장비를 구입하거나 매각합니다.', '현재 구입 불가능한 것은 붉은색으로 표시됩니다.'] },
|
||||||
che_건국: {
|
che_건국: {
|
||||||
lines: ['현재 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
|
lines: ['현재 중·소도시에서 나라를 세웁니다.', '국가 성향별 장단점을 확인한 뒤 국명과 색상을 정하세요.'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<Record<string, number>> | 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(' '),
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -61,6 +61,8 @@ export type CommandInputContext = {
|
|||||||
nationGold?: number;
|
nationGold?: number;
|
||||||
nationRice?: number;
|
nationRice?: number;
|
||||||
nationLevel?: number;
|
nationLevel?: number;
|
||||||
|
/** 숙련전환 전/후 미리보기용 본인 병과별 숙련. */
|
||||||
|
dexterity?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommandInputField = {
|
export type CommandInputField = {
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ import {
|
|||||||
scoutMessageRows,
|
scoutMessageRows,
|
||||||
type ScoutMessageRow,
|
type ScoutMessageRow,
|
||||||
} from '../command/commandNationDetails';
|
} from '../command/commandNationDetails';
|
||||||
|
import {
|
||||||
|
dexConversionPreview,
|
||||||
|
dexGradeName,
|
||||||
|
equipmentOptionText,
|
||||||
|
nationTypeRows,
|
||||||
|
} from '../command/commandPersonalDetails';
|
||||||
import {
|
import {
|
||||||
commandArgumentFieldContract,
|
commandArgumentFieldContract,
|
||||||
shouldPreserveCommandArgumentValue,
|
shouldPreserveCommandArgumentValue,
|
||||||
@@ -259,14 +265,41 @@ const colorOptionStyle = (field: CommandInputField, option?: CommandOption): CSS
|
|||||||
if (isCounterStrategyField(field) && option && strategyOption(option).remainTurn > 0) {
|
if (isCounterStrategyField(field) && option && strategyOption(option).remainTurn > 0) {
|
||||||
return { color: 'red' };
|
return { color: 'red' };
|
||||||
}
|
}
|
||||||
|
if (field.optionSource === 'items' && option?.availableNow === false) {
|
||||||
|
return { color: 'red' };
|
||||||
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCounterStrategyField = (field: CommandInputField): boolean =>
|
const isCounterStrategyField = (field: CommandInputField): boolean =>
|
||||||
props.commandKey === 'che_피장파장' && field.key === 'commandType';
|
props.commandKey === 'che_피장파장' && field.key === 'commandType';
|
||||||
const strategyOption = (option: CommandOption) => counterStrategyOption(option, props.options.strategyCooldowns);
|
const strategyOption = (option: CommandOption) => counterStrategyOption(option, props.options.strategyCooldowns);
|
||||||
const optionText = (field: CommandInputField, option: CommandOption): string =>
|
const isDexConversionField = (field: CommandInputField): boolean =>
|
||||||
isCounterStrategyField(field) ? strategyOption(option).label : option.label;
|
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(() =>
|
const cityTargetField = computed(() =>
|
||||||
props.fields.find(
|
props.fields.find(
|
||||||
@@ -584,6 +617,24 @@ watch(
|
|||||||
>: {{ row.amount.toLocaleString() }}
|
>: {{ row.amount.toLocaleString() }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<ul v-if="nationTypeTable.length" class="nation-type-list" data-testid="nation-type-list">
|
||||||
|
<li
|
||||||
|
v-for="row in nationTypeTable"
|
||||||
|
:key="String(row.value)"
|
||||||
|
:class="{ selected: nationTypeField && row.value === values[nationTypeField.key] }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="nation-type-name"
|
||||||
|
:aria-pressed="Boolean(nationTypeField && row.value === values[nationTypeField.key])"
|
||||||
|
@click="nationTypeField && setSelectValue(nationTypeField, String(row.value))"
|
||||||
|
>
|
||||||
|
- {{ row.name }}
|
||||||
|
</button>
|
||||||
|
<span>: <span class="nation-type-pros">{{ row.pros }}</span>,</span>
|
||||||
|
<span class="nation-type-cons">{{ row.cons }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
<div v-for="field in visibleFields" :key="field.key" class="argument-row">
|
<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
|
||||||
@@ -836,6 +887,27 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="dexPreview" class="dex-conversion-preview" data-testid="dex-conversion-preview">
|
||||||
|
<div v-for="row in dexPreview" :key="row.armName" class="dex-conversion-row">
|
||||||
|
<div>{{ row.armName }}</div>
|
||||||
|
<div class="dex-bracket-open">[</div>
|
||||||
|
<div :style="{ color: row.before.color }">{{ row.before.name }}</div>
|
||||||
|
<div class="dex-amount">{{ row.before.amount.toLocaleString() }}</div>
|
||||||
|
<div>]</div>
|
||||||
|
<div class="dex-arrow">→</div>
|
||||||
|
<div class="dex-bracket-open">[</div>
|
||||||
|
<div :style="{ color: row.after.color }">{{ row.after.name }}</div>
|
||||||
|
<div class="dex-amount">{{ row.after.amount.toLocaleString() }}</div>
|
||||||
|
<div>]</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="commandKey === 'che_숙련전환' && values.srcArmType === values.destArmType"
|
||||||
|
class="dex-conversion-same"
|
||||||
|
data-testid="dex-conversion-same"
|
||||||
|
>
|
||||||
|
감소 대상과 전환 대상 숙련이 같습니다. 다른 병과를 고르세요.
|
||||||
|
</div>
|
||||||
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
<div v-if="!isValid" class="argument-error" role="alert">필수 입력을 확인하세요.</div>
|
||||||
</div>
|
</div>
|
||||||
<section
|
<section
|
||||||
@@ -974,6 +1046,100 @@ small {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ref che_건국.vue 성향표: col-2/col-4/col-4, lg 이상 col-lg-1/col-lg-2/col-lg-2. */
|
||||||
|
.nation-type-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0 6px 2rem;
|
||||||
|
color: #e8ddc4;
|
||||||
|
font-size: var(--sammo-font-size-normal);
|
||||||
|
line-height: 1.45;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-type-list li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: calc(100% / 6) calc(100% / 3) calc(100% / 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.nation-type-list li {
|
||||||
|
grid-template-columns: calc(100% / 12) calc(100% / 6) calc(100% / 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ref 최소 폭 500px보다 좁으면 비율 칸이 장단점을 줄바꿈하므로 내용 폭 열로 바꾼다(Core 보조). */
|
||||||
|
@media (max-width: 499px) {
|
||||||
|
.nation-type-list {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-type-list li {
|
||||||
|
grid-template-columns: max-content max-content minmax(0, 1fr);
|
||||||
|
column-gap: 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-type-list li.selected {
|
||||||
|
background: rgba(201, 164, 90, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-type-name {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-type-list li.selected .nation-type-name {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-type-pros {
|
||||||
|
color: cyan;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nation-type-cons {
|
||||||
|
color: magenta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ref che_숙련전환.vue 전/후 grid(3ch 1ch 2ch 10ch 1ch 3ch 1ch 2ch 10ch 1ch).
|
||||||
|
* 한글 병과명과 EX 등급이 옆 칸을 덮지 않도록 병과명은 4em, 등급은 3ch로 넓힌다.
|
||||||
|
*/
|
||||||
|
.dex-conversion-preview {
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #e8ddc4;
|
||||||
|
font-size: var(--sammo-font-size-normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dex-conversion-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4em 1ch 3ch 10ch 1ch 3ch 1ch 3ch 10ch 1ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dex-bracket-open,
|
||||||
|
.dex-amount {
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dex-amount {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dex-arrow {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dex-conversion-same {
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #ffb4a8;
|
||||||
|
font-size: var(--sammo-font-size-normal);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Ref che_임관.vue nation-list. 940px 이상은 130px 국가명 + 870px 권유문, 그 아래는 한 열이다.
|
* Ref che_임관.vue nation-list. 940px 이상은 130px 국가명 + 870px 권유문, 그 아래는 한 열이다.
|
||||||
* 지도·입력 2열 아래에 전체 폭으로 둔다.
|
* 지도·입력 2열 아래에 전체 폭으로 둔다.
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
dexConversionPreview,
|
||||||
|
dexGradeName,
|
||||||
|
equipmentOptionText,
|
||||||
|
nationTypeRows,
|
||||||
|
} from '../src/components/command/commandPersonalDetails.ts';
|
||||||
|
|
||||||
|
void test('equipment options mark unavailable purchases and sales like Ref (불가)', () => {
|
||||||
|
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: '쌀수입↓ 수성↓' },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user