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

View File

@@ -51,9 +51,36 @@ In `public/js/app.js` oben:
## Rangliste
- Gespeichert in `data/leaderboard.json` (wird automatisch angelegt, überlebt Neustarts).
- Endpoints: `GET /api/leaderboard` (Top 10), `POST /api/score` `{name, score}`.
- Namen werden beim Anzeigen escaped (kein XSS). Kein Anti-Cheat — Spaßspiel.
- Zwei Boards: **Hall of Fame** (Allzeit-Top-5) + **Heute** (Top-10). Gespeichert in
`data/leaderboard.json` (wird automatisch angelegt, überlebt Neustarts).
- **Anti-Cheat:** Der Score ist server-autoritär — der Server vergibt zu Spielbeginn eine
Session (`/api/game/start`), zählt jeden gemeldeten Treffer (`/api/game/hit`) mit
Mindestabstand (gegen Auto-Klicker) und deckelt nach echter Spielzeit. Beim Eintragen
(`/api/score`) zählt **nur** diese Server-Zahl; ein Score ohne gültige Session wird
abgelehnt. Namen werden beim Anzeigen escaped (kein XSS).
## Verwaltung per CLI
Auf dem Server (im Projektordner):
```sh
# Rangliste
node server/admin.js list # beide Boards anzeigen
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-all # komplette Rangliste leeren
# Betriebsmodus (wirkt SOFORT, ohne Neustart)
node server/admin.js mode # aktuellen Modus anzeigen
node server/admin.js mode public # Spiel öffentlich unter "/"
node server/admin.js mode private geheim # "/" gesperrt, Spiel nur unter "/geheim"
```
(Kurzform via npm: `npm run lb -- <befehl>`.)
- **public** (Default): `/` ist das Spiel.
- **private**: `/` zeigt „You are not allowed to play", das Spiel läuft nur unter dem
gewählten Pfad (`/<pfad>`) — praktisch zum Testen im Live-Betrieb.
## Deployment (Ausblick)

View File

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="#fff"/>
<path d="M50 10 A40 40 0 0 1 90 50 L50 50 Z" fill="#ff5a5f"/>
<path d="M50 90 A40 40 0 0 1 10 50 L50 50 Z" fill="#3ea6ff"/>
<path d="M50 10 A40 40 0 0 0 10 50 L50 50 Z" fill="#ffd23f"/>
<path d="M50 90 A40 40 0 0 0 90 50 L50 50 Z" fill="#2ec26b"/>
<circle cx="50" cy="50" r="40" fill="none" stroke="#26193f" stroke-width="3"/></svg>

Before

Width:  |  Height:  |  Size: 465 B

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect x="18" y="20" width="64" height="60" rx="6" fill="#3ea6ff"/>
<rect x="18" y="20" width="64" height="60" rx="6" fill="none" stroke="#1c6fb0" stroke-width="3"/>
<rect x="18" y="34" width="64" height="7" fill="#fff"/>
<rect x="18" y="59" width="64" height="7" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 362 B

BIN
library/props/mattes1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
library/props/mattes2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M12 55 Q35 30 50 52 Q65 30 88 55" stroke="#4a4a5a" stroke-width="6"
fill="none" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 193 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M50 88 C20 60 20 25 50 18 C80 25 80 60 50 88 Z" fill="#ffb3c7"/>
<path d="M50 20 L50 86 M35 30 L44 82 M65 30 L56 82 M25 45 L38 80 M75 45 L62 80"
stroke="#e26d94" stroke-width="3" fill="none" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 308 B

BIN
library/props/noemi1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
library/props/noemi2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

BIN
library/props/noemi3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M46 90 Q52 55 50 40 L58 40 Q54 60 54 90 Z" fill="#8b5a2b"/>
<g fill="#2ec26b">
<path d="M52 40 Q30 25 12 32 Q32 30 52 42 Z"/>
<path d="M52 40 Q74 25 90 34 Q70 30 52 42 Z"/>
<path d="M52 40 Q35 20 30 6 Q46 22 55 40 Z"/>
<path d="M52 40 Q70 22 78 10 Q60 24 55 40 Z"/>
</g>
<circle cx="52" cy="40" r="5" fill="#c98a3a"/></svg>

Before

Width:  |  Height:  |  Size: 428 B

View File

@@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<path d="M20 45 H80" stroke="#26193f" stroke-width="5"/>
<rect x="16" y="42" width="28" height="20" rx="9" fill="#26193f"/>
<rect x="56" y="42" width="28" height="20" rx="9" fill="#26193f"/>
<rect x="20" y="46" width="9" height="6" rx="3" fill="#7fd0ff"/>
<rect x="60" y="46" width="9" height="6" rx="3" fill="#7fd0ff"/></svg>

Before

Width:  |  Height:  |  Size: 410 B

View File

