Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion C7Engine/AI/UnitAI/SettlerAI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ C7GameData.UnitAI.MoveResult UnitAI.PlayTurnImpl(Player player, MapUnit unit) {
unit.movementPoints.onConsumeAll();
return C7GameData.UnitAI.Result.InProgress;
} else {
return this.TryToMoveAlongPath(unit, ref data.pathToDestination);
C7GameData.UnitAI.MoveResult moveResult = this.TryToMoveAlongPath(unit, ref data.pathToDestination);
if (moveResult.Result == C7GameData.UnitAI.Result.Error) {
return FindNewDestination(unit, player);
}
return moveResult;
}
break;
case SettlerAIData.SettlerGoal.JOIN_CITY:
Expand Down Expand Up @@ -99,5 +103,28 @@ private static bool IsInvalidCityLocation(Tile tile) {
public string SummarizePlan() {
return "SettlerAI: " + data.ToString();
}

// The destination became unreachable (issue #213); pick a new one, or
// fall back to JOIN_CITY if nothing is left.
// TODO: prefer path-checking at selection time over exclude-and-repick.
public C7GameData.UnitAI.MoveResult FindNewDestination(MapUnit unit, Player player) {
data.unreachableDestinations.Add(data.destination);
log.Information($"Settler {unit.id} cannot reach {data.destination}, retargeting");

Tile newDestination = SettlerLocationAI.FindSettlerLocation(unit.location, player, data.unreachableDestinations);
if (newDestination == Tile.NONE) {
data.goal = SettlerAIData.SettlerGoal.JOIN_CITY;
log.Information($"Settler {unit.id} has no reachable destination left, joining a city instead");
} else {
data.destination = newDestination;
PathingAlgorithm algorithm = PathingAlgorithmChooser.GetAlgorithm(unit);
data.pathToDestination = algorithm.PathFrom(unit.location, newDestination, unit);
log.Information($"Settler {unit.id} retargeting from an unreachable tile to {newDestination}");
}

// Consume movement so PlayTurn does not retry the failed move this turn.
unit.movementPoints.onConsumeAll();
return C7GameData.UnitAI.Result.InProgress;
}
}
}
15 changes: 8 additions & 7 deletions C7Engine/AI/UnitAI/SettlerLocationAI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ namespace C7Engine {
public class SettlerLocationAI {
private static readonly Serilog.ILogger Log = Serilog.Log.ForContext<SettlerLocationAI>();

//Figures out where to plant Settlers
public static Tile FindSettlerLocation(Tile start, Player player) {
Dictionary<Tile, float> scores = GetScoredSettlerCandidates(start, player);
//Figures out where to plant Settlers.
public static Tile FindSettlerLocation(Tile start, Player player, HashSet<Tile> excludedTiles = null) {
Dictionary<Tile, float> scores = GetScoredSettlerCandidates(start, player, excludedTiles);
if (scores.Count == 0 || scores.Values.Max() <= 0) {
return Tile.NONE; //nowhere to settle
}
Expand All @@ -18,19 +18,20 @@ public static Tile FindSettlerLocation(Tile start, Player player) {
return result;
}

public static Dictionary<Tile, float> GetScoredSettlerCandidates(Tile start, Player player) {
public static Dictionary<Tile, float> GetScoredSettlerCandidates(Tile start, Player player, HashSet<Tile> excludedTiles = null) {
List<MapUnit> playerUnits = player.units;
// TODO: handle settling other continents
IEnumerable<Tile> candidates = player.tileKnowledge.AllKnownTiles().Where(t => !IsInvalidCityLocation(t) && t.continent == start.continent);
Dictionary<Tile, float> scores = AssignTileScores(start, player, candidates, playerUnits.FindAll(u => u.unitType.name == "Settler"));
Dictionary<Tile, float> scores = AssignTileScores(start, player, candidates, playerUnits.FindAll(u => u.unitType.name == "Settler"), excludedTiles);
return scores;
}

private static Dictionary<Tile, float> AssignTileScores(Tile startTile, Player player, IEnumerable<Tile> candidates, List<MapUnit> playerSettlers) {
private static Dictionary<Tile, float> AssignTileScores(Tile startTile, Player player, IEnumerable<Tile> candidates, List<MapUnit> playerSettlers, HashSet<Tile> excludedTiles = null) {
Dictionary<Tile, float> scores = new();
var memo = new Dictionary<string, float>();

candidates = candidates.Where(t => !SettlerAlreadyMovingTowardsTile(t, playerSettlers) && t.IsAllowCities());
// Only tiles the settler failed to reach (issue #213) are excluded.
candidates = candidates.Where(t => !SettlerAlreadyMovingTowardsTile(t, playerSettlers) && t.IsAllowCities() && (excludedTiles == null || !excludedTiles.Contains(t)));

foreach (Tile t in candidates) {
float score = GetTileYieldScore(t, player, memo);
Expand Down
5 changes: 5 additions & 0 deletions C7Engine/C7GameData/AIData/SettlerAIData.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Collections.Generic;

namespace C7GameData.AIData {
/**
* I'm playing around with different possibilities for AI here.
Expand Down Expand Up @@ -25,6 +27,9 @@ public enum SettlerGoal {
public TilePath pathToDestination;
public MapUnit escort;

// Tiles the settler could not reach (issue #213). Cleared when the AI is re-created.
public HashSet<Tile> unreachableDestinations = new();

public override string ToString() {
return goal + " at " + destination;
}
Expand Down
189 changes: 189 additions & 0 deletions EngineTests/AI/UnitAI/SettlerDestinationBlockedTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
using C7Engine;
using C7GameData;
using C7GameData.AIData;
using C7GameData.Save;
using EngineTests.Utils;
using System.Collections.Generic;
using Xunit;

namespace EngineTests.AI.UnitAI;

/// <summary>
/// Tests for issue #213: a settler whose destination becomes unreachable while
/// en route (e.g. a rival unit parks on it) picks a new destination instead of
/// re-evaluating the same impossible task every turn.
/// Selection does not reject a tile just because a foreign unit sits on it.
/// The tile is excluded only when the settler actually fails to reach it
/// (SettlerAI.FindNewDestination). These tests call that seam directly, since
/// PlayTurn cannot move units in the reduced test map.
/// </summary>
public sealed class SettlerDestinationBlockedTest : MapBase {
// start = (50,50), destination = (52,50), one tile east of start
private readonly Player aiPlayer;
private readonly Player rival;
private readonly Tile start;
private readonly Tile destination;

public SettlerDestinationBlockedTest() {
InitilizeStartTile(MakeDesertTile(), new TileLocation(50, 50));
start = startTile;

destination = MakeHillTile();
destination.XCoordinate = 52;
destination.YCoordinate = 50;
// Extra production so the destination clearly beats the hill alternative.
destination.overlayTerrainType.baseShieldProduction = 2;
AddNeighborsAndUpdateMap(start, destination, TileDirection.EAST);
AddNeighborsAndUpdateMap(destination, start, TileDirection.WEST);

aiPlayer = MakeAiPlayer();
rival = MakeCivPlayer();

foreach (Tile tile in new List<Tile> { start, destination }) {
aiPlayer.tileKnowledge.knownTiles.Add(tile);
}

// With a home city, the settler goes looking for a spot.
Tile homeTile = MakePlainsTile();
homeTile.XCoordinate = 10;
homeTile.YCoordinate = 10;
aiPlayer.cities.Add(new City(homeTile, aiPlayer, "Home", ID.None("")));
}

private static Player MakeCivPlayer() {
Player player = new Player();
player.id = ID.FromString("rival-1");
player.civilization = new Civilization();
return player;
}

private static Player MakeAiPlayer() {
Player player = new Player();
player.id = ID.FromString("ai-1");
player.civilization = new Civilization();
player.government = new Government();
player.rules = MakeTestRules();
return player;
}

private MapUnit MakeSettlerOnStart() {
MapUnit settler = MakeLandUnit(1);
settler.unitType.name = "Settler";
settler.owner = aiPlayer;
settler.nationality = aiPlayer.civilization;
settler.location = start;
start.unitsOnTile.Add(settler);
aiPlayer.units.Add(settler);
return settler;
}

private void ParkRivalUnitOnDestination() {
MapUnit blocker = MakeLandUnit(1);
blocker.unitType.attack = 1;
blocker.owner = rival;
blocker.location = destination;
destination.unitsOnTile.Add(blocker);
}

// A second hills candidate at (46,50), three hops west of the destination, so it stays
// reachable when the destination is excluded.
private Tile AddAlternativeCandidateWestOfStart() {
Tile midWest = MakePlainsTile();
AddNeighborsAndUpdateMap(start, midWest, TileDirection.WEST); // midWest = (48,50)
AddNeighborsAndUpdateMap(midWest, start, TileDirection.EAST);

Tile alternativeWest = MakeHillTile();
AddNeighborsAndUpdateMap(midWest, alternativeWest, TileDirection.WEST); // alternativeWest = (46,50)
AddNeighborsAndUpdateMap(alternativeWest, midWest, TileDirection.EAST);

aiPlayer.tileKnowledge.knownTiles.Add(midWest);
aiPlayer.tileKnowledge.knownTiles.Add(alternativeWest);
return alternativeWest;
}

[Fact]
private void ForeignOccupiedTileIsNotRejectedUntilItIsUnreachable() {
EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(3));

Assert.Equal(destination, SettlerLocationAI.FindSettlerLocation(start, aiPlayer));

// Still chosen with a unit parked on it; the exclusion set is what filters.
ParkRivalUnitOnDestination();
Assert.Equal(destination, SettlerLocationAI.FindSettlerLocation(start, aiPlayer));

HashSet<Tile> excluded = new HashSet<Tile> { destination };
Tile chosen = SettlerLocationAI.FindSettlerLocation(start, aiPlayer, excluded);
Assert.NotEqual(destination, chosen);
}

[Fact]
private void SettlerRetargetsWhenDestinationBecomesUnreachable() {
EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(4));

Tile alternative = AddAlternativeCandidateWestOfStart();

MapUnit settler = MakeSettlerOnStart();
settler.movementPoints.reset(settler.unitType.movement);

SettlerAIData data = SettlerAI.MakeAiData(settler, aiPlayer);
Assert.Equal(SettlerAIData.SettlerGoal.BUILD_CITY, data.goal);
Assert.Equal(destination, data.destination);

ParkRivalUnitOnDestination();

SettlerAI settlerAi = new SettlerAI(data);
settler.currentAI = settlerAi;
C7GameData.UnitAI.MoveResult result = settlerAi.FindNewDestination(settler, aiPlayer);

Assert.Equal(C7GameData.UnitAI.Result.InProgress, result.Result);
Assert.Contains(destination, data.unreachableDestinations);
Assert.Equal(alternative, data.destination);
Assert.Equal(SettlerAIData.SettlerGoal.BUILD_CITY, data.goal);
Assert.NotEmpty(data.pathToDestination.path);
Assert.False(settler.movementPoints.canMove);
}

[Fact]
private void SettlerGivesUpWhenNoReachableDestinationRemains() {
EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(5));

MapUnit settler = MakeSettlerOnStart();
settler.movementPoints.reset(settler.unitType.movement);

SettlerAIData data = SettlerAI.MakeAiData(settler, aiPlayer);
Assert.Equal(destination, data.destination);
ParkRivalUnitOnDestination();

// Only the blocked tile and the start (too close to it) are known.
SettlerAI settlerAi = new SettlerAI(data);
settler.currentAI = settlerAi;
C7GameData.UnitAI.MoveResult result = settlerAi.FindNewDestination(settler, aiPlayer);

Assert.Equal(C7GameData.UnitAI.Result.InProgress, result.Result);
Assert.Contains(destination, data.unreachableDestinations);
Assert.Equal(SettlerAIData.SettlerGoal.JOIN_CITY, data.goal);
Assert.False(settler.movementPoints.canMove);
}

[Fact]
private void SettlerFailsGracefullyWhenDestinationBlockedMidJourney() {
EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(2));

MapUnit settler = MakeSettlerOnStart();
settler.movementPoints.reset(settler.unitType.movement);

SettlerAIData data = SettlerAI.MakeAiData(settler, aiPlayer);
Assert.Equal(SettlerAIData.SettlerGoal.BUILD_CITY, data.goal);
Assert.Equal(destination, data.destination);
Assert.NotEmpty(data.pathToDestination.path);

ParkRivalUnitOnDestination();

SettlerAI settlerAi = new SettlerAI(data);
C7GameData.UnitAI.MoveResult result = settlerAi.TryToMoveAlongPath(settler, ref data.pathToDestination);

// Fails without exception; SettlerAI turns this Error into a retarget.
Assert.Equal(C7GameData.UnitAI.Result.Error, result.Result);
Assert.Equal(Tile.NONE, data.pathToDestination.Next());
}
}
Loading