Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3b55348
feat(viewer): surface caster Spell Save DC + Attack Bonus on the char…
Jun 2, 2026
84e6092
feat(viewer): surface Hit Dice + Passive Perception on the character …
Jun 2, 2026
550f556
fix(viewer): synchronous in-flight lock on combat postMove (double-su…
Jun 2, 2026
2ae9d98
fix(viewer): synchronous in-flight lock on table postMove (double-sub…
Jun 2, 2026
20b79fe
fix(viewer): synchronous in-flight lock on parley pick/sendFreeForm (…
Jun 2, 2026
b0cf17b
feat(bestiary): reveal resistances/immunities at slain-tier (depth ch…
Jun 2, 2026
fb5a6eb
fix(merchant): haggle price arrow (no '2423' run-together) + Confirm …
Jun 2, 2026
a991f00
fix(merchant): live coin purse from /character-surface (kills Market-…
Jun 2, 2026
e4e9b99
feat(bestiary): 'Browse all' reference mode — codex usable before any…
Jun 2, 2026
3c3cda5
feat(merchant): item detail pane (optimizer #2 — Market had no proper…
Jun 2, 2026
16f5ef2
feat(dialogue): per-slot success-odds chip (BG3 'how hard is this roll')
Jun 2, 2026
c02367d
feat(character): join SRD feature DESCRIPTIONS into the read-model (w…
Jun 2, 2026
c59d7a0
feat(travel): route danger/difficulty/road-type readout on the atlas …
Jun 2, 2026
cae245c
feat(relations): surface faction membership (joined + rank) — join->g…
Jun 2, 2026
4854275
fix(engine): resolve 'any' skill placeholder at creation (Bard showed…
Jun 2, 2026
abe4d69
merge origin/main (#583 zone-bands etc.) into feat/depth-cheap-wins —…
Jun 2, 2026
c8dd6a2
test(fix): read skills via get_character (full sheet), not get_state …
Jun 2, 2026
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
8 changes: 8 additions & 0 deletions servers/engine/bestiary.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,14 @@ def intel_projection(name: str, tier: int) -> Optional[dict]:
out["abilities"] = dict(sb["abilities"])
if sb.get("saves"):
out["saves"] = dict(sb["saves"])
# #depth: at slain-tier, reveal the defenses you'd learn by killing it — the single most
# tactically load-bearing facts. stat_block populates these (lines ~394-397) but the tier-3
# reveal dropped them, so a player who SLEW an Adult Red Dragon couldn't learn it's
# fire-immune. Emit only non-empty lists (the hide-when-blank UI drops the rest).
for _rk in ("damage_resistances", "damage_immunities", "damage_vulnerabilities", "condition_immunities"):
_rv = sb.get(_rk)
if _rv:
out[_rk] = list(_rv)
out["known_actions"] = [
str(a.get("name", "")).strip() for a in sb.get("actions", []) if a.get("name")
][:8]
Expand Down
11 changes: 10 additions & 1 deletion servers/engine/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,7 +1091,16 @@ def _apply_srd_class_defaults(ch, class_name: str, level: int, set_base_ac: bool
# `skills` list to choose; this only fills an otherwise-empty list.
if not ch.skill_proficiencies:
sk = srd_tables.class_skills(cname)
ch.skill_proficiencies = list(sk.get("from", []))[: int(sk.get("count", 0))]
pool = [str(s).strip().lower() for s in sk.get("from", []) if str(s).strip()]
# A "choose any N skills" class (Bard, etc.) encodes its pool as the placeholder
# ["any"] — which is NOT a real skill. Persisting it literally renders 0 proficiencies
# on the sheet (QA: optimizer crit — a level-1 Bard showed no skills and bailed).
# Expand "any" to the full skill list so we store concrete proficiencies, keeping
# any explicitly-listed real skills first.
if "any" in pool:
explicit = [s for s in pool if s != "any" and s in SKILL_ABILITIES]
pool = explicit + [s for s in SKILL_ABILITIES if s not in explicit]
ch.skill_proficiencies = pool[: int(sk.get("count", 0))]
_recompute_spellcasting(ch)
_seed_starting_spells(ch, cname, level)
_recompute_class_resources(ch)
Expand Down
15 changes: 15 additions & 0 deletions servers/engine/tests/test_adversarial_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,18 @@ def test_issue51_campaign_new_command_creates_one_campaign():
text = (_ROOT / "commands" / "campaign-new.md").read_text(encoding="utf-8")
assert "create_campaign` to get a campaign id, then" not in text # the two-campaign instruction
assert "start_adventure(adventure_id)" in text and "Do NOT call `create_campaign` first" in text


def test_any_skill_class_resolves_to_concrete_skills():
# QA (optimizer crit, sweep_v7): a "choose any N skills" class (Bard's class_skills =
# {count:3, from:['any']}) must persist CONCRETE skill proficiencies, not the literal
# ['any'] placeholder — which matches no skill and rendered 0 proficiencies on the sheet,
# making a min-maxer bail. Creation now expands 'any' to the real skill pool.
cid = _campaign()
bard = server.create_character(cid, "Lute", kind="player", class_name="Bard",
level=1, apply_srd_defaults=True)["id"]
ch = server.get_character(cid, bard) # FULL sheet — get_state party is only a vitals summary
skills = ch.get("skill_proficiencies") or []
assert skills, "Bard should have default skill proficiencies, not an empty sheet"
assert "any" not in skills, f"unresolved 'any' placeholder persisted: {skills}"
assert len(skills) == 3, f"Bard should get 3 concrete skills, got {skills}"
12 changes: 12 additions & 0 deletions servers/engine/tests/test_bestiary.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,18 @@ def test_intel_projection_tier_gating():
assert set(t2.keys()) - {"tier"} <= set(t3.keys())


def test_intel_projection_tier3_reveals_resistances_and_immunities():
"""#depth regression guard: slain-tier (3) must reveal damage resistances/immunities/
vulnerabilities + condition immunities — the most tactically load-bearing facts. stat_block
populates them but the tier-3 reveal previously DROPPED them. The Adult Red Dragon is
fire-immune; tier 2 (engaged) must not reveal it yet (strict gating)."""
t3 = bestiary.intel_projection("Adult Red Dragon", 3)
assert t3 is not None
assert "fire" in (t3.get("damage_immunities") or [])
t2 = bestiary.intel_projection("Adult Red Dragon", 2)
assert "damage_immunities" not in t2 # defenses gated until slain-tier


def test_player_bestiary_no_intel_is_back_compat():
"""player_bestiary() with no intel is BYTE-identical to the pre-#263 preview surface."""
out = bestiary.player_bestiary("goblin", 10)
Expand Down
48 changes: 47 additions & 1 deletion viewer/openworlds/screen-bestiary.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ function liveBestiaryEntry(item) {
stats: (item?.abilities && typeof item.abilities === "object") ? item.abilities : undefined,
tactics: item?.tactics ? String(item.tactics) : "",
knownActions: Array.isArray(item?.known_actions) ? item.known_actions.filter((a) => String(a).trim()) : [],
// #depth: tier-3 (slain) defenses — the most tactically load-bearing facts (the engine now
// passes these through intel_projection). Each row hidden when blank.
resistances: Array.isArray(item?.damage_resistances) ? item.damage_resistances.filter((x) => String(x).trim()) : [],
immunities: Array.isArray(item?.damage_immunities) ? item.damage_immunities.filter((x) => String(x).trim()) : [],
vulnerabilities: Array.isArray(item?.damage_vulnerabilities) ? item.damage_vulnerabilities.filter((x) => String(x).trim()) : [],
conditionImmunities: Array.isArray(item?.condition_immunities) ? item.condition_immunities.filter((x) => String(x).trim()) : [],
contentOrigin: String(item?.content_origin || "srd"),
source: item?.source ? String(item.source) : "",
license: item?.license ? String(item.license) : "",
Expand All @@ -121,6 +127,11 @@ function ScreenBestiary({ onNavigate, state, setState }) {
const [tab, setTab] = React.useState("creatures");
const [selected, setSelected] = React.useState(null);
const [filter, setFilter] = React.useState("");
// BE-depth (optimizer #1): "Browse all" reference mode. The intel codex (#263) is
// fog-of-war until the party SLAYS creatures, so in real play it reads "zero creature
// names." This toggles ?reference=1 → the public SRD preview for every creature (name +
// CR + the preview stat line), making the codex useful from turn one. Off = earned-intel.
const [browseAll, setBrowseAll] = React.useState(false);
// Live codex from /bestiary-surface; null until the first successful fetch.
const [liveCreatures, setLiveCreatures] = React.useState(null);
// World/region label for the codex eyebrow. Data-driven when the surface carries a label
Expand All @@ -136,6 +147,9 @@ function ScreenBestiary({ onNavigate, state, setState }) {
try {
const params = new URLSearchParams(surfaceQuery.replace(/^\?/, ""));
if (q) params.set("q", q); else params.delete("q");
// Browse-all: bypass earned intel (?reference=1) + widen the page so the SRD browse
// returns a useful spread, not just the first 20.
if (browseAll) { params.set("reference", "1"); params.set("limit", "50"); }
const qs = params.toString();
const response = await fetch("/bestiary-surface" + (qs ? "?" + qs : ""), { cache: "no-store" });
if (!response.ok) throw new Error(`bestiary surface ${response.status}`);
Expand All @@ -151,7 +165,7 @@ function ScreenBestiary({ onNavigate, state, setState }) {
if (isCancelled()) return;
/* keep the last good surface; the empty-state shows until the first success */
}
}, [surfaceQuery]);
}, [surfaceQuery, browseAll]);

React.useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -194,6 +208,17 @@ function ScreenBestiary({ onNavigate, state, setState }) {
<Panel framed style={{ padding: 22, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div className="eyebrow" style={{ color: "var(--crimson)" }}>Encyclopaedia of</div>
<h2 className="h1" style={{ fontSize: 22 }}>{worldLabel || "the Sword Coast"}</h2>
{/* BE-depth: toggle the fog-of-war intel codex vs the full public SRD reference browse,
so the codex isn't useless before the party has slain anything (optimizer #1). */}
<button
onClick={() => setBrowseAll((v) => !v)}
className="btn ghost sm"
aria-pressed={browseAll}
style={{ marginTop: 6, fontSize: 10, alignSelf: "flex-start" }}
title={browseAll ? "Showing every creature (public SRD reference)" : "Showing only creatures your party has encountered — click to browse all"}
>
{browseAll ? "✓ Browse all" : "Browse all"}
</button>
<Divider />

<div style={{ display: "flex", gap: 4, marginBottom: 12 }}>
Expand Down Expand Up @@ -377,6 +402,27 @@ function BestiaryEntry({ entry, tab }) {
</>
)}

{/* #depth: Defenses — resistances/immunities/vulnerabilities/condition-immunities learned at
slain-tier (the single most tactically load-bearing facts). Each row hidden when empty. */}
{((entry.immunities && entry.immunities.length) || (entry.resistances && entry.resistances.length) || (entry.vulnerabilities && entry.vulnerabilities.length) || (entry.conditionImmunities && entry.conditionImmunities.length)) ? (
<>
<Divider />
<SectionTitle>Defenses</SectionTitle>
{entry.immunities && entry.immunities.length > 0 && (
<div className="tag-row" style={{ marginTop: 6 }}><span className="eyebrow" style={{ marginRight: 6 }}>Immune</span>{entry.immunities.map((x) => <Pill key={`im-${x}`}>{x}</Pill>)}</div>
)}
{entry.resistances && entry.resistances.length > 0 && (
<div className="tag-row" style={{ marginTop: 6 }}><span className="eyebrow" style={{ marginRight: 6 }}>Resist</span>{entry.resistances.map((x) => <Pill key={`re-${x}`}>{x}</Pill>)}</div>
)}
{entry.vulnerabilities && entry.vulnerabilities.length > 0 && (
<div className="tag-row" style={{ marginTop: 6 }}><span className="eyebrow" style={{ marginRight: 6 }}>Vulnerable</span>{entry.vulnerabilities.map((x) => <Pill key={`vu-${x}`}>{x}</Pill>)}</div>
)}
{entry.conditionImmunities && entry.conditionImmunities.length > 0 && (
<div className="tag-row" style={{ marginTop: 6 }}><span className="eyebrow" style={{ marginRight: 6 }}>Cond. Immune</span>{entry.conditionImmunities.map((x) => <Pill key={`ci-${x}`}>{x}</Pill>)}</div>
)}
</>
) : null}

{/* Provenance — authored (non-SRD) content credits its source/license. */}
{entry.contentOrigin === "authored" && (entry.source || entry.license || entry.provenance) && (
<>
Expand Down
27 changes: 27 additions & 0 deletions viewer/openworlds/screen-character.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,13 @@ function ScreenCharacter({ onNavigate, state, setState }) {
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 4, marginTop: 8 }}>
<StatLine k="AC" v={hero.stats.ac} />
<StatLine k="Speed" v={`${hero.stats.speed} ft`} />
{/* #depth: read-model now emits passivePerception + hitDice/hitDiceRemaining (server.py) */}
{hero.stats.passivePerception != null ? (
<StatLine k="Passive Perc" v={hero.stats.passivePerception} />
) : null}
{hero.stats.hitDice ? (
<StatLine k="Hit Dice" v={`${hero.stats.hitDiceRemaining}/${hero.stats.hitDice}`} />
) : null}
</div>

<Divider />
Expand Down Expand Up @@ -1048,6 +1055,26 @@ function SpellsTab({ hero }) {
return (
<div>
<SectionTitle ordinal="·" right={browseCta}>Spellbook</SectionTitle>
{/* #depth: surface the caster header the read-model already computes (hero.spellcasting:
{abilityShort, spellSaveDc, spellAttackBonus}, server.py _character_spellcasting). Omitted
for non-casters (spellcasting null). The optimizer persona's #1-cited missing number. */}
{hero.spellcasting ? (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", margin: "6px 0 14px" }}>
{[
`Spell Save DC ${hero.spellcasting.spellSaveDc}`,
`Spell Attack ${hero.spellcasting.spellAttackBonus >= 0 ? "+" : ""}${hero.spellcasting.spellAttackBonus}`,
`${String(hero.spellcasting.abilityShort || "").toUpperCase()} casting`,
].map((label) => (
<span key={label} style={{
padding: "4px 11px",
background: "rgba(176,141,87,0.12)",
boxShadow: "inset 0 0 0 1px rgba(140,100,60,0.3)",
fontFamily: "var(--f-display)", fontSize: 11, letterSpacing: "0.06em",
color: "var(--ink-900)", whiteSpace: "nowrap",
}}>{label}</span>
))}
</div>
) : null}
<SpellSlotTrack slots={slots} />
{groups.length ? (
groups.map((group) => (
Expand Down
7 changes: 7 additions & 0 deletions viewer/openworlds/screen-combat.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ function ScreenCombat({ onNavigate, state }) {
const [selectedToken, setSelectedToken] = React.useState("");
const [localLog, setLocalLog] = React.useState([]);
const [busyAction, setBusyAction] = React.useState("");
// #robustness: synchronous in-flight lock. setBusyAction is async (state read at the disabled
// check is stale), so two rapid clicks before re-render both pass — the adversarial's "Attack
// dies on double-click" / double-submit vector. busyRef gates synchronously.
const busyRef = React.useRef(false);
const toast = window.useToast ? window.useToast() : (() => {});

const loadSurface = React.useCallback(async (isCancelled = () => false) => {
Expand Down Expand Up @@ -130,6 +134,8 @@ function ScreenCombat({ onNavigate, state }) {
});
return;
}
if (busyRef.current) return; // already submitting — drop the rapid double-click / double-Enter
busyRef.current = true;
setBusyAction(action.id);
try {
const response = await fetch("/move", {
Expand All @@ -154,6 +160,7 @@ function ScreenCombat({ onNavigate, state }) {
} catch (error) {
toast({ kind: "danger", title: "Move not sent", body: error?.message || "The viewer could not reach /move." });
} finally {
busyRef.current = false;
setBusyAction("");
}
};
Expand Down
30 changes: 25 additions & 5 deletions viewer/openworlds/screen-dialogue.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ function ParleyMenu({ surface, slots, difficulty, setDifficulty, history, setHis
// actual text (not a hardcoded placeholder). Send is disabled until something is typed.
const [freeFormMode, setFreeFormMode] = React.useState(false);
const [userText, setUserText] = React.useState("");
// #robustness: synchronous in-flight lock — pick()/sendFreeForm() fire /move fire-and-forget with
// no guard, so a rapid double-click queues duplicate social-check/say intents. Cleared in .finally.
const submittingRef = React.useRef(false);

const pick = (slot) => {
const move = { kind: "check", name: `${slot.label} (DC ${slot.suggested_dc})`, skill: slot.skill, dc: slot.suggested_dc, text: `attempts ${slot.label} (DC ${slot.suggested_dc})` };
Expand All @@ -121,14 +124,16 @@ function ParleyMenu({ surface, slots, difficulty, setDifficulty, history, setHis
toast({ kind: "danger", eyebrow: "Parley", title: "Preview — no live DM", body: "Open a chronicle from Chronicles to converse; then the DM voices and adjudicates the approach you pick." });
return;
}
if (submittingRef.current) return; // already submitting a check — drop the rapid double-click
submittingRef.current = true;
fetch("/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...move, campaign: surface.campaign_id || "" }),
}).then((r) => r.json().catch(() => ({}))).then((payload) => {
if (payload && payload.ok === false) throw new Error(payload.reason || "move rejected");
toast({ kind: "item", eyebrow: "Parley", title: `${actorName} — ${slot.label}`, body: `Requested a ${slot.label} check at DC ${slot.suggested_dc}.` });
}).catch((e) => toast({ kind: "danger", title: "Move not sent", body: e?.message || "The viewer could not reach /move." }));
}).catch((e) => toast({ kind: "danger", title: "Move not sent", body: e?.message || "The viewer could not reach /move." })).finally(() => { submittingRef.current = false; });
};

const openFreeForm = () => {
Expand All @@ -142,6 +147,8 @@ function ParleyMenu({ surface, slots, difficulty, setDifficulty, history, setHis
const sendFreeForm = () => {
const text = userText.trim();
if (!text) return;
if (submittingRef.current) return; // already sending — drop the rapid double-click / double-Enter
submittingRef.current = true;
fetch("/move", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "say", text, campaign: surface.campaign_id || "" }),
Expand All @@ -151,7 +158,7 @@ function ParleyMenu({ surface, slots, difficulty, setDifficulty, history, setHis
setUserText("");
setFreeFormMode(false);
toast({ kind: "item", eyebrow: "Parley", title: `${actorName} — Free-form`, body: "Spoke their own words — the DM adjudicates." });
}).catch((e) => toast({ kind: "danger", title: "Move not sent", body: e?.message || "The viewer could not reach /move." }));
}).catch((e) => toast({ kind: "danger", title: "Move not sent", body: e?.message || "The viewer could not reach /move." })).finally(() => { submittingRef.current = false; });
};

return (
Expand Down Expand Up @@ -242,10 +249,18 @@ function ParleyMenu({ surface, slots, difficulty, setDifficulty, history, setHis

{/* Skill slot choices */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{slots.map((slot, i) => (
{slots.map((slot, i) => {
// Dialogue-depth (BG3 "how hard is this roll"): a per-slot success-odds chip.
// p = passing d20 faces / 20, clamped to [5%,95%] (nat-1 always fails, nat-20
// always succeeds). Pure client-derived from the modifier + suggested_dc the
// surface already carries — no engine/read-model change.
const oddsFaces = Math.max(1, Math.min(19, 21 - (slot.suggested_dc - slot.modifier)));
const oddsPct = Math.round(oddsFaces / 20 * 100);
const oddsTone = oddsPct >= 65 ? "var(--emerald)" : oddsPct >= 35 ? "#c9a227" : "var(--crimson)";
return (
<button key={slot.skill} onClick={() => pick(slot)} style={{
display: "grid",
gridTemplateColumns: "24px 1fr auto auto",
gridTemplateColumns: "24px 1fr auto auto auto",
gap: 10, alignItems: "center",
padding: "8px 12px",
textAlign: "left",
Expand Down Expand Up @@ -277,8 +292,13 @@ function ParleyMenu({ surface, slots, difficulty, setDifficulty, history, setHis
<span style={{ fontFamily: "var(--f-display)", color: "var(--b-500)", fontSize: 12, letterSpacing: "0.1em" }}>
DC {slot.suggested_dc}
</span>
<span title={`Roll d20 ${slot.modifier >= 0 ? "+" : ""}${slot.modifier} vs DC ${slot.suggested_dc} → about ${oddsPct}% to succeed`}
style={{ fontFamily: "var(--f-mono)", fontSize: 11, fontWeight: 700, color: oddsTone, minWidth: 36, textAlign: "right" }}>
{oddsPct}%
</span>
</button>
))}
);
})}

{/* Free-form path — always present (free_form is always true). When active,
reveal a textarea so the player types their OWN line and POSTs that text. */}
Expand Down
Loading
Loading