Files
d4rkbot/src/tuning.js
T
D4rkst3randClaude Opus 5 e215cd4c7c
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
LS-Modul: Besitzkarte auf dem Luftbild und Preiskurven
Der Feed eines LS-Servers gibt viel mehr her als die Spielerzahl, die der
Monitor bisher davon benutzt hat: in denselben 49 KB stehen 163 Parzellen mit
Besitzer und Hektar, 130 Felder, jedes Fahrzeug samt Ladung und die Position
jedes Spielers. Und das Luftbild liefert der Server auch, in jeder Groesse bis
4096.

Also eine Karte: gekaufte Parzellen nach Hof eingefaerbt, Kreisflaeche gleich
Hektar, Fahrzeuge und Spieler live, dazu eine Hof-Uebersicht mit Flaeche und
Wert. Wechselt Land den Besitzer, kommt eine Meldung — beim allerersten
Durchlauf bewusst nicht, sonst haette der Botstart eine Meldung pro bereits
gekaufter Parzelle abgesetzt.

Dazu die Preiskurven aus der economy.xml: je Fruchtart, was sie in welcher der
zwoelf Perioden bringt, als Balkenzeile mit "jetzt verkaufen" oder "+43 Prozent
im Hochwinter".

Was NICHT geht, und zwar grundsaetzlich: welche Frucht auf welchem Feld steht
und wie weit sie ist. Das liegt in einer binaeren Density-Map, die kein
Endpunkt herausgibt. Ich habe 18 Dateinamen durchprobiert, es gibt genau drei.
Die Mods, die das anzeigen, sind reine HUD-Overlays ohne Ausgang. Und keiner
der fuenf anderen LS-Bots kann es, obwohl einer davon Geld kostet. Das steht
mit allen Belegen in docs/ls-feed.md, damit die Frage nicht dreimal kommt.

Beim Bauen aufgefallen:

- istLsServer() gab den Zugangs-Code zurueck statt true — die &&-Kette liefert
  den letzten wahren Wert. Beim ersten Log-Aufruf haette der Code im Klartext
  im Protokoll gestanden.
- Die Karte als PNG war 2,5 MB, als JPEG 268 KB. Es ist ein Luftbild.
- Nach Preisschwankung sortiert gewinnt Spargel: 26 bis 7540 Euro, und Weizen
  taucht gar nicht mehr auf. Die Liste arbeitet jetzt mit einer Auswahl.
- Die Jahreszeit steht in keinem Endpunkt. Schaetzbar waere sie, aber nur
  solange niemand die Zeitskala aendert — also eine Einstellung.

Geprueft gegen echte Serverdaten: Zerlegen der Statusabfrage inklusive
Vollstaendigkeit aller 163 Parzellen, Ladung ohne Diesel und leere Slots,
Hof-Uebersicht ohne verlorene Parzellen, Preiskurve mit vollem und leerem
Balken an der richtigen Stelle, flache Kurve ohne Division durch Null,
Besitzwechsel inklusive Erstlauf und neu hinzugekommener Parzelle,
Hof-Namen aus der Einstellung, SVG ohne NaN und mit maskierten Namen, und der
Merker in der Datenbank inklusive kaputtem JSON.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:52:25 +02:00

268 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) };
}