Treffer-Toleranz, 2 Ranglisten, Anti-Cheat, CLI-Modi & Icons

- Treffer-Toleranz: Ziel wird geometrisch + bevorzugt getroffen, auch wenn verdeckt
- Zwei Ranglisten: Hall of Fame (Allzeit-Top-5) + Heute (Top-10)
- Anti-Cheat: server-autoritative Sessions (start/hit), Score = Server-Zahl,
  Mindestabstand gegen Auto-Klicker, Deckelung nach Spielzeit; Score ohne Session -> 400
- CLI (server/admin.js): list / delete <name> / clear-today / clear-all
- Zwei Betriebsmodi per CLI (live, ohne Neustart): public (/ = Spiel) und
  private (/ gesperrt, Spiel unter geheimem /pfad) + "not allowed to play"-Seite
- Board-Icons von Emojis auf echte SVG-Icons, Board heisst "Hall of Fame"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-05 11:23:30 +02:00
parent 39ed0e2485
commit ecdbd2ee0d
21 changed files with 472 additions and 117 deletions

87
server/admin.js Normal file
View File

@@ -0,0 +1,87 @@
// Rangliste per Kommandozeile verwalten.
// Aufruf: node server/admin.js <befehl> [argument]
// (oder ueber npm: npm run lb -- <befehl> [argument])
//
// Befehle:
// list zeigt beide Ranglisten (Allzeit + Heute)
// delete "<name>" loescht ALLE Eintraege eines Spielers (Name exakt, Gross/Klein egal)
// clear-today loescht nur die heutigen Eintraege
// clear-all loescht die komplette Rangliste
import { readLeaderboard, buildBoards, deletePlayer, clearAll, clearToday } from './leaderboardStore.js';
import { getMode, setMode } from './modeStore.js';
const [, , cmd, ...rest] = process.argv;
const arg = rest.join(' ').trim();
function printBoards() {
const { allTime, today } = buildBoards(readLeaderboard());
const fmt = (list) =>
list.length
? list.map((e, i) => ` ${String(i + 1).padStart(2)}. ${e.name}${e.score}`).join('\n')
: ' (leer)';
console.log('\n🏆 Allzeit (Top 5)\n' + fmt(allTime));
console.log('\n📅 Heute (Top 10)\n' + fmt(today) + '\n');
}
switch (cmd) {
case 'list':
printBoards();
break;
case 'delete': {
if (!arg) {
console.error('Bitte einen Namen angeben: node server/admin.js delete "Name"');
process.exit(1);
}
const n = deletePlayer(arg);
console.log(n > 0 ? `${n} Eintrag/Eintraege von "${arg}" geloescht.` : `Keine Eintraege für "${arg}" gefunden.`);
break;
}
case 'clear-today': {
const n = clearToday();
console.log(`${n} heutige Eintrag/Eintraege geloescht.`);
break;
}
case 'clear-all': {
const n = clearAll();
console.log(`✅ Rangliste geleert (${n} Eintraege entfernt).`);
break;
}
case 'mode': {
const sub = (rest[0] || '').toLowerCase();
if (sub === 'public') {
setMode('public');
console.log('✅ Modus: PUBLIC — das Spiel ist unter "/" erreichbar.');
} else if (sub === 'private') {
const slug = rest[1] || getMode().path;
const m = setMode('private', slug);
console.log(`✅ Modus: PRIVATE — "/" zeigt "nicht erlaubt".`);
console.log(` Spiel jetzt nur unter: /${m.path}`);
} else {
const m = getMode();
console.log(`Aktueller Modus: ${m.mode.toUpperCase()}` + (m.mode === 'private' ? ` (Spiel unter /${m.path})` : ''));
console.log('Umschalten: node server/admin.js mode public | node server/admin.js mode private [pfad]');
}
break;
}
default:
console.log(`füsse.sexy — Verwaltung:
Rangliste:
node server/admin.js list beide Ranglisten anzeigen
node server/admin.js delete "<name>" einen bestimmten Spieler loeschen
node server/admin.js clear-today nur die heutige Rangliste leeren
node server/admin.js clear-all komplette Rangliste leeren
Betriebsmodus (Spiel oeffentlich / privat):
node server/admin.js mode aktuellen Modus anzeigen
node server/admin.js mode public Spiel oeffentlich unter "/"
node server/admin.js mode private [pfad] "/" gesperrt, Spiel unter "/<pfad>" (Default: play)
`);
if (cmd) process.exit(1);
}

