From 0d94cbb4a0907638318f41fcc33d1e67d1b4cbad Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sat, 7 Mar 2026 04:47:56 +0100 Subject: [PATCH] fix tiebreaker handling --- src/lib/server/match/engine.ts | 19 +++ src/lib/server/match/orchestrator.ts | 140 +++++++++++++----- src/routes/(app)/matches/[id]/+page.server.ts | 31 ++-- src/routes/(app)/matches/[id]/+page.svelte | 12 +- 4 files changed, 154 insertions(+), 48 deletions(-) diff --git a/src/lib/server/match/engine.ts b/src/lib/server/match/engine.ts index c90feeb..43d9114 100644 --- a/src/lib/server/match/engine.ts +++ b/src/lib/server/match/engine.ts @@ -6,6 +6,7 @@ import { matchGame, matchGameScore, matchQueue, + mappoolSlot, playerRating, teamMember } from '$lib/server/db/schema'; @@ -407,6 +408,24 @@ export async function pickMap(matchId: string, participantId: string, mappoolSlo throw new Error('This map has already been played'); } + // ── Tiebreaker restrictions ── + const config = m.config as MatchConfig; + const winsNeeded = Math.ceil(config.bestOf / 2); + const allAtMatchPoint = participants.every((p) => p.score === winsNeeded - 1); + + const slot = await db.query.mappoolSlot.findFirst({ + where: eq(mappoolSlot.id, mappoolSlotId) + }); + + if (slot) { + if (slot.category === 'TB' && !allAtMatchPoint) { + throw new Error('Tiebreaker can only be picked at match point'); + } + if (allAtMatchPoint && slot.category !== 'TB') { + throw new Error('Only tiebreaker maps can be picked at match point'); + } + } + const [game] = await db .insert(matchGame) .values({ diff --git a/src/lib/server/match/orchestrator.ts b/src/lib/server/match/orchestrator.ts index 0e32c08..0962680 100644 --- a/src/lib/server/match/orchestrator.ts +++ b/src/lib/server/match/orchestrator.ts @@ -1,5 +1,5 @@ import { db } from '$lib/server/db'; -import { match, matchGame, user } from '$lib/server/db/schema'; +import { match, matchGame, matchParticipant, user } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types'; import { getMatchFull, submitRoll, pickMap, submitGameScores, cancelMatch } from './engine'; @@ -160,11 +160,24 @@ export function onMatchStateChange(matchId: string, newState: string) { const sorted = [...current.participants].sort( (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) ); + + // Check if this was a tiebreaker re-roll + const autoRollConfig = m.config as MatchConfig; + const autoRollWins = Math.ceil(autoRollConfig.bestOf / 2); + const isTBAutoRoll = current.participants.every((p) => p.score === autoRollWins - 1); + if (lobby?.isAlive) { - await lobby.chat( - `Rolls complete! ${sorted[0]?.team.name} picks first. ` + - `Use !pick (e.g. !pick NM1). You have 2 minutes.` - ); + if (isTBAutoRoll) { + await lobby.chat( + `Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. ` + + `Use !pick TB (e.g. !pick TB1). You have 2 minutes.` + ); + } else { + await lobby.chat( + `Rolls complete! ${sorted[0]?.team.name} picks first. ` + + `Use !pick (e.g. !pick NM1). You have 2 minutes.` + ); + } } } } catch (err: any) { @@ -190,23 +203,16 @@ export function onMatchStateChange(matchId: string, newState: string) { const expectedIdx = m.games.length % sorted.length; const picker = sorted[expectedIdx]; - // Find available (unplayed, non-TB) slots + // Find available slots — at match point, only TB maps are pickable const playedSlotIds = new Set(m.games.map((g) => g.mappoolSlotId)); - const available = (m.mappool?.slots ?? []).filter( - (s) => !playedSlotIds.has(s.id) && s.category !== 'TB' - ); + const winsNeeded = Math.ceil(config.bestOf / 2); + const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); - if (available.length === 0) { - // Only TB left — check if it's match point - const winsNeeded = Math.ceil(config.bestOf / 2); - const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); - if (allAtMatchPoint) { - const tb = (m.mappool?.slots ?? []).find( - (s) => s.category === 'TB' && !playedSlotIds.has(s.id) - ); - if (tb) available.push(tb); - } - } + const available = (m.mappool?.slots ?? []).filter((s) => { + if (playedSlotIds.has(s.id)) return false; + if (allAtMatchPoint) return s.category === 'TB'; + return s.category !== 'TB'; + }); if (available.length === 0) { await timeoutMatch(matchId, 'No available maps to auto-pick.'); @@ -441,10 +447,23 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) ); await sleep(500); - await lobby.chat( - `Rolls complete! ${sorted[0]?.team.name} picks first. ` + - `Use !pick (e.g. !pick NM1). You have 2 minutes.` - ); + + // Check if this is a tiebreaker re-roll + const rollConfig = updated.config as MatchConfig; + const rollWinsNeeded = Math.ceil(rollConfig.bestOf / 2); + const isTBRoll = updated.participants.every((p) => p.score === rollWinsNeeded - 1); + + if (isTBRoll) { + await lobby.chat( + `Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. ` + + `Use !pick TB (e.g. !pick TB1). You have 2 minutes.` + ); + } else { + await lobby.chat( + `Rolls complete! ${sorted[0]?.team.name} picks first. ` + + `Use !pick (e.g. !pick NM1). You have 2 minutes.` + ); + } } else if (isLastRoll && remaining.length === updated.participants.length) { // All rolled the same value — everyone must reroll await lobby.chat(`Tie! All players rolled ${value}. Please !roll again.`); @@ -518,15 +537,18 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) { return; } - // ── Tiebreaker restriction ── - if (slot.category === 'TB') { - const config = m.config as MatchConfig; - const winsNeeded = Math.ceil(config.bestOf / 2); - const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); - if (!allAtMatchPoint) { - await lobby.chat(`${username}: Tiebreaker can only be picked at match point!`); - return; - } + // ── Tiebreaker restrictions ── + const config = m.config as MatchConfig; + const winsNeeded = Math.ceil(config.bestOf / 2); + const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); + + if (slot.category === 'TB' && !allAtMatchPoint) { + await lobby.chat(`${username}: Tiebreaker can only be picked at match point!`); + return; + } + if (allAtMatchPoint && slot.category !== 'TB') { + await lobby.chat(`${username}: Only tiebreaker maps can be picked at match point!`); + return; } const game = await pickMap(matchId, expectedPicker!.id, slot.id); @@ -826,6 +848,58 @@ async function collectScores(matchId: string, matchGameId: string, lobby: Tourna expectedPlayers.delete(matchId); joinedPlayers.delete(matchId); } else { + // ── Check for tiebreaker situation ── + const allAtMatchPoint = updated.participants.every((p) => p.score === winsNeeded - 1); + + if (allAtMatchPoint) { + const playedSlotIds = new Set(updated.games.map((g) => g.mappoolSlotId)); + const tbSlots = (updated.mappool?.slots ?? []).filter( + (s) => s.category === 'TB' && !playedSlotIds.has(s.id) + ); + + if (tbSlots.length === 1) { + // Single TB — auto-pick it + const picker = sortedPs[updated.games.length % sortedPs.length]; + const tbLabel = `TB${tbSlots[0].orderInCategory}`; + try { + await sleep(1000); + await lobby.chat( + `🔥 Tiebreaker! ${matchScoreStr} — auto-picking ${tbLabel}!` + ); + } catch { /* ignore */ } + + const game = await pickMap(matchId, picker!.id, tbSlots[0].id); + playPickedMap(matchId, game.id).catch((err) => + console.error('[Orchestrator] TB auto-pick play failed:', err.message) + ); + return; + } else if (tbSlots.length > 1) { + // Multiple TBs — re-roll to decide who picks + for (const p of updated.participants) { + await db + .update(matchParticipant) + .set({ rollValue: null, pickOrder: null }) + .where(eq(matchParticipant.id, p.id)); + } + await db + .update(match) + .set({ state: MATCH_STATES.ROLLING }) + .where(eq(match.id, matchId)); + + onMatchStateChange(matchId, MATCH_STATES.ROLLING); + + try { + await sleep(1000); + await lobby.chat( + `🔥 Tiebreaker! ${matchScoreStr} — ${tbSlots.length} tiebreaker maps available. ` + + `Roll to decide who picks! Type !roll. You have 5 minutes.` + ); + } catch { /* ignore */ } + return; + } + // Zero unplayed TBs at match point — fall through to normal picking + } + // ── Back to PICKING — arm the pick timeout ── onMatchStateChange(matchId, MATCH_STATES.PICKING); diff --git a/src/routes/(app)/matches/[id]/+page.server.ts b/src/routes/(app)/matches/[id]/+page.server.ts index e57b0b0..2162b86 100644 --- a/src/routes/(app)/matches/[id]/+page.server.ts +++ b/src/routes/(app)/matches/[id]/+page.server.ts @@ -167,9 +167,19 @@ export const actions: Actions = { const sorted = [...updated.participants].sort( (a: any, b: any) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) ); - lobby.chat( - `Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick (e.g. !pick NM1) or pick in web UI.` - ).catch(() => {}); + const rollConfig = updated.config as MatchConfig; + const rollWinsNeeded = Math.ceil(rollConfig.bestOf / 2); + const isTBRoll = updated.participants.every((p: any) => p.score === rollWinsNeeded - 1); + + if (isTBRoll) { + lobby.chat( + `Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. Use !pick TB or pick in web UI.` + ).catch(() => {}); + } else { + lobby.chat( + `Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick (e.g. !pick NM1) or pick in web UI.` + ).catch(() => {}); + } } else if (isLastRoll && updated.participants.every((p: any) => p.rollValue === null)) { lobby.chat(`Tie! All players rolled ${value}. Please !roll again.`).catch(() => {}); } @@ -205,16 +215,17 @@ export const actions: Actions = { return { error: "It's not your turn to pick" }; } - // ── Tiebreaker restriction ── - // TB maps can only be picked when both teams are at match point (e.g. 2-2 in BO5) + // ── Tiebreaker restrictions ── const config = m.config as MatchConfig; const winsNeeded = Math.ceil(config.bestOf / 2); + const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); const slot = m.mappool?.slots?.find((s) => s.id === slotId); - if (slot?.category === 'TB') { - const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); - if (!allAtMatchPoint) { - return { error: 'Tiebreaker can only be picked when both teams are at match point' }; - } + + if (slot?.category === 'TB' && !allAtMatchPoint) { + return { error: 'Tiebreaker can only be picked when both teams are at match point' }; + } + if (allAtMatchPoint && slot?.category !== 'TB') { + return { error: 'Only tiebreaker maps can be picked at match point' }; } try { diff --git a/src/routes/(app)/matches/[id]/+page.svelte b/src/routes/(app)/matches/[id]/+page.svelte index bc94969..8846efe 100644 --- a/src/routes/(app)/matches/[id]/+page.svelte +++ b/src/routes/(app)/matches/[id]/+page.svelte @@ -253,8 +253,8 @@ {#if m.state === 'ROLLING'}
-

🎲 Roll Phase

-

Highest roll picks first

+

{isTiebreakerAllowed ? '🔥 Tiebreaker Roll' : '🎲 Roll Phase'}

+

{isTiebreakerAllowed ? 'Highest roll picks the tiebreaker' : 'Highest roll picks first'}

{#each m.participants as p} @@ -425,11 +425,13 @@ {@const isPlayed = playedSlotIds.has(slot.id)} {@const isTB = slot.category === 'TB'} {@const tbLocked = isTB && !isTiebreakerAllowed} + {@const matchPointLocked = !isTB && isTiebreakerAllowed} + {@const slotLocked = tbLocked || matchPointLocked} {@const cantPick = !isMyTurnToPick || !pickingPhase}