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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-09 07:18:57 +00:00
co-authored by Claude Opus 4.6
parent b28d0b220e
commit 7b82803868
6 changed files with 135 additions and 38 deletions
+39 -33
View File
@@ -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<never>((_resolve, reject) => setTimeout(() => reject(new Error('timeout')), 5_000)),
new Promise<never>((_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 {
+35 -1
View File
@@ -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)
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -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,
}))
+15 -1
View File
@@ -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'
+43
View File
@@ -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