diff --git a/README.md b/README.md index da73459..bc7d57c 100644 --- a/README.md +++ b/README.md @@ -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 -- `.) + +- **public** (Default): `/` ist das Spiel. +- **private**: `/` zeigt „You are not allowed to play", das Spiel läuft nur unter dem + gewählten Pfad (`/`) — praktisch zum Testen im Live-Betrieb. ## Deployment (Ausblick) diff --git a/library/props/ball.svg b/library/props/ball.svg deleted file mode 100644 index 42ba2f0..0000000 --- a/library/props/ball.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/library/props/handtuch.svg b/library/props/handtuch.svg deleted file mode 100644 index 3285f28..0000000 --- a/library/props/handtuch.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/library/props/mattes1.png b/library/props/mattes1.png new file mode 100644 index 0000000..1b257e8 Binary files /dev/null and b/library/props/mattes1.png differ diff --git a/library/props/mattes2.png b/library/props/mattes2.png new file mode 100644 index 0000000..3ae1aea Binary files /dev/null and b/library/props/mattes2.png differ diff --git a/library/props/moewe.svg b/library/props/moewe.svg deleted file mode 100644 index dec50b3..0000000 --- a/library/props/moewe.svg +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/library/props/muschel.svg b/library/props/muschel.svg deleted file mode 100644 index 1cbe109..0000000 --- a/library/props/muschel.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - diff --git a/library/props/noemi1.png b/library/props/noemi1.png new file mode 100644 index 0000000..10dcf94 Binary files /dev/null and b/library/props/noemi1.png differ diff --git a/library/props/noemi2.jpeg b/library/props/noemi2.jpeg new file mode 100644 index 0000000..b6065e6 Binary files /dev/null and b/library/props/noemi2.jpeg differ diff --git a/library/props/noemi3.png b/library/props/noemi3.png new file mode 100644 index 0000000..3ce85cb Binary files /dev/null and b/library/props/noemi3.png differ diff --git a/library/props/palme.svg b/library/props/palme.svg deleted file mode 100644 index f79f6ea..0000000 --- a/library/props/palme.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/library/props/sonnenbrille.svg b/library/props/sonnenbrille.svg deleted file mode 100644 index 9138848..0000000 --- a/library/props/sonnenbrille.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/package.json b/package.json index 63cecc8..4692aba 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/blocked.html b/public/blocked.html new file mode 100644 index 0000000..8e0fa6e --- /dev/null +++ b/public/blocked.html @@ -0,0 +1,18 @@ + + +füsse.sexy + + + +
+
+
+

füsse.sexy

+
+
🚧
+

You are not allowed to play

+

Das Spiel ist gerade nicht verfügbar. Schau später nochmal vorbei.

+
+
+
+
diff --git a/public/css/style.css b/public/css/style.css index b40de83..a48f4e2 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; } diff --git a/public/index.html b/public/index.html index 92b0a8e..3623da5 100644 --- a/public/index.html +++ b/public/index.html @@ -12,9 +12,15 @@

WANTED! — schnapp dir den gesuchten Fuß, bevor die Zeit abläuft.

-
-

🏆 Rangliste

-
    +
    +
    +

    Hall of Fame

    +
      +
      +
      +

      Heute

      +
        +
        @@ -54,9 +60,15 @@
        -
        -

        🏆 Rangliste

        -
          +
          +
          +

          Hall of Fame

          +
            +
            +
            +

            Heute

            +
              +
              diff --git a/public/js/app.js b/public/js/app.js index 154dfda..066db17 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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,30 +315,59 @@ 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) { - state.score += 1; - $('score').textContent = state.score; - $('score').parentElement.classList.remove('bump'); - void $('score').parentElement.offsetWidth; - $('score').parentElement.classList.add('bump'); - state.timeLeft = Math.min(state.timeLeft + BONUS, MAX_TIME); - flashField('ok'); - buildRound(); // neues Plakat, andere Farbe - } else { - state.timeLeft = Math.max(state.timeLeft - PENALTY, 0); + 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'); + void $('score').parentElement.offsetWidth; + $('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 +} + +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(); } + flashField('miss'); + showFeedback('Daneben! −2s'); + if (state.timeLeft <= 0) gameOver(); } let feedbackT = null; @@ -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(); diff --git a/server/admin.js b/server/admin.js new file mode 100644 index 0000000..c989da8 --- /dev/null +++ b/server/admin.js @@ -0,0 +1,87 @@ +// Rangliste per Kommandozeile verwalten. +// Aufruf: node server/admin.js [argument] +// (oder ueber npm: npm run lb -- [argument]) +// +// Befehle: +// list zeigt beide Ranglisten (Allzeit + Heute) +// delete "" 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 "" 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 "/" (Default: play) +`); + if (cmd) process.exit(1); +} diff --git a/server/index.js b/server/index.js index 22a7636..91c5145 100644 --- a/server/index.js +++ b/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})` : ''}`); }); diff --git a/server/leaderboardStore.js b/server/leaderboardStore.js new file mode 100644 index 0000000..062261e --- /dev/null +++ b/server/leaderboardStore.js @@ -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; +} diff --git a/server/modeStore.js b/server/modeStore.js new file mode 100644 index 0000000..89b9230 --- /dev/null +++ b/server/modeStore.js @@ -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 "/" 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; +}