Repository navigation
Settler re-picks a destination when its chosen spot becomes unreachable (#213) #1002
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
ajhalme
merged 6 commits into
C7-Game:Development
from
Billytifft:billy/ai-routing-foreign-barriers
Oct 4, 2026
Merged
Changes from 1 commit
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5d3ca42
Avoid routing through rival cities; don't send settlers to unreachabl…
Billytifft 46ea2b7
Address review: reuse MapUnit.HasForeignUnits instead of duplicating
Billytifft 122afb4
Rework #213 fix: repath to a new destination when the chosen one beco…
Billytifft fe2a2f4
Add TODO for a longer-term fix than exclude-and-repick (#213)
Billytifft 8ccb550
Trim comments on the #213 rework
Billytifft 6368a67
Drop #371 rival-city routing tests from this PR
Billytifft File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using C7Engine; | ||
| using C7Engine.Pathing; | ||
| using C7GameData; | ||
| using C7GameData.Save; | ||
| using EngineTests.Utils; | ||
| using Xunit; | ||
|
|
||
| namespace EngineTests.AI.Pathing; | ||
|
|
||
| /// <summary> | ||
| /// Regression tests for https://github.com/C7-Game/OpenCiv3/issues/371: | ||
| /// units should not route through rival cities. A city tile owned by another | ||
| /// civ must be excluded from the middle of a path, while still remaining a | ||
| /// valid destination for a combat unit that wants to attack it. | ||
| /// </summary> | ||
| public sealed class LandUnitAvoidsRivalCityTest : MapBase { | ||
| // A 3x4 patch of plains tiles arranged so that the direct line from the | ||
| // start to the destination passes through the rival city, but the city | ||
| // can be flanked along the top or bottom row: | ||
| // (48,48) (50,48) (52,48) (54,48) | ||
| // (48,50) (50,50) (52,50) (54,50) | ||
| // (48,52) (50,52) (52,52) (54,52) | ||
| // start = (50,50), rival city = (52,50), destination = (54,50) | ||
| private readonly List<Tile> grid = new(); | ||
| private readonly Player human; | ||
| private readonly Player rival; | ||
| private readonly Tile cityTile; | ||
| private readonly Tile destinationTile; | ||
|
|
||
| public LandUnitAvoidsRivalCityTest() { | ||
| int[,] coordinates = { | ||
| { 48, 48 }, { 50, 48 }, { 52, 48 }, { 54, 48 }, | ||
| { 48, 50 }, { 50, 50 }, { 52, 50 }, { 54, 50 }, | ||
| { 48, 52 }, { 50, 52 }, { 52, 52 }, { 54, 52 }, | ||
| }; | ||
|
|
||
| for (int i = 0; i < coordinates.GetLength(0); i++) { | ||
| Tile tile = MakePlainsTile(); | ||
| tile.XCoordinate = coordinates[i, 0]; | ||
| tile.YCoordinate = coordinates[i, 1]; | ||
| grid.Add(tile); | ||
| } | ||
|
|
||
| startTile = grid.Single(t => t.XCoordinate == 50 && t.YCoordinate == 50); | ||
| cityTile = grid.Single(t => t.XCoordinate == 52 && t.YCoordinate == 50); | ||
| destinationTile = grid.Single(t => t.XCoordinate == 54 && t.YCoordinate == 50); | ||
|
|
||
| ComputeAllNeighbors(grid.ToHashSet()); | ||
|
|
||
| human = MakeHumanPlayer("human-1"); | ||
| rival = MakeCivPlayer("rival-1"); | ||
|
|
||
| // The human has explored the whole patch, including the rival city. | ||
| foreach (Tile tile in grid) { | ||
| human.tileKnowledge.knownTiles.Add(tile); | ||
| } | ||
|
|
||
| // Belongs to a rival civ. | ||
| cityTile.cityAtTile = new City(cityTile, rival, "Rival City", ID.None("city")); | ||
| } | ||
|
|
||
| private static Player MakeCivPlayer(string id) { | ||
| Player player = new Player(); | ||
| player.id = ID.FromString(id); | ||
| player.civilization = new Civilization(); | ||
| return player; | ||
| } | ||
|
|
||
| private static Player MakeHumanPlayer(string id) { | ||
| Player player = MakeCivPlayer(id); | ||
| player.isHuman = true; | ||
| return player; | ||
| } | ||
|
|
||
| private MapUnit MakeLandUnitOnStart(Player owner, bool combat) { | ||
| MapUnit unit = MakeLandUnit(2); | ||
| if (combat) { | ||
| unit.unitType.attack = 1; | ||
| } | ||
| unit.owner = owner; | ||
| unit.location = startTile; | ||
| startTile.unitsOnTile.Add(unit); | ||
| return unit; | ||
| } | ||
|
|
||
| private static TilePath ComputePath(MapUnit unit, Tile destination) { | ||
| return PathingAlgorithmChooser.GetAlgorithm(unit).PathFrom(unit.location, destination, unit); | ||
| } | ||
|
|
||
| [Fact] | ||
| private void TestHumanCombatUnitDoesNotPathThroughRivalCityAtPeace() { | ||
| EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(1234)); | ||
| MapUnit unit = MakeLandUnitOnStart(human, combat: true); | ||
|
|
||
| TilePath path = ComputePath(unit, destinationTile); | ||
|
|
||
| // It can still reach the destination... | ||
| Assert.NotEmpty(path.path); | ||
| Assert.Contains(destinationTile, path.path); | ||
| // ...but must not route through the rival city. | ||
| Assert.DoesNotContain(cityTile, path.path); | ||
| } | ||
|
|
||
| [Fact] | ||
| private void TestHumanCombatUnitDoesNotPathThroughRivalCityAtWar() { | ||
| EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(1235)); | ||
| human.DeclareWarOn(rival, currentTurn: 1); | ||
| MapUnit unit = MakeLandUnitOnStart(human, combat: true); | ||
|
|
||
| TilePath path = ComputePath(unit, destinationTile); | ||
|
|
||
| Assert.NotEmpty(path.path); | ||
| Assert.Contains(destinationTile, path.path); | ||
| Assert.DoesNotContain(cityTile, path.path); | ||
| } | ||
|
|
||
| [Fact] | ||
| private void TestAiCombatUnitDoesNotPathThroughRivalCityAtPeace() { | ||
| EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(1236)); | ||
| Player aiPlayer = MakeCivPlayer("ai-1"); | ||
| MapUnit unit = MakeLandUnitOnStart(aiPlayer, combat: true); | ||
|
|
||
| TilePath path = ComputePath(unit, destinationTile); | ||
|
|
||
| Assert.NotEmpty(path.path); | ||
| Assert.Contains(destinationTile, path.path); | ||
| Assert.DoesNotContain(cityTile, path.path); | ||
| } | ||
|
|
||
| [Fact] | ||
| private void TestAiCombatUnitDoesNotPathThroughRivalCityAtWar() { | ||
| EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(1237)); | ||
| Player aiPlayer = MakeCivPlayer("ai-1"); | ||
| aiPlayer.DeclareWarOn(rival, currentTurn: 1); | ||
| MapUnit unit = MakeLandUnitOnStart(aiPlayer, combat: true); | ||
|
|
||
| TilePath path = ComputePath(unit, destinationTile); | ||
|
|
||
| Assert.NotEmpty(path.path); | ||
| Assert.Contains(destinationTile, path.path); | ||
| Assert.DoesNotContain(cityTile, path.path); | ||
| } | ||
|
|
||
| [Fact] | ||
| private void TestAiSettlerDoesNotPathThroughRivalCity() { | ||
| // The scenario described in #371: an AI settler routes through Athens. | ||
| EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(1240)); | ||
| Player aiPlayer = MakeCivPlayer("ai-1"); | ||
| MapUnit unit = MakeLandUnitOnStart(aiPlayer, combat: false); | ||
|
|
||
| TilePath path = ComputePath(unit, destinationTile); | ||
|
|
||
| // A non-combat unit cannot enter the rival city at all, so no path | ||
| // through it (or to tiles beyond it) is possible. | ||
| Assert.DoesNotContain(cityTile, path.path); | ||
| } | ||
|
|
||
| [Fact] | ||
| private void TestCombatUnitCanTargetRivalCityAsDestination() { | ||
| // Attacking a rival city is legitimate - only traversal is banned. | ||
| EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(1238)); | ||
| Player aiPlayer = MakeCivPlayer("ai-1"); | ||
| aiPlayer.DeclareWarOn(rival, currentTurn: 1); | ||
| MapUnit unit = MakeLandUnitOnStart(aiPlayer, combat: true); | ||
|
|
||
| TilePath path = ComputePath(unit, cityTile); | ||
|
|
||
| // A combat unit should be able to move onto (attack) a rival city. | ||
| Assert.NotEmpty(path.path); | ||
| Assert.Contains(cityTile, path.path); | ||
| } | ||
|
|
||
| [Fact] | ||
| private void TestNonCombatUnitCannotEnterRivalCityEvenAsDestination() { | ||
| EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(1239)); | ||
| Player aiPlayer = MakeCivPlayer("ai-1"); | ||
| MapUnit unit = MakeLandUnitOnStart(aiPlayer, combat: false); | ||
|
|
||
| TilePath path = ComputePath(unit, cityTile); | ||
|
|
||
| // A settler or worker cannot be ordered into a rival city at all. | ||
| Assert.Empty(path.path); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| 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 https://github.com/C7-Game/OpenCiv3/issues/213: an AI settler | ||
| /// whose path destination becomes unreachable must not stay stuck forever. | ||
| /// The original crash ("Could not get next part of path") is gone, but | ||
| /// FindSettlerLocation still re-picks a destination the settler can no longer | ||
| /// reach (e.g. because a rival unit parked on it), so the settler re-evaluates | ||
| /// the same impossible task every turn. | ||
| /// </summary> | ||
| public sealed class SettlerDestinationBlockedTest : MapBase { | ||
| // start = (50,50), destination = (52,50), one tile east | ||
| 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; | ||
| 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); | ||
| } | ||
|
|
||
| // The AI already has a home, so its 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); | ||
| } | ||
|
|
||
| [Fact] | ||
| private void SettlerDoesNotRetargetTheUnreachableDestination() { | ||
| EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(1)); | ||
|
|
||
| // Sanity check: with the destination clear, it is the chosen spot. | ||
| Assert.Equal(destination, SettlerLocationAI.FindSettlerLocation(start, aiPlayer)); | ||
|
|
||
| // A rival unit parks on the destination (the #213 scenario). | ||
| ParkRivalUnitOnDestination(); | ||
|
|
||
| // It must no longer be chosen, or the settler will re-evaluate this | ||
| // same impossible task forever. | ||
| Tile chosen = SettlerLocationAI.FindSettlerLocation(start, aiPlayer); | ||
| Assert.NotEqual(destination, chosen); | ||
| } | ||
|
|
||
| [Fact] | ||
| private void SettlerFailsGracefullyWhenDestinationBlockedMidJourney() { | ||
| EngineStorage.InitializeGameDataForTests(new C7GameData.GameData(2)); | ||
|
|
||
| MapUnit settler = MakeSettlerOnStart(); | ||
| settler.movementPoints.reset(settler.unitType.movement); | ||
|
|
||
| // The settler heads off while the destination is still clear. | ||
| SettlerAIData data = SettlerAI.MakeAiData(settler, aiPlayer); | ||
| Assert.Equal(SettlerAIData.SettlerGoal.BUILD_CITY, data.goal); | ||
| Assert.Equal(destination, data.destination); | ||
| Assert.NotEmpty(data.pathToDestination.path); | ||
|
|
||
| // The destination gets blocked while en route. | ||
| ParkRivalUnitOnDestination(); | ||
|
|
||
| SettlerAI settlerAi = new SettlerAI(data); | ||
| C7GameData.UnitAI.MoveResult result = settlerAi.TryToMoveAlongPath(settler, ref data.pathToDestination); | ||
|
|
||
| // The move fails gracefully (no exception) and the repath also cannot | ||
| // reach the blocked destination. | ||
| Assert.Equal(C7GameData.UnitAI.Result.Error, result.Result); | ||
| Assert.Equal(Tile.NONE, data.pathToDestination.Next()); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.