Ein Embed je Hof, das sich selbst aktualisiert
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Der Fuhrpark stand bisher nur als Gesamtzahl im Karten-Embed. Jetzt bekommt
jeder Hof eine eigene Nachricht im selben Kanal, die bei jedem Durchlauf
bearbeitet statt neu gepostet wird: Land, Fahrzeuge mit Wert und
Betriebsstunden, was in die Werkstatt muss, was gewaschen gehoert und was
geladen ist.

Die Fahrzeuge kommen aus vehicles.xml, blockweise gelesen — Verschleiss und
Schmutz stehen in Kindelementen und gehoeren sonst dem falschen Fahrzeug.
Gemietete Missionsfahrzeuge bleiben draussen, sie verfaelschen Anzahl und
Wert. Faellt die Datei einmal aus, bleiben die Embeds stehen, statt dass ein
Hof verschwindet und gleich darauf doppelt gepostet wird.

Damit tauchen auch Hoefe auf, die noch kein Land gekauft haben: die Liste der
Hoefe kommt aus Parzellen und Fuhrpark zusammen.

Dazu raus, was nur den Kanal vollgeschrieben hat: die Meldung zum
Monatswechsel und die zum Landkauf. Der Monat wird still weitergestellt — er
steht ohnehin im Embed. Damit faellt der Ereignis-Kanal weg und mit ihm die
gespeicherte Besitzliste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 14:53:02 +02:00
co-authored by Claude Opus 5
parent 0875dff127
commit 9b681754fa
5 changed files with 265 additions and 82 deletions
+1 -1
View File
@@ -101,7 +101,7 @@ auf nichts mehr: keine Posts, keine Hintergrund-Prüfungen.
| Funktion | Was sie macht | | 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 | | 🎮 **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 ### Server & Technik
+49 -1
View File
@@ -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 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. 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 `<vehicle>`:
| 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: `<wearable damage>` (Verschleiss, 0…1), `<dirtNode amount>`
(Schmutz, 0…1) und `<unit fillType fillLevel>` 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 ## 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 **Welche Frucht auf einem Feld steht und wie weit sie ist.** Das liegt in
`densityMap_fruits.gdm`, einer Binärdatei ohne öffentliche Spezifikation, die `densityMap_fruits.gdm`, einer Binärdatei ohne öffentliche Spezifikation, die
nur im kompletten Savegame-ZIP steckt — und das lädt man über die nur im kompletten Savegame-ZIP steckt — und das lädt man über die
Admin-Oberfläche, nicht über den Code. 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 Info](https://www.farming-simulator.com/mod.php?mod_id=232903) und [Additional
Field Info](https://github.com/yumi-modding/FS25_additionalFieldInfo) berechnen Field Info](https://github.com/yumi-modding/FS25_additionalFieldInfo) berechnen
genau diese Werte, sind aber reine HUD-Overlays: Lua, `modDesc.xml`, ein Icon — genau diese Werte, sind aber reine HUD-Overlays: Lua, `modDesc.xml`, ein Icon —
+195 -67
View File
@@ -15,7 +15,7 @@ import { brandEmbed } from '../embeds.js';
import { brandFooter } from '../runtime-settings.js'; import { brandFooter } from '../runtime-settings.js';
import { moduleEnabled } from '../modules.js'; import { moduleEnabled } from '../modules.js';
import { tuning, tuningMs, everyTuned } from '../tuning.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 */ /** Alle Attribute eines XML-Tags als Objekt */
const attrsVon = (roh) => 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(/<vehicle ([^>]*)>([\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(/<wearable damage="([\d.]+)"/)?.[1] ?? 0),
dreck: Number(rumpf.match(/<dirtNode amount="([\d.]+)"/)?.[1] ?? 0),
ladung: [...rumpf.matchAll(/<unit [^>]*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 ──────────────────────────────────────────────────────────────── // ── Preise ────────────────────────────────────────────────────────────────
export const PERIODEN = [ export const PERIODEN = [
@@ -353,6 +409,89 @@ export function hofEmbed(client, stand, namen = {}) {
return embed; 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 */ /** Karte + Embed als fertige Nachricht */
export async function hofNachricht(client, stand, bildB64, namen = {}, breite = 1024) { export async function hofNachricht(client, stand, bildB64, namen = {}, breite = 1024) {
const embed = hofEmbed(client, stand, namen); const embed = hofEmbed(client, stand, namen);
@@ -402,8 +541,10 @@ export const SORTEN_NAMEN = {
OILRADISH: 'Ölrettich', STRAW: 'Stroh', CHAFF: 'Häcksel', OILRADISH: 'Ölrettich', STRAW: 'Stroh', CHAFF: 'Häcksel',
SILAGE: 'Silage', FORAGE: 'Mischration', HAY: 'Heu', SILAGE: 'Silage', FORAGE: 'Mischration', HAY: 'Heu',
// Tierisches // 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', MANURE: 'Mist', LIQUIDMANURE: 'Gülle', DIGESTATE: 'Gärrest',
COMPOST: 'Kompost', MINERAL_FEED: 'Mineralfutter', PIGFOOD: 'Schweinefutter',
// Betriebsstoffe // Betriebsstoffe
SEEDS: 'Saatgut', FERTILIZER: 'Dünger', LIQUIDFERTILIZER: 'Flüssigdünger', SEEDS: 'Saatgut', FERTILIZER: 'Dünger', LIQUIDFERTILIZER: 'Flüssigdünger',
HERBICIDE: 'Herbizid', LIME: 'Kalk', WATER: 'Wasser', 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 stats = await holen(`${basis}/dedicated-server-stats.xml?code=${code}`, timeoutMs);
const stand = statsLesen(stats); const stand = statsLesen(stats);
// Karte und Preise sind Beiwerk: fehlen sie, bleibt der Rest brauchbar // Karte, Preise und Fuhrpark sind Beiwerk: fehlt eins, bleibt der Rest
const [bild, preise] = await Promise.all([ // 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) holen(`${basis}/dedicated-server-stats-map.jpg?code=${code}&quality=85&size=${kartenBreite}`, timeoutMs, false)
.then((b) => b.toString('base64')).catch(() => null), .then((b) => b.toString('base64')).catch(() => null),
holen(`${basis}/dedicated-server-savegame.html?file=economy&code=${code}`, timeoutMs) holen(`${basis}/dedicated-server-savegame.html?file=economy&code=${code}`, timeoutMs)
.then(preiseLesen).catch(() => []), .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. * Gibt die ID zurück, wenn eine neue entstanden ist — beim Bearbeiten `null`,
* Beim allerersten Durchlauf gibt es nichts zu melden — sonst käme beim Start * dann bleibt der Merker, wie er ist.
* des Bots eine Meldung pro bereits gekaufter Parzelle.
*/ */
export function besitzDiff(vorher, nachher) { async function pflegen(kanal, id, beimBearbeiten, beimSenden) {
if (!vorher) return []; if (id) {
const alt = new Map(vorher.map((p) => [p.id, p.hof])); const vorhanden = await kanal.messages.fetch(id).catch(() => null);
const raus = []; if (vorhanden) {
for (const p of nachher) { await vorhanden.edit(beimBearbeiten);
const vor = alt.get(p.id); return null;
if (vor === undefined || vor === p.hof) continue;
raus.push({ parzelle: p, von: vor, nach: p.hof });
} }
return raus; }
} const neu = await kanal.send(beimSenden);
return neu.id;
/** 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}`;
} }
/** /**
@@ -564,44 +702,24 @@ export async function lsTick(client) {
const channelId = getSetting('ls_channel_id'); const channelId = getSetting('ls_channel_id');
const kanal = channelId ? await client.channels.fetch(channelId).catch(() => null) : null; 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; const breite = Number(tuning('ls_map_size')) || 1024;
for (const s of server) { for (const s of server) {
try { try {
const { stand, bild, preise } = await lsAbfragen(s, { const { stand, bild, preise, fahrzeuge } = await lsAbfragen(s, {
timeoutMs: tuningMs.seconds('monitor_timeout'), timeoutMs: tuningMs.seconds('monitor_timeout'),
kartenBreite: breite, kartenBreite: breite,
}); });
const namen = hofNamen(getSetting('ls_farm_names')); const namen = hofNamen(getSetting('ls_farm_names'));
// Besitzwechsel melden, bevor der neue Stand gespeichert wird
const alt = lsState(s.id); 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 // Neuer Spieltag = neuer Monat. Still weitergestellt, nicht
// eine Jahreszeit eingetragen ist — ohne Ausgangspunkt liesse sich // gemeldet: die Preisliste braucht den Monat, aber eine Nachricht
// nur raten, und ein geratener Monat ist schlimmer als keiner. // dafür wäre einmal am Tag Lärm für eine Zahl, die ohnehin im
const gesetzt = periodeLesen(getSetting('ls_period')); // Embed steht.
if (tagUmgeschlagen(alt?.tageszeit, stand.tageszeit)) { if (tagUmgeschlagen(alt?.tageszeit, stand.tageszeit)) {
const naechste = naechstePeriode(gesetzt); const naechste = naechstePeriode(getSetting('ls_period'));
if (naechste) { if (naechste) setSetting('ls_period', 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(() => {});
}
} }
// Eine Antwort ohne Uhrzeit darf den letzten guten Stand nicht // Eine Antwort ohne Uhrzeit darf den letzten guten Stand nicht
// überschreiben — sonst gilt beim nächsten Durchlauf „nichts // ü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 (stand.tageszeit != null) setLsTageszeit(s.id, stand.tageszeit);
if (!kanal?.isTextBased()) continue; if (!kanal?.isTextBased()) continue;
// 1) Karte und Preise
const payload = await hofNachricht(client, stand, bild, namen, breite); 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) { if (preise.length > 0) {
payload.embeds.push(preisEmbed(client, preise, { payload.embeds.push(preisEmbed(client, preise, {
jetzt: periodeLesen(getSetting('ls_period')), // nach dem Weiterstellen neu gelesen 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; // Beim Bearbeiten muss der alte Anhang weg, sonst hängen zwei
if (alteId) { // Karten an der Nachricht und Discord zeigt die alte.
const vorhanden = await kanal.messages.fetch(alteId).catch(() => null); const karteId = await pflegen(kanal, alt?.message_id, { ...payload, attachments: [] }, payload);
if (vorhanden) { if (karteId) setLsMessage(s.id, karteId);
// Beim Bearbeiten muss der alte Anhang weg, sonst hängen
// zwei Karten an der Nachricht und Discord zeigt die alte. // 2) Ein Embed je Hof, ebenfalls bearbeitet statt neu gepostet.
await vorhanden.edit({ ...payload, attachments: [] }); // Kam die Fahrzeugdatei nicht an, bleibt alles stehen, wie es ist:
continue; // 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;
} }
// 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];
} }
const neu = await kanal.send(payload); setLsHofMessages(s.id, merker);
setLsMessage(s.id, neu.id);
} catch (error) { } catch (error) {
console.error(`[ls-farm] ${s.name}:`, error.message); console.error(`[ls-farm] ${s.name}:`, error.message);
} }
+17 -9
View File
@@ -1023,7 +1023,6 @@ db.exec(`
CREATE TABLE IF NOT EXISTS ls_state ( CREATE TABLE IF NOT EXISTS ls_state (
server_id INTEGER PRIMARY KEY, server_id INTEGER PRIMARY KEY,
message_id TEXT, message_id TEXT,
besitz TEXT,
geprueft TEXT NOT NULL DEFAULT (datetime('now')) geprueft TEXT NOT NULL DEFAULT (datetime('now'))
); );
`); `);
@@ -1032,10 +1031,6 @@ const lsMessageStmt = db.prepare(`
INSERT INTO ls_state (server_id, message_id) VALUES (?, ?) INSERT INTO ls_state (server_id, message_id) VALUES (?, ?)
ON CONFLICT(server_id) DO UPDATE SET message_id = excluded.message_id 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 // Letzte gesehene Tageszeit — daran erkennt das Modul den Tageswechsel
{ {
const cols = db.prepare('PRAGMA table_info(ls_state)').all().map((c) => c.name); 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 ON CONFLICT(server_id) DO UPDATE SET tageszeit = excluded.tageszeit
`); `);
export const setLsTageszeit = (serverId, ms) => lsTagStmt.run(serverId, ms); 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 = ?'); const lsVergessenStmt = db.prepare('DELETE FROM ls_state WHERE server_id = ?');
export const lsState = (serverId) => { export const lsState = (serverId) => {
const r = lsStateStmt.get(serverId); const r = lsStateStmt.get(serverId);
if (!r) return null; if (!r) return null;
// Kaputtes JSON darf den Durchlauf nicht anhalten — dann gilt eben // Kaputtes JSON darf den Durchlauf nicht anhalten — dann gilt eben
// "noch nichts bekannt" und beim nächsten Mal steht wieder etwas drin. // "noch nichts bekannt" und beim nächsten Mal steht wieder etwas drin.
let besitz = null; let hofMessages = {};
try { besitz = r.besitz ? JSON.parse(r.besitz) : null; } catch { besitz = null; } try {
return { ...r, besitz }; 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 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; export const forgetLsState = (serverId) => lsVergessenStmt.run(serverId).changes > 0;
// Erreichbarkeit stundenweise zusammengefasst — für den Uptime-Balken. // Erreichbarkeit stundenweise zusammengefasst — für den Uptime-Balken.
// Die Rohdaten (alle 2 min) wären über eine Woche rund 5000 Zeilen pro Server; // Die Rohdaten (alle 2 min) wären über eine Woche rund 5000 Zeilen pro Server;
+2 -3
View File
@@ -248,12 +248,11 @@ export const MODULES = [
}, },
{ {
id: 'ls_farm', group: 'gaming', name: 'LS — Höfe & Preise', nav: true, id: 'ls_farm', group: 'gaming', name: 'LS — Höfe & Preise', nav: true,
desc: 'Landwirtschafts-Simulator: Besitzkarte mit Höfen, Fahrzeugen und Spielern, ' desc: 'Landwirtschafts-Simulator: Besitzkarte, Preiskurven übers Jahr und je Hof ein '
+ 'dazu die Preiskurven übers Jahr. Braucht einen LS-Server mit Zugangs-Code unter „Game-Server".', + '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', 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' } }], requires: [{ setting: 'ls_channel_id', label: 'Karten-Kanal', create: { name: 'ls-hoefe', topic: 'Besitzkarte und Preise vom LS-Server' } }],
fields: [ 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_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_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.' }, { setting: 'ls_crops', label: 'Sorten in der Preisliste', art: 'text', hint: 'kommagetrennt, leer = die üblichen. Ohne Auswahl verdrängt Spargel jede echte Feldfrucht.' },