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
+196 -68
View File
@@ -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(/<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 ────────────────────────────────────────────────────────────────
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);
}