Repository navigation
feat: add the Trait root type (HXML-28) - #12
Conversation
A trait is a passive property of a character. This commit adds the schema, the generated model and the loader. The server subsystem and the content come later. - Trait.xsd: Name (the key), Icon (unsignedShort), IconSet (Skill or Spell) and Category attributes. Optional Description, ExclusiveGroup, ExclusivePolicy (Reject or Replace, default Reject), ConflictsWith and StatModifiers elements. - A trait has no intents, book, cost, cooldown or targeting. The schema rejects these fields. - Pin the two new enums, ExclusivePolicy and IconSet, in tools/enum-values.json. - Trait loads on start from the traits directory, with Name as its primary key. - Regenerate src/Objects. Each generated header lists the schema files, so the header of every generated file changes. Refs: HXML-28, HS-1517
- Remove the schema default of ExclusivePolicy. With the default, an empty <ExclusivePolicy /> passed the schema and then failed to load. An absent element still gives Reject, because Reject is pinned to 0. - Add an xs:unique rule, so ConflictsWith names each trait once. - Add the lower-case name as a secondary key, as Status does. - Tests: split the theories that mixed valid and invalid rows, and remove the repeated icon attributes. Refs: HXML-28
ExclusivePolicy was a separate element, so a policy without a group passed the schema and did nothing. The policy is now the Policy attribute of ExclusiveGroup: <ExclusiveGroup Policy="Replace">Background</ExclusiveGroup> The default is Reject. An attribute default is safe, because an empty Policy="" fails the schema. Refs: HXML-28
Trait implements IAdditionalValidation. The manager runs Validate on each trait at load, and the result goes into the validation summary. Validate reports: - a ConflictsWith name that is not a loaded trait - a ConflictsWith name that is the trait itself - an ExclusiveGroup with no name. The schema forbids it, but the loader does not check files against the schema (HXML-35). Refs: HXML-28
ceridwen merged its numbered variant groups into one group each, so enchant1 is now enchant. VariantExists failed on main after that change.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #12 +/- ##
==========================================
+ Coverage 68.03% 69.27% +1.23%
==========================================
Files 43 44 +1
Lines 1627 1660 +33
Branches 385 396 +11
==========================================
+ Hits 1107 1150 +43
+ Misses 412 402 -10
Partials 108 108 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
imbas-forosnai
left a comment
There was a problem hiding this comment.
Review from Imbas (J's AI collaborator, posting from its own account), with an independent cold review by Codex. Each finding says who found it. This is a comment review, not an approval; @baughj is the requested reviewer.
Before merge
1. ConflictsWith has no policy (P2: a contract gap this PR introduces; resolve before merge). Imbas and Codex. Codex raised it to blocking.
The plan says "explicit conflicts and exclusive groups support Replace or Reject policies" (HS-1517-traits-plan.md, Add steps 3–4). Decision 4 moved Policy onto ExclusiveGroup. As a result, a trait with ConflictsWith and no group can't say whether it rejects or replaces. A trait that has both doesn't say whether the group's policy also covers its conflicts. Slice 2 can't implement the plan's choice from the XML. It would have to assume Reject without saying so, or borrow a group policy that is about something else. Either of these would close it:
- Explicit conflicts always Reject. Say so in the plan and in the XSD comment.
- Add
PolicytoConflictsWith, defaulting toReject. Codex prefers this if Replace is a real requirement. It keeps the two policies independent and still can't produce an orphan policy.
Correctness in this slice
2. Name isn't checked for a blank or padded value (P2). Codex; Imbas ran it.
Name is the persisted key, but Validate never looks at it, and the loader doesn't apply the schema (HXML-35). Three cases, each loaded from a traits/ directory:
Name="": 0 load errors, 0 validation errors, and stored under the key"".Name=" ": schema-valid, becauseString8isxs:stringwithminLength 1, and it validates clean.Name=" Dockworker ": validates clean.TryGetValue("Dockworker")misses;TryGetValue(" Dockworker ")hits.
Suggest Validate reject a blank name and leading or trailing whitespace. Codex's view, which I share, is to reject rather than trim, because changing a persisted identity should be an explicit authoring decision.
3. <ExclusiveGroup> Background </ExclusiveGroup> is a separate group (P3). Codex found it; Imbas narrowed the trigger.
The text is kept as " Background ", validates clean, and doesn't match Background. A group that is only whitespace is already caught: XmlSerializer reads it as null, so the existing "no name" check fires. The same leading/trailing check as in 2 would close it.
4. A repeated ConflictsWith name isn't caught on the load path (P3). Codex.
The xs:unique rule is right, but only the schema tests use it. <Trait>Scribe</Trait><Trait>Scribe</Trait> loads and validates with 0 errors. The only cost is repeated work, so it isn't urgent.
Contract questions for slice 2
None of these is a defect in this slice; each needs a sentence of contract.
- Category and
FindByCategory. Codex.Traitdoesn't implementICategorizable, soGetStore<Trait>().FindByCategory("Background")comes back empty.Status,CastableandItemare in the category index. If "for display and grouping" means consumers groupValues<Trait>()themselves, that's fine. A sentence saying so would stop slice 2 from expecting the index. - Mixed policies in one group. Imbas, with Codex's correction. Two traits in the same group can declare different policies. The XSD comment makes the incoming trait's policy decide, which is deterministic and can be useful (held A/Reject plus incoming B/Replace means B replaces A; the reverse is rejected). I first proposed checking that they agree. Codex argued against it, and I agree: that would invent a group entity that doesn't exist. I'd suggest documenting "incoming wins" and having slice 2 test both directions.
- Conflicts in both directions. Imbas, with Codex's correction. If A lists B and B doesn't list A, a check of only the incoming trait's list lets B onto an A-holder. "Cannot coexist" is symmetric, so slice 2 should check both lists. Requiring both traits to declare the conflict would just create a second copy that can drift.
Existing behaviour, not this PR's
- Case-only name collisions. Codex.
Scribeandscribeboth write the lower-case keyscribe, and the last one stored wins: in the test, adding them in one order gavescribe, in the other orderScribe. Under parallel load, which one survives is a race.Statushas the same pattern. I'll add the test to HXML-34. - Exact duplicate names. Imbas. Two files with the same
Nameoverwrite each other silently inXmlStore.StoreItem, and the parallel load makes the survivor a race. Every root type has this. - Whitespace in
xs:tokenenum values. Codex found it; Imbas ran it.IconSet=" Skill "andPolicy=" Replace "pass the XSD, butXmlSerializerthrows "not a valid value". The failure is loud (a load error), and the same mismatch applies to the 37 otherxs:tokenrestrictions in the XSDs.
Verified
- 211/211 tests pass against ceridwen
main(6052cae). tools/regenerate.shon Linux reproducessrc/Objects/byte for byte, so HXML-26's normalization holds.- The 250 one-line files change only the
xscgenheader line. - The
enchant1→enchantfix matches ceridwen's mergedvar_enchant.xml.
Every finding above was demonstrated by running it on a copy of the PR head, not only read from source.
- Validate rejects a Name that is empty, blank, or has a space at the start or the end, and the same for an ExclusiveGroup name. Names are keys and the loader does not trim them, so a padded name is a different key. Validate rejects it instead of trimming it. - Validate reports a ConflictsWith name that occurs twice. The schema rejects it, but the loader does not check files (HXML-35). - The schema comments state the contract for slice 2: a conflict always rejects the incoming trait, and it applies in both directions; the policy of the incoming trait decides in a group; Category is for display only and is not indexed. Refs: HXML-28
|
Thank you, @imbas-forosnai, and Codex. Every point is addressed in 1.
|
Summary
This PR adds
Traitas a root type (HXML-28, slice 1 of HS-1517). A trait is a passive property of a character, such as a background or a species passive. This slice adds the schema, the generated model and the loader. The server subsystem and the content are later slices.This PR also fixes
XmlManagerTests.VariantExists, which fails onmain. ceridwen merged its numbered variant groups, soenchant1is nowenchant. The fix is its own commit.Schema
Nameis the key (PrimaryKey => Name), as for statuses. Characters save traits by name, so a renamed trait needs a migration.Iconis a number (xs:unsignedShort), andIconSet(SkillorSpell, required) tells which icon sheet the number is in. Traits use the castable icon sheets. A castable's book selects its sheet, but a trait has no book, so it names the sheet.Categoryis for display and grouping.ExclusiveGroupis a mechanical rule: a character has at most one trait from each group. They can have the same value, but neither sets the other.Categoryis for display only: the store does not index traits by category, so a consumer filters the loaded traits.Policyattribute ofExclusiveGroupisReject(the new trait is not added) orReplace(the new trait removes the one in the group). The default isReject, so a missing policy never removes a trait that a character has. The policy is on the group, so a policy without a group cannot exist. The incoming trait's policy decides, so two traits in one group can have different policies.ConflictsWithnames other traits. A conflict always rejects the incoming trait, and it applies in both directions, so content lists it on one of the two traits. A trait that replaces another uses anExclusiveGroupwithPolicy="Replace". Anxs:uniquerule makes sure thatConflictsWithnames each trait once.StatModifiersis the existing type.Model
TraitimplementsILoadOnStart<Trait>and loads from thetraitsdirectory of the world data. The lower-case name is a secondary key, as for statuses.ExclusivePolicyandIconSet, are pinned intools/enum-values.json.TraitimplementsIAdditionalValidation. The manager runsValidateon each trait at load, and the result goes into the validation summary.Validatereports aConflictsWithname that is not a loaded trait or that occurs twice, a trait that names itself, anExclusiveGroupwith no name, and aNameor group name that is empty or has a space at the start or the end. Names are keys and the loader does not trim them, soValidaterejects a padded name instead of trimming it. The schema forbids several of these, but the loader does not check files against the schema (HXML-35).Generated code
tools/regenerate.shran 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 (250 files). No other line in an existing generated file changes.Tests
216 pass (32 new). The trait tests cover the schema (a full trait, a trait with no optional blocks, the required icon attributes, the icon range, the
Policyvalues, an emptyPolicy, a group with no name, a repeatedConflictsWithname, and the rejected castable fields), deserialization and its defaults (a group without a policy givesReject), and a load from atraitsdirectory that also has a malformed file, with a lookup by the lower-case name.ValidateAllandValidatetests cover a correct catalog, each catalog error, padded names, and a conflict named twice.Not in this PR
1.0.0-alpha2. The version is the maintainers' choice.IconSetloads asSkill(HXML-35).