fix(openworlds): persist house + biography in engine snapshot (Closes #383) - #392
fix(openworlds): persist house + biography in engine snapshot (Closes #383)#392100yenadmin wants to merge 2 commits into
Conversation
…383) Loop-10 follow-up to #277. PR #369 wired house + biography from the Creation wizard's inputs into the bindHero spec, but the engine seating path silently dropped both fields at FIVE sites: the Character model, create_character's signature, the Character() constructor call, play.sh's spec-to-create_character bridge, and the /character-surface projection. A player who authored House = "Three Bells" + Biography = "..." in the wizard saw both vanish the moment they hit Bind. This PR threads both fields end-to-end + adds the UI rendering + the regression test the parent issue's AC5 asked for. The five sites -------------- 1. servers/engine/models.py — Character model Added `house: str = ""` and `biography: str = ""` in the identity block (next to race/classes/background/alignment). Both are simple Optional[str]-style fields with "" default. _StrictModel REJECTS extra fields, so this PR is what makes accepting these kwargs possible at all. Defaults preserve backward-compat: existing snapshots without the keys deserialize to "" with no migration. 2. servers/engine/server.py — create_character Added `house: str = ""` + `biography: str = ""` kwargs at the end of the signature (after `met=False`) so positional callers stay working. Threaded both into the Character(...) constructor inside the campaign_lock so the persist path picks them up. The save then carries them into the snapshot per the normal model save flow. 3. scripts/play.sh — wizard-spec bridge The embedded Python that translates the bindHero spec JSON into create_character kwargs now reads both fields from the spec dict (with "" coalesce for null safety) and passes them. This is the single place where the wizard's prose actually crosses the process boundary into the engine. 4. viewer/server.py — /character-surface projection Added "house" and "biography" to the _character_sheet return dict, projected via _text() so they're string-safe for the read- model contract (never None or non-string). The Character screen reads this projection. 5. viewer/openworlds/screen-character.jsx — render - Hero header card: subtle italic "of House {hero.house}" line between the name and the race/class line, only rendered when hero.house is non-empty. Reads as a subtitle to the name. - LineagePanel (right column): "Biography" section with eyebrow label and a whiteSpace:pre-wrap paragraph for the player's authored narrative. Only renders when hero.biography is non- empty. Distinct from `lineageNote` (which projects from the engine's backstory/personality fields) so an authored hero's biography never overrides the lineage prose. Tests ----- New: servers/engine/tests/test_house_biography_persistence.py - test_create_character_accepts_house_and_biography_kwargs Sanity: the signature actually carries the new kwargs (no TypeError on call). - test_house_and_biography_persist_on_the_character_model The model carries both fields after creation and the save_campaign round-trip inside create_character preserves them. Asserts via get_state. - test_house_and_biography_default_to_empty_when_omitted Existing call sites (NPC/monster spawn without the new kwargs) keep working — defaults are "" + the snapshot carries "" not None. - test_house_and_biography_round_trip_through_get_state End-to-end: explicit values written, snapshot serialized to dict via get_state, fields present + intact. This is the contract the viewer's /character-surface depends on. Pre-existing identity fields (race, background) still work alongside the new ones. Verification ------------ - All 5 modified .py files parse via `ast.parse()`. - Brace + paren balance clean on screen-character.jsx (652/652, 540/540). - Test follows the canonical pattern from test_action_economy.py + test_adversarial_release.py (server.create_campaign(name)["id"]). - Snapshot round-trip relies on the existing Pydantic _StrictModel ser/de path — no model_validate / dump customization needed because the new fields are plain `str = ""`. Acceptance criteria (per #383) ------------------------------ - [x] Pydantic field added (house, biography) - [x] create_character signature accepts both kwargs - [x] play.sh threads both from spec to create_character - [x] /character-surface projects both - [x] screen-character.jsx renders both - [x] Pytest asserts the contract end-to-end (4 tests covering kwargs accept, persistence, defaults, round-trip) Out of scope ------------ - BACKSTORY MERGE: the issue body called out that #383 should NOT rename `personality`/`backstory` or merge `biography` into them. These remain DISTINCT fields with distinct sources. Lineage prose from engine-side flavor stays in `lineageNote`; player-authored biography stays in `biography`. - Player-rename mid-play: this PR seeds house+bio at character creation time. There's no update_character path here for player- authored prose — out of scope until a "rename / edit your hero" affordance lands. - Companion / NPC house + biography: the engine model now CARRIES the fields for all characters, but only the player wizard authors them. Companions/NPCs default "" and remain so unless some future seed or update path sets them. Collision audit --------------- - servers/engine/models.py — last touched on main by PR #355. This PR adds 2 fields to an existing class. No overlap. - servers/engine/server.py — last touched on main by PR #355. This PR extends create_character. No overlap with #355's max_hp work. - scripts/play.sh — last touched on main by PR #360 (DM resolver fallback). This PR extends the create_character call inside a different python block. No overlap. - viewer/server.py — last touched on main by PR #370 (#286 feats fix). This PR adds 2 entries to the _character_sheet return dict. No overlap. - viewer/openworlds/screen-character.jsx — last touched on main by PR #364. This PR adds conditionally-rendered nodes that only appear when fields are set. No overlap with #364's surface. - Main agent's open #388 — cold-open DM territory, zero overlap. Refs ---- - Closes #383 (Loop-10 follow-up: persist house + biography in engine snapshot) - Parent #277 (Creation wizard wires the fields; PR #369 closed the viewer half) - Builds on PR #369 (the wizard-side fix); completes the chain to the engine
📝 WalkthroughWalkthroughThis PR adds two new optional player-authored identity fields to the character system: ChangesHouse and Biography Character Fields
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
…383) CI revealed get_state returns a summary shape (party as list of dicts, no top-level 'characters' key). get_character is the right tool for asserting Character model fields — it returns ch.model_dump(mode="json") which carries every field on the model, including the new house + biography. Changes ------- - test_house_and_biography_persist_on_the_character_model - test_house_and_biography_default_to_empty_when_omitted - test_house_and_biography_round_trip_through_get_state All three now call: pc = server.get_character(camp, rec["id"]) instead of: snapshot = server.get_state(camp) pc = snapshot["characters"][rec["id"]] # KeyError The 4th test (kwargs accept) was already passing; unchanged.
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
viewer/openworlds/screen-character.jsx (1)
771-773:⚠️ Potential issue | 🟠 Major | ⚡ Quick winBiography is skipped when race/note are empty due to early return.
Line 771 short-circuits before the new biography block, so a hero with only
biographyset still shows “No lineage recorded” and never renders biography.Suggested fix
- if (!race && !note) { + const biography = (hero.biography || "").trim(); + if (!race && !note && !biography) { return <p className="body muted" style={{ marginTop: 0, fontSize: 14 }}>No lineage recorded for this hero.</p>; } @@ - {(hero.biography || "").trim() && ( + {biography && ( <div style={{ marginTop: (note || race) ? 12 : 0 }}> <div className="eyebrow" style={{ fontSize: 11, letterSpacing: "0.14em", color: "var(--ink-600)", marginBottom: 4 }}>Biography</div> <p className="body" style={{ margin: 0, fontSize: 14, color: "var(--ink-700)", whiteSpace: "pre-wrap" }}> - {hero.biography} + {hero.biography} </p> </div> )}Also applies to: 801-808
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@viewer/openworlds/screen-character.jsx` around lines 771 - 773, The early return checks only race and note causing biography to be skipped; update the condition that returns the "No lineage recorded" paragraph to also check biography (i.e., change the if (!race && !note) to if (!race && !note && !biography>) or alternatively move the biography rendering above that return so a non-empty biography is rendered; apply the same fix to the similar block around the code handling lines 801-808 to ensure biography is considered there too.
🧹 Nitpick comments (1)
servers/engine/tests/test_house_biography_persistence.py (1)
76-77: ⚡ Quick winStrengthen round-trip assertions to exact equality.
For persistence regressions,
startswith/substring checks can pass even with truncation or unexpected mutation. Prefer asserting full string equality forhouseandbiographyin these checks.Suggested assertion tightening
- assert pc["biography"].startswith("Three winters in the Iron Shield") + assert pc["biography"] == "Three winters in the Iron Shield; one summer at the Spear Gate." ... - assert "Ember" in pc["house"] - assert "Hellfire" in pc["biography"] + assert pc["house"] == "Ember (foundling — no kin recorded)" + assert pc["biography"] == ( + "Born into the Avernus engine-shops with the Hellfire still in her chest. " + "The infernal contract was cut; the heart is hers again. The forge owes her a name." + )Also applies to: 121-122
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@servers/engine/tests/test_house_biography_persistence.py` around lines 76 - 77, The assertions use startswith which allows truncation; change both checks to strict equality: replace the assertion on pc["house"] and pc["biography"] to assert pc["house"] == "Anvilforge" and assert pc["biography"] == "Three winters in the Iron Shield..." (use the full expected biography string present in the test), and make the same exact-equality replacements for the corresponding assertions around lines 121-122 so the round-trip persistence test validates exact matches for pc["house"] and pc["biography"].
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@viewer/openworlds/screen-character.jsx`:
- Around line 771-773: The early return checks only race and note causing
biography to be skipped; update the condition that returns the "No lineage
recorded" paragraph to also check biography (i.e., change the if (!race &&
!note) to if (!race && !note && !biography>) or alternatively move the biography
rendering above that return so a non-empty biography is rendered; apply the same
fix to the similar block around the code handling lines 801-808 to ensure
biography is considered there too.
---
Nitpick comments:
In `@servers/engine/tests/test_house_biography_persistence.py`:
- Around line 76-77: The assertions use startswith which allows truncation;
change both checks to strict equality: replace the assertion on pc["house"] and
pc["biography"] to assert pc["house"] == "Anvilforge" and assert pc["biography"]
== "Three winters in the Iron Shield..." (use the full expected biography string
present in the test), and make the same exact-equality replacements for the
corresponding assertions around lines 121-122 so the round-trip persistence test
validates exact matches for pc["house"] and pc["biography"].
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 54f2b378-58db-4fa4-aa55-ed24539ee02f
📒 Files selected for processing (6)
scripts/play.shservers/engine/models.pyservers/engine/server.pyservers/engine/tests/test_house_biography_persistence.pyviewer/openworlds/screen-character.jsxviewer/server.py
…383) (#656) Loop-10 follow-up to #277/#369. PR #369 wired `house` + `biography` from the Creation wizard's inputs into the bindHero spec, but the engine seating path silently dropped both fields. A player who authored House + Biography in the wizard saw both vanish the moment they hit Bind. Threads both fields end-to-end (additive, schema additive-only): 1. servers/engine/models.py Character gains house/biography (str = "") 2. servers/engine/server.py create_character kwargs + constructor 3. scripts/play.sh bindHero spec -> create_character bridge 4. viewer/server.py /character-surface read-model projection 5. screen-character.jsx "of House X" subtitle + Biography paragraph Empty strings are honest no-ops — additive defaults deserialize existing snapshots unchanged; NPC/monster spawn call sites keep working. Regression guard: servers/engine/tests/test_house_biography_persistence.py (4 tests: kwargs accept, model persistence, empty defaults, round-trip). Revives PR #392 (rebased onto current main; LineagePanel empty-guard also includes biography so a bio-only hero isn't dropped). Co-authored-by: Eva <arncalso@gmail.com>
|
Superseded by #656 (merged) — a fresh re-implementation off current main with a 4-test regression guard. Closing the stale branch. |
TL;DR
Loop-10 follow-up to #277. PR #369 wired
house+biographyfrom the Creation wizard's inputs into thebindHerospec, but the engine seating path silently dropped both fields at FIVE sites: the Character model,create_character's signature, theCharacter()constructor call,play.sh's spec-to-create_character bridge, and the/character-surfaceprojection. A player who authoredHouse = "Three Bells"+Biography = "..."in the wizard saw both vanish the moment they hit Bind.This PR threads both fields end-to-end + adds the UI rendering + the regression test the parent issue's AC5 asked for.
Closes
The five sites threaded
servers/engine/models.pyCharactermodel gainshouse: str = ""andbiography: str = ""in the identity block._StrictModelrejects extra fields — adding them at the model is what makes accepting these kwargs possible.servers/engine/server.pycreate_charactersignature accepts both kwargs (positional callers stay working) + theCharacter(...)constructor call threads them insidecampaign_lock.scripts/play.shcreate_characterkwargs now reads both fields from the spec dict with""coalesce. This is the single place where the wizard's prose crosses the process boundary into the engine.viewer/server.py_character_sheetprojects"house"and"biography"to the/character-surfaceread-model via_text()for string safety.viewer/openworlds/screen-character.jsxLineagePanelrenders a "Biography" section with eyebrow label +whiteSpace: pre-wrapparagraph when set. Both honest-empty otherwise.Tests
New:
servers/engine/tests/test_house_biography_persistence.py— 4 teststest_create_character_accepts_house_and_biography_kwargsTypeErroron calltest_house_and_biography_persist_on_the_character_modelget_state)test_house_and_biography_default_to_empty_when_omitted""notNonetest_house_and_biography_round_trip_through_get_stateTests follow the canonical pattern from
test_action_economy.py+test_adversarial_release.py(server.create_campaign(name)["id"]).Verification
ast.parseon all 5 modified .py + new testscreen-character.jsx_StrictModelser/destr = "")Acceptance criteria (per #383)
house,biography)create_charactersignature accepts both kwargsplay.shthreads both from spec tocreate_character/character-surfaceprojects bothscreen-character.jsxrenders bothOut of scope (intentionally)
personality/backstoryor mergebiographyinto them. These remain DISTINCT fields with distinct sources. Lineage prose from engine-side flavor stays inlineageNote; player-authored biography stays inbiography."".Collision audit
servers/engine/models.pyservers/engine/server.pyscripts/play.shviewer/server.pyviewer/openworlds/screen-character.jsxDO NOT MERGE yet
Per owner direction. CI will verify pytest correctness. Ready for review.
Refs
Summary by CodeRabbit
Release Notes