diff --git a/.gitignore b/.gitignore index 402e0389..b6e5e04d 100755 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ factorio.tar cw-local-config.json .cw-channel-update support/modUpdateHistory.dat +map-gen-json/ diff --git a/README.md b/README.md index eece5db6..b5b2de19 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Common generated files and directories: - `cw.lock` - `../playerdb.json` - `../map-gen-json/` +- `./map-gen-json/` for per-server `/map-exchange` custom settings - `./log/` - `./audit-log/` - `../www/public_html/archive/` diff --git a/cfg/globalCfg.go b/cfg/globalCfg.go index 25f89df4..32c945e8 100755 --- a/cfg/globalCfg.go +++ b/cfg/globalCfg.go @@ -44,10 +44,18 @@ func setGlobalDefaults() { if Global.Paths.DataFiles.DBFile == "" { Global.Paths.DataFiles.DBFile = "playerdb.json" } + if Global.Paths.Folders.ServersRoot == "" { + ex, err := os.Executable() + if err == nil { + exPath := filepath.Dir(ex) + p := filepath.Clean(filepath.Join(exPath, "..")) + Global.Paths.Folders.ServersRoot = p + "/" + } + } if Global.Paths.Folders.MapGenerators == "" { - Global.Paths.Folders.MapGenerators = "map-gen-json" + Global.Paths.Folders.MapGenerators = constants.DefaultMapGeneratorsDir - err := os.MkdirAll(Global.Paths.Folders.ServersRoot+"/"+Global.Paths.Folders.MapGenerators, os.ModePerm) + err := os.MkdirAll(filepath.Join(Global.Paths.Folders.ServersRoot, Global.Paths.Folders.MapGenerators), os.ModePerm) if err != nil { cwlog.DoLogCW("Could not create map-gen-json directory.") } @@ -64,15 +72,6 @@ func setGlobalDefaults() { cwlog.DoLogCW("No group name specified. Random one generated.") } - if Global.Paths.Folders.ServersRoot == "" { - ex, err := os.Executable() - if err == nil { - exPath := filepath.Dir(ex) - p := filepath.Clean(filepath.Join(exPath, "..")) - Global.Paths.Folders.ServersRoot = p + "/" - } - } - if Global.Paths.ChatWirePrefix == "" { Global.Paths.ChatWirePrefix = "cw-" } diff --git a/cfg/pathutil.go b/cfg/pathutil.go index 84df3bda..8731e2ef 100644 --- a/cfg/pathutil.go +++ b/cfg/pathutil.go @@ -1,6 +1,12 @@ package cfg -import "ChatWire/constants" +import ( + "os" + "path/filepath" + "strings" + + "ChatWire/constants" +) // GetFactorioFolder returns the path to the Factorio installation for the current server. func GetFactorioFolder() string { @@ -27,3 +33,41 @@ func GetSavesFolder() string { Global.Paths.Folders.FactorioDir + "/" + Global.Paths.Folders.Saves } + +// GetSharedMapGeneratorFolder returns the parent-level folder for reusable named generators. +func GetSharedMapGeneratorFolder() string { + return filepath.Join(Global.Paths.Folders.ServersRoot, Global.Paths.Folders.MapGenerators) +} + +// GetLocalMapGeneratorFolder returns this ChatWire instance's local generator folder. +func GetLocalMapGeneratorFolder() string { + folder := Global.Paths.Folders.MapGenerators + if folder == "" { + folder = constants.DefaultMapGeneratorsDir + } + + folder = filepath.Base(filepath.Clean(folder)) + if folder == "." || folder == string(os.PathSeparator) { + folder = constants.DefaultMapGeneratorsDir + } + + localPath := filepath.Join(".", folder) + if absPath, err := filepath.Abs(localPath); err == nil { + return absPath + } + return localPath +} + +// GetMapGeneratorFolder returns the folder where a generator's JSON files live. +func GetMapGeneratorFolder(name string) string { + if strings.EqualFold(name, constants.CustomMapGeneratorName) { + return GetLocalMapGeneratorFolder() + } + return GetSharedMapGeneratorFolder() +} + +// GetMapGeneratorFiles returns Factorio's map-gen and map-settings JSON paths for a generator. +func GetMapGeneratorFiles(name string) (string, string) { + dir := GetMapGeneratorFolder(name) + return filepath.Join(dir, name+"-gen.json"), filepath.Join(dir, name+"-set.json") +} diff --git a/commands/commands_moderator.go b/commands/commands_moderator.go index b13ebeac..5067eb86 100644 --- a/commands/commands_moderator.go +++ b/commands/commands_moderator.go @@ -295,6 +295,20 @@ func moderatorCommands() []glob.CommandData { Type: discordgo.ChatApplicationCommand, }, Function: moderator.MapReset, ModeratorOnly: true}, + {AppCmd: glob.AppCmdData{ + Name: "map-exchange", + Description: "Generate a custom map from a Factorio map exchange string.", + Type: discordgo.ChatApplicationCommand, + Options: []glob.OptionData{ + { + Name: "exchange-string", + Description: "Paste the Factorio map exchange string.", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + }, + }, + Function: moderator.MapExchange, ModeratorOnly: true}, {AppCmd: glob.AppCmdData{ Name: "config-server", Description: "Server settings and options, such as the name.", diff --git a/commands/moderator/mapExchange.go b/commands/moderator/mapExchange.go new file mode 100644 index 00000000..ac45ed8c --- /dev/null +++ b/commands/moderator/mapExchange.go @@ -0,0 +1,55 @@ +package moderator + +import ( + "fmt" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + + "ChatWire/constants" + "ChatWire/disc" + "ChatWire/fact" + "ChatWire/glob" + "ChatWire/modupdate" +) + +// MapExchange converts a Factorio map exchange string into the custom map +// generator files and immediately generates a new save from them. +func MapExchange(cmd *glob.CommandData, i *discordgo.InteractionCreate) { + if fact.FactorioBooted || fact.FactIsRunning { + disc.InteractionEphemeralResponse(i, "Error:", "Factorio is currently running. You must stop Factorio first.") + return + } + + exchangeString := "" + for _, arg := range i.ApplicationCommandData().Options { + if arg.Type == discordgo.ApplicationCommandOptionString && strings.EqualFold(arg.Name, "exchange-string") { + exchangeString = arg.StringValue() + break + } + } + + if strings.TrimSpace(exchangeString) == "" { + disc.InteractionEphemeralResponse(i, "Error:", "You must supply a map exchange string.") + return + } + + disc.InteractionEphemeralResponse(i, "Status:", "Converting map exchange string and generating a custom map.") + + fileName, err := fact.GenCustomMapFromExchange(exchangeString) + if err != nil { + msg := fmt.Sprintf("Unable to generate custom map: %v", err) + disc.InteractionEphemeralResponseColor(i, "Error:", msg, glob.COLOR_RED) + return + } + + if i != nil && i.Member != nil && i.Member.User != nil { + newHist := modupdate.ModHistoryItem{InfoItem: true, + Name: "Generate Custom Map By: " + i.Member.User.Username, Notes: fileName, Date: time.Now()} + modupdate.AddModHistory(newHist) + } + + msg := fmt.Sprintf("Generated `%s` using `%s` map settings.", fileName, constants.CustomMapGeneratorName) + disc.InteractionEphemeralResponse(i, "Map exchange", msg) +} diff --git a/commands/moderator/settingsUtil.go b/commands/moderator/settingsUtil.go index b423829b..a6196313 100755 --- a/commands/moderator/settingsUtil.go +++ b/commands/moderator/settingsUtil.go @@ -11,19 +11,21 @@ import ( /* Get list of map generation presets, because an invalid one will make map generation fail */ func getMapGenNames() []string { - path := cfg.Global.Paths.Folders.ServersRoot + cfg.Global.Paths.Folders.MapGenerators + output := []string{"none", constants.CustomMapGeneratorName} + + path := cfg.GetSharedMapGeneratorFolder() files, err := os.ReadDir(path) if err != nil { cwlog.DoLogCW(err.Error()) - return nil + return output } - var output []string - - output = append(output, "none") for _, f := range files { if strings.HasSuffix(f.Name(), "-gen.json") { - output = append(output, strings.TrimSuffix(f.Name(), "-gen.json")) + name := strings.TrimSuffix(f.Name(), "-gen.json") + if !strings.EqualFold(name, constants.CustomMapGeneratorName) { + output = append(output, name) + } } } return output diff --git a/constants/consts.go b/constants/consts.go index a3c1d18b..666f3c8b 100755 --- a/constants/consts.go +++ b/constants/consts.go @@ -5,7 +5,7 @@ import "time" const ( ProgName = "ChatWire" Version = "2706-04.27.2026-1905a" // build-mm.dd.yyyy-hhmmsuffix - CWEpoch = 1653239822390688174 //Never change this + CWEpoch = 1653239822390688174 //Never change this SeenDivisor = 60 SeenEpoch = 1546326000 Unknown = "Unknown" @@ -15,29 +15,31 @@ const ( PassExpireSec = 5 * 60 /* ChatWire files */ - CWGlobalConfig = "../cw-global-config.json" - CWLocalConfig = "cw-local-config.json" - WhitelistName = "server-whitelist.json" - AdminlistName = "server-adminlist.json" - ServSettingsName = "server-settings.json" - ModSettingsName = "mod-settings.dat" - ModListName = "mod-list.json" - ModVersionsName = "mod-versions.json" - ModsQueueFolder = "mods-queue" - ModsFolder = "mods" - OldModsDir = "old" - RoleListFile = "../RoleList.dat" - VoteFile = "votes.dat" - ArchiveFolderSuffix = " maps" - TempSaveName = "softmod.tmp" - BootUpdateDelayMin = 2 - SusWarningInterval = 2 - CrashReportInterval = 300 - MaxSaveBackups = 10 - ModPackLifeMins = 180 - ModPackCooldownMin = 5 - MaxModPacks = 4 - ErrMsgDelay = time.Second * 3 + CWGlobalConfig = "../cw-global-config.json" + CWLocalConfig = "cw-local-config.json" + WhitelistName = "server-whitelist.json" + AdminlistName = "server-adminlist.json" + ServSettingsName = "server-settings.json" + ModSettingsName = "mod-settings.dat" + ModListName = "mod-list.json" + ModVersionsName = "mod-versions.json" + DefaultMapGeneratorsDir = "map-gen-json" + CustomMapGeneratorName = "custom" + ModsQueueFolder = "mods-queue" + ModsFolder = "mods" + OldModsDir = "old" + RoleListFile = "../RoleList.dat" + VoteFile = "votes.dat" + ArchiveFolderSuffix = " maps" + TempSaveName = "softmod.tmp" + BootUpdateDelayMin = 2 + SusWarningInterval = 2 + CrashReportInterval = 300 + MaxSaveBackups = 10 + ModPackLifeMins = 180 + ModPackCooldownMin = 5 + MaxModPacks = 4 + ErrMsgDelay = time.Second * 3 // Default offset added to Factorio server port for RCON RconPortOffset = 10000 diff --git a/fact/mapExchange.go b/fact/mapExchange.go new file mode 100644 index 00000000..591bfbf5 --- /dev/null +++ b/fact/mapExchange.go @@ -0,0 +1,698 @@ +// Portions of the binary map exchange parser are adapted from +// rfvgyhn/factorio-exchange-string-parser. +// +// # MIT License +// +// # Copyright (c) 2024 rfvgyhn +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +package fact + +import ( + "bytes" + "compress/zlib" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "hash/crc32" + "io" + "math" + "os" + "path/filepath" + "strings" + "unicode" + + "ChatWire/cfg" + "ChatWire/constants" + "ChatWire/cwlog" + "ChatWire/util" +) + +// MapExchangeData contains the JSON payloads Factorio accepts as map settings. +type MapExchangeData struct { + Version [4]uint16 + MapGenSettings map[string]interface{} + MapSettings map[string]interface{} + Checksum uint32 + ChecksumOK bool +} + +// ParseMapExchangeString converts a Factorio map exchange string into the two +// JSON settings tables expected by Factorio's --map-gen-settings and +// --map-settings command line flags. +// +// The binary field order follows Factorio's documented exchange string format +// and the MIT-licensed converter at: +// https://github.com/rfvgyhn/factorio-exchange-string-parser +func ParseMapExchangeString(input string) (*MapExchangeData, error) { + trimmed := strings.TrimSpace(input) + if strings.HasPrefix(trimmed, "{") { + return parseMapExchangeJSON(trimmed) + } + + compact := strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r + }, trimmed) + + if !strings.HasPrefix(compact, ">>>") || !strings.HasSuffix(compact, "<<<") { + return nil, fmt.Errorf("invalid map exchange string: expected >>>...<<<") + } + + encoded := strings.TrimSuffix(strings.TrimPrefix(compact, ">>>"), "<<<") + decoded, err := decodeMapExchangeBase64(encoded) + if err != nil { + return nil, fmt.Errorf("invalid map exchange base64: %w", err) + } + + zr, err := zlib.NewReader(bytes.NewReader(decoded)) + if err != nil { + return nil, fmt.Errorf("invalid or unsupported map exchange compression: %w", err) + } + raw, err := io.ReadAll(zr) + closeErr := zr.Close() + if err != nil { + return nil, fmt.Errorf("unable to decompress map exchange string: %w", err) + } + if closeErr != nil { + return nil, fmt.Errorf("unable to close map exchange decoder: %w", closeErr) + } + if len(raw) < 4 { + return nil, fmt.Errorf("map exchange data is too short") + } + + p := newMapExchangeParser(raw) + version := p.readVersion() + atLeastV2 := versionAtLeast(version, 2, 0, 0, 0) + + data := &MapExchangeData{ + Version: version, + MapGenSettings: nil, + MapSettings: nil, + } + _ = p.readUint8() // Unknown exchange-format byte. + data.MapGenSettings = p.readMapGenSettings(atLeastV2) + data.MapSettings = p.readMapSettings(atLeastV2) + data.Checksum = p.readUint32() + + if p.err != nil { + return nil, p.err + } + if p.pos != len(raw) { + return nil, fmt.Errorf("unexpected data after map exchange payload: %d bytes", len(raw)-p.pos) + } + + crcIndex := len(raw) - 4 + actual := binary.LittleEndian.Uint32(raw[crcIndex:]) + expected := crc32.ChecksumIEEE(raw[:crcIndex]) + data.ChecksumOK = actual == expected + if !data.ChecksumOK { + cwlog.DoLogCW("ParseMapExchangeString: checksum failed") + } + + return data, nil +} + +func parseMapExchangeJSON(input string) (*MapExchangeData, error) { + var parsed struct { + MapSettings map[string]interface{} `json:"map_settings"` + MapGenSettings map[string]interface{} `json:"map_gen_settings"` + MapSettingsCamel map[string]interface{} `json:"mapSettings"` + MapGenSettingsCam map[string]interface{} `json:"mapGenSettings"` + } + + dec := json.NewDecoder(strings.NewReader(input)) + dec.UseNumber() + if err := dec.Decode(&parsed); err != nil { + return nil, fmt.Errorf("invalid map exchange JSON: %w", err) + } + + if parsed.MapSettings == nil { + parsed.MapSettings = parsed.MapSettingsCamel + } + if parsed.MapGenSettings == nil { + parsed.MapGenSettings = parsed.MapGenSettingsCam + } + if parsed.MapSettings == nil || parsed.MapGenSettings == nil { + return nil, fmt.Errorf("map exchange JSON must contain map_settings and map_gen_settings") + } + + return &MapExchangeData{ + MapSettings: parsed.MapSettings, + MapGenSettings: parsed.MapGenSettings, + ChecksumOK: true, + }, nil +} + +func decodeMapExchangeBase64(encoded string) ([]byte, error) { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err == nil { + return decoded, nil + } + + padded := encoded + switch len(padded) % 4 { + case 2: + padded += "==" + case 3: + padded += "=" + } + if padded != encoded { + if decoded, padErr := base64.StdEncoding.DecodeString(padded); padErr == nil { + return decoded, nil + } + } + + decoded, rawErr := base64.RawStdEncoding.DecodeString(encoded) + if rawErr == nil { + return decoded, nil + } + return nil, err +} + +// WriteCustomMapExchangeFiles writes the parsed exchange settings as the +// "custom" map generator pair used by GenNewMap. +func WriteCustomMapExchangeFiles(exchangeString string) (string, string, error) { + data, err := ParseMapExchangeString(exchangeString) + if err != nil { + return "", "", err + } + + genPath, setPath := cfg.GetMapGeneratorFiles(constants.CustomMapGeneratorName) + dir := filepath.Dir(genPath) + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return "", "", fmt.Errorf("unable to create map generator directory: %w", err) + } + + if err := util.WriteJSONAtomic(genPath, data.MapGenSettings, 0644); err != nil { + return "", "", fmt.Errorf("unable to write custom map-gen settings: %w", err) + } + if err := util.WriteJSONAtomic(setPath, data.MapSettings, 0644); err != nil { + return "", "", fmt.Errorf("unable to write custom map settings: %w", err) + } + + return genPath, setPath, nil +} + +// GenCustomMapFromExchange writes custom map settings, selects the custom +// generator in local config, then generates a new save using GenNewMap. +func GenCustomMapFromExchange(exchangeString string) (string, error) { + if _, _, err := WriteCustomMapExchangeFiles(exchangeString); err != nil { + return "", err + } + + cfg.Local.Settings.MapGenerator = constants.CustomMapGeneratorName + if !cfg.WriteLCfg() { + return "", fmt.Errorf("unable to save cw-local config") + } + + fileName, err := GenNewMap() + if err != nil { + return "", err + } + return fileName, nil +} + +type mapExchangeParser struct { + data []byte + pos int + err error + lastPosition mapExchangePosition +} + +type mapExchangePosition struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +func newMapExchangeParser(data []byte) *mapExchangeParser { + return &mapExchangeParser{data: data} +} + +func (p *mapExchangeParser) setErr(format string, args ...interface{}) { + if p.err == nil { + p.err = fmt.Errorf(format, args...) + } +} + +func (p *mapExchangeParser) readBytes(n int) []byte { + if p.err != nil { + return nil + } + if n < 0 || p.pos+n > len(p.data) { + p.setErr("map exchange data ended unexpectedly at byte %d", p.pos) + return nil + } + out := p.data[p.pos : p.pos+n] + p.pos += n + return out +} + +func (p *mapExchangeParser) readBool() bool { + return p.readUint8() != 0 +} + +func (p *mapExchangeParser) readUint8() uint8 { + b := p.readBytes(1) + if b == nil { + return 0 + } + return b[0] +} + +func (p *mapExchangeParser) readInt16() int16 { + b := p.readBytes(2) + if b == nil { + return 0 + } + return int16(binary.LittleEndian.Uint16(b)) +} + +func (p *mapExchangeParser) readUint16() uint16 { + b := p.readBytes(2) + if b == nil { + return 0 + } + return binary.LittleEndian.Uint16(b) +} + +func (p *mapExchangeParser) readInt32() int32 { + b := p.readBytes(4) + if b == nil { + return 0 + } + return int32(binary.LittleEndian.Uint32(b)) +} + +func (p *mapExchangeParser) readUint32() uint32 { + b := p.readBytes(4) + if b == nil { + return 0 + } + return binary.LittleEndian.Uint32(b) +} + +func (p *mapExchangeParser) readUint32SO() uint32 { + value := p.readUint8() + if value == 0xff { + return p.readUint32() + } + return uint32(value) +} + +func (p *mapExchangeParser) readFloat() float64 { + return float64(math.Float32frombits(p.readUint32())) +} + +func (p *mapExchangeParser) readDouble() float64 { + b := p.readBytes(8) + if b == nil { + return 0 + } + return math.Float64frombits(binary.LittleEndian.Uint64(b)) +} + +func (p *mapExchangeParser) readString() string { + size := p.readUint32SO() + if size > uint32(len(p.data)-p.pos) { + p.setErr("map exchange string length %d exceeds remaining data", size) + return "" + } + b := p.readBytes(int(size)) + if b == nil { + return "" + } + return string(b) +} + +func (p *mapExchangeParser) readVersion() [4]uint16 { + return [4]uint16{p.readUint16(), p.readUint16(), p.readUint16(), p.readUint16()} +} + +func versionAtLeast(version [4]uint16, major, minor, patch, dev uint16) bool { + target := [4]uint16{major, minor, patch, dev} + for i := 0; i < len(version); i++ { + if version[i] > target[i] { + return true + } + if version[i] < target[i] { + return false + } + } + return true +} + +func (p *mapExchangeParser) readOptional(readValue func() interface{}) interface{} { + if !p.readBool() { + return nil + } + return readValue() +} + +func (p *mapExchangeParser) readArray(readItem func() interface{}) []interface{} { + size := p.readUint32SO() + if size > 100000 { + p.setErr("map exchange array length %d is too large", size) + return nil + } + out := make([]interface{}, 0, int(size)) + for i := uint32(0); i < size && p.err == nil; i++ { + out = append(out, readItem()) + } + return out +} + +func (p *mapExchangeParser) readStringArray() []interface{} { + return p.readArray(func() interface{} { + return p.readString() + }) +} + +func (p *mapExchangeParser) readStringDict(readValue func() interface{}) map[string]interface{} { + size := p.readUint32SO() + if size > 100000 { + p.setErr("map exchange dictionary length %d is too large", size) + return nil + } + out := make(map[string]interface{}, int(size)) + for i := uint32(0); i < size && p.err == nil; i++ { + key := p.readString() + out[key] = readValue() + } + return out +} + +func (p *mapExchangeParser) readFrequencySizeRichness() interface{} { + return map[string]interface{}{ + "frequency": p.readFloat(), + "size": p.readFloat(), + "richness": p.readFloat(), + } +} + +func (p *mapExchangeParser) readAutoplaceSetting() interface{} { + return map[string]interface{}{ + "treat_missing_as_default": p.readBool(), + "settings": p.readStringDict(func() interface{} { + return p.readFrequencySizeRichness() + }), + } +} + +func (p *mapExchangeParser) readMapPosition() interface{} { + var x, y float64 + xDiff := float64(p.readInt16()) / 256 + if xDiff == float64(0x7fff)/256 { + x = float64(p.readInt32()) / 256 + y = float64(p.readInt32()) / 256 + } else { + yDiff := float64(p.readInt16()) / 256 + x = p.lastPosition.X + xDiff + y = p.lastPosition.Y + yDiff + } + + p.lastPosition.X = x + p.lastPosition.Y = y + + return map[string]interface{}{ + "x": x, + "y": y, + } +} + +func (p *mapExchangeParser) readBoundingBox() interface{} { + return map[string]interface{}{ + "left_top": p.readMapPosition(), + "right_bottom": p.readMapPosition(), + "orientation": map[string]interface{}{ + "x": p.readInt16(), + "y": p.readInt16(), + }, + } +} + +func (p *mapExchangeParser) readCliffSettings(atLeastV2 bool) interface{} { + settings := map[string]interface{}{ + "name": p.readString(), + } + if atLeastV2 { + _ = p.readUint8() // New 2.x field not represented in JSON settings. + } + settings["cliff_elevation_0"] = p.readFloat() + settings["cliff_elevation_interval"] = p.readFloat() + settings["richness"] = p.readFloat() + if atLeastV2 { + settings["cliff_smoothing"] = p.readFloat() + } + return settings +} + +func (p *mapExchangeParser) readTerritorySettings() interface{} { + return map[string]interface{}{ + "units": p.readStringArray(), + "territory_index_expression": p.readString(), + "territory_variation_expression": p.readString(), + "minimum_territory_size": p.readUint32(), + } +} + +func (p *mapExchangeParser) readMapGenSettings(atLeastV2 bool) map[string]interface{} { + terrainSegmentation := float64(0) + water := float64(0) + if !atLeastV2 { + terrainSegmentation = p.readFloat() + water = p.readFloat() + } + + settings := map[string]interface{}{ + "autoplace_controls": p.readStringDict(func() interface{} { + return p.readFrequencySizeRichness() + }), + "autoplace_settings": p.readStringDict(func() interface{} { + return p.readAutoplaceSetting() + }), + "default_enable_all_autoplace_controls": p.readBool(), + "seed": p.readUint32(), + "width": p.readUint32(), + "height": p.readUint32(), + "area_to_generate_at_start": p.readBoundingBox(), + "starting_area": p.readFloat(), + "peaceful_mode": p.readBool(), + "starting_points": nil, + "property_expression_names": nil, + "cliff_settings": nil, + } + if atLeastV2 { + settings["no_enemies_mode"] = p.readBool() + } + settings["starting_points"] = p.readArray(func() interface{} { + return p.readMapPosition() + }) + settings["property_expression_names"] = p.readStringDict(func() interface{} { + return p.readString() + }) + settings["cliff_settings"] = p.readCliffSettings(atLeastV2) + if atLeastV2 { + territorySettings := p.readOptional(func() interface{} { + return p.readTerritorySettings() + }) + if territorySettings != nil { + settings["territory_settings"] = territorySettings + } + } else { + settings["terrain_segmentation"] = terrainSegmentation + settings["water"] = water + } + return settings +} + +func (p *mapExchangeParser) readPollution() interface{} { + return map[string]interface{}{ + "enabled": p.readOptional(func() interface{} { return p.readBool() }), + "diffusion_ratio": p.readOptional(func() interface{} { return p.readDouble() }), + "min_to_diffuse": p.readOptional(func() interface{} { return p.readDouble() }), + "ageing": p.readOptional(func() interface{} { return p.readDouble() }), + "expected_max_per_chunk": p.readOptional(func() interface{} { return p.readDouble() }), + "min_to_show_per_chunk": p.readOptional(func() interface{} { return p.readDouble() }), + "min_pollution_to_damage_trees": p.readOptional(func() interface{} { return p.readDouble() }), + "pollution_with_max_forest_damage": p.readOptional(func() interface{} { return p.readDouble() }), + "pollution_per_tree_damage": p.readOptional(func() interface{} { return p.readDouble() }), + "pollution_restored_per_tree_damage": p.readOptional(func() interface{} { return p.readDouble() }), + "max_pollution_to_restore_trees": p.readOptional(func() interface{} { return p.readDouble() }), + "enemy_attack_pollution_consumption_modifier": p.readOptional(func() interface{} { return p.readDouble() }), + } +} + +func (p *mapExchangeParser) readRealSteering() interface{} { + return map[string]interface{}{ + "radius": p.readOptional(func() interface{} { return p.readDouble() }), + "separation_factor": p.readOptional(func() interface{} { return p.readDouble() }), + "separation_force": p.readOptional(func() interface{} { return p.readDouble() }), + "force_unit_fuzzy_goto_behavior": p.readOptional(func() interface{} { return p.readBool() }), + } +} + +func (p *mapExchangeParser) readSteering() interface{} { + return map[string]interface{}{ + "default": p.readRealSteering(), + "moving": p.readRealSteering(), + } +} + +func (p *mapExchangeParser) readEnemyEvolution() interface{} { + return map[string]interface{}{ + "enabled": p.readOptional(func() interface{} { return p.readBool() }), + "time_factor": p.readOptional(func() interface{} { return p.readDouble() }), + "destroy_factor": p.readOptional(func() interface{} { return p.readDouble() }), + "pollution_factor": p.readOptional(func() interface{} { return p.readDouble() }), + } +} + +func (p *mapExchangeParser) readEnemyExpansion() interface{} { + return map[string]interface{}{ + "enabled": p.readOptional(func() interface{} { return p.readBool() }), + "max_expansion_distance": p.readOptional(func() interface{} { return p.readUint32() }), + "friendly_base_influence_radius": p.readOptional(func() interface{} { return p.readUint32() }), + "enemy_building_influence_radius": p.readOptional(func() interface{} { return p.readUint32() }), + "building_coefficient": p.readOptional(func() interface{} { return p.readDouble() }), + "other_base_coefficient": p.readOptional(func() interface{} { return p.readDouble() }), + "neighbouring_chunk_coefficient": p.readOptional(func() interface{} { return p.readDouble() }), + "neighbouring_base_chunk_coefficient": p.readOptional(func() interface{} { return p.readDouble() }), + "max_colliding_tiles_coefficient": p.readOptional(func() interface{} { return p.readDouble() }), + "settler_group_min_size": p.readOptional(func() interface{} { return p.readUint32() }), + "settler_group_max_size": p.readOptional(func() interface{} { return p.readUint32() }), + "min_expansion_cooldown": p.readOptional(func() interface{} { return p.readUint32() }), + "max_expansion_cooldown": p.readOptional(func() interface{} { return p.readUint32() }), + } +} + +func (p *mapExchangeParser) readUnitGroup() interface{} { + return map[string]interface{}{ + "min_group_gathering_time": p.readOptional(func() interface{} { return p.readUint32() }), + "max_group_gathering_time": p.readOptional(func() interface{} { return p.readUint32() }), + "max_wait_time_for_late_members": p.readOptional(func() interface{} { return p.readUint32() }), + "max_group_radius": p.readOptional(func() interface{} { return p.readDouble() }), + "min_group_radius": p.readOptional(func() interface{} { return p.readDouble() }), + "max_member_speedup_when_behind": p.readOptional(func() interface{} { return p.readDouble() }), + "max_member_slowdown_when_ahead": p.readOptional(func() interface{} { return p.readDouble() }), + "max_group_slowdown_factor": p.readOptional(func() interface{} { return p.readDouble() }), + "max_group_member_fallback_factor": p.readOptional(func() interface{} { return p.readDouble() }), + "member_disown_distance": p.readOptional(func() interface{} { return p.readDouble() }), + "tick_tolerance_when_member_arrives": p.readOptional(func() interface{} { return p.readUint32() }), + "max_gathering_unit_groups": p.readOptional(func() interface{} { return p.readUint32() }), + "max_unit_group_size": p.readOptional(func() interface{} { return p.readUint32() }), + } +} + +func (p *mapExchangeParser) readPathFinder() interface{} { + return map[string]interface{}{ + "fwd2bwd_ratio": p.readOptional(func() interface{} { return p.readInt32() }), + "goal_pressure_ratio": p.readOptional(func() interface{} { return p.readDouble() }), + "use_path_cache": p.readOptional(func() interface{} { return p.readBool() }), + "max_steps_worked_per_tick": p.readOptional(func() interface{} { return p.readDouble() }), + "max_work_done_per_tick": p.readOptional(func() interface{} { return p.readUint32() }), + "short_cache_size": p.readOptional(func() interface{} { return p.readUint32() }), + "long_cache_size": p.readOptional(func() interface{} { return p.readUint32() }), + "short_cache_min_cacheable_distance": p.readOptional(func() interface{} { return p.readDouble() }), + "short_cache_min_algo_steps_to_cache": p.readOptional(func() interface{} { return p.readUint32() }), + "long_cache_min_cacheable_distance": p.readOptional(func() interface{} { return p.readDouble() }), + "cache_max_connect_to_cache_steps_multiplier": p.readOptional(func() interface{} { return p.readUint32() }), + "cache_accept_path_start_distance_ratio": p.readOptional(func() interface{} { return p.readDouble() }), + "cache_accept_path_end_distance_ratio": p.readOptional(func() interface{} { return p.readDouble() }), + "negative_cache_accept_path_start_distance_ratio": p.readOptional(func() interface{} { return p.readDouble() }), + "negative_cache_accept_path_end_distance_ratio": p.readOptional(func() interface{} { return p.readDouble() }), + "cache_path_start_distance_rating_multiplier": p.readOptional(func() interface{} { return p.readDouble() }), + "cache_path_end_distance_rating_multiplier": p.readOptional(func() interface{} { return p.readDouble() }), + "stale_enemy_with_same_destination_collision_penalty": p.readOptional(func() interface{} { return p.readDouble() }), + "ignore_moving_enemy_collision_distance": p.readOptional(func() interface{} { return p.readDouble() }), + "enemy_with_different_destination_collision_penalty": p.readOptional(func() interface{} { return p.readDouble() }), + "general_entity_collision_penalty": p.readOptional(func() interface{} { return p.readDouble() }), + "general_entity_subsequent_collision_penalty": p.readOptional(func() interface{} { return p.readDouble() }), + "extended_collision_penalty": p.readOptional(func() interface{} { return p.readDouble() }), + "max_clients_to_accept_any_new_request": p.readOptional(func() interface{} { return p.readUint32() }), + "max_clients_to_accept_short_new_request": p.readOptional(func() interface{} { return p.readUint32() }), + "direct_distance_to_consider_short_request": p.readOptional(func() interface{} { return p.readUint32() }), + "short_request_max_steps": p.readOptional(func() interface{} { return p.readUint32() }), + "short_request_ratio": p.readOptional(func() interface{} { return p.readDouble() }), + "min_steps_to_check_path_find_termination": p.readOptional(func() interface{} { return p.readUint32() }), + "start_to_goal_cost_multiplier_to_terminate_path_find": p.readOptional(func() interface{} { return p.readDouble() }), + "overload_levels": p.readOptional(func() interface{} { + return p.readArray(func() interface{} { return p.readUint32() }) + }), + "overload_multipliers": p.readOptional(func() interface{} { + return p.readArray(func() interface{} { return p.readDouble() }) + }), + "negative_path_cache_delay_interval": p.readOptional(func() interface{} { return p.readUint32() }), + } +} + +func (p *mapExchangeParser) readDifficultySettings(atLeastV2 bool) interface{} { + if atLeastV2 { + return map[string]interface{}{ + "technology_price_multiplier": p.readDouble(), + "spoil_time_modifier": p.readDouble(), + } + } + + recipeDifficulty := p.readUint8() + technologyDifficulty := p.readUint8() + technologyPriceMultiplier := p.readDouble() + researchQueue := []string{"always", "after-victory", "never"} + queueIndex := int(p.readUint8()) + queueSetting := "" + if queueIndex >= 0 && queueIndex < len(researchQueue) { + queueSetting = researchQueue[queueIndex] + } else { + p.setErr("invalid research queue setting %d", queueIndex) + } + + return map[string]interface{}{ + "recipe_difficulty": recipeDifficulty, + "technology_difficulty": technologyDifficulty, + "technology_price_multiplier": technologyPriceMultiplier, + "research_queue_setting": queueSetting, + } +} + +func (p *mapExchangeParser) readAsteroidsSettings() interface{} { + return map[string]interface{}{ + "spawning_rate": p.readOptional(func() interface{} { return p.readDouble() }), + "max_ray_portals_expanded_per_tick": p.readOptional(func() interface{} { return p.readUint32() }), + } +} + +func (p *mapExchangeParser) readMapSettings(atLeastV2 bool) map[string]interface{} { + settings := map[string]interface{}{ + "pollution": p.readPollution(), + "steering": p.readSteering(), + "enemy_evolution": p.readEnemyEvolution(), + "enemy_expansion": p.readEnemyExpansion(), + "unit_group": p.readUnitGroup(), + "path_finder": p.readPathFinder(), + "max_failed_behavior_count": p.readUint32(), + "difficulty_settings": p.readDifficultySettings(atLeastV2), + } + if atLeastV2 { + settings["asteroids"] = p.readAsteroidsSettings() + } + return settings +} diff --git a/fact/mapExchange_test.go b/fact/mapExchange_test.go new file mode 100644 index 00000000..ca10c546 --- /dev/null +++ b/fact/mapExchange_test.go @@ -0,0 +1,106 @@ +package fact + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "ChatWire/cfg" + "ChatWire/constants" +) + +const sampleMapExchangeString = `>>>eNpjYmBg8AFiBh6W5PzEHAaGBnsY5krOLyhILdLNL0pFFuZMLipNSdXNz0RVnJqXmlupm5RYDFUMAg32HJlF+XnoJvDkJZaWZRbHJ+dkpqUhS7AW5SdnFyOLiBWXJBaVZOalxycWpSbG5+ZnFpeUoprGWlySn4cqUlKUmopiDHdpUWJeZmkuuktYyxNLUosgPAeIKKOn9iTzhhY5BhD+X8+g8P8/CANZD4A+AmEGxgaIDkagIAywQj3DoOAIxE5wSxgZGKtF1rk/rJpizwhRqecAZXyAihxIgol4whh+DjilVGAMEyRzjMHgMxIDYmkJ0AqoKg4HBAMi2QKSZGTsfbt1wfdjF+wY/6z8eMk3KcGe0dBV5N0Ho3V2QEl2kHeZ4MSsmSCwE+YVBpiZD+yhUjftGc+eAYE39oysIB0iIMLBAkgc8GZmYBTgA7IW9AAJBRkGmNPsYMaIODCmgcE3mE8ewxiX7dH9AQwIG5DhciDiBIgAWwh3GSOE6dDvwOggD5OVRCgB6jdiQHZDCsKHJ2HWHkayH80hmBGB7A80ERUHLNHABbIwBU68YIa7BhieF9hhPIf5DozMIAZI1RegGIQHkoEZBaEFHMDBzQxPlB/sUVMaiAEyJP29ZCQA2Pi9LQ==<<<` + +func TestParseMapExchangeString(t *testing.T) { + data, err := ParseMapExchangeString(sampleMapExchangeString) + if err != nil { + t.Fatalf("ParseMapExchangeString returned error: %v", err) + } + + if data.Version[0] == 0 { + t.Fatalf("expected non-zero Factorio version, got %v", data.Version) + } + if !data.ChecksumOK { + t.Fatal("expected checksum to validate") + } + if len(data.MapGenSettings) == 0 { + t.Fatal("expected map gen settings") + } + if len(data.MapSettings) == 0 { + t.Fatal("expected map settings") + } + if _, ok := data.MapGenSettings["autoplace_controls"]; !ok { + t.Fatal("expected autoplace_controls in map gen settings") + } + if _, ok := data.MapSettings["pollution"]; !ok { + t.Fatal("expected pollution in map settings") + } +} + +func TestParseMapExchangeJSON(t *testing.T) { + data, err := ParseMapExchangeString(`{"map_settings":{"pollution":{"enabled":true}},"map_gen_settings":{"seed":123}}`) + if err != nil { + t.Fatalf("ParseMapExchangeString returned error: %v", err) + } + if got := data.MapGenSettings["seed"].(json.Number).String(); got != "123" { + t.Fatalf("unexpected seed: %v", got) + } +} + +func TestWriteCustomMapExchangeFiles(t *testing.T) { + oldRoot := cfg.Global.Paths.Folders.ServersRoot + oldMapGenerators := cfg.Global.Paths.Folders.MapGenerators + oldWD, err := os.Getwd() + if err != nil { + t.Fatalf("failed reading working directory: %v", err) + } + t.Cleanup(func() { + cfg.Global.Paths.Folders.ServersRoot = oldRoot + cfg.Global.Paths.Folders.MapGenerators = oldMapGenerators + if err := os.Chdir(oldWD); err != nil { + t.Fatalf("failed restoring working directory: %v", err) + } + }) + + tmp := t.TempDir() + sharedRoot := t.TempDir() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("failed changing working directory: %v", err) + } + cfg.Global.Paths.Folders.ServersRoot = sharedRoot + string(os.PathSeparator) + cfg.Global.Paths.Folders.MapGenerators = "map-gen-json" + + genPath, setPath, err := WriteCustomMapExchangeFiles(sampleMapExchangeString) + if err != nil { + t.Fatalf("WriteCustomMapExchangeFiles returned error: %v", err) + } + + wantGen := filepath.Join(tmp, "map-gen-json", constants.CustomMapGeneratorName+"-gen.json") + wantSet := filepath.Join(tmp, "map-gen-json", constants.CustomMapGeneratorName+"-set.json") + if genPath != wantGen { + t.Fatalf("unexpected gen path: got %q want %q", genPath, wantGen) + } + if setPath != wantSet { + t.Fatalf("unexpected set path: got %q want %q", setPath, wantSet) + } + + for _, path := range []string{genPath, setPath} { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed reading %s: %v", path, err) + } + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("invalid JSON in %s: %v", path, err) + } + if len(parsed) == 0 { + t.Fatalf("expected JSON payload in %s", path) + } + } + + sharedGen := filepath.Join(sharedRoot, "map-gen-json", constants.CustomMapGeneratorName+"-gen.json") + if _, err := os.Stat(sharedGen); !os.IsNotExist(err) { + t.Fatalf("custom map exchange file should not be written to shared generator folder: %s", sharedGen) + } +} diff --git a/fact/mapReset.go b/fact/mapReset.go index cb107c5d..bb34a291 100755 --- a/fact/mapReset.go +++ b/fact/mapReset.go @@ -40,8 +40,8 @@ func getMapTypeNum(mapt string) int { func Map_reset(doReport bool) error { SetAutolaunch(false, false) return submitLifecycleRequestAndWait(Request{ - Kind: ActionMapReset, - Reason: "Server rebooting for map reset!", + Kind: ActionMapReset, + Reason: "Server rebooting for map reset!", RequestID: fmt.Sprintf("map-reset-%d", time.Now().UnixNano()), }) } @@ -207,11 +207,13 @@ func GenNewMap() (string, error) { /* Append map gen if set */ if cfg.Local.Settings.MapGenerator != "" && !strings.EqualFold(cfg.Local.Settings.MapGenerator, "none") { + genSettingsPath, mapSettingsPath := cfg.GetMapGeneratorFiles(cfg.Local.Settings.MapGenerator) + factargs = append(factargs, "--map-gen-settings") - factargs = append(factargs, cfg.Global.Paths.Folders.ServersRoot+cfg.Global.Paths.Folders.MapGenerators+"/"+cfg.Local.Settings.MapGenerator+"-gen.json") + factargs = append(factargs, genSettingsPath) factargs = append(factargs, "--map-settings") - factargs = append(factargs, cfg.Global.Paths.Folders.ServersRoot+cfg.Global.Paths.Folders.MapGenerators+"/"+cfg.Local.Settings.MapGenerator+"-set.json") + factargs = append(factargs, mapSettingsPath) } else { factargs = append(factargs, "--preset") factargs = append(factargs, MapPreset) diff --git a/modupdate/newUpdate.go b/modupdate/newUpdate.go index e43f249a..442e1f17 100644 --- a/modupdate/newUpdate.go +++ b/modupdate/newUpdate.go @@ -2,6 +2,7 @@ package modupdate import ( "ChatWire/cfg" + "ChatWire/constants" "ChatWire/cwlog" "ChatWire/disc" "ChatWire/fact" @@ -22,6 +23,8 @@ const ( modUpdateProgressInterval = time.Minute ) +var downloadModInfo = DownloadModInfo + func CheckModsManual(force bool) { checkMods(force, true, true) } @@ -96,6 +99,27 @@ func dependencySatisfied(depInfo depRequires, installedVersion string, planned [ return checkVersion(depInfo.equality, depInfo.version, version) } +func releaseMatchesFactorioVersion(rel modRelease) bool { + modVersion := strings.TrimSpace(rel.InfoJSON.FactorioVersion) + gameVersion := strings.TrimSpace(fact.FactorioVersion) + if modVersion == "" || gameVersion == "" || strings.EqualFold(gameVersion, constants.Unknown) { + return true + } + + modParts, err := versionToInt(modVersion) + if err != nil { + cwlog.DoLogCW("resolveDeps: invalid factorio_version for release %s: %v", rel.Version, err) + return false + } + gameParts, err := versionToInt(gameVersion) + if err != nil { + cwlog.DoLogCW("resolveDeps: invalid Factorio version %s: %v", gameVersion, err) + return true + } + + return modParts.parts[0] == gameParts.parts[0] && modParts.parts[1] == gameParts.parts[1] +} + func resolveDeps(modPortalData []modPortalFullData, wasDep bool, depth int, parents []string, progress *modUpdateProgress) ([]downloadData, error) { if depth > 10 { @@ -124,6 +148,7 @@ func resolveDeps(modPortalData []modPortalFullData, wasDep bool, depth int, pare if item.installed.Version != "" { candidate.Version = item.installed.Version } + var candidateDeps []downloadData //Check all releases for _, rel := range item.Releases { @@ -150,7 +175,12 @@ func resolveDeps(modPortalData []modPortalFullData, wasDep bool, depth int, pare } //If release is newer check deps if releaseNewer { + if !releaseMatchesFactorioVersion(rel) { + continue + } + depsMet := true + var releaseDeps []downloadData for _, dep := range rel.InfoJSON.Dependencies { depInfo := parseDep(dep) @@ -193,7 +223,7 @@ func resolveDeps(modPortalData []modPortalFullData, wasDep bool, depth int, pare } //We do not have the dep, download info if !haveDepInfo { - depPortalInfo, err = DownloadModInfo(depInfo.name) + depPortalInfo, err = downloadModInfo(depInfo.name) if err != nil { cwlog.DoLogCW("resolveDeps: dep: DownloadModInfo: %v", err) return []downloadData{}, err @@ -216,8 +246,12 @@ func resolveDeps(modPortalData []modPortalFullData, wasDep bool, depth int, pare } //Download dep and all of dep's deps. if len(dl) > 0 { - for _, item := range dl { - downloadMods = addDownload(item, downloadMods) + for _, depDownload := range dl { + if depDownload.RequiredByName == "" { + depDownload.RequiredByName = item.Name + depDownload.RequiredByVersion = rel.Version + } + releaseDeps = addDownload(depDownload, releaseDeps) } } } @@ -225,6 +259,7 @@ func resolveDeps(modPortalData []modPortalFullData, wasDep bool, depth int, pare //If deps were met, we can update the candidate if depsMet { candidate = rel + candidateDeps = releaseDeps } } } @@ -232,6 +267,9 @@ func resolveDeps(modPortalData []modPortalFullData, wasDep bool, depth int, pare //Add candidate to the download list if candidate.Version != "0.0.0" && item.installed.Version != candidate.Version { + for _, dep := range candidateDeps { + downloadMods = addDownload(dep, downloadMods) + } downloadMods = addDownload(downloadData{Title: item.Title, Name: item.Name, Filename: candidate.FileName, OldFilename: item.installed.Filename, Data: candidate, Version: candidate.Version, OldVersion: item.installed.Version, wasDep: wasDep, diff --git a/modupdate/newUpdate_test.go b/modupdate/newUpdate_test.go index 475d3e26..35f42aa7 100644 --- a/modupdate/newUpdate_test.go +++ b/modupdate/newUpdate_test.go @@ -1,6 +1,10 @@ package modupdate -import "testing" +import ( + "testing" + + "ChatWire/fact" +) func TestDependencySatisfiedUnversionedDepPresent(t *testing.T) { good, err := dependencySatisfied(depRequires{name: "dep"}, "1.0.0", nil) @@ -53,3 +57,152 @@ func TestDependencySatisfiedVersionedDepUnsatisfiedAfterRecursion(t *testing.T) t.Fatal("expected dependency to remain unsatisfied after recursion") } } + +func TestResolveDepsDropsSupersededReleaseDependencies(t *testing.T) { + oldFactorioVersion := fact.FactorioVersion + oldDownloadModInfo := downloadModInfo + t.Cleanup(func() { + fact.FactorioVersion = oldFactorioVersion + downloadModInfo = oldDownloadModInfo + }) + + fact.FactorioVersion = "2.0.76" + downloadModInfo = func(name string) (modPortalFullData, error) { + return modPortalFullData{ + Name: name, + Title: name, + Releases: []modRelease{ + { + Version: "1.0.0", + FileName: name + "_1.0.0.zip", + InfoJSON: modInfoJSON{ + FactorioVersion: "2.0", + }, + }, + }, + }, nil + } + + downloads, err := resolveDeps([]modPortalFullData{ + { + Name: "parent", + Title: "Parent", + Releases: []modRelease{ + { + Version: "1.0.0", + FileName: "parent_1.0.0.zip", + InfoJSON: modInfoJSON{ + FactorioVersion: "2.0", + Dependencies: []string{"dep >= 1.0.0"}, + }, + }, + { + Version: "2.0.0", + FileName: "parent_2.0.0.zip", + InfoJSON: modInfoJSON{ + FactorioVersion: "2.0", + }, + }, + }, + }, + }, false, 0, nil, nil) + if err != nil { + t.Fatalf("resolveDeps returned error: %v", err) + } + if len(downloads) != 1 { + t.Fatalf("expected only final parent download, got %+v", downloads) + } + if downloads[0].Name != "parent" || downloads[0].Version != "2.0.0" { + t.Fatalf("unexpected selected download: %+v", downloads[0]) + } +} + +func TestResolveDepsTagsFinalDependencyRequester(t *testing.T) { + oldFactorioVersion := fact.FactorioVersion + oldDownloadModInfo := downloadModInfo + t.Cleanup(func() { + fact.FactorioVersion = oldFactorioVersion + downloadModInfo = oldDownloadModInfo + }) + + fact.FactorioVersion = "2.0.76" + downloadModInfo = func(name string) (modPortalFullData, error) { + return modPortalFullData{ + Name: name, + Title: name, + Releases: []modRelease{ + { + Version: "1.0.0", + FileName: name + "_1.0.0.zip", + InfoJSON: modInfoJSON{ + FactorioVersion: "2.0", + }, + }, + }, + }, nil + } + + downloads, err := resolveDeps([]modPortalFullData{ + { + Name: "parent", + Title: "Parent", + Releases: []modRelease{ + { + Version: "2.0.0", + FileName: "parent_2.0.0.zip", + InfoJSON: modInfoJSON{ + FactorioVersion: "2.0", + Dependencies: []string{"dep >= 1.0.0"}, + }, + }, + }, + }, + }, false, 0, nil, nil) + if err != nil { + t.Fatalf("resolveDeps returned error: %v", err) + } + + for _, dl := range downloads { + if dl.Name == "dep" { + if !dl.wasDep { + t.Fatalf("expected dep download to be marked as dependency: %+v", dl) + } + if dl.RequiredByName != "parent" || dl.RequiredByVersion != "2.0.0" { + t.Fatalf("expected dependency requester parent-2.0.0, got %+v", dl) + } + return + } + } + t.Fatalf("expected dependency download in %+v", downloads) +} + +func TestResolveDepsSkipsWrongFactorioVersionRelease(t *testing.T) { + oldFactorioVersion := fact.FactorioVersion + t.Cleanup(func() { + fact.FactorioVersion = oldFactorioVersion + }) + + fact.FactorioVersion = "2.0.76" + downloads, err := resolveDeps([]modPortalFullData{ + { + Name: "stdlib", + Title: "Factorio Standard Library", + Releases: []modRelease{ + { + Version: "1.4.8", + FileName: "stdlib_1.4.8.zip", + InfoJSON: modInfoJSON{ + FactorioVersion: "1.1", + Dependencies: []string{"base >= 1.1.0"}, + }, + }, + }, + }, + }, true, 0, nil, nil) + if err != nil { + t.Fatalf("resolveDeps returned error: %v", err) + } + if len(downloads) != 0 { + t.Fatalf("expected no downloads for incompatible factorio_version, got %+v", downloads) + } +} diff --git a/modupdate/struct.go b/modupdate/struct.go index 238dc039..a73f419c 100644 --- a/modupdate/struct.go +++ b/modupdate/struct.go @@ -32,10 +32,11 @@ type ModHistoryItem struct { } type downloadData struct { - Name, Title string - Filename, OldFilename string - Data modRelease - Complete bool + Name, Title string + Filename, OldFilename string + RequiredByName, RequiredByVersion string + Data modRelease + Complete bool Version, OldVersion string diff --git a/modupdate/util.go b/modupdate/util.go index 05401208..5d740fdd 100644 --- a/modupdate/util.go +++ b/modupdate/util.go @@ -413,7 +413,11 @@ func downloadMods(downloadList []downloadData) string { buf := "" if dl.wasDep { - buf = fmt.Sprintf("Downloading dependency: %v", longBuf) + if dl.RequiredByName != "" { + buf = fmt.Sprintf("Downloading dependency for %s-%s: %v", dl.RequiredByName, dl.RequiredByVersion, longBuf) + } else { + buf = fmt.Sprintf("Downloading dependency: %v", longBuf) + } } else { buf = fmt.Sprintf("Downloading: %v", longBuf) } @@ -510,6 +514,9 @@ func downloadMods(downloadList []downloadData) string { if dl.OldFilename != "" { noteMsg = UpdatedNote } + if dl.wasDep && dl.RequiredByName != "" { + noteMsg = fmt.Sprintf("%s dependency for %s-%s", noteMsg, dl.RequiredByName, dl.RequiredByVersion) + } newUpdate := ModHistoryItem{ Name: dl.Name, Notes: noteMsg, Date: time.Now(), Version: dl.Data.Version, Filename: dl.Data.FileName,