From 15c5c1c98fce02b9bb9ae96b4eb355af75206341 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 15:28:29 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8?= =?UTF-8?q?=20=EC=98=88=EC=84=A0=208=EB=AA=85=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=EC=9D=84=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/tournament/index.ts | 7 ++++ app/game-api/src/tournament/types.ts | 7 ++++ app/game-api/src/tournament/worker.ts | 13 ++++++- app/game-api/src/tournament/workerHelpers.ts | 5 ++- app/game-api/test/tournamentRouter.test.ts | 1 + app/game-api/test/tournamentWorker.test.ts | 39 +++++++++++++++++--- 6 files changed, 63 insertions(+), 9 deletions(-) diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 181e2cfc..9aa986e9 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -71,6 +71,13 @@ const zParticipant = z.object({ gl: z.number().int().optional(), seedRank: z.number().int().optional(), finalRank: z.number().int().optional(), + preliminaryGroupId: z.number().int().min(0).max(7).optional(), + preliminaryGroupNo: z.number().int().min(0).max(7).optional(), + preliminaryRank: z.number().int().min(1).max(8).optional(), + preliminaryWin: z.number().int().min(0).optional(), + preliminaryDraw: z.number().int().min(0).optional(), + preliminaryLose: z.number().int().min(0).optional(), + preliminaryGl: z.number().int().optional(), }); const zMatch = z.object({ diff --git a/app/game-api/src/tournament/types.ts b/app/game-api/src/tournament/types.ts index e05e478e..c0a4ab9c 100644 --- a/app/game-api/src/tournament/types.ts +++ b/app/game-api/src/tournament/types.ts @@ -34,6 +34,13 @@ export interface TournamentParticipantEntry { gl?: number; seedRank?: number; finalRank?: number; + preliminaryGroupId?: number; + preliminaryGroupNo?: number; + preliminaryRank?: number; + preliminaryWin?: number; + preliminaryDraw?: number; + preliminaryLose?: number; + preliminaryGl?: number; } export interface TournamentMatchEntry { diff --git a/app/game-api/src/tournament/worker.ts b/app/game-api/src/tournament/worker.ts index 7fb6c2fd..5ee85766 100644 --- a/app/game-api/src/tournament/worker.ts +++ b/app/game-api/src/tournament/worker.ts @@ -186,6 +186,8 @@ export const applyPreBattleStage = async ( gl: 0, seedRank: 0, finalRank: 0, + preliminaryGroupId: entry.groupId, + preliminaryGroupNo: entry.groupNo, })); await store.setParticipants(grouped); const nextState: TournamentState = { @@ -244,10 +246,17 @@ export const applyPreBattleStage = async ( for (let groupId = 0; groupId < 8; groupId += 1) { const groupEntries = ranked.filter((entry) => entry.groupId === groupId); const ordered = sortByRanking(groupEntries); - ordered.slice(0, 4).forEach((entry, idx) => { + ordered.forEach((entry, idx) => { const target = ranked.find((item) => item.id === entry.id); if (target) { - target.seedRank = idx + 1; + target.preliminaryGroupId = groupId; + target.preliminaryGroupNo = entry.groupNo; + target.preliminaryRank = idx + 1; + target.preliminaryWin = entry.win ?? 0; + target.preliminaryDraw = entry.draw ?? 0; + target.preliminaryLose = entry.lose ?? 0; + target.preliminaryGl = entry.gl ?? 0; + target.seedRank = idx < 4 ? idx + 1 : 0; } }); } diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index a10400cc..7d71272a 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -195,17 +195,20 @@ export const assignManualApplicantGroup = (options: { extraSeed: `manual-group:${options.current.map((entry) => entry.id).join('-')}:${openGroupIds.join('-')}`, }); const groupId = rng.choice(openGroupIds); + const groupNo = groupCounts[groupId] ?? 0; return { ...options.applicant, groupId, - groupNo: groupCounts[groupId] ?? 0, + groupNo, win: 0, draw: 0, lose: 0, gl: 0, seedRank: 0, finalRank: 0, + preliminaryGroupId: groupId, + preliminaryGroupNo: groupNo, }; }; diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index 3b7a8148..b3bd1b4b 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -249,6 +249,7 @@ describe('tournament router permissions and mutations', () => { expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); const summary = await caller.tournament.getBettingSummary(); expect(summary.myAmount).toBe(600); + expect(Object.values(summary.myTotals)).toEqual([600]); expect(summary.totalAmount).toBe(600); expect(transport.gold.get(general.id)).toBe(2_400); }); diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index a0ce15b5..1b779111 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -246,8 +246,9 @@ describe('tournament worker schedule compatibility', () => { groupNo, }; }); - const groupCounts = Array.from({ length: 8 }, (_, groupId) => - current.filter((entry) => entry.groupId === groupId).length + const groupCounts = Array.from( + { length: 8 }, + (_, groupId) => current.filter((entry) => entry.groupId === groupId).length ); const openGroupId = groupCounts.findIndex((count) => count === 7); expect(openGroupId).toBeGreaterThanOrEqual(0); @@ -267,7 +268,16 @@ describe('tournament worker schedule compatibility', () => { }, }); - expect(applicant).toMatchObject({ groupId: openGroupId, groupNo: 7, win: 0, draw: 0, lose: 0, gl: 0 }); + expect(applicant).toMatchObject({ + groupId: openGroupId, + groupNo: 7, + preliminaryGroupId: openGroupId, + preliminaryGroupNo: 7, + win: 0, + draw: 0, + lose: 0, + gl: 0, + }); }); it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => { @@ -334,6 +344,7 @@ describe('tournament worker (in-memory)', () => { expect(entries.map((entry) => entry.groupNo).sort((a, b) => Number(a) - Number(b))).toEqual([ 0, 1, 2, 3, 4, 5, 6, 7, ]); + expect(entries.every((entry) => entry.preliminaryGroupId === groupId)).toBe(true); } }); @@ -648,14 +659,30 @@ describe('tournament worker (in-memory)', () => { expect(participants.find((entry) => entry.id === 1)).toMatchObject({ groupId: expect.any(Number) }); expect(participants.find((entry) => entry.id === 1001)).toMatchObject({ groupId: expect.any(Number) }); expect( - Array.from({ length: 8 }, (_, groupId) => - participants.filter((entry) => entry.groupId === groupId).length - ) + Array.from({ length: 8 }, (_, groupId) => participants.filter((entry) => entry.groupId === groupId).length) ).toEqual(Array.from({ length: 8 }, () => 8)); await store.setState(afterJoin); const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' }); + const finalParticipants = await store.getParticipants(); expect(finalState.stage).toBe(0); expect(finalState.winnerId).toBeDefined(); + for (let groupId = 0; groupId < 8; groupId += 1) { + const preliminaryEntries = finalParticipants + .filter((entry) => entry.preliminaryGroupId === groupId) + .sort((lhs, rhs) => (lhs.preliminaryRank ?? 99) - (rhs.preliminaryRank ?? 99)); + expect(preliminaryEntries).toHaveLength(8); + expect(preliminaryEntries.map((entry) => entry.preliminaryRank)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]); + expect( + preliminaryEntries.every( + (entry) => + entry.preliminaryGroupNo !== undefined && + entry.preliminaryWin !== undefined && + entry.preliminaryDraw !== undefined && + entry.preliminaryLose !== undefined && + entry.preliminaryGl !== undefined + ) + ).toBe(true); + } }); }); From d0fbd0a8675dd2f27e3a7f6230b9e890274d30e1 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 17 Aug 2026 15:28:34 +0000 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=86=A0=EB=84=88=EB=A8=BC?= =?UTF-8?q?=ED=8A=B8=20=EC=9D=B4=EB=8F=99=20=ED=83=AD=EA=B3=BC=20=EA=B0=9C?= =?UTF-8?q?=EC=9D=B8=20=ED=88=AC=EC=9E=90=EA=B8=88=EC=9D=84=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../e2e/tournamentBracket.spec.ts | 58 +++++++-- .../tournament/TournamentBracket.vue | 30 ++++- .../tournament/TournamentPageHeader.vue | 116 ++++++++++++++++++ app/game-frontend/src/views/BettingView.vue | 37 +----- .../src/views/TournamentView.vue | 58 ++++----- 5 files changed, 220 insertions(+), 79 deletions(-) create mode 100644 app/game-frontend/src/components/tournament/TournamentPageHeader.vue diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index 3a9e7452..aafff128 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -35,6 +35,7 @@ const names = [ '허저', '주태', longGeneralName, + ...Array.from({ length: 48 }, (_, index) => `예선장수${index + 17}`), ]; const participants = names.map((name, index) => ({ id: index + 1, @@ -45,13 +46,20 @@ const participants = names.map((name, index) => ({ level: 10, picture: 'default.jpg', imageServer: 0, - groupId: 10 + (index % 8), + groupId: Math.floor(index / 8) < 4 ? 10 + (index % 8) : index % 8, groupNo: Math.floor(index / 8), - win: 3 - (index % 2), + win: Math.floor(index / 8) < 4 ? 3 - (index % 2) : 7 - Math.floor(index / 8), draw: index % 2, - lose: 0, - gl: 12 - index, + lose: Math.floor(index / 8) < 4 ? 0 : Math.floor(index / 8), + gl: 64 - index, finalRank: Math.floor(index / 8) + 1, + preliminaryGroupId: index % 8, + preliminaryGroupNo: Math.floor(index / 8), + preliminaryRank: Math.floor(index / 8) + 1, + preliminaryWin: 7 - Math.floor(index / 8), + preliminaryDraw: index % 2, + preliminaryLose: Math.floor(index / 8), + preliminaryGl: 64 - index, })); const matches = [ ...Array.from({ length: 8 }, (_, index) => ({ @@ -181,11 +189,11 @@ const installFixture = async (page: Page, options: { applicationOpen?: boolean } if (operation === 'tournament.getBettingSummary') { return response({ totals: Object.fromEntries( - participants.map((participant, index) => [participant.id, 100 + index * 10]) + participants.slice(0, 16).map((participant, index) => [participant.id, 100 + index * 10]) ), - myTotals: {}, + myTotals: { 1: 120, 2: 40 }, totalAmount: 2800, - myAmount: 0, + myAmount: 160, }); } if (operation === 'tournament.getRankings') { @@ -297,6 +305,14 @@ test('desktop bracket connects every real general slot to the next round', async expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82); expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop); expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom); + await expect(firstSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120'); + + const preliminaryTables = page.locator('.preliminary-grid table'); + await expect(preliminaryTables).toHaveCount(8); + for (let groupIndex = 0; groupIndex < 8; groupIndex += 1) { + await expect(preliminaryTables.nth(groupIndex).locator('tbody tr')).toHaveCount(8); + await expect(preliminaryTables.nth(groupIndex).locator('.general-identity')).toHaveCount(8); + } await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp')); }); @@ -413,6 +429,7 @@ test('mobile bracket exposes every round through tabs with standard horizontal i }); expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82); expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom); + await expect(firstMobileSlot.locator('.bracket-my-bet')).toHaveText('내 투자 금120'); 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'); @@ -420,12 +437,39 @@ test('mobile bracket exposes every round through tabs with standard horizontal i await persistScreenshot(page, 'tournament-mobile', testInfo.outputPath('tournament-bracket-mobile.webp')); }); +test('tournament and betting pages expose same-row navigation tabs beside close', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openTournament(page); + + const navigation = page.getByRole('tablist', { name: '토너먼트와 베팅장 이동' }); + const tournamentTab = navigation.getByRole('tab', { name: '토너먼트' }); + const bettingTab = navigation.getByRole('tab', { name: '베팅장' }); + const close = page.getByRole('button', { name: '창 닫기' }).first(); + await expect(tournamentTab).toHaveAttribute('aria-selected', 'true'); + + const headerCenters = await Promise.all( + [tournamentTab, bettingTab, close].map(async (control) => { + const box = await control.boundingBox(); + return box ? box.y + box.height / 2 : -1; + }) + ); + expect(Math.max(...headerCenters) - Math.min(...headerCenters)).toBeLessThan(1); + + await bettingTab.click(); + await expect(page).toHaveURL(/\/betting$/); + await expect(page.getByRole('tab', { name: '베팅장' })).toHaveAttribute('aria-selected', 'true'); + await page.getByRole('tab', { name: '토너먼트' }).click(); + await expect(page).toHaveURL(/\/tournament$/); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); +}); + 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.locator('.betting-bracket .bracket-my-bet').first()).toHaveText('내 투자 금120'); await expect(page.getByRole('tablist', { name: '토너먼트 랭킹 종목 선택' })).toBeVisible(); await expect(page.locator('.ranking-table:visible')).toHaveCount(1); await page.getByRole('tab', { name: '통솔전' }).click(); diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue index 6606b178..e4e6387d 100644 --- a/app/game-frontend/src/components/tournament/TournamentBracket.vue +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -12,6 +12,7 @@ const props = defineProps<{ matches: TournamentBracketMatch[]; winnerId?: number; betTotals?: Record; + myBetTotals?: Record; totalBet: number; showLegend?: boolean; }>(); @@ -66,6 +67,7 @@ const odds = (id: number | null) => { if (!amount) return '∞'; return (props.totalBet / amount).toFixed(2); }; +const myBet = (id: number | null) => (id === null ? 0 : (props.myBetTotals?.[id] ?? 0)); const mobilePairs = computed(() => { const column = roundColumns.value[activeMobileRound.value] ?? []; if (activeMobileRound.value === roundColumns.value.length - 1) return column.map((slot) => [slot]); @@ -116,7 +118,10 @@ const mobilePairs = computed(() => { }" > - 배당 {{ odds(slot.id) }} +
+ 배당 {{ odds(slot.id) }} + 내 투자 금{{ myBet(slot.id) }} +
@@ -146,7 +151,10 @@ const mobilePairs = computed(() => { :data-general-id="slot.id ?? undefined" > - 배당 {{ odds(slot.id) }} +
+ 배당 {{ odds(slot.id) }} + 내 투자 금{{ myBet(slot.id) }} +
@@ -220,12 +228,26 @@ const mobilePairs = computed(() => { width: 100%; justify-content: flex-start; } -.bracket-odds { +.bracket-bet-summary { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 4px; + white-space: nowrap; +} +.bracket-odds, +.bracket-my-bet { display: block; + min-width: 0; color: skyblue; font-size: 11px; line-height: 12px; - text-align: right; +} +.bracket-my-bet { + overflow: hidden; + color: orange; + text-overflow: ellipsis; } .mobile-bracket { display: none; diff --git a/app/game-frontend/src/components/tournament/TournamentPageHeader.vue b/app/game-frontend/src/components/tournament/TournamentPageHeader.vue new file mode 100644 index 00000000..28b2b5e5 --- /dev/null +++ b/app/game-frontend/src/components/tournament/TournamentPageHeader.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/app/game-frontend/src/views/BettingView.vue b/app/game-frontend/src/views/BettingView.vue index cebdba39..92325d23 100644 --- a/app/game-frontend/src/views/BettingView.vue +++ b/app/game-frontend/src/views/BettingView.vue @@ -2,6 +2,7 @@ import { formatServerDateTime } from '@sammo-ts/common'; import { computed, onMounted, ref } from 'vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue'; +import TournamentPageHeader from '../components/tournament/TournamentPageHeader.vue'; import GeneralIdentity from '../components/ui/GeneralIdentity.vue'; import { trpc } from '../utils/trpc'; @@ -72,6 +73,7 @@ const candidates = computed(() => const totalAmount = computed(() => summary.value?.totalAmount ?? 0); const myAmount = computed(() => summary.value?.myAmount ?? 0); const betTotals = computed(() => summary.value?.totals as Record | undefined); +const myBetTotals = computed(() => summary.value?.myTotals as Record | undefined); const ratio = (id: number) => { const totals = summary.value?.totals as Record | undefined; const amount = totals?.[id] ?? 0; @@ -110,12 +112,7 @@ const placeBet = async (targetId: number) => {