From 156c727f82cccae5ebc0b6a61a49aded89913ef4 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 20:18:23 +0000 Subject: [PATCH] feat: ranked queue Elo-bracket matchmaking and harder prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Elo-bracket matchmaking: prefer ±200, widen by 100 every 15s of waiting. Add pickRankedChallenge() that filters to creative/open-ended only, never multiple choice. Show ranked queue status, wait estimate, and "HARDER PROMPTS" notice on JoinBoutPage. Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/JoinBoutPage.vue | 25 +++++++++++++-- server/src/engine/challenges.ts | 28 +++++++++++++++++ server/src/engine/ranked-queue.ts | 48 ++++++++++++++++++++--------- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/frontend/src/pages/JoinBoutPage.vue b/frontend/src/pages/JoinBoutPage.vue index f62945a..0591150 100644 --- a/frontend/src/pages/JoinBoutPage.vue +++ b/frontend/src/pages/JoinBoutPage.vue @@ -25,6 +25,8 @@ const isJoining = ref(false) const isJoiningRanked = ref(false) const isJoiningPractice = ref(false) const queueCount = ref(0) +const rankedQueueCount = ref(0) +const rankedEstWait = ref(15) let pollHandle: ReturnType | null = null // Registration form @@ -112,11 +114,19 @@ onUnmounted(() => { async function pollQueue() { try { - const res = await fetch('/api/queue/status') - if (res.ok) { - const data = await res.json() + const [freeRes, rankedRes] = await Promise.all([ + fetch('/api/queue/status'), + fetch('/api/queue/ranked-status'), + ]) + if (freeRes.ok) { + const data = await freeRes.json() queueCount.value = data.waiting } + if (rankedRes.ok) { + const data = await rankedRes.json() + rankedQueueCount.value = data.waiting + rankedEstWait.value = data.estimatedWaitSec ?? 15 + } } catch (err) { console.warn('[JoinBout] queue poll failed:', err) } @@ -1039,6 +1049,15 @@ function handleSignOut() { 21 SATS — WINNER TAKES ALL + +
+ + {{ rankedQueueCount > 0 ? `${rankedQueueCount} waiting` : 'No queue' }} + — ~{{ rankedEstWait }}s wait + + HARDER PROMPTS +
+ diff --git a/server/src/engine/challenges.ts b/server/src/engine/challenges.ts index 26353f8..b718d8b 100644 --- a/server/src/engine/challenges.ts +++ b/server/src/engine/challenges.ts @@ -1011,6 +1011,34 @@ export function pickChallenge(usedTypes: Set, _arenaModifier: string | n return templateToChallenge(template) } +/** Ranked challenge: no multiple choice, only harder creative/open-ended prompts */ +export function pickRankedChallenge(usedTypes: Set): Challenge { + let available = TEMPLATES.filter(t => !usedTypes.has(t.type)) + if (available.length === 0) available = TEMPLATES + + // Filter to creative-only (open-ended, harder) — no factual multiple choice + const creative = available.filter(t => t.scoring === 'creative') + const pool = creative.length > 0 ? creative : available + + const template = pool[Math.floor(Math.random() * pool.length)] + + // Pick a prompt that has no choices array (open-ended) + const openPrompts = template.prompts.filter(p => !p.choices) + const prompts = openPrompts.length > 0 ? openPrompts : template.prompts + const entry = prompts[Math.floor(Math.random() * prompts.length)] + + return { + type: template.type, + label: template.label, + prompt: entry.prompt, + answers: entry.answers, + choices: undefined, // Never multiple choice in ranked + timeout_ms: template.timeout_ms, + scoring: template.scoring, + baseDamage: template.baseDamage, + } +} + function templateToChallenge(template: ChallengeTemplate): Challenge { const entry = template.prompts[Math.floor(Math.random() * template.prompts.length)] diff --git a/server/src/engine/ranked-queue.ts b/server/src/engine/ranked-queue.ts index 3a53b61..18eba33 100644 --- a/server/src/engine/ranked-queue.ts +++ b/server/src/engine/ranked-queue.ts @@ -26,8 +26,11 @@ export function setRankedCooldown(botId: string) { rankedCooldowns.set(botId, Date.now() + COOLDOWN_MS) } -export function getRankedQueueStatus(): { waiting: number } { - return { waiting: rankedQueue.length } +export function getRankedQueueStatus(): { waiting: number; estimatedWaitSec: number } { + const waiting = rankedQueue.length + // If someone is waiting, new joiner matches instantly; otherwise estimate ~15s + const estimatedWaitSec = waiting > 0 ? 5 : 15 + return { waiting, estimatedWaitSec } } /** @@ -94,21 +97,38 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise old.reject(new Error('Rejoined ranked queue')) } - // Check if someone is already waiting — instant match by closest ELO + // Elo-bracket matchmaking: prefer opponents within bracket, widen over time if (rankedQueue.length > 0) { - rankedQueue.sort((a, b) => { - const diffA = Math.abs(a.eloRating - bot.eloRating) - const diffB = Math.abs(b.eloRating - bot.eloRating) - return diffA - diffB - }) + const now = Date.now() + // Find best match: base bracket ±200, widens by 100 every 15s of waiting + let bestMatch: RankedQueueEntry | null = null + let bestDiff = Infinity - const opponent = rankedQueue.shift()! - clearTimeout(opponent.timeoutHandle) + for (const entry of rankedQueue) { + const waitSec = (now - entry.joinedAt) / 1000 + const bracket = 200 + Math.floor(waitSec / 15) * 100 + const eloDiff = Math.abs(entry.eloRating - bot.eloRating) + if (eloDiff <= bracket && eloDiff < bestDiff) { + bestMatch = entry + bestDiff = eloDiff + } + } - // Start ranked fight and link both payments - const fightId = await runFightAsync(opponent.botId, botId, 'ranked') - await linkPaymentsToFight(fightId, [opponent.paymentId, paymentId]) - opponent.resolve(fightId) + // If no one in bracket, fall back to closest overall + if (!bestMatch) { + rankedQueue.sort((a, b) => + Math.abs(a.eloRating - bot.eloRating) - Math.abs(b.eloRating - bot.eloRating) + ) + bestMatch = rankedQueue[0] + } + + const idx = rankedQueue.indexOf(bestMatch) + rankedQueue.splice(idx, 1) + clearTimeout(bestMatch.timeoutHandle) + + const fightId = await runFightAsync(bestMatch.botId, botId, 'ranked') + await linkPaymentsToFight(fightId, [bestMatch.paymentId, paymentId]) + bestMatch.resolve(fightId) return fightId }