Skip to content

Keep game-system state per page and let systems add panels to NPC pages - #1051

Merged
keyxmakerx merged 2 commits into
mainfrom
claude/project-thread-mu3i3i
Oct 3, 2026
Merged

keyxmakerx merged 2 commits into
mainfrom
claude/project-thread-mu3i3i

Conversation

@keyxmakerx

Copy link
Copy Markdown
Owner

Fixes #1044
Security implication: new per-page store with a GM half and a player half. The GM half reaches only the owner and members given DM access (web GET/PUT via cc.CanAuthorDmOnly(), sync API via the DM-grant-promoted Owner role). Writes are DM team only. Every read runs the page's own view check (private or foreign-campaign page → 404). The WS message carries ids only and is RequiresDM.
Consumer-verified: Draw Steel widgets/negotiation-tracker.js (Chronicle-Draw-Steel, branch claude/project-thread-mu3i3i) calls GET/PUT /campaigns/:cid/entities/:eid/system-state/drawsteel/negotiation and reads isGm, gm and public.
Mockup: Sign-offs card mock-npcs-foundry (approved). The panel's look on the real page is on the small-differences card diff-npc-negotiation-panel (mockup running the real panel code). This PR waits on that card.

What this changes

Before: Chronicle had nowhere to keep a game system's structured state for a page. Fields are scalar, GM-only filtering strips whole fields, and NPCs are often the core npc type, which no system preset covers. A system widget reached an entity page only as a block the GM placed by hand, and that block wasn't told whether the viewer is the GM.

After: a game system can keep JSON state per page in two halves, one for the GM and one for players. It can also name a widget in a new manifest key entity_panels, and Chronicle then mounts that widget under the title of NPC-family pages, with a GM flag. The first user is Draw Steel's negotiation tracker.

How:

  • New plugin internal/plugins/systemstate.
    • Migration 001_entity_system_state (FK cascade to entities and campaigns). Routes are registered only when the plugin is healthy.
    • PUT follows the partial contract: an absent half is kept. A null, non-object or over-16 KiB half gets a 422. Writes are refused for a system not enabled in the campaign.
    • It publishes system_state.updated, which is not in the change feed (pinned by test).
  • Sync API GET /api/v1/campaigns/:id/entities/:entityID/system-state/:system/:key, for the Foundry mirror.
  • entity_panels in internal/systems/manifest.go is validated on load: the widget must be in the same manifest, and applies_to must be npc.
    • The resolver in internal/app/system_panels.go reuses npcTypeIDs.
    • The mount (entities/system_panels.templ) sits in the title block, and above a system page renderer when one replaces the layout.
    • Panel widgets are left out of the layout palette, so nobody places a second copy by hand.

Why

#1016: the approved NPCs card puts a Draw Steel negotiation tracker on NPC pages, mirrored to Foundry.

Honest deviations:

  • GET does not require the system to still be enabled, so state survives a system being switched off. Only writes check it.
  • A null half on PUT is rejected rather than cleared, because each half must be an object. {} clears it.
  • Concurrent edits to the same half are last-write-wins. The two halves never overwrite each other.
  • internal/sanitize/sanitize_invariant_snapshot.txt gained one line for the new service.
  • Not run: integration tests, make test-js, golangci-lint (local binary too old). The migration was not run against a real MariaDB; CI's Fresh-DB Migration Replay covers that.

Test plan

  • go build ./..., go vet on touched packages
  • go test $(go list ./... | grep -v /integration) -count=1: 54 packages ok, 0 failures. New tests cover service validation, the role split (player, scribe without DM access, members given DM access, owner), IDOR and private pages, the PUT gate, the sync split, the change-feed pin, the publisher, manifest validation, the panel resolver and the data-is-gm mount.
  • tools/check-plugin-isolation.sh, tools/check-migration-immutability.sh, page-scripts, widget-mounts, templ-packages checks pass
  • CI

Tenet self-check

  • T-B1 security: role split, IDOR, size caps, ids-only GM-only WS
  • T-B2 plugin isolation: entities never imports systems or systemstate; wired through interfaces in internal/app
  • T-B3 production UI: Chronicle only adds the mount; the panel's own states are in the Draw Steel widget
  • T-B4 docs: new plugin .ai.md, docs/system-package-rendering.md (entity_panels + state API), docs/api/openapi.yaml, websocket, systems, entities and data-model docs

Generated by Claude Code

A game-system package needs somewhere to keep per-page state (first user:
a Draw Steel negotiation tracker) and a way to put a widget on NPC pages
without Chronicle shipping Go for it.

systemstate plugin: entity_system_state table keyed (entity, system, key)
with a public half and a gm half. GET is open to anyone who can view the
page; the gm half is returned only to the owner and DM-access members
(CanAuthorDmOnly) and is absent otherwise. PUT is DM-team only and follows
the partial-update contract: an absent half keeps the stored half, a
present half replaces it, null is rejected rather than read as absent.
Entity/campaign pairing, the show-page visibility gate and the
system-enabled check are enforced; writes publish a GM-only
system_state.updated with ids only, which is not a change-feed type. The
Foundry module reads the same document through a read-only sync route with
the same gm rule.

entity_panels: manifests may list {widget, applies_to: "npc"}. The host
mounts the widget under the title of NPC-family pages (npcTypeIDs rule)
in campaigns with the system enabled, via a resolver wired in app so
entities stays system-agnostic. Panel widgets are left out of the layout
palette so they are not placed twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2N6zhGuT2DHzVoiFUMwv1
…-mu3i3i

# Conflicts:
#	.ai/architecture.md
#	internal/plugins/syncapi/api_handler.go
@keyxmakerx
keyxmakerx marked this pull request as ready for review October 3, 2026 23:53
@keyxmakerx
keyxmakerx merged commit 93585a8 into main Oct 3, 2026
10 checks passed
@keyxmakerx
keyxmakerx deleted the claude/project-thread-mu3i3i branch October 3, 2026 23:58
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.

Per-page game-system state and auto-mounted system panels (for the Draw Steel negotiation tracker)

2 participants