diff --git a/app/game-api/src/maps/worldMap.ts b/app/game-api/src/maps/worldMap.ts index 18289014..9bd65a3a 100644 --- a/app/game-api/src/maps/worldMap.ts +++ b/app/game-api/src/maps/worldMap.ts @@ -10,6 +10,11 @@ export type BaseMapResult = { startYear: number; year: number; month: number; + techLevelLimit: { + maxLevel: number; + initialLevel: number; + increaseYears: number; + }; cityList: MapCityCompact[]; nationList: MapNationCompact[]; }; @@ -64,6 +69,23 @@ const readState = (meta: Record): number => { return 0; }; +const readPositiveInteger = (value: unknown, fallback: number): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + const normalized = Math.floor(value); + return normalized > 0 ? normalized : fallback; +}; + +const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLevelLimit'] => { + const constValues = asRecord(asRecord(worldState.config).const); + return { + maxLevel: readPositiveInteger(constValues.maxTechLevel, 12), + initialLevel: readPositiveInteger(constValues.initialAllowedTechLevel, 1), + increaseYears: readPositiveInteger(constValues.techLevelIncYear, 5), + }; +}; + const normalizeNumberRecord = (value: unknown): Record => { if (!isRecord(value)) { return {}; @@ -180,6 +202,7 @@ const loadBaseMap = async ( startYear: resolveStartYear(worldState), year: worldState.currentYear, month: worldState.currentMonth, + techLevelLimit: resolveTechLevelLimit(worldState), cityList, nationList, }; diff --git a/app/game-api/src/turns/reservedTurns.ts b/app/game-api/src/turns/reservedTurns.ts index ffb44394..4487743e 100644 --- a/app/game-api/src/turns/reservedTurns.ts +++ b/app/game-api/src/turns/reservedTurns.ts @@ -19,6 +19,7 @@ export interface ReservedTurnView { export interface ReservedTurnSnapshot { revision: number; turns: ReservedTurnView[]; + autorunLimit?: number | null; } export interface ReservedTurnUpdate { @@ -170,13 +171,19 @@ export const listGeneralTurns = async (db: DatabaseClient, generalId: number): P }; export const getGeneralTurnSnapshot = async (db: DatabaseClient, generalId: number): Promise => { - const [turns, revisionRow] = await Promise.all([ + const [turns, revisionRow, general] = await Promise.all([ loadGeneralTurns(db, generalId), db.generalTurnRevision.findUnique({ where: { generalId } }), + db.general.findUnique({ where: { id: generalId }, select: { meta: true } }), ]); + const rawAutorunLimit = isRecord(general?.meta) ? general.meta.autorun_limit : undefined; return { revision: revisionRow?.revision ?? 0, turns: serializeTurnList(turns), + autorunLimit: + typeof rawAutorunLimit === 'number' && Number.isFinite(rawAutorunLimit) + ? Math.trunc(rawAutorunLimit) + : null, }; }; diff --git a/app/game-api/test/publicCachedMapHistory.test.ts b/app/game-api/test/publicCachedMapHistory.test.ts index fce501a4..8fc17e45 100644 --- a/app/game-api/test/publicCachedMapHistory.test.ts +++ b/app/game-api/test/publicCachedMapHistory.test.ts @@ -26,7 +26,13 @@ const buildContext = () => { findFirst: vi.fn(async () => ({ currentYear: 190, currentMonth: 3, - config: {}, + config: { + const: { + maxTechLevel: 10, + initialAllowedTechLevel: 2, + techLevelIncYear: 4, + }, + }, meta: { scenarioMeta: { startYear: 184 } }, })), }, @@ -38,12 +44,8 @@ const buildContext = () => { }, $queryRaw: vi .fn() - .mockResolvedValueOnce([ - { id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }, - ]) - .mockResolvedValueOnce([ - { id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }, - ]), + .mockResolvedValueOnce([{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }]) + .mockResolvedValueOnce([{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }]), }; const context: GameApiContext = { db: db as unknown as DatabaseClient, @@ -70,6 +72,11 @@ describe('public.getCachedMap', () => { expect(result).toMatchObject({ year: 190, month: 3, + techLevelLimit: { + maxLevel: 10, + initialLevel: 2, + increaseYears: 4, + }, history: [ { id: 9, text: '최근 정세' }, { id: 8, text: '이전 정세' }, diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index e0b80bca..32dd49be 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -824,7 +824,7 @@ describe('appRouter', () => { }); it('returns reserved general turns', async () => { - const general = buildGeneralRow({ id: 11 }); + const general = buildGeneralRow({ id: 11, meta: { autorun_limit: 2408 } }); const generalTurns: GeneralTurnRow[] = [ { id: 1, @@ -839,6 +839,7 @@ describe('appRouter', () => { const response = await caller.turns.reserved.getGeneral({ generalId: 11 }); expect(response.revision).toBe(0); + expect(response.autorunLimit).toBe(2408); expect(response.turns[0]?.action).toBe('che_화계'); expect(response.turns[0]?.index).toBe(0); }); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index d15403eb..a4d3dec3 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -71,6 +71,7 @@ const map = { startYear: 180, year: 200, month: 1, + techLevelLimit: { maxLevel: 12, initialLevel: 1, increaseYears: 5 }, cityList: castleFixtures.map(({ id, level }) => [id, level, 0, 1, 1, 1]), nationList: [[1, '아국', '#008000', 1]], spyList: {}, @@ -134,7 +135,8 @@ const install = async ( page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member', trade: number | null = 100, - globalNationCount = 2 + globalNationCount = 2, + mapFixture = map ) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_info'); @@ -148,13 +150,22 @@ const install = async ( body: await readImage(relativePath), }); }); + await page.route('**/game/**', async (route) => { + const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/game/')[1] ?? ''); + const fixturePath = `game/${relativePath}`; + await route.fulfill({ + status: 200, + contentType: imageContentType(fixturePath), + body: await readImage(fixturePath), + }); + }); await page.route(`**${gameBasePath}/api/trpc/**`, async (route) => { const results = operationNames(route).map((operation) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } }); if (operation === 'join.getConfig') return response({}); if (operation === 'general.me') return response(generalContext); - if (operation === 'world.getMap') return response(map); + if (operation === 'world.getMap') return response(mapFixture); if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { return response({ turns: [], revision: 0 }); @@ -258,7 +269,7 @@ const install = async ( ].slice(0, globalNationCount), diplomacy: { 1: { 1: 2, 2: 0 }, 2: { 1: 0, 2: 2 } }, conflict: [], - map, + map: mapFixture, }); if (operation === 'world.getMapLayout') return response(layout); if (operation === 'world.getCurrentCity') @@ -380,6 +391,66 @@ test('global-info renders the ref nation summary columns beside the map', async await page.setViewportSize({ width: 1200, height: 900 }); await go(page, 'global-info'); + const mapTitle = page.locator('.map-title'); + await expect(mapTitle).toHaveText(/200年 1月/u); + const titleBackground = await page + .locator('.map-top') + .evaluate((element) => getComputedStyle(element).backgroundImage); + expect(titleBackground).toContain('ltitle.jpg'); + expect(titleBackground).toContain('rtitle.jpg'); + const titleTextBackground = await mapTitle.evaluate((element) => getComputedStyle(element).backgroundImage); + expect(titleTextBackground).toContain('ad.gif'); + expect(titleTextBackground).toContain('spring.gif'); + await mapTitle.hover(); + const titleTooltip = page.locator('.map-title-tooltip'); + await expect(titleTooltip).toBeVisible(); + await expect(titleTooltip).toContainText('기술등급 제한 : 5등급 (205년 해제)'); + const titleGeometry = await page.locator('.map-top').evaluate((element) => { + const band = element.getBoundingClientRect(); + const title = element.querySelector('.map-title')?.getBoundingClientRect(); + const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect(); + return { + band: { width: band.width, height: band.height }, + title: title ? { width: title.width, height: title.height } : null, + tooltip: tooltip ? { width: tooltip.width, height: tooltip.height } : null, + }; + }); + expect(titleGeometry).toEqual({ + band: { width: 700, height: 20 }, + title: { width: 160, height: 20 }, + tooltip: { width: 220, height: 28 }, + }); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-title-hover.png'), fullPage: true }); + } + + const hoveredCastle = page.locator('.city-base').first(); + await hoveredCastle.hover(); + const cityTooltip = page.locator('.map-tooltip'); + await expect(cityTooltip).toBeVisible(); + await expect(cityTooltip.locator('.tooltip-title')).toHaveText('【하북|특】업'); + await expect(cityTooltip.locator('.tooltip-body')).toHaveText('아국'); + const cityTooltipStyle = await cityTooltip.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { + width: rect.width, + backgroundColor: style.backgroundColor, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }); + expect(cityTooltipStyle.width).toBeGreaterThanOrEqual(120); + expect(cityTooltipStyle).toMatchObject({ + backgroundColor: 'rgb(30, 164, 255)', + fontSize: '14px', + lineHeight: '15px', + }); + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-city-hover.png'), fullPage: true }); + } + await expect .poll(async () => { const [matrixWrapBox, matrixBox] = await Promise.all([ @@ -395,7 +466,9 @@ test('global-info renders the ref nation summary columns beside the map', async .poll(() => page .locator('.map-area .city-icon') - .evaluateAll((images: HTMLImageElement[]) => images.every((image) => image.complete && image.naturalWidth > 0)) + .evaluateAll((images: HTMLImageElement[]) => + images.every((image) => image.complete && image.naturalWidth > 0) + ) ) .toBe(true); const castleGeometry = await page.locator('.map-area').evaluate((mapArea) => { @@ -460,6 +533,10 @@ test('global-info renders the ref nation summary columns beside the map', async resolve(artifactRoot, 'core-global-info-computed-dom.json'), `${JSON.stringify( { + titleBackground, + titleTextBackground, + titleGeometry, + cityTooltipStyle, geometry, castleGeometry, headings: await summary.locator('th').allTextContents(), @@ -512,11 +589,45 @@ test('global-info renders the ref nation summary columns beside the map', async expect(mobileGeometry.map?.width).toBe(500); expect(mobileGeometry.summary?.width).toBe(500); expect(mobileGeometry.summary?.y).toBe(mobileGeometry.map?.bottom); + await mapTitle.hover(); + await expect(titleTooltip).toBeVisible(); + const mobileTitleGeometry = await page.locator('.map-top').evaluate((element) => { + const band = element.getBoundingClientRect(); + const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect(); + return { + band: { width: band.width, height: band.height }, + tooltip: tooltip ? { x: tooltip.x, width: tooltip.width } : null, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(mobileTitleGeometry.band).toEqual({ width: 500, height: 20 }); + expect(mobileTitleGeometry.tooltip?.width).toBe(220); + expect(mobileTitleGeometry.documentWidth).toBe(500); + await hoveredCastle.hover(); + await expect(cityTooltip).toBeVisible(); + await expect(cityTooltip.locator('.tooltip-title')).toHaveText('【하북|특】업'); if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'core-global-info-mobile-city-hover.png'), + fullPage: true, + }); await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-mobile.png'), fullPage: true }); } }); +test('map title keeps the ref early-game restriction boundary and color', async ({ page }) => { + await install(page, 'member', 100, 2, { ...map, startYear: 198 }); + await page.setViewportSize({ width: 1200, height: 900 }); + await go(page, 'global-info'); + + const mapTitle = page.locator('.map-title'); + await expect(mapTitle).toHaveCSS('color', 'rgb(255, 255, 0)'); + await mapTitle.hover(); + await expect(page.locator('.map-title-tooltip')).toHaveText( + '초반제한 기간 : 0년 12개월 (201년)기술등급 제한 : 1등급 (203년 해제)' + ); +}); + test('global-info diplomacy height follows the active nation count', async ({ page }) => { await install(page, 'member', 100, 1); await go(page, 'global-info'); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index fb10f760..7000a1fa 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -32,6 +32,9 @@ type NavigationFixture = { refreshDelayMs?: number; largeCommandTable?: boolean; reservedTurns?: Array<{ index: number; action: string; args: Record }>; + messages?: unknown; + messageContacts?: unknown; + autorunLimit?: number | null; dashboardResponses?: Array<{ bytes: number; contextKind: string | null; @@ -403,10 +406,16 @@ const installFixture = async (page: Page, state: NavigationFixture) => { } if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { - return response({ turns: state.reservedTurns ?? [], revision: 0 }); + return response({ + turns: state.reservedTurns ?? [], + revision: 0, + autorunLimit: state.autorunLimit ?? null, + }); } - if (operation === 'messages.getRecent') return response(emptyMessages(state.permission)); - if (operation === 'messages.getContacts') return response({ nation: [] }); + if (operation === 'messages.getRecent') { + return response(state.messages ?? emptyMessages(state.permission)); + } + if (operation === 'messages.getContacts') return response(state.messageContacts ?? { nation: [] }); if (operation === 'general.getRecentRecords') { return response({ global: [{ id: 3, text: '장수 동향 기록' }], @@ -617,7 +626,23 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.main-nation-menu [data-navigation-id="tournament"]')).toHaveClass(/highlight/); const gameInfoButton = global.locator('[data-menu-id="game-info"]'); + const bettingButton = global.locator('[data-navigation-id="nation-betting"]'); + await expect + .poll(() => + bettingButton.evaluate((element) => ({ + backgroundColor: getComputedStyle(element).backgroundColor, + backgroundImage: getComputedStyle(element).backgroundImage, + })) + ) + .toEqual({ backgroundColor: 'rgb(0, 88, 44)', backgroundImage: 'none' }); + await bettingButton.hover(); + await expect + .poll(() => bettingButton.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe('rgb(0, 88, 44)'); await gameInfoButton.focus(); + await expect + .poll(() => gameInfoButton.evaluate((element) => getComputedStyle(element).backgroundColor)) + .toBe('rgb(0, 88, 44)'); await gameInfoButton.press('Enter'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'true'); await expect(global.locator('#global-menu-game-info')).toBeVisible(); @@ -631,6 +656,74 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); +test('pure NPC message senders are not rendered as reply targets', async ({ page }) => { + const target = (generalId: number, generalName: string) => ({ + generalId, + generalName, + nationId: 1, + nationName: '위', + color: '#008000', + icon: '', + }); + const messages = { + ...emptyMessages(0), + public: [ + { + id: 102, + text: 'NPC 메시지', + time: '2026-08-12 12:00:00', + msgType: 'public', + src: target(22, '순수NPC'), + dest: null, + option: {}, + }, + { + id: 101, + text: '유저 메시지', + time: '2026-08-12 11:59:00', + msgType: 'public', + src: target(21, '유저장수'), + dest: null, + option: {}, + }, + ], + }; + const state: NavigationFixture = { + officerLevel: 1, + permission: 0, + nationLevel: 1, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + messages, + messageContacts: { + nation: [ + { + nationId: 1, + mailbox: 9001, + name: '위', + color: '#008000', + general: [[21, '유저장수', 0]], + }, + ], + }, + }; + await installFixture(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await waitForMain(page); + + const npcMessage = page.locator('.desktop-message-panel .msg-plate[data-id="102"]'); + const userMessage = page.locator('.desktop-message-panel .msg-plate[data-id="101"]'); + await expect(npcMessage.locator('.msg-header')).toContainText('순수NPC:위'); + await expect(npcMessage.locator('.msg-header')).not.toContainText('↩'); + await expect(npcMessage.getByRole('button', { name: /순수NPC/ })).toHaveCount(0); + await expect(userMessage.getByRole('button', { name: /유저장수:위.*↩/ })).toBeVisible(); + await userMessage.getByRole('button', { name: /유저장수:위.*↩/ }).click(); + await expect(page.locator('.desktop-message-panel #mailbox_list')).toHaveValue('21'); + await persistArtifact(page, `${basePath.slice(1)}-npc-reply-targets-desktop-1200`); +}); + test('main cards and command input stay inside their Ref-sized grid slots', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, @@ -641,11 +734,12 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn generalMeCalls: 0, operations: [], largeCommandTable: true, - reservedTurns: Array.from({ length: 14 }, (_, index) => ({ + reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, - action: `command-${index}`, + action: index === 0 ? '휴식' : `command-${index}`, args: {}, })), + autorunLimit: 2224, }; await installFixture(page, state); await page.setViewportSize({ width: 1200, height: 900 }); @@ -728,6 +822,27 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn expect(new Set(desktopGeometry.visibleControlBoxes.map(({ y }) => y)).size).toBe(1); expect(desktopGeometry.bottomActionBoxes).toHaveLength(3); expect(new Set(desktopGeometry.bottomActionBoxes.map(({ y }) => y)).size).toBe(1); + await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(15); + const autonomousRest = page.locator('[data-main-target="commands"] .action-column > div').first(); + await expect(autonomousRest).toContainText('휴식(자율 행동)'); + expect(await autonomousRest.evaluate((element) => getComputedStyle(element).color)).toBe('rgb(170, 255, 255)'); + + const tenthTurnButton = page.getByRole('button', { name: '10턴 명령 입력' }); + await tenthTurnButton.click(); + const quickPicker = page.getByTestId('command-picker'); + await expect(quickPicker).toBeVisible(); + const quickPickerAlignment = await quickPicker.evaluate((element) => { + const row = element + .closest('.reserved-command-editor') + ?.querySelector('.action-column > div:nth-child(10)'); + if (!row) throw new Error('10th command row missing'); + return { + pickerTop: element.getBoundingClientRect().top, + rowTop: row.getBoundingClientRect().top, + }; + }); + expect(quickPickerAlignment.pickerTop - quickPickerAlignment.rowTop).toBeCloseTo(30, 0); + await quickPicker.getByRole('button', { name: '명령 입력 닫기' }).click(); const modeButton = page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '고급 모드', @@ -736,6 +851,27 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn await modeButton.focus(); await expect(modeButton).toBeFocused(); await modeButton.click(); + const advancedControlGeometry = await page.locator('[data-main-target="commands"] .reserved-command-editor').evaluate( + (editor) => { + const range = editor.querySelector('.range-menu'); + const recent = [...editor.querySelectorAll('.control-pad summary')].find((element) => + element.textContent?.includes('최근 실행') + ); + const advanced = editor.querySelector('.advanced-actions'); + const queue = editor.querySelector('.queue-grid'); + if (!range || !recent || !advanced || !queue) throw new Error('advanced command controls missing'); + return { + rangeTop: range.getBoundingClientRect().top, + recentTop: recent.getBoundingClientRect().top, + advancedTop: advanced.getBoundingClientRect().top, + advancedBottom: advanced.getBoundingClientRect().bottom, + queueTop: queue.getBoundingClientRect().top, + }; + } + ); + expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop); + expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop); + expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop); await page.locator('[data-main-target="commands"] .select-command').click(); const picker = page.getByTestId('command-picker'); await expect(picker).toBeVisible(); @@ -765,6 +901,32 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn expect(pickerGeometry.horizontalOverflow).toBeLessThanOrEqual(0); await picker.getByRole('button', { name: '명령 입력 닫기' }).click(); + await page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '일반 모드' }).click(); + const collapsedPanelHeight = await page + .locator('[data-main-target="commands"]') + .evaluate((element) => element.getBoundingClientRect().height); + await page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }).click(); + await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30); + const expandedDesktopGeometry = await page.locator('.layout-desktop').evaluate((layout) => { + const commands = layout.querySelector('[data-main-target="commands"]'); + const city = layout.querySelector('[data-main-target="city"]'); + const nation = layout.querySelector('[data-main-target="nation"]'); + if (!commands || !city || !nation) throw new Error('expanded desktop panels missing'); + return { + commandHeight: commands.getBoundingClientRect().height, + commandBottom: commands.getBoundingClientRect().bottom, + cityBottom: city.getBoundingClientRect().bottom, + nationTop: nation.getBoundingClientRect().top, + verticalOverflow: commands.scrollHeight - commands.clientHeight, + overflowY: getComputedStyle(commands).overflowY, + }; + }); + expect(expandedDesktopGeometry.commandHeight).toBeGreaterThan(collapsedPanelHeight); + expect(expandedDesktopGeometry.verticalOverflow).toBeLessThanOrEqual(0); + expect(expandedDesktopGeometry.overflowY).toBe('visible'); + expect(expandedDesktopGeometry.cityBottom).toBe(expandedDesktopGeometry.commandBottom); + expect(expandedDesktopGeometry.nationTop).toBeGreaterThanOrEqual(expandedDesktopGeometry.commandBottom); + const captureProgress = async (name: string) => { if (!artifactRoot) return; await mkdir(artifactRoot, { recursive: true }); @@ -852,9 +1014,9 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn }; await captureProgress('desktop-1200'); - await page.locator('[data-main-target="commands"] .control-pad').getByRole('button', { name: '일반 모드' }).click(); await page.setViewportSize({ width: 500, height: 900 }); await expect(page.locator('.layout-mobile')).toBeVisible(); + await page.locator('[data-main-target="commands"] .bottom-actions').getByRole('button', { name: '펼치기' }).click(); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(500); await expect(page.locator('[data-main-target="city"] [role="progressbar"]')).toHaveCount(8); await expect(page.locator('[data-main-target="general"] [role="progressbar"]')).toHaveCount(4); @@ -889,7 +1051,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn controlBoxes, }; }); - expect(mobileGeometry.panelHeight).toBe(645); + expect(mobileGeometry.panelHeight).toBeGreaterThan(645); expect(mobileGeometry.editorLeft).toBeGreaterThanOrEqual(mobileGeometry.panelLeft); expect(mobileGeometry.editorRight).toBeLessThanOrEqual(mobileGeometry.panelRight); expect(mobileGeometry.horizontalOverflow).toBeLessThanOrEqual(0); @@ -897,6 +1059,7 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn expect(mobileGeometry.controlColumns.split(' ')).toHaveLength(3); expect(mobileGeometry.controlBoxes).toHaveLength(3); expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1); + await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30); await captureProgress('mobile-500'); }); @@ -925,6 +1088,15 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await page.setViewportSize({ width: 500, height: 900 }); + await expect + .poll(() => + page + .locator('.main-global-menu') + .first() + .locator('[data-navigation-id="nation-betting"]') + .evaluate((element) => getComputedStyle(element).backgroundColor) + ) + .toBe('rgb(0, 88, 44)'); const documentGeometry = await page.locator('.main-page').evaluate((element) => { const rect = element.getBoundingClientRect(); return { diff --git a/app/game-frontend/src/components/command/ReservedCommandEditor.vue b/app/game-frontend/src/components/command/ReservedCommandEditor.vue index 54db3422..44f462de 100644 --- a/app/game-frontend/src/components/command/ReservedCommandEditor.vue +++ b/app/game-frontend/src/components/command/ReservedCommandEditor.vue @@ -50,6 +50,7 @@ const commandArgsValid = ref(false); const expanded = ref(false); const menuRevision = ref(0); const pendingReservation = ref(null); +const collapsedRowCount = 15; const loadStorage = (key: string) => { storage.value = new CommandStorage(key); @@ -108,7 +109,11 @@ const labelMap = computed(() => { for (const group of groups) for (const command of group.values) map.set(command.key, command.name); return map; }); -const displayRows = computed(() => props.rows.slice(0, expanded.value || props.compact ? props.rows.length : 14)); +const displayRows = computed(() => + props.rows.slice(0, expanded.value || props.compact ? props.rows.length : collapsedRowCount) +); +const quickPickerTop = computed(() => `${70 + (quickTarget.value ?? 0) * 34.4}px`); +const rowLabel = (row: ReservedCommandRow): string => row.label ?? labelMap.value.get(row.action) ?? row.action; const selectedIndices = () => normalizedSelection(selected.value, previousSelected.value, props.rows.length); const pattern = () => extractPattern(props.rows, selectedIndices()); const touchMenus = () => (menuRevision.value += 1); @@ -396,6 +401,87 @@ const clickOutsideMenu = (event: Event) => { +
+
+ 선택한 턴을 + +
+ +
+
{
- {{ row.label ?? labelMap.get(row.action) ?? row.action }} + {{ rowLabel(row) }} + (자율 행동)
@@ -461,87 +549,6 @@ const clickOutsideMenu = (event: Event) => {
-
-
- 선택한 턴을 - -
- -
-
@@ -550,7 +557,12 @@ const clickOutsideMenu = (event: Event) => {
-
+
{{ quickTarget === null ? '선택한 턴' : `${quickTarget + 1}턴` }} 명령 입력 @@ -628,6 +640,9 @@ const clickOutsideMenu = (event: Event) => { gap: 4px; padding: 3px 0; } +.queue-area { + order: 2; +} .control-pad > button, .clock, .legacy-menu > summary, @@ -779,6 +794,13 @@ const clickOutsideMenu = (event: Event) => { .action-column > div:nth-child(even) { background: #071638; } +.action-column > div.autonomous { + color: #aaffff; +} +.action-column small { + font-size: 0.72em; + line-height: 1; +} .edit-column button { background: #444; cursor: pointer; @@ -786,6 +808,7 @@ const clickOutsideMenu = (event: Event) => { .advanced-actions { display: grid; grid-template-columns: 5fr 7fr; + order: 1; } .advanced-actions > * { border-radius: 0 !important; @@ -808,6 +831,10 @@ const clickOutsideMenu = (event: Event) => { background: #303030; box-shadow: 0 6px 16px #000; } +.reserved-command-editor:not(.compact) .command-picker { + max-height: none; + overflow: visible; +} .command-picker > header { display: flex; align-items: center; diff --git a/app/game-frontend/src/components/command/types.ts b/app/game-frontend/src/components/command/types.ts index 58bf0e13..d01f78d4 100644 --- a/app/game-frontend/src/components/command/types.ts +++ b/app/game-frontend/src/components/command/types.ts @@ -49,6 +49,7 @@ export type ReservedCommandRow = { time?: string; year?: number; month?: number; + autonomous?: boolean; }; export type CommandPatternEntry = { diff --git a/app/game-frontend/src/components/main/CommandListPanel.vue b/app/game-frontend/src/components/main/CommandListPanel.vue index 72ac9811..d44b72fe 100644 --- a/app/game-frontend/src/components/main/CommandListPanel.vue +++ b/app/game-frontend/src/components/main/CommandListPanel.vue @@ -12,6 +12,7 @@ const props = defineProps<{ currentYear?: number; currentMonth?: number; turnTermMinutes?: number; + autorunLimit?: number | null; storageKey?: string; }>(); @@ -43,6 +44,7 @@ const rows = computed(() => { label: labelMap.value.get(turn.action) ?? turn.action, year: Math.floor(absoluteMonth / 12), month: (absoluteMonth % 12) + 1, + autonomous: props.autorunLimit != null && absoluteMonth <= props.autorunLimit - 1, time: date ? term >= 5 ? `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}` diff --git a/app/game-frontend/src/components/main/MainGlobalMenu.vue b/app/game-frontend/src/components/main/MainGlobalMenu.vue index 2f37254a..a149751d 100644 --- a/app/game-frontend/src/components/main/MainGlobalMenu.vue +++ b/app/game-frontend/src/components/main/MainGlobalMenu.vue @@ -115,6 +115,33 @@ const isActive = (link: MainNavigationLinkItem) => link.id === 'survey' && props min-width: 0; } +.main-global-menu > :deep(.main-menu-link), +.main-menu-popup > .main-menu-button, +.main-menu-split > :deep(.main-menu-link), +.main-menu-split > .main-menu-split__toggle { + border-color: var(--sammo-button-navigation-border); + background-color: var(--sammo-button-navigation-bg); + background-image: none; +} + +.main-global-menu > :deep(.main-menu-link:hover), +.main-global-menu > :deep(.main-menu-link:focus-visible), +.main-global-menu > :deep(.main-menu-link:active), +.main-menu-popup > .main-menu-button:hover, +.main-menu-popup > .main-menu-button:focus-visible, +.main-menu-popup > .main-menu-button:active, +.main-menu-popup > .main-menu-button[aria-expanded='true'], +.main-menu-split > :deep(.main-menu-link:hover), +.main-menu-split > :deep(.main-menu-link:focus-visible), +.main-menu-split > :deep(.main-menu-link:active), +.main-menu-split > .main-menu-split__toggle:hover, +.main-menu-split > .main-menu-split__toggle:focus-visible, +.main-menu-split > .main-menu-split__toggle:active, +.main-menu-split > .main-menu-split__toggle[aria-expanded='true'] { + border-color: var(--sammo-button-navigation-border); + background-color: var(--sammo-button-navigation-bg); +} + .main-menu-popup > .main-menu-button, .main-menu-split > :deep(.main-menu-link) { width: 100%; diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue index ba8b6ab9..b21cd294 100644 --- a/app/game-frontend/src/components/main/MapViewer.vue +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -13,6 +13,11 @@ interface MapSummary { year: number; month: number; startYear: number; + techLevelLimit?: { + maxLevel: number; + initialLevel: number; + increaseYears: number; + }; cityList: [number, number, number, number, number, number][]; nationList: [number, string, string, number][]; myCity?: number | null; @@ -198,9 +203,77 @@ const mapSummary = computed(() => { if (!props.mapData) { return ''; } - return `${props.mapData.year}년 ${props.mapData.month}월`; + return `${props.mapData.year}年 ${props.mapData.month}月`; }); +const titleColor = computed(() => { + if (!props.mapData) { + return undefined; + } + const { startYear, year } = props.mapData; + if (year < startYear + 1) { + return 'magenta'; + } + if (year < startYear + 2) { + return 'orange'; + } + if (year < startYear + 3) { + return 'yellow'; + } + return undefined; +}); + +const titleTooltipLines = computed(() => { + if (!props.mapData) { + return []; + } + + const { startYear, year, month } = props.mapData; + const lines: string[] = []; + if (year <= startYear + 3) { + // Ref uses joinYearMonth(startYear + 3, 0) as the limit boundary. + const remainingMonths = (startYear + 3) * 12 - 1 - (year * 12 + month - 1); + const remainYear = Math.trunc(remainingMonths / 12); + const remainMonth = (remainingMonths % 12) + 1; + lines.push( + `초반제한 기간 : ${remainYear}년${remainMonth > 0 ? ` ${remainMonth}개월` : ''} (${startYear + 3}년)` + ); + } + + const limit = props.mapData.techLevelLimit ?? { + maxLevel: 12, + initialLevel: 1, + increaseYears: 5, + }; + const currentLevel = Math.min( + limit.maxLevel, + Math.max(1, Math.floor((year - startYear) / limit.increaseYears) + limit.initialLevel) + ); + if (currentLevel === limit.maxLevel) { + lines.push(`기술등급 제한 : ${currentLevel}등급 (최종)`); + } else { + lines.push(`기술등급 제한 : ${currentLevel}등급 (${currentLevel * limit.increaseYears + startYear}년 해제)`); + } + return lines; +}); + +const titleBandStyle = computed(() => + detailMode.value + ? { + backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`, + } + : {} +); + +const titleTextStyle = computed(() => + detailMode.value + ? { + color: titleColor.value, + backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`, + } + : { color: titleColor.value } +); + const mapThemeClass = computed(() => { return `map-theme-${mapTheme.value}`; }); @@ -269,6 +342,24 @@ const hoveredCity = computed(() => { return cityViews.value.find((city) => city.id === hoveredCityId.value) ?? null; }); +const hoveredCityTitle = computed(() => { + if (!hoveredCity.value) { + return ''; + } + return `【${hoveredCity.value.regionName}|${hoveredCity.value.levelName}】${hoveredCity.value.name}`; +}); + +const tooltipPosition = computed(() => { + const width = 120; + const offset = 10; + const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value; + const left = elementX.value + width + offset > mapPixelWidth ? elementX.value - width - 5 : elementX.value + offset; + return { + left: `${Math.max(0, left)}px`, + top: `${elementY.value + 30}px`, + }; +}); + const setHoveredCity = (cityId: number | null) => { mapStore.setHoveredCity(cityId); }; @@ -280,8 +371,13 @@ const selectCity = (cityId: number) => { + + {{ message.src.generalName }}:{{ message.src.nationName }} + {{ message.src.generalName }} diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index c178bd5c..4fd05e72 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -65,6 +65,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess?: BoardAccess | null; reservedGeneralTurns?: ReservedTurnView[] | null; reservedGeneralRevision?: number; + reservedGeneralAutorunLimit?: number | null; globalRecords?: RecentRecord[]; generalRecords?: RecentRecord[]; worldHistory?: RecentRecord[]; @@ -94,6 +95,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { const boardAccess = ref(null); const reservedGeneralTurns = ref(null); const reservedGeneralRevision = ref(0); + const reservedGeneralAutorunLimit = ref(null); const globalRecords = ref([]); const generalRecords = ref([]); const worldHistory = ref([]); @@ -331,6 +333,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess.value = null; reservedGeneralTurns.value = null; reservedGeneralRevision.value = 0; + reservedGeneralAutorunLimit.value = null; resetRecentRecords(null); commandTableRevision = null; boardAccessRevision = null; @@ -351,6 +354,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { boardAccess.value = null; reservedGeneralTurns.value = null; reservedGeneralRevision.value = 0; + reservedGeneralAutorunLimit.value = null; resetRecentRecords(null); contextRevision = null; commandTableRevision = null; @@ -384,6 +388,9 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (patch.reservedGeneralRevision !== undefined) { reservedGeneralRevision.value = patch.reservedGeneralRevision; } + if (patch.reservedGeneralAutorunLimit !== undefined) { + reservedGeneralAutorunLimit.value = patch.reservedGeneralAutorunLimit; + } if (patch.globalRecords !== undefined) { globalRecords.value = structurallyShare(globalRecords.value, patch.globalRecords); lastGeneralRecordId = Math.max(lastGeneralRecordId, patch.globalRecords[0]?.id ?? 0); @@ -425,6 +432,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { patch.boardAccess = boardAccessSnapshot ?? null; patch.reservedGeneralTurns = toRaw(reservedGeneralTurns.value as unknown) as ReservedTurnView[] | null; patch.reservedGeneralRevision = reservedGeneralRevision.value; + patch.reservedGeneralAutorunLimit = reservedGeneralAutorunLimit.value; patch.globalRecords = toRaw(globalRecords.value); patch.generalRecords = toRaw(generalRecords.value); patch.worldHistory = toRaw(worldHistory.value); @@ -552,6 +560,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { generalTurns.turns ) as ReservedTurnView[]; reservedGeneralRevision.value = generalTurns.revision; + reservedGeneralAutorunLimit.value = generalTurns.autorunLimit ?? null; if (records) { applyRecentRecords(records); } @@ -672,6 +681,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { if (generalTurns !== undefined) { patch.reservedGeneralTurns = generalTurns.turns; patch.reservedGeneralRevision = generalTurns.revision; + patch.reservedGeneralAutorunLimit = generalTurns.autorunLimit ?? null; } if (records) { const nextGlobalRecords = mergeRecentRecords(globalRecords.value, records.global); @@ -836,12 +846,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }); reservedGeneralTurns.value = result.turns; reservedGeneralRevision.value = result.revision; + reservedGeneralAutorunLimit.value = result.autorunLimit ?? null; } catch (err) { error.value = resolveErrorMessage(err); const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); if (snapshot) { reservedGeneralTurns.value = snapshot.turns; reservedGeneralRevision.value = snapshot.revision; + reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null; } } }; @@ -859,12 +871,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }); reservedGeneralTurns.value = result.turns; reservedGeneralRevision.value = result.revision; + reservedGeneralAutorunLimit.value = result.autorunLimit ?? null; } catch (err) { error.value = resolveErrorMessage(err); const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); if (snapshot) { reservedGeneralTurns.value = snapshot.turns; reservedGeneralRevision.value = snapshot.revision; + reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null; } } }; @@ -882,12 +896,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }); reservedGeneralTurns.value = result.turns; reservedGeneralRevision.value = result.revision; + reservedGeneralAutorunLimit.value = result.autorunLimit ?? null; } catch (err) { error.value = resolveErrorMessage(err); const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); if (snapshot) { reservedGeneralTurns.value = snapshot.turns; reservedGeneralRevision.value = snapshot.revision; + reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null; } } }; @@ -903,12 +919,14 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { }); reservedGeneralTurns.value = result.turns; reservedGeneralRevision.value = result.revision; + reservedGeneralAutorunLimit.value = result.autorunLimit ?? null; } catch (err) { error.value = resolveErrorMessage(err); const snapshot = await trpc.turns.reserved.getGeneral.query({ generalId: id }).catch(() => null); if (snapshot) { reservedGeneralTurns.value = snapshot.turns; reservedGeneralRevision.value = snapshot.revision; + reservedGeneralAutorunLimit.value = snapshot.autorunLimit ?? null; } } }; @@ -1192,6 +1210,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { messageContacts, boardAccess, reservedGeneralTurns, + reservedGeneralAutorunLimit, globalRecords, generalRecords, worldHistory, diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index df777c8d..33fbe570 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -46,6 +46,7 @@ const { messages, boardAccess, reservedGeneralTurns, + reservedGeneralAutorunLimit, globalRecords, generalRecords, worldHistory, @@ -218,6 +219,7 @@ watch( :current-year="lobbyInfo?.year" :current-month="lobbyInfo?.month" :turn-term-minutes="lobbyInfo?.turnTerm" + :autorun-limit="reservedGeneralAutorunLimit" @set-general-turns="reserveGeneralTurns" @shift-general-turns="shiftGeneralTurns" @repeat-general-turns="repeatGeneralTurns" @@ -341,6 +343,7 @@ watch( :current-year="lobbyInfo?.year" :current-month="lobbyInfo?.month" :turn-term-minutes="lobbyInfo?.turnTerm" + :autorun-limit="reservedGeneralAutorunLimit" @set-general-turns="reserveGeneralTurns" @shift-general-turns="shiftGeneralTurns" @repeat-general-turns="repeatGeneralTurns" @@ -571,6 +574,7 @@ button { .layout-desktop { display: grid; grid-template-columns: repeat(10, minmax(0, 1fr)); + grid-template-rows: 520px minmax(125px, auto) auto; gap: 0; align-items: start; } @@ -584,32 +588,30 @@ button { .layout-desktop > [data-main-target='commands'] { grid-column: 8 / 11; - grid-row: 1; - height: 645px; + grid-row: 1 / 3; + min-height: 645px; width: 290px; margin-left: 10px; - overflow-y: auto; + overflow: visible; } .layout-desktop > [data-main-target='city'] { grid-column: 1 / 8; - grid-row: 1; + grid-row: 2; + align-self: stretch; min-height: 125px; - margin-top: 520px; } .layout-desktop > [data-main-target='nation'] { grid-column: 1 / 6; - grid-row: 1; + grid-row: 3; min-height: 193px; - margin-top: 645px; } .layout-desktop > [data-main-target='general'] { grid-column: 6 / 11; - grid-row: 1; + grid-row: 3; min-height: 193px; - margin-top: 645px; } .layout-desktop > [data-main-target], @@ -719,8 +721,8 @@ button { } .layout-mobile [data-main-target='commands'] { - height: 645px; - overflow-y: auto; + min-height: 645px; + overflow: visible; } .layout-mobile [data-main-target='nation'], diff --git a/app/game-frontend/src/views/YearbookView.vue b/app/game-frontend/src/views/YearbookView.vue index e61d97df..f2db0e68 100644 --- a/app/game-frontend/src/views/YearbookView.vue +++ b/app/game-frontend/src/views/YearbookView.vue @@ -35,6 +35,7 @@ const history = ref(null); const selectedYearMonth = ref(null); const settingsOpen = ref(false); const rankingBottom = ref(localStorage.getItem('yearbook-ranking-bottom') === 'true'); +let historyRequestId = 0; const serverID = computed(() => { const value = route.query.serverID; const raw = Array.isArray(value) ? value[0] : value; @@ -71,19 +72,27 @@ const loadHistory = async (): Promise => { if (selectedYearMonth.value === null) { return; } + const requestId = ++historyRequestId; loading.value = true; errorMessage.value = ''; try { const { year, month } = parseYearMonth(selectedYearMonth.value); const result = await trpc.yearbook.getHistory.query({ year, month, serverID: serverID.value }); + if (requestId !== historyRequestId) { + return; + } if ('data' in result) { history.value = result.data; } } catch (error) { - history.value = null; + if (requestId !== historyRequestId) { + return; + } errorMessage.value = error instanceof Error ? error.message : '연감 데이터를 불러오지 못했습니다.'; } finally { - loading.value = false; + if (requestId === historyRequestId) { + loading.value = false; + } } }; @@ -129,7 +138,13 @@ onMounted(async () => { 연 감 - + @@ -164,9 +179,9 @@ onMounted(async () => {
불러오는 중...
-
+
- +