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:
104
public/js/app.js
104
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) {
|
||||
|
||||
Reference in New Issue
Block a user