diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 77abd65e..fec115e6 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -137,7 +137,24 @@ export const tournamentRouter = router({ store.getMatches(), store.getBettingEntries(), ]); - return { state, participants, matches, betCount: bets.length }; + const participantIds = [...new Set(participants.map((participant) => participant.id))]; + const iconRows = + participantIds.length === 0 + ? [] + : await ctx.db.general.findMany({ + where: { id: { in: participantIds } }, + select: { id: true, picture: true, imageServer: true }, + }); + const iconsByGeneralId = new Map(iconRows.map((general) => [general.id, general])); + const publicParticipants = participants.map((participant) => { + const icon = iconsByGeneralId.get(participant.id); + return { + ...participant, + picture: icon?.picture ?? null, + imageServer: icon?.imageServer ?? 0, + }; + }); + return { state, participants: publicParticipants, matches, betCount: bets.length }; }), getRankings: authedProcedure.query(async ({ ctx }) => { await getMyGeneral(ctx); @@ -177,7 +194,16 @@ export const tournamentRouter = router({ } const generals = await ctx.db.general.findMany({ where: { id: { in: [...rankMap.keys()] } }, - select: { id: true, name: true, npcState: true, leadership: true, strength: true, intel: true }, + select: { + id: true, + name: true, + npcState: true, + picture: true, + imageServer: true, + leadership: true, + strength: true, + intel: true, + }, }); return tournamentRankTypes.map((prefix) => { @@ -201,6 +227,8 @@ export const tournamentRouter = router({ generalId: general.id, name: general.name, npcState: general.npcState, + picture: general.picture, + imageServer: general.imageServer, stat, games: win + draw + lose, win, diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 3005f784..be304ace 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -102,6 +102,24 @@ const resolveMapName = (worldState: WorldStateRow, fallback: string): string => return typeof mapName === 'string' && mapName.trim().length > 0 ? mapName : fallback; }; +const plainLegacyInfo = (value: string): string => + value + .replace(//giu, ' · ') + .replace(/<[^>]+>/gu, '') + .replace(/\s+/gu, ' ') + .trim(); + +const readGeneralMetaNumber = (meta: unknown, key: string): number | null => { + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null; + const value = (meta as Record)[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +}; + const assertReservedTurnPermission = async ( worldState: WorldStateRow, general: GeneralRow, @@ -183,7 +201,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number }; for (const item of moduleBundle.itemModules) { if (item.buyable) { - items[item.slot].push({ value: item.key, label: item.name }); + const cost = item.cost ?? 0; + const currentSecurity = city?.security ?? 0; + const availability = + currentSecurity < item.reqSecu + ? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요` + : general.gold < cost + ? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요` + : '현재 구입 가능'; + items[item.slot].push({ + value: item.key, + label: item.name, + description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`, + }); } } const inputOptions: TurnCommandInputOptions = { @@ -205,11 +235,19 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number crewTypes: (environment.unitSet.crewTypes ?? []) .filter((entry) => !entry.requirements.some((requirement) => requirement.type === 'Impossible')) .map((entry) => ({ value: entry.id, label: entry.name })), - armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => ({ - value: Number(value), - label, + armTypes: Object.entries(environment.unitSet.armTypes ?? {}).map(([value, label]) => { + const dexterity = readGeneralMetaNumber(general.meta, `dex${value}`); + return { + value: Number(value), + label, + ...(dexterity === null ? {} : { description: `현재 숙련 ${dexterity.toLocaleString()}` }), + }; + }), + nationTypes: traits.nationTypes.map((entry) => ({ + value: entry.key, + label: entry.name, + description: plainLegacyInfo(entry.info), })), - nationTypes: traits.nationTypes.map((entry) => ({ value: entry.key, label: entry.name })), colors: TURN_COMMAND_NATION_COLORS.map((color, index) => ({ value: index, label: `색상 ${index + 1}`, @@ -226,6 +264,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number unitSet: environment.unitSet, generalActionModules: moduleBundle.general, }), + context: { + actorGold: general.gold, + actorRice: general.rice, + ...(city ? { citySecurity: city.security } : {}), + ...(nation ? { nationGold: nation.gold, nationRice: nation.rice, nationLevel: nation.level } : {}), + }, }; return buildTurnCommandTable({ diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index e25e3eab..6aa2ea19 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -15,6 +15,7 @@ export interface TurnCommandOption { value: TurnCommandOptionValue; label: string; color?: string; + description?: string; } export interface TurnCommandRecruitmentCrewType { @@ -50,14 +51,7 @@ export interface TurnCommandRecruitmentInfo { } export type TurnCommandOptionSource = - | 'cities' - | 'nations' - | 'generals' - | 'crewTypes' - | 'armTypes' - | 'nationTypes' - | 'colors' - | 'items'; + 'cities' | 'nations' | 'generals' | 'crewTypes' | 'armTypes' | 'nationTypes' | 'colors' | 'items'; export interface TurnCommandInputField { key: string; @@ -83,14 +77,50 @@ export interface TurnCommandInputOptions { colors: TurnCommandOption[]; items: Record; recruitment: TurnCommandRecruitmentInfo | null; + context?: { + actorGold: number; + actorRice: number; + citySecurity?: number; + nationGold?: number; + nationRice?: number; + nationLevel?: number; + }; } // 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다. export const TURN_COMMAND_NATION_COLORS = [ - '#FF0000', '#800000', '#A0522D', '#FF6347', '#FFA500', '#FFDAB9', '#FFD700', '#FFFF00', - '#7CFC00', '#00FF00', '#808000', '#008000', '#2E8B57', '#008080', '#20B2AA', '#6495ED', - '#7FFFD4', '#AFEEEE', '#87CEEB', '#00FFFF', '#00BFFF', '#0000FF', '#000080', '#483D8B', - '#7B68EE', '#BA55D3', '#800080', '#FF00FF', '#FFC0CB', '#F5F5DC', '#E0FFFF', '#FFFFFF', + '#FF0000', + '#800000', + '#A0522D', + '#FF6347', + '#FFA500', + '#FFDAB9', + '#FFD700', + '#FFFF00', + '#7CFC00', + '#00FF00', + '#808000', + '#008000', + '#2E8B57', + '#008080', + '#20B2AA', + '#6495ED', + '#7FFFD4', + '#AFEEEE', + '#87CEEB', + '#00FFFF', + '#00BFFF', + '#0000FF', + '#000080', + '#483D8B', + '#7B68EE', + '#BA55D3', + '#800080', + '#FF00FF', + '#FFC0CB', + '#F5F5DC', + '#E0FFFF', + '#FFFFFF', '#A9A9A9', ] as const; diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index d2f59ac9..5cef826e 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -84,6 +84,8 @@ const buildGeneral = (id: number, userId: string, gold = 2_000): GeneralRow => id, userId, name: `장수${id}`, + picture: `${id}.jpg`, + imageServer: id % 2, leadership: 70 + id, strength: 60 + id, intel: 50 + id, @@ -296,6 +298,10 @@ describe('tournament router permissions and mutations', () => { const sections = await ownerCaller.tournament.getRankings(); expect(sections).toHaveLength(4); expect(sections[0]?.entries.map((entry) => entry.generalId)).toEqual([second.id, first.id]); + expect(sections[0]?.entries[0]).toMatchObject({ + picture: '2.jpg', + imageServer: 0, + }); const generalLessCaller = appRouter.createCaller( buildContext({ redis, transport, generals: [first, second], userId: 'user-3', rankRows }) @@ -303,6 +309,33 @@ describe('tournament router permissions and mutations', () => { await expect(generalLessCaller.tournament.getRankings()).rejects.toMatchObject({ code: 'NOT_FOUND' }); }); + it('joins current dedicated icon metadata to the public tournament snapshot', async () => { + const redis = new MemoryRedis(); + const transport = new TournamentTransport(); + const owner = buildGeneral(11, 'user-1'); + const rival = buildGeneral(12, 'user-2'); + await setTournamentFixture(redis, { + stage: 7, + phase: 0, + type: 0, + auto: true, + openYear: 193, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-07-26T01:00:00.000Z', + }); + const caller = appRouter.createCaller( + buildContext({ redis, transport, generals: [owner, rival], userId: 'user-1' }) + ); + + const snapshot = await caller.tournament.getSnapshot(); + + expect(snapshot.participants).toEqual([ + expect.objectContaining({ id: 11, picture: '11.jpg', imageServer: 1 }), + expect.objectContaining({ id: 12, picture: '12.jpg', imageServer: 0 }), + ]); + }); + it('refunds gold when the tournament bet rank update fails', async () => { const redis = new MemoryRedis(); const transport = new TournamentTransport(); diff --git a/app/game-engine/test/gatewayRuntimeAction.integration.test.ts b/app/game-engine/test/gatewayRuntimeAction.integration.test.ts index 8be39b24..aefa199d 100644 --- a/app/game-engine/test/gatewayRuntimeAction.integration.test.ts +++ b/app/game-engine/test/gatewayRuntimeAction.integration.test.ts @@ -50,6 +50,8 @@ integration('gateway runtime action consumer', () => { create: { profileName, profile: 'runtime', + instanceKey: 'consumer-integration', + currentScenario: 'consumer-integration', scenario: 'consumer-integration', apiPort: 15998, status: 'RUNNING', diff --git a/app/game-frontend/e2e/board.spec.ts b/app/game-frontend/e2e/board.spec.ts index 0a027abf..183f2aa3 100644 --- a/app/game-frontend/e2e/board.spec.ts +++ b/app/game-frontend/e2e/board.spec.ts @@ -274,6 +274,8 @@ test('matches the ref meeting-room geometry, typography, textures, and controls' 'src', 'https://sam-image.hided.net/icons/22.jpg' ); + await expect(page.locator('.article-header .date')).toHaveText('07-26 19:20'); + await expect(page.locator('.comment-row .date')).toHaveText('07-26 19:25'); if (artifactRoot) { await page.screenshot({ path: resolve(artifactRoot, 'board-core-desktop.png'), @@ -392,7 +394,9 @@ test('uses the ref 500px responsive form widths', async ({ page }) => { await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible(); }); -test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }, testInfo) => { +test('retains article and comment input after a failed mutation, then reloads after success', async ({ + page, +}, testInfo) => { const state: BoardFixture = { permission: 2, canMeeting: true, @@ -408,7 +412,9 @@ test('retains article and comment input after a failed mutation, then reloads af await page.locator('#board-title').fill('새 제목'); await page.locator('#board-content').fill('새 내용'); await page.locator('#submitArticle').click(); - const articleToast = page.getByTestId('game-toast').filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' }); + const articleToast = page + .getByTestId('game-toast') + .filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' }); await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error'); await expect(articleToast).toHaveAttribute('role', 'alert'); await expect(page.locator('#board-title')).toHaveValue('새 제목'); @@ -416,7 +422,13 @@ test('retains article and comment input after a failed mutation, then reloads af const desktopToastGeometry = await articleToast.evaluate((element) => { const rect = element.getBoundingClientRect(); - return { left: rect.left, right: rect.right, top: rect.top, width: rect.width, viewportWidth: window.innerWidth }; + return { + left: rect.left, + right: rect.right, + top: rect.top, + width: rect.width, + viewportWidth: window.innerWidth, + }; }); expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0); expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth); @@ -435,10 +447,14 @@ test('retains article and comment input after a failed mutation, then reloads af await page.setViewportSize({ width: 390, height: 844 }); const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth); await commentInput.press('Enter'); - const commentToast = page.getByTestId('game-toast').filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' }); + const commentToast = page + .getByTestId('game-toast') + .filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' }); await expect(commentToast).toBeVisible(); await expect - .poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)) + .poll(async () => + commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom) + ) .toBeGreaterThanOrEqual(0); const mobileToastGeometry = await commentToast.evaluate((element) => { const rect = element.getBoundingClientRect(); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 3d6868cf..4b0f563c 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -15,11 +15,11 @@ const operations = (route: Route) => const inputOptions = { cities: [ { value: 1, label: '업 (아국)' }, - { value: 2, label: '허창 (적국)' }, + { value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' }, ], nations: [ { value: 1, label: '아국', color: '#008000' }, - { value: 2, label: '적국', color: '#800000' }, + { value: 2, label: '적국', color: '#800000', description: '수도 허창' }, ], generals: [ { value: 1, label: '장수 (아국 · 업)' }, @@ -75,6 +75,14 @@ const inputOptions = { }, ], }, + context: { + actorGold: 1000, + actorRice: 1000, + citySecurity: 500, + nationGold: 5000, + nationRice: 6000, + nationLevel: 1, + }, }; const commandTable = { general: [ @@ -152,6 +160,27 @@ const commandTable = { }, ], }, + { + category: '외교', + values: [ + { + key: 'che_선전포고', + name: '선전포고', + reqArg: true, + possible: true, + status: 'needsInput', + inputFields: [ + { + key: 'destNationId', + label: '대상 국가', + kind: 'select', + required: true, + optionSource: 'nations', + }, + ], + }, + ], + }, ], inputOptions, }; @@ -177,8 +206,46 @@ const generalContext = { dedication: 0, items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, }, - city: { id: 1, name: '업', level: 8, region: 1, population: 1000, populationMax: 2000 }, - nation: { id: 1, name: '아국', color: '#008000', level: 1 }, + city: { + id: 1, + name: '업', + level: 8, + levelName: '특', + region: 1, + regionName: '하북', + nationId: 1, + nationName: '아국', + population: 1000, + populationMax: 2000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + trust: 70, + trade: 100, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + supplyState: 1, + frontState: 0, + }, + nation: { + id: 1, + name: '아국', + color: '#008000', + level: 1, + levelName: '호족', + gold: 5000, + rice: 6000, + tech: 100, + typeCode: 'che_중립', + typeName: '중립', + capitalCityId: 1, + capitalCityName: '업', + }, settings: {}, penalties: {}, }; @@ -247,8 +314,11 @@ const install = async (page: Page, rejectGeneral = false) => { if (name === 'world.getMapLayout') return response({ mapName: 'che', - cityList: [{ id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [] }], - regionMap: { 1: '하북' }, + cityList: [ + { id: 1, name: '업', level: 8, region: 1, x: 100, y: 100, path: [2] }, + { id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] }, + ], + regionMap: { 1: '하북', 2: '예주' }, levelMap: { 8: '특' }, }); if (name === 'auth.status') return response({ ok: true }); @@ -271,8 +341,14 @@ const install = async (page: Page, rejectGeneral = false) => { startYear: 180, year: 200, month: 1, - cityList: [[1, 8, 0, 1, 1, 1]], - nationList: [[1, '아국', '#008000', 1]], + cityList: [ + [1, 8, 0, 1, 1, 1], + [2, 7, 40, 2, 2, 1], + ], + nationList: [ + [1, '아국', '#008000', 1], + [2, '적국', '#800000', 2], + ], spyList: {}, shownByGeneralList: [], myCity: 1, @@ -341,13 +417,35 @@ const install = async (page: Page, rejectGeneral = false) => { test('enters general and nation command arguments and sends exact values', async ({ page }) => { const requests = await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('/'); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); await page.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click(); const form = page.getByTestId('command-argument-form'); await expect(form).toBeVisible(); - await form.locator('select').selectOption('2'); + await expect(form.getByTestId('command-argument-map')).toBeVisible(); + await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 화계를 실행합니다.'); + await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 0칸'); + await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click(); + await expect(form.locator('select')).toHaveValue('2'); + await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 도시에서 1칸'); + await form.getByTestId('command-argument-map').locator('.map-city').nth(1).hover(); + expect( + await form + .getByTestId('command-argument-map') + .locator('.map-city') + .nth(1) + .evaluate((element) => getComputedStyle(element).cursor) + ).toBe('pointer'); + await form.getByTestId('command-argument-map').locator('.map-city').nth(1).focus(); + await expect(form.getByTestId('command-argument-map').locator('.map-city').nth(1)).toBeFocused(); + await expect(page).toHaveURL(/\/$/); + const mapGeometry = await form.getByTestId('command-argument-map').evaluate((element) => { + const area = element.querySelector('.map-area')!; + const rect = area.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); await page.getByTestId('command-picker').getByRole('button', { name: '입력', exact: true }).click(); await expect(page.locator('[data-command-scope="general"] .action-column > div').first()).toHaveText('화계'); @@ -379,6 +477,9 @@ test('enters general and nation command arguments and sends exact values', async expect(JSON.stringify(requests)).toContain('"amount":300'); expect(JSON.stringify(requests)).toContain('"destGeneralId":2'); + expect(mapGeometry.width).toBeGreaterThan(650); + expect(mapGeometry.height / mapGeometry.width).toBeCloseTo(5 / 7, 2); + expect(geometry.width).toBeGreaterThan(200); expect(geometry.rowHeight).toBeGreaterThanOrEqual(34); expect(geometry.borderStyle).toBe('solid'); @@ -493,6 +594,52 @@ test('shows Ref recruitment details and preserves the 1000px desktop and 500px m await expect(mercenaryForm.locator('.mobile-selected-panel output')).toHaveText('1,346금'); }); +test('uses the map to choose a nation target in the chief command window', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/che/chief-center'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click(); + await picker.getByRole('button', { name: /선전포고/ }).click(); + const form = picker.getByTestId('command-argument-form'); + await expect(form.getByTestId('command-argument-guidance')).toContainText('초반 제한'); + await form.getByTestId('command-argument-map').locator('.map-city').nth(1).click(); + await expect(form.locator('select')).toHaveValue('2'); + await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개'); + await expect(page).toHaveURL(/\/che\/chief-center$/); + await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true }); +}); + +test('fits the city map option window inside the Ref-compatible 500px mobile page', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto('/'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: /화계/ }).click(); + const geometry = await picker.evaluate((element) => { + const map = element.querySelector('[data-testid="command-argument-map"] .map-area')!; + const pickerRect = element.getBoundingClientRect(); + const mapRect = map.getBoundingClientRect(); + return { + pickerX: pickerRect.x, + pickerRight: pickerRect.right, + pickerWidth: pickerRect.width, + pickerScrollWidth: element.scrollWidth, + mapWidth: mapRect.width, + mapHeight: mapRect.height, + }; + }); + expect(geometry.pickerX).toBeGreaterThanOrEqual(0); + expect(geometry.pickerRight).toBeLessThanOrEqual(500); + expect(geometry.pickerWidth).toBeGreaterThanOrEqual(488); + expect(geometry.pickerScrollWidth).toBeLessThanOrEqual(geometry.pickerWidth); + expect(geometry.mapWidth).toBeGreaterThan(470); + expect(geometry.mapHeight / geometry.mapWidth).toBeCloseTo(5 / 7, 2); + await page.screenshot({ path: test.info().outputPath('main-city-map-option-mobile.png'), fullPage: true }); +}); + test('keeps the entered command visible and reports a server validation error', async ({ page }) => { await install(page, true); await page.goto('/'); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 202496d2..62fc536e 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; -import { expect, test, type Page, type Route } from '@playwright/test'; +import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; const response = (data: unknown) => ({ result: { data } }); @@ -20,6 +20,23 @@ const persistParityArtifact = async (page: Page, name: string, geometry: unknown ]); }; +const readGeneralPanelImages = async (panel: Locator) => + panel.evaluate((element) => + [...element.querySelectorAll('.general-image')].map((image) => { + const rect = image.getBoundingClientRect(); + const style = getComputedStyle(image); + return { + label: image.getAttribute('aria-label'), + width: rect.width, + height: rect.height, + backgroundImage: style.backgroundImage, + backgroundSize: style.backgroundSize, + pointerEvents: style.pointerEvents, + userSelect: style.userSelect, + }; + }) + ); + type FixtureState = { permission: 'head' | 'member'; myset: number; @@ -545,9 +562,15 @@ test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표 const generalCard = page.locator('.general-card'); await expect(generalCard).toContainText('성격안전'); - await expect(generalCard).toContainText('전투특기신산'); - await expect(generalCard).toContainText('내정특기상재'); + await expect(generalCard).toContainText('특기상재 / 신산'); await expect(generalCard).not.toContainText('che_'); + await expect(generalCard).toHaveAttribute('data-general-basic-card', ''); + const mainImages = await readGeneralPanelImages(generalCard); + expect(mainImages).toHaveLength(2); + expect(mainImages[0]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' }); + expect(mainImages[0]?.backgroundImage).toContain('/icons/default.jpg'); + expect(mainImages[1]).toMatchObject({ width: 64, height: 64, pointerEvents: 'none', userSelect: 'none' }); + expect(mainImages[1]?.backgroundImage).toContain('/game/crewtype1.png'); const geometry = await nationCard.evaluate((element) => { const rect = element.getBoundingClientRect(); @@ -587,9 +610,9 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명 await expect(nationCard).toContainText('국가 등급주자사'); const generalCard = page.locator('.general-card'); - await expect(generalCard.locator('.general-title')).toContainText('검증장수 · 간의대부'); + await expect(generalCard.locator('.general-title')).toContainText('검증장수 【 간의대부 | 건강 】'); await expect(generalCard).toContainText('병종보병'); - await expect(generalCard).toContainText('계급29품관'); + await expect(generalCard).toContainText('계급 29품관'); const cityCard = page.locator('.city-card'); await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업'); @@ -646,11 +669,19 @@ test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ p expect(mobileWidth).toBe(1016); }); -test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in place', async ({ page }) => { +test('내 정보&설정 keeps desktop density and becomes a 390px horizontal-identity layout', async ({ page }) => { const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; await install(page, state); await page.setViewportSize({ width: 1000, height: 900 }); await page.goto('my-page'); + await expect(page.locator('.general-table')).toHaveAttribute('data-general-basic-card', ''); + const myPageImages = await readGeneralPanelImages(page.locator('.general-table')); + expect(myPageImages.map(({ width, height }) => ({ width, height }))).toEqual([ + { width: 64, height: 64 }, + { width: 64, height: 64 }, + ]); + expect(myPageImages[0]?.backgroundImage).toContain('/icons/default.jpg'); + expect(myPageImages[1]?.backgroundImage).toContain('/game/crewtype1.png'); await expect(page.locator('.legacy-general-details')).toContainText('계급 29품관'); await expect(page.locator('.legacy-general-details')).toContainText('병종 보병'); await expect(page.locator('.item-group')).toContainText('명마'); @@ -696,7 +727,7 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac }; }); expect(desktop.width).toBe(1000); - expect(desktop.minWidth).toBe('500px'); + expect(desktop.minWidth).toBe('0px'); expect(desktop.fontSize).toBe('14px'); expect(desktop.columns.split(' ')).toHaveLength(2); expect(desktop.titleHeight).toBeCloseTo(54, 0); @@ -766,26 +797,39 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac expect(state.settingMutations.at(-1)).not.toHaveProperty('generalId'); } - await page.setViewportSize({ width: 500, height: 900 }); + await page.setViewportSize({ width: 390, height: 900 }); await page.reload(); const mobile = await page.locator('#container').evaluate((element) => { const rect = element.getBoundingClientRect(); const settings = element.querySelector('.settings-column')!.getBoundingClientRect(); + const icon = element.querySelector('[data-general-basic-card] .general-icon')!.getBoundingClientRect(); + const name = element.querySelector('[data-general-basic-card] .general-title')!.getBoundingClientRect(); return { width: rect.width, scrollWidth: document.documentElement.scrollWidth, columns: getComputedStyle(element.querySelector('.top-grid')!).gridTemplateColumns, settingsOffset: settings.x - rect.x, settingsWidth: settings.width, + identity: { + iconRight: icon.right, + nameLeft: name.left, + iconTop: icon.top, + iconBottom: icon.bottom, + nameTop: name.top, + nameBottom: name.bottom, + }, }; }); expect(mobile).toMatchObject({ - width: 500, - scrollWidth: 500, - columns: '500px', + width: 390, + scrollWidth: 390, + columns: '390px', settingsOffset: 0, - settingsWidth: 500, + settingsWidth: 390, }); + expect(mobile.identity.nameLeft).toBeGreaterThanOrEqual(mobile.identity.iconRight - 1); + expect(mobile.identity.nameTop).toBeLessThan(mobile.identity.iconBottom); + expect(mobile.identity.nameBottom).toBeGreaterThan(mobile.identity.iconTop); await persistParityArtifact(page, 'core-my-page-mobile', mobile); }); @@ -1109,10 +1153,15 @@ test('감찰부 keeps the selector interaction and shows the permission error pa await expect(page.locator('.selector-row select').nth(1)).toHaveValue('8'); await page.getByRole('button', { name: '다음 ▶' }).click(); await expect(page.locator('.selector-row select').nth(1)).toHaveValue('7'); - await expect(page.locator('.battle-general-name')).toContainText('검증장수 (간의대부)'); + await expect(page.locator('.battle-general-name')).toContainText('검증장수 【 간의대부 | 건강 】'); await expect(page.locator('.battle-general-extra')).toContainText('계급29품관'); - await expect(page.locator('.battle-general-extra')).toContainText('병종보병'); + await expect(page.locator('.battle-general-card')).toContainText('병종보병'); await expect(page.locator('.battle-general-card')).not.toContainText('che_'); + await expect(page.locator('.battle-general-card')).toHaveAttribute('data-general-basic-card', ''); + const battleImages = await readGeneralPanelImages(page.locator('.battle-general-card')); + expect(battleImages).toHaveLength(2); + expect(battleImages[0]?.backgroundImage).toContain('/icons/default.jpg'); + expect(battleImages[1]?.backgroundImage).toContain('/game/crewtype1.png'); await expect(page.locator('.battle-general-card [role="progressbar"]')).toHaveCount(14); await expect(page.locator('.battle-general-card [aria-label*="1,275,975 (EX+)"]')).toHaveCount(5); expect( diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 0a2e49e3..1a63ce7a 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -34,6 +34,8 @@ type NavigationFixture = { largeCommandTable?: boolean; currentYear?: number; currentMonth?: number; + scenarioTitle?: string; + latestVote?: { id: number; title: string; hasVoted: boolean } | null; globalRecords?: Array<{ id: number; text: string }>; generalRecords?: Array<{ id: number; text: string }>; worldHistory?: Array<{ id: number; text: string }>; @@ -309,6 +311,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { year: state.currentYear ?? 185, month: state.currentMonth ?? 1, turnTerm: 10, + scenarioTitle: state.scenarioTitle ?? '', }); } if (operation === 'dashboard.getContextBundleDelta') { @@ -421,7 +424,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => { onlineGenerals: '메뉴검증장수', nationNotice: '

국가 방침

', lastExecuted: null, - latestVote: { id: 9, title: '메뉴 설문', hasVoted: false }, + latestVote: + state.latestVote === undefined + ? { id: 9, title: '메뉴 설문', hasVoted: false } + : state.latestVote, }); } if (operation === 'board.getAccess') { @@ -508,7 +514,7 @@ const installRealtimeHarness = async (page: Page) => { const waitForMain = async (page: Page) => { await page.goto('./'); - await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); + await expect(page.locator('.game-shell__title')).toBeVisible(); await expect(page.locator('.main-global-menu').first()).toBeVisible(); await expect(page.locator('.main-nation-menu')).toBeVisible(); await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3); @@ -561,8 +567,24 @@ const persistArtifact = async (page: Page, name: string) => { globalPopup: describe('#mobile-global-menu'), nationPopup: describe('#mobile-nation-menu'), quickPopup: describe('#mobile-quick-menu'), + commandMenu: describe('.reserved-command-editor details[open] .menu-items'), + commandDividers: [...document.querySelectorAll('.reserved-command-editor details[open] .menu-divider')].map( + (element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + borderTop: style.borderTop, + margin: style.margin, + }; + } + ), }; }); + const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first(); + if (await commandMenu.isVisible()) { + await commandMenu.screenshot({ path: resolve(target, `${name}-menu.png`) }); + } await Promise.all([ page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }), writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`), @@ -576,6 +598,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro nationLevel: 3, stage: 1, npcMode: 1, + scenarioTitle: '메인 화면 검증 시나리오', generalMeCalls: 0, operations: [], }; @@ -589,6 +612,45 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-mobile')).toHaveCount(0); + await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1); + await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분'); + await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오'); + await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중'); + await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문'); + const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => { + const title = element.querySelector('.game-shell__title'); + const subtitle = element.querySelector('.game-shell__subtitle'); + const activity = element.querySelector('.activity-status'); + const tournament = element.querySelector('.tournament-status'); + const survey = element.querySelector('.vote-status'); + if (!title || !subtitle || !activity || !tournament || !survey) { + throw new Error('main header status geometry is incomplete'); + } + return { + title: title.getBoundingClientRect().toJSON(), + subtitle: subtitle.getBoundingClientRect().toJSON(), + activity: activity.getBoundingClientRect().toJSON(), + tournament: tournament.getBoundingClientRect().toJSON(), + survey: survey.getBoundingClientRect().toJSON(), + activityColumns: getComputedStyle(activity).gridTemplateColumns, + }; + }); + expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom); + expect(headerStatusGeometry.activity.width).toBeCloseTo(666.67, 0); + expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0); + expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0); + expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(2); + const tournamentStatusLink = page.locator('.tournament-status a'); + const surveyStatusLink = page.locator('.vote-status a'); + await tournamentStatusLink.hover(); + await expect + .poll(() => tournamentStatusLink.evaluate((element) => getComputedStyle(element).cursor)) + .toBe('pointer'); + await surveyStatusLink.focus(); + await expect(surveyStatusLink).toBeFocused(); + await expect + .poll(() => surveyStatusLink.evaluate((element) => getComputedStyle(element).textDecorationLine)) + .toContain('underline'); const contentOrder = await page .locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]') .evaluateAll((elements) => @@ -642,7 +704,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(gameInfoButton).toBeFocused(); await gameInfoButton.click(); - await page.getByRole('heading', { name: '전장 현황' }).click(); + await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click(); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); @@ -882,6 +944,10 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn await tenthTurnButton.click(); const quickPicker = page.getByTestId('command-picker'); await expect(quickPicker).toBeVisible(); + await tenthTurnButton.click(); + await expect(quickPicker).toBeHidden(); + await tenthTurnButton.click(); + await expect(quickPicker).toBeVisible(); const quickPickerAlignment = await quickPicker.evaluate((element) => { const row = element .closest('.reserved-command-editor') @@ -923,6 +989,30 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop); expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop); expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop); + const rangeMenu = page.locator('[data-main-target="commands"] .range-menu'); + await rangeMenu.locator('summary').click(); + const rangeDividers = rangeMenu.locator('.menu-divider'); + await expect(rangeDividers).toHaveCount(1); + await expect(rangeDividers.first()).toBeVisible(); + expect(await rangeDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe( + '1px solid rgb(68, 68, 68)' + ); + await persistArtifact(page, `${basePath.slice(1)}-command-range-divider-desktop-1200`); + await rangeMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false)); + await expect(rangeMenu).not.toHaveAttribute('open', ''); + + const selectedMenu = page.locator('[data-main-target="commands"] .selected-menu'); + await selectedMenu.locator('summary').click(); + const selectedMenuDividers = selectedMenu.locator('.menu-divider'); + await expect(selectedMenuDividers).toHaveCount(3); + await expect(selectedMenuDividers.first()).toBeVisible(); + expect(await selectedMenuDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe( + '1px solid rgb(68, 68, 68)' + ); + await persistArtifact(page, `${basePath.slice(1)}-command-selected-dividers-desktop-1200`); + await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false)); + await expect(selectedMenu).not.toHaveAttribute('open', ''); + await page.locator('[data-main-target="commands"] .select-command').click(); const picker = page.getByTestId('command-picker'); await expect(picker).toBeVisible(); @@ -1111,6 +1201,11 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn 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); + const mobileTurnButton = page.getByRole('button', { name: '10턴 명령 입력' }); + await mobileTurnButton.click(); + await expect(page.getByTestId('command-picker')).toBeVisible(); + await mobileTurnButton.click(); + await expect(page.getByTestId('command-picker')).toBeHidden(); await captureProgress('mobile-500'); }); @@ -1121,6 +1216,8 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy nationLevel: 3, stage: 6, npcMode: 1, + scenarioTitle: '모바일 검증 시나리오', + latestVote: null, generalMeCalls: 0, operations: [], }; @@ -1139,6 +1236,27 @@ 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(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1); + await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분'); + await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중'); + await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음'); + const activityGeometry = await page.locator('.activity-status').evaluate((element) => { + const tournament = element.querySelector('.tournament-status'); + const survey = element.querySelector('.vote-status'); + if (!tournament || !survey) throw new Error('activity status is incomplete'); + return { + width: element.getBoundingClientRect().width, + tournamentWidth: tournament.getBoundingClientRect().width, + surveyWidth: survey.getBoundingClientRect().width, + columns: getComputedStyle(element).gridTemplateColumns, + }; + }); + expect(activityGeometry).toMatchObject({ + width: 500, + tournamentWidth: 250, + surveyWidth: 250, + columns: '250px 250px', + }); await expect .poll(() => page @@ -1175,6 +1293,125 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); }); +test('real mobile devices initially fit the complete 500px game canvas', async ({ browser }, testInfo) => { + test.setTimeout(60_000); + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the mobile viewport contract'); + } + + const deviceWidths = [360, 390, 480]; + const measurements: Record = {}; + + for (const deviceWidth of deviceWidths) { + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: deviceWidth, height: 844 }, + screen: { width: deviceWidth, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 6, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }; + await installFixture(mobilePage, state); + await waitForMain(mobilePage); + + const mainGeometry = await mobilePage.locator('.main-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + screenWidth: screen.availWidth, + innerWidth: window.innerWidth, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + visualViewportScale: window.visualViewport?.scale ?? null, + documentScrollWidth: document.documentElement.scrollWidth, + canvas: { + left: rect.left, + right: rect.right, + width: rect.width, + }, + }; + }); + + expect(mainGeometry.viewportMeta).toBe('width=500'); + expect(mainGeometry.screenWidth).toBe(deviceWidth); + expect(mainGeometry.layoutViewportWidth).toBe(500); + expect(mainGeometry.visualViewportWidth).toBeCloseTo(500, 2); + expect(mainGeometry.visualViewportScale).toBeCloseTo(deviceWidth / 500, 2); + expect(mainGeometry.documentScrollWidth).toBeLessThanOrEqual(mainGeometry.innerWidth); + expect(mainGeometry.canvas).toEqual({ left: 0, right: 500, width: 500 }); + expect(mainGeometry.canvas.right).toBeLessThanOrEqual((mainGeometry.visualViewportWidth ?? 0) + 0.01); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await mobilePage.screenshot({ + path: resolve(artifactRoot, `initial-mobile-fit-${deviceWidth}.png`), + fullPage: true, + }); + } + + const routeGeometry: Record = {}; + if (deviceWidth === 390) { + for (const target of [ + 'chief-center', + 'battle-center', + 'inherit', + 'nation-betting', + ]) { + await mobilePage.goto(target); + await expect + .poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth)) + .toBe('500px'); + routeGeometry[target] = await mobilePage.locator('#app').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + left: rect.left, + right: rect.right, + width: rect.width, + }; + }); + const geometry = routeGeometry[target] as { + viewportMeta: string; + layoutViewportWidth: number; + visualViewportWidth: number; + left: number; + right: number; + width: number; + }; + expect(geometry.viewportMeta).toBe('width=500'); + expect(geometry.layoutViewportWidth).toBe(500); + expect(geometry.visualViewportWidth).toBeCloseTo(500, 2); + expect(geometry.left).toBeCloseTo(0, 2); + expect(geometry.right).toBeCloseTo(500, 2); + expect(geometry.width).toBeCloseTo(500, 2); + } + } + + measurements[String(deviceWidth)] = { main: mainGeometry, routes: routeGeometry }; + await context.close(); + } + + if (artifactRoot) { + await writeFile( + resolve(artifactRoot, 'initial-mobile-fit-computed-dom.json'), + `${JSON.stringify(measurements, null, 2)}\n` + ); + } +}); + test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index 301b572a..ceb0b626 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -18,6 +18,8 @@ const general = { stats: { leadership: 70, strength: 60, intelligence: 50 }, experienceLevel: 9, dedicationLevel: 1, + dedicationText: '30품관', + bill: 600, injury: 0, gold: 1000, rice: 2000, @@ -28,6 +30,24 @@ const general = { refreshScoreTotal: 10, permission: 'normal', }; +const otherGeneral = { + ...general, + id: 2, + name: '다른장수', + npcState: 1, + stats: { leadership: 40, strength: 80, intelligence: 65 }, + experienceLevel: 12, + dedicationLevel: 3, + dedicationText: '28품관', + bill: 1000, + gold: 3000, + rice: 500, + personality: { key: '용장', name: '용장', info: '공격적인 성격' }, + specialDomestic: { key: '상재', name: '상재', info: '상업 특기' }, + specialWar: { key: '돌격', name: '돌격', info: '전투 특기' }, + belong: 4, + refreshScoreTotal: 20, +}; const install = async (page: Page, secretAllowed = true) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_general'); @@ -42,7 +62,7 @@ const install = async (page: Page, secretAllowed = true) => { return response({ nation: { id: 1, name: '위', color: '#008000', level: 3 }, viewer: { generalId: 1, permission: 0 }, - generals: [general], + generals: [general, otherGeneral], }); if (operation === 'nation.getSecretGeneralList') { if (!secretAllowed) @@ -108,19 +128,91 @@ test('nation generals keeps the 1000px legacy grid and redacted member columns', await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('nation/generals'); await expect(page.locator('#nation-general-list')).toContainText('테스트장수'); - await expect(page.locator('#nation-general-list')).toContainText('?'); const computed = await page.locator('.general-page').evaluate((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { x: rect.x, width: rect.width, fontSize: style.fontSize, fontFamily: style.fontFamily }; }); - expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '16px' }); - expect(computed.fontFamily).toContain('Times New Roman'); + expect(computed).toMatchObject({ x: 100, width: 1000, fontSize: '14px' }); + expect(computed.fontFamily).toContain('Pretendard'); expect(await page.locator('#nation-general-list').evaluate((el) => getComputedStyle(el).borderCollapse)).toBe( 'separate' ); - expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1030); - expect((await page.locator('#nation-general-list tbody tr').boundingBox())?.height).toBe(66); + expect((await page.locator('#nation-general-list').boundingBox())?.width).toBe(1000); + expect((await page.locator('#nation-general-list tbody tr').first().boundingBox())?.height).toBe(68); + await page.getByRole('button', { name: '보기 모드⌄' }).click(); + await page.getByRole('button', { name: '전투', exact: true }).click(); + await expect(page.locator('#nation-general-list')).toContainText('?'); +}); + +test('nation generals restores Ref group, saved view, sort, and Korean search behavior', async ({ page }, testInfo) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/generals'); + const table = page.locator('#nation-general-list'); + await page.screenshot({ path: testInfo.outputPath('core-initial.png'), fullPage: true }); + + const statGroupButton = page.getByRole('button', { name: '능력치 접기' }); + await expect(statGroupButton).toHaveAttribute('aria-expanded', 'true'); + expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); + await statGroupButton.hover(); + expect(await statGroupButton.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(48, 54, 56)'); + await statGroupButton.focus(); + await expect(statGroupButton).toBeFocused(); + expect(await statGroupButton.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe('solid'); + await statGroupButton.click(); + await page.screenshot({ path: testInfo.outputPath('core-stat-collapsed.png'), fullPage: true }); + await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false'); + await expect(table.locator('thead')).toContainText('통|무|지'); + await expect(table.locator('tr[data-general-id="1"]')).toContainText('70|60|50'); + + await page.getByRole('button', { name: '능력치 펼치기' }).click(); + await page.getByLabel('장수명 필터').fill('ㅌㅅㅌㅈㅅ'); + await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); + await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); + await page.getByLabel('장수명 필터').fill(''); + await page.getByLabel('통솔 필터').fill('>= 60'); + await expect(table.locator('tr[data-general-id="1"]')).toBeVisible(); + await expect(table.locator('tr[data-general-id="2"]')).toHaveCount(0); + await page.getByLabel('통솔 필터').fill(''); + + await page.getByRole('button', { name: '통솔 정렬' }).click(); + await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '1'); + await page.getByRole('button', { name: '통솔 정렬' }).click(); + await expect(table.locator('tbody tr[data-general-id]').first()).toHaveAttribute('data-general-id', '2'); + + await page.getByRole('button', { name: '능력치 접기' }).click(); + await page.getByRole('button', { name: '열 선택⌄' }).click(); + await page.getByLabel('쌀', { exact: true }).uncheck(); + await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0); + await page.getByRole('button', { name: '보기 모드⌄' }).click(); + page.once('dialog', async (dialog) => { + expect(dialog.type()).toBe('prompt'); + await dialog.accept('내 보기'); + }); + await page.getByRole('button', { name: /보관하기/ }).click(); + await expect + .poll(() => + page.evaluate(() => ({ + settings: localStorage.getItem('GeneralListDisplaySetting'), + last: localStorage.getItem('LastUsedSettingsKey_pageNationGeneral'), + })) + ) + .toMatchObject({ settings: expect.stringContaining('내 보기'), last: '[false,"내 보기"]' }); + + await page.reload(); + await expect(page.getByRole('button', { name: '능력치 펼치기' })).toHaveAttribute('aria-expanded', 'false'); + await expect(page.getByRole('button', { name: '쌀 정렬' })).toHaveCount(0); + await page.getByRole('button', { name: '보기 모드⌄' }).click(); + await expect(page.getByRole('button', { name: '내 보기', exact: true })).toBeVisible(); + page.once('dialog', async (dialog) => { + expect(dialog.type()).toBe('confirm'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '내 보기 설정 삭제' }).click(); + await expect + .poll(() => page.evaluate(() => localStorage.getItem('GeneralListDisplaySetting'))) + .not.toContain('내 보기'); }); test('both pages preserve the legacy 1000px overflow contract at 500px', async ({ page }) => { diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 920ffa54..9ae56137 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -1,15 +1,22 @@ import { expect, test, type Page, type Route } from '@playwright/test'; -import { readFile } from 'node:fs/promises'; +import { mkdir, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const responsiveArtifactDir = process.env.TOURNAMENT_RESPONSIVE_ARTIFACT_DIR; const imageRoots = [ ...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []), resolve(repositoryRoot, '../image/game'), resolve(repositoryRoot, '../../image/game'), ]; +const iconRoots = [ + ...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'icons')] : []), + resolve(repositoryRoot, '../image/icons'), + resolve(repositoryRoot, '../../image/icons'), + resolve(repositoryRoot, '../../sam_rebuild/image/icons'), +]; const names = [ '관우', '장료', @@ -35,6 +42,8 @@ const participants = names.map((name, index) => ({ strength: 80, intel: 80, level: 10, + picture: 'default.jpg', + imageServer: 0, groupId: 10 + (index % 8), groupNo: Math.floor(index / 8), win: 3 - (index % 2), @@ -88,6 +97,26 @@ const readReferenceImage = async (filename: string): Promise => { throw new Error(`Reference image not found: ${filename}`); }; +const readReferenceIcon = async (filename: string): Promise => { + for (const iconRoot of iconRoots) { + try { + return await readFile(resolve(iconRoot, filename)); + } catch { + // Worktrees can be nested at different depths. + } + } + throw new Error(`Reference icon not found: ${filename}`); +}; + +const persistScreenshot = async (page: Page, name: string, fallbackPath: string) => { + if (!responsiveArtifactDir) { + await page.screenshot({ path: fallbackPath, fullPage: true }); + return; + } + await mkdir(responsiveArtifactDir, { recursive: true }); + await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true }); +}; + const installFixture = async (page: Page) => { await page.addInitScript((profile) => { window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); @@ -98,6 +127,9 @@ const installFixture = async (page: Page) => { await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) }); }); } + await page.route('**/icons/default.jpg', async (route) => { + await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceIcon('default.jpg') }); + }); await page.route(gameTrpcRoute, async (route) => { const results = operationNames(route).map((operation) => { if (operation === 'auth.status') return response({ ok: true }); @@ -125,12 +157,43 @@ const installFixture = async (page: Page) => { } if (operation === 'tournament.getBettingSummary') { return response({ - totals: Object.fromEntries(participants.map((participant, index) => [participant.id, 100 + index * 10])), + totals: Object.fromEntries( + participants.map((participant, index) => [participant.id, 100 + index * 10]) + ), myTotals: {}, totalAmount: 2800, myAmount: 0, }); } + if (operation === 'tournament.getRankings') { + return response( + [ + ['tt', '전 력 전', '종합'], + ['tl', '통 솔 전', '통솔'], + ['ts', '일 기 토', '무력'], + ['ti', '설 전', '지력'], + ].map(([prefix, title, statLabel]) => ({ + prefix, + title, + statLabel, + entries: participants.slice(0, 6).map((participant, index) => ({ + rank: index + 1, + generalId: participant.id, + name: participant.name, + picture: participant.picture, + imageServer: participant.imageServer, + npcState: 0, + stat: 240 - index, + games: 10, + win: 7, + draw: 1, + lose: 2, + score: 22 - index, + prizes: 3, + })), + })) + ); + } return response(null); }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); @@ -162,16 +225,20 @@ test('desktop bracket connects every real general slot to the next round', async connectorCenter: firstConnector.x + firstConnector.width / 2, championCenter: champion.x + champion.width / 2, finalistCenters: finalists.map((rect) => rect.x + rect.width / 2), - connectorQuarters: [firstConnector.x + firstConnector.width / 4, firstConnector.x + (firstConnector.width * 3) / 4], + connectorQuarters: [ + firstConnector.x + firstConnector.width / 4, + firstConnector.x + (firstConnector.width * 3) / 4, + ], }; }); - expect(geometry.canvasWidth).toBe(2000); + expect(geometry.canvasWidth).toBeGreaterThanOrEqual(1000); + expect(geometry.canvasWidth).toBeLessThanOrEqual(1200); expect(Math.abs(geometry.connectorCenter - geometry.championCenter)).toBeLessThan(1); expect(geometry.finalistCenters).toHaveLength(2); expect(Math.abs(geometry.finalistCenters[0]! - geometry.connectorQuarters[0]!)).toBeLessThan(1); expect(Math.abs(geometry.finalistCenters[1]! - geometry.connectorQuarters[1]!)).toBeLessThan(1); - await page.screenshot({ path: testInfo.outputPath('tournament-bracket-desktop.webp'), fullPage: true }); + await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp')); }); test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => { @@ -197,5 +264,62 @@ test('mobile bracket shows every round and general within the handheld width', a expect(bounds.width).toBe(390); expect(bounds.minX).toBeGreaterThanOrEqual(0); expect(bounds.maxX).toBeLessThanOrEqual(390); - await page.screenshot({ path: testInfo.outputPath('tournament-bracket-mobile.webp'), fullPage: true }); + const identity = await bracket + .locator('.mobile-bracket-name') + .first() + .evaluate((element) => { + const icon = element.querySelector('img')!.getBoundingClientRect(); + const name = element.querySelector('.general-identity-name')!.getBoundingClientRect(); + return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y }; + }); + expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight); + expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8); + await expect(page.getByRole('tablist', { name: '본선 조 선택' })).toBeVisible(); + await page.getByRole('tab', { name: '二조' }).first().click(); + await expect(page.getByRole('tab', { name: '二조' }).first()).toHaveAttribute('aria-selected', 'true'); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp')); +}); + +test('mobile betting rankings use tabs and keep dedicated icons beside general names', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await installFixture(page); + await page.goto('betting'); + + await expect(page.locator('.candidate-card')).toHaveCount(16); + await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible(); + await expect(page.locator('.ranking-table:visible')).toHaveCount(1); + await page.getByRole('tab', { name: '통솔전' }).click(); + await expect(page.getByRole('tab', { name: '통솔전' })).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.ranking-table:visible thead')).toContainText('통 솔 전'); + + const identity = await page + .locator('.ranking-table:visible .general-identity') + .first() + .evaluate((element) => { + const icon = element.querySelector('img')!.getBoundingClientRect(); + const name = element.querySelector('.general-identity-name')!.getBoundingClientRect(); + return { iconRight: icon.right, nameLeft: name.left, iconY: icon.y, nameY: name.y }; + }); + expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight); + expect(Math.abs(identity.iconY - identity.nameY)).toBeLessThan(8); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + await persistScreenshot(page, 'tournament-ranking-mobile', testInfo.outputPath('tournament-ranking-mobile.webp')); +}); + +test('desktop betting presents icon-and-name cards and all four rankings without document overflow', async ({ + page, +}, testInfo) => { + await page.setViewportSize({ width: 1365, height: 900 }); + await installFixture(page); + await page.goto('betting'); + + await expect(page.locator('.candidate-card')).toHaveCount(16); + await expect(page.locator('.ranking-table:visible')).toHaveCount(4); + const columns = await page + .locator('.candidate-grid') + .evaluate((element) => getComputedStyle(element).gridTemplateColumns.split(' ').length); + expect(columns).toBe(4); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1365); + await persistScreenshot(page, 'tournament-ranking-desktop', testInfo.outputPath('tournament-ranking-desktop.webp')); }); diff --git a/app/game-frontend/index.html b/app/game-frontend/index.html index 7e6e4af1..45033591 100644 --- a/app/game-frontend/index.html +++ b/app/game-frontend/index.html @@ -2,7 +2,7 @@ - + Sammo HiDCHe - Game diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 9f05f19f..0564b4d9 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -39,6 +39,13 @@ body { min-width: 500px; } +/* These redesigned identity/tournament screens own a true handheld layout. */ +#app:has(.responsive-settings-page), +#app:has(#tournament-container), +#app:has(#tournament-betting-container) { + min-width: 320px; +} + body:has(.battle-page), body:has(.chief-page), body:has(.global-page), diff --git a/app/game-frontend/src/components/chief/ChiefCommandEditor.vue b/app/game-frontend/src/components/chief/ChiefCommandEditor.vue index 5beb9fd1..dcb8b051 100644 --- a/app/game-frontend/src/components/chief/ChiefCommandEditor.vue +++ b/app/game-frontend/src/components/chief/ChiefCommandEditor.vue @@ -1,7 +1,13 @@ diff --git a/app/game-frontend/src/components/main/MapCityBasic.vue b/app/game-frontend/src/components/main/MapCityBasic.vue index d678446d..27aaab9d 100644 --- a/app/game-frontend/src/components/main/MapCityBasic.vue +++ b/app/game-frontend/src/components/main/MapCityBasic.vue @@ -1,5 +1,6 @@ diff --git a/app/game-frontend/src/components/ui/LegacyGeneralProgress.vue b/app/game-frontend/src/components/ui/LegacyGeneralProgress.vue index bf052102..b45a5a39 100644 --- a/app/game-frontend/src/components/ui/LegacyGeneralProgress.vue +++ b/app/game-frontend/src/components/ui/LegacyGeneralProgress.vue @@ -15,7 +15,9 @@ type GeneralProgress = { }; }; -const props = defineProps<{ general: GeneralProgress }>(); +const props = withDefaults(defineProps<{ general: GeneralProgress; showPrimary?: boolean }>(), { + showPrimary: true, +}); const statRows = computed(() => [ @@ -50,7 +52,7 @@ const experiencePercent = computed(() =>