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

@@ -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) {