// Stellwerte: alle Zahlen, die vorher fest im Code standen — XP-Beträge, // Wartezeiten, Prüf-Intervalle, Obergrenzen. // // `module` verbindet einen Wert mit seiner Funktion — die Modul-Seite zeigt ihn // dann dort, wo er hingehört, statt nur in der flachen Werte-Liste. Ein Wert // darf zu mehreren gehören (die Thread-Archivdauer gilt für Tickets UND // Modmail); dann steht dort eine Liste. // // Wie bei den Modulen und Vorlagen lebt der Standard im Code und die // Datenbank enthält nur Abweichungen. `min`/`max` sind keine Kosmetik: sie // verhindern, dass ein Vertipper den Bot in eine Prüfschleife im // Sekundentakt schickt oder das Level-System unbrauchbar macht. import { getSetting, setSetting } from './db.js'; export const TUNING_GROUPS = [ { id: 'level', label: 'Level & Aktivität' }, { id: 'zeiten', label: 'Wann der Bot postet' }, { id: 'pruefung', label: 'Prüf-Intervalle' }, { id: 'grenzen', label: 'Obergrenzen' }, ]; export const TUNING = [ /* ── Level & Aktivität ────────────────────────────── */ { id: 'xp_min', module: 'levels', group: 'level', label: 'XP pro Nachricht — mindestens', unit: 'XP', default: 15, min: 1, max: 500, }, { id: 'xp_max', module: 'levels', group: 'level', label: 'XP pro Nachricht — höchstens', hint: 'Der Bot würfelt zwischen beiden Werten.', unit: 'XP', default: 25, min: 1, max: 500, }, { id: 'xp_cooldown', module: 'levels', group: 'level', label: 'Wartezeit zwischen zwei XP-Gutschriften', hint: 'Verhindert, dass Vielschreiber das Level-System leerlaufen lassen.', unit: 'Sekunden', default: 60, min: 5, max: 3600, }, { id: 'trigger_cooldown', module: 'triggers', group: 'level', label: 'Wartezeit für Auto-Antworten', hint: 'Pro Schlüsselwort und Kanal — sonst antwortet der Bot sich in Grund und Boden.', unit: 'Sekunden', default: 30, min: 5, max: 3600, }, /* ── Wann der Bot postet ──────────────────────────── */ { id: 'birthday_hour', module: 'birthdays', group: 'zeiten', label: 'Geburtstags-Gratulation ab', hint: 'Uhrzeit in Europe/Berlin. Gratuliert wird einmal am Tag.', unit: 'Uhr', default: 9, min: 0, max: 23, }, { id: 'recap_weekday', module: 'weekly_recap', group: 'zeiten', label: 'Wochen-Rückblick am', hint: '0 = Sonntag, 1 = Montag … 6 = Samstag.', unit: 'Wochentag', default: 0, min: 0, max: 6, }, { id: 'recap_hour', module: 'weekly_recap', group: 'zeiten', label: 'Wochen-Rückblick ab', unit: 'Uhr', default: 20, min: 0, max: 23, }, /* ── Prüf-Intervalle ──────────────────────────────── */ { id: 'monitor_interval', module: 'server_monitor', group: 'pruefung', label: 'Game-Server prüfen alle', unit: 'Minuten', default: 2, min: 1, max: 120, }, { id: 'monitor_timeout', module: 'server_monitor', group: 'pruefung', label: 'Game-Server — Antwort abwarten', unit: 'Sekunden', default: 8, min: 2, max: 60, }, { id: 'monitor_fails', module: 'server_monitor', group: 'pruefung', label: 'Game-Server — Alarm nach', hint: 'Fehlversuchen in Folge. 1 meldet jeden Aussetzer, höher meldet nur echte Ausfälle.', unit: 'Fehlversuchen', default: 2, min: 1, max: 10, }, { id: 'ls_interval', module: 'ls_farm', group: 'pruefung', label: 'LS-Karte auffrischen alle', hint: 'Die Karte kostet pro Durchlauf ein paar hundert Kilobyte — öfter als der Server-Monitor lohnt nicht.', unit: 'Minuten', default: 10, min: 2, max: 240, }, { id: 'ls_map_size', module: 'ls_farm', group: 'pruefung', label: 'LS-Karte Kantenlänge', hint: 'Pixel. 1024 sind rund 250 KB, 2048 rund 1,5 MB.', unit: 'Pixel', default: 1024, min: 512, max: 2048, }, { id: 'watchdog_interval', module: 'watchdog', group: 'pruefung', label: 'Adressen prüfen alle', unit: 'Minuten', default: 2, min: 1, max: 120, }, { id: 'watchdog_timeout', module: 'watchdog', group: 'pruefung', label: 'Adressen — Antwort abwarten', unit: 'Sekunden', default: 10, min: 2, max: 60, }, { id: 'watchdog_fails', module: 'watchdog', group: 'pruefung', label: 'Adressen — Alarm nach', unit: 'Fehlversuchen', default: 2, min: 1, max: 10, }, { id: 'social_interval', module: 'social', group: 'pruefung', label: 'Twitch & YouTube prüfen alle', hint: 'Zu kurz bringt nichts — YouTube-Feeds aktualisieren sich ohnehin nur alle paar Minuten.', unit: 'Minuten', default: 5, min: 1, max: 180, }, { id: 'birthday_interval', module: 'birthdays', group: 'pruefung', label: 'Geburtstage prüfen alle', unit: 'Minuten', default: 15, min: 1, max: 120, }, /* ── Obergrenzen ──────────────────────────────────── */ { id: 'raid_joins', module: 'anti_raid', group: 'grenzen', label: 'Raid-Verdacht ab', hint: 'So viele Beitritte im Zeitfenster lösen die Sperre aus. Zu niedrig, und ein Stream-Shoutout reicht schon.', unit: 'Beitritte', default: 8, min: 3, max: 100, }, { id: 'raid_window', module: 'anti_raid', group: 'grenzen', label: 'Raid — Zeitfenster', unit: 'Sekunden', default: 20, min: 5, max: 600, }, { id: 'linked_sync', module: 'linked_roles', group: 'pruefung', label: 'Verknüpfte Rollen abgleichen alle', hint: 'Discord fragt die Werte nicht selbst nach — ohne Abgleich bliebe eine Rolle auf dem Stand des Verknüpfungs-Tages.', unit: 'Stunden', default: 6, min: 1, max: 168, }, { id: 'raid_lockdown', module: 'anti_raid', group: 'zeiten', label: 'Raid-Sperre dauert', hint: 'Danach hebt der Bot sie von selbst wieder auf.', unit: 'Minuten', default: 15, min: 1, max: 720, }, { id: 'ticket_transcript_max', module: 'tickets', group: 'grenzen', label: 'Nachrichten im Ticket-Protokoll', hint: 'Ältere fallen weg. Hoch gesetzt dauert das Schließen länger.', unit: 'Nachrichten', default: 500, min: 50, max: 2000, }, { id: 'gallery_max_images', module: 'gallery', group: 'grenzen', label: 'Bilder je Galerie-Beitrag', unit: 'Bilder', default: 4, min: 1, max: 10, }, { id: 'commits_shown', module: 'commit_feed', group: 'grenzen', label: 'Commits je Push-Embed', hint: 'Der Rest wird als „… und N weitere" zusammengefasst.', unit: 'Commits', default: 10, min: 1, max: 20, }, { id: 'thread_archive_days', module: ['tickets', 'modmail'], group: 'grenzen', label: 'Threads archivieren nach', hint: 'Für Ticket- und Modmail-Threads. Discord erlaubt 1, 3 oder 7 Tage.', unit: 'Tagen', default: 7, min: 1, max: 7, choices: [1, 3, 7], }, { id: 'modlog_cache_days', module: 'modlog', group: 'grenzen', label: 'Nachrichten für das Protokoll aufbewahren', hint: 'Damit bei einer gelöschten Nachricht Verfasser und Wortlaut dastehen. ' + 'Kürzer ist sparsamer, länger reicht weiter zurück. 0 = gar nicht mitschreiben.', unit: 'Tagen', default: 7, min: 0, max: 30, }, ]; const byId = new Map(TUNING.map((t) => [t.id, t])); /** * Wert lesen — immer eine gültige Zahl im erlaubten Bereich. * @param {string} id * @returns {number} */ export function tuning(id) { const def = byId.get(id); if (!def) return 0; // Achtung: Number('') ist 0, nicht NaN — ohne diese Prüfung würde ein // ungesetzter Wert auf das Minimum fallen statt auf den Standard. const stored = String(getSetting(`tune_${id}`) ?? '').trim(); if (stored === '') return def.default; const raw = Number(stored); if (!Number.isFinite(raw)) return def.default; return Math.min(def.max, Math.max(def.min, Math.round(raw))); } /** Derselbe Wert in Millisekunden — spart die Rechnerei an jeder Aufrufstelle */ export const tuningMs = { seconds: (id) => tuning(id) * 1000, minutes: (id) => tuning(id) * 60 * 1000, hours: (id) => tuning(id) * 60 * 60 * 1000, }; /** * Wert setzen. Leer stellt den Standard wieder her. * Mit `dryRun` wird nur geprüft — damit ein Formular mit mehreren Werten * entweder ganz oder gar nicht gespeichert wird. * @returns {{ ok: true } | { ok: false, error: string }} */ export function setTuning(id, value, { dryRun = false } = {}) { const def = byId.get(id); if (!def) return { ok: false, error: `Unbekannter Wert: ${id}` }; if (value === '' || value === null || value === undefined) { if (!dryRun) setSetting(`tune_${id}`, ''); return { ok: true }; } const n = Number(value); if (!Number.isFinite(n) || !Number.isInteger(n)) { return { ok: false, error: `${def.label}: ganze Zahl erwartet` }; } if (n < def.min || n > def.max) { return { ok: false, error: `${def.label}: ${def.min}–${def.max} ${def.unit}` }; } if (def.choices && !def.choices.includes(n)) { return { ok: false, error: `${def.label}: erlaubt sind ${def.choices.join(', ')}` }; } if (!dryRun) setSetting(`tune_${id}`, String(n)); return { ok: true }; } /** Alle Werte mit Zustand — fürs Webinterface */ export function tuningStates() { return TUNING.map((t) => { const custom = getSetting(`tune_${t.id}`); return { id: t.id, label: t.label, hint: t.hint ?? null, group: t.group, // Immer als Liste nach außen — der Aufrufer muss dann nicht // zwischen einem Wert und mehreren unterscheiden modules: t.module ? [t.module].flat() : [], unit: t.unit, min: t.min, max: t.max, choices: t.choices ?? null, default: t.default, value: tuning(t.id), customized: Boolean(String(custom ?? '').trim()), }; }); } /** * Wiederkehrende Aufgabe, deren Abstand aus einem Stellwert kommt. Der Wert * wird vor jeder Runde neu gelesen — ein geändertes Intervall greift damit ab * dem nächsten Durchlauf, ohne den Bot neu zu starten. (Ein setInterval mit * festem Abstand könnte das nicht.) * * @param {string} id — Stellwert * @param {'hours'|'minutes'|'seconds'} unit * @param {() => unknown} task * @param {string} tag — Kennung fürs Fehler-Log * @returns {() => void} Abbrechen */ export function everyTuned(id, unit, task, tag = id) { let timer; // Unbekannte Einheit wäre schlimmer als ein Tippfehler: der Abstand läge // um Faktor 60 oder 3600 daneben, ohne dass es jemand merkt. const delay = () => { const f = tuningMs[unit]; if (!f) throw new Error(`everyTuned(${id}): unbekannte Einheit "${unit}"`); return f(id); }; const run = async () => { try { await task(); } catch (error) { console.error(`[${tag}]`, error); } timer = setTimeout(run, delay()); }; timer = setTimeout(run, delay()); return () => clearTimeout(timer); } /** * XP-Spanne — sorgt dafür, dass Minimum und Maximum nicht vertauscht sind, * falls jemand das Maximum unter das Minimum setzt. */ export function xpRange() { const a = tuning('xp_min'); const b = tuning('xp_max'); return { min: Math.min(a, b), max: Math.max(a, b) }; }