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 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-05 12:09:47 +02:00
parent 53cdfc7346
commit 8dbff16cec
4 changed files with 144 additions and 2 deletions

View File

@@ -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');

View File

@@ -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 "<name>" Spieler sperren (+ Eintraege entfernen)
node server/admin.js unban "<name>" 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 "/"

51
server/banStore.js Normal file
View File

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

View File

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