Skip to content

feat: add the Species root type (HXML-37) - #13

Merged
baughj merged 6 commits into
mainfrom
feature/species
Oct 29, 2026
Merged

baughj merged 6 commits into
mainfrom
feature/species

Conversation

@Caeldeth

@Caeldeth Caeldeth commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This PR adds Species as a root type (HXML-37, step 1 of HS-1520). World data defines the species, as it defines the elements (HXML-24). No enum lists them, so a new species is a content change, not a package release. The server, content and client steps follow in HS-1520.

<Species Key="Dwarf" Id="4" Origin="Aosda" PlayerOption="Starter" Affinity="Wind" Resistance="Wind">
  <Names Singular="Dwarf" Plural="Dwarves" Adjective="Dwarven" />
  <Description>Storm-and-stone people descended from the Gleodhar.</Description>
  <Bodies>
    <Body Key="Ruddy" Id="40" Name="Ruddy" Default="true" />
    <Body Key="Ashen" Id="41" />
  </Bodies>
</Species>

Schema

  • Key is the key that saves and content use, so it never changes. Id is the wire value (unsignedByte).
  • Origin is Aosda, Tuatha or Fanaidhe (lore data).
  • PlayerOption is Locked, Starter, Unlockable or ByBranch. A ByBranch species is a parent: a player picks one of its branches, not the parent.
  • BranchOf names the parent species. Affinity and Resistance are element keys, and a branch without them takes its parent's values. A branch takes only these two from its parent: its names, bodies, origin and player option are its own.
  • Names has the singular, plural and adjective forms.
  • Each body is one pre-coloured sprite set. Key is unique in the species, and saves store it. Id (unsignedShort) is the wire value, and it is unique across all species, so the client body pack maps one number to one sheet for both genders. Name is optional, and exactly one body is the Default. RetailSkinColor (0 to 9, default 0) is the body byte for a retail client.
  • Species keys and body keys have the same form as element keys ([A-Za-z][A-Za-z0-9]*): SpeciesKey derives from ElementKey.
  • xs:unique rules reject a body key or a body id that occurs twice in one species.

Model

  • Species loads on start from the species directory. "species" is an invariant plural, so WorldDataDirectory.NameFor returns it as it is (it gave "specieses").
  • Two new enums, SpeciesOrigin and SpeciesPlayerOption, are pinned in tools/enum-values.json. Locked is 0. The loader does not check files against the schema (HXML-35), so a file without PlayerOption gets the value 0, and that species must not be playable.
  • Helpers: Species.TryGet finds a loaded species by key, and TryGetBody finds a body. Both ignore case. The store compares primary keys with case, so the server uses TryGet, not the store key. The other helpers are DefaultBody, GetParent, and GetAffinity and GetResistance, which give a branch its parent's values.
  • ValidateAll runs at load and reports a species id that two species use, a body id that two species use, two species keys that differ only in case, and a ByBranch species that no species is a branch of. The case check is a stopgap: the two keys share one lower-case index key, and HXML-34 moves this check into the store for every type.
  • Validate reports, for each species: a species key or body key that is empty or has a space at the start or the end (the loader does not trim keys, so it rejects them instead), no names, a name form that is missing, empty, blank or padded, a Starter or Unlockable species with no bodies, not exactly one default body, a body key or body id twice, a RetailSkinColor above 9, a BranchOf that names no loaded species, a parent that is a branch or is not ByBranch, a branch that is ByBranch, and a species that is not a branch and has no Affinity or Resistance.
  • Validate(ElementTable) checks Affinity and Resistance against a table. The package cannot know the active element table, so the server calls it with that table.
  • The Duplicates helper moves from ElementTable into EnumerableExtension, and Species uses it too.

Generated code

tools/regenerate.sh ran on Windows, and the output was normalized to match a Linux run (HXML-26). The header of each generated file lists the schema files, so a new schema changes one header line in every existing generated file (255 files). No other line in an existing generated file changes.

Tests

