Skip to content

Commit d9dea6f

Browse files
authored
Merge pull request #913 from ajhalme/improve-settler-location-ai
Improve settler location AI
2 parents cbc0d44 + 609d049 commit d9dea6f

6 files changed

Lines changed: 77 additions & 29 deletions

File tree

C7Engine/AI/StrategicAI/ExpansionPriority.cs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public override void CalculateWeightAndMetadata(Player player) {
2020
if (player.cities.Count < 2) {
2121
this.calculatedWeight = 1000;
2222
} else {
23-
int score = UtilityCalculations.CalculateAvailableLandScore(player);
23+
float score = UtilityCalculations.CalculateAvailableLandScore(player);
2424
score = ApplyEarlyGameMultiplier(score);
2525
score = ApplyNationTraitMultiplier(score, player);
2626

@@ -58,18 +58,17 @@ public override string ToString() {
5858
return "ExpansionPriority";
5959
}
6060

61-
private int ApplyEarlyGameMultiplier(int score) {
61+
private float ApplyEarlyGameMultiplier(float score) {
6262
//If it's early game, multiply this score.
6363
//TODO: We haven't implemented the part for "how many turns does the game have?" yet. So this is hard-coded.
64-
int gameTurn = EngineStorage.gameData.turn;
65-
int percentOfGameFinished = (gameTurn * 100) / TEMP_GAME_LENGTH;
66-
if (percentOfGameFinished < EARLY_GAME_CUTOFF) {
67-
score = score * (EARLY_GAME_CUTOFF - percentOfGameFinished) / 5;
68-
}
69-
return score;
64+
var gameTurn = EngineStorage.gameData.turn;
65+
var percentOfGameFinished = (gameTurn * 100) / TEMP_GAME_LENGTH;
66+
var isEarlyGame = percentOfGameFinished < EARLY_GAME_CUTOFF;
67+
var multiplier = isEarlyGame ? (EARLY_GAME_CUTOFF - percentOfGameFinished) / 5.0f : 1.0f;
68+
return score * multiplier;
7069
}
7170

72-
private int ApplyNationTraitMultiplier(int score, Player player) {
71+
private float ApplyNationTraitMultiplier(float score, Player player) {
7372
// TODO: The "Expansionist" trait should give a higher priority to this strategic priority.
7473
return score;
7574
}
Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11

22
using System.Collections.Generic;
3+
using System.Linq;
34
using C7GameData;
45

56
namespace C7Engine.AI.StrategicAI {
@@ -11,16 +12,14 @@ namespace C7Engine.AI.StrategicAI {
1112
public class UtilityCalculations {
1213

1314
private static readonly int PossibleCityLocationScore = 2; //how much weight to give to each possible city location
14-
private static readonly int TileScoreDivider = 10; //how much to divide each location's tile score by
15+
private static readonly float TileScoreDivider = 10f; //how much to divide each location's tile score by
1516

16-
public static int CalculateAvailableLandScore(Player player) {
17+
public static float CalculateAvailableLandScore(Player player) {
1718
//Figure out if there's land to settle, and how much
18-
Dictionary<Tile, float> possibleLocations = SettlerLocationAI.GetScoredSettlerCandidates(player.cities[0].location, player);
19-
int score = possibleLocations.Count * PossibleCityLocationScore;
20-
foreach (int i in possibleLocations.Values) {
21-
score += i / TileScoreDivider;
22-
}
23-
return score;
19+
var possibleLocations = SettlerLocationAI.GetScoredSettlerCandidates(player.cities[0].location, player);
20+
var availableLand = possibleLocations.Count * PossibleCityLocationScore;
21+
var settlementQuality = possibleLocations.Values.Sum(i => i / TileScoreDivider);
22+
return settlementQuality + availableLand;
2423
}
2524
}
2625
}

C7Engine/AI/StrategicAI/WarPriority.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public override void CalculateWeightAndMetadata(Player player) {
4646
}
4747
}
4848

49-
bool outOfLandToExpandTo = UtilityCalculations.CalculateAvailableLandScore(player) == 0;
49+
bool outOfLandToExpandTo = UtilityCalculations.CalculateAvailableLandScore(player) < 1;
5050

5151
// Don't go to war if there's still land we should be expanding to.
5252
if (!outOfLandToExpandTo) {

C7Engine/AI/UnitAI/SettlerLocationAI.cs

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System;
12
using C7GameData;
23
using System.Collections.Generic;
34
using System.Linq;
@@ -27,15 +28,27 @@ public static Dictionary<Tile, float> GetScoredSettlerCandidates(Tile start, Pla
2728

2829
private static Dictionary<Tile, float> AssignTileScores(Tile startTile, Player player, IEnumerable<Tile> candidates, List<MapUnit> playerSettlers) {
2930
Dictionary<Tile, float> scores = new();
31+
var memo = new Dictionary<string, float>();
32+
3033
candidates = candidates.Where(t => !SettlerAlreadyMovingTowardsTile(t, playerSettlers) && t.IsAllowCities());
34+
3135
foreach (Tile t in candidates) {
32-
float score = GetTileYieldScore(t, player);
33-
//For simplicity's sake, I'm only going to look at immediate neighbors here, but
34-
//a lot more things should be considered over time.
35-
foreach (Tile nt in t.neighbors.Values) {
36-
score += GetTileYieldScore(nt, player);
36+
float score = GetTileYieldScore(t, player, memo);
37+
38+
// Consider all tiles within the BFC for total score.
39+
// Score contribution decreases linearly with distance, by 1/R with each step:
40+
// e.g., with four ranks of workable tiles, R=4:
41+
// city | 100% | 75% | 50% | 25% | 0% | 0% | ..
42+
var maxRank = player.rules.MaxRankOfWorkableTiles;
43+
foreach (Tile workable in t.GetTilesWithinRankDistance(maxRank)) {
44+
if (workable == Tile.NONE)
45+
continue;
46+
var rank = t.rankDistanceTo(workable);
47+
if (rank <= 0)
48+
continue;
49+
var adjustment = Math.Max(0, (maxRank - rank + 1f) / maxRank);
50+
score += GetTileYieldScore(workable, player, memo) * adjustment;
3751
}
38-
//TODO #802: Also look at the next ring out, with lower weights.
3952

4053
//Prefer hills for defense, and coast for boats and such.
4154
if (t.baseTerrainType.Key == "hills") {
@@ -45,6 +58,9 @@ private static Dictionary<Tile, float> AssignTileScores(Tile startTile, Player p
4558
score += player.civilization.Adjustments.WaterBonus;
4659
}
4760

61+
// Let defensibility play a role
62+
score += (float)t.baseTerrainType.defenseBonus.amount * 20.0f;
63+
4864
//Lower scores if they are far away
4965
float preDistanceScore = score;
5066
int distance = startTile.distanceTo(t);
@@ -63,7 +79,11 @@ private static Dictionary<Tile, float> AssignTileScores(Tile startTile, Player p
6379
return scores;
6480
}
6581

66-
private static float GetTileYieldScore(Tile t, Player owner) {
82+
private static float GetTileYieldScore(Tile t, Player owner, Dictionary<string, float> memo) {
83+
var key = $"Tile_{t.XCoordinate}_{t.YCoordinate}";
84+
if (memo.TryGetValue(key, out var value))
85+
return value;
86+
6787
float score = owner.civilization.Adjustments.FoodYieldBonus * t.foodYield(owner).yield;
6888
score += owner.civilization.Adjustments.ProductionYieldBonus * t.productionYield(owner).yield;
6989
score += owner.civilization.Adjustments.CommerceYieldBonus * t.commerceYield(owner).yield;
@@ -74,13 +94,14 @@ private static float GetTileYieldScore(Tile t, Player owner) {
7494
score += owner.civilization.Adjustments.LuxuryResourceBonus;
7595
}
7696
}
97+
98+
memo[key] = score;
7799
return score;
78100
}
79101

80102
private static bool IsInvalidCityLocation(Tile tile) {
81-
if (tile.HasCity) {
103+
if (tile == Tile.NONE || tile.HasCity)
82104
return true;
83-
}
84105
foreach (Tile neighbor in tile.neighbors.Values) {
85106
if (neighbor.HasCity) {
86107
return true;

EngineTests/AI/UnitAI/SettlerLocationTest.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
using System;
12
using C7Engine;
23
using C7GameData;
34
using C7GameData.AIData;
45
using C7GameData.Save;
56
using EngineTests.Utils;
67
using System.Collections.Generic;
8+
using System.Linq;
79
using Xunit;
810

911
namespace EngineTests.AI.UnitAI {
@@ -83,20 +85,23 @@ private void CloseOverFar() {
8385

8486
// a single plains tile surrounded by desert
8587
// not a good settlement spot, but our settler is already on this tile
86-
InitilizeStartTile(MakePlainsTile(), new TileLocation(50, 50));
88+
InitilizeStartTile(MakePlainsTile(), new TileLocation(25, 25));
8789
Tile close = startTile;
8890
map.AddRange(SurroundTile(close, MakeDesertTileWithDefaultYield));
8991

9092
// a hill tile surrounded by flood plains
9193
// high settlement score from yield but very far away
92-
InitilizeStartTile(MakeHillTile(), new TileLocation(200, 50));
94+
InitilizeStartTile(MakeHillTile(), new TileLocation(200, 25));
9395
Tile far = startTile;
9496
map.AddRange(SurroundTile(far, MakeFloodPlainTileWithDefaultYield));
9597

98+
InitPartialGameMap(250, 50, map);
99+
96100
Player player = MakeTestPlayer(map);
97101
Tile chosenTile = SettlerLocationAI.FindSettlerLocation(close, player);
98102
Assert.Equal(close, chosenTile);
99103
}
104+
100105
[Fact]
101106
private void NotAlreadyBeingSettled() {
102107
// just one hill tile

EngineTests/Utils/MapBase.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,17 @@ protected Player MakeTestPlayer(List<Tile> knownTiles) {
4545
Player player = MakePlayer(true);
4646
player.civilization = new Civilization();
4747
player.government = new Government();
48+
player.rules = MakeTestRules();
4849
foreach (Tile tile in knownTiles) { player.tileKnowledge.knownTiles.Add(tile); }
4950
return player;
5051
}
5152

53+
protected static Rules MakeTestRules() {
54+
return new Rules() {
55+
MaxRankOfWorkableTiles = 2
56+
};
57+
}
58+
5259
private TileDirection[] directions = {
5360
TileDirection.NORTH,
5461
TileDirection.NORTHEAST,
@@ -207,4 +214,21 @@ protected Tile MakeOceanTile() {
207214
overlayTerrainType = new() { Key = "ocean", movementCost = 1 }
208215
};
209216
}
217+
218+
/// <summary>
219+
/// Given game map dimensions in tiles and an incomplete set of tiles, fill the
220+
/// game map with NONE tiles, and then overlay the known tiles.
221+
/// </summary>
222+
/// <param name="tilesWidth"></param>
223+
/// <param name="tilesHeight"></param>
224+
/// <param name="tiles"></param>
225+
protected void InitPartialGameMap(int tilesWidth, int tilesHeight, List<Tile> tiles) {
226+
gameMap.numTilesWide = tilesWidth;
227+
gameMap.numTilesTall = tilesHeight;
228+
gameMap.tiles = Enumerable.Range(0, tilesWidth * tilesHeight).Select(x => Tile.NONE).ToList();
229+
foreach (var tile in tiles) {
230+
var idx = gameMap.tileCoordsToIndex(tile.XCoordinate, tile.YCoordinate);
231+
gameMap.tiles[idx] = tile;
232+
}
233+
}
210234
}

0 commit comments

Comments
 (0)