diff --git a/app/game-api/src/turns/reservedTurns.ts b/app/game-api/src/turns/reservedTurns.ts index 43ec023c..7d216cdf 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -165,20 +165,22 @@ const loadGeneralTurns = async (db: DatabaseClient, generalId: number): Promise< return buildTurnListFromRows(rows, MAX_GENERAL_TURNS); }; +const loadGeneralAutorunLimit = async (db: DatabaseClient, generalId: number): Promise => { + const general = await db.general.findUnique({ where: { id: generalId }, select: { meta: true } }); + const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined; + return typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit) ? Math.trunc(rawAutorunLimit) : null; +}; + export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise => { - const [turns, revisionRow, general] = await Promise.all([ + const [turns, revisionRow, autorunLimit] = await Promise.all([ loadGeneralTurns(db, generalId), db.generalTurnRevision.findUnique({ where: { generalId } }), - db.general.findUnique({ where: { id: generalId }, select: { meta: true } }), + loadGeneralAutorunLimit(db, generalId), ]); - const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined; return { revision: revisionRow?.revision ?? 0, turns: serializeTurnList(turns), - autorunLimit: - typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit) - ? Math.trunc(rawAutorunLimit) - : null, + autorunLimit, }; }; @@ -331,7 +333,7 @@ export const setGeneralTurns = async ( } } await persistGeneralTurns(db, generalId, turns); - return { revision, turns: serializeTurnList(turns) }; + return { revision, turns: serializeTurnList(turns), autorunLimit: await loadGeneralAutorunLimit(db, generalId) }; }; export const setGeneralTurn = async ( @@ -357,7 +359,7 @@ export const shiftGeneralTurns = async ( const turns = await loadGeneralTurns(db, generalId); const shifted = applyShift(turns, amount); await persistGeneralTurns(db, generalId, shifted); - return { revision, turns: serializeTurnList(shifted) }; + return { revision, turns: serializeTurnList(shifted), autorunLimit: await loadGeneralAutorunLimit(db, generalId) }; }; export const repeatGeneralTurns = async ( @@ -372,7 +374,7 @@ export const repeatGeneralTurns = async ( const revision = await claimGeneralRevision(db, generalId, expectedRevision); const turns = applyRepeat(await loadGeneralTurns(db, generalId), amount); await persistGeneralTurns(db, generalId, turns); - return { revision, turns: serializeTurnList(turns) }; + return { revision, turns: serializeTurnList(turns), autorunLimit: await loadGeneralAutorunLimit(db, generalId) }; }; export const setNationTurns = async ( diff --git a/app/game-api/test/reservedTurns.test.ts b/app/game-api/test/reservedTurns.test.ts index cce2678a..1534c2df 100644 --- a/app/game-api/test/reservedTurns.test.ts +++ b/app/game-api/test/reservedTurns.test.ts @@ -16,7 +16,7 @@ import { ReservedTurnRevisionConflictError, } from '../src/turns/reservedTurns.js'; -const buildDb = () => { +const buildDb = (autorunLimit: number | null = null) => { const generalTurns = new Map(); const nationTurns = new Map(); const generalRevisions = new Map(); @@ -45,7 +45,7 @@ const buildDb = () => { findFirst: async () => null, }, general: { - findUnique: async () => null, + findUnique: async () => ({ meta: autorunLimit === null ? {} : { autorun_limit: autorunLimit } }), }, city: { findUnique: async () => null, @@ -200,22 +200,25 @@ const buildDb = () => { describe('reservedTurns', () => { it('sets and shifts general turns', async () => { - const { db } = buildDb(); + const { db } = buildDb(2408); const initial = await setGeneralTurn(db, 1, 0, 'che_화계', { destCityId: 10 }, 0); expect(initial.revision).toBe(1); expect(initial.turns).toHaveLength(MAX_GENERAL_TURNS); expect(initial.turns[0]?.action).toBe('che_화계'); + expect(initial.autorunLimit).toBe(2408); const pushed = await shiftGeneralTurns(db, 1, 1, initial.revision); expect(pushed.revision).toBe(2); expect(pushed.turns[0]?.action).toBe('휴식'); expect(pushed.turns[1]?.action).toBe('che_화계'); + expect(pushed.autorunLimit).toBe(2408); const pulled = await shiftGeneralTurns(db, 1, -1, pushed.revision); expect(pulled.turns[0]?.action).toBe('che_화계'); expect(pulled.turns[MAX_GENERAL_TURNS - 1]?.action).toBe('휴식'); + expect(pulled.autorunLimit).toBe(2408); await expect(setGeneralTurn(db, 1, 2, 'che_훈련', {}, 1)).rejects.toBeInstanceOf( ReservedTurnRevisionConflictError @@ -285,7 +288,7 @@ describe('reservedTurns', () => { }); it('repeats the leading general pattern at the legacy interval', async () => { - const { db } = buildDb(); + const { db } = buildDb(2408); const seeded = await setGeneralTurns( db, 4, @@ -309,6 +312,7 @@ describe('reservedTurns', () => { 'che_사기진작', 'che_징병', ]); + expect(repeated.autorunLimit).toBe(2408); }); it('supports nation bulk/repeat and preserves the legacy amount-12 no-op', async () => { diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index f99f2ab4..5f92b9ff 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -869,7 +869,7 @@ describe('appRouter', () => { }); it('validates and persists general command arguments from the authenticated owner', async () => { - const general = buildGeneralRow({ id: 13 }); + const general = buildGeneralRow({ id: 13, meta: { autorun_limit: 2408 } }); const writes: unknown[] = []; const changeJournal = new ChangeJournal(); const caller = appRouter.createCaller( @@ -885,6 +885,7 @@ describe('appRouter', () => { }); expect(response.turns[0]).toMatchObject({ action: 'che_화계', args: { destCityId: 7 } }); + expect(response.autorunLimit).toBe(2408); expect(writes).toHaveLength(1); const written = writes[0] as { data: unknown[] }; expect(written.data).toHaveLength(30); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index c2dc3319..b217dd01 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -43,6 +43,7 @@ const inputOptions = { cities: [ { value: 1, label: '업 (아국)' }, { value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' }, + { value: 3, label: '단양 (오)' }, ], nations: [ { value: 1, label: '아국', color: '#008000' }, @@ -182,6 +183,27 @@ const commandTable = { }, ], }, + { + category: '군사', + values: [ + { + key: 'che_출병', + name: '출병', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'destCityId', + label: '대상 도시', + kind: 'select', + required: true, + optionSource: 'cities', + }, + ], + }, + ], + }, ], nation: [ { @@ -312,6 +334,7 @@ const generalContext = { experience: 0, dedication: 0, items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, + turnTime: '2026-08-17T12:00:00.000Z', }, city: { id: 1, @@ -483,7 +506,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: if (name === 'turns.getCommandTable') return response(commandTableResponse); if (name === 'nation.getChiefCenter') return response(chiefCenter); if (name === 'turns.reserved.getGeneral') - return response({ turns: generalTurns, revision: generalRevision }); + return response({ turns: generalTurns, revision: generalRevision, autorunLimit: 2403 }); if (name === 'turns.reserved.getNation') return response({ turns: nationTurns, revision: nationRevision }); if (name === 'general.getRecentRecords') return response({ global: [], general: [], history: [] }); if (name === 'general.getFrontStatus') @@ -520,7 +543,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: generalTurns[index] = { index, action: entry.action, args: entry.args ?? {} }; } generalRevision += 1; - return response({ ok: true, revision: generalRevision, turns: generalTurns }); + return response({ ok: true, revision: generalRevision, turns: generalTurns, autorunLimit: 2403 }); } if (name === 'turns.reserved.setNationBulk') { requests.push(body); @@ -561,7 +584,7 @@ test('renders and accepts every Ref strategy command at mobile width', async ({ await button.click(); const form = picker.getByTestId('command-argument-form'); await expect(form.getByTestId('command-argument-guidance')).toContainText(strategy.guidance); - await expect(form.locator('select option')).toHaveCount(2); + await expect(form.locator('select option')).toHaveCount(3); await picker.getByRole('button', { name: '명령 다시 선택', exact: true }).click(); } await picker.screenshot({ path: test.info().outputPath('all-strategy-commands-mobile.png') }); @@ -709,7 +732,7 @@ test('reserves force move, retirement, and resignation from the user command pic await expect(forceMoveForm.getByTestId('command-argument-guidance')).toContainText('선택한 도시로 강행합니다.'); await forceMoveForm.locator('select').selectOption('2'); await picker.getByRole('button', { name: '입력', exact: true }).click(); - await expect(editor.locator('.action-column > div').nth(2)).toHaveText('강행'); + await expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】으로 강행'); const serialized = JSON.stringify(requests); expect(serialized).toContain('"action":"che_은퇴","args":{}'); @@ -856,7 +879,9 @@ test('enters general and nation command arguments and sends exact values', async return { width: rect.width, height: rect.height }; }); 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 expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText( + '【허창】에 화계실행' + ); await page.goto('/che/chief-center'); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); @@ -879,7 +904,9 @@ test('enters general and nation command arguments and sends exact values', async }; }); await chiefPicker.getByRole('button', { name: '입력', exact: true }).click(); - await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText('포상'); + await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText( + '【관우】 쌀 300 포상' + ); expect(JSON.stringify(requests)).toContain('"destCityId":2'); expect(JSON.stringify(requests)).toContain('"isGold":false'); @@ -961,7 +988,9 @@ test('uses a Ref-style full recruitment page without horizontal overflow on desk await infantry.getByRole('button', { name: '절반', exact: true }).click(); await page.screenshot({ path: testInfo.outputPath('recruitment-desktop.png') }); await picker.getByRole('button', { name: '입력', exact: true }).click(); - await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('징병'); + await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText( + '【보병】 3500명 징병' + ); expect(JSON.stringify(requests)).toContain('"crewType":1100'); expect(JSON.stringify(requests)).toContain('"amount":3500'); @@ -1167,6 +1196,53 @@ test('keeps the entered command visible and reports a server validation error', await expect(page.getByTestId('command-argument-form').locator('select')).toHaveValue('2'); }); +test('keeps Ref command briefs and autonomous-action state after a turn mutation', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/'); + + const editor = page.locator('[data-command-scope="general"]'); + const status = editor.locator('[data-command-autorun-status]'); + const firstRow = editor.locator('.action-column > div').first(); + await expect(status).toHaveText(/자율 행동: 200年 3月 · \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}까지/u); + await expect(firstRow).toContainText('휴식(자율 행동)'); + expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)'); + + 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: '출병', exact: true }).click(); + await picker.getByTestId('command-argument-form').locator('select').selectOption('3'); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + + await expect(firstRow).toHaveText('【단양】으로 출병'); + await expect(firstRow).toHaveAttribute('title', /자율 행동/u); + expect(await firstRow.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)'); + await expect(status).toHaveText(/자율 행동: 200年 3月 · .*까지/u); + + const desktopGeometry = await editor.evaluate((element) => { + const statusElement = element.querySelector('[data-command-autorun-status]'); + const row = element.querySelector('.action-column > div'); + if (!statusElement || !row) throw new Error('autonomous command geometry is missing'); + return { + horizontalOverflow: element.scrollWidth - element.clientWidth, + statusWidth: statusElement.getBoundingClientRect().width, + editorWidth: element.getBoundingClientRect().width, + rowHeight: row.getBoundingClientRect().height, + }; + }); + expect(desktopGeometry.horizontalOverflow).toBeLessThanOrEqual(0); + expect(desktopGeometry.statusWidth).toBeLessThanOrEqual(desktopGeometry.editorWidth); + expect(desktopGeometry.rowHeight).toBeGreaterThanOrEqual(20); + await page.screenshot({ path: test.info().outputPath('command-brief-autorun-desktop-1200.png'), fullPage: true }); + + await page.setViewportSize({ width: 500, height: 900 }); + await expect(firstRow).toHaveText('【단양】으로 출병'); + await expect(status).toBeVisible(); + expect(await editor.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0); + await page.screenshot({ path: test.info().outputPath('command-brief-autorun-mobile-500.png'), fullPage: true }); +}); + test('uses drag selection, clipboard paste, and a stored template in advanced mode', async ({ page }) => { const requests = await install(page); await page.goto('/'); @@ -1194,7 +1270,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo 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 expect(editor.locator('.action-column > div').nth(2)).toHaveText('【허창】에 화계실행'); await drag(0, 2); await editor.locator('details.selected-menu > summary').click(); @@ -1205,7 +1281,7 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo await expect(editor.locator('.index-column > button.selected')).toHaveCount(15); 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 expect(editor.locator('.action-column > div').nth(5)).toHaveText('【허창】에 화계실행'); await drag(0, 2); page.once('dialog', (dialog) => dialog.accept('화계 세트')); @@ -1284,7 +1360,9 @@ test('keeps the shared main and chief shell geometry and interaction states', as await chiefArgumentForm.locator('input[type=number]').fill('300'); await chiefArgumentForm.locator('select').selectOption('2'); await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); - await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText('포상'); + await expect(page.locator('[data-command-scope="nation"] .action-column > div').first()).toHaveText( + '【관우】 쌀 300 포상' + ); 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/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 10e9a11d..52b42b6b 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -21,6 +21,7 @@ import type { CommandTable, ReservedCommandRow, } from './types'; +import { formatReservedCommandBrief } from './reservedCommandBrief'; const props = withDefaults( defineProps<{ @@ -37,6 +38,7 @@ const props = withDefaults( currentTime?: string; mapData?: CommandMapData | null; mapLayout?: CommandMapLayout | null; + autonomousUntil?: string | null; }>(), { maxPushTurn: 6, @@ -47,6 +49,7 @@ const props = withDefaults( currentTime: '--:--:--', mapData: null, mapLayout: null, + autonomousUntil: null, } ); @@ -70,6 +73,7 @@ const commandArgsValid = ref(false); const expanded = ref(false); const menuRevision = ref(0); const pendingReservation = ref(null); +const editorElement = ref(null); const pickerElement = ref(null); const collapsedRowCount = 15; @@ -133,12 +137,16 @@ const labelMap = computed(() => { const displayRows = computed(() => props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount) ); -const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px`); +const quickPickerTop = ref('38px'); const isRecruitmentCommand = computed( () => selectedCommand.value?.key === 'che_징병' || selectedCommand.value?.key === 'che_모병' ); const isRecruitmentOverlayOpen = computed(() => pickerOpen.value && isRecruitmentCommand.value); -const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action; +const rowLabel = (row: ReservedCommandRow): string => + formatReservedCommandBrief(props.scope, row.action, row.args, props.commandTable) || + row.label || + labelMap.value.get(row.action) || + row.action; const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length); const pattern = () => extractPattern(props.rows, selectedIndices()); const touchMenus = () => (menuRevision.value += 1); @@ -163,6 +171,19 @@ const finishDrag = (next: Set) => { }; const openPicker = (turnIndex?: number) => { + if (!props.compact && editorElement.value) { + const editorRect = editorElement.value.getBoundingClientRect(); + const anchor = + turnIndex === undefined + ? editorElement.value.querySelector('.control-pad') + : editorElement.value.querySelector(`[data-turn-index="${turnIndex}"]`); + if (anchor) { + const anchorRect = anchor.getBoundingClientRect(); + quickPickerTop.value = `${ + turnIndex === undefined ? anchorRect.bottom - editorRect.top : anchorRect.top - editorRect.top + 30 + }px`; + } + } quickTarget.value = turnIndex ?? null; pickerOpen.value = true; selectedCommand.value = null; @@ -281,7 +302,7 @@ const rearrange = (direction: 'pull' | 'push') => { const textCopy = async () => { const lines = selectedIndices().map((index) => { const row = props.rows[index]; - return `${index + 1}턴 ${row?.label ?? labelMap.value.get(row?.action ?? '') ?? row?.action ?? ''}`; + return `${index + 1}턴 ${row ? rowLabel(row) : ''}`; }); await navigator.clipboard.writeText(lines.join('\n')); releaseSelection(); @@ -310,6 +331,7 @@ const clickOutsideMenu = (event: Event) => {