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
42 changes: 42 additions & 0 deletions .github/workflows/unit_tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: 🧪 Unit Tests
permissions:
contents: read
on:
push:
pull_request:

jobs:
unit_tests:
name: 🧪 Unit Tests
runs-on: ubuntu-latest
# Only run the workflow if it's not a PR or if it's a PR from a fork.
# This prevents duplicate workflows from running on PR's that originate
# from the repository itself.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_NOLOGO: true
defaults:
run:
# Use bash shells on all platforms.
shell: bash
steps:
- name: 🧾 Checkout
uses: actions/checkout@v7
with:
lfs: true
submodules: 'recursive'

- name: 💽 Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
# Use the .NET SDK from global.json in the root of the repository.
global-json-file: global.json

# Only the test project is restored and built: it depends on
# OpenPolytopia.Common, so Godot is never needed to run these tests.
- name: 📦 Restore Dependencies
run: dotnet restore OpenPolytopia.UnitTest/OpenPolytopia.UnitTest.csproj

- name: 🧪 Run Unit Tests
run: dotnet test OpenPolytopia.UnitTest/OpenPolytopia.UnitTest.csproj --no-restore
6 changes: 4 additions & 2 deletions OpenPolytopia.Common/DamageCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ public static void ComputeMeleeDamage(uint attack, uint hp, uint maxHp, uint ene
var attackForce = attack * ((float)hp / maxHp);
var defenseForce = enemyDefense * ((float)enemyHp / enemyMaxHp) * enemyDefenseBonus;
var totalDamage = attackForce + defenseForce;
attackDamage = (uint)MathF.Round((attackForce / totalDamage) * attack * 4.5f);
defenseDamage = (uint)MathF.Round((defenseForce / totalDamage) * defenseForce * 4.5f);
// Polytopia rounds halves up, while MathF.Round defaults to banker's rounding
attackDamage = (uint)MathF.Round((attackForce / totalDamage) * attack * 4.5f, MidpointRounding.AwayFromZero);
defenseDamage =
(uint)MathF.Round((defenseForce / totalDamage) * enemyDefense * 4.5f, MidpointRounding.AwayFromZero);
}

/// <summary>
Expand Down
11 changes: 5 additions & 6 deletions OpenPolytopia.Common/Directions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
/// Represents cardinal and diagonal directions for grid-based movement
/// </summary>
public enum Direction {
Up, Down, Left, Right,

Check warning on line 9 in OpenPolytopia.Common/Directions.cs

View workflow job for this annotation

GitHub Actions / 🧪 Unit Tests

Missing XML comment for publicly visible type or member 'Direction.Right'

Check warning on line 9 in OpenPolytopia.Common/Directions.cs

View workflow job for this annotation

GitHub Actions / 🧪 Unit Tests

Missing XML comment for publicly visible type or member 'Direction.Left'

Check warning on line 9 in OpenPolytopia.Common/Directions.cs

View workflow job for this annotation

GitHub Actions / 🧪 Unit Tests

Missing XML comment for publicly visible type or member 'Direction.Down'

Check warning on line 9 in OpenPolytopia.Common/Directions.cs

View workflow job for this annotation

GitHub Actions / 🧪 Unit Tests

Missing XML comment for publicly visible type or member 'Direction.Up'
UpLeft, UpRight, DownLeft, DownRight

Check warning on line 10 in OpenPolytopia.Common/Directions.cs

View workflow job for this annotation

GitHub Actions / 🧪 Unit Tests

Missing XML comment for publicly visible type or member 'Direction.UpRight'

Check warning on line 10 in OpenPolytopia.Common/Directions.cs

View workflow job for this annotation

GitHub Actions / 🧪 Unit Tests

Missing XML comment for publicly visible type or member 'Direction.UpLeft'
}

/// <summary>
Expand All @@ -28,16 +28,15 @@
new(1, 0),
new(-1, -1),
new(1, -1),
new(-1, -1),
new(-1, 1),
new(1, 1)
];

