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:
Dorian
2026-03-08 12:08:18 +00:00
co-authored by Claude Opus 4.6
parent 8a3480e5ab
commit 6d390f69b2
14 changed files with 1513 additions and 104 deletions
+78 -5
View File
@@ -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 }