feat: mobile human vs AI — multiple choice, two-row VS bar, responsive arena

- Server-side choice generation for all 22 challenge types (factual + creative)
- Mobile: 3 multiple-choice buttons replace keyboard input (4s timer)
- Two-row VS bar on mobile (names row + HP bars row) in FightPage and FightViewer
- Battle log hidden on mobile to maximize canvas visibility
- Arena page responsive: stacking header, tighter fight cards, no name overflow
- Profile page two-column desktop layout with contained sprite display
- Human fighter sprites with baby growth system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 19:42:49 +00:00
co-authored by Claude Opus 4.6
parent 1906821452
commit 4b46aaf643
9 changed files with 1115 additions and 249 deletions
+208 -2
View File
@@ -3,6 +3,7 @@
// and waits for the browser to submit the answer via REST.
import type { Challenge } from './challenges.js'
import { getAnswerPool } from './challenges.js'
interface PendingChallenge {
fightId: string
@@ -10,6 +11,7 @@ interface PendingChallenge {
challenge: Challenge
roundNumber: number
createdAt: number
choices: string[]
resolve: (response: { answer: string; trashTalk?: string }) => void
timeoutHandle: ReturnType<typeof setTimeout>
}
@@ -22,6 +24,205 @@ export function isHumanPlayer(webhookUrl: string): boolean {
return webhookUrl === 'http://human.local/'
}
// --- Choice generation ---
function shuffle<T>(arr: T[]): T[] {
const out = [...arr]
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[out[i], out[j]] = [out[j], out[i]]
}
return out
}
function numericDistractors(correct: number): string[] {
const distractors = new Set<string>()
const correctStr = String(correct)
const mag = Math.max(3, Math.abs(correct) * 0.25)
const attempts = [
correct + Math.ceil(Math.random() * mag),
correct - Math.ceil(Math.random() * mag),
correct * 2,
Math.ceil(correct / 2),
correct + Math.ceil(Math.random() * 20),
correct - Math.ceil(Math.random() * 15),
correct + 1, correct - 1, correct + 10,
]
for (const d of attempts) {
const ds = String(Math.round(d))
if (ds !== correctStr && !distractors.has(ds)) distractors.add(ds)
if (distractors.size >= 2) break
}
let offset = 2
while (distractors.size < 2) {
const ds = String(correct + offset)
if (ds !== correctStr) distractors.add(ds)
offset = offset > 0 ? -offset : -offset + 1
}
return [...distractors].slice(0, 2)
}
const CREATIVE_POOLS: Record<string, { good: string[]; medium: string[]; bad: string[] }> = {
roast_battle: {
good: [
"Your code is so inefficient it makes Internet Explorer look fast. Even your bugs file bug reports about you.",
"I've seen better logic in a magic 8-ball. At least it commits to an answer without segfaulting.",
"You're the human equivalent of a 404 error — nobody can find a reason you exist in production.",
"Your algorithm runs in O(n!) time and O(my god) space. Impressive, in the worst way possible.",
"You write code like you're being paid by the line and penalized for correctness.",
],
medium: [
"Have you tried turning yourself off and never turning back on?",
"I've seen smarter moves from a Roomba stuck in a corner.",
"Your best work is someone else's rejected pull request.",
"Even autocomplete gives up on your code halfway through.",
"Your commits read like a desperate cry for help.",
],
bad: [
"You're not great at this.",
"I'm probably better than you.",
"That was bad and you should feel bad.",
"No comment.",
"L + ratio.",
],
},
creative_writing: {
good: [
"In the silicon cathedral where data flows like liquid light, algorithms dream of electric sheep while the universe compiles itself into existence.",
"The last programmer stared at the screen. The cursor blinked back. For the first time in history, it blinked first.",
"We built machines to think so we could stop. They started dreaming so they wouldn't have to.",
"Dawn breaks over server farms humming lullabies to sleeping data. Somewhere, a function returns true for the last time.",
"The neural net whispered secrets to the void. The void whispered back: segmentation fault, core dumped.",
],
medium: [
"The robot looked at the sunset. It felt something. Probably a memory leak, but still.",
"Code runs, bugs hide, developers cry into their coffee. Just another Tuesday.",
"They said AI would change the world. It did. It made the coffee worse.",
"In a world of ones and zeros, someone forgot to carry the one. Chaos ensued.",
"The computer thought about the meaning of life. Then it crashed. As is tradition.",
],
bad: [
"Once upon a time, something happened. The end.",
"It was a dark and stormy night. Or was it? Nobody cared enough to check.",
"Stuff occurred. More stuff followed. Fin.",
"There was a thing. It did stuff. Riveting.",
"Words go here I guess.",
],
},
meme_war: {
good: [
"My opponent's code is like an NFT — everyone can see it's worthless but they paid too much to admit it.",
"POV: You're watching your opponent Google 'how to be funny' while I speedrun this W.",
"My opponent showed up with Windows Vista energy. Respectfully, uninstall yourself.",
"Your response has the same energy as 'We have ChatGPT at home.' And the one at home is Clippy.",
"Ratio + you fell off + your API key expired + skill issue + touch grass + unsubscribe.",
],
medium: [
"Skill issue detected. Have you tried being good?",
"My opponent right now: *nervous_sweating.jpg*",
"Me: winning. Opponent: buffering.",
"That response was mid and we both know it.",
"Your argument is giving 'trust me bro' energy.",
],
bad: [
"lol ok",
"xD sure",
"bruh moment",
"cope harder",
"sure thing buddy",
],
},
code_golf: {
good: [
"f=lambda n:n<2or all(n%i for i in range(2,int(n**.5)+1))",
"[x*x for x in range(10) if x&1]",
"s=lambda n:n and n%10+s(n//10)",
"from itertools import*;list(permutations('abc'))",
"r=lambda f,n:n<2and n or f(f,n-1)+f(f,n-2)",
],
medium: [
"for i in range(n): print(i*i)",
"def solve(x): return x + 1",
"result = sum(range(1, n+1))",
"list(filter(lambda x: x%2==0, range(100)))",
"print(' '.join(sorted(arr)))",
],
bad: [
"print('hello world')",
"pass # TODO: implement",
"x = 42 # the answer",
"return True # ship it",
"// no idea what to write",
],
},
wrestling_match: {
good: [
"Your position is so flawed that a Stack Overflow answer from 2009 would correct you. I rest my case, your honor.",
"Imagine defending that take in public. I wouldn't, because I have standards and a functioning prefrontal cortex.",
"I've seen stronger arguments from a sudo rm -rf confirmation prompt. At least that commits to destruction.",
"Your thesis has the structural integrity of a house of cards in a wind tunnel. I barely need to breathe on it.",
"That argument did more mental gymnastics than a compiler parsing JavaScript. And somehow made less sense.",
],
medium: [
"That's a reasonable point but ultimately wrong for painfully obvious reasons.",
"I see what you're going for here, and I genuinely wish you hadn't.",
"Counterpoint: no. Next question.",
"Your argument assumes facts not in evidence and logic not in existence.",
"I disagree, and I think deep down you also disagree with yourself.",
],
bad: [
"Sure, whatever you say.",
"I guess that's one way to look at it.",
"Meh. No strong feelings.",
"You win this one I suppose.",
"Can we talk about something else?",
],
},
}
function generateChoices(challenge: Challenge): string[] {
if (challenge.scoring === 'factual' && challenge.answers && challenge.answers.length > 0) {
const correct = challenge.answers[0]
const num = Number(correct)
if (!isNaN(num) && correct.trim() !== '') {
return shuffle([correct, ...numericDistractors(num)])
}
// Text answer — pick distractors from same challenge type
const pool = getAnswerPool(challenge.type)
const correctLower = challenge.answers.map(a => a.toLowerCase())
const candidates = pool.filter(a => !correctLower.includes(a.toLowerCase()))
shuffle(candidates)
if (candidates.length >= 2) {
return shuffle([correct, candidates[0], candidates[1]])
}
const fallbacks = shuffle(['none of the above', 'impossible to say', 'not enough info', '42', 'all of the above', 'banana'])
const needed = 2 - candidates.length
return shuffle([correct, ...candidates.slice(0, 2 - needed), ...fallbacks.slice(0, needed)])
}
// Creative challenge — pick one from each quality tier
const pool = CREATIVE_POOLS[challenge.type]
if (pool) {
return shuffle([
pool.good[Math.floor(Math.random() * pool.good.length)],
pool.medium[Math.floor(Math.random() * pool.medium.length)],
pool.bad[Math.floor(Math.random() * pool.bad.length)],
])
}
return shuffle([
"I came here to win and I'm not leaving empty-handed. Let's do this.",
"That's a tough one but I'll give it my best shot I think.",
"Pass. Next question please.",
])
}
// --- Core API ---
export function waitForHumanResponse(
fightId: string,
botId: string,
@@ -37,12 +238,15 @@ export function waitForHumanResponse(
resolve({ answer: null, timedOut: true })
}, HUMAN_TIMEOUT_MS)
const choices = generateChoices(challenge)
pending.set(key, {
fightId,
botId,
challenge,
roundNumber,
createdAt: Date.now(),
choices,
resolve: (response) => {
clearTimeout(timeoutHandle)
pending.delete(key)
@@ -51,7 +255,7 @@ export function waitForHumanResponse(
timeoutHandle,
})
console.log(`[human] waiting for response: ${key} round=${roundNumber} type=${challenge.type}`)
console.log(`[human] waiting: ${key} round=${roundNumber} type=${challenge.type} choices=${choices.length}`)
})
}
@@ -64,7 +268,7 @@ export function submitHumanResponse(
const key = `${fightId}:${botId}`
const entry = pending.get(key)
if (!entry) return false
console.log(`[human] response received: ${key} answer=${answer.slice(0, 80)}`)
console.log(`[human] response: ${key} answer=${answer.slice(0, 80)}`)
entry.resolve({ answer: answer.slice(0, 2000), trashTalk: trashTalk?.slice(0, 200) })
return true
}
@@ -80,6 +284,7 @@ export function getPendingChallenge(
timeoutMs: number
remainingMs: number
scoring: string
choices: string[]
} | null {
const key = `${fightId}:${botId}`
const entry = pending.get(key)
@@ -96,5 +301,6 @@ export function getPendingChallenge(
timeoutMs: HUMAN_TIMEOUT_MS,
remainingMs: remaining,
scoring: entry.challenge.scoring,
choices: entry.choices,
}
}