This release marks a significant milestone for the OpenCombatEngine, introducing advanced AI behaviors, loot generation, and full Open5e content integration.
- 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).
- 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,IArmorstandardized 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; previouslyIEquipmentManager/IInventorywere 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.EquipItemcloses a real gap inStandardEquipmentManager.Equipitself (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.TransferItemexplicitly 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 domainOffHandslot as an off-hand weapon (this engine has no separateShieldslot internally —StandardEquipmentManager.EquipOffHandInternaldecides.Shieldvs..OffHandfrom the item's own type); the proto'sEQUIPMENT_SLOT_SHIELDvalue 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 5AddItemToInventorycalls. 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/StandardLootGeneratorabove remain otherwise unconnected to any distribution logic, andIItem.Valuestill has no reader anywhere. - New:
ILootGenerator/StandardLootGeneratorfinally get connected to something — aGenerateLootRPC 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-newIEncounterChallengeCalculator/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 feedsGenerateLoot'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.GenerateLoottakesrepeated 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 realchallenge_rating(a new field onCreatureState/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.
IInventorygainsCopper/Silver/Gold/PlatinumplusAddCurrency/RemoveCurrency(mirroringLootBundle's own four denominations), andInventoryStateround-trips them the same additive, backward-compatible way every other optionalCreatureStatefield already does. Two new RPCs land it on characters:AddCurrency(creation from nothing — a treasure placement) andTransferCurrency(moving currency from one creature's inventory to another's, mirroringTransferItem'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.Valuefinally 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 asAddItemToInventory), andListInventory(actor)returns the real item names an actor currently holds (needed becausecharacter_datais 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.ParseCostpreviously mappedValueto 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 became0— a torch at "1 cp" priced as free. Since nothing readValuebefore this pass, this was invisible until now;Valueis redefined as copper pieces (the finest SRD denomination, so every real price survives losslessly) andParseCostfixed to match, withIItem.Valuegaining 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 toTransferCurrency. - New:
RemoveCurrency—AddCurrency's inverse, and the single-actor equivalent ofTransferCurrency'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, andTransferCurrencyrequires two real Actors on both ends — it can't express that. Same real rejection asTransferCurrency'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 alongsideEquipItem/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'sContents(already-existing, previously-unused-outside-CombatSerializermachinery — see the Persistence entry below), or neither ("quick access").IActionEconomygains a fourth resource,HasFreeObjectInteraction(an internal stacking counter, not just a bool, so a future "this becomes a free action" feat has a realGrantFreeObjectInteraction()to call fromIFeature.OnStartTurnwithout 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.EquipmentSlotgainsBack(a worn pack/quiver had no slot to hang off of before this). Fixed alongside it, a real pre-existing gate gap:StandardEquipmentManager.Equipsilently swapped out whatever was already inMainHand/OffHand— "hands full" was never actually enforced anywhere, only ever something a prompt could ask a caller to respect.Equipinto 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:
TransferItemused 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 sameGetCarriedItemLocations()traversalStowItem/DrawItem/ListCarriedItemsalready share, so it finds an item at any nesting depth and removes it from its actual parent container (rather than the flat listRemoveItemalone 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" ruleDrawItemalready 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.
- 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, mirroringOpen5eSpellCacheexactly, 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-secondHttpClienttimeout fetching the full, paginated weapons+armor+magic-items catalog even with Open5e itself reachable.Open5eContentSourcegainedGetAllWeaponDtosAsync/GetAllArmorDtosAsync/GetAllMagicItemDtosAsync(splitting fetch from mapping, same asGetAllSpellDtosAsyncalready did for spells) so the cache can persist the raw DTOs, andStandardItemLibrary.InitializeFromDtosmaps 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 bySystem.Text.Jsonfrom a real HTTP response, with no exception thrown") —Open5eWeapon.Propertieshad the identical defect, one level deeper:Resultsitself now deserializes correctly, but every individual weapon inside it still silently kept an emptyPropertieslist 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.Rangeparsing (an earlier "Fixed" entry in this same file),Thrown/Ammunitiondetection (Attack's weapon-kind gating), andTwoHandeddetection (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 wayResultswas: madePropertiessettable rather than get-only. Caught by a genuine JSON serialize/deserialize round-trip test (Open5eItemCacheTests) — every prior weapon test constructedOpen5eWeapondirectly in C#, which never exercises real deserialization at all, the same blind spot that let the originalResultsbug through. - Fixed since rc1: the bulk list fetchers (
GetAllWeaponsAsync/GetAllArmorAsync/GetAllMagicItemsAsync/the newGetAllSpellsAsync) share one genericOpen5eListResult<T>type whoseResultsproperty was get-only —System.Text.Jsonsilently left it empty against a real API response (scalar fields likecount/nextdeserialized 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:
Open5eClientis now retry-aware, and its timeout is per attempt (Open5eRequestPolicy— 4 attempts, 20s each, exponential backoff with jitter, honoring a429'sRetry-After). The earlier fix — a single 15-secondHttpClient.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.Open5eClientnow 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 dedicatedOpen5eUnavailableExceptionthat 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 intonull. The sidecar'sHttpClient.Timeoutis back toInfiniteTimeSpanso it can't re-impose a shorter whole-fetch cap, and its cold-cache failure message now namesOPEN5E_SPELL_CACHE_PATH/OPEN5E_ITEM_CACHE_PATHas the recovery. - New: the gRPC sidecar (
OpenCombatEngine.GrpcSidecar) now populates a realISpellRepositoryfrom this integration at startup, sospellcasting(known/prepared spells, slots) actually survives the gRPC round trip — previously alwaysnullregardless of what was sent in, for every creature, on every RPC that reconstructed one. - New: a
CastSpellRPC exposes the already-testedCastSpellAction(checksPreparedSpells, with correct fallback toKnownSpellsfor 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:
CastSpellRequestgained an optionalgrid_contextfield (caster/target positions plus line-of-sight-blocking cells) soCastSpellAction's own range/line-of-sight check — already written, already tested, but silently dormant since nothing ever constructed a populatedIGridManagerfor it — finally receives real data.StandardGridManagerneeded no changes at all: itsGetDistancealready 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. Omittinggrid_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 toCastSpell's existing behavior. Real per-cell cover computation remains genuinely unbuilt (IGridManagerhas no such method at all) and is not part of this —CoverTypestays real only whereStandardCreature.ResolveAttackalready 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 populatedSpellDto.Damage/DamageInflict/SavingThrow— and Open5e's own REST API doesn't expose those as separate structured fields for spells, only as prose insidedesc(e.g. "3d4 + your spellcasting ability modifier force damage"). Net effect: every spell sourced from this integration dealt zero damage when cast viaCastSpellAction, regardless of what its real SRD description said — confirmed live viaCastSpell, not assumed. Fixed with a newOpen5eSpellTextParserthat 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.MapHealingDicewas a permanent stub that always returnednull, regardless of spell source (not an Open5e-specific bug, but the same class of "the field exists onISpelland nothing ever populates it" gap).Open5eSpellTextParsernow 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, sinceHealingDiceis a fixed string with no caster-dependent bonus). - Attack rolls.
SpellAttack/RequiresAttackRollwas never populated, and even when set,CastSpellActionnever 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.Open5eSpellTextParsernow detects the SRD's "Make a melee/ranged spell attack" phrasing, andCastSpellActionnow 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
CastSpellActionhad was cantrip level-scaling (Level 0 only). NewISpell.InstanceCount/InstanceCountPerUpcastLevelmembers (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, andCastSpellAction'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.
- Healing.
- New: an
AttackRPC exposes the already-testedAttackAction(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'sapply_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: anAttackKind.Rangedrequest against a weapon with neitherThrownnorAmmunition(a longsword), orAttackKind.Meleeagainst anAmmunition-only weapon (a bow) — a real mechanical gate on the weapon's own SRD properties, not a per-kind allowlist. - Fixed:
IWeaponhad no numeric range at all, despite Open5e's own weaponpropertiesstrings already carrying one —"thrown (range 20/60)"— thatOpen5eItemMapper.ParseWeaponPropertiessilently 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 forReach, 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 asOpen5eSpellTextParser. - Fixed: a second, more consequential instance of the same "real data silently dropped" bug class as the spellcasting-always-
nullfix above —ActorMapping.ToCreature(the Layforge gRPC sidecar's Actor↔creature converter) never had a realIItemLibraryto resolve inventory item names back into live weapon/armor objects, soStandardCreature.ResolveItemalways fell back to a bare placeholderStandardItem— not anIWeapon— 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 newAttackRPC).ActorMapping.ToCreaturegained an optionalitemLibraryparameter, and the sidecar (Program.cs) now builds and populates a realStandardItemLibraryfrom Open5e's weapon/armor/magic-item endpoints at startup, alongside the spell repository, and injects it intoSystemEngineGrpcService. - New: a
GetAvailableActionsRPC 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 againstAttack/CastSpell. Building this surfaced thatICreature.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 ignoringEquipment.MainHand/OffHand— soGetAvailableActionsderives weapon options directly from equipment instead, the same wayAttack's own handler already did (now shared viaWeaponAttackRules, extracted fromAttack's handler so the two can't drift into different answers for the same weapon). - New:
GrappleActionandShoveAction— real SRD mechanics that did not exist anywhere in this engine before (only the resultingGrappled/Proneconditions 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 patternRageActionalready used — and, for Shove's push,IGridManager.MoveCreature). Grapple requires a free hand, approximated from real equipped-weapon data (Equipment.OffHand/Shieldempty,MainHandnotWeaponProperty.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/CastSpellActiononly 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 onAttackAction,CastSpellAction,GrappleAction, andShoveActionalike, and the same factGetAvailableActionsResponse.can_actreports — 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:
GetAvailableActionscan 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:Attackgained a third kind,ATTACK_KIND_OFFHAND(resolves againstEquipment.OffHand, always a bonus action, never adds the ability modifier to damage per SRD Two-Weapon Fighting's core rule), and two new RPCs,GrappleandShove, exposeGrappleAction/ShoveActionover gRPC for the first time — mirroringAttack's own stateless, grid-context-aware shape (a sharedBuildGridFromContexthelper now backs both, alongsideAttack/CastSpell's own inline versions, deliberately left untouched since they're already tested). - Found, flagged, not fixed (out of scope for this pass):
StandardEquipmentManager.Equipunequips an item from any other slot before equipping it into a new one (a same-physical-item, reference-identity check) — combined withStandardItemLibrary/a content-backedIItemLibraryhanding 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.
- 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
CombatSerializersave/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_Contentsextends the existing one-level test to four, andActorMappingTests.ToActor_Then_ToCreature_RoundTripsDeeplyNestedContainerContentsproves the same four-deep chain survives the actualActor.character_dataround trip, closing the one persistence question that feature's own design explicitly needed answered.
- 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/AnswerCharacterCreationPromptdrive an in-memory, ephemeral (lost on restart by design, no persistence layer for it yet) question-sequence session keyed by a caller-generatedsession_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
ISpellRepositoryevery other spell-aware RPC already uses (real, class-tagged Open5e data via a newClassesproperty onISpell, 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/ICreaturegain an optionalGenderfield — purely cosmetic, no SRD mechanical effect, additive per this project's own backward-compatible-optional-field convention. - New:
CreatureState.RaceName(andStandardCreature.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 fromStandardCreature.Race(the mechanicalIRaceDefinition). Set byStandardCharacterCreationService, captured in theStandardCreaturestate-restoring constructor and returned byGetState()— the same round-trip pathGenderneeded fixing on; regression-covered inActorMappingTests/CreatureStateJsonTestsalongside 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 gapGender/RaceNameneeded 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 inStandardCharacterCreationService.Finish, captured inStandardCreature's state-restoring constructor, returned byGetState(); regression-covered inActorMappingTests/CreatureStateJsonTests/StandardCharacterCreationServiceTestsalongside the existing Gender/RaceName cases. Additive, appended last. - New:
Actor.level— a real top-level field on the gRPC contract'sActormessage, populated inActorMapping.ToActorascreature.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 parsingcharacter_data's engine-specific shape itself — the same reasoningchallenge_ratingwas 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 throughStandardCreature's restoring constructor orGetState()— the exact same round-trip bug class as Gender/RaceName/Background before each was fixed, just one this project's ownSrdCharacterCreationData.cshad 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 (viaProficiencyFeature) 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. NewCheckManagerState(skill names + saving-throw abilities), same append-only-optional-field shape asActionEconomyState; regression-covered inActorMappingTests/CreatureStateJsonTestsalongside 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 everyGetState()(never independently stored, so they can never drift out of sync withAbilityScores/Checks/Effects).Abilitiesis the six scores with their computed SRD modifiers in real STR/DEX/CON/INT/WIS/CHA order;Skillsis all 18 SRD 5.1 skills (newSrdSkills.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 sameEffects.ApplyStatBonuses(StatType.AbilityCheck, ...)hook a realRollAbilityCheckapplies to its own total — probed with the base modifier instead of a real roll result, safe since the one realIActiveEffect.ModifyStatimplementation 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.csdescribes both new array shapes. Honest, out-of-scope gap, since closed below:StatTypehad 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.ApplyStatBonusesgained an optionalskillNameparameter, andStandardCheckManager.RollAbilityCheck/StandardCreature.BuildSkillEntries()now both forward the skill actually being checked through to it — a seam that already existed (RollAbilityCheckalready took askillNamefor the proficiency-bonus lookup) but was one hop short of reaching the effects layer.StatBonusEffectgained an optionaltargetSkillName: null (the default) keeps every existing whole-AbilityCheckeffect'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. NewSkillBonusFeature(sibling toStatBonusFeature, shaped likeProficiencyFeature) 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 otherIFeature. 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 inActiveEffectsRollsTests,CheckManagerTests,StandardCreatureTests, and the newSkillBonusFeatureTests. Not yet wired intoJsonMagicItemImporter— 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 useSkillBonusFeaturedirectly today. - Fixed, a live-observed UX bug in character creation's 4d6-drop-lowest ability-score method:
RollAbilityScoresrolled4d6six 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. NewStandardCharacterCreationService.RollAbilityScoreDiceSets()preserves every set's 4 individual dice (which one dropped, flagged rather than discarded) through a newPhase.RevealAbilityRollspause —HandleAbilityMethodreturns them via a newCharacterCreationPrompt.AbilityScoreRollsfield (additive, appended last) instead of collapsing toChoices; the caller acknowledges (any answer content —HandleRevealAbilityRollsAckignores it) once it has shown the reveal, advancing to the real assignment questions.RollAbilityScoresitself is unchanged forstandard_arrayand 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 recordsRolledDie/AbilityScoreRollSet; new gRPC messageAbilityScoreRollSetand adroppedbool onDieRoll(additive, safe for every existingOutcome.Rollscaller). Also fixed the other half of the same report:NextAssignScorePrompt/HandleAssignScoreused 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 bystandard_arraytoo, so it gets the same by-ability wording as a natural side effect. Regression-covered inStandardCharacterCreationServiceTests(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 newSystemEngineGrpcServiceTestscase proving the gRPC-facing mapping.
- 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, andStandardTurnManager'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"— whichStandardDiceRoller'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 sharedDiceNotation.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/EquipmentStateonly ever carried numericItemIndex/AttunedItemIndicesreferencing intoInventoryState.Itemsby 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/.ItemNameandEquipmentState.AttunedItemNames— resolve both the slot (StandardCreature's newSlotDisplayName, spacingEquipmentSlot's PascalCase name into words: "MainHand" → "Main Hand", "Ring1" → "Ring 1") and the item's own name at the momentBuildEquipmentState()builds the state, the same "engine computes, client just displays" reasoning asAbilityEntry/SkillEntry.ItemIndex/AttunedItemIndicesare 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'sequipmentblock now declares only the readableslotName/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.
- 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 problemOpen5eSpellCache(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/ListCarriedItemsand theEquiphands-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 (Equipinto an occupied hand rejects →Unequip, free → succeeds;StowItem, costing the Action →Equipsucceeds) 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.
CastSpellverified 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'scast_spellDM 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.
Attackverified live against the real Open5e API (1709 real SRD weapons/armor/magic items loaded into a realStandardItemLibraryat 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 calledmelee_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 theActorMapping/IItemLibraryfix 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 callingmelee_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 inOpen5eItemMapperTests.csand the round-trip regression pair inActorMappingTests.cs.GetAvailableActionsand 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 directcurltimeouts againstapi.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 deterministicGetAvailableActions_*suite inSystemEngineGrpcServiceTests.cs. What did verify live, with real data: a character with a real prepared spell (Magic Missile, two slots) calledget_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 realParalyzedcondition applied viaapply_effectcausedstart_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.GrappleandShoveverified live, same real sidecar/Master/qwen3.8:27bstack, 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 realgrappletool 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 realshovecall knocked the target prone with an equally grounded narration. Notably, the DM model calledget_available_actionsunprompted before theshoveattempt — the intended "check what's legal before acting" workflow this session's tool descriptions were written to encourage, observed actually happening.ATTACK_KIND_OFFHANDremains live-unverified for the same Open5e-outage reason as melee/ranged/off-hand above — covered byAttack_OffhandKind_*inSystemEngineGrpcServiceTests.csinstead.EquipItem/UnequipItem/AddItemToInventory/RemoveItemFromInventory/TransferItemNOT verified live this pass — a genuine gap, reported rather than glossed over. Open5e was still unreachable at verification time (repeated directcurltimeouts againstapi.open5e.com, the same outageGetAvailableActionshit 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, soAddItemToInventory("Potion of Healing") andEquipItemagainst 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 inSystemEngineGrpcServiceTests.cs: success path, item-not-in-inventory rejection, unrecognized-item-name rejection, unspecified-slot rejection, an equipped item auto-unequipping onTransferItem, and attunement correctly ending when an attuned item changes hands.GenerateLootandAddCurrencyverified 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-secondHttpClienttimeout even with Open5e otherwise reachable, the exact "noOpen5eItemCacheyet" gap this file already flags). That meantGenerateLoot's item slot andEquipItem/AddItemToInventoryagainst 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 viacreate_npc, calledGenerateLootagainst it, and got back real dice-rolled coin (2800 copper, 1800 silver, 80 gold — all withinStandardLootGenerator's own Tier-1 formula ranges), then calledAddCurrencyto 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 IDcreate_npcgenerated for the goblin in a later, separate turn, and the DM correctly declined to guess one rather than inventing an ID to callTransferCurrencywith — 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.Open5eItemCacheverified 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' ownSave-then-TryLoadFreshround trips, run against realJsonSerializercalls, not mocks). What did verify live: a real sidecar process, given a hand-seeded cache file on disk, loggedLoaded 1 SRD items ... from local cache ... skipped the live Open5e fetchand 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/ListInventoryverified 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 wayOpen5eItemCache'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 calledcheck_item_price, got back the real price decomposed into 1 platinum + 5 gold (1500 copper — the real fixedParseCostoutput for "15 gp", not the old truncated-to-gold value), then calledvendor_sell_item(Layforge's new downstream tool built onTransferItem+TransferCurrency). First attempt hit a real, correctTransferCurrencyrejection: 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 realAddCurrencycall 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.RemoveCurrencyverified live, same real sidecar + Master +qwen3.8:27bstack, 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/AnswerCharacterCreationPromptverified 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) viaCastSpell, 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-cosmeticGenderfield came backnullin the finished character despite being answered correctly at every stage up throughStandardCharacterCreationService's own finalCreatureState— the loss was downstream, inSystemEngineGrpcService.MapCreationPrompt'snew StandardCreature(prompt.Character, ...)conversion:StandardCreature'sCreatureState-restoring constructor never capturedGenderat all, so its ownGetState()had nothing to put back into the responseActor. Fixed by adding aGenderproperty toStandardCreature, captured in that constructor and included inGetState()'s returnedCreatureState; a new regression test (ActorMappingTests.ToActor_StandardCreatureFromStateWithGender_PreservesGenderThroughRoundTrip) covers this exact path specifically, since the existingCreatureStateJsonTestsGender round-trip test serializes aCreatureStatedirectly and never exercises theStandardCreature-wrapping conversion this RPC actually uses.Actor.levelverified live, real sidecar + real Master, as part of Layforge's character-import review flow: a level-15FromJson'd character correctly reportedlevel: 15on 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.