z_mirror/fmm
git clone https://git.raiguard.me/z_mirror/fmm
76c75f2
trunk
1package fmm 2 3import ( 4"cmp" 5"encoding/json" 6"errors" 7"fmt" 8"os" 9"path/filepath" 10"slices" 11) 12 13// Manager manages mdos for a given game directory. A game directory is 14// considered valid if it has either a config-path.cfg file or a 15// config/config.ini file. 16type Manager struct { 17DoSave bool 18Portal ModPortal 19 20gamePath string 21internalModsPath string 22modListJsonPath string 23modSettingsPath string 24modsPath string 25 26mods map [string ]* Mod 27 28modSettings * ModSettings 29} 30 31type PlayerData struct { 32Token string 33Username string 34} 35 36// Creates a new Manager for the given game directory. A game directory is 37// considered valid if it has either a config-path.cfg file or a 38// config/config.ini file. The player's username and token will 39// be automatically retrieved from `player-data.json` if it exists. 40func NewManager (gamePath string ,modsPath string ) (* Manager ,error ) { 41if ! entryExists (gamePath ,"data" ,"base" ,"info.json" )|| ! entryExists (modsPath ) { 42return nil ,ErrInvalidGameDirectory 43 } 44 45m := Manager { 46DoSave :true , 47Portal :ModPortal { 48downloadPath :modsPath , 49mods :map [string ]* PortalModInfo {}, 50server :"https://mods.factorio.com" , 51 }, 52 53gamePath :gamePath , 54internalModsPath :filepath .Join (gamePath ,"data" ), 55modListJsonPath :filepath .Join (modsPath ,"mod-list.json" ), 56modsPath :modsPath , 57modSettingsPath :filepath .Join (modsPath ,"mod-settings.dat" ), 58mods :map [string ]* Mod {}, 59 } 60 61if err := m .readPlayerData ();err != nil { 62return nil ,errors .Join (errors .New ("unable to get player data" ),err ) 63 } 64 65if ! entryExists (m .modsPath ) { 66if err := os .Mkdir ("mods" ,0755 );err != nil { 67return nil ,errors .Join (errors .New ("failed to create mods directory" ),err ) 68 } 69 } 70 71if err := m .parseInternalMods ();err != nil { 72return nil ,errors .Join (errors .New ("error parsing internal mods" ),err ) 73 } 74 75if err := m .parseMods ();err != nil { 76return nil ,errors .Join (errors .New ("error parsing mods" ),err ) 77 } 78 79for _ ,mod := range m .mods { 80slices .SortFunc (mod .releases ,func (a * Release ,b * Release )int { 81switch a .Version .Cmp (& b .Version ) { 82case VersionLt : 83return - 1 84case VersionGt : 85return 1 86case VersionEq : 87return 0 88// Should be unreachable 89default : 90return 0 91 } 92 }) 93 } 94 95if err := m .parseModList ();err != nil { 96return nil ,errors .Join (errors .New ("error parsing mod-list.json" ),err ) 97 } 98 99if base ,_ := m .GetMod ("base" );base != nil { 100m .Portal .baseVersion = & base .GetLatestRelease ().Version 101 } 102 103if entryExists (m .modSettingsPath ) { 104file ,err := os .Open (m .modSettingsPath ) 105if err != nil { 106return nil ,errors .Join (errors .New ("error parsing mod-settings.dat" ),err ) 107 } 108r := newDatReader (file ) 109m .modSettings = ptr (r .ReadModSettings ()) 110 } 111 112return & m ,nil 113} 114 115// Add downloads and enables the given mod. Returns the version that was added. 116func (m * Manager )Add (mod ModIdent ) (* Version ,error ) { 117ver ,err := m .Enable (mod ) 118if err == nil { 119return ver ,nil 120 } 121 122if ! errors .Is (err ,ErrModNotFoundLocal )&& ! errors .Is (err ,ErrNoCompatibleRelease ) { 123return nil ,err 124 } 125 126filepath ,err := m .Portal .DownloadRelease (mod .Name ,mod .Version ) 127if err != nil { 128return nil ,err 129 } 130release ,err := releaseFromFile (filepath ) 131if err != nil { 132return nil ,err 133 } 134m .addRelease (release ,false ) 135m .Enable (mod ) 136return & release .Version ,nil 137} 138 139// Requests the mod to be disabled. 140func (m * Manager )Disable (modName string )error { 141mod ,err := m .GetMod (modName ) 142if err != nil { 143return err 144 } 145if mod .Enabled == nil { 146return ErrModAlreadyDisabled 147 } 148mod .Enabled = nil 149return nil 150} 151 152// Requests all non-internal mods to be disabled. 153func (m * Manager )DisableAll () { 154for _ ,mod := range m .mods { 155// base is the only mod that is always enabled by default 156if mod .Name != "base" { 157mod .Enabled = nil 158 } 159 } 160} 161 162// Enable the given mod, if it exists. If version is nil, it will default to 163// the newest local release. Returns the version that was enabled, if any. 164func (m * Manager )Enable (ident ModIdent ) (* Version ,error ) { 165mod ,err := m .GetMod (ident .Name ) 166if err != nil { 167return nil ,err 168 } 169release := mod .GetRelease (ident .Version ) 170if release == nil { 171return nil ,ErrNoCompatibleRelease 172 } 173if mod .Enabled != nil && * mod .Enabled == release .Version { 174return nil ,nil 175 } 176toEnable := & release .Version 177mod .Enabled = toEnable 178return toEnable ,nil 179} 180 181// Retrieves the corresponding Mod object. 182func (m * Manager )GetMod (name string ) (* Mod ,error ) { 183mod := m .mods [name ] 184if mod == nil { 185return nil ,ErrModNotFoundLocal 186 } 187return mod ,nil 188} 189 190// GetMods returns a list of the mods managed by this Manager. 191func (m * Manager )GetMods () []ModIdent { 192mods := []ModIdent {} 193for _ ,mod := range m .mods { 194for _ ,release := range mod .releases { 195mods = append (mods ,ModIdent {Name :mod .Name ,Version :& release .Version }) 196 } 197 } 198return mods 199} 200 201// GetLatestMods gets a list of the newest mods managed by this Manager. 202func (m * Manager )GetLatestMods () []ModIdent { 203mods := []ModIdent {} 204for _ ,mod := range m .mods { 205mods = append (mods ,ModIdent {mod .Name ,& mod .releases [len (mod .releases )- 1 ].Version }) 206 } 207return mods 208} 209 210// Applies the requested modifications and saves to mod-list.json. 211func (m * Manager )Save ()error { 212if ! m .DoSave { 213return nil 214 } 215internalMods := map [string ]bool {} 216var ModListJson ModListJson 217for name ,mod := range m .mods { 218ModListJson .Mods = append (ModListJson .Mods ,ModListJsonMod { 219Name :name , 220Enabled :mod .Enabled != nil , 221Version :mod .Enabled , 222 }) 223if mod .isInternal { 224internalMods [name ]= true 225 } 226 } 227 228slices .SortFunc (ModListJson .Mods ,func (a ,b ModListJsonMod )int { 229if internalMods [a .Name ]!= internalMods [b .Name ] { 230if internalMods [a .Name ] { 231return - 1 232 }else { 233return 1 234 } 235 } 236if a .Name != b .Name { 237return cmp .Compare (a .Name ,b .Name ) 238 } 239switch a .Version .Cmp (b .Version ) { 240case VersionLt : 241return - 1 242case VersionGt : 243return 1 244default : 245return 0 246 } 247 }) 248 249marshaled ,err := json .MarshalIndent (ModListJson ,"" ," " ) 250if err != nil { 251return err 252 } 253err = os .WriteFile (m .modListJsonPath ,marshaled ,0666 ) 254if err != nil { 255return errors .Join (errors .New ("failed to write mod-list.json" ),err ) 256 } 257if m .modSettings != nil { 258file ,err := os .Create (m .modSettingsPath + ".tmp" ) 259if err != nil { 260return errors .Join (errors .New ("failed to open mod-settings.dat" ),err ) 261 } 262defer file .Close () 263w := newDatWriter (file ) 264w .WriteModSettings (m .modSettings ) 265w .Flush () 266if w .err != nil { 267return errors .Join (errors .New ("failed to write mod settings" ),w .err ) 268 } 269if err := os .Rename (file .Name (),m .modSettingsPath );err != nil { 270return errors .Join (errors .New ("failed to overwrite mod settings file" ),err ) 271 } 272 } 273return nil 274} 275 276// Returns the current upload API key. 277func (m * Manager )GetApiKey ()string { 278return m .Portal .apiKey 279} 280 281// Returns true if the Manager has an upload API key. 282func (m * Manager )HasApiKey ()bool { 283return m .Portal .apiKey != "" 284} 285 286// Sets the API key used for mod uploading. 287func (m * Manager )SetApiKey (key string ) { 288m .Portal .apiKey = key 289} 290 291// Returns the current player data. 292func (m * Manager )GetPlayerData ()PlayerData { 293return m .Portal .playerData 294} 295 296// Returns true if the Manager has valid player data. 297func (m * Manager )HasPlayerData ()bool { 298return m .Portal .playerData .Token != "" && m .Portal .playerData .Username != "" 299} 300 301// Sets the player data used for downloading mods. The player data will be 302// automatically retrieved from the game directory if it is available. 303func (m * Manager )SetPlayerData (playerData PlayerData ) { 304m .Portal .playerData = playerData 305} 306 307func (m * Manager )addRelease (release * Release ,isInternal bool ) { 308mod := m .mods [release .Name ] 309if mod == nil { 310mod = & Mod { 311Name :release .Name , 312releases : []* Release {}, 313isInternal :isInternal , 314 } 315m .mods [release .Name ]= mod 316 } 317mod .releases = append (mod .releases ,release ) 318} 319 320func (m * Manager )parseModList ()error { 321m .Enable (ModIdent {Name :"base" }) 322mlj ,err := ParseModListJson (m .modListJsonPath ) 323if err != nil { 324return err 325 } 326if mlj == nil { 327m .Enable (ModIdent {Name :"base" }) 328return nil 329 } 330 331for _ ,modEntry := range mlj .Mods { 332if ! modEntry .Enabled { 333continue 334 } 335mod := m .mods [modEntry .Name ] 336if mod == nil { 337continue 338 } 339if release := mod .GetRelease (modEntry .Version );release != nil { 340enabled := release .Version 341mod .Enabled = & enabled 342 } 343 } 344 345return nil 346} 347 348func (m * Manager )parseInternalMods ()error { 349entries ,err := os .ReadDir (m .internalModsPath ) 350if err != nil { 351return errors .Join (errors .New ("could not read internal mods directory" ),err ) 352 } 353 354for _ ,entry := range entries { 355filename := entry .Name () 356if filename == "core" || ! entry .Type ().IsDir () { 357continue 358 } 359// Not all directories are necessarily mods 360if _ ,err := os .Stat (filepath .Join (m .internalModsPath ,filename ,"info.json" ));err != nil { 361continue 362 } 363release ,err := releaseFromFile (filepath .Join (m .internalModsPath ,filename )) 364if err != nil { 365return errors .Join (errors .New (fmt .Sprint ("unable to parse " ,filename )),err ) 366 } 367m .addRelease (release ,true ) 368 } 369 370return nil 371} 372 373func (m * Manager )parseMods ()error { 374entries ,err := os .ReadDir (m .modsPath ) 375if err != nil { 376return errors .Join (errors .New ("could not read mods directory" ),err ) 377 } 378 379for _ ,entry := range entries { 380filename := entry .Name () 381if filename == "mod-list.json" || filename == "mod-settings.dat" { 382continue 383 } 384release ,err := releaseFromFile (filepath .Join (m .modsPath ,filename )) 385if err != nil { 386return errors .Join (errors .New (fmt .Sprint ("invalid mod " ,filename )),err ) 387 } 388m .addRelease (release ,false ) 389 } 390 391return nil 392} 393 394func (m * Manager )readPlayerData ()error { 395playerDataJsonPath := filepath .Join (m .gamePath ,"player-data.json" ) 396if ! entryExists (playerDataJsonPath ) { 397return nil 398 } 399 400data ,err := os .ReadFile (playerDataJsonPath ) 401if err != nil { 402return errors .Join (errors .New ("unable to read player-data.json" ),err ) 403 } 404var playerDataJson playerDataJson 405err = json .Unmarshal (data ,& playerDataJson ) 406if err != nil { 407return errors .Join (errors .New ("invalid player-data.json format" ),err ) 408 } 409if playerDataJson .ServiceToken != nil { 410m .Portal .playerData .Token = * playerDataJson .ServiceToken 411 } 412if playerDataJson .ServiceUsername != nil { 413m .Portal .playerData .Username = * playerDataJson .ServiceUsername 414 } 415 416return nil 417} 418 419func entryExists (pathParts ... string )bool { 420_ ,err := os .Stat (filepath .Join (pathParts ... )) 421return err == nil 422} 423 424func (m * Manager )ExpandDependencies (mods []ModIdent ,fetchFromPortal bool ) []ModIdent { 425visited := map [string ]bool {} 426toVisit := []Dependency {} 427for _ ,mod := range mods { 428toVisit = append (toVisit ,Dependency { 429Name :mod .Name , 430Version :mod .Version , 431Kind :DependencyRequired , 432Req :VersionEq , 433 }) 434 } 435output := []ModIdent {} 436 437for i := 0 ;i < len (toVisit );i += 1 { 438dep := toVisit [i ] 439if visited [dep .Name ] { 440continue 441 } 442visited [dep .Name ]= true 443var ident * ModIdent 444var deps []* Dependency 445mod ,err := m .GetMod (dep .Name ) 446if err != nil && err != ErrModNotFoundLocal { 447fmt .Println (err ) 448 } 449if mod != nil { 450release := mod .GetMatchingRelease (& dep ) 451if release != nil { 452ident = & ModIdent {Name :release .Name ,Version :& release .Version } 453deps = release .Dependencies 454 } 455 } 456if ident == nil && fetchFromPortal { 457var release * PortalModRelease 458release ,err = m .Portal .GetMatchingRelease (& dep ) 459if err == nil { 460ident = & ModIdent {dep .Name ,& release .Version } 461deps = release .InfoJson .Dependencies 462 } 463 } 464if err != nil { 465fmt .Println (err ) 466continue 467 } 468if ident == nil { 469continue 470 } 471output = append (output ,* ident ) 472for _ ,dep := range deps { 473if dep .Kind == DependencyRequired || dep .Kind == DependencyNoLoadOrder || dep .Kind == DependencyRecommended { 474toVisit = append (toVisit ,* dep ) 475 } 476 } 477 } 478 479return output 480} 481 482func (m * Manager )MergeStartupModSettings (input PropertyTree )error { 483if input == nil { 484return nil 485 } 486if _ ,ok := input .(* PropertyTreeNone );ok { 487return nil 488 } 489inputSettings ,ok := input .(* PropertyTreeDict ) 490if ! ok { 491return errors .New ("input mod settings have invalid structure" ) 492 } 493if m .modSettings == nil { 494base ,err := m .GetMod ("base" ) 495if err != nil { 496return err 497 } 498m .modSettings = & ModSettings { 499MapVersion :base .GetLatestRelease ().Version , 500Settings :& PropertyTreeDict { 501"startup" :& PropertyTreeDict {}, 502"runtime-global" :& PropertyTreeDict {}, 503"runtime-per-user" :& PropertyTreeDict {}, 504 }, 505 } 506 } 507 508modSettings ,ok := m .modSettings .Settings .(* PropertyTreeDict ) 509if ! ok { 510return errors .New ("mod settings have invalid structure" ) 511 } 512startupSettings ,ok := (* modSettings )["startup" ].(* PropertyTreeDict ) 513if ! ok { 514return errors .New ("mod startup settings have invalid structure" ) 515 } 516for key ,value := range * inputSettings { 517 (* startupSettings )[key ]= value 518 } 519 520m .DoSave = true 521 522return nil 523} 524 525func (m * Manager )CheckDownloadUpdates (mods []ModIdent ) { 526if len (mods )== 0 { 527mods = m .GetLatestMods () 528 } 529for _ ,mod := range mods { 530info ,err := m .Portal .GetModInfo (mod .Name ) 531if err != nil { 532fmt .Println (err ) 533continue 534 } 535if len (info .Releases )== 0 { 536continue // WTF? 537 } 538if info .Releases [len (info .Releases )- 1 ].Version .Cmp (mod .Version )== VersionGt { 539m .Portal .DownloadLatestRelease (mod .Name ) 540 } 541 } 542}