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:
co-authored by
Claude Opus 4.6
parent
5e598e3987
commit
7761f713d7
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user