diff --git a/OpenPolytopia.Common/EmbeddedResources.cs b/OpenPolytopia.Common/EmbeddedResources.cs
index afc606b..8672fca 100644
--- a/OpenPolytopia.Common/EmbeddedResources.cs
+++ b/OpenPolytopia.Common/EmbeddedResources.cs
@@ -9,7 +9,8 @@ public static class EmbeddedResources {
// serialization conventions shared by all the json resources
private static readonly JsonSerializerOptions _jsonOptions = new() {
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(UnicodeRanges.All),
- TypeInfoResolver = JsonTypeInfoResolver.Combine(TribeGenerationContext.Default, TroopGenerationContext.Default),
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(TribeGenerationContext.Default, TroopGenerationContext.Default,
+ TechTreeGenerationContext.Default),
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
@@ -23,6 +24,11 @@ public static class EmbeddedResources {
///
public static string TribesData => GetResource("OpenPolytopia.Common.resources.tribes.json");
+ ///
+ /// Get the tech tree data from the json file
+ ///
+ public static string TechTreeData => GetResource("OpenPolytopia.Common.resources.tech_tree.json");
+
///
/// Deserializes the troops from the embedded json file
///
@@ -37,6 +43,13 @@ public static class EmbeddedResources {
public static TribesSerializedData? LoadTribes() =>
JsonSerializer.Deserialize(TribesData, _jsonOptions);
+ ///
+ /// Deserializes the tech tree from the embedded json file
+ ///
+ /// the tech tree data, or null if the json is empty
+ public static TechTreeSerializedData? LoadTechTree() =>
+ JsonSerializer.Deserialize(TechTreeData, _jsonOptions);
+
///
/// Returns the content of an embedded resource
///
diff --git a/OpenPolytopia.Common/OpenPolytopia.Common.csproj b/OpenPolytopia.Common/OpenPolytopia.Common.csproj
index 8534f7e..806bd49 100644
--- a/OpenPolytopia.Common/OpenPolytopia.Common.csproj
+++ b/OpenPolytopia.Common/OpenPolytopia.Common.csproj
@@ -25,6 +25,10 @@
PreserveNewest
+
+
+ PreserveNewest
+
diff --git a/OpenPolytopia.Common/TechTree.cs b/OpenPolytopia.Common/TechTree.cs
index 1a52ea4..ff32711 100644
--- a/OpenPolytopia.Common/TechTree.cs
+++ b/OpenPolytopia.Common/TechTree.cs
@@ -1,68 +1,210 @@
namespace OpenPolytopia.Common;
using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.Linq;
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
///
-/// Tech tree for a single player
+/// The shape of a tech tree, shared by every player
///
-public class TechTree {
- ///
- /// Climbing branch
- ///
- public SubTreeTech ClimbingBranch { get; } = new([
- new NodeTech { Id = "climbing" },
- new NodeTech { Id = "mining" },
- new NodeTech { Id = "meditation" },
- new NodeTech { Id = "smithery" },
- new NodeTech { Id = "philosophy" }
- ]);
+///
+/// A definition only holds which node lives in which branch and in which slot, the researched state lives in the
+/// built from it
+///
+/// A definition is immutable, returns a new definition instead of modifying this one
+///
+public class TechTreeDefinition {
+ private readonly Dictionary> _branches;
///
- /// Fishing branch
+ /// Returns the node ids of a branch, ordered by slot
///
- public SubTreeTech FishingBranch { get; } = new([
- new NodeTech { Id = "fishing" },
- new NodeTech { Id = "sailing" },
- new NodeTech { Id = "ramming" },
- new NodeTech { Id = "navigation" },
- new NodeTech { Id = "aquatism" }
- ]);
+ /// the branch type
+ /// if branch is an invalid
+ public IReadOnlyList this[BranchType branch] =>
+ _branches.TryGetValue(branch, out var nodes)
+ ? nodes
+ : throw new ArgumentOutOfRangeException(nameof(branch), branch, "branch is invalid");
+
+ private TechTreeDefinition(Dictionary branches) {
+ _branches = branches.ToDictionary(branch => branch.Key, branch => Array.AsReadOnly(branch.Value));
+ }
///
- /// Hunting branch
+ /// Builds a definition from the deserialized data of a tech tree
///
- public SubTreeTech HuntingBranch { get; } = new([
- new NodeTech { Id = "hunting" },
- new NodeTech { Id = "archery" },
- new NodeTech { Id = "forestry" },
- new NodeTech { Id = "spiritualism" },
- new NodeTech { Id = "mathematics" }
- ]);
+ /// the deserialized data of the tech tree
+ ///
+ /// if a branch isn't a valid , is missing, is declared twice, doesn't have exactly
+ /// nodes or if the same node id is used more than once
+ ///
+ ///
+ /// if data, the list of the branches, a branch or the list of the nodes of a branch is null
+ ///
+ ///
+ ///
+ /// var data = JsonSerializer.Deserialize<TechTreeSerializedData>(EmbeddedResources.TechTreeData, options);
+ /// var definition = TechTreeDefinition.FromSerializedData(data);
+ ///
+ ///
+ public static TechTreeDefinition FromSerializedData(TechTreeSerializedData data) {
+ ArgumentNullException.ThrowIfNull(data);
+
+ // required only checks that the json set the property, and a json null sets it just fine
+ ArgumentNullException.ThrowIfNull(data.Branches);
+
+ var branches = new Dictionary(data.Branches.Count);
+ foreach (var branch in data.Branches) {
+ // a json null is an element of the list like any other, and nothing looks at the required members of a branch
+ // that never got built
+ ArgumentNullException.ThrowIfNull(branch);
+
+ // the json converter of BranchType accepts numbers too, so a branch that doesn't exist can get this far
+ if (!Enum.IsDefined(branch.Type)) {
+ throw new ArgumentException($"branch {branch.Type} isn't a valid branch", nameof(data));
+ }
+
+ ArgumentNullException.ThrowIfNull(branch.Nodes);
+
+ if (branch.Nodes.Any(string.IsNullOrWhiteSpace)) {
+ throw new ArgumentException($"branch {branch.Type} has a node without an id", nameof(data));
+ }
+
+ if (branch.Nodes.Count != SubTreeTech.MAX_NODES) {
+ throw new ArgumentException(
+ $"branch {branch.Type} has {branch.Nodes.Count} nodes; every branch needs exactly {SubTreeTech.MAX_NODES} nodes",
+ nameof(data));
+ }
+
+ if (!branches.TryAdd(branch.Type, [.. branch.Nodes])) {
+ throw new ArgumentException($"branch {branch.Type} is declared more than once", nameof(data));
+ }
+ }
+
+ foreach (var branch in Enum.GetValues()) {
+ if (!branches.ContainsKey(branch)) {
+ throw new ArgumentException($"branch {branch} is missing", nameof(data));
+ }
+ }
+
+ var duplicate = branches.Values
+ .SelectMany(nodes => nodes)
+ .GroupBy(id => id)
+ .FirstOrDefault(group => group.Count() > 1);
+ if (duplicate != null) {
+ throw new ArgumentException($"node {duplicate.Key} is declared more than once", nameof(data));
+ }
+
+ return new TechTreeDefinition(branches);
+ }
///
- /// Riding branch
+ /// Returns a new definition where every replaced node is swapped with the node of the override
///
- public SubTreeTech RidingBranch { get; } = new([
- new NodeTech { Id = "riding" },
- new NodeTech { Id = "roads" },
- new NodeTech { Id = "free_spirit" },
- new NodeTech { Id = "trade" },
- new NodeTech { Id = "chivalry" }
- ]);
+ /// the overrides to apply, in order
+ /// a new definition with the overrides applied
+ ///
+ /// A node is always swapped in place so the branch keeps its nodes and every
+ /// node keeps its
+ ///
+ ///
+ /// if an override doesn't have a node to replace or an id, if a replaced node isn't in the tree or if the new id is
+ /// already used by another node
+ ///
+ /// if overrides or one of the overrides is null
+ ///
+ ///
+ /// var definition = baseDefinition.Override([new TechOverride { Replaces = "fishing", Id = "free_diving" }]);
+ ///
+ ///
+ public TechTreeDefinition Override(IEnumerable overrides) {
+ ArgumentNullException.ThrowIfNull(overrides);
+
+ var branches = _branches.ToDictionary(branch => branch.Key, branch => branch.Value.ToArray());
+ foreach (var techOverride in overrides) {
+ // an override writes an id in the tree like the json does, so it needs the same check on it
+ ArgumentNullException.ThrowIfNull(techOverride, nameof(overrides));
+
+ if (string.IsNullOrWhiteSpace(techOverride.Replaces)) {
+ throw new ArgumentException($"the override with id {techOverride.Id} doesn't replace anything",
+ nameof(overrides));
+ }
+
+ if (string.IsNullOrWhiteSpace(techOverride.Id)) {
+ throw new ArgumentException($"the override of {techOverride.Replaces} doesn't have an id", nameof(overrides));
+ }
+
+ var (branch, index) = Find(branches, techOverride.Replaces) ??
+ throw new ArgumentException($"node {techOverride.Replaces} isn't in the tree",
+ nameof(overrides));
+ if (techOverride.Id != techOverride.Replaces && Find(branches, techOverride.Id) != null) {
+ throw new ArgumentException($"node {techOverride.Id} is already in the tree", nameof(overrides));
+ }
+
+ branches[branch][index] = techOverride.Id;
+ }
+
+ return new TechTreeDefinition(branches);
+ }
///
- /// Organization branch
+ /// Creates the tech tree of a player
///
- public SubTreeTech OrganizationBranch { get; } = new([
- new NodeTech { Id = "organization" },
- new NodeTech { Id = "farming" },
- new NodeTech { Id = "strategy" },
- new NodeTech { Id = "construction" },
- new NodeTech { Id = "diplomacy" }
- ]);
+ /// the tribe of the player, if null no override is applied and nothing is researched
+ /// a tech tree where only the starting node of the tribe is researched
+ ///
+ /// The starting node is the tier 0 node of , so it's resolved after the overrides
+ /// of the tribe are applied
+ ///
+ ///
+ /// if an override of the tribe is invalid, see
+ ///
+ ///
+ /// if isn't a valid
+ ///
+ ///
+ ///
+ /// var techTree = definition.CreateTechTree(tribeManager[TribeType.Imperius]);
+ ///
+ ///
+ public TechTree CreateTechTree(Tribe? tribe = null) {
+ if (tribe == null) {
+ return new TechTree(this);
+ }
+
+ var definition = tribe.TechOverrides is { Count: > 0 } overrides ? Override(overrides) : this;
+ var techTree = new TechTree(definition);
+ techTree[tribe.StartingBranch].Research(definition[tribe.StartingBranch][0]);
+ return techTree;
+ }
+
+ private static (BranchType, int)? Find(Dictionary branches, string id) {
+ foreach (var (branch, nodes) in branches) {
+ var index = Array.IndexOf(nodes, id);
+ if (index != -1) {
+ return (branch, index);
+ }
+ }
+
+ return null;
+ }
+}
+
+///
+/// Tech tree for a single player
+///
+///
+/// Every tech tree is built from a , use
+/// to get the tree of a tribe with its overrides already applied
+///
+/// Every tree owns its nodes, so researching something on a tree never touches the definition nor the tree of another
+/// player
+///
+public class TechTree {
+ private readonly Dictionary _branches;
///
/// Gets the branch given its type
@@ -70,25 +212,20 @@ public class TechTree {
/// the branch type
/// if branch is an invalid
public SubTreeTech this[BranchType branch] =>
- branch switch {
- BranchType.Climbing => ClimbingBranch,
- BranchType.Fishing => FishingBranch,
- BranchType.Hunting => HuntingBranch,
- BranchType.Organization => OrganizationBranch,
- BranchType.Riding => RidingBranch,
- _ => throw new ArgumentOutOfRangeException(nameof(branch), branch, "branch is invalid")
- };
+ _branches.TryGetValue(branch, out var subTree)
+ ? subTree
+ : throw new ArgumentOutOfRangeException(nameof(branch), branch, "branch is invalid");
///
- /// Initializes the tech tree with an optional starting tech
+ /// Initializes the tech tree from a definition, with nothing researched
///
- /// the starting tech
- public TechTree(StartingTech? startingTech = null) {
- if (startingTech == null) {
- return;
- }
+ /// the definition to build the tree from
+ /// if definition is null
+ public TechTree(TechTreeDefinition definition) {
+ ArgumentNullException.ThrowIfNull(definition);
- this[startingTech.Value.Branch].Research(startingTech.Value.Id);
+ _branches = Enum.GetValues()
+ .ToDictionary(branch => branch, branch => new SubTreeTech(definition[branch]));
}
}
@@ -105,34 +242,110 @@ public enum BranchType {
}
///
-/// The starting tech struct used in
+/// A node of the default tech tree replaced by a tribe
///
-public readonly struct StartingTech {
- public required BranchType Branch { get; init; }
+///
+/// The replaced node is found by its id because every id is unique in the tree, the new node takes its slot so it also
+/// takes its tier and its cost
+///
+public class TechOverride {
+ ///
+ /// The id of the node to replace
+ ///
+ public required string Replaces { get; init; }
+
+ ///
+ /// The id of the node taking its place
+ ///
public required string Id { get; init; }
}
///
/// A subtree tech, or a branch
///
-/// array of nodes in the subtree
///
-/// The max nodes of a branch is 5; node 0: tier 0; node 1 and node 2: tier 1; node 3 and node 4: tier 2
+/// The nodes of a branch are always ; node 0: tier 0; node 1 and node 2: tier 1;
+/// node 3 and node 4: tier 2
+///
+/// The tier of a node comes from its slot, so a branch builds its own nodes to make sure no node can ever get a tier
+/// that doesn't belong to its slot
///
-public class SubTreeTech(NodeTech[] nodes) {
- public NodeTech[] Nodes { get; } = nodes;
+public class SubTreeTech {
+ ///
+ /// How many nodes a branch has
+ ///
+ public const int MAX_NODES = 5;
+
+ private readonly NodeTech[] _nodes;
+
+ ///
+ /// The nodes of this branch, ordered by slot
+ ///
+ ///
+ /// It's read only because the slot of a node is what gives it its tier, so swapping a node with another one would
+ /// let it take a tier that isn't the one of its slot
+ ///
+ public IReadOnlyList Nodes => _nodes;
///
/// Returns the node that has that id, or null if no node has been found
///
/// the node id
- public NodeTech? this[string id] => Nodes.FirstOrDefault(node => node.Id == id);
+ public NodeTech? this[string id] => Array.Find(_nodes, node => node.Id == id);
+
+ ///
+ /// Initializes a branch with its nodes, none of them researched
+ ///
+ /// the ids of the nodes, ordered by slot
+ ///
+ /// if ids doesn't have exactly ids, if an id is null or blank or if the same id is
+ /// used twice
+ ///
+ /// if ids is null
+ ///
+ /// A node is always looked up by its id, so an id that isn't an id is a slot no one can research nor price and two
+ /// nodes sharing one make the second slot the unreachable one
+ ///
+ public SubTreeTech(IReadOnlyList ids) {
+ ArgumentNullException.ThrowIfNull(ids);
+
+ if (ids.Count != MAX_NODES) {
+ throw new ArgumentException($"a branch needs exactly {MAX_NODES} nodes, got {ids.Count}", nameof(ids));
+ }
+
+ if (ids.Any(string.IsNullOrWhiteSpace)) {
+ throw new ArgumentException("a branch can't have a node without an id", nameof(ids));
+ }
+
+ if (ids.Distinct().Count() != ids.Count) {
+ throw new ArgumentException("a branch can't have the same node id twice", nameof(ids));
+ }
+
+ _nodes = [.. ids.Select((id, index) => new NodeTech { Id = id, Tier = TierOf(index) })];
+ }
+
+ ///
+ /// Returns the tier of a node given its slot in the branch
+ ///
+ /// the slot of the node
+ /// if index isn't a slot of the branch
+ public static uint TierOf(int index) =>
+ index switch {
+ 0 => 0,
+ 1 or 2 => 1,
+ 3 or 4 => 2,
+ _ => throw new ArgumentOutOfRangeException(nameof(index), index, $"index must be between 0 and {MAX_NODES - 1}")
+ };
///
- /// Whether a node as been marked as researched
+ /// Whether a node has been marked as researched
///
/// the node id
/// if the node has been researched; always false if no node has been found with that id
+ ///
+ /// This is the shorthand for the check every caller wants, so a node that doesn't exist and a node no one researched
+ /// yet both answer false; use when the two cases have to be told apart
+ ///
public bool HasResearched(string id) => this[id]?.Researched ?? false;
///
@@ -140,30 +353,70 @@ public class SubTreeTech(NodeTech[] nodes) {
///
/// the node id
/// number of cities owned by the player
- /// the cost for that node
- public uint ComputeCost(string id, uint cities) {
- for (var i = 0; i < Nodes.Length; i++) {
- if (Nodes[i].Id == id) {
- return i == 0 ? cities + 4 : i <= 2 ? (cities * 2) + 4 : (cities * 3) + 4;
- }
- }
-
- return 0;
+ /// the cost for that node; null if no node has been found with that id
+ ///
+ /// It returns null and not 0 because 0 is a price a caller can pay, so a node that doesn't exist would be free
+ ///
+ public uint? ComputeCost(string id, uint cities) {
+ var node = this[id];
+ return node == null ? null : (cities * (node.Tier + 1)) + 4;
}
///
/// Marks a node as researched
///
/// the node id
- public void Research(string id) {
+ /// if the node has been marked; false if no node has been found with that id
+ public bool Research(string id) {
var tech = this[id];
- if (tech != null) {
- tech.Researched = true;
+ if (tech == null) {
+ return false;
}
+
+ tech.Researched = true;
+ return true;
}
}
+///
+/// A node of a branch
+///
public class NodeTech {
- public bool Researched { get; set; }
+ ///
+ /// Whether the player has researched this node
+ ///
+ ///
+ /// The researched state is set only inside this assembly and is the only place
+ /// doing it, so researching something has a single place where it can be paid for
+ ///
+ public bool Researched { get; internal set; }
+
+ ///
+ /// The id of this node
+ ///
public required string Id { get; init; }
+
+ ///
+ /// The tier of this node, it's what makes its cost grow
+ ///
+ ///
+ /// The tier is the one of the slot of the node, so only this assembly builds nodes and
+ /// is the only type doing it
+ ///
+ public uint Tier { get; internal init; }
+}
+
+public class BranchSerializedData {
+ public required BranchType Type { get; init; }
+ public required List Nodes { get; init; }
}
+
+public class TechTreeSerializedData {
+ public required List Branches { get; init; }
+}
+
+[JsonSourceGenerationOptions(WriteIndented = true)]
+[JsonSerializable(typeof(TechOverride))]
+[JsonSerializable(typeof(BranchSerializedData))]
+[JsonSerializable(typeof(TechTreeSerializedData))]
+public partial class TechTreeGenerationContext : JsonSerializerContext;
diff --git a/OpenPolytopia.Common/Tribe.cs b/OpenPolytopia.Common/Tribe.cs
index 8216745..5df7aab 100644
--- a/OpenPolytopia.Common/Tribe.cs
+++ b/OpenPolytopia.Common/Tribe.cs
@@ -23,6 +23,11 @@ public class TribeManager {
///
/// the type of the tribe
/// the data of the tribe
+ ///
+ /// if isn't a valid or if the tribe is already
+ /// registered
+ ///
+ /// if tribe is null
///
///
/// var imperiusTribe = new Tribe {
@@ -32,15 +37,44 @@ public class TribeManager {
/// tribeManager.RegisterTribe(TribeType.Imperius, imperiusTribe);
///
///
- public void RegisterTribe(TribeType type, Tribe tribe) => Tribes.Add(type, tribe);
+ public void RegisterTribe(TribeType type, Tribe tribe) {
+ ArgumentNullException.ThrowIfNull(tribe);
+
+ // same as the branches of the tech tree, the json converter accepts numbers so a branch that doesn't exist can
+ // get this far; the overrides can't be checked here because they need the definition of the tech tree
+ if (!Enum.IsDefined(tribe.StartingBranch)) {
+ throw new ArgumentException($"tribe {type} starts on {tribe.StartingBranch}, which isn't a valid branch",
+ nameof(tribe));
+ }
+
+ // Add says an item with the same key is already there, the tech tree says which branch is declared twice
+ if (!Tribes.TryAdd(type, tribe)) {
+ throw new ArgumentException($"tribe {type} is registered more than once", nameof(type));
+ }
+ }
///
/// Register multiple tribes
///
/// the deserialized data of all tribes to register
+ ///
+ /// if the of a tribe isn't a valid or if a tribe is
+ /// declared more than once
+ ///
+ ///
+ /// if tribes, the list of the tribes, a tribe or the data of a tribe is null
+ ///
public void RegisterTribes(TribesSerializedData tribes) {
+ ArgumentNullException.ThrowIfNull(tribes);
+
+ // required only checks that the json set the property, and a json null sets it just fine
+ ArgumentNullException.ThrowIfNull(tribes.Tribes);
+
foreach (var tribe in tribes.Tribes) {
- RegisterTribe(tribe.TribeType, tribe.Tribe);
+ // a json null is an element of the list like any other, the data of the tribe is checked by RegisterTribe
+ ArgumentNullException.ThrowIfNull(tribe);
+
+ RegisterTribe(tribe.Type, tribe.Tribe);
}
}
}
@@ -50,9 +84,24 @@ public void RegisterTribes(TribesSerializedData tribes) {
///
public class Tribe {
///
- /// The starting tech of a tribe
+ /// The branch a tribe starts with
+ ///
+ ///
+ /// A tribe always starts with the tier 0 node of this branch already researched, whether it's the default node or
+ /// one of its
+ ///
+ public required BranchType StartingBranch { get; init; }
+
+ ///
+ /// The nodes of the default tech tree this tribe replaces with its own
///
- public required StartingTech StartingTech { get; init; }
+ ///
+ /// A tribe without unique techs doesn't need to declare anything, so this is null most of the times
+ ///
+ /// It can't default to an empty list because the source generated deserializer skips property initializers on
+ /// types with required properties
+ ///
+ public List? TechOverrides { get; init; }
///
/// The resource spawn rates of a tribe
@@ -175,7 +224,7 @@ public enum Wonder {
}
public class TribeSerializedData {
- public required TribeType TribeType { get; init; }
+ public required TribeType Type { get; init; }
public required Tribe Tribe { get; init; }
}
diff --git a/OpenPolytopia.Common/resources/tech_tree.json b/OpenPolytopia.Common/resources/tech_tree.json
new file mode 100644
index 0000000..022f464
--- /dev/null
+++ b/OpenPolytopia.Common/resources/tech_tree.json
@@ -0,0 +1,54 @@
+{
+ "branches": [
+ {
+ "type": "climbing",
+ "nodes": [
+ "climbing",
+ "mining",
+ "meditation",
+ "smithery",
+ "philosophy"
+ ]
+ },
+ {
+ "type": "fishing",
+ "nodes": [
+ "fishing",
+ "sailing",
+ "ramming",
+ "navigation",
+ "aquatism"
+ ]
+ },
+ {
+ "type": "hunting",
+ "nodes": [
+ "hunting",
+ "archery",
+ "forestry",
+ "spiritualism",
+ "mathematics"
+ ]
+ },
+ {
+ "type": "riding",
+ "nodes": [
+ "riding",
+ "roads",
+ "free_spirit",
+ "trade",
+ "chivalry"
+ ]
+ },
+ {
+ "type": "organization",
+ "nodes": [
+ "organization",
+ "farming",
+ "strategy",
+ "construction",
+ "diplomacy"
+ ]
+ }
+ ]
+}
diff --git a/OpenPolytopia.Common/resources/tribes.json b/OpenPolytopia.Common/resources/tribes.json
index 351d9fe..4527d37 100644
--- a/OpenPolytopia.Common/resources/tribes.json
+++ b/OpenPolytopia.Common/resources/tribes.json
@@ -1,12 +1,9 @@
{
"tribes": [
{
- "tribe_type": "imperius",
+ "type": "imperius",
"tribe": {
- "starting_tech": {
- "branch": "organization",
- "id": "organization"
- },
+ "starting_branch": "organization",
"spawn_rate": {
"fruit_rate": 2.0,
"crop_rate": 1.0,
diff --git a/OpenPolytopia/test/src/TechTreeTest.cs b/OpenPolytopia/test/src/TechTreeTest.cs
index 7154813..24b2115 100644
--- a/OpenPolytopia/test/src/TechTreeTest.cs
+++ b/OpenPolytopia/test/src/TechTreeTest.cs
@@ -1,48 +1,370 @@
namespace OpenPolytopia;
+using System;
+using System.Text.Json;
+using System.Text.Unicode;
using Chickensoft.GoDotTest;
using Common;
using Godot;
using Shouldly;
public class TechTreeTest(Node testScene) : TestClass(testScene) {
+ private static readonly JsonSerializerOptions _techTreeOptions = new() {
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(UnicodeRanges.All),
+ TypeInfoResolver = TechTreeGenerationContext.Default,
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
+ };
+
+ private static readonly JsonSerializerOptions _tribeOptions = new() {
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Create(UnicodeRanges.All),
+ TypeInfoResolver = TribeGenerationContext.Default,
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
+ };
+
+ private static readonly SpawnRate _spawnRate = new() {
+ FruitRate = 1f, CropRate = 1f, AnimalRate = 1f, FishRate = 1f, MineralRate = 1f
+ };
+
+ private static readonly TerrainRate _terrainRate = new() {
+ ForestRate = 1f, MountainRate = 1f, WaterRate = 1f
+ };
+
+ private TechTreeDefinition _definition = null!;
+
+ [Setup]
+ public void Setup() {
+ var data = EmbeddedResources.LoadTechTree();
+ data.ShouldNotBeNull();
+ _definition = TechTreeDefinition.FromSerializedData(data);
+ }
+
[Test]
- public void TestStartingTech() {
- const BranchType branch = BranchType.Climbing;
- const string id = "climbing";
- var startingTech = new StartingTech { Branch = branch, Id = id };
- var techTree = new TechTree(startingTech);
- techTree[branch].HasResearched(id).ShouldBeTrue();
+ public void TestDefaultTree() {
+ _definition[BranchType.Climbing].Count.ShouldBe(SubTreeTech.MAX_NODES);
+ _definition[BranchType.Climbing][0].ShouldBe("climbing");
+ _definition[BranchType.Organization][4].ShouldBe("diplomacy");
}
[Test]
- public void TestStartingTech2() {
- const BranchType branch = BranchType.Climbing;
- const string id = "climbing";
- var techTree = new TechTree();
- techTree[branch].HasResearched(id).ShouldBeFalse();
+ public void TestNothingResearched() {
+ var techTree = _definition.CreateTechTree();
+ techTree[BranchType.Climbing].HasResearched("climbing").ShouldBeFalse();
}
[Test]
public void TestResearch() {
- const BranchType branch = BranchType.Climbing;
- const string id = "climbing";
- var techTree = new TechTree();
- techTree[branch].HasResearched(id).ShouldBeFalse();
- techTree[branch].Research(id);
- techTree[branch].HasResearched(id).ShouldBeTrue();
+ var techTree = _definition.CreateTechTree();
+ techTree[BranchType.Climbing].Research("climbing").ShouldBeTrue();
+ techTree[BranchType.Climbing].HasResearched("climbing").ShouldBeTrue();
}
+ [Test]
+ public void TestResearchUnknownNode() {
+ var techTree = _definition.CreateTechTree();
+ techTree[BranchType.Climbing].Research("swimming").ShouldBeFalse();
+ techTree[BranchType.Climbing].HasResearched("swimming").ShouldBeFalse();
+ }
+
+ [Test]
+ public void TestInvalidBranch() =>
+ Should.Throw(() => _definition.CreateTechTree()[(BranchType)42]);
+
[Test]
public void TestComputeCost() {
- const BranchType branch = BranchType.Climbing;
- var id = "climbing";
- var cities = 1u;
- var techTree = new TechTree();
- techTree[branch].ComputeCost(id, cities).ShouldBe(5u);
- cities = 2;
- techTree[branch].ComputeCost(id, cities).ShouldBe(6u);
- id = "smithery";
- techTree[branch].ComputeCost(id, cities).ShouldBe(10u);
+ var branch = _definition.CreateTechTree()[BranchType.Climbing];
+ branch.ComputeCost("climbing", 1).ShouldBe(5u);
+ branch.ComputeCost("climbing", 2).ShouldBe(6u);
+ branch.ComputeCost("mining", 2).ShouldBe(8u);
+ branch.ComputeCost("smithery", 2).ShouldBe(10u);
+ branch.ComputeCost("swimming", 2).ShouldBeNull();
+ }
+
+ [Test]
+ public void TestTiers() {
+ SubTreeTech.TierOf(0).ShouldBe(0u);
+ SubTreeTech.TierOf(1).ShouldBe(1u);
+ SubTreeTech.TierOf(2).ShouldBe(1u);
+ SubTreeTech.TierOf(3).ShouldBe(2u);
+ SubTreeTech.TierOf(4).ShouldBe(2u);
+ Should.Throw(() => SubTreeTech.TierOf(SubTreeTech.MAX_NODES));
+ }
+
+ [Test]
+ public void TestNodeTiers() {
+ var branch = _definition.CreateTechTree()[BranchType.Climbing];
+ branch.Nodes.Count.ShouldBe(SubTreeTech.MAX_NODES);
+ for (var index = 0; index < branch.Nodes.Count; index++) {
+ branch.Nodes[index].Tier.ShouldBe(SubTreeTech.TierOf(index));
+ }
+ }
+
+ [Test]
+ public void TestBranchNodesCount() => Should.Throw(() => new SubTreeTech(["climbing"]));
+
+ [Test]
+ public void TestBranchNodeWithoutId() {
+ Should.Throw(() =>
+ new SubTreeTech(["climbing", "mining", "meditation", "smithery", ""]));
+ Should.Throw(() =>
+ new SubTreeTech(["climbing", "mining", "meditation", "smithery", null!]));
+ }
+
+ [Test]
+ public void TestBranchDuplicatedNodes() =>
+ Should.Throw(() =>
+ new SubTreeTech(["climbing", "climbing", "meditation", "smithery", "philosophy"]));
+
+ [Test]
+ public void TestIndependentTrees() {
+ var first = _definition.CreateTechTree();
+ var second = _definition.CreateTechTree();
+ first[BranchType.Climbing].Research("climbing").ShouldBeTrue();
+ second[BranchType.Climbing].HasResearched("climbing").ShouldBeFalse();
+ }
+
+ [Test]
+ public void TestMissingBranch() {
+ var data = EmbeddedResources.LoadTechTree();
+ data.ShouldNotBeNull();
+ data.Branches.RemoveAt(0);
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(data));
+ }
+
+ [Test]
+ public void TestUndefinedBranch() {
+ var data = EmbeddedResources.LoadTechTree();
+ data.ShouldNotBeNull();
+ data.Branches.Add(new BranchSerializedData { Type = (BranchType)42, Nodes = ["a", "b", "c", "d", "e"] });
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(data));
+ }
+
+ [Test]
+ public void TestUndefinedBranchFromJson() {
+ // the guard exists because the converter takes numbers, so the json is the path that has to be checked
+ const string JSON = """
+ {"branches": [{"type": 42, "nodes": ["a", "b", "c", "d", "e"]}]}
+ """;
+ var data = JsonSerializer.Deserialize(JSON, _techTreeOptions);
+ data.ShouldNotBeNull();
+ data.Branches[0].Type.ShouldBe((BranchType)42);
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(data));
+ }
+
+ [Test]
+ public void TestNullBranches() {
+ var data = JsonSerializer.Deserialize("""{"branches": null}""", _techTreeOptions);
+ data.ShouldNotBeNull();
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(data));
+ }
+
+ [Test]
+ public void TestNullBranchNodes() {
+ var data = JsonSerializer.Deserialize("""
+ {"branches": [{"type": "climbing", "nodes": null}]}
+ """, _techTreeOptions);
+ data.ShouldNotBeNull();
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(data));
+ }
+
+ [Test]
+ public void TestNullBranch() {
+ var data = JsonSerializer.Deserialize("""{"branches": [null]}""", _techTreeOptions);
+ data.ShouldNotBeNull();
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(data));
+ }
+
+ [Test]
+ public void TestNullNode() {
+ var data = EmbeddedResources.LoadTechTree();
+ data.ShouldNotBeNull();
+ data.Branches[0].Nodes[1] = null!;
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(data));
+ }
+
+ [Test]
+ public void TestDuplicatedBranch() {
+ var data = EmbeddedResources.LoadTechTree();
+ data.ShouldNotBeNull();
+ data.Branches.Add(data.Branches[0]);
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(data));
+ }
+
+ [Test]
+ public void TestBranchWithWrongNodesCount() {
+ var data = EmbeddedResources.LoadTechTree();
+ data.ShouldNotBeNull();
+ data.Branches[0].Nodes.RemoveAt(0);
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(data));
+ }
+
+ [Test]
+ public void TestDuplicatedNode() {
+ var data = EmbeddedResources.LoadTechTree();
+ data.ShouldNotBeNull();
+ data.Branches[1].Nodes[0] = data.Branches[0].Nodes[0];
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(data));
+ }
+
+ [Test]
+ public void TestNullArguments() {
+ Should.Throw(() => TechTreeDefinition.FromSerializedData(null!));
+ Should.Throw(() => _definition.Override(null!));
+ Should.Throw(() => new SubTreeTech(null!));
+ Should.Throw(() => new TechTree(null!));
+ }
+
+ [Test]
+ public void TestOverride() {
+ var definition = _definition.Override([new TechOverride { Replaces = "fishing", Id = "free_diving" }]);
+ definition[BranchType.Fishing].Count.ShouldBe(SubTreeTech.MAX_NODES);
+ definition[BranchType.Fishing][0].ShouldBe("free_diving");
+ definition[BranchType.Fishing][1].ShouldBe("sailing");
+ definition.CreateTechTree()[BranchType.Fishing].ComputeCost("free_diving", 2).ShouldBe(6u);
+ }
+
+ [Test]
+ public void TestOverrideKeepsTheDefinition() {
+ _definition.Override([new TechOverride { Replaces = "fishing", Id = "free_diving" }]);
+ _definition[BranchType.Fishing][0].ShouldBe("fishing");
+ }
+
+ [Test]
+ public void TestOverrideUnknownNode() =>
+ Should.Throw(() =>
+ _definition.Override([new TechOverride { Replaces = "swimming", Id = "free_diving" }]));
+
+ [Test]
+ public void TestOverrideDuplicatedNode() =>
+ Should.Throw(() =>
+ _definition.Override([new TechOverride { Replaces = "fishing", Id = "mining" }]));
+
+ [Test]
+ public void TestOverrideWithoutId() {
+ Should.Throw(() =>
+ _definition.Override([new TechOverride { Replaces = "fishing", Id = "" }]));
+ Should.Throw(() =>
+ _definition.Override([new TechOverride { Replaces = "fishing", Id = null! }]));
+ }
+
+ [Test]
+ public void TestOverrideWithoutReplaces() {
+ Should.Throw(() =>
+ _definition.Override([new TechOverride { Replaces = "", Id = "free_diving" }]));
+ Should.Throw(() =>
+ _definition.Override([new TechOverride { Replaces = null!, Id = "free_diving" }]));
+ }
+
+ [Test]
+ public void TestNullOverride() =>
+ Should.Throw(() => _definition.Override([null!]));
+
+ [Test]
+ public void TestChainedOverrides() {
+ var definition = _definition.Override([
+ new TechOverride { Replaces = "fishing", Id = "free_diving" },
+ new TechOverride { Replaces = "free_diving", Id = "deep_diving" }
+ ]);
+ definition[BranchType.Fishing][0].ShouldBe("deep_diving");
+ }
+
+ [Test]
+ public void TestStartingBranch() {
+ var tribe = new Tribe {
+ StartingBranch = BranchType.Climbing,
+ StartingStars = 5,
+ SpawnRate = _spawnRate,
+ TerrainRate = _terrainRate
+ };
+ var techTree = _definition.CreateTechTree(tribe);
+ techTree[BranchType.Climbing].HasResearched("climbing").ShouldBeTrue();
+ techTree[BranchType.Climbing].HasResearched("mining").ShouldBeFalse();
+ }
+
+ [Test]
+ public void TestStartingBranchWithOverride() {
+ var tribe = new Tribe {
+ StartingBranch = BranchType.Fishing,
+ StartingStars = 5,
+ SpawnRate = _spawnRate,
+ TerrainRate = _terrainRate,
+ TechOverrides = [new TechOverride { Replaces = "fishing", Id = "free_diving" }]
+ };
+ var techTree = _definition.CreateTechTree(tribe);
+ techTree[BranchType.Fishing].HasResearched("free_diving").ShouldBeTrue();
+ techTree[BranchType.Fishing].HasResearched("fishing").ShouldBeFalse();
+ }
+
+ [Test]
+ public void TestTribeWithUndefinedStartingBranch() {
+ var tribe = new Tribe {
+ StartingBranch = (BranchType)42,
+ StartingStars = 5,
+ SpawnRate = _spawnRate,
+ TerrainRate = _terrainRate
+ };
+ Should.Throw(() => new TribeManager().RegisterTribe(TribeType.Imperius, tribe));
+ }
+
+ [Test]
+ public void TestNullTribe() {
+ var tribeManager = new TribeManager();
+ Should.Throw(() => tribeManager.RegisterTribe(TribeType.Imperius, null!));
+ Should.Throw(() => tribeManager.RegisterTribes(null!));
+
+ var tribes = JsonSerializer.Deserialize("""{"tribes": null}""", _tribeOptions);
+ tribes.ShouldNotBeNull();
+ Should.Throw(() => tribeManager.RegisterTribes(tribes));
+ }
+
+ [Test]
+ public void TestNullTribeFromJson() {
+ foreach (var json in new[] {
+ """{"tribes": [null]}""", """{"tribes": [{"type": "imperius", "tribe": null}]}"""
+ }) {
+ var tribes = JsonSerializer.Deserialize(json, _tribeOptions);
+ tribes.ShouldNotBeNull();
+ Should.Throw(() => new TribeManager().RegisterTribes(tribes));
+ }
+ }
+
+ [Test]
+ public void TestDuplicatedTribe() {
+ var tribe = new Tribe {
+ StartingBranch = BranchType.Climbing,
+ StartingStars = 5,
+ SpawnRate = _spawnRate,
+ TerrainRate = _terrainRate
+ };
+ var tribeManager = new TribeManager();
+ tribeManager.RegisterTribe(TribeType.Imperius, tribe);
+ Should.Throw(() => tribeManager.RegisterTribe(TribeType.Imperius, tribe));
+ }
+
+ [Test]
+ public void TestBranchTypeInJson() {
+ // JsonStringEnumConverter doesn't read EnumMember on net8, so "climbing" only works because it's the name of
+ // the member lowercased; a branch of two words needs the converter with a naming policy in the options
+ var data = new BranchSerializedData { Type = BranchType.Climbing, Nodes = [.. _definition[BranchType.Climbing]] };
+ JsonSerializer.Serialize(data, _techTreeOptions).ShouldContain("\"Climbing\"");
+ JsonSerializer.Deserialize("""{"type": "climbing", "nodes": []}""", _techTreeOptions)
+ .ShouldNotBeNull().Type.ShouldBe(BranchType.Climbing);
+ }
+
+ [Test]
+ public void TestTribesResource() {
+ var tribes = EmbeddedResources.LoadTribes();
+ tribes.ShouldNotBeNull();
+ var tribeManager = new TribeManager();
+ tribeManager.RegisterTribes(tribes);
+ // every tribe of the resource has to build a tree, so an override naming nothing fails here and not mid game
+ foreach (var (type, tribe) in tribeManager.Tribes) {
+ var techTree = _definition.CreateTechTree(tribe);
+ techTree[tribe.StartingBranch].Nodes[0].Researched.ShouldBeTrue($"{type} doesn't start on anything");
+ }
+
+ var imperius = tribeManager[TribeType.Imperius];
+ imperius.ShouldNotBeNull();
+ imperius.StartingBranch.ShouldBe(BranchType.Organization);
+ _definition.CreateTechTree(imperius)[BranchType.Organization].HasResearched("organization").ShouldBeTrue();
}
}
diff --git a/OpenPolytopia/test/src/TerrainGenerationTest.cs b/OpenPolytopia/test/src/TerrainGenerationTest.cs
index 0344c1e..7793483 100644
--- a/OpenPolytopia/test/src/TerrainGenerationTest.cs
+++ b/OpenPolytopia/test/src/TerrainGenerationTest.cs
@@ -17,7 +17,7 @@ public class TerrainGenerationTest(Node testScene) : TestClass(testScene) {
var tribeManager = new TribeManager();
tribeManager.RegisterTribe(TribeType.Imperius,
new Tribe {
- StartingTech = new StartingTech { Branch = BranchType.Organization, Id = "organization" },
+ StartingBranch = BranchType.Organization,
SpawnRate = new SpawnRate {
FruitRate = 2.0f, CropRate = 1.0f, AnimalRate = 0.5f, MineralRate = 1.0f, FishRate = 1.0f
},