Skip to content

Latest commit

 

History

History
91 lines (82 loc) · 56.8 KB

RELEASE_NOTES.md

File metadata and controls

91 lines (82 loc) · 56.8 KB

Release Candidate 1 (v0.6.0-rc1)

This release marks a significant milestone for the OpenCombatEngine, introducing advanced AI behaviors, loot generation, and full Open5e content integration.

Key Features

🧠 Advanced AI System

  • Tier 1 (Zombie): Basic "Move & Attack" logic for simple creatures.
  • Tier 2 (Tactical): Smart targeting (focus on weak/low HP enemies) and self-preservation logic.
  • Tier 3 (Role-Based): Specialized behaviors like "Artillery" (Kiting/Ranged superiority).

⚔️ Item Library & Loot

  • Standard Item Library: Integration with Open5e to fetch Weapons, Armor, and Magic Items.
  • Procedural Loot Generation: Generate CR-appropriate treasure bundles including Gold/Silver/Copper and Items based on rarity probabilities.
  • Unified Interfaces: IItem, IWeapon, IArmor standardized across the engine.
  • New: five stateless RPCs — EquipItem, UnequipItem, AddItemToInventory, RemoveItemFromInventory, TransferItem — give a downstream consumer its first real way to change a character's equipment/inventory after initial creation; previously IEquipmentManager/IInventory were real and tested in isolation, but nothing ever exposed them over gRPC, so nothing outside the engine itself could equip a found weapon, don armor, pick up loot, discard something, or hand an item to another character. EquipItem closes a real gap in StandardEquipmentManager.Equip itself (documented, ADR 0016): the manager doesn't verify the item is actually carried before equipping it, so the handler does that check before ever calling it. TransferItem explicitly ends attunement on the giving side first when the item being transferred is currently attuned (SRD: giving away an attuned item ends attunement) — StandardInventory.RemoveItem's own existing auto-unequip-on-removal does not also do this. A shield equips into the same domain OffHand slot as an off-hand weapon (this engine has no separate Shield slot internally — StandardEquipmentManager.EquipOffHandInternal decides .Shield vs. .OffHand from the item's own type); the proto's EQUIPMENT_SLOT_SHIELD value exists purely so a caller can say "shield" rather than "off_hand". No quantity/stacking support — each item is a distinct inventory entry, matching this engine's existing model; "the party finds 5 torches" is 5 AddItemToInventory calls. Combat-loot distribution, death/inheritance handling, off-site possessions (mounts, stashes), and a buy/sell/vendor economy are real, deliberately separate follow-ons this closes the door to but does not itself build — ILootGenerator/StandardLootGenerator above remain otherwise unconnected to any distribution logic, and IItem.Value still has no reader anywhere.
  • New: ILootGenerator/StandardLootGenerator finally get connected to something — a GenerateLoot RPC exposes them for the first time, and combining multiple creatures' challenge ratings into one group-appropriate roll (an evil high priest, three acolytes, and ten guards should net more total treasure than the priest alone, not be capped at his CR in isolation) is a brand-new IEncounterChallengeCalculator/StandardEncounterChallengeCalculator: the standard CR-to-XP conversion plus the standard monster-count XP multiplier, mapped back down to the single highest CR the resulting budget affords, which is what feeds GenerateLoot's own existing, untouched tier logic. Deliberately lives on this side of the gRPC boundary rather than in a downstream consumer — this is game-specific encounter math, and a non-D&D system engine would have no equivalent concept at all. GenerateLoot takes repeated Actor participants, not a raw CR int, so a caller never computes or invents this number itself; every participant must resolve to a real creature carrying a real challenge_rating (a new field on CreatureState/ICreature, nullable rather than defaulting to 0 since 0 is itself a real, valid SRD CR — collapsing "never assigned one" into 0 would silently make an un-authored creature loot-eligible) or the whole call is rejected.
  • New: real currency. IInventory gains Copper/Silver/Gold/Platinum plus AddCurrency/RemoveCurrency (mirroring LootBundle's own four denominations), and InventoryState round-trips them the same additive, backward-compatible way every other optional CreatureState field already does. Two new RPCs land it on characters: AddCurrency (creation from nothing — a treasure placement) and TransferCurrency (moving currency from one creature's inventory to another's, mirroring TransferItem's exact remove-then-add-with-real-rejection shape; fails if a requested denomination exceeds what's actually carried, and deliberately does not make change across denominations).
  • New: IItem.Value finally gets a reader — line 16 above already flagged it as unused, and a downstream buy/sell/vendor economy pass needed real price data. Two new stateless RPCs: GetItemInfo(item_name) returns the real item library's price for a real item (an unrecognized name is a real rejection, same as AddItemToInventory), and ListInventory(actor) returns the real item names an actor currently holds (needed because character_data is opaque to the downstream Master consumer by design — it has no way to interpret the wire-format inventory shape itself). Fixed alongside it, a genuine bug: Open5eItemMapper.ParseCost previously mapped Value to whole gold pieces via integer truncation (its own comment read // Convert to cp? Or keep as gold value? ... Let's assume Gold for now.), so anything cheaper than 1 gp silently became 0 — a torch at "1 cp" priced as free. Since nothing read Value before this pass, this was invisible until now; Value is redefined as copper pieces (the finest SRD denomination, so every real price survives losslessly) and ParseCost fixed to match, with IItem.Value gaining the XML doc comment it never had explaining the unit. GetItemInfo's response decomposes the price into the fewest coins (platinum first) so a caller can hand the result straight to TransferCurrency.
  • New: RemoveCurrency — AddCurrency's inverse, and the single-actor equivalent of TransferCurrency's own remove-half. A downstream Layforge campaign-pack pass needed to debit an actor's currency into a location-scoped stash that isn't itself a creature, and TransferCurrency requires two real Actors on both ends — it can't express that. Same real rejection as TransferCurrency's remove-half when a requested denomination exceeds what's actually carried.
  • New: item carry location, derived rather than stored — a downstream (Layforge) narration-continuity bug (an item narrated as "tucked into a pack," then later "clinking at his hip," with nothing anywhere tracking which was actually true) turned out to need a real mechanical fix, not just a prompt fix: retrieving something properly packed away now costs real time. Three new stateless RPCs — StowItem, DrawItem, ListCarriedItems — sit alongside EquipItem/TransferItem/ListInventory. ICreature.GetCarriedItemLocations() is the one new traversal all three (and nothing else) read from — a location is never a stored field, it's computed on the spot from what's already structurally real: equipped-slot, nested inside another item's Contents (already-existing, previously-unused-outside-CombatSerializer machinery — see the Persistence entry below), or neither ("quick access"). IActionEconomy gains a fourth resource, HasFreeObjectInteraction (an internal stacking counter, not just a bool, so a future "this becomes a free action" feat has a real GrantFreeObjectInteraction() to call from IFeature.OnStartTurn without this needing to change again) — drawing an already-quick-access item spends it, once per turn, no fallback to the Action if already spent; drawing something actually stowed, or stowing something away properly, always costs the Action instead. EquipmentSlot gains Back (a worn pack/quiver had no slot to hang off of before this). Fixed alongside it, a real pre-existing gate gap: StandardEquipmentManager.Equip silently swapped out whatever was already in MainHand/OffHand — "hands full" was never actually enforced anywhere, only ever something a prompt could ask a caller to respect. Equip into an already-occupied hand slot (by a different item — re-confirming the same item already there is unaffected) now rejects with a real reason (unequip it, free; or stow it, costing the Action) rather than swapping; scoped to just the two hand slots, every other slot keeps its original silent-swap ergonomics unchanged. A known gap this same release flagged — looting a corpse's stowed items wasn't possible through any existing RPC — is closed below.
  • Fixed: TransferItem used to search only the flat inventory list (Inventory.GetItem), so an item stowed inside a container — looting a corpse's own backpack, exactly the gap this file flagged two entries up — could never be found or moved at all, only something already equipped or quick-access. Now looked up via the same GetCarriedItemLocations() traversal StowItem/DrawItem/ListCarriedItems already share, so it finds an item at any nesting depth and removes it from its actual parent container (rather than the flat list RemoveItem alone can see). It always lands on the recipient's side as quick-access, never re-nested inside one of the recipient's own containers — the same "surfaces to quick-access" rule DrawItem already documents. Transferring a container itself (not something inside it) carries its own nested contents along intact, since they live inside that same object rather than a separate structure this had to reconstruct.

