diff --git a/docs/ls-feed.md b/docs/ls-feed.md index d10c7a0..eced3eb 100644 --- a/docs/ls-feed.md +++ b/docs/ls-feed.md @@ -83,10 +83,24 @@ einer davon Geld kostet: - [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. +**Der aktuelle Monat.** Er steht in keinem Endpunkt. + +`playTime` aus dem Savegame taugt nicht dafür: die Datei wird nur beim +Autosave geschrieben und hinkt bis zu einer Viertelstunde hinterher. Über zwei +Stunden gemessen stieg `playTime` um 50, während die Spieluhr 18,7 Stunden +weiterlief — daraus lässt sich nichts rechnen. + +`dayTime` in der Statusabfrage ist dagegen live und zählt Millisekunden seit +Tagesbeginn. Bei `plannedDaysPerPeriod = 1` ist jeder Rücksprung um Mitternacht +ein Monatswechsel. Also: Monat einmal im Panel eintragen, danach zählt der Bot +an dieser Uhr selbst weiter. Steht der Server leer, steht auch die Spielzeit — +und der Monat bleibt richtigerweise stehen. + +Die zwölf Perioden sind übrigens schlicht Monate: `EARLY_SPRING` ist März, +`EARLY_AUTUMN` September. + +**`growthMode`** sagt, ob überhaupt etwas wächst (`2` = pausiert). Auf die +Preise hat das keinen Einfluss — die Kurve hängt am Monat, nicht am Wachstum. ## Fallstricke diff --git a/src/bot/ls-farm.js b/src/bot/ls-farm.js index 090eeda..cc54576 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, listGameservers, lsState, setLsMessage, setLsBesitz } from '../db.js'; +import { getSetting, setSetting, listGameservers, lsState, setLsMessage, setLsBesitz, setLsTageszeit } from '../db.js'; /** Alle Attribute eines XML-Tags als Objekt */ const attrsVon = (roh) => @@ -44,6 +44,10 @@ export function statsLesen(xml) { // Ohne mapSize lässt sich nichts zeichnen — dann lieber gar keine Karte groesse: Number(server.mapSize) || null, version: server.version ?? null, + // Millisekunden seit Tagesbeginn. Die einzige Uhr im Feed, die wirklich + // live ist — playTime aus dem Savegame wird nur beim Autosave neu + // geschrieben und hinkt bis zu einer Viertelstunde hinterher. + tageszeit: Number(server.dayTime) || 0, parzellen: tags(xml, 'Farmland').map((f) => ({ id: Number(f.id), name: f.name ?? String(f.id), hof: Number(f.owner), // 0 = noch zu kaufen @@ -103,11 +107,56 @@ export const PERIODEN = [ 'EARLY_AUTUMN', 'MID_AUTUMN', 'LATE_AUTUMN', 'EARLY_WINTER', 'MID_WINTER', 'LATE_WINTER', ]; +/** + * Die zwölf Perioden sind schlicht die Monate — das Spiel zeigt sie auch so + * an. `EARLY_SPRING` ist März, und ab da geht es der Reihe nach weiter. + * Im Panel und im Embed steht deshalb der Monat und nicht der Schlüssel. + */ 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', + EARLY_SPRING: 'März', MID_SPRING: 'April', LATE_SPRING: 'Mai', + EARLY_SUMMER: 'Juni', MID_SUMMER: 'Juli', LATE_SUMMER: 'August', + EARLY_AUTUMN: 'September', MID_AUTUMN: 'Oktober', LATE_AUTUMN: 'November', + EARLY_WINTER: 'Dezember', MID_WINTER: 'Januar', LATE_WINTER: 'Februar', +}; + +/** + * Was im Panel steht, in eine Periode übersetzen. + * + * Nimmt den Monatsnamen („September", „sept"), die Monatszahl und weiterhin + * den rohen Schlüssel. Niemand soll `EARLY_AUTUMN` nachschlagen müssen, um zu + * sagen, dass gerade September ist. + * + * @returns {string|null} Periodenschlüssel oder null bei Unsinn + */ +export function periodeLesen(text) { + const roh = String(text ?? '').trim(); + if (!roh) return null; + const gross = roh.toUpperCase(); + if (PERIODEN.includes(gross)) return gross; + + // Monatszahl: 1 = Januar, nicht 1 = erste Periode. Wer „9" schreibt, meint + // September und nicht November. + if (/^\d{1,2}$/.test(roh)) { + const monat = Number(roh); + if (monat < 1 || monat > 12) return null; + return PERIODEN.find((p) => MONAT_NUMMER[p] === monat) ?? null; + } + // Name, auch abgekürzt: "sept" trifft September, "mä" trifft März + const klein = roh.toLowerCase().replace(/[^a-zäöü]/g, ''); + const treffer = PERIODEN.filter((p) => { + const monat = PERIODE_LABEL[p].toLowerCase(); + return monat.startsWith(klein) || klein.startsWith(monat); + }); + // "ju" passt auf Juni und Juli — dann lieber nichts als das falsche + return treffer.length === 1 ? treffer[0] : null; +} + +/** Periodenschlüssel → Monatszahl im Kalender */ +const MONAT_NUMMER = { + EARLY_SPRING: 3, MID_SPRING: 4, LATE_SPRING: 5, + EARLY_SUMMER: 6, MID_SUMMER: 7, LATE_SUMMER: 8, + EARLY_AUTUMN: 9, MID_AUTUMN: 10, LATE_AUTUMN: 11, + EARLY_WINTER: 12, MID_WINTER: 1, LATE_WINTER: 2, }; /** @@ -286,7 +335,7 @@ export function hofEmbed(client, stand, namen = {}) { 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`); + .map(([art, menge]) => `**${sortenName(art)}** ${Math.round(menge).toLocaleString('de-DE')} l`); embed.addFields({ name: 'In den Fahrzeugen', value: zeilen.join(' · ').slice(0, 1000) }); } return embed; @@ -315,8 +364,48 @@ export const STANDARD_SORTEN = [ 'GRAPE', 'OLIVE', 'MILK', 'WOOL', 'EGG', 'SILAGE', 'WOODCHIPS', ]; -/** "SUGARBEET_CUT" → "sugarbeet cut" */ -const sortenName = (art) => art.toLowerCase().replace(/_/g, ' '); +/** + * Deutsche Namen für das, was der Feed als `WHEAT` oder `CLOVER_WINDROW` + * liefert. Bewusst nicht vollständig — 371 Sorten übersetzt niemand von Hand, + * und was fehlt, fällt lesbar zurück statt zu verschwinden. + */ +export const SORTEN_NAMEN = { + // Feldfrüchte + WHEAT: 'Weizen', BARLEY: 'Gerste', OAT: 'Hafer', RYE: 'Roggen', + TRITICALE: 'Triticale', SPELT: 'Dinkel', CANOLA: 'Raps', MAIZE: 'Mais', + SUNFLOWER: 'Sonnenblumen', SOYBEAN: 'Sojabohnen', POTATO: 'Kartoffeln', + SUGARBEET: 'Zuckerrüben', SUGARBEET_CUT: 'Zuckerrübenschnitzel', + SUGARCANE: 'Zuckerrohr', COTTON: 'Baumwolle', SORGHUM: 'Sorghum', + RICE: 'Reis', RICELONGGRAIN: 'Langkornreis', MILLET: 'Hirse', + GRAPE: 'Trauben', OLIVE: 'Oliven', POPLAR: 'Pappel', + // Gemüse + CARROT: 'Karotten', PARSNIP: 'Pastinaken', BEETROOT: 'Rote Beete', + SPINACH: 'Spinat', PEA: 'Erbsen', GREENBEAN: 'Buschbohnen', + LETTUCE: 'Kopfsalat', TOMATO: 'Tomaten', STRAWBERRY: 'Erdbeeren', + ASPARAGUSWHITE: 'Spargel, weiß', ASPARAGUSGREEN: 'Spargel, grün', + // Futter und Schnittgut + GRASS: 'Gras', GRASS_WINDROW: 'Gras (Schwad)', DRYGRASS_WINDROW: 'Heu', + CLOVER: 'Rotklee', CLOVER_WINDROW: 'Rotklee (Schwad)', + ALFALFA: 'Luzerne', ALFALFA_WINDROW: 'Luzerne (Schwad)', + OILRADISH: 'Ölrettich', STRAW: 'Stroh', CHAFF: 'Häcksel', + SILAGE: 'Silage', FORAGE: 'Mischration', HAY: 'Heu', + // Tierisches + MILK: 'Milch', WOOL: 'Wolle', EGG: 'Eier', HONEY: 'Honig', + MANURE: 'Mist', LIQUIDMANURE: 'Gülle', DIGESTATE: 'Gärrest', + // Betriebsstoffe + SEEDS: 'Saatgut', FERTILIZER: 'Dünger', LIQUIDFERTILIZER: 'Flüssigdünger', + HERBICIDE: 'Herbizid', LIME: 'Kalk', WATER: 'Wasser', + DIESEL: 'Diesel', DEF: 'AdBlue', ELECTRICCHARGE: 'Strom', + WOODCHIPS: 'Hackschnitzel', WOOD: 'Holz', STONE: 'Steine', +}; + +/** `SUGARBEET_CUT` → „Zuckerrübenschnitzel"; Unbekanntes bleibt lesbar */ +export function sortenName(art) { + const schluessel = String(art ?? '').toUpperCase(); + if (SORTEN_NAMEN[schluessel]) return SORTEN_NAMEN[schluessel]; + // Kein Treffer: wenigstens keine Versalien und keine Unterstriche + return schluessel.toLowerCase().replace(/_/g, ' '); +} /** Preis-Embed: was wann am meisten bringt */ export function preisEmbed(client, preise, { jetzt = null, anzahl = 12, sorten = null } = {}) { @@ -487,13 +576,30 @@ export async function lsTick(client) { } 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')); + 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(() => {}); + } + } + setLsTageszeit(s.id, stand.tageszeit); + 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, + jetzt: periodeLesen(getSetting('ls_period')), // nach dem Weiterstellen neu gelesen sorten: String(getSetting('ls_crops') ?? '').split(',').map((s) => s.trim()).filter(Boolean), })); } @@ -515,6 +621,27 @@ export async function lsTick(client) { } } +/** + * Ist seit der letzten Abfrage ein Spieltag vorbei? + * + * `dayTime` zählt Millisekunden seit Tagesbeginn und springt um Mitternacht + * zurück. Läuft ein Monat über einen Tag — bei Farming Simulator die übliche + * Einstellung `plannedDaysPerPeriod = 1` —, ist jeder dieser Sprünge ein + * Monatswechsel. + * + * Bewusst über die Uhr und nicht über die Wanduhr: steht der Server leer, + * steht auch die Spielzeit, und dann darf der Monat nicht weiterlaufen. + */ +export const tagUmgeschlagen = (vorher, jetzt) => + Number.isFinite(vorher) && vorher > 0 && jetzt < vorher; + +/** Nächste Jahreszeit; unbekannte Eingabe ergibt null statt eines Ratewerts */ +export function naechstePeriode(periode) { + const jetzt = periodeLesen(periode); + if (!jetzt) return null; + return PERIODEN[(PERIODEN.indexOf(jetzt) + 1) % PERIODEN.length]; +} + /** "1=Alexander, 2=Timo" → { 1: 'Alexander', 2: 'Timo' } */ export function hofNamen(roh) { const raus = {}; diff --git a/src/db.js b/src/db.js index a931924..771f71a 100644 --- a/src/db.js +++ b/src/db.js @@ -1007,6 +1007,16 @@ 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); + if (!cols.includes('tageszeit')) db.exec('ALTER TABLE ls_state ADD COLUMN tageszeit INTEGER'); +} +const lsTagStmt = db.prepare(` + INSERT INTO ls_state (server_id, tageszeit) VALUES (?, ?) + ON CONFLICT(server_id) DO UPDATE SET tageszeit = excluded.tageszeit +`); +export const setLsTageszeit = (serverId, ms) => lsTagStmt.run(serverId, ms); const lsVergessenStmt = db.prepare('DELETE FROM ls_state WHERE server_id = ?'); export const lsState = (serverId) => { const r = lsStateStmt.get(serverId); diff --git a/src/modules.js b/src/modules.js index 74b344c..69bebd6 100644 --- a/src/modules.js +++ b/src/modules.js @@ -251,7 +251,7 @@ export const MODULES = [ 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_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.' }, ], }, diff --git a/src/web/api.js b/src/web/api.js index 3843bb2..3757acc 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -882,6 +882,17 @@ ${rssItems} function currentSettings() { return { + // Alles, was die Modulliste kennt, zuerst — die Einträge darunter + // überschreiben es, wo es Standardwerte oder Env-Rückfälle gibt. + // + // Ohne das gilt für jedes neue Modul: gespeichert wird, angezeigt + // nicht. Das Feld steht leer da, obwohl der Wert in der Datenbank + // liegt, und der Haken „einsatzbereit" widerspricht dem Formular. + ...Object.fromEntries( + Object.entries(moduleSettingKeys()) + .filter(([art]) => art !== 'schalter') // Schalter sind Ja/Nein, siehe unten + .flatMap(([, keys]) => keys.map((k) => [k, getSetting(k) ?? ''])) + ), commit_channel_id: commitChannelId(), devlog_channel_id: devlogChannelId(), release_channel_id: releaseChannelId() ?? '',