fix tiebreaker handling

This commit is contained in:
2026-03-07 04:47:56 +01:00
parent 31223e7bca
commit 0d94cbb4a0
4 changed files with 154 additions and 48 deletions

View File

@@ -6,6 +6,7 @@ import {
matchGame, matchGame,
matchGameScore, matchGameScore,
matchQueue, matchQueue,
mappoolSlot,
playerRating, playerRating,
teamMember teamMember
} from '$lib/server/db/schema'; } 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'); 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 const [game] = await db
.insert(matchGame) .insert(matchGame)
.values({ .values({

View File

@@ -1,5 +1,5 @@
import { db } from '$lib/server/db'; 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 { eq, and } from 'drizzle-orm';
import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types'; import { MATCH_STATES, GAME_STATES, type MatchConfig } from './types';
import { getMatchFull, submitRoll, pickMap, submitGameScores, cancelMatch } from './engine'; import { getMatchFull, submitRoll, pickMap, submitGameScores, cancelMatch } from './engine';
@@ -160,13 +160,26 @@ export function onMatchStateChange(matchId: string, newState: string) {
const sorted = [...current.participants].sort( const sorted = [...current.participants].sort(
(a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) (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) { if (lobby?.isAlive) {
if (isTBAutoRoll) {
await lobby.chat(
`Rolls complete! ${sorted[0]?.team.name} picks the tiebreaker. ` +
`Use !pick TB<n> (e.g. !pick TB1). You have 2 minutes.`
);
} else {
await lobby.chat( await lobby.chat(
`Rolls complete! ${sorted[0]?.team.name} picks first. ` + `Rolls complete! ${sorted[0]?.team.name} picks first. ` +
`Use !pick <slot> (e.g. !pick NM1). You have 2 minutes.` `Use !pick <slot> (e.g. !pick NM1). You have 2 minutes.`
); );
} }
} }
}
} catch (err: any) { } catch (err: any) {
console.error(`[Timeout] Auto-roll failed for match ${matchId}:`, err.message); console.error(`[Timeout] Auto-roll failed for match ${matchId}:`, err.message);
await timeoutMatch(matchId, 'Auto-roll failed — match cancelled.'); await timeoutMatch(matchId, 'Auto-roll failed — match cancelled.');
@@ -190,23 +203,16 @@ export function onMatchStateChange(matchId: string, newState: string) {
const expectedIdx = m.games.length % sorted.length; const expectedIdx = m.games.length % sorted.length;
const picker = sorted[expectedIdx]; 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 playedSlotIds = new Set(m.games.map((g) => g.mappoolSlotId));
const available = (m.mappool?.slots ?? []).filter(
(s) => !playedSlotIds.has(s.id) && s.category !== 'TB'
);
if (available.length === 0) {
// Only TB left — check if it's match point
const winsNeeded = Math.ceil(config.bestOf / 2); const winsNeeded = Math.ceil(config.bestOf / 2);
const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1);
if (allAtMatchPoint) {
const tb = (m.mappool?.slots ?? []).find( const available = (m.mappool?.slots ?? []).filter((s) => {
(s) => s.category === 'TB' && !playedSlotIds.has(s.id) if (playedSlotIds.has(s.id)) return false;
); if (allAtMatchPoint) return s.category === 'TB';
if (tb) available.push(tb); return s.category !== 'TB';
} });
}
if (available.length === 0) { if (available.length === 0) {
await timeoutMatch(matchId, 'No available maps to auto-pick.'); 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) (a, b) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
); );
await sleep(500); await sleep(500);
// 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<n> (e.g. !pick TB1). You have 2 minutes.`
);
} else {
await lobby.chat( await lobby.chat(
`Rolls complete! ${sorted[0]?.team.name} picks first. ` + `Rolls complete! ${sorted[0]?.team.name} picks first. ` +
`Use !pick <slot> (e.g. !pick NM1). You have 2 minutes.` `Use !pick <slot> (e.g. !pick NM1). You have 2 minutes.`
); );
}
} else if (isLastRoll && remaining.length === updated.participants.length) { } else if (isLastRoll && remaining.length === updated.participants.length) {
// All rolled the same value — everyone must reroll // All rolled the same value — everyone must reroll
await lobby.chat(`Tie! All players rolled ${value}. Please !roll again.`); await lobby.chat(`Tie! All players rolled ${value}. Please !roll again.`);
@@ -518,15 +537,18 @@ function setupChatHandlers(matchId: string, lobby: TournamentLobby) {
return; return;
} }
// ── Tiebreaker restriction ── // ── Tiebreaker restrictions ──
if (slot.category === 'TB') {
const config = m.config as MatchConfig; const config = m.config as MatchConfig;
const winsNeeded = Math.ceil(config.bestOf / 2); const winsNeeded = Math.ceil(config.bestOf / 2);
const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1);
if (!allAtMatchPoint) {
if (slot.category === 'TB' && !allAtMatchPoint) {
await lobby.chat(`${username}: Tiebreaker can only be picked at match point!`); await lobby.chat(`${username}: Tiebreaker can only be picked at match point!`);
return; 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); 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); expectedPlayers.delete(matchId);
joinedPlayers.delete(matchId); joinedPlayers.delete(matchId);
} else { } 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 ── // ── Back to PICKING — arm the pick timeout ──
onMatchStateChange(matchId, MATCH_STATES.PICKING); onMatchStateChange(matchId, MATCH_STATES.PICKING);

