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 },