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/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index 270f0fdf..5a3d4438 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1993,7 +1993,7 @@ export const createReservedTurnHandler = async (options: { let deleteGeneral = false; const deletedTroopIds = Array.from(commandDeletedTroopIds); const lifecycleSnapshot = cloneTurnGeneral(currentGeneral); - if (currentGeneral.meta.killturn <= 0 && typeof currentGeneral.deadYear === 'number') { + if (currentGeneral.meta.killturn <= 0) { if ( currentGeneral.npcState === 1 && typeof currentGeneral.deadYear === 'number' && 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/generalTurnLifecycle.test.ts b/app/game-engine/test/generalTurnLifecycle.test.ts index 05f6b330..ce7a1eb8 100644 --- a/app/game-engine/test/generalTurnLifecycle.test.ts +++ b/app/game-engine/test/generalTurnLifecycle.test.ts @@ -316,12 +316,13 @@ describe('legacy general turn lifecycle', () => { expect(harness.world.peekDirtyState().deletedGenerals).toContain(1); }); - it('keeps compatibility fixtures without legacy lifespan metadata alive', async () => { + it('deletes an expired NPC even when its in-memory lifespan metadata is missing', async () => { const harness = await createTurnTestHarness({ snapshot: makeSnapshot([ makeGeneral({ deadYear: undefined, - npcState: 2, + npcState: 4, + name: 'ⓖ의병', meta: { killturn: 1 }, }), ]), @@ -332,9 +333,9 @@ describe('legacy general turn lifecycle', () => { await harness.runOneTick(); - expect(harness.world.getGeneralById(1)).not.toBeNull(); - expect(harness.world.getGeneralById(1)!.meta.killturn).toBe(0); - expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('active'); + expect(harness.world.getGeneralById(1)).toBeNull(); + expect(harness.world.peekDirtyState().deletedGenerals).toContain(1); + expect(harness.world.peekDirtyState().lifecycleEvents[0]?.outcome).toBe('deleted'); }); it('retires a player general and resets inherited stats and rank state', async () => { 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/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index 0d84baff..560e2b67 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -130,6 +130,45 @@ const generals = [ }, ]; +const npcGenerals = [ + { + id: 10, + name: '낮은장수', + ownerName: '', + npcState: 0, + level: 4, + nationId: 2, + nationName: '촉', + personality: null, + specialDomestic: null, + specialWar: null, + statTotal: 120, + leadership: 30, + strength: 50, + intelligence: 40, + experience: 100, + dedication: 50, + }, + { + id: 20, + name: '높은장수', + ownerName: '빙의자', + npcState: 1, + level: 8, + nationId: 1, + nationName: '위', + personality: null, + specialDomestic: null, + specialWar: null, + statTotal: 240, + leadership: 90, + strength: 70, + intelligence: 80, + experience: 500, + dedication: 300, + }, +]; + const parseSort = (route: Route): number => { try { const request = route.request(); @@ -224,6 +263,20 @@ const install = async ( sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals; return response({ sort, generals: rows }); } + if (operation === 'public.getNpcList') { + const sort = parseSort(route); + const rows = [...npcGenerals].sort((left, right) => { + if (sort === 2) return left.nationId - right.nationId || left.id - right.id; + if (sort === 3) return right.statTotal - left.statTotal || left.id - right.id; + if (sort === 4) return right.leadership - left.leadership || left.id - right.id; + if (sort === 5) return right.strength - left.strength || left.id - right.id; + if (sort === 6) return right.intelligence - left.intelligence || left.id - right.id; + if (sort === 7) return right.experience - left.experience || left.id - right.id; + if (sort === 8) return right.dedication - left.dedication || left.id - right.id; + return left.name.localeCompare(right.name) || left.id - right.id; + }); + return response({ sort, generals: rows, tokenKeepCounts: {} }); + } return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); @@ -340,7 +393,7 @@ test('nation and general directories preserve the fixed legacy Chromium geometry await expect.poll(() => accessPages).toContain('nation-list'); expect(accessPages).not.toContain('general-list'); - const header = page.locator('.general-table thead td').first(); + const header = page.locator('.general-table thead th').first(); expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg'); const icon = page.locator('.general-icon').first(); await expect(icon).toBeVisible(); @@ -392,6 +445,78 @@ test('general directory submits the legacy sort selector and keeps wounded/bonus expect(await page.locator('#viewType').evaluate((element) => document.activeElement === element)).toBe(true); }); +test('directory sort controls stay legible in dark mode and sortable headers apply the matching option', async ({ + page, +}, testInfo) => { + await install(page); + await page.goto('general-list'); + + const select = page.locator('#viewType'); + const submit = page.getByRole('button', { name: '정렬하기' }); + const colors = await select.evaluate((element) => { + const selectStyle = getComputedStyle(element); + const optionStyle = getComputedStyle(element.querySelector('option')!); + return { + selectBackground: selectStyle.backgroundColor, + selectColor: selectStyle.color, + optionBackground: optionStyle.backgroundColor, + optionColor: optionStyle.color, + }; + }); + expect(colors).toEqual({ + selectBackground: 'rgb(24, 35, 29)', + selectColor: 'rgb(247, 250, 248)', + optionBackground: 'rgb(24, 35, 29)', + optionColor: 'rgb(247, 250, 248)', + }); + const defaultButton = await submit.evaluate((element) => { + const style = getComputedStyle(element); + return { + background: style.backgroundColor, + color: style.color, + borderBottomWidth: style.borderBottomWidth, + cursor: style.cursor, + }; + }); + expect(defaultButton).toEqual({ + background: 'rgb(55, 90, 127)', + color: 'rgb(255, 255, 255)', + borderBottomWidth: '3px', + cursor: 'pointer', + }); + await submit.hover(); + await page.mouse.down(); + expect(await submit.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('1px'); + await page.mouse.up(); + + await page.getByRole('button', { name: '삭턴 기준 정렬' }).click(); + await expect(select).toHaveValue('8'); + await expect(page.locator('th[aria-sort="ascending"]')).toContainText('삭턴'); + await expect(page.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); + await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 500, height: 844 }); + await expect(select).toHaveCSS('background-color', 'rgb(24, 35, 29)'); + await expect(submit).toHaveCSS('border-bottom-width', '3px'); + await page.screenshot({ path: testInfo.outputPath('directory-sort-controls-mobile.png'), fullPage: true }); +}); + +test('npc directory reuses the dark sort controls and sorts from a table header', async ({ page }, testInfo) => { + await install(page); + await page.goto('npc-list'); + await expect(page.locator('.npc-table tbody tr[data-general-id]')).toHaveCount(2); + await page.getByRole('button', { name: '통솔 기준 정렬' }).click(); + await expect(page.locator('#npc-list-sort')).toHaveValue('4'); + await expect(page.locator('.npc-table th[aria-sort="descending"]')).toContainText('통솔'); + await expect(page.locator('.npc-table tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '20'); + await expect(page.locator('#npc-list-sort')).toHaveCSS('background-color', 'rgb(24, 35, 29)'); + await expect(page.getByRole('button', { name: '정렬하기' })).toHaveCSS('background-color', 'rgb(55, 90, 127)'); + await page.screenshot({ path: testInfo.outputPath('npc-sort-controls-desktop.png'), fullPage: true }); + await page.setViewportSize({ width: 500, height: 844 }); + await expect(page.locator('#npc-list-sort')).toHaveCSS('color', 'rgb(247, 250, 248)'); + await page.screenshot({ path: testInfo.outputPath('npc-sort-controls-mobile.png'), fullPage: true }); +}); + test('nation directory reuses only the public general-directory row on hover and keyboard focus', async ({ page }) => { const requestedOperations: string[] = []; await install(page, 'general', [], requestedOperations); 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/e2e/nationCityOfficeIntegration.spec.ts b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts index 7cdb7855..04aff5ae 100644 --- a/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts +++ b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts @@ -353,6 +353,15 @@ test('암행부 행을 도시별로 나누고 수뇌의 인사부 즉시 임명 await expect(page.locator('.city-user-table')).toHaveCount(0); await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0); + const citySort = page.locator('#nation-city-sort'); + await expect(citySort).toHaveCSS('background-color', 'rgb(24, 35, 29)'); + await citySort.selectOption('5'); + await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(page.locator('.city th[aria-sort="descending"]').first()).toContainText('농업'); + await page.getByRole('button', { name: '시세 기준 정렬' }).first().click(); + await expect(citySort).toHaveValue('10'); + await expect(page.locator('.city th[aria-sort="descending"]').first()).toContainText('시세'); + await page.getByRole('button', { name: '암행부 연동' }).click(); await expect(page.locator('.city-user-table')).toHaveCount(2); await expect(page.locator('.city[data-city-id="1"] .city-user-table tr[data-general-id="21"]')).toContainText( diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index 47387c3f..906c40d2 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -149,6 +149,30 @@ const install = async (page: Page, secretAllowed = true) => { { action: '휴식', args: {} }, ], }, + { + id: 2, + name: '부유장수', + npcState: 0, + injury: 0, + stats: { leadership: 60, strength: 50, intelligence: 40 }, + leadershipBonus: 0, + experienceLevel: 8, + troopId: 1, + troopName: '제1부대', + gold: 3000, + rice: 1000, + cityId: 2, + cityName: '낙양', + defenceTrain: 80, + defenceTrainText: '◎', + crewTypeId: 2, + crew: 100, + train: 80, + atmos: 80, + killTurn: 3, + turnTime: '2026-01-01T02:02:00.000Z', + reservedCommands: [], + }, ], }); } @@ -381,7 +405,7 @@ test('secret office renders five Ref-style command briefs and the forbidden erro '5 : 휴식', ]); await expect(commandRows.nth(2)).toHaveAttribute('title', '【다른장수】에게 쌀 200을 증여'); - const geometry = await page.locator('#secret-general-list .turns').evaluate((element) => { + const geometry = await page.locator('#secret-general-list .turns').first().evaluate((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { @@ -416,3 +440,22 @@ test('secret office renders five Ref-style command briefs and the forbidden erro await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); await expect(page.locator('#secret-general-list')).toHaveCount(0); }); + +test('secret office applies the selected sort on submit and immediately from sortable headers', async ({ page }) => { + await install(page); + await page.goto('nation/secret'); + const rows = page.locator('#secret-general-list tbody tr[data-general-id]'); + await expect(rows.first()).toHaveAttribute('data-general-id', '1'); + + await page.locator('#secret-list-sort').selectOption('1'); + await expect(rows.first()).toHaveAttribute('data-general-id', '1'); + await page.getByRole('button', { name: '정렬하기' }).click(); + await expect(rows.first()).toHaveAttribute('data-general-id', '2'); + await expect(page.locator('#secret-general-list th[aria-sort="descending"]')).toContainText('자 금'); + + await page.getByRole('button', { name: '도시 기준 정렬' }).click(); + await expect(page.locator('#secret-list-sort')).toHaveValue('3'); + await expect(rows.first()).toHaveAttribute('data-general-id', '1'); + await expect(page.locator('#secret-list-sort')).toHaveCSS('color', 'rgb(247, 250, 248)'); + await expect(page.getByRole('button', { name: '정렬하기' })).toHaveCSS('border-bottom-width', '3px'); +}); diff --git a/app/game-frontend/src/assets/styles/legacy-controls.css b/app/game-frontend/src/assets/styles/legacy-controls.css index 6a2f54fb..b7111403 100644 --- a/app/game-frontend/src/assets/styles/legacy-controls.css +++ b/app/game-frontend/src/assets/styles/legacy-controls.css @@ -38,6 +38,109 @@ opacity: 0.65; } +/* + * Compact sorting controls used by the Ref-style directory pages. Native + * dark-mode selects vary by browser, so both the closed control and its option + * popup own an explicit high-contrast palette. The submit control keeps a + * raised face and pressed edge without increasing the legacy title row. + */ +.legacy-sort-form { + min-height: 25px; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin: 0; +} + +.legacy-sort-select, +.legacy-sort-submit { + box-sizing: border-box; + height: 25px; + font: inherit; +} + +.legacy-sort-select { + min-width: 78px; + border: 1px solid #91a39a; + border-radius: 3px; + padding: 1px 24px 1px 6px; + background-color: #18231d; + color: #f7faf8; + color-scheme: dark; + cursor: pointer; +} + +.legacy-sort-select option { + background-color: #18231d; + color: #f7faf8; +} + +.legacy-sort-select option:checked { + background-color: #375a7f; + color: #fff; +} + +.legacy-sort-submit { + margin-top: 0; + border-color: #27405a; + border-style: solid; + border-width: 0 1px 3px; + border-radius: 3px; + padding: 1px 9px; + background: #375a7f; + color: #fff; + font-weight: 700; + line-height: 21px; + vertical-align: middle; + cursor: pointer; +} + +.legacy-sort-submit:hover { + margin-top: 1px; + border-bottom-width: 2px; +} + +.legacy-sort-submit:active { + margin-top: 2px; + border-bottom-width: 1px; +} + +.legacy-sort-select:focus-visible, +.legacy-sort-submit:focus-visible, +.legacy-sort-header:focus-visible { + outline: 2px solid var(--sammo-color-accent); + outline-offset: 1px; +} + +.legacy-sort-header { + width: 100%; + min-height: 18px; + margin: 0; + border: 0; + padding: 0 2px; + background: transparent; + color: inherit; + font: inherit; + line-height: inherit; + cursor: pointer; +} + +.legacy-sort-header:hover { + background: rgb(255 255 255 / 12%); +} + +.legacy-sort-indicator { + margin-left: 2px; + color: #9ee7ba; + font-size: 0.75em; + opacity: 0.65; +} + +[aria-sort] .legacy-sort-indicator { + opacity: 1; +} + /* * Ref Bootstrap 5.2 + Lumen button family. This class owns the common raised * edge and pressed movement. Semantic modifiers below only select face, edge, @@ -160,7 +263,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/directory/GeneralDirectoryTable.vue b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue index ac0b74ea..141d09cb 100644 --- a/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue +++ b/app/game-frontend/src/components/directory/GeneralDirectoryTable.vue @@ -4,18 +4,51 @@ import { formatOfficerLevelText } from '../../utils/nationFormat'; import { getNpcColor } from '../../utils/npcColor'; import type { GeneralDirectoryGeneral } from '../../types/directory'; -withDefaults( +type SortDirection = 'ascending' | 'descending'; +type Header = { + label: string; + sort?: number; + direction?: SortDirection; + title?: string; +}; + +const props = withDefaults( defineProps<{ generals: GeneralDirectoryGeneral[]; loading?: boolean; layout?: 'responsive' | 'card'; + activeSort?: number; }>(), { loading: false, layout: 'responsive', + activeSort: undefined, } ); +const emit = defineEmits<{ sort: [value: number] }>(); + +const headers: ReadonlyArray
= [ + { label: '얼 굴' }, + { label: '이 름' }, + { label: '연령', sort: 14, direction: 'descending' }, + { label: '성격', sort: 11, direction: 'descending' }, + { label: '특기' }, + { label: '레 벨', sort: 10, direction: 'descending' }, + { label: '국 가', sort: 1, direction: 'ascending' }, + { label: '명 성', sort: 5, direction: 'descending' }, + { label: '계 급', sort: 6, direction: 'descending' }, + { label: '관 직', sort: 7, direction: 'descending' }, + { label: '통솔', sort: 2, direction: 'descending' }, + { label: '무력', sort: 3, direction: 'descending' }, + { label: '지력', sort: 4, direction: 'descending' }, + { label: '삭턴', sort: 8, direction: 'ascending' }, + { label: '벌점', sort: 9, direction: 'descending' }, +]; + +const ariaSort = (header: Header): SortDirection | undefined => + header.sort === props.activeSort ? header.direction : undefined; + const injuredStat = (value: number, injury: number): number => Math.trunc((value * (100 - injury)) / 100); @@ -40,21 +73,28 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value - 얼 굴 - 이 름 - 연령 - 성격 - 특기 - 레 벨 - 국 가 - 명 성 - 계 급 - 관 직 - 통솔 - 무력 - 지력 - 삭턴 - 벌점 + + + + @@ -232,7 +272,8 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value line-height: 1.3; word-break: break-all; } -.directory-table td { +.directory-table td, +.directory-table th { border: 1px solid gray; padding: 0; word-break: break-all; @@ -242,6 +283,8 @@ const injuredStat = (value: number, injury: number): number => Math.trunc((value text-align: center; background-color: #14241b; background-image: var(--sammo-texture-green); + color: inherit; + font-weight: 400; } .general-icon { display: inline; 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/components/ui/LegacySortControls.vue b/app/game-frontend/src/components/ui/LegacySortControls.vue new file mode 100644 index 00000000..2dcfbe16 --- /dev/null +++ b/app/game-frontend/src/components/ui/LegacySortControls.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/game-frontend/src/views/GeneralListView.vue b/app/game-frontend/src/views/GeneralListView.vue index d74ec57b..9ba1e5fc 100644 --- a/app/game-frontend/src/views/GeneralListView.vue +++ b/app/game-frontend/src/views/GeneralListView.vue @@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue'; import { useRouter } from 'vue-router'; import GeneralDirectoryTable from '../components/directory/GeneralDirectoryTable.vue'; +import LegacySortControls from '../components/ui/LegacySortControls.vue'; import type { GeneralDirectoryGeneral } from '../types/directory'; import { trpc } from '../utils/trpc'; @@ -45,6 +46,15 @@ const loadDirectory = async () => { } }; +const updateSort = (value: number): void => { + sort.value = value as SortKey; +}; + +const sortByHeader = (value: number): void => { + updateSort(value); + void loadDirectory(); +}; + onMounted(() => { void loadDirectory(); }); @@ -63,22 +73,21 @@ onMounted(() => { -
- - - -
+ - + @@ -121,13 +130,6 @@ onMounted(() => { padding: 5px 10px; font-size: 14px; } -.sort-form { - margin: 0; -} -.sort-form select, -.sort-form button { - font-size: 14px; -} .directory-error { width: 998px; margin: 0; 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/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(