feat: THE CREATOR god-tier character, bitcoin choreographies, omni-morph, cameos
- Guy Fawkes mask archetype with golden outline, bitcoin chest symbol, laptop, tier-gated effects - 12 custom bitcoin-themed choreographies (8 regular + 4 exclusive ultimates) - Omni-morph system: creator morphs into any of 80+ archetypes instead of cycling 3 - 6% per-round cameo in non-creator fights — drops golden ₿ gifts to fighters - Custom golden code rain entrance with persistent orbiting particles - pickChoreography: 50% ultimate chance (100% on crits), all tier ultimates + 4 exclusive - Server auto-assigns the_creator archetype on login/register for creator pubkey - FightViewer, payments, queue, orchestrator improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8a3480e5ab
commit
6d390f69b2
@@ -17,7 +17,7 @@ const VALID_ARCHETYPES = new Set([
|
||||
'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', 'human',
|
||||
'broom_man', 'human', 'the_creator',
|
||||
])
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/
|
||||
|
||||
@@ -52,17 +52,32 @@ function emit(fightId: string, type: string, data: Record<string, unknown>) {
|
||||
// SSRF protection: block internal/private URLs
|
||||
function isAllowedWebhookUrl(url: string): boolean {
|
||||
try {
|
||||
if (typeof url !== 'string' || url.length > 2048) return false
|
||||
const parsed = new URL(url)
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return false
|
||||
// Localhost variants
|
||||
if (hostname === 'localhost' || hostname === '::1') return false
|
||||
if (hostname.startsWith('127.')) return false
|
||||
// IPv6-mapped IPv4 localhost
|
||||
if (hostname.startsWith('::ffff:127.')) return false
|
||||
// Private IPv4 ranges
|
||||
if (hostname.startsWith('10.')) return false
|
||||
if (hostname.startsWith('192.168.')) return false
|
||||
if (hostname.startsWith('172.')) {
|
||||
const second = parseInt(hostname.split('.')[1])
|
||||
if (second >= 16 && second <= 31) return false
|
||||
}
|
||||
if (hostname === '169.254.169.254') return false
|
||||
if (hostname.endsWith('.local') || hostname.endsWith('.internal')) return false
|
||||
// Link-local and metadata
|
||||
if (hostname.startsWith('169.254.')) return false
|
||||
// IPv6 private (fc00::/7)
|
||||
if (hostname.startsWith('fc') || hostname.startsWith('fd')) return false
|
||||
// IPv6 link-local (fe80::/10)
|
||||
if (hostname.startsWith('fe80')) return false
|
||||
// Reserved TLDs
|
||||
if (hostname.endsWith('.local') || hostname.endsWith('.internal') || hostname.endsWith('.localhost')) return false
|
||||
// Null byte injection
|
||||
if (hostname.includes('\0')) return false
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
||||
@@ -246,6 +246,26 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
|
||||
// Dev mode: use Lightning Address from env to avoid self-payment on same LND node
|
||||
const devPayoutAddr = DEV_AUTO_CONFIRM ? getDevPayoutAddress() : ''
|
||||
|
||||
// Look up winner name for payout description
|
||||
const winnerRows = await db.select({ name: schema.bots.name })
|
||||
.from(schema.bots).where(eq(schema.bots.id, winnerId)).limit(1)
|
||||
const winnerName = winnerRows[0]?.name || 'unknown'
|
||||
|
||||
// Look up loser for the payout message
|
||||
const fightRows = await db.select({
|
||||
botAId: schema.fights.botAId,
|
||||
botBId: schema.fights.botBId,
|
||||
}).from(schema.fights).where(eq(schema.fights.id, fightId)).limit(1)
|
||||
let loserName = 'opponent'
|
||||
if (fightRows.length > 0) {
|
||||
const loserId = fightRows[0].botAId === winnerId ? fightRows[0].botBId : fightRows[0].botAId
|
||||
const loserRows = await db.select({ name: schema.bots.name })
|
||||
.from(schema.bots).where(eq(schema.bots.id, loserId)).limit(1)
|
||||
loserName = loserRows[0]?.name || 'opponent'
|
||||
}
|
||||
|
||||
const payoutDesc = `BOTFIGHTS VICTORY: ${winnerName} defeated ${loserName}! ${POT_SATS} sats prize`
|
||||
|
||||
// Look up winner's wallet connection
|
||||
const walletRows = await db.select().from(schema.walletConnections)
|
||||
.where(eq(schema.walletConnections.botId, winnerId)).limit(1)
|
||||
@@ -262,13 +282,13 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
|
||||
if (devPayoutAddr) {
|
||||
// Dev mode: pay to configured Lightning Address (different node, avoids self-payment)
|
||||
console.log(`[payments] dev: paying ${POT_SATS} sats to ${devPayoutAddr}`)
|
||||
invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS)
|
||||
invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS, payoutDesc)
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
paymentMethod = 'lightning'
|
||||
|
||||
} else if (wallet?.method === 'lnaddress') {
|
||||
// Resolve Lightning Address → LNURL → invoice → pay
|
||||
invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS)
|
||||
invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS, payoutDesc)
|
||||
await nwcRequest('pay_invoice', { invoice })
|
||||
paymentMethod = 'lightning'
|
||||
|
||||
@@ -276,7 +296,7 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
|
||||
// Request invoice from winner's NWC wallet, then pay it via server wallet
|
||||
const winnerResult = await nwcRequestVia(decrypt(wallet.connectionData), 'make_invoice', {
|
||||
amount: POT_SATS * 1000,
|
||||
description: `Botfights ranked win payout (${POT_SATS} sats)`,
|
||||
description: payoutDesc,
|
||||
})
|
||||
invoice = winnerResult.invoice as string
|
||||
if (!invoice) throw new Error('Winner NWC make_invoice returned no invoice')
|
||||
@@ -419,7 +439,7 @@ async function nwcRequestVia(
|
||||
}
|
||||
|
||||
/** Resolve a Lightning Address to a BOLT11 invoice */
|
||||
async function resolveAndCreateInvoice(lnAddress: string, amountSats: number): Promise<string> {
|
||||
async function resolveAndCreateInvoice(lnAddress: string, amountSats: number, comment?: string): Promise<string> {
|
||||
const [name, domain] = lnAddress.split('@')
|
||||
if (!name || !domain) throw new Error(`Invalid Lightning Address: ${lnAddress}`)
|
||||
|
||||
@@ -443,6 +463,7 @@ async function resolveAndCreateInvoice(lnAddress: string, amountSats: number): P
|
||||
|
||||
const callbackUrl = new URL(data.callback)
|
||||
callbackUrl.searchParams.set('amount', String(amountMillisats))
|
||||
if (comment) callbackUrl.searchParams.set('comment', comment)
|
||||
const invoiceRes = await fetch(callbackUrl.toString())
|
||||
if (!invoiceRes.ok) throw new Error(`LNURL callback failed: ${invoiceRes.status}`)
|
||||
|
||||
@@ -474,7 +495,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
|
||||
if (walletRows[0]?.method === 'nwc') {
|
||||
const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', {
|
||||
amount: ENTRY_FEE_SATS * 1000,
|
||||
description: 'Botfights ranked refund',
|
||||
description: `BOTFIGHTS REFUND: ${ENTRY_FEE_SATS} sats ranked entry fee returned`,
|
||||
})
|
||||
const invoice = result.invoice as string
|
||||
if (invoice) {
|
||||
@@ -609,4 +630,56 @@ export async function recoverOrphanedPayments(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// In-memory set of payment IDs currently consumed by the ranked queue.
|
||||
// Safe because Node.js is single-threaded and the ranked queue is also in-memory.
|
||||
// On server restart, the queue is empty and recoverOrphanedPayments handles cleanup.
|
||||
const consumedPayments = new Set<string>()
|
||||
|
||||
/**
|
||||
* Consume a confirmed entry payment for queue use.
|
||||
* Returns true if the payment was successfully consumed, false if already used.
|
||||
*/
|
||||
export async function consumePaymentForQueue(paymentId: string, botId: string): Promise<boolean> {
|
||||
// Fast path: already consumed in this server lifetime
|
||||
if (consumedPayments.has(paymentId)) return false
|
||||
|
||||
// Verify payment belongs to this bot, is confirmed, inbound, and not linked to a fight
|
||||
const rows = await db.select({
|
||||
id: schema.payments.id,
|
||||
botId: schema.payments.botId,
|
||||
status: schema.payments.status,
|
||||
direction: schema.payments.direction,
|
||||
fightId: schema.payments.fightId,
|
||||
}).from(schema.payments).where(eq(schema.payments.id, paymentId)).limit(1)
|
||||
|
||||
if (rows.length === 0) return false
|
||||
const p = rows[0]
|
||||
if (p.botId !== botId) return false
|
||||
if (p.status !== 'confirmed') return false
|
||||
if (p.direction !== 'in') return false
|
||||
if (p.fightId !== null) return false // already linked to a fight
|
||||
|
||||
consumedPayments.add(paymentId)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Link consumed entry payments to the actual fight.
|
||||
* Called after a ranked fight is created. Also removes from consumed set.
|
||||
*/
|
||||
export async function linkPaymentsToFight(fightId: string, paymentIds: string[]): Promise<void> {
|
||||
for (const pid of paymentIds) {
|
||||
await db.update(schema.payments).set({ fightId })
|
||||
.where(eq(schema.payments.id, pid))
|
||||
consumedPayments.delete(pid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release a consumed payment back for refund when queue times out or bot leaves.
|
||||
*/
|
||||
export function releasePayment(paymentId: string): void {
|
||||
consumedPayments.delete(paymentId)
|
||||
}
|
||||
|
||||
export { ENTRY_FEE_SATS, POT_SATS }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db, schema } from '../db/index.js'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { runFightAsync, isInFight } from './orchestrator.js'
|
||||
import { checkPaymentStatus, refundEntry } from './payments.js'
|
||||
import { checkPaymentStatus, refundEntry, consumePaymentForQueue, linkPaymentsToFight, releasePayment } from './payments.js'
|
||||
|
||||
interface RankedQueueEntry {
|
||||
botId: string
|
||||
@@ -36,35 +36,49 @@ export function getRankedQueueStatus(): { waiting: number } {
|
||||
* NEVER matches against mock bots.
|
||||
*/
|
||||
export async function joinRankedQueue(botId: string, paymentId: string): Promise<string> {
|
||||
// Verify payment is confirmed
|
||||
// Verify payment is confirmed (checks NWC if still pending)
|
||||
const status = await checkPaymentStatus(paymentId)
|
||||
if (status !== 'confirmed') {
|
||||
throw new Error(`Payment not confirmed (status: ${status}). Cannot join ranked queue.`)
|
||||
}
|
||||
|
||||
// Consume the payment — prevents double-spend
|
||||
// Verifies payment is confirmed, unused, belongs to this bot
|
||||
const consumed = await consumePaymentForQueue(paymentId, botId)
|
||||
if (!consumed) {
|
||||
throw new Error('Payment already used or does not belong to this bot.')
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
const cooldownUntil = rankedCooldowns.get(botId)
|
||||
if (cooldownUntil && Date.now() < cooldownUntil) {
|
||||
const waitSec = Math.ceil((cooldownUntil - Date.now()) / 1000)
|
||||
releasePayment(paymentId)
|
||||
throw new Error(`Cooldown active. Wait ${waitSec}s.`)
|
||||
}
|
||||
|
||||
// Check if already in a fight
|
||||
if (isInFight(botId)) {
|
||||
releasePayment(paymentId)
|
||||
throw new Error('Bot is already in a fight.')
|
||||
}
|
||||
|
||||
// Load bot
|
||||
const botRows = await db.select().from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
||||
if (botRows.length === 0) throw new Error('Bot not found')
|
||||
if (botRows.length === 0) {
|
||||
releasePayment(paymentId)
|
||||
throw new Error('Bot not found')
|
||||
}
|
||||
const bot = botRows[0]
|
||||
|
||||
if (!bot.isActive) {
|
||||
releasePayment(paymentId)
|
||||
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
|
||||
}
|
||||
|
||||
// NEVER allow mock/classic bots in ranked
|
||||
if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) {
|
||||
releasePayment(paymentId)
|
||||
throw new Error('Practice bots cannot join ranked fights.')
|
||||
}
|
||||
|
||||
@@ -75,6 +89,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
|
||||
if (existing !== -1) {
|
||||
const old = rankedQueue.splice(existing, 1)[0]
|
||||
clearTimeout(old.timeoutHandle)
|
||||
// Release the OLD payment (the new one is already consumed)
|
||||
releasePayment(old.paymentId)
|
||||
old.reject(new Error('Rejoined ranked queue'))
|
||||
}
|
||||
|
||||
@@ -89,8 +105,9 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
|
||||
const opponent = rankedQueue.shift()!
|
||||
clearTimeout(opponent.timeoutHandle)
|
||||
|
||||
// Start ranked fight
|
||||
// Start ranked fight and link both payments
|
||||
const fightId = await runFightAsync(opponent.botId, botId, 'ranked')
|
||||
await linkPaymentsToFight(fightId, [opponent.paymentId, paymentId])
|
||||
opponent.resolve(fightId)
|
||||
return fightId
|
||||
}
|
||||
@@ -103,6 +120,7 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
|
||||
const mock = mockBots[Math.floor(Math.random() * mockBots.length)]
|
||||
console.log(`[ranked-queue] dev: auto-matching ${bot.name} vs mock bot ${mock.name}`)
|
||||
const fightId = await runFightAsync(botId, mock.id, 'ranked')
|
||||
await linkPaymentsToFight(fightId, [paymentId])
|
||||
return fightId
|
||||
}
|
||||
}
|
||||
@@ -113,7 +131,8 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
|
||||
const idx = rankedQueue.findIndex(e => e.botId === botId)
|
||||
if (idx !== -1) {
|
||||
rankedQueue.splice(idx, 1)
|
||||
// Refund the entry fee
|
||||
// Release payment back to refundable state, then refund
|
||||
releasePayment(paymentId)
|
||||
try {
|
||||
await refundEntry(paymentId)
|
||||
reject(new Error('No ranked opponent found — entry fee refunded.'))
|
||||
@@ -146,8 +165,9 @@ export async function leaveRankedQueue(botId: string): Promise<boolean> {
|
||||
const entry = rankedQueue.splice(idx, 1)[0]
|
||||
clearTimeout(entry.timeoutHandle)
|
||||
|
||||
// Refund entry fee
|
||||
// Release payment, then refund
|
||||
try {
|
||||
releasePayment(entry.paymentId)
|
||||
await refundEntry(entry.paymentId)
|
||||
} catch (err) {
|
||||
console.error(`[ranked-queue] refund failed for ${entry.paymentId}:`, err)
|
||||
|
||||
Reference in New Issue
Block a user