-
Notifications
You must be signed in to change notification settings - Fork 0
fix(charsheet): Spell Save DC + Spell Attack Bonus header & class features on Abilities tab #416
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3465,6 +3465,10 @@ def _slot_lvl(k): | |
| "stats": stats, | ||
| "skills": skills, | ||
| "spells": spells, | ||
| # Character-level casting summary (Spell Save DC + Spell Attack Bonus) for the top | ||
| # of the Spells tab. None for a non-caster (Fighter/Rogue) — the screen omits it | ||
| # rather than show a fake DC. Derived from the PC's casting ability + proficiency. | ||
| "spellcasting": _character_spellcasting(ch), | ||
| "spellSlots": spell_slots, | ||
| "classResources": class_resources, | ||
| "conditions": conditions, | ||
|
|
@@ -3620,19 +3624,26 @@ def _spell_meta(name: str) -> dict: | |
| } | ||
|
|
||
|
|
||
| def _spell_save_dc(ch: dict) -> int | None: | ||
| """A caster's spell save DC = 8 + proficiency + casting-ability modifier, mirroring | ||
| engine ``server.spell_save_dc`` read-only from the snapshot. Returns None when the | ||
| character has no SRD caster class (a Fighter with stray spell names, an NPC) — we then | ||
| omit the DC rather than invent one. Honest: reads only engine-set abilities/prof.""" | ||
| def _casting_ability(ch: dict) -> str | None: | ||
| """The full ability key (e.g. "intelligence") the character casts with, from their | ||
| FIRST SRD caster class (mirror of engine srd_tables._CASTING_ABILITY). Returns None | ||
| for a non-caster (Fighter/Rogue/NPC with stray spell names) so callers omit DC/attack | ||
| rather than fabricate one. Honest: reads only the engine-set `classes` list.""" | ||
| classes = ch.get("classes") if isinstance(ch.get("classes"), list) else [] | ||
| ability = None | ||
| for cl in classes: | ||
| if isinstance(cl, dict): | ||
| a = _CASTING_ABILITY.get(_text(cl.get("name")).lower()) | ||
| if a: | ||
| ability = a | ||
| break | ||
| return a | ||
| return None | ||
|
Comment on lines
3635
to
+3638
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Support Line 3635 only reads Proposed fix def _casting_ability(ch: dict) -> str | None:
@@
classes = ch.get("classes") if isinstance(ch.get("classes"), list) else []
for cl in classes:
if isinstance(cl, dict):
- a = _CASTING_ABILITY.get(_text(cl.get("name")).lower())
+ class_name = _text(cl.get("name") or cl.get("class_name")).lower()
+ a = _CASTING_ABILITY.get(class_name)
if a:
return a
return None🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| def _spell_save_dc(ch: dict) -> int | None: | ||
| """A caster's spell save DC = 8 + proficiency + casting-ability modifier, mirroring | ||
| engine ``server.spell_save_dc`` read-only from the snapshot. Returns None when the | ||
| character has no SRD caster class (a Fighter with stray spell names, an NPC) — we then | ||
| omit the DC rather than invent one. Honest: reads only engine-set abilities/prof.""" | ||
| ability = _casting_ability(ch) | ||
| if ability is None: | ||
| return None | ||
| abilities = ch.get("abilities") if isinstance(ch.get("abilities"), dict) else {} | ||
|
|
@@ -3641,6 +3652,38 @@ def _spell_save_dc(ch: dict) -> int | None: | |
| return 8 + prof + _ability_mod(abilities.get(ability)) | ||
|
|
||
|
|
||
| def _spell_attack_bonus(ch: dict) -> int | None: | ||
| """A caster's spell attack bonus = proficiency + casting-ability modifier, mirroring | ||
| engine ``server.spell_save_dc``'s `spell_attack_bonus` (server.py: prof + mod). Returns | ||
| None for a non-caster (no SRD caster class) so the UI omits it rather than show a fake | ||
| +0. Honest: reads only engine-set abilities/prof.""" | ||
| ability = _casting_ability(ch) | ||
| if ability is None: | ||
| return None | ||
| abilities = ch.get("abilities") if isinstance(ch.get("abilities"), dict) else {} | ||
| prof = _num(ch.get("proficiency_bonus")) | ||
| prof = int(prof) if prof is not None else 2 | ||
| return prof + _ability_mod(abilities.get(ability)) | ||
|
|
||
|
|
||
| def _character_spellcasting(ch: dict) -> dict | None: | ||
| """Character-level spellcasting summary for the TOP of the Spells tab — the once-at-the-top | ||
| Spell Save DC + Spell Attack Bonus a caster needs to plan (the way D&D Beyond shows them), | ||
| derived from the PC's spellcasting ability + proficiency. Returns None for a non-caster | ||
| (no SRD caster class) so the screen omits the block entirely — an honest Fighter shows | ||
| nothing, never a fabricated DC 0. Reuses the same #410 formula helpers (no new math).""" | ||
| ability = _casting_ability(ch) | ||
| if ability is None: | ||
| return None | ||
| return { | ||
| "ability": ability, | ||
| # short SRD code (int/wis/cha) for a compact "INT" badge in the UI | ||
| "abilityShort": ability[:3], | ||
| "spellSaveDc": _spell_save_dc(ch), | ||
| "spellAttackBonus": _spell_attack_bonus(ch), | ||
| } | ||
|
|
||
|
|
||
| def _spell_card(name: str, time_label: str, save_dc: int | None) -> dict: | ||
| """One spell's render card for the heroes screen: the name plus the engine's REAL SRD | ||
| rules fields (level / school / range / casting time / duration / components / save / damage) | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| """Character-sheet data-depth surface tests (optimizer-persona gaps). | ||
|
|
||
| Two read-model gaps an optimizer flagged in a built-.app playtest: | ||
|
|
||
| Bug 1 - the Spells tab never exposed the *character-level* Spell Save DC and Spell | ||
| Attack Bonus (only per-spell saveDc from PR #410). A caster could not plan. | ||
| A non-caster must NOT get a fabricated DC. | ||
|
|
||
| Bug 2 - the Abilities tab read "No active abilities recorded" for a L3 wizard even | ||
| though the engine populates class/subclass features as NAMES in | ||
| Character.features (already surfaced by the read-model as `classFeatures`). | ||
| The fix surfaces those on the character surface so the Abilities tab can | ||
| render them; feature DESCRIPTIONS and RACIAL TRAITS are not modeled by the | ||
| engine, so they stay absent (never fabricated). | ||
|
|
||
| Mirrors test_readmodel_surfaces.py: load server.py via importlib, drive the real | ||
| /character-surface route against a model-conformant snapshot written to a temp state dir. | ||
| """ | ||
|
|
||
| import copy | ||
| import http.client | ||
| import importlib.util | ||
| import json | ||
| import os | ||
| import tempfile | ||
| import threading | ||
| import unittest | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| _SERVER_PATH = Path(__file__).resolve().parents[1] / "server.py" | ||
| _SPEC = importlib.util.spec_from_file_location("viewer_server_charsheet", _SERVER_PATH) | ||
| assert _SPEC is not None | ||
| server = importlib.util.module_from_spec(_SPEC) | ||
| assert _SPEC.loader is not None | ||
| _SPEC.loader.exec_module(server) | ||
|
|
||
|
|
||
| # A model-conformant snapshot: a level-3 evocation Wizard (INT caster) + a level-4 Fighter | ||
| # (non-caster). The Wizard carries engine-populated class features (NAMES) in `features`. | ||
| _SNAPSHOT = { | ||
| "id": "camp_depth", | ||
| "title": "The Tower at Dusk", | ||
| "world_id": "stolen-marches", | ||
| "day": 3, | ||
| "party": ["elara", "thornwick"], | ||
| "characters": { | ||
| "elara": { | ||
| "id": "elara", "name": "Elara Moonwhisper", "kind": "player", "race": "High Elf", | ||
| "alignment": "Neutral Good", | ||
| "classes": [{"name": "Wizard", "level": 3, "subclass": "School of Evocation"}], | ||
| "abilities": {"strength": 8, "dexterity": 14, "constitution": 13, | ||
| "intelligence": 16, "wisdom": 12, "charisma": 10}, | ||
| "proficiency_bonus": 2, "armor_class": 12, "max_hp": 17, "current_hp": 17, | ||
| "spell_slots": {"1": {"maximum": 4, "used": 0}, "2": {"maximum": 2, "used": 0}}, | ||
| "spells_known": ["Fire Bolt", "Magic Missile", "Shield"], | ||
| "spells_prepared": ["Magic Missile", "Scorching Ray"], | ||
| # Engine-populated class/subclass feature NAMES (srd_tables.features_through). | ||
| "features": ["Arcane Recovery", "Evocation Savant", "Sculpt Spells"], | ||
| }, | ||
| "thornwick": { | ||
| "id": "thornwick", "name": "Thornwick", "kind": "player", "race": "Human", | ||
| "alignment": "Lawful Neutral", | ||
| "classes": [{"name": "Fighter", "level": 4, "subclass": "Champion"}], | ||
| "abilities": {"strength": 16, "dexterity": 12, "constitution": 14, | ||
| "intelligence": 10, "wisdom": 11, "charisma": 9}, | ||
| "proficiency_bonus": 2, "armor_class": 18, "max_hp": 36, "current_hp": 36, | ||
| "features": ["Second Wind", "Action Surge", "Improved Critical"], | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
|
|
||
| class _QuietHandler(server._Handler): | ||
| def log_message(self, fmt: str, *args: object) -> None: | ||
| return | ||
|
|
||
|
|
||
| class CharsheetDepthTests(unittest.TestCase): | ||
| # ── direct unit coverage of the new read-model helpers ────────────────────── | ||
|
|
||
| def test_caster_spellcasting_summary(self): | ||
| """Wizard L3, INT 16: DC = 8 + prof(2) + int_mod(+3) = 13; attack = prof + mod = +5.""" | ||
| cast = server._character_spellcasting(_SNAPSHOT["characters"]["elara"]) | ||
| self.assertIsNotNone(cast) | ||
| self.assertEqual(cast["ability"], "intelligence") | ||
| self.assertEqual(cast["abilityShort"], "int") | ||
| self.assertEqual(cast["spellSaveDc"], 13) | ||
| self.assertEqual(cast["spellAttackBonus"], 5) | ||
|
|
||
| def test_noncaster_has_no_fabricated_spellcasting(self): | ||
| """A Fighter has no SRD caster class -> summary is None (no fake DC/attack).""" | ||
| self.assertIsNone(server._character_spellcasting(_SNAPSHOT["characters"]["thornwick"])) | ||
| self.assertIsNone(server._spell_save_dc(_SNAPSHOT["characters"]["thornwick"])) | ||
| self.assertIsNone(server._spell_attack_bonus(_SNAPSHOT["characters"]["thornwick"])) | ||
|
|
||
| def test_dc_and_attack_track_proficiency_and_ability(self): | ||
| """Higher level + ability => higher DC/attack, proving derivation (not hardcoded).""" | ||
| higher = copy.deepcopy(_SNAPSHOT["characters"]["elara"]) | ||
| higher["classes"][0]["level"] = 5 | ||
| higher["proficiency_bonus"] = 3 | ||
| higher["abilities"]["intelligence"] = 18 | ||
| cast = server._character_spellcasting(higher) | ||
| # prof 3, int_mod(18) = +4 -> DC 8+3+4 = 15; attack 3+4 = +7 | ||
| self.assertEqual(cast["spellSaveDc"], 15) | ||
| self.assertEqual(cast["spellAttackBonus"], 7) | ||
|
|
||
| # ── end-to-end via the real /character-surface route ──────────────────────── | ||
|
|
||
| def setUp(self): | ||
| self._tmp = Path(self.enterContext(tempfile.TemporaryDirectory())) | ||
| self._old_state = os.environ.get("CLAWDND_STATE_DIR") | ||
| os.environ["CLAWDND_STATE_DIR"] = str(self._tmp) | ||
| _QuietHandler.campaign_id = "" | ||
| _QuietHandler.transcript_path = "" | ||
| _QuietHandler.chat_path = "" | ||
| _QuietHandler.pinned = False | ||
| self._httpd = server.ThreadingHTTPServer(("127.0.0.1", 0), _QuietHandler) | ||
| self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True) | ||
| self._thread.start() | ||
| self._host, self._port = self._httpd.server_address | ||
|
|
||
| def tearDown(self): | ||
| self._httpd.shutdown() | ||
| self._httpd.server_close() | ||
| self._thread.join(timeout=2) | ||
| if self._old_state is None: | ||
| os.environ.pop("CLAWDND_STATE_DIR", None) | ||
| else: | ||
| os.environ["CLAWDND_STATE_DIR"] = self._old_state | ||
|
|
||
| def _write(self, campaign_id: str, payload: dict) -> None: | ||
| cdir = self._tmp / "campaigns" / campaign_id | ||
| cdir.mkdir(parents=True) | ||
| (cdir / "snapshot.json").write_text(json.dumps(payload), encoding="utf-8") | ||
|
|
||
| def _get_json(self, path: str) -> tuple[int, dict]: | ||
| conn = http.client.HTTPConnection(self._host, self._port, timeout=5) | ||
| try: | ||
| conn.request("GET", path) | ||
| resp = conn.getresponse() | ||
| body = resp.read() | ||
| return resp.status, (json.loads(body.decode("utf-8")) if body else {}) | ||
| finally: | ||
| conn.close() | ||
|
|
||
| def _party(self, surface: dict) -> dict: | ||
| return {c["id"]: c for c in surface["party"]} | ||
|
|
||
| def test_surface_exposes_caster_spell_dc_and_attack(self): | ||
| self._write("camp_depth", _SNAPSHOT) | ||
| status, surface = self._get_json("/character-surface?campaign=camp_depth") | ||
| self.assertEqual(status, 200) | ||
| elara = self._party(surface)["elara"] | ||
| cast = elara["spellcasting"] | ||
| self.assertIsNotNone(cast) | ||
| self.assertEqual(cast["spellSaveDc"], 13) | ||
| self.assertEqual(cast["spellAttackBonus"], 5) | ||
| self.assertEqual(cast["abilityShort"], "int") | ||
|
|
||
| def test_surface_omits_spellcasting_for_non_caster(self): | ||
| self._write("camp_depth", _SNAPSHOT) | ||
| _status, surface = self._get_json("/character-surface?campaign=camp_depth") | ||
| thornwick = self._party(surface)["thornwick"] | ||
| # Key present for a stable shape, value None -> the Spells tab header omits itself. | ||
| self.assertIn("spellcasting", thornwick) | ||
| self.assertIsNone(thornwick["spellcasting"]) | ||
|
|
||
| def test_surface_surfaces_engine_class_features(self): | ||
| """Bug 2: the engine's `features` NAMES reach the surface (as classFeatures) so the | ||
| Abilities tab can render them instead of 'No active abilities recorded'.""" | ||
| self._write("camp_depth", _SNAPSHOT) | ||
| _status, surface = self._get_json("/character-surface?campaign=camp_depth") | ||
| elara = self._party(surface)["elara"] | ||
| names = [c["name"] for c in elara["classFeatures"]] | ||
| self.assertIn("Arcane Recovery", names) | ||
| self.assertIn("Evocation Savant", names) | ||
| # subclass (School of Magic) is surfaced as the archetype, so the tab has context | ||
| self.assertEqual(elara["archetype"], "School of Evocation") | ||
| # honest: the engine models feature NAMES, not descriptions -> detail is empty | ||
| arcane = next(c for c in elara["classFeatures"] if c["name"] == "Arcane Recovery") | ||
| self.assertEqual(arcane["detail"], "") | ||
|
|
||
| def test_surface_class_features_empty_when_engine_has_none(self): | ||
| """A character with no engine-populated features surfaces an empty list (honest), | ||
| not fabricated feature text.""" | ||
| snap = copy.deepcopy(_SNAPSHOT) | ||
| snap["characters"]["elara"]["features"] = [] | ||
| self._write("camp_empty", snap) | ||
| _status, surface = self._get_json("/character-surface?campaign=camp_empty") | ||
| elara = self._party(surface)["elara"] | ||
| self.assertEqual(elara["classFeatures"], []) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't mention racial features here unless you check
hero.raceTraits.Line 711 only proves
hero.abilitiesandhero.classFeaturesare empty, so a hero can still have projected racial traits and get told none are recorded.✏️ Suggested copy fix
🤖 Prompt for AI Agents