Modliste: melden was sich geaendert hat, einzeln laden
Deploy / check (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

Bisher gab es fuer Mods nur einen Link auf das Sammelpaket des Hosters — vier
Gigabyte, auch wenn sich zwei Dateien geaendert haben. Jetzt liest der Bot die
Modliste aus der Statusabfrage, die er ohnehin holt, und meldet im Kanal, was
neu, aktualisiert oder entfernt wurde. Beim ersten Durchlauf bleibt es still,
sonst kaeme eine Meldung ueber 110 "neue" Mods.

Geaendert heisst: andere Version oder anderer Hash. Beides einzeln reicht
nicht — manche Modder bessern nach, ohne die Version zu erhoehen.

Dazu die Seite /mods/<server>: jeder Mod mit Version und Groesse, Suche ueber
Titel, Dateiname und Autor, und ein Download je Zeile. Der laeuft durch den
Bot, damit der Zugangs-Code des Spielservers nicht in einem Link landet — mit
dem Code liesse sich auch der ganze Spielstand lesen. Angefragt wird nur, was
wirklich in der Modliste steht; ueber den Dateinamen kommt man an nichts
anderes heran.

Groessen lernt der Bot beim Weiterleiten. Der Spielserver beantwortet kein
HEAD (501) und ignoriert Range, es gibt also keinen billigen Weg, sie vorher
zu erfahren — und 4 GB nur fuers Anzeigen zu holen waere keiner.

Nebenbei gefunden und behoben: 'server.mods' und 'commands.tag' gab es je
zweimal in den Sprachdateien. Der spaetere Eintrag gewinnt still, deshalb
stand auf dem neuen Knopf woertlich "%s Mods (110)" und auf der Befehlsseite
die Beschreibung von /tag statt der Kopfzeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 11:22:55 +02:00
co-authored by Claude Opus 5
parent 9b681754fa
commit 1ef06f747c
12 changed files with 527 additions and 10 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), 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/<server>` 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 ### Server & Technik
+36
View File
@@ -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=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=vehicles` | ~39 KB | jedes Fahrzeug mit Preis, `farmId`, Betriebsstunden |
| `dedicated-server-savegame.html?file=economy` | ~305 KB | Preiskurve je Fruchtart über zwölf Perioden | | `dedicated-server-savegame.html?file=economy` | ~305 KB | Preiskurve je Fruchtart über zwölf Perioden |
| `mods/<name>.zip` | 0,07 1381 MB | die Mod-Datei selbst, **nicht** unter `/feed` |
Andere Dateinamen antworten mit `204` und leerem Rumpf. Geprüft und **nicht** Andere Dateinamen antworten mit `204` und leerem Rumpf. Geprüft und **nicht**
verfügbar: `farms`, `farmland`, `farmlands`, `fields`, `field`, `items`, 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 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.
## 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 ## Was in der Fahrzeugdatei steht
`file=vehicles` ist die ergiebigste der drei Savegame-Dateien. Je `<vehicle>`: `file=vehicles` ist die ergiebigste der drei Savegame-Dateien. Je `<vehicle>`:
+2
View File
@@ -11,6 +11,7 @@ import Commits from './pages/Commits.jsx';
import Settings from './pages/Settings.jsx'; import Settings from './pages/Settings.jsx';
import Profil from './pages/Profil.jsx'; import Profil from './pages/Profil.jsx';
import Server from './pages/Server.jsx'; import Server from './pages/Server.jsx';
import Mods from './pages/Mods.jsx';
import Status from './pages/Status.jsx'; import Status from './pages/Status.jsx';
import Home from './pages/Home.jsx'; import Home from './pages/Home.jsx';
import DevlogDetail from './pages/DevlogDetail.jsx'; import DevlogDetail from './pages/DevlogDetail.jsx';
@@ -219,6 +220,7 @@ export default function App() {
<Route path="/roadmap" element={<Roadmap />} /> <Route path="/roadmap" element={<Roadmap />} />
<Route path="/wunsch/:id" element={<WunschDetail />} /> <Route path="/wunsch/:id" element={<WunschDetail />} />
<Route path="/server" element={<Server />} /> <Route path="/server" element={<Server />} />
<Route path="/mods/:id" element={<Mods />} />
<Route path="/status" element={<Status />} /> <Route path="/status" element={<Status />} />
<Route path="/galerie" element={<Galerie />} /> <Route path="/galerie" element={<Galerie />} />
<Route path="/level" element={<Level />} /> <Route path="/level" element={<Level />} />
+14 -1
View File
@@ -165,6 +165,19 @@ export default {
'server.offline': 'Offline', 'server.offline': 'Offline',
'server.waiting': 'Warte auf Check', 'server.waiting': 'Warte auf Check',
'server.players24h': 'Spielerzahl · letzte 24h', '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.peak': 'Peak',
'server.uptime': 'Uptime', 'server.uptime': 'Uptime',
'server.measuredSince': 'misst seit %s', '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.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.bdayDel': 'Trägt ihn wieder aus.',
'commands.remind': 'Erinnert dich per Direktnachricht — etwa „2h" oder „30m".', '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.ping': 'Lebenszeichen des Bots.',
'commands.umfrage': 'Startet eine Abstimmung mit bis zu vier Antworten — Discord zählt selbst aus.', '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.', 'commands.warn': 'Verwarnt jemanden, schickt eine Nachricht und schreibt es ins Protokoll.',
+14 -1
View File
@@ -164,6 +164,19 @@ export default {
'server.offline': 'Offline', 'server.offline': 'Offline',
'server.waiting': 'Waiting for check', 'server.waiting': 'Waiting for check',
'server.players24h': 'Player count · last 24h', '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.peak': 'Peak',
'server.uptime': 'Uptime', 'server.uptime': 'Uptime',
'server.measuredSince': 'measuring since %s', '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.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.bdayDel': 'Removes it again.',
'commands.remind': 'Reminds you by direct message — something like “2h” or “30m”.', '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.ping': 'A sign of life from the bot.',
'commands.umfrage': 'Starts a vote with up to four answers — Discord does the counting.', '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.', 'commands.warn': 'Warns someone, sends them a message and writes it to the log.',
+1 -1
View File
@@ -12,7 +12,7 @@ const GROUPS = [
['/geburtstag setzen tag:… monat:…', 'commands.bdaySet'], ['/geburtstag setzen tag:… monat:…', 'commands.bdaySet'],
['/geburtstag entfernen', 'commands.bdayDel'], ['/geburtstag entfernen', 'commands.bdayDel'],
['/remind dauer:… text:…', 'commands.remind'], ['/remind dauer:… text:…', 'commands.remind'],
['/tag name:…', 'commands.tag'], ['/tag name:…', 'commands.cmdTag'],
['/ping', 'commands.ping'], ['/ping', 'commands.ping'],
['/umfrage frage:… a:… b:…', 'commands.umfrage'], ['/umfrage frage:… a:… b:…', 'commands.umfrage'],
]], ]],
+130
View File
@@ -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 (
<>
<section className="page-header">
<div className="page-bg-text">MODS</div>
<div className="page-header-grid" aria-hidden="true" />
<div className="page-header-content">
<div className="page-tag">{t('mods.tag')}</div>
<h1 className="page-title">{daten?.server?.name ?? t('mods.title')}</h1>
<p className="page-subtitle">{t('mods.subtitle')}</p>
</div>
</section>
<section className="content">
{fehler && <p className="notice">{t('mods.error')}</p>}
{!fehler && !daten && <SkeletonCards n={1} />}
{daten && (
<>
<div className="mod-bar">
<div className="mod-count">
<IconPackage />
<strong>{daten.mods.length}</strong> {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 && (
<span className="mod-sum"> · {groesse(summe)}</span>
)}
</div>
<label className="mod-search">
<IconSearch />
<input
type="search"
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder={t('mods.search')}
aria-label={t('mods.search')}
/>
</label>
</div>
{daten.mods.length === 0 && <p className="notice">{t('mods.none')}</p>}
{daten.mods.length > 0 && treffer.length === 0 && (
<p className="notice">{t('mods.noHit', suche)}</p>
)}
<ul className="mod-list">
{treffer.map((m) => (
<li className="mod-row" key={m.name}>
<div className="mod-info">
<span className="mod-title">{m.titel}</span>
<span className="mod-meta">
{m.autor && <>{m.autor} · </>}
<code>{m.name}</code>
</span>
</div>
<span className="mod-version">{m.version}</span>
<span className="mod-size">{groesse(m.bytes) ?? ''}</span>
<a
className="mod-dl"
href={`/api/servers/${id}/mods/${encodeURIComponent(m.name)}.zip`}
// Kein target=_blank: der Browser lädt
// wegen Content-Disposition ohnehin
// herunter, ein leeres Tab hilft keinem.
download
>
<IconDownload />
<span>{t('mods.download')}</span>
</a>
</li>
))}
</ul>
<p className="mod-hint">{t('mods.hint')}</p>
<p className="mod-hint">
<Link to="/server">{t('mods.back')}</Link>
</p>
</>
)}
</section>
</>
);
}
+10 -1
View File
@@ -2,6 +2,7 @@
// Live-Status, 24h-Spielerzahl-Verlauf, Uptime und Peak. // Live-Status, 24h-Spielerzahl-Verlauf, Uptime und Peak.
// Daten sammelt der Server-Monitor alle 2 Minuten. // Daten sammelt der Server-Monitor alle 2 Minuten.
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { apiGet } from '../api.js'; import { apiGet } from '../api.js';
import { SkeletonCards } from '../components/Skeleton.jsx'; import { SkeletonCards } from '../components/Skeleton.jsx';
import { IconGame, IconMapPin, IconClock, IconCheck, IconTeam, IconPackage, IconZap, IconDownload, IconArchive, IconLock } from '../icons.jsx'; import { IconGame, IconMapPin, IconClock, IconCheck, IconTeam, IconPackage, IconZap, IconDownload, IconArchive, IconLock } from '../icons.jsx';
@@ -189,7 +190,7 @@ function ServerCard({ s }) {
<UptimeTrack buckets={s.uptime} /> <UptimeTrack buckets={s.uptime} />
{(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) && (
<div className="srv-connect"> <div className="srv-connect">
{s.connect_url && ( {s.connect_url && (
<a className="btn btn-save with-icon" href={s.connect_url}> <a className="btn btn-save with-icon" href={s.connect_url}>
@@ -201,6 +202,14 @@ function ServerCard({ s }) {
<IconDownload size={15} /> {t('server.modsDownload')} <IconDownload size={15} /> {t('server.modsDownload')}
</a> </a>
)} )}
{/* 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 && (
<Link className="btn btn-ghost with-icon" to={`/mods/${s.id}`}>
<IconPackage size={15} /> {t('server.modList')} ({s.mod_liste})
</Link>
)}
{/* Frei eingetragene Links. `noreferrer` weil sie überall {/* Frei eingetragene Links. `noreferrer` weil sie überall
hinführen können — das ist ja der Sinn. */} hinführen können — das ist ja der Sinn. */}
{(s.links ?? []).map((l) => ( {(s.links ?? []).map((l) => (
+61
View File
@@ -2900,3 +2900,64 @@ button.with-icon, a.with-icon { justify-content: center; }
font-family: var(--mono); font-size: .68rem; color: var(--muted2); font-family: var(--mono); font-size: .68rem; color: var(--muted2);
letter-spacing: .04em; margin-top: .1rem; 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: ' ·'; }
}
+149 -4
View File
@@ -12,10 +12,10 @@
import { AttachmentBuilder, Events } from 'discord.js'; import { AttachmentBuilder, Events } from 'discord.js';
import sharp from 'sharp'; import sharp from 'sharp';
import { brandEmbed } from '../embeds.js'; import { brandEmbed } from '../embeds.js';
import { brandFooter } from '../runtime-settings.js'; import { brandFooter, hubUrl } 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, setLsTageszeit, setLsHofMessages } from '../db.js'; import { getSetting, setSetting, listGameservers, lsState, setLsMessage, setLsTageszeit, setLsHofMessages, setLsMods } 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,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(/<Mods>([\s\S]*?)<\/Mods>/);
if (!block) return [];
return [...block[1].matchAll(/<Mod ([^>]*)>([^<]*)<\/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);
}
/** &amp;#93; und Konsorten aus dem Feed zurückübersetzen */
function entschaerft(text) {
return String(text)
.replaceAll('&amp;', '&')
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&quot;', '"');
}
/**
* 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 ────────────────────────────────────────────────────────────── // ── Fuhrpark ──────────────────────────────────────────────────────────────
/** /**
@@ -621,6 +746,17 @@ export function feedBasis(server) {
return `http://${server.host}:${port}/feed`; 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/<name>.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? * 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 code = encodeURIComponent(server.token);
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);
const mods = modsLesen(stats);
// Karte, Preise und Fuhrpark sind Beiwerk: fehlt eins, bleibt der Rest // Karte, Preise und Fuhrpark sind Beiwerk: fehlt eins, bleibt der Rest
// brauchbar. Deshalb einzeln abgefangen statt einmal um alles herum. // 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) holen(`${basis}/dedicated-server-savegame.html?file=vehicles&code=${code}`, timeoutMs)
.then(fahrzeugeLesen).catch(() => null), .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) { for (const s of server) {
try { try {
const { stand, bild, preise, fahrzeuge } = await lsAbfragen(s, { const { stand, mods, bild, preise, fahrzeuge } = await lsAbfragen(s, {
timeoutMs: tuningMs.seconds('monitor_timeout'), timeoutMs: tuningMs.seconds('monitor_timeout'),
kartenBreite: breite, kartenBreite: breite,
}); });
@@ -726,8 +863,16 @@ export async function lsTick(client) {
// bekannt", und der echte Mitternachtssprung geht verloren. // bekannt", und der echte Mitternachtssprung geht verloren.
if (stand.tageszeit != null) setLsTageszeit(s.id, stand.tageszeit); 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 (!kanal?.isTextBased()) continue;
if (modWechsel) await kanal.send({ embeds: [modsEmbed(client, modWechsel, s)] });
// 1) Karte und Preise // 1) Karte und Preise
const payload = await hofNachricht(client, stand, bild, namen, breite); const payload = await hofNachricht(client, stand, bild, namen, breite);
if (preise.length > 0) { if (preise.length > 0) {
+37 -1
View File
@@ -1052,6 +1052,37 @@ const lsHofStmt = db.prepare(`
ON CONFLICT(server_id) DO UPDATE SET hof_messages = excluded.hof_messages ON CONFLICT(server_id) DO UPDATE SET hof_messages = excluded.hof_messages
`); `);
export const setLsHofMessages = (serverId, map) => lsHofStmt.run(serverId, JSON.stringify(map)); 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 = ?'); 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);
@@ -1063,7 +1094,12 @@ export const lsState = (serverId) => {
const roh = JSON.parse(r.hof_messages || '{}'); const roh = JSON.parse(r.hof_messages || '{}');
if (roh && typeof roh === 'object' && !Array.isArray(roh)) hofMessages = roh; if (roh && typeof roh === 'object' && !Array.isArray(roh)) hofMessages = roh;
} catch { /* kaputt gespeichert — dann werden die Embeds neu gepostet */ } } 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 setLsMessage = (serverId, messageId) => lsMessageStmt.run(serverId, messageId);
export const forgetLsState = (serverId) => lsVergessenStmt.run(serverId).changes > 0; export const forgetLsState = (serverId) => lsVergessenStmt.run(serverId).changes > 0;
+72
View File
@@ -27,12 +27,14 @@ import {
saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp, saveSsoApp, listSsoApps, deleteSsoApp, getSsoApp,
createService, updateService, listServices, deleteService, createService, updateService, listServices, deleteService,
savePage, getPage, listPages, listPublishedPages, deletePage, savePage, getPage, listPages, listPublishedPages, deletePage,
lsState, lsModSizes, setLsModSize,
} from '../db.js'; } from '../db.js';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { moduleStates, moduleDetail, setModuleEnabled, moduleEnabled, moduleFavorites, setModuleFavorites, moduleSettingKeys, MODULE_GROUPS, MODULES, MAX_FAVORITES } from '../modules.js'; 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 { templateStates, setTemplate, TEMPLATE_GROUPS } from '../templates.js';
import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js'; import { tuningStates, setTuning, tuning, TUNING_GROUPS } from '../tuning.js';
import { lastResults, GAME_ICONS, spielName, spielListe, zugangStand } from '../bot/server-monitor.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 { watchdogState, watchedServices } from '../bot/watchdog.js';
import { listRules, setRuleEnabled } from '../bot/automod.js'; import { listRules, setRuleEnabled } from '../bot/automod.js';
import { import {
@@ -226,6 +228,9 @@ export function registerApiRoutes(app, client) {
address: s.address || null, address: s.address || null,
connect_url: s.connect_url || null, connect_url: s.connect_url || null,
mods_url: s.mods_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 ?? [], links: s.links ?? [],
zugang: zugangStand(s, r ?? {}), zugang: zugangStand(s, r ?? {}),
// Wie der Server sich im Spiel nennt, falls anders als hier // Wie der Server sich im Spiel nennt, falls anders als hier
@@ -1646,6 +1651,73 @@ ${rssItems}
return { servers: listGameservers() }; 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 // Alle Spiele, die gamedig abfragen kann — damit die Auswahl im Panel nicht
// an einer von Hand gepflegten Liste hängt und jedes Update automatisch // an einer von Hand gepflegten Liste hängt und jedes Update automatisch
// neue Spiele mitbringt. Steht in der Liste auch, welcher Standard-Port // neue Spiele mitbringt. Steht in der Liste auch, welcher Standard-Port