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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user