// fuesse.sexy — "Wanted!" (Fuss-Fahndung) // Schlanker Express-Server: liefert das Frontend + die Bildchen aus, stellt die Liste // der verfuegbaren Fuesse/Deko bereit und verwaltet eine persistente Rangliste. import express from 'express'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, readdirSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { readLeaderboard, buildBoards, addScore } from './leaderboardStore.js'; import { getMode } from './modeStore.js'; import { isBanned } from './banStore.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); const PUBLIC_DIR = join(ROOT, 'public'); const LIBRARY_DIR = join(ROOT, 'library'); const PORT = process.env.PORT || 3000; const IMAGE_EXT = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif']; // ---- Bildchen aus der Library einlesen ---- function isImage(f) { return IMAGE_EXT.some((ext) => f.toLowerCase().endsWith(ext)); } function prettyName(file) { return file .replace(/\.[^.]+$/, '') .replace(/[-_]+/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()); } function listImages(dir, urlPrefix) { if (!existsSync(dir)) return []; return readdirSync(dir) .filter(isImage) .sort() .map((f) => ({ id: f.replace(/\.[^.]+$/, ''), name: prettyName(f), url: `${urlPrefix}/${f}` })); } const feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet'); const props = listImages(join(LIBRARY_DIR, 'props'), '/library/props'); if (feet.length < 2) { console.warn(`[server] Achtung: nur ${feet.length} Fuss/Fuesse in library/feet — brauche mind. 2.`); } else { console.log(`[server] ${feet.length} Fuesse, ${props.length} Deko-Bildchen geladen.`); } const app = express(); app.use(express.json()); // Caching komplett verhindern (Browser UND Proxys wie Cloudflare). Sonst laden bei // manchen Nutzern nach einem Update alte JS/CSS-Dateien -> "laedt nicht richtig". const NO_STORE = 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; function noStore(res) { res.setHeader('Cache-Control', NO_STORE); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } app.use((req, res, next) => { noStore(res); next(); }); // Die HTML-Shells nie direkt ausliefern -> immer ueber die Mode-Gates unten // (verhindert, dass man den private-Modus per /index.html umgeht). app.get(['/index.html', '/blocked.html'], (req, res) => res.redirect(302, '/')); // Statische Assets (CSS/JS/Emoji), aber KEIN automatisches index.html (index:false). // etag/lastModified aus + no-store -> keine (bedingten) Caches, auch nicht in Proxys. app.use(express.static(PUBLIC_DIR, { index: false, etag: false, lastModified: false, setHeaders: noStore })); app.use('/library', express.static(LIBRARY_DIR, { etag: false, lastModified: false, setHeaders: noStore })); // Verfuegbare Bildchen app.get('/api/feet', (req, res) => { res.json({ feet, props }); }); // ---- Anticheat: server-autoritative Runden ---- // Der Server bestimmt pro Runde das Ziel + alle Items (mit IDs) und validiert den // geklickten Treffer gegen die Ziel-ID der AKTUELLEN Runde. Blindes "Treffer-Spam" bringt // nichts mehr — man muss pro Punkt die richtige (server-gewaehlte) Runde loesen. // Zusaetzlich: Reaktionszeit-Untergrenze, Mindestabstand, Deckelung nach echter Spielzeit. // (Ein eigens gebauter Solver-Bot bleibt in einem Browserspiel theoretisch moeglich — // 100% fälschungssicher geht clientseitig nicht.) const games = new Map(); // sessionId -> { startedAt, lastHitAt, hits, round } const MIN_HIT_INTERVAL_MS = 55; // schneller = verworfen const MIN_REACTION_MS = 120; // Runde muss min. so lange sichtbar gewesen sein const MAX_SESSION_MS = 15 * 60 * 1000; // Session-Lebensdauer const MIN_AVG_SEC_PER_HIT = 0.2; // max. Schnitt-Tempo -> Score-Deckel nach Zeit const ABS_CAP = 100000; function shuffleArr(a) { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } const pickRand = (arr) => arr[Math.floor(Math.random() * arr.length)]; // Objekt-Anzahl (muss zur Client-Formel passen) function objectCount(score) { let count = 10 + Math.floor(score * 1.5); if (score >= 15) count += Math.floor((score - 15) / 3) * 5; return Math.min(count, 100); } // Neue Runde: 1 Ziel-Fuss + Ablenker (andere Fuesse + etwas Deko), gemischt. function makeRound(hits) { const count = objectCount(hits); const target = pickRand(feet); const otherFeet = feet.filter((f) => f.url !== target.url); const items = [{ id: randomUUID(), url: target.url, isTarget: true }]; for (let i = 0; i < count - 1; i++) { const useProp = props.length && Math.random() < 0.22; const t = useProp ? pickRand(props) : otherFeet.length ? pickRand(otherFeet) : target; items.push({ id: randomUUID(), url: t.url, isTarget: false }); } shuffleArr(items); return { nonce: randomUUID(), targetId: items.find((it) => it.isTarget).id, targetUrl: target.url, score: hits, items: items.map((it) => ({ id: it.id, url: it.url })), // KEINE Ziel-Kennzeichnung }; } function issueRound(g, round) { g.round = { nonce: round.nonce, targetId: round.targetId, issuedAt: Date.now() }; } function publicRound(r) { return { nonce: r.nonce, score: r.score, targetUrl: r.targetUrl, items: r.items }; } function pruneGames() { const now = Date.now(); for (const [id, g] of games) if (now - g.startedAt > MAX_SESSION_MS) games.delete(id); if (games.size > 5000) { const oldest = [...games.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt); for (let i = 0; i < oldest.length - 5000; i++) games.delete(oldest[i][0]); } } app.post('/api/game/start', (req, res) => { pruneGames(); const sessionId = randomUUID(); const g = { startedAt: Date.now(), lastHitAt: 0, hits: 0, round: null }; const round = makeRound(0); issueRound(g, round); games.set(sessionId, g); res.json({ sessionId, round: publicRound(round) }); }); app.post('/api/game/hit', (req, res) => { const { sessionId, nonce, itemId } = req.body || {}; const g = games.get(sessionId); if (!g || !g.round) return res.status(404).json({ error: 'Keine Session.' }); const now = Date.now(); if (now - g.startedAt > MAX_SESSION_MS) { games.delete(sessionId); return res.status(410).json({ error: 'Session abgelaufen.' }); } // Nur die AKTUELLE Runde zaehlt; instant/zu schnelle Klicks werden verworfen if (nonce !== g.round.nonce) return res.json({ correct: false, stale: true }); if (now - g.round.issuedAt < MIN_REACTION_MS) return res.json({ correct: false }); if (now - g.lastHitAt < MIN_HIT_INTERVAL_MS) return res.json({ correct: false }); if (itemId === g.round.targetId) { g.hits += 1; g.lastHitAt = now; const next = makeRound(g.hits); issueRound(g, next); return res.json({ correct: true, round: publicRound(next) }); } return res.json({ correct: false }); }); // Darf auf dem aktuellen Pfad gespielt werden? (Client sendet nur seinen Pfad-Slug, // der geheime Pfad wird NICHT preisgegeben.) Dient zum Abbruch laufender Spiele. app.get('/api/status', (req, res) => { const m = getMode(); const slug = String(req.query.path || '').replace(/^\/+/, ''); res.json({ allowed: m.mode === 'public' || slug === m.path }); }); // Ranglisten: Allzeit-Top-5 + Heute-Top-10 app.get('/api/leaderboard', (req, res) => { res.json(buildBoards(readLeaderboard())); }); // Score eintragen -> Score kommt aus der SERVER-Session (Anticheat), nicht vom Client. app.post('/api/score', (req, res) => { const { name, sessionId } = req.body || {}; const g = games.get(sessionId); if (!g) return res.status(400).json({ error: 'Keine gueltige Spiel-Session.' }); games.delete(sessionId); // Session ist einmalig einloesbar const elapsedSec = (Date.now() - g.startedAt) / 1000; const maxByTime = Math.floor(elapsedSec / MIN_AVG_SEC_PER_HIT); const score = Math.max(0, Math.min(g.hits, maxByTime, ABS_CAP)); const cleanName = String(name || 'Anonym').trim().slice(0, 20) || 'Anonym'; if (isBanned(cleanName)) return res.status(403).json({ error: 'Name gesperrt.' }); res.json(addScore(cleanName, score)); }); // ---- Mode-Gates (Live: getMode() liest pro Request die Datei) ---- function sendHtml(res, file) { noStore(res); res.sendFile(join(PUBLIC_DIR, file)); } // Startseite: public -> Spiel, private -> "nicht erlaubt" app.get('/', (req, res) => { sendHtml(res, getMode().mode === 'private' ? 'blocked.html' : 'index.html'); }); // Geheimer Spiel-Pfad: nur im private-Modus aktiv, nur beim passenden Slug app.get('/:slug', (req, res, next) => { const m = getMode(); if (m.mode === 'private' && req.params.slug === m.path) return sendHtml(res, 'index.html'); next(); }); app.listen(PORT, () => { const m = getMode(); console.log(`[server] fuesse.sexy (Wanted!) laeuft auf http://localhost:${PORT}`); console.log(`[server] Modus: ${m.mode}${m.mode === 'private' ? ` (Spiel unter /${m.path})` : ''}`); });