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
54 changes: 43 additions & 11 deletions viewer/openworlds/screen-dialogue.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,16 @@ function ParleyEmpty({ status }) {

function ParleyMenu({ surface, slots, difficulty, setDifficulty, history, setHistory, onNavigate, toast }) {
const actorName = surface.actor || "Hero";
// #751: the conversation TARGET. The header used to name `surface.actor` (the lead PC) — a
// parley that named the wrong speaker. When the surface binds an NPC, name THAT NPC and pin to
// it for the interaction; the actor's sheet still drives the skill slots below. No npc -> the
// header keeps today's behavior (the lead speaker), so the no-target freeform parley is unchanged.
const npc = surface.npc && typeof surface.npc === "object" ? surface.npc : null;
const npcName = npc && npc.name ? npc.name : "";
const canAct = Boolean(surface.can_act);
// #615: reuse the Relations screen's DispositionDot meter (exported on window) for a live
// attitude read while talking — reads the existing disposition band (fine at 0 -> "neutral").
const DispositionDot = window.DispositionDot;
const sceneScope = surface.imageScope ||
(surface.location_id ? `location:${surface.location_id}` : "");
// Free-form path: reveal a textarea so the player types their OWN line, then POST that
Expand Down Expand Up @@ -184,17 +193,40 @@ function ParleyMenu({ surface, slots, difficulty, setDifficulty, history, setHis
display: "flex", justifyContent: "space-between", alignItems: "flex-start",
zIndex: 3,
}}>
<div style={{
padding: "8px 16px",
background: "linear-gradient(180deg, var(--w-100), var(--w-300))",
color: "var(--b-200)",
boxShadow: "inset 0 0 0 1px var(--w-500), inset 0 1px 0 rgba(255,220,170,0.2)",
fontFamily: "var(--f-display)",
fontSize: 11,
letterSpacing: "0.22em",
textTransform: "uppercase",
}}>
Parley · Speaking with {actorName} · {surface.dayLabel || "the table"}
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 6 }}>
<div style={{
padding: "8px 16px",
background: "linear-gradient(180deg, var(--w-100), var(--w-300))",
color: "var(--b-200)",
boxShadow: "inset 0 0 0 1px var(--w-500), inset 0 1px 0 rgba(255,220,170,0.2)",
fontFamily: "var(--f-display)",
fontSize: 11,
letterSpacing: "0.22em",
textTransform: "uppercase",
}}>
Parley · Speaking with {npcName || actorName} · {surface.dayLabel || "the table"}
</div>
{/* #615: live disposition meter for the bound NPC — the same DispositionDot the
Relations screen uses, so the player sees where they stand WHILE talking. Reads
the existing disposition band (present even at attitude_value 0 -> "neutral"). */}
{npc && npc.disposition && DispositionDot && (
<div style={{
display: "inline-flex", alignItems: "center", gap: 8,
padding: "4px 12px",
background: "linear-gradient(180deg, var(--w-100), var(--w-300))",
boxShadow: "inset 0 0 0 1px var(--w-500)",
}}>
<span className="eyebrow" style={{ color: "var(--b-300)", letterSpacing: "0.16em", fontSize: 9 }}>
Disposition
</span>
<DispositionDot d={npc.disposition} />
{typeof npc.attitude_value === "number" && (
<span style={{ fontFamily: "var(--f-mono)", fontSize: 10, color: "var(--ink-600)" }}>
{npc.attitude_value > 0 ? "+" : ""}{npc.attitude_value}
</span>
)}
</div>
)}
</div>
{/* Approach difficulty for THIS attempt — sets the suggested DC of the check
the engine projects (easy 10 / medium 14 / hard 18, before any house shift). */}
Expand Down
54 changes: 52 additions & 2 deletions viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5164,13 +5164,47 @@ def _live_parley_event(snapshot: dict) -> dict | None:
return None


