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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d0f0a84a57
commit
ad96d1158f
@@ -1115,8 +1115,8 @@ export function generateHumanSpriteSheet(
|
|||||||
const r = 40 + Math.sin(t * Math.PI * 4 + a * 0.8) * 8
|
const r = 40 + Math.sin(t * Math.PI * 4 + a * 0.8) * 8
|
||||||
const gx = cx + xOff + Math.round(Math.cos(angle) * r)
|
const gx = cx + xOff + Math.round(Math.cos(angle) * r)
|
||||||
const gy = by + Math.floor(bodyH / 2) + Math.round(Math.sin(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)
|
fill(gx - 1, gy - 1, 3, 3, 'rgba(255,204,68,0.19)', ox, oy)
|
||||||
px(gx, gy, '#ffcc4460', 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
|
fill(hx + 2, hy - 4, 4, 2, hatLt, ox, oy) // highlight
|
||||||
// Visor
|
// 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') {
|
} else if (costume.hatShape === 'pointy') {
|
||||||
for (let h = 0; h < 18; h++) {
|
for (let h = 0; h < 18; h++) {
|
||||||
const w = Math.max(1, Math.floor((headW + 2) * (1 - h / 18)))
|
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 r = Math.floor(bodyW / 2) + 6
|
||||||
const gx = cx + hOff + Math.round(Math.cos(angle) * r)
|
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)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,10 +210,10 @@ export function generateSpriteSheet(
|
|||||||
// Tier 4+: full golden ₿ with glow
|
// Tier 4+: full golden ₿ with glow
|
||||||
if (tier >= 4) {
|
if (tier >= 4) {
|
||||||
// Golden glow behind
|
// Golden glow behind
|
||||||
px(ec - 2, ey, '#ffd70066', ox, oy)
|
px(ec - 2, ey, 'rgba(255,215,0,0.4)', ox, oy)
|
||||||
px(ec + 2, ey, '#ffd70066', ox, oy)
|
px(ec + 2, ey, 'rgba(255,215,0,0.4)', ox, oy)
|
||||||
px(ec, ey - 2, '#ffd70066', ox, oy)
|
px(ec, ey - 2, 'rgba(255,215,0,0.4)', ox, oy)
|
||||||
px(ec, ey + 2, '#ffd70066', ox, oy)
|
px(ec, ey + 2, 'rgba(255,215,0,0.4)', ox, oy)
|
||||||
}
|
}
|
||||||
const bc = tier >= 4 ? '#ffd700' : tier >= 2 ? '#ff8c00' : '#ff6600'
|
const bc = tier >= 4 ? '#ffd700' : tier >= 2 ? '#ff8c00' : '#ff6600'
|
||||||
// Vertical stroke
|
// Vertical stroke
|
||||||
@@ -298,8 +298,8 @@ export function generateSpriteSheet(
|
|||||||
px(projX + 1, projY + 1, '#ffaa00', ox, oy)
|
px(projX + 1, projY + 1, '#ffaa00', ox, oy)
|
||||||
px(projX + 2, projY, '#ffcc00', ox, oy)
|
px(projX + 2, projY, '#ffcc00', ox, oy)
|
||||||
px(projX - 1, projY, '#ff2200', ox, oy)
|
px(projX - 1, projY, '#ff2200', ox, oy)
|
||||||
px(projX - 2, projY + 1, '#ff440066', ox, oy)
|
px(projX - 2, projY + 1, 'rgba(255,68,0,0.4)', ox, oy)
|
||||||
px(projX - 3, projY, '#ff220044', ox, oy)
|
px(projX - 3, projY, 'rgba(255,34,0,0.27)', ox, oy)
|
||||||
} else {
|
} else {
|
||||||
px(projX, projY, '#00eeff', ox, oy)
|
px(projX, projY, '#00eeff', ox, oy)
|
||||||
px(projX + 1, projY - 1, '#44ffff', ox, oy)
|
px(projX + 1, projY - 1, '#44ffff', ox, oy)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import HumanPreview from '../components/HumanPreview.vue'
|
|||||||
import WalletConnect from '../components/WalletConnect.vue'
|
import WalletConnect from '../components/WalletConnect.vue'
|
||||||
import BetHistory from '../components/BetHistory.vue'
|
import BetHistory from '../components/BetHistory.vue'
|
||||||
import type { SpriteCustomization } from '../game/sprites'
|
import type { SpriteCustomization } from '../game/sprites'
|
||||||
|
import { ensureAudioContext } from '../game/audio'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -84,6 +85,7 @@ interface QueueEntry {
|
|||||||
const stats = ref<BotStats | null>(null)
|
const stats = ref<BotStats | null>(null)
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const isJoining = ref(false)
|
const isJoining = ref(false)
|
||||||
|
const isJoiningPractice = ref(false)
|
||||||
const showChoose = ref(false)
|
const showChoose = ref(false)
|
||||||
const waitingFighters = ref<QueueEntry[]>([])
|
const waitingFighters = ref<QueueEntry[]>([])
|
||||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||||
@@ -345,6 +347,31 @@ async function fightSpecific(opponentBotId: string) {
|
|||||||
isJoining.value = false
|
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() {
|
function handleSignOut() {
|
||||||
logout()
|
logout()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
@@ -548,6 +575,20 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Practice -->
|
||||||
|
<button
|
||||||
|
class="w-full mt-2 py-2.5 bg-white/3 border border-white/10 text-text-muted
|
||||||
|
font-display font-bold text-xs tracking-widest
|
||||||
|
hover:bg-white/5 hover:text-text-secondary transition-all
|
||||||
|
disabled:opacity-50 disabled:cursor-wait
|
||||||
|
flex items-center justify-center gap-2"
|
||||||
|
:disabled="isJoiningPractice"
|
||||||
|
@click="practice"
|
||||||
|
>
|
||||||
|
<span v-if="isJoiningPractice" class="w-3.5 h-3.5 border-2 border-white/20 border-t-white/50 rounded-full animate-spin" />
|
||||||
|
{{ isJoiningPractice ? 'STARTING...' : 'PRACTICE' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<p v-if="fightError" class="font-mono text-[10px] text-neon-pink mt-2">{{ fightError }}</p>
|
<p v-if="fightError" class="font-mono text-[10px] text-neon-pink mt-2">{{ fightError }}</p>
|
||||||
|
|
||||||
<!-- Choose your fight panel -->
|
<!-- Choose your fight panel -->
|
||||||
|
|||||||
@@ -4,15 +4,24 @@ const isDev = process.env.NODE_ENV !== 'production'
|
|||||||
|
|
||||||
const MAX_MAP_SIZE = 10_000
|
const MAX_MAP_SIZE = 10_000
|
||||||
|
|
||||||
const hitCounts = new Map<string, { count: number; resetAt: number }>()
|
// Track all maps for periodic cleanup
|
||||||
|
const allMaps: Map<string, { count: number; resetAt: number }>[] = []
|
||||||
|
|
||||||
// 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(() => {
|
export const cleanupInterval = setInterval(() => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
for (const [key, entry] of hitCounts) {
|
for (const map of allMaps) {
|
||||||
if (now > entry.resetAt) hitCounts.delete(key)
|
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) {
|
if (botHitCounts.size > MAX_MAP_SIZE) {
|
||||||
const sorted = [...botHitCounts.entries()].sort((a, b) => a[1] - b[1])
|
const sorted = [...botHitCounts.entries()].sort((a, b) => a[1] - b[1])
|
||||||
const toRemove = sorted.slice(0, botHitCounts.size - MAX_MAP_SIZE)
|
const toRemove = sorted.slice(0, botHitCounts.size - MAX_MAP_SIZE)
|
||||||
@@ -20,16 +29,22 @@ export const cleanupInterval = setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 5 * 60 * 1000)
|
}, 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) {
|
export function rateLimit(windowMs: number, maxHits: number) {
|
||||||
|
// Each rateLimit() call gets its own isolated counter map
|
||||||
|
const hitCounts = new Map<string, { count: number; resetAt: number }>()
|
||||||
|
allMaps.push(hitCounts)
|
||||||
|
|
||||||
return async (c: Context, next: Next) => {
|
return async (c: Context, next: Next) => {
|
||||||
if (isDev) return next()
|
if (isDev) return next()
|
||||||
|
|
||||||
// Extract real IP — prefer trusted proxy headers over spoofable x-forwarded-for
|
const key = getIp(c)
|
||||||
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 now = Date.now()
|
const now = Date.now()
|
||||||
const entry = hitCounts.get(key)
|
const entry = hitCounts.get(key)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user