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:
126
server/index.js
126
server/index.js
@@ -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})` : ''}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user