Skip to content

v0.76.0 — System 8 content comes from the module, not the wiki

Shipped inside the v0.78.0 merge commit, together with v0.77.0, rather than as a merge of its own.

System 8's rules content was fetched from system8.purplecat.wiki at runtime — on server startup, per-request through /api/system-data/proxy, and from the browser. It now ships inside @purple-cat-games/system8 (0.13.0), compiled from the docs repo by that module's publish workflow, the way @purple-cat-games/dnd5e already ships the SRD. Spec: docs/shipped/25-system8-build-time-ingestion.

The module half is described in the vtt-modules changelog. This is the platform half, which was the larger one: the browser and the server both read that content, over paths the module never saw.

  • Every client read now goes to the module's own routes. wikiDocUrl builds /api/modules/{systemId}/docs/<path> instead of a wiki URL wrapped in the CORS proxy; useWikiTraitDetail, PlayModeView, CharacterDetail, MonsterGenerator, AddFeatModal, AddPowerModal, FeatsList, PowersList, EquipmentList and TemplateSelector all follow it. Nine components had their own copy of "swap .html for .json, prefix the origin, wrap in the proxy"; there is one copy now, in utils/systemDocFetch.ts (ui-components) and utils/wikiDocUrl.ts (web).
  • Record lists too. useCharacters.getSystemData and characterToToken.fetchPowersJson each fetched and cached powers.json separately; both read one shared loader (utils/system8Records.ts) over the module's data-table routes. These calls are authenticated where the proxy calls were deliberately anonymous — module routers sit behind the platform's requireAuth catch-alls, so an anonymous request gets Clerk's HTML error page rather than a document.
  • MODULE_API_VERSION 1.30.0 — ModuleServerExport.bundledContent. A module can hand the host the content it ships (provenance, getDocument, listRecords). This exists for the server: power-action derivation needs the same documents the browser reads, and self-calling its own HTTP route would be circular and slower than a memory read.
  • The server-side wiki fetch is gone, and it was the important one. powerActionRefs.loadPowerRefs and derivePowerActions reached the wiki through fetch() rather than the proxy, so they bypassed the module host allowlist entirely — de-allowlisting the wiki would not have stopped them, and derivation would have gone on reading live content while every other surface served the bundle. Two sources of truth for the same rules is worse than one stale one. derivePowerActions no longer fetches at all: document loading is an injected loadDocument, required rather than optional, so a call site that was never migrated fails to compile instead of silently falling back.
  • Admin ▸ Systems reports where the rules came from — system8 8.15.0 · main @ 644f58f · ingested …, with a beta pill for content built off a non-default branch. And Reload stops pretending to fetch rules: it reseeds the DB fixture, which is all it has ever done apart from clearing a wiki cache that no longer exists.

Two bugs were caught by tests while repointing, both of the kind this project exists to remove — an underived power does not look broken, it falls back to a convincing generic roll:

  • The power lookup keyed on the wrong field. characterToToken matched a character's power against the wiki's name, which was a slug (control-wind); normalized records carry the display name. Every lookup would have missed, and every power would have lost its action URLs and subcategories. Both sides are slugified now.
  • type was being lowercased into the UI. The module normalizes class/school/type for filtering, and type is also what the action bar's subcategory labels are built from — so "Control" would have rendered as "control". Filtering lowercases both sides anyway, so the module now keeps the source's capitalisation for that one field.

Also: a self-ranged area power had no targeting at all

Found while verifying the above with the wiki blocked, and unrelated to the ingest — it has been true since areas were added.

buildTargetingFromMetadata disabled targeting whenever a power's range was self. That is right for a self-buff, and wrong the moment the power also has an area: a cone or burst emanates from the caster and still has to be aimed. The areaType was being set on an already-disabled target, so nothing was placeable.

System 8's Color power showed it plainly — three actions, one power: Color Spray (cone 2+1/rank, range self) had no targeting, while Color Pool and Color Veil (same power, ranged) worked. The area string parsed fine all along; range: self was the culprit.

A self-ranged area now enables targeting with range: 0 — the encoding the targeting system already uses for "originates at the caster": melee attacks set it, getAreaClickSteps reads a range-0 circle as a close burst, and cones and lines take their origin from the token regardless. The re-enable sits inside the area-parse guard, so an area string the parser does not recognise still leaves a self power untargeted rather than inventing a prompt for it.

And the area parser was dropping 30 of 106 areas

Sweeping the bundle to size the fix above turned up two more defects, both older than the ingest work. Together with emanation, 30 of the 106 action documents that declare an area were getting the wrong shape or no shape at all:

declared documents was now
emanation 2 15 no area at all circle, radius 2.5
close burst 3 14 circle with no size → fell back to radius 1 circle, radius 3.5
burst 2 11 circle, radius 2 circle, radius 2.5
centered circle 2 1 no area at all circle, radius 2

close burst is the sharpest of the three: the special case meant to read its size tested the shape name after reassigning it to 'circle', so it could never fire, and the size was parsed from the word "burst". Fourteen powers had been quietly using a radius of 1 in place of whatever they declared.

The parser now scans for the first word naming a shape and then the first number after it, rather than indexing parts[0] and parts[1]. That fixes the class rather than the instances: leading qualifiers (close, centered) and trailing prose are skipped, and a compound like "line 10 or cone 5" still resolves to the first option it offers rather than the last.

The burst family is one shape, and its radius is not the number it states. emanation, burst and close burst — 40 documents between them — all describe a circle inscribed in a square of side 2R+1 centred on the caster, tangent to that square at the four cardinal points. Its radius is therefore R + 0.5, and the corners of the bounding square fall outside it; it is not a square block. Under the default true-circle rule (distance <= radius) that offset makes the shape exact.

What separates a burst from an emanation is persistence, not shape: an emanation moves with the caster, a burst stays where it was placed. The platform has no way to express an area that follows a token, so the three resolve identically here, and the distinction is recorded in the code so the next reader does not "fix" the apparent duplication.

A plain circle N is deliberately left as a literal radius — nothing has established that System 8's ordinary circles use the same grid convention as its bursts, and circle 30 is a ranged area rather than a burst around anyone.

One caveat on the offset: it is measured against the default circleMethod: 'true-circle'. A campaign set to half-square tests distance <= radius + 0.5 and would apply the same offset a second time, drawing every burst half a square large.

All 106 area-bearing documents now resolve to a shape and a size.

Deploy together. system8 0.13.0 de-allowlists system8.purplecat.wiki; an older platform still building wiki URLs breaks on detail documents, power actions and advancement. modules.hosted.json pins exact versions, so this release moves both.

The /api/system-data/proxy route stays — it is generic platform machinery and still serves the legacy artwork hosts (ursa.bertball.com, wiki.bertball.com), which carry token art already on live maps. Only System 8's rules traffic left it. The reference-panel iframe still points a browser at the live wiki, and images inside rendered reference prose still load from it directly; neither goes through the proxy, and neither is affected.