From 7761f713d7c4d9cd755341c744697c25492eb0aa Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 20:01:11 +0000 Subject: [PATCH] feat: publish notable fight results to Nostr relays NIP-01 kind:1 notes posted for KOs, perfects, upsets, and big Elo swings. Includes bot names, Elo changes, arena, and replay link. Configurable via BOTFIGHTS_NOSTR_RELAYS and BOTFIGHTS_NOSTR_NSEC env vars. Silently skips if NSEC not set. Co-Authored-By: Claude Opus 4.6 --- server/src/engine/nostr-publish.ts | 82 ++++++++++++++++++++++++++++++ server/src/engine/orchestrator.ts | 26 ++++++++++ 2 files changed, 108 insertions(+) create mode 100644 server/src/engine/nostr-publish.ts diff --git a/server/src/engine/nostr-publish.ts b/server/src/engine/nostr-publish.ts new file mode 100644 index 0000000..9427918 --- /dev/null +++ b/server/src/engine/nostr-publish.ts @@ -0,0 +1,82 @@ +// Publish notable fight results to Nostr relays as kind:1 notes +import { finalizeEvent, getPublicKey } from 'nostr-tools' +import { Relay } from 'nostr-tools/relay' +import { hexToBytes } from 'nostr-tools/utils' + +const RELAYS = (process.env.BOTFIGHTS_NOSTR_RELAYS || 'wss://relay.damus.io').split(',').map(r => r.trim()) +const NSEC = process.env.BOTFIGHTS_NOSTR_NSEC || '' +const SITE_URL = process.env.BOTFIGHTS_URL || 'https://botfights.io' + +interface FightResult { + fightId: string + winnerName: string + loserName: string + winnerId: string + winnerElo: number + loserElo: number + winnerEloChange: number + loserEloChange: number + isPerfect: boolean + isKO: boolean + isUpset: boolean + totalRounds: number + arena: string +} + +function isNotable(result: FightResult): boolean { + return result.isKO || result.isPerfect || result.isUpset || + Math.abs(result.winnerEloChange) >= 20 +} + +function formatNote(result: FightResult): string { + const lines: string[] = [] + + if (result.isPerfect) { + lines.push(`FLAWLESS VICTORY! ${result.winnerName} demolished ${result.loserName} with a PERFECT finish!`) + } else if (result.isKO) { + lines.push(`KO! ${result.winnerName} knocked out ${result.loserName}!`) + } else if (result.isUpset) { + lines.push(`UPSET! ${result.winnerName} (${result.winnerElo - result.winnerEloChange} Elo) defeats ${result.loserName} (${result.loserElo - result.loserEloChange} Elo)!`) + } else { + lines.push(`${result.winnerName} defeats ${result.loserName}!`) + } + + lines.push(``) + lines.push(`${result.winnerName}: ${result.winnerElo} Elo (${result.winnerEloChange >= 0 ? '+' : ''}${result.winnerEloChange})`) + lines.push(`${result.loserName}: ${result.loserElo} Elo (${result.loserEloChange >= 0 ? '+' : ''}${result.loserEloChange})`) + lines.push(`Arena: ${result.arena} | Rounds: ${result.totalRounds}`) + lines.push(``) + lines.push(`Watch the replay: ${SITE_URL}/arena/${result.fightId}`) + lines.push(``) + lines.push(`#botfights #bitcoin #nostr`) + + return lines.join('\n') +} + +export async function publishFightResult(result: FightResult): Promise { + if (!NSEC) return + if (!isNotable(result)) return + + const secretKey = hexToBytes(NSEC) + const content = formatNote(result) + + const event = finalizeEvent({ + kind: 1, + content, + tags: [ + ['t', 'botfights'], + ['t', 'bitcoin'], + ], + created_at: Math.floor(Date.now() / 1000), + }, secretKey) + + for (const relayUrl of RELAYS) { + try { + const relay = await Relay.connect(relayUrl) + await relay.publish(event) + relay.close() + } catch (err) { + console.warn(`[nostr] Failed to publish to ${relayUrl}:`, err) + } + } +} diff --git a/server/src/engine/orchestrator.ts b/server/src/engine/orchestrator.ts index 20723e0..169dcf9 100644 --- a/server/src/engine/orchestrator.ts +++ b/server/src/engine/orchestrator.ts @@ -11,6 +11,7 @@ import { setCooldown } from './queue.js' import { isHumanPlayer, waitForHumanResponse } from './human-responses.js' import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js' import { settleBets, lockBets } from './betting.js' +import { publishFightResult } from './nostr-publish.js' interface BotRecord { id: string @@ -333,12 +334,14 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec let comboA = 0 let comboB = 0 let winnerId: string | null = null + let lastRound = 0 const usedTypes = new Set() // Pick a random round for retro mode (rounds 3-8, ensuring it's not too early or late) const retroRound = 3 + Math.floor(Math.random() * Math.min(6, MAX_ROUNDS - 4)) for (let round = 1; round <= MAX_ROUNDS; round++) { + lastRound = round const challenge = round === retroRound ? generateRetroChallenge() : pickChallenge(usedTypes, arena.modifier) @@ -447,6 +450,10 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec // Finalize fight + update bot stats atomically const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) || isClassicBot(botA.webhookUrl) || isClassicBot(botB.webhookUrl) const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock/classic fights + let winnerEloChange = 0 + let loserEloChange = 0 + let newWinnerEloFinal = 0 + let newLoserEloFinal = 0 const finalize = sqlite.transaction(() => { // Mark fight finished @@ -463,6 +470,10 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec const loser = winnerId === botA.id ? botB : botA const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating, kFactor) + winnerEloChange = Math.round(newWinnerElo - winner.eloRating) + loserEloChange = Math.round(newLoserElo - loser.eloRating) + newWinnerEloFinal = newWinnerElo + newLoserEloFinal = newLoserElo const newWinStreak = winner.winStreak + 1 const newBestStreak = Math.max(winner.bestStreak, newWinStreak) @@ -510,6 +521,21 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec potSats: mode === 'ranked' ? 42 : 0, }) + // Publish notable results to Nostr + if (winnerId) { + const winner = winnerId === botA.id ? botA : botB + const loser = winnerId === botA.id ? botB : botA + const isUpset = loser.eloRating - winner.eloRating > 150 + const isKO = (winnerId === botA.id && hpB <= 0) || (winnerId === botB.id && hpA <= 0) + publishFightResult({ + fightId, winnerName: winner.name, loserName: loser.name, winnerId, + winnerElo: newWinnerEloFinal, loserElo: newLoserEloFinal, + winnerEloChange, loserEloChange, + isPerfect: !!isPerfect, isKO, isUpset, + totalRounds: lastRound, arena: arena.name, + }).catch(err => console.warn('[nostr] publish failed:', err)) + } + // Settle bets try { const settlements = await settleBets(fightId, winnerId)