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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-05 12:39:19 +02:00
parent 8dbff16cec
commit 221cba417f
3 changed files with 143 additions and 72 deletions

View File

@@ -53,11 +53,14 @@ In `public/js/app.js` oben:
- Zwei Boards: **Hall of Fame** (Allzeit-Top-5) + **Heute** (Top-10). Gespeichert in - Zwei Boards: **Hall of Fame** (Allzeit-Top-5) + **Heute** (Top-10). Gespeichert in
`data/leaderboard.json` (wird automatisch angelegt, überlebt Neustarts). `data/leaderboard.json` (wird automatisch angelegt, überlebt Neustarts).
- **Anti-Cheat:** Der Score ist server-autoritär — der Server vergibt zu Spielbeginn eine - **Anti-Cheat:** **server-autoritative Runden.** Der Server bestimmt pro Runde das Ziel
Session (`/api/game/start`), zählt jeden gemeldeten Treffer (`/api/game/hit`) mit und alle Items (mit zufälligen IDs) und validiert jeden Treffer gegen die Ziel-ID der
Mindestabstand (gegen Auto-Klicker) und deckelt nach echter Spielzeit. Beim Eintragen *aktuellen* Runde (`/api/game/hit` mit `nonce` + `itemId`). Blindes „Treffer-Spam"
(`/api/score`) zählt **nur** diese Server-Zahl; ein Score ohne gültige Session wird bringt dadurch nichts mehr; zusätzlich Reaktionszeit-Untergrenze, Mindestabstand und
abgelehnt. Namen werden beim Anzeigen escaped (kein XSS). 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 ## 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-today # nur die heutige Rangliste leeren
node server/admin.js clear-all # komplette 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) # Betriebsmodus (wirkt SOFORT, ohne Neustart)
node server/admin.js mode # aktuellen Modus anzeigen node server/admin.js mode # aktuellen Modus anzeigen
node server/admin.js mode public # Spiel öffentlich unter "/" node server/admin.js mode public # Spiel öffentlich unter "/"

View File

