Files
fuesse-sexy/server/index.js
Guido 221cba417f 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>
2026-08-05 12:39:19 +02:00

232 lines
8.9 KiB
JavaScript

// fuesse.sexy — "Wanted!" (Fuss-Fahndung)
// Schlanker Express-Server: liefert das Frontend + die Bildchen aus, stellt die Liste
// der verfuegbaren Fuesse/Deko bereit und verwaltet eine persistente Rangliste.
import express from 'express';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, readdirSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { readLeaderboard, buildBoards, addScore } from './leaderboardStore.js';
import { getMode } from './modeStore.js';
import { isBanned } from './banStore.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const PUBLIC_DIR = join(ROOT, 'public');
const LIBRARY_DIR = join(ROOT, 'library');
const PORT = process.env.PORT || 3000;
const IMAGE_EXT = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif'];
// ---- Bildchen aus der Library einlesen ----
function isImage(f) {
return IMAGE_EXT.some((ext) => f.toLowerCase().endsWith(ext));
}
function prettyName(file) {
return file
.replace(/\.[^.]+$/, '')
.replace(/[-_]+/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function listImages(dir, urlPrefix) {
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter(isImage)
.sort()
.map((f) => ({ id: f.replace(/\.[^.]+$/, ''), name: prettyName(f), url: `${urlPrefix}/${f}` }));
}
const feet = listImages(join(LIBRARY_DIR, 'feet'), '/library/feet');
const props = listImages(join(LIBRARY_DIR, 'props'), '/library/props');
if (feet.length < 2) {
console.warn(`[server] Achtung: nur ${feet.length} Fuss/Fuesse in library/feet — brauche mind. 2.`);
} else {
console.log(`[server] ${feet.length} Fuesse, ${props.length} Deko-Bildchen geladen.`);
}
const app = express();
app.use(express.json());
// Caching komplett verhindern (Browser UND Proxys wie Cloudflare). Sonst laden bei
// manchen Nutzern nach einem Update alte JS/CSS-Dateien -> "laedt nicht richtig".
const NO_STORE = 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
function noStore(res) {
res.setHeader('Cache-Control', NO_STORE);
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
app.use((req, res, next) => {
noStore(res);
next();
});
// Die HTML-Shells nie direkt ausliefern -> immer ueber die Mode-Gates unten
// (verhindert, dass man den private-Modus per /index.html umgeht).
app.get(['/index.html', '/blocked.html'], (req, res) => res.redirect(302, '/'));
// Statische Assets (CSS/JS/Emoji), aber KEIN automatisches index.html (index:false).
// etag/lastModified aus + no-store -> keine (bedingten) Caches, auch nicht in Proxys.
app.use(express.static(PUBLIC_DIR, { index: false, etag: false, lastModified: false, setHeaders: noStore }));
app.use('/library', express.static(LIBRARY_DIR, { etag: false, lastModified: false, setHeaders: noStore }));
// Verfuegbare Bildchen
app.get('/api/feet', (req, res) => {
res.json({ feet, props });
});
// ---- 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.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);
if (games.size > 5000) {
const oldest = [...games.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt);
for (let i = 0; i < oldest.length - 5000; i++) games.delete(oldest[i][0]);
}
}
app.post('/api/game/start', (req, res) => {
pruneGames();
const sessionId = randomUUID();
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 { 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(sessionId);
return res.status(410).json({ error: 'Session abgelaufen.' });
}
// 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,
// der geheime Pfad wird NICHT preisgegeben.) Dient zum Abbruch laufender Spiele.
app.get('/api/status', (req, res) => {
const m = getMode();
const slug = String(req.query.path || '').replace(/^\/+/, '');
res.json({ allowed: m.mode === 'public' || slug === m.path });
});
// Ranglisten: Allzeit-Top-5 + Heute-Top-10
app.get('/api/leaderboard', (req, res) => {
res.json(buildBoards(readLeaderboard()));
});
// Score eintragen -> Score kommt aus der SERVER-Session (Anticheat), nicht vom Client.
app.post('/api/score', (req, res) => {
const { name, sessionId } = req.body || {};
const g = games.get(sessionId);
if (!g) return res.status(400).json({ error: 'Keine gueltige Spiel-Session.' });
games.delete(sessionId); // Session ist einmalig einloesbar
const elapsedSec = (Date.now() - g.startedAt) / 1000;
const maxByTime = Math.floor(elapsedSec / MIN_AVG_SEC_PER_HIT);
const score = Math.max(0, Math.min(g.hits, maxByTime, ABS_CAP));
const cleanName = String(name || 'Anonym').trim().slice(0, 20) || 'Anonym';
if (isBanned(cleanName)) return res.status(403).json({ error: 'Name gesperrt.' });
res.json(addScore(cleanName, score));
});
// ---- Mode-Gates (Live: getMode() liest pro Request die Datei) ----
function sendHtml(res, file) {
noStore(res);
res.sendFile(join(PUBLIC_DIR, file));
}
// Startseite: public -> Spiel, private -> "nicht erlaubt"
app.get('/', (req, res) => {
sendHtml(res, getMode().mode === 'private' ? 'blocked.html' : 'index.html');
});
// Geheimer Spiel-Pfad: nur im private-Modus aktiv, nur beim passenden Slug
app.get('/:slug', (req, res, next) => {
const m = getMode();
if (m.mode === 'private' && req.params.slug === m.path) return sendHtml(res, 'index.html');
next();
});
app.listen(PORT, () => {
const m = getMode();
console.log(`[server] fuesse.sexy (Wanted!) laeuft auf http://localhost:${PORT}`);
console.log(`[server] Modus: ${m.mode}${m.mode === 'private' ? ` (Spiel unter /${m.path})` : ''}`);
});