def _parley_npc_block(snapshot: dict, npc_id: str, live_event: dict | None) -> dict | None:
"""The conversation TARGET for a parley, projected read-only from the snapshot — or None.

#751 (header names the NPC, pinned) + #615 (disposition meter): the Parley header used to
name ``surface.actor`` (the lead PC) — a conversation that named the wrong speaker. Bind the
surface to the NPC the party faces so the header renders THAT NPC and pins to its id for the
interaction. Resolution order: an explicit ``npc_id`` (the interlocutor the player opened the
conversation with — authoritative, fixes the "switch mid-interaction" half) wins; else the
live Event's ``anchor_npc_id`` (the engine already chose WHO a stumble-into is about). An
absent/unknown id degrades to None (no block) — exactly like an unknown event_id, never raises.

The block carries the canonical ``disposition`` bucket (reusing `_attitude_disposition`, the
same predicate the Relations screen uses) + the raw ``attitude_value`` (fine at 0 -> "neutral")
so the Dialogue screen can render a live disposition meter (DispositionDot). Pure READ —
nothing on the NPC is mutated; the actor's sheet still drives the skill slots."""
chars = snapshot.get("characters") if isinstance(snapshot.get("characters"), dict) else {}
target_id = npc_id or (_text(live_event.get("anchor_npc_id")) if isinstance(live_event, dict) else "")
if not target_id:
return None
ch = chars.get(target_id)
if not isinstance(ch, dict):
return None # unknown id -> freeform parley (graceful degrade, like a bad event_id)
av = _num(ch.get("attitude_value"))
return {
"id": _text(ch.get("id"), target_id),
"name": _text(ch.get("name"), target_id),
"attitude": _text(ch.get("attitude")),
"attitude_value": int(av) if av is not None else 0,
"met": bool(ch.get("met")),
"disposition": _attitude_disposition(ch),
}


