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,