Skip to content
Open
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
424 changes: 388 additions & 36 deletions C7/Text/c7-static-map-save-standalone.json

Large diffs are not rendered by default.

424 changes: 388 additions & 36 deletions C7/Text/c7-static-map-save.json

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions C7Engine/AI/ChooseProducible.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,9 @@ private static float ScoreUnit(ProducibleStats stats, City city, Player player,
temp.location = city.location;

////////////////////////////////////////////////////////////////////
///
///
/// Raw score adjustments based on unit stats.
///
///

// Weight the unit's attack and defense score by comparing it to the
// best stat available.
Expand All @@ -89,9 +89,9 @@ private static float ScoreUnit(ProducibleStats stats, City city, Player player,
score -= populationCostPenalty;

////////////////////////////////////////////////////////////////////
///
///
/// Naval units
///
///

if (unit.categories.Contains("Sea")) {
// Don't bother building naval units unless we border the ocean.
Expand All @@ -108,9 +108,9 @@ private static float ScoreUnit(ProducibleStats stats, City city, Player player,
}

////////////////////////////////////////////////////////////////////
///
///
/// Adjustments based on the city situation.
///
///

// Prioritize defending the city if it is unguarded.
if (!cityGuarded && unit.defense == 0) {
Expand All @@ -129,9 +129,9 @@ private static float ScoreUnit(ProducibleStats stats, City city, Player player,
}

////////////////////////////////////////////////////////////////////
///
///
/// Adjustments for settlers and workers.
///
///

// Don't built a worker or settler if we don't have enough population.
if (CityIsTooSmall(city, unit)) {
Expand Down Expand Up @@ -315,10 +315,10 @@ private static int NumberOfReachableOpenCitySpots(City city) {

// Note: GetScoredSettlerCandidates already excludes tiles with
// settlers moving towards them.
Dictionary<Tile, int> scoredLocations = SettlerLocationAI.GetScoredSettlerCandidates(city.location, city.owner);
List<KeyValuePair<Tile, int>> orderedScores = scoredLocations.OrderByDescending(t => t.Value).ToList();
Dictionary<Tile, float> scoredLocations = SettlerLocationAI.GetScoredSettlerCandidates(city.location, city.owner);
List<KeyValuePair<Tile, float>> orderedScores = scoredLocations.OrderByDescending(t => t.Value).ToList();

foreach ((Tile tile, int score) in orderedScores) {
foreach ((Tile tile, float score) in orderedScores) {
if (scoredLocations.ContainsKey(tile)) {
++result;

Expand Down
10 changes: 5 additions & 5 deletions C7Engine/AI/StrategicAI/UtilityCalculations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ namespace C7Engine.AI.StrategicAI {
/// </summary>
public class UtilityCalculations {

private static readonly int POSSIBLE_CITY_LOCATION_SCORE = 2; //how much weight to give to each possible city location
private static readonly int TILE_SCORE_DIVIDER = 10; //how much to divide each location's tile score by
private static readonly int PossibleCityLocationScore = 2; //how much weight to give to each possible city location
private static readonly int TileScoreDivider = 10; //how much to divide each location's tile score by

public static int CalculateAvailableLandScore(Player player) {
//Figure out if there's land to settle, and how much
Dictionary<Tile, int> possibleLocations = SettlerLocationAI.GetScoredSettlerCandidates(player.cities[0].location, player);
int score = possibleLocations.Count * POSSIBLE_CITY_LOCATION_SCORE;
Dictionary<Tile, float> possibleLocations = SettlerLocationAI.GetScoredSettlerCandidates(player.cities[0].location, player);
int score = possibleLocations.Count * PossibleCityLocationScore;
foreach (int i in possibleLocations.Values) {
score += i / TILE_SCORE_DIVIDER;
score += i / TileScoreDivider;
}
return score;
}
Expand Down
5 changes: 2 additions & 3 deletions C7Engine/AI/UnitAI/SettlerAI.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
using System;
using C7Engine.Pathing;
using C7GameData;
using C7GameData.AIData;
using Serilog;

namespace C7Engine {
public class SettlerAI : C7GameData.UnitAI {
public class SettlerAI : UnitAI {
private static ILogger log = Log.ForContext<SettlerAI>();
public SettlerAIData data;

Expand All @@ -17,7 +16,7 @@ public static SettlerAIData MakeAiData(MapUnit unit, Player player) {
settlerAiData.destination = unit.location;
log.Information("No cities yet! Set AI for unit to settler AI with destination of " + settlerAiData.destination);
} else {
settlerAiData.destination = SettlerLocationAI.findSettlerLocation(unit.location, player);
settlerAiData.destination = SettlerLocationAI.FindSettlerLocation(unit.location, player);
if (settlerAiData.destination == Tile.NONE) {
//This is possible if all tiles within 4 tiles of a city are either not land, or already claimed
//by another colonist. Longer-term, the AI shouldn't be building settlers if that is the case,
Expand Down
56 changes: 22 additions & 34 deletions C7Engine/AI/UnitAI/SettlerLocationAI.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,14 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using C7GameData;
using System.Collections.Generic;
using System.Linq;
using C7GameData.AIData;
using Serilog;

namespace C7Engine {
public class SettlerLocationAI {
private static ILogger log = Log.ForContext<SettlerLocationAI>();

//Eventually, there should be different weights based on whether the AI already
//has the resource or not (more important to secure ones that they don't have).
//But since we don't have trade networks yet, for now there's only one value.
static int STRATEGIC_RESOURCE_BONUS = 20;
static int LUXURY_RESOURCE_BONUS = 15;
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, int> scores = GetScoredSettlerCandidates(start, player);
public static Tile FindSettlerLocation(Tile start, Player player) {
Dictionary<Tile, float> scores = GetScoredSettlerCandidates(start, player);
if (scores.Count == 0 || scores.Values.Max() <= 0) {
return Tile.NONE; //nowhere to settle
}
Expand All @@ -27,43 +17,41 @@ public static Tile findSettlerLocation(Tile start, Player player) {
return result;
}

public static Dictionary<Tile, int> GetScoredSettlerCandidates(Tile start, Player player) {
public static Dictionary<Tile, float> GetScoredSettlerCandidates(Tile start, Player player) {
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, int> 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"));
return scores;
}

private static Dictionary<Tile, int> AssignTileScores(Tile startTile, Player player, IEnumerable<Tile> candidates, List<MapUnit> playerSettlers) {
Dictionary<Tile, int> scores = new Dictionary<Tile, int>();
private static Dictionary<Tile, float> AssignTileScores(Tile startTile, Player player, IEnumerable<Tile> candidates, List<MapUnit> playerSettlers) {
Dictionary<Tile, float> scores = new();
candidates = candidates.Where(t => !SettlerAlreadyMovingTowardsTile(t, playerSettlers) && t.IsAllowCities());
foreach (Tile t in candidates) {
int score = GetTileYieldScore(t, player);
float score = GetTileYieldScore(t, player);
//For simplicity's sake, I'm only going to look at immediate neighbors here, but
//a lot more things should be considered over time.
foreach (Tile nt in t.neighbors.Values) {
score += GetTileYieldScore(nt, player);
}
//TODO: Also look at the next ring out, with lower weights.
//TODO #802: Also look at the next ring out, with lower weights.

//Prefer hills for defense, and coast for boats and such.
if (t.baseTerrainType.Key == "hills") {
score += 10;
score += player.civilization.Adjustments.HillsBonus;
}
if (t.NeighborsWater()) {
score += 10;
score += player.civilization.Adjustments.WaterBonus;
}

//Lower scores if they are far away
int preDistanceScore = score;
float preDistanceScore = score;
int distance = startTile.distanceTo(t);
if (distance > 4) {
score -= distance * 2;
}
if (distance > 8) {
score -= distance * 4;
if (distance > player.civilization.Adjustments.DistancePenaltyRadius) {
score += player.civilization.Adjustments.DistancePenalty * distance;
}

//Distance can never lower score beyond 1; the AI will always try to settle those worthless tundras.
//(This could actually be modified in the future, but for now is also a safety rail)
if (preDistanceScore > 0 && score <= 0) {
Expand All @@ -75,15 +63,15 @@ private static Dictionary<Tile, int> AssignTileScores(Tile startTile, Player pla
return scores;
}

private static int GetTileYieldScore(Tile t, Player owner) {
int score = t.foodYield(owner).yield * 5;
score += t.productionYield(owner).yield * 3;
score += t.commerceYield(owner).yield * 2;
private static float GetTileYieldScore(Tile t, Player owner) {
float score = owner.civilization.Adjustments.FoodYieldBonus * t.foodYield(owner).yield;
score += owner.civilization.Adjustments.ProductionYieldBonus * t.productionYield(owner).yield;
score += owner.civilization.Adjustments.CommerceYieldBonus * t.commerceYield(owner).yield;
if (owner.KnowsAboutResource(t.Resource)) {
if (t.Resource.Category == ResourceCategory.STRATEGIC) {
score += STRATEGIC_RESOURCE_BONUS;
score += owner.civilization.Adjustments.StrategicResourceBonus;
} else if (t.Resource.Category == ResourceCategory.LUXURY) {
score += LUXURY_RESOURCE_BONUS;
score += owner.civilization.Adjustments.LuxuryResourceBonus;
}
}
return score;
Expand Down
22 changes: 22 additions & 0 deletions C7Engine/C7GameData/Civilization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,28 @@ public Civilization(string name) {

public bool isBarbarian = false;

public class SettlerTileAdjustments {
public int DistancePenaltyRadius = 4;

// TODO: Eventually, there should be different weights based on whether the AI already
// has the resource or not (more important to secure ones that they don't have).
// But since we don't have trade networks yet, for now there's only one value.

// multipliers
public float CommerceYieldBonus = 2;
public float DistancePenalty = -2;
public float FoodYieldBonus = 5;
public float ProductionYieldBonus = 3;

// constants
public float HillsBonus = 10;
public float LuxuryResourceBonus = 15;
public float StrategicResourceBonus = 20;
public float WaterBonus = 10;
}

public SettlerTileAdjustments Adjustments = new();

[JsonIgnore]
public UnitPrototype uniqueUnit;

Expand Down
127 changes: 127 additions & 0 deletions EngineTests/AI/UnitAI/SettlerLocationTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using C7Engine;
using C7GameData;
using C7GameData.AIData;
using C7GameData.Save;
using EngineTests.Utils;
using System.Collections.Generic;
using Xunit;

namespace EngineTests.AI.UnitAI {
public sealed class SettlerLocationAITests : MapBase {
private Tile MakeDesertTileWithDefaultYield() {
Tile tile = MakeDesertTile();
tile.overlayTerrainType.baseFoodProduction = 0;
tile.overlayTerrainType.baseShieldProduction = 1;
tile.overlayTerrainType.baseCommerceProduction = 0;
return tile;
}
private Tile MakeFloodPlainTileWithDefaultYield() {
Tile tile = MakeFloodPlainTile();
tile.overlayTerrainType.baseFoodProduction = 3;
tile.overlayTerrainType.baseShieldProduction = 0;
tile.overlayTerrainType.baseCommerceProduction = 0;
return tile;
}

[Fact]
private void HillsOverPlains() {
// a single hill tile surrounded by desert
InitilizeStartTile(MakeHillTile(), new TileLocation(56, 50));
Tile hills = startTile;
List<Tile> map = SurroundTile(hills, MakeDesertTile);

// a single plains tile surrounded by desert
InitilizeStartTile(MakePlainsTile(), new TileLocation(44, 50));
Tile plains = startTile;
map.AddRange(SurroundTile(plains, MakeDesertTile));

// starting position at the midpoint between the two viable candidates
InitilizeStartTile(MakeDesertTile(), new TileLocation(50, 50));
Tile start = startTile;
startTile.map = gameMap;

// settler should choose the hill
Player player = MakeTestPlayer(map);
Tile chosenTile = SettlerLocationAI.FindSettlerLocation(start, player);
Assert.Equal(hills, chosenTile);
}
[Fact]
private void CoastalHillsOverInlandHills() {
List<Tile> map = new();

// a hill tile with a coast tile neighbor
InitilizeStartTile(MakeHillTile(), new TileLocation(56, 50));
Tile coastalHills = startTile;
map.Add(coastalHills);
map.Add(AddNeighborsAndUpdateMap(coastalHills, MakeCoastTile(), TileDirection.NORTH));
map.Add(AddNeighborsAndUpdateMap(coastalHills, MakeDesertTile(), TileDirection.NORTHEAST));
map.Add(AddNeighborsAndUpdateMap(coastalHills, MakeDesertTile(), TileDirection.EAST));
map.Add(AddNeighborsAndUpdateMap(coastalHills, MakeDesertTile(), TileDirection.SOUTHEAST));
map.Add(AddNeighborsAndUpdateMap(coastalHills, MakeDesertTile(), TileDirection.SOUTH));
map.Add(AddNeighborsAndUpdateMap(coastalHills, MakeDesertTile(), TileDirection.SOUTHWEST));
map.Add(AddNeighborsAndUpdateMap(coastalHills, MakeDesertTile(), TileDirection.WEST));
map.Add(AddNeighborsAndUpdateMap(coastalHills, MakeDesertTile(), TileDirection.NORTHWEST));

// a hill surrounded by desert
InitilizeStartTile(MakeHillTile(), new TileLocation(44, 50));
Tile inlandHills = startTile;
map.AddRange(SurroundTile(inlandHills, MakeDesertTile));

// starting position at the midpoint between the two viable candidates
InitilizeStartTile(MakeDesertTile(), new TileLocation(50, 50));
Tile start = startTile;
startTile.map = gameMap;

// settler should choose the coastal hill even when starting on the inland one a few tiles away
Player player = MakeTestPlayer(map);
Tile chosenTile = SettlerLocationAI.FindSettlerLocation(start, player);
Assert.Equal(coastalHills, chosenTile);
}
[Fact]
private void CloseOverFar() {
List<Tile> map = new();

// a single plains tile surrounded by desert
// not a good settlement spot, but our settler is already on this tile
InitilizeStartTile(MakePlainsTile(), new TileLocation(50, 50));
Tile close = startTile;
map.AddRange(SurroundTile(close, MakeDesertTileWithDefaultYield));

// a hill tile surrounded by flood plains
// high settlement score from yield but very far away
InitilizeStartTile(MakeHillTile(), new TileLocation(200, 50));
Tile far = startTile;
map.AddRange(SurroundTile(far, MakeFloodPlainTileWithDefaultYield));

Player player = MakeTestPlayer(map);
Tile chosenTile = SettlerLocationAI.FindSettlerLocation(close, player);
Assert.Equal(close, chosenTile);
}
[Fact]
private void NotAlreadyBeingSettled() {
// just one hill tile
InitilizeStartTile(MakeHillTile(), new TileLocation(50, 50));
startTile.map = gameMap;

List<Tile> map = new();
map.Add(startTile);
Player player = MakeTestPlayer(map);

// with no other settlers in play, our test settler chooses the only available tile
Tile chosenTile = SettlerLocationAI.FindSettlerLocation(startTile, player);
Assert.Equal(startTile, chosenTile);

// add a settler whose destination is the one known tile
MapUnit settler = MakeLandUnit();
settler.unitType.name = "Settler";
SettlerAIData data = new();
data.destination = startTile;
settler.currentAI = new SettlerAI(data);
player.units.Add(settler);

// now the test settler should have nowhere to go
chosenTile = SettlerLocationAI.FindSettlerLocation(startTile, player);
Assert.Equal(Tile.NONE, chosenTile);
}
}
}
Loading