feat: let tribes declare their own tech tree - #59
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbKNRFwEyd7ceG1pBcG3fB
Enn3Developer
left a comment
There was a problem hiding this comment.
Adversarial review
Reviewed the full diff at 1d387f8. No .NET SDK is available in this environment, so everything below is from reading the code — I could not build or run the suite, and the CS1572/IDE0021 calls are from the rules in .editorconfig and <GenerateDocumentationFile>, not from an observed build log.
The direction is right and the two bugs you found on the way are real fixes. Moving the tree into data, deriving the starting tech from the branch after overrides, and keying overrides on the node id rather than a {branch, slot} pair all remove ways for the data to contradict itself. I checked for call sites outside the diff — StartingTech, new TechTree() and TribeSerializedData.TribeType have no other consumers in the solution, so the breaking renames are self-contained. ComputeCost's new formula matches the old one on every slot.
Eight findings, none blocking on their own:
Invariants that moved out of the type they belong to
SubTreeTech's constructor validates the node count but not the tiers — and cost now comes fromTier, not from the slot. The count check doesn't prevent the miscosting the PR description says it prevents.TechTreeDefinition's indexer returns the internalstring[]behind anIReadOnlyList<string>, so the "immutable, shared by every player" definition is one cast away from being mutated in place.
Bad data that gets in
3. FromSerializedData rejects a missing branch but not an extra one — "type": 9 deserializes fine (allowIntegerValues defaults to true), passes all four validations, poisons the duplicate-id check, and is then silently dropped by TechTree.
4. Tribe.StartingBranch and Tribe.TechOverrides are validated only when CreateTechTree runs, so a malformed tribe fails during game setup rather than at load.
Silent failure
5. ComputeCost still answers 0 for an unknown id. You hardened Research against exactly this, and 0 is a valid price — once stars are wired up, a typo'd tech is free.
Coverage
6. Two of the four FromSerializedData validations have no test, and the "one definition, independent per-player state" property isn't asserted anywhere.
Build hygiene
7. <param name="nodes"> survived the removal of SubTreeTech's primary constructor → CS1572, which .editorconfig does not silence and docs.yaml builds through.
8. Two expression-bodied constructors against csharp_style_expression_bodied_constructors = false:warning; they're the only ones in the solution.
Details and suggested fixes are inline. My picks for this PR are 1, 3 and 5 — 1 because the type now carries a cost-affecting field nothing checks, 3 because FromSerializedData is the only gate in front of the data, and 5 because it turns into a free-tech bug the moment the wiring lands. 2, 4, 6 are fine as follow-ups; 7 and 8 are one-liners.
Generated by Claude Code
| /// <param name="id">the node id</param> | ||
| public NodeTech? this[string id] => Nodes.FirstOrDefault(node => node.Id == id); | ||
|
|
||
| public SubTreeTech(NodeTech[] nodes) { |
There was a problem hiding this comment.
The node-count check doesn't actually prevent miscosting — the tier does, and it isn't checked.
The PR description says SubTreeTech now throws on the wrong node count "so a broken tree fails at load instead of silently miscosting techs". But cost no longer comes from the slot, it comes from NodeTech.Tier, and this constructor accepts whatever tiers it is handed. The class remark right above states the invariant ("node 0: tier 0; node 1 and node 2: tier 1; node 3 and node 4: tier 2") and nothing enforces it.
var branch = new SubTreeTech([
new NodeTech { Id = "climbing", Tier = 0 },
new NodeTech { Id = "mining", Tier = 0 }, // should be 1
new NodeTech { Id = "meditation", Tier = 0 }, // should be 1
new NodeTech { Id = "smithery", Tier = 0 }, // should be 2
new NodeTech { Id = "philosophy", Tier = 0 } // should be 2
]);
branch.ComputeCost("philosophy", 4); // 8, should be 16Five nodes, constructor happy, every tier-2 tech at half price. Today the only caller is TechTree's constructor which passes TierOf(index), so the shipped path is correct — but the type is public with a public constructor and a public Nodes array, and the invariant now lives in the caller instead of in the type.
Since Tier is a pure function of the slot, the cheapest fix is to not let it be passed in at all — have SubTreeTech take the ids and stamp the tiers itself:
public SubTreeTech(IReadOnlyList<string> ids) {
if (ids.Count != MAX_NODES) { throw ... }
Nodes = [.. ids.Select((id, index) => new NodeTech { Id = id, Tier = TierOf(index) })];
}That also removes Tier from NodeTech's required init surface, deletes the duplicated Select in TechTree's constructor, and makes TierOf an implementation detail rather than a contract callers have to remember to honour. If you'd rather keep the current constructor, at least assert nodes[i].Tier == TierOf(i) in the loop.
Generated by Claude Code
| /// <param name="cities">number of cities owned by the player</param> | ||
| /// <returns>the cost for that node</returns> | ||
| /// <returns>the cost for that node; 0 if no node has been found with that id</returns> | ||
| public uint ComputeCost(string id, uint cities) { |
There was a problem hiding this comment.
An unknown id costs 0 stars — the same silent failure Research was just hardened against.
Research now returns false on a typo so it can't fail silently, which is the right call. ComputeCost sitting right next to it still answers 0 for an id that isn't in the branch, and 0 isn't a sentinel here — it's a perfectly valid price the caller will happily charge:
var cost = branch.ComputeCost("free_divng", cities); // typo → 0
if (player.Stars >= cost) { player.Stars -= cost; branch.Research("free_divng"); }The player pays nothing, Research returns false, the return value is dropped at the call site, and the tech is never researched. Once stars/cities get wired up this is a free-tech bug that no test will catch, because TestComputeCost asserts exactly this behaviour (branch.ComputeCost("swimming", 2).ShouldBe(0u)).
It's pre-existing behaviour, but the method is being rewritten in this PR and the sibling method is being fixed for precisely this reason, so it's worth making the pair consistent. uint? with null for "no such node" is the smallest change and forces callers to deal with it; throwing is also defensible given Override/FromSerializedData already treat an unknown id as an error everywhere else in this file.
Generated by Claude Code
| foreach (var branch in Enum.GetValues<BranchType>()) { | ||
| if (!branches.ContainsKey(branch)) { | ||
| throw new ArgumentException($"branch {branch} is missing", nameof(data)); | ||
| } | ||
| } |
There was a problem hiding this comment.
This checks that no branch is missing, but not that no extra branch is present — an undefined BranchType passes every validation and is then silently dropped.
[JsonConverter(typeof(JsonStringEnumConverter<BranchType>))] is applied with the parameterless constructor, so allowIntegerValues defaults to true and a numeric type deserializes without complaint:
{ "type": 9, "nodes": ["a", "b", "c", "d", "e"] }(BranchType)9 isn't a declared member, but nothing here rejects it:
- the node-count check passes (5 nodes)
TryAddsucceeds (no collision with the real branches)- this loop passes, because all five real branches are still present
- the duplicate-id check does include its nodes, so a phantom branch can make a legitimate tree fail to load with a confusing "node X is declared more than once"
Then TechTree's constructor iterates Enum.GetValues<BranchType>() and drops it — five node ids that exist in the definition, are reachable through Override, and are unreachable from every TechTree. Override on one of those ids succeeds and applies to nothing.
FromSerializedData is the only gate in front of this data, so it's worth closing here. Either validate in the first loop:
if (!Enum.IsDefined(branch.Type)) {
throw new ArgumentException($"branch {branch.Type} isn't a valid branch", nameof(data));
}or, equivalently and more cheaply, replace this loop with a single branches.Count != Enum.GetValues<BranchType>().Length check plus the existing per-branch ContainsKey — count equality on top of "every declared branch is defined" rules out both missing and extra in one go.
Generated by Claude Code
| public IReadOnlyList<string> this[BranchType branch] => | ||
| _branches.TryGetValue(branch, out var nodes) | ||
| ? nodes | ||
| : throw new ArgumentOutOfRangeException(nameof(branch), branch, "branch is invalid"); |
There was a problem hiding this comment.
The class doc promises immutability, but this hands out the internal array.
IReadOnlyList<string> is a read-only view, not a read-only object — nodes here is the very string[] stored in _branches, and string[] implements IReadOnlyList<string>, so the reference survives a cast:
((string[])definition[BranchType.Fishing])[0] = "not_fishing";That mutates the shared definition in place. Given the remark on the class ("shared by every player", "immutable, Override returns a new definition instead of modifying this one"), the blast radius is every player created afterwards, plus every Override derived from it — Override clones from _branches, so it inherits the corruption rather than isolating it.
TechTree's constructor is currently the only consumer and only reads, so nothing is broken today; it's the invariant that's unguarded. Cheapest fix is to hand out a real wrapper:
private readonly Dictionary<BranchType, ReadOnlyCollection<string>> _branches;building each with Array.AsReadOnly(...) in FromSerializedData/Override (which already own freshly-allocated arrays, so no extra copying), or store ImmutableArray<string> if you'd rather not pay the wrapper allocation. Either way Override's .Clone() and the string[]-typed Find helper stay internal, which is where the mutability belongs.
Generated by Claude Code
| /// A tribe always starts with the tier 0 node of this branch already researched, whether it's the default node or | ||
| /// one of its <see cref="TechOverrides"/> | ||
| /// </remarks> | ||
| public required BranchType StartingBranch { get; init; } |
There was a problem hiding this comment.
Nothing validates StartingBranch, so a bad tribe fails during game setup with an undocumented exception instead of at load.
Same root cause as the BranchType note in TechTree.cs: JsonStringEnumConverter<BranchType> accepts integers, so "starting_branch": 9 deserializes fine, RegisterTribes accepts it, and TribeManager[TribeType.X] hands back a Tribe that looks valid. The failure only surfaces here:
techTree[tribe.StartingBranch].Research(definition[tribe.StartingBranch][0]);as an ArgumentOutOfRangeException out of TechTree's indexer — halfway through building a player, after some players have already been created. CreateTechTree's <exception> tag only documents ArgumentException from Override, so this one is undocumented as well.
TechOverrides on the next property has the same shape of problem: the ids in it are only checked when CreateTechTree runs, so a tribe that names a node that doesn't exist is accepted at registration and blows up later.
RegisterTribe/RegisterTribes is the natural place to catch both — it's the one chokepoint every tribe goes through, and it already throws (Tribes.Add) on a duplicate type, so validating there is consistent. An Enum.IsDefined(tribe.StartingBranch) check plus a dry-run definition.Override(tribe.TechOverrides) would turn every one of these into a load-time error with the tribe's name in the message. That does mean handing the definition to TribeManager, which may be more coupling than you want right now — if so, at least document the ArgumentOutOfRangeException on CreateTechTree.
Generated by Claude Code
| /// <param name="nodes">array of nodes in the subtree</param> | ||
| /// <exception cref="ArgumentException">if <c>nodes</c> doesn't have exactly <see cref="MAX_NODES"/> nodes</exception> | ||
| /// <remarks> | ||
| /// 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 <see cref="MAX_NODES"/>; node 0: tier 0; node 1 and node 2: tier 1; | ||
| /// node 3 and node 4: tier 2 | ||
| /// </remarks> | ||
| public class SubTreeTech(NodeTech[] nodes) { | ||
| public NodeTech[] Nodes { get; } = nodes; | ||
| public class SubTreeTech { |
There was a problem hiding this comment.
<param name="nodes"> is left over from the primary constructor that this PR removed — that's a CS1572 warning.
SubTreeTech was public class SubTreeTech(NodeTech[] nodes), so <param name="nodes"> on the type declaration was valid. Now that the primary constructor is gone and the parameters live on the explicit constructor below, the type has no parameter called nodes, and with <GenerateDocumentationFile>true</GenerateDocumentationFile> in OpenPolytopia.Common.csproj the compiler emits:
CS1572: XML comment has a param tag for 'nodes', but there is no parameter by that name
.editorconfig silences CS1591 but not CS1572, so this is a new warning on every build, and docs.yaml runs dotnet build before docfx.
The <exception> tag has the same problem in spirit — it documents the constructor's behaviour but is attached to the type, so docfx renders it on the class page while the constructor (the thing that actually throws) is documented with nothing. Both tags want to move down onto public SubTreeTech(NodeTech[] nodes), leaving <summary> and <remarks> on the type.
Generated by Claude Code
| ? nodes | ||
| : throw new ArgumentOutOfRangeException(nameof(branch), branch, "branch is invalid"); | ||
|
|
||
| private TechTreeDefinition(Dictionary<BranchType, string[]> branches) => _branches = branches; |
There was a problem hiding this comment.
Expression-bodied constructor — .editorconfig asks for a block body, at warning severity.
csharp_style_expression_bodied_constructors = false:warningThat's IDE0021 ("Use block body for constructor"), and dotnet_analyzer_diagnostic.severity = warning is set too. These are the only two expression-bodied constructors in the solution — every other constructor in OpenPolytopia.Common uses a block body — so this is new noise rather than an existing pattern.
Same applies to TechTree's constructor at line 175, where it also costs readability: a ToDictionary over a collection expression over a Select inside a => is doing enough that a block body with a named local would read better.
Generated by Claude Code
| public void TestMissingBranch() { | ||
| var data = JsonSerializer.Deserialize<TechTreeSerializedData>(EmbeddedResources.TechTreeData, _techTreeOptions); | ||
| data.ShouldNotBeNull(); | ||
| data.Branches.RemoveAt(0); | ||
| Should.Throw<ArgumentException>(() => TechTreeDefinition.FromSerializedData(data)); | ||
| } | ||
|
|
||
| [Test] | ||
| public void TestDuplicatedNode() { | ||
| var data = JsonSerializer.Deserialize<TechTreeSerializedData>(EmbeddedResources.TechTreeData, _techTreeOptions); | ||
| data.ShouldNotBeNull(); | ||
| data.Branches[1].Nodes[0] = data.Branches[0].Nodes[0]; | ||
| Should.Throw<ArgumentException>(() => TechTreeDefinition.FromSerializedData(data)); | ||
| } | ||
|
|
There was a problem hiding this comment.
Two of the four validations FromSerializedData claims are untested, and the headline "shared by every player" property has no test at all.
The PR description lists four rejections: branch missing, branch declared twice, branch without exactly MAX_NODES nodes, node id used more than once. Only two are covered here (TestMissingBranch, TestDuplicatedNode). The other two are the ones most likely to break, because the code paths are subtle:
- wrong node count —
TestBranchNodesCountexercisesSubTreeTech's constructor directly, notFromSerializedData's own check at the top of the loop. Those are separateifs with separate messages; deleting the one inFromSerializedDataleaves the whole suite green (theSubTreeTechthrow would still fire, but only later, fromTechTree's constructor, and with the "load-time" guarantee gone). - branch declared twice — the
TryAddbranch has no coverage. Note it's also order-dependent with the count check:data.Branches.Add(data.Branches[0])is a one-liner that covers it.
Separately, the central claim of the design — one definition, independent per-player state — isn't asserted anywhere:
var a = _definition.CreateTechTree();
var b = _definition.CreateTechTree();
a[BranchType.Climbing].Research("climbing");
b[BranchType.Climbing].HasResearched("climbing").ShouldBeFalse();It passes today (TechTree's constructor allocates fresh NodeTechs per tree), but it's exactly the thing that a future "cache the SubTreeTechs on the definition" optimisation would quietly break, and it'd break as a cross-player state leak rather than a crash. TestOverrideKeepsTheDefinition covers the definition-level half of this; this is the tree-level half.
Generated by Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbKNRFwEyd7ceG1pBcG3fB
|
Addressed in 1 — tiers aren't checked. Fixed the way you suggested: public SubTreeTech(IReadOnlyList<string> ids) {
if (ids.Count != MAX_NODES) { throw ... }
Nodes = [.. ids.Select((id, index) => new NodeTech { Id = id, Tier = TierOf(index) })];
}
2 — the definition hands out its internal array. 3 — an extra branch passes every check. 5 — an unknown id costs 0. 6 — coverage. Added 7 and 8. 4 — the tribe isn't validated at load. Left as is, only documented: Generated by Claude Code |
Enn3Developer
left a comment
There was a problem hiding this comment.
Adversarial review, round 2 — c24279d
Re-reviewed against the new head. Same caveat as last time: no .NET SDK in this environment, so nothing here was compiled or run — the claims below come from reading the code, .editorconfig, and the csproj.
What the fix commit resolved
Six of the eight findings from the first round are closed, and closed properly rather than papered over:
- tiers are derived, not passed —
SubTreeTech(IReadOnlyList<string>)stampsTierOf(index)itself,NodeTech.Tierlostrequiredand gainedinternal init, andTechTree's constructor lost the duplicatedSelect. The invariant now lives in the type instead of in its caller, which was the point. ComputeCostreturnsuint?— with a remark explaining why0was wrong, andTestComputeCostupdated.Enum.IsDefinedon deserialized branches — with a comment naming the converter behaviour that makes it necessary.ReadOnlyCollection<string>behind the definition indexer — the cast-and-mutate route is gone.- CS1572 and both expression-bodied constructors — fixed.
- test coverage —
TestUndefinedBranch,TestDuplicatedBranch,TestBranchWithWrongNodesCount,TestNodeTiersandTestIndependentTreescover the gaps I listed, including the "independent per-player state" property.
The two I raised that weren't code-changed (Tribe.StartingBranch validation, exception documentation) got documentation instead — reasonable, and one of them is the subject of a follow-up below.
This round
Nothing blocking, and no regression from the fix commit. Five findings, roughly in the order I'd act on them:
SubTreeTech.Nodeshands out the backing array — the encapsulation fix landed onTechTreeDefinitionbut not here, and droppingrequiredfromTieris what makesbranch.Nodes[4] = new NodeTech { Id = "philosophy" }compile from a consumer assembly and produce the tier-0-in-slot-4 miscost the PR set out to prevent. Latent, not live: nothing writes toNodestoday.Tribe.StartingBranchstill has the holeFromSerializedDatajust closed — same enum, same converter, same resource shape, and the new comment on the tree-side guard documents the hazard explicitly. Follow-up on the still-open thread from round 1.SubTreeTech's constructor doesn't enforce distinct ids — a repeated id makes a slot permanently unreachable and mispriced.FromSerializedDatacatches it tree-wide, so this is only reachable through the public constructor.HasResearchedkept the silentfalse— the argument written intoComputeCost's new remark applies to it unchanged. Consistency, not a bug.- JSON
nullescapes asNullReferenceException—requireddoesn't reject explicit nulls, so{"branches": null}bypasses the five documented rejections. Minor; the resource is author-controlled.
If you only take two, take 1 and 2 — they're the ones where the code now states an invariant it doesn't fully hold.
Two things I checked and am not reporting, to save you re-deriving them: the ComputeCost signature change has no callers outside TechTreeTest, and TestDuplicatedBranch's data.Branches.Add(data.Branches[0]) does reach the TryAdd throw rather than tripping an earlier check.
Generated by Claude Code
| /// <summary> | ||
| /// The nodes of this branch, ordered by slot | ||
| /// </summary> | ||
| public NodeTech[] Nodes { get; } |
There was a problem hiding this comment.
The encapsulation fix stopped one level short: this hands out the internal array, and it's the exact hole the new remark says is closed.
TechTreeDefinition now returns ReadOnlyCollection<string>, which is the right fix. But SubTreeTech — the type the new remark describes as building "its own nodes to make sure no node can ever get a tier that doesn't belong to its slot" — still exposes its backing array by reference:
var branch = definition.CreateTechTree()[BranchType.Climbing];
branch.Nodes[4] = new NodeTech { Id = "philosophy" }; // Tier is internal init → defaults to 0
branch.ComputeCost("philosophy", 4); // 8, should be 16That compiles from any consumer assembly. Dropping required from Tier is what makes it compile: an object initializer that omits Tier used to be a compile error, now it silently yields a tier-0 node, and slot 4 accepts it. Same reference lets a caller reorder the slots, or write branch.Nodes[0] = null! and turn this[id]'s FirstOrDefault into an NRE.
Nothing in the repo writes to Nodes today — every use is a read — so this is a latent hole rather than a live bug. It's worth closing because the invariant just moved into this type on purpose, and this property is the one door left open:
private readonly NodeTech[] _nodes;
/// <summary>
/// The nodes of this branch, ordered by slot
/// </summary>
public IReadOnlyList<NodeTech> Nodes => _nodes;TestNodeTiers would swap .Length for .Count; nothing else in the solution touches it.
Two smaller things that came in with the same change:
- the remark on
NodeTech.Tiersays "onlySubTreeTechcan set it", butinternalmeans every type inOpenPolytopia.Commoncan. If you want the doc to be literally true, nestingNodeTechinSubTreeTechwith aprivate initis the only way to get there; otherwise "only this assembly builds nodes" is the accurate phrasing. Researchedis still a public setter, sobranch.Nodes[0].Researched = truebypassesResearchand its newboolreturn. Less serious — the result is the same state — but ifResearchbecomes the audited path for spending stars, an unguarded setter next to it is a trap.
Generated by Claude Code
| /// <remarks> | ||
| /// A tribe always starts with the tier 0 node of this branch already researched, whether it's the default node or | ||
| /// one of its <see cref="TechOverrides"/> | ||
| /// </remarks> | ||
| public required BranchType StartingBranch { get; init; } |
There was a problem hiding this comment.
Follow-up: the undefined-enum hole was closed on the tree side and left open here, and the new code comment proves it's a known hazard.
FromSerializedData now carries:
// the json converter of BranchType accepts numbers too, so a branch that doesn't exist can get this far
if (!Enum.IsDefined(branch.Type)) { ... }StartingBranch is the same BranchType, deserialized by the same converter, out of the same kind of embedded resource — but no Enum.IsDefined. "starting_branch": 42 in tribes.json deserializes, RegisterTribes accepts it, TribeManager[type] returns a Tribe that looks valid, and the failure lands in CreateTechTree as an ArgumentOutOfRangeException partway through building a player. The <exception> tag added to CreateTechTree documents that crash rather than preventing it, which is a fair call if it was deliberate — flagging it because the two paths are now inconsistent about an identical hazard, not because documenting was wrong.
The gap is one line wherever tribes get validated:
public void RegisterTribe(TribeType type, Tribe tribe) {
if (!Enum.IsDefined(tribe.StartingBranch)) {
throw new ArgumentException($"tribe {type} starts on {tribe.StartingBranch}, which isn't a valid branch", nameof(tribe));
}
Tribes.Add(type, tribe);
}TechOverrides on the next property is the deferred-validation half of the same thing and genuinely does need the definition to check, so leaving that one to CreateTechTree is reasonable.
Worth noting separately: TestUndefinedBranch constructs the bad value in C# —
data.Branches.Add(new BranchSerializedData { Type = (BranchType)42, ... });— so it covers the guard but never exercises the JSON path the comment above the guard cites as the reason it exists. If JsonStringEnumConverter were ever constructed with allowIntegerValues: false, or the converter attribute changed, that test would keep passing while the premise underneath it changed. Deserializing {"type": 42, "nodes": [...]} from a literal string asserts the actual claim.
Generated by Claude Code
| public SubTreeTech(IReadOnlyList<string> ids) { | ||
| if (ids.Count != MAX_NODES) { | ||
| throw new ArgumentException($"a branch needs exactly {MAX_NODES} nodes, got {ids.Count}", nameof(ids)); | ||
| } | ||
|
|
||
| Nodes = [.. ids.Select((id, index) => new NodeTech { Id = id, Tier = TierOf(index) })]; | ||
| } |
There was a problem hiding this comment.
The one invariant this constructor still doesn't enforce: ids have to be distinct, or a slot becomes unreachable.
The rewrite is the right shape — tiers can't be wrong anymore because they're derived, not passed. What's left is that this[id] is a linear search returning the first match, so a repeated id makes every later slot with that id dead:
var branch = new SubTreeTech(["climbing", "climbing", "meditation", "smithery", "philosophy"]);
branch.Research("climbing"); // marks slot 0
branch.Nodes[1].Researched; // false, and no call can ever make it true
branch.ComputeCost("climbing", 2); // 6 — slot 1 is tier 1, its real price is 8Slot 1 exists, costs nothing to reach, and can never be researched or priced correctly. Empty and whitespace ids have the same effect, and ["a", "a", "a", "a", "a"] passes the count check while collapsing the entire branch to one node.
FromSerializedData's global duplicate check already rules this out for trees built from tech_tree.json, and Override can't introduce one either, so the shipped path is safe. But this constructor is public on a public type, and the commit that introduced it is titled "make the tech tree types enforce their own invariants" — this is the invariant that didn't come along. It's also the cheapest of the three to check:
if (ids.Distinct().Count() != ids.Count) {
throw new ArgumentException("a branch can't have the same node id twice", nameof(ids));
}If you'd rather not pay that on every branch construction, the alternative is to make the constructor internal and let FromSerializedData remain the only gate — that's a defensible answer too, and it makes TestBranchNodesCount the thing that has to move rather than the thing that has to grow a sibling. What's awkward is the current middle ground: public constructor, two of three invariants enforced.
Generated by Claude Code
| }; | ||
|
|
||
| /// <summary> | ||
| /// Whether a node has been marked as researched |
There was a problem hiding this comment.
HasResearched is the third member of the trio and kept the silent default that the other two just lost.
The reasoning added to ComputeCost is exactly right:
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
false is a value a caller acts on for the same reason. HasResearched("free_divng") and HasResearched("free_diving") on a tribe that never took the override both answer false, and the caller can't tell "exists, not researched yet" from "no such node":
foreach (var id in uiNodeIds) {
if (!branch.HasResearched(id)) { ShowAsAvailable(id); } // a typo'd id renders as a researchable tech
}Click it and ComputeCost returns null, so whatever you do with the nullable now has to handle a case the UI already committed to — and Research returns false after the star cost was already displayed. Three members, three different ways of saying "no such node": null, false, false.
bool? would make it consistent, though it's the least pleasant of the three signatures to consume. Given this[id] is already public and returns NodeTech?, an alternative that costs nothing is to let callers ask the question directly — branch[id] is { Researched: true } for the check, branch[id] is null for existence — and document HasResearched as the convenience overload that deliberately conflates the two. Either is fine; the thing worth avoiding is having fixed the sibling methods for a stated reason that applies here unchanged.
Not blocking, and unlike the other two there's no caller to break yet.
Generated by Claude Code
| public static TechTreeDefinition FromSerializedData(TechTreeSerializedData data) { | ||
| var branches = new Dictionary<BranchType, string[]>(data.Branches.Count); | ||
| foreach (var branch in data.Branches) { |
There was a problem hiding this comment.
Minor: required doesn't reject an explicit JSON null, so malformed data leaves here as an NRE instead of the documented ArgumentException.
required guarantees the property was set, and a JSON null sets it. TechTreeSerializedData.Branches and BranchSerializedData.Nodes are non-nullable List<T>, but neither the source generator nor required enforces that at runtime:
{"branches": null}→data.Branches.Counton line 51,NullReferenceException{"type": "climbing", "nodes": null}→branch.Nodes.Counton line 58, same{"nodes": ["a", null, "c", "d", "e"]}→ count is 5,TryAddsucceeds, the duplicate check groups the singlenullinto a group of one and passes, and aNodeTechends up withId = nulldespiterequired string Id. Two nulls anywhere in the tree do throw, with the messagenode is declared more than once.
The doc block right above this lists five ways the data can be rejected, and this is a sixth that escapes as a different exception type from a different layer. The file is embedded and under your control, so the realistic trigger is a hand-edit or a merge accident rather than untrusted input — hence minor. But FromSerializedData is the only gate in front of the resource, and a null check is one line each:
ArgumentNullException.ThrowIfNull(data.Branches);plus the same for branch.Nodes, and branch.Nodes.Any(string.IsNullOrWhiteSpace) folded into the existing per-branch validation if you want the id case covered too.
Generated by Claude Code
|
Round 2 is in 1. 2. 3. distinct ids in the constructor — took the check rather than making the constructor 4. 5. JSON Build is clean and the suite is at 72 passed / 0 failed / 0 skipped locally. Generated by Claude Code |
Enn3Developer
left a comment
There was a problem hiding this comment.
Third pass, against e6d5da6. Same caveat as the last two: no .NET SDK in this environment, so nothing here was compiled or run — everything below comes from reading the code, the resources and the merge.
All five round-2 findings are genuinely closed, and the two I called out as the picks got the stronger of the two available fixes: _nodes is private with IReadOnlyList<NodeTech> over it and this[id] searches the field directly, and RegisterTribe validates StartingBranch with a comment tying it to the tree-side guard. TestUndefinedBranchFromJson is the right instinct — it asserts the premise ({"type": 42} deserializing to (BranchType)42) instead of assuming it, which is what the previous test was missing.
Four of the five new findings are the same shape: an invariant was fixed at the gate where it was reported, and the other gates for the same invariant were left alone. That's now happened three rounds running — Enum.IsDefined on branches but not tribes, distinctness in SubTreeTech but not Override, and now null/blank ids in FromSerializedData but not the two other paths that write ids into the tree.
Ranked:
Overridedoesn't check the ids it writes — the one to take."tech_overrides": [{"replaces": "fishing", "id": ""}]intribes.jsonputs an empty id in slot 0 and the tribe starts researched on it;"id": nullgets aNodeTech.Idof null through arequired stringand makes the node addressable bynull. The comment onFromSerializedDataexplaining whyrequiredisn't enough applies word for word toTechOverride.Id.- The PR doesn't merge, and the natural resolution breaks
tribes.jsonloading. #49 landed the same two fixes the opposite way — it renamed the JSON key to"tribe_type"where this PR renamed the property toType— so the resolution has to be made inTribe.csandtribes.jsontogether. And git already auto-mergesstarting_starsinto the file twice with no conflict marker, whichSystem.Text.Jsonaccepts silently. SubTreeTech's constructor has the same blank-id gap, by the same reasoning you used to justify putting theDistinctcheck there rather than sealing the constructor.Researched's new remark repeats the exact overclaim this commit fixed onTier—internal setis an assembly boundary, not a single-caller guarantee.- The
branch.Nodesnull guard has no test that pins it;TestNullNodepasses with or without it, since it exercises the element path andArgumentNullExceptionsatisfiesShould.Throw<ArgumentException>anyway. Plus the hand-writtenparamNamearguments, whichThrowIfNullalready supplies.
None are blocking except the merge, which is blocking by definition. 1 and 3 are one decision — folding the check into SubTreeTech's constructor covers both paths at the cost of a vaguer message.
Two things I checked and am not filing: the ordering of the new validations in FromSerializedData is right (null before .Count, and putting the blank check ahead of the count check doesn't shadow TestBranchWithWrongNodesCount), and Override's duplicate guard holds up against the cases that looked promising — chained swaps, two overrides claiming the same id, and the Id == Replaces no-op all behave. CreateTechTree discarding Research's bool is safe today, since the tree and the id both come from the same definition; it's only worth a line if that stops being true.
Generated by Claude Code
| public TechTreeDefinition Override(IEnumerable<TechOverride> overrides) { | ||
| var branches = _branches.ToDictionary(branch => branch.Key, branch => branch.Value.ToArray()); | ||
| foreach (var techOverride in 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; |
There was a problem hiding this comment.
The null/blank id hardening landed on one of the two paths that put ids into the tree.
FromSerializedData now rejects a null or blank node id, with the comment explaining exactly why required doesn't do it for you:
// required only checks that the json set the property, and a json null sets it just fineTechOverride.Id is required string and reaches the tree through this method, and the same sentence applies to it verbatim — but nothing here checks it. Override only asks whether the id is already in the tree, and a null or blank id never is, so the guard on line 121 waves it straight through.
tech_overrides lives in tribes.json, so this is reachable from the same author-controlled resource the other guard protects:
"tech_overrides": [
{ "replaces": "fishing", "id": "" }
]Find locates fishing at slot 0, "" != "fishing" and Find(branches, "") is null, so branches[Fishing][0] = "". The private constructor doesn't validate, SubTreeTech's count and Distinct checks both pass, and CreateTechTree then researches it — the tribe starts on a node whose id is the empty string. FromSerializedData would have rejected that exact tree shape with "branch fishing has a node without an id".
With "id": null it's worse: the array gets a null, NodeTech.Id ends up null despite being required string, and since Array.Find compares with ==, the node becomes addressable by null — HasResearched(null) and ComputeCost(null, n) both hit it. A second override with "replaces": null would then find that slot, because Array.IndexOf(nodes, null) stops being −1 once a null is in there.
A null element in the list itself ("tech_overrides": [null]) is the same family — it NREs on techOverride.Replaces.
The cheap fix is to validate the override at the top of the loop, next to where you already reason about ids:
foreach (var techOverride in overrides) {
ArgumentNullException.ThrowIfNull(techOverride, nameof(overrides));
if (string.IsNullOrWhiteSpace(techOverride.Id)) {
throw new ArgumentException("an override can't have a node without an id", nameof(overrides));
}
...The <exception> block would want the same line the other one got.
Generated by Claude Code
| public SubTreeTech(IReadOnlyList<string> ids) { | ||
| if (ids.Count != MAX_NODES) { | ||
| throw new ArgumentException($"a branch needs exactly {MAX_NODES} nodes, got {ids.Count}", 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) })]; | ||
| } |
There was a problem hiding this comment.
Same gap as the Override comment, at the third gate: this constructor now enforces the count and the distinctness of the ids, but not that an id is actually an id.
new SubTreeTech(["climbing", "mining", "meditation", "smithery", ""]) // fine
new SubTreeTech(["climbing", "mining", "meditation", "smithery", null!]) // fineBoth build a branch whose slot 4 has an unusable id, and the null one produces a NodeTech with Id = null even though it's declared required string.
The reason you gave for taking the Distinct check here rather than making the constructor internal applies unchanged:
a branch is cheap to build and the constructor being public is what makes it testable
If the constructor is public, blank ids are its problem too — and folding the check in here would also cover the Override path for free, since every override eventually flows through this constructor. That might be the better single place for both:
if (ids.Any(string.IsNullOrWhiteSpace)) {
throw new ArgumentException("a branch can't have a node without an id", nameof(ids));
}The trade-off is the error message: caught here it says "a branch", caught in Override it can name the offending override. FromSerializedData would keep its own check either way since it wants to name the branch.
Generated by Claude Code
| /// <remarks> | ||
| /// <see cref="SubTreeTech.Research"/> is the only way to mark a node, so it stays the single place where researching | ||
| /// something can be paid for | ||
| /// </remarks> | ||
| public bool Researched { get; internal set; } |
There was a problem hiding this comment.
This commit fixed the overclaim on Tier and then wrote the identical one on Researched, four lines down.
Tier went from "only SubTreeTech can set it" to "only this assembly builds nodes and SubTreeTech is the only type doing it" — precise about what internal actually buys, which is the whole point. But Researched is internal set and the new remark says:
SubTreeTech.Researchis the only way to mark a node, so it stays the single place where researching something can be paid for
internal doesn't say that. Any type in OpenPolytopia.Common can write branch.Nodes[0].Researched = true — and Nodes is public, so it doesn't even need to reach through SubTreeTech. What the accessor guarantees is that nothing outside the assembly can, which is a real narrowing and worth stating, just not this one. Since the stated goal is that researching is paid for exactly once, and the payment code doesn't exist yet, the wording will be load-bearing when it does.
Same shape as Tier's new remark would do it:
The tier and the researched state are set only inside this assembly, and <see cref="SubTreeTech.Research"/>
is the only place doing it, so researching something has a single place where it can be paid for
Worth checking there's no InternalsVisibleTo in play — there isn't one today, so the test project genuinely can't set it, which is what makes Research's bool return the only way the tests observe this.
Generated by Claude Code
| // required only checks that the json set the property, and a json null sets it just fine | ||
| ArgumentNullException.ThrowIfNull(data.Branches, "data.Branches"); | ||
|
|
||
| var branches = new Dictionary<BranchType, string[]>(data.Branches.Count); | ||
| foreach (var branch in data.Branches) { | ||
| // 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, "data.Branches[].Nodes"); |
There was a problem hiding this comment.
Two small things about the two new ThrowIfNull calls.
The branch.Nodes guard on line 64 has no test. TestNullBranches covers line 55, and TestNullNode reads like it covers this one but doesn't — it nulls an element:
data.Branches[0].Nodes[1] = null!;which is caught by the IsNullOrWhiteSpace check below, not by line 64. "nodes": null is a distinct shape and required List<string> accepts it for the same reason "branches": null gets through. It's also the one assertion in the pair that would notice if the guard were dropped, since TestNullNode passes with or without it — Should.Throw<ArgumentException> is satisfied by ArgumentNullException too, so neither of the two new tests actually pins line 64. A third one mirroring TestNullBranches closes it:
var data = JsonSerializer.Deserialize<TechTreeSerializedData>(
"""{"branches": [{"type": "climbing", "nodes": null}]}""", _techTreeOptions);The paramName arguments are hand-written where they don't need to be. ThrowIfNull takes [CallerArgumentExpression("argument")] string? paramName = null, so line 55 gets "data.Branches" from the compiler already and passing it is a no-op that can drift from the expression next to it. Line 64 passes "data.Branches[].Nodes", which isn't an expression the compiler would ever produce and isn't a parameter name either — a caller reading ParamName off the exception now sees three different values from one method ("data" from every ArgumentException, plus those two). Dropping both arguments gives "data.Branches" and "branch.Nodes", which is what ParamName is for.
Minor either way — the guards themselves are right, and putting the null check before data.Branches.Count is the correct order.
Generated by Claude Code
|
|
||
| public class TribeSerializedData { | ||
| public required TribeType TribeType { get; init; } | ||
| public required TribeType Type { get; init; } |
There was a problem hiding this comment.
This PR no longer merges, and the obvious resolution silently breaks tribe loading. mergeable_state is dirty: master moved to 3b3db3d (Terrain generation, #49), which touched EmbeddedResources.cs and resources/tribes.json — both of which this PR also rewrites.
The important part isn't the conflict, it's that #49 fixed the same two bugs this PR fixed, in the opposite direction. The PR description says:
TribeSerializedData.TribeTypedoesn't map to"type"underSnakeCaseLower. Both fixed (the property is nowType, likeTroopSerializedData)
master kept public required TribeType TribeType and renamed the JSON key instead:
"tribe_type": "imperius",So the two halves of the fix now live on opposite sides, in two different files, and only one of them conflicts. Resolving tribes.json in favour of master while this line stays Type leaves a required property with no matching key, and JsonSerializer throws on tribes.json at load — which is exactly the bug both PRs set out to fix, restored by the merge. Whichever convention wins, the pick has to be made in both files at once.
The second one is worse because git resolves it without a conflict marker. Each side moved starting_stars out of its wrong position by a different route — this PR pulled it up into tribe, #49 pushed it down — so the merged file has it twice:
"tribe": {
"starting_branch": "organization",
"starting_stars": 7,
"spawn_rate": { ... },
"terrain_rate": { ... },
"starting_stars": 7 // <- from the other side, no conflict raised
}Duplicate keys are legal JSON and System.Text.Json takes the last one silently, so nothing fails and nothing warns. It only shows up by reading the merged resource. (starting_tech does resolve cleanly to deleted, so that part is fine.)
Worth a look while you're in there: #49 added EmbeddedResources.LoadTroops()/LoadTribes() behind a shared _jsonOptions whose resolver is Combine(TribeGenerationContext.Default, TroopGenerationContext.Default). After the merge, TechTreeData is the one resource with no Load* counterpart and TechTreeGenerationContext is the one context not in that resolver — TestTechTree's hand-built options are currently the only thing that can read it. Not a conflict, just the seam the merge creates.
Generated by Claude Code
|
Round 3 answered in 2 — the merge. Resolved in favour of On the seam you spotted: 1 and 3 — the ids. Taken as one decision, but at both gates rather than one. 4 — 5 — the guards. Both 90 passed / 0 failed / 0 skipped locally, cspell clean. Generated by Claude Code |
Enn3Developer
left a comment
There was a problem hiding this comment.
Round 4, against 26bb8d8. Still no .NET SDK in this environment, so nothing here was compiled or run — everything below comes from reading the code, the resources and the merge. CI is green on all six checks, so mergeable_state: blocked is a required review, not a failure.
All five round-3 findings are closed, and two of them better than asked. The merge went to "type"/Type with starting_stars back to a single occurrence in #49's position, TechTreeGenerationContext is in the shared resolver and every test that reads the resource now goes through LoadTechTree() — the hand-written options survive only where they're actually needed, for the json literals. And you found the TerrainGenerationTest/StartingTech breakage the merge produced silently, which was the failure mode I described but not one I'd found.
Four findings, three of them one shape. The recurring pattern from rounds 1–3 is still here, but it has narrowed: it used to be an invariant fixed at one gate and left at the others, and now it's null fixed for the contents and left for the containers. 26bb8d8 guarded overrides' elements and skipped the two sibling lists that get deserialized the same way.
TechTree.cs55–66 —{"branches": [null]}is aNullReferenceException. Exactly the guardOverridejust got, in the loopOverride's guard was modelled on. Sits betweenTestNullNodeandTestNullBranchNodesin your own test list, with neither a check nor a test.Tribe.cs56–59 — same class twice:{"tribes": [null]}and"tribe": nullboth NRE out ofRegisterTribes, andRegisterTribe(type, null!)is public.TechTree.cs115–124 —OverridedocumentsArgumentNullExceptionand throws it only for elements;Override,FromSerializedData,SubTreeTechandTechTreeall dereference their argument unguarded. PlusReplacesgets no id check whereIdnow does, so a null one reportsnode isn't in the tree.TechTreeTest.cs280–289 —TestTribesResourcepins Imperius by name rather than checking the file. This test is the one place that owns both a definition and every tribe, so it's where the deferred "overrides aren't validated at load" finding can actually be closed, and the loop is the same length as the pin.
None of these is blocking. 1 and 2 are the only ones that produce a wrong exception type at runtime, and both need a resource that's currently hand-written and correct.
Checked and deliberately not filed. Override's duplicate guard against chained swaps, two overrides claiming one id, and the Id == Replaces no-op — all still correct, and the chain test covers the interesting one. Whitespace inside an id (" fishing" passes IsNullOrWhiteSpace and is a distinct id) — a typo class, not a broken invariant. ComputeCost's unchecked uint arithmetic — not reachable with plausible city counts. Re-deriving the override definition per player instead of per tribe — allocation only, and a tribe has at most a handful of players. TechTree's ctor re-validating what the definition already guarantees — cheap, and it's what makes SubTreeTech usable on its own.
One thing I couldn't verify without an SDK, so treat it as a question rather than a finding: [EnumMember(Value = "climbing")] on BranchType isn't read by JsonStringEnumConverter<T> on net8.0 as far as I know — the attribute STJ honours for this arrived later, and PropertyNamingPolicy doesn't touch enum values. If that's right, "climbing" parses because the read path matches member names case-insensitively and every value here happens to be its member name lowercased, which also means writing a BranchType emits "Climbing", not "climbing". It costs nothing today and the convention comes from TroopType/TribeType on master, not from this PR. It stops costing nothing the first time a branch needs two words — [EnumMember(Value = "free_diving")] FreeDiving would fail to parse. A one-line round trip test through _jsonOptions settles it either way, and the tree being data now makes branches the thing most likely to grow.
Two things I want to credit because they're the kind that only show up when something changes: FromSerializedData requiring every BranchType means adding a member to the enum without adding it to tech_tree.json fails loudly at load rather than producing a tree with a hole, and the id guards landing at all three gates means this[id], Research, HasResearched and ComputeCost are now null-safe for free, since no stored id can match a null.
Generated by Claude Code
| ArgumentNullException.ThrowIfNull(data.Branches); | ||
|
|
||
| var branches = new Dictionary<BranchType, string[]>(data.Branches.Count); | ||
| foreach (var branch in data.Branches) { | ||
| // 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)) { |
There was a problem hiding this comment.
A null branch in the json still gets an NullReferenceException, and this is the loop the new Override guard was copied from.
26bb8d8 added ArgumentNullException.ThrowIfNull(techOverride, ...) to Override because a list deserialized from json can hold a null element. data.Branches is the same kind of list and didn't get the same line:
{ "branches": [null] }ThrowIfNull(data.Branches) passes — the list is there — and then line 60 reads branch.Type off a null and the whole thing comes out as NullReferenceException instead of one of the ArgumentExceptions the doc block lists. required doesn't help here for the reason your own comment on line 54 gives, one level further out: nothing constructs a BranchSerializedData for a json null, so its required members are never looked at.
The gap is visible against your own tests. TestNullNode covers the null element of Nodes, TestNullBranchNodes covers the null list, and the null element of Branches — the case sitting between them — has neither a guard nor a test.
foreach (var branch in data.Branches) {
// a json null is an element of the list like any other, the same as an override
ArgumentNullException.ThrowIfNull(branch);
if (!Enum.IsDefined(branch.Type)) {Worth noting what already holds up: with every id now non-null and non-blank at all three gates, this[id], Research, HasResearched and ComputeCost are safe against a null id for free — node.Id == null can't match anything, so they answer "no such node" instead of finding one. That's the part of the round-3 hole that closed properly.
Generated by Claude Code
| public void RegisterTribes(TribesSerializedData tribes) { | ||
| foreach (var tribe in tribes.Tribes) { | ||
| RegisterTribe(tribe.TribeType, tribe.Tribe); | ||
| RegisterTribe(tribe.Type, tribe.Tribe); | ||
| } |
There was a problem hiding this comment.
Same class of hole on the tribe side, twice over.
RegisterTribes walks a list that comes straight out of json and dereferences both the element and one of its fields without checking either:
{ "tribes": [null] } // tribe.Type -> NullReferenceException
{ "tribes": [{ "type": "imperius", "tribe": null }] } // tribe.StartingBranch -> NullReferenceExceptionThe second one is the more likely of the two to actually happen, because tribe is the field a hand edit is most likely to blank out while restructuring, and it's exactly the shape the starting_stars misplacement had before this PR fixed it.
RegisterTribe is public too, so RegisterTribe(TribeType.Imperius, null!) reaches line 40 the same way. Since it's already the single place where a tribe gets validated, it's also the natural place for the check:
public void RegisterTribe(TribeType type, Tribe tribe) {
ArgumentNullException.ThrowIfNull(tribe);
// same as the branches of the tech tree, ...and one ArgumentNullException.ThrowIfNull(tribe) inside the foreach of RegisterTribes for the element.
While you're in here: Tribes.Add throws a bare ArgumentException on a tribe declared twice in the resource, with the framework's "An item with the same key has already been added." That's the same failure FromSerializedData reports as branch {x} is declared twice, and the doc block above only advertises the StartingBranch one. Pre-existing, but the tech tree side now sets the bar for what these messages look like.
Generated by Claude Code
| /// </code> | ||
| /// </example> | ||
| public TechTreeDefinition Override(IEnumerable<TechOverride> 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.Id)) { | ||
| throw new ArgumentException($"the override of {techOverride.Replaces} doesn't have an id", nameof(overrides)); |
There was a problem hiding this comment.
Override now documents ArgumentNullException but only throws it for the elements, never for the argument.
Override(null!) doesn't reach line 121 at all: ToDictionary runs fine, then foreach asks a null for its enumerator and you get NullReferenceException. The doc comment added on line 115 reads as though the null case is handled, and for an element it is — for the collection it isn't.
It's the same on every public entry point of the new types, none of which guards the argument it immediately dereferences:
| call | first dereference | what comes out |
|---|---|---|
Override(null!) |
foreach |
NullReferenceException |
FromSerializedData(null!) |
data.Branches |
NullReferenceException |
new SubTreeTech(null!) |
ids.Count |
NullReferenceException |
new TechTree(null!) |
definition[branch] |
NullReferenceException |
Nullable enable means every one of these needs a null! at the call site, so none of them is going to happen by accident from C#. But that argument applies just as well to techOverride — which also can't be null without null! from C#, and got a guard anyway because the json path can produce one. The types now check everything they're handed inside the argument and nothing about it, which is a strange line to draw, and it's four lines to make it consistent.
Smaller thing in the same block: Id gets a real id check on line 123 and Replaces gets none, so { "replaces": null, "id": "x" } falls through to line 126 and reports node isn't in the tree — correct outcome, message with a hole in it. Folding Replaces into the line 123 check gets both named properly.
Generated by Claude Code
| public void TestTribesResource() { | ||
| var tribes = EmbeddedResources.LoadTribes(); | ||
| tribes.ShouldNotBeNull(); | ||
| var tribeManager = new TribeManager(); | ||
| tribeManager.RegisterTribes(tribes); | ||
| var imperius = tribeManager[TribeType.Imperius]; | ||
| imperius.ShouldNotBeNull(); | ||
| imperius.StartingBranch.ShouldBe(BranchType.Organization); | ||
| _definition.CreateTechTree(imperius)[BranchType.Organization].HasResearched("organization").ShouldBeTrue(); | ||
| } |
There was a problem hiding this comment.
This test names the one tribe in the resource instead of checking the resource, which leaves the one deferred finding deferred for no reason.
The round-1 answer for "the tribe isn't validated at load" was that validating overrides needs the definition and nothing owns both yet, so the load order should be decided by whatever wires it. That's fair for TribeManager — but this test owns both, right here: _definition on one side, every tribe of tribes.json on the other. It just doesn't use them that way. Today it asserts Imperius by hand, and Imperius is the only tribe and has no overrides, so the assertion is about a tribe rather than about the file.
The consequence is the whole point of the feature. The first tribe that ships a tech_overrides block gets no check on it at all: a replaces that names a node nobody has (a typo, or a node another PR renamed) throws ArgumentException out of CreateTechTree, at whatever point a game decides to build that player's tree. Nothing before that says a word, and it's a resource-only edit so nothing in review has to touch C# either.
Looping is the same length as pinning:
[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 that names 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");
}
}That keeps what the current test proves — the resource deserializes, registers and starts researched — and makes it hold for whatever gets added next, which is the part that has to survive without someone remembering this test exists. Keep the Imperius assertions too if you want the concrete values pinned; they're cheap and they document the file.
Generated by Claude Code
|
Round 4 answered in 1 and 2 — the null containers. Took the 3 — the arguments. 4 — The enum question — confirmed, all of it. Built your case on net8.0 with So I didn't fix it here because the fix isn't confined to this PR: it's registering the converter in the options rather than on the enum, and Converters = { new JsonStringEnumConverter<BranchType>(JsonNamingPolicy.SnakeCaseLower) }which gives 97 passed / 0 failed / 0 skipped locally, cspell clean. Generated by Claude Code |
Tribes can now replace nodes of the default tech tree with their own, no wiring to stars/cities/effects yet.
The default tree is data now
The five branches were hardcoded in
TechTree, so there was nothing to patch. They now live inresources/tech_tree.json(embedded liketroops.jsonandtribes.json) and get loaded into aTechTreeDefinition, which only holds the shape of the tree: which node is in which branch and in which slot. The researched state stays in theTechTreebuilt from it, so the definition is shared by every player and is immutable.FromSerializedDatarefuses a tree where a branch is missing, declared twice, doesn't have exactly 5 nodes or where the same node id shows up more than once.Overrides
A tribe declares what it replaces:
The replaced node is found by its id because every id is unique in the tree, so the branch and the slot don't need to be written down and can't contradict each other. The new node takes the slot of the old one, which means it also takes its tier and its cost.
Overrides only swap in place, they never add or remove nodes: the cost of a node comes from its slot, so a branch with 6 nodes would have a node without a tier.
SubTreeTechnow throws if it doesn't get exactlyMAX_NODESnodes, so a broken tree fails at load instead of silently miscosting techs.Overridereturns a new definition,CreateTechTree(tribe)applies the overrides of the tribe and researches its starting node.Starting tech
Tribe.StartingTechwas a{branch, id}pair written by hand: as soon as a tribe overrode its tier 0 node, that id had to change with it or the tribe would silently start with nothing researched. It's now justStartingBranch, the starting node is the tier 0 node of that branch resolved after the overrides, so the two can't drift apart.Other things in here
ComputeCostreads the tier off the node instead of scanning for the index; the formula is now(cities * (tier + 1)) + 4, same numbers as beforeResearchreturns whether the node was found, so a typo isn't silent anymoreTechTree[branch]andTechTreeDefinition[branch]still throw on an invalidBranchTypeTwo bugs found on the way
tribes.jsondidn't deserialize at all:starting_starswas a sibling oftribeinstead of a field of it, andTribeSerializedData.TribeTypedoesn't map to"type"underSnakeCaseLower. Both fixed (the property is nowType, likeTroopSerializedData), and there's a test loading the resource so it can't rot again.TechOverridescan't default to an empty list: the source generated deserializer skips property initializers on types with required properties, so it came back null. It's nullable now.Tests
18 tests in
TechTreeTest, covering the tree loading and its validation, costs and tiers, research, overrides (in place, chained, unknown node, duplicated id, definition untouched), the starting branch with and without an override, andtribes.jsonloading. Full suite is green: 62 passed, 0 failed.Generated by Claude Code