diff --git a/README.md b/README.md index 3051fb6..327d9a2 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), 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)) | +| 🚜 **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. Dazu die **Modliste**: der Bot meldet, was neu, aktualisiert oder entfernt wurde, und auf `/mods/` steht jeder Mod einzeln zum Laden — durch den Bot hindurch, damit der Zugangs-Code des Spielservers nicht in einem Link landet. **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 e4ad325..9e72575 100644 --- a/docs/ls-feed.md +++ b/docs/ls-feed.md @@ -16,6 +16,7 @@ der Hoster ausgibt; der Port ist **nicht** immer 8080. | `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 | +| `mods/.zip` | 0,07 – 1381 MB | die Mod-Datei selbst, **nicht** unter `/feed` | Andere Dateinamen antworten mit `204` und leerem Rumpf. Geprüft und **nicht** verfügbar: `farms`, `farmland`, `farmlands`, `fields`, `field`, `items`, @@ -61,6 +62,41 @@ 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. +## Mods + +Die Statusabfrage listet jeden Mod mit `name`, `author`, `version` und `hash`. +Der Name ist der Dateiname ohne `.zip` — damit lässt sich jede Datei einzeln +laden, ohne das Sammelpaket über alles zu ziehen. Auf dem Testserver: **109 von +110 Mods abrufbar, zusammen 4,06 GB.** + +Der `hash` ist GIANTS' eigener Fingerabdruck. Was er ist, steht nirgends, und +gemessen ist er **weder** die MD5 der ausgelieferten Zip **noch** die der +`modDesc.xml` darin. Als Prüfsumme für heruntergeladene Dateien taugt er also +nicht — als Melder „hier hat sich etwas geändert" schon. + +Was beim Bauen wichtig ist: + +| | | +|---|---| +| `HEAD` | **501** — nicht unterstützt, die Grösse gibt es nur per GET | +| `Range` | wird ignoriert (200 statt 206) — **kein Fortsetzen** | +| `Last-Modified` | vorhanden, aber der Zeitpunkt des Hochladens, nicht der Version | +| `ETag` | keiner | +| Zwei Abrufe | byte-gleich — statische Dateien, kein Neupacken | + +Kein Fortsetzen ist der wunde Punkt: die grösste Datei auf dem Testserver ist +**1,38 GB**. Bricht der Download ab, fängt er von vorn an. Wer das besser +machen will, muss selbst zwischenspeichern. + +**Gekaufte Inhalte antworten mit 403.** `pdlc_strawHarvestPack` ist das +einzige der 110, das der Server nicht herausgibt — richtig so, es ist bezahltes +DLC und kommt über Steam. + +**Der Dateiname muss nichts mit dem Mod zu tun haben.** Auf dem Testserver +heisst ein Eintrag `all_mods_download`, ist aber „Bale Autoload Pack" — da hat +jemand den Sammel-Download des Hosters in den Mod-Ordner gelegt. Das Spiel +lädt ihn anstandslos, aber jeder Abgleich über den Namen stolpert darüber. + ## Was in der Fahrzeugdatei steht `file=vehicles` ist die ergiebigste der drei Savegame-Dateien. Je ``: diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b15dde3..94ff626 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -11,6 +11,7 @@ import Commits from './pages/Commits.jsx'; import Settings from './pages/Settings.jsx'; import Profil from './pages/Profil.jsx'; import Server from './pages/Server.jsx'; +import Mods from './pages/Mods.jsx'; import Status from './pages/Status.jsx'; import Home from './pages/Home.jsx'; import DevlogDetail from './pages/DevlogDetail.jsx'; @@ -219,6 +220,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/locales/de.js b/frontend/src/locales/de.js index c34da50..6e96ad1 100644 --- a/frontend/src/locales/de.js +++ b/frontend/src/locales/de.js @@ -165,6 +165,19 @@ export default { 'server.offline': 'Offline', 'server.waiting': 'Warte auf Check', 'server.players24h': 'Spielerzahl · letzte 24h', + // ── Mods eines LS-Servers ───────────────────────── + 'mods.tag': '// Modliste', + 'mods.title': 'Mods', + 'mods.subtitle': 'Alle Mods dieses Servers einzeln zum Laden — statt eines Pakets über alles.', + 'mods.error': 'Modliste konnte nicht geladen werden.', + 'mods.none': 'Für diesen Server ist noch keine Modliste bekannt.', + 'mods.noHit': 'Nichts gefunden für „%s".', + 'mods.count': 'Mods', + 'mods.search': 'Mod suchen …', + 'mods.download': 'Laden', + 'mods.hint': 'Die Dateien gehören in deinen Mod-Ordner: Dokumente\\My Games\\FarmingSimulator2025\\mods. Gekaufte Inhalte (DLC) fehlen hier — die kommen über Steam.', + 'mods.back': '← Zurück zu den Servern', + 'server.modList': 'Mods ansehen', 'server.peak': 'Peak', 'server.uptime': 'Uptime', 'server.measuredSince': 'misst seit %s', @@ -306,7 +319,7 @@ export default { 'commands.bdaySet': 'Trägt deinen Geburtstag ein. Der Bot gratuliert morgens und vergibt eine Rolle für den Tag.', 'commands.bdayDel': 'Trägt ihn wieder aus.', 'commands.remind': 'Erinnert dich per Direktnachricht — etwa „2h" oder „30m".', - 'commands.tag': 'Ruft einen gespeicherten Textbaustein ab, mit Vorschlägen beim Tippen.', + 'commands.cmdTag': 'Ruft einen gespeicherten Textbaustein ab, mit Vorschlägen beim Tippen.', 'commands.ping': 'Lebenszeichen des Bots.', 'commands.umfrage': 'Startet eine Abstimmung mit bis zu vier Antworten — Discord zählt selbst aus.', 'commands.warn': 'Verwarnt jemanden, schickt eine Nachricht und schreibt es ins Protokoll.', diff --git a/frontend/src/locales/en.js b/frontend/src/locales/en.js index b0c103f..b6e575f 100644 --- a/frontend/src/locales/en.js +++ b/frontend/src/locales/en.js @@ -164,6 +164,19 @@ export default { 'server.offline': 'Offline', 'server.waiting': 'Waiting for check', 'server.players24h': 'Player count · last 24h', + // ── Mods of an FS server ────────────────────────── + 'mods.tag': '// Mod list', + 'mods.title': 'Mods', + 'mods.subtitle': 'Every mod on this server, one by one — instead of one big bundle.', + 'mods.error': 'Could not load the mod list.', + 'mods.none': 'No mod list known for this server yet.', + 'mods.noHit': 'Nothing found for "%s".', + 'mods.count': 'mods', + 'mods.search': 'Search mods …', + 'mods.download': 'Download', + 'mods.hint': 'The files belong in your mod folder: Documents\\My Games\\FarmingSimulator2025\\mods. Purchased content (DLC) is not listed here — that comes via Steam.', + 'mods.back': '← Back to the servers', + 'server.modList': 'View mods', 'server.peak': 'Peak', 'server.uptime': 'Uptime', 'server.measuredSince': 'measuring since %s', @@ -395,7 +408,7 @@ export default { 'commands.bdaySet': 'Saves your birthday. The bot congratulates you in the morning and hands out a role for the day.', 'commands.bdayDel': 'Removes it again.', 'commands.remind': 'Reminds you by direct message — something like “2h” or “30m”.', - 'commands.tag': 'Fetches a saved text snippet, with suggestions as you type.', + 'commands.cmdTag': 'Fetches a saved text snippet, with suggestions as you type.', 'commands.ping': 'A sign of life from the bot.', 'commands.umfrage': 'Starts a vote with up to four answers — Discord does the counting.', 'commands.warn': 'Warns someone, sends them a message and writes it to the log.', diff --git a/frontend/src/pages/BotCommands.jsx b/frontend/src/pages/BotCommands.jsx index 2f95c09..f651155 100644 --- a/frontend/src/pages/BotCommands.jsx +++ b/frontend/src/pages/BotCommands.jsx @@ -12,7 +12,7 @@ const GROUPS = [ ['/geburtstag setzen tag:… monat:…', 'commands.bdaySet'], ['/geburtstag entfernen', 'commands.bdayDel'], ['/remind dauer:… text:…', 'commands.remind'], - ['/tag name:…', 'commands.tag'], + ['/tag name:…', 'commands.cmdTag'], ['/ping', 'commands.ping'], ['/umfrage frage:… a:… b:…', 'commands.umfrage'], ]], diff --git a/frontend/src/pages/Mods.jsx b/frontend/src/pages/Mods.jsx new file mode 100644 index 0000000..cea326e --- /dev/null +++ b/frontend/src/pages/Mods.jsx @@ -0,0 +1,130 @@ +// Modliste eines LS-Servers mit Download. +// +// Der Sinn ist nicht, 110 Mods hübsch zu zeigen, sondern die Frage „was fehlt +// mir noch" zu beantworten. Deshalb steht die Suche oben und jede Zeile hat +// ihren eigenen Download — statt eines Sammel-Links über alles, bei dem man +// 4 GB zieht, um zwei geänderte Dateien zu bekommen. +// +// Die Downloads laufen über den Bot, nicht direkt zum Spielserver: dessen +// Zugangs-Code öffnet auch den Spielstand und gehört nicht in einen Link. +import { useEffect, useMemo, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { apiGet } from '../api.js'; +import { SkeletonCards } from '../components/Skeleton.jsx'; +import { IconSearch, IconDownload, IconPackage } from '../icons.jsx'; +import { useT } from '../i18n.jsx'; + +/** 8.2 MB — Grössen kennt der Bot erst, wenn ein Mod einmal geladen wurde */ +function groesse(bytes) { + if (!bytes) return null; + return bytes >= 1073741824 + ? `${(bytes / 1073741824).toFixed(2)} GB` + : `${Math.round(bytes / 1048576)} MB`; +} + +export default function Mods() { + const { id } = useParams(); + const { t } = useT(); + const [daten, setDaten] = useState(null); + const [fehler, setFehler] = useState(false); + const [suche, setSuche] = useState(''); + + useEffect(() => { + setDaten(null); + setFehler(false); + apiGet(`/api/servers/${id}/mods`).then(setDaten).catch(() => setFehler(true)); + window.scrollTo(0, 0); + }, [id]); + + const treffer = useMemo(() => { + const mods = daten?.mods ?? []; + const q = suche.trim().toLowerCase(); + if (!q) return mods; + return mods.filter((m) => `${m.titel} ${m.name} ${m.autor}`.toLowerCase().includes(q)); + }, [daten, suche]); + + const bekannt = (daten?.mods ?? []).filter((m) => m.bytes); + const summe = bekannt.reduce((s, m) => s + m.bytes, 0); + + return ( + <> +
+
MODS
+
+ +
+ {fehler &&

{t('mods.error')}

} + {!fehler && !daten && } + + {daten && ( + <> +
+
+ + {daten.mods.length} {t('mods.count')} + {/* Nur zeigen, was wir wirklich wissen — sonst + liest sich eine halbe Summe wie die ganze. */} + {bekannt.length === daten.mods.length && summe > 0 && ( + · {groesse(summe)} + )} +
+ +
+ + {daten.mods.length === 0 &&

{t('mods.none')}

} + {daten.mods.length > 0 && treffer.length === 0 && ( +

{t('mods.noHit', suche)}

+ )} + +
    + {treffer.map((m) => ( +
  • +
    + {m.titel} + + {m.autor && <>{m.autor} · } + {m.name} + +
    + {m.version} + {groesse(m.bytes) ?? ''} + + + {t('mods.download')} + +
  • + ))} +
+ +

{t('mods.hint')}

+

+ {t('mods.back')} +

+ + )} +
+ + ); +} diff --git a/frontend/src/pages/Server.jsx b/frontend/src/pages/Server.jsx index 75ff5c8..fa0c940 100644 --- a/frontend/src/pages/Server.jsx +++ b/frontend/src/pages/Server.jsx @@ -2,6 +2,7 @@ // Live-Status, 24h-Spielerzahl-Verlauf, Uptime und Peak. // Daten sammelt der Server-Monitor alle 2 Minuten. import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { apiGet } from '../api.js'; import { SkeletonCards } from '../components/Skeleton.jsx'; import { IconGame, IconMapPin, IconClock, IconCheck, IconTeam, IconPackage, IconZap, IconDownload, IconArchive, IconLock } from '../icons.jsx'; @@ -189,7 +190,7 @@ function ServerCard({ s }) { - {(s.connect_url || s.mods_url || s.links?.length > 0 || s.address) && ( + {(s.connect_url || s.mods_url || s.mod_liste || s.links?.length > 0 || s.address) && (
{s.connect_url && ( @@ -201,6 +202,14 @@ function ServerCard({ s }) { {t('server.modsDownload')} )} + {/* Kennt der Bot die Modliste, führt der Weg auf die + eigene Seite: dort steht jeder Mod einzeln, statt dass + man für zwei Änderungen das ganze Paket zieht. */} + {s.mod_liste > 0 && ( + + {t('server.modList')} ({s.mod_liste}) + + )} {/* Frei eingetragene Links. `noreferrer` weil sie überall hinführen können — das ist ja der Sinn. */} {(s.links ?? []).map((l) => ( diff --git a/frontend/src/style.css b/frontend/src/style.css index e6159ec..103a8dc 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -2900,3 +2900,64 @@ button.with-icon, a.with-icon { justify-content: center; } font-family: var(--mono); font-size: .68rem; color: var(--muted2); letter-spacing: .04em; margin-top: .1rem; } + +/* ── MOD-LISTE (LS-Server) ─────────────────────────── */ +.mod-bar { + display: flex; justify-content: space-between; align-items: center; + gap: 1rem; flex-wrap: wrap; margin-bottom: 1.2rem; +} +.mod-count { + display: flex; align-items: center; gap: .5rem; + font-family: var(--mono); font-size: .72rem; letter-spacing: .12em; + text-transform: uppercase; color: var(--muted); +} +.mod-count strong { color: var(--neon); font-size: 1rem; font-weight: 400; } +.mod-sum { color: var(--muted2); } +.mod-search { + display: flex; align-items: center; gap: .5rem; flex: 1 1 240px; max-width: 340px; + border: 1px solid var(--border); background: var(--bg2); padding: .45rem .75rem; + color: var(--muted); +} +.mod-search:focus-within { border-color: var(--neon); } +.mod-search input { + flex: 1; min-width: 0; background: none; border: 0; outline: none; + color: var(--text); font-family: var(--body); font-size: 1rem; +} +.mod-list { list-style: none; display: flex; flex-direction: column; gap: 1px; } +.mod-row { + display: grid; align-items: center; gap: .9rem; + grid-template-columns: 1fr auto auto auto; + padding: .7rem .9rem; background: var(--bg2); + border-left: 2px solid transparent; +} +.mod-row:hover { background: var(--bg3); border-left-color: var(--neon); } +.mod-info { min-width: 0; } +.mod-title { display: block; color: var(--text); font-size: 1.05rem; } +.mod-meta { + display: block; font-family: var(--mono); font-size: .62rem; + color: var(--muted2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.mod-version, .mod-size { + font-family: var(--mono); font-size: .68rem; color: var(--muted); + white-space: nowrap; +} +/* Feste Spalte, damit die Downloadknöpfe untereinander stehen und nicht + je nach Dateigrösse wandern — auch dann, wenn die Grösse noch fehlt. */ +.mod-size { min-width: 4.5rem; text-align: right; } +.mod-dl { + display: inline-flex; align-items: center; gap: .4rem; + border: 1px solid var(--border); padding: .35rem .7rem; + font-family: var(--mono); font-size: .65rem; letter-spacing: .1em; + text-transform: uppercase; color: var(--muted); white-space: nowrap; + transition: border-color .15s, color .15s; +} +.mod-dl:hover { border-color: var(--neon); color: var(--neon); } +.mod-hint { color: var(--muted2); font-size: .9rem; margin-top: 1.2rem; } + +@media (max-width: 640px) { + .mod-row { grid-template-columns: 1fr auto; grid-template-areas: 'info dl' 'meta meta'; } + .mod-info { grid-area: info; } + .mod-dl { grid-area: dl; } + .mod-version, .mod-size { grid-area: meta; text-align: left; min-width: 0; } + .mod-version::after { content: ' ·'; } +} diff --git a/src/bot/ls-farm.js b/src/bot/ls-farm.js index 7fc411e..710ae6c 100644 --- a/src/bot/ls-farm.js +++ b/src/bot/ls-farm.js @@ -12,10 +12,10 @@ import { AttachmentBuilder, Events } from 'discord.js'; import sharp from 'sharp'; import { brandEmbed } from '../embeds.js'; -import { brandFooter } from '../runtime-settings.js'; +import { brandFooter, hubUrl } from '../runtime-settings.js'; import { moduleEnabled } from '../modules.js'; import { tuning, tuningMs, everyTuned } from '../tuning.js'; -import { getSetting, setSetting, listGameservers, lsState, setLsMessage, setLsTageszeit, setLsHofMessages } from '../db.js'; +import { getSetting, setSetting, listGameservers, lsState, setLsMessage, setLsTageszeit, setLsHofMessages, setLsMods } from '../db.js'; /** Alle Attribute eines XML-Tags als Objekt */ const attrsVon = (roh) => @@ -111,6 +111,131 @@ export function hofUebersicht(parzellen) { }; } +// ── Mods ────────────────────────────────────────────────────────────────── + +/** + * Die Modliste aus der Statusabfrage. + * + * Steht ganz oben im XML, noch vor den Parzellen. Je Mod gibt es `name` + * (= Dateiname ohne .zip), `author`, `version` und `hash` — GIANTS' eigenen + * Fingerabdruck. Was der genau ist, weiss ich nicht: gemessen ist er weder + * die MD5 der ausgelieferten Zip noch die der modDesc.xml. Für den Abgleich + * reicht, dass er sich mit dem Mod ändert. + */ +export function modsLesen(xml) { + const block = String(xml ?? '').match(/([\s\S]*?)<\/Mods>/); + if (!block) return []; + return [...block[1].matchAll(/]*)>([^<]*)<\/Mod>/g)].map(([, kopf, titel]) => { + const a = attrsVon(kopf); + return { + name: a.name ?? '', + titel: entschaerft(titel.trim()) || a.name || '?', + autor: entschaerft(a.author ?? ''), + version: a.version ?? '', + hash: a.hash ?? '', + }; + }).filter((m) => m.name); +} + +/** &#93; und Konsorten aus dem Feed zurückübersetzen */ +function entschaerft(text) { + return String(text) + .replaceAll('&', '&') + .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) + .replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); +} + +/** + * Was hat sich an der Modliste geändert? + * + * Beim ersten Durchlauf gibt es nichts zu melden — sonst käme beim Botstart + * eine Meldung über 110 „neue" Mods. + * + * Geändert heisst: andere Version **oder** anderer Hash. Der Hash allein + * reicht nicht, weil manche Modder nachbessern, ohne die Version zu erhöhen — + * und die Version allein reicht auch nicht, aus demselben Grund. + */ +export function modsDiff(vorher, jetzt) { + if (!Array.isArray(vorher) || vorher.length === 0) return null; + const alt = new Map(vorher.map((m) => [m.name, m])); + const neu = new Map(jetzt.map((m) => [m.name, m])); + const geaendert = []; + for (const m of jetzt) { + const v = alt.get(m.name); + if (v && (v.version !== m.version || v.hash !== m.hash)) { + geaendert.push({ ...m, vorher: v.version }); + } + } + const diff = { + neu: jetzt.filter((m) => !alt.has(m.name)), + geaendert, + entfernt: vorher.filter((m) => !neu.has(m.name)), + }; + return diff.neu.length + diff.geaendert.length + diff.entfernt.length > 0 ? diff : null; +} + +/** Eine Zeile je Mod, aber nie mehr als in ein Discord-Feld passt */ +function modZeilen(mods, zeile, grenze = 1000) { + const alle = mods.map(zeile); + const raus = []; + let laenge = 0; + for (const z of alle) { + // Der Hinweis auf den Rest muss selbst noch hineinpassen + if (laenge + z.length + 1 > grenze - 30) { + raus.push(`… und ${alle.length - raus.length} weitere`); + break; + } + raus.push(z); + laenge += z.length + 1; + } + return raus.join('\n'); +} + +/** + * Was sich an den Mods getan hat — als Meldung im Kanal. + * + * Bewusst nur die Änderung und nicht die ganze Liste: wer alle 110 sehen + * will, geht auf die Seite. Hier steht, was man nachladen muss. + */ +export function modsEmbed(client, diff, server) { + const seite = `${hubUrl()}/mods/${server.id}`; + const teile = []; + if (diff.neu.length) teile.push(`${diff.neu.length} neu`); + if (diff.geaendert.length) teile.push(`${diff.geaendert.length} aktualisiert`); + if (diff.entfernt.length) teile.push(`${diff.entfernt.length} entfernt`); + + const embed = brandEmbed(client, 'MOD') + .setTitle('🧩 Modliste geändert') + .setURL(seite) + .setDescription(`**${server.name}** — ${teile.join(', ')}.\nAlle Mods und Downloads: [auf der Seite](${seite})`) + .setFooter({ text: brandFooter('MOD') }) + .setTimestamp(); + + if (diff.geaendert.length) { + embed.addFields({ + name: `⬆️ Aktualisiert (${diff.geaendert.length})`, + // Gleiche Version, anderer Hash: der Modder hat nachgebessert, + // ohne die Version zu erhöhen. „1.3.0.0 → 1.3.0.0" wäre Unsinn. + value: modZeilen(diff.geaendert, (m) => (m.vorher === m.version + ? `**${m.titel}** ${m.version} · neu gepackt` + : `**${m.titel}** ${m.vorher} → ${m.version}`)), + }); + } + if (diff.neu.length) { + embed.addFields({ + name: `➕ Neu (${diff.neu.length})`, + value: modZeilen(diff.neu, (m) => `**${m.titel}** ${m.version}`), + }); + } + if (diff.entfernt.length) { + embed.addFields({ + name: `➖ Entfernt (${diff.entfernt.length})`, + value: modZeilen(diff.entfernt, (m) => `~~${m.titel}~~`), + }); + } + return embed; +} + // ── Fuhrpark ────────────────────────────────────────────────────────────── /** @@ -621,6 +746,17 @@ export function feedBasis(server) { return `http://${server.host}:${port}/feed`; } +/** + * Wo die Mod-Dateien liegen. + * + * Nicht unter `/feed` — die Zips hängen eine Ebene höher direkt am Webserver + * des Spiels: `http://host:port/mods/.zip?code=…`. Deshalb wird das + * `/feed` vom Ende abgeschnitten statt eine zweite Einstellung zu bauen. + */ +export function modBasis(server) { + return feedBasis(server).replace(/\/feed\/?$/i, ''); +} + /** * Ist das ein LS-Server, den wir abfragen können? * @@ -651,6 +787,7 @@ export async function lsAbfragen(server, { timeoutMs = 15000, kartenBreite = 102 const code = encodeURIComponent(server.token); const stats = await holen(`${basis}/dedicated-server-stats.xml?code=${code}`, timeoutMs); const stand = statsLesen(stats); + const mods = modsLesen(stats); // Karte, Preise und Fuhrpark sind Beiwerk: fehlt eins, bleibt der Rest // brauchbar. Deshalb einzeln abgefangen statt einmal um alles herum. @@ -665,7 +802,7 @@ export async function lsAbfragen(server, { timeoutMs = 15000, kartenBreite = 102 holen(`${basis}/dedicated-server-savegame.html?file=vehicles&code=${code}`, timeoutMs) .then(fahrzeugeLesen).catch(() => null), ]); - return { stand, bild, preise, fahrzeuge }; + return { stand, mods, bild, preise, fahrzeuge }; } @@ -706,7 +843,7 @@ export async function lsTick(client) { for (const s of server) { try { - const { stand, bild, preise, fahrzeuge } = await lsAbfragen(s, { + const { stand, mods, bild, preise, fahrzeuge } = await lsAbfragen(s, { timeoutMs: tuningMs.seconds('monitor_timeout'), kartenBreite: breite, }); @@ -726,8 +863,16 @@ export async function lsTick(client) { // bekannt", und der echte Mitternachtssprung geht verloren. if (stand.tageszeit != null) setLsTageszeit(s.id, stand.tageszeit); + // Modliste: erst vergleichen, dann merken. Die Meldung ist die + // einzige, die noch von sich aus schreibt — sie kommt selten und + // sagt etwas, das man wissen muss, bevor man verbindet. + const modWechsel = mods.length > 0 ? modsDiff(alt?.mods, mods) : null; + if (mods.length > 0) setLsMods(s.id, mods); + if (!kanal?.isTextBased()) continue; + if (modWechsel) await kanal.send({ embeds: [modsEmbed(client, modWechsel, s)] }); + // 1) Karte und Preise const payload = await hofNachricht(client, stand, bild, namen, breite); if (preise.length > 0) { diff --git a/src/db.js b/src/db.js index 991db27..9a1ed38 100644 --- a/src/db.js +++ b/src/db.js @@ -1052,6 +1052,37 @@ const lsHofStmt = db.prepare(` ON CONFLICT(server_id) DO UPDATE SET hof_messages = excluded.hof_messages `); export const setLsHofMessages = (serverId, map) => lsHofStmt.run(serverId, JSON.stringify(map)); +// Die zuletzt gesehene Modliste — daraus entsteht die Änderungsmeldung. +// Dazu je Mod die Dateigrösse, sobald sie einmal bekannt ist: der Server +// beantwortet kein HEAD, also lernt der Bot sie beim ersten Weiterleiten +// eines Downloads statt 4 GB nur fürs Anzeigen zu holen. +{ + const cols = db.prepare('PRAGMA table_info(ls_state)').all().map((c) => c.name); + if (!cols.includes('mods')) db.exec('ALTER TABLE ls_state ADD COLUMN mods TEXT'); +} +const lsModsStmt = db.prepare(` + INSERT INTO ls_state (server_id, mods) VALUES (?, ?) + ON CONFLICT(server_id) DO UPDATE SET mods = excluded.mods +`); +export const setLsMods = (serverId, mods) => lsModsStmt.run(serverId, JSON.stringify(mods)); +db.exec(` + CREATE TABLE IF NOT EXISTS ls_mod_sizes ( + server_id INTEGER NOT NULL, + name TEXT NOT NULL, + bytes INTEGER NOT NULL, + gesehen TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (server_id, name) + ); +`); +const lsModSizeStmt = db.prepare(` + INSERT INTO ls_mod_sizes (server_id, name, bytes, gesehen) VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(server_id, name) DO UPDATE SET bytes = excluded.bytes, gesehen = datetime('now') +`); +export const setLsModSize = (serverId, name, bytes) => lsModSizeStmt.run(serverId, name, bytes); +const lsModSizesStmt = db.prepare('SELECT name, bytes FROM ls_mod_sizes WHERE server_id = ?'); +export const lsModSizes = (serverId) => + Object.fromEntries(lsModSizesStmt.all(serverId).map((r) => [r.name, r.bytes])); + const lsVergessenStmt = db.prepare('DELETE FROM ls_state WHERE server_id = ?'); export const lsState = (serverId) => { const r = lsStateStmt.get(serverId); @@ -1063,7 +1094,12 @@ export const lsState = (serverId) => { 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 }; + let mods = null; + try { + const roh = r.mods ? JSON.parse(r.mods) : null; + if (Array.isArray(roh)) mods = roh; + } catch { /* kaputt gespeichert — gilt als „noch nichts bekannt" */ } + return { ...r, hofMessages, mods }; }; export const setLsMessage = (serverId, messageId) => lsMessageStmt.run(serverId, messageId); export const forgetLsState = (serverId) => lsVergessenStmt.run(serverId).changes > 0; diff --git a/src/web/api.js b/src/web/api.js index 30566c3..d3fe1de 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -27,12 +27,14 @@ import { saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp, createService, updateService, listServices, deleteService, savePage, getPage, listPages, listPublishedPages, deletePage, + lsState, lsModSizes, setLsModSize, } from '../db.js'; import crypto from 'node:crypto'; import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, moduleSettingKeys, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js'; import { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js'; import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js'; import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../bot/server-monitor.js'; +import { istLsServer, modBasis } from '../bot/ls-farm.js'; import { watchdogState, watchedServices } from '../bot/watchdog.js'; import { listRules, setRuleEnabled } from '../bot/automod.js'; import { @@ -226,6 +228,9 @@ export function registerApiRoutes(app, client) { address: s.address || null, connect_url: s.connect_url || null, mods_url: s.mods_url || null, + // Wie viele Mods der Bot von diesem Server kennt — nur dann + // lohnt der Weg auf die Mod-Seite. null heisst „gibt es nicht". + mod_liste: lsState(s.id)?.mods?.length || null, links: s.links ?? [], zugang: zugangStand(s, r ?? {}), // Wie der Server sich im Spiel nennt, falls anders als hier @@ -1646,6 +1651,73 @@ ${rssItems} return { servers: listGameservers() }; }); + // --- Modliste eines LS-Servers (öffentlich) --- + // + // Die Liste steht in der Statusabfrage, die der LS-Tick ohnehin holt; hier + // kommt sie aus der Datenbank statt bei jedem Seitenaufruf vom Spielserver. + app.get('/api/servers/:id/mods', async (request, reply) => { + const server = getGameserver(Number(request.params.id)); + if (!server || !istLsServer(server)) return reply.code(404).send({ error: 'Kein LS-Server' }); + const stand = lsState(server.id); + const groessen = lsModSizes(server.id); + return { + server: { id: server.id, name: server.name, spiel: spielName(server.type) }, + geprueft: stand?.geprueft ?? null, + mods: (stand?.mods ?? []).map((m) => ({ + // Der Hash ist der Fingerabdruck des Spiels und hat auf einer + // öffentlichen Seite nichts verloren — er sagt nichts, was ein + // Besucher braucht, und lädt zum Fehlschluss ein, er sei eine + // Prüfsumme der Datei. + name: m.name, titel: m.titel, autor: m.autor, version: m.version, + bytes: groessen[m.name] ?? null, + })), + }; + }); + + // Download durch den Bot hindurch. Der Zugangs-Code des Spielservers bleibt + // damit hier und steht nicht in einem Link, den jemand weitergibt — mit dem + // Code liesse sich sonst auch der ganze Spielstand lesen. + app.get('/api/servers/:id/mods/:datei', async (request, reply) => { + const server = getGameserver(Number(request.params.id)); + if (!server || !istLsServer(server)) return reply.code(404).send({ error: 'Kein LS-Server' }); + + // Nur Namen, die wirklich in der Modliste stehen. Das ist keine + // Formatprüfung, sondern eine Positivliste: ohne sie liesse sich über + // den Dateinamen jede beliebige Adresse des Spielservers abrufen. + const name = String(request.params.datei ?? '').replace(/\.zip$/i, ''); + const stand = lsState(server.id); + const mod = (stand?.mods ?? []).find((m) => m.name === name); + if (!mod) return reply.code(404).send({ error: 'Mod nicht in der Liste' }); + + const ziel = `${modBasis(server)}/mods/${encodeURIComponent(name)}.zip` + + `?code=${encodeURIComponent(server.token)}`; + let quelle; + try { + quelle = await fetch(ziel, { signal: AbortSignal.timeout(30_000) }); + } catch { + return reply.code(502).send({ error: 'Spielserver antwortet nicht' }); + } + if (!quelle.ok || !quelle.body) { + // 403 heisst in aller Regel: gekauftes DLC, das der Server nicht + // weitergeben darf. Das ist kein Fehler, sondern eine Auskunft. + const code = quelle.status === 403 ? 403 : 502; + return reply.code(code).send({ + error: code === 403 ? 'Gekaufter Inhalt — über Steam installieren' : 'Download fehlgeschlagen', + }); + } + + // Grösse merken: der Spielserver beantwortet kein HEAD, also ist das + // die einzige Gelegenheit, sie ohne zusätzlichen Verkehr zu erfahren. + const laenge = Number(quelle.headers.get('content-length')); + if (Number.isFinite(laenge) && laenge > 0) { + setLsModSize(server.id, name, laenge); + reply.header('content-length', String(laenge)); + } + reply.header('content-type', 'application/zip'); + reply.header('content-disposition', `attachment; filename="${name}.zip"`); + return reply.send(quelle.body); + }); + // Alle Spiele, die gamedig abfragen kann — damit die Auswahl im Panel nicht // an einer von Hand gepflegten Liste hängt und jedes Update automatisch // neue Spiele mitbringt. Steht in der Liste auch, welcher Standard-Port