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 */}
-
-
- {/* 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 (
+
+
+
+
+
-
+ {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..0aecf968 100644
--- a/viewer/server.py
+++ b/viewer/server.py
@@ -1368,6 +1368,288 @@ 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)
+ connections = row.get("connections")
+ connections = connections if isinstance(connections, list) else []
+ 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 connections if _text(c) in visible_ids],
+ })
+ out.sort(key=lambda loc: (not loc["current"], loc["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
+ 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 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"]))
+ 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 +2768,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 +2978,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..19df3594
--- /dev/null
+++ b/viewer/tests/test_atlas_surface.py
@@ -0,0 +1,236 @@
+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 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",
+ "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")