diff --git a/C7Engine/AI/BarbarianAI.cs b/C7Engine/AI/BarbarianAI.cs index 5080de3b..4cf19b9d 100644 --- a/C7Engine/AI/BarbarianAI.cs +++ b/C7Engine/AI/BarbarianAI.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using C7Engine.AI.UnitAI; using Serilog; namespace C7Engine { @@ -11,40 +12,89 @@ public class BarbarianAI { private ILogger log = Log.ForContext(); + // Threshold at which barbarians take risky actions. + // In the future this may vary based on barbarian settings, and perhaps the situation + const double COLLEGE_TRY_THRESHOLD = 1.0 / 3; + public void PlayTurn(Player player, GameData gameData) { if (!player.isBarbarians) { throw new System.Exception("Barbarian AI can only play barbarian players"); } - // Copy unit list into temporary array so we can remove units while iterating. - // TODO: We also need to handle units spawned during the loop, e.g. leaders, armies, enslaved units. This is not so much an - // issue for the barbs but will be for similar loops elsewhere in the AI logic. - foreach (MapUnit unit in player.units.ToArray()) { - if (UnitIsFreeToMove(unit)) { - while (unit.movementPoints.canMove) { - //Move randomly - List validTiles = unit.unitType.categories.Contains("Sea") ? unit.location.GetCoastNeighbors() : unit.location.GetLandNeighbors(); - if (validTiles.Count == 0) { - //This can happen if a barbarian galley spawns next to a 1-tile lake, moves there, and doesn't have anywhere else to go. - log.Warning("WARNING: No valid tiles for barbarian to move to"); - break; - } - Tile newLocation = validTiles[GameData.rng.Next(validTiles.Count)]; - //Because it chooses a semi-cardinal direction at random, not accounting for map, it could get none - //if it tries to move e.g. north from the north pole. Hence, this check. - if (newLocation != Tile.NONE) { - log.Debug("Moving barbarian at " + unit.location + " to " + newLocation); - if (!unit.move(unit.location.directionTo(newLocation))) { + BarbarianPlayer barbs = (BarbarianPlayer)player; + + foreach (BarbarianTribe tribe in barbs.getTribes()) + { + // Copy unit list into temporary array so we can remove units while iterating. + foreach (MapUnit unit in tribe.GetUnits().ToArray()) { + if (UnitIsFreeToMove(unit)) { + while (unit.movementPoints.canMove) { + //Move randomly + List validTiles = unit.unitType.categories.Contains("Sea") + ? unit.location.GetCoastNeighbors() + : unit.location.GetLandNeighbors(); + if (validTiles.Count == 0) { + //This can happen if a barbarian galley spawns next to a 1-tile lake, moves there, and doesn't have anywhere else to go. + log.Warning("WARNING: No valid tiles for barbarian to move to"); break; } - } else { - //Avoid potential infinite loop. - break; + + //Check if there are any undefended units that can be taken! + foreach (Tile tile in validTiles) { + if (tile.unitsOnTile.Exists(mapUnit => IsUndefendedUnit(mapUnit))) { + bool alive = unit.move(unit.location.directionTo(tile)); + // TODO: Restructure so we can avoid gotos. + goto nextMovementPoint; + } + } + + //See if there are and tile where we might be able to to defeat another unit, if we give + //it the old college try + foreach (Tile tile in validTiles) { + if (tile.unitsOnTile.Exists(mapUnit => mapUnit.owner != barbs)) { + MapUnit topDefender = tile.FindTopDefender(unit); + double odds = CombatOdds.OddsOfVictory(unit, topDefender); + if (odds > COLLEGE_TRY_THRESHOLD) { + log.Information("Barbarian attacking " + topDefender.unitType + " with odds of " + odds); + bool alive = unit.move(unit.location.directionTo(tile)); + if (alive) { + goto nextMovementPoint; + } + goto nextUnit; + } + } + } + + Tile newLocation = validTiles[GameData.rng.Next(validTiles.Count)]; + //Because it chooses a semi-cardinal direction at random, not accounting for map, it could get none + //if it tries to move e.g. north from the north pole. Hence, this check. + if (newLocation != Tile.NONE) { + log.Debug("Moving barbarian at " + unit.location + " to " + newLocation); + if (!unit.move(unit.location.directionTo(newLocation))) { + break; + } + } else { + //Avoid potential infinite loop. + break; + } +nextMovementPoint: ; } } +nextUnit: ; } } } + + private static bool IsUndefendedUnit(MapUnit unit) { + if (unit.owner.isBarbarians) { + return false; + } + + return unit.location.unitsOnTile.Count(mapUnit => { + return mapUnit.unitType.defense > 0; + }) == 0; + } + private static bool UnitIsFreeToMove(MapUnit unit) { if (!unit.location.hasBarbarianCamp) { diff --git a/C7Engine/AI/UnitAI/CombatOdds.cs b/C7Engine/AI/UnitAI/CombatOdds.cs new file mode 100644 index 00000000..b5626093 --- /dev/null +++ b/C7Engine/AI/UnitAI/CombatOdds.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using C7GameData; + +namespace C7Engine.AI.UnitAI { + /** + * This is the start of a combat odds calculator. + * Its primary purpose is to make the AI well-informed about the risks it is taking, so it can defeat the human + * more easily. + * Its secondary purpose is to allow an eventual Civ4-style UI giving the human a helpful tooltip about the odds, + * thus no longer placing the human at a disadvantage relative to the AI. + * This class will start out very simple, but over time we will add more complex scenarios, such as calculating + * odds per round of combat, and calculating the likely results of whole-stack combat, which will allow the AI + * to make smart decisions about how many units it needs to defeat human defenses. + */ + public class CombatOdds { + public static double OddsOfVictory(MapUnit attacker, MapUnit defender) { + //Yanked from MapUnitExtension's `fight` method + IEnumerable attackBonuses = attacker.ListStrengthBonusesVersus(defender, CombatRole.Attack , attacker.facingDirection), + defenseBonuses = defender.ListStrengthBonusesVersus(attacker, CombatRole.Defense, attacker.facingDirection); + + double attackerStrength = attacker.unitType.attack * StrengthBonus.ListToMultiplier(attackBonuses), + defenderStrength = defender.unitType.defense * StrengthBonus.ListToMultiplier(defenseBonuses); + + return attackerStrength / (attackerStrength + defenderStrength); + } + } +} diff --git a/C7Engine/EntryPoints/TurnHandling.cs b/C7Engine/EntryPoints/TurnHandling.cs index 0e2c7816..a4b1341c 100644 --- a/C7Engine/EntryPoints/TurnHandling.cs +++ b/C7Engine/EntryPoints/TurnHandling.cs @@ -81,40 +81,41 @@ private static bool PlayPlayerTurns(GameData gameData, bool firstTurn) private static void SpawnBarbarians(GameData gameData) { //Generate new barbarian units. - Player barbPlayer = gameData.players.Find(player => player.isBarbarians); - foreach (Tile tile in gameData.map.barbarianCamps) { - //7% chance of a new barbarian. Probably should scale based on barbarian activity. - int result = GameData.rng.Next(100); - log.Verbose("Random barb result = " + result); - if (result < 4) { - MapUnit newUnit = new MapUnit(); - newUnit.location = tile; - newUnit.owner = gameData.players[0]; - newUnit.unitType = gameData.barbarianInfo.basicBarbarian; - newUnit.experienceLevelKey = gameData.defaultExperienceLevelKey; - newUnit.experienceLevel = gameData.defaultExperienceLevel; - newUnit.hitPointsRemaining = 3; - newUnit.isFortified = true; //todo: hack for unit selection - - tile.unitsOnTile.Add(newUnit); - gameData.mapUnits.Add(newUnit); - barbPlayer.units.Add(newUnit); - log.Debug("New barbarian added at " + tile); - } - else if (tile.NeighborsWater() && result < 6) { - MapUnit newUnit = new MapUnit(); - newUnit.location = tile; - newUnit.owner = gameData.players[0]; //todo: make this reliably point to the barbs - newUnit.unitType = gameData.barbarianInfo.barbarianSeaUnit; - newUnit.experienceLevelKey = gameData.defaultExperienceLevelKey; - newUnit.experienceLevel = gameData.defaultExperienceLevel; - newUnit.hitPointsRemaining = 3; - newUnit.isFortified = true; //todo: hack for unit selection - - tile.unitsOnTile.Add(newUnit); - gameData.mapUnits.Add(newUnit); - barbPlayer.units.Add(newUnit); - log.Debug("New barbarian galley added at " + tile); + BarbarianPlayer barbPlayer = (BarbarianPlayer)gameData.players.Find(player => player.isBarbarians); + foreach (BarbarianTribe tribe in barbPlayer.getTribes()) { + foreach (Tile tile in tribe.GetCamps()) { + //7% chance of a new barbarian. Probably should scale based on barbarian activity. + int result = GameData.rng.Next(100); + log.Verbose("Random barb result = " + result); + if (result < 5) { + MapUnit newUnit = new MapUnit(); + newUnit.location = tile; + newUnit.owner = barbPlayer; + newUnit.unitType = gameData.barbarianInfo.basicBarbarian; + newUnit.experienceLevelKey = gameData.defaultExperienceLevelKey; + newUnit.experienceLevel = gameData.defaultExperienceLevel; + newUnit.hitPointsRemaining = 3; + newUnit.isFortified = true; //todo: hack for unit selection + + tile.unitsOnTile.Add(newUnit); + gameData.mapUnits.Add(newUnit); + tribe.AddUnit(newUnit); + log.Debug("New barbarian added at " + tile); + } else if (tile.NeighborsWater() && result < 7) { + MapUnit newUnit = new MapUnit(); + newUnit.location = tile; + newUnit.owner = barbPlayer; + newUnit.unitType = gameData.barbarianInfo.barbarianSeaUnit; + newUnit.experienceLevelKey = gameData.defaultExperienceLevelKey; + newUnit.experienceLevel = gameData.defaultExperienceLevel; + newUnit.hitPointsRemaining = 3; + newUnit.isFortified = true; //todo: hack for unit selection + + tile.unitsOnTile.Add(newUnit); + gameData.mapUnits.Add(newUnit); + tribe.AddUnit(newUnit); + log.Debug("New barbarian galley added at " + tile); + } } } } diff --git a/C7Engine/MapUnitExtensions.cs b/C7Engine/MapUnitExtensions.cs index e2192bde..1fe5ce35 100644 --- a/C7Engine/MapUnitExtensions.cs +++ b/C7Engine/MapUnitExtensions.cs @@ -295,6 +295,12 @@ public static void OnEnterTile(this MapUnit unit, Tile tile) if (tile.hasBarbarianCamp && (!unit.owner.isBarbarians)) { tile.DisbandNonDefendingUnits(); EngineStorage.gameData.map.barbarianCamps.Remove(tile); + BarbarianPlayer barbarians = (BarbarianPlayer)EngineStorage.gameData.players.Find(player => player.isBarbarians); + foreach (BarbarianTribe tribe in barbarians.getTribes()) { + if (tribe.GetCamps().Contains(tile)) { + tribe.RemoveCamp(tile); + } + } tile.hasBarbarianCamp = false; } @@ -430,12 +436,7 @@ public static void disband(this MapUnit unit) // EngineStorage.animTracker.endAnimation(unit, false); TODO: Must send message instead of call directly unit.location.unitsOnTile.Remove(unit); gameData.mapUnits.Remove(unit); - foreach(Player player in gameData.players) - { - if (player.units.Contains(unit)) { - player.units.Remove(unit); - } - } + unit.owner.RemoveUnit(unit); } public static bool canBuildCity(this MapUnit unit) diff --git a/C7GameData/BarbarianPlayer.cs b/C7GameData/BarbarianPlayer.cs new file mode 100644 index 00000000..8bb59777 --- /dev/null +++ b/C7GameData/BarbarianPlayer.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace C7GameData { + /** + * Special class for the barbarian player. + * They have some unique behavior, notably being organized by tribe. + */ + public class BarbarianPlayer : Player { + private List tribes = new List(); + + public BarbarianPlayer(uint color) { + guid = Guid.NewGuid().ToString(); + this.color = (int)(color & 0xFFFFFFFF); + this.isBarbarians = true; + } + + public void AddTribe(Tile tile, MapUnit startingUnit) { + tribes.Add(new BarbarianTribe(tile, startingUnit)); + } + + public ReadOnlyCollection getTribes() { + return tribes.AsReadOnly(); + } + + public override void RemoveUnit(MapUnit unit) { + foreach (BarbarianTribe tribe in tribes) { + if (tribe.GetUnits().Contains(unit)) { + tribe.RemoveUnit(unit); + break; + } + } + } + } +} diff --git a/C7GameData/BarbarianTribe.cs b/C7GameData/BarbarianTribe.cs new file mode 100644 index 00000000..2c4f9514 --- /dev/null +++ b/C7GameData/BarbarianTribe.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace C7GameData { + public class BarbarianTribe { + private string name = "Vandals"; + private List campLocations = new List(); + private TileKnowledge tileKnowledge = new TileKnowledge(); + private List units = new List(); + + public BarbarianTribe(Tile startingCamp, MapUnit startingUnit) { + campLocations.Add(startingCamp); + tileKnowledge.AddTilesToKnown(startingCamp); + units.Add(startingUnit); + } + + public ReadOnlyCollection GetUnits() { + return units.AsReadOnly(); + } + + public void AddUnit(MapUnit unit) { + this.units.Add(unit); + } + + public void RemoveUnit(MapUnit unit) { + this.units.Remove(unit); + } + + public ReadOnlyCollection GetCamps() { + return campLocations.AsReadOnly(); + } + + public void RemoveCamp(Tile tile) { + this.campLocations.Remove(tile); + } + } +} diff --git a/C7GameData/GameData.cs b/C7GameData/GameData.cs index d8a92a99..e61b0e74 100644 --- a/C7GameData/GameData.cs +++ b/C7GameData/GameData.cs @@ -99,8 +99,7 @@ public Player CreateDummyGameData() this.turn = 0; uint white = 0xFFFFFFFF; - Player barbarianPlayer = new Player(white); - barbarianPlayer.isBarbarians = true; + BarbarianPlayer barbarianPlayer = new BarbarianPlayer(white); players.Add(barbarianPlayer); Civilization carthage = new Civilization(); @@ -235,6 +234,7 @@ public Player CreateDummyGameData() barbarian.facingDirection = TileDirection.SOUTHEAST; barbarian.location.hasBarbarianCamp = true; map.barbarianCamps.Add(barbCampLocation); + barbarianPlayer.AddTribe(barbCampLocation, barbarian); } } diff --git a/C7GameData/Player.cs b/C7GameData/Player.cs index 6e27b61f..6a236b5a 100644 --- a/C7GameData/Player.cs +++ b/C7GameData/Player.cs @@ -83,6 +83,12 @@ public bool KnowsAboutResource(Resource resource) { return true; } + public virtual void RemoveUnit(MapUnit unit) { + if (units.Contains(unit)) { + units.Remove(unit); + } + } + public override string ToString() { if (civilization != null) return civilization.cityNames.First();