merge: 최신 main을 Gateway 초기화 수명주기 브랜치에 통합

This commit is contained in:
2026-08-21 01:18:28 +00:00
14 changed files with 512 additions and 192 deletions
+33 -7
View File
@@ -6,10 +6,7 @@ import {
} from '@sammo-ts/logic';
import { describe, expect, it } from 'vitest';
import {
buildTurnCommandInputFields,
parseReservedTurnArgs,
} from '../src/turns/commandInput.js';
import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js';
describe('turn command argument input', () => {
it('builds supported fields for every argument-bearing command module', async () => {
@@ -22,6 +19,9 @@ describe('turn command argument input', () => {
key: spec.key,
fields: buildTurnCommandInputFields(spec),
}));
const nationFields = nation
.filter((spec) => spec.reqArg)
.map((spec) => ({ key: spec.key, fields: buildTurnCommandInputFields(spec) }));
expect(argumentSpecs).toHaveLength(44);
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: '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 () => {
@@ -50,8 +78,6 @@ describe('turn command argument input', () => {
amount: 200,
destGeneralId: 7,
});
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow(
'Unknown general turn command'
);
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
});
});
+67 -18
View File
@@ -522,6 +522,21 @@ const commandTable = {
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] },
],
regionMap: { 1: '하북', 2: '예주' },
levelMap: { 8: '특' },
levelMap: { 8: '특', 7: '대' },
});
if (name === 'auth.status') return response({ ok: true });
if (name === 'lobby.info')
@@ -1778,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 });
});
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 page.setViewportSize({ width: 1200, height: 900 });
const cases = [
{ 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: '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) {
@@ -1803,24 +1825,51 @@ test('shows city or capital maps for every requested chief command', async ({ pa
await expect(map, `${entry.action} 지도`).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.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 }) => {
+65
View File
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { basename, resolve } from 'node:path';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js';
import { touchDrag } from './touchDrag.js';
const response = (data: unknown) => ({ result: { data } });
const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR;
@@ -1469,6 +1470,70 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버
.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 [
['daemon timeout', 'TIMEOUT'],
['engine transaction 오류', 'INTERNAL_SERVER_ERROR'],
+53
View File
@@ -3,6 +3,7 @@ import { mkdir, readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gameProfile, gameTrpcRoute } from './gameTestPaths.js';
import { touchDrag } from './touchDrag.js';
type FixtureState = {
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');
});
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 }) => {
const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] };
await installFixture(page, state);
+77
View File
@@ -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');
}
};
+1
View File
@@ -47,6 +47,7 @@
"mitt": "^3.0.1",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vuedraggable-es": "4.1.1",
"vue-router": "^4.6.4",
"zod": "^4.3.5"
},
@@ -1,6 +1,10 @@
import type { CommandInputField } from './types';
export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital';
export type CommandArgumentPresentation = {
lines: string[];
mapTarget?: 'city' | 'nation' | 'capital';
mapTarget?: CommandArgumentMapTarget;
};
const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' });
@@ -98,4 +102,18 @@ const PRESENTATIONS: Record<string, CommandArgumentPresentation> = {
export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation =>
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);
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, reactive, watch, type CSSProperties } from 'vue';
import MapViewer from './MapViewer.vue';
import { commandArgumentPresentation } from '../command/commandArgumentPresentation';
import { commandArgumentPresentation, resolveCommandArgumentMapTarget } from '../command/commandArgumentPresentation';
import { commandCityOptions } from '../command/commandArgumentOptions';
import {
commandArgumentFieldContract,
@@ -161,20 +161,15 @@ const nationTargetField = computed(() =>
(field) => field.kind === 'select' && field.optionSource === 'nations' && field.key === 'destNationId'
)
);
const showMap = computed(
() =>
Boolean(props.mapData && props.mapLayout) &&
((presentation.value.mapTarget === 'city' && cityTargetField.value) ||
(presentation.value.mapTarget === 'nation' && nationTargetField.value) ||
presentation.value.mapTarget === 'capital')
);
const mapTarget = computed(() => resolveCommandArgumentMapTarget(props.commandKey, props.fields));
const showMap = computed(() => Boolean(props.mapData && props.mapLayout && mapTarget.value));
const mapSelectedCityId = computed<number | 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];
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];
if (typeof value !== 'number') return null;
return (
@@ -183,7 +178,7 @@ const mapSelectedCityId = computed<number | null>(() => {
null
);
}
if (presentation.value.mapTarget === 'capital') {
if (mapTarget.value === 'capital') {
const myNation = props.mapData.myNation;
return props.mapData.nationList.find((entry) => entry[0] === myNation)?.[3] ?? null;
}
@@ -197,17 +192,17 @@ const currentCityName = computed(() => {
});
const selectedMapTargetName = computed(() => {
if (presentation.value.mapTarget === 'city') {
if (mapTarget.value === 'city') {
const cityId = mapSelectedCityId.value;
if (!cityId) return '-';
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];
if (typeof nationId !== 'number') return '-';
return props.mapData?.nationList.find((nation) => nation[0] === nationId)?.[1] ?? '-';
}
if (presentation.value.mapTarget === 'capital') {
if (mapTarget.value === 'capital') {
const cityId = mapSelectedCityId.value;
if (!cityId) return '-';
return props.mapLayout?.cityList.find((city) => city.id === cityId)?.name ?? '-';
@@ -239,7 +234,7 @@ const distanceFromMyCity = (destination: number): number | null => {
const mapTargetSummary = computed(() => {
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 dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return '';
@@ -255,7 +250,7 @@ const mapTargetSummary = computed(() => {
.filter(Boolean)
.join(' · ');
}
if (presentation.value.mapTarget === 'nation' && nationTargetField.value) {
if (mapTarget.value === 'nation' && nationTargetField.value) {
const value = values[nationTargetField.value.key];
if (typeof value !== 'number') return '';
const nation = props.mapData.nationList.find((entry) => entry[0] === value);
@@ -264,7 +259,7 @@ const mapTargetSummary = computed(() => {
const cityCount = props.mapData.cityList.filter((entry) => entry[3] === value).length;
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 dynamic = props.mapData.cityList.find((entry) => entry[0] === mapSelectedCityId.value);
if (!city) return '';
@@ -277,11 +272,11 @@ const mapTargetSummary = computed(() => {
const selectMapCity = (cityId: number) => {
if (!props.mapData) return;
if (presentation.value.mapTarget === 'city' && cityTargetField.value) {
if (mapTarget.value === 'city' && cityTargetField.value) {
setSelectValue(cityTargetField.value, String(cityId));
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];
if (nationId && nationId > 0) setSelectValue(nationTargetField.value, String(nationId));
}
@@ -416,24 +411,20 @@ watch(
:detail-mode="true"
:fit-container="true"
:show-current-city-marker="true"
:readonly="presentation.mapTarget === 'capital'"
:readonly="mapTarget === 'capital'"
@select-city="selectMapCity"
/>
<small v-if="presentation.mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
<small v-if="mapTarget === 'capital'">현재 명령이 적용될 수도를 지도에서 확인하세요.</small>
<small v-else>지도에서 도시를 클릭하거나 아래 목록에서 대상을 선택하세요.</small>
<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>
<strong>{{ currentCityName }}</strong>
</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="status-key">{{
presentation.mapTarget === 'nation'
? '선택 국가'
: presentation.mapTarget === 'capital'
? '현재 수도'
: '선택 도시'
mapTarget === 'nation' ? '선택 국가' : mapTarget === 'capital' ? '현재 수도' : '선택 도시'
}}</span>
<strong>{{ selectedMapTargetName }}</strong>
</span>
@@ -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 }),
}
);
},
});
+37 -55
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import SortableStringList from '../components/ui/SortableStringList';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
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 { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue';
@@ -58,7 +59,6 @@ const selectedIconId = ref('');
const cssSaving = ref(false);
const mobileLayoutDialog = ref<HTMLDialogElement | null>(null);
const mobileLayoutOrder = ref<MobileMainPanelId[]>(loadMobileMainPanelOrder());
const mobileLayoutDragIndex = ref<number | null>(null);
const session = useSessionStore();
let cssTimer: number | null = null;
const readPendingDieOnPrestartId = (): string => {
@@ -180,9 +180,9 @@ const items = computed<Array<{ key: ItemSlotKey; slotName: string; displayName:
);
const iconChoices = computed(() => data.value?.iconChoices ?? []);
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])
) as Record<MobileMainPanelId, string>;
);
const openMobileLayoutDialog = () => {
mobileLayoutOrder.value = loadMobileMainPanelOrder();
@@ -194,20 +194,6 @@ const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => {
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 = () => {
mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER];
};
@@ -697,7 +683,6 @@ onMounted(() => {
ref="mobileLayoutDialog"
class="mobile-layout-dialog"
aria-labelledby="mobile-layout-dialog-title"
@close="mobileLayoutDragIndex = null"
>
<div class="mobile-layout-dialog__header">
<h2 id="mobile-layout-dialog-title">모바일 레이아웃 순서 바꾸기</h2>
@@ -706,42 +691,39 @@ onMounted(() => {
</form>
</div>
<p>항목을 끌어 놓거나 ·아래 버튼으로 상대 순서를 바꿉니다.</p>
<ol class="mobile-layout-list">
<li
v-for="(panelId, index) in mobileLayoutOrder"
:key="panelId"
:data-mobile-layout-id="panelId"
draggable="true"
@dragstart="startMobileLayoutDrag($event, index)"
@dragend="mobileLayoutDragIndex = null"
@dragover.prevent
@drop.stop="dropMobileLayoutItem($event, index)"
>
<span class="mobile-layout-handle" aria-hidden="true"></span>
<span class="mobile-layout-label">
<span class="mobile-layout-position">{{ index + 1 }}</span>
{{ mobileLayoutLabels[panelId] }}
</span>
<span class="mobile-layout-move-buttons">
<button
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
:disabled="index === 0"
@click="moveMobileLayoutItem(index, index - 1)"
>
</button>
<button
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
:disabled="index === mobileLayoutOrder.length - 1"
@click="moveMobileLayoutItem(index, index + 1)"
>
</button>
</span>
</li>
</ol>
<SortableStringList
:list="mobileLayoutOrder"
tag="ol"
class="mobile-layout-list"
>
<template #item="{ element: panelId, index }">
<li :data-mobile-layout-id="panelId">
<span class="mobile-layout-handle" aria-hidden="true"></span>
<span class="mobile-layout-label">
<span class="mobile-layout-position">{{ index + 1 }}</span>
{{ mobileLayoutLabels[panelId] }}
</span>
<span class="mobile-layout-move-buttons">
<button
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 위로`"
:disabled="index === 0"
@click="moveMobileLayoutItem(index, index - 1)"
>
</button>
<button
type="button"
:aria-label="`${mobileLayoutLabels[panelId]} 아래로`"
:disabled="index === mobileLayoutOrder.length - 1"
@click="moveMobileLayoutItem(index, index + 1)"
>
</button>
</span>
</li>
</template>
</SortableStringList>
<div class="mobile-layout-dialog__actions">
<button type="button" @click="resetMobileLayoutOrder">기본값</button>
<form method="dialog"><button type="submit">취소</button></form>
+44 -79
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import SortableStringList from '../components/ui/SortableStringList';
import { npcPriorityHelp } from '../utils/npcPriorityHelp';
import { trpc } from '../utils/trpc';
@@ -8,7 +9,6 @@ type NpcPolicyResponse = Awaited<ReturnType<typeof trpc.npc.getPolicy.query>>;
type NationPolicy = NpcPolicyResponse['currentNationPolicy'];
type NumericPolicyKey = Exclude<keyof NationPolicy, 'CombatForce' | 'SupportForce' | 'DevelopForce'>;
type PrioritySectionKey = 'nation' | 'general';
type PriorityBucket = 'active' | 'inactive';
interface PolicyField {
key: NumericPolicyKey;
@@ -35,12 +35,6 @@ interface PriorityPanel {
state: PriorityListState;
}
interface DragState {
section: PrioritySectionKey;
bucket: PriorityBucket;
index: number;
}
const loading = ref(false);
const error = 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 lastSavedNationPriority = ref<string[]>([]);
const lastSavedGeneralPriority = ref<string[]>([]);
const dragState = ref<DragState | null>(null);
const resolveErrorMessage = (value: unknown): string => {
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>
<template>
@@ -474,66 +447,58 @@ const dropPriority = (event: DragEvent, section: PrioritySectionKey, bucket: Pri
<div class="priority-columns">
<div class="priority-column">
<div class="sub_bar legacy-bg2">비활성</div>
<div
<SortableStringList
:list="panel.state.inactive"
:group="`npc-priority-${panel.key}`"
tag="div"
class="priority-list"
@dragover.prevent
@drop="dropPriority($event, panel.key, 'inactive')"
>
<div class="inactive-header">&lt;비활성화 항목들&gt;</div>
<div
v-for="(item, index) in panel.state.inactive"
:key="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">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
<template #header>
<div class="inactive-header">&lt;비활성화 항목들&gt;</div>
</template>
<template #item="{ element: item }">
<div class="priority-item">
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
</div>
</div>
</div>
</div>
</template>
</SortableStringList>
</div>
<div class="priority-column">
<div class="sub_bar legacy-bg2">활성</div>
<div
<SortableStringList
:list="panel.state.active"
:group="`npc-priority-${panel.key}`"
tag="div"
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)"
>
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
<template #item="{ element: item }">
<div class="priority-item">
<div class="priority_info">
<span class="drag-handle"></span>
<span>{{ item }}</span>
<button
class="help-button"
type="button"
:aria-label="`${item} 설명`"
:data-text="npcPriorityHelp[item] ?? '설명 없음'"
>
?
</button>
</div>
</div>
</div>
</div>
</template>
</SortableStringList>
</div>
</div>
<div class="control_bar priority-control">
@@ -4,6 +4,7 @@ import test from 'node:test';
import {
commandArgumentPresentation,
presentedCommandKeys,
resolveCommandArgumentMapTarget,
} from '../src/components/command/commandArgumentPresentation.ts';
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);
}
});
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 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 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.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['"]/);
});
});