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:
co-authored by
Claude Opus 4.6
parent
d22d739666
commit
ecac2bf246
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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'",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user