/// <summary>
/// Cast all movement vectors for full 8-directional movement
/// Movement vector of a single direction
/// </summary>
/// <remarks>
/// Precomputed array of Vector2I values extracted from Directions.
/// </remarks>
/// <param name="direction">the direction to convert</param>
/// <returns>the matching entry of <see cref="Directions"/></returns>
public static Vector2I ToVector2I(this Direction direction) => Directions[(int)direction];

/// <summary>
Expand All @@ -55,7 +54,7 @@
/// Up and down movement vectors for vertical constraints
/// </summary>
/// <remarks>
/// Contains vectors: (0, 1) and (0, -1)
/// Contains vectors: (0, -1) and (0, 1)
/// </remarks>
public static readonly Vector2I[] Vertical = [
Direction.Up.ToVector2I(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
namespace OpenPolytopia;

using Chickensoft.GoDotTest;
using Common;
using Godot;
using Shouldly;

public class CityDataTest(Node testScene) : TestClass(testScene) {
[Test]
public class CityDataTest {
[Fact]
public void TestOwner() {
const int owner = 2;
var cityData = new CityData();
Expand All @@ -15,7 +14,7 @@ public void TestOwner() {
cityData.Owner.ShouldBe(owner);
}

[Test]
[Fact]
public void TestLevel() {
const int level = 2;
var cityData = new CityData();
Expand All @@ -24,7 +23,7 @@ public void TestLevel() {
cityData.Level.ShouldBe(level);
}

[Test]
[Fact]
public void TestMaxPopulation() {
const int level = 2;
var cityData = new CityData();
Expand All @@ -33,7 +32,7 @@ public void TestMaxPopulation() {
cityData.MaxPopulation.ShouldBe(level + 1);
}

[Test]
[Fact]
public void TestPopulation() {
const int population = 2;
var cityData = new CityData();
Expand All @@ -42,7 +41,7 @@ public void TestPopulation() {
cityData.Population.ShouldBe(population);
}

[Test]
[Fact]
public void TestTroops() {
const int troops = 2;
var cityData = new CityData();
Expand All @@ -51,7 +50,7 @@ public void TestTroops() {
cityData.Troops.ShouldBe(troops);
}

[Test]
[Fact]
public void TestParks() {
const int parks = 2;
var cityData = new CityData();
Expand All @@ -60,7 +59,7 @@ public void TestParks() {
cityData.Parks.ShouldBe(parks);
}

[Test]
[Fact]
public void TestWall() {
const bool wall = true;
var cityData = new CityData();
Expand All @@ -69,7 +68,7 @@ public void TestWall() {
cityData.Wall.ShouldBe(wall);
}

[Test]
[Fact]
public void TestForge() {
const bool forge = true;
var cityData = new CityData();
Expand All @@ -78,7 +77,7 @@ public void TestForge() {
cityData.Forge.ShouldBe(forge);
}

[Test]
[Fact]
public void TestCapital() {
const bool capital = true;
var cityData = new CityData();
Expand All @@ -87,7 +86,7 @@ public void TestCapital() {
cityData.Capital.ShouldBe(capital);
}

[Test]
[Fact]
public void TestConnected() {
const bool connected = true;
var cityData = new CityData();
Expand All @@ -96,7 +95,7 @@ public void TestConnected() {
cityData.Connected.ShouldBe(connected);
}

[Test]
[Fact]
public void TestStars() {
const int level = 2;
var cityData = new CityData();
Expand All @@ -111,7 +110,7 @@ public void TestStars() {
cityData.Stars.ShouldBe(7);
}

[Test]
[Fact]
public void TestLevelUp() {
var cityData = new CityData();
var result = cityData.LevelUp();
Expand Down
83 changes: 83 additions & 0 deletions OpenPolytopia.UnitTest/DamageCalculatorTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
namespace OpenPolytopia;

using Common;
using Godot;
using Shouldly;

public class DamageCalculatorTest {
[Fact]
public void TestEvenMatch() {
// two full health warriors trade 5 damage each way
DamageCalculator.ComputeMeleeDamage(2, 10, 10, 2, 10, 10, out var attackDamage, out var defenseDamage);
attackDamage.ShouldBe(5u);
defenseDamage.ShouldBe(5u);
}

[Fact]
public void TestWoundedAttacker() {
// a wounded attacker hits for less and takes more in return
DamageCalculator.ComputeMeleeDamage(2, 5, 10, 2, 10, 10, out var attackDamage, out var defenseDamage);
attackDamage.ShouldBe(3u);
defenseDamage.ShouldBe(6u);
}

[Fact]
public void TestWoundedDefender() {
// a wounded defender takes more and retaliates for less
DamageCalculator.ComputeMeleeDamage(2, 10, 10, 2, 5, 10, out var attackDamage, out var defenseDamage);
attackDamage.ShouldBe(6u);
defenseDamage.ShouldBe(3u);
}

[Fact]
public void TestDefenseBonus() {
// the retaliation scales linearly with the defense, not with the bonus applied to it
DamageCalculator.ComputeMeleeDamage(2, 10, 10, 2, 10, 10, out var attackDamage, out var defenseDamage, 1.5f);
attackDamage.ShouldBe(4u);
defenseDamage.ShouldBe(5u);

DamageCalculator.ComputeMeleeDamage(2, 10, 10, 2, 10, 10, out attackDamage, out defenseDamage, 4.0f);
attackDamage.ShouldBe(2u);
defenseDamage.ShouldBe(7u);
}

[Fact]
public void TestUnevenStats() {
// a stronger attacker against a full health warrior
DamageCalculator.ComputeMeleeDamage(3, 10, 10, 2, 10, 10, out var attackDamage, out var defenseDamage);
attackDamage.ShouldBe(8u);
defenseDamage.ShouldBe(4u);

// more health doesn't change the outcome as long as both are undamaged
DamageCalculator.ComputeMeleeDamage(3, 15, 15, 2, 10, 10, out attackDamage, out defenseDamage);
attackDamage.ShouldBe(8u);
defenseDamage.ShouldBe(4u);

DamageCalculator.ComputeMeleeDamage(5, 40, 40, 2, 10, 10, out attackDamage, out defenseDamage);
attackDamage.ShouldBe(16u);
defenseDamage.ShouldBe(3u);
}

[Fact]
public void TestRoundsHalvesUp() {
// 5 * (2 / 4) * 4.5 rounds up to 5, banker's rounding would give 4
DamageCalculator.ComputeMeleeDamage(2, 10, 10, 2, 10, 10, out var attackDamage, out _);
attackDamage.ShouldBe(5u);
}

[Fact]
public void TestRangedDamage() {
// ranged attacks deal the melee attack damage without taking any retaliation
DamageCalculator.ComputeMeleeDamage(2, 10, 10, 2, 5, 10, out var attackDamage, out _);
DamageCalculator.ComputeRangedDamage(2, 10, 10, 2, 5, 10).ShouldBe(attackDamage);
DamageCalculator.ComputeRangedDamage(2, 10, 10, 2, 10, 10, 1.5f).ShouldBe(4u);
}

[Fact]
public void TestSplashDamage() {
// splash is half the ranged damage, rounded down
DamageCalculator.ComputeSplashDamage(2, 10, 10, 2, 10, 10).ShouldBe(2u);
DamageCalculator.ComputeSplashDamage(3, 10, 10, 2, 10, 10).ShouldBe(4u);
DamageCalculator.ComputeSplashDamage(2, 10, 10, 2, 10, 10, 1.5f).ShouldBe(2u);
}
}
70 changes: 70 additions & 0 deletions OpenPolytopia.UnitTest/DirectionsTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
namespace OpenPolytopia;

using System.Linq;
using Common;
using Godot;
using Shouldly;

public class DirectionsTest {
[Fact]
public void TestCardinalVectors() {
Direction.Up.ToVector2I().ShouldBe(new Vector2I(0, -1));
Direction.Down.ToVector2I().ShouldBe(new Vector2I(0, 1));
Direction.Left.ToVector2I().ShouldBe(new Vector2I(-1, 0));
Direction.Right.ToVector2I().ShouldBe(new Vector2I(1, 0));
}

[Fact]
public void TestDiagonalVectors() {
Direction.UpLeft.ToVector2I().ShouldBe(new Vector2I(-1, -1));
Direction.UpRight.ToVector2I().ShouldBe(new Vector2I(1, -1));
Direction.DownLeft.ToVector2I().ShouldBe(new Vector2I(-1, 1));
Direction.DownRight.ToVector2I().ShouldBe(new Vector2I(1, 1));
}

[Fact]
public void TestDiagonalsAreCardinalSums() {
Direction.UpLeft.ToVector2I().ShouldBe(Direction.Up.ToVector2I() + Direction.Left.ToVector2I());
Direction.UpRight.ToVector2I().ShouldBe(Direction.Up.ToVector2I() + Direction.Right.ToVector2I());
Direction.DownLeft.ToVector2I().ShouldBe(Direction.Down.ToVector2I() + Direction.Left.ToVector2I());
Direction.DownRight.ToVector2I().ShouldBe(Direction.Down.ToVector2I() + Direction.Right.ToVector2I());
}

[Fact]
public void TestAllDirectionsAreDistinct() {
// a duplicated vector means a whole direction is unreachable when pathfinding
WrapperDirection.Directions.Length.ShouldBe(8);
WrapperDirection.Directions.Distinct().Count().ShouldBe(8);
}

[Fact]
public void TestOppositeDirectionsCancelOut() {
(Direction.Up.ToVector2I() + Direction.Down.ToVector2I()).ShouldBe(Vector2I.Zero);
(Direction.Left.ToVector2I() + Direction.Right.ToVector2I()).ShouldBe(Vector2I.Zero);
(Direction.UpLeft.ToVector2I() + Direction.DownRight.ToVector2I()).ShouldBe(Vector2I.Zero);
(Direction.UpRight.ToVector2I() + Direction.DownLeft.ToVector2I()).ShouldBe(Vector2I.Zero);
}

[Fact]
public void TestHorizontalAndVertical() {
WrapperDirection.Horizontal.ShouldBe(new[] { new Vector2I(-1, 0), new Vector2I(1, 0) });
WrapperDirection.Vertical.ShouldBe(new[] { new Vector2I(0, -1), new Vector2I(0, 1) });

// the two sets must not overlap, bridges rely on them being mutually exclusive
WrapperDirection.Horizontal.Intersect(WrapperDirection.Vertical).ShouldBeEmpty();
}

[Fact]
public void TestEveryNeighborIsReachable() {
// every tile around the origin must be covered exactly once
var origin = new Vector2I(0, 0);
var neighbors = WrapperDirection.Directions.Select(direction => origin + direction).ToList();

for (var x = -1; x <= 1; x++) {
for (var y = -1; y <= 1; y++) {
var expected = new Vector2I(x, y);
neighbors.Count(neighbor => neighbor == expected).ShouldBe(expected == origin ? 0 : 1);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
namespace OpenPolytopia;

using Chickensoft.GoDotTest;
using Common;
using Godot;
using Shouldly;

public class GridTest(Node testScene) : TestClass(testScene) {
[Test]
public class GridTest {
[Fact]
public void TestPositionToIndex() {
var grid = new Grid(10);
grid.GridPositionToIndex(new Vector2I(2, 2)).ShouldBe(22u);
}

[Test]
[Fact]
public void TestIndexToPosition() {
var grid = new Grid(10);
grid.IndexToGridPosition(22u).ShouldBe(new Vector2I(2, 2));
}

[Test]
[Fact]
public void TestModifyTile() {
var grid = new Grid(10);
grid.ModifyTile(0, (ref Tile tile) => tile.Owner = 2);
Expand Down
Loading
Loading