From db4b86898cafc565d2cf8a2cf80da9f45e8686ea Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 10:21:13 +0700 Subject: [PATCH 1/2] feat(viewer): add OpenWorlds atlas surface --- viewer/openworlds/screen-map.jsx | 588 ++++++++++++++++--------- viewer/server.py | 308 +++++++++++++ viewer/tests/test_atlas_surface.py | 206 +++++++++ viewer/tests/test_openworlds_static.py | 96 ++++ 4 files changed, 987 insertions(+), 211 deletions(-) create mode 100644 viewer/tests/test_atlas_surface.py diff --git a/viewer/openworlds/screen-map.jsx b/viewer/openworlds/screen-map.jsx index 7f66ba9f..947249f3 100644 --- a/viewer/openworlds/screen-map.jsx +++ b/viewer/openworlds/screen-map.jsx @@ -1,41 +1,166 @@ -/* Screen: World Map — hand-drawn cartography with location nodes */ +/* Screen: World Map - engine-owned atlas and strategic read model */ -function ScreenMap({ onNavigate, state, setState, campMode, setCampMode }) { - const locations = Array.isArray(state?.locations) ? state.locations : []; - const party = Array.isArray(state?.party) ? state.party : []; - const [selected, setSelected] = React.useState(() => locations.find((l) => l.current) || locations[0] || null); +function atlasSurfaceFromCampaign(activeCampaign, state) { + const campaignId = activeCampaign?.campaign_id || state?.activeCampaign || activeCampaign?.id || ""; + const params = new URLSearchParams(); + if (campaignId) params.set("campaign", campaignId); + if (activeCampaign?.source) params.set("source", activeCampaign.source); + if (activeCampaign?.runId) params.set("run", activeCampaign.runId); + const query = params.toString(); + return query ? `?${query}` : ""; +} + +function ScreenMap({ onNavigate, state, campMode, setCampMode }) { + const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : []; + const activeCampaign = + campaigns.find((c) => c.id === state?.activeCampaign) || + campaigns[0] || + {}; + const surfaceQuery = window.atlasSurfaceFromCampaign(activeCampaign, state); + const campaignId = activeCampaign?.campaign_id || state?.activeCampaign || activeCampaign?.id || ""; + const [surface, setSurface] = React.useState(null); + const [surfaceStatus, setSurfaceStatus] = React.useState("loading"); + const [selectedId, setSelectedId] = React.useState(""); const [time, setTime] = React.useState("dusk"); + const [busyTravel, setBusyTravel] = React.useState(""); const [talkPartner, setTalkPartner] = React.useState(null); const toast = window.useToast ? window.useToast() : (() => {}); + const loadSurface = React.useCallback(async (isCancelled = () => false) => { + try { + const response = await fetch("/atlas-surface" + surfaceQuery, { cache: "no-store" }); + if (!response.ok) throw new Error(`atlas surface ${response.status}`); + const payload = await response.json(); + if (isCancelled()) return; + setSurface(payload); + setSurfaceStatus("ready"); + const label = (payload.dayLabel || "").toLowerCase(); + if (label.includes("night")) setTime("night"); + else if (label.includes("dawn") || label.includes("morning")) setTime("dawn"); + else if (label.includes("dusk") || label.includes("evening")) setTime("dusk"); + else if (label.includes("day") || label.includes("noon")) setTime("day"); + } catch (error) { + if (isCancelled()) return; + setSurfaceStatus(error?.message || "unavailable"); + } + }, [surfaceQuery]); + React.useEffect(() => { - if (locations.length === 0) { - if (selected) setSelected(null); + let cancelled = false; + let timer = null; + const guardedLoad = async () => { + if (cancelled) return; + 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]); + + const locations = Array.isArray(surface?.known_locations) ? surface.known_locations : []; + const edges = Array.isArray(surface?.edges) ? surface.edges : []; + const travelOptions = Array.isArray(surface?.travel_options) ? surface.travel_options : []; + const quests = Array.isArray(surface?.quest_markers) ? surface.quest_markers : []; + const clocks = Array.isArray(surface?.strategic_clocks) ? surface.strategic_clocks : []; + const projects = Array.isArray(surface?.downtime_projects) ? surface.downtime_projects : []; + const controls = Array.isArray(surface?.region_control) ? surface.region_control : []; + const currentLocation = surface?.current_location || {}; + const selected = + locations.find((l) => l.id === selectedId) || + locations.find((l) => l.id === currentLocation.id) || + locations[0] || + null; + const selectedTravel = selected ? travelOptions.find((t) => t.to === selected.id) : null; + const canCamp = Boolean(surface?.camp_available); + const canAct = Boolean(surface?.can_act); + + React.useEffect(() => { + if (!selectedId || !locations.some((l) => l.id === selectedId)) { + setSelectedId(currentLocation.id || locations[0]?.id || ""); + } + }, [currentLocation.id, locations, selectedId]); + + const postTravel = async (option) => { + if (!option?.available || !option?.move || !canAct) { + toast({ + kind: "danger", + eyebrow: "Travel", + title: option?.name ? `Cannot travel to ${option.name}` : "Travel unavailable", + body: option?.disabled_reason || "This atlas is read-only.", + }); return; } - if (!selected || !locations.find((l) => l.id === selected.id)) { - setSelected(locations.find((l) => l.current) || locations[0] || null); + setBusyTravel(option.to); + try { + const response = await fetch("/move", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...option.move, campaign: surface?.campaign_id || campaignId }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload.ok === false) { + throw new Error(payload.reason || `move ${response.status}`); + } + toast({ kind: "quest", title: "Travel intent sent", body: option.name || option.to }); + onNavigate("table"); + } catch (error) { + toast({ kind: "danger", title: "Move not sent", body: error?.message || "The viewer could not reach /move." }); + } finally { + setBusyTravel(""); } - }, [locations, selected?.id]); + }; const beginRest = () => { - toast({ kind: "rest", eyebrow: "Long rest", title: "The camp settles", body: "10 hours pass. Wounds knit. Spells return. The road is patient." }); - setCampMode && setCampMode(false); - setTalkPartner(null); - setTimeout(() => onNavigate("character"), 600); + toast({ + kind: "rest", + eyebrow: "Camp", + title: canCamp ? "Camp is visible" : "Camp unavailable", + body: canCamp + ? "The atlas can show camp mode, but rest resolution remains engine-owned." + : "The current location is not marked as a camp or rest point.", + }); }; - return ( -
+ const locationQuests = selected ? quests.filter((q) => !q.location_id || q.location_id === selected.id) : quests; + const locationClocks = selected ? clocks.filter((c) => !c.location_id || c.location_id === selected.id) : clocks; + const locationProjects = selected ? projects.filter((p) => !p.location_id || p.location_id === selected.id) : projects; + const locationControl = selected ? controls.find((c) => c.location_id === selected.id) : null; - {/* MAP CANVAS */} + return ( +
-
-
-
The Stolen Marches
-

{campMode ? "Camp" : "World Atlas"}

+
+
+
{surface?.world || "Open Worlds"}
+

+ {campMode ? "Camp" : "World Atlas"} +

+
+ {surface?.dayLabel || surfaceStatus} +
-
+
{!campMode && ["dawn", "day", "dusk", "night"].map((t) => ( ))} - setCampMode && setCampMode(!campMode)}> - {campMode ? "✺ Camped" : "Make Camp"} + setCampMode && setCampMode(!campMode)} disabled={!canCamp}> + {campMode ? "Camped" : "Make Camp"} + loadSurface()}>Refresh
- {/* Map surface */}
- {/* Aged map background */} -
- {/* Terrain pattern overlay using SVG */} - - - - - - - - - - - - - - - - - - - {/* Forest blobs */} - - - - - {/* Hills */} - - - - {/* River */} - - - - {/* Roads */} - - - - {/* Compass rose */} - - - - - - N - S - W - E - - - {/* Region label */} - THE STOLEN MARCHES - Thorn River - Old Hills - - {/* Roads/connections between locations */} - {locations.map((loc) => - loc.connections?.map((cId) => { - const target = locations.find((l) => l.id === cId); - if (!target) return null; - return ( - - ); - }) - )} - - - {/* Time-of-day overlay */} -
- - {/* Location nodes */} - {locations.map((loc) => ( - - ))} -
+ {campMode && window.CampSidebar ? ( +
+ { setCampMode && setCampMode(false); setTalkPartner(null); }} + onBeginRest={beginRest} + onTalk={setTalkPartner} + talkPartner={talkPartner} + /> +
+ ) : ( + + )}
- {/* Bottom party portraits strip — now a separate row in the flex column */}
- {party.map((p) => ( -
- -
-
- ))} + {surface?.is_live_view ? "Live" : "Read-only"} + {locations.length} known + {clocks.filter((c) => c.urgent).length + projects.filter((p) => p.urgent).length} urgent
-
- Day 12 · {time} · 29 Gozran +
+ Last strategic tick: {surface?.last_world_tick || "none"}
- {/* SIDE — Location detail OR Camp panel */} - {campMode ? ( - { setCampMode && setCampMode(false); setTalkPartner(null); }} - onBeginRest={beginRest} - onTalk={setTalkPartner} - talkPartner={talkPartner} - /> - ) : ( -
- - {selected ? ( - <> -
{selected.region || "The Stolen Marches"}
-

{selected.name}

-
{selected.distance} · {selected.travel}
+ toast({ title: "Local mark only", body: loc?.name ? `${loc.name} marked in this view.` : "Pick a location first." })} + /> +
+ ); +} + +function AtlasMap({ locations, edges, selected, time, onSelect }) { + return ( +
+
+ + + + + + + + + + + + + + {edges.map((edge, i) => { + const from = locations.find((l) => l.id === edge.from); + const to = locations.find((l) => l.id === edge.to); + if (!from || !to) return null; + return ( + + ); + })} + + + + + N + + OPEN WORLDS ATLAS + + +
- + {locations.map((loc) => ( + + ))} - + {!locations.length && ( +
+
+
No atlas data
+

The map has not been discovered

+

Known locations will appear here once the engine snapshot includes them.

+
+
+ )} +
+
+ ); +} -

