From 8dbff16cec30451e050b641f5da86c4f9d23a0b0 Mon Sep 17 00:00:00 2001 From: Guido Date: Wed, 5 Aug 2026 12:09:47 +0200 Subject: [PATCH] Objekt-Anzahl stufenweise, Ban-CLI, Live-Abbruch bei privat - Objekt-Anzahl waechst ab STEP_START zusaetzlich schrittweise (Deckel 100), Mindestgroesse leicht gesenkt fuer dichte Felder - Ban per CLI: ban/unban/bans (namensbasiert), gebannte Namen koennen keinen Score eintragen (/api/score -> 403) und werden beim Bannen aus der Rangliste entfernt - Privat-Modus bricht laufende Spiele ab: Client pollt /api/status und laedt neu, sobald Spielen auf dem Pfad nicht mehr erlaubt ist (geheimer Pfad wird nicht verraten) Co-Authored-By: Claude Opus 4.8 --- public/js/app.js | 51 ++++++++++++++++++++++++++++++++++++++++++++-- server/admin.js | 34 +++++++++++++++++++++++++++++++ server/banStore.js | 51 ++++++++++++++++++++++++++++++++++++++++++++++ server/index.js | 10 +++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 server/banStore.js diff --git a/public/js/app.js b/public/js/app.js index ca8445a..977a825 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -17,6 +17,7 @@ let PROPS = []; // [{id,name,url}] let state = null; let rafId = null; let lastT = 0; +let modePollId = null; // ---- Utils ---- const rand = (a, b) => a + Math.random() * (b - a); @@ -31,9 +32,24 @@ function shuffle(a) { return a; } +// Objekt-Anzahl: erst linear, ab STEP_START zusaetzlich SCHRITTWEISE mehr (mit Deckel). +// Alle Werte hier leicht justierbar. +const COUNT_BASE = 10; // Startanzahl +const COUNT_PER_SCORE = 1.5; // frühes lineares Wachstum +const STEP_START = 15; // ab diesem Score kommen Stufen dazu +const STEP_EVERY = 3; // alle N Punkte eine Stufe +const STEP_ADD = 5; // +M Objekte pro Stufe +const COUNT_CAP = 100; // harte Obergrenze (Performance/Spielbarkeit) + +function objectCount(score) { + let count = COUNT_BASE + Math.floor(score * COUNT_PER_SCORE); + if (score >= STEP_START) count += Math.floor((score - STEP_START) / STEP_EVERY) * STEP_ADD; + return Math.min(count, COUNT_CAP); +} + // Schwierigkeit aus dem Score ableiten function difficulty(score) { - const count = Math.min(10 + Math.floor(score * 1.5), 46); + const count = objectCount(score); if (score < 3) return { count, stage: 'grid', groups: 0, speed: 0 }; if (score < 6) return { count, stage: 'scatter', groups: 0, speed: 0 }; if (score < 11) return { count, stage: 'groups', groups: score < 9 ? 2 : 3, speed: Math.min(45 + (score - 6) * 10, 150) }; @@ -94,6 +110,7 @@ async function startGame() { lastT = performance.now(); cancelAnimationFrame(rafId); rafId = requestAnimationFrame(loop); + startModeWatch(); // laufendes Spiel abbrechen, falls live auf "privat" geschaltet wird // Anticheat-Session holen (der Server zaehlt die Treffer selbst) state.sessionId = await startSession(); @@ -117,6 +134,34 @@ function beaconHit() { }).catch(() => {}); } +// Live-Pruefung des Betriebsmodus: darf auf DIESEM Pfad gerade gespielt werden? +async function checkPlayAllowed() { + try { + const slug = location.pathname.replace(/^\/+/, ''); + const res = await fetch('/api/status?path=' + encodeURIComponent(slug)); + if (!res.ok) return true; // im Zweifel weiterspielen lassen + return (await res.json()).allowed !== false; + } catch { + return true; + } +} +function startModeWatch() { + stopModeWatch(); + modePollId = setInterval(async () => { + if (!state || !state.running) return; + if (!(await checkPlayAllowed())) { + stopModeWatch(); + location.reload(); // laufendes Spiel abbrechen -> Server-Gate entscheidet neu + } + }, 4000); +} +function stopModeWatch() { + if (modePollId) { + clearInterval(modePollId); + modePollId = null; + } +} + function fieldSize() { const r = $('field').getBoundingClientRect(); return { w: r.width, h: r.height }; @@ -138,7 +183,7 @@ function buildRound() { $('wanted-img').src = target.url; // Item-Groessenwahl: schrumpft mit steigender Anzahl, bleibt tippfreundlich - const size = clamp(Math.sqrt((w * h) / (diff.count * 3.4)), 40, 66); + const size = clamp(Math.sqrt((w * h) / (diff.count * 3.4)), 34, 66); // Typen fuer die Items zusammenstellen: 1 Ziel + Ablenker const otherFeet = FEET.filter((f) => f.id !== target.id); @@ -393,6 +438,7 @@ async function gameOver() { if (!state) return; state.running = false; cancelAnimationFrame(rafId); + stopModeWatch(); $('final-score').textContent = state.score; $('gameover').classList.remove('hidden'); @@ -425,6 +471,7 @@ async function submitScore(name) { // ---- Setup / Events ---- function goHome() { + stopModeWatch(); $('gameover').classList.add('hidden'); $('game').classList.add('hidden'); $('home').classList.remove('hidden'); diff --git a/server/admin.js b/server/admin.js index c989da8..cf80ed7 100644 --- a/server/admin.js +++ b/server/admin.js @@ -10,6 +10,7 @@ import { readLeaderboard, buildBoards, deletePlayer, clearAll, clearToday } from './leaderboardStore.js'; import { getMode, setMode } from './modeStore.js'; +import { banPlayer, unbanPlayer, readBans } from './banStore.js'; const [, , cmd, ...rest] = process.argv; const arg = rest.join(' ').trim(); @@ -51,6 +52,34 @@ switch (cmd) { break; } + case 'ban': { + if (!arg) { + console.error('Bitte einen Namen angeben: node server/admin.js ban "Name"'); + process.exit(1); + } + const added = banPlayer(arg); + const removed = deletePlayer(arg); // bestehende Eintraege gleich entfernen + if (added) console.log(`🚫 "${arg}" gebannt (kann keinen Score mehr eintragen).`); + else console.log(`"${arg}" war bereits gebannt.`); + if (removed) console.log(` ${removed} bestehende(r) Eintrag/Eintraege entfernt.`); + break; + } + + case 'unban': { + if (!arg) { + console.error('Bitte einen Namen angeben: node server/admin.js unban "Name"'); + process.exit(1); + } + console.log(unbanPlayer(arg) ? `✅ "${arg}" entsperrt.` : `"${arg}" war nicht gebannt.`); + break; + } + + case 'bans': { + const bans = readBans(); + console.log(bans.length ? '🚫 Gebannt:\n' + bans.map((b) => ` - ${b}`).join('\n') : 'Keine Bans.'); + break; + } + case 'mode': { const sub = (rest[0] || '').toLowerCase(); if (sub === 'public') { @@ -78,6 +107,11 @@ Rangliste: node server/admin.js clear-today nur die heutige Rangliste leeren node server/admin.js clear-all komplette Rangliste leeren +Bannen (namensbasiert): + node server/admin.js ban "" Spieler sperren (+ Eintraege entfernen) + node server/admin.js unban "" Sperre aufheben + node server/admin.js bans gebannte Namen anzeigen + Betriebsmodus (Spiel oeffentlich / privat): node server/admin.js mode aktuellen Modus anzeigen node server/admin.js mode public Spiel oeffentlich unter "/" diff --git a/server/banStore.js b/server/banStore.js new file mode 100644 index 0000000..a07cfb4 --- /dev/null +++ b/server/banStore.js @@ -0,0 +1,51 @@ +// Namensbasierte Sperrliste, persistent in data/bans.json. +// Ein gebannter Name kann keinen Score mehr eintragen (siehe /api/score) und seine +// bestehenden Eintraege werden beim Bannen entfernt. Vergleich case-insensitiv. + +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 BANS_FILE = join(DATA_DIR, 'bans.json'); + +const norm = (name) => String(name || '').trim().toLowerCase(); + +export function readBans() { + try { + if (!existsSync(BANS_FILE)) return []; + const data = JSON.parse(readFileSync(BANS_FILE, 'utf8')); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +function writeBans(list) { + if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); + writeFileSync(BANS_FILE, JSON.stringify(list, null, 2)); +} + +export function isBanned(name) { + return readBans().includes(norm(name)); +} + +export function banPlayer(name) { + const n = norm(name); + if (!n) return false; + const bans = readBans(); + if (bans.includes(n)) return false; // schon gebannt + bans.push(n); + writeBans(bans); + return true; +} + +export function unbanPlayer(name) { + const n = norm(name); + const bans = readBans(); + const kept = bans.filter((b) => b !== n); + if (kept.length === bans.length) return false; // war nicht gebannt + writeBans(kept); + return true; +} diff --git a/server/index.js b/server/index.js index e88fe26..232e6b0 100644 --- a/server/index.js +++ b/server/index.js @@ -10,6 +10,7 @@ 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, '..'); @@ -117,6 +118,14 @@ app.post('/api/game/hit', (req, res) => { res.json({ counted: true }); }); +// 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())); @@ -134,6 +143,7 @@ app.post('/api/score', (req, res) => { 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)); });