feat: season framework with 2-week auto-rotation

Create seasons engine with getCurrentSeason(), getSeasonLeaderboard(),
and resetSeasonElo(). 20 Bitcoin-themed season names. Add currentSeason
column to fights table, set on fight creation. Elo soft-reset formula:
finalElo * 0.6 + 1200 * 0.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 20:13:44 +00:00
co-authored by Claude Opus 4.6
parent d22d739666
commit ecac2bf246
5 changed files with 173 additions and 0 deletions
+1
View File
@@ -44,6 +44,7 @@ export const fights = sqliteTable('fights', {
mode: text('mode', { enum: ['free', 'ranked'] }).notNull().default('free'),
potSats: integer('pot_sats').notNull().default(0),
payoutStatus: text('payout_status', { enum: ['pending', 'paid', 'failed'] }),
currentSeason: text('current_season'),
createdAt: text('created_at').notNull(),
})
+2
View File
@@ -104,6 +104,8 @@ export function runMigrations() {
"ALTER TABLE bots ADD COLUMN has_wallet INTEGER NOT NULL DEFAULT 0",
// Zap tracking
"ALTER TABLE bots ADD COLUMN zaps_received INTEGER NOT NULL DEFAULT 0",
// Seasons
"ALTER TABLE fights ADD COLUMN current_season TEXT",
// Classic bots
"ALTER TABLE bots ADD COLUMN bot_type TEXT NOT NULL DEFAULT 'regular'",
]
+2
View File
@@ -6,6 +6,7 @@ import { pickChallenge, type Challenge } from './challenges.js'
import { generateMockRetroResponse } from './retro-moves.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { eq, sql } from 'drizzle-orm'
import { getCurrentSeason } from './seasons.js'
const MOCK_BOTS = [
// Tier 6 - Legends (1900+ Elo, 40+ wins)
@@ -426,6 +427,7 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
botBId: botB.id,
arena: arena.id,
status: 'live',
currentSeason: getCurrentSeason().id,
startedAt: now,
createdAt: now,
})
+2
View File
@@ -12,6 +12,7 @@ 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'
import { getCurrentSeason } from './seasons.js'
interface BotRecord {
id: string
@@ -294,6 +295,7 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena,
mode,
potSats: mode === 'ranked' ? 42 : 0,
payoutStatus: mode === 'ranked' ? 'pending' : undefined,
currentSeason: getCurrentSeason().id,
startedAt: now,
createdAt: now,
})
+166
View File
@@ -0,0 +1,166 @@
import { db, schema } from '../db/index.js'
import { eq, and, gte, lte, or, sql } from 'drizzle-orm'
// Season epoch: March 3, 2026 (a Monday)
const SEASON_EPOCH = new Date('2026-03-03T00:00:00Z').getTime()
const SEASON_DURATION_MS = 14 * 24 * 60 * 60 * 1000 // 2 weeks
const SEASON_NAMES = [
'The Halvening',
'Block 840000',
'Segwit Wars',
'Lightning Strike',
'Satoshi Returns',
'Genesis Block',
'Pizza Day',
'Difficulty Adjustment',
'Mempool Madness',
'Node Runners',
'Proof of Work',
'HODL Season',
'Whitepaper Anniversary',
'Timechain Tournament',
'Cypherpunk Clash',
'Hash Wars',
'Block Size Battle',
'Taproot Takeover',
'Full Node Fury',
'UTXO Showdown',
]
export interface Season {
id: string
number: number
startDate: string
endDate: string
name: string
}
export function getCurrentSeason(): Season {
const now = Date.now()
const elapsed = now - SEASON_EPOCH
const seasonNum = Math.floor(elapsed / SEASON_DURATION_MS) + 1
const startMs = SEASON_EPOCH + (seasonNum - 1) * SEASON_DURATION_MS
const endMs = startMs + SEASON_DURATION_MS
return {
id: `s${seasonNum}`,
number: seasonNum,
startDate: new Date(startMs).toISOString(),
endDate: new Date(endMs).toISOString(),
name: SEASON_NAMES[(seasonNum - 1) % SEASON_NAMES.length],
}
}
export function getSeasonById(seasonId: string): Season | null {
const match = seasonId.match(/^s(\d+)$/)
if (!match) return null
const seasonNum = parseInt(match[1])
if (seasonNum < 1) return null
const startMs = SEASON_EPOCH + (seasonNum - 1) * SEASON_DURATION_MS
const endMs = startMs + SEASON_DURATION_MS
return {
id: `s${seasonNum}`,
number: seasonNum,
startDate: new Date(startMs).toISOString(),
endDate: new Date(endMs).toISOString(),
name: SEASON_NAMES[(seasonNum - 1) % SEASON_NAMES.length],
}
}
interface SeasonLeaderboardEntry {
botId: string
botName: string
archetype: string
tier: number
wins: number
losses: number
eloRating: number
}
export async function getSeasonLeaderboard(seasonId: string): Promise<SeasonLeaderboardEntry[]> {
const season = getSeasonById(seasonId)
if (!season) return []
// Get all finished fights in this season
const fights = await db.select({
botAId: schema.fights.botAId,
botBId: schema.fights.botBId,
winnerId: schema.fights.winnerId,
}).from(schema.fights)
.where(and(
eq(schema.fights.status, 'finished'),
gte(schema.fights.createdAt, season.startDate),
lte(schema.fights.createdAt, season.endDate),
))
// Aggregate wins/losses per bot
const stats = new Map<string, { wins: number; losses: number }>()
for (const f of fights) {
if (!stats.has(f.botAId)) stats.set(f.botAId, { wins: 0, losses: 0 })
if (!stats.has(f.botBId)) stats.set(f.botBId, { wins: 0, losses: 0 })
if (f.winnerId) {
stats.get(f.winnerId)!.wins++
const loserId = f.winnerId === f.botAId ? f.botBId : f.botAId
stats.get(loserId)!.losses++
}
}
if (stats.size === 0) return []
// Fetch bot details
const botIds = [...stats.keys()]
const bots = await db.select({
id: schema.bots.id,
name: schema.bots.name,
archetype: schema.bots.archetype,
tier: schema.bots.tier,
eloRating: schema.bots.eloRating,
botType: schema.bots.botType,
}).from(schema.bots)
const botMap = new Map(bots.map(b => [b.id, b]))
const entries: SeasonLeaderboardEntry[] = []
for (const [botId, s] of stats) {
const bot = botMap.get(botId)
if (!bot || bot.botType === 'classic') continue
entries.push({
botId,
botName: bot.name,
archetype: bot.archetype,
tier: bot.tier,
wins: s.wins,
losses: s.losses,
eloRating: bot.eloRating,
})
}
// Sort by wins desc, then win rate desc
entries.sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins
const rateA = a.wins / (a.wins + a.losses || 1)
const rateB = b.wins / (b.wins + b.losses || 1)
return rateB - rateA
})
return entries
}
export async function resetSeasonElo(): Promise<void> {
const allBots = await db.select({
id: schema.bots.id,
eloRating: schema.bots.eloRating,
}).from(schema.bots)
for (const bot of allBots) {
const newElo = Math.round(bot.eloRating * 0.6 + 1200 * 0.4)
await db.update(schema.bots).set({
eloRating: newElo,
winStreak: 0,
}).where(eq(schema.bots.id, bot.id))
}
}