Skip to content

feat(viewer): bind OpenWorlds campaigns to local state - #127

Merged
100yenadmin merged 2 commits into
mainfrom
viewer/openworlds-campaigns-binding
May 25, 2026
Merged

feat(viewer): bind OpenWorlds campaigns to local state#127
100yenadmin merged 2 commits into
mainfrom
viewer/openworlds-campaigns-binding

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented May 25, 2026

Copy link
Copy Markdown
Member

Summary

Binds the exact OpenWorlds Chronicles launcher to real local ClawDnD campaign state instead of prototype campaign rows.

  • Adds a read-only GET /openworlds/campaigns.json viewer API.
  • Scans the active viewer state dir, repo-local play-state/*, and repo-local qa/state/* campaign snapshots.
  • Projects browser-safe campaign cards for the OpenWorlds launcher: source/run, world, day/time, location, party, provider, live/stale, current/resumable, quest count, sessions, and safe recap.
  • Updates the OpenWorlds app shell to fetch the catalog on load and replace demo rows when the API succeeds.
  • Keeps prototype data as a fallback only if the catalog endpoint is unavailable.
  • Keeps engine/viewer authority boundaries intact: the API reads snapshots and session mtimes only; it never imports engine writers or writes campaign state.

Refs #114.

Architecture Notes

The new viewer catalog is deliberately downstream-only:

  • viewer/server.py
    • _campaign_catalog_roots() discovers read-only roots from the active CLAWDND_STATE_DIR, play-state/*, and qa/state/*.
    • build_openworlds_campaign_summary(...) projects only player-facing fields and omits local absolute paths, dm_notes, lore recall input, sealed agenda text, raw transcripts, and other private state.
    • _openworlds_campaigns(...) returns the browser contract plus state_authority: engine and write_lane: /move.
    • canResume is true only when the campaign is in the viewer's active state root, because /dashboard?campaign=... cannot switch the server to a different state dir.
  • viewer/openworlds/app.jsx
    • Fetches /openworlds/campaigns.json once on load.
    • Replaces demo campaigns with the viewer catalog when successful.
    • Preserves demo fallback only on endpoint failure.
  • viewer/openworlds/screen-launcher.jsx
    • Handles real catalog fields defensively.
    • Opens the existing dashboard for resumable active-state campaigns.
    • Sends non-resumable historical/QA entries to the existing monitor instead of pretending the prototype table is live.

State Authority / Safety

This PR does not change engine, rules, voice, provider, or macOS public APIs.

The browser still has no direct campaign write path. The only game-intent write lane remains POST /move, and this PR does not add new POST routes.

Validation

Local validation from /Volumes/LEXAR/repos/ClawDnD-openworlds-campaigns-binding:

python3 -m py_compile viewer/server.py
python3 -m unittest discover -s viewer/tests -q
python3 scripts/license_check.py
git diff --check

Result: 20 viewer tests passed; license check passed.

Rendered smoke:

  • Started viewer/server.py against a throwaway state dir under /Volumes/LEXAR/Codex/clawdnd-openworlds-campaign-smoke.*.
  • Verified /openworlds/campaigns.json returned the real campaign row.
  • Used headless Chrome via CDP against http://127.0.0.1:8876/openworlds/.
  • Confirmed visible body text includes Smoke at the Gate and Lower City, excludes the prototype The Long Road to Odrun, and reports no runtime exceptions.

No Swift files changed, so swift build --package-path macos/ClawDnDApp was intentionally not rerun for this viewer-only PR.

Rollback

Reverting this commit returns OpenWorlds to the exact prototype-data launcher while preserving the already-merged /openworlds/ static route and macOS WebView route.

Summary by CodeRabbit

  • New Features

    • Campaign library loads on app startup and a new /openworlds/campaigns.json catalog endpoint lists available campaigns (including provider labels and read-only/monitor links).
    • Improved campaign details: party, chapter, region, day badge, recap fallbacks, and resume CTA with optional redirect.
  • Bug Fixes

    • Safer state updates on unmount and selection fallback when campaigns change.
  • Tests

    • Added catalog tests and helpers to ensure no private fields and repo/QA discovery.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6b9e64d3-13e1-417e-aa35-515be210a704

📥 Commits

Reviewing files that changed from the base of the PR and between 59ee4e0 and 0a58c49.

📒 Files selected for processing (3)
  • viewer/openworlds/screen-launcher.jsx
  • viewer/server.py
  • viewer/tests/test_openworlds_static.py
📜 Recent review details
🧰 Additional context used
🪛 Ruff (0.15.14)
viewer/server.py

[warning] 817-817: Using the global statement to update _openworlds_catalog_cache is discouraged

(PLW0603)

🔇 Additional comments (9)
viewer/openworlds/screen-launcher.jsx (1)

8-14: LGTM!

Also applies to: 16-20, 254-259

viewer/tests/test_openworlds_static.py (1)

152-152: LGTM!

Also applies to: 160-160, 201-202, 211-219

viewer/server.py (7)

47-47: LGTM!


533-601: LGTM!


603-675: LGTM!


677-749: LGTM!


752-860: LGTM!


1750-1767: LGTM!


1914-1915: LGTM!


📝 Walkthrough

Walkthrough

Backend discovers and summarizes campaigns from viewer state plus repo play-state/qa, caches and serves a capped catalog via GET /openworlds/campaigns.json. Frontend loads the catalog, keeps selection synced, supports resume/monitor redirects, and renders derived display fields. Tests validate projection, privacy, and discovery roots.

Changes

Campaign Catalog Backend

Layer / File(s) Summary
Imports & discovery helpers
viewer/server.py
Add quote import and filesystem utilities to resolve campaign directories, count sessions, create stable run IDs, and enumerate catalog roots across viewer state, repo play-state/*/campaigns, and qa/state/*/campaigns.
Projection formatting helpers
viewer/server.py
String normalization, day badge, party card/HP handling, relative time labels, and provider inference used to render browser-safe campaign rows.
Catalog caching & aggregation
viewer/server.py
Signature-based snapshot cache, recency-only refresh path, sorting/capping, and _openworlds_campaigns() aggregator producing shelf payload with totals and provider metadata.
Config mode update & route
viewer/server.py
Switch OpenWorlds to viewer-read-model (demo_data=false, demo_data_fallback=true) and add GET /openworlds/campaigns.json route returning the projection.

Campaign Browser Frontend

Layer / File(s) Summary
App-level catalog loading
viewer/openworlds/app.jsx
Mount-only effect fetches /openworlds/campaigns.json (cache:no-store), cancels on unmount, normalizes campaigns array, and sets activeCampaign/campaignCatalog (loaded or failed with demo-fallback).
Selection sync & resume
viewer/openworlds/screen-launcher.jsx
Effect ensures selected campaign matches state.activeCampaign when missing; onResume sets activeCampaign then optionally navigates to campaign.resumeUrl or campaign.monitorUrl.
Display helpers & detail rendering
viewer/openworlds/screen-launcher.jsx
Add normalizeCampaignParty, campaignRegion, campaignDayBadge, campaignChapter and use derived values for vignette label, header/pill/subtitle, stats, party list fallback, recap fallback, and conditional CTA label (canResume).
CampaignRow list rendering
viewer/openworlds/screen-launcher.jsx
Compute status ("Live" or source label), use campaignRegion() as subtitle fallback, default lastPlayed to "unknown", and format chapter for list rows.

Campaign Catalog Tests

Layer / File(s) Summary
Test setup & config assertions
viewer/tests/test_openworlds_static.py
Preserve/restore server._HERE across tests; adjust config assertions to expect demo_data=false and demo_data_fallback=true.
Projection tests & helpers
viewer/tests/test_openworlds_static.py
Add tests that /openworlds/campaigns.json projects live campaign rows without private fields and that repo play-state and qa campaigns are surfaced with correct IDs/provider/read-only/canResume/monitorUrl. Include _write_snapshot() and assert_no_private_keys() helpers.

Possibly Related Issues

Possibly Related PRs

  • 100yenadmin/ClawDnD#126 — Related client integration that opens the viewer at the new /openworlds route and depends on the catalog endpoints added here.

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: binding OpenWorlds campaigns to local state instead of using prototype demo data.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Warning

Review ran into problems

🔥 Problems

Errors were encountered while retrieving linked issues.

Errors (1)
  • LINEAR integration encountered authorization issues. Please disconnect and reconnect the integration in the CodeRabbit UI.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@viewer/openworlds/screen-launcher.jsx`:
- Around line 8-11: The effect and resume paths currently reuse
state.activeCampaign without confirming that id exists in campaigns, letting
selected become a stale invalid id; update the React.useEffect that sets
selected so it first checks campaigns.some(c => c.id === state?.activeCampaign)
and only uses state.activeCampaign when that check passes, otherwise fall back
to campaigns[0]?.id || ""; apply the same membership validation wherever
selected is re-assigned (the other effect at lines 13-17) and ensure onResume
uses a validated id (i.e., only resume when campaigns.some(c => c.id ===
selected) is true or substitute a valid campaign id) so selection and resume
always target an existing campaign.
- Around line 251-253: campaignRegion currently returns raw values which may be
non-strings and cause downstream calls like region.toLowerCase() to throw;
update the campaignRegion function to coerce the chosen value to a string (or
fall back to "Unknown") before returning — e.g. pick the first defined value
from c?.region, c?.location, c?.world and return String(value) (or value != null
? String(value) : "Unknown") so callers can safely call string methods.

In `@viewer/server.py`:
- Around line 751-792: The _openworlds_campaigns function rebuilds the entire
catalog on every call; add an mtime-based cache (pattern used by
_monitor_card_cache and _monitor_campaigns) to avoid reloading unchanged
campaign snapshot.json files on frequent polls: compute a cache key from
current_campaigns_dir (or roots) plus the latest mtime of snapshot.json files
(or per-root mtimes), store the summary list and total/now in a module-level
cache with that mtime, and return the cached value when mtimes haven’t changed;
update cache on change and ensure cache eviction/size (80) semantics match
existing behavior.

In `@viewer/tests/test_openworlds_static.py`:
- Around line 197-201: Add explicit assertions for the readOnly flag on the two
catalog entries: assert that by_id["play:play-20260525:camp_play"]["readOnly"]
is False (repo-local entry) and that by_id["qa:wave3-red:camp_qa"]["readOnly"]
is True (QA catalog entry); place these assertions alongside the existing
provider/canResume/monitorUrl assertions in test_openworlds_static.py so the
test locks the readOnly contract for both sources.
- Around line 155-158: Test currently checks substrings in the serialized JSON;
instead, parse the JSON back to a Python object and assert that private keys
themselves are not present anywhere in the structure. After the existing encoded
= json.dumps(campaign), call json.loads(encoded) and run a small recursive check
(e.g., assert_no_private_keys) over the resulting dict/list to ensure keys like
"private note", "hidden agenda", "private canon" (and other sensitive keys such
as "notes", "scenes", "lore") do not appear as dictionary keys anywhere; update
the test in test_openworlds_static.py (around the existing encoded variable) to
use this parsed-object key-based assertion rather than substring assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3500984f-0ad2-4942-8b21-4cedfd7137e9

📥 Commits

Reviewing files that changed from the base of the PR and between 040c3d8 and 59ee4e0.

📒 Files selected for processing (4)
  • viewer/openworlds/app.jsx
  • viewer/openworlds/screen-launcher.jsx
  • viewer/server.py
  • viewer/tests/test_openworlds_static.py
📜 Review details
🧰 Additional context used
🧬 Code graph analysis (3)
viewer/openworlds/screen-launcher.jsx (1)
viewer/openworlds/chrome.jsx (2)
  • Placeholder (204-213)
  • Pill (200-202)
viewer/tests/test_openworlds_static.py (1)
viewer/tests/test_build_options_bridge.py (2)
  • setUp (17-20)
  • tearDown (22-26)
viewer/server.py (1)
viewer/tests/test_openworlds_static.py (1)
  • _Handler (19-19)
🔇 Additional comments (16)
viewer/server.py (13)

47-47: LGTM!


533-537: LGTM!


540-544: LGTM!


547-557: LGTM!


560-600: LGTM!


603-607: LGTM!


610-615: LGTM!


618-644: LGTM!


647-662: LGTM!


665-674: LGTM!


677-748: LGTM!


1682-1699: LGTM!


1846-1847: LGTM!

viewer/openworlds/app.jsx (1)

21-65: LGTM!

viewer/openworlds/screen-launcher.jsx (1)

18-25: LGTM!

Also applies to: 122-126, 141-147, 192-196, 206-207, 217-217, 238-249, 255-258, 260-264, 280-281, 309-311

viewer/tests/test_openworlds_static.py (1)

28-29: LGTM!

Also applies to: 47-47, 83-85, 101-154, 160-196, 203-206

Comment thread viewer/openworlds/screen-launcher.jsx
Comment thread viewer/openworlds/screen-launcher.jsx
Comment thread viewer/server.py Outdated
Comment thread viewer/tests/test_openworlds_static.py
Comment thread viewer/tests/test_openworlds_static.py
@100yenadmin
100yenadmin merged commit 257f016 into main May 25, 2026
5 checks passed
@100yenadmin
100yenadmin deleted the viewer/openworlds-campaigns-binding branch May 25, 2026 22:06
100yenadmin added a commit that referenced this pull request May 26, 2026
… (#205)

Generalize the proven companion stage-machine onto a FACTION-owned
reputation/standing gauge so a faction questline can be JOINED, grown
through service, and led to a world-changing finale — closing the map's
"Faction.reputation is tracked but nothing reads it to gate anything" gap.
Reuse, not rebuild: the lifecycle enum, the _apply_structured_effect ripple
path, the degrade-not-abort seed loader, and the advise-not-act Director
surface are all generalized from the companion-arc / Event (Layer 3) machinery.

- models: additive Faction fields (rank, monotonic standing>=0, joined,
  questline_arc_id; old snapshots round-trip) + FactionArc/FactionArcStage
  (per-stage unlock_at gauge gate, Outcome-shaped finale_effect with an
  effect_applied idempotency latch) + Campaign.faction_arcs.
- faction_arc.py (pure module, mirrors events.py): gauge-gate eval (locked
  below unlock_at, available at/above; sign picks direction), join-gated
  evaluate, read-only rank-up detector, and a finale that ripples via
  worldsim._apply_structured_effect exactly once.
- server tools: join_faction (arms the arc), grant_standing (the monotonic
  membership gauge), advance_faction_arc (gate-enforced, finale-once),
  set_faction_arc, get_faction_arcs, check_faction_arcs (advance + advisory
  nudges) — mirroring the companion-quest-arc surface.
- scene_debt: a faction_rank_available detector (advisory, severity low).
- content: faction seed reads the new fields; a new degrade-not-abort
  faction_arcs world/overlay block (its own block, since a faction isn't a
  Character so companion_seeds doesn't fit).
- exemplar: the Flaming Fist's "Banner of the Fist" arc in world.json —
  take the oath (reputation) -> earn the captaincy (standing) -> raise the
  banner (the world-changing finale puts the Gate under the Fist's banner).
  Original in-character prose, CC-BY fan content.

Additive + idempotent (empty == today byte-for-byte). 39 new tests; full
engine suite 1253 passing single-process; license_check green.

Co-authored-by: Eva <eva@100yen.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant