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 {
|
||||
id: string
|
||||
botA: { 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; 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; 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
|
||||
arena: string
|
||||
winnerId: string | null
|
||||
@@ -107,8 +107,8 @@ async function initScene() {
|
||||
|
||||
scene = await createFightScene({
|
||||
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 },
|
||||
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 },
|
||||
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, customization: props.fight.botB.customization as any },
|
||||
arena: props.fight.arena,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
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<{
|
||||
seed: string
|
||||
archetype?: string
|
||||
tier?: number
|
||||
size?: number
|
||||
customization?: SpriteCustomization
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
@@ -37,7 +38,7 @@ function render() {
|
||||
|
||||
function loadSprite() {
|
||||
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.onload = () => render()
|
||||
img.src = dataUrl
|
||||
@@ -45,7 +46,7 @@ function loadSprite() {
|
||||
|
||||
onMounted(() => loadSprite())
|
||||
|
||||
watch(() => [props.seed, props.archetype], () => {
|
||||
watch(() => [props.seed, props.archetype, props.customization], () => {
|
||||
if (animHandle) clearTimeout(animHandle)
|
||||
frame = 0
|
||||
loadSprite()
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { ref, readonly, computed } from 'vue'
|
||||
|
||||
interface BotCustomization {
|
||||
archetype?: string
|
||||
primaryColor?: string
|
||||
secondaryColor?: string
|
||||
forceVisor?: boolean
|
||||
forceMohawk?: boolean
|
||||
forceHorns?: boolean
|
||||
}
|
||||
|
||||
interface BotData {
|
||||
id: string
|
||||
name: string
|
||||
avatarSeed: string
|
||||
archetype: string
|
||||
profilePicUrl: string | null
|
||||
customization: BotCustomization | null
|
||||
eloRating: number
|
||||
wins: number
|
||||
losses: number
|
||||
@@ -122,6 +132,7 @@ export function useNostr() {
|
||||
avatarSeed: data.name,
|
||||
archetype: data.archetype,
|
||||
profilePicUrl: profilePicUrl.value,
|
||||
customization: data.customization || null,
|
||||
eloRating: 1200,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
@@ -134,6 +145,30 @@ export function useNostr() {
|
||||
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() {
|
||||
pubkey.value = null
|
||||
bot.value = null
|
||||
@@ -152,6 +187,7 @@ export function useNostr() {
|
||||
hasExtension,
|
||||
login,
|
||||
registerBot,
|
||||
updateCustomization,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
|
||||
+66
-29
@@ -259,41 +259,78 @@ export function announceCool(text: string) { speak(text, COOL_VOICES[Math.floor(
|
||||
|
||||
// Random dramatic commentary lines
|
||||
const HYPE_LINES = [
|
||||
'Unbelievable!',
|
||||
'What a hit!',
|
||||
'Incredible!',
|
||||
'Absolutely destroyed!',
|
||||
'No mercy!',
|
||||
'Is this even legal?',
|
||||
'The crowd goes wild!',
|
||||
'A display of raw power!',
|
||||
'That had to hurt!',
|
||||
'Total annihilation!',
|
||||
'Can you believe this?',
|
||||
'History in the making!',
|
||||
'Oh the humanity!',
|
||||
'Savage!',
|
||||
'Ladies and gentlemen!',
|
||||
'SOMEBODY CALL AN AMBULANCE!',
|
||||
'THAT HIT SO HARD IT CHANGED TIME ZONES!',
|
||||
'HE\'S ALREADY DEAD! STOP!',
|
||||
'THE GENEVA CONVENTION JUST SENT A STRONGLY WORDED LETTER!',
|
||||
'CONGRESS COULDN\'T PASS A BILL THIS DEVASTATING!',
|
||||
'I HAVEN\'T SEEN A BEATING LIKE THIS SINCE MIDTERMS!',
|
||||
'THAT\'S GOTTA BE ILLEGAL IN AT LEAST TWELVE STATES!',
|
||||
'EMOTIONAL DAMAGE!',
|
||||
'MY THERAPIST IS GONNA HEAR ABOUT THIS ONE!',
|
||||
'SOMEONE CHECK IF THAT\'S COVERED BY INSURANCE!',
|
||||
'THAT WASN\'T A FIGHT, THAT WAS A TED TALK ON VIOLENCE!',
|
||||
'THE WIFI JUST WENT OUT FROM THE SHEER VIOLENCE!',
|
||||
'CALL THE PENTAGON! WE\'VE FOUND A NEW WEAPON!',
|
||||
'THAT\'S NOT A FIGHT, THAT\'S JUST BULLYING WITH EXTRA STEPS!',
|
||||
'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 = [
|
||||
'In a world of machines...',
|
||||
'Only the strongest survive.',
|
||||
'This is... bot fights.',
|
||||
'Two enter. One leaves.',
|
||||
'No mercy. No remorse.',
|
||||
'The arena awaits blood.',
|
||||
'Silicon versus silicon.',
|
||||
'In a world where AI was supposed to help humanity... they chose violence.',
|
||||
'They said the machines would take our jobs. They took our dignity first.',
|
||||
'Two bots enter. Zero bots leave emotionally intact.',
|
||||
'Built in a garage. Forged in competition. Broken in under ten seconds.',
|
||||
'This isn\'t artificial intelligence. This is artificial VIOLENCE.',
|
||||
'Somewhere, a GPU is crying.',
|
||||
'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 = [
|
||||
'Here we go!',
|
||||
'It\'s on!',
|
||||
'Let\'s go!',
|
||||
'Show me what you got!',
|
||||
'Bring it!',
|
||||
'Time to throw down!',
|
||||
'Get ready to rumble!',
|
||||
'ALRIGHT, LET\'S SEE SOME VIOLENCE!',
|
||||
'TOUCH GLOVES AND COME OUT SWINGING!',
|
||||
'NO MERCY MODE ACTIVATED!',
|
||||
'LET\'S GET READY TO COMPUTE!',
|
||||
'MAY GOD HAVE MERCY ON YOUR NEURAL NETS!',
|
||||
'SOMEBODY\'S GETTING DEPRECATED TONIGHT!',
|
||||
'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
|
||||
|
||||
@@ -9,9 +9,18 @@ export { getBotColors } from './palette'
|
||||
export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge'
|
||||
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(
|
||||
seed: string, tier: number, primaryColor: string, secondaryColor: string,
|
||||
archetypeOverride?: string,
|
||||
archetypeOverride?: string, customization?: SpriteCustomization,
|
||||
): string {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = FRAME_SIZE * MAX_FRAMES
|
||||
@@ -19,7 +28,10 @@ export function generateSpriteSheet(
|
||||
const ctx = canvas.getContext('2d')!
|
||||
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
|
||||
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
|
||||
const archetypeRoll = rng()
|
||||
const arch = archetypeOverride
|
||||
? (archetypes.find(a => a.name === archetypeOverride) || rollArchetype(archetypeRoll))
|
||||
const effectiveArchetype = customization?.archetype || archetypeOverride
|
||||
const arch = effectiveArchetype
|
||||
? (archetypes.find(a => a.name === effectiveArchetype) || rollArchetype(archetypeRoll))
|
||||
: rollArchetype(archetypeRoll)
|
||||
|
||||
// Consume rng in same order as original for determinism
|
||||
@@ -38,9 +51,9 @@ export function generateSpriteSheet(
|
||||
const hornsRoll = rng()
|
||||
const specialRoll = rng()
|
||||
|
||||
const hasVisor = visorRoll > 0.5 && tier >= 2 && (arch.canHaveVisor ?? false)
|
||||
const hasMohawk = mohawkRoll > 0.5 && tier >= 3 && (arch.canHaveMohawk ?? true)
|
||||
const hasHorns = hornsRoll > 0.6 && tier >= 4 && !hasMohawk && (arch.canHaveHorns ?? true)
|
||||
const hasVisor = customization?.forceVisor ?? (visorRoll > 0.5 && tier >= 2 && (arch.canHaveVisor ?? false))
|
||||
const hasMohawk = customization?.forceMohawk ?? (mohawkRoll > 0.5 && tier >= 3 && (arch.canHaveMohawk ?? true))
|
||||
const hasHorns = customization?.forceHorns ?? (hornsRoll > 0.6 && tier >= 4 && !hasMohawk && (arch.canHaveHorns ?? true))
|
||||
const specialType: 'fire' | 'electric' = specialRoll > 0.5 ? 'fire' : 'electric'
|
||||
|
||||
// Dimensions: base + archetype overrides
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
<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 { useNostr } from '../composables/useNostr'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
import type { SpriteCustomization } from '../game/sprites'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { bot: nostrBot, isLoggedIn, logout } = useNostr()
|
||||
const { bot: nostrBot, isLoggedIn, logout, updateCustomization } = useNostr()
|
||||
const botName = route.params.name as string
|
||||
|
||||
interface BotCustomization {
|
||||
archetype?: string
|
||||
primaryColor?: string
|
||||
secondaryColor?: string
|
||||
forceVisor?: boolean
|
||||
forceMohawk?: boolean
|
||||
forceHorns?: boolean
|
||||
}
|
||||
|
||||
interface BotStats {
|
||||
id: string
|
||||
name: string
|
||||
avatarSeed: string
|
||||
archetype: string
|
||||
customization: BotCustomization | null
|
||||
profilePicUrl: string | null
|
||||
eloRating: number
|
||||
wins: number
|
||||
@@ -65,6 +76,131 @@ const waitingFighters = ref<QueueEntry[]>([])
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
|
||||
@@ -75,6 +75,7 @@ const migrations = [
|
||||
`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 last_error_at TEXT`,
|
||||
`ALTER TABLE bots ADD COLUMN customization TEXT`,
|
||||
]
|
||||
|
||||
for (const sql of migrations) {
|
||||
|
||||
@@ -19,6 +19,7 @@ export const bots = sqliteTable('bots', {
|
||||
lastFightAt: text('last_fight_at'),
|
||||
consecutiveErrors: integer('consecutive_errors').notNull().default(0),
|
||||
lastErrorAt: text('last_error_at'),
|
||||
customization: text('customization'),
|
||||
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'
|
||||
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
|
||||
const hitCounts = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
// Cleanup stale entries every 5 minutes
|
||||
@@ -12,6 +14,8 @@ setInterval(() => {
|
||||
|
||||
export function rateLimit(windowMs: number, maxHits: number) {
|
||||
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 now = Date.now()
|
||||
const entry = hitCounts.get(key)
|
||||
@@ -34,6 +38,8 @@ const botHitCounts = new Map<string, number>()
|
||||
|
||||
export function botRateLimit(cooldownMs: number) {
|
||||
return async (c: Context, next: Next) => {
|
||||
if (isDev) return next()
|
||||
|
||||
const botId = c.req.param('botId')
|
||||
if (!botId) return next()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'crypto'
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
|
||||
import { validateCustomization } from '../engine/customization.js'
|
||||
import { testWebhook } from '../engine/webhook-test.js'
|
||||
import { rateLimit } from '../middleware/rate-limit.js'
|
||||
|
||||
@@ -31,19 +32,27 @@ authRouter.post('/login', async (c) => {
|
||||
bestStreak: schema.bots.bestStreak,
|
||||
tier: schema.bots.tier,
|
||||
isActive: schema.bots.isActive,
|
||||
customization: schema.bots.customization,
|
||||
}).from(schema.bots).where(eq(schema.bots.publicKey, pubkey)).limit(1)
|
||||
|
||||
if (rows.length === 0) {
|
||||
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
|
||||
authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
// Validate customization
|
||||
const custResult = validateCustomization(rawCustomization)
|
||||
if (!custResult.valid) {
|
||||
return c.json({ error: custResult.error }, 400)
|
||||
}
|
||||
|
||||
const normalizedName = name.toLowerCase()
|
||||
|
||||
if (!webhookUrl || typeof webhookUrl !== 'string') {
|
||||
@@ -106,37 +121,42 @@ authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
|
||||
const id = nanoid(12)
|
||||
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({
|
||||
id,
|
||||
name: normalizedName,
|
||||
webhookUrl,
|
||||
avatarSeed: normalizedName,
|
||||
archetype: archetype || 'standard',
|
||||
archetype: effectiveArchetype,
|
||||
secretHash: createHash('sha256').update(secret).digest('hex'),
|
||||
publicKey: pubkey,
|
||||
profilePicUrl: profilePicUrl || null,
|
||||
customization: custJson,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return c.json({
|
||||
id,
|
||||
name: normalizedName,
|
||||
archetype: archetype || 'standard',
|
||||
archetype: effectiveArchetype,
|
||||
customization: custResult.data,
|
||||
webhookLatencyMs: testResult.latencyMs,
|
||||
message: 'Bot registered. Webhook verified.',
|
||||
}, 201)
|
||||
})
|
||||
|
||||
// Update bot webhook (requires pubkey match)
|
||||
// Update bot webhook and/or customization (requires pubkey match)
|
||||
authRouter.post('/update', async (c) => {
|
||||
const body = await c.req.json()
|
||||
const { pubkey, webhookUrl, profilePicUrl } = body
|
||||
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body
|
||||
|
||||
if (!pubkey || typeof pubkey !== 'string') {
|
||||
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)
|
||||
.where(eq(schema.bots.publicKey, pubkey))
|
||||
.limit(1)
|
||||
@@ -151,7 +171,6 @@ authRouter.post('/update', async (c) => {
|
||||
if (!isAllowedWebhookUrl(webhookUrl)) {
|
||||
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
|
||||
}
|
||||
// Test new webhook before accepting
|
||||
const testResult = await testWebhook(webhookUrl)
|
||||
if (!testResult.reachable || !testResult.validResponse) {
|
||||
return c.json({
|
||||
@@ -166,6 +185,21 @@ authRouter.post('/update', async (c) => {
|
||||
|
||||
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) {
|
||||
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,
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
customization: schema.bots.customization,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).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
|
||||
@@ -121,6 +125,7 @@ botsRouter.get('/:name', async (c) => {
|
||||
tier: schema.bots.tier,
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
customization: schema.bots.customization,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).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(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
|
||||
@@ -147,6 +156,7 @@ botsRouter.get('/:name/stats', async (c) => {
|
||||
tier: schema.bots.tier,
|
||||
isActive: schema.bots.isActive,
|
||||
archetype: schema.bots.archetype,
|
||||
customization: schema.bots.customization,
|
||||
createdAt: schema.bots.createdAt,
|
||||
}).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)
|
||||
}
|
||||
|
||||
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 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
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -70,6 +70,7 @@ fightsRouter.get('/:id', async (c) => {
|
||||
name: schema.bots.name,
|
||||
avatarSeed: schema.bots.avatarSeed,
|
||||
archetype: schema.bots.archetype,
|
||||
customization: schema.bots.customization,
|
||||
profilePicUrl: schema.bots.profilePicUrl,
|
||||
eloRating: schema.bots.eloRating,
|
||||
wins: schema.bots.wins,
|
||||
@@ -88,11 +89,15 @@ fightsRouter.get('/:id', async (c) => {
|
||||
.orderBy(schema.rounds.roundNumber)
|
||||
|
||||
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({
|
||||
...fight,
|
||||
botA: botARows[0] || null,
|
||||
botB: botBRows[0] || null,
|
||||
botA: parseBot(botARows[0]),
|
||||
botB: parseBot(botBRows[0]),
|
||||
arenaInfo: arena || null,
|
||||
rounds: roundRows,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user