From a403652761f193e69141044314537d1a10e3c6b1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 04:45:38 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=9C=A0=EC=82=B0=20=EC=A0=84?= =?UTF-8?q?=ED=88=AC=20=ED=8A=B9=EA=B8=B0=20=EA=B3=A0=EC=A0=95=EA=B3=BC=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 초기화 시 전투 특기를 null로 정규화하고 이전 특기 배열을 보존해 다음 월 고정 배정이 daemon 재시작 없이 동작하게 한다. 다중 변경 내역이 문서 높이를 늘리도록 하고 API, 월간 persistence, 실제 Chromium 회귀 검증을 추가한다. --- app/game-api/src/router/inherit/index.ts | 14 ++-- app/game-api/test/inheritRouter.test.ts | 84 +++++++++++++++++++ app/game-engine/src/turn/commandRegistry.ts | 2 +- .../src/turn/worldCommandHandler.ts | 4 +- .../monthlySpecialityBetrayAction.test.ts | 81 ++++++++++++++++++ ...alityBetrayPersistence.integration.test.ts | 26 +++++- app/game-frontend/src/views/InheritView.vue | 6 +- docs/frontend-legacy-parity.md | 2 +- packages/common/src/turnDaemon/types.ts | 2 +- .../inheritance-management.spec.ts | 71 +++++++++++++++- 10 files changed, 273 insertions(+), 19 deletions(-) diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 955ceb2e..9eb5742f 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -72,6 +72,11 @@ const parseBuffRecord = (raw: unknown): Record => { const serializeBuffRecord = (buff: Record): string => JSON.stringify(buff); +const readStringList = (raw: unknown): string[] => { + const parsed = typeof raw === 'string' ? parseJson(raw) : raw; + return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string') : []; +}; + const readBuffLevel = (buff: Record, key: InheritBuffType): number => { const compatibilityKey = key === 'domesticSuccessProb' ? 'success' : key === 'domesticFailProb' ? 'fail' : null; return Math.max(0, Math.min(5, Math.floor(buff[key] ?? (compatibilityKey ? buff[compatibilityKey] : 0) ?? 0))); @@ -133,7 +138,7 @@ const patchGeneral = async ( strength?: number; intelligence?: number; }; - specialWar?: string; + specialWar?: string | null; } ): Promise => { const result = await ctx.turnDaemon.requestCommand({ @@ -530,16 +535,15 @@ export const inheritRouter = router({ } const meta = asRecord(general.meta); - const prevList = - parseJson(typeof meta.prev_types_special2 === 'string' ? meta.prev_types_special2 : null) ?? []; + const prevList = readStringList(meta.prev_types_special2); prevList.push(general.special2Code); await patchGeneral(ctx, general.id, { - specialWar: 'None', + specialWar: null, meta: { ...meta, inheritResetSpecialWar: nextLevel, - prev_types_special2: JSON.stringify(prevList), + prev_types_special2: prevList, }, }); diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index 72d28db3..5860eec8 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -331,6 +331,90 @@ describe('inherit router actor and permission boundaries', () => { ); }); + it('reserves the selected Ref war trait and charges the authenticated owner once', async () => { + const fixture = buildContext({ + inheritancePoint: 5_000, + configConst: { availableSpecialWar: ['che_의술'] }, + }); + + await expect( + appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' }) + ).resolves.toEqual({ ok: true }); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'patchGeneral', + generalId: 7, + patch: { meta: { inheritSpecificSpecialWar: 'che_의술' } }, + }); + expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } })); + expect(fixture.logCreate).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + year: 200, + month: 4, + text: '4000 포인트로 다음 전투 특기로 의술 지정', + }, + }); + }); + + it('does not dispatch or charge when a different war trait is already reserved', async () => { + const fixture = buildContext({ + inheritancePoint: 5_000, + general: buildGeneral({ meta: { inheritSpecificSpecialWar: 'che_신산' } }), + configConst: { availableSpecialWar: ['che_의술'] }, + }); + + await expect( + appRouter.createCaller(fixture.context).inherit.setNextSpecialWar({ specialKey: 'che_의술' }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '이미 예약한 특기가 있습니다.' }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); + }); + + it('resets the current war trait to the in-memory null sentinel and preserves Ref history as an array', async () => { + const fixture = buildContext({ + inheritancePoint: 2_000, + general: buildGeneral({ meta: { prev_types_special2: ['che_돌격'], marker: 3 } }), + }); + + await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).resolves.toEqual({ ok: true }); + + expect(fixture.requestCommand).toHaveBeenCalledWith({ + type: 'patchGeneral', + generalId: 7, + patch: { + specialWar: null, + meta: { + prev_types_special2: ['che_돌격', 'che_선봉'], + marker: 3, + inheritResetSpecialWar: 0, + }, + }, + }); + expect(fixture.pointUpsert).toHaveBeenCalledWith(expect.objectContaining({ update: { value: 1_000 } })); + expect(fixture.logCreate).toHaveBeenCalledWith({ + data: { + userId: 'user-1', + year: 200, + month: 4, + text: '1000 포인트로 전투 특기 초기화', + }, + }); + }); + + it('does not dispatch or charge when the current war trait is already blank', async () => { + const fixture = buildContext({ inheritancePoint: 2_000, general: buildGeneral({ special2Code: 'None' }) }); + + await expect(appRouter.createCaller(fixture.context).inherit.resetSpecialWar()).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: '이미 전투 특기가 공란입니다.', + }); + expect(fixture.requestCommand).not.toHaveBeenCalled(); + expect(fixture.pointUpsert).not.toHaveBeenCalled(); + expect(fixture.logCreate).not.toHaveBeenCalled(); + }); + it('queues Ref-compatible nextTurnTimeBase without moving the current scheduled turn', async () => { const fixture = buildContext({ inheritancePoint: 2_000, diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 2d218bb6..51ea335d 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -257,7 +257,7 @@ const zPatchGeneral = z.object({ intelligence: zFiniteNumber.optional(), }) .optional(), - specialWar: z.string().optional(), + specialWar: z.string().nullable().optional(), }), }); diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index 54e24035..e712f08d 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -734,10 +734,10 @@ async function handlePatchGeneral( ...command.patch.stats, }; } - if (typeof command.patch.specialWar === 'string') { + if (command.patch.specialWar !== undefined) { patch.role = { ...general.role, - specialWar: command.patch.specialWar, + specialWar: command.patch.specialWar === 'None' ? null : command.patch.specialWar, }; } diff --git a/app/game-engine/test/monthlySpecialityBetrayAction.test.ts b/app/game-engine/test/monthlySpecialityBetrayAction.test.ts index 4d0c9e5f..d7b1a786 100644 --- a/app/game-engine/test/monthlySpecialityBetrayAction.test.ts +++ b/app/game-engine/test/monthlySpecialityBetrayAction.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest'; import { LogCategory, LogFormat } from '@sammo-ts/logic'; +import type { TurnDaemonCommand } from '@sammo-ts/common'; import { InMemoryTurnWorld } from '../src/turn/inMemoryWorld.js'; +import { normalizeTurnDaemonCommand } from '../src/turn/commandRegistry.js'; import { createAddGlobalBetrayHandler, createAssignGeneralSpecialityHandler, } from '../src/turn/monthlySpecialityBetrayAction.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const event: TurnEvent = { @@ -177,6 +180,84 @@ describe('monthly speciality and betrayal actions', () => { ]); }); + it.each([ + ['고정 후 초기화', ['reserve', 'reset']], + ['초기화 후 고정', ['reset', 'reserve']], + ] as const)('%s 순서에서도 다음 월에 지정한 전투 특기를 지급한다', async (_label, steps) => { + const world = buildWorld(); + const initial = world.getGeneralById(3)!; + const initialMeta = { ...initial.meta }; + delete initialMeta.inheritSpecificSpecialWar; + world.updateGeneral(3, { + role: { ...initial.role, specialWar: 'che_신산' }, + meta: initialMeta, + }); + world.acknowledgeDirtyState(world.peekDirtyState()); + + const commandHandler = createTurnDaemonCommandHandler({ world }); + let requestIndex = 0; + const dispatchPatch = async (patch: Extract['patch']) => { + requestIndex += 1; + const command = normalizeTurnDaemonCommand({ + requestId: `inherit-war-trait-${requestIndex}`, + sentAt: '2026-08-21T00:00:00.000Z', + command: { type: 'patchGeneral', generalId: 3, patch }, + }); + expect(command).not.toBeNull(); + await expect(commandHandler.handle(command!)).resolves.toMatchObject({ type: 'patchGeneral', ok: true }); + }; + + for (const step of steps) { + const current = world.getGeneralById(3)!; + if (step === 'reserve') { + await dispatchPatch({ + meta: { ...current.meta, inheritSpecificSpecialWar: 'che_의술' }, + }); + } else { + await dispatchPatch({ + specialWar: null, + meta: { + ...current.meta, + inheritResetSpecialWar: 0, + prev_types_special2: ['che_신산'], + }, + }); + } + } + + expect(world.getGeneralById(3)?.role.specialWar).toBeNull(); + expect(world.getGeneralById(3)?.meta).toMatchObject({ + inheritSpecificSpecialWar: 'che_의술', + prev_types_special2: ['che_신산'], + }); + + await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], environment, event); + + expect(world.getGeneralById(3)?.role.specialWar).toBe('che_의술'); + expect(world.getGeneralById(3)?.meta).not.toHaveProperty('inheritSpecificSpecialWar'); + expect(world.getGeneralById(3)?.meta.prev_types_special2).toEqual(['che_신산']); + expect( + world + .peekDirtyState() + .logs.filter((log) => log.generalId === 3) + .map((log) => log.text) + ).toEqual(['특기 【의술】을 습득', '특기 【의술】을 익혔습니다!']); + }); + + it('normalizes the legacy None sentinel before monthly eligibility checks', async () => { + const world = buildWorld(); + const target = world.getGeneralById(3)!; + world.updateGeneral(3, { role: { ...target.role, specialWar: 'che_신산' } }); + world.acknowledgeDirtyState(world.peekDirtyState()); + const commandHandler = createTurnDaemonCommandHandler({ world }); + + await expect( + commandHandler.handle({ type: 'patchGeneral', generalId: 3, patch: { specialWar: 'None' } }) + ).resolves.toMatchObject({ type: 'patchGeneral', ok: true }); + + expect(world.getGeneralById(3)?.role.specialWar).toBeNull(); + }); + it('does nothing before the three-year opening period ends', async () => { const world = buildWorld(); await createAssignGeneralSpecialityHandler({ getWorld: () => world })([], { ...environment, year: 192 }, event); diff --git a/app/game-engine/test/monthlySpecialityBetrayPersistence.integration.test.ts b/app/game-engine/test/monthlySpecialityBetrayPersistence.integration.test.ts index b16990a6..df5c3269 100644 --- a/app/game-engine/test/monthlySpecialityBetrayPersistence.integration.test.ts +++ b/app/game-engine/test/monthlySpecialityBetrayPersistence.integration.test.ts @@ -9,6 +9,7 @@ import { createAddGlobalBetrayHandler, createAssignGeneralSpecialityHandler, } from '../src/turn/monthlySpecialityBetrayAction.js'; +import { createTurnDaemonCommandHandler } from '../src/turn/worldCommandHandler.js'; import type { TurnEvent, TurnGeneral, TurnWorldSnapshot, TurnWorldState } from '../src/turn/types.js'; const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL; @@ -105,7 +106,7 @@ integration('monthly speciality and betrayal persistence', () => { }), buildGeneral(generalIds[1], { domestic: 'che_경작', - war: null, + war: 'che_신산', meta: { specage: 99, specage2: 30, @@ -198,6 +199,22 @@ integration('monthly speciality and betrayal persistence', () => { const hooks = await createDatabaseTurnHooks(databaseUrl!, world); try { + const reservedGeneral = world.getGeneralById(generalIds[1])!; + const commandHandler = createTurnDaemonCommandHandler({ world }); + await expect( + commandHandler.handle({ + type: 'patchGeneral', + generalId: reservedGeneral.id, + patch: { + specialWar: null, + meta: { + ...reservedGeneral.meta, + inheritResetSpecialWar: 0, + prev_types_special2: ['che_신산'], + }, + }, + }) + ).resolves.toMatchObject({ type: 'patchGeneral', ok: true }); await world.advanceMonth(new Date('0200-01-01T00:00:00.000Z')); await hooks.hooks.flushChanges?.({ lastTurnTime: state.lastTurnTime.toISOString(), @@ -214,7 +231,12 @@ integration('monthly speciality and betrayal persistence', () => { expect(rows[0]?.specialCode).not.toBe('None'); expect(rows[0]?.meta).toMatchObject({ betray: 2 }); expect(rows[1]).toMatchObject({ special2Code: 'che_의술' }); - expect(rows[1]?.meta).toMatchObject({ betray: 3, marker: 2 }); + expect(rows[1]?.meta).toMatchObject({ + betray: 3, + marker: 2, + inheritResetSpecialWar: 0, + prev_types_special2: ['che_신산'], + }); expect(rows[1]?.meta).not.toHaveProperty('inheritSpecificSpecialWar'); expect(await db.logEntry.count({ where: { generalId: { in: [...generalIds] } } })).toBe(4); } finally { diff --git a/app/game-frontend/src/views/InheritView.vue b/app/game-frontend/src/views/InheritView.vue index 1c576894..049ef965 100644 --- a/app/game-frontend/src/views/InheritView.vue +++ b/app/game-frontend/src/views/InheritView.vue @@ -796,12 +796,12 @@ onMounted(() => { width: min(100%, 1000px); margin: 0 auto; border: 1px solid #888; - overflow: hidden; + overflow-x: hidden; box-sizing: border-box; position: relative; padding: 0 7px; color: #fff; - height: 1597px; + min-height: 1597px; font: 14px/21px var(--sammo-font-sans); } @@ -1017,7 +1017,7 @@ a:not(.legacy-button):focus-visible { } .inherit-page { - height: 3047.5px; + min-height: 3047.5px; } .shop-item .buy-button { diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index b97c1bc5..6b530827 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -86,7 +86,7 @@ storage, route guards, and image loading. | best general | `hwe/a_bestGeneral.php` | authenticated 500/1000px ranking and unique-item grids, user/NPC switch, 100/64px cell/image geometry, title/button computed styles, retained-data API error | | hall of fame | `hwe/a_hallOfFame.php` | public 500/1000px container, 100px ranking cells, 64px natural image, title/button/select computed styles, scenario switch and retained-data API error | | yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | -| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error | +| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error, two 30-row history pages that expand the document and keep the last row/load-more button reachable by scrolling | | nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | | public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | | survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error | diff --git a/packages/common/src/turnDaemon/types.ts b/packages/common/src/turnDaemon/types.ts index dcfdb6e4..045eb4ba 100644 --- a/packages/common/src/turnDaemon/types.ts +++ b/packages/common/src/turnDaemon/types.ts @@ -230,7 +230,7 @@ export type TurnDaemonCommand = strength?: number; intelligence?: number; }; - specialWar?: string; + specialWar?: string | null; }; } | { diff --git a/tools/frontend-legacy-parity/inheritance-management.spec.ts b/tools/frontend-legacy-parity/inheritance-management.spec.ts index 7b50b62a..8f3a121f 100644 --- a/tools/frontend-legacy-parity/inheritance-management.spec.ts +++ b/tools/frontend-legacy-parity/inheritance-management.spec.ts @@ -4,7 +4,7 @@ import { dirname, extname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); -const imageRoot = resolve(repositoryRoot, '../../image'); +const imageRoot = process.env.FRONTEND_PARITY_IMAGE_ROOT ?? resolve(repositoryRoot, '../../image'); const artifactRoot = process.env.FRONTEND_PARITY_ARTIFACT_DIR; const gameUrl = `http://127.0.0.1:${process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'}/che/inherit`; @@ -117,9 +117,21 @@ const statusFixture = { currentStat: { leadership: 70, strength: 45, intel: 85 }, }; -const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) => { +interface InheritanceLogFixture { + id: number; + year: number; + month: number; + text: string; + createdAt: string; +} + +const installFixture = async ( + page: Page, + options: { failBuff?: boolean; logPages?: InheritanceLogFixture[][] } = {} +) => { let buffMutationCount = 0; let resetTurnMutationCount = 0; + let logRequestCount = 0; const uniqueAuctionRequests: unknown[] = []; await installImages(page); await page.addInitScript(() => { @@ -148,7 +160,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) }); } if (name === 'inherit.getLogs') { - return response([ + const defaultPage = [ { id: 2, year: 200, @@ -156,7 +168,11 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) text: '1000 포인트로 장수 소유자 확인', createdAt: '2026-07-26T00:00:00.000Z', }, - ]); + ]; + const pages = options.logPages ?? [defaultPage]; + const pageIndex = Math.min(logRequestCount, pages.length - 1); + logRequestCount += 1; + return response(pages[pageIndex] ?? []); } if (name === 'join.getConfig') { return response({ rules: { stat: { total: 200, min: 10, max: 100 } } }); @@ -184,6 +200,7 @@ const installFixture = async (page: Page, options: { failBuff?: boolean } = {}) return { buffMutationCount: () => buffMutationCount, resetTurnMutationCount: () => resetTurnMutationCount, + logRequestCount: () => logRequestCount, uniqueAuctionRequests, }; }; @@ -333,6 +350,52 @@ test.describe('inheritance management legacy parity', () => { await expect(page.locator('#inherit_previous_value')).toHaveValue('12,000'); }); + test('keeps every paged inheritance log reachable by document scrolling', async ({ page }) => { + const buildPage = (firstId: number, count: number): InheritanceLogFixture[] => + Array.from({ length: count }, (_, index) => { + const id = firstId - index; + return { + id, + year: 200, + month: 4, + text: `유산 포인트 변경 내역 ${id}`, + createdAt: `2026-07-${String((id % 27) + 1).padStart(2, '0')}T00:00:00.000Z`, + }; + }); + const fixture = await installFixture(page, { + logPages: [buildPage(60, 30), buildPage(30, 30), []], + }); + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto(gameUrl); + await expect(page.locator('.log-row')).toHaveCount(30); + + const firstHeight = await page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0); + const moreButton = page.getByRole('button', { name: '더 가져오기' }); + await moreButton.click(); + await expect(page.locator('.log-row')).toHaveCount(60); + await expect(page.locator('.log-row').last()).toContainText('유산 포인트 변경 내역 1'); + const expandedHeight = await page.evaluate(() => document.scrollingElement?.scrollHeight ?? 0); + expect(expandedHeight).toBeGreaterThan(firstHeight); + + await page.evaluate(() => window.scrollTo(0, document.scrollingElement?.scrollHeight ?? 0)); + await expect(page.locator('.log-row').last()).toBeInViewport(); + await expect(moreButton).toBeInViewport(); + expect( + await page.evaluate(() => + Math.abs( + window.scrollY + window.innerHeight - (document.scrollingElement?.scrollHeight ?? window.innerHeight) + ) + ) + ).toBeLessThanOrEqual(1); + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'inherit-core-mobile-60-logs.png'), fullPage: true }); + } + + await moreButton.click(); + await expect.poll(fixture.logRequestCount).toBe(3); + await expect(moreButton).toBeDisabled(); + }); + test('selects a Ref default unique and starts its auction from the inheritance page', async ({ page }) => { const fixture = await installFixture(page); await page.goto(gameUrl); From aa479c1f0f6b71b784b9d0ab9977665ea8fe1455 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 04:49:52 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(game-ui):=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=EC=A0=9C=EC=96=B4=EB=A5=BC=20=ED=84=B4=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EA=B8=B0=20=EC=95=84=EB=9E=98=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-frontend/e2e/mainNavigation.spec.ts | 219 +++++++++++------- .../src/assets/styles/legacy-controls.css | 2 +- .../src/components/main/MainTurnControls.vue | 84 +++++++ app/game-frontend/src/views/MainView.vue | 88 ++----- .../reference-progress-bars.mjs | 29 +++ 5 files changed, 275 insertions(+), 147 deletions(-) create mode 100644 app/game-frontend/src/components/main/MainTurnControls.vue diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index daa3b475..251de1d6 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1047,10 +1047,10 @@ const persistArtifact = async (page: Page, name: string) => { quickPopup: describe('#mobile-quick-menu'), gameHeader: describe('.game-shell__header'), gameTitle: describe('.game-shell__title'), - gameHeaderActions: describe('.desktop-action-controls'), - headerRealtime: describe('.desktop-action-controls__realtime'), - headerRefresh: describe('.desktop-action-controls__refresh'), - headerLobby: describe('.desktop-action-controls__lobby'), + turnControls: describe('.main-turn-controls'), + turnAutoRefresh: describe('.main-turn-controls__auto'), + turnManualRefresh: describe('.main-turn-controls__manual'), + turnLobby: describe('.main-turn-controls__lobby'), legacyGameInfo: describe('.legacy-game-info'), activityStatus: describe('.activity-status'), executionStatus: describe('.execution-status'), @@ -2382,21 +2382,18 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy const activityGeometry = await page.locator('.activity-status').evaluate((element) => { const main = element.closest('.main-page'); const header = main?.querySelector('.game-shell__header'); - const headerActions = header?.querySelector('.desktop-action-controls'); const execution = element.querySelector('.execution-status'); const tournament = element.querySelector('.tournament-status'); const survey = element.querySelector('.vote-status'); - if (!header || !headerActions || !execution || !tournament || !survey) { + if (!header || !execution || !tournament || !survey) { throw new Error('mobile header or activity status is incomplete'); } const headerRect = header.getBoundingClientRect(); - const headerActionsRect = headerActions.getBoundingClientRect(); return { headerHeight: headerRect.height, headerLeft: headerRect.left, headerRight: headerRect.right, - headerActionsLeft: headerActionsRect.left, - headerActionsRight: headerActionsRect.right, + headerActionCount: header.querySelectorAll('button').length, width: element.getBoundingClientRect().width, executionWidth: execution.getBoundingClientRect().width, tournamentWidth: tournament.getBoundingClientRect().width, @@ -2404,10 +2401,9 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy columns: getComputedStyle(element).gridTemplateColumns, }; }); - expect(activityGeometry.headerHeight).toBeGreaterThan(90); - expect(activityGeometry.headerHeight).toBeLessThan(110); - expect(activityGeometry.headerActionsLeft).toBeGreaterThanOrEqual(activityGeometry.headerLeft); - expect(activityGeometry.headerActionsRight).toBeLessThanOrEqual(activityGeometry.headerRight); + expect(activityGeometry.headerHeight).toBeGreaterThan(40); + expect(activityGeometry.headerHeight).toBeLessThan(70); + expect(activityGeometry.headerActionCount).toBe(0); expect(activityGeometry.width).toBe(500); expect(activityGeometry.executionWidth).toBeCloseTo(166.67, 0); expect(activityGeometry.tournamentWidth).toBeCloseTo(166.67, 0); @@ -3050,7 +3046,7 @@ test('all main Lumen button families share the rounded pressed geometry', async await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); - const controls: Array<[string, Locator]> = [ + const controls: Array<[string, Locator, { borderLeft?: string; radius?: string }?]> = [ [ '천통국 베팅', page.locator('.main-global-menu[data-menu-position="top"] [data-navigation-id="nation-betting"]'), @@ -3076,9 +3072,17 @@ test('all main Lumen button families share the rounded pressed geometry', async '펼치기', page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }), ], - ['실시간 동기화', page.locator('.desktop-action-controls').getByRole('button', { name: /실시간 동기화:/u })], - ['갱 신', page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' })], - ['로비로', page.locator('.desktop-action-controls').getByRole('button', { name: '로비로' })], + [ + '자동 갱신', + page.locator('.main-turn-controls').getByRole('button', { name: '자동 갱신 ON' }), + { borderLeft: '0px', radius: '0px 5.25px 5.25px 0px' }, + ], + [ + '갱 신', + page.locator('.main-turn-controls').getByRole('button', { name: '갱 신' }), + { radius: '5.25px 0px 0px 5.25px' }, + ], + ['로비로', page.locator('.main-turn-controls').getByRole('button', { name: '로비로' })], ]; const measure = (control: Locator) => @@ -3101,7 +3105,7 @@ test('all main Lumen button families share the rounded pressed geometry', async }); const evidence: Record> = {}; - for (const [index, [label, control]] of controls.entries()) { + for (const [index, [label, control, expectedGeometry]] of controls.entries()) { await expect(control, `${label} control`).toBeVisible(); await expect(control).toHaveClass(/legacy-button/u); await control.scrollIntoViewIfNeeded(); @@ -3116,8 +3120,8 @@ test('all main Lumen button families share the rounded pressed geometry', async borderTop: '0px', borderRight: '1px', borderBottom: '4px', - borderLeft: '1px', - radius: '5.25px', + borderLeft: expectedGeometry?.borderLeft ?? '1px', + radius: expectedGeometry?.radius ?? '5.25px', filter: 'none', }); @@ -3164,7 +3168,7 @@ test('all main Lumen button families share the rounded pressed geometry', async } state.permission = 0; - await page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' }).click(); + await page.locator('.main-turn-controls').getByRole('button', { name: '갱 신' }).click(); const disabledSecret = page.locator('.layout-desktop [data-navigation-id="secret-board"]'); await expect(disabledSecret).toHaveAttribute('aria-disabled', 'true'); await disabledSecret.scrollIntoViewIfNeeded(); @@ -3186,7 +3190,9 @@ test('all main Lumen button families share the rounded pressed geometry', async await persistArtifact(page, `${basePath.slice(1)}-main-lumen-button-families`); }); -test('lobby action is separated on desktop and anchors opposite the refresh action on mobile', async ({ page }) => { +test('places the joined refresh controls and lobby below the turn editor without changing the desktop baseline', async ({ + page, +}) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -3195,67 +3201,114 @@ test('lobby action is separated on desktop and anchors opposite the refresh acti npcMode: 1, generalMeCalls: 0, operations: [], + refreshDelayMs: 300, + largeCommandTable: true, + reservedTurns: Array.from({ length: 30 }, (_, index) => ({ + index, + action: index === 0 ? '휴식' : `command-${index}`, + args: {}, + })), }; await installFixture(page, state); await page.setViewportSize({ width: 1200, height: 900 }); await waitForMain(page); - const actions = page.locator('.desktop-action-controls'); - const realtime = page.locator('.desktop-action-controls__realtime'); - const refresh = page.locator('.desktop-action-controls__refresh'); - const lobby = page.locator('.desktop-action-controls__lobby'); + await expect(page.locator('.game-shell__header button')).toHaveCount(0); const measure = () => - page.evaluate(() => { - const rect = (selector: string) => { - const element = document.querySelector(selector); - if (!element) throw new Error(`${selector} is missing`); - const box = element.getBoundingClientRect(); - return { left: box.left, right: box.right, top: box.top, bottom: box.bottom, width: box.width }; + page.locator('[data-main-target="commands"]').evaluate((commands) => { + const box = (element: Element) => { + const rect = element.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }; }; - const actionStyle = getComputedStyle(document.querySelector('.desktop-action-controls')!); + const find = (selector: string) => { + const element = commands.querySelector(selector); + if (!element) throw new Error(`${selector} is missing`); + return element; + }; + const editor = find('.reserved-command-editor'); + const controls = find('.main-turn-controls'); + const pair = find('.main-turn-controls__refresh-pair'); + const manual = find('.main-turn-controls__manual'); + const auto = find('.main-turn-controls__auto'); + const lobby = find('.main-turn-controls__lobby'); + const manualStyle = getComputedStyle(manual); + const autoStyle = getComputedStyle(auto); return { - actions: rect('.desktop-action-controls'), - realtime: rect('.desktop-action-controls__realtime'), - refresh: rect('.desktop-action-controls__refresh'), - lobby: rect('.desktop-action-controls__lobby'), - title: rect('.game-shell__title'), - display: actionStyle.display, - columns: actionStyle.gridTemplateColumns, + commands: box(commands), + editor: box(editor), + controls: box(controls), + pair: box(pair), + manual: box(manual), + auto: box(auto), + lobby: box(lobby), + controlsAfterEditor: Boolean( + editor.compareDocumentPosition(controls) & Node.DOCUMENT_POSITION_FOLLOWING + ), + manualRadius: { + topRight: manualStyle.borderTopRightRadius, + bottomRight: manualStyle.borderBottomRightRadius, + }, + autoRadius: { + topLeft: autoStyle.borderTopLeftRadius, + bottomLeft: autoStyle.borderBottomLeftRadius, + }, + manualRightBorder: manualStyle.borderRightWidth, + autoLeftBorder: autoStyle.borderLeftWidth, + overflow: commands.scrollWidth - commands.clientWidth, }; }); - await expect(actions).toHaveCSS('display', 'flex'); let layout = await measure(); - expect(layout.lobby.left - layout.refresh.right).toBeGreaterThanOrEqual(20); - expect(layout.refresh.top).toBeCloseTo(layout.lobby.top, 2); - expect(layout.refresh.width).toBeLessThanOrEqual(62); - expect(layout.lobby.width).toBeLessThanOrEqual(62); + const cityBottom = await page + .locator('[data-main-target="city"]') + .evaluate((element) => element.getBoundingClientRect().bottom); + expect(layout.commands.bottom).toBe(cityBottom); + expect(layout.commands.height).toBeCloseTo(645, 0); + expect(layout.controlsAfterEditor).toBe(true); + expect(layout.controls.top).toBeGreaterThanOrEqual(layout.editor.bottom); + expect(layout.manual.right).toBe(layout.auto.left); + expect(layout.pair.right + 4).toBe(layout.lobby.left); + expect(layout.manualRadius).toEqual({ topRight: '0px', bottomRight: '0px' }); + expect(layout.autoRadius).toEqual({ topLeft: '0px', bottomLeft: '0px' }); + expect(layout.manualRightBorder).toBe('1px'); + expect(layout.autoLeftBorder).toBe('0px'); + expect(layout.overflow).toBeLessThanOrEqual(0); + const autoRefresh = page.locator('.layout-desktop .main-turn-controls__auto'); + await expect(autoRefresh).toHaveAttribute('aria-pressed', 'true'); + await autoRefresh.click(); + await expect(page.locator('.layout-desktop .main-turn-controls__auto')).toHaveAccessibleName('자동 갱신 OFF'); + await expect(page.locator('.layout-desktop .main-turn-controls__auto')).toHaveAttribute('aria-pressed', 'false'); + const manualRefresh = page.locator('.layout-desktop .main-turn-controls__manual'); + const callsBeforeManualRefresh = state.generalMeCalls; + await manualRefresh.click(); + await expect(manualRefresh).toBeEnabled(); + await expect(manualRefresh).toHaveAttribute('aria-busy', 'true'); + await manualRefresh.click(); + await expect(page.getByTestId('game-toast')).toContainText('이미 정보를 갱신하고 있습니다.'); + await expect(manualRefresh).toHaveAttribute('aria-busy', 'false'); + expect(state.generalMeCalls).toBe(callsBeforeManualRefresh + 1); await page.setViewportSize({ width: 500, height: 900 }); - await expect(actions).toHaveCSS('display', 'grid'); - await expect(realtime).toBeVisible(); - await expect(refresh).toBeVisible(); - await expect(lobby).toBeVisible(); + await expect(page.locator('.layout-mobile .main-turn-controls')).toBeVisible(); layout = await measure(); - expect(layout.columns.split(' ')).toHaveLength(3); - expect(layout.actions.left).toBeCloseTo(0, 2); - expect(layout.actions.right).toBeCloseTo(500, 2); - expect(layout.refresh.left).toBeCloseTo(layout.actions.left, 2); - expect(layout.lobby.right).toBeCloseTo(layout.actions.right, 2); - expect(layout.refresh.right).toBeLessThan(layout.realtime.left); - expect(layout.realtime.right).toBeLessThan(layout.lobby.left); - expect(layout.refresh.top).toBeCloseTo(layout.lobby.top, 2); - expect(layout.actions.top).toBeGreaterThanOrEqual(layout.title.bottom); - expect(layout.refresh.width).toBeLessThanOrEqual(62); - expect(layout.lobby.width).toBeLessThanOrEqual(62); - expect( - await page.evaluate(() => ({ - document: document.documentElement.scrollWidth - document.documentElement.clientWidth, - body: document.body.scrollWidth - document.body.clientWidth, - })) - ).toEqual({ document: 0, body: 0 }); + expect(layout.commands.width).toBe(500); + expect(layout.controlsAfterEditor).toBe(true); + expect(layout.controls.top).toBeGreaterThanOrEqual(layout.editor.bottom); + expect(layout.manual.right).toBe(layout.auto.left); + expect(layout.pair.right + 4).toBe(layout.lobby.left); + expect(layout.overflow).toBeLessThanOrEqual(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBe( + 0 + ); - await persistArtifact(page, `${basePath.slice(1)}-main-lobby-action-layout`); + await persistArtifact(page, `${basePath.slice(1)}-main-turn-action-layout`); }); test('mobile main Lumen button families keep the same state geometry without overflow', async ({ page }) => { @@ -3284,14 +3337,17 @@ test('mobile main Lumen button families keep the same state geometry without ove page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '당기기' }), page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '미루기' }), page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }), - page.locator('.desktop-action-controls').getByRole('button', { name: /실시간 동기화:/u }), - page.locator('.desktop-action-controls').getByRole('button', { name: '갱 신' }), - page.locator('.desktop-action-controls').getByRole('button', { name: '로비로' }), + page.locator('.layout-mobile .main-turn-controls').getByRole('button', { name: /자동 갱신/u }), + page.locator('.layout-mobile .main-turn-controls').getByRole('button', { name: '갱 신' }), + page.locator('.layout-mobile .main-turn-controls').getByRole('button', { name: '로비로' }), ]; - for (const control of controls) { + for (const [index, control] of controls.entries()) { await expect(control).toBeVisible(); await expect(control).toHaveClass(/legacy-button/u); - await expect(control).toHaveCSS('border-radius', '5.25px'); + await expect(control).toHaveCSS( + 'border-radius', + index === 7 ? '0px 5.25px 5.25px 0px' : index === 8 ? '5.25px 0px 0px 5.25px' : '5.25px' + ); await expect(control).toHaveCSS('border-bottom-width', '4px'); } @@ -3356,8 +3412,9 @@ test('mobile single document refreshes once and preserves tokens on lobby return await expect(page.locator(selector)).toBeVisible(); } - const autoRefresh = page.getByRole('button', { name: '자동 갱신 ON' }); - const manualRefresh = page.getByRole('button', { name: '직접 갱신' }); + const mobileBottom = page.locator('.main-mobile-bottom'); + const autoRefresh = mobileBottom.getByRole('button', { name: '자동 갱신 ON' }); + const manualRefresh = mobileBottom.getByRole('button', { name: '직접 갱신' }); await expect(autoRefresh).toHaveAttribute('aria-pressed', 'true'); await expect(autoRefresh.locator('strong')).toHaveCSS('color', 'rgb(158, 240, 184)'); await expect(manualRefresh).toHaveAttribute('aria-busy', 'false'); @@ -3397,7 +3454,7 @@ test('mobile single document refreshes once and preserves tokens on lobby return await expect(autoRefresh).toHaveCSS('border-bottom-width', '3px'); await expect(autoRefresh).toHaveCSS('margin-top', '1px'); await autoRefresh.click(); - const disabledAutoRefresh = page.getByRole('button', { name: '자동 갱신 OFF' }); + const disabledAutoRefresh = mobileBottom.getByRole('button', { name: '자동 갱신 OFF' }); await expect(disabledAutoRefresh).toHaveAttribute('aria-pressed', 'false'); await expect(disabledAutoRefresh.locator('strong')).toHaveCSS('color', 'rgb(187, 187, 187)'); await expect @@ -3414,8 +3471,8 @@ test('mobile single document refreshes once and preserves tokens on lobby return await expect(page.locator('.general-title')).toContainText('직접갱신된장수'); const callsBeforeEnable = state.generalMeCalls; - await page.getByRole('button', { name: '자동 갱신 OFF' }).click(); - await expect(page.getByRole('button', { name: '자동 갱신 ON' })).toHaveAttribute('aria-pressed', 'true'); + await mobileBottom.getByRole('button', { name: '자동 갱신 OFF' }).click(); + await expect(mobileBottom.getByRole('button', { name: '자동 갱신 ON' })).toHaveAttribute('aria-pressed', 'true'); await expect.poll(() => state.generalMeCalls).toBeGreaterThan(callsBeforeEnable); await expect .poll(() => @@ -4155,8 +4212,10 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn pages.map((currentPage) => expect(currentPage.locator('.general-title')).toContainText('탭공유갱신장수')) ); - await followerPage.getByRole('button', { name: /실시간 동기화/u }).click(); - await expect(followerPage.getByRole('button', { name: /실시간 동기화: 끔/u })).toBeVisible(); + await followerPage.locator('.layout-desktop .main-turn-controls__auto').click(); + await expect(followerPage.locator('.layout-desktop .main-turn-controls__auto')).toHaveAccessibleName( + '자동 갱신 OFF' + ); const callsBeforeExcludedRefresh = state.generalMeCalls; state.generalName = '리더만갱신장수'; await leaderPage.evaluate(() => { @@ -4182,7 +4241,7 @@ test('same-account main tabs share one realtime diff and exclude a tab while syn await expect(leaderPage.locator('.general-title')).toContainText('리더만갱신장수'); await expect(followerPage.locator('.general-title')).toContainText('탭공유갱신장수'); - await followerPage.getByRole('button', { name: /실시간 동기화: 끔/u }).click(); + await followerPage.locator('.layout-desktop .main-turn-controls__auto').click(); await expect(followerPage.locator('.general-title')).toContainText('리더만갱신장수'); await expect .poll(async () => { diff --git a/app/game-frontend/src/assets/styles/legacy-controls.css b/app/game-frontend/src/assets/styles/legacy-controls.css index 6a2f54fb..66ffc7f0 100644 --- a/app/game-frontend/src/assets/styles/legacy-controls.css +++ b/app/game-frontend/src/assets/styles/legacy-controls.css @@ -160,7 +160,7 @@ * toggle's overlapping left border. These rules intentionally follow the * Lumen family so its border shorthand cannot restore the inner rounding. */ -.legacy-split-button > .main-menu-link { +.legacy-split-button > :is(.main-menu-link, .legacy-split-button__main) { border-radius: 5.25px 0 0 5.25px; } diff --git a/app/game-frontend/src/components/main/MainTurnControls.vue b/app/game-frontend/src/components/main/MainTurnControls.vue new file mode 100644 index 00000000..aed3cc56 --- /dev/null +++ b/app/game-frontend/src/components/main/MainTurnControls.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index 0116365b..01d3ae2c 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -17,6 +17,7 @@ import MainFrontStatus from '../components/main/MainFrontStatus.vue'; import MainGlobalMenu from '../components/main/MainGlobalMenu.vue'; import MainNationMenu from '../components/main/MainNationMenu.vue'; import MainMobileBottomBar from '../components/main/MainMobileBottomBar.vue'; +import MainTurnControls from '../components/main/MainTurnControls.vue'; import { defaultGlobalNavigation, type MainNavigationEntry, @@ -86,7 +87,6 @@ const { messageDraftText, targetMailbox, mailboxGroups, - realtimeLabel, } = storeToRefs(dashboard); const nationAccess = computed(() => ({ @@ -223,31 +223,6 @@ watch(

{{ gameTitle }}

-
- - - -
@@ -282,7 +257,7 @@ watch(