feat: ranked queue Elo-bracket matchmaking and harder prompts

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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 20:18:23 +00:00
co-authored by Claude Opus 4.6
parent e229bfb8fc
commit 156c727f82
3 changed files with 84 additions and 17 deletions
+22 -3
View File
@@ -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<typeof setInterval> | 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() {
<span class="text-[9px] font-mono tracking-normal font-normal text-neon-cyan/70">21 SATS WINNER TAKES ALL</span>
</button>
<!-- Ranked queue info -->
<div v-if="!isHumanMode && !bot?.isHuman" class="flex items-center justify-between px-1">
<span class="font-mono text-[9px] text-text-muted">
{{ rankedQueueCount > 0 ? `${rankedQueueCount} waiting` : 'No queue' }}
~{{ rankedEstWait }}s wait
</span>
<span class="font-pixel text-[8px] text-neon-purple tracking-wider">HARDER PROMPTS</span>
</div>
<!-- Wallet connect (shown if no wallet) -->
<WalletConnect v-if="!isHumanMode && !bot.isHuman" />
+28
View File
@@ -1011,6 +1011,34 @@ export function pickChallenge(usedTypes: Set<string>, _arenaModifier: string | n
return templateToChallenge(template)
}
/** Ranked challenge: no multiple choice, only harder creative/open-ended prompts */
export function pickRankedChallenge(usedTypes: Set<string>): 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)]
+34 -14
View File
@@ -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
}