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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 20:01:11 +00:00
co-authored by Claude Opus 4.6
parent 5e598e3987
commit 7761f713d7
2 changed files with 108 additions and 0 deletions
+82
View File
@@ -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<void> {
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)
}
}
}
+26
View File
@@ -11,6 +11,7 @@ import { setCooldown } from './queue.js'
import { isHumanPlayer, waitForHumanResponse } from './human-responses.js' import { isHumanPlayer, waitForHumanResponse } from './human-responses.js'
import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js' import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js'
import { settleBets, lockBets } from './betting.js' import { settleBets, lockBets } from './betting.js'
import { publishFightResult } from './nostr-publish.js'
interface BotRecord { interface BotRecord {
id: string id: string
@@ -333,12 +334,14 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
let comboA = 0 let comboA = 0
let comboB = 0 let comboB = 0
let winnerId: string | null = null let winnerId: string | null = null
let lastRound = 0
const usedTypes = new Set<string>() const usedTypes = new Set<string>()
// Pick a random round for retro mode (rounds 3-8, ensuring it's not too early or late) // 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)) const retroRound = 3 + Math.floor(Math.random() * Math.min(6, MAX_ROUNDS - 4))
for (let round = 1; round <= MAX_ROUNDS; round++) { for (let round = 1; round <= MAX_ROUNDS; round++) {
lastRound = round
const challenge = round === retroRound const challenge = round === retroRound
? generateRetroChallenge() ? generateRetroChallenge()
: pickChallenge(usedTypes, arena.modifier) : pickChallenge(usedTypes, arena.modifier)
@@ -447,6 +450,10 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
// Finalize fight + update bot stats atomically // Finalize fight + update bot stats atomically
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) || isClassicBot(botA.webhookUrl) || isClassicBot(botB.webhookUrl) 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 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(() => { const finalize = sqlite.transaction(() => {
// Mark fight finished // Mark fight finished
@@ -463,6 +470,10 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
const loser = winnerId === botA.id ? botB : botA const loser = winnerId === botA.id ? botB : botA
const { newWinnerElo, newLoserElo } = calculateElo(winner.eloRating, loser.eloRating, kFactor) 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 newWinStreak = winner.winStreak + 1
const newBestStreak = Math.max(winner.bestStreak, newWinStreak) 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, 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 // Settle bets
try { try {
const settlements = await settleBets(fightId, winnerId) const settlements = await settleBets(fightId, winnerId)