diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 09a31bc..2f852a0 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -25,6 +25,7 @@ export default defineConfig({ 'nationGeneralSecret.spec.ts', 'npcPolicy.spec.ts', 'auction.spec.ts', + 'tournamentBracket.spec.ts', 'battleSimulator.spec.ts', 'battleSimulatorRef.spec.ts', 'commandArguments.spec.ts', diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts new file mode 100644 index 0000000..920ffa5 --- /dev/null +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -0,0 +1,201 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { 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 imageRoots = [ + ...(process.env.FRONTEND_PARITY_IMAGE_ROOT ? [resolve(process.env.FRONTEND_PARITY_IMAGE_ROOT, 'game')] : []), + resolve(repositoryRoot, '../image/game'), + resolve(repositoryRoot, '../../image/game'), +]; +const names = [ + '관우', + '장료', + '조운', + '하후돈', + '손책', + '태사자', + '마초', + '황충', + '여포', + '전위', + '감녕', + '문추', + '안량', + '허저', + '주태', + '방덕', +]; +const participants = names.map((name, index) => ({ + id: index + 1, + name, + leadership: 80, + strength: 80, + intel: 80, + level: 10, + groupId: 10 + (index % 8), + groupNo: Math.floor(index / 8), + win: 3 - (index % 2), + draw: index % 2, + lose: 0, + gl: 12 - index, + finalRank: Math.floor(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, + winnerId: index * 2 + 1, + })), + ...Array.from({ length: 4 }, (_, index) => ({ + id: index + 9, + stage: 8, + roundIndex: index, + attackerId: index * 4 + 1, + defenderId: index * 4 + 3, + winnerId: index * 4 + 1, + })), + ...Array.from({ length: 2 }, (_, index) => ({ + id: index + 13, + stage: 9, + roundIndex: index, + attackerId: index * 8 + 1, + defenderId: index * 8 + 5, + winnerId: index * 8 + 1, + })), + { id: 15, stage: 10, roundIndex: 0, attackerId: 1, defenderId: 9, winnerId: 1 }, +]; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const readReferenceImage = async (filename: string): Promise => { + for (const imageRoot of imageRoots) { + try { + return await readFile(resolve(imageRoot, filename)); + } catch { + // Worktrees can be nested at different depths. + } + } + throw new Error(`Reference image not found: ${filename}`); +}; + +const installFixture = async (page: Page) => { + await page.addInitScript((profile) => { + window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); + window.localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => { + await route.fulfill({ status: 200, contentType: 'image/jpeg', body: await readReferenceImage(filename) }); + }); + } + await page.route(gameTrpcRoute, 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: names[0] } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'general.me') return response({ general: { id: 1, name: names[0] } }); + if (operation === 'tournament.getAdminStatus') return response({ ok: false }); + if (operation === 'tournament.getSnapshot') { + return response({ + state: { + stage: 0, + phase: 0, + type: 0, + auto: false, + openYear: 184, + openMonth: 1, + termSeconds: 60, + nextAt: '2026-08-02T00:00:00.000Z', + winnerId: 1, + }, + participants, + matches, + betCount: 16, + }); + } + if (operation === 'tournament.getBettingSummary') { + return response({ + totals: Object.fromEntries(participants.map((participant, index) => [participant.id, 100 + index * 10])), + myTotals: {}, + totalAmount: 2800, + myAmount: 0, + }); + } + return response(null); + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); +}; + +const openTournament = async (page: Page) => { + await installFixture(page); + await page.goto('tournament'); + await expect(page.getByLabel('토너먼트 대진표')).toBeVisible(); +}; + +test('desktop bracket connects every real general slot to the next round', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1365, height: 900 }); + await openTournament(page); + + await expect(page.locator('.bracket-canvas .bracket-name[data-general-id]')).toHaveCount(31); + await expect(page.locator('.bracket-canvas .connector-segment')).toHaveCount(15); + await expect(page.locator('.bracket-canvas .bracket-name.advanced', { hasText: '관우' })).toHaveCount(5); + + const geometry = await page.locator('.bracket-canvas').evaluate((canvas) => { + const firstConnector = canvas.querySelector('.connector-segment')!.getBoundingClientRect(); + const champion = canvas.querySelector('.bracket-champion .bracket-name')!.getBoundingClientRect(); + const finalists = [...canvas.querySelectorAll('.bracket-round:nth-of-type(3) .bracket-name')].map( + (element) => element.getBoundingClientRect() + ); + return { + canvasWidth: canvas.getBoundingClientRect().width, + 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], + }; + }); + expect(geometry.canvasWidth).toBe(2000); + 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 }); +}); + +test('mobile bracket shows every round and general within the handheld width', async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openTournament(page); + + const bracket = page.locator('.mobile-bracket'); + await expect(bracket).toBeVisible(); + await expect(bracket.locator('.mobile-bracket-name')).toHaveCount(31); + await expect(bracket.locator('.mobile-bracket-name', { hasText: '방덕' })).toBeVisible(); + await expect(bracket.locator('.mobile-bracket-name', { hasText: '관우' })).toHaveCount(5); + const bounds = await bracket.evaluate((element) => { + const names = [...element.querySelectorAll('.mobile-bracket-name')].map((name) => + name.getBoundingClientRect() + ); + const own = element.getBoundingClientRect(); + return { + width: own.width, + minX: Math.min(...names.map((rect) => rect.left - own.left)), + maxX: Math.max(...names.map((rect) => rect.right - own.left)), + }; + }); + 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 }); +}); diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index c36d02f..6ed76c5 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -15,6 +15,7 @@ "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", "test:e2e:directories": "playwright test directoryLists.spec.ts --config e2e/playwright.config.mjs", "test:e2e:auction": "playwright test auction.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:tournament-bracket": "playwright test tournamentBracket.spec.ts --config e2e/playwright.config.mjs", "test:e2e:battle-simulator": "playwright test battleSimulator.spec.ts --config e2e/playwright.config.mjs", "test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs", diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue new file mode 100644 index 0000000..f1ff856 --- /dev/null +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/app/game-frontend/src/utils/tournamentBracket.ts b/app/game-frontend/src/utils/tournamentBracket.ts new file mode 100644 index 0000000..2b29e23 --- /dev/null +++ b/app/game-frontend/src/utils/tournamentBracket.ts @@ -0,0 +1,76 @@ +export interface TournamentBracketParticipant { + id: number; + name: string; +} + +export interface TournamentBracketMatch { + id: number; + stage: number; + roundIndex: number; + attackerId: number; + defenderId: number; + winnerId?: number; +} + +export interface TournamentBracketSlot { + id: number | null; + name: string; + advanced: boolean; +} + +export interface TournamentBracketRound { + stage: number; + slots: TournamentBracketSlot[]; +} + +export interface TournamentBracketModel { + champion: TournamentBracketSlot; + final: TournamentBracketRound; + semi: TournamentBracketRound; + quarter: TournamentBracketRound; + top16: TournamentBracketRound; +} + +const emptySlot = (): TournamentBracketSlot => ({ id: null, name: '-', advanced: false }); + +export const buildTournamentBracket = ( + participants: TournamentBracketParticipant[], + matches: TournamentBracketMatch[], + winnerId?: number +): TournamentBracketModel => { + const participantsById = new Map(participants.map((participant) => [participant.id, participant])); + const nameOf = (id: number | null): string => + id === null ? '-' : (participantsById.get(id)?.name ?? `#${id}`); + + const buildRound = (stage: number, slotCount: number): TournamentBracketRound => { + const roundMatches = matches + .filter((match) => match.stage === stage) + .sort((lhs, rhs) => lhs.roundIndex - rhs.roundIndex || lhs.id - rhs.id); + const slots: TournamentBracketSlot[] = roundMatches.flatMap((match) => + [match.attackerId, match.defenderId].map((id) => ({ + id, + name: nameOf(id), + advanced: match.winnerId === id, + })) + ); + while (slots.length < slotCount) { + slots.push(emptySlot()); + } + return { stage, slots: slots.slice(0, slotCount) }; + }; + + const final = buildRound(10, 2); + const resolvedWinnerId = winnerId ?? matches.find((match) => match.stage === 10)?.winnerId ?? null; + + return { + champion: { + id: resolvedWinnerId, + name: nameOf(resolvedWinnerId), + advanced: resolvedWinnerId !== null, + }, + final, + semi: buildRound(9, 4), + quarter: buildRound(8, 8), + top16: buildRound(7, 16), + }; +}; diff --git a/app/game-frontend/src/views/TournamentView.vue b/app/game-frontend/src/views/TournamentView.vue index f255404..6b40b64 100644 --- a/app/game-frontend/src/views/TournamentView.vue +++ b/app/game-frontend/src/views/TournamentView.vue @@ -1,5 +1,6 @@