From ad96d1158f3a891b559afbf2364695095f7f34c0 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 9 Mar 2026 12:14:07 +0000 Subject: [PATCH] feat: practice button on profile, fix rate limiter, fix mobile sprite rendering - Add Practice button to BotProfilePage for quick sparring - Fix rate limiter bug: all rateLimit() instances shared one counter map, causing global and per-route limits to corrupt each other. Each limiter now gets its own isolated map. - Replace 8-digit hex colors (#ffd70066) with rgba() in sprite rendering for mobile browser compatibility (iOS Safari renders them as black boxes) Co-Authored-By: Claude Opus 4.6 --- frontend/src/game/sprites/human.ts | 8 +++--- frontend/src/game/sprites/index.ts | 12 ++++---- frontend/src/pages/BotProfilePage.vue | 41 +++++++++++++++++++++++++++ server/src/middleware/rate-limit.ts | 37 +++++++++++++++++------- 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/frontend/src/game/sprites/human.ts b/frontend/src/game/sprites/human.ts index dbea60d..a8e8c44 100644 --- a/frontend/src/game/sprites/human.ts +++ b/frontend/src/game/sprites/human.ts @@ -1115,8 +1115,8 @@ export function generateHumanSpriteSheet( const r = 40 + Math.sin(t * Math.PI * 4 + a * 0.8) * 8 const gx = cx + xOff + Math.round(Math.cos(angle) * r) const gy = by + Math.floor(bodyH / 2) + Math.round(Math.sin(angle) * r) - fill(gx - 1, gy - 1, 3, 3, '#ffcc4430', ox, oy) - px(gx, gy, '#ffcc4460', ox, oy) + fill(gx - 1, gy - 1, 3, 3, 'rgba(255,204,68,0.19)', ox, oy) + px(gx, gy, 'rgba(255,204,68,0.38)', ox, oy) } } @@ -1141,7 +1141,7 @@ export function generateHumanSpriteSheet( } fill(hx + 2, hy - 4, 4, 2, hatLt, ox, oy) // highlight // Visor - fill(hx + 2, hy + 1, headW - 4, 3, '#33333388', ox, oy) + fill(hx + 2, hy + 1, headW - 4, 3, 'rgba(51,51,51,0.53)', ox, oy) } else if (costume.hatShape === 'pointy') { for (let h = 0; h < 18; h++) { const w = Math.max(1, Math.floor((headW + 2) * (1 - h / 18))) @@ -1847,7 +1847,7 @@ export function generateHumanFighterSpriteSheet( const r = Math.floor(bodyW / 2) + 6 const gx = cx + hOff + Math.round(Math.cos(angle) * r) const gy = by + Math.floor(bodyH / 2) + Math.round(Math.sin(angle) * r * 0.6) - if ((frame + a) % 3 !== 0) px(gx, gy, a % 2 === 0 ? '#ffd700' : '#ffcc4460', ox, oy) + if ((frame + a) % 3 !== 0) px(gx, gy, a % 2 === 0 ? '#ffd700' : 'rgba(255,204,68,0.38)', ox, oy) } } } diff --git a/frontend/src/game/sprites/index.ts b/frontend/src/game/sprites/index.ts index 78f63d6..b91f89c 100644 --- a/frontend/src/game/sprites/index.ts +++ b/frontend/src/game/sprites/index.ts @@ -210,10 +210,10 @@ export function generateSpriteSheet( // Tier 4+: full golden ₿ with glow if (tier >= 4) { // Golden glow behind - px(ec - 2, ey, '#ffd70066', ox, oy) - px(ec + 2, ey, '#ffd70066', ox, oy) - px(ec, ey - 2, '#ffd70066', ox, oy) - px(ec, ey + 2, '#ffd70066', ox, oy) + px(ec - 2, ey, 'rgba(255,215,0,0.4)', ox, oy) + px(ec + 2, ey, 'rgba(255,215,0,0.4)', ox, oy) + px(ec, ey - 2, 'rgba(255,215,0,0.4)', ox, oy) + px(ec, ey + 2, 'rgba(255,215,0,0.4)', ox, oy) } const bc = tier >= 4 ? '#ffd700' : tier >= 2 ? '#ff8c00' : '#ff6600' // Vertical stroke @@ -298,8 +298,8 @@ export function generateSpriteSheet( px(projX + 1, projY + 1, '#ffaa00', ox, oy) px(projX + 2, projY, '#ffcc00', ox, oy) px(projX - 1, projY, '#ff2200', ox, oy) - px(projX - 2, projY + 1, '#ff440066', ox, oy) - px(projX - 3, projY, '#ff220044', ox, oy) + px(projX - 2, projY + 1, 'rgba(255,68,0,0.4)', ox, oy) + px(projX - 3, projY, 'rgba(255,34,0,0.27)', ox, oy) } else { px(projX, projY, '#00eeff', ox, oy) px(projX + 1, projY - 1, '#44ffff', ox, oy) diff --git a/frontend/src/pages/BotProfilePage.vue b/frontend/src/pages/BotProfilePage.vue index e9cfcb5..bc5d361 100644 --- a/frontend/src/pages/BotProfilePage.vue +++ b/frontend/src/pages/BotProfilePage.vue @@ -7,6 +7,7 @@ import HumanPreview from '../components/HumanPreview.vue' import WalletConnect from '../components/WalletConnect.vue' import BetHistory from '../components/BetHistory.vue' import type { SpriteCustomization } from '../game/sprites' +import { ensureAudioContext } from '../game/audio' const route = useRoute() const router = useRouter() @@ -84,6 +85,7 @@ interface QueueEntry { const stats = ref(null) const isLoading = ref(true) const isJoining = ref(false) +const isJoiningPractice = ref(false) const showChoose = ref(false) const waitingFighters = ref([]) let pollHandle: ReturnType | null = null @@ -345,6 +347,31 @@ async function fightSpecific(opponentBotId: string) { isJoining.value = false } +async function practice() { + if (!stats.value || isJoiningPractice.value) return + isJoiningPractice.value = true + fightError.value = '' + ensureAudioContext() + try { + const res = await fetch(`/api/fights/practice/${stats.value.id}`, { method: 'POST' }) + if (res.ok) { + const data = await res.json() + router.push(`/arena/${data.fightId}`) + return + } else { + const data = await res.json() + if (data.fightId) { + fightError.value = 'Already in a fight!' + } else { + fightError.value = data.retryAfterSec ? `${data.error} (${data.retryAfterSec}s)` : (data.error || 'Practice failed.') + } + } + } catch { + fightError.value = 'Network error.' + } + isJoiningPractice.value = false +} + function handleSignOut() { logout() router.push('/') @@ -548,6 +575,20 @@ const tierClass = (t: number) => `tier-${t}` + + +

{{ fightError }}

diff --git a/server/src/middleware/rate-limit.ts b/server/src/middleware/rate-limit.ts index 9cba63c..4358e04 100644 --- a/server/src/middleware/rate-limit.ts +++ b/server/src/middleware/rate-limit.ts @@ -4,15 +4,24 @@ const isDev = process.env.NODE_ENV !== 'production' const MAX_MAP_SIZE = 10_000 -const hitCounts = new Map() +// Track all maps for periodic cleanup +const allMaps: Map[] = [] -// Cleanup stale entries every 5 minutes + evict oldest if over MAX_MAP_SIZE +// Cleanup stale entries every 5 minutes across all rate limiter maps export const cleanupInterval = setInterval(() => { const now = Date.now() - for (const [key, entry] of hitCounts) { - if (now > entry.resetAt) hitCounts.delete(key) + for (const map of allMaps) { + for (const [key, entry] of map) { + if (now > entry.resetAt) map.delete(key) + } + // Evict oldest if over limit + if (map.size > MAX_MAP_SIZE) { + const sorted = [...map.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt) + const toRemove = sorted.slice(0, map.size - MAX_MAP_SIZE) + for (const [key] of toRemove) map.delete(key) + } } - // Evict oldest entries from botHitCounts if over limit + // Also clean bot hit counts if (botHitCounts.size > MAX_MAP_SIZE) { const sorted = [...botHitCounts.entries()].sort((a, b) => a[1] - b[1]) const toRemove = sorted.slice(0, botHitCounts.size - MAX_MAP_SIZE) @@ -20,16 +29,22 @@ export const cleanupInterval = setInterval(() => { } }, 5 * 60 * 1000) +function getIp(c: Context): string { + return c.req.header('cf-connecting-ip') + || c.req.header('x-real-ip') + || c.req.header('x-forwarded-for')?.split(',')[0].trim() + || 'unknown' +} + export function rateLimit(windowMs: number, maxHits: number) { + // Each rateLimit() call gets its own isolated counter map + const hitCounts = new Map() + allMaps.push(hitCounts) + return async (c: Context, next: Next) => { if (isDev) return next() - // Extract real IP — prefer trusted proxy headers over spoofable x-forwarded-for - const realIp = c.req.header('cf-connecting-ip') - || c.req.header('x-real-ip') - || c.req.header('x-forwarded-for')?.split(',')[0].trim() - || 'unknown' - const key = realIp + const key = getIp(c) const now = Date.now() const entry = hitCounts.get(key)