167 lines
4.4 KiB
TypeScript
167 lines
4.4 KiB
TypeScript
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))
|
||
|
|
}
|
||
|
|
}
|