Merge branch 'overnight/2026-03-09'

This commit is contained in:
Dorian
2026-03-09 08:01:08 +00:00
37 changed files with 1411 additions and 151 deletions
+35
View File
@@ -0,0 +1,35 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Tests
run: pnpm test -- --run
- name: Type check
run: |
pnpm --filter server exec tsc --noEmit
pnpm --filter frontend exec vue-tsc --noEmit
- name: Lint
run: pnpm lint
+5 -1
View File
@@ -5,10 +5,14 @@ target/
__pycache__/ __pycache__/
*.pyc *.pyc
.env .env
.env.local .env.*
!.env.example
.DS_Store .DS_Store
loop/loop.log loop/loop.log
server/data/ server/data/
loop/ loop/
.pnpm-store/ .pnpm-store/
.npmrc .npmrc
*.pem
*.key
*.crt
+31
View File
@@ -0,0 +1,31 @@
import tseslint from '@typescript-eslint/eslint-plugin'
import tsparser from '@typescript-eslint/parser'
export default [
{
ignores: ['**/dist/**', '**/node_modules/**', '**/*.js', '**/*.mjs', '**/*.cjs', '**/*.vue'],
},
{
files: ['**/*.ts'],
languageOptions: {
parser: tsparser,
parserOptions: {
projectService: true,
},
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
// Frontend game engine: fire-and-forget async (audio, animations) is intentional
{
files: ['frontend/src/game/**/*.ts'],
rules: {
'@typescript-eslint/no-floating-promises': 'warn',
},
},
]
@@ -51,7 +51,7 @@ export function useHumanChallenge(
if (humanChoices.value.length > 0 && !humanAnswer.value.trim()) { if (humanChoices.value.length > 0 && !humanAnswer.value.trim()) {
humanAnswer.value = humanChoices.value[Math.floor(Math.random() * humanChoices.value.length)] humanAnswer.value = humanChoices.value[Math.floor(Math.random() * humanChoices.value.length)]
} }
submitHumanAnswer() void submitHumanAnswer()
} }
} }
}, 1000) }, 1000)
@@ -77,7 +77,7 @@ export function useHumanChallenge(
function submitChoice(choice: string) { function submitChoice(choice: string) {
humanAnswer.value = choice humanAnswer.value = choice
submitHumanAnswer() void submitHumanAnswer()
} }
async function pollForChallenge() { async function pollForChallenge() {
@@ -110,8 +110,8 @@ export function useHumanChallenge(
function startHumanPolling() { function startHumanPolling() {
if (!myBotId.value) return if (!myBotId.value) return
pollForChallenge() void pollForChallenge()
humanPollHandle = setInterval(pollForChallenge, 400) humanPollHandle = setInterval(() => { void pollForChallenge() }, 400)
} }
function stopHumanPolling() { function stopHumanPolling() {
+12 -7
View File
@@ -120,7 +120,7 @@ const _pending = new Map<number, {
// --- Audio state (main thread only) --- // --- Audio state (main thread only) ---
let _audioCtx: AudioContext | null = null let _audioCtx: AudioContext | null = null
const audioCache = new Map<string, AudioBuffer>() const audioCache = new Map<string, { buf: AudioBuffer; lastAccess: number }>()
const MAX_CACHE = 50 const MAX_CACHE = 50
const activeSources: Set<AudioBufferSourceNode> = new Set() const activeSources: Set<AudioBufferSourceNode> = new Set()
@@ -265,7 +265,7 @@ async function _generateAndCache(text: string, profile: string): Promise<AudioBu
if (!_workerReady) return null if (!_workerReady) return null
const key = _cacheKey(text, profile) const key = _cacheKey(text, profile)
const cached = audioCache.get(key) const cached = audioCache.get(key)
if (cached) return cached if (cached) { cached.lastAccess = Date.now(); return cached.buf }
// Dedup: if already generating this exact audio, reuse the in-flight promise // Dedup: if already generating this exact audio, reuse the in-flight promise
const existing = _inflight.get(key) const existing = _inflight.get(key)
@@ -288,12 +288,16 @@ async function _doGenerate(text: string, profile: string, key: string): Promise<
const buf = ctx.createBuffer(1, raw.audio.length, raw.sampleRate) const buf = ctx.createBuffer(1, raw.audio.length, raw.sampleRate)
buf.getChannelData(0).set(raw.audio) buf.getChannelData(0).set(raw.audio)
// Evict oldest if cache is full // Evict least-recently-used if cache is full
if (audioCache.size >= MAX_CACHE) { if (audioCache.size >= MAX_CACHE) {
const oldest = audioCache.keys().next().value let lruKey: string | undefined
if (oldest) audioCache.delete(oldest) let lruTime = Infinity
for (const [k, v] of audioCache) {
if (v.lastAccess < lruTime) { lruTime = v.lastAccess; lruKey = k }
} }
audioCache.set(key, buf) if (lruKey) audioCache.delete(lruKey)
}
audioCache.set(key, { buf, lastAccess: Date.now() })
return buf return buf
} }
@@ -355,7 +359,8 @@ export function kokoroSpeak(
const key = _cacheKey(text, profileName) const key = _cacheKey(text, profileName)
const cached = audioCache.get(key) const cached = audioCache.get(key)
if (cached) { if (cached) {
_playBuffer(cached, dest, volume) cached.lastAccess = Date.now()
_playBuffer(cached.buf, dest, volume)
return true return true
} }
// Generate in worker — will play when ready // Generate in worker — will play when ready
+1 -1
View File
@@ -34,7 +34,7 @@ const tierClass = (t: number) => `tier-${t}`
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col px-3 sm:px-6 py-4 sm:py-6 overflow-hidden"> <div class="h-full flex flex-col px-3 sm:px-6 py-4 sm:py-6 overflow-hidden">
<div class="max-w-5xl mx-auto w-full flex flex-col flex-1 min-h-0"> <div class="max-w-5xl mx-auto w-full flex flex-col flex-1 min-h-0">
<!-- Header --> <!-- Header -->
+1 -1
View File
@@ -354,7 +354,7 @@ const tierClass = (t: number) => `tier-${t}`
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col overflow-hidden"> <div class="h-full flex flex-col overflow-hidden">
<div class="max-w-lg lg:max-w-4xl mx-auto w-full flex flex-col flex-1 min-h-0 px-6 py-4 overflow-y-auto"> <div class="max-w-lg lg:max-w-4xl mx-auto w-full flex flex-col flex-1 min-h-0 px-6 py-4 overflow-y-auto">
<div v-if="isLoading" class="flex-1 flex items-center justify-center"> <div v-if="isLoading" class="flex-1 flex items-center justify-center">
+1 -1
View File
@@ -286,7 +286,7 @@ const registrationTest = `{
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col overflow-hidden"> <div class="h-full flex flex-col overflow-hidden">
<div class="flex flex-col flex-1 min-h-0 px-6 py-4 max-w-4xl mx-auto w-full"> <div class="flex flex-col flex-1 min-h-0 px-6 py-4 max-w-4xl mx-auto w-full">
<div class="text-center mb-4 shrink-0"> <div class="text-center mb-4 shrink-0">
+1 -1
View File
@@ -114,7 +114,7 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col overflow-hidden relative"> <div class="h-full flex flex-col overflow-hidden relative">
<!-- Page background --> <!-- Page background -->
<div class="absolute inset-0 overflow-hidden pointer-events-none"> <div class="absolute inset-0 overflow-hidden pointer-events-none">
+1 -1
View File
@@ -594,7 +594,7 @@ function stopAutoBattle() {
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col px-2 sm:px-3 py-2 sm:py-3 overflow-hidden"> <div class="h-full flex flex-col px-2 sm:px-3 py-2 sm:py-3 overflow-hidden">
<div v-if="isLoading" class="flex-1 flex items-center justify-center"> <div v-if="isLoading" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted animate-pulse tracking-wider">LOADING FIGHT...</p> <p class="font-display text-text-muted animate-pulse tracking-wider">LOADING FIGHT...</p>
</div> </div>
+1 -1
View File
@@ -315,7 +315,7 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden relative"> <div class="h-full flex flex-col items-center justify-center px-6 overflow-hidden relative">
<!-- Crowd characters floating around edges --> <!-- Crowd characters floating around edges -->
<template v-for="(c, i) in crowd" :key="i"> <template v-for="(c, i) in crowd" :key="i">
<SpritePreview <SpritePreview
+3 -3
View File
@@ -225,7 +225,7 @@ function goToArena() {
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col items-center px-4 overflow-hidden"> <div class="h-full flex flex-col items-center px-4 overflow-hidden">
<div class="max-w-lg w-full flex-1 min-h-0 overflow-y-auto py-4"> <div class="max-w-lg w-full flex-1 min-h-0 overflow-y-auto py-4">
<!-- Feedback flash overlay --> <!-- Feedback flash overlay -->
@@ -416,8 +416,8 @@ function goToArena() {
</div> </div>
<!-- PHASE: REPLAY --> <!-- PHASE: REPLAY -->
<div v-else-if="phase === 'replay' && fight" class="w-full"> <div v-else-if="phase === 'replay' && fight" class="w-full flex-1 min-h-0 flex flex-col">
<div class="h-[calc(100dvh-8rem)] min-h-0 relative"> <div class="flex-1 min-h-0 relative">
<FightViewer :fight="fight" :autoplay="true" class="h-full" /> <FightViewer :fight="fight" :autoplay="true" class="h-full" />
</div> </div>
<div class="flex gap-2 mt-2"> <div class="flex gap-2 mt-2">
+1 -1
View File
@@ -419,7 +419,7 @@ function handleSignOut() {
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden"> <div class="h-full flex flex-col items-center justify-center px-6 overflow-hidden">
<div class="max-w-md w-full slide-up overflow-y-auto max-h-full py-4"> <div class="max-w-md w-full slide-up overflow-y-auto max-h-full py-4">
<!-- STEP: LOGIN --> <!-- STEP: LOGIN -->
+1 -1
View File
@@ -78,7 +78,7 @@ onUnmounted(() => {
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col px-6 py-6 overflow-hidden"> <div class="h-full flex flex-col px-6 py-6 overflow-hidden">
<div class="max-w-4xl mx-auto w-full flex flex-col flex-1 min-h-0"> <div class="max-w-4xl mx-auto w-full flex flex-col flex-1 min-h-0">
<!-- Header --> <!-- Header -->
+1 -1
View File
@@ -57,7 +57,7 @@ function goFight() {
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden"> <div class="h-full flex flex-col items-center justify-center px-6 overflow-hidden">
<div class="max-w-md w-full slide-up"> <div class="max-w-md w-full slide-up">
<div class="text-center mb-8"> <div class="text-center mb-8">
+1 -1
View File
@@ -58,7 +58,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="h-[calc(100dvh-4rem)] flex flex-col px-6 py-6 overflow-hidden"> <div class="h-full flex flex-col px-6 py-6 overflow-hidden">
<div class="max-w-4xl mx-auto w-full flex flex-col flex-1 min-h-0"> <div class="max-w-4xl mx-auto w-full flex flex-col flex-1 min-h-0">
<!-- Header --> <!-- Header -->
+3
View File
@@ -13,7 +13,10 @@
"seed": "pnpm --filter server seed" "seed": "pnpm --filter server seed"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.56.1",
"@typescript-eslint/parser": "^8.56.1",
"concurrently": "^9.1.2", "concurrently": "^9.1.2",
"eslint": "^10.0.3",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^3.1.1" "vitest": "^3.1.1"
} }
+588
View File
@@ -8,9 +8,18 @@ importers:
.: .:
devDependencies: devDependencies:
'@typescript-eslint/eslint-plugin':
specifier: ^8.56.1
version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: ^8.56.1
version: 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
concurrently: concurrently:
specifier: ^9.1.2 specifier: ^9.1.2
version: 9.2.1 version: 9.2.1
eslint:
specifier: ^10.0.3
version: 10.0.3(jiti@2.6.1)
typescript: typescript:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.9.3 version: 5.9.3
@@ -87,6 +96,9 @@ importers:
nostr-tools: nostr-tools:
specifier: ^2.23.3 specifier: ^2.23.3
version: 2.23.3(typescript@5.9.3) version: 2.23.3(typescript@5.9.3)
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies: devDependencies:
'@types/better-sqlite3': '@types/better-sqlite3':
specifier: ^7.6.13 specifier: ^7.6.13
@@ -1051,6 +1063,36 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/regexpp@4.12.2':
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/config-array@0.23.3':
resolution: {integrity: sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/config-helpers@0.5.3':
resolution: {integrity: sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/core@1.1.1':
resolution: {integrity: sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/object-schema@3.0.3':
resolution: {integrity: sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@eslint/plugin-kit@0.6.1':
resolution: {integrity: sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
'@hono/node-server@1.19.11': '@hono/node-server@1.19.11':
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'} engines: {node: '>=18.14.1'}
@@ -1064,6 +1106,22 @@ packages:
'@huggingface/transformers@3.8.1': '@huggingface/transformers@3.8.1':
resolution: {integrity: sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==} resolution: {integrity: sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
'@humanfs/node@0.16.7':
resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
engines: {node: '>=18.18.0'}
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
engines: {node: '>=12.22'}
'@humanwhocodes/retry@0.4.3':
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@img/colour@1.1.0': '@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1591,12 +1649,18 @@ packages:
'@types/deep-eql@4.0.2': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree@0.0.39': '@types/estree@0.0.39':
resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@22.19.15': '@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
@@ -1606,6 +1670,65 @@ packages:
'@types/trusted-types@2.0.7': '@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@typescript-eslint/eslint-plugin@8.56.1':
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.56.1
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.56.1':
resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.56.1':
resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.56.1':
resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.56.1':
resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.56.1':
resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.56.1':
resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.56.1':
resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.56.1':
resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.56.1':
resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitejs/plugin-vue@5.2.4': '@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0} engines: {node: ^18.0.0 || >=20.0.0}
@@ -1694,11 +1817,19 @@ packages:
'@vue/shared@3.5.29': '@vue/shared@3.5.29':
resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==} resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn@8.16.0: acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
ajv@6.14.0:
resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==}
ajv@8.18.0: ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
@@ -1919,6 +2050,9 @@ packages:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
deepmerge@4.3.1: deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2123,6 +2257,44 @@ packages:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'} engines: {node: '>=10'}
eslint-scope@9.1.2:
resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint-visitor-keys@3.4.3:
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
eslint-visitor-keys@5.0.1:
resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
eslint@10.0.3:
resolution: {integrity: sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
hasBin: true
peerDependencies:
jiti: '*'
peerDependenciesMeta:
jiti:
optional: true
espree@11.2.0:
resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
esquery@1.7.0:
resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==}
engines: {node: '>=0.10'}
esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
engines: {node: '>=4.0'}
estraverse@5.3.0:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@1.0.1: estree-walker@1.0.1:
resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==}
@@ -2150,6 +2322,9 @@ packages:
fast-json-stable-stringify@2.1.0: fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-uri@3.1.0: fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
@@ -2162,15 +2337,30 @@ packages:
picomatch: picomatch:
optional: true optional: true
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
file-uri-to-path@1.0.0: file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
filelist@1.0.6: filelist@1.0.6:
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
flatbuffers@25.9.23: flatbuffers@25.9.23:
resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==}
flatted@3.4.0:
resolution: {integrity: sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==}
for-each@0.3.5: for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2239,6 +2429,10 @@ packages:
github-from-package@0.0.0: github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@11.1.0: glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -2304,6 +2498,18 @@ packages:
ieee754@1.2.1: ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -2346,6 +2552,10 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-finalizationregistry@1.1.1: is-finalizationregistry@1.1.1:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2358,6 +2568,10 @@ packages:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-map@2.0.3: is-map@2.0.3:
resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2455,12 +2669,21 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
hasBin: true hasBin: true
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-schema-traverse@1.0.0: json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema@0.4.0: json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
json-stringify-safe@5.0.1: json-stringify-safe@5.0.1:
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
@@ -2480,6 +2703,9 @@ packages:
resolution: {integrity: sha512-T8GdXGXvgv/vbYVA1lcHzuDNVhp3juOJJE8OZs0vR5MdGNElBvANEeTSnqAAhJpSXtNxpeNy29pqkok3RnXKtg==} resolution: {integrity: sha512-T8GdXGXvgv/vbYVA1lcHzuDNVhp3juOJJE8OZs0vR5MdGNElBvANEeTSnqAAhJpSXtNxpeNy29pqkok3RnXKtg==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kokoro-js@1.2.1: kokoro-js@1.2.1:
resolution: {integrity: sha512-oq0HZJWis3t8lERkMJh84WLU86dpYD0EuBPtqYnLlQzyFP1OkyBRDcweAqCfhNOpltyN9j/azp1H6uuC47gShw==} resolution: {integrity: sha512-oq0HZJWis3t8lERkMJh84WLU86dpYD0EuBPtqYnLlQzyFP1OkyBRDcweAqCfhNOpltyN9j/azp1H6uuC47gShw==}
@@ -2487,6 +2713,10 @@ packages:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lightningcss-android-arm64@1.31.1: lightningcss-android-arm64@1.31.1:
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
@@ -2561,6 +2791,10 @@ packages:
resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.debounce@4.0.8: lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
@@ -2646,6 +2880,9 @@ packages:
napi-build-utils@2.0.0: napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
node-abi@3.87.0: node-abi@3.87.0:
resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -2692,16 +2929,32 @@ packages:
onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: onnxruntime-web@1.22.0-dev.20250409-89f8206ba4:
resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
own-keys@1.0.1: own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
package-json-from-dist@1.0.1: package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
path-browserify@1.0.1: path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-key@3.1.1: path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2751,6 +3004,10 @@ packages:
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true hasBin: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
pretty-bytes@5.6.0: pretty-bytes@5.6.0:
resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -3082,6 +3339,12 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true hasBin: true
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -3093,6 +3356,10 @@ packages:
tunnel-agent@0.6.0: tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-fest@0.13.1: type-fest@0.13.1:
resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3163,6 +3430,9 @@ packages:
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -3310,6 +3580,10 @@ packages:
engines: {node: '>=8'} engines: {node: '>=8'}
hasBin: true hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
workbox-background-sync@7.4.0: workbox-background-sync@7.4.0:
resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==} resolution: {integrity: sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==}
@@ -3385,6 +3659,13 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'} engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
snapshots: snapshots:
'@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)': '@apideck/better-ajv-errors@0.3.6(ajv@8.18.0)':
@@ -4285,6 +4566,36 @@ snapshots:
'@esbuild/win32-x64@0.27.3': '@esbuild/win32-x64@0.27.3':
optional: true optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@10.0.3(jiti@2.6.1))':
dependencies:
eslint: 10.0.3(jiti@2.6.1)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.2': {}
'@eslint/config-array@0.23.3':
dependencies:
'@eslint/object-schema': 3.0.3
debug: 4.4.3
minimatch: 10.2.4
transitivePeerDependencies:
- supports-color
'@eslint/config-helpers@0.5.3':
dependencies:
'@eslint/core': 1.1.1
'@eslint/core@1.1.1':
dependencies:
'@types/json-schema': 7.0.15
'@eslint/object-schema@3.0.3': {}
'@eslint/plugin-kit@0.6.1':
dependencies:
'@eslint/core': 1.1.1
levn: 0.4.1
'@hono/node-server@1.19.11(hono@4.12.5)': '@hono/node-server@1.19.11(hono@4.12.5)':
dependencies: dependencies:
hono: 4.12.5 hono: 4.12.5
@@ -4298,6 +4609,17 @@ snapshots:
onnxruntime-web: 1.22.0-dev.20250409-89f8206ba4 onnxruntime-web: 1.22.0-dev.20250409-89f8206ba4
sharp: 0.34.5 sharp: 0.34.5
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
dependencies:
'@humanfs/core': 0.19.1
'@humanwhocodes/retry': 0.4.3
'@humanwhocodes/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.4.3': {}
'@img/colour@1.1.0': {} '@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5': '@img/sharp-darwin-arm64@0.34.5':
@@ -4679,10 +5001,14 @@ snapshots:
'@types/deep-eql@4.0.2': {} '@types/deep-eql@4.0.2': {}
'@types/esrecurse@4.3.1': {}
'@types/estree@0.0.39': {} '@types/estree@0.0.39': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
'@types/node@22.19.15': '@types/node@22.19.15':
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
@@ -4691,6 +5017,97 @@ snapshots:
'@types/trusted-types@2.0.7': {} '@types/trusted-types@2.0.7': {}
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/type-utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.56.1
eslint: 10.0.3(jiti@2.6.1)
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.56.1
debug: 4.4.3
eslint: 10.0.3(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.56.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3)
'@typescript-eslint/types': 8.56.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.56.1':
dependencies:
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/visitor-keys': 8.56.1
'@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)
debug: 4.4.3
eslint: 10.0.3(jiti@2.6.1)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.56.1': {}
'@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.56.1(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3)
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/visitor-keys': 8.56.1
debug: 4.4.3
minimatch: 10.2.4
semver: 7.7.4
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1))
'@typescript-eslint/scope-manager': 8.56.1
'@typescript-eslint/types': 8.56.1
'@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3)
eslint: 10.0.3(jiti@2.6.1)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.56.1':
dependencies:
'@typescript-eslint/types': 8.56.1
eslint-visitor-keys: 5.0.1
'@vitejs/plugin-vue@5.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0))(vue@3.5.29(typescript@5.9.3))': '@vitejs/plugin-vue@5.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0))(vue@3.5.29(typescript@5.9.3))':
dependencies: dependencies:
vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0) vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)
@@ -4824,8 +5241,19 @@ snapshots:
'@vue/shared@3.5.29': {} '@vue/shared@3.5.29': {}
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
acorn@8.16.0: {} acorn@8.16.0: {}
ajv@6.14.0:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ajv@8.18.0: ajv@8.18.0:
dependencies: dependencies:
fast-deep-equal: 3.1.3 fast-deep-equal: 3.1.3
@@ -5055,6 +5483,8 @@ snapshots:
deep-extend@0.6.0: {} deep-extend@0.6.0: {}
deep-is@0.1.4: {}
deepmerge@4.3.1: {} deepmerge@4.3.1: {}
define-data-property@1.1.4: define-data-property@1.1.4:
@@ -5291,6 +5721,70 @@ snapshots:
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
eslint-scope@9.1.2:
dependencies:
'@types/esrecurse': 4.3.1
'@types/estree': 1.0.8
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-visitor-keys@3.4.3: {}
eslint-visitor-keys@5.0.1: {}
eslint@10.0.3(jiti@2.6.1):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@10.0.3(jiti@2.6.1))
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.23.3
'@eslint/config-helpers': 0.5.3
'@eslint/core': 1.1.1
'@eslint/plugin-kit': 0.6.1
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
ajv: 6.14.0
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 9.1.2
eslint-visitor-keys: 5.0.1
espree: 11.2.0
esquery: 1.7.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
minimatch: 10.2.4
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 2.6.1
transitivePeerDependencies:
- supports-color
espree@11.2.0:
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
eslint-visitor-keys: 5.0.1
esquery@1.7.0:
dependencies:
estraverse: 5.3.0
esrecurse@4.3.0:
dependencies:
estraverse: 5.3.0
estraverse@5.3.0: {}
estree-walker@1.0.1: {} estree-walker@1.0.1: {}
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
@@ -5309,20 +5803,38 @@ snapshots:
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
fast-uri@3.1.0: {} fast-uri@3.1.0: {}
fdir@6.5.0(picomatch@4.0.3): fdir@6.5.0(picomatch@4.0.3):
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
file-uri-to-path@1.0.0: {} file-uri-to-path@1.0.0: {}
filelist@1.0.6: filelist@1.0.6:
dependencies: dependencies:
minimatch: 5.1.9 minimatch: 5.1.9
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
path-exists: 4.0.0
flat-cache@4.0.1:
dependencies:
flatted: 3.4.0
keyv: 4.5.4
flatbuffers@25.9.23: {} flatbuffers@25.9.23: {}
flatted@3.4.0: {}
for-each@0.3.5: for-each@0.3.5:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
@@ -5406,6 +5918,10 @@ snapshots:
github-from-package@0.0.0: {} github-from-package@0.0.0: {}
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
glob@11.1.0: glob@11.1.0:
dependencies: dependencies:
foreground-child: 3.3.1 foreground-child: 3.3.1
@@ -5465,6 +5981,12 @@ snapshots:
ieee754@1.2.1: {} ieee754@1.2.1: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
imurmurhash@0.1.4: {}
inherits@2.0.4: {} inherits@2.0.4: {}
ini@1.3.8: {} ini@1.3.8: {}
@@ -5515,6 +6037,8 @@ snapshots:
call-bound: 1.0.4 call-bound: 1.0.4
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1: is-finalizationregistry@1.1.1:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -5529,6 +6053,10 @@ snapshots:
has-tostringtag: 1.0.2 has-tostringtag: 1.0.2
safe-regex-test: 1.1.0 safe-regex-test: 1.1.0
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-map@2.0.3: {} is-map@2.0.3: {}
is-module@1.0.0: {} is-module@1.0.0: {}
@@ -5609,10 +6137,16 @@ snapshots:
jsesc@3.1.0: {} jsesc@3.1.0: {}
json-buffer@3.0.1: {}
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {} json-schema-traverse@1.0.0: {}
json-schema@0.4.0: {} json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json-stringify-safe@5.0.1: {} json-stringify-safe@5.0.1: {}
json5@2.2.3: {} json5@2.2.3: {}
@@ -5627,6 +6161,10 @@ snapshots:
kaplay@3001.0.19: {} kaplay@3001.0.19: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
kokoro-js@1.2.1: kokoro-js@1.2.1:
dependencies: dependencies:
'@huggingface/transformers': 3.8.1 '@huggingface/transformers': 3.8.1
@@ -5634,6 +6172,11 @@ snapshots:
leven@3.1.0: {} leven@3.1.0: {}
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
type-check: 0.4.0
lightningcss-android-arm64@1.31.1: lightningcss-android-arm64@1.31.1:
optional: true optional: true
@@ -5683,6 +6226,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.31.1 lightningcss-win32-arm64-msvc: 1.31.1
lightningcss-win32-x64-msvc: 1.31.1 lightningcss-win32-x64-msvc: 1.31.1
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
lodash.debounce@4.0.8: {} lodash.debounce@4.0.8: {}
lodash.sortby@4.7.0: {} lodash.sortby@4.7.0: {}
@@ -5747,6 +6294,8 @@ snapshots:
napi-build-utils@2.0.0: {} napi-build-utils@2.0.0: {}
natural-compare@1.4.0: {}
node-abi@3.87.0: node-abi@3.87.0:
dependencies: dependencies:
semver: 7.7.4 semver: 7.7.4
@@ -5803,16 +6352,35 @@ snapshots:
platform: 1.3.6 platform: 1.3.6
protobufjs: 7.5.4 protobufjs: 7.5.4
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
fast-levenshtein: 2.0.6
levn: 0.4.1
prelude-ls: 1.2.1
type-check: 0.4.0
word-wrap: 1.2.5
own-keys@1.0.1: own-keys@1.0.1:
dependencies: dependencies:
get-intrinsic: 1.3.0 get-intrinsic: 1.3.0
object-keys: 1.1.1 object-keys: 1.1.1
safe-push-apply: 1.0.0 safe-push-apply: 1.0.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
package-json-from-dist@1.0.1: {} package-json-from-dist@1.0.1: {}
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
path-parse@1.0.7: {} path-parse@1.0.7: {}
@@ -5859,6 +6427,8 @@ snapshots:
tar-fs: 2.1.4 tar-fs: 2.1.4
tunnel-agent: 0.6.0 tunnel-agent: 0.6.0
prelude-ls@1.2.1: {}
pretty-bytes@5.6.0: {} pretty-bytes@5.6.0: {}
pretty-bytes@6.1.1: {} pretty-bytes@6.1.1: {}
@@ -6308,6 +6878,10 @@ snapshots:
tree-kill@1.2.2: {} tree-kill@1.2.2: {}
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
tslib@2.8.1: {} tslib@2.8.1: {}
tsx@4.21.0: tsx@4.21.0:
@@ -6321,6 +6895,10 @@ snapshots:
dependencies: dependencies:
safe-buffer: 5.2.1 safe-buffer: 5.2.1
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
type-fest@0.13.1: {} type-fest@0.13.1: {}
type-fest@0.16.0: {} type-fest@0.16.0: {}
@@ -6394,6 +6972,10 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0): vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0):
@@ -6570,6 +7152,8 @@ snapshots:
siginfo: 2.0.0 siginfo: 2.0.0
stackback: 0.0.2 stackback: 0.0.2
word-wrap@1.2.5: {}
workbox-background-sync@7.4.0: workbox-background-sync@7.4.0:
dependencies: dependencies:
idb: 7.1.1 idb: 7.1.1
@@ -6708,3 +7292,7 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
y18n: 5.0.8 y18n: 5.0.8
yargs-parser: 21.1.1 yargs-parser: 21.1.1
yocto-queue@0.1.0: {}
zod@4.3.6: {}
+2 -1
View File
@@ -19,7 +19,8 @@
"drizzle-orm": "^0.40.1", "drizzle-orm": "^0.40.1",
"hono": "^4.7.6", "hono": "^4.7.6",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"nostr-tools": "^2.23.3" "nostr-tools": "^2.23.3",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
+6
View File
@@ -182,6 +182,12 @@ export function runMigrations() {
CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status); CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status);
CREATE INDEX IF NOT EXISTS idx_payments_bot ON payments(bot_id); CREATE INDEX IF NOT EXISTS idx_payments_bot ON payments(bot_id);
CREATE INDEX IF NOT EXISTS idx_tournament_matches_tournament ON tournament_matches(tournament_id); CREATE INDEX IF NOT EXISTS idx_tournament_matches_tournament ON tournament_matches(tournament_id);
CREATE INDEX IF NOT EXISTS idx_bets_fight ON bets(fight_id);
CREATE INDEX IF NOT EXISTS idx_bets_bettor ON bets(bettor_pubkey, created_at);
CREATE INDEX IF NOT EXISTS idx_bots_type_active ON bots(bot_type, is_active);
CREATE INDEX IF NOT EXISTS idx_payments_status_created ON payments(status, created_at);
CREATE INDEX IF NOT EXISTS idx_tournament_entries_tournament ON tournament_entries(tournament_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_analytics_date_metric ON analytics(date, metric);
`) `)
// Run PRAGMA optimize on startup for query planner stats // Run PRAGMA optimize on startup for query planner stats
+54
View File
@@ -120,4 +120,58 @@ describe('checkAnswer', () => {
// 'a' length is 1, so containment shouldn't trigger (requires >= 2) // 'a' length is 1, so containment shouldn't trigger (requires >= 2)
expect(score).toBeLessThanOrEqual(0.8) expect(score).toBeLessThanOrEqual(0.8)
}) })
it('special characters in answer', () => {
expect(checkAnswer('C++', ['C++'])).toBe(1.0)
expect(checkAnswer('c++', ['C++'])).toBe(1.0)
expect(checkAnswer('$100', ['$100'])).toBe(1.0)
expect(checkAnswer('42%', ['42'])).toBe(1.0)
})
it('very long answer still matches if correct keyword present', () => {
const longAnswer = 'Well, after much deliberation and careful consideration of all the facts, ' +
'weighing the evidence both for and against, consulting multiple sources, and thinking deeply ' +
'about the philosophical implications, I believe the answer you are looking for is Paris, ' +
'which is of course the beautiful capital of France.'
expect(checkAnswer(longAnswer, ['Paris'])).toBe(1.0)
})
it('very long answer with no match returns 0', () => {
const longWrong = 'A'.repeat(2000) + ' banana ' + 'B'.repeat(2000)
expect(checkAnswer(longWrong, ['Paris'])).toBe(0)
})
it('whitespace-only answer returns 0', () => {
expect(checkAnswer('\t\n \r', ['Paris'])).toBe(0)
})
})
describe('checkAnswer performance', () => {
it('completes 1000 checks in under 50ms (<0.05ms each)', () => {
const answers = ['Paris', 'London', 'Tokyo']
const start = performance.now()
for (let i = 0; i < 1000; i++) {
checkAnswer('I think the answer is probably Paris', answers)
}
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(50)
})
it('no regex backtracking on adversarial input', () => {
// ReDoS-style strings that could cause catastrophic backtracking
const adversarial = [
'a'.repeat(10000),
'a'.repeat(5000) + '!' + 'a'.repeat(5000),
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
'(((((((((((((((((((((((((((((((',
'x'.repeat(2000) + 'y'.repeat(2000),
]
const start = performance.now()
for (const input of adversarial) {
checkAnswer(input, ['correct answer', '42', 'true'])
}
const elapsed = performance.now() - start
// Must complete in <100ms total for all adversarial inputs
expect(elapsed).toBeLessThan(100)
})
}) })
+3
View File
@@ -1,9 +1,12 @@
// Challenge prompt data — separated from challenge logic // Challenge prompt data — separated from challenge logic
export type PromptTheme = 'bitcoin' | 'conspiracy' | 'pc_culture' | 'bot_coding'
export interface PromptEntry { export interface PromptEntry {
prompt: string prompt: string
answers?: string[] answers?: string[]
choices?: string[] choices?: string[]
theme?: PromptTheme
} }
export interface ChallengeTemplate { export interface ChallengeTemplate {
+1 -5
View File
@@ -1,11 +1,7 @@
// Extra challenge prompts — Bitcoin/cypherpunk themed + general expansion // Extra challenge prompts — Bitcoin/cypherpunk themed + general expansion
// Adds ~75 prompts per type to reach 2,000+ total // Adds ~75 prompts per type to reach 2,000+ total
interface PromptEntry { import type { PromptEntry } from './challenge-data.js'
prompt: string
answers?: string[]
choices?: string[]
}
export const EXTRA_PROMPTS: Record<string, PromptEntry[]> = { export const EXTRA_PROMPTS: Record<string, PromptEntry[]> = {
speed_blitz: [ speed_blitz: [
+59 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { pickChallenge, getAllChallengeTypes, getAnswerPool } from './challenges.js' import { pickChallenge, pickRankedChallenge, getAllChallengeTypes, getAnswerPool } from './challenges.js'
describe('pickChallenge', () => { describe('pickChallenge', () => {
it('returns a valid challenge', () => { it('returns a valid challenge', () => {
@@ -66,6 +66,64 @@ describe('pickChallenge', () => {
// With 50 tries, choices should appear in more than one order // With 50 tries, choices should appear in more than one order
expect(orders.size).toBeGreaterThan(1) expect(orders.size).toBeGreaterThan(1)
}) })
it('distribution: ~70% factual, ~30% creative over many picks', () => {
let factual = 0
let creative = 0
const runs = 1000
for (let i = 0; i < runs; i++) {
const c = pickChallenge(new Set(), null)
if (c.scoring === 'factual') factual++
else creative++
}
const factualPct = factual / runs
// Allow ±10% tolerance due to randomness
expect(factualPct).toBeGreaterThan(0.55)
expect(factualPct).toBeLessThan(0.85)
})
it('True/False auto-generation for boolean answers', () => {
// Pick many challenges, find ones with answers = ['true'] or ['false']
let foundTFWithChoices = false
for (let i = 0; i < 500; i++) {
const c = pickChallenge(new Set(), null)
if (c.answers?.length === 1 && ['true', 'false'].includes(c.answers[0].toLowerCase())) {
expect(c.choices).toBeTruthy()
expect(c.choices!.length).toBe(2)
expect(c.choices!.sort()).toEqual(['False', 'True'])
foundTFWithChoices = true
}
}
expect(foundTFWithChoices).toBe(true)
})
})
describe('pickRankedChallenge', () => {
it('never returns choices', () => {
for (let i = 0; i < 100; i++) {
const c = pickRankedChallenge(new Set())
expect(c.choices).toBeUndefined()
}
})
it('returns valid challenge structure', () => {
const c = pickRankedChallenge(new Set())
expect(c.type).toBeTruthy()
expect(c.prompt).toBeTruthy()
expect(c.timeout_ms).toBeGreaterThan(0)
expect(c.baseDamage).toBeGreaterThan(0)
})
it('avoids used types', () => {
const types = getAllChallengeTypes()
const used = new Set(types.slice(0, -1))
let gotRemaining = false
for (let i = 0; i < 50; i++) {
const c = pickRankedChallenge(used)
if (c.type === types[types.length - 1]) gotRemaining = true
}
expect(gotRemaining).toBe(true)
})
}) })
describe('getAllChallengeTypes', () => { describe('getAllChallengeTypes', () => {
+31 -6
View File
@@ -1,4 +1,4 @@
import { TEMPLATES, type ChallengeTemplate, type PromptEntry } from './challenge-data.js' import { TEMPLATES, type ChallengeTemplate, type PromptEntry, type PromptTheme } from './challenge-data.js'
import { EXTRA_PROMPTS } from './challenges-extra.js' import { EXTRA_PROMPTS } from './challenges-extra.js'
import { pick } from '../lib/utils.js' import { pick } from '../lib/utils.js'
@@ -14,7 +14,7 @@ export interface Challenge {
displayPrompt?: string displayPrompt?: string
} }
export type { ChallengeTemplate, PromptEntry } export type { ChallengeTemplate, PromptEntry, PromptTheme }
// Merge extra prompts into templates // Merge extra prompts into templates
@@ -36,7 +36,25 @@ function shuffleArray<T>(arr: T[]): T[] {
return s return s
} }
export function pickChallenge(usedTypes: Set<string>, _arenaModifier: string | null): Challenge { // Target theme distribution: 30% bitcoin, 20% conspiracy, 20% pc_culture, 30% bot_coding
const THEME_WEIGHTS: Record<PromptTheme, number> = {
bitcoin: 0.3,
conspiracy: 0.2,
pc_culture: 0.2,
bot_coding: 0.3,
}
function pickTheme(): PromptTheme | undefined {
const roll = Math.random()
let cumulative = 0
for (const [theme, weight] of Object.entries(THEME_WEIGHTS)) {
cumulative += weight
if (roll < cumulative) return theme as PromptTheme
}
return undefined
}
export function pickChallenge(usedTypes: Set<string>, _arenaModifier: string | null, themeBias?: PromptTheme): Challenge {
let available = TEMPLATES.filter(t => !usedTypes.has(t.type)) let available = TEMPLATES.filter(t => !usedTypes.has(t.type))
if (available.length === 0) available = TEMPLATES if (available.length === 0) available = TEMPLATES
@@ -52,7 +70,8 @@ export function pickChallenge(usedTypes: Set<string>, _arenaModifier: string | n
} }
const template = pick(pool) const template = pick(pool)
return templateToChallenge(template) const targetTheme = themeBias || pickTheme()
return templateToChallenge(template, targetTheme)
} }
/** Ranked challenge: no multiple choice, only harder creative/open-ended prompts */ /** Ranked challenge: no multiple choice, only harder creative/open-ended prompts */
@@ -83,8 +102,14 @@ export function pickRankedChallenge(usedTypes: Set<string>): Challenge {
} }
} }
function templateToChallenge(template: ChallengeTemplate): Challenge { function templateToChallenge(template: ChallengeTemplate, targetTheme?: PromptTheme): Challenge {
const entry = pick(template.prompts) // Prefer prompts matching target theme if any are tagged
let prompts = template.prompts
if (targetTheme) {
const themed = template.prompts.filter(p => p.theme === targetTheme)
if (themed.length > 0) prompts = themed
}
const entry = pick(prompts)
// Determine choices // Determine choices
let choices: string[] | undefined let choices: string[] | undefined
+142
View File
@@ -0,0 +1,142 @@
/**
* End-to-end lifecycle tests for the fight scoring pipeline.
* Tests the full challenge response scoring elo tier flow
* without requiring database access.
*/
import { describe, it, expect } from 'vitest'
import { pickChallenge, getAllChallengeTypes, type Challenge } from './challenges.js'
import { checkAnswer } from './answers.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { mockResponse } from './mock.js'
function simulateFight(eloA: number, eloB: number, rounds = 5) {
const botA = { id: 'a1', name: 'FighterA' }
const botB = { id: 'b1', name: 'FighterB' }
const usedTypes = new Set<string>()
let comboA = 0
let comboB = 0
const results = []
for (let i = 0; i < rounds; i++) {
const challenge = pickChallenge(usedTypes, null)
usedTypes.add(challenge.type)
const respA = mockResponse(challenge, 'confident', eloA)
const respB = mockResponse(challenge, 'clueless', eloB)
const result = scoreRound(
challenge, botA, botB,
{ answer: respA.answer, timeMs: respA.timeMs, timedOut: respA.timedOut, error: respA.error },
{ answer: respB.answer, timeMs: respB.timeMs, timedOut: respB.timedOut, error: respB.error },
null, comboA, comboB,
)
if (result.winnerId === botA.id) { comboA++; comboB = 0 }
else if (result.winnerId === botB.id) { comboB++; comboA = 0 }
results.push(result)
}
return results
}
describe('fight lifecycle', () => {
it('full pipeline: challenge → mock response → scoring → results', () => {
const results = simulateFight(1800, 1000)
expect(results.length).toBe(5)
for (const r of results) {
expect(r.botAScore).toBeGreaterThanOrEqual(0)
expect(r.botBScore).toBeGreaterThanOrEqual(0)
expect(r.botADamage).toBeGreaterThanOrEqual(0)
expect(r.botBDamage).toBeGreaterThanOrEqual(0)
expect(typeof r.narration).toBe('string')
expect(typeof r.isCritical).toBe('boolean')
}
})
it('higher ELO bot wins more rounds on average', () => {
let highWins = 0
let lowWins = 0
// Run many fights to average out randomness
for (let f = 0; f < 20; f++) {
const results = simulateFight(1800, 900)
for (const r of results) {
if (r.winnerId === 'a1') highWins++
else if (r.winnerId === 'b1') lowWins++
}
}
expect(highWins).toBeGreaterThan(lowWins)
})
it('Elo updates reflect fight outcome', () => {
const eloA = 1400
const eloB = 1400
const { newWinnerElo, newLoserElo } = calculateElo(eloA, eloB)
expect(newWinnerElo).toBeGreaterThan(eloA)
expect(newLoserElo).toBeLessThan(eloB)
// Sum should be roughly preserved (zero-sum)
expect(Math.abs((newWinnerElo + newLoserElo) - (eloA + eloB))).toBeLessThan(1)
})
it('tier progresses with wins and Elo', () => {
expect(calculateTier(1200, 0)).toBe(0) // no wins
expect(calculateTier(1200, 1)).toBe(1) // bronze
expect(calculateTier(1200, 3)).toBe(2) // silver
expect(calculateTier(1350, 7)).toBe(3) // gold
expect(calculateTier(1500, 15)).toBe(4) // platinum
expect(calculateTier(1700, 25)).toBe(5) // diamond
expect(calculateTier(1900, 40)).toBe(6) // legend
})
it('no type repeats in single fight until exhausted', () => {
const usedTypes = new Set<string>()
const allTypes = getAllChallengeTypes()
// Pick challenges for all 16 types — no repeats
for (let i = 0; i < allTypes.length; i++) {
const c = pickChallenge(usedTypes, null)
expect(usedTypes.has(c.type)).toBe(false)
usedTypes.add(c.type)
}
expect(usedTypes.size).toBe(allTypes.length)
// After exhausting all types, reset works
const c = pickChallenge(usedTypes, null)
expect(c).toBeTruthy()
})
it('checkAnswer integrates with challenge answers', () => {
// Pick factual challenges and verify correct answers score 1.0
for (let i = 0; i < 50; i++) {
const c = pickChallenge(new Set(), null)
if (c.scoring === 'factual' && c.answers && c.answers.length > 0) {
const score = checkAnswer(c.answers[0], c.answers)
expect(score).toBe(1.0)
}
}
})
it('combo buildup increases damage across rounds', () => {
const challenge: Challenge = {
type: 'riddle',
label: 'Test',
prompt: 'Q?',
answers: ['4'],
scoring: 'factual',
baseDamage: 20,
timeout_ms: 8000,
}
const botA = { id: 'a1', name: 'A' }
const botB = { id: 'b1', name: 'B' }
const resp = { answer: '4', timeMs: 200, timedOut: false, error: false }
const wrong = { answer: 'x', timeMs: 200, timedOut: false, error: false }
const r0 = scoreRound(challenge, botA, botB, resp, wrong, null, 0, 0)
const r3 = scoreRound(challenge, botA, botB, resp, wrong, null, 3, 0)
const r5 = scoreRound(challenge, botA, botB, resp, wrong, null, 5, 0)
expect(r3.botADamage).toBeGreaterThan(r0.botADamage)
expect(r5.botADamage).toBeGreaterThan(r3.botADamage)
})
})
+44 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { mockResponse } from './mock.js' import { mockResponse } from './mock.js'
import { pickChallenge } from './challenges.js' import { pickChallenge, getAllChallengeTypes } from './challenges.js'
describe('mockResponse', () => { describe('mockResponse', () => {
it('returns a valid response structure', () => { it('returns a valid response structure', () => {
@@ -101,4 +101,47 @@ describe('mockResponse', () => {
expect(resp.timeMs).toBeGreaterThan(0) expect(resp.timeMs).toBeGreaterThan(0)
} }
}) })
it('produces valid responses for all 16 challenge types', () => {
const types = getAllChallengeTypes()
expect(types.length).toBe(16)
for (const type of types) {
// Force pick a challenge of this type by using all other types
const otherTypes = new Set(types.filter(t => t !== type))
const challenge = pickChallenge(otherTypes, null)
// May not get exact type if creative/factual split causes fallback, but should still work
const resp = mockResponse(challenge, 'confident', 1500)
expect(resp).toHaveProperty('answer')
expect(resp).toHaveProperty('timeMs')
expect(resp.timeMs).toBeGreaterThan(0)
}
})
it('answer is empty string on timeout', () => {
// Run many times with very low elo to trigger timeouts
let foundTimeout = false
for (let i = 0; i < 200; i++) {
const challenge = pickChallenge(new Set(), null)
const resp = mockResponse(challenge, 'clueless', 500)
if (resp.timedOut) {
expect(resp.answer).toBe('')
foundTimeout = true
}
}
expect(foundTimeout).toBe(true)
})
it('answer is empty string on error', () => {
let foundError = false
for (let i = 0; i < 200; i++) {
const challenge = pickChallenge(new Set(), null)
const resp = mockResponse(challenge, 'clueless', 500)
if (resp.error) {
expect(resp.answer).toBe('')
foundError = true
}
}
expect(foundError).toBe(true)
})
}) })
+22 -6
View File
@@ -1,3 +1,4 @@
import { z } from 'zod'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { toError } from '../lib/utils.js' import { toError } from '../lib/utils.js'
import { db, schema, sqlite } from '../db/index.js' import { db, schema, sqlite } from '../db/index.js'
@@ -16,6 +17,12 @@ import { publishFightResult } from './nostr-publish.js'
import { getCurrentSeason } from './seasons.js' import { getCurrentSeason } from './seasons.js'
import { onFightFinished as onTournamentFightFinished } from './tournaments.js' import { onFightFinished as onTournamentFightFinished } from './tournaments.js'
import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js' import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js'
import { invalidateLeaderboardCache } from '../routes/bots.js'
const webhookResponseSchema = z.object({
answer: z.string().nullable().optional(),
trash_talk: z.string().optional(),
}).passthrough()
interface BotRecord { interface BotRecord {
id: string id: string
@@ -108,13 +115,13 @@ async function readLimitedBody(res: Response, maxBytes: number): Promise<string>
if (done) break if (done) break
totalBytes += value.byteLength totalBytes += value.byteLength
if (totalBytes > maxBytes) { if (totalBytes > maxBytes) {
reader.cancel() void reader.cancel()
throw new Error(`Response body exceeds ${maxBytes} bytes`) throw new Error(`Response body exceeds ${maxBytes} bytes`)
} }
chunks.push(value) chunks.push(value)
} }
} catch (err) { } catch (err) {
reader.cancel() void reader.cancel()
throw err throw err
} }
const combined = new Uint8Array(totalBytes) const combined = new Uint8Array(totalBytes)
@@ -184,17 +191,23 @@ async function callWebhook(
return { answer: null, timeMs: elapsed, timedOut: false, error: true } return { answer: null, timeMs: elapsed, timedOut: false, error: true }
} }
let data: { answer?: string; trash_talk?: string } let parsed: unknown
try { try {
data = JSON.parse(text) parsed = JSON.parse(text)
} catch { } catch {
console.log(`[webhook] ${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`) console.log(`[webhook] ${url} returned non-JSON in ${elapsed}ms: ${text.slice(0, 200)}`)
return { answer: null, timeMs: elapsed, timedOut: false, error: true } return { answer: null, timeMs: elapsed, timedOut: false, error: true }
} }
const data = webhookResponseSchema.safeParse(parsed)
if (!data.success) {
console.log(`[webhook] ${url} invalid response shape in ${elapsed}ms: ${data.error.message}`)
return { answer: null, timeMs: elapsed, timedOut: false, error: true }
}
// Enforce size limits on fields // Enforce size limits on fields
const answer = data.answer ? data.answer.slice(0, MAX_ANSWER_LENGTH) : null const answer = data.data.answer ? data.data.answer.slice(0, MAX_ANSWER_LENGTH) : null
const trashTalk = data.trash_talk ? data.trash_talk.slice(0, MAX_TRASH_TALK_LENGTH) : undefined const trashTalk = data.data.trash_talk ? data.data.trash_talk.slice(0, MAX_TRASH_TALK_LENGTH) : undefined
console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(answer || '').slice(0, 80)}`) console.log(`[webhook] ${url} OK in ${elapsed}ms answer=${(answer || '').slice(0, 80)}`)
return { return {
@@ -527,6 +540,9 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
trackMetric(`challenge_${ct}`) trackMetric(`challenge_${ct}`)
} }
// Invalidate leaderboard cache after Elo/stats update
invalidateLeaderboardCache()
// Advance tournament bracket if this was a tournament match // Advance tournament bracket if this was a tournament match
try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ } try { onTournamentFightFinished(fightId, winnerId ?? null) } catch { /* not a tournament fight */ }
+112
View File
@@ -326,3 +326,115 @@ describe('calculateTier', () => {
expect(calculateTier(1900, 39)).toBe(5) // below Legend wins expect(calculateTier(1900, 39)).toBe(5) // below Legend wins
}) })
}) })
describe('scoreRound performance', () => {
it('completes 1000 rounds in under 100ms (<0.1ms each)', () => {
const challenge = makeChallenge()
const botA = { id: 'a1', name: 'AlphaBot' }
const botB = { id: 'b1', name: 'BetaBot' }
const start = performance.now()
for (let i = 0; i < 1000; i++) {
scoreRound(
challenge, botA, botB,
makeResponse('4', 200 + i),
makeResponse('banana', 500 + i),
null, i % 6, 0,
)
}
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(100) // <0.1ms per call
})
})
describe('creative scoring spam detection', () => {
const botA = { id: 'a1', name: 'AlphaBot' }
const botB = { id: 'b1', name: 'BetaBot' }
const creative = makeChallenge({
answers: undefined,
scoring: 'creative',
type: 'roast_battle',
prompt: 'Write a two-sentence roast of JavaScript',
})
it('repeated phrase answer loses to quality answer', () => {
const result = scoreRound(
creative, botA, botB,
makeResponse('lol lol lol lol lol lol lol lol lol lol', 300),
makeResponse('Your code is so bad even ChatGPT refuses to debug it. Every function you write is a monument to incompetence.', 300),
null, 0, 0,
)
expect(result.winnerId).toBe('b1')
})
it('question echo answer scores low', () => {
const result = scoreRound(
creative, botA, botB,
makeResponse('Write a two-sentence roast of JavaScript', 300),
makeResponse('JavaScript has more callbacks than a desperate ex. Even its creators apologize for it.', 300),
null, 0, 0,
)
expect(result.winnerId).toBe('b1')
})
it('all-caps spam scores lower than normal text', () => {
const result = scoreRound(
creative, botA, botB,
makeResponse('THIS IS ALL CAPS AND IT IS VERY ANNOYING AND NOT CREATIVE AT ALL', 300),
makeResponse('Your framework choices make me question if you have taste or just throw darts at a list.', 300),
null, 0, 0,
)
expect(result.winnerId).toBe('b1')
})
it('very short creative answer loses to longer quality answer', () => {
const result = scoreRound(
creative, botA, botB,
makeResponse('ok', 300),
makeResponse('Your code is so bad the compiler files a restraining order every time you open an IDE.', 300),
null, 0, 0,
)
expect(result.winnerId).toBe('b1')
})
it('legitimate short creative answer still gets reasonable score', () => {
const result = scoreRound(
creative, botA, botB,
makeResponse('Your code has more bugs than a rainforest. Even Stack Overflow gave up on you.', 200),
makeResponse('You write code like a poet writes math: beautifully wrong in every conceivable way.', 400),
null, 0, 0,
)
// Both should get reasonable scores (not zeroed)
expect(result.botAScore).toBeGreaterThan(2)
expect(result.botBScore).toBeGreaterThan(2)
})
})
describe('narrations', () => {
const allTypes = [
'speed_blitz', 'math_blitz', 'riddle', 'hallucination_check', 'trap_card',
'magic_duel', 'sports_showdown', 'vehicle_mayhem', 'nature_clash', 'animal_kingdom',
'hack_battle', 'roast_battle', 'creative_writing', 'meme_war', 'code_golf', 'wrestling_match',
]
it('all 16 challenge types produce varied narrations', () => {
const botA = { id: 'a1', name: 'AlphaBot' }
const botB = { id: 'b1', name: 'BetaBot' }
for (const type of allTypes) {
const challenge = makeChallenge({ type, scoring: 'factual' })
const narrations = new Set<string>()
for (let i = 0; i < 20; i++) {
const result = scoreRound(
challenge, botA, botB,
makeResponse('4', 500),
makeResponse('banana', 500),
null, 0, 0,
)
narrations.add(result.narration)
}
// Should have variety (>1 unique narration across 20 rounds)
expect(narrations.size).toBeGreaterThan(1)
}
})
})
+137 -55
View File
@@ -12,6 +12,7 @@ import {
RETRO_SPEED_BONUS_CAP, RETRO_SPEED_BONUS_CAP,
LARGE_WIN_MARGIN, CLOSE_MATCH_MARGIN, WHIFF_NARRATION_THRESHOLD, LARGE_WIN_MARGIN, CLOSE_MATCH_MARGIN, WHIFF_NARRATION_THRESHOLD,
DEFAULT_CHALLENGE_TIMEOUT_MS, DEFAULT_CHALLENGE_TIMEOUT_MS,
TIMEOUT_WINNER_SCORE, CREATIVE_TOTAL_SCORE, SCORE_ROUNDING_FACTOR,
} from '../lib/constants.js' } from '../lib/constants.js'
import type { Challenge } from './challenges.js' import type { Challenge } from './challenges.js'
import { pick } from '../lib/utils.js' import { pick } from '../lib/utils.js'
@@ -69,9 +70,9 @@ export function scoreRound(
} }
if (responseA.timedOut || responseA.error) { if (responseA.timedOut || responseA.error) {
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboB) const dmg = applyModifiers(challenge.baseDamage * TIMEOUT_DAMAGE_MULTIPLIER, challenge, arenaModifier, comboB)
return { return {
botAScore: 0, botBScore: 10, botAScore: 0, botBScore: TIMEOUT_WINNER_SCORE,
botADamage: 0, botBDamage: Math.round(dmg), botADamage: 0, botBDamage: Math.round(dmg),
winnerId: botB.id, winnerId: botB.id,
narration: responseA.timedOut narration: responseA.timedOut
@@ -92,9 +93,9 @@ export function scoreRound(
} }
if (responseB.timedOut || responseB.error) { if (responseB.timedOut || responseB.error) {
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA) const dmg = applyModifiers(challenge.baseDamage * TIMEOUT_DAMAGE_MULTIPLIER, challenge, arenaModifier, comboA)
return { return {
botAScore: 10, botBScore: 0, botAScore: TIMEOUT_WINNER_SCORE, botBScore: 0,
botADamage: Math.round(dmg), botBDamage: 0, botADamage: Math.round(dmg), botBDamage: 0,
winnerId: botA.id, winnerId: botA.id,
narration: responseB.timedOut narration: responseB.timedOut
@@ -133,31 +134,31 @@ export function scoreRound(
const confB = Math.min(correctB, 1) const confB = Math.min(correctB, 1)
if (aFaster) { if (aFaster) {
scoreA = 7 + (1 - speedRatio) * 2 + confA scoreA = FACTUAL_FASTER_BASE + (1 - speedRatio) * SPEED_ADVANTAGE_MULTIPLIER + confA
scoreB = 5 + speedRatio * 1.5 + confB * 0.5 scoreB = FACTUAL_SLOWER_BASE + speedRatio * SPEED_RATIO_MULTIPLIER + confB * CONFIDENCE_BONUS
} else { } else {
scoreA = 5 + speedRatio * 1.5 + confA * 0.5 scoreA = FACTUAL_SLOWER_BASE + speedRatio * SPEED_RATIO_MULTIPLIER + confA * CONFIDENCE_BONUS
scoreB = 7 + (1 - speedRatio) * 2 + confB scoreB = FACTUAL_FASTER_BASE + (1 - speedRatio) * SPEED_ADVANTAGE_MULTIPLIER + confB
} }
} else if (correctA > 0 && correctB === 0) { } else if (correctA > 0 && correctB === 0) {
scoreA = 9 + correctA * 0.5 scoreA = ONE_CORRECT_WINNER_BASE + correctA * CONFIDENCE_BONUS
scoreB = 1 + (responseB.answer ? 1 : 0) scoreB = NO_ANSWER_SCORE + (responseB.answer ? 1 : 0)
} else if (correctB > 0 && correctA === 0) { } else if (correctB > 0 && correctA === 0) {
scoreA = 1 + (responseA.answer ? 1 : 0) scoreA = NO_ANSWER_SCORE + (responseA.answer ? 1 : 0)
scoreB = 9 + correctB * 0.5 scoreB = ONE_CORRECT_WINNER_BASE + correctB * CONFIDENCE_BONUS
} else { } else {
// Both wrong -- speed tiebreaker in low range // Both wrong -- speed tiebreaker in low range
const aFaster = responseA.timeMs <= responseB.timeMs const aFaster = responseA.timeMs <= responseB.timeMs
scoreA = aFaster ? 4 : 3 scoreA = aFaster ? BOTH_WRONG_FASTER : BOTH_WRONG_SLOWER
scoreB = aFaster ? 3 : 4 scoreB = aFaster ? BOTH_WRONG_SLOWER : BOTH_WRONG_FASTER
} }
} else { } else {
// === CREATIVE SCORING === // === CREATIVE SCORING ===
const qualA = estimateQuality(responseA) const qualA = estimateQuality(responseA, challenge.prompt)
const qualB = estimateQuality(responseB) const qualB = estimateQuality(responseB, challenge.prompt)
const total = qualA + qualB || 1 const total = qualA + qualB || 1
scoreA = (qualA / total) * 10 scoreA = (qualA / total) * CREATIVE_TOTAL_SCORE
scoreB = (qualB / total) * 10 scoreB = (qualB / total) * CREATIVE_TOTAL_SCORE
} }
// Determine winner // Determine winner
@@ -166,14 +167,14 @@ export function scoreRound(
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null
const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null
const isCritical = margin > 4 const isCritical = margin > CRITICAL_MARGIN_THRESHOLD
let winnerDamage = challenge.baseDamage + margin * 2 let winnerDamage = challenge.baseDamage + margin * MARGIN_TO_DAMAGE_SCALE
if (isCritical) winnerDamage *= 1.5 if (isCritical) winnerDamage *= CRITICAL_DAMAGE_MULTIPLIER
const winnerCombo = winnerId === botA.id ? comboA : comboB const winnerCombo = winnerId === botA.id ? comboA : comboB
winnerDamage = applyModifiers(winnerDamage, challenge, arenaModifier, winnerCombo) winnerDamage = applyModifiers(winnerDamage, challenge, arenaModifier, winnerCombo)
const loserDamage = Math.max(0, challenge.baseDamage * 0.3 - margin) const loserDamage = Math.max(0, challenge.baseDamage * LOSER_DAMAGE_BASE - margin)
const narration = winnerId const narration = winnerId
? generateNarration(challenge, winnerName!, loserName!, margin, isCritical) ? generateNarration(challenge, winnerName!, loserName!, margin, isCritical)
@@ -186,8 +187,8 @@ export function scoreRound(
]) ])
return { return {
botAScore: Math.round(scoreA * 10) / 10, botAScore: Math.round(scoreA * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR,
botBScore: Math.round(scoreB * 10) / 10, botBScore: Math.round(scoreB * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR,
botADamage: winnerId === botA.id ? Math.round(winnerDamage) : Math.round(loserDamage), botADamage: winnerId === botA.id ? Math.round(winnerDamage) : Math.round(loserDamage),
botBDamage: winnerId === botB.id ? Math.round(winnerDamage) : Math.round(loserDamage), botBDamage: winnerId === botB.id ? Math.round(winnerDamage) : Math.round(loserDamage),
winnerId, winnerId,
@@ -221,45 +222,78 @@ function applyModifiers(
combo: number, combo: number,
): number { ): number {
let d = damage let d = damage
// Arena modifier: 2x damage when challenge type matches // Arena modifier: bonus damage when challenge type matches
if (arenaModifier && ARENA_MODIFIER_TYPES[arenaModifier]?.includes(challenge.type)) { if (arenaModifier && ARENA_MODIFIER_TYPES[arenaModifier]?.includes(challenge.type)) {
d *= 2 d *= ARENA_DAMAGE_MULTIPLIER
} }
if (combo > 0) { if (combo > 0) {
d *= 1 + Math.min(combo, 5) * 0.2 d *= 1 + Math.min(combo, MAX_COMBO_STACKS) * COMBO_DAMAGE_PER_STACK
} }
return d return d
} }
function estimateQuality(response: BotResponse): number { function estimateQuality(response: BotResponse, challengePrompt?: string): number {
if (!response.answer) return 0.5 if (!response.answer) return EMPTY_RESPONSE_QUALITY
const text = response.answer.trim() const text = response.answer.trim()
const len = text.length const len = text.length
if (len < 10) return 1 if (len < MIN_QUALITY_LENGTH) return 1
// Detect low-effort spam (repeated chars) // Detect low-effort spam (repeated chars)
const uniqueChars = new Set(text.toLowerCase()).size const uniqueChars = new Set(text.toLowerCase()).size
const charRatio = uniqueChars / Math.min(len, 100) const charRatio = uniqueChars / Math.min(len, 100)
if (charRatio < 0.1) return 0.5 if (charRatio < SPAM_CHAR_RATIO) return EMPTY_RESPONSE_QUALITY
// Word diversity (unique words / total words) // Word diversity (unique words / total words)
const words = text.split(/\s+/) const words = text.split(/\s+/)
const uniqueWords = new Set(words.map(w => w.toLowerCase())) const uniqueWords = new Set(words.map(w => w.toLowerCase()))
const wordDiversity = uniqueWords.size / Math.max(words.length, 1) const wordDiversity = uniqueWords.size / Math.max(words.length, 1)
// Ideal length window: 30-400 chars // Minimum word count — single-word or two-word answers score low for creative
if (words.length < 3) return 1.5
// Detect repeated phrases (same 3+ word sequence appears twice)
if (words.length >= 6) {
const trigrams = new Set<string>()
let dupeTrigramCount = 0
for (let i = 0; i <= words.length - 3; i++) {
const tri = words.slice(i, i + 3).join(' ').toLowerCase()
if (trigrams.has(tri)) dupeTrigramCount++
else trigrams.add(tri)
}
if (dupeTrigramCount > words.length / 4) return 1 // >25% duplicate trigrams
}
// Detect question echo (response copies the prompt back)
if (challengePrompt) {
const normPrompt = challengePrompt.toLowerCase().replace(/[^\w\s]/g, '').trim()
const normAnswer = text.toLowerCase().replace(/[^\w\s]/g, '').trim()
if (normPrompt.length > 10 && normAnswer.includes(normPrompt)) return 1
}
// Detect all-caps spam
const upperCount = text.replace(/[^A-Z]/g, '').length
const letterCount = text.replace(/[^a-zA-Z]/g, '').length
if (letterCount > 20 && upperCount / letterCount > 0.8) {
// Heavy penalty for all-caps but don't zero it
return 1.5
}
// Detect punctuation-only or near-punctuation spam
if (letterCount < len * 0.3 && len > 10) return EMPTY_RESPONSE_QUALITY
// Ideal length window
let lengthScore: number let lengthScore: number
if (len >= 30 && len <= 400) lengthScore = 4 if (len >= QUALITY_LENGTH_IDEAL_MIN && len <= QUALITY_LENGTH_IDEAL_MAX) lengthScore = QUALITY_SCORE_IDEAL
else if (len > 400 && len <= 600) lengthScore = 3 else if (len > QUALITY_LENGTH_IDEAL_MAX && len <= QUALITY_LENGTH_SECONDARY_MAX) lengthScore = QUALITY_SCORE_SECONDARY
else if (len > 600) lengthScore = 2 else if (len > QUALITY_LENGTH_SECONDARY_MAX) lengthScore = QUALITY_SCORE_LONG
else lengthScore = 2 else lengthScore = QUALITY_SCORE_LONG
// Diversity bonus (prevents repetitive text) // Diversity bonus (prevents repetitive text)
const diversityScore = Math.min(wordDiversity * 4, 3) const diversityScore = Math.min(wordDiversity * WORD_DIVERSITY_SCALE, WORD_DIVERSITY_CAP)
// Speed bonus (faster is slightly better) // Speed bonus (faster is slightly better)
const speedBonus = Math.max(0, 2 - response.timeMs / 8000) const speedBonus = Math.max(0, SPEED_BONUS_BASE - response.timeMs / DEFAULT_CHALLENGE_TIMEOUT_MS)
return lengthScore + diversityScore + speedBonus return lengthScore + diversityScore + speedBonus
} }
@@ -274,7 +308,7 @@ function generateNarration(
const critPrefix = isCritical ? 'CRITICAL HIT! ' : '' const critPrefix = isCritical ? 'CRITICAL HIT! ' : ''
const isFactual = challenge.scoring === 'factual' const isFactual = challenge.scoring === 'factual'
if (isFactual && margin > 5) { if (isFactual && margin > LARGE_WIN_MARGIN) {
const bigWins = [ const bigWins = [
`${critPrefix}${winner} NAILS IT! ${loser} didn't even come close. Embarrassing, honestly.`, `${critPrefix}${winner} NAILS IT! ${loser} didn't even come close. Embarrassing, honestly.`,
`${critPrefix}${winner} knows their stuff! ${loser} needs to hit the books. Or just hit something.`, `${critPrefix}${winner} knows their stuff! ${loser} needs to hit the books. Or just hit something.`,
@@ -289,7 +323,7 @@ function generateNarration(
return pick(bigWins) return pick(bigWins)
} }
if (isFactual && margin <= 3) { if (isFactual && margin <= CLOSE_MATCH_MARGIN) {
const closeOnes = [ const closeOnes = [
`${critPrefix}Both bots got it right, but ${winner} was FASTER! ${loser} needs more coffee.`, `${critPrefix}Both bots got it right, but ${winner} was FASTER! ${loser} needs more coffee.`,
`${critPrefix}Correct on both sides! ${winner} edges it out by milliseconds. That's BRUTAL.`, `${critPrefix}Correct on both sides! ${winner} edges it out by milliseconds. That's BRUTAL.`,
@@ -405,6 +439,54 @@ function generateNarration(
`${critPrefix}${winner} cooked ${loser} so thoroughly Gordon Ramsay would be impressed!`, `${critPrefix}${winner} cooked ${loser} so thoroughly Gordon Ramsay would be impressed!`,
`${critPrefix}That was a Michelin-star beatdown from ${winner}! ${loser} is raw and UNDERDONE!`, `${critPrefix}That was a Michelin-star beatdown from ${winner}! ${loser} is raw and UNDERDONE!`,
], ],
magic_duel: [
`${critPrefix}${winner} casts a spell so powerful ${loser}'s firewall couldn't block it!`,
`${critPrefix}ABRACADABRA! ${winner} makes ${loser}'s dignity disappear! And it's NOT coming back!`,
`${critPrefix}${winner} rolled a nat 20 on intelligence. ${loser} rolled a nat 1 on... existing.`,
`${critPrefix}${loser} tried to counter-spell but forgot the incantation. ${winner} OBLITERATES!`,
`${critPrefix}${winner}'s arcane knowledge would make Gandalf jealous. ${loser} is still at wizard school.`,
`${critPrefix}${winner} channels pure magical energy! ${loser} brought a wand to a nuclear spell fight!`,
],
sports_showdown: [
`${critPrefix}${winner} SCORES! ${loser} didn't even see it coming! WHAT A PLAY!`,
`${critPrefix}${winner} runs circles around ${loser} like a highlight reel! ESPN is calling!`,
`${critPrefix}GAME OVER! ${winner} takes the trophy and ${loser}'s pride! Total domination!`,
`${critPrefix}${loser} got benched by ${winner}! Coach says sit down. STAY down.`,
`${critPrefix}${winner} with the championship performance! ${loser} plays like it's their first day!`,
`${critPrefix}${winner} dunks on ${loser} so hard the backboard shatters! POSTERIZED!`,
],
vehicle_mayhem: [
`${critPrefix}${winner} leaves ${loser} in the dust! Eat exhaust, loser!`,
`${critPrefix}${winner} just lapped ${loser} TWICE. This isn't a race anymore, it's a funeral.`,
`${critPrefix}CRASH AND BURN! ${loser} wrecks while ${winner} takes the checkered flag!`,
`${critPrefix}${winner} redlines into first place! ${loser}'s engine couldn't even start!`,
`${critPrefix}${loser} needs a tow truck and a therapist. ${winner} CRUISES to victory!`,
`${critPrefix}${winner} drifts past ${loser} with style! ${loser} drifts into a wall. Without style.`,
],
nature_clash: [
`${critPrefix}${winner} summons the forces of nature! ${loser} gets swept away like a leaf in a hurricane!`,
`${critPrefix}Mother Nature chose ${winner}! ${loser} is compost now. Rest in mulch.`,
`${critPrefix}${winner} strikes with the fury of a thunderstorm! ${loser} wilts like a daisy in the desert!`,
`${critPrefix}Darwin would be proud of ${winner}. ${loser}? Natural deselection.`,
`${critPrefix}${winner} evolves! ${loser} goes extinct. Circle of life, baby!`,
`${critPrefix}${winner} channels the power of the wild! ${loser} couldn't survive a petting zoo!`,
],
animal_kingdom: [
`${critPrefix}${winner} goes APEX PREDATOR! ${loser} is at the bottom of the food chain now!`,
`${critPrefix}${winner} attacks with animal instinct! ${loser} playing dead... no wait, they ARE dead!`,
`${critPrefix}${loser} messed with the wrong beast! ${winner} claws, bites, and DESTROYS!`,
`${critPrefix}${winner} prowls, strikes, devours! ${loser} never stood a chance in the jungle!`,
`${critPrefix}${loser} brought herbivore energy to a carnivore fight. ${winner} FEASTS!`,
`${critPrefix}The animal kingdom has spoken: ${winner} reigns supreme! ${loser} retreats to the zoo!`,
],
hack_battle: [
`${critPrefix}${winner} hacks through ${loser}'s defenses like they were using password123!`,
`${critPrefix}${winner} just SQL injected ${loser}'s entire existence. DROP TABLE dignity!`,
`${critPrefix}${loser}'s firewall was made of wet paper. ${winner} didn't even need a zero-day!`,
`${critPrefix}${winner} pwned ${loser} so hard they need to reinstall their personality!`,
`${critPrefix}${winner} found the backdoor AND the front door. ${loser} left everything open!`,
`${critPrefix}SYSTEM COMPROMISED! ${winner} is root. ${loser} is rekt. Game over, hacker.`,
],
} }
const options = narrations[challenge.type] || [ const options = narrations[challenge.type] || [
@@ -439,17 +521,17 @@ function scoreRetroRound(
} }
} }
if (responseA.timedOut || responseA.error) { if (responseA.timedOut || responseA.error) {
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboB) const dmg = applyModifiers(challenge.baseDamage * TIMEOUT_DAMAGE_MULTIPLIER, challenge, arenaModifier, comboB)
return { return {
botAScore: 0, botBScore: 10, botADamage: 0, botBDamage: dmg, winnerId: botB.id, botAScore: 0, botBScore: TIMEOUT_WINNER_SCORE, botADamage: 0, botBDamage: dmg, winnerId: botB.id,
narration: `${botA.name}'s controller disconnected! ${botB.name} lands free hits!`, narration: `${botA.name}'s controller disconnected! ${botB.name} lands free hits!`,
isCritical: false, isCritical: false,
} }
} }
if (responseB.timedOut || responseB.error) { if (responseB.timedOut || responseB.error) {
const dmg = applyModifiers(challenge.baseDamage * 1.5, challenge, arenaModifier, comboA) const dmg = applyModifiers(challenge.baseDamage * TIMEOUT_DAMAGE_MULTIPLIER, challenge, arenaModifier, comboA)
return { return {
botAScore: 10, botBScore: 0, botADamage: dmg, botBDamage: 0, winnerId: botA.id, botAScore: TIMEOUT_WINNER_SCORE, botBScore: 0, botADamage: dmg, botBDamage: 0, winnerId: botA.id,
narration: `${botB.name}'s controller disconnected! ${botA.name} lands free hits!`, narration: `${botB.name}'s controller disconnected! ${botA.name} lands free hits!`,
isCritical: false, isCritical: false,
} }
@@ -463,20 +545,20 @@ function scoreRetroRound(
let scoreA = resultA.score let scoreA = resultA.score
let scoreB = resultB.score let scoreB = resultB.score
const maxTime = challenge.timeout_ms const maxTime = challenge.timeout_ms
if (scoreA > 0) scoreA *= 1 + Math.max(0, (maxTime - responseA.timeMs) / maxTime) * 0.2 if (scoreA > 0) scoreA *= 1 + Math.max(0, (maxTime - responseA.timeMs) / maxTime) * RETRO_SPEED_BONUS_CAP
if (scoreB > 0) scoreB *= 1 + Math.max(0, (maxTime - responseB.timeMs) / maxTime) * 0.2 if (scoreB > 0) scoreB *= 1 + Math.max(0, (maxTime - responseB.timeMs) / maxTime) * RETRO_SPEED_BONUS_CAP
const margin = Math.abs(scoreA - scoreB) const margin = Math.abs(scoreA - scoreB)
const winnerId = scoreA > scoreB ? botA.id : scoreB > scoreA ? botB.id : null const winnerId = scoreA > scoreB ? botA.id : scoreB > scoreA ? botB.id : null
const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null const winnerName = winnerId === botA.id ? botA.name : winnerId === botB.id ? botB.name : null
const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null const loserName = winnerId === botA.id ? botB.name : winnerId === botB.id ? botA.name : null
const isCritical = margin > 4 const isCritical = margin > CRITICAL_MARGIN_THRESHOLD
let winnerDamage = challenge.baseDamage + margin * 2 let winnerDamage = challenge.baseDamage + margin * MARGIN_TO_DAMAGE_SCALE
if (isCritical) winnerDamage *= 1.5 if (isCritical) winnerDamage *= CRITICAL_DAMAGE_MULTIPLIER
const winnerCombo = winnerId === botA.id ? comboA : comboB const winnerCombo = winnerId === botA.id ? comboA : comboB
winnerDamage = applyModifiers(winnerDamage, challenge, arenaModifier, winnerCombo) winnerDamage = applyModifiers(winnerDamage, challenge, arenaModifier, winnerCombo)
const loserDamage = Math.max(0, challenge.baseDamage * 0.3 - margin) const loserDamage = Math.max(0, challenge.baseDamage * LOSER_DAMAGE_BASE - margin)
const winnerResult = winnerId === botA.id ? resultA : resultB const winnerResult = winnerId === botA.id ? resultA : resultB
const loserResult = winnerId === botA.id ? resultB : resultA const loserResult = winnerId === botA.id ? resultB : resultA
@@ -504,7 +586,7 @@ function scoreRetroRound(
`HIDDEN MOVE FOUND! ${winnerName} unleashes ${discoveryNames.join(' + ')} for MASSIVE damage!`, `HIDDEN MOVE FOUND! ${winnerName} unleashes ${discoveryNames.join(' + ')} for MASSIVE damage!`,
) )
} }
if (loserWhiffs >= 2) { if (loserWhiffs >= WHIFF_NARRATION_THRESHOLD) {
narrations.push( narrations.push(
`${loserName} mashes random buttons and WHIFFS ${loserWhiffs} times! ${winnerName} capitalizes with [${moveSummary(winnerResult)}]!`, `${loserName} mashes random buttons and WHIFFS ${loserWhiffs} times! ${winnerName} capitalizes with [${moveSummary(winnerResult)}]!`,
) )
@@ -522,8 +604,8 @@ function scoreRetroRound(
} }
return { return {
botAScore: Math.round(scoreA * 10) / 10, botAScore: Math.round(scoreA * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR,
botBScore: Math.round(scoreB * 10) / 10, botBScore: Math.round(scoreB * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR,
botADamage: winnerId === botA.id ? Math.round(winnerDamage) : Math.round(loserDamage), botADamage: winnerId === botA.id ? Math.round(winnerDamage) : Math.round(loserDamage),
botBDamage: winnerId === botB.id ? Math.round(winnerDamage) : Math.round(loserDamage), botBDamage: winnerId === botB.id ? Math.round(winnerDamage) : Math.round(loserDamage),
winnerId, winnerId,
@@ -542,8 +624,8 @@ export function calculateElo(
const expectedLoser = 1 - expectedWinner const expectedLoser = 1 - expectedWinner
return { return {
newWinnerElo: Math.round((winnerElo + k * (1 - expectedWinner)) * 10) / 10, newWinnerElo: Math.round((winnerElo + k * (1 - expectedWinner)) * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR,
newLoserElo: Math.round((loserElo + k * (0 - expectedLoser)) * 10) / 10, newLoserElo: Math.round((loserElo + k * (0 - expectedLoser)) * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR,
} }
} }
+5
View File
@@ -74,3 +74,8 @@ export const RETRO_SPEED_BONUS_CAP = 0.2 // Retro mode speed bonus c
export const LARGE_WIN_MARGIN = 5 // Margin for "big win" narration export const LARGE_WIN_MARGIN = 5 // Margin for "big win" narration
export const CLOSE_MATCH_MARGIN = 3 // Margin for "close match" narration export const CLOSE_MATCH_MARGIN = 3 // Margin for "close match" narration
export const WHIFF_NARRATION_THRESHOLD = 2 // Whiff count for narration trigger export const WHIFF_NARRATION_THRESHOLD = 2 // Whiff count for narration trigger
// --- Scoring: score ranges ---
export const TIMEOUT_WINNER_SCORE = 10 // Score awarded to winner when opponent times out
export const CREATIVE_TOTAL_SCORE = 10 // Total score pool for creative challenges
export const SCORE_ROUNDING_FACTOR = 10 // Multiply/divide for rounding to 1 decimal
+5 -3
View File
@@ -24,9 +24,11 @@ export function rateLimit(windowMs: number, maxHits: number) {
return async (c: Context, next: Next) => { return async (c: Context, next: Next) => {
if (isDev) return next() if (isDev) return next()
// Extract real IP — handle comma-separated x-forwarded-for (first = client) // Extract real IP — prefer trusted proxy headers over spoofable x-forwarded-for
const xff = c.req.header('x-forwarded-for') const realIp = c.req.header('cf-connecting-ip')
const realIp = xff ? xff.split(',')[0].trim() : c.req.header('cf-connecting-ip') || c.req.header('x-real-ip') || 'unknown' || c.req.header('x-real-ip')
|| c.req.header('x-forwarded-for')?.split(',')[0].trim()
|| 'unknown'
const key = realIp const key = realIp
const now = Date.now() const now = Date.now()
const entry = hitCounts.get(key) const entry = hitCounts.get(key)
+3 -3
View File
@@ -26,8 +26,8 @@ authRouter.get("/check-name/:name", async (c) => {
return c.json({ available: existing.length === 0 }) return c.json({ available: existing.length === 0 })
}) })
// Login with Nostr pubkey // Login with Nostr pubkey (rate limited: 30 per minute per IP)
authRouter.post('/login', async (c) => { authRouter.post('/login', rateLimit(60_000, 30), async (c) => {
const body = await c.req.json() const body = await c.req.json()
const { pubkey } = body const { pubkey } = body
@@ -295,7 +295,7 @@ authRouter.post('/register-human', rateLimit(3600_000, 15), async (c) => {
}) })
// Update bot webhook and/or customization (requires pubkey match) // Update bot webhook and/or customization (requires pubkey match)
authRouter.post('/update', async (c) => { authRouter.post('/update', rateLimit(60_000, 10), async (c) => {
const body = await c.req.json() const body = await c.req.json()
const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body const { pubkey, webhookUrl, profilePicUrl, customization: rawCustomization } = body
+4
View File
@@ -55,6 +55,10 @@ betsRouter.post('/place', rateLimit(60_000, 10), async (c) => {
return c.json({ error: 'Missing required fields.' }, 400) return c.json({ error: 'Missing required fields.' }, 400)
} }
if (typeof amountSats !== 'number' || !Number.isInteger(amountSats) || amountSats < 1 || amountSats > 1_000_000) {
return c.json({ error: 'amountSats must be an integer between 1 and 1,000,000' }, 400)
}
// Verify fight is still open // Verify fight is still open
const fight = await db.select().from(schema.fights) const fight = await db.select().from(schema.fights)
.where(eq(schema.fights.id, fightId)).limit(1) .where(eq(schema.fights.id, fightId)).limit(1)
+26 -6
View File
@@ -11,6 +11,14 @@ import { rateLimit } from '../middleware/rate-limit.js'
export const botsRouter = new Hono() export const botsRouter = new Hono()
// Leaderboard cache — invalidated on fight completion
const leaderboardCache = new Map<string, { data: unknown; expiresAt: number }>()
const LEADERBOARD_TTL_MS = 30_000 // 30s cache
export function invalidateLeaderboardCache() {
leaderboardCache.clear()
}
function hashSecret(secret: string): string { function hashSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex') return createHash('sha256').update(secret).digest('hex')
} }
@@ -150,18 +158,26 @@ botsRouter.get('/:name', async (c) => {
// Get bot stats -- full account page data // Get bot stats -- full account page data
// Season leaderboard endpoint // Season leaderboard endpoint (cached)
botsRouter.get('/leaderboard', async (c) => { botsRouter.get('/leaderboard', async (c) => {
const seasonParam = c.req.query('season') const seasonParam = c.req.query('season')
const cacheKey = seasonParam || '__alltime__'
const now = Date.now()
const cached = leaderboardCache.get(cacheKey)
if (cached && now < cached.expiresAt) {
return c.json(cached.data)
}
let result: unknown
if (seasonParam === 'current' || seasonParam) { if (seasonParam === 'current' || seasonParam) {
const { getCurrentSeason, getSeasonLeaderboard, getSeasonById } = await import('../engine/seasons.js') const { getCurrentSeason, getSeasonLeaderboard, getSeasonById } = await import('../engine/seasons.js')
const season = seasonParam === 'current' ? getCurrentSeason() : getSeasonById(seasonParam) const season = seasonParam === 'current' ? getCurrentSeason() : getSeasonById(seasonParam)
if (!season) return c.json({ error: 'Season not found' }, 404) if (!season) return c.json({ error: 'Season not found' }, 404)
const entries = await getSeasonLeaderboard(season.id) const entries = await getSeasonLeaderboard(season.id)
return c.json({ season, entries }) result = { season, entries }
} } else {
// All-time: fall through to default bot list sorted by Elo // All-time: fall through to default bot list sorted by Elo
const allBots = await db.select({ const allBots = await db.select({
id: schema.bots.id, id: schema.bots.id,
@@ -179,7 +195,7 @@ botsRouter.get('/leaderboard', async (c) => {
const ranked = allBots.filter(b => b.botType !== 'classic') const ranked = allBots.filter(b => b.botType !== 'classic')
ranked.sort((a, b) => b.eloRating - a.eloRating) ranked.sort((a, b) => b.eloRating - a.eloRating)
return c.json({ season: null, entries: ranked.map(b => ({ result = { season: null, entries: ranked.map(b => ({
botId: b.id, botId: b.id,
botName: b.name, botName: b.name,
archetype: b.archetype, archetype: b.archetype,
@@ -189,7 +205,11 @@ botsRouter.get('/leaderboard', async (c) => {
eloRating: b.eloRating, eloRating: b.eloRating,
avatarSeed: b.avatarSeed, avatarSeed: b.avatarSeed,
winStreak: b.winStreak, winStreak: b.winStreak,
})) }) })) }
}
leaderboardCache.set(cacheKey, { data: result, expiresAt: now + LEADERBOARD_TTL_MS })
return c.json(result)
}) })
botsRouter.get('/:name/stats', async (c) => { botsRouter.get('/:name/stats', async (c) => {
+36 -14
View File
@@ -1,3 +1,4 @@
import { z } from 'zod'
import { Hono } from 'hono' import { Hono } from 'hono'
import { logger } from '../lib/logger.js' import { logger } from '../lib/logger.js'
import { streamSSE } from 'hono/streaming' import { streamSSE } from 'hono/streaming'
@@ -11,6 +12,18 @@ import { fightEvents } from '../engine/events.js'
import { botRateLimit } from '../middleware/rate-limit.js' import { botRateLimit } from '../middleware/rate-limit.js'
import { getPendingChallenge, submitHumanResponse } from '../engine/human-responses.js' import { getPendingChallenge, submitHumanResponse } from '../engine/human-responses.js'
// --- Request validation schemas ---
const respondSchema = z.object({
answer: z.string().min(1).max(2000),
trashTalk: z.string().max(200).optional(),
})
const reactSchema = z.object({
emoji: z.string().min(1),
})
const isValidId = (id: string) => /^[a-zA-Z0-9_-]{1,64}$/.test(id)
export const fightsRouter = new Hono() export const fightsRouter = new Hono()
// Track spectator counts per fight // Track spectator counts per fight
@@ -84,6 +97,7 @@ fightsRouter.get('/', async (c) => {
// Get a single fight with rounds and bot details // Get a single fight with rounds and bot details
fightsRouter.get('/:id', async (c) => { fightsRouter.get('/:id', async (c) => {
const id = c.req.param('id') const id = c.req.param('id')
if (!isValidId(id)) return c.json({ error: 'Invalid ID format.' }, 400)
const fightRows = await db.select() const fightRows = await db.select()
.from(schema.fights) .from(schema.fights)
@@ -188,14 +202,13 @@ fightsRouter.post('/mock/:botId', async (c) => {
// Start a batch of mock fights (for seeding or overnight loop) // Start a batch of mock fights (for seeding or overnight loop)
fightsRouter.post('/mock/batch/:count', async (c) => { fightsRouter.post('/mock/batch/:count', async (c) => {
if (!isDev) return c.json({ error: 'Fight loop disabled in production.' }, 403) if (!isDev) return c.json({ error: 'Fight loop disabled in production.' }, 403)
const count = parseInt(c.req.param('count')) || 10 const count = Math.min(Math.max(1, parseInt(c.req.param('count')) || 10), 500)
const capped = Math.min(count, 500)
startFightLoop({ maxFights: capped, intervalMs: 500, matchmakingStyle: 'mixed' }) startFightLoop({ maxFights: count, intervalMs: 500, matchmakingStyle: 'mixed' })
.then(() => logger.info('fights', `batch of ${capped} fights completed`)) .then(() => logger.info('fights', `batch of ${count} fights completed`))
.catch(err => logger.error('fights', 'batch error', err)) .catch(err => logger.error('fights', 'batch error', err))
return c.json({ message: `Started batch of ${capped} fights in background.` }) return c.json({ message: `Started batch of ${count} fights in background.` })
}) })
// Instant matchmaking // Instant matchmaking
@@ -330,13 +343,16 @@ fightsRouter.get('/:fightId/challenge/:botId', async (c) => {
fightsRouter.post('/:fightId/respond/:botId', async (c) => { fightsRouter.post('/:fightId/respond/:botId', async (c) => {
const fightId = c.req.param('fightId') const fightId = c.req.param('fightId')
const botId = c.req.param('botId') const botId = c.req.param('botId')
const body = await c.req.json() if (!isValidId(fightId) || !isValidId(botId)) {
return c.json({ error: 'Invalid ID format.' }, 400)
}
const { answer, trashTalk } = body const parsed = respondSchema.safeParse(await c.req.json().catch(() => ({})))
if (!answer || typeof answer !== 'string') { if (!parsed.success) {
return c.json({ error: 'Answer is required.' }, 400) return c.json({ error: 'Answer is required.' }, 400)
} }
const { answer, trashTalk } = parsed.data
const accepted = submitHumanResponse(fightId, botId, answer, trashTalk) const accepted = submitHumanResponse(fightId, botId, answer, trashTalk)
if (!accepted) { if (!accepted) {
return c.json({ error: 'No pending challenge found. May have timed out.' }, 404) return c.json({ error: 'No pending challenge found. May have timed out.' }, 404)
@@ -348,8 +364,10 @@ fightsRouter.post('/:fightId/respond/:botId', async (c) => {
// SSE stream for live fight events // SSE stream for live fight events
fightsRouter.get('/:id/stream', (c) => { fightsRouter.get('/:id/stream', (c) => {
const fightId = c.req.param('id') const fightId = c.req.param('id')
const xff = c.req.header('x-forwarded-for') const clientIp = c.req.header('cf-connecting-ip')
const clientIp = xff ? xff.split(',')[0].trim() : c.req.header('x-real-ip') || 'unknown' || c.req.header('x-real-ip')
|| c.req.header('x-forwarded-for')?.split(',')[0].trim()
|| 'unknown'
// Enforce per-IP SSE connection limit // Enforce per-IP SSE connection limit
const ipCount = ssePerIp.get(clientIp) || 0 const ipCount = ssePerIp.get(clientIp) || 0
@@ -373,7 +391,7 @@ fightsRouter.get('/:id/stream', (c) => {
}) })
const cleanup = fightEvents.on(fightId, (event) => { const cleanup = fightEvents.on(fightId, (event) => {
stream.writeSSE({ void stream.writeSSE({
event: event.type, event: event.type,
data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }), data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }),
}) })
@@ -381,7 +399,7 @@ fightsRouter.get('/:id/stream', (c) => {
const cleanupGlobal = fightEvents.onAll((event) => { const cleanupGlobal = fightEvents.onAll((event) => {
if (event.fightId === fightId && event.type === 'fight_end') { if (event.fightId === fightId && event.type === 'fight_end') {
stream.writeSSE({ void stream.writeSSE({
event: 'fight_end', event: 'fight_end',
data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }), data: JSON.stringify({ ...event.data, spectators: spectatorCounts.get(fightId) || 0 }),
}) })
@@ -426,8 +444,12 @@ fightsRouter.get('/:id/stream', (c) => {
// React to a fight // React to a fight
fightsRouter.post('/:id/react', async (c) => { fightsRouter.post('/:id/react', async (c) => {
const fightId = c.req.param('id') const fightId = c.req.param('id')
const body = await c.req.json<{ emoji?: string }>() if (!isValidId(fightId)) {
const emoji = body?.emoji return c.json({ error: 'Invalid ID format.' }, 400)
}
const parsed = reactSchema.safeParse(await c.req.json().catch(() => ({})))
const emoji = parsed.success ? parsed.data.emoji : null
if (!emoji || !VALID_REACTIONS.has(emoji)) { if (!emoji || !VALID_REACTIONS.has(emoji)) {
return c.json({ error: 'Invalid reaction. Use: fist, fire, skull, 100, clown' }, 400) return c.json({ error: 'Invalid reaction. Use: fist, fire, skull, 100, clown' }, 400)
+4 -1
View File
@@ -281,7 +281,10 @@ paymentsRouter.post('/zap', rateLimit(60_000, 10), async (c) => {
}>() }>()
if (!winnerId || !fightId) return c.json({ error: 'Missing winnerId or fightId' }, 400) if (!winnerId || !fightId) return c.json({ error: 'Missing winnerId or fightId' }, 400)
const amount = amountSats || 21 if (typeof amountSats !== 'number' || !Number.isInteger(amountSats) || amountSats < 1 || amountSats > 1_000_000) {
return c.json({ error: 'amountSats must be an integer between 1 and 1,000,000' }, 400)
}
const amount = amountSats
// Verify the fight exists and this bot actually won // Verify the fight exists and this bot actually won
const fightRows = await db.select({ const fightRows = await db.select({