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

@@ -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,