🌐 Open5e Integration

  • Direct API Access: Fetch Spells, Monsters, and Items directly from api.open5e.com.
  • Seamless Mapping: Automatic conversion of Open5e JSON data to Engine DTOs.
  • New: Open5eItemCache, mirroring Open5eSpellCache exactly, for the same real, live-observed reason — this file's own earlier entries said the item catalog was "small enough" to not need a cache; that turned out to be optimistic, since a later sidecar startup hit the 15-second HttpClient timeout fetching the full, paginated weapons+armor+magic-items catalog even with Open5e itself reachable. Open5eContentSource gained GetAllWeaponDtosAsync/GetAllArmorDtosAsync/GetAllMagicItemDtosAsync (splitting fetch from mapping, same as GetAllSpellDtosAsync already did for spells) so the cache can persist the raw DTOs, and StandardItemLibrary.InitializeFromDtos maps and loads them without ever touching the network — the sidecar's own cache-first/stale-fallback startup logic now mirrors the spell repository's exactly.
  • Fixed: building that cache's own round-trip test surfaced a second, deeper instance of the exact bug class this repo already fixed once for Open5eListResult<T>.Results ("a get-only collection property is not reliably populated by System.Text.Json from a real HTTP response, with no exception thrown") — Open5eWeapon.Properties had the identical defect, one level deeper: Results itself now deserializes correctly, but every individual weapon inside it still silently kept an empty Properties list against the real Open5e API. Every weapon-property-dependent feature was quietly running on an empty list for any weapon actually fetched live from Open5e — IWeapon.Range parsing (an earlier "Fixed" entry in this same file), Thrown/Ammunition detection (Attack's weapon-kind gating), and TwoHanded detection (Grapple's free-hand check) all silently fell back to their SRD-default behavior instead of each weapon's real properties, for every live-fetched weapon, this whole time. Fixed the same way Results was: made Properties settable rather than get-only. Caught by a genuine JSON serialize/deserialize round-trip test (Open5eItemCacheTests) — every prior weapon test constructed Open5eWeapon directly in C#, which never exercises real deserialization at all, the same blind spot that let the original Results bug through.
  • Fixed since rc1: the bulk list fetchers (GetAllWeaponsAsync/GetAllArmorAsync/GetAllMagicItemsAsync/the new GetAllSpellsAsync) share one generic Open5eListResult<T> type whose Results property was get-only — System.Text.Json silently left it empty against a real API response (scalar fields like count/next deserialized fine; only the results array was affected), with no exception thrown. Every prior test of this path used a mock constructed directly in C#, which never exercises real deserialization, so this went unnoticed. Found live while wiring the gRPC sidecar's spell repository; the fix (a settable property) applies to all four content types, not just spells.
  • Fixed since rc1: Open5eClient is now retry-aware, and its timeout is per attempt (Open5eRequestPolicy — 4 attempts, 20s each, exponential backoff with jitter, honoring a 429's Retry-After). The earlier fix — a single 15-second HttpClient.Timeout — turned out to be the wrong knob: that budget covered the whole ~14-page paginated spell fetch (plus a separate weapons+armor+magic-items pull), so two or three slow pages against Open5e's Cloudflare front exhausted it and the whole fetch threw. A downstream Layforge self-hoster hit exactly that on a clean clone — failed to populate spell repository … no local cache exists, with nothing to fall back on. Open5eClient now rides out the transient failures (a timed-out attempt, a connection error, 429/5xx) instead; only a genuinely unreachable Open5e — every attempt failing — throws, now a dedicated Open5eUnavailableException that the bulk list fetchers deliberately let propagate (a half-fetched SRD list must not be cached as if complete) while single-slug lookups still swallow into null. The sidecar's HttpClient.Timeout is back to InfiniteTimeSpan so it can't re-impose a shorter whole-fetch cap, and its cold-cache failure message now names OPEN5E_SPELL_CACHE_PATH/OPEN5E_ITEM_CACHE_PATH as the recovery.
  • New: the gRPC sidecar (OpenCombatEngine.GrpcSidecar) now populates a real ISpellRepository from this integration at startup, so spellcasting (known/prepared spells, slots) actually survives the gRPC round trip — previously always null regardless of what was sent in, for every creature, on every RPC that reconstructed one.
  • New: a CastSpell RPC exposes the already-tested CastSpellAction (checks PreparedSpells, with correct fallback to KnownSpells for a non-prepared caster; checks/consumes a slot; rolls the target's real saving throw) over gRPC for the first time — previously reachable only from within the engine itself, never from a downstream consumer like Layforge.
  • New: CastSpellRequest gained an optional grid_context field (caster/target positions plus line-of-sight-blocking cells) so CastSpellAction's own range/line-of-sight check — already written, already tested, but silently dormant since nothing ever constructed a populated IGridManager for it — finally receives real data. StandardGridManager needed no changes at all: its GetDistance already returns Chebyshev distance × 5 (feet, 5-ft squares), the exact convention the calling side (Layforge's own generated combat map) already uses, so positions cross the gRPC boundary with zero unit conversion. Omitting grid_context (the default — no combat map exists for a given cast) behaves exactly as before this field existed; this is additive, not a breaking change to CastSpell's existing behavior. Real per-cell cover computation remains genuinely unbuilt (IGridManager has no such method at all) and is not part of this — CoverType stays real only where StandardCreature.ResolveAttack already used it directly.
  • New: a local on-disk cache (Open5eSpellCache) for the sidecar's startup spell fetch — real problem found live during this project's own development: repeatedly restarting the sidecar during local testing hammered Open5e's API enough to get this environment rate-limited/blocked by Open5e's own Cloudflare front, leaving the sidecar unable to start (or start quickly) at all. Startup now checks a local cache (OPEN5E_SPELL_CACHE_PATH, default next to the binary) first and skips the live fetch entirely if it's fresh (age-based staleness, 7-day default — SRD content is static enough that a conditional-request/ETag scheme would just be network round trips spent to learn "nothing changed"); a live fetch that does happen writes the cache for next time; a live fetch that fails falls back to a stale cache rather than starting with zero spells. HttpClient's startup timeout was also tightened from the 100-second default to 15 seconds, so a genuinely unreachable Open5e fails into the cache fallback quickly instead of leaving the process looking hung.
  • Fixed: Open5eAdapter.ToStandard(Open5eSpell) never populated SpellDto.Damage/DamageInflict/SavingThrow — and Open5e's own REST API doesn't expose those as separate structured fields for spells, only as prose inside desc (e.g. "3d4 + your spellcasting ability modifier force damage"). Net effect: every spell sourced from this integration dealt zero damage when cast via CastSpellAction, regardless of what its real SRD description said — confirmed live via CastSpell, not assumed. Fixed with a new Open5eSpellTextParser that recovers damage dice/type and saving-throw ability from the SRD's own consistent phrasing ("takes XdY <type> damage", "<ability> saving throw") — best-effort prose parsing, not a full NLP solution, but it covers the standard phrasing every sampled SRD spell (Magic Missile, Fireball, Sacred Flame, Poison Spray, and others) actually uses.
  • Fixed: three follow-up gaps found alongside the one above, all now closed —
    • Healing. SpellMapper.MapHealingDice was a permanent stub that always returned null, regardless of spell source (not an Open5e-specific bug, but the same class of "the field exists on ISpell and nothing ever populates it" gap). Open5eSpellTextParser now also extracts a healing spell's dice from "regain[s]/restore[s] ... hit points [equal to XdY]" phrasing (Cure Wounds' "regains ... hit points equal to 1d8 + your spellcasting ability modifier" → "1d8"; the ability-modifier suffix is dropped, same simplification damage dice already accept, since HealingDice is a fixed string with no caster-dependent bonus).
    • Attack rolls. SpellAttack/RequiresAttackRoll was never populated, and even when set, CastSpellAction never branched on it — an attack-roll spell (Ray of Frost, Scorching Ray, Guiding Bolt, Inflict Wounds, Chill Touch) always hit and dealt full damage, the same "auto-hit" bug the save-based fix above closed for save spells. Open5eSpellTextParser now detects the SRD's "Make a melee/ranged spell attack" phrasing, and CastSpellAction now actually rolls 1d20 + proficiency + casting ability modifier against the target's own armor class (natural 20 always hits, natural 1 always misses, no crit-damage-doubling yet) before applying that instance's damage.
    • Multi-instance spells. Magic Missile's three darts, Scorching Ray's three rays — each independently rolled per SRD — previously only ever resolved as a single instance, since the only multiplier CastSpellAction had was cantrip level-scaling (Level 0 only). New ISpell.InstanceCount/InstanceCountPerUpcastLevel members (default 1/0 — no effect on the overwhelming majority of spells) are populated from "you create <count> ... darts/rays/..." and "one more/additional ... for each slot level above" phrasing, and CastSpellAction's damage loop now rolls (and, for an attack-roll spell, independently attack-rolls) each instance separately — a miss on one ray no longer blocks or reduces the others.
  • New: an Attack RPC exposes the already-tested AttackAction (real attack roll, advantage/disadvantage from conditions and flanking, crits, range/LOS) over gRPC for the first time — previously the only way a martial character's attack could be resolved was Layforge's apply_effect, which has no weapon-legality or range/LOS concept at all. The engine checks the attacker's actual equipped main-hand weapon and rejects the call when it can't do what's being asked: an AttackKind.Ranged request against a weapon with neither Thrown nor Ammunition (a longsword), or AttackKind.Melee against an Ammunition-only weapon (a bow) — a real mechanical gate on the weapon's own SRD properties, not a per-kind allowlist.
  • Fixed: IWeapon had no numeric range at all, despite Open5e's own weapon properties strings already carrying one — "thrown (range 20/60)" — that Open5eItemMapper.ParseWeaponProperties silently discarded by keeping only the leading word ("thrown"). IWeapon.Range (feet) is now real, parsed from that same text, with an SRD-accurate fallback (10 ft for Reach, 5 ft otherwise) only when a weapon's properties carry no range text at all — same "parse real Open5e data, don't hardcode a table" principle as Open5eSpellTextParser.
  • Fixed: a second, more consequential instance of the same "real data silently dropped" bug class as the spellcasting-always-null fix above — ActorMapping.ToCreature (the Layforge gRPC sidecar's Actor↔creature converter) never had a real IItemLibrary to resolve inventory item names back into live weapon/armor objects, so StandardCreature.ResolveItem always fell back to a bare placeholder StandardItem — not an IWeapon — meaning every equipped weapon silently came back unequipped after a gRPC round trip, regardless of what a character actually had equipped, for every RPC that reconstructs a creature (not just the new Attack RPC). ActorMapping.ToCreature gained an optional itemLibrary parameter, and the sidecar (Program.cs) now builds and populates a real StandardItemLibrary from Open5e's weapon/armor/magic-item endpoints at startup, alongside the spell repository, and injects it into SystemEngineGrpcService.
  • New: a GetAvailableActions RPC computes the full concrete list of mechanically legal actions a character can take right now — every equipped-weapon attack option (melee, ranged, and now a genuinely new off-hand/secondary-weapon bonus-action attack per SRD Two-Weapon Fighting), Grapple and Shove options, and every currently-castable prepared/known spell, each against every candidate target supplied by the caller — rather than leaving the caller to guess or trial-and-error against Attack/CastSpell. Building this surfaced that ICreature.Actions (which looked like a ready-made menu) actually under-represents a player character's real options — it only ever yields a hardcoded "Unarmed Strike" for equipped-weapon attacks, completely ignoring Equipment.MainHand/OffHand — so GetAvailableActions derives weapon options directly from equipment instead, the same way Attack's own handler already did (now shared via WeaponAttackRules, extracted from Attack's handler so the two can't drift into different answers for the same weapon).
  • New: GrappleAction and ShoveAction — real SRD mechanics that did not exist anywhere in this engine before (only the resulting Grappled/Prone conditions existed; nothing ever applied them). Both are an opposed check (source's Strength (Athletics) against the target's better of Athletics/Acrobatics) built entirely from already-tested primitives (ICheckManager.RollAbilityCheck, ConditionFactory/Conditions.AddCondition — the same pattern RageAction already used — and, for Shove's push, IGridManager.MoveCreature). Grapple requires a free hand, approximated from real equipped-weapon data (Equipment.OffHand/Shield empty, MainHand not WeaponProperty.TwoHanded) since this engine doesn't model creature size or a dedicated "hand" concept beyond what's inferable from equipment. Neither models creature Size (SRD's "no more than one size category larger" restriction) — genuinely unmodeled anywhere in this engine, a documented simplification, not an oversight.
  • New: a real source-side incapacitation gate (IncapacitationCheck) — before this, AttackAction/CastSpellAction only ever checked a target's Paralyzed/Stunned/Petrified conditions (for advantage); nothing checked whether the creature attempting to act was itself incapacitated. Now a real precondition on AttackAction, CastSpellAction, GrappleAction, and ShoveAction alike, and the same fact GetAvailableActionsResponse.can_act reports — deliberately independent of the HP-based Unconscious/Dying/Dead status (StartTurn's automatic death save continues to work exactly as before; a downed character still gets their turn to roll it).
  • New: GetAvailableActions can now actually be acted on, not just advertised — the same-release follow-up closing the gap the first cut of this feature deliberately flagged rather than shipped silently: Attack gained a third kind, ATTACK_KIND_OFFHAND (resolves against Equipment.OffHand, always a bonus action, never adds the ability modifier to damage per SRD Two-Weapon Fighting's core rule), and two new RPCs, Grapple and Shove, expose GrappleAction/ShoveAction over gRPC for the first time — mirroring Attack's own stateless, grid-context-aware shape (a shared BuildGridFromContext helper now backs both, alongside Attack/CastSpell's own inline versions, deliberately left untouched since they're already tested).
  • Found, flagged, not fixed (out of scope for this pass): StandardEquipmentManager.Equip unequips an item from any other slot before equipping it into a new one (a same-physical-item, reference-identity check) — combined with StandardItemLibrary/a content-backed IItemLibrary handing out one shared instance per item name for non-magic items, dual-wielding two of the exact same named weapon (e.g. twin daggers from the same content source) silently steals the item back out of the first hand instead of equipping both. Discovered incidentally while testing off-hand attacks; a real, pre-existing engine limitation, not introduced by this release.

💾 Persistence & State

  • Full Serialization: Save and Load combat encounters to JSON.
  • Resilient Restoration: Win conditions and event subscriptions persist across save/load cycles.
  • New test coverage, no code change needed: nested-container contents (a potion in a flask in a purse in a pack) already round-tripped correctly through the internal CombatSerializer save/load path at one level deep, but had zero coverage either at greater depth or through the external gRPC/JSON wire path (ActorMapping/CreatureStateJson) the item-carry-location feature above actually uses in production. Both gaps are closed by new tests, not new code — Should_Save_And_Load_FourDeep_Nested_Container_Contents extends the existing one-level test to four, and ActorMappingTests.ToActor_Then_ToCreature_RoundTripsDeeplyNestedContainerContents proves the same four-deep chain survives the actual Actor.character_data round trip, closing the one persistence question that feature's own design explicitly needed answered.

🧙 Character Creation

  • New: ICharacterCreationService/StandardCharacterCreationService — a genuinely new kind of engine surface, the first stateful System Engine RPC in this project (every other RPC is stateless per-call): StartCharacterCreation/AnswerCharacterCreationPrompt drive an in-memory, ephemeral (lost on restart by design, no persistence layer for it yet) question-sequence session keyed by a caller-generated session_id, deciding the next question purely from what's been answered so far rather than a fixed step list a downstream caller would otherwise have to hardcode. Curated SRD 5.1 data, hand-authored rather than sourced from Open5e (whose API has no races/classes/backgrounds endpoints): 4 races (Human, Elf, Dwarf, Halfling), 4 classes (Fighter, Wizard, Cleric, Rogue), 4 backgrounds (Acolyte, Criminal, Folk Hero, Soldier), 2 ability-score methods (standard array, 4d6-drop-lowest), fixed one-kit-per-class starting equipment, no subclass selection (irrelevant at level 1 for any of the four).
  • New: a single CharacterCreationMode (QUICK/DETAILED) flag controls how many steps the same sequence surfaces rather than branching into two separate generators — quick mode asks exactly three real questions (race, class, gender) then auto-rolls background/ability-score assignment/spell picks internally using the identical decision logic detailed mode exposes as real prompts, so the two can never silently drift into different generation rules.
  • New: a level-1 Wizard or Cleric is a real SRD spellcaster the moment creation finishes — 3 cantrips, 2 first-level slots, and prepared/known spells equal to ability modifier + level (minimum one) — chosen from the same live ISpellRepository every other spell-aware RPC already uses (real, class-tagged Open5e data via a new Classes property on ISpell, added as a C# 8 default interface member so no existing test fake needed changes), never a second, sync-prone hand-authored spell list.
  • New: CreatureState/ICreature gain an optional Gender field — purely cosmetic, no SRD mechanical effect, additive per this project's own backward-compatible-optional-field convention.
  • New: CreatureState.RaceName (and StandardCreature.RaceName) — the plain SRD race string ("Human"/"Elf"/"Dwarf"/"Halfling") the character was created as. The mechanical racial effects are already baked into ability scores at creation; this exists only so a downstream consumer can render "Male Dwarven Fighter" without re-deriving it. Distinct from StandardCreature.Race (the mechanical IRaceDefinition). Set by StandardCharacterCreationService, captured in the StandardCreature state-restoring constructor and returned by GetState() — the same round-trip path Gender needed fixing on; regression-covered in ActorMappingTests / CreatureStateJsonTests alongside it. Additive, appended last.
  • Fixed since rc1: the gender creation prompt sent an empty choice list, so a downstream client (Layforge) rendered it as a free-text box — the wrong control. It now offers a fixed Male / Female choice list and rejects anything else, case-normalized like the race/class/background prompts already were.
  • Fixed since rc1: CreatureState.Background/StandardCreature.Background — the chosen (or, in quick mode, auto-rolled) SRD background ("Criminal", "Sage", "Acolyte", "Soldier", "Folk Hero") was already used at creation for starting equipment/gold, but never actually appeared on the finished character — the exact same class of round-trip gap Gender/RaceName needed fixing, just never caught for this field until a downstream consumer (Layforge, composing a personalized character-introduction narration) needed to read it. Same fix shape: set in StandardCharacterCreationService.Finish, captured in StandardCreature's state-restoring constructor, returned by GetState(); regression-covered in ActorMappingTests/CreatureStateJsonTests/StandardCharacterCreationServiceTests alongside the existing Gender/RaceName cases. Additive, appended last.
  • New: Actor.level — a real top-level field on the gRPC contract's Actor message, populated in ActorMapping.ToActor as creature.LevelManager.TotalLevel (already a real property — the sum of every class level for a multiclass character, no new engine logic needed). Exists specifically so a downstream consumer (Layforge's new character-import review flow) can read a character's level without parsing character_data's engine-specific shape itself — the same reasoning challenge_rating was added for earlier. 0 means "no class levels to report" (e.g. a pure-CR monster stat block), never a real level.
  • Fixed: StandardCheckManager's skill/saving-throw proficiency sets (_skillProficiencies/_savingThrowProficiencies) were never persisted anywhere at all — no state class, nothing threaded through StandardCreature's restoring constructor or GetState() — the exact same round-trip bug class as Gender/RaceName/Background before each was fixed, just one this project's own SrdCharacterCreationData.cs had already flagged in a comment ("this scope omits skill/tool proficiencies... this engine's CreatureState has no field to persist them on today") without a fix landing until now. Every proficiency granted at character creation (via ProficiencyFeature) was silently lost on the very next save/load or gRPC round trip — meaning a proficient skill check has likely applied zero proficiency bonus in every real game this project has run. New CheckManagerState (skill names + saving-throw abilities), same append-only-optional-field shape as ActionEconomyState; regression-covered in ActorMappingTests/CreatureStateJsonTests alongside the existing Gender/RaceName/Background/ActionEconomy cases, including the same "a save predating this field restores to empty, not an exception" backward-compat case.
  • New: CreatureState.Abilities/.Skills — two purely derived, display-oriented arrays computed fresh on every GetState() (never independently stored, so they can never drift out of sync with AbilityScores/Checks/Effects). Abilities is the six scores with their computed SRD modifiers in real STR/DEX/CON/INT/WIS/CHA order; Skills is all 18 SRD 5.1 skills (new SrdSkills.All — no such canonical list existed anywhere in this engine before) with each one's real total modifier (ability modifier, plus proficiency bonus when proficient, plus any active general ability-check bonus via the same Effects.ApplyStatBonuses(StatType.AbilityCheck, ...) hook a real RollAbilityCheck applies to its own total — probed with the base modifier instead of a real roll result, safe since the one real IActiveEffect.ModifyStat implementation for this stat is purely additive). Exists so a schema-driven client (Layforge's character sheet) can render a real Ability Scores/Skills table without teaching itself the SRD modifier formula or an 18-skill list — the engine that owns the rule computes it, the client just displays whatever it's given. CharacterSchema.cs describes both new array shapes. Honest, out-of-scope gap, since closed below: StatType had no per-skill granularity at the time this landed (only whole-ability-check/whole-saving-throw) — an item or feat that boosted one named skill only had no representation anywhere in this engine.
  • New, closes the gap above: IActiveEffect.ModifyStat/IEffectManager.ApplyStatBonuses gained an optional skillName parameter, and StandardCheckManager.RollAbilityCheck/StandardCreature.BuildSkillEntries() now both forward the skill actually being checked through to it — a seam that already existed (RollAbilityCheck already took a skillName for the proficiency-bonus lookup) but was one hop short of reaching the effects layer. StatBonusEffect gained an optional targetSkillName: null (the default) keeps every existing whole-AbilityCheck effect's behavior exactly as before; set, it matches only that one named skill (case-insensitive), so a "+2 Intimidation" item no longer has to (incorrectly) boost every Charisma check to be expressed at all. New SkillBonusFeature (sibling to StatBonusFeature, shaped like ProficiencyFeature) is the authoring surface — one instance per skill-scoped bonus, attached to a creature the same way a magic item or feat already attaches any other IFeature. A real roll and the Skills-tab display resolve through the identical call, so they can never silently disagree, including for this new skill-scoped case. Regression-covered in ActiveEffectsRollsTests, CheckManagerTests, StandardCreatureTests, and the new SkillBonusFeatureTests. Not yet wired into JsonMagicItemImporter — that needs a real 5e.tools per-skill-bonus item example to map against, rather than a guessed field name; hand-authored content and tests can use SkillBonusFeature directly today.
  • Fixed, a live-observed UX bug in character creation's 4d6-drop-lowest ability-score method: RollAbilityScores rolled 4d6 six times and immediately collapsed each roll to its drop-lowest sum inline (IndividualRolls.OrderBy(...).Skip(1).Sum()) — the individual dice never survived past that one loop iteration, so a caller (Layforge Master) could relay only a bare, pre-summed total per ability score with zero visibility into what was actually rolled. Live report: a player rolling "5 12's and one 13" had to blind-assign each total to an ability with no way to see the other five. New StandardCharacterCreationService.RollAbilityScoreDiceSets() preserves every set's 4 individual dice (which one dropped, flagged rather than discarded) through a new Phase.RevealAbilityRolls pause — HandleAbilityMethod returns them via a new CharacterCreationPrompt.AbilityScoreRolls field (additive, appended last) instead of collapsing to Choices; the caller acknowledges (any answer content — HandleRevealAbilityRollsAck ignores it) once it has shown the reveal, advancing to the real assignment questions. RollAbilityScores itself is unchanged for standard_array and now just derives its totals-only return from the new dice-preserving method for the 4d6 method, so quick mode (which never shows dice to the player) needed no changes at all. New Core records RolledDie/AbilityScoreRollSet; new gRPC message AbilityScoreRollSet and a dropped bool on DieRoll (additive, safe for every existing Outcome.Rolls caller). Also fixed the other half of the same report: NextAssignScorePrompt/HandleAssignScore used to ask by score ("Assign the score 14 to which ability?"), showing only the current total — now asks by ability ("Assign which score to Strength?"), offering every remaining rolled total as a choice, so the player always sees the whole pool. This phase is shared by standard_array too, so it gets the same by-ability wording as a natural side effect. Regression-covered in StandardCharacterCreationServiceTests (dice-set correctness including a forced-duplicate-value tie case, the reveal-then-ack sequence, a full by-ability walkthrough, standard_array's new wording, an out-of-pool rejection case) and a new SystemEngineGrpcServiceTests case proving the gRPC-facing mapping.

🛠️ Core Engine

  • Robust Combat Loop: Handling of initiative, death skipping, and customizable win conditions.
  • Condition System: Comprehensive status effect tracking (Blinded, Restrained, etc.) impacting Core mechanics (Advantage/Disadvantage).
  • Fixed, a real live-observed bug affecting every d20 roll with a negative net modifier: StandardCheckManager.RollAbilityCheck/RollSavingThrow, AttackAction's attack roll, and StandardTurnManager's initiative roll all built their dice notation by naively string-interpolating a signed modifier directly after a literal + (e.g. $"1d20+{modifier}"). For a negative modifier that produces a double sign — "1d20+-1" — which StandardDiceRoller's own notation regex (a single leading +/- before the digits) rejects outright. Ability checks/saving throws failed the call entirely (Result.Failure, surfacing as a real rejection with no roll made at all); the attack roll and initiative roll each already had their own defensive "roll failed, fall back to a flat value" path, so they didn't crash, but silently skipped the actual d20 for any creature with a negative attack bonus or negative Initiative — always "rolling" the bare bonus alone rather than a genuine 1-20. Confirmed live: a Charisma 8 (-1 modifier, no relevant proficiency) character's Persuasion check to haggle down a price came back a flat rejection with zero dice ever rolled. New shared DiceNotation.WithModifier(diceExpression, modifier) (Implementation/Dice/) builds the string correctly ("1d20-1", never "1d20+-1") and is now the single place all four sites construct a modifier-carrying roll from.
  • Fixed a live-observed UX gap: the Equipment sidebar tab was always either empty or unreadable. EquippedSlotState/EquipmentState only ever carried numeric ItemIndex/AttunedItemIndices referencing into InventoryState.Items by position — a schema-driven client (Layforge) has no domain knowledge to cross-reference an array by index itself, so the tab showed raw numbers at best (and most characters equip nothing at creation, so it showed nothing at all), making it look like dead weight next to Inventory's real item names. New, purely derived, additive fields — EquippedSlotState.SlotName/.ItemName and EquipmentState.AttunedItemNames — resolve both the slot (StandardCreature's new SlotDisplayName, spacing EquipmentSlot's PascalCase name into words: "MainHand" → "Main Hand", "Ring1" → "Ring 1") and the item's own name at the moment BuildEquipmentState() builds the state, the same "engine computes, client just displays" reasoning as AbilityEntry/SkillEntry. ItemIndex/AttunedItemIndices are unchanged and still drive the real restore path (StandardCreature's state-restoring constructor equips by index, untouched) — the new fields are display-only, never read back. CharacterSchema.cs's equipment block now declares only the readable slotName/itemName/attunedItemNames — the raw index fields stay out of the client-facing schema entirely, so a schema-driven table renders "Main Hand: Quarterstaff" directly with zero client-side cross-referencing logic.

Verification

  • Test Coverage: 843 Unit and Integration tests passing — every project, including Open5eIntegrationTests (the 3 tests that intentionally hit the real Open5e API rather than a mock). Those 3 genuinely failed with socket/TLS timeouts partway through this release's own development, for real: Open5e's API became unreachable from this environment for a stretch — almost certainly the exact rate-limiting/blocking problem Open5eSpellCache (above) exists to make survivable — and passed again once connectivity returned, confirmed on a second full run. Not a regression from any change in this release, but real, live-observed evidence for why the new cache exists.
  • StowItem/DrawItem/ListCarriedItems and the Equip hands-full gate are unit-tested only, not yet verified live this pass — a genuine gap, reported rather than glossed over. Both the hands-full sequence proof (Equip into an occupied hand rejects → Unequip, free → succeeds; StowItem, costing the Action → Equip succeeds) and the deep-nested-container persistence question are covered by deterministic tests (SystemEngineGrpcServiceTests' StowItem_*/DrawItem_*/ListCarriedItems_*/EquipItem_HandSlot* sections, ActorMappingTests.ToActor_Then_ToCreature_RoundTripsDeeplyNestedContainerContents, Should_Save_And_Load_FourDeep_Nested_Container_Contents), but a real DM model narrating the feature end to end (the original crowbar-in-a-pack continuity bug this feature exists to fix) has not yet been run against a live sidecar.
  • End-to-End Verified: Full "Mock Battle" scenarios run successfully, validating the interaction of all systems.
  • Spellcasting round trip verified live against the real Open5e API and a real downstream consumer (Layforge's Master + a real LLM): a character with a spell known-but-not-prepared, and another prepared, correctly distinguished the two through the gRPC sidecar.
  • CastSpell verified live against the real Open5e API, the gRPC sidecar, and a real downstream consumer (Layforge's Master + a real LLM, qwen3.8:27b): a known-but-unprepared spell was hard-rejected by the engine (not just narrated as failing), and a prepared spell with an available slot succeeded — both surfaced correctly through Layforge's cast_spell DM tool.
  • Damage/healing/multi-instance fixes verified live, same stack: a prepared Magic Missile cast dealt 8 damage (three independently-rolled 1d4+1 darts, consistent with a real 3-instance roll rather than one), and a prepared Cure Wounds cast raised the caster from 10/18 to 16/18 HP (a real 1d8 roll, persisted). Attack-roll gating (Ray of Frost/Scorching Ray-style) is covered by 9 new deterministic-dice unit tests rather than a live run — a live LLM session can't control which d20 a hit/miss test needs, so this one is unit-only, not silently claimed as live-verified.
  • Spell cache verified live against a real sidecar, two full startup cycles: the first, with no cache present, fetched all 1435 spells live from Open5e and wrote the cache (~20s); the second, immediately after with the same cache file, loaded all 1435 spells from disk in ~4s and logged that it skipped the live fetch entirely.
  • Attack verified live against the real Open5e API (1709 real SRD weapons/armor/magic items loaded into a real StandardItemLibrary at sidecar startup), the gRPC sidecar, and a real downstream consumer (Layforge's Master + a real LLM, qwen3.8:27b): a character with a real equipped Longsword called melee_attack, the engine rolled a real attack and dealt real damage against a goblin NPC (7 HP down to barely conscious), and the mutated state persisted on both sides — proving the full chain (Open5e → IItemLibrary → ActorMapping → AttackAction) works end to end, including the ActorMapping/IItemLibrary fix above, which this attack could not have succeeded without. The weapon-kind rejection path (a Shortbow-equipped character asked to attack in melee) was attempted live twice; both times the DM model narrated a plausible-sounding refusal on its own judgment without ever calling melee_attack — a real finding, reported honestly rather than claimed as a live engine rejection. That branch is instead covered by deterministic gRPC-layer tests (Attack_MeleeAttackWithBowEquipped_ReturnsFailure, Attack_RangedAttackWithLongswordEquipped_ReturnsFailure, Attack_NoWeaponEquipped_ReturnsFailure) plus the weapon-range-parsing tests in Open5eItemMapperTests.cs and the round-trip regression pair in ActorMappingTests.cs.
  • GetAvailableActions and the turn-order incapacitation gate verified live against a real sidecar and a real LLM (qwen3.8:27b) — with one honest asterisk: Open5e was genuinely unreachable during this verification run (confirmed via direct curl timeouts against api.open5e.com), so the sidecar's item library never populated and the weapon-dependent options (melee/ranged/off-hand) could not be exercised live this time; that path is covered instead by the deterministic GetAvailableActions_* suite in SystemEngineGrpcServiceTests.cs. What did verify live, with real data: a character with a real prepared spell (Magic Missile, two slots) called get_available_actions, and the DM's own narration — generated only from the real tool-result JSON — correctly reported "Magic Missile is prepared, with two first-level slots full" and full action economy available; separately, a real Paralyzed condition applied via apply_effect caused start_combat's own turn search to produce the exact deterministic, Master-composed skip narration ("Kestrel is Paralyzed and cannot act this turn.") rather than relying on the DM model to notice or narrate it correctly.
  • Grapple and Shove verified live, same real sidecar/Master/qwen3.8:27b stack, in a later pass once the execution layer existed — these need no equipped weapon at all, so the Open5e outage above didn't block them: a real grapple tool call succeeded with the DM narrating actual opposed-check numbers pulled from the real result ("its Athletics roll of 4 against Kestrel's 16"), correctly describing the Grappled condition and its speed-to-0 effect; a real shove call knocked the target prone with an equally grounded narration. Notably, the DM model called get_available_actions unprompted before the shove attempt — the intended "check what's legal before acting" workflow this session's tool descriptions were written to encourage, observed actually happening. ATTACK_KIND_OFFHAND remains live-unverified for the same Open5e-outage reason as melee/ranged/off-hand above — covered by Attack_OffhandKind_* in SystemEngineGrpcServiceTests.cs instead.
  • EquipItem/UnequipItem/AddItemToInventory/RemoveItemFromInventory/TransferItem NOT verified live this pass — a genuine gap, reported rather than glossed over. Open5e was still unreachable at verification time (repeated direct curl timeouts against api.open5e.com, the same outage GetAvailableActions hit above), and unlike the spell repository, the sidecar's item library (StandardItemLibrary/Open5eContentSource) has no local on-disk cache of its own yet — Program.cs's own startup comments already flag this as a known future gap, not something discovered fresh here. With Open5e down, the sidecar starts with a genuinely empty item library, so AddItemToInventory("Potion of Healing") and EquipItem against a real catalog item cannot be exercised live right now, full stop — not merely "wasn't attempted." All five RPCs are instead covered by 16 new deterministic tests in SystemEngineGrpcServiceTests.cs: success path, item-not-in-inventory rejection, unrecognized-item-name rejection, unspecified-slot rejection, an equipped item auto-unequipping on TransferItem, and attunement correctly ending when an attuned item changes hands.
  • GenerateLoot and AddCurrency verified live, real sidecar + Master + qwen3.8:27b, Open5e reachable again by this later pass for the spell/base fetch — with one honest asterisk: the item library still failed to populate (a real live-observed data point, not a retry-flake: the larger, uncached weapons/armor/magic-item fetch hit the sidecar's 15-second HttpClient timeout even with Open5e otherwise reachable, the exact "no Open5eItemCache yet" gap this file already flags). That meant GenerateLoot's item slot and EquipItem/AddItemToInventory against a real catalog item still couldn't be exercised this pass either — but the currency math needs no item library at all, and it worked end to end: the DM created a real CR-0.25 goblin NPC via create_npc, called GenerateLoot against it, and got back real dice-rolled coin (2800 copper, 1800 silver, 80 gold — all within StandardLootGenerator's own Tier-1 formula ranges), then called AddCurrency to place 80 gold directly into the goblin's own inventory before the fight — narrated correctly from the real tool results throughout, including the DM's own reasonable choice to track the unplaced copper/silver as party loot rather than dump all of it onto one goblin (nothing in the tool contract forces an even split — matches this project's existing "party decision quorum left to DM narrative judgment" design precedent). Post-combat looting (give_item/TransferCurrency) was not exercised live this pass for an unrelated reason: the live-test harness's own script had no way to hand the DM the real ID create_npc generated for the goblin in a later, separate turn, and the DM correctly declined to guess one rather than inventing an ID to call TransferCurrency with — a real, positive demonstration of "gates over prompting" (never invent an identifier) rather than a defect. TransferCurrency's mechanics are proven instead by its own deterministic tests: success moving currency between two real inventories, and a real rejection when source can't afford the requested amount.
  • Open5eItemCache verified live, its cache-hit path against a real running sidecar process — with an honest limitation on the other half. A fresh startup attempt (cache deliberately cleared first) hit Open5e's item-catalog timeout for a third time this session, live-observed once again — the exact motivating problem, ironically preventing a live "fetch succeeds, cache gets written" demonstration; that half is proven by deterministic tests instead (Open5eItemCacheTests' own Save-then-TryLoadFresh round trips, run against real JsonSerializer calls, not mocks). What did verify live: a real sidecar process, given a hand-seeded cache file on disk, logged Loaded 1 SRD items ... from local cache ... skipped the live Open5e fetch and started instantly — proving the cache-hit read path (Open5eItemCache.TryLoadFresh → StandardItemLibrary.InitializeFromDtos) works end to end in a real process, not just in-memory in a test runner. Once a real live fetch does succeed and populates the cache for the first time (whether in this environment on a better-connected run, or for any self-hoster whose network fares better against Open5e than this development environment has all session), every startup after that skips the slow fetch entirely, the same win the spell cache already proved twice.
  • GetItemInfo/ListInventory verified live, real sidecar + Master + qwen3.8:27b, same "no live Open5e access this environment" constraint as every item-library-dependent RPC above, worked around the same way Open5eItemCache's own verification was: a hand-seeded local cache file (two real SRD weapons, Longsword 15 gp and Dagger 2 gp) let a real, unmodified sidecar binary start with a genuine, if small, real item library — no code path was skipped or mocked to make this pass. A shopkeeper NPC was stocked with a real Longsword (AddItemToInventory); a buyer asked to purchase it in natural language, and the DM model correctly called check_item_price, got back the real price decomposed into 1 platinum + 5 gold (1500 copper — the real fixed ParseCost output for "15 gp", not the old truncated-to-gold value), then called vendor_sell_item (Layforge's new downstream tool built on TransferItem+TransferCurrency). First attempt hit a real, correct TransferCurrency rejection: the buyer held 20 gold and zero platinum, and this engine's existing "no change-making across denominations" behavior (line 18 above) correctly refused the sale rather than silently netting value across coin types — narrated accurately by the DM ("won't make change from a coin he doesn't have"). After a real AddCurrency call gave the buyer one platinum piece, the identical request succeeded end to end: the shopkeeper's SQLite-persisted row lost the Longsword and gained 1 platinum + 5 gold, the buyer's gained the Longsword and dropped to exactly 15 gold remaining — real, engine-computed numbers, not narrated approximations.
  • RemoveCurrency verified live, same real sidecar + Master + qwen3.8:27b stack, as part of Layforge's campaign-pack pass: a player character stashed 10 real gold at a location (stash_currency, built on this RPC), and the character's own real, SQLite-persisted currency dropped from 20 gold to 10 gold — a real debit, not a Master-side ledger entry with the engine's own copy of the character's currency left untouched.
  • StartCharacterCreation/AnswerCharacterCreationPrompt verified live, real sidecar + real Master, over the actual WS protocol (no LLM needed — this feature doesn't touch narration): a full quick-roll and a full detailed roll both completed end to end, with the SRD ability-modifier spellcasting formula (WIS 17 → +3 mod → 4 leveled spells known/prepared; WIS 11 → +0 mod → the "minimum of one" floor) and the Human +1-to-all racial bonus (assigned 15/14/13/12/10/8 → final 16/15/14/13/11/9) both confirmed exactly correct by manual recomputation, not just "plausible." The detailed-rolled Cleric's real, engine-generated spellcasting data then actually cast a prepared 1st-level spell (a real slot consumed, 2→1 remaining) and a cantrip (no slot touched) via CastSpell, and a spell never chosen at creation (Fireball) was correctly rejected with "Spell Fireball is not prepared." — proving the generated data isn't just schema-valid, it's mechanically usable. A real bug was found and fixed along the way: the purely-cosmetic Gender field came back null in the finished character despite being answered correctly at every stage up through StandardCharacterCreationService's own final CreatureState — the loss was downstream, in SystemEngineGrpcService.MapCreationPrompt's new StandardCreature(prompt.Character, ...) conversion: StandardCreature's CreatureState-restoring constructor never captured Gender at all, so its own GetState() had nothing to put back into the response Actor. Fixed by adding a Gender property to StandardCreature, captured in that constructor and included in GetState()'s returned CreatureState; a new regression test (ActorMappingTests.ToActor_StandardCreatureFromStateWithGender_PreservesGenderThroughRoundTrip) covers this exact path specifically, since the existing CreatureStateJsonTests Gender round-trip test serializes a CreatureState directly and never exercises the StandardCreature-wrapping conversion this RPC actually uses.
  • Actor.level verified live, real sidecar + real Master, as part of Layforge's character-import review flow: a level-15 FromJson'd character correctly reported level: 15 on the wire, driving a real deterministic rejection downstream (outside a configured 1–5 campaign range) with zero LLM calls involved. Unit-covered for the multiclass-sum and no-class-levels cases (ActorMappingTests.ToActor_MulticlassCreature_LevelIsSumOfEveryClass/ToActor_CreatureWithNoClassLevels_LevelIsZero), since a live run can't easily prove the sum logic across more than the one character it happens to construct.