diff --git a/app/game-api/src/router/tournament/index.ts b/app/game-api/src/router/tournament/index.ts index 7c8dba4c..181e2cfc 100644 --- a/app/game-api/src/router/tournament/index.ts +++ b/app/game-api/src/router/tournament/index.ts @@ -7,6 +7,7 @@ import type { TournamentState } from '../../tournament/types.js'; import { TournamentStore } from '../../tournament/store.js'; import { buildTournamentKeys } from '../../tournament/keys.js'; +import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js'; import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js'; import { getMyGeneral } from '../shared/general.js'; import { loadCurrentGameTime } from '../../services/gameClock.js'; @@ -412,52 +413,31 @@ export const tournamentRouter = router({ }); } - const settingResult = await ctx.turnDaemon.requestCommand({ - type: 'setMySetting', - generalId: general.id, - settings: { tnmt: 1 }, + const meta = asRecord(general.meta); + const level = typeof meta.explevel === 'number' ? meta.explevel : 0; + const applicant = assignManualApplicantGroup({ + state, + baseSeed: String(asRecord(worldState?.meta).hiddenSeed ?? 'tournament'), + current: participants, + applicant: { + id: general.id, + name: general.name, + leadership: general.leadership, + strength: general.strength, + intel: general.intel, + level, + }, }); - if (!settingResult || settingResult.type !== 'setMySetting' || !settingResult.ok) { + const next = participants.concat(applicant); + + try { + await store.setParticipants(next); + } catch (error) { await ctx.turnDaemon.requestCommand({ type: 'adjustGeneralResources', reason: 'tournamentJoinRollback', adjustments: [{ generalId: general.id, goldDelta: develCost }], }); - throw new TRPCError({ - code: 'BAD_REQUEST', - message: - settingResult && settingResult.type === 'setMySetting' - ? (settingResult.reason ?? '요청에 실패했습니다.') - : 'Unexpected response', - }); - } - - const meta = asRecord(general.meta); - const level = typeof meta.explevel === 'number' ? meta.explevel : 0; - const next = participants.concat({ - id: general.id, - name: general.name, - leadership: general.leadership, - strength: general.strength, - intel: general.intel, - level, - }); - - try { - await store.setParticipants(next); - } catch (error) { - await Promise.all([ - ctx.turnDaemon.requestCommand({ - type: 'adjustGeneralResources', - reason: 'tournamentJoinRollback', - adjustments: [{ generalId: general.id, goldDelta: develCost }], - }), - ctx.turnDaemon.requestCommand({ - type: 'setMySetting', - generalId: general.id, - settings: { tnmt: 0 }, - }), - ]); throw error; } return { ok: true, count: next.length }; diff --git a/app/game-api/src/tournament/workerHelpers.ts b/app/game-api/src/tournament/workerHelpers.ts index ecac5fa7..a10400cc 100644 --- a/app/game-api/src/tournament/workerHelpers.ts +++ b/app/game-api/src/tournament/workerHelpers.ts @@ -155,6 +155,60 @@ export const assignGroupSlots = ( }); }; +/** + * Ref assigns a manual applicant to one uniformly selected non-full preliminary + * group as part of the join request. Keeping that assignment in the persisted + * participant projection lets the applicant see the group immediately while + * the later participant-fill pass can still balance automatic applicants. + */ +export const assignManualApplicantGroup = (options: { + state: TournamentState; + baseSeed: string; + current: TournamentParticipantEntry[]; + applicant: TournamentParticipantEntry; + groupCount?: number; + groupSize?: number; +}): TournamentParticipantEntry => { + const groupCount = options.groupCount ?? 8; + const groupSize = options.groupSize ?? 8; + const groupCounts = Array.from({ length: groupCount }, () => 0); + + for (const participant of options.current) { + const groupId = participant.groupId; + if (groupId !== undefined && groupId >= 0 && groupId < groupCount) { + groupCounts[groupId] = (groupCounts[groupId] ?? 0) + 1; + } + } + + const openGroupIds = groupCounts.flatMap((count, groupId) => (count < groupSize ? [groupId] : [])); + if (openGroupIds.length === 0) { + throw new Error('참가 인원이 가득 찼습니다.'); + } + + const rng = createTournamentRng(options.baseSeed, { + openYear: options.state.openYear, + openMonth: options.state.openMonth, + stage: 1, + phase: options.state.phase, + matchIndex: options.applicant.id, + participantIndex: options.current.length, + extraSeed: `manual-group:${options.current.map((entry) => entry.id).join('-')}:${openGroupIds.join('-')}`, + }); + const groupId = rng.choice(openGroupIds); + + return { + ...options.applicant, + groupId, + groupNo: groupCounts[groupId] ?? 0, + win: 0, + draw: 0, + lose: 0, + gl: 0, + seedRank: 0, + finalRank: 0, + }; +}; + const selectWeighted = (rng: ReturnType, pool: Array<{ item: T; weight: number }>): T => rng.choiceUsingWeightPair(pool.map((entry) => [entry.item, entry.weight])); diff --git a/app/game-api/test/tournamentRouter.test.ts b/app/game-api/test/tournamentRouter.test.ts index 1e84de66..3b7a8148 100644 --- a/app/game-api/test/tournamentRouter.test.ts +++ b/app/game-api/test/tournamentRouter.test.ts @@ -204,6 +204,20 @@ describe('tournament router permissions and mutations', () => { expect(transport.gold.get(general.id)).toBe(1_800); expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1); + expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0); + const snapshot = await caller.tournament.getSnapshot(); + expect(snapshot.participants).toHaveLength(1); + expect(snapshot.participants[0]).toMatchObject({ + id: general.id, + groupId: expect.any(Number), + groupNo: 0, + win: 0, + draw: 0, + lose: 0, + gl: 0, + }); + expect(snapshot.participants[0]!.groupId).toBeGreaterThanOrEqual(0); + expect(snapshot.participants[0]!.groupId).toBeLessThan(8); }); it('serializes concurrent bets and enforces the legacy per-user 1000 limit', async () => { diff --git a/app/game-api/test/tournamentWorker.test.ts b/app/game-api/test/tournamentWorker.test.ts index 052ac944..a0ce15b5 100644 --- a/app/game-api/test/tournamentWorker.test.ts +++ b/app/game-api/test/tournamentWorker.test.ts @@ -12,7 +12,12 @@ import type { TournamentState, } from '../src/tournament/types.js'; import { applyBattle, applyPreBattleStage, settleTournamentOutcome } from '../src/tournament/worker.js'; -import { buildBettingPayouts, resolveBettingCloseAt, resolveNextAt } from '../src/tournament/workerHelpers.js'; +import { + assignManualApplicantGroup, + buildBettingPayouts, + resolveBettingCloseAt, + resolveNextAt, +} from '../src/tournament/workerHelpers.js'; import type { TurnDaemonTransport } from '../src/daemon/transport.js'; class MemoryRedis { @@ -226,6 +231,45 @@ const runTournamentToCompletion = async (options: { const delayTick = async (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); describe('tournament worker schedule compatibility', () => { + it('수동 참가자를 즉시 남은 예선 조의 다음 슬롯에 배치한다', () => { + const current = Array.from({ length: 63 }, (_, index): TournamentParticipantEntry => { + const groupId = index < 47 ? index % 8 : (index + 1) % 8; + const groupNo = Math.floor(index / 8); + return { + id: index + 1, + name: `참가자${index + 1}`, + leadership: 70, + strength: 70, + intel: 70, + level: 10, + groupId, + groupNo, + }; + }); + 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); + expect(groupCounts.filter((count) => count === 7)).toHaveLength(1); + + const applicant = assignManualApplicantGroup({ + state: createTournamentState(), + baseSeed: 'manual-join-seed', + current, + applicant: { + id: 100, + name: '즉시배치', + leadership: 80, + strength: 81, + intel: 82, + level: 20, + }, + }); + + expect(applicant).toMatchObject({ groupId: openGroupId, groupNo: 7, win: 0, draw: 0, lose: 0, gl: 0 }); + }); + it('catches up from the stored schedule instead of discarding elapsed legacy phases', () => { const state = createTournamentState({ termSeconds: 600, @@ -600,6 +644,14 @@ describe('tournament worker (in-memory)', () => { expect(participants.some((entry) => entry.id === 99)).toBe(false); expect(participants.some((entry) => entry.id === 1001)).toBe(true); expect(participants.some((entry) => entry.id < 0)).toBe(true); + expect(participants.every((entry) => entry.groupId !== undefined && entry.groupNo !== undefined)).toBe(true); + 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 + ) + ).toEqual(Array.from({ length: 8 }, () => 8)); await store.setState(afterJoin); const finalState = await runTournamentToCompletion({ store, prisma, baseSeed: 'seed' }); diff --git a/app/game-frontend/e2e/tournamentBracket.spec.ts b/app/game-frontend/e2e/tournamentBracket.spec.ts index d0358b0a..3a9e7452 100644 --- a/app/game-frontend/e2e/tournamentBracket.spec.ts +++ b/app/game-frontend/e2e/tournamentBracket.spec.ts @@ -118,7 +118,8 @@ const persistScreenshot = async (page: Page, name: string, fallbackPath: string) await page.screenshot({ path: resolve(responsiveArtifactDir, `${name}.webp`), fullPage: true }); }; -const installFixture = async (page: Page) => { +const installFixture = async (page: Page, options: { applicationOpen?: boolean } = {}) => { + let joined = false; await page.addInitScript((profile) => { window.localStorage.setItem('sammo-game-token', 'ga_tournament_bracket_playwright'); window.localStorage.setItem('sammo-game-profile', profile); @@ -141,7 +142,7 @@ const installFixture = async (page: Page) => { if (operation === 'tournament.getSnapshot') { return response({ state: { - stage: 0, + stage: options.applicationOpen ? 1 : 0, phase: 0, type: 0, auto: false, @@ -151,11 +152,32 @@ const installFixture = async (page: Page) => { nextAt: '2026-08-02T00:00:00.000Z', winnerId: 1, }, - participants, + participants: + options.applicationOpen && !joined + ? [] + : options.applicationOpen + ? [ + { + ...participants[0], + groupId: 0, + groupNo: 0, + win: 0, + draw: 0, + lose: 0, + gl: 0, + seedRank: 0, + finalRank: 0, + }, + ] + : participants, matches, betCount: 16, }); } + if (operation === 'tournament.join') { + joined = true; + return response({ ok: true, count: 1 }); + } if (operation === 'tournament.getBettingSummary') { return response({ totals: Object.fromEntries( @@ -245,9 +267,67 @@ test('desktop bracket connects every real general slot to the next round', async expect(geometry.horizontalIdentities).toBe(true); expect(Math.abs(geometry.firstParentY - geometry.firstPairAverageY)).toBeLessThan(1); + const controls = await page.locator('#tournament-container').evaluate((container) => { + const bounds = (selector: string) => container.querySelector(selector)!.getBoundingClientRect(); + const refresh = bounds('.toolbar button:first-child'); + const join = bounds('.join-button'); + const close = bounds('.close-button'); + return { + refresh: { width: refresh.width, height: refresh.height }, + join: { width: join.width, height: join.height }, + close: { width: close.width, height: close.height }, + }; + }); + expect(controls.refresh).toEqual({ width: 72, height: 44 }); + expect(controls.join).toEqual({ width: 72, height: 44 }); + expect(controls.close).toEqual({ width: 88, height: 44 }); + + const firstSlot = page.locator('.desktop-bracket-name').first(); + const oddsContainment = await firstSlot.evaluate((slot) => { + const card = slot.getBoundingClientRect(); + const odds = slot.querySelector('.bracket-odds')!.getBoundingClientRect(); + return { + cardTop: card.top, + cardBottom: card.bottom, + oddsTop: odds.top, + oddsBottom: odds.bottom, + cardHeight: card.height, + }; + }); + expect(oddsContainment.cardHeight).toBeGreaterThanOrEqual(82); + expect(oddsContainment.oddsTop).toBeGreaterThanOrEqual(oddsContainment.cardTop); + expect(oddsContainment.oddsBottom).toBeLessThanOrEqual(oddsContainment.cardBottom); + await persistScreenshot(page, 'tournament-desktop', testInfo.outputPath('tournament-bracket-desktop.webp')); }); +test('join refresh shows the assigned preliminary group immediately with accessible controls', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await installFixture(page, { applicationOpen: true }); + await page.goto('tournament'); + + const refresh = page.getByRole('button', { name: '갱신' }); + const join = page.getByRole('button', { name: '참가' }); + const close = page.getByRole('button', { name: '창 닫기' }).first(); + await expect(join).toBeEnabled(); + await join.click(); + + await expect(page.getByRole('status')).toHaveText('참가 신청이 반영되었습니다.'); + await expect(join).toBeDisabled(); + await expect(page.locator('.preliminary-grid .general-identity', { hasText: names[0] })).toBeVisible(); + + for (const control of [refresh, join, close]) { + const box = await control.boundingBox(); + expect(box?.height).toBe(44); + expect(box?.width).toBeGreaterThanOrEqual(72); + } + await refresh.focus(); + await expect(refresh).toBeFocused(); + await refresh.hover(); + await expect(refresh).toHaveCSS('filter', 'brightness(1.25)'); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); +}); + test('mobile bracket exposes every round through tabs with standard horizontal identities', async ({ page, }, testInfo) => { @@ -325,6 +405,14 @@ test('mobile bracket exposes every round through tabs with standard horizontal i expect(identity.nameLeft).toBeGreaterThanOrEqual(identity.iconRight - 1); expect(identity.nameTop).toBeLessThan(identity.iconBottom); expect(identity.nameBottom).toBeGreaterThan(identity.iconTop); + const firstMobileSlot = bracket.locator('.mobile-bracket-name').first(); + const mobileOddsContainment = await firstMobileSlot.evaluate((slot) => { + const card = slot.getBoundingClientRect(); + const odds = slot.querySelector('.bracket-odds')!.getBoundingClientRect(); + return { cardBottom: card.bottom, oddsBottom: odds.bottom, cardHeight: card.height }; + }); + expect(mobileOddsContainment.cardHeight).toBeGreaterThanOrEqual(82); + expect(mobileOddsContainment.oddsBottom).toBeLessThanOrEqual(mobileOddsContainment.cardBottom); 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'); diff --git a/app/game-frontend/src/components/tournament/TournamentBracket.vue b/app/game-frontend/src/components/tournament/TournamentBracket.vue index eb1da364..6606b178 100644 --- a/app/game-frontend/src/components/tournament/TournamentBracket.vue +++ b/app/game-frontend/src/components/tournament/TournamentBracket.vue @@ -28,8 +28,10 @@ const roundColumns = computed(() => [ ]); const desktopX = [110, 355, 600, 845, 1090]; const cardWidth = 190; +const desktopSlotHeight = 88; +const desktopCanvasHeight = desktopSlotHeight * 16; const slotY = (columnIndex: number, slotIndex: number) => { - const slotHeight = 72 * 2 ** columnIndex; + const slotHeight = desktopSlotHeight * 2 ** columnIndex; return slotHeight / 2 + slotIndex * slotHeight; }; const connections = computed(() => @@ -77,8 +79,8 @@ const mobilePairs = computed(() => { -
-
+