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
125 changes: 98 additions & 27 deletions viewer/openworlds/screen-acts.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,52 @@
/* Screen: Acts — chronicle progression, current act, memorable moments */

function ScreenActs({ onNavigate, state, setState }) {
const [selectedAct, setSelectedAct] = React.useState(ACTS.find((a) => a.current) || ACTS[0]);
const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : [];
const activeCampaign =
campaigns.find((c) => c.id === state?.activeCampaign) ||
campaigns[0] ||
{};
const campaignId = activeCampaign.campaign_id || state?.activeCampaign || activeCampaign.id || "";
const [surface, setSurface] = React.useState(null);
const [surfaceStatus, setSurfaceStatus] = React.useState("loading");
const acts = (Array.isArray(surface?.acts) && surface.acts.length) ? surface.acts : (surface ? [] : ACTS);
const currentAct = acts.find((a) => a.id === surface?.currentActId) || acts.find((a) => a.current) || acts[0] || null;
const [selectedActId, setSelectedActId] = React.useState("");
const selectedAct = acts.find((a) => a.id === selectedActId) || currentAct;

const loadSurface = React.useCallback(async (isCancelled = () => false) => {
const query = window.combatSurfaceFromCampaign
? window.combatSurfaceFromCampaign(activeCampaign, state)
: (campaignId ? `?campaign=${encodeURIComponent(campaignId)}` : "");
try {
const response = await fetch("/acts-surface" + query, { cache: "no-store" });
if (!response.ok) throw new Error(`acts surface ${response.status}`);
const payload = await response.json();
if (isCancelled()) return;
setSurface(payload);
setSurfaceStatus("ready");
} catch (error) {
if (!isCancelled()) setSurfaceStatus(error?.message || "unavailable");
}
}, [activeCampaign, state, campaignId]);

React.useEffect(() => {
let cancelled = false;
let timer = null;
const guardedLoad = async () => { if (!cancelled) await loadSurface(() => cancelled); };
const stopPolling = () => { if (timer !== null) { window.clearInterval(timer); timer = null; } };
const startPolling = () => { if (timer === null) timer = window.setInterval(guardedLoad, 7000); };
const handleVisibility = () => {
if (document.visibilityState === "visible") { guardedLoad(); startPolling(); } else { stopPolling(); }
};
document.addEventListener("visibilitychange", handleVisibility);
handleVisibility();
return () => { cancelled = true; stopPolling(); document.removeEventListener("visibilitychange", handleVisibility); };
}, [loadSurface]);

React.useEffect(() => {
if (currentAct?.id && !acts.some((a) => a.id === selectedActId)) setSelectedActId(currentAct.id);
}, [currentAct?.id, selectedActId, acts]);

return (
<div className="screen" style={{ height: "100%", display: "grid", gridTemplateColumns: "1fr 1.2fr", gap: 14, padding: 14 }}>
Expand All @@ -10,8 +55,18 @@ function ScreenActs({ onNavigate, state, setState }) {
<Panel framed style={{ padding: 22, overflow: "auto" }}>
<div className="eyebrow" style={{ color: "var(--crimson)" }}>The Chronicle, in</div>
<h1 className="h1" style={{ fontSize: 22 }}>Acts</h1>
<div className="body-sm muted" style={{ marginTop: 4 }}>
{surface ? (surface.tracked ? surface.dayLabel : surface.emptyState?.title) : surfaceStatus}
</div>
<Divider />

{surface && !surface.tracked && (
<div style={{ padding: 12, marginBottom: 12, background: "rgba(176,141,87,0.08)", boxShadow: "inset 0 0 0 1px rgba(140,100,60,0.28)" }}>
<div className="eyebrow">Read-only</div>
<div className="body-sm muted" style={{ marginTop: 4 }}>{surface.emptyState?.body}</div>
</div>
)}

<div style={{ position: "relative", paddingLeft: 24 }}>
{/* Spine */}
<div style={{
Expand All @@ -22,30 +77,32 @@ function ScreenActs({ onNavigate, state, setState }) {
boxShadow: "0 0 0 1px var(--b-600)",
}} />

{ACTS.map((a, i) => (
{acts.map((a, i) => (
<ActSpineRow
key={a.id}
act={a}
isLast={i === ACTS.length - 1}
selected={selectedAct.id === a.id}
onSelect={() => setSelectedAct(a)}
isLast={i === acts.length - 1}
selected={selectedAct?.id === a.id}
onSelect={() => setSelectedActId(a.id)}
/>
))}
{!acts.length && <div className="body-sm muted">No compiled acts are available for this save yet.</div>}
</div>
</Panel>

{/* RIGHT — Act detail */}
<Panel framed style={{ padding: 28, overflow: "auto" }}>
<ActDetail act={selectedAct} />
<ActDetail act={selectedAct} surface={surface} />
</Panel>
</div>
);
}

function ActSpineRow({ act, isLast, selected, onSelect }) {
const tone = act.status === "complete" ? "var(--emerald)" :
act.status === "current" ? "var(--gold-glow)" :
act.status === "future" ? "var(--ink-500)" :
const status = act.status || (act.current ? "current" : "");
const tone = ["complete", "completed", "resolved"].includes(status) ? "var(--emerald)" :
["current", "active"].includes(status) ? "var(--gold-glow)" :
["future", "planned"].includes(status) ? "var(--ink-500)" :
"var(--b-400)";
return (
<div style={{ position: "relative", paddingBottom: isLast ? 0 : 24 }}>
Expand Down Expand Up @@ -78,19 +135,19 @@ function ActSpineRow({ act, isLast, selected, onSelect }) {
}}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 6 }}>
<span style={{ fontFamily: "var(--f-display)", fontSize: 13, letterSpacing: "0.08em", color: act.status === "future" ? "var(--ink-600)" : "var(--ink-900)" }}>
{act.status === "future" ? "?????" : act.name}
{status === "future" ? "?????" : (act.name || act.title)}
</span>
<span className="muted" style={{ fontFamily: "var(--f-mono)", fontSize: 10 }}>{act.duration}</span>
<span className="muted" style={{ fontFamily: "var(--f-mono)", fontSize: 10 }}>{act.duration || status}</span>
</div>
<div className="hand muted" style={{ fontSize: 12, marginTop: 2 }}>
{act.status === "future" ? "the chronicle has not reached this yet" : act.subtitle}
{status === "future" ? "the chronicle has not reached this yet" : (act.subtitle || act.summary || `${(act.beats || []).length} beats`)}
</div>
{act.status === "current" && (
{(status === "current" || status === "active" || act.current) && (
<div style={{ marginTop: 6 }}>
<Pill tone="crimson" dot>You are here</Pill>
</div>
)}
{act.status === "complete" && (
{["complete", "completed", "resolved"].includes(status) && (
<div style={{ marginTop: 6, display: "flex", gap: 4 }}>
<Pill tone="emerald">Resolved</Pill>
{act.outcome && <span className="hand muted" style={{ fontSize: 11, alignSelf: "center" }}>· {act.outcome}</span>}
Expand All @@ -101,8 +158,21 @@ function ActSpineRow({ act, isLast, selected, onSelect }) {
);
}

function ActDetail({ act }) {
if (act.status === "future") {
function ActDetail({ act, surface }) {
if (!act) {
return (
<div style={{ display: "grid", placeItems: "center", height: "100%", textAlign: "center" }}>
<div>
<h2 className="h1" style={{ fontSize: 22 }}>{surface?.emptyState?.title || "No act selected"}</h2>
<p className="hand muted" style={{ marginTop: 6, maxWidth: 420 }}>
{surface?.emptyState?.body || "The chronicle is waiting for compiled campaign-director state."}
</p>
</div>
</div>
);
}
const status = act.status || (act.current ? "current" : "");
if (status === "future") {
return (
<div style={{ display: "grid", placeItems: "center", height: "100%", textAlign: "center" }}>
<div>
Expand All @@ -117,17 +187,17 @@ function ActDetail({ act }) {
}
return (
<div>
<div className="eyebrow" style={{ color: "var(--crimson)" }}>Act {act.numeral}{act.status === "current" ? " · in progress" : ""}</div>
<h1 className="h1" style={{ fontSize: 28 }}>{act.name}</h1>
<div className="hand" style={{ fontSize: 16, color: "var(--ink-700)" }}>{act.subtitle}</div>
<div className="eyebrow" style={{ color: "var(--crimson)" }}>Act {act.numeral || act.id}{(status === "current" || status === "active") ? " · in progress" : ""}</div>
<h1 className="h1" style={{ fontSize: 28 }}>{act.name || act.title}</h1>
<div className="hand" style={{ fontSize: 16, color: "var(--ink-700)" }}>{act.subtitle || status}</div>

<Divider />

{act.illustration && (
<Placeholder label={`illustration · ${act.illustration}`} h={140} framed style={{ width: "100%", marginBottom: 16 }} />
)}

<p className="body dropcap">{act.synopsis}</p>
<p className="body dropcap">{act.synopsis || act.summary || "This act has no player-facing synopsis yet."}</p>

<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8, marginTop: 18 }}>
<StatLine k="Began" v={act.beginDate} />
Expand All @@ -138,11 +208,11 @@ function ActDetail({ act }) {
<Divider />

<SectionTitle ordinal="·">Key choices made</SectionTitle>
{act.choices.length === 0 ? (
{!(act.choices || surface?.majorChoices || []).length ? (
<div className="hand muted">No turning points yet. The road still has shape to give.</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{act.choices.map((c, i) => (
{(act.choices || surface?.majorChoices || []).map((c, i) => (
<div key={i} style={{
padding: 10,
background: "rgba(176,141,87,0.08)",
Expand All @@ -168,23 +238,24 @@ function ActDetail({ act }) {

<Divider />

<SectionTitle>Moments the chronicle remembers</SectionTitle>
<SectionTitle>Beats and callbacks</SectionTitle>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
{act.memories.map((m, i) => (
{(act.memories || act.beats || surface?.threads || []).map((m, i) => (
<div key={i} style={{
padding: 10,
background: "rgba(95, 75, 45, 0.06)",
boxShadow: "inset 0 0 0 1px rgba(140,100,60,0.3)",
}}>
<Placeholder label={`sketch · ${m.sketch}`} h={70} framed />
<Placeholder label={`chronicle · ${m.sketch || m.status || m.label || "beat"}`} h={70} framed />
<div className="hand" style={{ fontSize: 13, marginTop: 6, color: "var(--ink-700)", fontStyle: "italic" }}>
"{m.text}"
"{m.text || m.title || m.questTitle || m.note}"
</div>
<div className="muted" style={{ fontFamily: "var(--f-mono)", fontSize: 9, marginTop: 4 }}>
{m.when}
{m.when || (m.triggerDay ? `day ${m.triggerDay}` : m.status)}
</div>
</div>
))}
{!(act.memories || act.beats || surface?.threads || []).length && <div className="body-sm muted">No beats have been tracked for this act yet.</div>}
</div>

{act.partyAtStart && (
Expand Down
108 changes: 108 additions & 0 deletions viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2266,6 +2266,110 @@ def build_journal_surface(
}


def _acts_from_path(snapshot: dict) -> tuple[bool, str, list[dict], list[dict]]:
"""Project optional adventure-path state without inventing progress when it is absent."""
path = snapshot.get("adventure_path")
if not isinstance(path, dict):
return False, "", [], []
current = _text(path.get("current_act_id") or path.get("currentActId"))
raw_acts = path.get("acts")
diagnostics_raw = path.get("diagnostics")
acts: list[dict] = []
if isinstance(raw_acts, list):
for index, act in enumerate(raw_acts, start=1):
if not isinstance(act, dict):
continue
act_id = _text(act.get("id"), f"act-{index}")
beats: list[dict] = []
raw_beats = act.get("beats")
if isinstance(raw_beats, list):
for beat_index, beat in enumerate(raw_beats, start=1):
if not isinstance(beat, dict):
continue
beats.append({
"id": _text(beat.get("id"), f"{act_id}:beat-{beat_index}"),
"title": _text(beat.get("title"), "Untitled beat"),
"status": _text(beat.get("status"), "planned"),
})
status = _text(act.get("status"), "planned")
acts.append({
"id": act_id,
"title": _text(act.get("title") or act.get("name"), f"Act {index}"),
"status": status,
"current": act_id == current or status in {"active", "current"},
"summary": _text(act.get("summary") or act.get("synopsis")),
"beats": beats,
})
diagnostics = [
{"message": _text(item)}
for item in (diagnostics_raw if isinstance(diagnostics_raw, list) else [])
if _text(item)
]
tracked = bool(acts or current or diagnostics)
if tracked and not current:
active = next((a for a in acts if a.get("current")), None)
current = _text(active.get("id")) if isinstance(active, dict) else ""
return tracked, current, acts, diagnostics


def _acts_major_choices(snapshot: dict) -> list[dict]:
decisions = snapshot.get("decisions")
out: list[dict] = []
if not isinstance(decisions, list):
return out
for decision in decisions:
if not isinstance(decision, dict):
continue
day = _num(decision.get("day"))
out.append({
"id": _text(decision.get("id"), f"decision-{len(out) + 1}"),
"day": int(day) if day is not None else None,
"summary": _text(decision.get("summary"), "A choice was recorded."),
"chosen": _text(decision.get("chosen")),
"context": _text(decision.get("rationale")),
})
out.sort(key=lambda row: (row.get("day") if row.get("day") is not None else -1, row.get("id") or ""), reverse=True)
return out[:12]


def build_acts_surface(
snapshot: dict,
*,
campaign_id: str,
live: bool,
is_live_view: bool,
) -> dict:
"""Read-only chronicle/payoff surface for the OpenWorlds Acts screen.

If no adventure-path state exists yet, the surface says so explicitly instead of
pretending prototype acts are real campaign progress.
"""
snapshot = snapshot if isinstance(snapshot, dict) else {}
tracked, current, acts, diagnostics = _acts_from_path(snapshot)
return {
"campaign_id": campaign_id,
"title": _text(snapshot.get("title"), campaign_id or "Open Worlds"),
"world": _text(snapshot.get("world_id"), "unknown"),
"dayLabel": _openworlds_day_label(snapshot),
"tracked": tracked,
"currentActId": current,
"acts": acts,
"majorChoices": _acts_major_choices(snapshot),
"threads": _journal_evolutions(snapshot),
"directorAdvisory": _director_advisory(snapshot),
"diagnostics": diagnostics,
"emptyState": {
"title": "Acts not tracked yet",
"body": "The campaign director has not compiled act progress for this save yet.",
},
"live": bool(live),
"is_live_view": bool(is_live_view),
"can_act": False,
"state_authority": "engine",
"write_lane": "/move",
}


# ── Character sheets surface (full party read model) ──────────────────────────

def _ability_mod(score: object) -> int:
Expand Down Expand Up @@ -4438,6 +4542,10 @@ def do_GET(self) -> None: # noqa: N802
# The quest journal read model: tracked quests + unresolved hooks (as rumors)
# + the Campaign Director's top structural debts (#72) as a GM advisory.
self._serve_simple_surface(parse_qs(parsed.query), build_journal_surface)
elif route == "/acts-surface":
# Read-only act/chronicle payoff surface. It shows compiled path state when the
# engine has one and otherwise says the act tracker is not wired for this save yet.
self._serve_simple_surface(parse_qs(parsed.query), build_acts_surface)
elif route == "/character-surface":
# The party's full character sheets (classes/skills/spells/resources/AC/death
# saves) projected from the engine snapshot into the heroes screen shape.
Expand Down
10 changes: 10 additions & 0 deletions viewer/tests/test_openworlds_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,16 @@ def test_openworlds_table_posts_only_enabled_session_actions(self):
self.assertNotIn("snapshot.json", source)
self.assertNotIn("writeSnapshot", source)

def test_openworlds_acts_screen_binds_viewer_acts_surface(self):
status, ctype, body = self._get("/openworlds/screen-acts.jsx")

self.assertEqual(status, 200)
self.assertIn("text/babel", ctype)
source = body.decode("utf-8")
self.assertIn('fetch("/acts-surface', source)
self.assertIn("window.combatSurfaceFromCampaign", source)
self.assertIn("emptyState", source)

def test_openworlds_rejects_path_traversal(self):
self.assertEqual(self._status("/openworlds/../server.py"), 404)
self.assertEqual(self._status("/openworlds/%2e%2e/server.py"), 404)
Expand Down
Loading
Loading