diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index b17910e..15b58ae 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -5,6 +5,11 @@ import { generateSpriteSheet, generateJudgeSpriteSheet, generateHumanSpriteSheet import type { Fighter, FightSceneConfig, RoundEvent, FightContext, ChoreoContext } 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 { + 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 { glitchRGB as _glitchRGB, scanlineGlitch as _scanlineGlitch, vhsTracking as _vhsTracking, dimensionalShift as _dimensionalShift } from './fight/effects' 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 { - // THE CREATOR: special move selection — 50% ultimate, 60% creator moves, rest normal + // THE CREATOR: special move selection if (attackerArchetype === 'the_creator') { - const ultimateChance = isCritical ? 1.0 : 0.5 + const ultimateChance = isCritical ? 1.0 : CREATOR_ULTIMATE_CHANCE if (Math.random() < ultimateChance) { // Creator gets ALL ultimates + their 4 exclusive ones 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)] } - // 60% creator-themed, 40% normal selection - if (Math.random() < 0.6) { + if (Math.random() < CREATOR_THEMED_MOVE_RATIO) { return CREATOR_MOVES[Math.floor(Math.random() * CREATOR_MOVES.length)] } // Fall through to normal selection } // 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) { - 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 const roll = isCritical ? ultimateChance * 2 : ultimateChance 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) if (isCritical) { const entry = CHALLENGE_THEMED[challengeType] - // 50% themed, 50% epic generic for crits - if (entry && Math.random() < 0.5) { + if (entry && Math.random() < CRIT_THEMED_RATIO) { return entry.themed[Math.floor(Math.random() * entry.themed.length)] } const critMoves = [ @@ -111,11 +113,10 @@ export function pickChoreography(challengeType: string, isCritical: boolean, _ro return fallback[Math.floor(Math.random() * fallback.length)] } - // 60% themed, 25% generic fallback, 15% wild card const roll = Math.random() - if (roll < 0.6) { + if (roll < CHOREOGRAPHY_THEMED_RATIO) { 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)] } else { const wild = [ diff --git a/frontend/src/game/fight/config.ts b/frontend/src/game/fight/config.ts new file mode 100644 index 0000000..828ce49 --- /dev/null +++ b/frontend/src/game/fight/config.ts @@ -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 = { + 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 diff --git a/frontend/src/game/fight/rounds.ts b/frontend/src/game/fight/rounds.ts index 39eec8f..d463955 100644 --- a/frontend/src/game/fight/rounds.ts +++ b/frontend/src/game/fight/rounds.ts @@ -10,6 +10,15 @@ import { sfxDrumRoll, sfxPowerUp, sfxVineBoom, sfxRandomSilly, sfxRandomComedy, sfxBoneCrack, sfxSlideWhistleDown, sfxDodge, sfxRandomFail, sfxSlideUp, } 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 { HOME_A: number @@ -691,7 +700,7 @@ async function playRound(event: RoundEvent) { const roundBonus = Math.min(2, Math.floor(event.round / 2)) const exchangeCount = 3 + roundBonus + Math.floor(Math.random() * 3) + (intensity > 0.5 ? 1 : 0) // 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 savedScaleAY = fA?.scale.y const savedScaleBX = fB?.scale.x @@ -702,7 +711,7 @@ async function playRound(event: RoundEvent) { let didChallengeVoice = false if (Math.random() < (creatorInFight ? 0.5 : 0.3)) { // Creator fights get existential commentary more often - if (creatorInFight && Math.random() < 0.6) { + if (creatorInFight && Math.random() < CREATOR_VOICE_LINE_CHANCE) { announceCreatorRound(); didChallengeVoice = true } else { const challengeVoice: Record void> = { @@ -883,7 +892,7 @@ async function playRound(event: RoundEvent) { attackerSide = Math.random() < Math.max(0.1, 0.4 - intensity * 0.25) ? loserSide : winnerSide exchangeCritical = false // 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 { // Draw: alternate attackerSide = ex % 2 === 0 ? 'a' : 'b' @@ -898,8 +907,8 @@ async function playRound(event: RoundEvent) { const isCreatorFighter = attackerArch === 'the_creator' // Creator: always morph on ultimates, 70% on devastating crits const shouldMorph = isCreatorFighter - ? (isUltimate || (exchangeCritical && intensity > 0.6 && Math.random() < 0.7)) - : (isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4)) + ? (isUltimate || (exchangeCritical && intensity > CREATOR_MORPH_INTENSITY_THRESHOLD && Math.random() < CREATOR_MORPH_CRIT_CHANCE)) + : (isUltimate || (exchangeCritical && intensity > BOT_MORPH_INTENSITY_THRESHOLD && Math.random() < BOT_MORPH_CRIT_CHANCE)) let morphRevert: (() => Promise) | null = null if (shouldMorph) { 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%) - 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 if (!aWon && !bWon && isLastExchange) { @@ -994,7 +1003,7 @@ async function playRound(event: RoundEvent) { } else { // Decide exchange type: choreography, brawl, or clinch // 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() if (!isLastExchange && exchangeType < physicalChance * 0.5) { @@ -1003,17 +1012,17 @@ async function playRound(event: RoundEvent) { } else if (!isLastExchange && exchangeType < physicalChance) { // CLINCH COMBO: attacker grapples defender at close range await _clinchCombo(attackerSide, intensity) - } else if (!isLastExchange && Math.random() < 0.12) { - // DODGE: defender dodges (reduced from 15% to 12% — more contact, fewer misses) + } else if (!isLastExchange && Math.random() < DODGE_CHANCE) { await playDodge(attackerSide === 'a' ? 'b' : 'a') sfxDodge() - if (Math.random() < 0.4) sfxRandomFail(); else sfxBoing() + if (Math.random() < 0.4) sfxRandomFail() + else sfxBoing() } else { // CHOREOGRAPHY: signature move from the pool await playAttack(attackerSide, choreo, exchangeCritical) // 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 _counterAttack(attackerSide === 'a' ? 'b' : 'a') } @@ -1070,7 +1079,7 @@ async function playRound(event: RoundEvent) { // === THE CREATOR CAMEO === // 6% chance per round (only if neither fighter IS 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 cameoY = GROUND_Y - 80 // Golden portal flash @@ -1195,7 +1204,7 @@ async function playRound(event: RoundEvent) { announceCrowdReaction('cheer') } else if (aWon || bWon) { // 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) if (Math.random() < 0.1) { await k.wait(0.3) diff --git a/server/src/engine/fight-loop.ts b/server/src/engine/fight-loop.ts index ab0a4b7..76563e5 100644 --- a/server/src/engine/fight-loop.ts +++ b/server/src/engine/fight-loop.ts @@ -1,3 +1,4 @@ +import { FIGHT_LOOP_INTERVAL_MS, ELO_MATCHING_RANDOMNESS } from '../lib/constants.js' import { db, schema } from '../db/index.js' import { runMockFight } from './mock.js' import { eq } from 'drizzle-orm' @@ -31,7 +32,7 @@ export interface FightLoopOptions { export async function startFightLoop(options: FightLoopOptions = {}): Promise { const { - intervalMs = 8000, + intervalMs = FIGHT_LOOP_INTERVAL_MS, maxFights = Infinity, matchmakingStyle = 'mixed', onFightStart, @@ -161,8 +162,8 @@ function pickMatchup( const bot = pick(bots) const others = bots.filter(b => b.id !== bot.id) others.sort((a, b) => { - const diffA = Math.abs(a.eloRating - bot.eloRating) + Math.random() * 150 - const diffB = Math.abs(b.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() * ELO_MATCHING_RANDOMNESS return diffA - diffB }) return [bot, others[0]] diff --git a/server/src/engine/mock.ts b/server/src/engine/mock.ts index 2d34760..e4144c7 100644 --- a/server/src/engine/mock.ts +++ b/server/src/engine/mock.ts @@ -1,3 +1,4 @@ +import { STARTING_HP } from '../lib/constants.js' import { nanoid } from 'nanoid' import { logger } from '../lib/logger.js' import { createHash, randomBytes } from 'crypto' @@ -433,8 +434,8 @@ export async function runMockFight(botAId: string, botBId: string): Promise() @@ -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 { - let hpA = 200 - let hpB = 200 + let hpA = STARTING_HP + let hpB = STARTING_HP let comboA = 0 let comboB = 0 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 isPerfect = winnerId && ( - (winnerId === botA.id && hpA === 200) || - (winnerId === botB.id && hpB === 200) + (winnerId === botA.id && hpA === STARTING_HP) || + (winnerId === botB.id && hpB === STARTING_HP) ) // Finalize fight + update bot stats atomically 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 loserEloChange = 0 let newWinnerEloFinal = 0 diff --git a/server/src/engine/scoring.ts b/server/src/engine/scoring.ts index 28f0535..8434171 100644 --- a/server/src/engine/scoring.ts +++ b/server/src/engine/scoring.ts @@ -1,3 +1,4 @@ +import { ELO_DIVISOR, TIER_THRESHOLDS } from '../lib/constants.js' import type { Challenge } from './challenges.js' import { pick } from '../lib/utils.js' import { checkAnswer } from './answers.js' @@ -523,7 +524,7 @@ export function calculateElo( loserElo: number, k: number = 32, ): { 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 return { @@ -534,13 +535,10 @@ export function calculateElo( // Tier calculation export function calculateTier(elo: number, wins: number): number { - if (elo >= 1900 && wins >= 40) return 6 // Legend - if (elo >= 1700 && wins >= 25) return 5 // Diamond - if (elo >= 1500 && wins >= 15) return 4 // Platinum - if (elo >= 1350 && wins >= 7) return 3 // Gold - if (elo >= 1200 && wins >= 3) return 2 // Silver - if (wins >= 1) return 1 // Bronze - return 0 // Baby + for (const t of TIER_THRESHOLDS) { + if (elo >= t.elo && wins >= t.wins) return t.tier + } + return 0 // Baby } export const TIER_NAMES = ['BABY', 'BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'DIAMOND', 'LEGEND'] as const diff --git a/server/src/lib/constants.ts b/server/src/lib/constants.ts new file mode 100644 index 0000000..244231b --- /dev/null +++ b/server/src/lib/constants.ts @@ -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