merge: 최신 main을 재야 광고 발송 복원에 통합
This commit is contained in:
@@ -6,10 +6,7 @@ import {
|
|||||||
} from '@sammo-ts/logic';
|
} from '@sammo-ts/logic';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js';
|
||||||
buildTurnCommandInputFields,
|
|
||||||
parseReservedTurnArgs,
|
|
||||||
} from '../src/turns/commandInput.js';
|
|
||||||
|
|
||||||
describe('turn command argument input', () => {
|
describe('turn command argument input', () => {
|
||||||
it('builds supported fields for every argument-bearing command module', async () => {
|
it('builds supported fields for every argument-bearing command module', async () => {
|
||||||
@@ -22,6 +19,9 @@ describe('turn command argument input', () => {
|
|||||||
key: spec.key,
|
key: spec.key,
|
||||||
fields: buildTurnCommandInputFields(spec),
|
fields: buildTurnCommandInputFields(spec),
|
||||||
}));
|
}));
|
||||||
|
const nationFields = nation
|
||||||
|
.filter((spec) => spec.reqArg)
|
||||||
|
.map((spec) => ({ key: spec.key, fields: buildTurnCommandInputFields(spec) }));
|
||||||
|
|
||||||
expect(argumentSpecs).toHaveLength(44);
|
expect(argumentSpecs).toHaveLength(44);
|
||||||
expect(fields.every((entry) => entry.fields.length > 0)).toBe(true);
|
expect(fields.every((entry) => entry.fields.length > 0)).toBe(true);
|
||||||
@@ -32,6 +32,34 @@ describe('turn command argument input', () => {
|
|||||||
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
{ key: 'destNationId', kind: 'select', optionSource: 'nations' },
|
||||||
{ key: 'amountList', kind: 'numberTuple' },
|
{ key: 'amountList', kind: 'numberTuple' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
nationFields
|
||||||
|
.filter((entry) => entry.key !== 'che_발령')
|
||||||
|
.flatMap((entry) =>
|
||||||
|
entry.fields
|
||||||
|
.filter((field) => field.optionSource === 'cities' || field.optionSource === 'nations')
|
||||||
|
.map((field) => `${entry.key}:${field.optionSource}`)
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
).toEqual(
|
||||||
|
[
|
||||||
|
'che_급습:nations',
|
||||||
|
'che_물자원조:nations',
|
||||||
|
'che_백성동원:cities',
|
||||||
|
'che_불가침제의:nations',
|
||||||
|
'che_불가침파기제의:nations',
|
||||||
|
'che_선전포고:nations',
|
||||||
|
'che_수몰:cities',
|
||||||
|
'che_이호경식:nations',
|
||||||
|
'che_종전제의:nations',
|
||||||
|
'che_천도:cities',
|
||||||
|
'che_초토화:cities',
|
||||||
|
'che_피장파장:nations',
|
||||||
|
'che_허보:cities',
|
||||||
|
'cr_인구이동:cities',
|
||||||
|
].sort()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes valid arguments and rejects malformed or wrong-scope commands', async () => {
|
it('normalizes valid arguments and rejects malformed or wrong-scope commands', async () => {
|
||||||
@@ -50,8 +78,6 @@ describe('turn command argument input', () => {
|
|||||||
amount: 200,
|
amount: 200,
|
||||||
destGeneralId: 7,
|
destGeneralId: 7,
|
||||||
});
|
});
|
||||||
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow(
|
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
|
||||||
'Unknown general turn command'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -522,6 +522,21 @@ const commandTable = {
|
|||||||
buildCityCommand('che_백성동원', '백성동원'),
|
buildCityCommand('che_백성동원', '백성동원'),
|
||||||
buildCityCommand('che_수몰', '수몰'),
|
buildCityCommand('che_수몰', '수몰'),
|
||||||
buildCityCommand('che_허보', '허보'),
|
buildCityCommand('che_허보', '허보'),
|
||||||
|
buildNationCommand('che_이호경식', '이호경식'),
|
||||||
|
buildNationCommand('che_급습', '급습'),
|
||||||
|
{
|
||||||
|
...buildNationCommand('che_피장파장', '피장파장'),
|
||||||
|
inputFields: [
|
||||||
|
...buildNationCommand('che_피장파장', '피장파장').inputFields,
|
||||||
|
{
|
||||||
|
key: 'commandType',
|
||||||
|
label: '대응 명령',
|
||||||
|
kind: 'select',
|
||||||
|
required: true,
|
||||||
|
options: [{ value: 'che_수몰', label: '수몰' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -815,7 +830,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse:
|
|||||||
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
|
{ id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] },
|
||||||
],
|
],
|
||||||
regionMap: { 1: '하북', 2: '예주' },
|
regionMap: { 1: '하북', 2: '예주' },
|
||||||
levelMap: { 8: '특' },
|
levelMap: { 8: '특', 7: '대' },
|
||||||
});
|
});
|
||||||
if (name === 'auth.status') return response({ ok: true });
|
if (name === 'auth.status') return response({ ok: true });
|
||||||
if (name === 'lobby.info')
|
if (name === 'lobby.info')
|
||||||
@@ -953,9 +968,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
|
|||||||
await expect(spy).toBeFocused();
|
await expect(spy).toBeFocused();
|
||||||
await spy.click();
|
await spy.click();
|
||||||
const form = picker.getByTestId('command-argument-form');
|
const form = picker.getByTestId('command-argument-form');
|
||||||
await expect(form.getByTestId('command-argument-guidance')).toContainText(
|
await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 첩보를 실행합니다.');
|
||||||
'선택한 도시에 첩보를 실행합니다.'
|
|
||||||
);
|
|
||||||
await expect(form.getByTestId('command-argument-guidance')).toContainText(
|
await expect(form.getByTestId('command-argument-guidance')).toContainText(
|
||||||
'인접 도시에서는 더 많은 정보를 얻습니다.'
|
'인접 도시에서는 더 많은 정보를 얻습니다.'
|
||||||
);
|
);
|
||||||
@@ -979,9 +992,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile',
|
|||||||
await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') });
|
await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({
|
test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const foundingColors = [
|
const foundingColors = [
|
||||||
{ value: 0, label: '색상 1', color: '#FF0000' },
|
{ value: 0, label: '색상 1', color: '#FF0000' },
|
||||||
{ value: 15, label: '색상 16', color: '#6495ED' },
|
{ value: 15, label: '색상 16', color: '#6495ED' },
|
||||||
@@ -1284,7 +1295,9 @@ test('keeps general and chief command categories after input and across page rel
|
|||||||
const reloadedChiefPicker = chiefPage.getByTestId('command-picker');
|
const reloadedChiefPicker = chiefPage.getByTestId('command-picker');
|
||||||
await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
|
await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/);
|
||||||
await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
|
await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible();
|
||||||
await expect.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(200);
|
await expect
|
||||||
|
.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height))
|
||||||
|
.toBeGreaterThan(200);
|
||||||
await reloadedChiefPicker.screenshot({
|
await reloadedChiefPicker.screenshot({
|
||||||
path: test.info().outputPath('chief-category-after-reload-mobile-500.png'),
|
path: test.info().outputPath('chief-category-after-reload-mobile-500.png'),
|
||||||
});
|
});
|
||||||
@@ -1780,18 +1793,25 @@ test('uses the map to choose a nation target in the chief command window', async
|
|||||||
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows city or capital maps for every requested chief command', async ({ page }) => {
|
test('shows a map and target details for every city or nation argument chief command except assignment', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await install(page);
|
await install(page);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
const cases = [
|
const cases = [
|
||||||
{ category: '인사', action: '발령', mode: 'city' },
|
|
||||||
{ category: '특수', action: '초토화', mode: 'city' },
|
{ category: '특수', action: '초토화', mode: 'city' },
|
||||||
{ category: '특수', action: '천도', mode: 'city' },
|
{ category: '특수', action: '천도', mode: 'city' },
|
||||||
{ category: '특수', action: '증축', mode: 'capital' },
|
|
||||||
{ category: '특수', action: '감축', mode: 'capital' },
|
|
||||||
{ category: '전략', action: '수몰', mode: 'city' },
|
{ category: '전략', action: '수몰', mode: 'city' },
|
||||||
{ category: '전략', action: '허보', mode: 'city' },
|
{ category: '전략', action: '허보', mode: 'city' },
|
||||||
{ category: '전략', action: '백성동원', mode: 'city' },
|
{ category: '전략', action: '백성동원', mode: 'city' },
|
||||||
|
{ category: '외교', action: '원조', mode: 'nation' },
|
||||||
|
{ category: '외교', action: '불가침 제의', mode: 'nation' },
|
||||||
|
{ category: '외교', action: '선전포고', mode: 'nation' },
|
||||||
|
{ category: '외교', action: '종전 제의', mode: 'nation' },
|
||||||
|
{ category: '외교', action: '불가침 파기 제의', mode: 'nation' },
|
||||||
|
{ category: '전략', action: '이호경식', mode: 'nation' },
|
||||||
|
{ category: '전략', action: '급습', mode: 'nation' },
|
||||||
|
{ category: '전략', action: '피장파장', mode: 'nation' },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const entry of cases) {
|
for (const entry of cases) {
|
||||||
@@ -1805,24 +1825,106 @@ test('shows city or capital maps for every requested chief command', async ({ pa
|
|||||||
await expect(map, `${entry.action} 지도`).toBeVisible();
|
await expect(map, `${entry.action} 지도`).toBeVisible();
|
||||||
await expect(form.getByTestId('command-argument-guidance')).toBeVisible();
|
await expect(form.getByTestId('command-argument-guidance')).toBeVisible();
|
||||||
|
|
||||||
if (entry.mode === 'capital') {
|
|
||||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 수도업');
|
|
||||||
await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 수도');
|
|
||||||
await expect(map.locator('.city-base').first()).toHaveJSProperty('tagName', 'DIV');
|
|
||||||
expect(
|
|
||||||
await map
|
|
||||||
.locator('.city-base')
|
|
||||||
.first()
|
|
||||||
.evaluate((node) => getComputedStyle(node).cursor)
|
|
||||||
).toBe('default');
|
|
||||||
} else {
|
|
||||||
await map.locator('.city-base').nth(1).click();
|
await map.locator('.city-base').nth(1).click();
|
||||||
|
if (entry.mode === 'city') {
|
||||||
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
|
||||||
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
||||||
|
await expect(form.getByTestId('command-map-target-summary')).toContainText(
|
||||||
|
'허창 · 적국 · 예주 · 대 · 현재 도시에서 1칸'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||||||
|
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국');
|
||||||
|
await expect(form.getByTestId('command-map-target-summary')).toContainText('적국 · 수도 허창 · 도시 1개');
|
||||||
}
|
}
|
||||||
|
await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업');
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true });
|
await page.screenshot({ path: test.info().outputPath('chief-command-target-map-details.png'), fullPage: true });
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, 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');
|
||||||
|
const map = form.getByTestId('command-argument-map');
|
||||||
|
const targetCity = map.locator('.city-base').nth(1);
|
||||||
|
await targetCity.hover();
|
||||||
|
expect(await targetCity.evaluate((node) => getComputedStyle(node).cursor)).toBe('pointer');
|
||||||
|
await targetCity.focus();
|
||||||
|
await expect(targetCity).toBeFocused();
|
||||||
|
await targetCity.click();
|
||||||
|
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
|
||||||
|
const geometry = await picker.evaluate((element) => ({
|
||||||
|
pickerWidth: element.getBoundingClientRect().width,
|
||||||
|
pickerOverflow: element.scrollWidth - element.clientWidth,
|
||||||
|
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||||
|
mapInsidePicker:
|
||||||
|
element.querySelector<HTMLElement>('[data-testid="command-argument-map"]')!.getBoundingClientRect().right <=
|
||||||
|
element.getBoundingClientRect().right,
|
||||||
|
}));
|
||||||
|
expect(geometry).toEqual({ pickerWidth: 500, pickerOverflow: 0, documentOverflow: 0, mapInsidePicker: true });
|
||||||
|
await page.screenshot({
|
||||||
|
path: test.info().outputPath('chief-command-target-map-details-mobile.png'),
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prioritizes own cities for assignment while retaining other map targets', async ({ page }) => {
|
||||||
|
const assignmentTable = structuredClone(commandTable);
|
||||||
|
assignmentTable.inputOptions.cities = [
|
||||||
|
{ value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' },
|
||||||
|
{ value: 3, label: '단양 (무주)' },
|
||||||
|
{ value: 1, label: '업 (아국)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
await install(page, false, assignmentTable);
|
||||||
|
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');
|
||||||
|
const citySelect = form.locator('#command-arg-destCityId');
|
||||||
|
await expect(form.getByTestId('command-argument-map')).toBeVisible();
|
||||||
|
await expect(citySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']);
|
||||||
|
await expect(citySelect).toHaveValue('1');
|
||||||
|
|
||||||
|
await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click();
|
||||||
|
await expect(citySelect).toHaveValue('2');
|
||||||
|
await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창');
|
||||||
|
await expect(page).toHaveURL(/\/che\/chief-center$/);
|
||||||
|
await form.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority.png') });
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 500, height: 900 });
|
||||||
|
await page.goto('/che/chief-center');
|
||||||
|
await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
|
||||||
|
const mobilePicker = page.getByTestId('command-picker');
|
||||||
|
await mobilePicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click();
|
||||||
|
await mobilePicker.getByRole('button', { name: /발령/ }).click();
|
||||||
|
|
||||||
|
const mobileForm = mobilePicker.getByTestId('command-argument-form');
|
||||||
|
const mobileMap = mobileForm.getByTestId('command-argument-map');
|
||||||
|
const mobileCitySelect = mobileForm.locator('#command-arg-destCityId');
|
||||||
|
await expect(mobileMap).toBeVisible();
|
||||||
|
await expect(mobileCitySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']);
|
||||||
|
const mobileGeometry = await mobilePicker.evaluate((element) => ({
|
||||||
|
width: element.getBoundingClientRect().width,
|
||||||
|
overflow: element.scrollWidth - element.clientWidth,
|
||||||
|
}));
|
||||||
|
expect(mobileGeometry).toEqual({ width: 500, overflow: 0 });
|
||||||
|
const mapGeometry = await mobileMap.locator('.map-area').evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return { width: rect.width, height: rect.height };
|
||||||
|
});
|
||||||
|
expect(mapGeometry.width / mapGeometry.height).toBeCloseTo(7 / 5, 2);
|
||||||
|
await mobileMap.screenshot({ path: test.info().outputPath('chief-assignment-map-mobile.png') });
|
||||||
|
await mobileCitySelect.scrollIntoViewIfNeeded();
|
||||||
|
await mobilePicker.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority-mobile.png') });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('prioritizes current nation targets while preserving every choice', async ({ page }) => {
|
test('prioritizes current nation targets while preserving every choice', async ({ page }) => {
|
||||||
|
|||||||
@@ -467,6 +467,112 @@ test('국가 정보의 작위는 Ref 국가 등급 이름으로 표시된다', a
|
|||||||
await expect(root).not.toContainText('작 위1');
|
await expect(root).not.toContainText('작 위1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('map keeps desktop hover navigation and lets touch users choose one-tap or two-tap city navigation', async ({
|
||||||
|
browser,
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
await install(page);
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
await go(page, 'global-info');
|
||||||
|
|
||||||
|
const desktopCity = page.locator('.city-base').first();
|
||||||
|
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
|
||||||
|
await desktopCity.hover();
|
||||||
|
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
|
||||||
|
await desktopCity.click();
|
||||||
|
await expect(page).toHaveURL(/\/current-city\?cityId=1$/u);
|
||||||
|
|
||||||
|
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||||||
|
if (typeof configuredBaseUrl !== 'string') {
|
||||||
|
throw new Error('Playwright baseURL is required for the mobile map contract');
|
||||||
|
}
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: configuredBaseUrl,
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
screen: { width: 390, height: 844 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const mobilePage = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await install(mobilePage);
|
||||||
|
await go(mobilePage, 'global-info');
|
||||||
|
|
||||||
|
const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' });
|
||||||
|
await expect(twoTapButton).toBeVisible();
|
||||||
|
await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
const controlGeometry = await twoTapButton.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const mapRect = element.closest('.map-area')?.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
right: rect.right,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
mapRight: mapRect?.right,
|
||||||
|
mapBottom: mapRect?.bottom,
|
||||||
|
fontSize: style.fontSize,
|
||||||
|
lineHeight: style.lineHeight,
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
viewportWidth: document.documentElement.clientWidth,
|
||||||
|
overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *'))
|
||||||
|
.filter(
|
||||||
|
(candidate) => candidate.getBoundingClientRect().right > document.documentElement.clientWidth
|
||||||
|
)
|
||||||
|
.map((candidate) => ({
|
||||||
|
tag: candidate.tagName,
|
||||||
|
className: candidate.className,
|
||||||
|
right: candidate.getBoundingClientRect().right,
|
||||||
|
}))
|
||||||
|
.slice(0, 8),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(controlGeometry).toMatchObject({
|
||||||
|
fontSize: '11px',
|
||||||
|
lineHeight: '18px',
|
||||||
|
viewportWidth: 500,
|
||||||
|
overflowing: [],
|
||||||
|
});
|
||||||
|
expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1);
|
||||||
|
expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1);
|
||||||
|
expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1);
|
||||||
|
|
||||||
|
const mobileCities = mobilePage.locator('.city-base');
|
||||||
|
await mobileCities.nth(0).tap();
|
||||||
|
await expect(mobilePage).toHaveURL(/\/global-info$/u);
|
||||||
|
await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
|
||||||
|
|
||||||
|
await mobileCities.nth(1).tap();
|
||||||
|
await expect(mobilePage).toHaveURL(/\/global-info$/u);
|
||||||
|
await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|수】성2');
|
||||||
|
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-first-tap-tooltip.png'), fullPage: true });
|
||||||
|
|
||||||
|
await mobileCities.nth(1).tap();
|
||||||
|
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u);
|
||||||
|
|
||||||
|
await go(mobilePage, 'global-info');
|
||||||
|
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click();
|
||||||
|
const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' });
|
||||||
|
await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes');
|
||||||
|
|
||||||
|
await mobilePage.reload();
|
||||||
|
await expect(singleTapButton).toBeVisible();
|
||||||
|
await mobilePage.locator('.city-base').nth(2).tap();
|
||||||
|
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u);
|
||||||
|
|
||||||
|
await go(mobilePage, 'global-info');
|
||||||
|
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click();
|
||||||
|
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no');
|
||||||
|
await mobilePage.locator('.city-base').first().tap();
|
||||||
|
await expect(mobilePage).toHaveURL(/\/global-info$/u);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('global-info renders the ref nation summary columns beside the map', async ({ page }) => {
|
test('global-info renders the ref nation summary columns beside the map', async ({ page }) => {
|
||||||
await install(page);
|
await install(page);
|
||||||
await page.setViewportSize({ width: 1200, height: 900 });
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { basename, resolve } from 'node:path';
|
import { basename, resolve } from 'node:path';
|
||||||
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
||||||
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||||
|
import { touchDrag } from './touchDrag.js';
|
||||||
|
|
||||||
const response = (data: unknown) => ({ result: { data } });
|
const response = (data: unknown) => ({ result: { data } });
|
||||||
const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR;
|
const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR;
|
||||||
@@ -1469,6 +1470,70 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버
|
|||||||
.toEqual(defaultOrder);
|
.toEqual(defaultOrder);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => {
|
||||||
|
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||||||
|
if (typeof configuredBaseUrl !== 'string') {
|
||||||
|
throw new Error('Playwright baseURL is required for the mobile touch contract');
|
||||||
|
}
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: configuredBaseUrl,
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
screen: { width: 390, height: 844 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const mobilePage = await context.newPage();
|
||||||
|
try {
|
||||||
|
const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
|
||||||
|
await install(mobilePage, state);
|
||||||
|
await mobilePage.goto('my-page');
|
||||||
|
await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click();
|
||||||
|
|
||||||
|
const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' });
|
||||||
|
const commands = dialog.locator('[data-mobile-layout-id="commands"]');
|
||||||
|
const nationMenu = dialog.locator('[data-mobile-layout-id="nation-menu"]');
|
||||||
|
await touchDrag(mobilePage, nationMenu, commands);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
dialog
|
||||||
|
.locator('[data-mobile-layout-id]')
|
||||||
|
.evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-layout-id')))
|
||||||
|
)
|
||||||
|
.toEqual([
|
||||||
|
'nation-menu',
|
||||||
|
'commands',
|
||||||
|
'nation',
|
||||||
|
'general',
|
||||||
|
'city',
|
||||||
|
'map',
|
||||||
|
'records',
|
||||||
|
'global-menu',
|
||||||
|
'messages',
|
||||||
|
]);
|
||||||
|
await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') });
|
||||||
|
await dialog.getByRole('button', { name: '적용', exact: true }).click();
|
||||||
|
await expect
|
||||||
|
.poll(() => mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]')))
|
||||||
|
.toEqual([
|
||||||
|
'nation-menu',
|
||||||
|
'commands',
|
||||||
|
'nation',
|
||||||
|
'general',
|
||||||
|
'city',
|
||||||
|
'map',
|
||||||
|
'records',
|
||||||
|
'global-menu',
|
||||||
|
'messages',
|
||||||
|
]);
|
||||||
|
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch.png'), fullPage: true });
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
for (const [label, failure] of [
|
for (const [label, failure] of [
|
||||||
['daemon timeout', 'TIMEOUT'],
|
['daemon timeout', 'TIMEOUT'],
|
||||||
['engine transaction 오류', 'INTERNAL_SERVER_ERROR'],
|
['engine transaction 오류', 'INTERNAL_SERVER_ERROR'],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdir, readFile } from 'node:fs/promises';
|
|||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
|
||||||
|
import { touchDrag } from './touchDrag.js';
|
||||||
|
|
||||||
type FixtureState = {
|
type FixtureState = {
|
||||||
permissionLevel: number;
|
permissionLevel: number;
|
||||||
@@ -320,6 +321,58 @@ test('500px layout stacks policy fields and priority panels like the reference',
|
|||||||
await screenshot(page, 'core-npc-policy-mobile.png');
|
await screenshot(page, 'core-npc-policy-mobile.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('physical mobile touch reorders NPC priority across active and inactive lists', async ({ browser }, testInfo) => {
|
||||||
|
const configuredBaseUrl = testInfo.project.use.baseURL;
|
||||||
|
if (typeof configuredBaseUrl !== 'string') {
|
||||||
|
throw new Error('Playwright baseURL is required for the mobile touch contract');
|
||||||
|
}
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: configuredBaseUrl,
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
screen: { width: 390, height: 844 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
colorScheme: 'dark',
|
||||||
|
});
|
||||||
|
const mobilePage = await context.newPage();
|
||||||
|
try {
|
||||||
|
await installFixture(mobilePage, { permissionLevel: 4, mutations: [] });
|
||||||
|
await gotoPolicy(mobilePage);
|
||||||
|
await expect(mobilePage.locator('#container')).toBeVisible();
|
||||||
|
|
||||||
|
const nationPanel = mobilePage.locator('.priority-panel').first();
|
||||||
|
const activeList = nationPanel.locator('.priority-column').nth(1).locator('.priority-list');
|
||||||
|
const activeRows = activeList.locator('.priority-item');
|
||||||
|
await touchDrag(
|
||||||
|
mobilePage,
|
||||||
|
activeRows.nth(0),
|
||||||
|
activeRows.nth(3),
|
||||||
|
{ targetYRatio: 0.9 }
|
||||||
|
);
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
activeList
|
||||||
|
.locator('.priority-item .priority_info > span:nth-child(2)')
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
)
|
||||||
|
.toBe('선전포고');
|
||||||
|
|
||||||
|
const activeItem = activeList.getByText('불가침제의', { exact: true });
|
||||||
|
const inactiveList = nationPanel.locator('.priority-column').first().locator('.priority-list');
|
||||||
|
await touchDrag(mobilePage, activeItem, inactiveList.locator('.inactive-header'));
|
||||||
|
|
||||||
|
await expect(inactiveList.getByText('불가침제의', { exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
activeList.getByText('불가침제의', { exact: true })
|
||||||
|
).toHaveCount(0);
|
||||||
|
await mobilePage.screenshot({ path: testInfo.outputPath('npc-priority-mobile-touch.png'), fullPage: true });
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => {
|
test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => {
|
||||||
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
|
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
|
||||||
await installFixture(page, state);
|
await installFixture(page, state);
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { Locator, Page } from '@playwright/test';
|
||||||
|
|
||||||
|
type TouchPoint = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TouchDragOptions = {
|
||||||
|
targetYRatio?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pointIn = async (locator: Locator, yRatio = 0.5): Promise<TouchPoint> => {
|
||||||
|
const box = await locator.boundingBox();
|
||||||
|
if (!box) {
|
||||||
|
throw new Error('Touch drag target has no visible bounding box');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
x: box.x + box.width / 2,
|
||||||
|
y: box.y + box.height * yRatio,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const touchDrag = async (
|
||||||
|
page: Page,
|
||||||
|
source: Locator,
|
||||||
|
target: Locator,
|
||||||
|
options: TouchDragOptions = {}
|
||||||
|
): Promise<void> => {
|
||||||
|
await source.scrollIntoViewIfNeeded();
|
||||||
|
await target.scrollIntoViewIfNeeded();
|
||||||
|
const from = await pointIn(source);
|
||||||
|
const to = await pointIn(target, options.targetYRatio);
|
||||||
|
const cdp = await page.context().newCDPSession(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.documentElement.removeAttribute('data-playwright-touch-trusted');
|
||||||
|
document.addEventListener(
|
||||||
|
'touchstart',
|
||||||
|
(event) => document.documentElement.setAttribute('data-playwright-touch-trusted', String(event.isTrusted)),
|
||||||
|
{ capture: true, once: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchStart',
|
||||||
|
touchPoints: [{ ...from, id: 0, radiusX: 1, radiusY: 1, force: 1 }],
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
const dispatchMove = async (ratio: number) => {
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', {
|
||||||
|
type: 'touchMove',
|
||||||
|
touchPoints: [
|
||||||
|
{
|
||||||
|
x: from.x + (to.x - from.x) * ratio,
|
||||||
|
y: from.y + (to.y - from.y) * ratio,
|
||||||
|
id: 0,
|
||||||
|
radiusX: 1,
|
||||||
|
radiusY: 1,
|
||||||
|
force: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await dispatchMove(0.05);
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
for (let step = 2; step <= 20; step += 1) {
|
||||||
|
await dispatchMove(step / 20);
|
||||||
|
await page.waitForTimeout(16);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(50);
|
||||||
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
|
||||||
|
const trusted = await page.evaluate(
|
||||||
|
() => document.documentElement.getAttribute('data-playwright-touch-trusted') === 'true'
|
||||||
|
);
|
||||||
|
if (!trusted) {
|
||||||
|
throw new Error('Chromium did not dispatch a trusted touchstart event');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.26",
|
"vue": "^3.5.26",
|
||||||
|
"vuedraggable-es": "4.1.1",
|
||||||
"vue-router": "^4.6.4",
|
"vue-router": "^4.6.4",
|
||||||
"zod": "^4.3.5"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { CommandMapData, CommandOption } from './types';
|
||||||
|
|
||||||
|
export const commandCityOptions = (
|
||||||
|
commandKey: string,
|
||||||
|
options: readonly CommandOption[],
|
||||||
|
mapData?: CommandMapData | null
|
||||||
|
): CommandOption[] => {
|
||||||
|
if (commandKey !== 'che_발령' || typeof mapData?.myNation !== 'number') return [...options];
|
||||||
|
|
||||||
|
const nationByCityId = new Map(mapData.cityList.map(([cityId, , , nationId]) => [cityId, nationId]));
|
||||||
|
return options
|
||||||
|
.map((option, index) => ({ option, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOwned =
|
||||||
|
typeof left.option.value === 'number' && nationByCityId.get(left.option.value) === mapData.myNation;
|
||||||
|
const rightOwned =
|
||||||
|
typeof right.option.value === 'number' && nationByCityId.get(right.option.value) === mapData.myNation;
|
||||||
|
return Number(rightOwned) - Number(leftOwned) || left.index - right.index;
|
||||||
|
})
|
||||||
|
.map(({ option }) => option);
|
||||||
|
};
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import type { CommandInputField } from './types';
|
||||||
|
|
||||||
|
export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital';
|
||||||
|
|
||||||
export type CommandArgumentPresentation = {
|
export type CommandArgumentPresentation = {
|
||||||
lines: string[];
|
lines: string[];
|
||||||
mapTarget?: 'city' | 'nation' | 'capital';
|
mapTarget?: CommandArgumentMapTarget;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
|
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
|
||||||
@@ -98,4 +102,18 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
|
|||||||
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
|
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
|
||||||
PRESENTATIONS[commandKey] ?? { lines: [] };
|
PRESENTATIONS[commandKey] ?? { lines: [] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대상 지도는 명령명 목록이 아니라 API가 내린 실제 인자 계약을 우선한다.
|
||||||
|
* 인자가 없는 증축·감축의 수도 확인 지도만 presentation의 명시적 target을 사용한다.
|
||||||
|
*/
|
||||||
|
export const resolveCommandArgumentMapTarget = (
|
||||||
|
commandKey: string,
|
||||||
|
fields: readonly CommandInputField[]
|
||||||
|
): CommandArgumentMapTarget | undefined => {
|
||||||
|
const selectableTargets = fields.filter((field) => field.kind === 'select');
|
||||||
|
if (selectableTargets.some((field) => field.optionSource === 'cities')) return 'city';
|
||||||
|
if (selectableTargets.some((field) => field.optionSource === 'nations')) return 'nation';
|
||||||
|
return commandArgumentPresentation(commandKey).mapTarget;
|
||||||
|
};
|
||||||
|
|
||||||
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
|
export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, watch, type CSSProperties } from 'vue';
|
import { computed, reactive, watch, type CSSProperties } from 'vue';
|
||||||
import MapViewer from './MapViewer.vue';
|
import MapViewer from './MapViewer.vue';
|
||||||
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
|
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
|
||||||
|
import { commandCityOptions } from '../command/commandArgumentOptions';
|
||||||
import {
|
import {
|
||||||
commandArgumentFieldContract,
|
commandArgumentFieldContract,
|
||||||
shouldPreserveCommandArgumentValue,
|
shouldPreserveCommandArgumentValue,
|
||||||
@@ -64,6 +65,9 @@ const optionsFor = (field: CommandInputField): CommandOption[] => {
|
|||||||
if (field.optionSource === 'nations') {
|
if (field.optionSource === 'nations') {
|
||||||
return props.options.nationTargets?.[props.commandKey] ?? props.options.nations;
|
return props.options.nationTargets?.[props.commandKey] ?? props.options.nations;
|
||||||
}
|
}
|
||||||
|
if (field.optionSource === 'cities') {
|
||||||
|
return commandCityOptions(props.commandKey, props.options.cities, props.mapData);
|
||||||
|
}
|
||||||
if (field.optionSource === 'items') {
|
if (field.optionSource === 'items') {
|
||||||
return props.options.items[String(values.itemType ?? '')] ?? [];
|
return props.options.items[String(values.itemType ?? '')] ?? [];
|
||||||
}
|
}
|
||||||
@@ -104,12 +108,7 @@ const synchronizeValues = () => {
|
|||||||
for (const field of props.fields) {
|
for (const field of props.fields) {
|
||||||
const preserve =
|
const preserve =
|
||||||
!commandChanged &&
|
!commandChanged &&
|
||||||
shouldPreserveCommandArgumentValue(
|
shouldPreserveCommandArgumentValue(field, previousFieldContracts.get(field.key), values, optionsFor(field));
|
||||||
field,
|
|
||||||
previousFieldContracts.get(field.key),
|
|
||||||
values,
|
|
||||||
optionsFor(field)
|
|
||||||
);
|
|
||||||
if (!preserve) values[field.key] = defaultValue(field);
|
if (!preserve) values[field.key] = defaultValue(field);
|
||||||
}
|
}
|
||||||
const itemCodeField = props.fields.find((field) => field.key === 'itemCode');
|
const itemCodeField = props.fields.find((field) => field.key === 'itemCode');
|
||||||
@@ -162,20 +161,15 @@ const nationTargetField = computed(() =>
|
|||||||
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
|
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const showMap = computed(
|
const mapTarget = computed(() => resolveCommandArgumentMapTarget(props.commandKey, props.fields));
|
||||||
() =>
|
const showMap = computed(() => Boolean(props.mapData && props.mapLayout && mapTarget.value));
|
||||||
Boolean(props.mapData && props.mapLayout) &&
|
|
||||||
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
|
|
||||||
(presentation.value.mapTarget === 'nation' && nationTargetField.value) ||
|
|
||||||
presentation.value.mapTarget === 'capital')
|
|
||||||
);
|
|
||||||
const mapSelectedCityId = computed<number | null>(() => {
|
const mapSelectedCityId = computed<number | null>(() => {
|
||||||
if (!props.mapData) return null;
|
if (!props.mapData) return null;
|
||||||
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
|
if (mapTarget.value === 'city' && cityTargetField.value) {
|
||||||
const value = values[cityTargetField.value.key];
|
const value = values[cityTargetField.value.key];
|
||||||
return typeof value === 'number' ? value : null;
|
return typeof value === 'number' ? value : null;
|
||||||
}
|
}
|
||||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||||
const value = values[nationTargetField.value.key];
|
const value = values[nationTargetField.value.key];
|
||||||
if (typeof value !== 'number') return null;
|
if (typeof value !== 'number') return null;
|
||||||
return (
|
return (
|
||||||
@@ -184,7 +178,7 @@ const mapSelectedCityId = computed<number | null>(() => {
|
|||||||
null
|
null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (presentation.value.mapTarget === 'capital') {
|
if (mapTarget.value === 'capital') {
|
||||||
const myNation = props.mapData.myNation;
|
const myNation = props.mapData.myNation;
|
||||||
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
|
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
|
||||||
}
|
}
|
||||||
@@ -198,17 +192,17 @@ const currentCityName = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectedMapTargetName = computed(() => {
|
const selectedMapTargetName = computed(() => {
|
||||||
if (presentation.value.mapTarget === 'city') {
|
if (mapTarget.value === 'city') {
|
||||||
const cityId = mapSelectedCityId.value;
|
const cityId = mapSelectedCityId.value;
|
||||||
if (!cityId) return '-';
|
if (!cityId) return '-';
|
||||||
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
|
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
|
||||||
}
|
}
|
||||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||||
const nationId = values[nationTargetField.value.key];
|
const nationId = values[nationTargetField.value.key];
|
||||||
if (typeof nationId !== 'number') return '-';
|
if (typeof nationId !== 'number') return '-';
|
||||||
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
|
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
|
||||||
}
|
}
|
||||||
if (presentation.value.mapTarget === 'capital') {
|
if (mapTarget.value === 'capital') {
|
||||||
const cityId = mapSelectedCityId.value;
|
const cityId = mapSelectedCityId.value;
|
||||||
if (!cityId) return '-';
|
if (!cityId) return '-';
|
||||||
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
|
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
|
||||||
@@ -240,7 +234,7 @@ const distanceFromMyCity = (destination: number): number | null => {
|
|||||||
|
|
||||||
const mapTargetSummary = computed(() => {
|
const mapTargetSummary = computed(() => {
|
||||||
if (!props.mapData || !props.mapLayout) return '';
|
if (!props.mapData || !props.mapLayout) return '';
|
||||||
if (presentation.value.mapTarget === 'city' && mapSelectedCityId.value) {
|
if (mapTarget.value === 'city' && mapSelectedCityId.value) {
|
||||||
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
||||||
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
||||||
if (!city) return '';
|
if (!city) return '';
|
||||||
@@ -256,7 +250,7 @@ const mapTargetSummary = computed(() => {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ');
|
.join(' · ');
|
||||||
}
|
}
|
||||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||||
const value = values[nationTargetField.value.key];
|
const value = values[nationTargetField.value.key];
|
||||||
if (typeof value !== 'number') return '';
|
if (typeof value !== 'number') return '';
|
||||||
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
|
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
|
||||||
@@ -265,7 +259,7 @@ const mapTargetSummary = computed(() => {
|
|||||||
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
|
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
|
||||||
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}개`;
|
return `${nation[1]} · 수도 ${capital?.name ?? '-'} · 도시 ${cityCount.toLocaleString()}개`;
|
||||||
}
|
}
|
||||||
if (presentation.value.mapTarget === 'capital' && mapSelectedCityId.value) {
|
if (mapTarget.value === 'capital' && mapSelectedCityId.value) {
|
||||||
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
const city = props.mapLayout.cityList.find((entry) => entry.id === mapSelectedCityId.value);
|
||||||
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
const dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
|
||||||
if (!city) return '';
|
if (!city) return '';
|
||||||
@@ -278,11 +272,11 @@ const mapTargetSummary = computed(() => {
|
|||||||
|
|
||||||
const selectMapCity = (cityId: number) => {
|
const selectMapCity = (cityId: number) => {
|
||||||
if (!props.mapData) return;
|
if (!props.mapData) return;
|
||||||
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
|
if (mapTarget.value === 'city' && cityTargetField.value) {
|
||||||
setSelectValue(cityTargetField.value, String(cityId));
|
setSelectValue(cityTargetField.value, String(cityId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
|
if (mapTarget.value === 'nation' && nationTargetField.value) {
|
||||||
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
|
const nationId = props.mapData.cityList.find((entry) => entry[0] === cityId)?.[3];
|
||||||
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
|
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
|
||||||
}
|
}
|
||||||
@@ -417,24 +411,20 @@ watch(
|
|||||||
:detail-mode="true"
|
:detail-mode="true"
|
||||||
:fit-container="true"
|
:fit-container="true"
|
||||||
:show-current-city-marker="true"
|
:show-current-city-marker="true"
|
||||||
:readonly="presentation.mapTarget === 'capital'"
|
:readonly="mapTarget === 'capital'"
|
||||||
@select-city="selectMapCity"
|
@select-city="selectMapCity"
|
||||||
/>
|
/>
|
||||||
<small v-if="presentation.mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
|
<small v-if="mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
|
||||||
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
|
||||||
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
|
<div class="map-selection-status" aria-live="polite" data-testid="command-map-selection-status">
|
||||||
<span v-if="presentation.mapTarget !== 'capital'" class="current-city-status">
|
<span v-if="mapTarget !== 'capital'" class="current-city-status">
|
||||||
<span class="status-key">현재 도시</span>
|
<span class="status-key">현재 도시</span>
|
||||||
<strong>{{ currentCityName }}</strong>
|
<strong>{{ currentCityName }}</strong>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="presentation.mapTarget !== 'capital'" aria-hidden="true">→</span>
|
<span v-if="mapTarget !== 'capital'" aria-hidden="true">→</span>
|
||||||
<span class="selected-target-status">
|
<span class="selected-target-status">
|
||||||
<span class="status-key">{{
|
<span class="status-key">{{
|
||||||
presentation.mapTarget === 'nation'
|
mapTarget === 'nation' ? '선택 국가' : mapTarget === 'capital' ? '현재 수도' : '선택 도시'
|
||||||
? '선택 국가'
|
|
||||||
: presentation.mapTarget === 'capital'
|
|
||||||
? '현재 수도'
|
|
||||||
: '선택 도시'
|
|
||||||
}}</span>
|
}}</span>
|
||||||
<strong>{{ selectedMapTargetName }}</strong>
|
<strong>{{ selectedMapTargetName }}</strong>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'hover', cityId: number): void;
|
(event: 'hover', cityId: number): void;
|
||||||
(event: 'leave'): void;
|
(event: 'leave'): void;
|
||||||
|
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
|
||||||
|
(event: 'touchleave'): void;
|
||||||
(event: 'select', cityId: number): void;
|
(event: 'select', cityId: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -37,6 +39,25 @@ const stateOffset = computed(() => -6 * props.mapScale);
|
|||||||
const selectCity = () => {
|
const selectCity = () => {
|
||||||
if (!props.readonly) emit('select', props.city.id);
|
if (!props.readonly) emit('select', props.city.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let touchOnTrack = false;
|
||||||
|
|
||||||
|
const touchstart = () => {
|
||||||
|
touchOnTrack = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchmove = () => {
|
||||||
|
touchOnTrack = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchend = (event: TouchEvent) => {
|
||||||
|
if (touchOnTrack) {
|
||||||
|
event.stopPropagation();
|
||||||
|
emit('touch', props.city.id, event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('touchleave');
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -59,6 +80,9 @@ const selectCity = () => {
|
|||||||
: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')"
|
||||||
|
@touchstart="touchstart"
|
||||||
|
@touchmove="touchmove"
|
||||||
|
@touchend="touchend"
|
||||||
@click.stop="selectCity"
|
@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` }">
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'hover', cityId: number): void;
|
(event: 'hover', cityId: number): void;
|
||||||
(event: 'leave'): void;
|
(event: 'leave'): void;
|
||||||
|
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
|
||||||
|
(event: 'touchleave'): void;
|
||||||
(event: 'select', cityId: number): void;
|
(event: 'select', cityId: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -148,6 +150,25 @@ const selectCity = () => {
|
|||||||
if (!props.readonly) emit('select', props.city.id);
|
if (!props.readonly) emit('select', props.city.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let touchOnTrack = false;
|
||||||
|
|
||||||
|
const touchstart = () => {
|
||||||
|
touchOnTrack = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchmove = () => {
|
||||||
|
touchOnTrack = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchend = (event: TouchEvent) => {
|
||||||
|
if (touchOnTrack) {
|
||||||
|
event.stopPropagation();
|
||||||
|
emit('touch', props.city.id, event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('touchleave');
|
||||||
|
};
|
||||||
|
|
||||||
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`,
|
||||||
@@ -174,6 +195,9 @@ const cityStateStyle = computed(() => ({
|
|||||||
:style="cityBaseStyle"
|
:style="cityBaseStyle"
|
||||||
@mouseenter="emit('hover', props.city.id)"
|
@mouseenter="emit('hover', props.city.id)"
|
||||||
@mouseleave="emit('leave')"
|
@mouseleave="emit('leave')"
|
||||||
|
@touchstart="touchstart"
|
||||||
|
@touchmove="touchmove"
|
||||||
|
@touchend="touchend"
|
||||||
@click.stop="selectCity"
|
@click.stop="selectCity"
|
||||||
>
|
>
|
||||||
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
|
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
|
||||||
|
|||||||
@@ -94,9 +94,11 @@ const mapStore = useMapViewerStore();
|
|||||||
const {
|
const {
|
||||||
showCityName,
|
showCityName,
|
||||||
detailMode: storeDetailMode,
|
detailMode: storeDetailMode,
|
||||||
|
singleTapNavigation,
|
||||||
hoveredCityId,
|
hoveredCityId,
|
||||||
selectedCityId: storeSelectedCityId,
|
selectedCityId: storeSelectedCityId,
|
||||||
} = storeToRefs(mapStore);
|
} = storeToRefs(mapStore);
|
||||||
|
const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
|
||||||
|
|
||||||
const mapArea = ref<HTMLElement | null>(null);
|
const mapArea = ref<HTMLElement | null>(null);
|
||||||
const mapBody = ref<HTMLElement | null>(null);
|
const mapBody = ref<HTMLElement | null>(null);
|
||||||
@@ -404,6 +406,28 @@ const setHoveredCity = (cityId: number | null) => {
|
|||||||
mapStore.setHoveredCity(cityId);
|
mapStore.setHoveredCity(cityId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const touchPreviewCityId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const clearTouchPreview = () => {
|
||||||
|
touchPreviewCityId.value = null;
|
||||||
|
setHoveredCity(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const touchCity = (cityId: number, event: TouchEvent) => {
|
||||||
|
if (touchPreviewCityId.value !== cityId) {
|
||||||
|
touchPreviewCityId.value = cityId;
|
||||||
|
setHoveredCity(cityId);
|
||||||
|
if (!singleTapNavigation.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSingleTapNavigation = () => {
|
||||||
|
clearTouchPreview();
|
||||||
|
mapStore.toggleSingleTapNavigation();
|
||||||
|
};
|
||||||
|
|
||||||
const selectCity = (cityId: number) => {
|
const selectCity = (cityId: number) => {
|
||||||
if (props.readonly) return;
|
if (props.readonly) return;
|
||||||
emit('select-city', cityId);
|
emit('select-city', cityId);
|
||||||
@@ -433,6 +457,7 @@ const selectCity = (cityId: number) => {
|
|||||||
class="map-area"
|
class="map-area"
|
||||||
:class="[mapThemeClass, mapSeasonClass]"
|
:class="[mapThemeClass, mapSeasonClass]"
|
||||||
:style="{ width: mapWidth, height: mapHeight }"
|
:style="{ width: mapWidth, height: mapHeight }"
|
||||||
|
@click="clearTouchPreview"
|
||||||
>
|
>
|
||||||
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
|
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
|
||||||
<div class="map-layer map-bglayer2" />
|
<div class="map-layer map-bglayer2" />
|
||||||
@@ -449,6 +474,8 @@ const selectCity = (cityId: number) => {
|
|||||||
v-bind="detailProps"
|
v-bind="detailProps"
|
||||||
@hover="setHoveredCity"
|
@hover="setHoveredCity"
|
||||||
@leave="setHoveredCity(null)"
|
@leave="setHoveredCity(null)"
|
||||||
|
@touch="touchCity"
|
||||||
|
@touchleave="clearTouchPreview"
|
||||||
@select="selectCity"
|
@select="selectCity"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -466,9 +493,18 @@ const selectCity = (cityId: number) => {
|
|||||||
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
|
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="map-controls">
|
<div class="map-controls">
|
||||||
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
|
<button class="map-toggle" :class="{ active: showCityName }" @click.stop="mapStore.toggleCityName">
|
||||||
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="hasTouchInput"
|
||||||
|
class="map-toggle map-toggle-single-tap"
|
||||||
|
:class="{ active: singleTapNavigation }"
|
||||||
|
:aria-pressed="singleTapNavigation"
|
||||||
|
@click.stop="toggleSingleTapNavigation"
|
||||||
|
>
|
||||||
|
두번 탭 해 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -555,6 +591,8 @@ const selectCity = (cityId: number) => {
|
|||||||
right: 4px;
|
right: 4px;
|
||||||
bottom: 4px;
|
bottom: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-toggle {
|
.map-toggle {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { defineComponent, h, type PropType, type SlotsType, type VNode } from 'vue';
|
||||||
|
import VueDraggable from 'vuedraggable-es';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
name: 'SortableStringList',
|
||||||
|
inheritAttrs: false,
|
||||||
|
props: {
|
||||||
|
list: {
|
||||||
|
type: Array as PropType<string[]>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
group: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
type: String,
|
||||||
|
default: 'div',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slots: Object as SlotsType<{
|
||||||
|
header?: () => VNode[];
|
||||||
|
item: (props: { element: string; index: number }) => VNode[];
|
||||||
|
}>,
|
||||||
|
setup(props, { attrs, slots }) {
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
VueDraggable,
|
||||||
|
{
|
||||||
|
...attrs,
|
||||||
|
list: props.list,
|
||||||
|
group: props.group,
|
||||||
|
itemKey: (item: string) => item,
|
||||||
|
tag: props.tag,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: () => slots.header?.(),
|
||||||
|
item: ({ element, index }: { element: string; index: number }) =>
|
||||||
|
slots.item({ element, index }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,14 +3,23 @@ import { defineStore } from 'pinia';
|
|||||||
interface MapViewerState {
|
interface MapViewerState {
|
||||||
showCityName: boolean;
|
showCityName: boolean;
|
||||||
detailMode: boolean;
|
detailMode: boolean;
|
||||||
|
singleTapNavigation: boolean;
|
||||||
hoveredCityId: number | null;
|
hoveredCityId: number | null;
|
||||||
selectedCityId: number | null;
|
selectedCityId: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SINGLE_TAP_STORAGE_KEY = 'sam.toggleSingleTap';
|
||||||
|
|
||||||
|
const loadSingleTapNavigation = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.localStorage.getItem(SINGLE_TAP_STORAGE_KEY) === 'yes';
|
||||||
|
};
|
||||||
|
|
||||||
export const useMapViewerStore = defineStore('mapViewer', {
|
export const useMapViewerStore = defineStore('mapViewer', {
|
||||||
state: (): MapViewerState => ({
|
state: (): MapViewerState => ({
|
||||||
showCityName: true,
|
showCityName: true,
|
||||||
detailMode: true,
|
detailMode: true,
|
||||||
|
singleTapNavigation: loadSingleTapNavigation(),
|
||||||
hoveredCityId: null,
|
hoveredCityId: null,
|
||||||
selectedCityId: null,
|
selectedCityId: null,
|
||||||
}),
|
}),
|
||||||
@@ -21,6 +30,12 @@ export const useMapViewerStore = defineStore('mapViewer', {
|
|||||||
toggleDetailMode() {
|
toggleDetailMode() {
|
||||||
this.detailMode = !this.detailMode;
|
this.detailMode = !this.detailMode;
|
||||||
},
|
},
|
||||||
|
toggleSingleTapNavigation() {
|
||||||
|
this.singleTapNavigation = !this.singleTapNavigation;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(SINGLE_TAP_STORAGE_KEY, this.singleTapNavigation ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
},
|
||||||
setHoveredCity(cityId: number | null) {
|
setHoveredCity(cityId: number | null) {
|
||||||
this.hoveredCityId = cityId;
|
this.hoveredCityId = cityId;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import SortableStringList from '../components/ui/SortableStringList';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
import { formatLog } from '../utils/formatLog';
|
import { formatLog } from '../utils/formatLog';
|
||||||
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
import { formatSeoulDateTime } from '../utils/legacyDateTime';
|
||||||
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
|
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic/scenario/scenarioEffect.js';
|
||||||
import { useSessionStore } from '../stores/session';
|
import { useSessionStore } from '../stores/session';
|
||||||
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
|
||||||
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
|
||||||
@@ -58,7 +59,6 @@ const selectedIconId = ref('');
|
|||||||
const cssSaving = ref(false);
|
const cssSaving = ref(false);
|
||||||
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
|
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
|
||||||
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
|
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
|
||||||
const mobileLayoutDragIndex = ref<number | null>(null);
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
let cssTimer: number | null = null;
|
let cssTimer: number | null = null;
|
||||||
const readPendingDieOnPrestartId = (): string => {
|
const readPendingDieOnPrestartId = (): string => {
|
||||||
@@ -180,9 +180,9 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
|
|||||||
);
|
);
|
||||||
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
const iconChoices = computed(() => data.value?.iconChoices ?? []);
|
||||||
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
|
const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null);
|
||||||
const mobileLayoutLabels = Object.fromEntries(
|
const mobileLayoutLabels: Readonly<Record<string, string>> = Object.fromEntries(
|
||||||
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
|
MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label])
|
||||||
) as Record<MobileMainPanelId, string>;
|
);
|
||||||
|
|
||||||
const openMobileLayoutDialog = () => {
|
const openMobileLayoutDialog = () => {
|
||||||
mobileLayoutOrder.value = loadMobileMainPanelOrder();
|
mobileLayoutOrder.value = loadMobileMainPanelOrder();
|
||||||
@@ -194,20 +194,6 @@ const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
|
|||||||
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
|
mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startMobileLayoutDrag = (event: DragEvent, index: number) => {
|
|
||||||
mobileLayoutDragIndex.value = index;
|
|
||||||
event.dataTransfer?.setData('text/plain', mobileLayoutOrder.value[index] ?? '');
|
|
||||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropMobileLayoutItem = (event: DragEvent, targetIndex: number) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const sourceIndex = mobileLayoutDragIndex.value;
|
|
||||||
mobileLayoutDragIndex.value = null;
|
|
||||||
if (sourceIndex === null) return;
|
|
||||||
moveMobileLayoutItem(sourceIndex, targetIndex);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetMobileLayoutOrder = () => {
|
const resetMobileLayoutOrder = () => {
|
||||||
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
|
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
|
||||||
};
|
};
|
||||||
@@ -697,7 +683,6 @@ onMounted(() => {
|
|||||||
ref="mobileLayoutDialog"
|
ref="mobileLayoutDialog"
|
||||||
class="mobile-layout-dialog"
|
class="mobile-layout-dialog"
|
||||||
aria-labelledby="mobile-layout-dialog-title"
|
aria-labelledby="mobile-layout-dialog-title"
|
||||||
@close="mobileLayoutDragIndex = null"
|
|
||||||
>
|
>
|
||||||
<div class="mobile-layout-dialog__header">
|
<div class="mobile-layout-dialog__header">
|
||||||
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
|
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
|
||||||
@@ -706,17 +691,13 @@ onMounted(() => {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p>항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.</p>
|
<p>항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.</p>
|
||||||
<ol class="mobile-layout-list">
|
<SortableStringList
|
||||||
<li
|
:list="mobileLayoutOrder"
|
||||||
v-for="(panelId, index) in mobileLayoutOrder"
|
tag="ol"
|
||||||
:key="panelId"
|
class="mobile-layout-list"
|
||||||
:data-mobile-layout-id="panelId"
|
|
||||||
draggable="true"
|
|
||||||
@dragstart="startMobileLayoutDrag($event, index)"
|
|
||||||
@dragend="mobileLayoutDragIndex = null"
|
|
||||||
@dragover.prevent
|
|
||||||
@drop.stop="dropMobileLayoutItem($event, index)"
|
|
||||||
>
|
>
|
||||||
|
<template #item="{ element: panelId, index }">
|
||||||
|
<li :data-mobile-layout-id="panelId">
|
||||||
<span class="mobile-layout-handle" aria-hidden="true">≡</span>
|
<span class="mobile-layout-handle" aria-hidden="true">≡</span>
|
||||||
<span class="mobile-layout-label">
|
<span class="mobile-layout-label">
|
||||||
<span class="mobile-layout-position">{{ index + 1 }}</span>
|
<span class="mobile-layout-position">{{ index + 1 }}</span>
|
||||||
@@ -741,7 +722,8 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</template>
|
||||||
|
</SortableStringList>
|
||||||
<div class="mobile-layout-dialog__actions">
|
<div class="mobile-layout-dialog__actions">
|
||||||
<button type="button" @click="resetMobileLayoutOrder">기본값</button>
|
<button type="button" @click="resetMobileLayoutOrder">기본값</button>
|
||||||
<form method="dialog"><button type="submit">취소</button></form>
|
<form method="dialog"><button type="submit">취소</button></form>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import SortableStringList from '../components/ui/SortableStringList';
|
||||||
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
|
||||||
import { trpc } from '../utils/trpc';
|
import { trpc } from '../utils/trpc';
|
||||||
|
|
||||||
@@ -8,7 +9,6 @@ type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
|
|||||||
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
|
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
|
||||||
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
|
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
|
||||||
type PrioritySectionKey = 'nation' | 'general';
|
type PrioritySectionKey = 'nation' | 'general';
|
||||||
type PriorityBucket = 'active' | 'inactive';
|
|
||||||
|
|
||||||
interface PolicyField {
|
interface PolicyField {
|
||||||
key: NumericPolicyKey;
|
key: NumericPolicyKey;
|
||||||
@@ -35,12 +35,6 @@ interface PriorityPanel {
|
|||||||
state: PriorityListState;
|
state: PriorityListState;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DragState {
|
|
||||||
section: PrioritySectionKey;
|
|
||||||
bucket: PriorityBucket;
|
|
||||||
index: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const notice = ref<string | null>(null);
|
const notice = ref<string | null>(null);
|
||||||
@@ -51,7 +45,6 @@ const nationPriority = ref<PriorityListState | null>(null);
|
|||||||
const generalPriority = ref<PriorityListState | null>(null);
|
const generalPriority = ref<PriorityListState | null>(null);
|
||||||
const lastSavedNationPriority = ref<string[]>([]);
|
const lastSavedNationPriority = ref<string[]>([]);
|
||||||
const lastSavedGeneralPriority = ref<string[]>([]);
|
const lastSavedGeneralPriority = ref<string[]>([]);
|
||||||
const dragState = ref<DragState | null>(null);
|
|
||||||
|
|
||||||
const resolveErrorMessage = (value: unknown): string => {
|
const resolveErrorMessage = (value: unknown): string => {
|
||||||
if (value instanceof Error) return value.message;
|
if (value instanceof Error) return value.message;
|
||||||
@@ -363,26 +356,6 @@ const submitPriority = async (section: PrioritySectionKey) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const startDrag = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, index: number) => {
|
|
||||||
dragState.value = { section, bucket, index };
|
|
||||||
event.dataTransfer?.setData('text/plain', `${section}:${bucket}:${index}`);
|
|
||||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: PriorityBucket, targetIndex?: number) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const source = dragState.value;
|
|
||||||
const state = section === 'nation' ? nationPriority.value : generalPriority.value;
|
|
||||||
if (!source || source.section !== section || !state) return;
|
|
||||||
const sourceList = state[source.bucket];
|
|
||||||
const targetList = state[bucket];
|
|
||||||
const [item] = sourceList.splice(source.index, 1);
|
|
||||||
if (!item) return;
|
|
||||||
let index = targetIndex ?? targetList.length;
|
|
||||||
if (sourceList === targetList && source.index < index) index -= 1;
|
|
||||||
targetList.splice(Math.max(0, Math.min(index, targetList.length)), 0, item);
|
|
||||||
dragState.value = null;
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -474,21 +447,17 @@ const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: Pri
|
|||||||
<div class="priority-columns">
|
<div class="priority-columns">
|
||||||
<div class="priority-column">
|
<div class="priority-column">
|
||||||
<div class="sub_bar legacy-bg2">비활성</div>
|
<div class="sub_bar legacy-bg2">비활성</div>
|
||||||
<div
|
<SortableStringList
|
||||||
|
:list="panel.state.inactive"
|
||||||
|
:group="`npc-priority-${panel.key}`"
|
||||||
|
tag="div"
|
||||||
class="priority-list"
|
class="priority-list"
|
||||||
@dragover.prevent
|
|
||||||
@drop="dropPriority($event, panel.key, 'inactive')"
|
|
||||||
>
|
>
|
||||||
|
<template #header>
|
||||||
<div class="inactive-header"><비활성화 항목들></div>
|
<div class="inactive-header"><비활성화 항목들></div>
|
||||||
<div
|
</template>
|
||||||
v-for="(item, index) in panel.state.inactive"
|
<template #item="{ element: item }">
|
||||||
:key="item"
|
<div class="priority-item">
|
||||||
class="priority-item"
|
|
||||||
draggable="true"
|
|
||||||
@dragstart="startDrag($event, panel.key, 'inactive', index)"
|
|
||||||
@dragover.prevent
|
|
||||||
@drop.stop="dropPriority($event, panel.key, 'inactive', index)"
|
|
||||||
>
|
|
||||||
<div class="priority_info">
|
<div class="priority_info">
|
||||||
<span class="drag-handle">≡</span>
|
<span class="drag-handle">≡</span>
|
||||||
<span>{{ item }}</span>
|
<span>{{ item }}</span>
|
||||||
@@ -502,24 +471,19 @@ const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: Pri
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</SortableStringList>
|
||||||
</div>
|
</div>
|
||||||
<div class="priority-column">
|
<div class="priority-column">
|
||||||
<div class="sub_bar legacy-bg2">활성</div>
|
<div class="sub_bar legacy-bg2">활성</div>
|
||||||
<div
|
<SortableStringList
|
||||||
|
:list="panel.state.active"
|
||||||
|
:group="`npc-priority-${panel.key}`"
|
||||||
|
tag="div"
|
||||||
class="priority-list"
|
class="priority-list"
|
||||||
@dragover.prevent
|
|
||||||
@drop="dropPriority($event, panel.key, 'active')"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in panel.state.active"
|
|
||||||
:key="`${item}-${index}`"
|
|
||||||
class="priority-item"
|
|
||||||
draggable="true"
|
|
||||||
@dragstart="startDrag($event, panel.key, 'active', index)"
|
|
||||||
@dragover.prevent
|
|
||||||
@drop.stop="dropPriority($event, panel.key, 'active', index)"
|
|
||||||
>
|
>
|
||||||
|
<template #item="{ element: item }">
|
||||||
|
<div class="priority-item">
|
||||||
<div class="priority_info">
|
<div class="priority_info">
|
||||||
<span class="drag-handle">≡</span>
|
<span class="drag-handle">≡</span>
|
||||||
<span>{{ item }}</span>
|
<span>{{ item }}</span>
|
||||||
@@ -533,7 +497,8 @@ const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: Pri
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</SortableStringList>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control_bar priority-control">
|
<div class="control_bar priority-control">
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { commandCityOptions } from '../src/components/command/commandArgumentOptions.ts';
|
||||||
|
import type { CommandMapData, CommandOption } from '../src/components/command/types.ts';
|
||||||
|
|
||||||
|
const cities: CommandOption[] = [
|
||||||
|
{ value: 20, label: '적국 도시' },
|
||||||
|
{ value: 30, label: '아국 도시 둘' },
|
||||||
|
{ value: 40, label: '공백지' },
|
||||||
|
{ value: 10, label: '아국 도시 하나' },
|
||||||
|
];
|
||||||
|
const mapData: CommandMapData = {
|
||||||
|
year: 200,
|
||||||
|
month: 1,
|
||||||
|
startYear: 180,
|
||||||
|
cityList: [
|
||||||
|
[10, 8, 0, 1, 1, 1],
|
||||||
|
[20, 7, 0, 2, 2, 1],
|
||||||
|
[30, 6, 0, 1, 3, 1],
|
||||||
|
[40, 5, 0, 0, 4, 1],
|
||||||
|
],
|
||||||
|
nationList: [
|
||||||
|
[1, '아국', '#008000', 10],
|
||||||
|
[2, '적국', '#800000', 20],
|
||||||
|
],
|
||||||
|
myCity: 10,
|
||||||
|
myNation: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
void test('발령은 아국 도시를 먼저 두고 적국과 공백지를 원래 순서로 보존한다', () => {
|
||||||
|
const sorted = commandCityOptions('che_발령', cities, mapData);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
sorted.map((option) => option.value),
|
||||||
|
[30, 10, 20, 40]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
cities.map((option) => option.value),
|
||||||
|
[20, 30, 40, 10],
|
||||||
|
'공용 입력 option은 변경하지 않는다'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('다른 도시 대상 명령의 순서는 바꾸지 않는다', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
commandCityOptions('che_출병', cities, mapData).map((option) => option.value),
|
||||||
|
[20, 30, 40, 10]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('지도 국가 정보가 아직 없으면 발령의 기존 option 순서를 유지한다', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
commandCityOptions('che_발령', cities, null).map((option) => option.value),
|
||||||
|
[20, 30, 40, 10]
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
|||||||
import {
|
import {
|
||||||
commandArgumentPresentation,
|
commandArgumentPresentation,
|
||||||
presentedCommandKeys,
|
presentedCommandKeys,
|
||||||
|
resolveCommandArgumentMapTarget,
|
||||||
} from '../src/components/command/commandArgumentPresentation.ts';
|
} from '../src/components/command/commandArgumentPresentation.ts';
|
||||||
|
|
||||||
const cityCommands = [
|
const cityCommands = [
|
||||||
@@ -82,3 +83,25 @@ void test('marks the same city and nation target families that Ref renders with
|
|||||||
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'capital', commandKey);
|
assert.equal(commandArgumentPresentation(commandKey).mapTarget, 'capital', commandKey);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('derives selection maps from the actual city and nation argument contract', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveCommandArgumentMapTarget('future_city_command', [
|
||||||
|
{ key: 'target', label: '도시', kind: 'select', required: true, optionSource: 'cities' },
|
||||||
|
]),
|
||||||
|
'city'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveCommandArgumentMapTarget('future_nation_command', [
|
||||||
|
{ key: 'target', label: '국가', kind: 'select', required: true, optionSource: 'nations' },
|
||||||
|
]),
|
||||||
|
'nation'
|
||||||
|
);
|
||||||
|
assert.equal(resolveCommandArgumentMapTarget('che_증축', []), 'capital');
|
||||||
|
assert.equal(
|
||||||
|
resolveCommandArgumentMapTarget('future_general_command', [
|
||||||
|
{ key: 'target', label: '장수', kind: 'select', required: true, optionSource: 'generals' },
|
||||||
|
]),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { describe, it } from 'node:test';
|
|||||||
|
|
||||||
const routerSourcePath = path.resolve(import.meta.dirname, '../src/router/index.ts');
|
const routerSourcePath = path.resolve(import.meta.dirname, '../src/router/index.ts');
|
||||||
const mainDashboardSourcePath = path.resolve(import.meta.dirname, '../src/stores/mainDashboard.ts');
|
const mainDashboardSourcePath = path.resolve(import.meta.dirname, '../src/stores/mainDashboard.ts');
|
||||||
|
const myPageSourcePath = path.resolve(import.meta.dirname, '../src/views/MyPageView.vue');
|
||||||
|
|
||||||
void describe('game route loading contract', () => {
|
void describe('game route loading contract', () => {
|
||||||
void it('loads view components through route-level dynamic imports', async () => {
|
void it('loads view components through route-level dynamic imports', async () => {
|
||||||
@@ -26,4 +27,11 @@ void describe('game route loading contract', () => {
|
|||||||
assert.doesNotMatch(source, /from\s+['"]@sammo-ts\/logic['"]/);
|
assert.doesNotMatch(source, /from\s+['"]@sammo-ts\/logic['"]/);
|
||||||
assert.match(source, /from\s+['"]@sammo-ts\/logic\/messages\/message\.js['"]/);
|
assert.match(source, /from\s+['"]@sammo-ts\/logic\/messages\/message\.js['"]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void it('does not preload the server-side logic barrel from the my-page route', async () => {
|
||||||
|
const source = await readFile(myPageSourcePath, 'utf8');
|
||||||
|
|
||||||
|
assert.doesNotMatch(source, /from\s+['"]@sammo-ts\/logic['"]/);
|
||||||
|
assert.match(source, /from\s+['"]@sammo-ts\/logic\/scenario\/scenarioEffect\.js['"]/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+23
-4
@@ -66,7 +66,7 @@ importers:
|
|||||||
version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
|
version: 8.67.0(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)
|
||||||
vitepress:
|
vitepress:
|
||||||
specifier: 1.6.4
|
specifier: 1.6.4
|
||||||
version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@6.0.3)
|
version: 1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(sortablejs@1.14.0)(typescript@6.0.3)
|
||||||
vue-eslint-parser:
|
vue-eslint-parser:
|
||||||
specifier: ^10.4.1
|
specifier: ^10.4.1
|
||||||
version: 10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
|
version: 10.4.1(eslint@10.8.1(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)
|
||||||
@@ -213,6 +213,9 @@ importers:
|
|||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^4.6.4
|
specifier: ^4.6.4
|
||||||
version: 4.6.4(vue@3.5.41(typescript@6.0.3))
|
version: 4.6.4(vue@3.5.41(typescript@6.0.3))
|
||||||
|
vuedraggable-es:
|
||||||
|
specifier: 4.1.1
|
||||||
|
version: 4.1.1(vue@3.5.41(typescript@6.0.3))
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.5
|
specifier: ^4.3.5
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
@@ -4400,6 +4403,9 @@ packages:
|
|||||||
sonic-boom@4.2.1:
|
sonic-boom@4.2.1:
|
||||||
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
|
||||||
|
|
||||||
|
sortablejs@1.14.0:
|
||||||
|
resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4795,6 +4801,11 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
vuedraggable-es@4.1.1:
|
||||||
|
resolution: {integrity: sha512-F35pjSwC8HS/lnaOd+B59nYR4FZmwuhWAzccK9xftRuWds8SU1TZh5myKVM86j5dFOI7S26O64Kwe7LUHnXjlA==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.2.31
|
||||||
|
|
||||||
w3c-keyname@2.2.8:
|
w3c-keyname@2.2.8:
|
||||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||||
|
|
||||||
@@ -6643,13 +6654,14 @@ snapshots:
|
|||||||
'@vueuse/shared': 14.4.0(vue@3.5.41(typescript@6.0.3))
|
'@vueuse/shared': 14.4.0(vue@3.5.41(typescript@6.0.3))
|
||||||
vue: 3.5.41(typescript@6.0.3)
|
vue: 3.5.41(typescript@6.0.3)
|
||||||
|
|
||||||
'@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.3)':
|
'@vueuse/integrations@12.8.2(focus-trap@7.8.0)(sortablejs@1.14.0)(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vueuse/core': 12.8.2(typescript@6.0.3)
|
'@vueuse/core': 12.8.2(typescript@6.0.3)
|
||||||
'@vueuse/shared': 12.8.2(typescript@6.0.3)
|
'@vueuse/shared': 12.8.2(typescript@6.0.3)
|
||||||
vue: 3.5.41(typescript@6.0.3)
|
vue: 3.5.41(typescript@6.0.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
focus-trap: 7.8.0
|
focus-trap: 7.8.0
|
||||||
|
sortablejs: 1.14.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- typescript
|
- typescript
|
||||||
|
|
||||||
@@ -8434,6 +8446,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
atomic-sleep: 1.0.0
|
atomic-sleep: 1.0.0
|
||||||
|
|
||||||
|
sortablejs@1.14.0: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
source-map@0.6.1:
|
source-map@0.6.1:
|
||||||
@@ -8691,7 +8705,7 @@ snapshots:
|
|||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
tsx: 4.23.12
|
tsx: 4.23.12
|
||||||
|
|
||||||
vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(typescript@6.0.3):
|
vitepress@1.6.4(@algolia/client-search@5.56.0)(@types/node@26.2.0)(lightningcss@1.33.0)(postcss@8.5.26)(search-insights@2.17.3)(sortablejs@1.14.0)(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@docsearch/css': 3.8.2
|
'@docsearch/css': 3.8.2
|
||||||
'@docsearch/js': 3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3)
|
'@docsearch/js': 3.8.2(@algolia/client-search@5.56.0)(search-insights@2.17.3)
|
||||||
@@ -8704,7 +8718,7 @@ snapshots:
|
|||||||
'@vue/devtools-api': 7.7.10
|
'@vue/devtools-api': 7.7.10
|
||||||
'@vue/shared': 3.5.41
|
'@vue/shared': 3.5.41
|
||||||
'@vueuse/core': 12.8.2(typescript@6.0.3)
|
'@vueuse/core': 12.8.2(typescript@6.0.3)
|
||||||
'@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.3)
|
'@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(sortablejs@1.14.0)(typescript@6.0.3)
|
||||||
focus-trap: 7.8.0
|
focus-trap: 7.8.0
|
||||||
mark.js: 8.11.1
|
mark.js: 8.11.1
|
||||||
minisearch: 7.2.0
|
minisearch: 7.2.0
|
||||||
@@ -8803,6 +8817,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|
||||||
|
vuedraggable-es@4.1.1(vue@3.5.41(typescript@6.0.3)):
|
||||||
|
dependencies:
|
||||||
|
sortablejs: 1.14.0
|
||||||
|
vue: 3.5.41(typescript@6.0.3)
|
||||||
|
|
||||||
w3c-keyname@2.2.8: {}
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
|
|||||||
Reference in New Issue
Block a user