From 155af934f4b9d5a6168a08b7328eba2245e6d2be Mon Sep 17 00:00:00 2001 From: stavrosfa <67387767+stavrosfa@users.noreply.github.com> Date: Sun, 15 Mar 2026 03:00:39 +0200 Subject: [PATCH 1/2] Implemented pickable and included-in-game civs Fix bug in .biq and .sav files where we would assign barbarians to another civ. Added barb camp tiles for .sav and .biq Fix bug when loading a .sav to assign the correct civ color --- C7/UIElements/NewGame/ScenarioSetup.cs | 13 +-- C7Engine/AI/PlayerAI.cs | 2 +- C7Engine/C7GameData/GameData.cs | 2 +- C7Engine/C7GameData/ImportCiv3.cs | 48 ++++++++-- C7Engine/C7GameData/Player.cs | 10 +++ C7Engine/C7GameData/Save/SaveGame.cs | 4 +- C7Engine/C7GameData/Save/SavePlayer.cs | 10 +++ C7Engine/GameSetup.cs | 1 + EngineTests/GameData/SaveTest.cs | 120 +++++++++++++++++++++++++ QueryCiv3/SavSections/Tile.cs | 2 +- 10 files changed, 194 insertions(+), 18 deletions(-) diff --git a/C7/UIElements/NewGame/ScenarioSetup.cs b/C7/UIElements/NewGame/ScenarioSetup.cs index af0fc5889..9f765af72 100644 --- a/C7/UIElements/NewGame/ScenarioSetup.cs +++ b/C7/UIElements/NewGame/ScenarioSetup.cs @@ -36,12 +36,13 @@ public override void _Ready() { // Set up buttons for the civs the player can play as. civilizations = save.Civilizations; - playerListContainer.Columns = (int)Math.Ceiling(save.Civilizations.Count / 12.0); - string initiallySelectedCiv = save.Civilizations[1].name; - foreach (Civilization civ in save.Civilizations) { - if (civ.isBarbarian) { - continue; - } + playerListContainer.Columns = (int)Math.Ceiling(civilizations.Count / 12.0); + + List pickablePlayers = save.Players.Where(p => p.canBePicked).ToList(); + string initiallySelectedCiv = pickablePlayers.First(p => p.canBePicked).civilization; + + foreach (SavePlayer player in pickablePlayers) { + Civilization civ = civilizations.Find(c => c.name == player.civilization); Civ3MenuButton button = new() { Text = civ.name, diff --git a/C7Engine/AI/PlayerAI.cs b/C7Engine/AI/PlayerAI.cs index c36c31b2c..b84f3dec3 100644 --- a/C7Engine/AI/PlayerAI.cs +++ b/C7Engine/AI/PlayerAI.cs @@ -21,7 +21,7 @@ public class PlayerAI { public static readonly int MAX_WATER_EXPLORERS = 4; public static async Task PlayTurn(Player player, GameData gameData) { - if (player.isHuman || player.isBarbarians) { + if (player.isHuman || player.isBarbarians || !player.isIncludedInGame) { return; } List techs = gameData.techs; diff --git a/C7Engine/C7GameData/GameData.cs b/C7Engine/C7GameData/GameData.cs index 97c73687c..19418c0a3 100644 --- a/C7Engine/C7GameData/GameData.cs +++ b/C7Engine/C7GameData/GameData.cs @@ -30,7 +30,7 @@ public class GameData { public List cities = new List(); - internal List civilizations = new List(); + public List civilizations { get; internal set; } = new List(); public List experienceLevels = new List(); public List techs = new(); diff --git a/C7Engine/C7GameData/ImportCiv3.cs b/C7Engine/C7GameData/ImportCiv3.cs index 9bc1e8795..c6ed54dad 100644 --- a/C7Engine/C7GameData/ImportCiv3.cs +++ b/C7Engine/C7GameData/ImportCiv3.cs @@ -126,6 +126,9 @@ private SaveGame importSav(string savePath, string defaultBicPath, Func= 0) { + tile.features.Add("barbarianCamp"); + } if (civ3Tile.BonusShield) { tile.features.Add("bonusShield"); } @@ -226,6 +229,9 @@ private SaveGame importBiq(string biqPath, string defaultBiqPath, Func= 0; --p) { + var player = save.Players[p]; + if (p != 0 && !player.isIncludedInGame) { + save.Players.Remove(player); + } + // make barbarians unpickable + if (p == 0) { + save.Players[p].canBePicked = false; + } + } + return save; } @@ -410,7 +434,10 @@ private void ImportBicLeaders() { save.Players.Add(MakeSavePlayerFromCiv(save.Civilizations[i], isBarbarian: i == 0, isHuman: false, - era: "")); + era: "", + // GameCiv[0] does not contain the barbarians, + // but we want to include them in the gameplay + theBiq.GameCiv[0].Contains(i) || i == 0)); // Set a government for players not associated with LEAD. // Usually, this applies only to barbarians, but in some scenarios @@ -425,6 +452,8 @@ private void ImportBicLeaders() { foreach (LEAD lead in theBiq.Lead) { SavePlayer player = save.Players[lead.Civ]; + player.canBePicked = lead.HumanPlayer == 1; + // Put the player in the correct starting era. player.eraCivilopediaName = theBiq.Eras[lead.InitialEra].CivilopediaEntry; @@ -471,7 +500,10 @@ private void ImportSavLeaders() { SavePlayer player = MakeSavePlayerFromCiv(civ, isBarbarian: i == 0, isHuman: i == 1, - era: theBiq.Eras[leader.Era].CivilopediaEntry); + era: theBiq.Eras[leader.Era].CivilopediaEntry, + // by default if the player is in the .sav file, well, it's included in the game + // in contrast to a .biq file where it can have a player/civ that is not included in the final gameplay + true); // Record what the player is currently researching. if (leader.Researching > -1) { @@ -499,6 +531,7 @@ private void ImportSavLeaders() { player.taxRate = leader.TaxRate; player.governmentId = save.Governments[leader.Government].id; player.inAnarchyUntilTurn = save.TurnNumber + leader.AnarchyTurnsLeft; + player.primaryColorIndex = leader.Color; save.Players.Add(player); i++; @@ -777,17 +810,16 @@ private void ImportSavLeaders() { } } - private SavePlayer MakeSavePlayerFromCiv(Civilization civ, bool isBarbarian, bool isHuman, string era) { + private SavePlayer MakeSavePlayerFromCiv(Civilization civ, bool isBarbarian, bool isHuman, string era, bool isIncludedInGame = true) { return new SavePlayer { id = ids.CreateID("player"), primaryColorIndex = civ.primaryColorIndex, secondaryColorIndex = civ.secondaryColorIndex, human = isHuman, civilization = civ.name, - + isIncludedInGame = isIncludedInGame, // Never let barbarians play before a real player. hasPlayedCurrentTurn = isBarbarian, - eraCivilopediaName = era, }; } @@ -853,7 +885,11 @@ private void ImportBicUnits() { // The owner index is into the list of civs, and we have a 1:1 // mapping of players and civs. - SavePlayer player = save.Players[unit.Owner]; + // The exception to this are barbarian units (unit.OwnerType == 1), + // where the owner points to the tribe (city name in other civs), rather than the player/civ + // TODO: implement tribes for barbarians + int owner = unit.OwnerType == 1 ? 0 : unit.Owner; + SavePlayer player = save.Players[owner]; ExperienceLevel experience = save.ExperienceLevels[unit.ExperienceLevel]; save.Units.Add(createUnitAtLocation(player, unit.Name, unit.UnitType, experience.key, experience.baseHitPoints, unit.X, unit.Y)); } diff --git a/C7Engine/C7GameData/Player.cs b/C7Engine/C7GameData/Player.cs index 915148bd0..4e9f02bb3 100644 --- a/C7Engine/C7GameData/Player.cs +++ b/C7Engine/C7GameData/Player.cs @@ -57,6 +57,16 @@ public class Player { public Civilization civilization; + // Answers if this player-civ is simply included in the game. + // Some .biq scenarios contain players/civs in their data + // that are not a part of the gameplay. + // ex. Mongols in `4 Middle Ages.biq` scenario. + public bool isIncludedInGame = true; + + // Answers if the human player can pick and play as this player-civ + // or is it only an AI player + public bool canBePicked = true; + public List units = new List(); public List cities = new List(); public TileKnowledge tileKnowledge { get; private set; } diff --git a/C7Engine/C7GameData/Save/SaveGame.cs b/C7Engine/C7GameData/Save/SaveGame.cs index 0d65ea5f2..e7419168d 100644 --- a/C7Engine/C7GameData/Save/SaveGame.cs +++ b/C7Engine/C7GameData/Save/SaveGame.cs @@ -292,9 +292,7 @@ private void ConvertUnits(GameData data) { proto.requiredTech = techDict[saveProto.requiredTech]; } - if (saveProto.unique != null) { - Civilization civ = civDict[saveProto.unique.civilization]; - + if (saveProto.unique != null && civDict.TryGetValue(saveProto.unique.civilization, out var civ)) { proto.unique = new() { civilization = civ }; diff --git a/C7Engine/C7GameData/Save/SavePlayer.cs b/C7Engine/C7GameData/Save/SavePlayer.cs index 4e433cb95..38f3ac1a8 100644 --- a/C7Engine/C7GameData/Save/SavePlayer.cs +++ b/C7Engine/C7GameData/Save/SavePlayer.cs @@ -10,6 +10,8 @@ public class SavePlayer { public bool human = false; public bool hasPlayedCurrentTurn = false; public bool defeated = false; + public bool isIncludedInGame = true; + public bool canBePicked = true; public string civilization; @@ -64,6 +66,8 @@ public Player ToPlayer(GameMap map, List civilizations, List civilizations, List p.isIncludedInGame) == 9); + Assert.True(game.players.Count(p => p.canBePicked) == 8); + Assert.True(game.players.Count == 9); + Assert.True(game.civilizations.Count == 32); + // Save the game. string outputDirectSavePath = PathUtils.getDataPath($"output/headless-game-direct-save-{outputFilePostfix}.json"); SaveGame.FromGameData(game).Save(outputDirectSavePath); @@ -425,6 +430,8 @@ private void CheckScenariosInCiv3Subfolder(string subfolder, string[] scenarioNa Assert.NotNull(game); Assert.NotNull(gd); + CheckPlayableCivs(name, game); + // Check that the human player has at least one settler or city in // each scenario, when looking at the SaveGame. foreach (SavePlayer player in game.Players) { @@ -487,4 +494,117 @@ private void CheckScenariosInCiv3Subfolder(string subfolder, string[] scenarioNa } } } + + private void CheckPlayableCivs(string name, SaveGame game) { + Console.WriteLine($"Running playability test for {name}"); + + // Playable checks + // 4 Middle Ages.biq + if (name.Equals("4 Middle Ages.biq")) { + Assert.True(game.Players.Count(p => p.isIncludedInGame) == 19); + Assert.True(game.Players.Count(p => p.canBePicked) == 13); + Assert.True(game.Players.Count == 19); + Assert.True(game.Civilizations.Count == 20); + + Assert.DoesNotContain(game.Players, p => p.civilization == "Mongols"); + + Assert.Contains(game.Civilizations, c => c.name == "Mongols"); + Assert.Contains(game.Civilizations, c => c.name == "Bulgars"); + Assert.Contains(game.Civilizations, c => c.name == "Poland"); + + foreach (var gamePlayer in game.Players) { + if (gamePlayer.civilization == "Turks") { + Assert.True(gamePlayer.primaryColorIndex == 14); + } + } + + // make sure barbarian save units are assigned correctly to the barbarian civ + foreach (var gameUnit in game.Units) { + // there is a horseman barb + barb camp unit in this location + if (gameUnit.currentLocation is { X: 7, Y: 103 }) { + Assert.Contains("barbarianCamp", game.Map.tiles.Find(t => t.X == 7 && t.Y == 103).features); + Assert.True(game.Players.Find(p => p.id == gameUnit.owner).civilization == "A Barbarian Chiefdom"); + } + } + } + // 4 MP Middle Ages.biq + if (name.Equals("4 MP Middle Ages.biq")) { + Assert.True(game.Players.Count(p => p.isIncludedInGame) == 9); + Assert.True(game.Players.Count(p => p.canBePicked) == 8); + Assert.True(game.Players.Count == 9); + Assert.True(game.Civilizations.Count == 20); + + Assert.DoesNotContain(game.Players, p => p.civilization == "Mongols"); + Assert.DoesNotContain(game.Players, p => p.civilization == "Bulgars"); + Assert.DoesNotContain(game.Players, p => p.civilization == "Poland"); + + Assert.Contains(game.Civilizations, c => c.name == "Mongols"); + Assert.Contains(game.Civilizations, c => c.name == "Bulgars"); + Assert.Contains(game.Civilizations, c => c.name == "Poland"); + } + // 6 Age of Discovery.biq + if (name.Equals("6 Age of Discovery.biq")) { + Assert.True(game.Players.Count(p => p.isIncludedInGame) == 10); + Assert.True(game.Players.Count(p => p.canBePicked) == 8); + Assert.True(game.Players.Count == 10); + Assert.True(game.Civilizations.Count == 10); + + Assert.Contains(game.Players, p => p.civilization == "Iroquois"); + Assert.Contains(game.Players, p => p.civilization == "France"); + Assert.Contains(game.Players, p => p.civilization == "Maya"); + + Assert.Contains(game.Civilizations, c => c.name == "Iroquois"); + Assert.Contains(game.Civilizations, c => c.name == "France"); + Assert.Contains(game.Civilizations, c => c.name == "Maya"); + } + // 6 MP Age of Discovery.biq + if (name.Equals("6 MP Age of Discovery.biq")) { + Assert.True(game.Players.Count(p => p.isIncludedInGame) == 9); + Assert.True(game.Players.Count(p => p.canBePicked) == 8); + Assert.True(game.Players.Count == 9); + Assert.True(game.Civilizations.Count == 10); + + Assert.DoesNotContain(game.Players, p => p.civilization == "Iroquois"); + Assert.Contains(game.Players, p => p.civilization == "France"); + Assert.Contains(game.Players, p => p.civilization == "Maya"); + + Assert.Contains(game.Civilizations, c => c.name == "Iroquois"); + Assert.Contains(game.Civilizations, c => c.name == "France"); + Assert.Contains(game.Civilizations, c => c.name == "Maya"); + } + // 8 Napoleonic Europe.biq + if (name.Equals("8 Napoleonic Europe.biq")) { + Assert.True(game.Players.Count(p => p.isIncludedInGame) == 13); + Assert.True(game.Players.Count(p => p.canBePicked) == 7); + Assert.True(game.Players.Count == 13); + Assert.True(game.Civilizations.Count == 13); + + Assert.Contains(game.Players, p => p.civilization == "Denmark"); + Assert.Contains(game.Players, p => p.civilization == "Portugal"); + Assert.Contains(game.Players, p => p.civilization == "Netherlands"); + Assert.Contains(game.Players, p => p.civilization == "Kingdom of Naples"); + + Assert.Contains(game.Civilizations, c => c.name == "Denmark"); + Assert.Contains(game.Civilizations, c => c.name == "Portugal"); + Assert.Contains(game.Civilizations, c => c.name == "Netherlands"); + Assert.Contains(game.Civilizations, c => c.name == "Kingdom of Naples"); + } + // 8 MP Napoleonic Europe.biq + if (name.Equals("8 MP Napoleonic Europe.biq")) { + Assert.True(game.Players.Count(p => p.isIncludedInGame) == 9); + Assert.True(game.Players.Count(p => p.canBePicked) == 7); + Assert.True(game.Players.Count == 9); + Assert.True(game.Civilizations.Count == 13); + + Assert.DoesNotContain(game.Players, p => p.civilization == "Denmark"); + Assert.DoesNotContain(game.Players, p => p.civilization == "Portugal"); + Assert.DoesNotContain(game.Players, p => p.civilization == "Netherlands"); + Assert.DoesNotContain(game.Players, p => p.civilization == "Kingdom of Naples"); + + Assert.Contains(game.Civilizations, c => c.name == "Denmark"); + Assert.Contains(game.Civilizations, c => c.name == "Portugal"); + Assert.Contains(game.Civilizations, c => c.name == "Netherlands"); + Assert.Contains(game.Civilizations, c => c.name == "Kingdom of Naples"); + } + } } diff --git a/QueryCiv3/SavSections/Tile.cs b/QueryCiv3/SavSections/Tile.cs index 1a63767f8..6e940ddb2 100644 --- a/QueryCiv3/SavSections/Tile.cs +++ b/QueryCiv3/SavSections/Tile.cs @@ -22,7 +22,7 @@ public unsafe struct TILE { public byte TextureLocation; // Between 0 and 80 inclusive (9*9 grid) public byte TextureFile; // 0: xtgc, 1: xgpc, 2: xdgc, 3: xdpc, 4: xdgp, 5: xggc, 6: wcso, 7: wsss, 8: wooo private fixed byte Flags1[6]; - public short BarbarianCamp; + public short BarbarianCamp; // -1: there is no camp on the tile, >= 0: there is a barb camp on the tile, the number is the barb tribe public short CityID; public short ColonyID; public short Continent; From ec768d8fd37cc2c4e74299dbc3682fdbdba13a0c Mon Sep 17 00:00:00 2001 From: stavrosfa <67387767+stavrosfa@users.noreply.github.com> Date: Thu, 2 Apr 2026 01:23:15 +0300 Subject: [PATCH 2/2] Clean up --- C7Engine/C7GameData/ImportCiv3.cs | 37 +++++++++++++------------- C7Engine/C7GameData/Save/SavePlayer.cs | 6 +++++ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/C7Engine/C7GameData/ImportCiv3.cs b/C7Engine/C7GameData/ImportCiv3.cs index c6ed54dad..eebb28b62 100644 --- a/C7Engine/C7GameData/ImportCiv3.cs +++ b/C7Engine/C7GameData/ImportCiv3.cs @@ -186,6 +186,10 @@ private SaveGame importSav(string savePath, string defaultBicPath, Func p.isBarbarian).ToList().ForEach(p => p.canBePicked = false); + return save; } @@ -328,16 +332,10 @@ private SaveGame importBiq(string biqPath, string defaultBiqPath, Func= 0; --p) { - var player = save.Players[p]; - if (p != 0 && !player.isIncludedInGame) { - save.Players.Remove(player); - } - // make barbarians unpickable - if (p == 0) { - save.Players[p].canBePicked = false; - } - } + save.Players = save.Players.Where(p => p.isBarbarian || p.isIncludedInGame).ToList(); + + // make barbarians unpickable + save.Players.Where(p => p.isBarbarian).ToList().ForEach(p => p.canBePicked = false); return save; } @@ -431,13 +429,16 @@ private void ImportBicLeaders() { // Make a player for each civ. The barbarians are always civ 0. for (int i = 0; i < save.Civilizations.Count; ++i) { - save.Players.Add(MakeSavePlayerFromCiv(save.Civilizations[i], - isBarbarian: i == 0, + Civilization civ = save.Civilizations[i]; + + // GameCiv[0] does not contain the barbarians, + // but we want to include them in the gameplay + bool isIncluded = theBiq.GameCiv[0].Contains(i) || civ.isBarbarian; + + save.Players.Add(MakeSavePlayerFromCiv(civ, isHuman: false, era: "", - // GameCiv[0] does not contain the barbarians, - // but we want to include them in the gameplay - theBiq.GameCiv[0].Contains(i) || i == 0)); + isIncluded)); // Set a government for players not associated with LEAD. // Usually, this applies only to barbarians, but in some scenarios @@ -498,7 +499,6 @@ private void ImportSavLeaders() { } Civilization civ = save.Civilizations[leader.RaceID]; SavePlayer player = MakeSavePlayerFromCiv(civ, - isBarbarian: i == 0, isHuman: i == 1, era: theBiq.Eras[leader.Era].CivilopediaEntry, // by default if the player is in the .sav file, well, it's included in the game @@ -810,7 +810,7 @@ private void ImportSavLeaders() { } } - private SavePlayer MakeSavePlayerFromCiv(Civilization civ, bool isBarbarian, bool isHuman, string era, bool isIncludedInGame = true) { + private SavePlayer MakeSavePlayerFromCiv(Civilization civ, bool isHuman, string era, bool isIncludedInGame = true) { return new SavePlayer { id = ids.CreateID("player"), primaryColorIndex = civ.primaryColorIndex, @@ -819,7 +819,8 @@ private SavePlayer MakeSavePlayerFromCiv(Civilization civ, bool isBarbarian, boo civilization = civ.name, isIncludedInGame = isIncludedInGame, // Never let barbarians play before a real player. - hasPlayedCurrentTurn = isBarbarian, + hasPlayedCurrentTurn = civ.isBarbarian, + isBarbarian = civ.isBarbarian, eraCivilopediaName = era, }; } diff --git a/C7Engine/C7GameData/Save/SavePlayer.cs b/C7Engine/C7GameData/Save/SavePlayer.cs index 38f3ac1a8..3463cb314 100644 --- a/C7Engine/C7GameData/Save/SavePlayer.cs +++ b/C7Engine/C7GameData/Save/SavePlayer.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Text.Json.Serialization; namespace C7GameData.Save { @@ -62,6 +63,11 @@ public class SavePlayer { // The current government of the player. public ID governmentId; + // Used when importing from .biq, to make it easier to distinguish barbarians from other players. + // It's not meant to be saved in the json. + [JsonIgnore] + public bool isBarbarian { get; init; } + public Player ToPlayer(GameMap map, List civilizations, List governments, List techs, Rules rules) { Player player = new Player{ id = id,