diff --git a/README.md b/README.md index dd53567..3051fb6 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen. | Funktion | Was sie macht | |---|---| | 🎮 **Game-Server-Monitor** | DiscordGSM-Stil: pro Server ein Live-Embed (🟢/🔴, Name im Spiel, 🔒 bei Passwort oder Whitelist, Spieler-Balken, Map, Version, Mod-Anzahl, Spieler-Liste, Knöpfe für Verbinden, Mod-Download und frei eintragbare Links wie Regeln oder TeamSpeak, 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)) | +| 🚜 **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), die Preiskurven übers Jahr mit „jetzt verkaufen oder warten", dazu **je Hof ein Embed**, das sich selbst aktualisiert: Land, Fuhrpark mit Wert und Betriebsstunden, was in die Werkstatt muss, was gewaschen gehört und was geladen ist. **Nicht möglich:** welche Frucht auf welchem Feld steht, und Tierställe — beides gibt der Feed nicht her (siehe [docs/ls-feed.md](docs/ls-feed.md)) | ### Server & Technik diff --git a/docs/ls-feed.md b/docs/ls-feed.md index 05a0b18..e4ad325 100644 --- a/docs/ls-feed.md +++ b/docs/ls-feed.md @@ -61,14 +61,62 @@ Mit dem richtigen Maßstab liegen die Parzellen-Schwerpunkte **unter 15 px** von ihrer Marke entfernt, gemessen auf einem 1024er Bild; mit dem falschen rund 200 px. Der Unterschied ist so deutlich, dass eine einzige Prüfung reicht. +## Was in der Fahrzeugdatei steht + +`file=vehicles` ist die ergiebigste der drei Savegame-Dateien. Je ``: + +| Attribut | Bedeutung | +|---|---| +| `filename` | Pfad zum Modell — `data/vehicles/newHolland/t7/t7.xml` | +| `farmId` | wem es gehört | +| `propertyState` | `OWNED` oder gemietet | +| `price` | Zeitwert | +| `age` | Alter in Spieltagen | +| `operatingTime` | Betriebszeit in **Sekunden** | +| `modName` | gesetzt, wenn es aus einem Mod stammt | + +In Kindelementen: `` (Verschleiss, 0…1), `` +(Schmutz, 0…1) und `` je Tank oder Ladefläche. + +Zwei Fallen dabei: + +- **Blockweise lesen, nicht attributweise.** Verschleiss und Schmutz stehen in + Kindelementen. Wer im ganzen Dokument nach `damage="…"` sucht, hängt sie + irgendeinem Fahrzeug an. +- **Gemietete Missionsfahrzeuge zählen nicht zum Hof.** Sie tauchen nur auf, + solange der Auftrag läuft, und verfälschen Anzahl und Wert. Auf dem + Testserver war das rund ein Drittel aller Einträge. + +Nicht drin steht die **Tankgrösse**. Die Datei nennt Liter, keine Prozent — +„276 l Diesel" geht, „38 % voll" nicht. + ## Was es nicht gibt +**Tierställe und ihr Zustand.** Ställe sind Placeables, und `placeables` +antwortet mit `204`. In den drei ausgelieferten Dateien kommt kein einziges +Tier vor: jeder Treffer auf `cow`, `pig` oder `animal` ist ein **Modname** +(„Old Cowshed", „Modern Pigsty"), kein Bestand. Damit gibt es weder Anzahl noch +Produktivität, Futter oder Sauberkeit. + + **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 +Daran ändert auch kein eigener Mod etwas. Die Lua-Umgebung des Spiels läuft in +einer Sandbox — GIANTS schreibt in der Skript-Dokumentation, allgemeine +System-Ein- und -Ausgabe sei abgeschaltet. Ein Mod kann also weder eine Datei +schreiben, die der Feed ausliefert, noch selbst etwas an den Bot schicken. + +Wer wirklich an die Rohdaten will, braucht **keinen Mod, sondern FTP**. Das ist +der Weg von [FS19_Webstats](https://github.com/J0hnHawk/FS19_Webstats): die +Web-API sei nicht erweitert worden, der einzige Weg an alle Daten führe über +FTP-Zugriff auf den Spielstand-Ordner. Damit läge das komplette Savegame vor, +`densityMap_fruits.gdm` inklusive — nur eben weiterhin ohne Spezifikation für +dessen Inhalt. + +[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 — diff --git a/src/bot/ls-farm.js b/src/bot/ls-farm.js index 0aa05a2..7fc411e 100644 --- a/src/bot/ls-farm.js +++ b/src/bot/ls-farm.js @@ -15,7 +15,7 @@ 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, setSetting, listGameservers, lsState, setLsMessage, setLsBesitz, setLsTageszeit } from '../db.js'; +import { getSetting, setSetting, listGameservers, lsState, setLsMessage, setLsTageszeit, setLsHofMessages } from '../db.js'; /** Alle Attribute eines XML-Tags als Objekt */ const attrsVon = (roh) => @@ -111,6 +111,62 @@ export function hofUebersicht(parzellen) { }; } +// ── Fuhrpark ────────────────────────────────────────────────────────────── + +/** + * Die Fahrzeuge aus der vehicles.xml. + * + * Blockweise statt attributweise: Verschleiss und Schmutz stehen in + * Kindelementen, und wer nur nach `damage="…"` sucht, ordnet sie irgendeinem + * Fahrzeug zu. + * + * Was NICHT drinsteht: die Tankgroesse. Die Datei nennt Liter, nicht Prozent — + * „276 l Diesel" geht, „38 % voll" nicht. + */ +export function fahrzeugeLesen(xml) { + return [...String(xml ?? '').matchAll(/]*)>([\s\S]*?)<\/vehicle>/g)].map(([, kopf, rumpf]) => { + const a = attrsVon(kopf); + return { + // aus data/vehicles/newHolland/t7/t7.xml wird "t7" + modell: (a.filename ?? '').split('/').pop()?.replace(/\.xml$/, '') || '?', + hof: Number(a.farmId) || 0, + preis: Number(a.price) || 0, + alter: Number(a.age) || 0, + // operatingTime kommt in Sekunden + stunden: (Number(a.operatingTime) || 0) / 3600, + eigen: a.propertyState === 'OWNED', + mod: a.modName || null, + verschleiss: Number(rumpf.match(/]*fillType="(\w+)"[^>]*fillLevel="([\d.]+)"/g)] + .map((m) => ({ art: m[1], menge: Number(m[2]) })) + .filter((f) => f.menge > 0.5), + }; + }); +} + +// Ab hier lohnt der Blick in die Werkstatt beziehungsweise die Waschanlage +const VERSCHLEISS_AB = 0.3; +const DRECK_AB = 0.7; + +/** Fahrzeuge eines Hofes, zusammengefasst */ +export function fuhrpark(fahrzeuge, hof) { + // Missionsfahrzeuge gehoeren nicht dem Hof — sie tauchen nur auf, solange + // der Auftrag laeuft, und wuerden Wert und Anzahl verfaelschen. + // Die Fahrzeugdatei wird einzeln geholt und darf einzeln ausfallen: dann + // gibt es eben keinen Fuhrpark statt gar keinem Durchlauf. + const meine = (fahrzeuge ?? []).filter((v) => v.eigen && v.hof === hof); + return { + anzahl: meine.length, + wert: meine.reduce((s, v) => s + v.preis, 0), + stunden: meine.reduce((s, v) => s + v.stunden, 0), + werkstatt: meine.filter((v) => v.verschleiss >= VERSCHLEISS_AB) + .sort((a, b) => b.verschleiss - a.verschleiss), + waschen: meine.filter((v) => v.dreck >= DRECK_AB).sort((a, b) => b.dreck - a.dreck), + fahrzeuge: meine, + }; +} + // ── Preise ──────────────────────────────────────────────────────────────── export const PERIODEN = [ @@ -353,6 +409,89 @@ export function hofEmbed(client, stand, namen = {}) { return embed; } +/** + * Ein Embed je Hof: Land, Fuhrpark und was ansteht. + * + * Getrennt statt alles in einem, weil jeder Hof seinen eigenen Leuten gehört — + * und weil ein Embed mit fünf Höfen darin niemandem hilft, der nur wissen + * will, ob sein Schlepper in die Werkstatt muss. + */ +export function hofDetailEmbed(client, hof, stand, fahrzeuge, namen = {}) { + const wer = namen[hof] ?? `Hof ${hof}`; + const land = hofUebersicht(stand.parzellen).hoefe.find((h) => h.hof === hof); + const fp = fuhrpark(fahrzeuge, hof); + const proz = (x) => `${Math.round(x * 100)} %`; + + const embed = brandEmbed(client, 'HOF') + .setColor(parseInt(hofFarbe(hof).slice(1), 16)) + .setTitle(`🚜 ${wer}`) + .setFooter({ text: `${brandFooter('HOF')}${stand.karte ? ` • ${stand.karte}` : ''}` }) + .setTimestamp(); + + embed.addFields( + { + name: 'Land', + value: land + ? `${land.parzellen} Parzellen · **${land.hektar.toFixed(1)} ha**\n${geld(land.wert)}` + : 'noch kein Land gekauft', + inline: true, + }, + { + name: 'Fuhrpark', + value: fp.anzahl > 0 + ? `**${fp.anzahl}** Fahrzeuge\n${geld(fp.wert)}` + : 'noch keine Fahrzeuge', + inline: true, + }, + { + name: 'Betriebsstunden', + value: fp.anzahl > 0 ? `${fp.stunden.toFixed(1)} h` : '—', + inline: true, + }, + ); + + if (fp.werkstatt.length > 0) { + embed.addFields({ + name: `🔧 In die Werkstatt (${fp.werkstatt.length})`, + value: fp.werkstatt.slice(0, 8) + .map((v) => `\`${v.modell}\` ${proz(v.verschleiss)}`).join(' · ').slice(0, 1000), + }); + } + if (fp.waschen.length > 0) { + embed.addFields({ + name: `🧽 Waschen (${fp.waschen.length})`, + value: fp.waschen.slice(0, 8) + .map((v) => `\`${v.modell}\` ${proz(v.dreck)}`).join(' · ').slice(0, 1000), + }); + } + + // Was in den Fahrzeugen dieses Hofes liegt — ohne Betriebsstoffe + const ladung = new Map(); + for (const v of fp.fahrzeuge) { + for (const l of v.ladung) { + if (['DIESEL', 'DEF', 'AIR', 'ELECTRICCHARGE'].includes(l.art)) continue; + ladung.set(l.art, (ladung.get(l.art) ?? 0) + l.menge); + } + } + if (ladung.size > 0) { + embed.addFields({ + name: 'Auf Lager', + value: [...ladung.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8) + .map(([art, menge]) => `**${sortenName(art)}** ${Math.round(menge).toLocaleString('de-DE')} l`) + .join(' · ').slice(0, 1000), + }); + } + return embed; +} + +/** Welche Höfe gibt es überhaupt? Aus Land und Fuhrpark zusammen. */ +export function hoefeImSpiel(stand, fahrzeuge) { + const ids = new Set(); + for (const p of stand?.parzellen ?? []) if (p.hof > 0) ids.add(p.hof); + for (const v of fahrzeuge ?? []) if (v.eigen && v.hof > 0) ids.add(v.hof); + return [...ids].sort((a, b) => a - b); +} + /** Karte + Embed als fertige Nachricht */ export async function hofNachricht(client, stand, bildB64, namen = {}, breite = 1024) { const embed = hofEmbed(client, stand, namen); @@ -402,8 +541,10 @@ export const SORTEN_NAMEN = { OILRADISH: 'Ölrettich', STRAW: 'Stroh', CHAFF: 'Häcksel', SILAGE: 'Silage', FORAGE: 'Mischration', HAY: 'Heu', // Tierisches - MILK: 'Milch', WOOL: 'Wolle', EGG: 'Eier', HONEY: 'Honig', + MILK: 'Milch', GOATMILK: 'Ziegenmilch', BUFFALOMILK: 'Büffelmilch', + WOOL: 'Wolle', EGG: 'Eier', HONEY: 'Honig', MANURE: 'Mist', LIQUIDMANURE: 'Gülle', DIGESTATE: 'Gärrest', + COMPOST: 'Kompost', MINERAL_FEED: 'Mineralfutter', PIGFOOD: 'Schweinefutter', // Betriebsstoffe SEEDS: 'Saatgut', FERTILIZER: 'Dünger', LIQUIDFERTILIZER: 'Flüssigdünger', HERBICIDE: 'Herbizid', LIME: 'Kalk', WATER: 'Wasser', @@ -511,42 +652,39 @@ export async function lsAbfragen(server, { timeoutMs = 15000, kartenBreite = 102 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([ + // Karte, Preise und Fuhrpark sind Beiwerk: fehlt eins, bleibt der Rest + // brauchbar. Deshalb einzeln abgefangen statt einmal um alles herum. + const [bild, preise, fahrzeuge] = 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(() => []), + // null heisst „nicht bekommen", [] heisst „wirklich keine Fahrzeuge". + // Der Unterschied zählt: bei einem Ausfall dürfen die Hof-Embeds nicht + // angefasst werden, sonst verschwindet ein Hof und wird neu gepostet. + holen(`${basis}/dedicated-server-savegame.html?file=vehicles&code=${code}`, timeoutMs) + .then(fahrzeugeLesen).catch(() => null), ]); - return { stand, bild, preise }; + return { stand, bild, preise, fahrzeuge }; } + /** - * Was hat sich am Besitz geändert? + * Vorhandene Nachricht bearbeiten, sonst eine neue schicken. * - * 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. + * Gibt die ID zurück, wenn eine neue entstanden ist — beim Bearbeiten `null`, + * dann bleibt der Merker, wie er ist. */ -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 }); +async function pflegen(kanal, id, beimBearbeiten, beimSenden) { + if (id) { + const vorhanden = await kanal.messages.fetch(id).catch(() => null); + if (vorhanden) { + await vorhanden.edit(beimBearbeiten); + return null; + } } - 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}`; + const neu = await kanal.send(beimSenden); + return neu.id; } /** @@ -564,44 +702,24 @@ export async function lsTick(client) { 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, { + const { stand, bild, preise, fahrzeuge } = 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 }))); - // Neuer Spieltag = neuer Monat. Nur weiterstellen, wenn überhaupt - // eine Jahreszeit eingetragen ist — ohne Ausgangspunkt liesse sich - // nur raten, und ein geratener Monat ist schlimmer als keiner. - const gesetzt = periodeLesen(getSetting('ls_period')); + // Neuer Spieltag = neuer Monat. Still weitergestellt, nicht + // gemeldet: die Preisliste braucht den Monat, aber eine Nachricht + // dafür wäre einmal am Tag Lärm für eine Zahl, die ohnehin im + // Embed steht. if (tagUmgeschlagen(alt?.tageszeit, stand.tageszeit)) { - const naechste = naechstePeriode(gesetzt); - if (naechste) { - setSetting('ls_period', naechste); - await alarm?.send({ - embeds: [brandEmbed(client, 'ZEIT') - .setTitle('📅 Neuer Monat auf dem Hof') - .setDescription(`Jetzt **${PERIODE_LABEL[naechste]}** — die Preise haben sich verschoben.`)], - }).catch(() => {}); - } + const naechste = naechstePeriode(getSetting('ls_period')); + if (naechste) setSetting('ls_period', naechste); } // Eine Antwort ohne Uhrzeit darf den letzten guten Stand nicht // überschreiben — sonst gilt beim nächsten Durchlauf „nichts @@ -609,27 +727,37 @@ export async function lsTick(client) { if (stand.tageszeit != null) setLsTageszeit(s.id, stand.tageszeit); if (!kanal?.isTextBased()) continue; + + // 1) Karte und Preise 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: periodeLesen(getSetting('ls_period')), // nach dem Weiterstellen neu gelesen - sorten: String(getSetting('ls_crops') ?? '').split(',').map((s) => s.trim()).filter(Boolean), + sorten: String(getSetting('ls_crops') ?? '').split(',').map((x) => x.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; - } + // Beim Bearbeiten muss der alte Anhang weg, sonst hängen zwei + // Karten an der Nachricht und Discord zeigt die alte. + const karteId = await pflegen(kanal, alt?.message_id, { ...payload, attachments: [] }, payload); + if (karteId) setLsMessage(s.id, karteId); + + // 2) Ein Embed je Hof, ebenfalls bearbeitet statt neu gepostet. + // Kam die Fahrzeugdatei nicht an, bleibt alles stehen, wie es ist: + // lieber einen Durchlauf lang alte Zahlen als ein Doppelpost. + if (fahrzeuge == null) continue; + const hofIds = hoefeImSpiel(stand, fahrzeuge); + const merker = { ...(alt?.hofMessages ?? {}) }; + for (const hof of hofIds) { + const inhalt = { embeds: [hofDetailEmbed(client, hof, stand, fahrzeuge, namen)] }; + const id = await pflegen(kanal, merker[hof], inhalt, inhalt); + if (id) merker[hof] = id; } - const neu = await kanal.send(payload); - setLsMessage(s.id, neu.id); + // Höfe, die es nicht mehr gibt, aus dem Merker werfen — sonst + // wächst er mit jedem gelöschten Hof weiter. + for (const hof of Object.keys(merker)) { + if (!hofIds.includes(Number(hof))) delete merker[hof]; + } + setLsHofMessages(s.id, merker); } catch (error) { console.error(`[ls-farm] ${s.name}:`, error.message); } diff --git a/src/db.js b/src/db.js index 202e830..991db27 100644 --- a/src/db.js +++ b/src/db.js @@ -1023,7 +1023,6 @@ 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')) ); `); @@ -1032,10 +1031,6 @@ 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') -`); // Letzte gesehene Tageszeit — daran erkennt das Modul den Tageswechsel { const cols = db.prepare('PRAGMA table_info(ls_state)').all().map((c) => c.name); @@ -1046,18 +1041,31 @@ const lsTagStmt = db.prepare(` ON CONFLICT(server_id) DO UPDATE SET tageszeit = excluded.tageszeit `); export const setLsTageszeit = (serverId, ms) => lsTagStmt.run(serverId, ms); + +// Eine gepflegte Nachricht je Hof: { "1": "messageId", "2": … } +{ + const cols = db.prepare('PRAGMA table_info(ls_state)').all().map((c) => c.name); + if (!cols.includes('hof_messages')) db.exec(`ALTER TABLE ls_state ADD COLUMN hof_messages TEXT NOT NULL DEFAULT '{}'`); +} +const lsHofStmt = db.prepare(` + INSERT INTO ls_state (server_id, hof_messages) VALUES (?, ?) + ON CONFLICT(server_id) DO UPDATE SET hof_messages = excluded.hof_messages +`); +export const setLsHofMessages = (serverId, map) => lsHofStmt.run(serverId, JSON.stringify(map)); 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 }; + let hofMessages = {}; + try { + const roh = JSON.parse(r.hof_messages || '{}'); + if (roh && typeof roh === 'object' && !Array.isArray(roh)) hofMessages = roh; + } catch { /* kaputt gespeichert — dann werden die Embeds neu gepostet */ } + return { ...r, hofMessages }; }; 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; diff --git a/src/modules.js b/src/modules.js index 9fe27e0..32e9518 100644 --- a/src/modules.js +++ b/src/modules.js @@ -248,12 +248,11 @@ export const MODULES = [ }, { id: 'ls_farm', group: 'gaming', name: 'LS — Höfe & Preise', nav: true, - 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".', + desc: 'Landwirtschafts-Simulator: Besitzkarte, Preiskurven übers Jahr und je Hof ein ' + + 'Embed mit Land, Fuhrpark und was in die Werkstatt muss. 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: 'Aktueller Monat', art: 'text', hint: 'z. B. „September" oder „9" — steht nicht im Feed, deshalb einmal eintragen. Danach zählt der Bot bei jedem Tageswechsel selbst weiter. Leer 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.' },