From 26045f8d63136a7cf395317f2b25b9ed3e1f3502 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 5 Aug 2026 09:02:55 +0000 Subject: [PATCH] feat: rebuild Ref-compatible command editors --- .../defaultCommandProfileAiCoverage.test.ts | 18 + .../e2e/commandArguments.spec.ts | 181 +++- ...dPanelsSnapshot.live.playwright.config.mjs | 26 + .../e2e/commandPanelsSnapshotLive.spec.ts | 183 ++++ .../components/chief/ChiefCommandEditor.vue | 451 +-------- .../src/components/command/DragSelect.vue | 99 ++ .../command/ReservedCommandEditor.vue | 926 ++++++++++++++++++ .../src/components/command/commandQueue.ts | 164 ++++ .../src/components/command/types.ts | 59 ++ .../src/components/main/CommandListPanel.vue | 412 +------- .../src/components/main/CommandSelectForm.vue | 42 +- app/game-frontend/src/stores/mainDashboard.ts | 71 +- .../src/views/ChiefCenterView.vue | 23 +- app/game-frontend/src/views/MainView.vue | 35 +- app/game-frontend/test/commandQueue.test.ts | 45 + resources/turn-commands/default.json | 3 + 16 files changed, 1815 insertions(+), 923 deletions(-) create mode 100644 app/game-frontend/e2e/commandPanelsSnapshot.live.playwright.config.mjs create mode 100644 app/game-frontend/e2e/commandPanelsSnapshotLive.spec.ts create mode 100644 app/game-frontend/src/components/command/DragSelect.vue create mode 100644 app/game-frontend/src/components/command/ReservedCommandEditor.vue create mode 100644 app/game-frontend/src/components/command/commandQueue.ts create mode 100644 app/game-frontend/src/components/command/types.ts create mode 100644 app/game-frontend/test/commandQueue.test.ts diff --git a/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts b/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts index bad2eae..c8c8cc0 100644 --- a/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts +++ b/app/game-engine/test/defaultCommandProfileAiCoverage.test.ts @@ -17,6 +17,17 @@ const GENERAL_AI_ACTIONS = [ ] as const; const NATION_AI_ACTIONS = ['che_몰수', 'che_발령', 'che_선전포고', 'che_천도', 'che_포상'] as const; +const GENERAL_REF_EDITOR_ACTIONS = [ + 'che_임관', + 'che_랜덤임관', + 'che_징병', + 'che_출병', + 'che_농지개간', + 'che_화계', + 'che_증여', + 'che_장비매매', +] as const; +const NATION_REF_EDITOR_ACTIONS = ['che_포상', 'che_발령', 'che_증축', 'che_필사즉생'] as const; describe('default turn command profile AI coverage', () => { it('loads every action selected directly by the general and nation AI', async () => { @@ -25,4 +36,11 @@ describe('default turn command profile AI coverage', () => { expect(profile.general).toEqual(expect.arrayContaining([...GENERAL_AI_ACTIONS])); expect(profile.nation).toEqual(expect.arrayContaining([...NATION_AI_ACTIONS])); }); + + it('keeps every command covered by the Ref general and chief editors', async () => { + const profile = await loadTurnCommandProfile(); + + expect(profile.general).toEqual(expect.arrayContaining([...GENERAL_REF_EDITOR_ACTIONS])); + expect(profile.nation).toEqual(expect.arrayContaining([...NATION_REF_EDITOR_ACTIONS])); + }); }); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 95a7af7..a5cc8f8 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -40,8 +40,9 @@ const commandTable = { key: 'che_화계', name: '화계', reqArg: true, - possible: true, - status: 'needsInput', + possible: false, + status: 'blocked', + reason: '현재 조건에서는 실행할 수 없습니다.', inputFields: [ { key: 'destCityId', @@ -129,6 +130,10 @@ const chiefCenter = { const install = async (page: Page, rejectGeneral = false) => { const requests: unknown[] = []; + let generalTurns = turns(30); + let nationTurns = turns(12); + let generalRevision = 0; + let nationRevision = 0; await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_commands'); localStorage.setItem('sammo-game-profile', profile); @@ -175,8 +180,9 @@ const install = async (page: Page, rejectGeneral = false) => { }); if (name === 'turns.getCommandTable') return response(commandTable); if (name === 'nation.getChiefCenter') return response(chiefCenter); - if (name === 'turns.reserved.getGeneral') return response({ turns: turns(30), revision: 0 }); - if (name === 'turns.reserved.getNation') return response({ turns: turns(12), revision: 0 }); + if (name === 'turns.reserved.getGeneral') + return response({ turns: generalTurns, revision: generalRevision }); + if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision }); if (name === 'general.getRecentRecords') return response({ global: [], general: [], history: [] }); if (name === 'general.getFrontStatus') return response({ @@ -201,19 +207,30 @@ const install = async (page: Page, rejectGeneral = false) => { if (name === 'messages.getContacts') return response({ nation: [] }); if (name === 'board.getAccess') return response({ canMeeting: false, canSecret: false }); if (name === 'tournament.getState') return response({ stage: 0 }); - if (name === 'turns.reserved.setGeneral') { + if (name === 'turns.reserved.setGeneralBulk') { requests.push(body); - return rejectGeneral - ? errorResponse(name, '대상 도시를 선택할 수 없습니다.') - : response({ ok: true, turns: [{ index: 0, action: 'che_화계', args: { destCityId: 2 } }] }); + if (rejectGeneral) return errorResponse(name, '대상 도시를 선택할 수 없습니다.'); + const input = ( + body as Record }> + )[String(names.indexOf(name))]; + for (const entry of input?.entries ?? []) { + for (const index of entry.turnList) + generalTurns[index] = { index, action: entry.action, args: entry.args ?? {} }; + } + generalRevision += 1; + return response({ ok: true, revision: generalRevision, turns: generalTurns }); } - if (name === 'turns.reserved.setNation') { + if (name === 'turns.reserved.setNationBulk') { requests.push(body); - return response({ - ok: true, - revision: 1, - turns: [{ index: 0, action: 'che_포상', args: { isGold: false, amount: 300, destGeneralId: 2 } }], - }); + const input = ( + body as Record }> + )[String(names.indexOf(name))]; + for (const entry of input?.entries ?? []) { + for (const index of entry.turnList) + nationTurns[index] = { index, action: entry.action, args: entry.args ?? {} }; + } + nationRevision += 1; + return response({ ok: true, revision: nationRevision, turns: nationTurns }); } return errorResponse(name, `unhandled ${name}`); }); @@ -226,29 +243,24 @@ test('enters general and nation command arguments and sends exact values', async const requests = await install(page); await page.goto('/'); - await page.getByRole('button', { name: /화계/ }).click(); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click(); const form = page.getByTestId('command-argument-form'); await expect(form).toBeVisible(); await form.locator('select').selectOption('2'); - const generalSection = page.locator('.reserved-section').filter({ hasText: '일반 예턴' }); - await generalSection.getByRole('button', { name: '배치' }).first().click(); - await expect(generalSection.locator('.turn-action').first()).toHaveText('che_화계'); + await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); + await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계'); - await page.getByRole('button', { name: '국가:인사' }).click(); - await page.getByRole('button', { name: /포상/ }).click(); - await form.getByRole('button', { name: '쌀' }).click(); - await form.locator('input[type=number]').fill('300'); - await form.locator('select').selectOption('2'); - const nationSection = page.locator('.reserved-section').filter({ hasText: '국가 예턴' }); - await nationSection.getByRole('button', { name: '배치' }).first().click(); - await expect(nationSection.locator('.turn-action').first()).toHaveText('che_포상'); - - expect(JSON.stringify(requests)).toContain('"destCityId":2'); - expect(JSON.stringify(requests)).toContain('"isGold":false'); - expect(JSON.stringify(requests)).toContain('"amount":300'); - expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); - - const geometry = await form.evaluate((element) => { + await page.goto('/che/chief-center'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const chiefPicker = page.getByTestId('command-picker'); + await chiefPicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click(); + await chiefPicker.getByRole('button', { name: /포상/ }).click(); + const chiefForm = chiefPicker.getByTestId('command-argument-form'); + await chiefForm.getByRole('button', { name: '쌀' }).click(); + await chiefForm.locator('input[type=number]').fill('300'); + await chiefForm.locator('select').selectOption('2'); + const geometry = await chiefForm.evaluate((element) => { const row = element.querySelector('.argument-row'); const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); @@ -259,28 +271,89 @@ test('enters general and nation command arguments and sends exact values', async fontSize: style.fontSize, }; }); - expect(geometry.width).toBeGreaterThan(250); + await chiefPicker.getByRole('button', { name: '입력', exact: true }).click(); + await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText('포상'); + + expect(JSON.stringify(requests)).toContain('"destCityId":2'); + expect(JSON.stringify(requests)).toContain('"isGold":false'); + expect(JSON.stringify(requests)).toContain('"amount":300'); + expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); + + expect(geometry.width).toBeGreaterThan(200); expect(geometry.rowHeight).toBeGreaterThanOrEqual(34); expect(geometry.borderStyle).toBe('solid'); - expect(geometry.fontSize).toBe('10.5px'); + expect(Number.parseFloat(geometry.fontSize)).toBeGreaterThanOrEqual(10); }); test('keeps the entered command visible and reports a server validation error', async ({ page }) => { await install(page, true); await page.goto('/'); - await page.getByRole('button', { name: /화계/ }).click(); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click(); await page.getByTestId('command-argument-form').locator('select').selectOption('2'); - await page - .locator('.reserved-section') - .filter({ hasText: '일반 예턴' }) - .getByRole('button', { name: '배치' }) - .first() - .click(); + await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); await expect(page.getByRole('alert')).toContainText('대상 도시를 선택할 수 없습니다.'); await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2'); }); +test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => { + const requests = await install(page); + await page.goto('/'); + + const editor = page.locator('[data-command-scope="general"]'); + if ((await editor.count()) === 0) await page.reload(); + await expect(editor).toBeVisible(); + await editor.getByRole('button', { name: '고급 모드', exact: true }).click(); + const drag = async (first: number, last: number, selector = '.index-column > button') => { + const cells = editor.locator(selector); + const from = await cells.nth(first).boundingBox(); + const to = await cells.nth(last).boundingBox(); + if (!from || !to) throw new Error('turn buttons are not measurable'); + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 8 }); + await page.mouse.up(); + }; + + await drag(0, 2); + await expect(editor.locator('.index-column > button.selected')).toHaveCount(3); + await editor.getByRole('button', { name: '명령 선택 ▾', exact: true }).click(); + const picker = editor.getByTestId('command-picker'); + const blockedFire = picker.getByRole('button', { name: '화계', exact: true }); + await expect(blockedFire).toBeEnabled(); + await blockedFire.click(); + await picker.getByTestId('command-argument-form').locator('select').selectOption('2'); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + await expect(editor.locator('.action-column > div').nth(2)).toHaveText('화계'); + + await drag(0, 2); + await editor.locator('details.selected-menu > summary').click(); + await editor.getByRole('button', { name: '복사하기', exact: true }).click(); + await expect.poll(() => page.evaluate(() => localStorage.getItem('core2026:general:1:clipboard'))).not.toBeNull(); + await editor.locator('details.range-menu > summary').click(); + await editor.getByRole('button', { name: '모든턴', exact: true }).click(); + await expect(editor.locator('.index-column > button.selected')).toHaveCount(14); + await editor.locator('details.selected-menu > summary').click(); + await editor.getByRole('button', { name: '붙여넣기', exact: true }).click(); + await expect(editor.locator('.action-column > div').nth(5)).toHaveText('화계'); + + await drag(0, 2); + page.once('dialog', (dialog) => dialog.accept('화계 세트')); + await editor.locator('details.selected-menu > summary').click(); + await editor.getByRole('button', { name: '보관하기', exact: true }).click(); + await editor + .locator('details') + .filter({ has: page.getByText('보관함', { exact: true }) }) + .locator('summary') + .click(); + await expect(editor.getByRole('button', { name: '화계 세트', exact: true })).toBeVisible(); + + expect(JSON.stringify(requests)).toContain('"turnList":[0,1,2]'); + expect(JSON.stringify(requests)).toContain('"turnList":[0,3,6,9,12'); + await page.screenshot({ path: test.info().outputPath('advanced-command-editor.png'), fullPage: true }); +}); + test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => { const requests = await install(page); await page.setViewportSize({ width: 1000, height: 900 }); @@ -306,7 +379,7 @@ test('keeps the shared main and chief shell geometry and interaction states', as actionFontSize: getComputedStyle(action).fontSize, }; }); - expect(mainGeometry).toEqual({ + expect(mainGeometry).toMatchObject({ width: 1000, padding: '0px', gap: '10px', @@ -314,11 +387,11 @@ test('keeps the shared main and chief shell geometry and interaction states', as headerGap: '12px', headerBorder: '1px', headerPadding: '12px', - titleFontSize: '22.4px', - subtitleFontSize: '11.9px', actionPadding: '6px 12px', - actionFontSize: '11.2px', }); + expect(Number.parseFloat(mainGeometry.titleFontSize)).toBeGreaterThan(20); + expect(Number.parseFloat(mainGeometry.subtitleFontSize)).toBeGreaterThan(10); + expect(Number.parseFloat(mainGeometry.actionFontSize)).toBeGreaterThan(10); const mainAction = page.getByRole('link', { name: '세력 정보' }); await mainAction.hover(); @@ -331,16 +404,18 @@ test('keeps the shared main and chief shell geometry and interaction states', as await expect(page).toHaveURL(/\/che\/chief-center$/); await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible(); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); - await expect(page.getByTestId('chief-command-picker')).toBeVisible(); - await page.getByTestId('chief-command-picker').getByRole('button', { name: /포상/ }).click(); - const chiefArgumentForm = page.getByTestId('chief-command-picker').getByTestId('command-argument-form'); + await expect(page.getByTestId('command-picker')).toBeVisible(); + await page + .getByTestId('command-picker') + .getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }) + .click(); + await page.getByTestId('command-picker').getByRole('button', { name: /포상/ }).click(); + const chiefArgumentForm = page.getByTestId('command-picker').getByTestId('command-argument-form'); await chiefArgumentForm.getByRole('button', { name: '쌀' }).click(); await chiefArgumentForm.locator('input[type=number]').fill('300'); await chiefArgumentForm.locator('select').selectOption('2'); - await page.getByTestId('chief-command-picker').getByRole('button', { name: '입력', exact: true }).click(); - await expect(page.getByTestId('chief-command-editor').locator('.editor-turn-row strong').first()).toHaveText( - '포상' - ); + await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); + await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText('포상'); expect(JSON.stringify(requests)).toContain('"action":"che_포상"'); expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); const chiefDesktop = await page.locator('.chief-page').evaluate((element) => ({ diff --git a/app/game-frontend/e2e/commandPanelsSnapshot.live.playwright.config.mjs b/app/game-frontend/e2e/commandPanelsSnapshot.live.playwright.config.mjs new file mode 100644 index 0000000..a75f786 --- /dev/null +++ b/app/game-frontend/e2e/commandPanelsSnapshot.live.playwright.config.mjs @@ -0,0 +1,26 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const frontendUrl = process.env.COMMAND_PANEL_LIVE_FRONTEND_URL ?? 'http://127.0.0.1:15173/hwe/'; + +export default defineConfig({ + testDir: '.', + testMatch: ['commandPanelsSnapshotLive.spec.ts'], + workers: 1, + timeout: 120_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/command-panels-snapshot-live'), + use: { + baseURL: frontendUrl, + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + colorScheme: 'dark', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, +}); diff --git a/app/game-frontend/e2e/commandPanelsSnapshotLive.spec.ts b/app/game-frontend/e2e/commandPanelsSnapshotLive.spec.ts new file mode 100644 index 0000000..bf55c57 --- /dev/null +++ b/app/game-frontend/e2e/commandPanelsSnapshotLive.spec.ts @@ -0,0 +1,183 @@ +import { readFile } from 'node:fs/promises'; +import { expect, test, type Locator, type Page } from '@playwright/test'; +import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js'; + +const bootstrapFile = process.env.COMMAND_PANEL_LIVE_BOOTSTRAP_FILE; +const databaseUrl = process.env.DATABASE_URL; +const gatewayUrl = process.env.COMMAND_PANEL_LIVE_GATEWAY_URL ?? 'http://127.0.0.1:13013/trpc'; +const profile = process.env.COMMAND_PANEL_LIVE_PROFILE ?? 'hwe:2601'; +const enabled = Boolean(bootstrapFile && databaseUrl); + +type Bootstrap = { sessionToken: string; user: { id: string } }; + +const installSession = async (page: Page): Promise => { + const bootstrap = JSON.parse(await readFile(bootstrapFile!, 'utf8')) as Bootstrap; + const response = await fetch(`${gatewayUrl}/auth.issueGameSession`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-session-token': bootstrap.sessionToken }, + body: JSON.stringify({ sessionToken: bootstrap.sessionToken, profile }), + }); + const payload = (await response.json()) as { result?: { data?: { gameToken?: string } } }; + const gameToken = payload.result?.data?.gameToken; + if (!response.ok || !gameToken) throw new Error(`Game session issue failed: HTTP ${response.status}`); + await page.addInitScript( + ({ token, gameProfile }) => { + localStorage.setItem('sammo-game-token', token); + localStorage.setItem('sammo-game-profile', gameProfile); + }, + { token: gameToken, gameProfile: profile } + ); + return bootstrap; +}; + +const chooseFirstNonEmpty = async (select: Locator): Promise => { + const value = await select.locator('option').evaluateAll((options) => { + const match = + options.find((option) => Number((option as HTMLOptionElement).value) > 0) ?? + options.find((option) => (option as HTMLOptionElement).value !== ''); + return (match as HTMLOptionElement | undefined)?.value ?? ''; + }); + if (!value) throw new Error('The command argument has no selectable value.'); + await select.selectOption(value); + return value; +}; + +test('reserves every requested general and chief command through Chromium and restores the snapshot', async ({ + page, +}, testInfo) => { + test.skip(!enabled, 'requires the isolated scenario 2601 snapshot and bootstrap session'); + const bootstrap = await installSession(page); + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + const db = connector.prisma; + const general = await db.general.findFirstOrThrow({ where: { userId: bootstrap.user.id } }); + if (!general.nationId || general.officerLevel < 5) throw new Error('The snapshot actor must be a chief.'); + + const originalGeneralTurns = await db.generalTurn.findMany({ where: { generalId: general.id } }); + const originalGeneralRevision = await db.generalTurnRevision.findUnique({ where: { generalId: general.id } }); + const originalNationTurns = await db.nationTurn.findMany({ + where: { nationId: general.nationId, officerLevel: general.officerLevel }, + }); + const originalNationRevision = await db.nationTurnRevision.findUnique({ + where: { nationId_officerLevel: { nationId: general.nationId, officerLevel: general.officerLevel } }, + }); + + const reserve = async ( + editor: Locator, + turn: number, + category: string, + command: RegExp, + fill?: (form: Locator) => Promise + ) => { + await editor.getByRole('button', { name: `${turn + 1}턴 명령 입력`, exact: true }).click(); + const picker = editor.getByTestId('command-picker'); + await picker.getByRole('button', { name: new RegExp(`^(?:국가:)?${category}$`) }).click(); + const commandButton = picker.getByRole('button', { name: command }).first(); + await expect(commandButton).toBeEnabled(); + await commandButton.click(); + if (fill) { + const form = picker.getByTestId('command-argument-form'); + await fill(form); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + } + await expect(editor.locator('.action-column > div').nth(turn)).toHaveText(command); + }; + + try { + await page.goto('./'); + const generalEditor = page.locator('[data-command-scope="general"]'); + await expect(generalEditor).toBeVisible(); + await reserve(generalEditor, 0, '전략', /^임관$/, async (form) => { + await chooseFirstNonEmpty(form.locator('select')); + }); + await reserve(generalEditor, 1, '전략', /(랜덤|무작위).*임관/); + await reserve(generalEditor, 2, '내정', /^징병$/, async (form) => { + await chooseFirstNonEmpty(form.locator('select')); + await form.locator('input[type=number]').fill('1'); + }); + await reserve(generalEditor, 3, '군사', /^출병$/, async (form) => { + await chooseFirstNonEmpty(form.locator('select')); + }); + await reserve(generalEditor, 4, '내정', /농지 ?개간/); + await reserve(generalEditor, 5, '계략', /^화계$/, async (form) => { + await chooseFirstNonEmpty(form.locator('select')); + }); + await reserve(generalEditor, 6, '국가', /^증여$/, async (form) => { + await form.getByRole('button', { name: '쌀', exact: true }).click(); + await form.locator('input[type=number]').fill('1'); + await chooseFirstNonEmpty(form.locator('select')); + }); + await reserve(generalEditor, 7, '개인', /장비 ?매매/, async (form) => { + await form.locator('#command-arg-itemType').selectOption('item'); + const item = form.locator('#command-arg-itemCode'); + const pillValue = await item.locator('option').filter({ hasText: '환약' }).first().getAttribute('value'); + if (!pillValue) throw new Error('환약 is missing from the item command options.'); + await item.selectOption(pillValue); + }); + await page.screenshot({ path: testInfo.outputPath('general-requested-commands.png'), fullPage: true }); + + await page.locator('[data-navigation-id="chief-center"]').click(); + await expect(page).toHaveURL(/\/chief-center$/); + const nationEditor = page.locator('[data-command-scope="nation"]'); + await expect(nationEditor).toBeVisible(); + await reserve(nationEditor, 0, '인사', /^포상$/, async (form) => { + await form.getByRole('button', { name: '쌀', exact: true }).click(); + await form.locator('input[type=number]').fill('1'); + await chooseFirstNonEmpty(form.locator('select')); + }); + await reserve(nationEditor, 1, '인사', /^발령$/, async (form) => { + await chooseFirstNonEmpty(form.locator('select').nth(0)); + await chooseFirstNonEmpty(form.locator('select').nth(1)); + }); + await reserve(nationEditor, 2, '특수', /^증축$/); + await reserve(nationEditor, 3, '전략', /^필사즉생$/); + + const generalRows = await db.generalTurn.findMany({ + where: { generalId: general.id, turnIdx: { in: [0, 1, 2, 3, 4, 5, 6, 7] } }, + orderBy: { turnIdx: 'asc' }, + }); + expect(generalRows.map((row: { actionCode: string }) => row.actionCode)).toEqual([ + 'che_임관', + 'che_랜덤임관', + 'che_징병', + 'che_출병', + 'che_농지개간', + 'che_화계', + 'che_증여', + 'che_장비매매', + ]); + expect(generalRows[7]?.arg).toMatchObject({ itemType: 'item' }); + const nationRows = await db.nationTurn.findMany({ + where: { + nationId: general.nationId, + officerLevel: general.officerLevel, + turnIdx: { in: [0, 1, 2, 3] }, + }, + orderBy: { turnIdx: 'asc' }, + }); + expect(nationRows.map((row: { actionCode: string }) => row.actionCode)).toEqual([ + 'che_포상', + 'che_발령', + 'che_증축', + 'che_필사즉생', + ]); + await page.screenshot({ path: testInfo.outputPath('chief-requested-commands.png'), fullPage: true }); + } finally { + await db.$transaction(async (transaction) => { + await transaction.generalTurn.deleteMany({ where: { generalId: general.id } }); + if (originalGeneralTurns.length) await transaction.generalTurn.createMany({ data: originalGeneralTurns }); + await transaction.generalTurnRevision.deleteMany({ where: { generalId: general.id } }); + if (originalGeneralRevision) + await transaction.generalTurnRevision.create({ data: originalGeneralRevision }); + await transaction.nationTurn.deleteMany({ + where: { nationId: general.nationId, officerLevel: general.officerLevel }, + }); + if (originalNationTurns.length) await transaction.nationTurn.createMany({ data: originalNationTurns }); + await transaction.nationTurnRevision.deleteMany({ + where: { nationId: general.nationId, officerLevel: general.officerLevel }, + }); + if (originalNationRevision) await transaction.nationTurnRevision.create({ data: originalNationRevision }); + }); + await connector.disconnect(); + } +}); diff --git a/app/game-frontend/src/components/chief/ChiefCommandEditor.vue b/app/game-frontend/src/components/chief/ChiefCommandEditor.vue index a452d4f..5beb9fd 100644 --- a/app/game-frontend/src/components/chief/ChiefCommandEditor.vue +++ b/app/game-frontend/src/components/chief/ChiefCommandEditor.vue @@ -1,445 +1,44 @@ - - diff --git a/app/game-frontend/src/components/command/DragSelect.vue b/app/game-frontend/src/components/command/DragSelect.vue new file mode 100644 index 0000000..106fb38 --- /dev/null +++ b/app/game-frontend/src/components/command/DragSelect.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue new file mode 100644 index 0000000..6f07ad1 --- /dev/null +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -0,0 +1,926 @@ + + + + + diff --git a/app/game-frontend/src/components/command/commandQueue.ts b/app/game-frontend/src/components/command/commandQueue.ts new file mode 100644 index 0000000..f0c07fb --- /dev/null +++ b/app/game-frontend/src/components/command/commandQueue.ts @@ -0,0 +1,164 @@ +import type { CommandPatternEntry, ReservedCommandRow } from './types'; + +const jsonClone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +const cloneArgs = (value: unknown): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return jsonClone(value as Record); +}; + +export const normalizedSelection = ( + selected: ReadonlySet, + previous: ReadonlySet, + maxTurns: number +): number[] => { + const source = selected.size ? selected : previous.size ? previous : new Set([0]); + return [...source].filter((index) => index >= 0 && index < maxTurns).sort((left, right) => left - right); +}; + +export const selectStep = (maxTurns: number, begin: number, step: number): Set => { + const result = new Set(); + for (let index = 0; index < maxTurns; index += 1) { + if ((index - begin) % step === 0) result.add(index); + } + return result; +}; + +export const extractPattern = (rows: ReservedCommandRow[], selection: number[]): CommandPatternEntry[] => { + if (!selection.length) return []; + const first = selection[0] ?? 0; + const grouped = new Map(); + for (const index of selection) { + const row = rows[index]; + if (!row) continue; + const args = cloneArgs(row.args); + const key = JSON.stringify([row.action, args]); + const relative = index - first; + const existing = grouped.get(key); + if (existing) { + existing.turnList.push(relative); + } else { + grouped.set(key, { turnList: [relative], action: row.action, args, label: row.label }); + } + } + return [...grouped.values()]; +}; + +export const amplifyPattern = ( + pattern: CommandPatternEntry[], + targets: number[], + maxTurns: number +): CommandPatternEntry[] => { + if (!pattern.length || !targets.length) return []; + const offsets = pattern.flatMap((entry) => entry.turnList); + if (!offsets.length) return []; + const minOffset = Math.min(...offsets); + const width = Math.max(...offsets) - minOffset + 1; + const anchors: number[] = []; + for (const target of [...targets].sort((a, b) => a - b)) { + const last = anchors.at(-1); + if (last === undefined || target >= last + width) anchors.push(target); + } + return pattern + .map((entry) => ({ + ...entry, + args: cloneArgs(entry.args), + turnList: entry.turnList + .flatMap((offset) => anchors.map((anchor) => anchor + offset - minOffset)) + .filter((index) => index >= 0 && index < maxTurns), + })) + .filter((entry) => entry.turnList.length > 0); +}; + +export const moveQueueRange = ( + rows: ReservedCommandRow[], + selection: number[], + direction: 'pull' | 'push', + restAction = '휴식' +): CommandPatternEntry[] => { + if (!selection.length) return []; + const first = selection[0] ?? 0; + const last = selection.at(-1) ?? first; + const width = last - first + 1; + const next = rows.map((row) => ({ action: row.action, args: cloneArgs(row.args), label: row.label })); + if (direction === 'pull') { + for (let index = first; index < rows.length - width; index += 1) next[index] = next[index + width]!; + for (let index = Math.max(first, rows.length - width); index < rows.length; index += 1) { + next[index] = { action: restAction, args: {}, label: '휴식' }; + } + } else { + for (let index = rows.length - 1; index >= first + width; index -= 1) next[index] = next[index - width]!; + for (let index = first; index < Math.min(rows.length, first + width); index += 1) { + next[index] = { action: restAction, args: {}, label: '휴식' }; + } + } + return next.map((entry, index) => ({ turnList: [index], ...entry })); +}; + +export class CommandStorage { + readonly recent = new Map(); + readonly templates = new Map(); + clipboard: CommandPatternEntry[] | undefined; + editMode = false; + activeCategory = ''; + private readonly key: string; + private readonly maxRecent: number; + + constructor(key: string, maxRecent = 10) { + this.key = key; + this.maxRecent = maxRecent; + this.load(); + } + + private read(suffix: string, fallback: T): T { + try { + return JSON.parse(localStorage.getItem(`${this.key}:${suffix}`) ?? '') as T; + } catch { + return fallback; + } + } + + private load(): void { + for (const entry of this.read('recent', [])) { + this.recent.set(JSON.stringify([entry.action, entry.args]), entry); + } + for (const [name, entries] of this.read>('templates', [])) { + this.templates.set(name, entries); + } + this.clipboard = this.read('clipboard', undefined); + this.editMode = localStorage.getItem(`${this.key}:editMode`) === '1'; + this.activeCategory = this.read('category', ''); + } + + saveState(): void { + localStorage.setItem(`${this.key}:editMode`, this.editMode ? '1' : '0'); + localStorage.setItem(`${this.key}:category`, JSON.stringify(this.activeCategory)); + } + + saveClipboard(pattern: CommandPatternEntry[]): void { + this.clipboard = jsonClone(pattern); + localStorage.setItem(`${this.key}:clipboard`, JSON.stringify(this.clipboard)); + } + + pushRecent(entry: CommandPatternEntry): void { + const key = JSON.stringify([entry.action, entry.args]); + this.recent.delete(key); + this.recent.set(key, jsonClone(entry)); + while (this.recent.size > this.maxRecent) this.recent.delete(this.recent.keys().next().value as string); + localStorage.setItem(`${this.key}:recent`, JSON.stringify([...this.recent.values()])); + } + + setTemplate(name: string, entries: CommandPatternEntry[]): void { + this.templates.set(name, jsonClone(entries)); + this.saveTemplates(); + } + + deleteTemplate(name: string): void { + this.templates.delete(name); + this.saveTemplates(); + } + + private saveTemplates(): void { + localStorage.setItem(`${this.key}:templates`, JSON.stringify([...this.templates.entries()])); + } +} diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts new file mode 100644 index 0000000..58bf0e1 --- /dev/null +++ b/app/game-frontend/src/components/command/types.ts @@ -0,0 +1,59 @@ +export type CommandOption = { value: string | number; label: string; color?: string }; + +export type CommandInputField = { + key: string; + label: string; + kind: 'text' | 'number' | 'boolean' | 'select' | 'numberTuple' | 'hidden'; + required: boolean; + min?: number; + max?: number; + step?: number; + constValue?: string | number; + options?: CommandOption[]; + optionSource?: 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items'; + tupleLabels?: string[]; +}; + +export type CommandAvailability = { + key: string; + name: string; + reqArg: boolean; + status: 'available' | 'blocked' | 'needsInput' | 'unknown'; + possible: boolean; + reason?: string; + inputFields: CommandInputField[]; +}; + +export type CommandGroup = { category: string; values: CommandAvailability[] }; + +export type CommandTable = { + general: CommandGroup[]; + nation: CommandGroup[]; + inputOptions: { + cities: CommandOption[]; + nations: CommandOption[]; + generals: CommandOption[]; + crewTypes: CommandOption[]; + armTypes: CommandOption[]; + nationTypes: CommandOption[]; + colors: CommandOption[]; + items: Record; + }; +}; + +export type ReservedCommandRow = { + index: number; + action: string; + args: unknown; + label?: string; + time?: string; + year?: number; + month?: number; +}; + +export type CommandPatternEntry = { + turnList: number[]; + action: string; + args: Record; + label?: string; +}; diff --git a/app/game-frontend/src/components/main/CommandListPanel.vue b/app/game-frontend/src/components/main/CommandListPanel.vue index ea0b7a3..72ac981 100644 --- a/app/game-frontend/src/components/main/CommandListPanel.vue +++ b/app/game-frontend/src/components/main/CommandListPanel.vue @@ -1,376 +1,68 @@ - - diff --git a/app/game-frontend/src/components/main/CommandSelectForm.vue b/app/game-frontend/src/components/main/CommandSelectForm.vue index 6027803..fdb7a10 100644 --- a/app/game-frontend/src/components/main/CommandSelectForm.vue +++ b/app/game-frontend/src/components/main/CommandSelectForm.vue @@ -26,6 +26,7 @@ const props = defineProps<{ loading: boolean; activeCategory?: string; scope?: 'all' | 'general' | 'nation'; + allowBlocked?: boolean; }>(); const emit = defineEmits<{ @@ -33,26 +34,42 @@ const emit = defineEmits<{ (event: 'update:activeCategory', category: string): void; }>(); +const nationCategoryOrder = ['휴식', '인사', '외교', '특수', '전략', '기타'] as const; +const effectiveScope = computed(() => { + if (props.commandTable?.general.length === 0 && props.commandTable.nation.length > 0) return 'nation'; + if (props.commandTable?.nation.length === 0 && props.commandTable.general.length > 0) return 'general'; + return props.scope ?? 'all'; +}); +const scopedGroups = computed(() => { + if (!props.commandTable) return { general: [] as CommandGroup[], nation: [] as CommandGroup[] }; + const nationCommands = props.commandTable.nation.flatMap((group) => + group.values.map((command) => ({ category: group.category === '국가' ? '특수' : group.category, command })) + ); + const nation = nationCategoryOrder.map((category) => ({ + category, + values: nationCommands.filter((entry) => entry.category === category).map((entry) => entry.command), + })); + return { general: props.commandTable.general, nation }; +}); + const categories = computed(() => { if (!props.commandTable) { return [] as Array<{ id: string; label: string; category: string; groupType: 'general' | 'nation' }>; } - const general = props.commandTable.general.map((group) => ({ + const general = scopedGroups.value.general.map((group) => ({ id: `general:${group.category}`, label: group.category, category: group.category, groupType: 'general' as const, })); - const nation = props.commandTable.nation.map((group) => ({ + const nation = scopedGroups.value.nation.map((group) => ({ id: `nation:${group.category}`, - label: `국가:${group.category}`, + label: effectiveScope.value === 'nation' ? group.category : `국가:${group.category}`, category: group.category, groupType: 'nation' as const, })); - if (props.scope === 'general') return general; - if (props.scope === 'nation') { - return nation.map((entry) => ({ ...entry, label: entry.category === '국가' ? '기타' : entry.category })); - } + if (effectiveScope.value === 'general') return general; + if (effectiveScope.value === 'nation') return nation; return [...general, ...nation]; }); @@ -64,7 +81,7 @@ const selectedGroup = computed(() => { const [scope, ...categoryParts] = selectedCategory.value.split(':'); const category = categoryParts.join(':'); return ( - props.commandTable[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ?? + scopedGroups.value[scope === 'nation' ? 'nation' : 'general'].find((group) => group.category === category) ?? null ); }); @@ -128,8 +145,9 @@ const commandTitle = (command: CommandAvailability) => 'command-item', command.status === 'available' ? 'ok' : '', command.status === 'blocked' ? 'blocked' : '', + command.status === 'blocked' && props.allowBlocked ? 'reservable' : '', ]" - :disabled="!command.possible" + :disabled="!props.allowBlocked && !command.possible" :title="commandTitle(command)" @click="emit('select', command.key)" > @@ -204,6 +222,12 @@ const commandTitle = (command: CommandAvailability) => cursor: not-allowed; } +.command-item.blocked.reservable { + color: #d8ccb1; + opacity: 1; + cursor: pointer; +} + .command-name { font-weight: 600; } diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 3dd36f3..7fdc787 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -45,9 +45,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const messageContacts = ref(null); const boardAccess = ref(null); const reservedGeneralTurns = ref(null); - const reservedNationTurns = ref(null); const reservedGeneralRevision = ref(0); - const reservedNationRevision = ref(0); const globalRecords = ref([]); const generalRecords = ref([]); const worldHistory = ref([]); @@ -261,9 +259,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (!context) { reservedGeneralTurns.value = null; - reservedNationTurns.value = null; reservedGeneralRevision.value = 0; - reservedNationRevision.value = 0; boardAccess.value = null; resetRecentRecords(null); loading.value = false; @@ -276,10 +272,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } const layoutPromise = mapLayout.value ? Promise.resolve(mapLayout.value) : trpc.world.getMapLayout.query(); const generalTurnsPromise = trpc.turns.reserved.getGeneral.query({ generalId: id }); - const nationTurnsPromise = - context.general.nationId > 0 && context.general.officerLevel >= 5 - ? trpc.turns.reserved.getNation.query({ generalId: id }) - : Promise.resolve(null); const recordsPromise = trpc.general.getRecentRecords .query({ lastGeneralRecordId, @@ -302,7 +294,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { contacts, access, generalTurns, - nationTurns, records, nextFrontStatus, ] = await Promise.all([ @@ -314,7 +305,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { trpc.messages.getContacts.query({ generalId: id }), trpc.board.getAccess.query(), generalTurnsPromise, - nationTurnsPromise, recordsPromise, frontStatusPromise, ]); @@ -328,8 +318,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess.value = access; reservedGeneralTurns.value = generalTurns.turns; reservedGeneralRevision.value = generalTurns.revision; - reservedNationTurns.value = nationTurns?.turns ?? null; - reservedNationRevision.value = nationTurns?.revision ?? 0; if (records) { globalRecords.value = mergeRecentRecords(globalRecords.value, records.global); generalRecords.value = mergeRecentRecords(generalRecords.value, records.general); @@ -528,58 +516,46 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { } }; - const setNationTurn = async (turnIndex: number, action: string, args: Record = {}) => { + const setGeneralTurns = async ( + entries: Array<{ turnList: number[]; action: string; args: Record }> + ) => { const id = generalId.value; - const currentGeneral = general.value; - if (!id || !currentGeneral) { - return; - } - if (currentGeneral.nationId <= 0 || currentGeneral.officerLevel < 5) { - return; - } + if (!id || !entries.length) return; try { - const result = await trpc.turns.reserved.setNation.mutate({ + const result = await trpc.turns.reserved.setGeneralBulk.mutate({ generalId: id, - turnIndex, - action, - args, - expectedRevision: reservedNationRevision.value, + entries, + expectedRevision: reservedGeneralRevision.value, }); - reservedNationTurns.value = result.turns; - reservedNationRevision.value = result.revision; + reservedGeneralTurns.value = result.turns; + reservedGeneralRevision.value = result.revision; } catch (err) { error.value = resolveErrorMessage(err); - const snapshot = await trpc.turns.reserved.getNation.query({ generalId: id }).catch(() => null); + const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); if (snapshot) { - reservedNationTurns.value = snapshot.turns; - reservedNationRevision.value = snapshot.revision; + reservedGeneralTurns.value = snapshot.turns; + reservedGeneralRevision.value = snapshot.revision; } } }; - const shiftNationTurns = async (amount: number) => { + const repeatGeneralTurns = async (amount: number) => { const id = generalId.value; - const currentGeneral = general.value; - if (!id || !currentGeneral) { - return; - } - if (currentGeneral.nationId <= 0 || currentGeneral.officerLevel < 5) { - return; - } + if (!id) return; try { - const result = await trpc.turns.reserved.shiftNation.mutate({ + const result = await trpc.turns.reserved.repeatGeneral.mutate({ generalId: id, amount, - expectedRevision: reservedNationRevision.value, + expectedRevision: reservedGeneralRevision.value, }); - reservedNationTurns.value = result.turns; - reservedNationRevision.value = result.revision; + reservedGeneralTurns.value = result.turns; + reservedGeneralRevision.value = result.revision; } catch (err) { error.value = resolveErrorMessage(err); - const snapshot = await trpc.turns.reserved.getNation.query({ generalId: id }).catch(() => null); + const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); if (snapshot) { - reservedNationTurns.value = snapshot.turns; - reservedNationRevision.value = snapshot.revision; + reservedGeneralTurns.value = snapshot.turns; + reservedGeneralRevision.value = snapshot.revision; } } }; @@ -737,7 +713,6 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { messageContacts, boardAccess, reservedGeneralTurns, - reservedNationTurns, globalRecords, generalRecords, worldHistory, @@ -758,8 +733,8 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { readLatestMessage, deleteMessage, setGeneralTurn, + setGeneralTurns, shiftGeneralTurns, - setNationTurn, - shiftNationTurns, + repeatGeneralTurns, }; }); diff --git a/app/game-frontend/src/views/ChiefCenterView.vue b/app/game-frontend/src/views/ChiefCenterView.vue index 688d009..e1d6fd0 100644 --- a/app/game-frontend/src/views/ChiefCenterView.vue +++ b/app/game-frontend/src/views/ChiefCenterView.vue @@ -8,6 +8,7 @@ import ChiefTurnCard from '../components/chief/ChiefTurnCard.vue'; import ChiefCommandEditor from '../components/chief/ChiefCommandEditor.vue'; import { trpc } from '../utils/trpc'; import { formatOfficerLevelText } from '../utils/nationFormat'; +import type { CommandPatternEntry } from '../components/command/types'; type ChiefTurn = { index: number; @@ -103,6 +104,9 @@ type TurnRow = { time: string; action: string; isRest: boolean; + args: unknown; + label: string; + actionCode: string; }; const loading = ref(false); @@ -243,6 +247,9 @@ const buildTurnRows = (chief: ChiefEntry): TurnRow[] => { index: turn.index, time: timeLabel, action: actionLabel, + label: actionLabel, + actionCode: turn.action, + args: turn.args, isRest: turn.action === '휴식', }; }); @@ -296,14 +303,12 @@ const shiftTurns = async (amount: number) => { } }; -const reserveTurn = async (payload: { index: number; action: string; args: Record }) => { +const reserveTurns = async (entries: CommandPatternEntry[]) => { if (!data.value || !isEditingAllowed.value) return; try { - const result = await trpc.turns.reserved.setNation.mutate({ + const result = await trpc.turns.reserved.setNationBulk.mutate({ generalId: data.value.me.id, - turnIndex: payload.index, - action: payload.action, - args: payload.args, + entries, expectedRevision: selectedChief.value?.revision ?? 0, }); updateMyTurns(result.turns, result.revision); @@ -352,8 +357,10 @@ const repeatTurns = async (amount: number) => { :rows="selectedChiefRows" :command-table="commandTable" :loading="commandLoading" + :general-id="data.me.id" + :officer-level="selectedChief.officerLevel" :mobile="true" - @reserve="reserveTurn" + @reserve-bulk="reserveTurns" @shift="shiftTurns" @repeat="repeatTurns" /> @@ -411,7 +418,9 @@ const repeatTurns = async (amount: number) => { :rows="chief.rows" :command-table="commandTable" :loading="commandLoading" - @reserve="reserveTurn" + :general-id="data.me.id" + :officer-level="chief.officerLevel" + @reserve-bulk="reserveTurns" @shift="shiftTurns" @repeat="repeatTurns" /> diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index f9bb136..d057a78 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -20,6 +20,7 @@ import { formatLog } from '../utils/formatLog'; import { useSessionStore } from '../stores/session'; import { useMainDashboardStore } from '../stores/mainDashboard'; import { trpc } from '../utils/trpc'; +import type { CommandPatternEntry } from '../components/command/types'; const session = useSessionStore(); const dashboard = useMainDashboardStore(); @@ -40,12 +41,10 @@ const { nation, worldMap, mapLayout, - selectedCity, commandTable, messages, boardAccess, reservedGeneralTurns, - reservedNationTurns, globalRecords, generalRecords, worldHistory, @@ -94,20 +93,16 @@ onUnmounted(() => { } }); -const reserveGeneralTurn = (payload: { index: number; action: string; args: Record }) => { - void dashboard.setGeneralTurn(payload.index, payload.action, payload.args); -}; - const shiftGeneralTurns = (amount: number) => { void dashboard.shiftGeneralTurns(amount); }; -const reserveNationTurn = (payload: { index: number; action: string; args: Record }) => { - void dashboard.setNationTurn(payload.index, payload.action, payload.args); +const reserveGeneralTurns = (entries: CommandPatternEntry[]) => { + void dashboard.setGeneralTurns(entries); }; -const shiftNationTurns = (amount: number) => { - void dashboard.shiftNationTurns(amount); +const repeatGeneralTurns = (amount: number) => { + void dashboard.repeatGeneralTurns(amount); }; const loadMainData = async () => { @@ -206,14 +201,14 @@ watch( @@ -329,14 +324,14 @@ watch( diff --git a/app/game-frontend/test/commandQueue.test.ts b/app/game-frontend/test/commandQueue.test.ts new file mode 100644 index 0000000..d5ebc59 --- /dev/null +++ b/app/game-frontend/test/commandQueue.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + amplifyPattern, + extractPattern, + moveQueueRange, + normalizedSelection, + selectStep, +} from '../src/components/command/commandQueue.ts'; + +const rows = ['A', 'B', 'A', 'C', '휴식', '휴식'].map((action, index) => ({ + index, + action, + args: action === 'A' ? { value: 1 } : {}, + label: action, +})); + +void test('keeps the Ref selection fallback and periodic range rules', () => { + assert.deepEqual(normalizedSelection(new Set(), new Set([3, 1]), 6), [1, 3]); + assert.deepEqual([...selectStep(8, 1, 3)], [1, 4, 7]); +}); + +void test('extracts a relative pattern and repeats it from selected anchors', () => { + const pattern = extractPattern(rows, [0, 1, 2]); + assert.deepEqual(pattern, [ + { turnList: [0, 2], action: 'A', args: { value: 1 }, label: 'A' }, + { turnList: [1], action: 'B', args: {}, label: 'B' }, + ]); + assert.deepEqual(amplifyPattern(pattern, [0, 3], 6), [ + { turnList: [0, 3, 2, 5], action: 'A', args: { value: 1 }, label: 'A' }, + { turnList: [1, 4], action: 'B', args: {}, label: 'B' }, + ]); +}); + +void test('pull and push rewrite the queue with rest at the opened range', () => { + assert.deepEqual( + moveQueueRange(rows, [1, 2], 'pull').map((entry) => entry.action), + ['A', 'C', '휴식', '휴식', '휴식', '휴식'] + ); + assert.deepEqual( + moveQueueRange(rows, [1, 2], 'push').map((entry) => entry.action), + ['A', '휴식', '휴식', 'B', 'A', 'C'] + ); +}); diff --git a/resources/turn-commands/default.json b/resources/turn-commands/default.json index 141a310..74cc841 100644 --- a/resources/turn-commands/default.json +++ b/resources/turn-commands/default.json @@ -13,6 +13,7 @@ "che_견문", "che_내정특기초기화", "che_전투특기초기화", + "che_장비매매", "che_출병", "che_주민선정", "che_정착장려", @@ -30,6 +31,7 @@ "che_소집해제", "che_군량매매", "che_물자조달", + "che_증여", "che_헌납", "che_이동", "che_선양", @@ -43,6 +45,7 @@ "che_부대탈퇴지시", "che_발령", "che_천도", + "che_증축", "che_선전포고", "che_불가침제의", "che_불가침파기제의",