diff --git a/C7/Game.cs b/C7/Game.cs index 0a0e9179..14b3da2e 100644 --- a/C7/Game.cs +++ b/C7/Game.cs @@ -320,6 +320,8 @@ private void OnPlayerStartTurn() { EngineStorage.ReadGameData((GameData gameData) => { log.Information("Starting player turn"); + new MsgCheckObsoleteDeals(controller).send(); + // If the player can now pick a new government, force them to do so. // When the popup is closed we call OnPlayerStartTurn again. This isn't // ideal, but we don't yet have a general purpose "show a popup and diff --git a/C7/UIElements/Diplomacy/DealScreen.cs b/C7/UIElements/Diplomacy/DealScreen.cs index 4e7d36be..88661f60 100644 --- a/C7/UIElements/Diplomacy/DealScreen.cs +++ b/C7/UIElements/Diplomacy/DealScreen.cs @@ -1,9 +1,8 @@ using C7Engine; using C7GameData; using Godot; -using System; using System.Collections.Generic; -using ConvertCiv3Media; +using static C7GameData.PlayerRelationship; // At a high level the deal screen has 4 parts; 2 "TradingTree"s per player, and // 2 "TradeOfferUi"s per player. @@ -73,7 +72,7 @@ private void CreateUI() { EngineStorage.ReadGameData((GameData gD) => { Player opponentPlayer = gD.players.Find(x => x.id == opponentPlayerId); Player humanPlayer = gD.players.Find(x => x.id == humanPlayerId); - bool playersAtWar = humanPlayer.playerRelationships[opponentPlayer.id].atWar; + bool playersAtWar = AtWar(humanPlayer, opponentPlayer); GetParent().AddLeaderHeadAndLabel(this, opponentPlayer, fontTheme); // Figure out which technologies can be traded by each player, if any. diff --git a/C7/UIElements/Popups/DiplomacySelection.cs b/C7/UIElements/Popups/DiplomacySelection.cs index 59cb452a..4004bd25 100644 --- a/C7/UIElements/Popups/DiplomacySelection.cs +++ b/C7/UIElements/Popups/DiplomacySelection.cs @@ -1,10 +1,6 @@ using Godot; -using System; -using System.Diagnostics; using C7GameData; -using C7GameData.Save; using System.Collections.Generic; -using Serilog; // The popup for selecting which other civilization to contact. public partial class DiplomacySelection : Popup { @@ -29,7 +25,7 @@ public override void _Ready() { int vOffset = 65; foreach (KeyValuePair kvp in player.playerRelationships) { - string status = kvp.Value.atWar ? "War" : "Peace"; + string status = kvp.Value.AtWar() ? "War" : "Peace"; AddButton($"{allPlayers.Find(x => x.id == kvp.Key).civilization.noun} (at {status})", vOffset, () => { Node parent = GetParent(); parent.EmitSignal(PopupOverlay.SignalName.HidePopup); diff --git a/C7Engine/AI/ChooseProducible.cs b/C7Engine/AI/ChooseProducible.cs index 9e8d1d82..90c78fa5 100644 --- a/C7Engine/AI/ChooseProducible.cs +++ b/C7Engine/AI/ChooseProducible.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; using System.Linq; -using C7Engine.AI.StrategicAI; using Serilog; using C7GameData; +using static C7GameData.PlayerRelationship; namespace C7Engine { public class ChooseProducible { @@ -52,7 +52,7 @@ private static float ScoreProducible(ProducibleStats stats, City city, Player pl private static float ScoreUnit(ProducibleStats stats, City city, Player player, UnitPrototype unit) { bool isSettler = unit.actions.Contains(UnitAction.BuildCity); bool isWorker = unit.isWorker; - bool atWar = PlayerAI.PlayerIsAtWarWithSomeone(player); + bool atWar = IsInAnyWar(player, EngineStorage.gameData.players); bool cityGuarded = city.location.unitsOnTile.Count(u => u.CanDefendOnLand()) > 0; bool hasUnescortedSettler = HasUnescortedSettler(city); var (totalUnits, allowedUnits, unitSupportCost) = player.TotalUnitsAllowedUnitsAndSupportCost(); @@ -179,7 +179,7 @@ private static float ScoreUnit(ProducibleStats stats, City city, Player player, } private static float ScoreBuilding(ProducibleStats stats, City city, Player player, Building building) { - bool atWar = PlayerAI.PlayerIsAtWarWithSomeone(player); + bool atWar = IsInAnyWar(player, EngineStorage.gameData.players); float score = 0; diff --git a/C7Engine/AI/PlayerAI.cs b/C7Engine/AI/PlayerAI.cs index 663a2eab..1e2a6de1 100644 --- a/C7Engine/AI/PlayerAI.cs +++ b/C7Engine/AI/PlayerAI.cs @@ -11,6 +11,7 @@ using C7Engine.AI.UnitAI; using Serilog; using System.Diagnostics; +using static C7GameData.PlayerRelationship; namespace C7Engine { public class PlayerAI { @@ -27,6 +28,8 @@ public static async Task PlayTurn(Player player, Random rng, List techs) { stopwatch.Start(); log.Information("-> Begin " + player.civilization.cityNames[0] + " turn"); + new MsgCheckObsoleteDeals(player).send(); + MaybeDoPriorityReevaluation(player); MaybePickTechToResearch(player, techs); @@ -167,7 +170,7 @@ public static UnitAI GetAIForUnit(MapUnit unit, Player player) { } // Special case: we're at war. - if (PlayerIsAtWarWithSomeone(player)) { + if (IsInAnyWar(player, EngineStorage.gameData.players)) { // Priority 1: ensure we don't have any unguarded cities. // // If this is an offensive unit only go defend if there are @@ -227,20 +230,6 @@ public static UnitAI GetAIForUnit(MapUnit unit, Player player) { return new DefenderAI(DefenderAI.MakeAiDataForDefendAtRiskCity(unit, player, minDefenders: int.MaxValue)); } - public static bool PlayerIsAtWarWithSomeone(Player player) { - foreach (KeyValuePair p in player.playerRelationships) { - if (p.Value.atWar) { - Player other = EngineStorage.gameData.players.Find(x => x.id == p.Key); - if (other.isBarbarians) { - continue; - } - - return true; - } - } - return false; - } - private static UnitAI GetCombatAIIfUnitCanAttackNearbyBarbCamp(MapUnit unit, Player player) { if (unit.unitType.attack <= 0) { return null; @@ -278,7 +267,7 @@ private static async Task AttemptTrading(Player us) { foreach (Player them in EngineStorage.gameData.players) { // We can't trade with players we don't know or players we're at // war with. - if (!us.playerRelationships.ContainsKey(them.id) || us.playerRelationships[them.id].atWar) { + if (!us.playerRelationships.ContainsKey(them.id) || AtWar(us, them)) { continue; } diff --git a/C7Engine/AI/StrategicAI/WarPriority.cs b/C7Engine/AI/StrategicAI/WarPriority.cs index 2a8e8299..12c3b734 100644 --- a/C7Engine/AI/StrategicAI/WarPriority.cs +++ b/C7Engine/AI/StrategicAI/WarPriority.cs @@ -40,7 +40,7 @@ public override void CalculateWeightAndMetadata(Player player) { foreach (KeyValuePair p in player.playerRelationships) { // TODO: Make sure having seen barbarians doesn't prevent us from // declaring new wars. - if (p.Value.atWar) { + if (p.Value.AtWar()) { this.calculatedWeight = 1000; return; } diff --git a/C7Engine/AI/UnitAI/CombatAI.cs b/C7Engine/AI/UnitAI/CombatAI.cs index 088eb37e..9fb43ac4 100644 --- a/C7Engine/AI/UnitAI/CombatAI.cs +++ b/C7Engine/AI/UnitAI/CombatAI.cs @@ -103,7 +103,7 @@ private static HashSet GetPlayersAtWarWith(Player player) { } foreach (KeyValuePair p in player.playerRelationships) { - if (p.Value.atWar) { + if (p.Value.AtWar()) { enemyIds.Add(p.Key); } } diff --git a/C7Engine/C7GameData/ImportCiv3.cs b/C7Engine/C7GameData/ImportCiv3.cs index e146571a..2758d7c3 100644 --- a/C7Engine/C7GameData/ImportCiv3.cs +++ b/C7Engine/C7GameData/ImportCiv3.cs @@ -502,14 +502,15 @@ private void ImportSavLeaders() { i = 0; foreach (QueryCiv3.Sav.LEAD leader in savData.Lead) { List contacts = leader.GetContact(); - List warStatus = leader.GetWarStatuses(); List refuseContactForTurns = leader.GetRefuseContactForTurns(); for (int j = 0; j < contacts.Count; ++j) { if (contacts[j] > 0) { QueryCiv3.Sav.LEAD_LEAD relationship = savData.ReputationRelationship[i][j]; save.Players[i].playerRelationships.Add(save.Players[j].id.ToString(), new PlayerRelationship() { - atWar = warStatus[j], warDeclarationCount = relationship.WarDeclarationCount, + // I don't think there is a way to figure this out for .sav or .biq files + // so by default we set this to 0 for these games + warDeclarationWithRoPActiveCount = 0, wasSneakAttacked = relationship.WasSneakAttacked == 1, refuseContactUntilTurn = refuseContactForTurns[j] > 0 ? @@ -749,17 +750,19 @@ private void ImportSavLeaders() { } foreach (SavePlayer savePlayer in save.Players) { - int other = 0; log.Information($"- - - - - - - - - - - - - - - - - - {savePlayer.civilization} - - - - - - - - - - - - - - - - - - "); - foreach (PlayerRelationship pr in savePlayer.playerRelationships.Values) { - other++; - foreach (MultiTurnDeal mtd in pr.multiTurnDeals) { - string against = mtd.dealSubType == DealSubType.MilitaryAlliance || mtd.dealSubType == DealSubType.TradeEmbargo ? $"(against: {save.Players.First(p => p.id == mtd.againstPlayer).civilization}({mtd.againstPlayer}))" : ""; + foreach (KeyValuePair pr in savePlayer.playerRelationships) { + if (pr.Value.multiTurnDeals.Count == 0) { + log.Information($"{savePlayer} is at war with {save.Players.First(c => c.id.ToString() == pr.Key)}"); + continue; + } + foreach (MultiTurnDeal mtd in pr.Value.multiTurnDeals) { + string against = mtd.dealSubType == DealSubType.MilitaryAlliance || mtd.dealSubType == DealSubType.TradeEmbargo ? $"(against: {save.Players.First(p => p.id == mtd.againstPlayer)})" : ""; string gpt = mtd.dealSubType == DealSubType.GoldPerTurn? $"({mtd.goldPerTurn} gold per turn)" : ""; string rpt = mtd.dealSubType == DealSubType.LuxuryPerTurn || mtd.dealSubType == DealSubType.ResourcePerTurn ? $"({mtd.resourcePerTurn} per turn)" : ""; - log.Information($"{savePlayer.civilization}({savePlayer.id}) " + + log.Information($"{savePlayer} " + $"has {mtd.dealSubType} " + - $"with {save.Players[other].civilization}({save.Players[other].id}) " + + $"with {save.Players.First(c => c.id.ToString() == pr.Key)} " + $"for another {mtd.TurnsRemaining(save.TurnNumber)} turns {mtd.turnStartDeal}-{mtd.turnEndDeal}" + $" {against} {gpt} {rpt} {mtd.dealDetails}"); } diff --git a/C7Engine/C7GameData/Player.cs b/C7Engine/C7GameData/Player.cs index 5ded2de7..144cb00c 100644 --- a/C7Engine/C7GameData/Player.cs +++ b/C7Engine/C7GameData/Player.cs @@ -2,10 +2,11 @@ using System.Collections.Generic; using System.Linq; using C7Engine.AI.StrategicAI; -using C7GameData.Save; using C7Engine; using Serilog; using static C7GameData.EraUtils; +using static C7GameData.MultiTurnDeal; +using static C7GameData.PlayerRelationship; namespace C7GameData { @@ -219,76 +220,70 @@ public bool HasExploredTile(Tile tile) { } public bool IsAtPeaceWith(Player other) { - // Evaluate this before checking for barbarians so barbarians don't - // attack themselves. - if (other == this) { - return true; - } - - if (other.isBarbarians || this.isBarbarians) { - return false; - } - - if (playerRelationships.ContainsKey(other.id)) { - return !playerRelationships[other.id].atWar; - } - return true; + return AtPeace(this, other); } public void EnsureRelationshipExists(Player other) { - if (isBarbarians || other.isBarbarians) + if (this.isBarbarians || other.isBarbarians || this.id == other.id || this.defeated || other.defeated) return; - if (!playerRelationships.ContainsKey(other.id)) { - playerRelationships.Add(other.id, new PlayerRelationship()); - } - if (!other.playerRelationships.ContainsKey(this.id)) { - other.playerRelationships.Add(this.id, new PlayerRelationship()); + // If the mutual relationship is not established it means that the 2 civs + // were not aware of each other (aka they just met), and therefore cannot be at war. + // Initialize the relationship and establish peace automatically between them. + if (!this.playerRelationships.ContainsKey(other.id) || !other.playerRelationships.ContainsKey(this.id)) { + this.playerRelationships.TryAdd(other.id, new PlayerRelationship()); + other.playerRelationships.TryAdd(this.id, new PlayerRelationship()); + RegisterMultiTurnDeal(this, other, DEFAULT_PEACE); + + log.Information($"Established first contact and relationship between players {this} and {other}"); } } public void DeclareWarOn(Player other, int currentTurn) { EnsureRelationshipExists(other); - playerRelationships[other.id].atWar = true; - - PlayerRelationship pr = other.playerRelationships[this.id]; - pr.atWar = true; - pr.warDeclarationCount += 1; - // Check to see if there was a sneak attack - we consider a sneak // attack any attack where the player's units were inside the // borders of the civ they're declaring war on. - foreach (Tile t in other.tileKnowledge.knownTiles) { - if (t.owningCity == null || t.owningCity.owner != other) { - continue; - } - if (t.unitsOnTile.Count == 0) { - continue; - } - if (t.unitsOnTile[0].owner == this) { - pr.wasSneakAttacked = true; - break; - } - } - + bool isSneakAttack = IsASneakAttackOn(other); + // TODO: take into account broken right of passage, or other deals, etc? + // Perhaps we need a dedicated method to calculate this. + // // Refuse contact from the aggressor civ until enough turns have // elapsed. The exact civ3 mechanism here is unknown, so we just // pick some reasonable random number. To penalize sneak attacks we // use a higher upper bound. - pr.refuseContactUntilTurn = currentTurn + new Random().Next(5, pr.wasSneakAttacked ? 16 : 12); + int refuseContactUntilTurn = currentTurn + new Random().Next(5, isSneakAttack ? 16 : 12); + + DeclareWar(this, other, isSneakAttack, refuseContactUntilTurn); // Whenever war is declared, re-evaluate priorities. turnsUntilPriorityReevaluation = 0; other.turnsUntilPriorityReevaluation = 0; } + private bool IsASneakAttackOn(Player other) { + foreach (Tile location in other.tileKnowledge.knownTiles) { + if (location.owningCity == null || location.owningCity.owner != other) { + continue; + } + if (location.unitsOnTile.Count == 0) { + continue; + } + if (location.unitsOnTile[0].owner == this) { + return true; + } + } + + return false; + } + public bool WillAcceptCommunicationFrom(Player other, int currentTurn) { EnsureRelationshipExists(other); PlayerRelationship pr = playerRelationships[other.id]; - if (!pr.atWar) { + if (AtPeace(this, other)) { return true; } return currentTurn >= pr.refuseContactUntilTurn; @@ -299,12 +294,6 @@ public bool SitsOutFirstTurn() { return isBarbarians; } - // TODO : This is a placeholder so that we can factor this in when calculating movement costs - // since multiturn deals are not yet implemented - public bool HasRightOfPassageAgreementWith(Player other) { - return false; - } - public static bool CanMoveFreely(Player player, Tile sourceTile, Tile targetTile) { if (!player.HasExploredTile(targetTile)) return true; @@ -322,7 +311,12 @@ public static bool CanMoveFreely(Player player, Tile sourceTile, Tile targetTile // All the other cases are either from or to "enemy" tiles // and without a RoP agreement the cost is never reduced. // check other && RoP - if (player.HasRightOfPassageAgreementWith(targetTileOwner)) { + if (sourceTileOwner != null && sourceTileOwner != player + && HaveActiveRightOfPassage(player, sourceTileOwner)) { + return true; + } + if (targetTileOwner != null && targetTileOwner != player + && HaveActiveRightOfPassage(player, targetTileOwner)) { return true; } @@ -349,7 +343,7 @@ public int RemainingCities() { public override string ToString() { if (civilization != null) - return civilization.cityNames.First(); + return $"{civilization.name} [{this.id}]"; return ""; } @@ -434,9 +428,7 @@ public void ExecuteDeal(GameData gameData, Player other, TradeOffer theirOffer, log.Information($" {this} gives {ourOffer.ToString()}, worth {ourOffer.GoldEquivalentFor(gameData, other)} gold"); log.Information($" {other} gives {theirOffer.ToString()}, worth {theirOffer.GoldEquivalentFor(gameData, this)} gold)"); if (theirOffer.partOfPeaceTreaty) { - log.Information($" {this} is now at peace with {other}"); - this.playerRelationships[other.id].atWar = false; - other.playerRelationships[this.id].atWar = false; + SignPeaceAfterWar(this, other, gameData); } if (ourOffer.gold.HasValue) { diff --git a/C7Engine/C7GameData/PlayerRelationship.cs b/C7Engine/C7GameData/PlayerRelationship.cs index fa03b3bc..004c1668 100644 --- a/C7Engine/C7GameData/PlayerRelationship.cs +++ b/C7Engine/C7GameData/PlayerRelationship.cs @@ -1,15 +1,35 @@ +using System; using System.Collections.Generic; +using System.Linq; +using C7Engine; +using Serilog; namespace C7GameData; // A class holding all the state of the relationship between two civs. +// If a relationship between 2 active civs doesn't exist it means that they haven't met yet. +// If a relationship exists but doesn't have any multi-turn deals on either side, +// it means that these 2 civs are at war, because Peace itself is a multi-turn deal. +// A civ can't (and shouldn't) have a relationship with themselves, barbarians or defeated players. +// +// Another important detail is that, a PlayerRelationship is a two part relationship, +// or a two-way relationship if it's easier to think about it like that. +// Therefore, a multiturn deal exists in both player's relationship info. +// If a deal is removed from player's 1 multiTurnDeals list, it still very much exists in player's 2 list, +// unless explicitly removed. public class PlayerRelationship { - public bool atWar = false; + private static ILogger log = Log.ForContext(); // p1.playerRelationships[p2].warDeclarationCount is the number of times // p2 declared war on p1. + // TODO: contribute towards reputation public int warDeclarationCount = 0; + // p1.playerRelationships[p2].warDeclarationWithRoPActiveCount is the number of times + // p2 declared war on p1 while having an active RoP. + // TODO: contribute towards reputation + public int warDeclarationWithRoPActiveCount = 0; + // true if a war declaration happened with units inside the player's // borders. public bool wasSneakAttacked = false; @@ -21,6 +41,189 @@ public class PlayerRelationship { public List multiTurnDeals = new List(); public bool declaredWarWithActiveRightOfPassage = false; + + public bool AtWar() { + return multiTurnDeals.Count == 0; + } + + /// + /// Returns true, and the left's player relationship to the right player, if it exists.
+ /// Otherwise returns false.
+ /// If you want the opposite relationship, flip the arguments when calling the method. + ///
+ /// + /// + /// + /// + public static bool TryGetRelationship(Player left, Player right, out PlayerRelationship relationship) { + relationship = null; + + if (left == null || right == null) return false; + if (left.id == right.id) return false; + if (left.isBarbarians || right.isBarbarians) return false; + if (left.defeated || right.defeated) return false; + if (!left.playerRelationships.TryGetValue(right.id, out var pr)) return false; + + relationship = pr; + return true; + } + + public static bool AtWar(Player left, Player right) { + // only one is barbarians, but not both + if (left.isBarbarians != right.isBarbarians) return true; + + if (TryGetRelationship(left, right, out var relationship) && relationship.AtWar()) { + return true; + } + + return false; + } + + public static bool AtPeace(Player left, Player right) { + return !AtWar(left, right); + } + + /// + /// Returns true if the player is at war with any of the other players/AI except the barbarians. + /// + /// + /// + /// + public static bool IsInAnyWar(Player player, List players) { + if (players.Any(other => !other.isBarbarians && AtWar(player, other))) { + return true; + } + + return false; + } + + public static bool HaveActiveRightOfPassage(Player left, Player right) { + return TryGetRelationship(left, right, out var pr) && + pr.multiTurnDeals.Any(d => d.dealSubType == DealSubType.RightOfPassage); + } + + // Breaks peace and all other multiturn deals when war is declared + public static void DeclareWar(Player aggressor, Player defender, bool sneakAttack, int refuseContactUntilTurn) { + var defenderRelationshipToAggressor = defender.playerRelationships[aggressor.id]; + var aggressorRelationshipToDefender = aggressor.playerRelationships[defender.id]; + // increment the times the aggressor has declared war on the defender + defenderRelationshipToAggressor.warDeclarationCount++; + + // increment the times the aggressor has declared war on the defender while there is an active RoP + if (HaveActiveRightOfPassage(aggressor, defender)) { + defenderRelationshipToAggressor.warDeclarationWithRoPActiveCount++; + } + + // update whether the defender was sneak attacked + defenderRelationshipToAggressor.wasSneakAttacked = sneakAttack; + + // Set for how many turns the defender will refuse contact from the aggressor + defenderRelationshipToAggressor.refuseContactUntilTurn = refuseContactUntilTurn; + + // TODO: Figure out a better formula to calculate the aggressor's refusal in turns. + // Right now it's hardcoded to half of what the defender's value is. + // + // The thinking is that if AI attacks a human, the human as a defender + // might try to talk to the AI aggressor earlier than an AI in it's place is programmed to do. + aggressorRelationshipToDefender.refuseContactUntilTurn = refuseContactUntilTurn / 2; + + // Finally clear all multi-turn deals, including Peace, which is how we actually declare war + aggressorRelationshipToDefender.multiTurnDeals = new List(); + defenderRelationshipToAggressor.multiTurnDeals = new List(); + + log.Information($"{aggressor} declared war on {defender}{(sneakAttack ? $" in a sneak attack" : "")}!" + + $" Defender is refusing contact for at least up to turn {refuseContactUntilTurn}" + + $" ({refuseContactUntilTurn - EngineStorage.gameData.turn} turns)!"); + } + + public static void SignPeaceAfterWar(Player left, Player right, GameData gameData) { + if (left.isBarbarians || left.defeated || right.isBarbarians || right.defeated || left.id == right.id) + throw new Exception($"Can't sign peace between {left} and {right}"); + + if (!AtWar(left, right)) + throw new Exception($"This is not the proper method to use if the two players are not at war"); + + MultiTurnDeal mtd = new MultiTurnDeal(DealType.DiplomaticAgreement, DealSubType.Peace, DealDetails.Exchange, + 0, null, gameData.rules.DefaultDealDuration, gameData.turn, null); + + RegisterMultiTurnDeal(left, right, mtd); + + left.playerRelationships[right.id].refuseContactUntilTurn = -1; + right.playerRelationships[left.id].refuseContactUntilTurn = -1; + + log.Information($"{left} signed a peace treaty with {right}"); + } + + public static void RegisterMultiTurnDeal(Player left, Player right, MultiTurnDeal mtd) { + if (mtd == null || mtd.dealDetails == DealDetails.None) + throw new Exception("Not a valid deal"); + + if (mtd.dealDetails == DealDetails.Exchange) { + RegisterTwoWayDeal(left, right, mtd); + return; + } + RegisterOneWayDeal(left, right, mtd); + } + + private static void RegisterOneWayDeal(Player left, Player right, MultiTurnDeal mtd) { + if (mtd.dealDetails == DealDetails.None || mtd.dealDetails == DealDetails.Exchange) + throw new Exception("This is not a valid one way deal. Perhaps you intended to use RegisterTwoWayDeal() instead."); + + // add the deal for this player + left.playerRelationships[right.id].multiTurnDeals.Add(mtd); + + // add the deal for the other player + right.playerRelationships[left.id].multiTurnDeals.Add(new MultiTurnDeal(mtd.dealType, mtd.dealSubType, + mtd.dealDetails == DealDetails.Inbound ? DealDetails.Outbound : DealDetails.Inbound, + mtd.goldPerTurn, mtd.resourcePerTurn, mtd.dealDuration, mtd.goldPerTurn, mtd.againstPlayer)); + } + + private static void RegisterTwoWayDeal(Player left, Player right, MultiTurnDeal mtd) { + if (mtd.dealDetails != DealDetails.Exchange) + throw new Exception("This is not a valid two way deal. Perhaps you intended to use RegisterOneWayDeal() instead."); + + // add the deal for this player + left.playerRelationships[right.id].multiTurnDeals.Add(mtd); + + // add the deal for the other player + right.playerRelationships[left.id].multiTurnDeals.Add(mtd); + } + + /// + /// This ends all multi-turn deals except peace, when they go over the initial agreed upon duration.
+ /// The multi-turn deal is only cancelled for the Player and not the other party. + ///
+ /// + /// + /// + public static void CheckForObsoleteDeals(Player player, List players, int currentTurn) { + var playerIds = players.Select(x => x.id).ToList(); + + // check player's relationship with the other players + foreach (var playerId in playerIds) { + Player other = players.First(p => p.id == playerId); + // if the player doesn't have a relationship with the other civ, or they are at war exit + if (TryGetRelationship(player, other, out var relationship) && !relationship.AtWar()) { + // we don't want to cancel peace + List deadDeals = relationship.multiTurnDeals + .Where(mtd => mtd != null + && mtd.dealSubType != DealSubType.Peace + && mtd.TurnsRemaining(currentTurn) <= 0) + .ToList(); + + foreach (MultiTurnDeal deadDeal in deadDeals) { + // TODO: Add a popup to notify if an AI/Human deal expires + // TODO: Add renegotiate logic (plus preferences option Always Renegotiate Deals) + log.Information($"Cancelling multi turn deal: {player} -- {other}"); + UnRegisterMultiTurnDeal(relationship, deadDeal); + } + } + } + } + + private static void UnRegisterMultiTurnDeal(PlayerRelationship relationship, MultiTurnDeal mtd) { + relationship.multiTurnDeals.Remove(mtd); + } } public class MultiTurnDeal { @@ -58,6 +261,56 @@ public MultiTurnDeal(DealType dealType, DealSubType dealSubType, DealDetails dea public int TurnsRemaining(int currentTurn) { return turnEndDeal - currentTurn <= 0 ? 0 : turnEndDeal - currentTurn; } + + // A multi turn peace deal that is from the start of the game without end. + // Used for when a civ meets another civ to establish the initial peace. + public static MultiTurnDeal DEFAULT_PEACE => new MultiTurnDeal(DealType.DiplomaticAgreement, DealSubType.Peace, + DealDetails.Exchange, 0, null, 0, 0, null); + + /// + /// Returns the counterpart to a deal.

+ /// The simplest example would be, if PlayerA gives wines to PlayerB, + /// PlayerA has an Outbound deal in their multiTurnDeals info, + /// whereas PlayerB has an Inbound deal, while the rest is the same.

+ /// This method, given PlayerA, PlayerB and the PlayerA's side of the deal (Outbound), + /// returns the PlayerB's side of the deal (Inbound).

+ /// We could have this also the other way around, and provide + /// PlayerB, PlayerA and PlayerB's side of the deal (Inbound), + /// and retrieve PlayerA's side of the deal (Outbound). + ///
+ /// + /// + /// + /// + public static MultiTurnDeal GetCounterpartDeal(Player playerA, Player playerB, MultiTurnDeal original) { + MultiTurnDeal mtd = null; + if (PlayerRelationship.TryGetRelationship(playerB, playerA, out var relationship)) { + DealDetails oppositeDetails = DealDetails.None; + if (original.dealDetails == DealDetails.Exchange) { + oppositeDetails = DealDetails.Exchange; + } else { + if (original.dealDetails == DealDetails.Inbound) { + oppositeDetails = DealDetails.Outbound; + } else { + oppositeDetails = DealDetails.Inbound; + } + } + + mtd = relationship.multiTurnDeals.FirstOrDefault(d => { + return + d.dealType == original.dealType + && d.dealSubType == original.dealSubType + && d.dealDetails == oppositeDetails + && d.goldPerTurn == original.goldPerTurn + && d.resourcePerTurn == original.resourcePerTurn + && d.dealDuration == original.dealDuration + && d.turnStartDeal == original.turnStartDeal + && d.againstPlayer == original.againstPlayer; + }); + + } + return mtd; + } } // https://github.com/maxpetul/C3X/blob/064c8307c5085205c0dc8f2ee5b61ad2c2606523/Civ3Conquests.h#L1399 diff --git a/C7Engine/C7GameData/Save/SavePlayer.cs b/C7Engine/C7GameData/Save/SavePlayer.cs index 1c5fc6b3..c9ff60dd 100644 --- a/C7Engine/C7GameData/Save/SavePlayer.cs +++ b/C7Engine/C7GameData/Save/SavePlayer.cs @@ -135,5 +135,11 @@ public SavePlayer(Player player) { playerRelationships.Add(keyValuePair.Key.ToString(), keyValuePair.Value); } } + + public override string ToString() { + if (civilization != null) + return $"{civilization} [{this.id}]"; + return ""; + } } } diff --git a/C7Engine/EntryPoints/MessageToEngine.cs b/C7Engine/EntryPoints/MessageToEngine.cs index 2c6d6a4a..b9fc53c6 100644 --- a/C7Engine/EntryPoints/MessageToEngine.cs +++ b/C7Engine/EntryPoints/MessageToEngine.cs @@ -1,3 +1,4 @@ +using System.Linq; using Serilog; namespace C7Engine { @@ -359,4 +360,22 @@ public override async void process() { public class MsgDiplomacyCompleted : MessageToEngine { public override void process() { } } + + public class MsgCheckObsoleteDeals : MessageToEngine { + private ILogger log = Log.ForContext(); + private Player player; + + public MsgCheckObsoleteDeals(Player player) { + this.player = player; + } + + public override void process() { + log.Information($"Checking to terminate any deals past their due duration for player {player}"); + + // TODO: Before we call this method to automatically end obsolete deals, we could make this more versatile. + // For example unless we have a good reason, as a human, receiving luxuries, gpt, + // or having an active RoP, doesn't hurt us. + PlayerRelationship.CheckForObsoleteDeals(player, EngineStorage.gameData.players, EngineStorage.gameData.turn); + } + } } diff --git a/C7Engine/EntryPoints/TurnHandling.cs b/C7Engine/EntryPoints/TurnHandling.cs index 9aee78b1..c37ff75e 100644 --- a/C7Engine/EntryPoints/TurnHandling.cs +++ b/C7Engine/EntryPoints/TurnHandling.cs @@ -1,9 +1,9 @@ using System.Diagnostics; -using C7Engine.AI; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Linq; using Serilog; +using static C7GameData.PlayerRelationship; namespace C7Engine { using System; diff --git a/EngineTests/GameData/PlayerRelationshipTest.cs b/EngineTests/GameData/PlayerRelationshipTest.cs new file mode 100644 index 00000000..2cabadae --- /dev/null +++ b/EngineTests/GameData/PlayerRelationshipTest.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; +using System.Linq; +using C7Engine; +using C7GameData; +using C7GameData.Save; +using Xunit; +using static C7GameData.PlayerRelationship; + +namespace EngineTests.GameData; + +public class PlayerRelationshipTest { + private static readonly string C7GameDataTestsFolderName = "EngineTests"; + private static string luaRulesDir => getBasePath("../C7/Lua/rules"); + private static string getBasePath(string file) => Path.Combine(testDirectory, file); + + private static string testDirectory { + get { + string[] parts = AppDomain.CurrentDomain.BaseDirectory.Split(Path.DirectorySeparatorChar); + int pos = parts.Reverse().ToList().FindIndex(s => s == C7GameDataTestsFolderName); + string up = string.Concat("..", Path.DirectorySeparatorChar); + string relativePath = string.Concat(Enumerable.Repeat(up, pos - 1)); + return Path.GetFullPath(relativePath); + } + } + + + [Fact] + public void TestHumanToBarbarianRelationship() { + string developerSave = getBasePath("../C7/Text/c7-static-map-save.json"); + SaveGame saveGame = SaveGame.Load(developerSave, null); + C7GameData.GameData gd = saveGame.ToGameData(luaRulesDir); + EngineStorage.InitializeGameDataForTests(gd); + + Player playerA = gd.players[0]; + Player playerB = gd.players[2]; + + Assert.False(TryGetRelationship(playerA, playerB, out _)); + + Assert.True(AtWar(playerA, playerB)); + // because playerA is barbarians + Assert.False(IsInAnyWar(playerB, gd.players)); + + // because a player can't sign peace with barbarians + Assert.Throws(() => SignPeaceAfterWar(playerA, playerB, gd)); + + Assert.False(TryGetRelationship(playerA, playerB, out _)); + } + + [Fact] + public void TestRelationshipAtVariousPoints() { + string developerSave = getBasePath("../C7/Text/c7-static-map-save.json"); + SaveGame saveGame = SaveGame.Load(developerSave, null); + C7GameData.GameData gd = saveGame.ToGameData(luaRulesDir); + EngineStorage.InitializeGameDataForTests(gd); + + Player playerA = gd.players[1]; + Player playerB = gd.players[2]; + + Assert.False(TryGetRelationship(playerA, playerB, out _)); + + playerA.EnsureRelationshipExists(playerB); + Assert.True(TryGetRelationship(playerA, playerB, out var relationshipA)); + PlayerRelationship relationshipB = null; + if (TryGetRelationship(playerB, playerA, out var relB)) { + relationshipB = relB; + } + Assert.True(AtPeace(playerA, playerB)); + Assert.False(IsInAnyWar(playerA, gd.players)); + Assert.False(IsInAnyWar(playerB, gd.players)); + Assert.False(HaveActiveRightOfPassage(playerA, playerB)); + + // because players not at war can't sign a peace treaty + Assert.Throws(() => SignPeaceAfterWar(playerA, playerB, gd)); + + MultiTurnDeal rop = new MultiTurnDeal(DealType.DiplomaticAgreement, DealSubType.RightOfPassage, DealDetails.Exchange, + 0, null, 20, 0, null); + + RegisterMultiTurnDeal(playerA, playerB, rop); + Assert.True(HaveActiveRightOfPassage(playerA, playerB)); + + int refusal = 10; + DeclareWar(playerA, playerB, false, refusal); + Assert.True(AtWar(playerA, playerB)); + Assert.True(IsInAnyWar(playerA, gd.players)); + Assert.True(IsInAnyWar(playerB, gd.players)); + Assert.True(relationshipA.multiTurnDeals.Count == 0); + Assert.True(relationshipB.multiTurnDeals.Count == 0); + Assert.False(HaveActiveRightOfPassage(playerA, playerB)); + Assert.False(relationshipA.wasSneakAttacked); + Assert.False(relationshipB.wasSneakAttacked); + Assert.False(relationshipA.warDeclarationWithRoPActiveCount == 1); + Assert.True(relationshipB.warDeclarationWithRoPActiveCount == 1); + Assert.True(relationshipA.refuseContactUntilTurn == refusal / 2); + Assert.True(relationshipB.refuseContactUntilTurn == refusal); + + SignPeaceAfterWar(playerA, playerB, gd); + Assert.True(AtPeace(playerA, playerB)); + Assert.False(IsInAnyWar(playerA, gd.players)); + Assert.False(IsInAnyWar(playerB, gd.players)); + Assert.False(HaveActiveRightOfPassage(playerA, playerB)); + } + + + [Fact] + public void TestMultiTurnDealRegistration() { + string developerSave = getBasePath("../C7/Text/c7-static-map-save.json"); + SaveGame saveGame = SaveGame.Load(developerSave, null); + C7GameData.GameData gd = saveGame.ToGameData(luaRulesDir); + EngineStorage.InitializeGameDataForTests(gd); + + Player playerA = gd.players[2]; + Player playerB = gd.players[3]; + playerA.EnsureRelationshipExists(playerB); + + // A's relationship to B (to be) + PlayerRelationship relationshipA = null; + // B's relationship to A (to be) + PlayerRelationship relationshipB = null; + + if (TryGetRelationship(playerA, playerB, out var relA)) + relationshipA = relA; + if (TryGetRelationship(playerB, playerA, out var relB)) + relationshipB = relB; + + Assert.True(relationshipA.multiTurnDeals.Count == 1); + Assert.True(relationshipB.multiTurnDeals.Count == 1); + + MultiTurnDeal horseDeal = new MultiTurnDeal(DealType.Resource, DealSubType.ResourcePerTurn, DealDetails.Inbound, + 0, "Horses", 20, 0, null); + + RegisterMultiTurnDeal(playerA, playerB, horseDeal); + + Assert.Contains(horseDeal, relationshipA.multiTurnDeals); + Assert.True(relationshipA.multiTurnDeals.Count == 2); + Assert.DoesNotContain(horseDeal, relationshipB.multiTurnDeals); + Assert.True(relationshipB.multiTurnDeals.Count == 2); + + Assert.Contains(horseDeal, relationshipA.multiTurnDeals); + Assert.Contains(MultiTurnDeal.GetCounterpartDeal(playerA, playerB, horseDeal), relationshipB.multiTurnDeals); + + + MultiTurnDeal winesDeal = new MultiTurnDeal(DealType.Resource, DealSubType.ResourcePerTurn, DealDetails.Outbound, + 0, "Wines", 20, 0, null); + + RegisterMultiTurnDeal(playerB, playerA, winesDeal); + + Assert.DoesNotContain(winesDeal, relationshipA.multiTurnDeals); + Assert.True(relationshipA.multiTurnDeals.Count == 3); + Assert.Contains(winesDeal, relationshipB.multiTurnDeals); + Assert.True(relationshipB.multiTurnDeals.Count == 3); + + Assert.Contains(winesDeal, relationshipB.multiTurnDeals); + Assert.Contains(MultiTurnDeal.GetCounterpartDeal(playerB, playerA, winesDeal), relationshipA.multiTurnDeals); + } +}