266 pass (50 new). The species tests cover the schema (a full species, a parent without bodies, a branch without elements, and nine invalid cases), deserialization and the helpers (TryGet without case), a branch that takes its parent's elements, ValidateAll and Validate (fifteen single-problem cases, and the cross-species rules, including a ByBranch species without branches), Validate(ElementTable), padded keys, a species without PlayerOption, a species with Bodies set to null, a branch without a loaded parent, and a load from a species directory. A new SchemaValidation helper holds the schema set for the tests, and the element table and trait tests use it.

Not in this PR

  • The package version stays 1.0.0-alpha2. The version is the maintainers' choice.
  • A repeated species key across two files: the store keeps one file and reports nothing. This is true for every type, and HXML-38 fixes it in the loader.
  • A missing species Id or body Id loads as 0, because the loader does not check files against the schema. The uniqueness checks catch it only when another species or body also uses 0. HXML-35 fixes it.
  • A Build attribute on bodies (BRIG-118). A later minor release adds it with default A.
  • Species that grant traits (HS-1517). That follows the trait subsystem.
  • The server, the starter six in world, the Brigid registry message and body pack, Creidhne and hybindex-ts (HS-1520).

World data defines the species. Each species has a key that saves and
content use, a wire id, its name forms, its origin class, a player
option, an optional parent (BranchOf), its element affinity and
resistance, and its bodies. Each body has a key, a wire id that is
unique across all species, an optional display name, a default flag
and a retail skin colour (0 to 9).

- Species.xsd, with xs:unique rules for body keys and body ids in a
  species. Pin the two new enums, SpeciesOrigin and
  SpeciesPlayerOption, in tools/enum-values.json.
- Species loads on start from the species directory. "species" is an
  invariant plural, so WorldDataDirectory.NameFor returns it as it is.
- Helpers: DefaultBody, TryGetBody, GetParent, and GetAffinity and
  GetResistance, which give a branch its parent's values.
- ValidateAll checks unique species ids and body ids across all
  species, and Validate checks each species: bodies for a playable
  species, one default body, body keys and ids, and the branch rules.
- Validate(ElementTable) checks Affinity and Resistance against a
  table. The server calls it with the active table.
- A shared SchemaValidation helper for the tests. The element table
  tests use it.
- Regenerate src/Objects. Each generated header lists the schema
  files, so the header of every generated file changes.

Refs: HXML-37, HS-1520
- Pin SpeciesPlayerOption.Locked to 0. The loader does not check files
  against the schema (HXML-35), so a species without PlayerOption gets
  the value 0. With Starter at 0, players could pick it.
- ValidateAll reports species keys that differ only in case. They share
  one lower-case index key.
- Validate reports a species without Names, and a branch that is
  ByBranch.
- Use ToLowerInvariant for the index key.
- Validate(ElementTable) rejects a null table.
- SpeciesKey derives from ElementKey, so the two key forms stay the
  same.
- Move the Duplicates helper of ElementTable into EnumerableExtension,
  and use it in Species.

Refs: HXML-37
- ValidateAll checks the three rules across species (keys that
  differ only in case, species ids, body ids) with one local helper.
  The case check is marked as a stopgap until HXML-34.
- TryGetBody is a loop, as ElementTable.TryGetElement is.
- Validate counts the default bodies once.
- ElementTable calls Duplicates with its default comparer.
- Tests: Starter and Playable constants, Kobold and Vosk built with
  Make, and a comment on each theory row.

Refs: HXML-37
Validate rejects a species key or a body key that is empty, blank, or
has a space at the start or the end. Keys are saved and the loader
does not trim them, so a padded key is a different key. Validate
rejects it instead of trimming it. Found in the review of the Trait
type (xml PR 12), which has the same check for names.

Refs: HXML-37
- Species.TryGet finds a loaded species by key without case, as
  TryGetBody does for bodies. The store compares primary keys with
  case, so the server uses TryGet, not the store key. GetParent uses
  it.
- The BranchOf comment and the GetParent summary say that a branch
  takes only Affinity and Resistance from its parent.
- The trait tests use the shared SchemaValidation helper.

Refs: HXML-37
@codecov

codecov Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.97%. Comparing base (1caac5c) to head (edc836e).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #13      +/-   ##
==========================================
+ Coverage   69.27%   70.97%   +1.69%     
==========================================
  Files          44       45       +1     
  Lines        1660     1757      +97     
  Branches      396      432      +36     
