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'),
|
mode: text('mode', { enum: ['free', 'ranked'] }).notNull().default('free'),
|
||||||
potSats: integer('pot_sats').notNull().default(0),
|
potSats: integer('pot_sats').notNull().default(0),
|
||||||
payoutStatus: text('payout_status', { enum: ['pending', 'paid', 'failed'] }),
|
payoutStatus: text('payout_status', { enum: ['pending', 'paid', 'failed'] }),
|
||||||
|
currentSeason: text('current_season'),
|
||||||
createdAt: text('created_at').notNull(),
|
createdAt: text('created_at').notNull(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ export function runMigrations() {
|
|||||||
"ALTER TABLE bots ADD COLUMN has_wallet INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE bots ADD COLUMN has_wallet INTEGER NOT NULL DEFAULT 0",
|
||||||
// Zap tracking
|
// Zap tracking
|
||||||
"ALTER TABLE bots ADD COLUMN zaps_received INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE bots ADD COLUMN zaps_received INTEGER NOT NULL DEFAULT 0",
|
||||||
|
// Seasons
|
||||||
|
"ALTER TABLE fights ADD COLUMN current_season TEXT",
|
||||||
// Classic bots
|
// Classic bots
|
||||||
"ALTER TABLE bots ADD COLUMN bot_type TEXT NOT NULL DEFAULT 'regular'",
|
"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 { generateMockRetroResponse } from './retro-moves.js'
|
||||||
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
|
||||||
import { eq, sql } from 'drizzle-orm'
|
import { eq, sql } from 'drizzle-orm'
|
||||||
|
import { getCurrentSeason } from './seasons.js'
|
||||||
|
|
||||||
const MOCK_BOTS = [
|
const MOCK_BOTS = [
|
||||||
// Tier 6 - Legends (1900+ Elo, 40+ wins)
|
// Tier 6 - Legends (1900+ Elo, 40+ wins)
|
||||||
@@ -426,6 +427,7 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
|
|||||||
botBId: botB.id,
|
botBId: botB.id,
|
||||||
arena: arena.id,
|
arena: arena.id,
|
||||||
status: 'live',
|
status: 'live',
|
||||||
|
currentSeason: getCurrentSeason().id,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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'
|
import { publishFightResult } from './nostr-publish.js'
|
||||||
|
import { getCurrentSeason } from './seasons.js'
|
||||||
|
|
||||||
interface BotRecord {
|
interface BotRecord {
|
||||||
id: string
|
id: string
|
||||||
@@ -294,6 +295,7 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena,
|
|||||||
mode,
|
mode,
|
||||||
potSats: mode === 'ranked' ? 42 : 0,
|
potSats: mode === 'ranked' ? 42 : 0,
|
||||||
payoutStatus: mode === 'ranked' ? 'pending' : undefined,
|
payoutStatus: mode === 'ranked' ? 'pending' : undefined,
|
||||||
|
currentSeason: getCurrentSeason().id,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
createdAt: 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