Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions scripts/play.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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-<hash> because the PC had no engine id yet. Now that create_character minted
Expand Down
7 changes: 7 additions & 0 deletions servers/engine/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions servers/engine/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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]
Expand Down
123 changes: 123 additions & 0 deletions servers/engine/tests/test_house_biography_persistence.py
Original file line number Diff line number Diff line change
@@ -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"
28 changes: 27 additions & 1 deletion viewer/openworlds/screen-character.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ function ScreenCharacter({ onNavigate, state, setState }) {
<div>
<div className="eyebrow" style={{ color: "var(--crimson)" }}>{hero.alignment}</div>
<h1 className="h1" style={{ marginTop: 2 }}>{hero.name}</h1>
{/* 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 && (
<div className="hand" style={{ fontSize: 14, color: "var(--ink-600)", marginTop: 0, fontStyle: "italic" }}>
of House {hero.house}
</div>
)}
<div className="hand" style={{ fontSize: 17, color: "var(--ink-700)", marginTop: 2 }}>
{[hero.race, hero.class, hero.archetype].map((s) => (s || "").trim()).filter(Boolean).join(" · ")}
</div>
Expand Down Expand Up @@ -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 <p className="body muted" style={{ marginTop: 0, fontSize: 14 }}>No lineage recorded for this hero.</p>;
}

Expand All @@ -1220,6 +1232,20 @@ function LineagePanel({ hero }) {
{note}
</p>
)}
{/* 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 && (
<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" }}>
{biography}
</p>
</div>
)}
</div>
);
}
Expand Down
8 changes: 8 additions & 0 deletions viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
}


Expand Down
Loading