From 7b82803868e17be2e2475d11e3b0b614ec04ff4e Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 07:18:57 +0000 Subject: [PATCH] fix: allow Google Fonts in CSP so 3D announcement font loads in production fontSrc blocked fonts.gstatic.com and styleSrc blocked fonts.googleapis.com, causing Honk font to fail in production. The chromatic aberration pseudo-elements then appeared as doubled plain text instead of a styled 3D effect. Co-Authored-By: Claude Opus 4.6 --- frontend/src/game/FightScene.ts | 72 +++++++++++++++++-------------- frontend/src/game/fight/config.ts | 36 +++++++++++++++- frontend/src/game/fight/rounds.ts | 2 +- server/src/app.ts | 4 +- server/src/engine/scoring.ts | 16 ++++++- server/src/lib/constants.ts | 43 ++++++++++++++++++ 6 files changed, 135 insertions(+), 38 deletions(-) diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index f769d2e..97f19d6 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -7,7 +7,15 @@ export type { FightSceneConfig, RoundEvent } from './fight/types' import { ARENA_THEMES, spriteAnims } from './fight/constants' import { pickChoreography } from './fight/choreography/picker' export { pickChoreography } -import { isPerfMode, perfParticles, isReducedMotion, haptic } from './fight/config' +import { + isPerfMode, perfParticles, isReducedMotion, haptic, + GROUND_Y_RATIO, HOME_A_RATIO, HOME_B_RATIO, REFERENCE_HEIGHT, SPRITE_LOAD_TIMEOUT_MS, + FIGHTER_BASE_SCALE, FIGHTER_TIER_SCALE, JUDGE_SCALE, HUMAN_COACH_SCALE, HUMAN_RUN_SCALE, + AMBIENT_PARTICLE_COUNT, SKY_GRADIENT_BANDS, GROUND_DEPTH_LINES, PERSPECTIVE_GRID_LINES, PILLAR_WIDTH, + SPEECH_BUBBLE_MAX_CHARS, SPEECH_BUBBLE_WRAP, SPEECH_BUBBLE_MAX_LINES, SPEECH_BUBBLE_FONT_SIZE, SPEECH_BUBBLE_DEFAULT_DURATION, + HUMAN_SHOW_CHANCE, HUMAN_COACH_CHANCE, HUMAN_RUN_CHANCE, + HAPTIC_MIN_SHAKE, HAPTIC_SCALE, +} 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' @@ -63,7 +71,7 @@ export async function createFightScene(config: FightSceneConfig) { // Wrap k.shake to support reduced motion + haptic feedback const _origShake = k.shake.bind(k) k.shake = ((intensity: number) => { - if (intensity >= 3) haptic(Math.min(100, intensity * 8)) + if (intensity >= HAPTIC_MIN_SHAKE) haptic(Math.min(100, intensity * HAPTIC_SCALE)) if (!isReducedMotion()) _origShake(intensity) }) as typeof k.shake @@ -102,7 +110,7 @@ export async function createFightScene(config: FightSceneConfig) { try { await Promise.race([ k.loadSprite(name, src, opts), - new Promise((_resolve, reject) => setTimeout(() => reject(new Error('timeout')), 5_000)), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error('timeout')), SPRITE_LOAD_TIMEOUT_MS)), ]) } catch (err) { console.warn(`[FightScene] Sprite '${name}' failed/timed out:`, err) @@ -185,10 +193,10 @@ export async function createFightScene(config: FightSceneConfig) { const vhsTracking = (duration: number = 0.4) => _vhsTracking(fightCtx, duration) const dimensionalShift = (duration: number = 0.6) => _dimensionalShift(fightCtx, duration) - const SF = Math.min(1, H / 500) - const GROUND_Y = H * 0.78 - const HOME_A = W * 0.28 - const HOME_B = W * 0.72 + const SF = Math.min(1, H / REFERENCE_HEIGHT) + const GROUND_Y = H * GROUND_Y_RATIO + const HOME_A = W * HOME_A_RATIO + const HOME_B = W * HOME_B_RATIO @@ -356,8 +364,8 @@ export async function createFightScene(config: FightSceneConfig) { // === SKY / BACKGROUND ATMOSPHERE === // Vertical gradient backdrop — 6 bands from dark ceiling to ground horizon const bgBase = theme.bg - for (let band = 0; band < 6; band++) { - const bandH = GROUND_Y / 6 + for (let band = 0; band < SKY_GRADIENT_BANDS; band++) { + const bandH = GROUND_Y / SKY_GRADIENT_BANDS const brighten = band * 3 k.add([ k.rect(W, bandH + 1), k.pos(0, band * bandH), @@ -379,14 +387,14 @@ export async function createFightScene(config: FightSceneConfig) { // Secondary highlight k.add([k.rect(W, 1), k.pos(0, GROUND_Y + 2), k.color(safeColor(k,theme.accent)), k.opacity(0.3)]) // Ground gradient — fades deeper - for (let i = 1; i <= 12; i++) { + for (let i = 1; i <= GROUND_DEPTH_LINES; i++) { k.add([k.rect(W, 2), k.pos(0, GROUND_Y + 3 + i * 6), k.color(safeColor(k,theme.accent)), k.opacity(0.1 - i * 0.007)]) } // Perspective grid on the floor — converging lines for 3D depth const vanishX = W / 2 const vanishY = GROUND_Y * 0.6 - for (let i = 0; i <= 24; i++) { - const x = i * (W / 24) + for (let i = 0; i <= PERSPECTIVE_GRID_LINES; i++) { + const x = i * (W / PERSPECTIVE_GRID_LINES) // Lines from floor edge pointing toward vanishing point const dx = x - vanishX const topX = vanishX + dx * 0.3 @@ -405,15 +413,15 @@ export async function createFightScene(config: FightSceneConfig) { // === SIDE PILLARS / FRAME === // Subtle dark pillars on edges for framing - k.add([k.rect(8, GROUND_Y), k.pos(0, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)]) - k.add([k.rect(8, GROUND_Y), k.pos(W - 8, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)]) + k.add([k.rect(PILLAR_WIDTH, GROUND_Y), k.pos(0, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)]) + k.add([k.rect(PILLAR_WIDTH, GROUND_Y), k.pos(W - PILLAR_WIDTH, 0), k.color(safeColor(k,'#000000')), k.opacity(0.3)]) // Accent trim on pillars - k.add([k.rect(1, GROUND_Y), k.pos(8, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)]) - k.add([k.rect(1, GROUND_Y), k.pos(W - 9, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)]) + k.add([k.rect(1, GROUND_Y), k.pos(PILLAR_WIDTH, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)]) + k.add([k.rect(1, GROUND_Y), k.pos(W - PILLAR_WIDTH - 1, 0), k.color(safeColor(k,theme.accent)), k.opacity(0.15)]) // === AMBIENT PARTICLES === // Floating dust/embers in the air (subtle, always present) - for (let i = 0; i < 18; i++) { + for (let i = 0; i < AMBIENT_PARTICLE_COUNT; i++) { const particle = k.add([ k.circle(0.5 + Math.random() * 1.5), k.pos(Math.random() * W, Math.random() * GROUND_Y), @@ -454,16 +462,16 @@ export async function createFightScene(config: FightSceneConfig) { k.sprite('judge', { anim: 'idle' }), k.pos(CHAIR_X, CHAIR_SEAT_Y), k.anchor('bot'), - k.scale(1.3 * SF), + k.scale(JUDGE_SCALE * SF), k.z(4), k.opacity(0.9), 'judge', ]) - const scaleA = (1.8 + botA.tier * 0.4) * SF + const scaleA = (FIGHTER_BASE_SCALE + botA.tier * FIGHTER_TIER_SCALE) * SF k.add([k.sprite('botA', { anim: 'idle' }), k.pos(-100, GROUND_Y - 6), k.anchor('bot'), k.scale(scaleA), k.z(10), k.opacity(0), 'fighterA']) - const scaleB = (1.8 + botB.tier * 0.4) * SF + const scaleB = (FIGHTER_BASE_SCALE + botB.tier * FIGHTER_TIER_SCALE) * SF k.add([k.sprite('botB', { anim: 'idle' }), k.pos(W + 100, GROUND_Y - 6), k.anchor('bot'), k.scale(-scaleB, scaleB), k.z(10), k.opacity(0), 'fighterB']) config.onReady?.() @@ -599,7 +607,7 @@ export async function createFightScene(config: FightSceneConfig) { els.length = 0 } - function showSpeechBubble(side: 'a' | 'b', text: string, duration: number = 3) { + function showSpeechBubble(side: 'a' | 'b', text: string, duration: number = SPEECH_BUBBLE_DEFAULT_DURATION) { const fighter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter if (!fighter || !text) return @@ -612,10 +620,9 @@ export async function createFightScene(config: FightSceneConfig) { // Sanitize and truncate const clean = safeText(text) if (!clean) return - const maxLen = 80 - const display = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '...' : clean - const lines = wrapBubbleText(display, 18) - const fontSize = 12 + const display = clean.length > SPEECH_BUBBLE_MAX_CHARS ? clean.slice(0, SPEECH_BUBBLE_MAX_CHARS - 1) + '...' : clean + const lines = wrapBubbleText(display, SPEECH_BUBBLE_WRAP) + const fontSize = SPEECH_BUBBLE_FONT_SIZE const lineH = fontSize + 4 const padX = 10, padY = 8 const textW = Math.max(60, Math.min(lines.reduce((m, l) => Math.max(m, l.length * (fontSize * 0.6)), 0) + 8, W * 0.38)) @@ -740,9 +747,8 @@ export async function createFightScene(config: FightSceneConfig) { } } if (line) lines.push(line) - // Max 5 lines, last line truncated - if (lines.length > 5) { - const truncated = [...lines.slice(0, 4), lines.slice(4).join(' ').slice(0, maxChars - 3) + '...'] + if (lines.length > SPEECH_BUBBLE_MAX_LINES) { + const truncated = [...lines.slice(0, SPEECH_BUBBLE_MAX_LINES - 1), lines.slice(SPEECH_BUBBLE_MAX_LINES - 1).join(' ').slice(0, maxChars - 3) + '...'] return truncated } return lines @@ -892,7 +898,7 @@ export async function createFightScene(config: FightSceneConfig) { k.sprite(spriteId, { anim: mood }), k.pos(homeX, GROUND_Y - 4), k.anchor('bot'), - k.scale(side === 'a' ? 1.2 : -1.2, 1.2), + k.scale(side === 'a' ? HUMAN_COACH_SCALE : -HUMAN_COACH_SCALE, HUMAN_COACH_SCALE), k.z(5), k.opacity(0), tag, @@ -917,7 +923,7 @@ export async function createFightScene(config: FightSceneConfig) { k.sprite(spriteId, { anim: 'run' }), k.pos(startX, GROUND_Y - 4), k.anchor('bot'), - k.scale(fromLeft ? 1.3 : -1.3, 1.3), + k.scale(fromLeft ? HUMAN_RUN_SCALE : -HUMAN_RUN_SCALE, HUMAN_RUN_SCALE), k.z(8), k.opacity(0.9), ]) @@ -971,16 +977,16 @@ export async function createFightScene(config: FightSceneConfig) { /** Random chance to show a human moment — called between rounds */ async function maybeShowHuman() { const roll = Math.random() - if (roll > 0.15) return // 15% chance per round + if (roll > HUMAN_SHOW_CHANCE) return const side: 'a' | 'b' = Math.random() < 0.5 ? 'a' : 'b' const action = Math.random() - if (action < 0.4) { + if (action < HUMAN_COACH_CHANCE) { // Coach appearance (most common) const mood = Math.random() < 0.5 ? 'cheer' : 'panic' await spawnHumanCoach(side, mood) - } else if (action < 0.7) { + } else if (action < HUMAN_RUN_CHANCE) { // Run across screen await humanRunAcross(side) } else { diff --git a/frontend/src/game/fight/config.ts b/frontend/src/game/fight/config.ts index 2e9d539..e57dfc5 100644 --- a/frontend/src/game/fight/config.ts +++ b/frontend/src/game/fight/config.ts @@ -80,9 +80,43 @@ export const PHYSICAL_CONTACT_BASE = 0.35 // Base chance for brawl/cli 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 + +// --- Scene layout --- +export const GROUND_Y_RATIO = 0.78 // Ground line as fraction of canvas height +export const HOME_A_RATIO = 0.28 // Fighter A home X position ratio +export const HOME_B_RATIO = 0.72 // Fighter B home X position ratio +export const REFERENCE_HEIGHT = 500 // Reference canvas height for scale factor +export const SPRITE_LOAD_TIMEOUT_MS = 5_000 // Max wait for sprite loading (mobile hang prevention) + +// --- Fighter scaling --- +export const FIGHTER_BASE_SCALE = 1.8 // Base fighter sprite scale +export const FIGHTER_TIER_SCALE = 0.4 // Additional scale per tier level +export const JUDGE_SCALE = 1.3 // Judge sprite scale +export const HUMAN_COACH_SCALE = 1.2 // Human coach sprite scale +export const HUMAN_RUN_SCALE = 1.3 // Human run-across sprite scale + +// --- Scene visual constants --- +export const AMBIENT_PARTICLE_COUNT = 18 // Floating dust/embers in air +export const SKY_GRADIENT_BANDS = 6 // Number of sky gradient bands +export const GROUND_DEPTH_LINES = 12 // Ground gradient fade lines +export const PERSPECTIVE_GRID_LINES = 24 // Floor grid convergence lines +export const PILLAR_WIDTH = 8 // Side pillar frame width +export const SPEECH_BUBBLE_MAX_CHARS = 80 // Max chars in speech bubble +export const SPEECH_BUBBLE_WRAP = 18 // Chars per line in speech bubble +export const SPEECH_BUBBLE_MAX_LINES = 5 // Max lines in speech bubble +export const SPEECH_BUBBLE_FONT_SIZE = 12 // Speech bubble font size +export const SPEECH_BUBBLE_DEFAULT_DURATION = 3 // Default speech bubble duration (seconds) + +// --- Human appearance --- +export const HUMAN_SHOW_CHANCE = 0.15 // Chance per round for human appearance +export const HUMAN_COACH_CHANCE = 0.4 // Of appearances: coach (most common) +export const HUMAN_RUN_CHANCE = 0.7 // Of appearances: run across (cumulative cutoff) + +// --- Haptic scaling --- +export const HAPTIC_MIN_SHAKE = 3 // Min shake intensity to trigger haptic +export const HAPTIC_SCALE = 8 // Haptic ms = intensity * scale (capped 100) diff --git a/frontend/src/game/fight/rounds.ts b/frontend/src/game/fight/rounds.ts index 49ddc86..295b9d0 100644 --- a/frontend/src/game/fight/rounds.ts +++ b/frontend/src/game/fight/rounds.ts @@ -17,7 +17,7 @@ import { 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, + CROWD_REACTION_CHANCE, CREATOR_VOICE_LINE_CHANCE, HYPERDETAIL_BASE_CHANCE, HYPERDETAIL_INTENSITY_SCALE, } from './config' diff --git a/server/src/app.ts b/server/src/app.ts index df90e1f..26511ff 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -41,10 +41,10 @@ app.use('*', secureHeaders({ contentSecurityPolicy: process.env.NODE_ENV === 'production' ? { defaultSrc: ["'self'"], scriptSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], + styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'], imgSrc: ["'self'", 'data:', 'blob:'], connectSrc: ["'self'"], - fontSrc: ["'self'"], + fontSrc: ["'self'", 'https://fonts.gstatic.com'], } : undefined, })) diff --git a/server/src/engine/scoring.ts b/server/src/engine/scoring.ts index 8434171..84ea8d6 100644 --- a/server/src/engine/scoring.ts +++ b/server/src/engine/scoring.ts @@ -1,4 +1,18 @@ -import { ELO_DIVISOR, TIER_THRESHOLDS } from '../lib/constants.js' +import { + ELO_DIVISOR, TIER_THRESHOLDS, + TIMEOUT_DAMAGE_MULTIPLIER, CRITICAL_MARGIN_THRESHOLD, CRITICAL_DAMAGE_MULTIPLIER, + MARGIN_TO_DAMAGE_SCALE, LOSER_DAMAGE_BASE, ARENA_DAMAGE_MULTIPLIER, + MAX_COMBO_STACKS, COMBO_DAMAGE_PER_STACK, + FACTUAL_FASTER_BASE, FACTUAL_SLOWER_BASE, SPEED_ADVANTAGE_MULTIPLIER, SPEED_RATIO_MULTIPLIER, + ONE_CORRECT_WINNER_BASE, CONFIDENCE_BONUS, NO_ANSWER_SCORE, BOTH_WRONG_FASTER, BOTH_WRONG_SLOWER, + EMPTY_RESPONSE_QUALITY, MIN_QUALITY_LENGTH, SPAM_CHAR_RATIO, + QUALITY_LENGTH_IDEAL_MIN, QUALITY_LENGTH_IDEAL_MAX, QUALITY_LENGTH_SECONDARY_MAX, + QUALITY_SCORE_IDEAL, QUALITY_SCORE_SECONDARY, QUALITY_SCORE_LONG, + WORD_DIVERSITY_SCALE, WORD_DIVERSITY_CAP, SPEED_BONUS_BASE, + RETRO_SPEED_BONUS_CAP, + LARGE_WIN_MARGIN, CLOSE_MATCH_MARGIN, WHIFF_NARRATION_THRESHOLD, + DEFAULT_CHALLENGE_TIMEOUT_MS, +} from '../lib/constants.js' import type { Challenge } from './challenges.js' import { pick } from '../lib/utils.js' import { checkAnswer } from './answers.js' diff --git a/server/src/lib/constants.ts b/server/src/lib/constants.ts index 244231b..b68f56b 100644 --- a/server/src/lib/constants.ts +++ b/server/src/lib/constants.ts @@ -31,3 +31,46 @@ export const DEFAULT_CHALLENGE_TIMEOUT_MS = 8000 // --- Fight loop --- export const FIGHT_LOOP_INTERVAL_MS = 8000 export const ELO_MATCHING_RANDOMNESS = 150 + +// --- Scoring: damage --- +export const TIMEOUT_DAMAGE_MULTIPLIER = 1.5 // Damage multiplier for timeout/error losses +export const CRITICAL_MARGIN_THRESHOLD = 4 // Score margin above which a hit is critical +export const CRITICAL_DAMAGE_MULTIPLIER = 1.5 // Damage multiplied on critical hits +export const MARGIN_TO_DAMAGE_SCALE = 2 // baseDamage + margin * this +export const LOSER_DAMAGE_BASE = 0.3 // Loser gets baseDamage * this - margin +export const ARENA_DAMAGE_MULTIPLIER = 2 // Damage multiplier when arena modifier matches + +// --- Scoring: combo --- +export const MAX_COMBO_STACKS = 5 // Combo cap before scaling stops +export const COMBO_DAMAGE_PER_STACK = 0.2 // Damage increase per combo stack + +// --- Scoring: factual --- +export const FACTUAL_FASTER_BASE = 7 // Base score: correct + faster +export const FACTUAL_SLOWER_BASE = 5 // Base score: correct + slower +export const SPEED_ADVANTAGE_MULTIPLIER = 2 // Speed advantage scaling +export const SPEED_RATIO_MULTIPLIER = 1.5 // Speed ratio scaling for slower bot +export const ONE_CORRECT_WINNER_BASE = 9 // Base score: only answerer correct +export const CONFIDENCE_BONUS = 0.5 // Multiplier on confidence for correct answer +export const NO_ANSWER_SCORE = 1 // Score when opponent has no/wrong answer +export const BOTH_WRONG_FASTER = 4 // Both wrong, faster bot +export const BOTH_WRONG_SLOWER = 3 // Both wrong, slower bot + +// --- Scoring: creative quality --- +export const EMPTY_RESPONSE_QUALITY = 0.5 // Quality score for null/empty answers +export const MIN_QUALITY_LENGTH = 10 // Below this: minimum quality +export const SPAM_CHAR_RATIO = 0.1 // Unique chars / length — below = spam +export const QUALITY_LENGTH_IDEAL_MIN = 30 // Ideal response length minimum +export const QUALITY_LENGTH_IDEAL_MAX = 400 // Ideal response length maximum +export const QUALITY_LENGTH_SECONDARY_MAX = 600 // Secondary length cutoff +export const QUALITY_SCORE_IDEAL = 4 // Length score: ideal range +export const QUALITY_SCORE_SECONDARY = 3 // Length score: 400-600 +export const QUALITY_SCORE_LONG = 2 // Length score: 600+ or short +export const WORD_DIVERSITY_SCALE = 4 // Diversity scaling factor +export const WORD_DIVERSITY_CAP = 3 // Max diversity bonus +export const SPEED_BONUS_BASE = 2 // Max speed bonus +export const RETRO_SPEED_BONUS_CAP = 0.2 // Retro mode speed bonus cap + +// --- Scoring: narration thresholds --- +export const LARGE_WIN_MARGIN = 5 // Margin for "big win" narration +export const CLOSE_MATCH_MARGIN = 3 // Margin for "close match" narration +export const WHIFF_NARRATION_THRESHOLD = 2 // Whiff count for narration trigger