From 221cba417f169e31d2216a091c7d003497055567 Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 5 Aug 2026 12:39:19 +0200 Subject: [PATCH] Anti-Cheat: server-autoritative Runden (blindes Treffer-Spam wirkungslos) Der Server bestimmt pro Runde Ziel + alle Items (mit zufaelligen IDs) und validiert jeden Treffer gegen die Ziel-ID der aktuellen Runde (nonce + itemId). Blindes Spammen von /api/game/hit bringt damit 0 Punkte. Zusaetzlich Reaktionszeit-Untergrenze und Mindestabstand; Score weiterhin nach Spielzeit gedeckelt. - Server: makeRound() + /api/game/start liefert erste Runde, /api/game/hit erwartet {nonce,itemId} und gibt bei korrektem Treffer die naechste Runde zurueck - Client: rendert server-gelieferte Runden, sendet geklickte itemId, Vorschub nur bei Server-Bestaetigung (Sofort-Flash fuers Gefuehl) - README: Ban-Befehle dokumentiert, Anti-Cheat-Abschnitt aktualisiert Co-Authored-By: Claude Opus 4.8 --- README.md | 18 +++++--- public/js/app.js | 104 ++++++++++++++++++++++++----------------------- server/index.js | 93 ++++++++++++++++++++++++++++++++++-------- 3 files changed, 143 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index bc7d57c..a197e9c 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,14 @@ In `public/js/app.js` oben: - Zwei Boards: **Hall of Fame** (Allzeit-Top-5) + **Heute** (Top-10). Gespeichert in `data/leaderboard.json` (wird automatisch angelegt, überlebt Neustarts). -- **Anti-Cheat:** Der Score ist server-autoritär — der Server vergibt zu Spielbeginn eine - Session (`/api/game/start`), zählt jeden gemeldeten Treffer (`/api/game/hit`) mit - Mindestabstand (gegen Auto-Klicker) und deckelt nach echter Spielzeit. Beim Eintragen - (`/api/score`) zählt **nur** diese Server-Zahl; ein Score ohne gültige Session wird - abgelehnt. Namen werden beim Anzeigen escaped (kein XSS). +- **Anti-Cheat:** **server-autoritative Runden.** Der Server bestimmt pro Runde das Ziel + und alle Items (mit zufälligen IDs) und validiert jeden Treffer gegen die Ziel-ID der + *aktuellen* Runde (`/api/game/hit` mit `nonce` + `itemId`). Blindes „Treffer-Spam" + bringt dadurch nichts mehr; zusätzlich Reaktionszeit-Untergrenze, Mindestabstand und + Deckelung nach echter Spielzeit. Beim Eintragen (`/api/score`) zählt **nur** die + server-gezählte Trefferzahl. Namen werden beim Anzeigen escaped (kein XSS). + > Hinweis: In einem Browserspiel bekommt der Client zwangsläufig die Renderdaten — + > ein eigens gebauter Solver-Bot bleibt theoretisch möglich, 100 % dicht geht nicht. ## Verwaltung per CLI @@ -70,6 +73,11 @@ node server/admin.js delete "Name" # einen bestimmten Spieler löschen node server/admin.js clear-today # nur die heutige Rangliste leeren node server/admin.js clear-all # komplette Rangliste leeren +# Bannen (namensbasiert, case-insensitiv) +node server/admin.js ban "Name" # sperren (+ vorhandene Einträge entfernen) +node server/admin.js unban "Name" # Sperre aufheben +node server/admin.js bans # gebannte Namen anzeigen + # Betriebsmodus (wirkt SOFORT, ohne Neustart) node server/admin.js mode # aktuellen Modus anzeigen node server/admin.js mode public # Spiel öffentlich unter "/" diff --git a/public/js/app.js b/public/js/app.js index 977a825..23a0e3f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -98,42 +98,38 @@ function renderLeaderboard(el, list) { } // ================= Spiel ================= +// Runden sind SERVER-AUTORITATIV: der Server bestimmt Ziel + Items (mit IDs) und +// validiert jeden Treffer. Der Client rendert nur, was der Server schickt. async function startGame() { $('home').classList.add('hidden'); $('gameover').classList.add('hidden'); $('game').classList.remove('hidden'); - state = { score: 0, timeLeft: START_TIME, items: [], groups: [], stage: 'grid', running: true, sessionId: null }; + state = { score: 0, timeLeft: START_TIME, items: [], groups: [], stage: 'grid', running: true, sessionId: null, nonce: null, awaiting: false }; $('score').textContent = '0'; - buildRound(); + + const start = await startSession(); + if (!start || !start.sessionId || !start.round) { + showFeedback('Server nicht erreichbar'); + return goHome(); + } + state.sessionId = start.sessionId; + buildRound(start.round); lastT = performance.now(); cancelAnimationFrame(rafId); rafId = requestAnimationFrame(loop); startModeWatch(); // laufendes Spiel abbrechen, falls live auf "privat" geschaltet wird - - // Anticheat-Session holen (der Server zaehlt die Treffer selbst) - state.sessionId = await startSession(); } async function startSession() { try { const res = await fetch('/api/game/start', { method: 'POST' }); - if (res.ok) return (await res.json()).sessionId; + if (res.ok) return await res.json(); // { sessionId, round } } catch {} return null; } -// Treffer dem Server melden (fire-and-forget). Der Server zaehlt & prueft Timing. -function beaconHit() { - if (!state || !state.sessionId) return; - fetch('/api/game/hit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId: state.sessionId }), - }).catch(() => {}); -} - // Live-Pruefung des Betriebsmodus: darf auf DIESEM Pfad gerade gespielt werden? async function checkPlayAllowed() { try { @@ -167,41 +163,32 @@ function fieldSize() { return { w: r.width, h: r.height }; } -// Baut eine neue Runde: neuer (zufaelliger) Ziel-Fuss + Ablenker, Layout je nach Stufe. -function buildRound() { +// Baut eine Runde aus SERVER-Daten auf (Ziel + Items mit IDs). Layout/Bewegung laufen +// clientseitig (nur kosmetisch); die Trefferpruefung macht der Server anhand der IDs. +function buildRound(round) { const field = $('field'); field.innerHTML = ''; state.items = []; state.groups = []; + state.nonce = round.nonce; + state.score = round.score; + $('score').textContent = state.score; const { w, h } = fieldSize(); - const diff = difficulty(state.score); + const diff = difficulty(round.score); state.stage = diff.stage; - // Ziel-Fusstyp zufaellig (Farbe wechselt jede Runde) - const target = pick(FEET); - $('wanted-img').src = target.url; + $('wanted-img').src = round.targetUrl; + const n = round.items.length; // Item-Groessenwahl: schrumpft mit steigender Anzahl, bleibt tippfreundlich - const size = clamp(Math.sqrt((w * h) / (diff.count * 3.4)), 34, 66); + const size = clamp(Math.sqrt((w * h) / (n * 3.4)), 34, 66); + const positions = layoutPositions(diff, n, w, h, size); - // Typen fuer die Items zusammenstellen: 1 Ziel + Ablenker - const otherFeet = FEET.filter((f) => f.id !== target.id); - const decoyPool = []; - // Ablenker: v.a. andersfarbige Fuesse (inkl. aehnliche), plus ein paar Deko-Props - for (let i = 0; i < diff.count - 1; i++) { - const useProp = PROPS.length && Math.random() < 0.22; - decoyPool.push({ type: useProp ? pick(PROPS) : pick(otherFeet), isTarget: false }); - } - const specs = shuffle([{ type: target, isTarget: true }, ...decoyPool]); - - // Positionen je Stufe - const positions = layoutPositions(diff, specs.length, w, h, size); - - specs.forEach((spec, i) => { + round.items.forEach((srv, i) => { const el = document.createElement('img'); el.className = 'item'; - el.src = spec.type.url; + el.src = srv.url; el.draggable = false; el.style.width = size + 'px'; el.style.height = size + 'px'; @@ -210,7 +197,8 @@ function buildRound() { const p = positions[i]; const item = { el, - isTarget: spec.isTarget, + id: srv.id, // Server-ID des Items (fuer die Trefferpruefung) + isTarget: srv.url === round.targetUrl, // nur fuer instantes Feedback; Server validiert final x: p.x, y: p.y, vx: 0, @@ -366,7 +354,7 @@ function updateTimerBar() { // wenn andere Bildchen darueberliegen. Nur wenn man klar daneben (aber auf einem Ablenker) // klickt, gibt es Abzug; leerer Klick kostet nichts. function onFieldPointerDown(e) { - if (!state || !state.running) return; + if (!state || !state.running || state.awaiting) return; const rect = $('field').getBoundingClientRect(); const px = e.clientX - rect.left; const py = e.clientY - rect.top; @@ -377,7 +365,7 @@ function onFieldPointerDown(e) { const cx = target.x + target.size / 2; const cy = target.y + target.size / 2; const tol = target.size / 2 + Math.max(16, target.size * 0.5); - if (Math.hypot(px - cx, py - cy) <= tol) return hitTarget(); + if (Math.hypot(px - cx, py - cy) <= tol) return hitTarget(target); } // 2) sonst: wurde ein Ablenker getroffen? oberstes zuerst (state.items: spaeter = oben) @@ -391,16 +379,32 @@ function onFieldPointerDown(e) { // 3) leerer Klick -> nichts } -function hitTarget() { - state.score += 1; - $('score').textContent = state.score; - $('score').parentElement.classList.remove('bump'); - void $('score').parentElement.offsetWidth; - $('score').parentElement.classList.add('bump'); - state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME); +// Treffer -> Server validiert (nonce + itemId). Nur bei Bestaetigung gibt es Punkt + +// naechste Runde. Sofort-Feedback (flash) fuers Gefuehl, Feld-Wechsel nach Server-Antwort. +async function hitTarget(target) { + if (state.awaiting) return; + state.awaiting = true; flashField('ok'); - beaconHit(); // Server zaehlt den Treffer (Anticheat) - buildRound(); // neues Plakat, andere Farbe + try { + const res = await fetch('/api/game/hit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId: state.sessionId, nonce: state.nonce, itemId: target.id }), + }); + const data = res.ok ? await res.json() : null; + if (data && data.correct && data.round) { + state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME); + $('score').parentElement.classList.remove('bump'); + void $('score').parentElement.offsetWidth; + $('score').parentElement.classList.add('bump'); + buildRound(data.round); // neues Plakat (Score kommt vom Server) + } + // sonst: Server hat NICHT gezaehlt (z.B. zu schnell) -> Runde bleibt, kein Punkt + } catch { + // Netzwerkfehler -> Runde bleibt bestehen + } finally { + if (state) state.awaiting = false; + } } function missDecoy(el) { diff --git a/server/index.js b/server/index.js index 232e6b0..877eccc 100644 --- a/server/index.js +++ b/server/index.js @@ -77,16 +77,63 @@ app.get('/api/feet', (req, res) => { res.json({ feet, props }); }); -// ---- Anticheat: server-autoritative Spiel-Sessions ---- -// Der Server vergibt zu Spielbeginn eine Session und zaehlt die Treffer SELBST. Beim -// Eintragen zaehlt nur diese Server-Zahl (nicht, was der Client behauptet). Zusaetzlich: -// Mindestabstand zwischen Treffern (gegen Auto-Klicker) + Deckelung nach echter Spielzeit. -const games = new Map(); // sessionId -> { startedAt, lastHitAt, hits } -const MIN_HIT_INTERVAL_MS = 55; // schneller = ignoriert (unmenschlich) +// ---- 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.18; // max. Schnitt-Tempo -> Score-Deckel nach Zeit +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); @@ -99,23 +146,35 @@ function pruneGames() { app.post('/api/game/start', (req, res) => { pruneGames(); const sessionId = randomUUID(); - games.set(sessionId, { startedAt: Date.now(), lastHitAt: 0, hits: 0 }); - res.json({ sessionId }); + 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 g = games.get(req.body && req.body.sessionId); - if (!g) return res.status(404).json({ error: 'Keine Session.' }); + 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(req.body.sessionId); + games.delete(sessionId); return res.status(410).json({ error: 'Session abgelaufen.' }); } - // Treffer zu schnell nach dem letzten -> ignorieren (Auto-Klicker), aber kein Fehler - if (now - g.lastHitAt < MIN_HIT_INTERVAL_MS) return res.json({ counted: false }); - g.lastHitAt = now; - g.hits += 1; - res.json({ counted: true }); + // 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,