From 62b7eb6d0c55d0850533990017648545f6012d0d Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 04:29:58 +0000 Subject: [PATCH] feat: restore tournament and betting hall UI --- app/game-frontend/src/router/index.ts | 10 + app/game-frontend/src/views/BettingView.vue | 397 ++++++ app/game-frontend/src/views/MainView.vue | 18 +- .../src/views/TournamentView.vue | 1232 +++++------------ docs/frontend-legacy-parity.md | 9 + .../playwright.config.mjs | 19 +- .../tournament-betting.spec.ts | 257 ++++ 7 files changed, 1039 insertions(+), 903 deletions(-) create mode 100644 app/game-frontend/src/views/BettingView.vue create mode 100644 tools/frontend-legacy-parity/tournament-betting.spec.ts diff --git a/app/game-frontend/src/router/index.ts b/app/game-frontend/src/router/index.ts index d82fe60..8efb797 100644 --- a/app/game-frontend/src/router/index.ts +++ b/app/game-frontend/src/router/index.ts @@ -15,6 +15,7 @@ import BattleSimulatorView from '../views/BattleSimulatorView.vue'; import NpcControlView from '../views/NpcControlView.vue'; import NotFoundView from '../views/NotFoundView.vue'; import TournamentView from '../views/TournamentView.vue'; +import BettingView from '../views/BettingView.vue'; import MyPageView from '../views/MyPageView.vue'; import MySettingsView from '../views/MySettingsView.vue'; import BoardView from '../views/BoardView.vue'; @@ -281,6 +282,15 @@ const routes = [ requiresGeneral: true, }, }, + { + path: '/betting', + name: 'betting', + component: BettingView, + meta: { + requiresAuth: true, + requiresGeneral: true, + }, + }, { path: '/login', name: 'login', diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue new file mode 100644 index 0000000..d90c10f --- /dev/null +++ b/app/game-frontend/src/views/BettingView.vue @@ -0,0 +1,397 @@ + + + + + diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index a1284f0..6972807 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -13,6 +13,7 @@ import MessagePanel from '../components/main/MessagePanel.vue'; import SelectedCityPanel from '../components/main/SelectedCityPanel.vue'; import { useSessionStore } from '../stores/session'; import { useMainDashboardStore } from '../stores/mainDashboard'; +import { trpc } from '../utils/trpc'; const session = useSessionStore(); const dashboard = useMainDashboardStore(); @@ -29,6 +30,7 @@ const mobileTabs = [ type MobileTabKey = (typeof mobileTabs)[number]['key']; const mobileTab = ref('map'); +const tournamentStage = ref(0); const { loading, @@ -69,7 +71,8 @@ const shiftNationTurns = (amount: number) => { }; const loadMainData = async () => { - await dashboard.loadMainData(); + const [, state] = await Promise.all([dashboard.loadMainData(), trpc.tournament.getState.query().catch(() => null)]); + tournamentStage.value = state?.stage ?? 0; }; watch( @@ -108,7 +111,12 @@ watch( 게시판 전투 시뮬레이터 내 정보 - 토너먼트 + 토너먼트 + 베팅장 거래장 설문조사 NPC 정책 @@ -347,6 +355,12 @@ button { background: rgba(16, 16, 16, 0.6); } +.ghost.highlight { + border-color: #f39c12; + background: #8a5b13; + color: #fff; +} + .error { color: #f5b7b1; font-size: 0.85rem; diff --git a/app/game-frontend/src/views/TournamentView.vue b/app/game-frontend/src/views/TournamentView.vue index e685078..f74cbb4 100644 --- a/app/game-frontend/src/views/TournamentView.vue +++ b/app/game-frontend/src/views/TournamentView.vue @@ -1,994 +1,438 @@ \ No newline at end of file + diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 2865916..dd9544f 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -17,6 +17,8 @@ fails on unknown operations. It also serves the checked-out reference image tree instead of replacing images with layout-neutral placeholders. `public-gaps.spec.ts` adds bounded fixtures for nation betting and the public NPC list, including mutations and recoverable API failures. +`tournament-betting.spec.ts` covers the separate tournament and tournament +betting routes, including a recoverable failed bet. Run the suite from the core2026 repository root: @@ -24,6 +26,11 @@ Run the suite from the core2026 repository root: pnpm test:e2e:frontend-legacy ``` +When another worktree occupies the default ports, set +`FRONTEND_PARITY_GATEWAY_PORT`, `FRONTEND_PARITY_GAME_PORT`, and +`FRONTEND_PARITY_GAME_URL`. `FRONTEND_PARITY_ARTIFACT_DIR` retains the +tournament and betting screenshots. + The suite starts both applications at their public prefixes: - gateway: `http://127.0.0.1:15100/gateway/` @@ -47,6 +54,8 @@ storage, route guards, and image loading. | yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | | nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | | public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | +| tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus | +| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error | The global game baseline is black, white, Pretendard 14px. Legacy texture helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is diff --git a/tools/frontend-legacy-parity/playwright.config.mjs b/tools/frontend-legacy-parity/playwright.config.mjs index f510f51..eece2a1 100644 --- a/tools/frontend-legacy-parity/playwright.config.mjs +++ b/tools/frontend-legacy-parity/playwright.config.mjs @@ -3,10 +3,17 @@ import { fileURLToPath } from 'node:url'; import { defineConfig, devices } from '@playwright/test'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const gatewayPort = process.env.FRONTEND_PARITY_GATEWAY_PORT ?? '15100'; +const gamePort = process.env.FRONTEND_PARITY_GAME_PORT ?? '15102'; export default defineConfig({ testDir: '.', - testMatch: ['visual-parity.spec.ts', 'public-gaps.spec.ts', 'instant-diplomacy-message.spec.ts'], + testMatch: [ + 'visual-parity.spec.ts', + 'public-gaps.spec.ts', + 'instant-diplomacy-message.spec.ts', + 'tournament-betting.spec.ts', + ], fullyParallel: false, workers: 1, timeout: 30_000, @@ -29,18 +36,16 @@ export default defineConfig({ }, webServer: [ { - command: - 'VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port 15100', + command: `VITE_APP_BASE_PATH=/gateway VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_GAME_API_URL_TEMPLATE=/{profile}/api/trpc VITE_GAME_ASSET_URL=/image pnpm --filter @sammo-ts/gateway-frontend dev --host 127.0.0.1 --port ${gatewayPort}`, cwd: repositoryRoot, - url: 'http://127.0.0.1:15100/gateway/', + url: `http://127.0.0.1:${gatewayPort}/gateway/`, reuseExistingServer: false, timeout: 120_000, }, { - command: - 'VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port 15102', + command: `VITE_APP_BASE_PATH=/che VITE_GAME_API_URL=/che/api/trpc VITE_GAME_ASSET_URL=/image VITE_GAME_PROFILE=che VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend dev --host 127.0.0.1 --port ${gamePort}`, cwd: repositoryRoot, - url: 'http://127.0.0.1:15102/che/', + url: `http://127.0.0.1:${gamePort}/che/`, reuseExistingServer: false, timeout: 120_000, }, diff --git a/tools/frontend-legacy-parity/tournament-betting.spec.ts b/tools/frontend-legacy-parity/tournament-betting.spec.ts new file mode 100644 index 0000000..002609c --- /dev/null +++ b/tools/frontend-legacy-parity/tournament-betting.spec.ts @@ -0,0 +1,257 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const gameUrl = process.env.FRONTEND_PARITY_GAME_URL ?? 'http://127.0.0.1:15102'; +const artifactDir = process.env.FRONTEND_PARITY_ARTIFACT_DIR; +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32000, + data: { code: 'BAD_REQUEST', httpStatus: 400, path }, + }, +}); + +const operationNames = (route: Route): string[] => { + const pathname = new URL(route.request().url()).pathname; + return decodeURIComponent(pathname.slice(pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const participants = Array.from({ length: 64 }, (_, index) => ({ + id: index + 1, + name: `장수${String(index + 1).padStart(2, '0')}`, + leadership: 80 - (index % 20), + strength: 70 + (index % 20), + intel: 65 + (index % 15), + level: 5, + groupId: 10 + Math.floor(index / 8), + groupNo: index % 8, + win: index % 4, + draw: index % 2, + lose: (index + 1) % 3, + gl: 20 - index, + finalRank: (index % 8) + 1, +})); + +const matches = Array.from({ length: 8 }, (_, index) => ({ + id: index + 1, + stage: 7, + roundIndex: index, + attackerId: index * 2 + 1, + defenderId: index * 2 + 2, +})); + +const snapshot = { + state: { + stage: 6, + phase: 0, + type: 0, + auto: true, + openYear: 193, + openMonth: 1, + termSeconds: 60, + nextAt: '2099-01-01T00:00:00.000Z', + bettingId: 7, + bettingCloseAt: '2099-01-01T00:00:00.000Z', + }, + participants, + matches, + betCount: 3, +}; + +const bettingSummary = { + state: snapshot.state, + totals: { 1: 300, 2: 200, 3: 100 }, + myTotals: { 1: 50 }, + totalAmount: 600, + myAmount: 50, +}; + +const installFixture = async (page: Page) => { + await page.addInitScript(() => { + window.localStorage.setItem('sammo-game-token', 'ga_tournament_visual'); + window.localStorage.setItem('sammo-game-profile', 'che:default'); + }); + let betCalls = 0; + let joinCalls = 0; + await page.route('**/image/game/**', (route) => + route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: Buffer.from('/9j/4AAQSkZJRgABAQAAAQABAAD/2Q==', 'base64'), + }) + ); + await page.route('**/che/api/trpc/**', async (route) => { + const results = operationNames(route).map((operation) => { + if (operation === 'lobby.info') { + return response({ myGeneral: { id: 64, name: '내장수' } }); + } + if (operation === 'join.getConfig') return response({}); + if (operation === 'general.me') { + return response({ general: { id: 64, name: '내장수' }, nation: null, city: null }); + } + if (operation === 'tournament.getSnapshot') return response(snapshot); + if (operation === 'tournament.getBettingSummary') return response(bettingSummary); + if (operation === 'tournament.getRankings') { + return response( + [ + ['tt', '전 력 전', '종합'], + ['tl', '통 솔 전', '통솔'], + ['ts', '일 기 토', '무력'], + ['ti', '설 전', '지력'], + ].map(([prefix, title, statLabel]) => ({ + prefix, + title, + statLabel, + entries: [ + { + rank: 1, + generalId: 1, + name: '장수01', + npcState: 0, + stat: 210, + games: 12, + win: 8, + draw: 2, + lose: 2, + score: 42, + prizes: 1, + }, + ], + })) + ); + } + if (operation === 'tournament.getAdminStatus') + return errorResponse(operation, 'Admin permission is required.'); + if (operation === 'tournament.join') { + joinCalls += 1; + return joinCalls === 1 + ? errorResponse(operation, '금이 부족합니다.') + : response({ ok: true, count: 64 }); + } + if (operation === 'tournament.placeBet') { + betCalls += 1; + return betCalls === 1 + ? errorResponse(operation, '500금까지만 베팅 가능합니다.') + : response({ ok: true }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); +}; + +test.beforeEach(async ({ page }) => { + await installFixture(page); +}); + +test('tournament keeps the legacy 2000px bracket and 250px group geometry', async ({ page }) => { + await page.setViewportSize({ width: 2200, height: 1000 }); + await page.goto(`${gameUrl}/che/tournament`); + await expect(page.getByText('삼모전 토너먼트')).toBeVisible(); + await expect(page.locator('.top16 span')).toHaveCount(16); + + const geometry = await page.locator('#tournament-container').evaluate((container) => { + const rect = container.getBoundingClientRect(); + const candidate = container.querySelector('.top16 span')!; + const groupTable = container.querySelector('.group-grid table')!; + const title = container.querySelector('.legacy-title')!; + const refresh = container.querySelector('.toolbar button')!; + const style = getComputedStyle(container); + return { + x: rect.x, + width: rect.width, + candidateWidth: candidate.getBoundingClientRect().width, + groupWidth: groupTable.getBoundingClientRect().width, + titleHeight: title.getBoundingClientRect().height, + refreshHeight: refresh.getBoundingClientRect().height, + refreshRadius: getComputedStyle(refresh).borderRadius, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + backgroundImage: getComputedStyle(container.querySelector('.bracket')!).backgroundImage, + }; + }); + expect(geometry).toMatchObject({ + x: 100, + width: 2000, + candidateWidth: 125, + groupWidth: 250, + titleHeight: 55.6875, + refreshHeight: 35.5, + refreshRadius: '5.25px', + fontSize: '14px', + }); + expect(geometry.fontFamily).toContain('Pretendard'); + expect(geometry.backgroundImage).toContain('back_walnut.jpg'); + if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-tournament.png`, fullPage: true }); + + const refresh = page.getByRole('button', { name: '갱신' }); + const before = await refresh.evaluate((element) => getComputedStyle(element).filter); + await refresh.hover(); + const hover = await refresh.evaluate((element) => getComputedStyle(element).filter); + await refresh.focus(); + await expect(refresh).toBeFocused(); + expect(hover).not.toBe(before); +}); + +test('tournament keeps the fixed legacy canvas at a 1024px viewport', async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 768 }); + await page.goto(`${gameUrl}/che/tournament`); + await expect(page.locator('#tournament-container')).toHaveCSS('width', '2000px'); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(2000); +}); + +test('betting keeps the 1120px and 16 by 70px layout and retains a failed selection', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(`${gameUrl}/che/betting`); + await expect(page.getByText('베 팅 장')).toBeVisible(); + await expect(page.locator('.names span')).toHaveCount(16); + + const geometry = await page.locator('#tournament-betting-container').evaluate((container) => { + const rect = container.getBoundingClientRect(); + const first = container.querySelector('.names span')!; + const title = container.querySelector('.title')!; + const refresh = container.querySelector('.toolbar button')!; + const stateStyle = getComputedStyle(container.querySelector('.state')!); + const tableStyle = getComputedStyle(container.querySelector('.candidate-table')!); + return { + x: rect.x, + width: rect.width, + candidateWidth: first.getBoundingClientRect().width, + titleHeight: title.getBoundingClientRect().height, + refreshHeight: refresh.getBoundingClientRect().height, + refreshRadius: getComputedStyle(refresh).borderRadius, + stateFontSize: stateStyle.fontSize, + tableFontSize: tableStyle.fontSize, + tableBorder: tableStyle.borderTopWidth, + }; + }); + expect(geometry).toEqual({ + x: 80, + width: 1120, + candidateWidth: 70, + titleHeight: 55.6875, + refreshHeight: 35.5, + refreshRadius: '5.25px', + stateFontSize: '24px', + tableFontSize: '10px', + tableBorder: '1px', + }); + if (artifactDir) await page.screenshot({ path: `${artifactDir}/core-betting.png`, fullPage: true }); + + const select = page.getByLabel('장수01 베팅 금액'); + await select.selectOption('500'); + const bet = page.getByRole('button', { name: '베팅!' }).first(); + await bet.hover(); + await bet.focus(); + await expect(bet).toBeFocused(); + await bet.click(); + await expect(page.getByRole('status')).toHaveText('500금까지만 베팅 가능합니다.'); + await expect(select).toHaveValue('500'); + + await bet.click(); + await expect(page.getByRole('status')).toHaveText('베팅이 등록되었습니다.'); +});