@@ -6,7 +6,8 @@
"scripts": {
"start": "node server/index.js",
"setup": "sh install.sh",
"reset-stats": "node scripts/reset-leaderboard.mjs"
"reset-stats": "node scripts/reset-leaderboard.mjs",
"lb": "node server/admin.js"
},
"dependencies": {
"express": "^4.19.2"

18
public/blocked.html Normal file
View File

@@ -0,0 +1,18 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>füsse.sexy</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='88'>🦶</text></svg>" />
<link rel="stylesheet" href="/css/style.css" />
<div id="app">
<section id="home">
<div class="home-inner">
<h1 class="brand">füsse<span class="brand-dot">.</span>sexy</h1>
<div class="blocked-card">
<div class="blocked-emoji">🚧</div>
<h2>You are not allowed to play</h2>
<p>Das Spiel ist gerade nicht verfügbar. Schau später nochmal vorbei.</p>
</div>
</div>
</section>
</div>

View File

@@ -38,14 +38,22 @@ button { font-family: inherit; cursor: pointer; }
/* ===== Startseite ===== */
#home { display: flex; align-items: center; justify-content: center; padding: 24px; overflow: auto; }
.home-inner { width: 100%; max-width: 460px; margin: auto; text-align: center; display: flex; flex-direction: column; gap: 16px; align-items: center; }
.home-inner { width: 100%; max-width: 560px; margin: auto; text-align: center; display: flex; flex-direction: column; gap: 16px; align-items: center; }
.blocked-card { width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: 18px; padding: 34px 24px; text-align: center; }
.blocked-emoji { font-size: 52px; }
.blocked-card h2 { margin: 10px 0 8px; color: var(--pink-soft); }
.blocked-card p { margin: 0; color: var(--muted); }
.boards { width: 100%; display: flex; gap: 12px; align-items: flex-start; }
.boards .board { flex: 1; min-width: 0; }
@media (max-width: 440px) { .boards { flex-direction: column; } }
.brand { font-size: 42px; font-weight: 900; letter-spacing: -1px; color: var(--pink); margin: 6px 0 0; }
.brand-dot { color: var(--pink-soft); }
.tagline { color: var(--muted); margin: 0; font-size: 15px; line-height: 1.4; }
.board { width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: 18px; padding: 14px 16px; }
.board.tight { padding: 10px 14px; }
.board h2 { margin: 2px 0 10px; font-size: 18px; }
.board h2 { margin: 2px 0 10px; font-size: 18px; display: flex; align-items: center; justify-content: center; gap: 8px; }
.ico { width: 18px; height: 18px; flex: 0 0 auto; color: var(--pink-soft); }
.leaderboard { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.leaderboard li { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 10px; background: var(--card); font-size: 15px; min-width: 0; }
.leaderboard li.top3 { background: #2f2a1a; }
@@ -114,7 +122,7 @@ button { font-family: inherit; cursor: pointer; }
/* ===== Game Over ===== */
#gameover { position: absolute; inset: 0; display: grid; place-items: center; background: rgba(0,0,0,0.7); z-index: 10; padding: 16px; overflow-y: auto; }
.go-card {
width: 100%; max-width: 420px; background: var(--panel); border: 1px solid var(--border);
width: 100%; max-width: 520px; background: var(--panel); border: 1px solid var(--border);
border-radius: 22px; padding: 24px; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.6);
display: flex; flex-direction: column; gap: 14px; animation: pop 0.3s ease;
}

View File

@@ -12,9 +12,15 @@
<p class="tagline"><b>WANTED!</b> — schnapp dir den gesuchten Fuß, bevor die Zeit abläuft.</p>
<button id="play-btn" class="big-btn">▶ Spielen</button>
<div class="boards">
<div class="board">
<h2>🏆 Rangliste</h2>
<ol id="leaderboard" class="leaderboard"></ol>
<h2><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 21h8"/><path d="M12 17v4"/><path d="M7 4h10v5a5 5 0 0 1-10 0V4Z"/><path d="M7 6H4v1a3 3 0 0 0 3 3"/><path d="M17 6h3v1a3 3 0 0 1-3 3"/></svg> Hall of Fame</h2>
<ol id="lb-alltime" class="leaderboard"></ol>
</div>
<div class="board">
<h2><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/></svg> Heute</h2>
<ol id="lb-today" class="leaderboard"></ol>
</div>
</div>
<button id="change-name" class="link-btn">Name ändern</button>
@@ -54,9 +60,15 @@
</div>
</div>
<div class="board tight">
<h2>🏆 Rangliste</h2>
<ol id="go-leaderboard" class="leaderboard"></ol>
<div class="boards tight">
<div class="board">
<h2><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 21h8"/><path d="M12 17v4"/><path d="M7 4h10v5a5 5 0 0 1-10 0V4Z"/><path d="M7 6H4v1a3 3 0 0 0 3 3"/><path d="M17 6h3v1a3 3 0 0 1-3 3"/></svg> Hall of Fame</h2>
<ol id="go-alltime" class="leaderboard"></ol>
</div>
<div class="board">
<h2><svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/></svg> Heute</h2>
<ol id="go-today" class="leaderboard"></ol>
</div>
</div>
<div class="go-actions">

View File

@@ -40,15 +40,21 @@ function difficulty(score) {
return { count, stage: 'individual', groups: 0, speed: Math.min(70 + (score - 11) * 12, 320) };
}
// ---- Rangliste ----
// ---- Rangliste (zwei Boards: Allzeit-Top-5 + Heute-Top-10) ----
const EMPTY_BOARDS = { allTime: [], today: [] };
async function fetchLeaderboard() {
try {
const res = await fetch('/api/leaderboard');
return res.ok ? await res.json() : [];
return res.ok ? await res.json() : EMPTY_BOARDS;
} catch {
return [];
return EMPTY_BOARDS;
}
}
function renderBoards(boards, alltimeEl, todayEl) {
const b = boards || EMPTY_BOARDS;
renderLeaderboard(alltimeEl, b.allTime || []);
renderLeaderboard(todayEl, b.today || []);
}
function renderLeaderboard(el, list) {
el.innerHTML = '';
if (!list.length) {
@@ -76,18 +82,39 @@ function renderLeaderboard(el, list) {
}
// ================= Spiel =================
function startGame() {
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 };
state = { score: 0, timeLeft: START_TIME, items: [], groups: [], stage: 'grid', running: true, sessionId: null };
$('score').textContent = '0';
buildRound();
lastT = performance.now();
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(loop);
// 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;
} 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(() => {});
}
function fieldSize() {
@@ -288,13 +315,38 @@ function updateTimerBar() {
bar.classList.toggle('low', state.timeLeft <= 6);
}
// ---- Treffer ----
// ---- Treffer (mit Toleranz) ----
// Klicks werden GEOMETRISCH geprueft (Abstand zum Mittelpunkt), nicht ueber das oberste
// DOM-Element. Das Ziel wird BEVORZUGT und mit grosszuegiger Toleranz getroffen — auch
// 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;
const el = e.target.closest('.item');
if (!el || !el.__item) return;
const item = el.__item;
if (item.isTarget) {
const rect = $('field').getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
// 1) Ziel bevorzugt, mit Toleranz (auch wenn verdeckt)
const target = state.items.find((it) => it.isTarget);
if (target) {
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();
}
// 2) sonst: wurde ein Ablenker getroffen? oberstes zuerst (state.items: spaeter = oben)
for (let i = state.items.length - 1; i >= 0; i--) {
const it = state.items[i];
if (it.isTarget) continue;
const cx = it.x + it.size / 2;
const cy = it.y + it.size / 2;
if (Math.hypot(px - cx, py - cy) <= it.size / 2) return missDecoy(it.el);
}
// 3) leerer Klick -> nichts
}
function hitTarget() {
state.score += 1;
$('score').textContent = state.score;
$('score').parentElement.classList.remove('bump');
@@ -302,17 +354,21 @@ function onFieldPointerDown(e) {
$('score').parentElement.classList.add('bump');
state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME);
flashField('ok');
beaconHit(); // Server zaehlt den Treffer (Anticheat)
buildRound(); // neues Plakat, andere Farbe
} else {
}
function missDecoy(el) {
state.timeLeft = Math.max(state.timeLeft - PENALTY, 0);
if (el) {
el.classList.remove('shake');
void el.offsetWidth;
el.classList.add('shake');
}
flashField('miss');
showFeedback('Daneben! 2s');
if (state.timeLeft <= 0) gameOver();
}
}
let feedbackT = null;
function showFeedback(msg) {
@@ -344,22 +400,23 @@ async function gameOver() {
const cachedName = localStorage.getItem(NAME_KEY);
if (cachedName) {
$('name-ask').classList.add('hidden');
const list = await submitScore(cachedName, state.score);
renderLeaderboard($('go-leaderboard'), list);
renderBoards(await submitScore(cachedName), $('go-alltime'), $('go-today'));
} else {
// Namensabfrage (nur beim ersten Mal)
$('name-ask').classList.remove('hidden');
renderLeaderboard($('go-leaderboard'), await fetchLeaderboard());
renderBoards(await fetchLeaderboard(), $('go-alltime'), $('go-today'));
$('name-input').focus();
}
}
async function submitScore(name, score) {
// Score eintragen: der Score kommt SERVERSEITIG aus der Session (Anticheat) — der Client
// sendet nur Name + sessionId, keine selbst behauptete Zahl.
async function submitScore(name) {
try {
const res = await fetch('/api/score', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, score }),
body: JSON.stringify({ name, sessionId: state && state.sessionId }),
});
if (res.ok) return await res.json();
} catch {}
@@ -374,7 +431,7 @@ function goHome() {
refreshHomeBoard();
}
async function refreshHomeBoard() {
renderLeaderboard($('leaderboard'), await fetchLeaderboard());
renderBoards(await fetchLeaderboard(), $('lb-alltime'), $('lb-today'));
}
async function init() {
@@ -402,7 +459,7 @@ async function init() {
const name = ($('name-input').value || '').trim().slice(0, 20) || 'Anonym';
localStorage.setItem(NAME_KEY, name);
$('name-ask').classList.add('hidden');
renderLeaderboard($('go-leaderboard'), await submitScore(name, state.score));
renderBoards(await submitScore(name), $('go-alltime'), $('go-today'));
});
$('name-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') $('save-score').click();

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;
}