def build_parley_surface(
snapshot: dict,
*,
campaign_id: str,
live: bool,
is_live_view: bool,
difficulty: str = "medium",
npc_id: str = "",
) -> dict:
"""Project a parley menu for the lead PC: actor + per-skill {skill, modifier,
suggested_dc} + alignment + free_form. Prefers the engine's own
Expand All @@ -5182,7 +5216,13 @@ def build_parley_surface(
``{id, prompt, anchor_npc_id, options:[{label, tag, skill, dc}], resolve_with}`` — so the
authored Event options surface as the menu slots. The free-form path STAYS (never a closed
set, #141 guard); a picked option still relays via /move and the DM agent calls
`resolve_event`. No live Event -> no block (today's freeform parley, byte-for-byte)."""
`resolve_event`. No live Event -> no block (today's freeform parley, byte-for-byte).

#751/#615: when the parley is bound to a tracked NPC (explicit ``npc_id`` query, else the
live Event's anchor NPC), an additive ``npc`` block — ``{id, name, attitude, attitude_value,
met, disposition}`` — is attached so the header names the conversation TARGET (not the lead
PC) and pins to it, and the screen can render a live disposition meter. Absent/unknown target
-> no ``npc`` key (byte-identical to today's freeform parley). Read-only throughout."""
snapshot = snapshot if isinstance(snapshot, dict) else {}
actor_id, actor = _lead_pc(snapshot)
# Parley backdrop scope (P2 fix): the old behavior reused the SAME `location:<id>` scope
Expand Down Expand Up @@ -5233,6 +5273,13 @@ def build_parley_surface(
base["imageScope"] = f"portrait-{anchor}"
elif ev_id:
base["imageScope"] = f"event:{ev_id}"
# #751/#615: bind the conversation TARGET (explicit npc_id, else the live event's anchor NPC)
# so the header names the NPC + pins it, and the screen can render a disposition meter. Attach
# before the empty/no-actor return so a parley with a target still carries the NPC even when no
# PC sheet resolves. Absent/unknown target -> no `npc` key (today's freeform parley unchanged).
npc_block = _parley_npc_block(snapshot, npc_id, live_event)
if npc_block is not None:
base["npc"] = npc_block
if not actor_id or not actor:
base["source"] = "empty"
return base
Expand Down Expand Up @@ -7693,11 +7740,14 @@ def do_GET(self) -> None: # noqa: N802
elif route == "/parley-surface":
# Sheet-correct social options for the lead PC (per-skill modifier + suggested
# DC + alignment + free_form) — the UI side of #141. ?difficulty tunes the DC band.
# ?npc=<id> binds the conversation TARGET so the header names that NPC + pins it
# and the disposition meter reads its attitude (#751 + #615); read-only throughout.
qs = parse_qs(parsed.query)
difficulty = _text((qs.get("difficulty") or [""])[0], "medium")
npc_id = _text((qs.get("npc") or [""])[0])
self._serve_simple_surface(
qs,
lambda snap, **kw: build_parley_surface(snap, difficulty=difficulty, **kw),
lambda snap, **kw: build_parley_surface(snap, difficulty=difficulty, npc_id=npc_id, **kw),
heal=True,
)
elif route == _OPENWORLDS_ROUTE:
Expand Down
166 changes: 166 additions & 0 deletions viewer/tests/test_dialogue_npc_header.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Component-behavior tests for the Parley (Dialogue) screen header + disposition meter.

Two felt v1.0.4 gaps, exercised against the REAL shipped `ParleyMenu` component (no
reimplementation) by transpiling screen-dialogue.jsx with the bundled Babel and capturing
its createElement tree under Node (mirrors test_chronicle_hygiene._render_log_entries):

• #751 — the Parley header read "Speaking with <PLAYER>": it named `surface.actor` (the
lead PC) instead of the NPC the party is talking TO. When the surface carries an `npc`
block, the header must name THAT NPC and the left portrait/label must be the NPC — pinned
to the bound id for the whole interaction. With no `npc` block the header keeps today's
behavior (the actor), so the no-target freeform parley is unchanged.

• #615 — a live disposition read on the Dialogue screen: when the surface carries an `npc`
block with a `disposition` band (reusing the engine-side _attitude_disposition) the screen
renders a DispositionDot meter so the player sees the NPC's current standing while talking.

Skipped if Node is not on PATH.
"""
from __future__ import annotations

import json
import shutil
import subprocess
from pathlib import Path

import pytest

HERE = Path(__file__).resolve().parent
OPENWORLDS = HERE.parent / "openworlds"
SCREEN_DIALOGUE = OPENWORLDS / "screen-dialogue.jsx"
BABEL = OPENWORLDS / "vendor" / "babel-standalone-7.29.0.min.js"


def _node() -> str:
node = shutil.which("node")
if not node:
pytest.skip("node not on PATH; skipping JS-behavior test")
return node


def _render_parley(surface: dict) -> dict:
"""Transpile screen-dialogue.jsx, render ParleyMenu(surface) with a captured createElement
tree, and return {"text": <flattened visible text>, "components": [<component names used>]}.

Bare capitalized JSX identifiers the component reaches for (Img, Panel, DispositionDot,
Divider, …) are stubbed in the sandbox as passthrough components that simply render their
children, so their NAME is recorded (lets us assert DispositionDot was used) and any text
props (label/d) are captured. window.Tooltip/slug/useToast are stubbed likewise."""
program = (
"const fs = require('fs'); const vm = require('vm');\n"
+ "const Babel = require(%s);\n" % json.dumps(str(BABEL))
+ "const src = fs.readFileSync(%s, 'utf8');\n" % json.dumps(str(SCREEN_DIALOGUE))
+ "const code = Babel.transform(src, { presets: ['react'], filename: 'screen-dialogue.jsx' }).code;\n"
+ "function h(type, props, ...children){ return { type: (typeof type==='function'?(type.name||'C'):type),"
+ " props: props||{}, children: children.flat(Infinity).filter(c=>c!=null) }; }\n"
+ "const React = { useState:(v)=>[typeof v==='function'?v():v,()=>{}], useRef:()=>({current:false}),"
+ " useCallback:(f)=>f, useEffect:()=>{}, createElement:h, Fragment:'F' };\n"
+ "const sb = { React, document:{ addEventListener(){}, removeEventListener(){}, visibilityState:'visible' },"
+ " fetch:()=>Promise.resolve({ ok:true, json:()=>Promise.resolve({}) }) };\n"
+ "sb.window = sb;\n"
# Passthrough stubs for the components/helpers ParleyMenu reaches for. Each renders its
# children so their text survives; their .name is captured by h() above.
+ "function Img(){ return h('Img', arguments[0]); }\n"
+ "function Panel(p){ return h('Panel', p, p&&p.children); }\n"
+ "function Divider(){ return h('Divider', {}); }\n"
+ "function DispositionDot(p){ return h('DispositionDot', p); }\n"
+ "sb.Img = Img; sb.Panel = Panel; sb.Divider = Divider; sb.DispositionDot = DispositionDot;\n"
+ "sb.window.Tooltip = function Tooltip(p){ return h('Tooltip', p, p&&p.children); };\n"
+ "sb.window.slug = (s)=>String(s||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');\n"
+ "sb.window.useToast = ()=>(()=>{});\n"
+ "sb.window.combatSurfaceFromCampaign = ()=>'';\n"
+ "vm.createContext(sb); vm.runInContext(code, sb);\n"
+ "const ParleyMenu = sb.window.ParleyMenu;\n"
+ "if (typeof ParleyMenu !== 'function') throw new Error('ParleyMenu not exported on window');\n"
+ "function textOf(n){ if(n==null) return ''; if(typeof n==='string'||typeof n==='number') return String(n);"
+ " if(Array.isArray(n)) return n.map(textOf).join(''); let s='';"
+ " if(n.props){ if(typeof n.props.label==='string') s+=' '+n.props.label; if(typeof n.props.d==='string') s+=' '+n.props.d; }"
+ " if(n.children) s+=n.children.map(textOf).join(''); return s; }\n"
+ "const used = [];\n"
+ "function collect(n){ if(n==null||typeof n!=='object') return; if(typeof n.type==='string') used.push(n.type);"
+ " if(Array.isArray(n.children)) n.children.forEach(collect); }\n"
+ "const surface = " + json.dumps(surface) + ";\n"
+ "const tree = ParleyMenu({ surface, slots: surface.skills||[], difficulty:'medium',"
+ " setDifficulty:()=>{}, history:[], setHistory:()=>{}, onNavigate:()=>{}, toast:()=>{} });\n"
+ "collect(tree);\n"
+ "process.stdout.write(JSON.stringify({ text: textOf(tree), components: used }));\n"
)
proc = subprocess.run(
[_node(), "--input-type=commonjs"], input=program, text=True, capture_output=True
)
if proc.returncode != 0:
raise AssertionError(f"node failed: {proc.stderr}")
return json.loads(proc.stdout)


_BASE = {
"campaign_id": "camp_marches",
"title": "The Long Road",
"dayLabel": "Day 12",
"actor": "Cassian Frostbreaker",
"actor_id": "cassian",
"alignment": "Neutral Good",
"location_id": "lanternrest",
"imageScope": "location:lanternrest",
"free_form": True,
"can_act": False,
"skills": [
{"skill": "persuasion", "label": "Persuasion", "modifier": 8, "suggested_dc": 14,
"proficient": True, "expertise": True, "core": True},
],
}


def _with_npc(**npc):
s = json.loads(json.dumps(_BASE))
s["npc"] = {"id": "olwen", "name": "Toll-keeper Olwen", "met": True,
"attitude": "guarded", "attitude_value": -10, "disposition": "cool"}
s["npc"].update(npc)
return s


# ── #751: header names the NPC, not the player ──────────────────────────────────

def test_751_header_names_the_npc_not_the_player():
out = _render_parley(_with_npc())
txt = out["text"]
assert "Toll-keeper Olwen" in txt, f"header must name the NPC; got: {txt!r}"
# The header line specifically must not say "Speaking with <player>".
assert "Speaking with Cassian Frostbreaker" not in txt, (
f"header still names the player (the #751 bug); got: {txt!r}"
)


def test_751_no_npc_block_keeps_actor_header():
# Regression: with no npc block the header keeps today's behavior (names the lead speaker),
# so the no-target freeform parley is byte-for-byte unchanged.
out = _render_parley(_BASE)
assert "Cassian Frostbreaker" in out["text"]


def test_751_npc_name_is_pinned_independent_of_actor():
# Even though the actor (lead PC) drives the skill slots, the bound NPC name is what the
# header shows — proving the header is pinned to the conversation target, not the actor.
out = _render_parley(_with_npc(id="mira", name="Mira of the Inkstain"))
assert "Mira of the Inkstain" in out["text"]
assert "Speaking with Cassian Frostbreaker" not in out["text"]


# ── #615: disposition meter renders on the Dialogue screen ──────────────────────

def test_615_disposition_dot_renders_when_npc_bound():
out = _render_parley(_with_npc(disposition="cool"))
assert "DispositionDot" in out["components"], (
f"a DispositionDot meter must render for the bound NPC; components: {out['components']}"
)


def test_615_no_disposition_meter_without_an_npc():
# No npc block -> no disposition meter (nothing to read), today's parley unchanged.
out = _render_parley(_BASE)
assert "DispositionDot" not in out["components"]


def test_615_disposition_meter_renders_even_at_zero_attitude():
out = _render_parley(_with_npc(attitude="", attitude_value=0, disposition="neutral"))
assert "DispositionDot" in out["components"]
Loading
Loading