From e215cd4c7cf698916d031eed47bd6f54a32d2e5e Mon Sep 17 00:00:00 2001 From: D4rkst3r Date: Sun, 9 Aug 2026 01:52:25 +0200 Subject: [PATCH] LS-Modul: Besitzkarte auf dem Luftbild und Preiskurven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 1 + docs/README.md | 1 + docs/ls-feed.md | 92 ++++++++ src/bot/ls-farm.js | 507 +++++++++++++++++++++++++++++++++++++++++++++ src/db.js | 34 +++ src/index.js | 2 + src/modules.js | 13 ++ src/tuning.js | 10 + 8 files changed, 660 insertions(+) create mode 100644 docs/ls-feed.md create mode 100644 src/bot/ls-farm.js diff --git a/README.md b/README.md index 66642ba..078128f 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | Funktion | Beschreibung | |---|---| | 🎮 **Game-Server-Monitor** | DiscordGSM-Stil: pro Server ein Live-Embed (🟢/🔴, Spieler-Balken, Map, Version, Mod-Anzahl, Spieler-Liste, Knöpfe für Verbinden und Mod-Download, Ping); **alle Spiele, die gamedig kennt** — die Auswahl im Panel kommt aus der Bibliothek selbst, inklusive Standard-Port und Hinweis auf nötige Zugangs-Codes (Farming Simulator, Terraria) — plus FiveM + HTTP-Check; Down/Up-Alerts mit Ausfalldauer | +| 🚜 **LS — Höfe & Preise** | Für Farming-Simulator-Server: Besitzkarte auf dem Luftbild des Servers (Parzellen nach Hof eingefärbt, Fläche = Hektar, Fahrzeuge und Spieler live), Hof-Übersicht mit Hektar und Wert, Meldung bei Landkauf, dazu die Preiskurven übers Jahr mit „jetzt verkaufen oder warten". **Nicht möglich:** welche Frucht auf welchem Feld steht — das gibt der Feed nicht her (siehe [docs/ls-feed.md](docs/ls-feed.md)) | | 🚨 **Wächter** | Prüft eigene Dienste in einstellbarem Takt, DM-Alarm bei Ausfall + Entwarnung; speist die öffentliche Statusseite | | 💓 **Herzschlag** | Der Bot zeichnet sich selbst auf — im Dashboard steht neben der Laufzeit ein Erreichbarkeits-Balken über 7 Tage. Fehlende Stunden sind rot: dass nichts dasteht, *ist* die Aussage | | 🔊 **Temp-Voice** | „Join to Create": Hub-Kanal betreten → eigener Voice-Kanal mit Control-Panel (Umbenennen, Sperren, Limit, Löschen — nur für den Besitzer), löscht sich wenn leer | diff --git a/docs/README.md b/docs/README.md index e804b06..edb3b77 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Alles, was zu lang für die [README](../README.md) ist. |---|---| | [konzept-zwei-seiten.md](konzept-zwei-seiten.md) | Warum Produktseite und Community-Hub getrennt sind, und wie | | [idee-mermaid.md](idee-mermaid.md) | Diagramme im eigenen Markdown rendern — Umfang, Kosten, offene Frage | +| [ls-feed.md](ls-feed.md) | Was ein LS-Server hergibt und was nicht — Endpunkte, Koordinaten, Fallstricke | ## Textentwürfe diff --git a/docs/ls-feed.md b/docs/ls-feed.md new file mode 100644 index 0000000..4ea433d --- /dev/null +++ b/docs/ls-feed.md @@ -0,0 +1,92 @@ +# Was ein LS-Server über sich verrät + +Aufgeschrieben, damit die Frage „können wir nicht auch …" nicht dreimal +gestellt wird. Alles hier ist an einem echten FS25-Server (1.21.0.0) geprüft, +nicht aus der Dokumentation abgeschrieben — die schweigt zu den Einzelheiten. + +## Die Endpunkte + +Alle brauchen `?code=`. Der Code steht in der Abfrage-URL, die +der Hoster ausgibt; der Port ist **nicht** immer 8080. + +| Endpunkt | Grösse | Inhalt | +|---|---|---| +| `dedicated-server-stats.xml` | ~49 KB | Server, Spieler, Mods, Parzellen, Felder, Fahrzeuge | +| `dedicated-server-stats-map.jpg` | 51 KB – 1,5 MB | Luftbild, `size=` 512…4096, `quality=` 1…100 | +| `dedicated-server-savegame.html?file=careerSavegame` | ~20 KB | Einstellungen, Geld, Spielzeit, Modliste | +| `dedicated-server-savegame.html?file=vehicles` | ~39 KB | jedes Fahrzeug mit Preis, `farmId`, Betriebsstunden | +| `dedicated-server-savegame.html?file=economy` | ~305 KB | Preiskurve je Fruchtart über zwölf Perioden | + +Andere Dateinamen antworten mit `204` und leerem Rumpf. Geprüft und **nicht** +verfügbar: `farms`, `farmland`, `farmlands`, `fields`, `field`, `items`, +`placeables`, `environment`, `sales`, `players`, `production`, `productions`, +`missions`, `densityMapHeight`, `treePlants`, `growth`, `weeds`, `fillTypes`. + +## Was in der Statusabfrage steht + +```xml + + + Name + + Titel + + + +``` + +`owner` ist die Hof-**Nummer**, nicht der Spielername. Welcher Spieler zu +welchem Hof gehört, steht in keinem Endpunkt — deshalb die Zuordnung im Panel. + +## Koordinaten + +Weltkoordinaten laufen von `-mapSize/2` bis `+mapSize/2`. Auf das Luftbild +gerechnet: **x nach rechts, z nach unten**, linear über die volle Breite. + +Gegen die Ingame-Karte geprüft: 14 von 15 markanten Feldnummern landen in der +richtigen Zone. Der eine Ausreisser ist ein sehr grosses Feld, dessen +Schwerpunkt knapp über einer Zonengrenze liegt. + +**Die eingebrannten Beschriftungen H1…H8 im Luftbild taugen nicht zum +Kalibrieren.** Die zeichnet der Map-Ersteller ans Hofgebäude, nicht in den +Schwerpunkt der Parzelle, und die Nummern daneben müssen nicht zur Farmland-ID +passen. Wer daran ausrichtet, landet gut 200 px daneben. + +## Was es nicht gibt + +**Welche Frucht auf einem Feld steht und wie weit sie ist.** Das liegt in +`densityMap_fruits.gdm`, einer Binärdatei ohne öffentliche Spezifikation, die +nur im kompletten Savegame-ZIP steckt — und das lädt man über die +Admin-Oberfläche, nicht über den Code. + +Daran ändert auch kein Mod etwas. [Crop Growth Stage +Info](https://www.farming-simulator.com/mod.php?mod_id=232903) und [Additional +Field Info](https://github.com/yumi-modding/FS25_additionalFieldInfo) berechnen +genau diese Werte, sind aber reine HUD-Overlays: Lua, `modDesc.xml`, ein Icon — +nichts, was eine Datei schreibt oder einen Port öffnet. + +Bestätigt durch die Konkurrenz. Keiner dieser Bots zeigt Feldfrüchte, obwohl +einer davon Geld kostet: + +- [GamePuls](https://bot.dein-gamepuls.de) — kommerziell, mit Premium-Stufe +- [Farm Sim Game](https://www.farmsimgame.com/) — gehostet +- [IceEagle132](https://github.com/IceEagle132/Farming-Simulator-25-Discord-Bot) +- [DJWoodZ](https://github.com/DJWoodZ/Farming-Simulator-Discord-Bot) +- [cloudmaker97](https://github.com/cloudmaker97/FS25-Discord-Bot) + +**Die aktuelle Jahreszeit.** Sie steht in keinem Endpunkt. Aus `playTime` und +`plannedDaysPerPeriod` liesse sie sich schätzen, aber nur solange niemand die +Zeitskala ändert — deshalb ist sie eine Einstellung im Panel und kein +Rateversuch. + +## Fallstricke + +- **Parzellen haben nur Schwerpunkt und Fläche, keine Umrisse.** Die Karte + zeichnet flächengleiche Kreise. Bei langen schmalen Feldern sieht man das. +- **Die Modliste steht vor den Parzellen.** Wer nur die ersten Zeilen + anschaut, hält den Feed für eine reine Statusabfrage. +- **Spargel sprengt jede Preisliste.** Er schwankt zwischen 26 und 7540 € und + verdrängt nach Schwankungsbreite sortiert jede echte Feldfrucht. Deshalb + arbeitet die Preisliste mit einer Auswahl statt mit einem Ranking. +- **Das Luftbild als PNG ist zehnmal so gross wie nötig** — 2,5 MB statt + 250 KB. Es ist ein Foto, also JPEG. diff --git a/src/bot/ls-farm.js b/src/bot/ls-farm.js new file mode 100644 index 0000000..7348123 --- /dev/null +++ b/src/bot/ls-farm.js @@ -0,0 +1,507 @@ +// Landwirtschafts-Simulator: Besitzkarte und Preiskurven. +// +// Der Feed eines LS-Servers gibt deutlich mehr her, als der Monitor bisher +// benutzt hat — die reine Statusabfrage lädt schon 49 KB, in denen Parzellen, +// Felder, Fahrzeuge und Spielerpositionen stehen. Hier wird das ausgewertet. +// +// Was der Feed NICHT hergibt, und woran auch kein Mod etwas ändert: welche +// Frucht auf einem Feld steht und wie weit sie ist. Das liegt in einer binären +// Density-Map, die kein Endpunkt herausgibt. Deshalb ist das hier eine +// Besitzkarte und keine Feldkarte — und die Parzellen sind flächengleiche +// Kreise, weil der Feed nur Schwerpunkt und Hektar liefert, keine Umrisse. +import { AttachmentBuilder, Events } from 'discord.js'; +import sharp from 'sharp'; +import { brandEmbed } from '../embeds.js'; +import { brandFooter } from '../runtime-settings.js'; +import { moduleEnabled } from '../modules.js'; +import { tuning, tuningMs, everyTuned } from '../tuning.js'; +import { getSetting, listGameservers, lsState, setLsMessage, setLsBesitz } from '../db.js'; + +/** Alle Attribute eines XML-Tags als Objekt */ +const attrsVon = (roh) => + Object.fromEntries([...roh.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); + +/** Alle selbstschliessenden Tags eines Namens */ +function tags(xml, name) { + return [...xml.matchAll(new RegExp(`<${name} ([^>]*?)/>`, 'g'))].map((m) => attrsVon(m[1])); +} + +/** + * Die Statusabfrage zerlegen. + * + * Bewusst mit regulären Ausdrücken statt einem XML-Parser: es geht um vier + * flache Tag-Sorten ohne Verschachtelung, und eine weitere Abhängigkeit für + * 20 Zeilen wäre unverhältnismässig. + */ +export function statsLesen(xml) { + const server = attrsVon(xml.match(/]*)>/)?.[1] ?? ''); + const spieler = [...xml.matchAll(/]*)>([^<]*)<\/Player>/g)] + .map((m) => ({ ...attrsVon(m[1]), name: m[2].trim() })) + .filter((p) => p.isUsed === 'true'); + return { + name: server.name ?? null, + karte: server.mapName ?? null, + // Ohne mapSize lässt sich nichts zeichnen — dann lieber gar keine Karte + groesse: Number(server.mapSize) || null, + version: server.version ?? null, + parzellen: tags(xml, 'Farmland').map((f) => ({ + id: Number(f.id), name: f.name ?? String(f.id), + hof: Number(f.owner), // 0 = noch zu kaufen + hektar: Number(f.area), preis: Number(f.price), + x: Number(f.x), z: Number(f.z), + })), + felder: tags(xml, 'Field').map((f) => ({ + id: Number(f.id), x: Number(f.x), z: Number(f.z), eigen: f.isOwned === 'true', + })), + fahrzeuge: tags(xml, 'Vehicle').map((v) => ({ + name: v.name, typ: v.type, kategorie: v.category, + x: Number(v.x), z: Number(v.z), + ladung: ladungLesen(v.fillTypes, v.fillLevels), + ki: v.isAIActive === 'true', + })), + spieler: spieler.map((p) => ({ + name: p.name, admin: p.isAdmin === 'true', minuten: Number(p.uptime) || 0, + // Wer im Menü steht, hat keine Position — der taucht auf der Karte nicht auf + x: p.x != null ? Number(p.x) : null, + z: p.z != null ? Number(p.z) : null, + })), + }; +} + +/** "SEEDS FERTILIZER" + "476.6 73.5" → [{ art, menge }] ohne die leeren Slots */ +function ladungLesen(arten, mengen) { + if (!arten || !mengen) return []; + const a = arten.trim().split(/\s+/); + const m = mengen.trim().split(/\s+/).map(Number); + return a + .map((art, i) => ({ art, menge: m[i] ?? 0 })) + .filter((l) => l.art !== 'UNKNOWN' && l.menge > 0.5); +} + +/** Besitz je Hof, grösster zuerst; Hof 0 (unverkauft) getrennt */ +export function hofUebersicht(parzellen) { + const proHof = new Map(); + for (const p of parzellen) { + const e = proHof.get(p.hof) ?? { hof: p.hof, parzellen: 0, hektar: 0, wert: 0 }; + e.parzellen++; + e.hektar += p.hektar; + e.wert += p.preis; + proHof.set(p.hof, e); + } + const alle = [...proHof.values()]; + return { + hoefe: alle.filter((h) => h.hof > 0).sort((a, b) => b.hektar - a.hektar), + frei: alle.find((h) => h.hof === 0) ?? { parzellen: 0, hektar: 0, wert: 0 }, + }; +} + +// ── Preise ──────────────────────────────────────────────────────────────── + +export const PERIODEN = [ + 'EARLY_SPRING', 'MID_SPRING', 'LATE_SPRING', + 'EARLY_SUMMER', 'MID_SUMMER', 'LATE_SUMMER', + 'EARLY_AUTUMN', 'MID_AUTUMN', 'LATE_AUTUMN', + 'EARLY_WINTER', 'MID_WINTER', 'LATE_WINTER', +]; +export const PERIODE_LABEL = { + EARLY_SPRING: 'Früh-Frühling', MID_SPRING: 'Mitt-Frühling', LATE_SPRING: 'Spät-Frühling', + EARLY_SUMMER: 'Früh-Sommer', MID_SUMMER: 'Hoch-Sommer', LATE_SUMMER: 'Spät-Sommer', + EARLY_AUTUMN: 'Früh-Herbst', MID_AUTUMN: 'Mitt-Herbst', LATE_AUTUMN: 'Spät-Herbst', + EARLY_WINTER: 'Früh-Winter', MID_WINTER: 'Hoch-Winter', LATE_WINTER: 'Spät-Winter', +}; + +/** + * Die Preiskurven aus der economy.xml. + * + * Das sind Jahreszeitpreise, keine Tagespreise: für jede Fruchtart steht da, + * was sie in welcher Periode bringt. Wer wissen will, wann sich Verkaufen + * lohnt, findet es genau hier — bei welchem Händler, steht nirgends. + */ +export function preiseLesen(xml) { + const raus = []; + for (const m of xml.matchAll(/([\s\S]*?)<\/fillType>/g)) { + const kurve = {}; + for (const p of m[2].matchAll(/([\d.]+)<\/period>/g)) { + kurve[p[1]] = Number(p[2]); + } + const werte = PERIODEN.map((p) => kurve[p]).filter((v) => v != null); + if (werte.length === 0) continue; + const hoch = Math.max(...werte); + const tief = Math.min(...werte); + raus.push({ + art: m[1], kurve, hoch, tief, + besteZeit: PERIODEN.find((p) => kurve[p] === hoch) ?? null, + schlechtesteZeit: PERIODEN.find((p) => kurve[p] === tief) ?? null, + // Wie viel Unterschied macht der Zeitpunkt? Darunter lohnt Warten nicht. + spanne: tief > 0 ? hoch / tief : 1, + }); + } + return raus; +} + +/** Zwölf Werte als Balkenzeile — Höhe relativ zwischen Tief und Hoch */ +const BLOECKE = '▁▂▃▄▅▆▇█'; +export function kurveZeichnen(eintrag) { + return PERIODEN.map((p) => { + const v = eintrag.kurve[p]; + if (v == null) return ' '; + if (eintrag.hoch === eintrag.tief) return BLOECKE[0]; + const anteil = (v - eintrag.tief) / (eintrag.hoch - eintrag.tief); + return BLOECKE[Math.min(BLOECKE.length - 1, Math.round(anteil * (BLOECKE.length - 1)))]; + }).join(''); +} + +// ── Karte ───────────────────────────────────────────────────────────────── + +// Farben je Hof. Mehr als sechs Höfe hat kaum ein Server; danach wiederholt +// es sich, was immer noch besser ist als eine Karte ohne Farben. +const HOF_FARBEN = ['#f5c518', '#4ea3ff', '#00d47e', '#ff6b9d', '#c084fc', '#ff8c42']; +export const hofFarbe = (hof) => HOF_FARBEN[(hof - 1) % HOF_FARBEN.length]; + +const esc = (s) => String(s).replace(/[<>&"']/g, (c) => + ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c])); + +/** + * Die Karte als SVG. + * + * Weltkoordinaten laufen von -groesse/2 bis +groesse/2; x nach rechts, z nach + * unten, linear über das ganze Luftbild. Das ist gegen die Ingame-Karte + * geprüft: 14 von 15 markanten Feldnummern landen in der richtigen Zone. + * + * @param {object} stand Ergebnis von statsLesen() + * @param {string} bildB64 Luftbild vom Server als base64-JPEG + * @param {number} S Kantenlänge in Pixeln + * @param {Record} namen Hof-Nummer → Anzeigename + */ +export function karteSvg(stand, bildB64, S = 1024, namen = {}) { + const G = stand.groesse; + const px = (w) => ((w + G / 2) / G) * S; + const teile = []; + + for (const p of stand.parzellen) { + if (p.hof === 0) continue; // unverkauftes Land nicht zumalen + const farbe = hofFarbe(p.hof); + // Kreisfläche = Hektar, damit die Grösse etwas aussagt statt Deko zu sein + const radiusM = Math.sqrt((p.hektar * 10000) / Math.PI); + const r = Math.max(4, (radiusM / G) * S); + teile.push(``); + teile.push(`${esc(p.name)}`); + } + for (const v of stand.fahrzeuge) { + const farbe = v.ki ? '#00d47e' : '#ff8c42'; // KI-Helfer heben sich ab + teile.push(``); + } + for (const s of stand.spieler) { + if (s.x == null) continue; + teile.push(``); + teile.push(`${esc(s.name)}`); + } + + const { hoefe } = hofUebersicht(stand.parzellen); + const legende = hoefe.map((h, i) => { + const farbe = hofFarbe(h.hof); + const wer = namen[h.hof] ?? `Hof ${h.hof}`; + return `` + + `${esc(wer)} — ${h.parzellen} Parzellen, ${h.hektar.toFixed(1)} ha`; + }).join(''); + + const online = stand.spieler.length; + const fuss = `${stand.karte ?? ''} · ${stand.fahrzeuge.length} Fahrzeuge · ${online} ${online === 1 ? 'Spieler' : 'Spieler'} online`; + + return ` + +${teile.join('\n')} +${legende} +${esc(fuss)} +`; +} + +/** + * Karte als Bild. + * + * JPEG, nicht PNG: der Untergrund ist ein Luftbild, und PNG macht daraus das + * Zehnfache — 2,5 MB statt 250 KB, alle paar Minuten neu hochgeladen. + */ +export async function karteBild(stand, bildB64, S = 1024, namen = {}) { + return sharp(Buffer.from(karteSvg(stand, bildB64, S, namen))) + .flatten({ background: '#0d0d0d' }) // JPEG kann kein Transparent + .jpeg({ quality: 86, mozjpeg: true }) + .toBuffer(); +} + +// ── Discord ─────────────────────────────────────────────────────────────── + +const geld = (n) => `${Math.round(n).toLocaleString('de-DE')} €`; + +/** Das Embed zur Karte: wer wie viel hat, und wer gerade spielt */ +export function hofEmbed(client, stand, namen = {}) { + const { hoefe, frei } = hofUebersicht(stand.parzellen); + const embed = brandEmbed(client, 'HÖFE') + .setTitle(`🚜 ${stand.name ?? 'LS-Server'}`) + .setFooter({ text: `${brandFooter('HÖFE')}${stand.karte ? ` • ${stand.karte}` : ''}` }) + .setTimestamp(); + + if (hoefe.length === 0) { + embed.setDescription('Noch hat kein Hof Land gekauft.'); + } else { + embed.addFields(hoefe.map((h) => ({ + name: namen[h.hof] ?? `Hof ${h.hof}`, + value: `${h.parzellen} Parzellen · **${h.hektar.toFixed(1)} ha**\n${geld(h.wert)}`, + inline: true, + }))); + } + embed.addFields({ + name: 'Noch zu haben', + value: `${frei.parzellen} Parzellen · ${frei.hektar.toFixed(1)} ha · ${geld(frei.wert)}`, + }); + + const drauf = stand.spieler; + if (drauf.length > 0) { + embed.addFields({ + name: `Gerade drauf (${drauf.length})`, + value: drauf.map((s) => `${s.admin ? '👑 ' : ''}\`${s.name}\``).join(' ').slice(0, 1000), + }); + } + // Was in den Fahrzeugen liegt, ist der einzige Hinweis darauf, was gerade + // geerntet wird — die Frucht auf dem Feld gibt der Feed nicht her. + const ladungen = new Map(); + for (const v of stand.fahrzeuge) { + for (const l of v.ladung) { + if (['DIESEL', 'DEF', 'AIR'].includes(l.art)) continue; // Betriebsstoffe + ladungen.set(l.art, (ladungen.get(l.art) ?? 0) + l.menge); + } + } + if (ladungen.size > 0) { + const zeilen = [...ladungen.entries()] + .sort((a, b) => b[1] - a[1]).slice(0, 8) + .map(([art, menge]) => `\`${art.toLowerCase()}\` ${Math.round(menge).toLocaleString('de-DE')} l`); + embed.addFields({ name: 'In den Fahrzeugen', value: zeilen.join(' · ').slice(0, 1000) }); + } + return embed; +} + +/** Karte + Embed als fertige Nachricht */ +export async function hofNachricht(client, stand, bildB64, namen = {}, breite = 1024) { + const embed = hofEmbed(client, stand, namen); + if (!bildB64 || !stand.groesse) return { embeds: [embed], files: [] }; + const bild = await karteBild(stand, bildB64, breite, namen); + const datei = new AttachmentBuilder(bild, { name: 'karte.jpg' }); + return { embeds: [embed.setImage('attachment://karte.jpg')], files: [datei] }; +} + +/** + * Was standardmässig in der Preisliste steht. + * + * Ohne diese Liste gewinnt Spargel: der schwankt zwischen 26 und 7540 € und + * verdrängt damit jede Frucht, die man tatsächlich hektarweise anbaut. Nach + * Schwankungsbreite zu sortieren wäre mathematisch richtig und praktisch + * wertlos. Die Auswahl lässt sich im Panel überschreiben. + */ +export const STANDARD_SORTEN = [ + 'WHEAT', 'BARLEY', 'OAT', 'CANOLA', 'MAIZE', 'SUNFLOWER', 'SOYBEAN', + 'POTATO', 'SUGARBEET', 'SUGARCANE', 'COTTON', 'SORGHUM', 'RICE', + 'GRAPE', 'OLIVE', 'MILK', 'WOOL', 'EGG', 'SILAGE', 'WOODCHIPS', +]; + +/** "SUGARBEET_CUT" → "sugarbeet cut" */ +const sortenName = (art) => art.toLowerCase().replace(/_/g, ' '); + +/** Preis-Embed: was wann am meisten bringt */ +export function preisEmbed(client, preise, { jetzt = null, anzahl = 12, sorten = null } = {}) { + const wunsch = (sorten?.length ? sorten : STANDARD_SORTEN).map((s) => s.toUpperCase()); + const nachRang = new Map(wunsch.map((s, i) => [s, i])); + // In der Reihenfolge der Liste, nicht nach Schwankung — so steht Weizen + // vorne, weil er wichtig ist, und nicht hinten, weil er ruhig ist. + const lohnend = preise + .filter((p) => nachRang.has(p.art)) + .sort((a, b) => nachRang.get(a.art) - nachRang.get(b.art)) + .slice(0, anzahl); + + const embed = brandEmbed(client, 'PREISE') + .setTitle('💰 Wann sich Verkaufen lohnt') + .setDescription( + jetzt + ? `Aktuell: **${PERIODE_LABEL[jetzt] ?? jetzt}**\n\`▁▂▃▄▅▆▇█\` = billig bis teuer über das Jahr` + : '`▁▂▃▄▅▆▇█` = billig bis teuer über das Jahr — die Jahreszeit steht in den Werten.' + ) + .setFooter({ text: brandFooter('PREISE') }) + .setTimestamp(); + + if (lohnend.length === 0) { + embed.setDescription('Keine der ausgewählten Sorten kommt auf diesem Server vor.'); + return embed; + } + for (const p of lohnend) { + const hier = jetzt ? p.kurve[jetzt] : null; + // Unter 5 % Abstand zum Hoch ist Warten Zeitverschwendung + const rat = hier == null ? '' + : hier >= p.hoch * 0.95 ? '\n**Jetzt verkaufen** — Höchststand.' + : `\n+${Math.round((p.hoch / hier - 1) * 100)} % im ${PERIODE_LABEL[p.besteZeit]}`; + embed.addFields({ + name: sortenName(p.art), + value: `\`${kurveZeichnen(p)}\`\n${Math.round(p.tief)}–${Math.round(p.hoch)} €` + + `${hier != null ? ` · jetzt **${Math.round(hier)} €**` : ''}${rat}`, + inline: true, + }); + } + return embed; +} + +// ── Abrufen und Posten ──────────────────────────────────────────────────── + +/** Basis-URL eines LS-Servers aus dem Monitor-Eintrag */ +function feedBasis(server) { + const port = server.port || 8080; + return `http://${server.host}:${port}/feed`; +} + +/** + * Ist das ein LS-Server, den wir abfragen können? + * + * Boolean() ist hier keine Kosmetik: ohne das gibt die Kette den Zugangs-Code + * selbst zurück, und der stünde beim ersten Log-Aufruf im Klartext da. + */ +export const istLsServer = (s) => Boolean( + String(s.type ?? '').startsWith('farmingsimulator') && s.host && s.token +); + +async function holen(url, timeoutMs, alsText = true) { + const res = await fetch(url, { + signal: AbortSignal.timeout(timeoutMs), + headers: { 'User-Agent': 'd4rkbot-ls/1.0' }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return alsText ? res.text() : Buffer.from(await res.arrayBuffer()); +} + +/** + * Alles holen, was ein LS-Server hergibt. + * + * Das Luftbild kostet je nach Grösse ein paar hundert KB — deshalb steht die + * Kantenlänge als Parameter da und nicht fest im Code. + */ +export async function lsAbfragen(server, { timeoutMs = 15000, kartenBreite = 1024 } = {}) { + const basis = feedBasis(server); + const code = encodeURIComponent(server.token); + const stats = await holen(`${basis}/dedicated-server-stats.xml?code=${code}`, timeoutMs); + const stand = statsLesen(stats); + + // Karte und Preise sind Beiwerk: fehlen sie, bleibt der Rest brauchbar + const [bild, preise] = await Promise.all([ + holen(`${basis}/dedicated-server-stats-map.jpg?code=${code}&quality=85&size=${kartenBreite}`, timeoutMs, false) + .then((b) => b.toString('base64')).catch(() => null), + holen(`${basis}/dedicated-server-savegame.html?file=economy&code=${code}`, timeoutMs) + .then(preiseLesen).catch(() => []), + ]); + return { stand, bild, preise }; +} + +/** + * Was hat sich am Besitz geändert? + * + * Nur Übergänge melden, bei denen wirklich jemand gekauft oder verkauft hat. + * Beim allerersten Durchlauf gibt es nichts zu melden — sonst käme beim Start + * des Bots eine Meldung pro bereits gekaufter Parzelle. + */ +export function besitzDiff(vorher, nachher) { + if (!vorher) return []; + const alt = new Map(vorher.map((p) => [p.id, p.hof])); + const raus = []; + for (const p of nachher) { + const vor = alt.get(p.id); + if (vor === undefined || vor === p.hof) continue; + raus.push({ parzelle: p, von: vor, nach: p.hof }); + } + return raus; +} + +/** Kauf-Meldung: "Hof 2 hat Parzelle 47 gekauft (2,3 ha)" */ +function wechselText(w, namen) { + const wer = (h) => (h === 0 ? null : namen[h] ?? `Hof ${h}`); + const gross = `${w.parzelle.hektar.toFixed(1)} ha`; + if (w.von === 0) return `🟢 **${wer(w.nach)}** hat Parzelle **${w.parzelle.name}** gekauft — ${gross}, ${geld(w.parzelle.preis)}`; + if (w.nach === 0) return `🔴 **${wer(w.von)}** hat Parzelle **${w.parzelle.name}** verkauft — ${gross}`; + return `🔁 Parzelle **${w.parzelle.name}** ging von **${wer(w.von)}** an **${wer(w.nach)}** — ${gross}`; +} + +/** + * Ein Durchlauf für alle LS-Server. + * + * Als eigene Funktion exportiert, damit sie sich testen lässt, ohne einen + * Timer laufen zu lassen. Alles, was hier schiefgehen kann, betrifft immer nur + * einen Server — deshalb steckt das try/catch in der Schleife und nicht darum. + */ +export async function lsTick(client) { + if (!moduleEnabled('ls_farm')) return; + + const server = listGameservers().filter(istLsServer); + if (server.length === 0) return; + + const channelId = getSetting('ls_channel_id'); + const kanal = channelId ? await client.channels.fetch(channelId).catch(() => null) : null; + const alarmId = getSetting('ls_event_channel_id') || channelId; + const alarm = alarmId ? await client.channels.fetch(alarmId).catch(() => null) : null; + const breite = Number(tuning('ls_map_size')) || 1024; + + for (const s of server) { + try { + const { stand, bild, preise } = await lsAbfragen(s, { + timeoutMs: tuningMs.seconds('monitor_timeout'), + kartenBreite: breite, + }); + const namen = hofNamen(getSetting('ls_farm_names')); + + // Besitzwechsel melden, bevor der neue Stand gespeichert wird + const alt = lsState(s.id); + const wechsel = besitzDiff(alt?.besitz, stand.parzellen); + if (wechsel.length > 0 && alarm?.isTextBased()) { + await alarm.send({ + embeds: [brandEmbed(client, 'LAND') + .setTitle('🚜 Land hat den Besitzer gewechselt') + .setDescription(wechsel.slice(0, 15).map((w) => wechselText(w, namen)).join('\n'))], + }).catch(() => {}); + } + setLsBesitz(s.id, stand.parzellen.map((p) => ({ id: p.id, hof: p.hof }))); + + if (!kanal?.isTextBased()) continue; + const payload = await hofNachricht(client, stand, bild, namen, breite); + // Preise ändern sich nur mit der Jahreszeit — ein zweites Embed in + // derselben Nachricht spart einen Dauerposten im Kanal. + if (preise.length > 0) { + payload.embeds.push(preisEmbed(client, preise, { + jetzt: getSetting('ls_period') || null, + sorten: String(getSetting('ls_crops') ?? '').split(',').map((s) => s.trim()).filter(Boolean), + })); + } + const alteId = alt?.message_id; + if (alteId) { + const vorhanden = await kanal.messages.fetch(alteId).catch(() => null); + if (vorhanden) { + // Beim Bearbeiten muss der alte Anhang weg, sonst hängen + // zwei Karten an der Nachricht und Discord zeigt die alte. + await vorhanden.edit({ ...payload, attachments: [] }); + continue; + } + } + const neu = await kanal.send(payload); + setLsMessage(s.id, neu.id); + } catch (error) { + console.error(`[ls-farm] ${s.name}:`, error.message); + } + } +} + +/** "1=Alexander, 2=Timo" → { 1: 'Alexander', 2: 'Timo' } */ +export function hofNamen(roh) { + const raus = {}; + for (const teil of String(roh ?? '').split(',')) { + const m = teil.match(/^\s*(\d+)\s*=\s*(.+?)\s*$/); + if (m) raus[Number(m[1])] = m[2]; + } + return raus; +} + +export function startLsFarm(client) { + everyTuned('ls_interval', 'minutes', () => lsTick(client), 'ls-farm'); + client.once(Events.ClientReady, () => lsTick(client).catch(() => {})); +} diff --git a/src/db.js b/src/db.js index 5bcbc14..a931924 100644 --- a/src/db.js +++ b/src/db.js @@ -986,6 +986,40 @@ const playerHistoryStmt = db.prepare(` WHERE server_id = ? AND ts >= datetime('now', ?) ORDER BY ts `); const prunePlayerHistoryStmt = db.prepare(`DELETE FROM player_history WHERE ts < datetime('now', '-7 days')`); + +// LS-Modul: das gepflegte Karten-Embed und der letzte Besitzstand je Server. +// Der Besitzstand ist nur da, um Käufe zu erkennen — ohne ihn müsste beim +// Start jede bereits gekaufte Parzelle als „neu gekauft" gemeldet werden. +db.exec(` + CREATE TABLE IF NOT EXISTS ls_state ( + server_id INTEGER PRIMARY KEY, + message_id TEXT, + besitz TEXT, + geprueft TEXT NOT NULL DEFAULT (datetime('now')) + ); +`); +const lsStateStmt = db.prepare('SELECT * FROM ls_state WHERE server_id = ?'); +const lsMessageStmt = db.prepare(` + INSERT INTO ls_state (server_id, message_id) VALUES (?, ?) + ON CONFLICT(server_id) DO UPDATE SET message_id = excluded.message_id +`); +const lsBesitzStmt = db.prepare(` + INSERT INTO ls_state (server_id, besitz, geprueft) VALUES (?, ?, datetime('now')) + ON CONFLICT(server_id) DO UPDATE SET besitz = excluded.besitz, geprueft = datetime('now') +`); +const lsVergessenStmt = db.prepare('DELETE FROM ls_state WHERE server_id = ?'); +export const lsState = (serverId) => { + const r = lsStateStmt.get(serverId); + if (!r) return null; + // Kaputtes JSON darf den Durchlauf nicht anhalten — dann gilt eben + // "noch nichts bekannt" und beim nächsten Mal steht wieder etwas drin. + let besitz = null; + try { besitz = r.besitz ? JSON.parse(r.besitz) : null; } catch { besitz = null; } + return { ...r, besitz }; +}; +export const setLsMessage = (serverId, messageId) => lsMessageStmt.run(serverId, messageId); +export const setLsBesitz = (serverId, besitz) => lsBesitzStmt.run(serverId, JSON.stringify(besitz)); +export const forgetLsState = (serverId) => lsVergessenStmt.run(serverId).changes > 0; // Erreichbarkeit stundenweise zusammengefasst — für den Uptime-Balken. // Die Rohdaten (alle 2 min) wären über eine Woche rund 5000 Zeilen pro Server; // als Stundeneimer sind es höchstens 168 und die Seite bleibt schlank. diff --git a/src/index.js b/src/index.js index ec84c79..229cb94 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ import { scheduleWeeklyRecap } from './bot/weekly-recap.js'; import { startWatchdog } from './bot/watchdog.js'; import { scheduleBackups } from './backup.js'; import { startServerMonitor } from './bot/server-monitor.js'; +import { startLsFarm } from './bot/ls-farm.js'; import { startGiveaways } from './bot/giveaways.js'; import { startScheduledPosts } from './web/scheduled-posts.js'; import { startSocialNotify } from './bot/social-notify.js'; @@ -22,6 +23,7 @@ try { startWatchdog(client); scheduleBackups(client); startServerMonitor(client); + startLsFarm(client); startGiveaways(client); startScheduledPosts(client); startSocialNotify(client); diff --git a/src/modules.js b/src/modules.js index f90a08f..b7d1800 100644 --- a/src/modules.js +++ b/src/modules.js @@ -242,6 +242,19 @@ export const MODULES = [ ], tool: { tab: 'server', label: 'Game-Server verwalten' }, }, + { + id: 'ls_farm', group: 'server', name: 'LS — Höfe & Preise', + desc: 'Landwirtschafts-Simulator: Besitzkarte mit Höfen, Fahrzeugen und Spielern, ' + + 'dazu die Preiskurven übers Jahr. Braucht einen LS-Server mit Zugangs-Code unter „Game-Server".', + tab: 'server', setting: 'ls_farm_enabled', + requires: [{ setting: 'ls_channel_id', label: 'Karten-Kanal', create: { name: 'ls-hoefe', topic: 'Besitzkarte und Preise vom LS-Server' } }], + fields: [ + { setting: 'ls_event_channel_id', label: 'Ereignis-Kanal', art: 'kanal', hint: 'Land gekauft oder verkauft; leer = in den Karten-Kanal' }, + { setting: 'ls_farm_names', label: 'Hof-Namen', art: 'text', hint: 'der Feed kennt nur Nummern — z. B. „1=Alexander, 2=Timo, 3=Kitty"' }, + { setting: 'ls_period', label: 'Aktuelle Jahreszeit', art: 'text', hint: 'z. B. MID_SUMMER — steht nicht im Feed. Leer lassen heißt: Kurve ohne „jetzt verkaufen"-Rat.' }, + { setting: 'ls_crops', label: 'Sorten in der Preisliste', art: 'text', hint: 'kommagetrennt, leer = die üblichen. Ohne Auswahl verdrängt Spargel jede echte Feldfrucht.' }, + ], + }, { id: 'watchdog', group: 'server', name: 'Erreichbarkeits-Wächter', desc: 'Prüft hinterlegte Adressen und meldet Ausfälle per Direktnachricht.', diff --git a/src/tuning.js b/src/tuning.js index 505ca62..8f47dd6 100644 --- a/src/tuning.js +++ b/src/tuning.js @@ -71,6 +71,16 @@ export const TUNING = [ 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,