@@ -98,42 +98,38 @@ function renderLeaderboard(el, list) {
} }
// ================= Spiel ================= // ================= 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() { async function startGame() {
$('home').classList.add('hidden'); $('home').classList.add('hidden');
$('gameover').classList.add('hidden'); $('gameover').classList.add('hidden');
$('game').classList.remove('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'; $('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(); lastT = performance.now();
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop); rafId = requestAnimationFrame(loop);
startModeWatch(); // laufendes Spiel abbrechen, falls live auf "privat" geschaltet wird 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() { async function startSession() {
try { try {
const res = await fetch('/api/game/start', { method: 'POST' }); 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 {} } catch {}
return null; 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? // Live-Pruefung des Betriebsmodus: darf auf DIESEM Pfad gerade gespielt werden?
async function checkPlayAllowed() { async function checkPlayAllowed() {
try { try {
@@ -167,41 +163,32 @@ function fieldSize() {
return { w: r.width, h: r.height }; return { w: r.width, h: r.height };
} }
// Baut eine neue Runde: neuer (zufaelliger) Ziel-Fuss + Ablenker, Layout je nach Stufe. // Baut eine Runde aus SERVER-Daten auf (Ziel + Items mit IDs). Layout/Bewegung laufen
function buildRound() { // clientseitig (nur kosmetisch); die Trefferpruefung macht der Server anhand der IDs.
function buildRound(round) {
const field = $('field'); const field = $('field');
field.innerHTML = ''; field.innerHTML = '';
state.items = []; state.items = [];
state.groups = []; state.groups = [];
state.nonce = round.nonce;
state.score = round.score;
$('score').textContent = state.score;
const { w, h } = fieldSize(); const { w, h } = fieldSize();
const diff = difficulty(state.score); const diff = difficulty(round.score);
state.stage = diff.stage; state.stage = diff.stage;
// Ziel-Fusstyp zufaellig (Farbe wechselt jede Runde) $('wanted-img').src = round.targetUrl;
const target = pick(FEET);
$('wanted-img').src = target.url;
const n = round.items.length;
// Item-Groessenwahl: schrumpft mit steigender Anzahl, bleibt tippfreundlich // 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 round.items.forEach((srv, i) => {
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) => {
const el = document.createElement('img'); const el = document.createElement('img');
el.className = 'item'; el.className = 'item';
el.src = spec.type.url; el.src = srv.url;
el.draggable = false; el.draggable = false;
el.style.width = size + 'px'; el.style.width = size + 'px';
el.style.height = size + 'px'; el.style.height = size + 'px';
@@ -210,7 +197,8 @@ function buildRound() {
const p = positions[i]; const p = positions[i];
const item = { const item = {
el, 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, x: p.x,
y: p.y, y: p.y,
vx: 0, vx: 0,
@@ -366,7 +354,7 @@ function updateTimerBar() {
// wenn andere Bildchen darueberliegen. Nur wenn man klar daneben (aber auf einem Ablenker) // wenn andere Bildchen darueberliegen. Nur wenn man klar daneben (aber auf einem Ablenker)
// klickt, gibt es Abzug; leerer Klick kostet nichts. // klickt, gibt es Abzug; leerer Klick kostet nichts.
function onFieldPointerDown(e) { function onFieldPointerDown(e) {
if (!state || !state.running) return; if (!state || !state.running || state.awaiting) return;
const rect = $('field').getBoundingClientRect(); const rect = $('field').getBoundingClientRect();
const px = e.clientX - rect.left; const px = e.clientX - rect.left;
const py = e.clientY - rect.top; const py = e.clientY - rect.top;
@@ -377,7 +365,7 @@ function onFieldPointerDown(e) {
const cx = target.x + target.size / 2; const cx = target.x + target.size / 2;
const cy = target.y + target.size / 2; const cy = target.y + target.size / 2;
const tol = target.size / 2 + Math.max(16, target.size * 0.5); 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) // 2) sonst: wurde ein Ablenker getroffen? oberstes zuerst (state.items: spaeter = oben)
@@ -391,16 +379,32 @@ function onFieldPointerDown(e) {
// 3) leerer Klick -> nichts // 3) leerer Klick -> nichts
} }
function hitTarget() { // Treffer -> Server validiert (nonce + itemId). Nur bei Bestaetigung gibt es Punkt +
state.score += 1; // naechste Runde. Sofort-Feedback (flash) fuers Gefuehl, Feld-Wechsel nach Server-Antwort.
$('score').textContent = state.score; async function hitTarget(target) {
$('score').parentElement.classList.remove('bump'); if (state.awaiting) return;
void $('score').parentElement.offsetWidth; state.awaiting = true;
$('score').parentElement.classList.add('bump');
state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME);
flashField('ok'); flashField('ok');
beaconHit(); // Server zaehlt den Treffer (Anticheat) try {
buildRound(); // neues Plakat, andere Farbe 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) { function missDecoy(el) {

View File

@@ -77,16 +77,63 @@ app.get('/api/feet', (req, res) => {
res.json({ feet, props }); res.json({ feet, props });
}); });
// ---- Anticheat: server-autoritative Spiel-Sessions ---- // ---- Anticheat: server-autoritative Runden ----
// Der Server vergibt zu Spielbeginn eine Session und zaehlt die Treffer SELBST. Beim // Der Server bestimmt pro Runde das Ziel + alle Items (mit IDs) und validiert den
// Eintragen zaehlt nur diese Server-Zahl (nicht, was der Client behauptet). Zusaetzlich: // geklickten Treffer gegen die Ziel-ID der AKTUELLEN Runde. Blindes "Treffer-Spam" bringt
// Mindestabstand zwischen Treffern (gegen Auto-Klicker) + Deckelung nach echter Spielzeit. // nichts mehr — man muss pro Punkt die richtige (server-gewaehlte) Runde loesen.
const games = new Map(); // sessionId -> { startedAt, lastHitAt, hits } // Zusaetzlich: Reaktionszeit-Untergrenze, Mindestabstand, Deckelung nach echter Spielzeit.
const MIN_HIT_INTERVAL_MS = 55; // schneller = ignoriert (unmenschlich) // (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 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; 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() { function pruneGames() {
const now = Date.now(); const now = Date.now();
for (const [id, g] of games) if (now - g.startedAt > MAX_SESSION_MS) games.delete(id); 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) => { app.post('/api/game/start', (req, res) => {
pruneGames(); pruneGames();
const sessionId = randomUUID(); const sessionId = randomUUID();
games.set(sessionId, { startedAt: Date.now(), lastHitAt: 0, hits: 0 }); const g = { startedAt: Date.now(), lastHitAt: 0, hits: 0, round: null };
res.json({ sessionId }); const round = makeRound(0);
issueRound(g, round);
games.set(sessionId, g);
res.json({ sessionId, round: publicRound(round) });
}); });
app.post('/api/game/hit', (req, res) => { app.post('/api/game/hit', (req, res) => {
const g = games.get(req.body && req.body.sessionId); const { sessionId, nonce, itemId } = req.body || {};
if (!g) return res.status(404).json({ error: 'Keine Session.' }); const g = games.get(sessionId);
if (!g || !g.round) return res.status(404).json({ error: 'Keine Session.' });
const now = Date.now(); const now = Date.now();
if (now - g.startedAt > MAX_SESSION_MS) { if (now - g.startedAt > MAX_SESSION_MS) {
games.delete(req.body.sessionId); games.delete(sessionId);
return res.status(410).json({ error: 'Session abgelaufen.' }); return res.status(410).json({ error: 'Session abgelaufen.' });
} }
// Treffer zu schnell nach dem letzten -> ignorieren (Auto-Klicker), aber kein Fehler // Nur die AKTUELLE Runde zaehlt; instant/zu schnelle Klicks werden verworfen
if (now - g.lastHitAt < MIN_HIT_INTERVAL_MS) return res.json({ counted: false }); if (nonce !== g.round.nonce) return res.json({ correct: false, stale: true });
g.lastHitAt = now; if (now - g.round.issuedAt < MIN_REACTION_MS) return res.json({ correct: false });
g.hits += 1; if (now - g.lastHitAt < MIN_HIT_INTERVAL_MS) return res.json({ correct: false });
res.json({ counted: true });
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, // Darf auf dem aktuellen Pfad gespielt werden? (Client sendet nur seinen Pfad-Slug,