refactor: extract magic numbers to named constants
Frontend: choreography selection ratios, morph trigger chances, creator cameo/ultimate chances, dodge/counter probabilities. Server: HP, K-factors, Elo divisor, tier thresholds, fight loop interval. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1731a64ae8
commit
7c8e404cf1
@@ -5,6 +5,11 @@ import { generateSpriteSheet, generateJudgeSpriteSheet, generateHumanSpriteSheet
|
|||||||
import type { Fighter, FightSceneConfig, RoundEvent, FightContext, ChoreoContext } from './fight/types'
|
import type { Fighter, FightSceneConfig, RoundEvent, FightContext, ChoreoContext } from './fight/types'
|
||||||
export type { FightSceneConfig, RoundEvent } from './fight/types'
|
export type { FightSceneConfig, RoundEvent } from './fight/types'
|
||||||
import { ARENA_THEMES, spriteAnims, CHALLENGE_THEMED, TIER_ULTIMATES, CREATOR_MOVES, CREATOR_ULTIMATES } from './fight/constants'
|
import { ARENA_THEMES, spriteAnims, CHALLENGE_THEMED, TIER_ULTIMATES, CREATOR_MOVES, CREATOR_ULTIMATES } from './fight/constants'
|
||||||
|
import {
|
||||||
|
CREATOR_ULTIMATE_CHANCE, CREATOR_THEMED_MOVE_RATIO,
|
||||||
|
CRIT_THEMED_RATIO, CHOREOGRAPHY_THEMED_RATIO, CHOREOGRAPHY_GENERIC_CUTOFF,
|
||||||
|
TIER_ULTIMATE_CHANCES,
|
||||||
|
} from './fight/config'
|
||||||
import { spawnSparks as _spawnSparks, spawnBulletHoles as _spawnBulletHoles, spawnExhaust as _spawnExhaust } from './fight/particles'
|
import { spawnSparks as _spawnSparks, spawnBulletHoles as _spawnBulletHoles, spawnExhaust as _spawnExhaust } from './fight/particles'
|
||||||
import { glitchRGB as _glitchRGB, scanlineGlitch as _scanlineGlitch, vhsTracking as _vhsTracking, dimensionalShift as _dimensionalShift } from './fight/effects'
|
import { glitchRGB as _glitchRGB, scanlineGlitch as _scanlineGlitch, vhsTracking as _vhsTracking, dimensionalShift as _dimensionalShift } from './fight/effects'
|
||||||
import { spawnProjectile as _spawnProjectile } from './fight/projectiles'
|
import { spawnProjectile as _spawnProjectile } from './fight/projectiles'
|
||||||
@@ -46,9 +51,9 @@ import {
|
|||||||
|
|
||||||
|
|
||||||
export function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0, attackerArchetype?: string): string {
|
export function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0, attackerArchetype?: string): string {
|
||||||
// THE CREATOR: special move selection — 50% ultimate, 60% creator moves, rest normal
|
// THE CREATOR: special move selection
|
||||||
if (attackerArchetype === 'the_creator') {
|
if (attackerArchetype === 'the_creator') {
|
||||||
const ultimateChance = isCritical ? 1.0 : 0.5
|
const ultimateChance = isCritical ? 1.0 : CREATOR_ULTIMATE_CHANCE
|
||||||
if (Math.random() < ultimateChance) {
|
if (Math.random() < ultimateChance) {
|
||||||
// Creator gets ALL ultimates + their 4 exclusive ones
|
// Creator gets ALL ultimates + their 4 exclusive ones
|
||||||
const all: string[] = [...CREATOR_ULTIMATES]
|
const all: string[] = [...CREATOR_ULTIMATES]
|
||||||
@@ -57,17 +62,15 @@ export function pickChoreography(challengeType: string, isCritical: boolean, _ro
|
|||||||
}
|
}
|
||||||
return all[Math.floor(Math.random() * all.length)]
|
return all[Math.floor(Math.random() * all.length)]
|
||||||
}
|
}
|
||||||
// 60% creator-themed, 40% normal selection
|
if (Math.random() < CREATOR_THEMED_MOVE_RATIO) {
|
||||||
if (Math.random() < 0.6) {
|
|
||||||
return CREATOR_MOVES[Math.floor(Math.random() * CREATOR_MOVES.length)]
|
return CREATOR_MOVES[Math.floor(Math.random() * CREATOR_MOVES.length)]
|
||||||
}
|
}
|
||||||
// Fall through to normal selection
|
// Fall through to normal selection
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier-gated ultimates: higher tier = higher chance of spectacular ultimate moves
|
// Tier-gated ultimates: higher tier = higher chance of spectacular ultimate moves
|
||||||
// Tier 2: 15% chance, Tier 3: 20%, Tier 4: 30%, Tier 5: 40%
|
|
||||||
if (attackerTier >= 2) {
|
if (attackerTier >= 2) {
|
||||||
const ultimateChance = attackerTier === 2 ? 0.15 : attackerTier === 3 ? 0.20 : attackerTier === 4 ? 0.30 : 0.40
|
const ultimateChance = TIER_ULTIMATE_CHANCES[attackerTier] ?? TIER_ULTIMATE_CHANCES[5]
|
||||||
// On crits, double the chance
|
// On crits, double the chance
|
||||||
const roll = isCritical ? ultimateChance * 2 : ultimateChance
|
const roll = isCritical ? ultimateChance * 2 : ultimateChance
|
||||||
if (Math.random() < roll) {
|
if (Math.random() < roll) {
|
||||||
@@ -85,8 +88,7 @@ export function pickChoreography(challengeType: string, isCritical: boolean, _ro
|
|||||||
// Critical hits always get BIG moves (but can still be themed)
|
// Critical hits always get BIG moves (but can still be themed)
|
||||||
if (isCritical) {
|
if (isCritical) {
|
||||||
const entry = CHALLENGE_THEMED[challengeType]
|
const entry = CHALLENGE_THEMED[challengeType]
|
||||||
// 50% themed, 50% epic generic for crits
|
if (entry && Math.random() < CRIT_THEMED_RATIO) {
|
||||||
if (entry && Math.random() < 0.5) {
|
|
||||||
return entry.themed[Math.floor(Math.random() * entry.themed.length)]
|
return entry.themed[Math.floor(Math.random() * entry.themed.length)]
|
||||||
}
|
}
|
||||||
const critMoves = [
|
const critMoves = [
|
||||||
@@ -111,11 +113,10 @@ export function pickChoreography(challengeType: string, isCritical: boolean, _ro
|
|||||||
return fallback[Math.floor(Math.random() * fallback.length)]
|
return fallback[Math.floor(Math.random() * fallback.length)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 60% themed, 25% generic fallback, 15% wild card
|
|
||||||
const roll = Math.random()
|
const roll = Math.random()
|
||||||
if (roll < 0.6) {
|
if (roll < CHOREOGRAPHY_THEMED_RATIO) {
|
||||||
return entry.themed[Math.floor(Math.random() * entry.themed.length)]
|
return entry.themed[Math.floor(Math.random() * entry.themed.length)]
|
||||||
} else if (roll < 0.85) {
|
} else if (roll < CHOREOGRAPHY_GENERIC_CUTOFF) {
|
||||||
return entry.generic[Math.floor(Math.random() * entry.generic.length)]
|
return entry.generic[Math.floor(Math.random() * entry.generic.length)]
|
||||||
} else {
|
} else {
|
||||||
const wild = [
|
const wild = [
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Gameplay constants — all tunable magic numbers in one place
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// --- Choreography selection probabilities ---
|
||||||
|
export const CREATOR_ULTIMATE_CHANCE = 0.5 // Creator ultimate chance (non-crit); 1.0 on crits
|
||||||
|
export const CREATOR_THEMED_MOVE_RATIO = 0.6 // Creator uses creator-themed moves 60% of the time
|
||||||
|
export const CRIT_THEMED_RATIO = 0.5 // On crits: 50% themed, 50% epic generic
|
||||||
|
export const CHOREOGRAPHY_THEMED_RATIO = 0.6 // Normal: 60% themed
|
||||||
|
export const CHOREOGRAPHY_GENERIC_CUTOFF = 0.85 // Normal: 25% generic (0.60–0.85), 15% wild (0.85–1.0)
|
||||||
|
|
||||||
|
// --- Tier-gated ultimate chances ---
|
||||||
|
export const TIER_ULTIMATE_CHANCES: Record<number, number> = {
|
||||||
|
2: 0.15,
|
||||||
|
3: 0.20,
|
||||||
|
4: 0.30,
|
||||||
|
5: 0.40,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Morph system ---
|
||||||
|
export const CREATOR_MORPH_CRIT_CHANCE = 0.7 // Creator morphs on devastating crits 70%
|
||||||
|
export const CREATOR_MORPH_INTENSITY_THRESHOLD = 0.6 // Creator morphs when intensity > 0.6
|
||||||
|
export const BOT_MORPH_CRIT_CHANCE = 0.4 // Regular bots morph on devastating crits 40%
|
||||||
|
export const BOT_MORPH_INTENSITY_THRESHOLD = 0.7 // Regular bots morph when intensity > 0.7
|
||||||
|
|
||||||
|
// --- Creator cameo ---
|
||||||
|
export const CREATOR_CAMEO_CHANCE = 0.06 // 6% per round in non-creator fights
|
||||||
|
|
||||||
|
// --- Round exchange probabilities ---
|
||||||
|
export const DODGE_CHANCE = 0.12 // Defender dodges instead of being hit
|
||||||
|
export const COUNTER_ATTACK_CHANCE = 0.25 // Defender counters after getting hit
|
||||||
|
export const SCANLINE_GLITCH_CHANCE = 0.25 // Random scanline glitch during exchanges
|
||||||
|
export const COMEDY_SFX_CHANCE = 0.15 // Comedy sound on non-decisive hits
|
||||||
|
export const PHYSICAL_CONTACT_BASE = 0.35 // Base chance for brawl/clinch
|
||||||
|
export const PHYSICAL_CONTACT_INTENSITY_SCALE = 0.2 // Added per unit of intensity
|
||||||
|
export const PHYSICAL_CONTACT_EXCHANGE_BONUS = 0.15 // Added after first exchange
|
||||||
|
export const CROWD_REACTION_CHANCE = 0.4 // Crowd reacts to round results
|
||||||
|
export const MAYBE_SHOW_HUMAN_CHANCE = 0.2 // Show human coach
|
||||||
|
export const CREATOR_VOICE_LINE_CHANCE = 0.6 // Creator gets a voice line
|
||||||
|
|
||||||
|
// --- Hyperdetail (grotesque close-up) ---
|
||||||
|
export const HYPERDETAIL_BASE_CHANCE = 0.05 // Base hyperdetail chance
|
||||||
|
export const HYPERDETAIL_INTENSITY_SCALE = 0.45 // Scales with intensity
|
||||||
@@ -10,6 +10,15 @@ import {
|
|||||||
sfxDrumRoll, sfxPowerUp, sfxVineBoom, sfxRandomSilly, sfxRandomComedy,
|
sfxDrumRoll, sfxPowerUp, sfxVineBoom, sfxRandomSilly, sfxRandomComedy,
|
||||||
sfxBoneCrack, sfxSlideWhistleDown, sfxDodge, sfxRandomFail, sfxSlideUp,
|
sfxBoneCrack, sfxSlideWhistleDown, sfxDodge, sfxRandomFail, sfxSlideUp,
|
||||||
} from '../audio'
|
} from '../audio'
|
||||||
|
import {
|
||||||
|
CREATOR_MORPH_CRIT_CHANCE, CREATOR_MORPH_INTENSITY_THRESHOLD,
|
||||||
|
BOT_MORPH_CRIT_CHANCE, BOT_MORPH_INTENSITY_THRESHOLD,
|
||||||
|
CREATOR_CAMEO_CHANCE, DODGE_CHANCE, COUNTER_ATTACK_CHANCE,
|
||||||
|
SCANLINE_GLITCH_CHANCE, COMEDY_SFX_CHANCE,
|
||||||
|
PHYSICAL_CONTACT_BASE, PHYSICAL_CONTACT_INTENSITY_SCALE, PHYSICAL_CONTACT_EXCHANGE_BONUS,
|
||||||
|
CROWD_REACTION_CHANCE, MAYBE_SHOW_HUMAN_CHANCE, CREATOR_VOICE_LINE_CHANCE,
|
||||||
|
HYPERDETAIL_BASE_CHANCE, HYPERDETAIL_INTENSITY_SCALE,
|
||||||
|
} from './config'
|
||||||
|
|
||||||
interface RoundDeps {
|
interface RoundDeps {
|
||||||
HOME_A: number
|
HOME_A: number
|
||||||
@@ -691,7 +700,7 @@ async function playRound(event: RoundEvent) {
|
|||||||
const roundBonus = Math.min(2, Math.floor(event.round / 2))
|
const roundBonus = Math.min(2, Math.floor(event.round / 2))
|
||||||
const exchangeCount = 3 + roundBonus + Math.floor(Math.random() * 3) + (intensity > 0.5 ? 1 : 0)
|
const exchangeCount = 3 + roundBonus + Math.floor(Math.random() * 3) + (intensity > 0.5 ? 1 : 0)
|
||||||
// Hyperdetail scales with intensity
|
// Hyperdetail scales with intensity
|
||||||
const hyperDetail = Math.random() < (0.05 + intensity * 0.45)
|
const hyperDetail = Math.random() < (HYPERDETAIL_BASE_CHANCE + intensity * HYPERDETAIL_INTENSITY_SCALE)
|
||||||
const savedScaleAX = fA?.scale.x
|
const savedScaleAX = fA?.scale.x
|
||||||
const savedScaleAY = fA?.scale.y
|
const savedScaleAY = fA?.scale.y
|
||||||
const savedScaleBX = fB?.scale.x
|
const savedScaleBX = fB?.scale.x
|
||||||
@@ -702,7 +711,7 @@ async function playRound(event: RoundEvent) {
|
|||||||
let didChallengeVoice = false
|
let didChallengeVoice = false
|
||||||
if (Math.random() < (creatorInFight ? 0.5 : 0.3)) {
|
if (Math.random() < (creatorInFight ? 0.5 : 0.3)) {
|
||||||
// Creator fights get existential commentary more often
|
// Creator fights get existential commentary more often
|
||||||
if (creatorInFight && Math.random() < 0.6) {
|
if (creatorInFight && Math.random() < CREATOR_VOICE_LINE_CHANCE) {
|
||||||
announceCreatorRound(); didChallengeVoice = true
|
announceCreatorRound(); didChallengeVoice = true
|
||||||
} else {
|
} else {
|
||||||
const challengeVoice: Record<string, () => void> = {
|
const challengeVoice: Record<string, () => void> = {
|
||||||
@@ -883,7 +892,7 @@ async function playRound(event: RoundEvent) {
|
|||||||
attackerSide = Math.random() < Math.max(0.1, 0.4 - intensity * 0.25) ? loserSide : winnerSide
|
attackerSide = Math.random() < Math.max(0.1, 0.4 - intensity * 0.25) ? loserSide : winnerSide
|
||||||
exchangeCritical = false
|
exchangeCritical = false
|
||||||
// Occasionally play a comedy sound on non-decisive hits
|
// Occasionally play a comedy sound on non-decisive hits
|
||||||
if (Math.random() < 0.15) { Math.random() < 0.5 ? sfxRandomComedy() : sfxRandomSilly() }
|
if (Math.random() < COMEDY_SFX_CHANCE) { Math.random() < 0.5 ? sfxRandomComedy() : sfxRandomSilly() }
|
||||||
} else {
|
} else {
|
||||||
// Draw: alternate
|
// Draw: alternate
|
||||||
attackerSide = ex % 2 === 0 ? 'a' : 'b'
|
attackerSide = ex % 2 === 0 ? 'a' : 'b'
|
||||||
@@ -898,8 +907,8 @@ async function playRound(event: RoundEvent) {
|
|||||||
const isCreatorFighter = attackerArch === 'the_creator'
|
const isCreatorFighter = attackerArch === 'the_creator'
|
||||||
// Creator: always morph on ultimates, 70% on devastating crits
|
// Creator: always morph on ultimates, 70% on devastating crits
|
||||||
const shouldMorph = isCreatorFighter
|
const shouldMorph = isCreatorFighter
|
||||||
? (isUltimate || (exchangeCritical && intensity > 0.6 && Math.random() < 0.7))
|
? (isUltimate || (exchangeCritical && intensity > CREATOR_MORPH_INTENSITY_THRESHOLD && Math.random() < CREATOR_MORPH_CRIT_CHANCE))
|
||||||
: (isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4))
|
: (isUltimate || (exchangeCritical && intensity > BOT_MORPH_INTENSITY_THRESHOLD && Math.random() < BOT_MORPH_CRIT_CHANCE))
|
||||||
let morphRevert: (() => Promise<void>) | null = null
|
let morphRevert: (() => Promise<void>) | null = null
|
||||||
if (shouldMorph) {
|
if (shouldMorph) {
|
||||||
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter
|
||||||
@@ -985,7 +994,7 @@ async function playRound(event: RoundEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Random scanline glitch during exchanges (25%)
|
// Random scanline glitch during exchanges (25%)
|
||||||
if (Math.random() < 0.25) scanlineGlitch(0.2)
|
if (Math.random() < SCANLINE_GLITCH_CHANCE) scanlineGlitch(0.2)
|
||||||
|
|
||||||
// Play the exchange — mix of choreography, brawls, clinches for ~50% physical contact
|
// Play the exchange — mix of choreography, brawls, clinches for ~50% physical contact
|
||||||
if (!aWon && !bWon && isLastExchange) {
|
if (!aWon && !bWon && isLastExchange) {
|
||||||
@@ -994,7 +1003,7 @@ async function playRound(event: RoundEvent) {
|
|||||||
} else {
|
} else {
|
||||||
// Decide exchange type: choreography, brawl, or clinch
|
// Decide exchange type: choreography, brawl, or clinch
|
||||||
// Brawl/clinch chance increases with later exchanges and higher intensity
|
// Brawl/clinch chance increases with later exchanges and higher intensity
|
||||||
const physicalChance = 0.35 + intensity * 0.2 + (ex > 1 ? 0.15 : 0)
|
const physicalChance = PHYSICAL_CONTACT_BASE + intensity * PHYSICAL_CONTACT_INTENSITY_SCALE + (ex > 1 ? PHYSICAL_CONTACT_EXCHANGE_BONUS : 0)
|
||||||
const exchangeType = Math.random()
|
const exchangeType = Math.random()
|
||||||
|
|
||||||
if (!isLastExchange && exchangeType < physicalChance * 0.5) {
|
if (!isLastExchange && exchangeType < physicalChance * 0.5) {
|
||||||
@@ -1003,17 +1012,17 @@ async function playRound(event: RoundEvent) {
|
|||||||
} else if (!isLastExchange && exchangeType < physicalChance) {
|
} else if (!isLastExchange && exchangeType < physicalChance) {
|
||||||
// CLINCH COMBO: attacker grapples defender at close range
|
// CLINCH COMBO: attacker grapples defender at close range
|
||||||
await _clinchCombo(attackerSide, intensity)
|
await _clinchCombo(attackerSide, intensity)
|
||||||
} else if (!isLastExchange && Math.random() < 0.12) {
|
} else if (!isLastExchange && Math.random() < DODGE_CHANCE) {
|
||||||
// DODGE: defender dodges (reduced from 15% to 12% — more contact, fewer misses)
|
|
||||||
await playDodge(attackerSide === 'a' ? 'b' : 'a')
|
await playDodge(attackerSide === 'a' ? 'b' : 'a')
|
||||||
sfxDodge()
|
sfxDodge()
|
||||||
if (Math.random() < 0.4) sfxRandomFail(); else sfxBoing()
|
if (Math.random() < 0.4) sfxRandomFail()
|
||||||
|
else sfxBoing()
|
||||||
} else {
|
} else {
|
||||||
// CHOREOGRAPHY: signature move from the pool
|
// CHOREOGRAPHY: signature move from the pool
|
||||||
await playAttack(attackerSide, choreo, exchangeCritical)
|
await playAttack(attackerSide, choreo, exchangeCritical)
|
||||||
|
|
||||||
// COUNTER-ATTACK: defender fights back after getting hit (25% chance on non-final exchanges)
|
// COUNTER-ATTACK: defender fights back after getting hit (25% chance on non-final exchanges)
|
||||||
if (!isLastExchange && !exchangeCritical && Math.random() < 0.25) {
|
if (!isLastExchange && !exchangeCritical && Math.random() < COUNTER_ATTACK_CHANCE) {
|
||||||
await k.wait(0.08)
|
await k.wait(0.08)
|
||||||
await _counterAttack(attackerSide === 'a' ? 'b' : 'a')
|
await _counterAttack(attackerSide === 'a' ? 'b' : 'a')
|
||||||
}
|
}
|
||||||
@@ -1070,7 +1079,7 @@ async function playRound(event: RoundEvent) {
|
|||||||
// === THE CREATOR CAMEO ===
|
// === THE CREATOR CAMEO ===
|
||||||
// 6% chance per round (only if neither fighter IS the creator)
|
// 6% chance per round (only if neither fighter IS the creator)
|
||||||
const neitherIsCreator = botA.archetype !== 'the_creator' && botB.archetype !== 'the_creator'
|
const neitherIsCreator = botA.archetype !== 'the_creator' && botB.archetype !== 'the_creator'
|
||||||
if (neitherIsCreator && Math.random() < 0.06) {
|
if (neitherIsCreator && Math.random() < CREATOR_CAMEO_CHANCE) {
|
||||||
const cameoX = k.width() / 2
|
const cameoX = k.width() / 2
|
||||||
const cameoY = GROUND_Y - 80
|
const cameoY = GROUND_Y - 80
|
||||||
// Golden portal flash
|
// Golden portal flash
|
||||||
@@ -1195,7 +1204,7 @@ async function playRound(event: RoundEvent) {
|
|||||||
announceCrowdReaction('cheer')
|
announceCrowdReaction('cheer')
|
||||||
} else if (aWon || bWon) {
|
} else if (aWon || bWon) {
|
||||||
// Regular round win: crowd reacts (40%) — only SFX, no speech
|
// Regular round win: crowd reacts (40%) — only SFX, no speech
|
||||||
if (Math.random() < 0.4) announceCrowdReaction(Math.random() < 0.5 ? 'cheer' : 'applause')
|
if (Math.random() < CROWD_REACTION_CHANCE) announceCrowdReaction(Math.random() < 0.5 ? 'cheer' : 'applause')
|
||||||
// Heartfelt announcer moment (10% chance on normal, non-critical rounds)
|
// Heartfelt announcer moment (10% chance on normal, non-critical rounds)
|
||||||
if (Math.random() < 0.1) {
|
if (Math.random() < 0.1) {
|
||||||
await k.wait(0.3)
|
await k.wait(0.3)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { FIGHT_LOOP_INTERVAL_MS, ELO_MATCHING_RANDOMNESS } from '../lib/constants.js'
|
||||||
import { db, schema } from '../db/index.js'
|
import { db, schema } from '../db/index.js'
|
||||||
import { runMockFight } from './mock.js'
|
import { runMockFight } from './mock.js'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
@@ -31,7 +32,7 @@ export interface FightLoopOptions {
|
|||||||
|
|
||||||
export async function startFightLoop(options: FightLoopOptions = {}): Promise<void> {
|
export async function startFightLoop(options: FightLoopOptions = {}): Promise<void> {
|
||||||
const {
|
const {
|
||||||
intervalMs = 8000,
|
intervalMs = FIGHT_LOOP_INTERVAL_MS,
|
||||||
maxFights = Infinity,
|
maxFights = Infinity,
|
||||||
matchmakingStyle = 'mixed',
|
matchmakingStyle = 'mixed',
|
||||||
onFightStart,
|
onFightStart,
|
||||||
@@ -161,8 +162,8 @@ function pickMatchup(
|
|||||||
const bot = pick(bots)
|
const bot = pick(bots)
|
||||||
const others = bots.filter(b => b.id !== bot.id)
|
const others = bots.filter(b => b.id !== bot.id)
|
||||||
others.sort((a, b) => {
|
others.sort((a, b) => {
|
||||||
const diffA = Math.abs(a.eloRating - bot.eloRating) + Math.random() * 150
|
const diffA = Math.abs(a.eloRating - bot.eloRating) + Math.random() * ELO_MATCHING_RANDOMNESS
|
||||||
const diffB = Math.abs(b.eloRating - bot.eloRating) + Math.random() * 150
|
const diffB = Math.abs(b.eloRating - bot.eloRating) + Math.random() * ELO_MATCHING_RANDOMNESS
|
||||||
return diffA - diffB
|
return diffA - diffB
|
||||||
})
|
})
|
||||||
return [bot, others[0]]
|
return [bot, others[0]]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { STARTING_HP } from '../lib/constants.js'
|
||||||
import { nanoid } from 'nanoid'
|
import { nanoid } from 'nanoid'
|
||||||
import { logger } from '../lib/logger.js'
|
import { logger } from '../lib/logger.js'
|
||||||
import { createHash, randomBytes } from 'crypto'
|
import { createHash, randomBytes } from 'crypto'
|
||||||
@@ -433,8 +434,8 @@ export async function runMockFight(botAId: string, botBId: string): Promise<stri
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
let hpA = 200
|
let hpA = STARTING_HP
|
||||||
let hpB = 200
|
let hpB = STARTING_HP
|
||||||
let comboA = 0
|
let comboA = 0
|
||||||
let comboB = 0
|
let comboB = 0
|
||||||
let winnerId: string | null = null
|
let winnerId: string | null = null
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ interface WebhookResponse {
|
|||||||
error: boolean
|
error: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ROUNDS = 10
|
import { MAX_ROUNDS, KO_THRESHOLD, MAX_RESPONSE_BYTES, STARTING_HP, ELO_K_FACTOR, ELO_K_FACTOR_MOCK } from '../lib/constants.js'
|
||||||
const KO_THRESHOLD = 0
|
|
||||||
const MAX_RESPONSE_BYTES = 10 * 1024 // 10KB
|
|
||||||
|
|
||||||
// Track bots currently in a fight to prevent concurrent fights
|
// Track bots currently in a fight to prevent concurrent fights
|
||||||
const activeFighters = new Set<string>()
|
const activeFighters = new Set<string>()
|
||||||
@@ -333,8 +331,8 @@ async function trackWebhookResult(botId: string, webhookUrl: string, succeeded:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena, mode: 'free' | 'ranked' = 'free'): Promise<void> {
|
async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRecord, arena: Arena, mode: 'free' | 'ranked' = 'free'): Promise<void> {
|
||||||
let hpA = 200
|
let hpA = STARTING_HP
|
||||||
let hpB = 200
|
let hpB = STARTING_HP
|
||||||
let comboA = 0
|
let comboA = 0
|
||||||
let comboB = 0
|
let comboB = 0
|
||||||
let winnerId: string | null = null
|
let winnerId: string | null = null
|
||||||
@@ -447,13 +445,13 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
|
|||||||
|
|
||||||
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : 'nobody'
|
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : 'nobody'
|
||||||
const isPerfect = winnerId && (
|
const isPerfect = winnerId && (
|
||||||
(winnerId === botA.id && hpA === 200) ||
|
(winnerId === botA.id && hpA === STARTING_HP) ||
|
||||||
(winnerId === botB.id && hpB === 200)
|
(winnerId === botB.id && hpB === STARTING_HP)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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 ? ELO_K_FACTOR_MOCK : ELO_K_FACTOR
|
||||||
let winnerEloChange = 0
|
let winnerEloChange = 0
|
||||||
let loserEloChange = 0
|
let loserEloChange = 0
|
||||||
let newWinnerEloFinal = 0
|
let newWinnerEloFinal = 0
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ELO_DIVISOR, TIER_THRESHOLDS } from '../lib/constants.js'
|
||||||
import type { Challenge } from './challenges.js'
|
import type { Challenge } from './challenges.js'
|
||||||
import { pick } from '../lib/utils.js'
|
import { pick } from '../lib/utils.js'
|
||||||
import { checkAnswer } from './answers.js'
|
import { checkAnswer } from './answers.js'
|
||||||
@@ -523,7 +524,7 @@ export function calculateElo(
|
|||||||
loserElo: number,
|
loserElo: number,
|
||||||
k: number = 32,
|
k: number = 32,
|
||||||
): { newWinnerElo: number; newLoserElo: number } {
|
): { newWinnerElo: number; newLoserElo: number } {
|
||||||
const expectedWinner = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / 400))
|
const expectedWinner = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / ELO_DIVISOR))
|
||||||
const expectedLoser = 1 - expectedWinner
|
const expectedLoser = 1 - expectedWinner
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -534,13 +535,10 @@ export function calculateElo(
|
|||||||
|
|
||||||
// Tier calculation
|
// Tier calculation
|
||||||
export function calculateTier(elo: number, wins: number): number {
|
export function calculateTier(elo: number, wins: number): number {
|
||||||
if (elo >= 1900 && wins >= 40) return 6 // Legend
|
for (const t of TIER_THRESHOLDS) {
|
||||||
if (elo >= 1700 && wins >= 25) return 5 // Diamond
|
if (elo >= t.elo && wins >= t.wins) return t.tier
|
||||||
if (elo >= 1500 && wins >= 15) return 4 // Platinum
|
}
|
||||||
if (elo >= 1350 && wins >= 7) return 3 // Gold
|
return 0 // Baby
|
||||||
if (elo >= 1200 && wins >= 3) return 2 // Silver
|
|
||||||
if (wins >= 1) return 1 // Bronze
|
|
||||||
return 0 // Baby
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TIER_NAMES = ['BABY', 'BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND', 'LEGEND'] as const
|
export const TIER_NAMES = ['BABY', 'BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND', 'LEGEND'] as const
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Server constants — all tunable magic numbers in one place
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// --- Fight rules ---
|
||||||
|
export const MAX_ROUNDS = 10
|
||||||
|
export const KO_THRESHOLD = 0
|
||||||
|
export const STARTING_HP = 200
|
||||||
|
export const MAX_RESPONSE_BYTES = 10 * 1024 // 10KB
|
||||||
|
export const MAX_ANSWER_LENGTH = 2000
|
||||||
|
export const MAX_TRASH_TALK_LENGTH = 200
|
||||||
|
|
||||||
|
// --- Elo ---
|
||||||
|
export const ELO_K_FACTOR = 32 // K-factor for real fights
|
||||||
|
export const ELO_K_FACTOR_MOCK = 12 // Dampened K-factor for mock/classic fights
|
||||||
|
export const ELO_DIVISOR = 400 // Standard Elo divisor
|
||||||
|
|
||||||
|
// --- Tier thresholds ---
|
||||||
|
export const TIER_THRESHOLDS = [
|
||||||
|
{ tier: 6, elo: 1900, wins: 40 }, // Legend
|
||||||
|
{ tier: 5, elo: 1700, wins: 25 }, // Diamond
|
||||||
|
{ tier: 4, elo: 1500, wins: 15 }, // Platinum
|
||||||
|
{ tier: 3, elo: 1350, wins: 7 }, // Gold
|
||||||
|
{ tier: 2, elo: 1200, wins: 3 }, // Silver
|
||||||
|
{ tier: 1, elo: 0, wins: 1 }, // Bronze
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// --- Default challenge timeout ---
|
||||||
|
export const DEFAULT_CHALLENGE_TIMEOUT_MS = 8000
|
||||||
|
|
||||||
|
// --- Fight loop ---
|
||||||
|
export const FIGHT_LOOP_INTERVAL_MS = 8000
|
||||||
|
export const ELO_MATCHING_RANDOMNESS = 150
|
||||||
Reference in New Issue
Block a user