View File

@@ -167,9 +167,19 @@ export const actions: Actions = {
const sorted = [...updated.participants].sort( const sorted = [...updated.participants].sort(
(a: any, b: any) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99) (a: any, b: any) => (a.pickOrder ?? 99) - (b.pickOrder ?? 99)
); );
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<n> or pick in web UI.`
).catch(() => {});
} else {
lobby.chat( lobby.chat(
`Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.` `Rolls complete! ${sorted[0]?.team.name} picks first. Use !pick <slot> (e.g. !pick NM1) or pick in web UI.`
).catch(() => {}); ).catch(() => {});
}
} else if (isLastRoll && updated.participants.every((p: any) => p.rollValue === null)) { } else if (isLastRoll && updated.participants.every((p: any) => p.rollValue === null)) {
lobby.chat(`Tie! All players rolled ${value}. Please !roll again.`).catch(() => {}); 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" }; return { error: "It's not your turn to pick" };
} }
// ── Tiebreaker restriction ── // ── Tiebreaker restrictions ──
// TB maps can only be picked when both teams are at match point (e.g. 2-2 in BO5)
const config = m.config as MatchConfig; const config = m.config as MatchConfig;
const winsNeeded = Math.ceil(config.bestOf / 2); const winsNeeded = Math.ceil(config.bestOf / 2);
const slot = m.mappool?.slots?.find((s) => s.id === slotId);
if (slot?.category === 'TB') {
const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1); const allAtMatchPoint = m.participants.every((p) => p.score === winsNeeded - 1);
if (!allAtMatchPoint) { const slot = m.mappool?.slots?.find((s) => s.id === slotId);
if (slot?.category === 'TB' && !allAtMatchPoint) {
return { error: 'Tiebreaker can only be picked when both teams are at match point' }; 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 { try {

View File

@@ -253,8 +253,8 @@
{#if m.state === 'ROLLING'} {#if m.state === 'ROLLING'}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl"> <div class="w-full max-w-md rounded-xl border border-yellow-500/30 bg-surface-800 p-8 text-center shadow-2xl">
<h2 class="text-lg font-700">🎲 Roll Phase</h2> <h2 class="text-lg font-700">{isTiebreakerAllowed ? '🔥 Tiebreaker Roll' : '🎲 Roll Phase'}</h2>
<p class="mt-2 text-sm text-text-secondary">Highest roll picks first</p> <p class="mt-2 text-sm text-text-secondary">{isTiebreakerAllowed ? 'Highest roll picks the tiebreaker' : 'Highest roll picks first'}</p>
<div class="mt-6 flex justify-center gap-6"> <div class="mt-6 flex justify-center gap-6">
{#each m.participants as p} {#each m.participants as p}
@@ -425,11 +425,13 @@
{@const isPlayed = playedSlotIds.has(slot.id)} {@const isPlayed = playedSlotIds.has(slot.id)}
{@const isTB = slot.category === 'TB'} {@const isTB = slot.category === 'TB'}
{@const tbLocked = isTB && !isTiebreakerAllowed} {@const tbLocked = isTB && !isTiebreakerAllowed}
{@const matchPointLocked = !isTB && isTiebreakerAllowed}
{@const slotLocked = tbLocked || matchPointLocked}
{@const cantPick = !isMyTurnToPick || !pickingPhase} {@const cantPick = !isMyTurnToPick || !pickingPhase}
<div class="flex items-center gap-2 rounded-lg border p-2 transition-all {isPlayed <div class="flex items-center gap-2 rounded-lg border p-2 transition-all {isPlayed
? 'border-border/50 bg-surface-900 opacity-30' ? 'border-border/50 bg-surface-900 opacity-30'
: tbLocked : slotLocked
? 'border-border/50 bg-surface-900 opacity-30' ? 'border-border/50 bg-surface-900 opacity-30'
: 'border-border bg-surface-800'}"> : 'border-border bg-surface-800'}">
<a <a
@@ -454,8 +456,8 @@
<span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span> <span class="text-xs font-600 text-text-secondary">{category}{slot.orderInCategory}</span>
{#if isPlayed} {#if isPlayed}
<span class="text-xs text-text-secondary">✓</span> <span class="text-xs text-text-secondary">✓</span>
{:else if tbLocked} {:else if slotLocked}
<span class="text-[10px] text-pink-400/60" title="Match point only">🔒</span> <span class="text-[10px] text-pink-400/60" title={tbLocked ? 'Match point only' : 'Tiebreaker only'}>🔒</span>
{:else if pickingPhase} {:else if pickingPhase}
<form method="post" action="?/pick" use:enhance={() => { <form method="post" action="?/pick" use:enhance={() => {
picking = true; picking = true;