@@ -0,0 +1,507 @@
// Landwirtschafts-Simulator: Besitzkarte und Preiskurven.
//
// Der Feed eines LS-Servers gibt deutlich mehr her, als der Monitor bisher
// benutzt hat — die reine Statusabfrage lädt schon 49 KB, in denen Parzellen,
// Felder, Fahrzeuge und Spielerpositionen stehen. Hier wird das ausgewertet.
//
// Was der Feed NICHT hergibt, und woran auch kein Mod etwas ändert: welche
// Frucht auf einem Feld steht und wie weit sie ist. Das liegt in einer binären
// Density-Map, die kein Endpunkt herausgibt. Deshalb ist das hier eine
// Besitzkarte und keine Feldkarte — und die Parzellen sind flächengleiche
// Kreise, weil der Feed nur Schwerpunkt und Hektar liefert, keine Umrisse.
import { AttachmentBuilder , Events } from 'discord.js' ;
import sharp from 'sharp' ;
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' ;
/** Alle Attribute eines XML-Tags als Objekt */
const attrsVon = ( roh ) =>
Object . fromEntries ( [ ... roh . matchAll ( /(\w+)="([^"]*)"/g ) ] . map ( ( m ) => [ m [ 1 ] , m [ 2 ] ] ) ) ;
/** Alle selbstschliessenden Tags eines Namens */
function tags ( xml , name ) {
return [ ... xml . matchAll ( new RegExp ( ` < ${ name } ([^>]*?)/> ` , 'g' ) ) ] . map ( ( m ) => attrsVon ( m [ 1 ] ) ) ;
}
/**
* Die Statusabfrage zerlegen.
*
* Bewusst mit regulären Ausdrücken statt einem XML-Parser: es geht um vier
* flache Tag-Sorten ohne Verschachtelung, und eine weitere Abhängigkeit für
* 20 Zeilen wäre unverhältnismässig.
*/
export function statsLesen ( xml ) {
const server = attrsVon ( xml . match ( /<Server ([^>]*)>/ ) ? . [ 1 ] ? ? '' ) ;
const spieler = [ ... xml . matchAll ( /<Player ([^>]*)>([^<]*)<\/Player>/g ) ]
. map ( ( m ) => ( { ... attrsVon ( m [ 1 ] ) , name : m [ 2 ] . trim ( ) } ) )
. filter ( ( p ) => p . isUsed === 'true' ) ;
return {
name : server . name ? ? null ,
karte : server . mapName ? ? null ,
// Ohne mapSize lässt sich nichts zeichnen — dann lieber gar keine Karte
groesse : Number ( server . mapSize ) || null ,
version : server . version ? ? null ,
parzellen : tags ( xml , 'Farmland' ) . map ( ( f ) => ( {
id : Number ( f . id ) , name : f . name ? ? String ( f . id ) ,
hof : Number ( f . owner ) , // 0 = noch zu kaufen
hektar : Number ( f . area ) , preis : Number ( f . price ) ,
x : Number ( f . x ) , z : Number ( f . z ) ,
} ) ) ,
felder : tags ( xml , 'Field' ) . map ( ( f ) => ( {
id : Number ( f . id ) , x : Number ( f . x ) , z : Number ( f . z ) , eigen : f . isOwned === 'true' ,
} ) ) ,
fahrzeuge : tags ( xml , 'Vehicle' ) . map ( ( v ) => ( {
name : v . name , typ : v . type , kategorie : v . category ,
x : Number ( v . x ) , z : Number ( v . z ) ,
ladung : ladungLesen ( v . fillTypes , v . fillLevels ) ,
ki : v . isAIActive === 'true' ,
} ) ) ,
spieler : spieler . map ( ( p ) => ( {
name : p . name , admin : p . isAdmin === 'true' , minuten : Number ( p . uptime ) || 0 ,
// Wer im Menü steht, hat keine Position — der taucht auf der Karte nicht auf
x : p . x != null ? Number ( p . x ) : null ,
z : p . z != null ? Number ( p . z ) : null ,
} ) ) ,
} ;
}
/** "SEEDS FERTILIZER" + "476.6 73.5" → [{ art, menge }] ohne die leeren Slots */
function ladungLesen ( arten , mengen ) {
if ( ! arten || ! mengen ) return [ ] ;
const a = arten . trim ( ) . split ( /\s+/ ) ;
const m = mengen . trim ( ) . split ( /\s+/ ) . map ( Number ) ;
return a
. map ( ( art , i ) => ( { art , menge : m [ i ] ? ? 0 } ) )
. filter ( ( l ) => l . art !== 'UNKNOWN' && l . menge > 0.5 ) ;
}
/** Besitz je Hof, grösster zuerst; Hof 0 (unverkauft) getrennt */
export function hofUebersicht ( parzellen ) {
const proHof = new Map ( ) ;
for ( const p of parzellen ) {
const e = proHof . get ( p . hof ) ? ? { hof : p . hof , parzellen : 0 , hektar : 0 , wert : 0 } ;
e . parzellen ++ ;
e . hektar += p . hektar ;
e . wert += p . preis ;
proHof . set ( p . hof , e ) ;
}
const alle = [ ... proHof . values ( ) ] ;
return {
hoefe : alle . filter ( ( h ) => h . hof > 0 ) . sort ( ( a , b ) => b . hektar - a . hektar ) ,
frei : alle . find ( ( h ) => h . hof === 0 ) ? ? { parzellen : 0 , hektar : 0 , wert : 0 } ,
} ;
}
// ── Preise ────────────────────────────────────────────────────────────────
export const PERIODEN = [
'EARLY_SPRING' , 'MID_SPRING' , 'LATE_SPRING' ,
'EARLY_SUMMER' , 'MID_SUMMER' , 'LATE_SUMMER' ,
'EARLY_AUTUMN' , 'MID_AUTUMN' , 'LATE_AUTUMN' ,
'EARLY_WINTER' , 'MID_WINTER' , 'LATE_WINTER' ,
] ;
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' ,
} ;
/**
* Die Preiskurven aus der economy.xml.
*
* Das sind Jahreszeitpreise, keine Tagespreise: für jede Fruchtart steht da,
* was sie in welcher Periode bringt. Wer wissen will, wann sich Verkaufen
* lohnt, findet es genau hier — bei welchem Händler, steht nirgends.
*/
export function preiseLesen ( xml ) {
const raus = [ ] ;
for ( const m of xml . matchAll ( /<fillType fillType="(\w+)">([\s\S]*?)<\/fillType>/g ) ) {
const kurve = { } ;
for ( const p of m [ 2 ] . matchAll ( /<period period="(\w+)">([\d.]+)<\/period>/g ) ) {
kurve [ p [ 1 ] ] = Number ( p [ 2 ] ) ;
}
const werte = PERIODEN . map ( ( p ) => kurve [ p ] ) . filter ( ( v ) => v != null ) ;
if ( werte . length === 0 ) continue ;
const hoch = Math . max ( ... werte ) ;
const tief = Math . min ( ... werte ) ;
raus . push ( {
art : m [ 1 ] , kurve , hoch , tief ,
besteZeit : PERIODEN . find ( ( p ) => kurve [ p ] === hoch ) ? ? null ,
schlechtesteZeit : PERIODEN . find ( ( p ) => kurve [ p ] === tief ) ? ? null ,
// Wie viel Unterschied macht der Zeitpunkt? Darunter lohnt Warten nicht.
spanne : tief > 0 ? hoch / tief : 1 ,
} ) ;
}
return raus ;
}
/** Zwölf Werte als Balkenzeile — Höhe relativ zwischen Tief und Hoch */
const BLOECKE = '▁▂▃▄▅▆▇█' ;
export function kurveZeichnen ( eintrag ) {
return PERIODEN . map ( ( p ) => {
const v = eintrag . kurve [ p ] ;
if ( v == null ) return ' ' ;
if ( eintrag . hoch === eintrag . tief ) return BLOECKE [ 0 ] ;
const anteil = ( v - eintrag . tief ) / ( eintrag . hoch - eintrag . tief ) ;
return BLOECKE [ Math . min ( BLOECKE . length - 1 , Math . round ( anteil * ( BLOECKE . length - 1 ) ) ) ] ;
} ) . join ( '' ) ;
}
// ── Karte ─────────────────────────────────────────────────────────────────
// Farben je Hof. Mehr als sechs Höfe hat kaum ein Server; danach wiederholt
// es sich, was immer noch besser ist als eine Karte ohne Farben.
const HOF _FARBEN = [ '#f5c518' , '#4ea3ff' , '#00d47e' , '#ff6b9d' , '#c084fc' , '#ff8c42' ] ;
export const hofFarbe = ( hof ) => HOF _FARBEN [ ( hof - 1 ) % HOF _FARBEN . length ] ;
const esc = ( s ) => String ( s ) . replace ( /[<>&"']/g , ( c ) =>
( { '<' : '<' , '>' : '>' , '&' : '&' , '"' : '"' , "'" : ''' } [ c ] ) ) ;
/**
* Die Karte als SVG.
*
* Weltkoordinaten laufen von -groesse/2 bis +groesse/2; x nach rechts, z nach
* unten, linear über das ganze Luftbild. Das ist gegen die Ingame-Karte
* geprüft: 14 von 15 markanten Feldnummern landen in der richtigen Zone.
*
* @param {object} stand Ergebnis von statsLesen()
* @param {string} bildB64 Luftbild vom Server als base64-JPEG
* @param {number} S Kantenlänge in Pixeln
* @param {Record<number,string>} namen Hof-Nummer → Anzeigename
*/
export function karteSvg ( stand , bildB64 , S = 1024 , namen = { } ) {
const G = stand . groesse ;
const px = ( w ) => ( ( w + G / 2 ) / G ) * S ;
const teile = [ ] ;
for ( const p of stand . parzellen ) {
if ( p . hof === 0 ) continue ; // unverkauftes Land nicht zumalen
const farbe = hofFarbe ( p . hof ) ;
// Kreisfläche = Hektar, damit die Grösse etwas aussagt statt Deko zu sein
const radiusM = Math . sqrt ( ( p . hektar * 10000 ) / Math . PI ) ;
const r = Math . max ( 4 , ( radiusM / G ) * S ) ;
teile . push ( ` <circle cx=" ${ px ( p . x ) . toFixed ( 1 ) } " cy=" ${ px ( p . z ) . toFixed ( 1 ) } " r=" ${ r . toFixed ( 1 ) } " fill=" ${ farbe } " fill-opacity=".34" stroke=" ${ farbe } " stroke-width="2"/> ` ) ;
teile . push ( ` <text x=" ${ px ( p . x ) . toFixed ( 1 ) } " y=" ${ ( px ( p . z ) + 4 ) . toFixed ( 1 ) } " text-anchor="middle" fill="#fff" font-family="monospace" font-size="11" font-weight="bold"> ${ esc ( p . name ) } </text> ` ) ;
}
for ( const v of stand . fahrzeuge ) {
const farbe = v . ki ? '#00d47e' : '#ff8c42' ; // KI-Helfer heben sich ab
teile . push ( ` <circle cx=" ${ px ( v . x ) . toFixed ( 1 ) } " cy=" ${ px ( v . z ) . toFixed ( 1 ) } " r="3" fill=" ${ farbe } " stroke="#000" stroke-width=".8"/> ` ) ;
}
for ( const s of stand . spieler ) {
if ( s . x == null ) continue ;
teile . push ( ` <circle cx=" ${ px ( s . x ) . toFixed ( 1 ) } " cy=" ${ px ( s . z ) . toFixed ( 1 ) } " r="6" fill="#fff" stroke="#000" stroke-width="2"/> ` ) ;
teile . push ( ` <text x=" ${ ( px ( s . x ) + 10 ) . toFixed ( 1 ) } " y=" ${ ( px ( s . z ) + 5 ) . toFixed ( 1 ) } " fill="#fff" stroke="#000" stroke-width="3" paint-order="stroke" font-family="monospace" font-size="14" font-weight="bold"> ${ esc ( s . name ) } </text> ` ) ;
}
const { hoefe } = hofUebersicht ( stand . parzellen ) ;
const legende = hoefe . map ( ( h , i ) => {
const farbe = hofFarbe ( h . hof ) ;
const wer = namen [ h . hof ] ? ? ` Hof ${ h . hof } ` ;
return ` <circle cx="22" cy=" ${ 30 + i * 24 } " r="7" fill=" ${ farbe } " fill-opacity=".4" stroke=" ${ farbe } " stroke-width="2"/> `
+ ` <text x="38" y=" ${ 35 + i * 24 } " fill="#fff" stroke="#000" stroke-width="3" paint-order="stroke" font-family="monospace" font-size="14"> ${ esc ( wer ) } — ${ h . parzellen } Parzellen, ${ h . hektar . toFixed ( 1 ) } ha</text> ` ;
} ) . join ( '' ) ;
const online = stand . spieler . length ;
const fuss = ` ${ stand . karte ? ? '' } · ${ stand . fahrzeuge . length } Fahrzeuge · ${ online } ${ online === 1 ? 'Spieler' : 'Spieler' } online ` ;
return ` <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${ S } ${ S } " width=" ${ S } " height=" ${ S } ">
<image href="data:image/jpeg;base64, ${ bildB64 } " x="0" y="0" width=" ${ S } " height=" ${ S } "/>
${ teile . join ( '\n' ) }
${ legende }
<text x=" ${ S - 12 } " y=" ${ S - 14 } " text-anchor="end" fill="#fff" stroke="#000" stroke-width="3" paint-order="stroke" font-family="monospace" font-size="13"> ${ esc ( fuss ) } </text>
</svg> ` ;
}
/**
* Karte als Bild.
*
* JPEG, nicht PNG: der Untergrund ist ein Luftbild, und PNG macht daraus das
* Zehnfache — 2,5 MB statt 250 KB, alle paar Minuten neu hochgeladen.
*/
export async function karteBild ( stand , bildB64 , S = 1024 , namen = { } ) {
return sharp ( Buffer . from ( karteSvg ( stand , bildB64 , S , namen ) ) )
. flatten ( { background : '#0d0d0d' } ) // JPEG kann kein Transparent
. jpeg ( { quality : 86 , mozjpeg : true } )
. toBuffer ( ) ;
}
// ── Discord ───────────────────────────────────────────────────────────────
const geld = ( n ) => ` ${ Math . round ( n ) . toLocaleString ( 'de-DE' ) } € ` ;
/** Das Embed zur Karte: wer wie viel hat, und wer gerade spielt */
export function hofEmbed ( client , stand , namen = { } ) {
const { hoefe , frei } = hofUebersicht ( stand . parzellen ) ;
const embed = brandEmbed ( client , 'HÖFE' )
. setTitle ( ` 🚜 ${ stand . name ? ? 'LS-Server' } ` )
. setFooter ( { text : ` ${ brandFooter ( 'HÖFE' ) } ${ stand . karte ? ` • ${ stand . karte } ` : '' } ` } )
. setTimestamp ( ) ;
if ( hoefe . length === 0 ) {
embed . setDescription ( 'Noch hat kein Hof Land gekauft.' ) ;
} else {
embed . addFields ( hoefe . map ( ( h ) => ( {
name : namen [ h . hof ] ? ? ` Hof ${ h . hof } ` ,
value : ` ${ h . parzellen } Parzellen · ** ${ h . hektar . toFixed ( 1 ) } ha** \n ${ geld ( h . wert ) } ` ,
inline : true ,
} ) ) ) ;
}
embed . addFields ( {
name : 'Noch zu haben' ,
value : ` ${ frei . parzellen } Parzellen · ${ frei . hektar . toFixed ( 1 ) } ha · ${ geld ( frei . wert ) } ` ,
} ) ;
const drauf = stand . spieler ;
if ( drauf . length > 0 ) {
embed . addFields ( {
name : ` Gerade drauf ( ${ drauf . length } ) ` ,
value : drauf . map ( ( s ) => ` ${ s . admin ? '👑 ' : '' } \` ${ s . name } \` ` ) . join ( ' ' ) . slice ( 0 , 1000 ) ,
} ) ;
}
// Was in den Fahrzeugen liegt, ist der einzige Hinweis darauf, was gerade
// geerntet wird — die Frucht auf dem Feld gibt der Feed nicht her.
const ladungen = new Map ( ) ;
for ( const v of stand . fahrzeuge ) {
for ( const l of v . ladung ) {
if ( [ 'DIESEL' , 'DEF' , 'AIR' ] . includes ( l . art ) ) continue ; // Betriebsstoffe
ladungen . set ( l . art , ( ladungen . get ( l . art ) ? ? 0 ) + l . menge ) ;
}
}
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 ` ) ;
embed . addFields ( { name : 'In den Fahrzeugen' , value : zeilen . join ( ' · ' ) . slice ( 0 , 1000 ) } ) ;
}
return embed ;
}
/** Karte + Embed als fertige Nachricht */
export async function hofNachricht ( client , stand , bildB64 , namen = { } , breite = 1024 ) {
const embed = hofEmbed ( client , stand , namen ) ;
if ( ! bildB64 || ! stand . groesse ) return { embeds : [ embed ] , files : [ ] } ;
const bild = await karteBild ( stand , bildB64 , breite , namen ) ;
const datei = new AttachmentBuilder ( bild , { name : 'karte.jpg' } ) ;
return { embeds : [ embed . setImage ( 'attachment://karte.jpg' ) ] , files : [ datei ] } ;
}
/**
* Was standardmässig in der Preisliste steht.
*
* Ohne diese Liste gewinnt Spargel: der schwankt zwischen 26 und 7540 € und
* verdrängt damit jede Frucht, die man tatsächlich hektarweise anbaut. Nach
* Schwankungsbreite zu sortieren wäre mathematisch richtig und praktisch
* wertlos. Die Auswahl lässt sich im Panel überschreiben.
*/
export const STANDARD _SORTEN = [
'WHEAT' , 'BARLEY' , 'OAT' , 'CANOLA' , 'MAIZE' , 'SUNFLOWER' , 'SOYBEAN' ,
'POTATO' , 'SUGARBEET' , 'SUGARCANE' , 'COTTON' , 'SORGHUM' , 'RICE' ,
'GRAPE' , 'OLIVE' , 'MILK' , 'WOOL' , 'EGG' , 'SILAGE' , 'WOODCHIPS' ,
] ;
/** "SUGARBEET_CUT" → "sugarbeet cut" */
const sortenName = ( art ) => art . toLowerCase ( ) . replace ( /_/g , ' ' ) ;
/** Preis-Embed: was wann am meisten bringt */
export function preisEmbed ( client , preise , { jetzt = null , anzahl = 12 , sorten = null } = { } ) {
const wunsch = ( sorten ? . length ? sorten : STANDARD _SORTEN ) . map ( ( s ) => s . toUpperCase ( ) ) ;
const nachRang = new Map ( wunsch . map ( ( s , i ) => [ s , i ] ) ) ;
// In der Reihenfolge der Liste, nicht nach Schwankung — so steht Weizen
// vorne, weil er wichtig ist, und nicht hinten, weil er ruhig ist.
const lohnend = preise
. filter ( ( p ) => nachRang . has ( p . art ) )
. sort ( ( a , b ) => nachRang . get ( a . art ) - nachRang . get ( b . art ) )
. slice ( 0 , anzahl ) ;
const embed = brandEmbed ( client , 'PREISE' )
. setTitle ( '💰 Wann sich Verkaufen lohnt' )
. setDescription (
jetzt
? ` Aktuell: ** ${ PERIODE _LABEL [ jetzt ] ? ? jetzt } ** \n \` ▁▂▃▄▅▆▇█ \` = billig bis teuer über das Jahr `
: '`▁▂▃▄▅▆▇█` = billig bis teuer über das Jahr — die Jahreszeit steht in den Werten.'
)
. setFooter ( { text : brandFooter ( 'PREISE' ) } )
. setTimestamp ( ) ;
if ( lohnend . length === 0 ) {
embed . setDescription ( 'Keine der ausgewählten Sorten kommt auf diesem Server vor.' ) ;
return embed ;
}
for ( const p of lohnend ) {
const hier = jetzt ? p . kurve [ jetzt ] : null ;
// Unter 5 % Abstand zum Hoch ist Warten Zeitverschwendung
const rat = hier == null ? ''
: hier >= p . hoch * 0.95 ? '\n**Jetzt verkaufen** — Höchststand.'
: ` \n + ${ Math . round ( ( p . hoch / hier - 1 ) * 100 ) } % im ${ PERIODE _LABEL [ p . besteZeit ] } ` ;
embed . addFields ( {
name : sortenName ( p . art ) ,
value : ` \` ${ kurveZeichnen ( p ) } \` \n ${ Math . round ( p . tief ) } – ${ Math . round ( p . hoch ) } € `
+ ` ${ hier != null ? ` · jetzt ** ${ Math . round ( hier ) } €** ` : '' } ${ rat } ` ,
inline : true ,
} ) ;
}
return embed ;
}
// ── Abrufen und Posten ────────────────────────────────────────────────────
/** Basis-URL eines LS-Servers aus dem Monitor-Eintrag */
function feedBasis ( server ) {
const port = server . port || 8080 ;
return ` http:// ${ server . host } : ${ port } /feed ` ;
}
/**
* Ist das ein LS-Server, den wir abfragen können?
*
* Boolean() ist hier keine Kosmetik: ohne das gibt die Kette den Zugangs-Code
* selbst zurück, und der stünde beim ersten Log-Aufruf im Klartext da.
*/
export const istLsServer = ( s ) => Boolean (
String ( s . type ? ? '' ) . startsWith ( 'farmingsimulator' ) && s . host && s . token
) ;
async function holen ( url , timeoutMs , alsText = true ) {
const res = await fetch ( url , {
signal : AbortSignal . timeout ( timeoutMs ) ,
headers : { 'User-Agent' : 'd4rkbot-ls/1.0' } ,
} ) ;
if ( ! res . ok ) throw new Error ( ` HTTP ${ res . status } ` ) ;
return alsText ? res . text ( ) : Buffer . from ( await res . arrayBuffer ( ) ) ;
}
/**
* Alles holen, was ein LS-Server hergibt.
*
* Das Luftbild kostet je nach Grösse ein paar hundert KB — deshalb steht die
* Kantenlänge als Parameter da und nicht fest im Code.
*/
export async function lsAbfragen ( server , { timeoutMs = 15000 , kartenBreite = 1024 } = { } ) {
const basis = feedBasis ( server ) ;
const code = encodeURIComponent ( server . token ) ;
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 ( [
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 ( ( ) => [ ] ) ,
] ) ;
return { stand , bild , preise } ;
}
/**
* Was hat sich am Besitz geändert?
*
* 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.
*/
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 } ) ;
}
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 } ` ;
}
/**
* Ein Durchlauf für alle LS-Server.
*
* Als eigene Funktion exportiert, damit sie sich testen lässt, ohne einen
* Timer laufen zu lassen. Alles, was hier schiefgehen kann, betrifft immer nur
* einen Server — deshalb steckt das try/catch in der Schleife und nicht darum.
*/
export async function lsTick ( client ) {
if ( ! moduleEnabled ( 'ls_farm' ) ) return ;
const server = listGameservers ( ) . filter ( istLsServer ) ;
if ( server . length === 0 ) return ;
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 , {
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 } ) ) ) ;
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 ,
sorten : String ( getSetting ( 'ls_crops' ) ? ? '' ) . split ( ',' ) . map ( ( s ) => s . 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 ;
}
}
const neu = await kanal . send ( payload ) ;
setLsMessage ( s . id , neu . id ) ;
} catch ( error ) {
console . error ( ` [ls-farm] ${ s . name } : ` , error . message ) ;
}
}
}
/** "1=Alexander, 2=Timo" → { 1: 'Alexander', 2: 'Timo' } */
export function hofNamen ( roh ) {
const raus = { } ;
for ( const teil of String ( roh ? ? '' ) . split ( ',' ) ) {
const m = teil . match ( /^\s*(\d+)\s*=\s*(.+?)\s*$/ ) ;
if ( m ) raus [ Number ( m [ 1 ] ) ] = m [ 2 ] ;
}
return raus ;
}
export function startLsFarm ( client ) {
everyTuned ( 'ls_interval' , 'minutes' , ( ) => lsTick ( client ) , 'ls-farm' ) ;
client . once ( Events . ClientReady , ( ) => lsTick ( client ) . catch ( ( ) => { } ) ) ;
}