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:
18
README.md
18
README.md
@@ -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 "/"
|
||||||
|
|||||||
100
public/js/app.js
100
public/js/app.js
@@ -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) {
|
||||||
|
if (state.awaiting) return;
|
||||||
|
state.awaiting = true;
|
||||||
|
flashField('ok');
|
||||||
|
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');
|
$('score').parentElement.classList.remove('bump');
|
||||||
void $('score').parentElement.offsetWidth;
|
void $('score').parentElement.offsetWidth;
|
||||||
$('score').parentElement.classList.add('bump');
|
$('score').parentElement.classList.add('bump');
|
||||||
state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME);
|
buildRound(data.round); // neues Plakat (Score kommt vom Server)
|
||||||
flashField('ok');
|
}
|
||||||
beaconHit(); // Server zaehlt den Treffer (Anticheat)
|
// sonst: Server hat NICHT gezaehlt (z.B. zu schnell) -> Runde bleibt, kein Punkt
|
||||||
buildRound(); // neues Plakat, andere Farbe
|
} catch {
|
||||||
|
// Netzwerkfehler -> Runde bleibt bestehen
|
||||||
|
} finally {
|
||||||
|
if (state) state.awaiting = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function missDecoy(el) {
|
function missDecoy(el) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
if (now - g.lastHitAt < MIN_HIT_INTERVAL_MS) return res.json({ correct: false });
|
||||||
|
|
||||||
|
if (itemId === g.round.targetId) {
|
||||||
g.hits += 1;
|
g.hits += 1;
|
||||||
res.json({ counted: true });
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user