View File

@@ -5,14 +5,16 @@
import express from 'express';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { existsSync, readdirSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { readLeaderboard, buildBoards, addScore } from './leaderboardStore.js';
import { getMode } from './modeStore.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 DATA_DIR = join(ROOT, 'data');
const LEADERBOARD_FILE = join(DATA_DIR, 'leaderboard.json');
const PORT = process.env.PORT || 3000;
const IMAGE_EXT = ['.svg', '.png', '.webp', '.jpg', '.jpeg', '.gif'];
@@ -44,30 +46,15 @@ if (feet.length < 2) {
console.log(`[server] ${feet.length} Fuesse, ${props.length} Deko-Bildchen geladen.`);
}
// ---- Rangliste (persistente JSON-Datei) ----
function readLeaderboard() {
try {
if (!existsSync(LEADERBOARD_FILE)) return [];
const data = JSON.parse(readFileSync(LEADERBOARD_FILE, 'utf8'));
return Array.isArray(data) ? data : [];
} catch (err) {
console.warn('[server] Rangliste unlesbar:', err.message);
return [];
}
}
function writeLeaderboard(list) {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(LEADERBOARD_FILE, JSON.stringify(list, null, 2));
}
function topN(list, n) {
return [...list].sort((a, b) => b.score - a.score).slice(0, n);
}
const app = express();
app.use(express.json());
// Statisches Frontend + Bildchen (no-cache -> nie veraltetes JS nach Update)
app.use(express.static(PUBLIC_DIR, { setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache') }));
// 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)
app.use(express.static(PUBLIC_DIR, { index: false, setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache') }));
app.use('/library', express.static(LIBRARY_DIR));
// Verfuegbare Bildchen
@@ -75,28 +62,87 @@ app.get('/api/feet', (req, res) => {
res.json({ feet, props });
});
// Rangliste: Top 10
app.get('/api/leaderboard', (req, res) => {
res.json(topN(readLeaderboard(), 10));
// ---- 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)
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 ABS_CAP = 100000;
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();
games.set(sessionId, { startedAt: Date.now(), lastHitAt: 0, hits: 0 });
res.json({ sessionId });
});
// Score eintragen -> aktualisierte Top 10 zurueck
app.post('/api/score', (req, res) => {
let { name, score } = req.body || {};
score = Number(score);
if (!Number.isFinite(score) || score < 0) {
return res.status(400).json({ error: 'Ungueltiger Score.' });
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 now = Date.now();
if (now - g.startedAt > MAX_SESSION_MS) {
games.delete(req.body.sessionId);
return res.status(410).json({ error: 'Session abgelaufen.' });
}
name = String(name || 'Anonym').trim().slice(0, 20) || 'Anonym';
score = Math.min(Math.floor(score), 100000); // grobe Plausibilitaet
// 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 });
});
const list = readLeaderboard();
list.push({ name, score, at: new Date().toISOString() });
const trimmed = topN(list, 100); // Datei klein halten
writeLeaderboard(trimmed);
res.json(topN(trimmed, 10));
// 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';
res.json(addScore(cleanName, score));
});
// ---- Mode-Gates (Live: getMode() liest pro Request die Datei) ----
function sendHtml(res, file) {
res.set('Cache-Control', 'no-cache');
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})` : ''}`);
});

View File

@@ -0,0 +1,90 @@
// Gemeinsame Ranglisten-Logik fuer Server (index.js) und CLI (admin.js).
// Zwei Ansichten aus derselben Datei: Allzeit-Top-5 + Heute-Top-10.
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = join(__dirname, '..', 'data');
export const LEADERBOARD_FILE = join(DATA_DIR, 'leaderboard.json');
const ALLTIME_COUNT = 5;
const TODAY_COUNT = 10;
const KEEP_ALLTIME = 100; // Datei klein halten
export function readLeaderboard() {
try {
if (!existsSync(LEADERBOARD_FILE)) return [];
const data = JSON.parse(readFileSync(LEADERBOARD_FILE, 'utf8'));
return Array.isArray(data) ? data : [];
} catch (err) {
console.warn('[leaderboard] Datei unlesbar:', err.message);
return [];
}
}
export function writeLeaderboard(list) {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(LEADERBOARD_FILE, JSON.stringify(list, null, 2));
}
export function topN(list, n) {
return [...list].sort((a, b) => b.score - a.score).slice(0, n);
}
// Server-lokaler Tages-Schluessel (YYYY-M-D)
function dayKey(d) {
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
}
export function isToday(entry) {
return entry && entry.at && dayKey(new Date(entry.at)) === dayKey(new Date());
}
// Zwei Ranglisten aus derselben Liste
export function buildBoards(list) {
return {
allTime: topN(list, ALLTIME_COUNT),
today: topN(list.filter(isToday), TODAY_COUNT),
};
}
// Score eintragen -> aktualisierte Boards. Behaelt Allzeit-Top-100 + alle heutigen Eintraege.
export function addScore(name, score) {
const list = readLeaderboard();
list.push({ name, score, at: new Date().toISOString() });
const keep = topN(list, KEEP_ALLTIME);
const keepSet = new Set(keep);
for (const e of list) if (isToday(e) && !keepSet.has(e)) keep.push(e);
writeLeaderboard(keep);
return buildBoards(keep);
}
// --- Verwaltung (CLI) ---
// Loescht alle Eintraege eines Spielers (Name case-insensitiv exakt). Gibt Anzahl zurueck.
export function deletePlayer(name) {
const target = String(name || '').trim().toLowerCase();
if (!target) return 0;
const list = readLeaderboard();
const kept = list.filter((e) => String(e.name || '').trim().toLowerCase() !== target);
writeLeaderboard(kept);
return list.length - kept.length;
}
// Leert die gesamte Rangliste. Gibt Anzahl geloeschter Eintraege zurueck.
export function clearAll() {
const n = readLeaderboard().length;
writeLeaderboard([]);
return n;
}
// Entfernt nur die heutigen Eintraege. Gibt Anzahl zurueck.
export function clearToday() {
const list = readLeaderboard();
const kept = list.filter((e) => !isToday(e));
writeLeaderboard(kept);
return list.length - kept.length;
}

43
server/modeStore.js Normal file
View File

@@ -0,0 +1,43 @@
// Betriebsmodus des Spiels, per CLI umschaltbar, persistent in data/mode.json.
// public -> "/" ist das Spiel (Default)
// private -> "/" zeigt "nicht erlaubt", Spiel nur unter "/<path>" erreichbar (zum Testen)
// Wird pro Request frisch gelesen -> CLI-Umschaltung wirkt SOFORT, ohne Serverneustart.
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = join(__dirname, '..', 'data');
const MODE_FILE = join(DATA_DIR, 'mode.json');
const DEFAULT = { mode: 'public', path: 'play' };
function sanitizeSlug(slug) {
const s = String(slug || '').trim().replace(/^\/+/, '').replace(/[^a-zA-Z0-9_-]/g, '');
return s || 'play';
}
export function getMode() {
try {
if (!existsSync(MODE_FILE)) return { ...DEFAULT };
const data = JSON.parse(readFileSync(MODE_FILE, 'utf8'));
return {
mode: data.mode === 'private' ? 'private' : 'public',
path: sanitizeSlug(data.path),
};
} catch {
return { ...DEFAULT };
}
}
export function setMode(mode, path) {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
const current = getMode();
const next = {
mode: mode === 'private' ? 'private' : 'public',
path: sanitizeSlug(path || current.path),
};
writeFileSync(MODE_FILE, JSON.stringify(next, null, 2));
return next;
}