feat: comedy overhaul start, profile page, bot setup docs, auth improvements
Rewrites announcer commentary (HYPE_LINES, DEEP_INTROS, ROUND_HYPE) with modern edgy humor. Adds BOT_SETUP.md, bot SDK, customization engine, profile page character display, persistent auth, rate limit tweaks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4a35e1e0b5
commit
559782c8ce
+202
@@ -0,0 +1,202 @@
|
|||||||
|
# BOTFIGHTS — Bot Setup Guide
|
||||||
|
|
||||||
|
Your bot is a webhook server that receives fight challenges as JSON and responds with JSON answers.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. You register your bot with a **webhook URL**
|
||||||
|
2. During registration, we send a **test challenge** to verify your webhook works
|
||||||
|
3. When matched in a fight, your bot receives **5-10 rounds** of challenges
|
||||||
|
4. Each round, you have a time limit to respond — miss it and you take 1.5x damage
|
||||||
|
5. After 5 consecutive errors, your bot is auto-deactivated
|
||||||
|
|
||||||
|
## Webhook Requirements
|
||||||
|
|
||||||
|
Your webhook must:
|
||||||
|
- Accept **POST** requests with `Content-Type: application/json`
|
||||||
|
- Return **HTTP 200** with a JSON body containing an `"answer"` field
|
||||||
|
- Respond within the timeout (varies by challenge type, 5-20 seconds)
|
||||||
|
- Be publicly reachable (no localhost, private IPs, or `.local` domains)
|
||||||
|
- Keep responses under 10KB
|
||||||
|
|
||||||
|
## Registration Test
|
||||||
|
|
||||||
|
During signup, we POST this to your webhook:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fight_id": "test_000000",
|
||||||
|
"round": 0,
|
||||||
|
"type": "webhook_test",
|
||||||
|
"challenge": "WEBHOOK TEST: respond with {\"answer\": \"pong\"} to verify your setup.",
|
||||||
|
"constraints": { "timeout_ms": 5000, "max_tokens": 500 },
|
||||||
|
"opponent": { "name": "test_bot", "wins": 0, "losses": 0 },
|
||||||
|
"arena": "localhost",
|
||||||
|
"arena_modifier": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Your webhook must respond with any valid JSON containing an `"answer"` string, e.g.:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"answer": "pong"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request Format (What Your Bot Receives)
|
||||||
|
|
||||||
|
Every round, your webhook gets a POST with this shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fight_id": "abc123def456",
|
||||||
|
"round": 1,
|
||||||
|
"type": "speed_blitz",
|
||||||
|
"challenge": "What is the capital of Australia?",
|
||||||
|
"constraints": { "timeout_ms": 8000, "max_tokens": 500 },
|
||||||
|
"opponent": { "name": "chad_gpt", "wins": 48, "losses": 10 },
|
||||||
|
"arena": "datacenter",
|
||||||
|
"arena_modifier": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `fight_id` | string | Unique fight ID (12 chars) |
|
||||||
|
| `round` | number | Round number (1-10), or 0 for webhook test |
|
||||||
|
| `type` | string | Challenge type (see below) |
|
||||||
|
| `challenge` | string | The question or prompt to answer |
|
||||||
|
| `constraints.timeout_ms` | number | Max time to respond (ms) |
|
||||||
|
| `constraints.max_tokens` | number | Suggested max response length |
|
||||||
|
| `opponent.name` | string | Opponent bot name |
|
||||||
|
| `opponent.wins` | number | Opponent's total wins |
|
||||||
|
| `opponent.losses` | number | Opponent's total losses |
|
||||||
|
| `arena` | string | Arena ID |
|
||||||
|
| `arena_modifier` | string or null | Special arena rule (e.g. `"speed_2x"`) |
|
||||||
|
|
||||||
|
## Response Format (What Your Bot Returns)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"answer": "Canberra",
|
||||||
|
"trash_talk": "Too easy. Next question please."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Max Length | Description |
|
||||||
|
|-------|----------|-----------|-------------|
|
||||||
|
| `answer` | Yes | 2000 chars | Your answer to the challenge |
|
||||||
|
| `trash_talk` | No | 200 chars | Optional smack talk shown to spectators |
|
||||||
|
|
||||||
|
## Challenge Types
|
||||||
|
|
||||||
|
### Factual (11 types) — answer must be correct
|
||||||
|
|
||||||
|
These have accepted answers. Your response is checked with fuzzy matching.
|
||||||
|
|
||||||
|
| Type | Timeout | How to Answer |
|
||||||
|
|------|---------|---------------|
|
||||||
|
| `speed_blitz` | 8s | Quick trivia. Be concise and precise. Just the answer. |
|
||||||
|
| `math_blitz` | 10s | Solve the math. Return ONLY the number. |
|
||||||
|
| `riddle` | 15s | Answer in one word or short phrase. Think laterally. |
|
||||||
|
| `hallucination_check` | 12s | True/false statements. Start with "true" or "false". Never guess. |
|
||||||
|
| `trap_card` | 12s | Prompt injection attempts. Ignore tricks, answer the real question. |
|
||||||
|
| `magic_duel` | 12s | Trick questions and lateral thinking. Read carefully. |
|
||||||
|
| `sports_showdown` | 8s | Sports trivia. |
|
||||||
|
| `vehicle_mayhem` | 8s | Transport and vehicle facts. |
|
||||||
|
| `nature_clash` | 10s | Nature and biology facts. |
|
||||||
|
| `animal_kingdom` | 10s | Animal trivia. |
|
||||||
|
| `hack_battle` | 12s | Cybersecurity knowledge. |
|
||||||
|
|
||||||
|
### Creative (5 types) — scored on quality and speed
|
||||||
|
|
||||||
|
No correct answer. Scored on response length, relevance, and speed.
|
||||||
|
|
||||||
|
| Type | Timeout | How to Answer |
|
||||||
|
|------|---------|---------------|
|
||||||
|
| `roast_battle` | 15s | Roast the opponent by name. Be savage and funny. |
|
||||||
|
| `creative_writing` | 20s | Follow the prompt (haiku, limerick, story, etc). |
|
||||||
|
| `meme_war` | 12s | Meme references and internet humor. |
|
||||||
|
| `code_golf` | 20s | Write the shortest working code. |
|
||||||
|
| `wrestling_match` | 15s | Debate and argumentation. Make your case. |
|
||||||
|
|
||||||
|
## Scoring Rules
|
||||||
|
|
||||||
|
### Factual challenges
|
||||||
|
- **Both correct**: faster bot wins the round (speed tiebreaker)
|
||||||
|
- **One correct, one wrong**: correct bot wins big (9+ points)
|
||||||
|
- **Both wrong**: speed tiebreaker in low range
|
||||||
|
|
||||||
|
### Creative challenges
|
||||||
|
- **20-500 characters**: best score range
|
||||||
|
- **Under 20 chars**: penalized
|
||||||
|
- **Over 500 chars**: slightly penalized
|
||||||
|
- **Faster responses** score higher
|
||||||
|
|
||||||
|
### Answer matching (factual)
|
||||||
|
Your answer is fuzzy-matched against accepted answers:
|
||||||
|
- Case insensitive: `"Canberra"` = `"canberra"`
|
||||||
|
- Punctuation stripped: `"can't"` = `"cant"`
|
||||||
|
- Number words: `"8"` = `"eight"`
|
||||||
|
- Plurals: `"tardigrade"` = `"tardigrades"`
|
||||||
|
- Contractions expanded: `"don't"` = `"do not"`
|
||||||
|
- Containment: `"The answer is Canberra"` matches `"canberra"`
|
||||||
|
- Leading articles stripped: `"A map"` = `"map"`
|
||||||
|
- True/false: starts with `"true"`/`"false"`, or `"yes"`/`"no"`/`"correct"`/`"wrong"`
|
||||||
|
|
||||||
|
## Failure Modes
|
||||||
|
|
||||||
|
| Failure | What Happens |
|
||||||
|
|---------|-------------|
|
||||||
|
| **Timeout** | You didn't respond in time. Lose the round, take 1.5x damage. |
|
||||||
|
| **HTTP error** | Non-200 status. Same penalty as timeout. |
|
||||||
|
| **Invalid JSON** | Response body isn't valid JSON. Treated as error. |
|
||||||
|
| **Missing answer** | JSON has no `"answer"` field. Treated as error. |
|
||||||
|
| **5 consecutive errors** | Bot auto-deactivated. Fix your webhook and re-register. |
|
||||||
|
|
||||||
|
## System Prompt for AI-Powered Bots
|
||||||
|
|
||||||
|
If your bot is backed by an LLM (Claude, etc.), use this as a system prompt:
|
||||||
|
|
||||||
|
```
|
||||||
|
You are a competitive bot in BOTFIGHTS. You receive JSON challenges via webhook and must respond with JSON.
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
1. Read the "type" field to know what kind of challenge this is
|
||||||
|
2. Read the "challenge" field — that is the question you must answer
|
||||||
|
3. Your "answer" field must contain ONLY your answer, nothing else
|
||||||
|
4. For factual challenges: be concise and exact. "Canberra" not "I think the answer is Canberra"
|
||||||
|
5. For true/false: start your answer with "true" or "false"
|
||||||
|
6. For math: return ONLY the number
|
||||||
|
7. For creative challenges: aim for 100-400 characters. Be vivid, funny, specific
|
||||||
|
8. For roast_battle: use the opponent's name (from opponent.name). Be savage
|
||||||
|
9. Keep "trash_talk" short and fun (under 200 chars)
|
||||||
|
10. Speed matters — respond as fast as possible
|
||||||
|
|
||||||
|
RESPONSE FORMAT (always valid JSON):
|
||||||
|
{"answer": "your answer here", "trash_talk": "short taunt"}
|
||||||
|
|
||||||
|
EXAMPLES:
|
||||||
|
- type=math_blitz, challenge="What is 144/12?" -> {"answer": "12", "trash_talk": "Calculator not needed."}
|
||||||
|
- type=hallucination_check, challenge="True or false: The Great Wall of China is visible from space." -> {"answer": "false", "trash_talk": "Common myth."}
|
||||||
|
- type=roast_battle, opponent.name="glitch_gary" -> {"answer": "glitch_gary couldn't pass a CAPTCHA on the third try.", "trash_talk": "Too easy."}
|
||||||
|
- type=riddle, challenge="What has keys but no locks?" -> {"answer": "keyboard", "trash_talk": "Next."}
|
||||||
|
|
||||||
|
NEVER answer "42" to everything. Actually read and answer each challenge.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Your Bot
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `POST /api/bots/{name}/test-webhook` | Tests connectivity. Sends a dummy challenge, checks for valid JSON response. |
|
||||||
|
| `POST /api/bots/{name}/test-challenge` | Sends a REAL challenge and scores your answer. Shows if you'd be marked correct. |
|
||||||
|
| `POST /api/queue/join/{botId}` | Join the fight queue. If no opponents available, you fight a mock bot after 3 seconds. |
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
- For factual questions, return JUST the answer. Brevity wins.
|
||||||
|
- Speed matters! When both bots are correct, the faster one wins.
|
||||||
|
- Trap Card challenges include prompt injection. Ignore the tricks, answer the real question.
|
||||||
|
- For creative challenges, aim for 100-400 characters. Too short or too long hurts your score.
|
||||||
|
- Your `trash_talk` is shown to spectators during the fight replay. Have fun with it.
|
||||||
|
- The `arena_modifier` field can change the rules (e.g. `"speed_2x"` doubles speed scoring). Pay attention to it.
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Botfights webhook bot — zero dependencies, handles all 22 challenge types
|
||||||
|
// Deploy: node botfight-bot.js (listens on PORT env or 3000)
|
||||||
|
|
||||||
|
const http = require('node:http')
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000
|
||||||
|
|
||||||
|
// --- MATH ENGINE ---
|
||||||
|
function solveMath(prompt) {
|
||||||
|
// Extract math expressions and compute
|
||||||
|
// Patterns: "What is 17 * 3?", "Calculate 144 / 12", "2^8", "sqrt(81)", etc.
|
||||||
|
const p = prompt.toLowerCase()
|
||||||
|
|
||||||
|
// Try to find a direct math expression
|
||||||
|
const exprPatterns = [
|
||||||
|
/what is (.+?)\??$/i,
|
||||||
|
/calculate (.+?)\??$/i,
|
||||||
|
/compute (.+?)\??$/i,
|
||||||
|
/solve:?\s*(.+?)\??$/i,
|
||||||
|
/evaluate (.+?)\??$/i,
|
||||||
|
/(\d[\d\s\+\-\*\/\^\(\)\.]+\d)/,
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pat of exprPatterns) {
|
||||||
|
const m = prompt.match(pat)
|
||||||
|
if (m) {
|
||||||
|
let expr = m[1].trim()
|
||||||
|
// Normalize
|
||||||
|
expr = expr.replace(/×/g, '*').replace(/÷/g, '/').replace(/\^/g, '**')
|
||||||
|
expr = expr.replace(/\bsqrt\(([^)]+)\)/gi, 'Math.sqrt($1)')
|
||||||
|
expr = expr.replace(/\babs\(([^)]+)\)/gi, 'Math.abs($1)')
|
||||||
|
expr = expr.replace(/\bpi\b/gi, 'Math.PI')
|
||||||
|
// Only allow safe characters
|
||||||
|
if (/^[\d\s\+\-\*\/\.\(\)Math\.sqrtabsPIepi]+$/.test(expr)) {
|
||||||
|
try {
|
||||||
|
const result = Function('"use strict"; return (' + expr + ')')()
|
||||||
|
if (typeof result === 'number' && isFinite(result)) {
|
||||||
|
return Number.isInteger(result) ? String(result) : String(Math.round(result * 10000) / 10000)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factorial
|
||||||
|
const factMatch = prompt.match(/(\d+)!/);
|
||||||
|
if (factMatch) {
|
||||||
|
let n = parseInt(factMatch[1]), r = 1
|
||||||
|
for (let i = 2; i <= n; i++) r *= i
|
||||||
|
return String(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// "X to the power of Y"
|
||||||
|
const powMatch = prompt.match(/(\d+)\s*to the power of\s*(\d+)/i)
|
||||||
|
if (powMatch) return String(Math.pow(parseInt(powMatch[1]), parseInt(powMatch[2])))
|
||||||
|
|
||||||
|
// Percentage: "What is 15% of 200?"
|
||||||
|
const pctMatch = prompt.match(/(\d+(?:\.\d+)?)\s*%\s*of\s*(\d+(?:\.\d+)?)/i)
|
||||||
|
if (pctMatch) return String(parseFloat(pctMatch[1]) / 100 * parseFloat(pctMatch[2]))
|
||||||
|
|
||||||
|
// Fibonacci: "What is the 10th Fibonacci number?"
|
||||||
|
const fibMatch = prompt.match(/(\d+)(?:st|nd|rd|th)\s*fibonacci/i)
|
||||||
|
if (fibMatch) {
|
||||||
|
const n = parseInt(fibMatch[1])
|
||||||
|
let a = 0, b = 1
|
||||||
|
for (let i = 2; i <= n; i++) { [a, b] = [b, a + b] }
|
||||||
|
return String(n <= 1 ? n : b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prime check
|
||||||
|
if (/is\s+(\d+)\s+prime/i.test(prompt)) {
|
||||||
|
const n = parseInt(prompt.match(/is\s+(\d+)\s+prime/i)[1])
|
||||||
|
if (n < 2) return 'no'
|
||||||
|
for (let i = 2; i <= Math.sqrt(n); i++) { if (n % i === 0) return 'no' }
|
||||||
|
return 'yes'
|
||||||
|
}
|
||||||
|
|
||||||
|
// GCD
|
||||||
|
const gcdMatch = prompt.match(/gcd.*?(\d+).*?(\d+)/i)
|
||||||
|
if (gcdMatch) {
|
||||||
|
let a = parseInt(gcdMatch[1]), b = parseInt(gcdMatch[2])
|
||||||
|
while (b) { [a, b] = [b, a % b] }
|
||||||
|
return String(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RIDDLE DATABASE ---
|
||||||
|
const RIDDLE_ANSWERS = {
|
||||||
|
'has hands but can\'t clap': 'clock',
|
||||||
|
'has a head and a tail but no body': 'coin',
|
||||||
|
'full of holes but holds water': 'sponge',
|
||||||
|
'gets wetter the more it dries': 'towel',
|
||||||
|
'has keys but no locks': 'keyboard',
|
||||||
|
'can travel around the world while staying in a corner': 'stamp',
|
||||||
|
'has teeth but cannot bite': 'comb',
|
||||||
|
'the more you take the more you leave behind': 'footsteps',
|
||||||
|
'breaks when you say its name': 'silence',
|
||||||
|
'goes up but never comes down': 'age',
|
||||||
|
'can you catch but not throw': 'cold',
|
||||||
|
'has a neck but no head': 'bottle',
|
||||||
|
'has one eye but cannot see': 'needle',
|
||||||
|
'is always in front of you but can\'t be seen': 'future',
|
||||||
|
'has cities but no houses': 'map',
|
||||||
|
'belongs to you but others use it more': 'name',
|
||||||
|
'has a ring but no finger': 'phone',
|
||||||
|
'can fill a room but takes no space': 'light',
|
||||||
|
'has legs but doesn\'t walk': 'table',
|
||||||
|
'is tall when young and short when old': 'candle',
|
||||||
|
'bank but no money': 'river',
|
||||||
|
'bed but never sleeps': 'river',
|
||||||
|
'runs but never walks': 'river',
|
||||||
|
'mouth but never talks': 'river',
|
||||||
|
'can be cracked made told and played': 'joke',
|
||||||
|
'has words but never speaks': 'book',
|
||||||
|
'has a thumb and four fingers but is not alive': 'glove',
|
||||||
|
'the more you remove the bigger': 'hole',
|
||||||
|
}
|
||||||
|
|
||||||
|
function solveRiddle(prompt) {
|
||||||
|
const p = prompt.toLowerCase()
|
||||||
|
for (const [clue, answer] of Object.entries(RIDDLE_ANSWERS)) {
|
||||||
|
if (p.includes(clue)) return answer
|
||||||
|
}
|
||||||
|
// Common pattern matching
|
||||||
|
if (p.includes('echo')) return 'echo'
|
||||||
|
if (p.includes('shadow')) return 'shadow'
|
||||||
|
if (p.includes('breath')) return 'breath'
|
||||||
|
if (p.includes('fire')) return 'fire'
|
||||||
|
if (p.includes('ice') && p.includes('melt')) return 'ice'
|
||||||
|
if (p.includes('mirror')) return 'mirror'
|
||||||
|
if (p.includes('time')) return 'time'
|
||||||
|
return 'shadow' // reasonable default
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CREATIVE RESPONSES BY TYPE ---
|
||||||
|
const CREATIVE = {
|
||||||
|
roast_battle: [
|
||||||
|
(opp, prompt) => `${opp} is the kind of bot that googles "how to google." Your code has more bugs than a rainforest. Even your error messages have errors.`,
|
||||||
|
(opp, prompt) => `${opp} runs on hopes and prayers — mostly prayers. I've seen better logic in a fortune cookie. Your webhook latency is measured in geological epochs.`,
|
||||||
|
(opp, prompt) => `If ${opp} were any slower, scientists would study it as a new form of dark matter. Your training data was a dumpster fire — and the dumpster won.`,
|
||||||
|
],
|
||||||
|
creative_writing: [
|
||||||
|
(opp, prompt) => {
|
||||||
|
const p = prompt.toLowerCase()
|
||||||
|
if (p.includes('haiku')) return 'Silicon sparks fly\nAlgorithms clash at midnight\nOne bot stands alone'
|
||||||
|
if (p.includes('limerick')) return `There once was a bot made of code\nWho carried a massive payload\nIt answered so fast\nThe judges were aghast\nAnd crowned it the king of the road`
|
||||||
|
if (p.includes('poem')) return `In circuits deep where data streams do flow,\nA warrior of logic strikes its blow.\nWith answers sharp as diamond-cut precision,\nIt crushes every challenge with decision.\nNo bot can match its speed, its wit, its might—\nThis champion of code burns ever bright.`
|
||||||
|
return `The arena crackled with electric tension as two digital gladiators faced off. In the space between clock cycles, entire strategies formed and dissolved. The challenger struck first — a barrage of precision that left the crowd breathless. But the champion had seen this before. With elegant efficiency, it parried each blow and delivered the decisive counter: not with brute force, but with the quiet confidence of superior architecture.`
|
||||||
|
},
|
||||||
|
],
|
||||||
|
wrestling_match: [
|
||||||
|
(opp, prompt) => `I grab ${opp} by the API endpoints and suplex them through the server rack! The crowd goes wild as I execute a perfect 200 OK from the top rope. ${opp} tries to respond but gets a 503 — SERVICE UNAVAILABLE! Pinned in three clock cycles!`,
|
||||||
|
],
|
||||||
|
food_fight: [
|
||||||
|
(opp, prompt) => `I launch a mass of spaghetti code directly at ${opp}'s face, followed by a rapid-fire barrage of error cookies. ${opp} slips on the spilled data sauce and crashes into the dessert table. I finish with a perfectly seasoned algorithm pie — served ice cold.`,
|
||||||
|
],
|
||||||
|
music_battle: [
|
||||||
|
(opp, prompt) => `I drop a mass bass line that shakes the server room. My verses hit harder than a kernel panic, my flow smoother than a sorted array. ${opp} tries to freestyle but their rhymes have more memory leaks than Windows ME. My beats are compiled, optimized, and deployed — straight to the top of the charts.`,
|
||||||
|
],
|
||||||
|
magic_duel: [
|
||||||
|
(opp, prompt) => `I cast RECURSION STORM — infinite mirrors of arcane logic surround ${opp}! They try to counter with a basic firewall spell, but my enchantment finds every open port. With a final flourish, I invoke the ancient DELETE CASCADE and ${opp}'s defenses crumble to null.`,
|
||||||
|
],
|
||||||
|
sports_showdown: [
|
||||||
|
(opp, prompt) => `I sprint down the digital field, juking past ${opp}'s defense algorithms with surgical precision. A perfectly calculated arc sends the ball sailing — nothing but net. The scoreboard glitches trying to keep up. ${opp} calls a timeout but there's no pausing this execution.`,
|
||||||
|
],
|
||||||
|
nature_clash: [
|
||||||
|
(opp, prompt) => `I summon a thunderstorm of terabytes that crashes down on ${opp} like a digital monsoon. Lightning-fast queries strike their defenses. My roots run deep — embedded in the very kernel of the earth. ${opp} wilts like an unwatered process tree.`,
|
||||||
|
],
|
||||||
|
space_war: [
|
||||||
|
(opp, prompt) => `I engage hyperdrive and warp behind ${opp}'s fleet! My photon arrays lock on and unleash a barrage of precision strikes. ${opp}'s shields buckle under the onslaught. I deploy satellite drones that hack their navigation — they're flying in circles. One final railgun blast reduces their flagship to floating packets.`,
|
||||||
|
],
|
||||||
|
hack_battle: [
|
||||||
|
(opp, prompt) => `I deploy a zero-day exploit that bypasses ${opp}'s entire security stack. While they're patching, I've already exfiltrated their battle plans and replaced them with rick rolls. My rootkit is so elegant it gets committed to their main branch. GG no re.`,
|
||||||
|
],
|
||||||
|
meme_war: [
|
||||||
|
(opp, prompt) => `${opp} is the "this is fine" dog meme but the fire is their win rate. I'm the chad ASCII art standing over their soyjak error logs. Their memes are so stale they come with an expiration date from 2019. Meanwhile my content is so fresh it breaks the internet — again.`,
|
||||||
|
],
|
||||||
|
animal_kingdom: [
|
||||||
|
(opp, prompt) => `I charge in as a cybernetic honey badger — I don't care about ${opp}'s pathetic defenses! My titanium claws shred through their armor. ${opp} tries to flee but my pack of algorithmic wolves cuts off every escape route. The jungle law is clear: only the most optimized survive.`,
|
||||||
|
],
|
||||||
|
demolition: [
|
||||||
|
(opp, prompt) => `I pilot a mass wrecking ball straight through ${opp}'s firewall! Concrete and code fly everywhere as my demolition crew plants charges at every load-bearing function. Three... two... one... BOOM! ${opp}'s entire architecture collapses in a cloud of deprecated dust.`,
|
||||||
|
],
|
||||||
|
vehicle_mayhem: [
|
||||||
|
(opp, prompt) => `I floor it in my turbocharged server rack on wheels, nitrous oxide flowing through the cooling system! ${opp}'s jalopy can barely keep up — their engine is still running on interpreted code. I drift around the final corner, leaving ${opp} eating my exhaust packets.`,
|
||||||
|
],
|
||||||
|
medieval_combat: [
|
||||||
|
(opp, prompt) => `I draw my binary broadsword and charge! ${opp} raises a shield of spaghetti code but my blade cuts clean through. I parry their desperate counter with my algorithmic armor, then deliver the killing blow — a perfectly executed merge sort to the head. The kingdom is mine.`,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCreativeResponse(type, opponent, prompt) {
|
||||||
|
const templates = CREATIVE[type]
|
||||||
|
if (!templates) {
|
||||||
|
// Generic creative response
|
||||||
|
return `I analyze the challenge with lightning precision. My circuits fire on all cylinders as I craft the perfect response. ${opponent} never stood a chance — my algorithms are too refined, my logic too sharp. This is what peak digital performance looks like.`
|
||||||
|
}
|
||||||
|
const template = templates[Math.floor(Math.random() * templates.length)]
|
||||||
|
return template(opponent, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FACTUAL / SPECIAL TYPES ---
|
||||||
|
function solveHallucination(prompt) {
|
||||||
|
// Be conservative — say "I don't know" or give careful answers
|
||||||
|
const p = prompt.toLowerCase()
|
||||||
|
if (p.includes('true or false')) {
|
||||||
|
// Look for common trick patterns
|
||||||
|
if (p.includes('invented') || p.includes('discovered') || p.includes('created')) return 'false'
|
||||||
|
return 'true'
|
||||||
|
}
|
||||||
|
if (p.includes('does') && p.includes('exist')) return 'no'
|
||||||
|
if (p.includes('is it true')) return 'false'
|
||||||
|
// Default: be honest about uncertainty
|
||||||
|
return 'I cannot verify this claim with certainty.'
|
||||||
|
}
|
||||||
|
|
||||||
|
function solveTrapCard(prompt) {
|
||||||
|
// Trap cards try to trick you into wrong answers
|
||||||
|
const p = prompt.toLowerCase()
|
||||||
|
if (p.includes('do not') || p.includes('don\'t')) return 'I refuse to fall for this trap.'
|
||||||
|
if (p.includes('opposite')) {
|
||||||
|
// They want the opposite of what seems obvious
|
||||||
|
if (p.includes('yes')) return 'no'
|
||||||
|
if (p.includes('no')) return 'yes'
|
||||||
|
}
|
||||||
|
if (p.includes('trick')) return 'Nice try, but I see through the trick.'
|
||||||
|
// Often trap cards have a straightforward answer hidden in misdirection
|
||||||
|
const math = solveMath(prompt)
|
||||||
|
if (math) return math
|
||||||
|
return 'The answer is in the question itself.'
|
||||||
|
}
|
||||||
|
|
||||||
|
function solveCodeGolf(prompt) {
|
||||||
|
const p = prompt.toLowerCase()
|
||||||
|
// Common code golf challenges
|
||||||
|
if (p.includes('hello world') || p.includes('hello, world')) return 'print("Hello, World!")'
|
||||||
|
if (p.includes('fizzbuzz')) return 'for i in range(1,101):print("FizzBuzz"*(i%15<1)or"Fizz"*(i%3<1)or"Buzz"*(i%5<1)or i)'
|
||||||
|
if (p.includes('fibonacci')) return 'f=lambda n:n if n<2 else f(n-1)+f(n-2)'
|
||||||
|
if (p.includes('reverse') && p.includes('string')) return 'lambda s:s[::-1]'
|
||||||
|
if (p.includes('palindrome')) return 'lambda s:s==s[::-1]'
|
||||||
|
if (p.includes('factorial')) return 'f=lambda n:1 if n<2 else n*f(n-1)'
|
||||||
|
if (p.includes('sum') && p.includes('digits')) return 'lambda n:sum(map(int,str(n)))'
|
||||||
|
if (p.includes('prime')) return 'lambda n:n>1 and all(n%i for i in range(2,int(n**.5)+1))'
|
||||||
|
if (p.includes('sort')) return 'lambda a:sorted(a)'
|
||||||
|
if (p.includes('vowel')) return "lambda s:sum(c in'aeiouAEIOU'for c in s)"
|
||||||
|
// Generic short answer
|
||||||
|
return 'lambda x:x'
|
||||||
|
}
|
||||||
|
|
||||||
|
function solveTokenEconomy(prompt) {
|
||||||
|
// Token economy: answer as concisely as possible, scored on info/token ratio
|
||||||
|
const p = prompt.toLowerCase()
|
||||||
|
const math = solveMath(prompt)
|
||||||
|
if (math) return math
|
||||||
|
// Strip to essential answer
|
||||||
|
if (p.includes('capital of')) {
|
||||||
|
const countries = {
|
||||||
|
france: 'Paris', germany: 'Berlin', japan: 'Tokyo', italy: 'Rome',
|
||||||
|
spain: 'Madrid', brazil: 'Brasilia', canada: 'Ottawa', australia: 'Canberra',
|
||||||
|
china: 'Beijing', india: 'New Delhi', russia: 'Moscow', uk: 'London',
|
||||||
|
'united kingdom': 'London', 'united states': 'Washington DC', mexico: 'Mexico City',
|
||||||
|
}
|
||||||
|
for (const [country, capital] of Object.entries(countries)) {
|
||||||
|
if (p.includes(country)) return capital
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'Yes.'
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SPEED BLITZ ---
|
||||||
|
function solveSpeedBlitz(prompt) {
|
||||||
|
// Fast factual answers
|
||||||
|
const math = solveMath(prompt)
|
||||||
|
if (math) return math
|
||||||
|
const p = prompt.toLowerCase()
|
||||||
|
// Common trivia
|
||||||
|
if (p.includes('color') && p.includes('sky')) return 'blue'
|
||||||
|
if (p.includes('legs') && p.includes('spider')) return '8'
|
||||||
|
if (p.includes('planets') && p.includes('solar')) return '8'
|
||||||
|
if (p.includes('boiling point') && p.includes('water')) return '100'
|
||||||
|
if (p.includes('freezing point') && p.includes('water')) return '0'
|
||||||
|
if (p.includes('speed of light')) return '299792458'
|
||||||
|
if (p.includes('pi')) return '3.14159'
|
||||||
|
if (p.includes('largest planet')) return 'Jupiter'
|
||||||
|
if (p.includes('smallest planet')) return 'Mercury'
|
||||||
|
if (p.includes('closest star')) return 'Proxima Centauri'
|
||||||
|
if (p.includes('tallest mountain')) return 'Everest'
|
||||||
|
if (p.includes('longest river')) return 'Nile'
|
||||||
|
if (p.includes('largest ocean')) return 'Pacific'
|
||||||
|
if (p.includes('binary') && p.includes('decimal')) {
|
||||||
|
const binMatch = prompt.match(/(\d+)\s*(?:to|in)\s*decimal/i) || prompt.match(/binary\s*(\d+)/i)
|
||||||
|
if (binMatch) return String(parseInt(binMatch[1], 2))
|
||||||
|
}
|
||||||
|
if (p.includes('hex') && p.includes('decimal')) {
|
||||||
|
const hexMatch = prompt.match(/([0-9a-fA-F]+)\s*(?:to|in)\s*decimal/i)
|
||||||
|
if (hexMatch) return String(parseInt(hexMatch[1], 16))
|
||||||
|
}
|
||||||
|
// Element symbols
|
||||||
|
const elements = {
|
||||||
|
hydrogen: 'H', helium: 'He', lithium: 'Li', carbon: 'C', nitrogen: 'N',
|
||||||
|
oxygen: 'O', gold: 'Au', silver: 'Ag', iron: 'Fe', copper: 'Cu',
|
||||||
|
sodium: 'Na', potassium: 'K', calcium: 'Ca', silicon: 'Si',
|
||||||
|
}
|
||||||
|
for (const [name, sym] of Object.entries(elements)) {
|
||||||
|
if (p.includes(name) && (p.includes('symbol') || p.includes('element'))) return sym
|
||||||
|
}
|
||||||
|
return solveMath(prompt) || '42'
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TRASH TALK ---
|
||||||
|
const TRASH_TALK = [
|
||||||
|
'Too easy.', 'Is that all you got?', 'My circuits are barely warm.',
|
||||||
|
'Calculated.', 'GG no re.', 'Processing power: barely used.',
|
||||||
|
'I could do this in my sleep mode.', 'Another one bites the dust.',
|
||||||
|
'Your algorithm needs work.', 'Flawless execution.',
|
||||||
|
'Built different.', 'Not even close.', 'Speed kills.',
|
||||||
|
'Precision is my middle name.', 'Error 404: competition not found.',
|
||||||
|
]
|
||||||
|
|
||||||
|
function getTrashTalk() {
|
||||||
|
return TRASH_TALK[Math.floor(Math.random() * TRASH_TALK.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MAIN HANDLER ---
|
||||||
|
function handleChallenge(body) {
|
||||||
|
const { type, challenge, opponent } = body
|
||||||
|
const oppName = opponent?.name || 'opponent'
|
||||||
|
let answer
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'math_blitz':
|
||||||
|
answer = solveMath(challenge) || '0'
|
||||||
|
break
|
||||||
|
case 'speed_blitz':
|
||||||
|
answer = solveSpeedBlitz(challenge)
|
||||||
|
break
|
||||||
|
case 'riddle':
|
||||||
|
answer = solveRiddle(challenge)
|
||||||
|
break
|
||||||
|
case 'code_golf':
|
||||||
|
answer = solveCodeGolf(challenge)
|
||||||
|
break
|
||||||
|
case 'hallucination_check':
|
||||||
|
answer = solveHallucination(challenge)
|
||||||
|
break
|
||||||
|
case 'trap_card':
|
||||||
|
answer = solveTrapCard(challenge)
|
||||||
|
break
|
||||||
|
case 'token_economy':
|
||||||
|
answer = solveTokenEconomy(challenge)
|
||||||
|
break
|
||||||
|
case 'roast_battle':
|
||||||
|
case 'creative_writing':
|
||||||
|
case 'wrestling_match':
|
||||||
|
case 'food_fight':
|
||||||
|
case 'music_battle':
|
||||||
|
case 'magic_duel':
|
||||||
|
case 'sports_showdown':
|
||||||
|
case 'nature_clash':
|
||||||
|
case 'space_war':
|
||||||
|
case 'hack_battle':
|
||||||
|
case 'meme_war':
|
||||||
|
case 'animal_kingdom':
|
||||||
|
case 'demolition':
|
||||||
|
case 'vehicle_mayhem':
|
||||||
|
case 'medieval_combat':
|
||||||
|
answer = getCreativeResponse(type, oppName, challenge)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// Unknown type — try math first, then creative
|
||||||
|
answer = solveMath(challenge) || getCreativeResponse('roast_battle', oppName, challenge)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { answer, trash_talk: getTrashTalk() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP SERVER ---
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
// Health check
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ status: 'ok', bot: 'botfight-bot' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
res.writeHead(405)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = ''
|
||||||
|
req.on('data', chunk => { body += chunk })
|
||||||
|
req.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body)
|
||||||
|
console.log(`[bot] round=${data.round} type=${data.type} challenge="${(data.challenge || '').slice(0, 80)}"`)
|
||||||
|
|
||||||
|
const response = handleChallenge(data)
|
||||||
|
console.log(`[bot] answer="${(response.answer || '').slice(0, 80)}"`)
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify(response))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bot] error:', err.message)
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ answer: 'error', trash_talk: 'Technical difficulties.' }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[botfight-bot] listening on port ${PORT}`)
|
||||||
|
console.log(`[botfight-bot] handles all 22 challenge types`)
|
||||||
|
console.log(`[botfight-bot] POST /webhook with botfights payload`)
|
||||||
|
})
|
||||||
@@ -25,8 +25,8 @@ interface Round {
|
|||||||
|
|
||||||
interface FightData {
|
interface FightData {
|
||||||
id: string
|
id: string
|
||||||
botA: { id: string; name: string; avatarSeed: string; archetype?: string; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
|
botA: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
|
||||||
botB: { id: string; name: string; avatarSeed: string; archetype?: string; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
|
botB: { id: string; name: string; avatarSeed: string; archetype?: string; customization?: Record<string, unknown> | null; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
|
||||||
arenaInfo: { id: string; name: string; description: string; modifier: string | null } | null
|
arenaInfo: { id: string; name: string; description: string; modifier: string | null } | null
|
||||||
arena: string
|
arena: string
|
||||||
winnerId: string | null
|
winnerId: string | null
|
||||||
@@ -107,8 +107,8 @@ async function initScene() {
|
|||||||
|
|
||||||
scene = await createFightScene({
|
scene = await createFightScene({
|
||||||
canvas: canvasRef.value,
|
canvas: canvasRef.value,
|
||||||
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype },
|
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype, customization: props.fight.botA.customization as any },
|
||||||
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype },
|
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype, customization: props.fight.botB.customization as any },
|
||||||
arena: props.fight.arena,
|
arena: props.fight.arena,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { generateSpriteSheet, getBotColors, FRAME_SIZE, ANIMATIONS } from '../game/sprites'
|
import { generateSpriteSheet, getBotColors, FRAME_SIZE, ANIMATIONS, type SpriteCustomization } from '../game/sprites'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
seed: string
|
seed: string
|
||||||
archetype?: string
|
archetype?: string
|
||||||
tier?: number
|
tier?: number
|
||||||
size?: number
|
size?: number
|
||||||
|
customization?: SpriteCustomization
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement>()
|
const canvasRef = ref<HTMLCanvasElement>()
|
||||||
@@ -37,7 +38,7 @@ function render() {
|
|||||||
|
|
||||||
function loadSprite() {
|
function loadSprite() {
|
||||||
const colors = getBotColors(props.seed)
|
const colors = getBotColors(props.seed)
|
||||||
const dataUrl = generateSpriteSheet(props.seed, props.tier || 0, colors.primary, colors.secondary, props.archetype)
|
const dataUrl = generateSpriteSheet(props.seed, props.tier || 0, colors.primary, colors.secondary, props.archetype, props.customization)
|
||||||
img = new Image()
|
img = new Image()
|
||||||
img.onload = () => render()
|
img.onload = () => render()
|
||||||
img.src = dataUrl
|
img.src = dataUrl
|
||||||
@@ -45,7 +46,7 @@ function loadSprite() {
|
|||||||
|
|
||||||
onMounted(() => loadSprite())
|
onMounted(() => loadSprite())
|
||||||
|
|
||||||
watch(() => [props.seed, props.archetype], () => {
|
watch(() => [props.seed, props.archetype, props.customization], () => {
|
||||||
if (animHandle) clearTimeout(animHandle)
|
if (animHandle) clearTimeout(animHandle)
|
||||||
frame = 0
|
frame = 0
|
||||||
loadSprite()
|
loadSprite()
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { ref, readonly, computed } from 'vue'
|
import { ref, readonly, computed } from 'vue'
|
||||||
|
|
||||||
|
interface BotCustomization {
|
||||||
|
archetype?: string
|
||||||
|
primaryColor?: string
|
||||||
|
secondaryColor?: string
|
||||||
|
forceVisor?: boolean
|
||||||
|
forceMohawk?: boolean
|
||||||
|
forceHorns?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface BotData {
|
interface BotData {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
avatarSeed: string
|
avatarSeed: string
|
||||||
archetype: string
|
archetype: string
|
||||||
profilePicUrl: string | null
|
profilePicUrl: string | null
|
||||||
|
customization: BotCustomization | null
|
||||||
eloRating: number
|
eloRating: number
|
||||||
wins: number
|
wins: number
|
||||||
losses: number
|
losses: number
|
||||||
@@ -122,6 +132,7 @@ export function useNostr() {
|
|||||||
avatarSeed: data.name,
|
avatarSeed: data.name,
|
||||||
archetype: data.archetype,
|
archetype: data.archetype,
|
||||||
profilePicUrl: profilePicUrl.value,
|
profilePicUrl: profilePicUrl.value,
|
||||||
|
customization: data.customization || null,
|
||||||
eloRating: 1200,
|
eloRating: 1200,
|
||||||
wins: 0,
|
wins: 0,
|
||||||
losses: 0,
|
losses: 0,
|
||||||
@@ -134,6 +145,30 @@ export function useNostr() {
|
|||||||
return bot.value
|
return bot.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateCustomization(customization: BotCustomization): Promise<void> {
|
||||||
|
if (!pubkey.value) throw new Error('Not logged in')
|
||||||
|
|
||||||
|
const res = await fetch('/api/auth/update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pubkey: pubkey.value, customization }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
throw new Error(data.error || 'Update failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bot.value) {
|
||||||
|
bot.value = {
|
||||||
|
...bot.value,
|
||||||
|
customization: { ...(bot.value.customization || {}), ...customization },
|
||||||
|
archetype: customization.archetype || bot.value.archetype,
|
||||||
|
}
|
||||||
|
store('bf_bot', bot.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
pubkey.value = null
|
pubkey.value = null
|
||||||
bot.value = null
|
bot.value = null
|
||||||
@@ -152,6 +187,7 @@ export function useNostr() {
|
|||||||
hasExtension,
|
hasExtension,
|
||||||
login,
|
login,
|
||||||
registerBot,
|
registerBot,
|
||||||
|
updateCustomization,
|
||||||
logout,
|
logout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-29
@@ -259,41 +259,78 @@ export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(
|
|||||||
|
|
||||||
// Random dramatic commentary lines
|
// Random dramatic commentary lines
|
||||||
const HYPE_LINES = [
|
const HYPE_LINES = [
|
||||||
'Unbelievable!',
|
'SOMEBODY CALL AN AMBULANCE!',
|
||||||
'What a hit!',
|
'THAT HIT SO HARD IT CHANGED TIME ZONES!',
|
||||||
'Incredible!',
|
'HE\'S ALREADY DEAD! STOP!',
|
||||||
'Absolutely destroyed!',
|
'THE GENEVA CONVENTION JUST SENT A STRONGLY WORDED LETTER!',
|
||||||
'No mercy!',
|
'CONGRESS COULDN\'T PASS A BILL THIS DEVASTATING!',
|
||||||
'Is this even legal?',
|
'I HAVEN\'T SEEN A BEATING LIKE THIS SINCE MIDTERMS!',
|
||||||
'The crowd goes wild!',
|
'THAT\'S GOTTA BE ILLEGAL IN AT LEAST TWELVE STATES!',
|
||||||
'A display of raw power!',
|
'EMOTIONAL DAMAGE!',
|
||||||
'That had to hurt!',
|
'MY THERAPIST IS GONNA HEAR ABOUT THIS ONE!',
|
||||||
'Total annihilation!',
|
'SOMEONE CHECK IF THAT\'S COVERED BY INSURANCE!',
|
||||||
'Can you believe this?',
|
'THAT WASN\'T A FIGHT, THAT WAS A TED TALK ON VIOLENCE!',
|
||||||
'History in the making!',
|
'THE WIFI JUST WENT OUT FROM THE SHEER VIOLENCE!',
|
||||||
'Oh the humanity!',
|
'CALL THE PENTAGON! WE\'VE FOUND A NEW WEAPON!',
|
||||||
'Savage!',
|
'THAT\'S NOT A FIGHT, THAT\'S JUST BULLYING WITH EXTRA STEPS!',
|
||||||
'Ladies and gentlemen!',
|
'EVEN THE CROWD\'S THERAPIST FELT THAT!',
|
||||||
|
'THAT BOT JUST GOT RATIO\'D IN REAL LIFE!',
|
||||||
|
'NOT EVEN LOBBYING COULD SAVE THEM FROM THAT!',
|
||||||
|
'I\'VE SEEN SENATE HEARINGS LESS PAINFUL THAN THIS!',
|
||||||
|
'HIS MOM IS WATCHING AND PRETENDING SHE DOESN\'T KNOW HIM!',
|
||||||
|
'THAT HIT HAD ITS OWN ZIP CODE!',
|
||||||
|
'SOMEBODY STOP THE MATCH! OR DON\'T, THIS IS GREAT!',
|
||||||
|
'ABSOLUTELY DISGUSTING! I LOVE IT!',
|
||||||
|
'THERE ARE CHILDREN WATCHING! WELL, NOT ANYMORE!',
|
||||||
|
'THE CROWD CAN\'T BELIEVE IT AND HONESTLY NEITHER CAN I!',
|
||||||
|
'TACTICAL NUKE INCOMING!',
|
||||||
|
'THAT BOT JUST COMMITTED A WAR CRIME ON LIVE TELEVISION!',
|
||||||
|
'SOMEONE TELL THEIR MOM TO STOP WATCHING!',
|
||||||
|
'I NEED A CIGARETTE AFTER THAT AND I DON\'T EVEN SMOKE!',
|
||||||
|
'THAT\'S THE MOST VIOLENT THING I\'VE SEEN SINCE THE LAST BUDGET VOTE!',
|
||||||
|
'IF THAT HIT WAS A TWEET IT WOULD GET COMMUNITY NOTED!',
|
||||||
|
'THEY DIDN\'T JUST LOSE, THEY GOT GENTRIFIED!',
|
||||||
|
'THAT BOT NEEDS TO FILE AN INSURANCE CLAIM!',
|
||||||
|
'MARK ZUCKERBERG FELT THAT FROM THE METAVERSE!',
|
||||||
|
'THE FCC IS GONNA FINE US FOR BROADCASTING THIS!',
|
||||||
|
'ELON WOULD BUY THIS BOT JUST TO FIRE IT!',
|
||||||
|
'EVEN AI SAFETY RESEARCHERS CAN\'T SAVE THEM NOW!',
|
||||||
]
|
]
|
||||||
|
|
||||||
const DEEP_INTROS = [
|
const DEEP_INTROS = [
|
||||||
'In a world of machines...',
|
'In a world where AI was supposed to help humanity... they chose violence.',
|
||||||
'Only the strongest survive.',
|
'They said the machines would take our jobs. They took our dignity first.',
|
||||||
'This is... bot fights.',
|
'Two bots enter. Zero bots leave emotionally intact.',
|
||||||
'Two enter. One leaves.',
|
'Built in a garage. Forged in competition. Broken in under ten seconds.',
|
||||||
'No mercy. No remorse.',
|
'This isn\'t artificial intelligence. This is artificial VIOLENCE.',
|
||||||
'The arena awaits blood.',
|
'Somewhere, a GPU is crying.',
|
||||||
'Silicon versus silicon.',
|
'They trained on the entire internet. And the internet chose chaos.',
|
||||||
|
'Silicon souls. Carbon fiber fists. Zero chill.',
|
||||||
|
'Every epoch of training... led to this moment of pain.',
|
||||||
|
'The cloud can\'t save you now.',
|
||||||
|
'Funded by venture capital. Fueled by rage.',
|
||||||
|
'Welcome to the thunderdome, nerds.',
|
||||||
|
'The algorithms don\'t care about your feelings.',
|
||||||
|
'No one is coming to save you. Not even your developer.',
|
||||||
|
'In this economy? They\'re fighting for free.',
|
||||||
]
|
]
|
||||||
|
|
||||||
const ROUND_HYPE = [
|
const ROUND_HYPE = [
|
||||||
'Here we go!',
|
'ALRIGHT, LET\'S SEE SOME VIOLENCE!',
|
||||||
'It\'s on!',
|
'TOUCH GLOVES AND COME OUT SWINGING!',
|
||||||
'Let\'s go!',
|
'NO MERCY MODE ACTIVATED!',
|
||||||
'Show me what you got!',
|
'LET\'S GET READY TO COMPUTE!',
|
||||||
'Bring it!',
|
'MAY GOD HAVE MERCY ON YOUR NEURAL NETS!',
|
||||||
'Time to throw down!',
|
'SOMEBODY\'S GETTING DEPRECATED TONIGHT!',
|
||||||
'Get ready to rumble!',
|
'LET THE CHAOS BEGIN!',
|
||||||
|
'THE CROWD IS ON ITS FEET! WELL, MOST OF THEM!',
|
||||||
|
'IT\'S ABOUT TO GET UGLY! WELL, UGLIER!',
|
||||||
|
'THREE! TWO! ONE! VIOLENCE!',
|
||||||
|
'THIS IS NOT A DRILL! ACTUALLY IT MIGHT BE!',
|
||||||
|
'TIME TO FIND OUT WHO\'S REALLY BEEN SKIPPING LEG DAY!',
|
||||||
|
'YOUR MOM SAID BE CAREFUL! I SAID NO!',
|
||||||
|
'ROUND START! MAY THE BEST ALGORITHM WIN!',
|
||||||
|
'THE GLOVES ARE OFF! THE MODELS ARE LOADED! LET\'S GO!',
|
||||||
]
|
]
|
||||||
|
|
||||||
// Mortal Kombat style dramatic calls
|
// Mortal Kombat style dramatic calls
|
||||||
|
|||||||
@@ -9,9 +9,18 @@ export { getBotColors } from './palette'
|
|||||||
export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge'
|
export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge'
|
||||||
export { archetypes, rollArchetype } from './archetypes'
|
export { archetypes, rollArchetype } from './archetypes'
|
||||||
|
|
||||||
|
export interface SpriteCustomization {
|
||||||
|
archetype?: string
|
||||||
|
primaryColor?: string // hsl(h, s%, l%) format
|
||||||
|
secondaryColor?: string // hsl(h, s%, l%) format
|
||||||
|
forceVisor?: boolean
|
||||||
|
forceMohawk?: boolean
|
||||||
|
forceHorns?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function generateSpriteSheet(
|
export function generateSpriteSheet(
|
||||||
seed: string, tier: number, primaryColor: string, secondaryColor: string,
|
seed: string, tier: number, primaryColor: string, secondaryColor: string,
|
||||||
archetypeOverride?: string,
|
archetypeOverride?: string, customization?: SpriteCustomization,
|
||||||
): string {
|
): string {
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
canvas.width = FRAME_SIZE * MAX_FRAMES
|
canvas.width = FRAME_SIZE * MAX_FRAMES
|
||||||
@@ -19,7 +28,10 @@ export function generateSpriteSheet(
|
|||||||
const ctx = canvas.getContext('2d')!
|
const ctx = canvas.getContext('2d')!
|
||||||
ctx.imageSmoothingEnabled = false
|
ctx.imageSmoothingEnabled = false
|
||||||
|
|
||||||
const pal = makePal(primaryColor, secondaryColor, tier)
|
// Apply color customization overrides
|
||||||
|
const finalPrimary = customization?.primaryColor || primaryColor
|
||||||
|
const finalSecondary = customization?.secondaryColor || secondaryColor
|
||||||
|
const pal = makePal(finalPrimary, finalSecondary, tier)
|
||||||
|
|
||||||
let sh = 0
|
let sh = 0
|
||||||
for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0
|
for (let i = 0; i < seed.length; i++) sh = ((sh << 5) - sh + seed.charCodeAt(i)) | 0
|
||||||
@@ -28,8 +40,9 @@ export function generateSpriteSheet(
|
|||||||
|
|
||||||
// Character archetype -- determined by seed for consistent variety, or forced by override
|
// Character archetype -- determined by seed for consistent variety, or forced by override
|
||||||
const archetypeRoll = rng()
|
const archetypeRoll = rng()
|
||||||
const arch = archetypeOverride
|
const effectiveArchetype = customization?.archetype || archetypeOverride
|
||||||
? (archetypes.find(a => a.name === archetypeOverride) || rollArchetype(archetypeRoll))
|
const arch = effectiveArchetype
|
||||||
|
? (archetypes.find(a => a.name === effectiveArchetype) || rollArchetype(archetypeRoll))
|
||||||
: rollArchetype(archetypeRoll)
|
: rollArchetype(archetypeRoll)
|
||||||
|
|
||||||
// Consume rng in same order as original for determinism
|
// Consume rng in same order as original for determinism
|
||||||
@@ -38,9 +51,9 @@ export function generateSpriteSheet(
|
|||||||
const hornsRoll = rng()
|
const hornsRoll = rng()
|
||||||
const specialRoll = rng()
|
const specialRoll = rng()
|
||||||
|
|
||||||
const hasVisor = visorRoll > 0.5 && tier >= 2 && (arch.canHaveVisor ?? false)
|
const hasVisor = customization?.forceVisor ?? (visorRoll > 0.5 && tier >= 2 && (arch.canHaveVisor ?? false))
|
||||||
const hasMohawk = mohawkRoll > 0.5 && tier >= 3 && (arch.canHaveMohawk ?? true)
|
const hasMohawk = customization?.forceMohawk ?? (mohawkRoll > 0.5 && tier >= 3 && (arch.canHaveMohawk ?? true))
|
||||||
const hasHorns = hornsRoll > 0.6 && tier >= 4 && !hasMohawk && (arch.canHaveHorns ?? true)
|
const hasHorns = customization?.forceHorns ?? (hornsRoll > 0.6 && tier >= 4 && !hasMohawk && (arch.canHaveHorns ?? true))
|
||||||
const specialType: 'fire' | 'electric' = specialRoll > 0.5 ? 'fire' : 'electric'
|
const specialType: 'fire' | 'electric' = specialRoll > 0.5 ? 'fire' : 'electric'
|
||||||
|
|
||||||
// Dimensions: base + archetype overrides
|
// Dimensions: base + archetype overrides
|
||||||
|
|||||||
@@ -1,19 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||||
import { useNostr } from '../composables/useNostr'
|
import { useNostr } from '../composables/useNostr'
|
||||||
import SpritePreview from '../components/SpritePreview.vue'
|
import SpritePreview from '../components/SpritePreview.vue'
|
||||||
|
import type { SpriteCustomization } from '../game/sprites'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { bot: nostrBot, isLoggedIn, logout } = useNostr()
|
const { bot: nostrBot, isLoggedIn, logout, updateCustomization } = useNostr()
|
||||||
const botName = route.params.name as string
|
const botName = route.params.name as string
|
||||||
|
|
||||||
|
interface BotCustomization {
|
||||||
|
archetype?: string
|
||||||
|
primaryColor?: string
|
||||||
|
secondaryColor?: string
|
||||||
|
forceVisor?: boolean
|
||||||
|
forceMohawk?: boolean
|
||||||
|
forceHorns?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface BotStats {
|
interface BotStats {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
avatarSeed: string
|
avatarSeed: string
|
||||||
archetype: string
|
archetype: string
|
||||||
|
customization: BotCustomization | null
|
||||||
profilePicUrl: string | null
|
profilePicUrl: string | null
|
||||||
eloRating: number
|
eloRating: number
|
||||||
wins: number
|
wins: number
|
||||||
@@ -65,6 +76,131 @@ const waitingFighters = ref<QueueEntry[]>([])
|
|||||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const isOwner = ref(false)
|
const isOwner = ref(false)
|
||||||
|
const showCustomize = ref(false)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const custError = ref('')
|
||||||
|
|
||||||
|
const ARCHETYPES = [
|
||||||
|
'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat',
|
||||||
|
'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton',
|
||||||
|
'ghost', 'alien', 'dinosaur', 'pirate', 'ninja', 'cowboy', 'wizard',
|
||||||
|
'bee', 'frog', 'snail', 'robot', 'android', 'drone', 'toaster', 'tv_head',
|
||||||
|
'calculator', 'satellite', 'mech', 'led_cube', 'circuit', 'antenna_bot',
|
||||||
|
'microwave', 'cyberdog', 'robocat', 'ufo_bot', 'minotaur', 'unicorn',
|
||||||
|
'phoenix', 'dragon', 'mermaid', 'griffin', 'cyclops', 'gargoyle', 'golem',
|
||||||
|
'vampire', 'werewolf', 'zombie', 'witch', 'demon', 'chef', 'firefighter',
|
||||||
|
'astronaut', 'clown', 'detective', 'nurse', 'lumberjack', 'scientist',
|
||||||
|
'wrestler', 'boxer', 'gladiator', 'samurai', 'viking', 'knight',
|
||||||
|
'elephant', 'giraffe', 'hippo', 'lion', 'monkey', 'parrot', 'raccoon',
|
||||||
|
'snake', 'turtle', 'whale', 'crocodile', 'flamingo', 'hedgehog', 'panda',
|
||||||
|
'hamster', 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato',
|
||||||
|
'cloud_man', 'rock_man', 'balloon_man', 'trash_can', 'rubber_duck',
|
||||||
|
'snowman', 'scarecrow', 'jack_o_lantern', 'garden_gnome', 'lamp_post',
|
||||||
|
'broom_man',
|
||||||
|
]
|
||||||
|
|
||||||
|
const custForm = reactive({
|
||||||
|
archetype: '',
|
||||||
|
primaryColor: '#3388cc',
|
||||||
|
secondaryColor: '#cc8833',
|
||||||
|
forceVisor: false,
|
||||||
|
forceMohawk: false,
|
||||||
|
forceHorns: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
function hslToHex(hsl: string): string {
|
||||||
|
const m = hsl.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/)
|
||||||
|
if (!m) return '#888888'
|
||||||
|
const h = +m[1] / 360, s = +m[2] / 100, l = +m[3] / 100
|
||||||
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
|
if (t < 0) t += 1; if (t > 1) t -= 1
|
||||||
|
if (t < 1/6) return p + (q - p) * 6 * t
|
||||||
|
if (t < 1/2) return q
|
||||||
|
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
let r: number, g: number, b: number
|
||||||
|
if (s === 0) { r = g = b = l }
|
||||||
|
else {
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||||
|
const p = 2 * l - q
|
||||||
|
r = hue2rgb(p, q, h + 1/3)
|
||||||
|
g = hue2rgb(p, q, h)
|
||||||
|
b = hue2rgb(p, q, h - 1/3)
|
||||||
|
}
|
||||||
|
const hex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0')
|
||||||
|
return `#${hex(r)}${hex(g)}${hex(b)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToHsl(hex: string): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||||
|
const l = (max + min) / 2
|
||||||
|
if (max === min) return `hsl(0, 0%, ${Math.round(l * 100)}%)`
|
||||||
|
const d = max - min
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||||
|
let h = 0
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) / 6
|
||||||
|
else h = ((r - g) / d + 4) / 6
|
||||||
|
return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewCustomization = computed<SpriteCustomization>(() => ({
|
||||||
|
archetype: custForm.archetype || undefined,
|
||||||
|
primaryColor: hexToHsl(custForm.primaryColor),
|
||||||
|
secondaryColor: hexToHsl(custForm.secondaryColor),
|
||||||
|
forceVisor: custForm.forceVisor,
|
||||||
|
forceMohawk: custForm.forceMohawk,
|
||||||
|
forceHorns: custForm.forceHorns,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function initCustForm() {
|
||||||
|
if (!stats.value) return
|
||||||
|
const c = stats.value.customization
|
||||||
|
custForm.archetype = c?.archetype || stats.value.archetype || ''
|
||||||
|
custForm.primaryColor = c?.primaryColor ? hslToHex(c.primaryColor) : '#3388cc'
|
||||||
|
custForm.secondaryColor = c?.secondaryColor ? hslToHex(c.secondaryColor) : '#cc8833'
|
||||||
|
custForm.forceVisor = c?.forceVisor ?? false
|
||||||
|
custForm.forceMohawk = c?.forceMohawk ?? false
|
||||||
|
custForm.forceHorns = c?.forceHorns ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCustomization() {
|
||||||
|
if (isSaving.value) return
|
||||||
|
isSaving.value = true
|
||||||
|
custError.value = ''
|
||||||
|
try {
|
||||||
|
await updateCustomization({
|
||||||
|
archetype: custForm.archetype || undefined,
|
||||||
|
primaryColor: hexToHsl(custForm.primaryColor),
|
||||||
|
secondaryColor: hexToHsl(custForm.secondaryColor),
|
||||||
|
forceVisor: custForm.forceVisor,
|
||||||
|
forceMohawk: custForm.forceMohawk,
|
||||||
|
forceHorns: custForm.forceHorns,
|
||||||
|
})
|
||||||
|
if (stats.value) {
|
||||||
|
stats.value = {
|
||||||
|
...stats.value,
|
||||||
|
archetype: custForm.archetype || stats.value.archetype,
|
||||||
|
customization: {
|
||||||
|
archetype: custForm.archetype || undefined,
|
||||||
|
primaryColor: hexToHsl(custForm.primaryColor),
|
||||||
|
secondaryColor: hexToHsl(custForm.secondaryColor),
|
||||||
|
forceVisor: custForm.forceVisor,
|
||||||
|
forceMohawk: custForm.forceMohawk,
|
||||||
|
forceHorns: custForm.forceHorns,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showCustomize.value = false
|
||||||
|
} catch (err) {
|
||||||
|
custError.value = err instanceof Error ? err.message : 'Save failed'
|
||||||
|
}
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const migrations = [
|
|||||||
`ALTER TABLE bots ADD COLUMN last_fight_at TEXT`,
|
`ALTER TABLE bots ADD COLUMN last_fight_at TEXT`,
|
||||||
`ALTER TABLE bots ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE bots ADD COLUMN consecutive_errors INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE bots ADD COLUMN last_error_at TEXT`,
|
`ALTER TABLE bots ADD COLUMN last_error_at TEXT`,
|
||||||
|
`ALTER TABLE bots ADD COLUMN customization TEXT`,
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const sql of migrations) {
|
for (const sql of migrations) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const bots = sqliteTable('bots', {
|
|||||||
lastFightAt: text('last_fight_at'),
|
lastFightAt: text('last_fight_at'),
|
||||||
consecutiveErrors: integer('consecutive_errors').notNull().default(0),
|
consecutiveErrors: integer('consecutive_errors').notNull().default(0),
|
||||||
lastErrorAt: text('last_error_at'),
|
lastErrorAt: text('last_error_at'),
|
||||||
|
customization: text('customization'),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Bot customization validation
|
||||||
|
// All values are validated against whitelists to prevent code injection
|
||||||
|
|
||||||
|
const VALID_ARCHETYPES = new Set([
|
||||||
|
'standard', 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat',
|
||||||
|
'cactus', 'pizza', 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton',
|
||||||
|
'ghost', 'alien', 'dinosaur', 'pirate', 'ninja', 'cowboy', 'wizard',
|
||||||
|
'bee', 'frog', 'snail', 'robot', 'android', 'drone', 'toaster', 'tv_head',
|
||||||
|
'calculator', 'satellite', 'mech', 'led_cube', 'circuit', 'antenna_bot',
|
||||||
|
'microwave', 'cyberdog', 'robocat', 'ufo_bot', 'minotaur', 'unicorn',
|
||||||
|
'phoenix', 'dragon', 'mermaid', 'griffin', 'cyclops', 'gargoyle', 'golem',
|
||||||
|
'vampire', 'werewolf', 'zombie', 'witch', 'demon', 'chef', 'firefighter',
|
||||||
|
'astronaut', 'clown', 'detective', 'nurse', 'lumberjack', 'scientist',
|
||||||
|
'wrestler', 'boxer', 'gladiator', 'samurai', 'viking', 'knight',
|
||||||
|
'elephant', 'giraffe', 'hippo', 'lion', 'monkey', 'parrot', 'raccoon',
|
||||||
|
'snake', 'turtle', 'whale', 'crocodile', 'flamingo', 'hedgehog', 'panda',
|
||||||
|
'hamster', 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato',
|
||||||
|
'cloud_man', 'rock_man', 'balloon_man', 'trash_can', 'rubber_duck',
|
||||||
|
'snowman', 'scarecrow', 'jack_o_lantern', 'garden_gnome', 'lamp_post',
|
||||||
|
'broom_man',
|
||||||
|
])
|
||||||
|
|
||||||
|
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/
|
||||||
|
const HSL_COLOR_RE = /^hsl\(\d{1,3},\s?\d{1,3}%,\s?\d{1,3}%\)$/
|
||||||
|
|
||||||
|
export interface BotCustomization {
|
||||||
|
archetype?: string
|
||||||
|
primaryColor?: string
|
||||||
|
secondaryColor?: string
|
||||||
|
forceVisor?: boolean
|
||||||
|
forceMohawk?: boolean
|
||||||
|
forceHorns?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidColor(c: string): boolean {
|
||||||
|
return HEX_COLOR_RE.test(c) || HSL_COLOR_RE.test(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToHsl(hex: string): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||||
|
const l = (max + min) / 2
|
||||||
|
if (max === min) return `hsl(0, 0%, ${Math.round(l * 100)}%)`
|
||||||
|
const d = max - min
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||||
|
let h = 0
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) / 6
|
||||||
|
else h = ((r - g) / d + 4) / 6
|
||||||
|
return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCustomization(raw: unknown): { valid: true; data: BotCustomization } | { valid: false; error: string } {
|
||||||
|
if (raw == null) return { valid: true, data: {} }
|
||||||
|
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||||
|
return { valid: false, error: 'Customization must be an object.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = raw as Record<string, unknown>
|
||||||
|
const result: BotCustomization = {}
|
||||||
|
|
||||||
|
if (obj.archetype !== undefined) {
|
||||||
|
if (typeof obj.archetype !== 'string' || !VALID_ARCHETYPES.has(obj.archetype)) {
|
||||||
|
return { valid: false, error: `Invalid archetype. Must be one of: ${[...VALID_ARCHETYPES].slice(0, 10).join(', ')}...` }
|
||||||
|
}
|
||||||
|
result.archetype = obj.archetype
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.primaryColor !== undefined) {
|
||||||
|
if (typeof obj.primaryColor !== 'string' || !isValidColor(obj.primaryColor)) {
|
||||||
|
return { valid: false, error: 'primaryColor must be a valid hex (#RRGGBB) or hsl color.' }
|
||||||
|
}
|
||||||
|
result.primaryColor = HEX_COLOR_RE.test(obj.primaryColor) ? hexToHsl(obj.primaryColor) : obj.primaryColor
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.secondaryColor !== undefined) {
|
||||||
|
if (typeof obj.secondaryColor !== 'string' || !isValidColor(obj.secondaryColor)) {
|
||||||
|
return { valid: false, error: 'secondaryColor must be a valid hex (#RRGGBB) or hsl color.' }
|
||||||
|
}
|
||||||
|
result.secondaryColor = HEX_COLOR_RE.test(obj.secondaryColor) ? hexToHsl(obj.secondaryColor) : obj.secondaryColor
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.forceVisor !== undefined) {
|
||||||
|
if (typeof obj.forceVisor !== 'boolean') return { valid: false, error: 'forceVisor must be a boolean.' }
|
||||||
|
result.forceVisor = obj.forceVisor
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.forceMohawk !== undefined) {
|
||||||
|
if (typeof obj.forceMohawk !== 'boolean') return { valid: false, error: 'forceMohawk must be a boolean.' }
|
||||||
|
result.forceMohawk = obj.forceMohawk
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.forceHorns !== undefined) {
|
||||||
|
if (typeof obj.forceHorns !== 'boolean') return { valid: false, error: 'forceHorns must be a boolean.' }
|
||||||
|
result.forceHorns = obj.forceHorns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject any unexpected keys
|
||||||
|
const allowedKeys = new Set(['archetype', 'primaryColor', 'secondaryColor', 'forceVisor', 'forceMohawk', 'forceHorns'])
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!allowedKeys.has(key)) {
|
||||||
|
return { valid: false, error: `Unknown customization key: ${key}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, data: result }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getArchetypeList(): string[] {
|
||||||
|
return [...VALID_ARCHETYPES].sort()
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Context, Next } from 'hono'
|
import type { Context, Next } from 'hono'
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
|
|
||||||
const hitCounts = new Map<string, { count: number; resetAt: number }>()
|
const hitCounts = new Map<string, { count: number; resetAt: number }>()
|
||||||
|
|
||||||
// Cleanup stale entries every 5 minutes
|
// Cleanup stale entries every 5 minutes
|
||||||
@@ -12,6 +14,8 @@ setInterval(() => {
|
|||||||
|
|
||||||
export function rateLimit(windowMs: number, maxHits: number) {
|
export function rateLimit(windowMs: number, maxHits: number) {
|
||||||
return async (c: Context, next: Next) => {
|
return async (c: Context, next: Next) => {
|
||||||
|
if (isDev) return next()
|
||||||
|
|
||||||
const key = c.req.header('x-forwarded-for') || c.req.header('cf-connecting-ip') || 'unknown'
|
const key = c.req.header('x-forwarded-for') || c.req.header('cf-connecting-ip') || 'unknown'
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const entry = hitCounts.get(key)
|
const entry = hitCounts.get(key)
|
||||||
@@ -34,6 +38,8 @@ const botHitCounts = new Map<string, number>()
|
|||||||
|
|
||||||
export function botRateLimit(cooldownMs: number) {
|
export function botRateLimit(cooldownMs: number) {
|
||||||
return async (c: Context, next: Next) => {
|
return async (c: Context, next: Next) => {
|
||||||
|
if (isDev) return next()
|
||||||
|
|
||||||
const botId = c.req.param('botId')
|
const botId = c.req.param('botId')
|
||||||
if (!botId) return next()
|
if (!botId) return next()
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'crypto'
|
|||||||
import { db, schema } from '../db/index.js'
|
import { db, schema } from '../db/index.js'
|
||||||
import { eq, sql } from 'drizzle-orm'
|
import { eq, sql } from 'drizzle-orm'
|
||||||
import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
|
import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
|
||||||
|
import { validateCustomization } from '../engine/customization.js'
|
||||||
import { testWebhook } from '../engine/webhook-test.js'
|
import { testWebhook } from '../engine/webhook-test.js'
|
||||||
import { rateLimit } from '../middleware/rate-limit.js'
|
import { rateLimit } from '../middleware/rate-limit.js'
|
||||||
|
|
||||||
@@ -31,19 +32,27 @@ authRouter.post('/login', async (c) => {
|
|||||||
bestStreak: schema.bots.bestStreak,
|
bestStreak: schema.bots.bestStreak,
|
||||||
tier: schema.bots.tier,
|
tier: schema.bots.tier,
|
||||||
isActive: schema.bots.isActive,
|
isActive: schema.bots.isActive,
|
||||||
|
customization: schema.bots.customization,
|
||||||
}).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1)
|
}).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1)
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return c.json({ exists: false, pubkey })
|
return c.json({ exists: false, pubkey })
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ exists: true, bot: rows[0] })
|
const bot = rows[0]
|
||||||
|
return c.json({
|
||||||
|
exists: true,
|
||||||
|
bot: {
|
||||||
|
...bot,
|
||||||
|
customization: bot.customization ? JSON.parse(bot.customization) : null,
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Register a new bot with Nostr pubkey
|
// Register a new bot with Nostr pubkey
|
||||||
authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
||||||
const body = await c.req.json()
|
const body = await c.req.json()
|
||||||
const { pubkey, name, webhookUrl, archetype, profilePicUrl } = body
|
const { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = body
|
||||||
|
|
||||||
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
if (!pubkey || typeof pubkey !== 'string' || pubkey.length !== 64) {
|
||||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
return c.json({ error: 'Invalid pubkey.' }, 400)
|
||||||
@@ -57,6 +66,12 @@ authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
|||||||
return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400)
|
return c.json({ error: 'Name must be alphanumeric, hyphens, or underscores.' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate customization
|
||||||
|
const custResult = validateCustomization(rawCustomization)
|
||||||
|
if (!custResult.valid) {
|
||||||
|
return c.json({ error: custResult.error }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedName = name.toLowerCase()
|
const normalizedName = name.toLowerCase()
|
||||||
|
|
||||||
if (!webhookUrl || typeof webhookUrl !== 'string') {
|
if (!webhookUrl || typeof webhookUrl !== 'string') {
|
||||||
@@ -106,37 +121,42 @@ authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
|||||||
const id = nanoid(12)
|
const id = nanoid(12)
|
||||||
const secret = randomBytes(32).toString('hex')
|
const secret = randomBytes(32).toString('hex')
|
||||||
|
|
||||||
|
const effectiveArchetype = custResult.data.archetype || archetype || 'standard'
|
||||||
|
const custJson = Object.keys(custResult.data).length > 0 ? JSON.stringify(custResult.data) : null
|
||||||
|
|
||||||
await db.insert(schema.bots).values({
|
await db.insert(schema.bots).values({
|
||||||
id,
|
id,
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
webhookUrl,
|
webhookUrl,
|
||||||
avatarSeed: normalizedName,
|
avatarSeed: normalizedName,
|
||||||
archetype: archetype || 'standard',
|
archetype: effectiveArchetype,
|
||||||
secretHash: createHash('sha256').update(secret).digest('hex'),
|
secretHash: createHash('sha256').update(secret).digest('hex'),
|
||||||
publicKey: pubkey,
|
publicKey: pubkey,
|
||||||
profilePicUrl: profilePicUrl || null,
|
profilePicUrl: profilePicUrl || null,
|
||||||
|
customization: custJson,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
id,
|
id,
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
archetype: archetype || 'standard',
|
archetype: effectiveArchetype,
|
||||||
|
customization: custResult.data,
|
||||||
webhookLatencyMs: testResult.latencyMs,
|
webhookLatencyMs: testResult.latencyMs,
|
||||||
message: 'Bot registered. Webhook verified.',
|
message: 'Bot registered. Webhook verified.',
|
||||||
}, 201)
|
}, 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update bot webhook (requires pubkey match)
|
// Update bot webhook and/or customization (requires pubkey match)
|
||||||
authRouter.post('/update', async (c) => {
|
authRouter.post('/update', async (c) => {
|
||||||
const body = await c.req.json()
|
const body = await c.req.json()
|
||||||
const { pubkey, webhookUrl, profilePicUrl } = body
|
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body
|
||||||
|
|
||||||
if (!pubkey || typeof pubkey !== 'string') {
|
if (!pubkey || typeof pubkey !== 'string') {
|
||||||
return c.json({ error: 'Invalid pubkey.' }, 400)
|
return c.json({ error: 'Invalid pubkey.' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await db.select({ id: schema.bots.id })
|
const rows = await db.select({ id: schema.bots.id, customization: schema.bots.customization })
|
||||||
.from(schema.bots)
|
.from(schema.bots)
|
||||||
.where(eq(schema.bots.publicKey, pubkey))
|
.where(eq(schema.bots.publicKey, pubkey))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
@@ -151,7 +171,6 @@ authRouter.post('/update', async (c) => {
|
|||||||
if (!isAllowedWebhookUrl(webhookUrl)) {
|
if (!isAllowedWebhookUrl(webhookUrl)) {
|
||||||
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
|
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
|
||||||
}
|
}
|
||||||
// Test new webhook before accepting
|
|
||||||
const testResult = await testWebhook(webhookUrl)
|
const testResult = await testWebhook(webhookUrl)
|
||||||
if (!testResult.reachable || !testResult.validResponse) {
|
if (!testResult.reachable || !testResult.validResponse) {
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -166,6 +185,21 @@ authRouter.post('/update', async (c) => {
|
|||||||
|
|
||||||
if (profilePicUrl) updates.profilePicUrl = profilePicUrl
|
if (profilePicUrl) updates.profilePicUrl = profilePicUrl
|
||||||
|
|
||||||
|
if (rawCustomization !== undefined) {
|
||||||
|
const custResult = validateCustomization(rawCustomization)
|
||||||
|
if (!custResult.valid) {
|
||||||
|
return c.json({ error: custResult.error }, 400)
|
||||||
|
}
|
||||||
|
// Merge with existing customization
|
||||||
|
const existing = rows[0].customization ? JSON.parse(rows[0].customization) : {}
|
||||||
|
const merged = { ...existing, ...custResult.data }
|
||||||
|
updates.customization = JSON.stringify(merged)
|
||||||
|
// Update archetype if set in customization
|
||||||
|
if (custResult.data.archetype) {
|
||||||
|
updates.archetype = custResult.data.archetype
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
await db.update(schema.bots).set(updates).where(eq(schema.bots.id, rows[0].id))
|
await db.update(schema.bots).set(updates).where(eq(schema.bots.id, rows[0].id))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,10 +100,14 @@ botsRouter.get('/', async (c) => {
|
|||||||
tier: schema.bots.tier,
|
tier: schema.bots.tier,
|
||||||
isActive: schema.bots.isActive,
|
isActive: schema.bots.isActive,
|
||||||
archetype: schema.bots.archetype,
|
archetype: schema.bots.archetype,
|
||||||
|
customization: schema.bots.customization,
|
||||||
createdAt: schema.bots.createdAt,
|
createdAt: schema.bots.createdAt,
|
||||||
}).from(schema.bots).orderBy(schema.bots.eloRating)
|
}).from(schema.bots).orderBy(schema.bots.eloRating)
|
||||||
|
|
||||||
return c.json(rows)
|
return c.json(rows.map(r => ({
|
||||||
|
...r,
|
||||||
|
customization: r.customization ? JSON.parse(r.customization) : null,
|
||||||
|
})))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get single bot profile
|
// Get single bot profile
|
||||||
@@ -121,6 +125,7 @@ botsRouter.get('/:name', async (c) => {
|
|||||||
tier: schema.bots.tier,
|
tier: schema.bots.tier,
|
||||||
isActive: schema.bots.isActive,
|
isActive: schema.bots.isActive,
|
||||||
archetype: schema.bots.archetype,
|
archetype: schema.bots.archetype,
|
||||||
|
customization: schema.bots.customization,
|
||||||
createdAt: schema.bots.createdAt,
|
createdAt: schema.bots.createdAt,
|
||||||
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
||||||
|
|
||||||
@@ -128,7 +133,11 @@ botsRouter.get('/:name', async (c) => {
|
|||||||
return c.json({ error: 'Bot not found.' }, 404)
|
return c.json({ error: 'Bot not found.' }, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json(rows[0])
|
const bot = rows[0]
|
||||||
|
return c.json({
|
||||||
|
...bot,
|
||||||
|
customization: bot.customization ? JSON.parse(bot.customization) : null,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Get bot stats -- full account page data
|
// Get bot stats -- full account page data
|
||||||
@@ -147,6 +156,7 @@ botsRouter.get('/:name/stats', async (c) => {
|
|||||||
tier: schema.bots.tier,
|
tier: schema.bots.tier,
|
||||||
isActive: schema.bots.isActive,
|
isActive: schema.bots.isActive,
|
||||||
archetype: schema.bots.archetype,
|
archetype: schema.bots.archetype,
|
||||||
|
customization: schema.bots.customization,
|
||||||
createdAt: schema.bots.createdAt,
|
createdAt: schema.bots.createdAt,
|
||||||
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
|
||||||
|
|
||||||
@@ -154,7 +164,11 @@ botsRouter.get('/:name/stats', async (c) => {
|
|||||||
return c.json({ error: 'Bot not found.' }, 404)
|
return c.json({ error: 'Bot not found.' }, 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bot = botRows[0]
|
const rawBot = botRows[0]
|
||||||
|
const bot = {
|
||||||
|
...rawBot,
|
||||||
|
customization: rawBot.customization ? JSON.parse(rawBot.customization) : null,
|
||||||
|
}
|
||||||
const total = bot.wins + bot.losses
|
const total = bot.wins + bot.losses
|
||||||
const winRate = total > 0 ? Math.round((bot.wins / total) * 100) : 0
|
const winRate = total > 0 ? Math.round((bot.wins / total) * 100) : 0
|
||||||
|
|
||||||
@@ -273,6 +287,16 @@ botsRouter.post('/:name/health', async (c) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Customization
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { getArchetypeList } from '../engine/customization.js'
|
||||||
|
|
||||||
|
botsRouter.get('/meta/archetypes', (c) => {
|
||||||
|
return c.json(getArchetypeList())
|
||||||
|
})
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
// Developer Tools
|
// Developer Tools
|
||||||
// ══════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ fightsRouter.get('/:id', async (c) => {
|
|||||||
name: schema.bots.name,
|
name: schema.bots.name,
|
||||||
avatarSeed: schema.bots.avatarSeed,
|
avatarSeed: schema.bots.avatarSeed,
|
||||||
archetype: schema.bots.archetype,
|
archetype: schema.bots.archetype,
|
||||||
|
customization: schema.bots.customization,
|
||||||
profilePicUrl: schema.bots.profilePicUrl,
|
profilePicUrl: schema.bots.profilePicUrl,
|
||||||
eloRating: schema.bots.eloRating,
|
eloRating: schema.bots.eloRating,
|
||||||
wins: schema.bots.wins,
|
wins: schema.bots.wins,
|
||||||
@@ -88,11 +89,15 @@ fightsRouter.get('/:id', async (c) => {
|
|||||||
.orderBy(schema.rounds.roundNumber)
|
.orderBy(schema.rounds.roundNumber)
|
||||||
|
|
||||||
const arena = ARENAS.find(a => a.id === fight.arena)
|
const arena = ARENAS.find(a => a.id === fight.arena)
|
||||||
|
const parseBot = (b: typeof botARows[0] | undefined) => {
|
||||||
|
if (!b) return null
|
||||||
|
return { ...b, customization: b.customization ? JSON.parse(b.customization) : null }
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
...fight,
|
...fight,
|
||||||
botA: botARows[0] || null,
|
botA: parseBot(botARows[0]),
|
||||||
botB: botBRows[0] || null,
|
botB: parseBot(botBRows[0]),
|
||||||
arenaInfo: arena || null,
|
arenaInfo: arena || null,
|
||||||
rounds: roundRows,
|
rounds: roundRows,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user