z_mirror/fmm

git clone https://git.raiguard.me/z_mirror/fmm

Caleb HeuerSupport recommended dependencies76c75f2

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