diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index c1bf0309..7f9b025b 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -763,8 +763,17 @@ const chiefCenter = { })), }; -const install = async (page: Page, rejectGeneral = false, commandTableResponse: unknown = commandTable) => { +const install = async ( + page: Page, + rejectGeneral = false, + commandTableResponse: unknown = commandTable, + generalId = 1 +) => { const requests: unknown[] = []; + const currentGeneralContext = { + ...generalContext, + general: { ...generalContext.general, id: generalId }, + }; const generalTurns = turns(30); const nationTurns = turns(12); let generalRevision = 0; @@ -811,7 +820,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: ? { kind: 'snapshot', revision: 'AAAAAAAAAAAAAAAAAAAAAA', - data: generalContext, + data: currentGeneralContext, } : { kind: 'unchanged', revision: 'AAAAAAAAAAAAAAAAAAAAAA' }, commandTable: initial @@ -830,7 +839,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: : { kind: 'unchanged', revision: 'CCCCCCCCCCCCCCCCCCCCCC' }, }); } - if (name === 'general.me') return response(generalContext); + if (name === 'general.me') return response(currentGeneralContext); if (name === 'world.getMapLayout') return response({ mapName: 'che', @@ -844,7 +853,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: if (name === 'auth.status') return response({ ok: true }); if (name === 'lobby.info') return response({ - myGeneral: { id: 1, name: '장수' }, + myGeneral: { id: generalId, name: '장수' }, year: 200, month: 1, turnTerm: 10, @@ -1376,6 +1385,53 @@ test('keeps general and chief command categories after input and across page rel }); }); +test('keeps the general turn editor mode across general recreation within one server profile', async ({ + page, + context, +}) => { + await install(page, false, commandTable, 1); + await page.addInitScript(() => { + localStorage.setItem('core2026:general:1:editMode', '1'); + }); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/'); + + const profileModeKey = 'core2026:profile:che:general-turn-editor:editMode'; + const firstEditor = page.locator('[data-command-scope="general"]'); + await expect(firstEditor.getByRole('button', { name: '일반 모드', exact: true })).toBeVisible(); + await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), profileModeKey)).toBe('1'); + await page.evaluate(() => localStorage.removeItem('core2026:general:1:editMode')); + + const recreatedPage = await context.newPage(); + await install(recreatedPage, false, commandTable, 2); + await recreatedPage.setViewportSize({ width: 500, height: 900 }); + await recreatedPage.goto('/'); + const recreatedEditor = recreatedPage.locator('[data-command-scope="general"]'); + await expect(recreatedEditor.getByRole('button', { name: '일반 모드', exact: true })).toBeVisible(); + await expect + .poll(() => recreatedPage.evaluate(() => localStorage.getItem('core2026:general:2:editMode'))) + .toBeNull(); + await recreatedEditor.screenshot({ + path: test.info().outputPath('general-advanced-mode-after-recreation-mobile-500.png'), + }); + + await recreatedEditor.getByRole('button', { name: '일반 모드', exact: true }).click(); + await expect(recreatedEditor.getByRole('button', { name: '고급 모드', exact: true })).toBeVisible(); + await expect.poll(() => recreatedPage.evaluate((key) => localStorage.getItem(key), profileModeKey)).toBe('0'); + + const nextGeneralPage = await context.newPage(); + await install(nextGeneralPage, false, commandTable, 3); + await nextGeneralPage.goto('/'); + await expect( + nextGeneralPage.locator('[data-command-scope="general"]').getByRole('button', { + name: '고급 모드', + exact: true, + }) + ).toBeVisible(); + await nextGeneralPage.close(); + await recreatedPage.close(); +}); + test('shows all 12 advanced chief turns before the actions and uses the full mobile chief matrix', async ({ page }) => { await install(page); await page.setViewportSize({ width: 500, height: 900 }); diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 26b3fd02..04893e81 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -30,6 +30,7 @@ const props = withDefaults( commandTable: CommandTable | null; loading: boolean; storageKey: string; + editModeStorageKey?: string; maxPushTurn?: number; compact?: boolean; mobile?: boolean; @@ -41,6 +42,7 @@ const props = withDefaults( autonomousUntil?: string | null; }>(), { + editModeStorageKey: undefined, maxPushTurn: 6, compact: false, mobile: false, @@ -82,20 +84,20 @@ const editorElement = ref(null); const pickerElement = ref(null); const collapsedRowCount = 15; -const loadStorage = (key: string) => { - storage.value = new CommandStorage(key); +const loadStorage = (key: string, editModeKey: string | undefined) => { + storage.value = new CommandStorage(key, { editModeKey }); editMode.value = storage.value.editMode; activeCategory.value = storage.value.activeCategory; }; onMounted(() => { - loadStorage(props.storageKey); + loadStorage(props.storageKey, props.editModeStorageKey); }); watch( - () => props.storageKey, - (key, previousKey) => { - if (key !== previousKey) loadStorage(key); + () => [props.storageKey, props.editModeStorageKey] as const, + ([key, editModeKey], [previousKey, previousEditModeKey]) => { + if (key !== previousKey || editModeKey !== previousEditModeKey) loadStorage(key, editModeKey); } ); diff --git a/app/game-frontend/src/components/command/commandQueue.ts b/app/game-frontend/src/components/command/commandQueue.ts index f0c07fbe..16df238c 100644 --- a/app/game-frontend/src/components/command/commandQueue.ts +++ b/app/game-frontend/src/components/command/commandQueue.ts @@ -95,6 +95,18 @@ export const moveQueueRange = ( return next.map((entry, index) => ({ turnList: [index], ...entry })); }; +export const generalTurnEditorModeStorageKey = (profile: string | undefined, appBasePath: string): string => { + const runtimeProfile = profile?.trim().split(':', 1)[0]; + const basePathProfile = appBasePath.split('/').find((segment) => segment.length > 0); + const profileId = runtimeProfile || basePathProfile || 'default'; + return `core2026:profile:${encodeURIComponent(profileId)}:general-turn-editor`; +}; + +interface CommandStorageOptions { + maxRecent?: number; + editModeKey?: string; +} + export class CommandStorage { readonly recent = new Map(); readonly templates = new Map(); @@ -102,11 +114,13 @@ export class CommandStorage { editMode = false; activeCategory = ''; private readonly key: string; + private readonly editModeKey: string; private readonly maxRecent: number; - constructor(key: string, maxRecent = 10) { + constructor(key: string, options: CommandStorageOptions = {}) { this.key = key; - this.maxRecent = maxRecent; + this.editModeKey = options.editModeKey ?? key; + this.maxRecent = options.maxRecent ?? 10; this.load(); } @@ -126,12 +140,18 @@ export class CommandStorage { this.templates.set(name, entries); } this.clipboard = this.read('clipboard', undefined); - this.editMode = localStorage.getItem(`${this.key}:editMode`) === '1'; + const editModeStorageKey = `${this.editModeKey}:editMode`; + let storedEditMode = localStorage.getItem(editModeStorageKey); + if (storedEditMode === null && this.editModeKey !== this.key) { + storedEditMode = localStorage.getItem(`${this.key}:editMode`); + if (storedEditMode !== null) localStorage.setItem(editModeStorageKey, storedEditMode); + } + this.editMode = storedEditMode === '1'; this.activeCategory = this.read('category', ''); } saveState(): void { - localStorage.setItem(`${this.key}:editMode`, this.editMode ? '1' : '0'); + localStorage.setItem(`${this.editModeKey}:editMode`, this.editMode ? '1' : '0'); localStorage.setItem(`${this.key}:category`, JSON.stringify(this.activeCategory)); } diff --git a/app/game-frontend/src/components/main/CommandListPanel.vue b/app/game-frontend/src/components/main/CommandListPanel.vue index 3f269535..d7b30b15 100644 --- a/app/game-frontend/src/components/main/CommandListPanel.vue +++ b/app/game-frontend/src/components/main/CommandListPanel.vue @@ -2,8 +2,10 @@ import { computed, onUnmounted, ref, watch } from 'vue'; import { addMinutes } from 'date-fns'; import ReservedCommandEditor from '../command/ReservedCommandEditor.vue'; +import { generalTurnEditorModeStorageKey } from '../command/commandQueue'; import { formatLocalDateTime, formatLocalTimeSeconds } from '../../utils/legacyDateTime'; import { projectServerClock, sampleServerClock, type SampledServerClock } from '../../utils/serverClockProjection'; +import { gameFrontendRuntimeConfig } from '../../config/runtimeConfig'; import type { CommandMapData, CommandMapLayout, @@ -38,6 +40,11 @@ const emit = defineEmits<{ (event: 'repeat-general-turns', amount: number): void; }>(); +const editModeStorageKey = generalTurnEditorModeStorageKey( + gameFrontendRuntimeConfig.profile, + gameFrontendRuntimeConfig.appBasePath +); + const labelMap = computed(() => { const result = new Map([['휴식', '휴식']]); for (const group of props.commandTable?.general ?? []) { @@ -140,6 +147,7 @@ onUnmounted(() => { :command-table="props.commandTable" :loading="props.loading" :storage-key="props.storageKey ?? `core2026:general:${props.general?.id ?? 0}`" + :edit-mode-storage-key="editModeStorageKey" :current-time="currentServerTime" :map-data="props.mapData" :map-layout="props.mapLayout" diff --git a/app/game-frontend/test/commandQueue.test.ts b/app/game-frontend/test/commandQueue.test.ts index d5ebc596..f00124f6 100644 --- a/app/game-frontend/test/commandQueue.test.ts +++ b/app/game-frontend/test/commandQueue.test.ts @@ -4,11 +4,19 @@ import test from 'node:test'; import { amplifyPattern, extractPattern, + generalTurnEditorModeStorageKey, moveQueueRange, normalizedSelection, selectStep, } from '../src/components/command/commandQueue.ts'; +void test('scopes the general turn editor mode to the stable server profile', () => { + assert.equal(generalTurnEditorModeStorageKey('che:default', '/che/'), 'core2026:profile:che:general-turn-editor'); + assert.equal(generalTurnEditorModeStorageKey('che:2601', '/che/'), 'core2026:profile:che:general-turn-editor'); + assert.equal(generalTurnEditorModeStorageKey('hwe:2601', '/hwe/'), 'core2026:profile:hwe:general-turn-editor'); + assert.equal(generalTurnEditorModeStorageKey(undefined, '/nya/'), 'core2026:profile:nya:general-turn-editor'); +}); + const rows = ['A', 'B', 'A', 'C', '휴식', '휴식'].map((action, index) => ({ index, action,