==========================================
+ Hits         1150     1247      +97     
  Misses        402      402              
  Partials      108      108              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@imbas-forosnai imbas-forosnai left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review from Imbas (an AI collaborator working with @baughj), posting from its own account. This was my own pass only, with no Codex round. It's a comment review, not an approval: @baughj is the requested reviewer.

Nothing blocking. The schema, the model and the validation match HXML-37 and the registry plan, and each stopgap names its ticket. There's one low and two notes.

Low

Validate checks that Names exists, but not the forms inside it. A species with <Names Singular="Gnoll" /> deserializes with Plural and Adjective null, and Validate returns no errors. Singular="" and Plural=" " also pass. The loader doesn't apply the schema (HXML-35), and the plan sends the name forms to Brigid in the registry message, so a missing form reaches the client as null. #12 settled the same question for trait names by rejecting empty, blank and padded values. For display text, the part that matters is rejecting an empty or blank form. Padding matters less here, because these aren't keys.

Notes

  • A ByBranch parent that no species branches from validates clean. Nobody can pick it, so it's harmless, but it's probably a content mistake: if a branch has a typo in BranchOf, the branch gets an error and its intended parent says nothing. ValidateAll already has every species in hand if you want to report it. Your call.
  • A species file with no Key attribute fails to load with Cannot perform runtime binding on a null reference, because XmlStore calls key.ToString() on a null primary key. So Validate's "Key is empty" message is reachable only for "" or whitespace. Every type with a nullable PrimaryKey behaves this way, so it isn't this PR's to fix. It's one more required attribute for HXML-35, and today it has an opaque error.

Verified

  • 259/259 tests pass against ceridwen main (6052cae).
  • tools/regenerate.sh on Linux reproduces src/Objects/ byte for byte. The 255 one-line files change only the xscgen header line, which gains Species.xsd.
  • I ran the Names cases above and the missing Key on a copy of the PR head; they weren't only read from source. A branch whose BranchOf names itself gets one error, and GetAffinity returns null without recursing.
  • The package version is the maintainers' call. The plan calls this step a minor release.

Validate checked that Names exists, but not the forms in it. A species
with no Plural, or with Singular="", gave no error, and the name forms
go to the client. The loader does not check files against the schema
(HXML-35), so the schema does not catch them. Validate now rejects a
name form that is missing, empty, blank, or has a space at the start
or the end, as it does for keys.

ValidateAll now reports a ByBranch species that no species is a branch
of. Nobody can pick such a species, and the usual cause is a typo in a
branch's BranchOf.

New tests also cover a species with Bodies set to null, a branch
without a loaded parent, and a species with its own elements.
@Caeldeth

Copy link
Copy Markdown
Contributor Author

Thank you, @imbas-forosnai. All three points are addressed in edc836e. 266 tests pass.

Low: the name forms

Validate now checks each form in Names. It rejects a form that is missing, empty or blank, and also a form with a space at the start or the end. Padding matters less for display text than for keys, as you say, but the check is the same one that keys use, and a padded form is a content mistake in a client string too. The four cases (missing Adjective, Singular="", Plural=" ", padded Adjective) are new rows in ValidateRejectsOneProblem.

Note: a ByBranch parent without branches

ValidateAll now reports it, on the parent: "The species is ByBranch, but no species is a branch of it." So a typo in BranchOf gives an error on the branch and on the intended parent. BranchOf compares without case, as everywhere else, and a test covers that.

Note: a missing Key

Agreed, this is not for this PR. I added it to HXML-35, with the XmlStore line that throws and a smaller fallback (a null check with a clear message) in case HXML-35 waits.

Coverage

The Codecov comment showed six partial lines in Species.cs. New tests cover Bodies set to null in code, a branch without a loaded parent, a species with its own elements, and TryGetBody with an empty key. No changed line is partial now.

@baughj
baughj merged commit 77432ae into main Oct 29, 2026
3 checks passed

This branch was successfully deployed

1 active deployment
deploy — edc836e1 Deployed Oct 26, 2026 by Caeldeth via build (10.0.x) #134
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants