diff --git a/scripts/play.sh b/scripts/play.sh index 681cd2b6..ee3e64ad 100755 --- a/scripts/play.sh +++ b/scripts/play.sh @@ -182,6 +182,11 @@ pc = server.create_character( background=spec.get("background", "") or "", skills=spec.get("skills") or None, apply_srd_defaults=True, + # Loop-10 #383: player-authored identity prose from the wizard's Family/House + # + Biography inputs. PR #369 threaded both into the bindHero spec; this is + # where the engine seating path picks them up. Empty == today's behavior. + house=str(spec.get("house", "") or ""), + biography=str(spec.get("biography", "") or ""), ) # 265 portrait re-key: the wizard generated a unique face to a PROVISIONAL content-scope # portrait-pc- because the PC had no engine id yet. Now that create_character minted diff --git a/servers/engine/models.py b/servers/engine/models.py index aae7ea1d..91adbc1a 100644 --- a/servers/engine/models.py +++ b/servers/engine/models.py @@ -607,6 +607,13 @@ class Character(_StrictModel): classes: list[ClassLevel] = Field(default_factory=list) background: str = "" alignment: str = "" + # Loop-10 #383: player-authored identity from the Creation wizard's "Family / + # House" + "Biography" inputs. PR #369 wired both into the bindHero spec but + # the engine seating path dropped them at 4 sites (this model, create_character, + # play.sh, /character-surface). Empty == today's behavior (no field was set + # before — additive, deserializes existing snapshots unchanged). + house: str = "" + biography: str = "" abilities: AbilityScores = Field(default_factory=AbilityScores) proficiency_bonus: int = 2 diff --git a/servers/engine/server.py b/servers/engine/server.py index 428a5542..44e44e91 100644 --- a/servers/engine/server.py +++ b/servers/engine/server.py @@ -1211,6 +1211,8 @@ def create_character( location_id: str = "", add_to_party: bool = True, met: bool = False, + house: str = "", + biography: str = "", ) -> dict: """Create a character (player, companion, npc, or monster) and persist it. @@ -1262,6 +1264,12 @@ def create_character( armor_class=armor_class, initiative_bonus=scores.modifier(Ability.DEX), met=bool(met) or kind in ("player", "companion"), # PC/companion are always "met" + # Loop-10 #383: player-authored identity prose threaded from the + # Creation wizard's house + biography inputs. Empty strings are + # today's behavior — the projection drops them through too, so the + # /character-surface payload only carries them when set. + house=house, + biography=biography, ) if skills: # explicit skill choices win over the class default-fill ch.skill_proficiencies = [s.lower() for s in skills if s.lower() in SKILL_ABILITIES] diff --git a/servers/engine/tests/test_house_biography_persistence.py b/servers/engine/tests/test_house_biography_persistence.py new file mode 100644 index 00000000..3db49b7a --- /dev/null +++ b/servers/engine/tests/test_house_biography_persistence.py @@ -0,0 +1,123 @@ +"""Loop-10 #383: regression test for house + biography persistence across the +create_character -> Character model -> snapshot -> /character-surface chain. + +PR #369 wired both fields into the Creation wizard's bindHero spec. The engine +seating path used to silently drop them at four sites -- this test asserts they +now flow end-to-end: + + 1. create_character() accepts house= + biography= kwargs + 2. Character model stores both fields + 3. The persisted snapshot carries them on the PC record + 4. (NOT tested here -- viewer-side) /character-surface surfaces them as + hero.house + hero.biography for screen-character.jsx to render +""" + +from __future__ import annotations + +import server + + +def _new_campaign() -> str: + """Return a fresh campaign id ready for create_character calls. + + Matches the pattern in test_action_economy.py / test_adversarial_release.py + (server.create_campaign(name)["id"]), which is what other server-level + tests use to get a campaign_id without going through the seed_campaign + content-loader path. + """ + return server.create_campaign("House-Biography Persistence")["id"] + + +def test_create_character_accepts_house_and_biography_kwargs(): + """Sanity: the create_character signature actually carries the new kwargs. + + Calling with the kwargs must not TypeError. This guards the screen-create -> + bindHero -> startProviderSession -> play.sh -> create_character chain at its + engine endpoint. + """ + camp = _new_campaign() + out = server.create_character( + camp, + name="Aubree Test", + kind="player", + race="human", + class_name="ranger", + abilities={"strength": 12, "dexterity": 14, "constitution": 12, + "intelligence": 10, "wisdom": 13, "charisma": 11}, + background="folk-hero", + apply_srd_defaults=True, + house="Three Bells", + biography="Once carried a king's letter to a place that does not exist anymore.", + ) + assert isinstance(out, dict) and "id" in out + + +def test_house_and_biography_persist_on_the_character_model(): + """The model must carry both fields after creation, and they must survive + the save_campaign round-trip implicit in create_character. + """ + camp = _new_campaign() + rec = server.create_character( + camp, + name="Aubree Anvil", + kind="player", + race="dwarf", + class_name="fighter", + abilities={"strength": 16, "dexterity": 12, "constitution": 15, + "intelligence": 10, "wisdom": 11, "charisma": 8}, + background="folk-hero", + apply_srd_defaults=True, + house="Anvilforge", + biography="Three winters in the Iron Shield; one summer at the Spear Gate.", + ) + pc = server.get_character(camp, rec["id"]) + assert pc["house"] == "Anvilforge" + assert pc["biography"].startswith("Three winters in the Iron Shield") + + +def test_house_and_biography_default_to_empty_when_omitted(): + """Existing call sites (NPC spawn, monster spawn, companion seat) MUST keep + working -- the new kwargs default to "" and the snapshot must NOT carry a + None/null for either field on a character whose creator didn't supply them. + Guards the additive-only contract. + """ + camp = _new_campaign() + rec = server.create_character( + camp, + name="Stoic Stranger", + kind="npc", + ) + npc = server.get_character(camp, rec["id"]) + assert npc["house"] == "" + assert npc["biography"] == "" + + +def test_house_and_biography_round_trip_through_get_character(): + """End-to-end: explicit values written, snapshot serialized to a dict + (get_character returns the projected record), and the fields are present + + intact. This is the contract the viewer's /character-surface depends on. + """ + camp = _new_campaign() + rec = server.create_character( + camp, + name="Karlach Ember", + kind="player", + race="tiefling", + class_name="barbarian", + abilities={"strength": 17, "dexterity": 14, "constitution": 16, + "intelligence": 8, "wisdom": 10, "charisma": 12}, + background="outlander", + apply_srd_defaults=True, + house="Ember (foundling -- no kin recorded)", + 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." + ), + ) + pc = server.get_character(camp, rec["id"]) + # House + biography survived the model -> snapshot -> dict path + assert "Ember" in pc["house"] + assert "Hellfire" in pc["biography"] + # Sanity: pre-existing identity fields still work alongside the new ones + assert pc["race"] == "tiefling" + assert pc["background"] == "outlander" diff --git a/viewer/openworlds/screen-character.jsx b/viewer/openworlds/screen-character.jsx index d1d5dc3d..9520c48a 100644 --- a/viewer/openworlds/screen-character.jsx +++ b/viewer/openworlds/screen-character.jsx @@ -145,6 +145,14 @@ function ScreenCharacter({ onNavigate, state, setState }) {
{hero.alignment}

{hero.name}

+ {/* Loop-10 #383: player-authored house line under the name, italic and + muted so it reads as a subtitle, not a status. Renders only when + the wizard's Family/House input is set; honest empty otherwise. */} + {hero.house && ( +
+ of House {hero.house} +
+ )}
{[hero.race, hero.class, hero.archetype].map((s) => (s || "").trim()).filter(Boolean).join(" · ")}
@@ -1195,8 +1203,12 @@ function LineagePanel({ hero }) { const subrace = (hero.subrace || "").trim(); const traits = Array.isArray(hero.raceTraits) ? hero.raceTraits.filter(Boolean) : []; const note = (hero.lineageNote || "").trim(); + // Loop-10 #383: the player-authored biography is its own narrative source. A + // hero with no race/flavor but an authored biography must still render it — + // include it in the honest-empty guard so a bio-only hero isn't dropped. + const biography = (hero.biography || "").trim(); - if (!race && !note) { + if (!race && !note && !biography) { return

No lineage recorded for this hero.

; } @@ -1220,6 +1232,20 @@ function LineagePanel({ hero }) { {note}

)} + {/* Loop-10 #383: the wizard's "Biography" textarea, surfaced as its own + paragraph with a small eyebrow so the player can see their authored + prose preserved on the Character sheet. Distinct from lineageNote + (which pulls backstory/personality from the engine's NPC-flavor + fields) — biography is the PLAYER's longer narrative input. Render + only when set; honest empty otherwise. */} + {biography && ( +
+
Biography
+

+ {biography} +

+
+ )}
); } diff --git a/viewer/server.py b/viewer/server.py index 7af72320..50e97c2a 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -3645,6 +3645,14 @@ def _slot_lvl(k): "raceTraits": [_text(t) for t in (ch.get("racial_traits") or ch.get("race_traits") or []) if _text(t)], "lineageNote": _text(ch.get("backstory")) or _text(ch.get("personality")), "lineage": _text(ch.get("backstory")) or _text(ch.get("personality")) or "No lineage recorded.", + # Loop-10 #383: player-authored identity from the Creation wizard. PR + # #369 wired both into the bindHero spec; this projection is the read- + # model the Character screen renders from. Empty strings are honest — + # an authored hero with blank Family/House + Biography renders the + # screen with no House line and no Biography paragraph (today's UX for + # any unset narrative field, e.g. concentration, backstory). + "house": _text(ch.get("house")), + "biography": _text(ch.get("biography")), }