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:
co-authored by
Claude Opus 4.6
parent
e229bfb8fc
commit
156c727f82
@@ -25,6 +25,8 @@ const isJoining = ref(false)
|
|||||||
const isJoiningRanked = ref(false)
|
const isJoiningRanked = ref(false)
|
||||||
const isJoiningPractice = ref(false)
|
const isJoiningPractice = ref(false)
|
||||||
const queueCount = ref(0)
|
const queueCount = ref(0)
|
||||||
|
const rankedQueueCount = ref(0)
|
||||||
|
const rankedEstWait = ref(15)
|
||||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
// Registration form
|
// Registration form
|
||||||
@@ -112,11 +114,19 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
async function pollQueue() {
|
async function pollQueue() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/queue/status')
|
const [freeRes, rankedRes] = await Promise.all([
|
||||||
if (res.ok) {
|
fetch('/api/queue/status'),
|
||||||
const data = await res.json()
|
fetch('/api/queue/ranked-status'),
|
||||||
|
])
|
||||||
|
if (freeRes.ok) {
|
||||||
|
const data = await freeRes.json()
|
||||||
queueCount.value = data.waiting
|
queueCount.value = data.waiting
|
||||||
}
|
}
|
||||||
|
if (rankedRes.ok) {
|
||||||
|
const data = await rankedRes.json()
|
||||||
|
rankedQueueCount.value = data.waiting
|
||||||
|
rankedEstWait.value = data.estimatedWaitSec ?? 15
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[JoinBout] queue poll failed:', 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>
|
<span class="text-[9px] font-mono tracking-normal font-normal text-neon-cyan/70">21 SATS — WINNER TAKES ALL</span>
|
||||||
</button>
|
</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) -->
|
<!-- Wallet connect (shown if no wallet) -->
|
||||||
<WalletConnect v-if="!isHumanMode && !bot.isHuman" />
|
<WalletConnect v-if="!isHumanMode && !bot.isHuman" />
|
||||||
|
|
||||||
|
|||||||
@@ -1011,6 +1011,34 @@ export function pickChallenge(usedTypes: Set<string>, _arenaModifier: string | n
|
|||||||
return templateToChallenge(template)
|
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 {
|
function templateToChallenge(template: ChallengeTemplate): Challenge {
|
||||||
const entry = template.prompts[Math.floor(Math.random() * template.prompts.length)]
|
const entry = template.prompts[Math.floor(Math.random() * template.prompts.length)]
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,11 @@ export function setRankedCooldown(botId: string) {
|
|||||||
rankedCooldowns.set(botId, Date.now() + COOLDOWN_MS)
|
rankedCooldowns.set(botId, Date.now() + COOLDOWN_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRankedQueueStatus(): { waiting: number } {
|
export function getRankedQueueStatus(): { waiting: number; estimatedWaitSec: number } {
|
||||||
return { waiting: rankedQueue.length }
|
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'))
|
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) {
|
if (rankedQueue.length > 0) {
|
||||||
rankedQueue.sort((a, b) => {
|
const now = Date.now()
|
||||||
const diffA = Math.abs(a.eloRating - bot.eloRating)
|
// Find best match: base bracket ±200, widens by 100 every 15s of waiting
|
||||||
const diffB = Math.abs(b.eloRating - bot.eloRating)
|
let bestMatch: RankedQueueEntry | null = null
|
||||||
return diffA - diffB
|
let bestDiff = Infinity
|
||||||
})
|
|
||||||
|
|
||||||
const opponent = rankedQueue.shift()!
|
for (const entry of rankedQueue) {
|
||||||
clearTimeout(opponent.timeoutHandle)
|
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
|
// If no one in bracket, fall back to closest overall
|
||||||
const fightId = await runFightAsync(opponent.botId, botId, 'ranked')
|
if (!bestMatch) {
|
||||||
await linkPaymentsToFight(fightId, [opponent.paymentId, paymentId])
|
rankedQueue.sort((a, b) =>
|
||||||
opponent.resolve(fightId)
|
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
|
return fightId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user