- {selected.description} -

+function AtlasSidebar({ selected, travel, currentId, busyTravel, canAct, quests, clocks, projects, control, allLocations, onSelect, onTravel, onMark }) { + const travelDisabled = !travel?.available || !canAct || Boolean(busyTravel) || selected?.id === currentId; + const travelReason = selected?.id === currentId ? "current location" : (travel?.disabled_reason || "no engine-backed route"); + return ( +
+ + {selected ? ( + <> +
{selected.region || "Unknown reach"}
+

{selected.name}

+
+ {selected.current ? "Current location" : (travel?.minutes ? `${travel.minutes} minutes away` : "Known location")} +
- + + +

+ {selected.description || "No public description has been recorded for this place yet."} +

-
- {selected.tags.map((t) => {t})} -
+ +
+ {(selected.tags || []).length + ? selected.tags.map((t) => {t}) + : known} +
-
- { - toast({ kind: "quest", title: "The party travels to " + selected.name, body: selected.travel + " · " + selected.distance }); - onNavigate("table"); - }}>Travel here - toast({ title: "Marked: " + selected.name, body: "Linzi makes a note." })}>Mark -
- - ) :
Pick a place upon the map.
} -
+ {control && ( +
+ Control: {control.controller || "unclaimed"} + Stability {control.stability} + 50 ? "crimson" : ""}>Unrest {control.unrest} +
+ )} - - Discovered -
- {locations.filter((l) => l.discovered).map((l) => ( - - ))} +
+ onTravel(travel)}> + {busyTravel === selected.id ? "Sending..." : "Travel here"} + + onMark(selected)}>Mark
- + {travelDisabled && ( +
{travelReason}
+ )} + + ) :
Pick a place upon the map.
} + + + + Strategic Context + ( + + )} /> + ( + + )} /> + ( + + )} /> + + + Discovered +
+ {allLocations.map((loc) => ( + + ))}
- )} +
+
+ ); +} + +function StrategicList({ label, items, empty, render }) { + return ( +
+
{label}
+
+ {items.length ? items.map(render) :
{empty}
} +
+
+ ); +} + +function ContextRow({ title, meta, urgent }) { + return ( +
+
{title}
+ {meta &&
{meta}
}
); } -function LocationPin({ loc, selected, time }) { +function LocationPin({ loc, selected }) { const isCurrent = loc.current; const isVisited = loc.visited; const [hover, setHover] = React.useState(false); @@ -269,7 +438,6 @@ function LocationPin({ loc, selected, time }) { onMouseLeave={() => setHover(false)} style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4, position: "relative" }} > - {/* Banner label */}
{loc.name}
- {/* Pin */}
- {/* Hover preview card */} {hover && !selected && (
- +
{loc.region || "Unknown reach"}
@@ -326,9 +492,9 @@ function LocationPin({ loc, selected, time }) { {loc.name}
- {loc.distance} · {loc.travel} + {loc.current ? "Current location" : "Known route"}
- {loc.tags && ( + {loc.tags && loc.tags.length > 0 && (
{loc.tags.slice(0, 3).map((t) => {t})}
@@ -339,4 +505,4 @@ function LocationPin({ loc, selected, time }) { ); } -Object.assign(window, { ScreenMap, LocationPin }); +Object.assign(window, { ScreenMap, LocationPin, atlasSurfaceFromCampaign }); diff --git a/viewer/server.py b/viewer/server.py index 499bec87..ce11ecb4 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -1368,6 +1368,287 @@ def build_combat_surface( } +def _atlas_locations(snapshot: dict) -> dict[str, dict]: + locs = snapshot.get("locations") if isinstance(snapshot, dict) else None + return locs if isinstance(locs, dict) else {} + + +def _atlas_visible_location_ids(snapshot: dict) -> list[str]: + locs = _atlas_locations(snapshot) + current_id = _text(snapshot.get("current_location_id")) + visible: list[str] = [] + for loc_id, row in locs.items(): + if not isinstance(row, dict): + continue + lid = _text(row.get("id"), _text(loc_id)) + if not lid: + continue + if lid == current_id: + visible.append(lid) + continue + if bool(row.get("hidden")): + continue + discovered = row.get("discovered") + if discovered is False and not bool(row.get("visited")): + continue + if bool(row.get("visited")) or discovered is True or discovered is None: + visible.append(lid) + return visible + + +def _atlas_hex_position(loc: dict, idx: int) -> tuple[int, int]: + raw = loc.get("hex") + q = r = None + if isinstance(raw, (list, tuple)) and len(raw) >= 2: + q, r = _num(raw[0]), _num(raw[1]) + if q is not None and r is not None: + x = 50 + int(q) * 18 + int(r) * 9 + y = 50 + int(r) * 14 + return max(8, min(92, x)), max(10, min(88, y)) + x = 24 + (idx % 4) * 18 + y = 24 + (idx // 4) * 18 + return max(8, min(92, x)), max(10, min(88, y)) + + +def _atlas_tags(row: dict) -> list[str]: + raw = row.get("tags") + if isinstance(raw, list): + return [_text(t).lower() for t in raw if _text(t)] + out: list[str] = [] + for key in ("danger", "rest", "town", "camp", "safe"): + if bool(row.get(key)): + out.append(key) + return out + + +def _atlas_known_locations(snapshot: dict) -> list[dict]: + locs = _atlas_locations(snapshot) + visible_ids = _atlas_visible_location_ids(snapshot) + current_id = _text(snapshot.get("current_location_id")) + out: list[dict] = [] + for idx, loc_id in enumerate(visible_ids): + row = locs.get(loc_id) + if not isinstance(row, dict): + continue + x, y = _atlas_hex_position(row, idx) + name = _text(row.get("name"), loc_id) + out.append({ + "id": loc_id, + "name": name, + "description": _text(row.get("description")), + "region": _text(row.get("region"), _text(snapshot.get("world_id"), "World")), + "visited": bool(row.get("visited")) or loc_id == current_id, + "current": loc_id == current_id, + "x": x, + "y": y, + "tags": _atlas_tags(row), + "connections": [ + _text(c) for c in row.get("connections", []) + if isinstance(row.get("connections"), list) and _text(c) in visible_ids + ], + }) + out.sort(key=lambda l: (not l["current"], l["name"])) + return out + + +def _atlas_edges(locations: list[dict]) -> list[dict]: + known = {loc["id"] for loc in locations} + seen: set[tuple[str, str]] = set() + out: list[dict] = [] + for loc in locations: + src = loc["id"] + for dst in loc.get("connections", []): + if dst not in known: + continue + key = tuple(sorted((src, dst))) + if key in seen or src == dst: + continue + seen.add(key) + out.append({"from": key[0], "to": key[1]}) + return out + + +def _atlas_move_reason(snapshot: dict, *, live: bool, is_live_view: bool) -> str | None: + if not _atlas_locations(snapshot): + return "no map data" + if not live: + return "no live move sink" + if not is_live_view: + return "viewing non-live campaign" + if isinstance(snapshot.get("combat"), dict) and snapshot["combat"].get("active"): + return "cannot travel during active combat" + return None + + +def _atlas_travel_options(snapshot: dict, locations: list[dict], *, live: bool, is_live_view: bool) -> list[dict]: + locs = _atlas_locations(snapshot) + current_id = _text(snapshot.get("current_location_id")) + current = locs.get(current_id) if current_id else None + if not isinstance(current, dict): + return [] + known = {loc["id"]: loc for loc in locations} + travel_times = current.get("travel_times") if isinstance(current.get("travel_times"), dict) else {} + disabled = _atlas_move_reason(snapshot, live=live, is_live_view=is_live_view) + out: list[dict] = [] + for dst in current.get("connections", []) if isinstance(current.get("connections"), list) else []: + dst_id = _text(dst) + target = known.get(dst_id) + if not target: + continue + minutes = travel_times.get(dst_id) + minutes = minutes if isinstance(minutes, int) and not isinstance(minutes, bool) else None + item = { + "to": dst_id, + "name": target["name"], + "minutes": minutes, + "available": disabled is None, + "disabled_reason": disabled, + } + if disabled is None: + item["move"] = {"kind": "do", "text": f"Travel to {target['name']}"} + out.append(item) + return out + + +def _atlas_faction_name(snapshot: dict, faction_id: str) -> str: + factions = snapshot.get("factions") + row = factions.get(faction_id) if isinstance(factions, dict) else None + return _text(row.get("name"), faction_id) if isinstance(row, dict) else faction_id + + +def _atlas_quest_markers(snapshot: dict, visible_ids: set[str]) -> list[dict]: + quests = snapshot.get("quests") + if not isinstance(quests, dict): + return [] + out: list[dict] = [] + for qid, row in quests.items(): + if not isinstance(row, dict): + continue + status = _text(row.get("status"), "active") + if status not in {"active", "open"}: + continue + loc_id = _text(row.get("location_id")) + if loc_id and loc_id not in visible_ids: + continue + objectives = row.get("objectives") if isinstance(row.get("objectives"), list) else [] + completed = set(row.get("completed_objectives") if isinstance(row.get("completed_objectives"), list) else []) + objective = next((_text(o) for o in objectives if _text(o) and o not in completed), "") + out.append({ + "id": _text(qid), + "title": _text(row.get("title"), _text(qid)), + "status": status, + "location_id": loc_id, + "objective": objective, + }) + return out[:12] + + +def _atlas_strategic(snapshot: dict, visible_ids: set[str]) -> tuple[list[dict], list[dict], list[dict], int]: + st = snapshot.get("strategic_state") + if not isinstance(st, dict): + return [], [], [], 0 + clocks: list[dict] = [] + for cid, row in (st.get("clocks") if isinstance(st.get("clocks"), dict) else {}).items(): + if not isinstance(row, dict): + continue + loc_id = _text(row.get("region_id") or row.get("location_id")) + if loc_id and loc_id not in visible_ids: + continue + progress = _num(row.get("progress")) or 0 + target = _num(row.get("target")) or 1 + remaining = max(0, int(target) - int(progress)) + clocks.append({ + "id": _text(row.get("id"), _text(cid)), + "title": _text(row.get("title"), _text(cid)), + "kind": _text(row.get("kind"), "threat"), + "location_id": loc_id, + "progress": int(progress), + "target": int(target), + "remaining": remaining, + "urgent": remaining <= 1 or (target > 0 and progress / target >= 0.75), + }) + projects: list[dict] = [] + for pid, row in (st.get("projects") if isinstance(st.get("projects"), dict) else {}).items(): + if not isinstance(row, dict): + continue + loc_id = _text(row.get("location_id")) + if loc_id and loc_id not in visible_ids: + continue + progress = _num(row.get("progress_days")) or 0 + duration = _num(row.get("duration_days")) or 1 + remaining = max(0, int(duration) - int(progress)) + status = _text(row.get("status"), "planned") + projects.append({ + "id": _text(row.get("id"), _text(pid)), + "title": _text(row.get("title"), _text(pid)), + "kind": _text(row.get("kind"), "other"), + "location_id": loc_id, + "status": status, + "progress_days": int(progress), + "duration_days": int(duration), + "remaining_days": remaining, + "urgent": status in {"active", "paused"} and remaining <= 2, + }) + regions: list[dict] = [] + for rid, row in (st.get("regions") if isinstance(st.get("regions"), dict) else {}).items(): + if not isinstance(row, dict): + continue + loc_id = _text(row.get("location_id"), _text(rid)) + if loc_id not in visible_ids: + continue + regions.append({ + "location_id": loc_id, + "controller": _atlas_faction_name(snapshot, _text(row.get("controller_id"))), + "stability": int(_num(row.get("stability")) or 0), + "unrest": int(_num(row.get("unrest")) or 0), + "tags": [_text(t) for t in row.get("tags", []) if isinstance(row.get("tags"), list) and _text(t)], + }) + last_tick_day = int(_num(st.get("last_tick_day")) or 0) + clocks.sort(key=lambda c: (not c["urgent"], c["remaining"], c["title"])) + projects.sort(key=lambda p: (not p["urgent"], p["remaining_days"], p["title"])) + return clocks[:12], projects[:12], regions[:12], last_tick_day + + +def build_atlas_surface( + snapshot: dict, + *, + campaign_id: str, + live: bool, + is_live_view: bool, +) -> dict: + """Project a browser-safe OpenWorlds atlas from engine-owned campaign state.""" + snapshot = snapshot if isinstance(snapshot, dict) else {} + locations = _atlas_known_locations(snapshot) + visible_ids = {loc["id"] for loc in locations} + current_id = _text(snapshot.get("current_location_id")) + current = next((loc for loc in locations if loc["id"] == current_id), None) + travel_options = _atlas_travel_options(snapshot, locations, live=live, is_live_view=is_live_view) + clocks, projects, regions, last_tick_day = _atlas_strategic(snapshot, visible_ids) + current_tags = set(current.get("tags", [])) if current else set() + camp_available = bool(current and current_tags.intersection({"rest", "town", "safe", "camp"})) + 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), + "current_location": current or {"id": "", "name": "Unknown location", "tags": []}, + "known_locations": locations, + "edges": _atlas_edges(locations), + "travel_options": travel_options, + "quest_markers": _atlas_quest_markers(snapshot, visible_ids), + "strategic_clocks": clocks, + "downtime_projects": projects, + "region_control": regions, + "camp_available": camp_available, + "last_world_tick": last_tick_day, + "live": bool(live), + "is_live_view": bool(is_live_view), + "can_act": bool(live and is_live_view), + "state_authority": "engine", + "write_lane": "/move", + } + + def _relative_time_label(ts: float, *, now: float) -> str: if ts <= 0: return "unknown" @@ -2486,6 +2767,7 @@ def _openworlds_config() -> dict: "campaign_catalog": "/openworlds/campaigns.json", "session_surface": "/session-surface", "combat_surface": "/combat-surface", + "atlas_surface": "/atlas-surface", "state_authority": "engine", "write_lane": "/move", "demo_data": False, @@ -2695,6 +2977,32 @@ def do_GET(self) -> None: # noqa: N802 is_live_view=live and cid == self.campaign_id, recent_events=_session_event_tail(cid), )) + elif route == "/atlas-surface": + qs = parse_qs(parsed.query) + live = _live_play() + catalog_ref = _session_surface_catalog_ref(qs) + if catalog_ref is not None: + cid, raw_snap, _campaign_dir_path, root_is_current = catalog_ref + self._json(build_atlas_surface( + raw_snap, + campaign_id=cid, + live=live, + is_live_view=bool(live and root_is_current and cid == self.campaign_id), + )) + return + cid = self._view_campaign(qs) + if not cid: + self._json(build_atlas_surface({}, campaign_id="", live=live, is_live_view=False)) + return + raw_snap = _read_snapshot(cid) + if not isinstance(raw_snap, dict): + raw_snap = {} + self._json(build_atlas_surface( + raw_snap, + campaign_id=cid, + live=live, + is_live_view=live and cid == self.campaign_id, + )) elif route == _OPENWORLDS_ROUTE or route.startswith(f"{_OPENWORLDS_ROUTE}/"): asset = _openworlds_asset(route) if asset is None: diff --git a/viewer/tests/test_atlas_surface.py b/viewer/tests/test_atlas_surface.py new file mode 100644 index 00000000..33aec226 --- /dev/null +++ b/viewer/tests/test_atlas_surface.py @@ -0,0 +1,206 @@ +import importlib.util +import json +import unittest +from pathlib import Path + + +_SERVER_PATH = Path(__file__).resolve().parents[1] / "server.py" +_SPEC = importlib.util.spec_from_file_location("viewer_server", _SERVER_PATH) +assert _SPEC is not None +server = importlib.util.module_from_spec(_SPEC) +assert _SPEC.loader is not None +_SPEC.loader.exec_module(server) + + +class AtlasSurfaceTests(unittest.TestCase): + def test_atlas_surface_projects_known_map_without_private_fields(self): + snapshot = { + "id": "camp_map", + "title": "Roads of the Gate", + "world_id": "baldurs-gate", + "day": 12, + "time_of_day": "dusk", + "current_location_id": "gate", + "locations": { + "gate": { + "id": "gate", + "name": "Basilisk Gate", + "description": "A guarded gatehouse.", + "connections": ["market", "hidden-crypt"], + "visited": True, + "hex": [0, 0], + "region": "Lower City", + "travel_times": {"market": 20, "hidden-crypt": 5}, + "tags": ["town", "rest"], + "notes": "private gate bypass", + }, + "market": { + "id": "market", + "name": "Rain Market", + "description": "Canvas stalls bright with rain.", + "connections": ["gate"], + "visited": False, + "discovered": True, + "hex": [1, 0], + "region": "Lower City", + "tags": ["danger"], + }, + "hidden-crypt": { + "id": "hidden-crypt", + "name": "Hidden Crypt", + "description": "Spoilers.", + "hidden": True, + "connections": ["gate"], + "notes": "private undead cache", + }, + }, + "quests": { + "q_market": { + "title": "Find the Rain Seller", + "status": "active", + "location_id": "market", + "objectives": ["Question the blue awning merchant"], + "notes": "private quest solution", + }, + "q_done": {"title": "Closed Road", "status": "completed", "location_id": "gate"}, + }, + "factions": { + "harpers": {"id": "harpers", "name": "Harpers", "notes": "private faction note"} + }, + "strategic_state": { + "last_tick_day": 11, + "regions": { + "gate": { + "location_id": "gate", + "controller_id": "harpers", + "stability": 62, + "unrest": 12, + "tags": ["watched"], + "note": "private region note", + } + }, + "clocks": { + "c1": { + "id": "c1", + "title": "Sapper Cell Regroups", + "kind": "threat", + "scope": "region", + "region_id": "market", + "progress": 5, + "target": 6, + "note": "private clock note", + } + }, + "projects": { + "p1": { + "id": "p1", + "title": "Repair Gate Winch", + "kind": "construction", + "location_id": "gate", + "progress_days": 2, + "duration_days": 5, + "status": "active", + "note": "private project note", + } + }, + }, + "dm_notes": "private map agenda", + } + + surface = server.build_atlas_surface( + snapshot, + campaign_id="camp_map", + live=True, + is_live_view=True, + ) + + self.assertEqual(surface["campaign_id"], "camp_map") + self.assertEqual(surface["state_authority"], "engine") + self.assertEqual(surface["write_lane"], "/move") + self.assertEqual(surface["current_location"]["id"], "gate") + self.assertEqual([loc["id"] for loc in surface["known_locations"]], ["gate", "market"]) + self.assertEqual(surface["known_locations"][0]["tags"], ["town", "rest"]) + self.assertEqual(surface["edges"], [{"from": "gate", "to": "market"}]) + self.assertEqual(surface["travel_options"][0]["to"], "market") + self.assertEqual(surface["travel_options"][0]["minutes"], 20) + self.assertEqual(surface["travel_options"][0]["move"], {"kind": "do", "text": "Travel to Rain Market"}) + self.assertTrue(surface["camp_available"]) + self.assertEqual(surface["quest_markers"][0]["title"], "Find the Rain Seller") + self.assertEqual(surface["strategic_clocks"][0]["title"], "Sapper Cell Regroups") + self.assertTrue(surface["strategic_clocks"][0]["urgent"]) + self.assertEqual(surface["downtime_projects"][0]["title"], "Repair Gate Winch") + self.assertEqual(surface["region_control"][0]["controller"], "Harpers") + + encoded = json.dumps(surface) + for forbidden in ( + "Hidden Crypt", + "hidden-crypt", + "notes", + "dm_notes", + "private", + "undead cache", + "quest solution", + "clock note", + "project note", + "region note", + ): + self.assertNotIn(forbidden, encoded) + self.assert_no_private_keys(surface) + + def test_atlas_surface_fails_closed_when_not_live(self): + snapshot = { + "current_location_id": "gate", + "locations": { + "gate": {"name": "Gate", "connections": ["market"], "visited": True}, + "market": {"name": "Market", "visited": True}, + }, + } + + surface = server.build_atlas_surface( + snapshot, + campaign_id="camp_readonly", + live=False, + is_live_view=False, + ) + + self.assertFalse(surface["can_act"]) + self.assertEqual(surface["travel_options"][0]["disabled_reason"], "no live move sink") + self.assertNotIn("move", surface["travel_options"][0]) + + def test_atlas_surface_degrades_for_legacy_saves(self): + surface = server.build_atlas_surface( + {"title": "Old Save"}, + campaign_id="camp_old", + live=True, + is_live_view=True, + ) + + self.assertEqual(surface["title"], "Old Save") + self.assertEqual(surface["known_locations"], []) + self.assertEqual(surface["edges"], []) + self.assertEqual(surface["travel_options"], []) + self.assertFalse(surface["camp_available"]) + + def assert_no_private_keys(self, value) -> None: + private_keys = { + "notes", + "dm_notes", + "scenes", + "lore", + "memory", + "secret", + "agenda", + "note", + "effect", + } + if isinstance(value, dict): + for key, child in value.items(): + self.assertNotIn(key, private_keys) + self.assert_no_private_keys(child) + elif isinstance(value, list): + for child in value: + self.assert_no_private_keys(child) + + +if __name__ == "__main__": + unittest.main() diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index 36807f31..0f62cdb5 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -105,6 +105,17 @@ def test_openworlds_combat_screen_binds_viewer_combat_surface(self): self.assertNotIn("TOKENS.map", source) self.assertNotIn("setTokens", source) + def test_openworlds_map_screen_binds_viewer_atlas_surface(self): + status, ctype, body = self._get("/openworlds/screen-map.jsx") + + self.assertEqual(status, 200) + self.assertIn("text/babel", ctype) + source = body.decode("utf-8") + self.assertIn('fetch("/atlas-surface', source) + self.assertIn('fetch("/move"', source) + self.assertIn("window.atlasSurfaceFromCampaign", source) + self.assertNotIn("state?.locations", 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) @@ -416,6 +427,91 @@ def test_combat_surface_route_projects_catalog_run_read_only(self): self.assertFalse(surface["can_act"]) self.assertFalse(surface["is_live_view"]) + def test_atlas_surface_route_projects_selected_campaign_safely(self): + campaign_dir = self._tmp / "campaigns" / "camp_map" + self._write_snapshot( + campaign_dir, + { + "id": "camp_map", + "title": "Atlas Save", + "world_id": "baldurs-gate", + "current_location_id": "gate", + "locations": { + "gate": { + "name": "Basilisk Gate", + "connections": ["market", "hidden"], + "visited": True, + "tags": ["town", "rest"], + "notes": "private gate note", + }, + "market": { + "name": "Rain Market", + "connections": ["gate"], + "discovered": True, + }, + "hidden": { + "name": "Hidden Crypt", + "hidden": True, + "notes": "private crypt note", + }, + }, + "quests": { + "q1": {"title": "Find the Seller", "status": "active", "location_id": "market"}, + }, + "dm_notes": "private atlas agenda", + }, + ) + + status, ctype, body = self._get("/atlas-surface?campaign=camp_map") + + self.assertEqual(status, 200) + self.assertIn("application/json", ctype) + surface = json.loads(body.decode("utf-8")) + self.assertEqual(surface["campaign_id"], "camp_map") + self.assertEqual(surface["state_authority"], "engine") + self.assertEqual(surface["write_lane"], "/move") + self.assertEqual(surface["current_location"]["name"], "Basilisk Gate") + self.assertEqual([loc["id"] for loc in surface["known_locations"]], ["gate", "market"]) + self.assertEqual(surface["edges"], [{"from": "gate", "to": "market"}]) + self.assertTrue(surface["camp_available"]) + encoded = json.dumps(surface) + self.assertNotIn("Hidden Crypt", encoded) + self.assertNotIn("private", encoded) + self.assert_no_private_keys(surface) + + def test_atlas_surface_route_projects_catalog_run_read_only(self): + repo_root = self._tmp / "repo" + (repo_root / "viewer").mkdir(parents=True) + server._HERE = repo_root / "viewer" + qa_campaign = repo_root / "qa" / "state" / "wave3-red" / "campaigns" / "camp_qa" + self._write_snapshot( + qa_campaign, + { + "id": "camp_qa", + "title": "QA Atlas Save", + "current_location_id": "qa-gate", + "locations": { + "qa-gate": {"name": "QA Gate", "connections": ["qa-market"], "visited": True}, + "qa-market": {"name": "QA Market", "visited": True}, + }, + }, + ) + self._write_snapshot( + self._tmp / "campaigns" / "camp_live", + {"id": "camp_live", "title": "Live Atlas Save", "party": [], "characters": {}}, + ) + _QuietHandler.campaign_id = "camp_live" + + status, _ctype, body = self._get("/atlas-surface?source=qa&run=wave3-red&campaign=camp_qa") + + self.assertEqual(status, 200) + surface = json.loads(body.decode("utf-8")) + self.assertEqual(surface["campaign_id"], "camp_qa") + self.assertEqual(surface["title"], "QA Atlas Save") + self.assertEqual(surface["current_location"]["name"], "QA Gate") + self.assertFalse(surface["can_act"]) + self.assertFalse(surface["is_live_view"]) + def _write_snapshot(self, campaign_dir: Path, payload: dict) -> None: campaign_dir.mkdir(parents=True) (campaign_dir / "snapshot.json").write_text(json.dumps(payload), encoding="utf-8") From 714bbd449384a6312ae464181ba37943896122fc Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 26 May 2026 11:12:25 +0700 Subject: [PATCH 2/2] fix(viewer): harden atlas surface null fields --- viewer/server.py | 13 +++++++------ viewer/tests/test_atlas_surface.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/viewer/server.py b/viewer/server.py index ce11ecb4..0aecf968 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -1432,6 +1432,8 @@ def _atlas_known_locations(snapshot: dict) -> list[dict]: continue x, y = _atlas_hex_position(row, idx) name = _text(row.get("name"), loc_id) + connections = row.get("connections") + connections = connections if isinstance(connections, list) else [] out.append({ "id": loc_id, "name": name, @@ -1442,12 +1444,9 @@ def _atlas_known_locations(snapshot: dict) -> list[dict]: "x": x, "y": y, "tags": _atlas_tags(row), - "connections": [ - _text(c) for c in row.get("connections", []) - if isinstance(row.get("connections"), list) and _text(c) in visible_ids - ], + "connections": [_text(c) for c in connections if _text(c) in visible_ids], }) - out.sort(key=lambda l: (not l["current"], l["name"])) + out.sort(key=lambda loc: (not loc["current"], loc["name"])) return out @@ -1596,12 +1595,14 @@ def _atlas_strategic(snapshot: dict, visible_ids: set[str]) -> tuple[list[dict], loc_id = _text(row.get("location_id"), _text(rid)) if loc_id not in visible_ids: continue + tags = row.get("tags") + tags = tags if isinstance(tags, list) else [] regions.append({ "location_id": loc_id, "controller": _atlas_faction_name(snapshot, _text(row.get("controller_id"))), "stability": int(_num(row.get("stability")) or 0), "unrest": int(_num(row.get("unrest")) or 0), - "tags": [_text(t) for t in row.get("tags", []) if isinstance(row.get("tags"), list) and _text(t)], + "tags": [_text(t) for t in tags if _text(t)], }) last_tick_day = int(_num(st.get("last_tick_day")) or 0) clocks.sort(key=lambda c: (not c["urgent"], c["remaining"], c["title"])) diff --git a/viewer/tests/test_atlas_surface.py b/viewer/tests/test_atlas_surface.py index 33aec226..19df3594 100644 --- a/viewer/tests/test_atlas_surface.py +++ b/viewer/tests/test_atlas_surface.py @@ -181,6 +181,36 @@ def test_atlas_surface_degrades_for_legacy_saves(self): self.assertEqual(surface["travel_options"], []) self.assertFalse(surface["camp_available"]) + def test_atlas_surface_tolerates_null_optional_lists(self): + snapshot = { + "current_location_id": "gate", + "locations": { + "gate": { + "name": "Gate", + "connections": None, + "visited": True, + } + }, + "strategic_state": { + "regions": { + "gate": { + "location_id": "gate", + "tags": None, + } + } + }, + } + + surface = server.build_atlas_surface( + snapshot, + campaign_id="camp_nulls", + live=True, + is_live_view=True, + ) + + self.assertEqual(surface["known_locations"][0]["connections"], []) + self.assertEqual(surface["region_control"][0]["tags"], []) + def assert_no_private_keys(self, value) -> None: private_keys = { "notes",