Pilgrimage

Pilgrimage: Skirmish

Versions

Pilgrimage Online is generated from the canonical rules in Notion, and the git commit of synced content is the version freeze — no edition is "live" until it is committed. Each rules page also carries its own status (v1.0 or Reconciled) and sync date in its footer. The history below is the plain-English record; newest first.

Added .claude/hooks/guard-self-kill.py as a PreToolUse hook on Bash in

.claude/settings.json, ported unchanged from Paideia (iamkeithmason/paideia#131).

The Bash tool runs each command as bash -c "<command>", so pkill -f PATTERN

also matches the calling shell and kills it mid-command; this happened twice in

one Paideia session on 28 Sep. The hook blocks pkill -f/--full, pgrep -f

combined with kill, and ps | grep | kill when the pattern matches the

command's own text, and suggests selecting by binary or a bracketed pattern

instead. Unparseable commands are allowed through.

npm run sim:r1 sweeps the Reinforcements rate that was adopted on 15 Sep,

against a metric that did not exist then. The verdict is

tools/sim/battle/REINFORCEMENTS.md; the criterion is

PREREGISTRATION-R1.md, committed in its own commit before

reinforcements.mjs existed, because the same session built the instrument,

ran it and read the result.

The finding. The Crossing as canon writes it leaves the Shadow absent for a

full Turn in 31% of battles, and momentarily absent in 49% — while the

Oathband wins 74.6% of those same battles in a median 7 Turns. A win-rate

measurement structurally cannot see an empty board, which is why this does not

contradict the 15 Sep pricing ("reinforcement is not the difficulty lever").

The two measure different things, and the metric is what decided the finding.

Of five rates at 10,000 battles an arm, only light — 2 faces of two Foes,

4 of one, 6 of none, first rolled Turn 2 — clears both bounds: **4.3%

collapse, 37.9% overrun, 1.66 Pilgrims OOA**. Everything heavier breaches

overrun; the 15 Sep rate misses the collapse target at 10.1%. The

selection rule ("leanest clearing both bounds, ties to lower OOA") was

committed with the criterion, before any data, because with six variants and

five metrics some variant wins on some reading.

Turn 2 beats Turn 1 for a reason the brief did not anticipate: median first

contact is Turn 3, so a Turn-1 arrival lands before the sides have met and

merely enlarges the starting force — 4.0 points of overrun for 0.7 of collapse.

The brief was overtaken, and answered against itself. It pre-authorised

building a separate abstract Trial model on the premise that nothing plays

Trials Turn by Turn. campaign.mjs does not, but crossing.mjs does, and

already implemented every mechanic the brief listed as needing building — plus

the reinforcement rule Keith had already ruled on. The engine was extended

instead, and its defaults did not move: `--reinforce-variant adopted

--arrival ahead` reproduces all four committed baselines numerically, so every

figure in NATURES.md still stands.

Three of the brief's "fixed inputs (decided)" contradicted canon and are

recorded in the report rather than quietly resolved: the **First Road premade

Oathband does not exist** (Breaker and Hunter are Shadow Champions, Stalwart is

absent from the corpus, Wayfarer is a word on the name generator); there is **no

Move stat and the brief omits Valour; and "week 1 (Renown 0)" is wrong** —

canon starts 4 Pilgrims at Renown 4 — and changes nothing, because ⌊4/10⌋ = 0

and both arms are byte-identical at 4.80 Foes. That null is a clean

demonstration of recorded canon gap #3, "Doom below 10 adds no Foes".

New: reinforcements.mjs (rates and arrival geometry as data, on the

natures.mjs pattern), reinforcements-report.mjs (the sweep),

reinforcements.test.mjs (22 mutation tests — a collapse rate is only

meaningful if the metric can be driven to a known value), and the npm scripts

sim:battle, sim:r1, sim:r1:test. Collapse derives from foesOnBoard,

signal 1 of the 2.6 pre-registration, so no new signal was invented and

--summary-json's contract is untouched; --outcome-json is a separate

emitter.

Also corrected: reinforce() was headed // PROPOSAL, off by default, which

had been untrue since the 15 Sep ruling adopted it and turned it on — a stale

marker in the file CLAUDE.md names as the worked example of the derivation

convention.

Reported against itself. On open-ground overrun reaches 41.7%, past the

bound, and is in the table rather than dropped. The brief's "22 Sep playtest

(1 OOA)" has no in-repo source and is not treated as a measurement. And two of

the brief's three archetypes are not delivered: the Vigil and the Reckoning

need an invented Oathband policy that would itself determine the collapse rate

it was built to measure, so they are named as the follow-up with their own

pre-registration rather than guessed at.

A correction, and a bug found by reading the Decision Log. Keith ruled on

23 Sep that reinforcement resolves the Shadow Generation table in full, "roll

twice" included, "however deep it goes" — citing a Turn-2 quadruple arrival as

what made a Trial memorable. reinforce() did not do that: it read "Roll

twice" as ONE reroll rather than TWO results, and dropped the slot entirely on a

second 'twice', so a quadruple arrival was unreachable. rollShadowForce() at

deployment had always been correct, so **the same canon table was resolved two

different ways depending on which end of the battle you were at.** Fixed, pinned

by a test (figures per arrival now measures 1.20, the deployment figure

shadow-nature-packages.md states), and every arm re-measured. The

recommendation did not change; the figures moved up to a point, and two of them

are already printed in canon and in the Decision Log row for Keith to correct.

Also corrected in the report: the brief's two-phase Reckoning was not an

invention — it is Settled 13 Jul 2026 in the Decision Log, just never written

into the-reckoning.md (In Canon: NO), so the synced mirror was stale rather

than the brief wrong. And the Moor readiness audit the brief cited does

exist, in the Decision Log, DONE 22 Sep; it just is not Plan row 5.5.

Still owed, and not doable from this container: the mirror is two chapters

short. Reinforcements on the Moor (22 Sep) and Reinforcements (24 Sep) are

both in the Rules database and neither is synced. There is no NOTION_TOKEN

here, so npm run sync and npm run sync:campaign are owed on a machine that

has one.

PR [#39](https://github.com/iamkeithmason/pilgrimage/pull/39).

The Notion Campaign Completion — Decision Register (bb0065de…, data source

ea075be2…) has been retired and absorbed into Pilgrimage — Decision Log

(35b684a5022c4054a92c35536f526495, data source

collection://84d6248e-df25-4933-89a1-978bc37efac1). All 57 rows moved with

their page IDs unchanged, so every link already written still resolves. This

commit makes the repo say so: nothing reads from or writes to the Register.

What changed. Every prose and comment reference to the Register now names

the Log — CLAUDE.md, the two simulator READMEs and HANDOVER.md,

predictions.md, campaign.mjs, crossing.mjs, natures.mjs, the playtest

pack's gap list, and research/invented-defaults.md. The old field vocabulary

went with it: Ruling → Decision Made, Decided → Date, and the sim README's

Decided <date> (Keith) labels now read Settled …, which is the

Log's own Status value. tools/sim/README.md carries a legend mapping its

decision table's columns onto the Log's fields.

New in CLAUDE.md: a Decision records section — one database, the

three classes (DECISION needs Keith · PROPOSAL has a default and work

continued · DONE is verified by a check), the Status mapping (Open → Under

Review, Decided → Settled), the field-name table, and the standing rule that

sub-agents may read Notion but every write stays in the orchestrating thread.

Also a note on the Notion space's seven hubs, with the point that IDs are

stable and paths are not, so repo references should use IDs.

A discrepancy found by reading the schema, and resolved. The brief said

the Log has no Superseded status; the live schema, fetched read-only through

the Notion MCP, does expose one. Flagged rather than papered over, and Keith

amended the same day: Superseded is real and is used. So the Status

vocabulary is four options — Settled / Provisional / Under Review /

Superseded — the Register maps on as Open → Under Review, Decided → Settled,

Superseded → Superseded, and when a new ruling replaces an old one the old row

is marked Superseded with the replacing row named in its Decision Made.

Worth noting the shape of it: the check that caught this was reading the

schema instead of trusting the brief's description of it, which is the

output-layer convention applied to a handover rather than to a number.

Not changed, deliberately. src/content/campaign/mark-the-shadow.md still

says "Open rulings are logged in the Campaign Completion — Decision Register".

It is a synced Layer-3 mirror of Notion and is not ours to edit; the fix is a

canon edit in Notion plus a re-sync. Dated history — this changelog's earlier

entries, critic-rounds/, tools/lint/reports/ and verdicts/ — is left

alone: those record what was true on their date.

Also recorded (not implemented). Keith settled on 2026-09-21 that Lore

keywords stay single-source in Notion but must be surfaced wherever they are

invoked — hover/expand on the web, an auto-generated reminder gloss in print —

with hand-written inline redefinitions still banned. The older "Book of Lore

and nowhere else" wording is corrected in CLAUDE.md and

tools/lint/build-lexicon.mjs, and tools/lint/NOTES.md carries a backlog

entry for the lint rule that would flag hand-authored definition text outside

Lore Definitions. It cannot be written until the gloss generator exists and

stamps its output: generated gloss and a hand-typed redefinition are identical

by design, so the discriminator has to be provenance, not wording.

Taxes withheld. Each Shadow Archetype taxes one player resource, and canon

says outright that the tax is *never named in rules text — the player

discovers it by paying it*. The record names them anyway, for the designer.

The pack now withholds them: two table columns and seven lines, out of *Mark

the Shadow and Shadow Nature Packages*, matched by what they say rather than

where they sit, so a re-sync cannot slip one through. Withheld at render,

never in the mirror — that is generated from Notion and is not ours to edit —

and PLAYTEST_SHOW_TAXES=1 npm run playtest prints the designer's copy. The

page states the count, and the count is produced by the pass that does the

withholding rather than typed into the prose.

45 pages, and a correction owed first. The 59-page pack was reported as

"about half full on many leaves". That was measured in characters per page,

which is the wrong instrument: a page of D12 tables and heads holds few

characters and is visually solid. Measured properly — ink extent against type

area — the body ran at 93% fill, so there was no slack to pack out and

fewer sheets could only come from a denser page or less content. No content

was cut. The page is now the book's scale × 0.85: leading 11.05 pt, 67 lines

= 261.18 mm, 176 mm measure against the book's 166. Putting --unit back to

13 pt with the book's margins restores the book's page exactly.

Four structural leaves came back along the way, each of them a fault rather

than a saving: a blank leaf where .front's break-after met .sheetform's

break-before; the colophon alone on a page at 27% ink, because a leaf pinned

to the type area overflows its last block instead of growing; the back matter

running on out of the last chapter once that pinning was removed (.front

breaks after itself, never before); and the title page, which a book earns

and a proof sheet does not — the wordmark is a masthead on the first text page

now, and the leaf it was taking printed at 46% ink.

The bug under all of it. .sheet carried a padding rule outside any media

query, at the same specificity as book.css's print reset and later in the

cascade. The print was therefore taking the @page margin and the padding:

a 144 mm measure where 176 was intended, on every leaf, through several

rounds of "why is this still 46 pages". Scoped to @media screen.

Worth keeping, because it is the same shape as the contents-clipping lesson

from earlier the same day: both were CSS silently doing something other than

what the source said, and neither was visible in the HTML. The completeness

check reads the built page, so it cannot see either. **What caught both was

rasterising the printed PDF and measuring it.** The instrument has to observe

the layer where the failure happens.

Also: the area arithmetic over-promises and now says so in the stylesheet. At

0.9 scale it predicted 48 body pages would become 34; they became 40, because

tables keep their minimum widths and a third of this corpus is table. 0.85 was

arrived at by measuring four scales, not by solving for one.

Keith: *"I think I have a critical path MVP for the campaign … I don't know if

we could produce a printout for playtesting?"* Yes — and the pack is now

npm run playtest, one command to

pdf/pilgrimage-campaign-playtest.pdf. 59 A4 pages, set on the printed book's

grid and faces, and a **companion to the Skirmish print rather than a

replacement**: the battle engine is not reprinted.

The finding that mattered came before any of the typesetting. The campaign

mirror was a chapter short. Shadow Nature Packages — the five Shadow

Archetypes, the whole of Session 2.6's output — is tagged Campaign in the

Rules database with Home Book Book of Weeks and Order 335, and had never

been synced: the mirror's last run was 2026-09-13 and the page was written on

the 15th. A playtest pack printed from the mirror as it stood would have gone

to the table with no Nature packages in it at all, which is the one thing

the playtest is meant to test. Re-synced; 57 files differed by their synced:

line alone, one file was new.

  • npm run playtest — scripts/make-playtest-pdf.mjs. One pass, no

Python: the book's two passes and its stamper buy folios and running heads,

and the pack trades those for a build that runs anywhere the site builds.

Before printing it asserts that every mirrored chapter is on the route,

which is the check that would have caught the missing chapter on its own.

  • /campaign/playtest — the print route. The mirror in Notion reading

order under its Book bands (III, V–VIII, numbered as the whole rulebook

numbers them, not from one), plus a running order, the model's ten

predictions with a box to tick, the twelve known gaps, and four record

sheets: Campaign Tracker, Oathband Roster, Week Record, Hesitation Log.

  • The three derived sections each name their sources, per the standing

convention, and the running order says outright that where it and a chapter

disagree the chapter is right. The gaps list exists so a hesitation at the

table can be told apart from one already on the books — *a hesitation that

is not on the list is the valuable kind.*

  • Editorial notes are kept, not edited out. Campaign canon carries a great

deal of the record talking about itself, including several notes that name a

tax the rules text deliberately withholds from a player. In a pack its

designer plays from, that is the useful half; it is labelled rather than

hidden, and a reader's edition will not carry it.

Repo work under it. A campaign collection (order nullable, so the Lost

Site's absent Order survives as null rather than becoming 0) and

src/lib/campaign.ts. The 723-line compositor moved out of book.astro into

src/components/BookCompositor.astro, and the dist server, the Chrome finder

and the print call out of make-pdf.mjs into scripts/lib/print.mjs — both

prints now share one copy, and the book's built HTML is byte-identical across

the move. PLAYTEST.md.

One typesetting lesson worth keeping. Setting the contents in two columns

with a stated height packed it neatly and then silently clipped three whole

Books off the bottom — and the completeness check passed, because it reads the

HTML and clipping is a visual act. Balanced columns with no height overflow

onto a second page instead: ugly and visible beats tidy and wrong. The leading

that keeps it to one leaf is measured and the arithmetic is in the CSS.

**The prediction sheet's staleness trigger fired, and it was a false alarm —

established rather than assumed.** tools/sim/predictions.md says to

regenerate if the mirror's synced: stamp moves. It moved. So the simulator

was run against the mirror before and after the re-sync at the sheet's own

command and seed, and the outputs are identical; the note is in the sheet. The

same check turned up two figures in its prose that don't match campaign.mjs

as committed and predate this work (Doom p90 18 vs 17; the final Heart point

split 57/43 vs 59.0/41.0) — logged in the sheet, not quietly rewritten.

Lint clean: campaign ratchet 6 error findings, 6 accepted, 0 new, 0 stale

with the new chapter in scope.

Session 2.6. Layered the six Shadow Nature Packages onto the Session 2.5 Crossing

sim as data (tools/sim/battle/natures.mjs), with the distinguishability

criterion **pre-registered in its own commit before the Natures were

implemented** (PREREGISTRATION.md) — |Cohen's d| ≥ 1.0 on a fixed list of

observable signals, because a player watches one battle and a p-value at 3000

battles answers a different question.

  • Verdict (tools/sim/battle/NATURES.md): **50 of 55 pairs separate, 46 of

them by Turn 2** — and every separation that early is composition, movement

or spacing rather than combat, since first contact is Turn 3.

  • The five that don't separate all involve the Broken Host. Its Burden

leashes the whole force to its bow line and masks whichever Nature it's

paired with — three of Composite's five permitted pairings contained it.

  • Creeping Rot cannot be told from having rolled no Nature at all, on the

Crossing as canon currently writes it.

  • Verifying the packages' keywords against the corpus turned up **four engine

departures from canon**, all fixed and default-on with off switches.

Group (X) was the big one — it's what makes Shadow Generation yield 1.20

figures a slot rather than 1.00.

Keith's ruling, same day: Broken Host dropped from Composite and the

pairing table re-curated; Creeping Rot's invisibility accepted (its tax is

campaign-layer, not battle-layer). The second ruling pushed further than

expected — a Nature that can't be read alone can't be read as half a pairing

either, so Creeping Rot is out of Composite too, leaving two pairings out

of the original ten (Unquiet Dead + Hollow Court, Unquiet Dead + Wild Dark).

Notion updated (Shadow Nature Packages, Mark the Shadow, fourteen Decision

Register rows), campaign mirror re-synced.

**That left one open question — was a two-entry Composite worth keeping —

answered the same day: Composite is deferred from release.** v1 ships five

Archetypes on a D12 at two bands each, 11–12 reading "choose any". Held back

rather than shipped thin: two pairings that both start with the Unquiet Dead

don't tax Preparation, which is the only reason Composite exists; it's the one

Archetype with no ground it can't win on (its own design framework's tell for

too-strong); and it carries the most rules surface in the book for one

campaign in six. Nothing deleted — the package, the measured pairing table and

the route back are kept.

Building it (not just reading it) found four canon gaps reading couldn't:

terrain never blocks Line of Sight (leaves the Hide Deed unperformable), the

Cairn is described two opposite ways in one sentence, Doom below 10 adds no

Foes, and no Crossing Event table can add a Foe mid-battle while six

set-piece Trials can. All logged as Decision Register rows.

Open threads for next time:

  • Decision Register now carries ~13 Open rows across Sessions 2 (Unquiet Dead

drafter), 2.5 and 2.6 — worth a triage pass to close what's already settled

by rulings recorded elsewhere and confirm what's still genuinely open

  • research/pre-registering-the-criterion.md — the write-up on pre-registering

before building the instrument that measures it

Built tools/sim/battle/ — a battle-level simulator, distinct from the

campaign-loop Monte Carlo (tools/sim/), that plays one Crossing on the Moor

to resolution and reports per Turn only what a player at the table could

see. Deliberately not a balance model, and deliberately knows nothing about the

six Shadow Natures — that pass handed over to a fresh session per the plan

row's independence requirement, briefed by tools/sim/battle/HANDOVER.md.

Both sides generated from canon, not intuition — this is the point of the

build, not a nicety. The Oathband comes from recruitment.md's 600 Supplies;

the Shadow from shadow-generation.md's rolled table. Earlier hand-rolled

inputs (an Oathband and a Shadow force "made up from intuition") had reported

the Major Trial at 34%; regenerating both sides from the actual corpus put the

same engine at 86% — not a refinement, the opposite conclusion. Write-up:

research/invented-defaults.md.

Open threads for next time:

  • Layer the six Shadow Natures onto the engine (became Session 2.6, above)
  • The book is the Codex. /skirmish/book is now the Bronze Codex — A4, two

columns, mirrored margins, one 13 pt baseline, Cinzel and Alegreya, eight

schematics. The old two-column book.astro is replaced, not kept beside it.

The files took permanent names: src/pages/skirmish/book.astro,

src/styles/book.css, src/components/BookFigure.astro,

src/data/book-folios.json.

  • The other three concepts are gone — field-manual, tome, atlas, their

stylesheets, components, data files and the two scripts/concepts/ helpers.

With them went six font families nothing else used: public/fonts/ is down

from 51 files to 7, Cinzel and Alegreya, latin subsets only. No dead CSS,

no orphaned @font-face, no link anywhere to a removed route.

  • npm run pdf is self-contained. The two-pass folio build and the stamping

pass lived in a scratch directory; they are now scripts/make-pdf.mjs,

scripts/book-folios.py and scripts/stamp-book.py. One command on a clean

checkout does build → print → find folios → rebuild → print → stamp →

pdf/pilgrimage-skirmish-v1.pdf. Verified in a fresh clone.

  • Folios are read from the PDF's named destinations, not its text. The

contents page now links each entry to its chapter and each figure entry to its

drawing, so Chrome writes a destination naming the page it landed on. That

removed the text-extraction step (and pypdfium2 with it), and made the

contents clickable in a reader as a side effect.

  • Dependencies, stated and checked. The PDF build needs Chrome and a Python

with pikepdf, reportlab and fonttools; both are checked before any work

starts and a missing one is named in plain words. The site build stays

zero-dependency — Cloudflare Pages runs npm run build and nothing else.

The print no longer needs the network at all.

  • brotli joins the Python list. fonttools reads woff2 through brotli,

which it declares as an optional extra rather than a dependency, so a fresh

pip install of the documented three left the stamper dying in a traceback

from fontTools' woff2 reader. brotli is now in PDF.md, the pre-flight

check in make-pdf.mjs and the scripts' own import guards, and a clean

install of the documented list runs npm run pdf to the finished PDF.

  • Layout lint now discovers one print document (--doc=book) rather than

four concepts. Standing result unchanged: 0 error · 1 warn · 1 info, both

known and documented in PDF.md rather than suppressed.

  • Umami wired, env-gated. The snippet is emitted only when both

PUBLIC_UMAMI_SRC and PUBLIC_UMAMI_WEBSITE_ID are set at build (Cloudflare

Pages env vars — both are public, not secrets), so the site ships

analytics-free until they're configured. No cookie banner (privacy-first).

  • The three Growth-Model custom events:
  • roster-completed — fired from the Oathband journal's Export handler (a

player exported a built company; the Phase-2 North Star proxy).

  • print — fired on any print via a site-wide beforeprint listener (the

Print button and Ctrl/Cmd+P), tagged with the page path.

  • dtrpg-referral — a delegated click listener fires for any link to

drivethrurpg.com (or an element tagged data-dtrpg), so it works the

moment the store link is added.

  • All event calls no-op safely when Umami isn't loaded. Verified both builds:

analytics off → no snippet/listeners emitted; analytics on → snippet on every

page plus both listeners. Setup documented in DEPLOY.md and .env.example.

  • DriveThruRPG PDF, generated from the same source. New /skirmish/book

route renders the whole Skirmish rulebook on one page — a cover, then every

chapter in reading order, each starting on a fresh page — reusing the shared

print stylesheet so the PDF matches the site's print output. npm run pdf

builds, then scripts/make-pdf.mjs serves dist/ and drives headless Chrome

to print it to pdf/pilgrimage-skirmish-v1.pdf (A5). Zero new dependencies

(shells out to an installed Chrome/Chromium, in the spirit of

sync-notion.mjs); falls back to system fonts if Google Fonts can't be

reached. pdf/ is gitignored — the PDF is a build artifact. Full notes in

PDF.md. The per-chapter "Terms on this page" appendix is suppressed in the

book (it carries the full glossary chapters).

  • Page-break follow-up (review): large tables now flow and break *between

rows* rather than jumping whole to the next page — a chapter that is "short

intro + one big table" (e.g. Effects) no longer strands its heading on a

near-empty page. Rows themselves are still never split.

  • Book/PDF now A4, two-column (review): the DriveThruRPG book page prints as

A4 at 9 pt in two balanced columns, with chapter headings and wide tables

spanning the full width — a denser, more rulebook-like format that roughly

halves the page count vs single-column A5 (~57 → ~33 pages). The per-chapter

website print is unchanged (single-column A5).

  • Print button on the content pages. A small reusable PrintButton

(window.print() → the dialog also offers "Save as PDF") now sits in the

apparatus rail of every rules chapter and in the header of the reference and

glossary pages. Hidden in the printed output; needs no layout of its own. The

rules index links to the new one-page book/print view.

  • Refactor: the prose typography + print rules moved to a shared

src/styles/rules-content.css, imported by both the reading view and the book

page, so the per-page and PDF print output can't drift. Verified byte-identical

per-chapter output after the move.

  • Print stylesheet QA across every chapter and table. Hardened page-break

discipline on the rules pages: headings stay with the text that follows

(break-after: avoid), table rows and blockquotes never split across a page

boundary, and a table that won't fit in the remaining space moves whole to the

next page rather than tearing mid-row. Paragraphs/list items carry

orphans/widows: 2.

  • Fixed a real print bug: the running chapter footer was position: fixed,

so in paged output the last lines of body text printed underneath it. The

version stamp now flows once at the end of the chapter — clean, no overlap.

  • Glossary print term-list appendix (the deferred Phase-C item). Each rules

chapter now ends, in print only, with a "Terms on this page" list — every

linked glossary term and its definition, alphabetised, built at build time by

the same rehype plugin that links the terms (rehype-glossary.mjs). Screen is

unchanged (tooltips + /glossary carry the definitions there); paper becomes

self-contained. Kept out of the heading pipeline so it never appears in the

contents tree or the "On this page" rail.

  • Glossary page: long letter groups may now flow across columns/pages, but a

single term's name never splits from its definition (.term breaks as a unit)

— replacing the old whole-letter-group break-inside: avoid that broke on the

big letters. Verified end-to-end by rendering chapters, the glossary, and the

reference page to PDF (A5 / A4) via headless Chromium.

  • /roster is now an interactive Oathband journal, replacing the static

sheet (its print output still yields the printable sheet). Mirrors the paper

roster's three pages — Oathband (name/cause/road/season, the Supplies ·

Renown · Legacy · Hope · Hallowed · Doom tracks, the ten Skills, eight

Blessings, Vows by Song, Marks/Hoard/Landmarks/Waymarks), Pilgrims (the

six stats M·A·V·W·P·E, armour/2 weapons/3 items, Lore), and Shadow

(name, Nature, opponent types, Shadow Lore) — and adds a Chronicle: the

weekly Travel → Trial → Camp loop, the Company-weeks instrument.

  • Local only, no accounts/server: state persists in the browser, keep

multiple Oathbands and switch between them, and export/import an Oathband

as JSON. Live helpers the paper can't do: Oathband Renown (sum of Pilgrims),

Camp Actions available (⌈Pilgrims ÷ 2⌉), and the Shadow-generation count

(⌊(Renown + Doom) ÷ 10⌋ + Pilgrims). Vows track as five increment pips.

  • Two print modes, no fudging between them — both landscape. *Print —

filled* lays out what you've entered (form chrome stripped — no dropdown

arrows, spinners or placeholder text), flowing the cards into two columns

so the page width is used rather than a narrow central ribbon. Print — blank

prints a proper panelled hand-fill sheet modelled on the paper roster's

distribution of space: page 1 Oathband (tracks · Marks · Skills · Vows ·

Blessings · Hoard), page 2 the Roster table, page 3 the Shadow. The Vows panel

gives structure but not content — ruled rows with five blank pips each, to fill

in by hand. Clean B&W.

  • Home rebuilt as the ascending masthead (direction chosen from a First

Bronze design-canvas exploration): a two-tone Fraunces wordmark — Pilgrim in

ink, age in bronze, the Bronze Age built into the logotype — over the pitch

line, one bronze CTA, and a foot-strip of quick facts. The illustration is one

original inline SVG: the Oathband climbing a mile-ticked road toward a

cup-and-ring sun-disc — the road that makes them pay for every mile, and

the light at its end. Flat black + bronze on the pale field; prints B&W and

reflows to a foot-strip on small screens. On the home page the header shows the

byline (the giant wordmark is the masthead). Plus the company-as-character

thesis.

  • /world: a grim-bright, type-led introduction to Lurûn (stone → bronze →

verdigris, the road, the Shadow, the Oathband), with carved cup-and-ring

dividers and a drop-initial. Copy is a first-pass scaffold drawn from

established language — to be replaced with canonical Lurûn worldbuilding.

  • Rules chapters now render as a three-column reading view: a contents tree

anchored to the left edge (its own chrome tone, a full-height divider from the

content) with collapsible book sections — the active book open with the

current chapter highlighted, the rest collapsed by default; the measured rules

column in the middle; and an apparatus rail on the right (its own divider)

with an auto "On this page" built from the chapter's headings.

  • First piece of interactive marginalia: on Naming the Oathband, a live

name generator in the rail rolls 2×D12 against the two naming tables

already on the page (no duplicated data) — e.g. "Wayworn Sons".

  • Wide rules tables now scroll inside their own container (never the page); the

view collapses to a single column with a "Contents" disclosure on small

screens; the header nav wraps to its own line on narrow widths. Prints as the

centre column only.

  • A build-time rehype plugin (src/lib/rehype-glossary.mjs) links the first

occurrence of each core glossary term in every rules chapter to its

/glossary#<term> anchor, with the definition in a native hover tooltip.

  • Matcher is built from the Core vocabulary only (src/lib/glossary.mjs),

first-occurrence-per-term-per-page, skipping headings/code/tables and the

glossary chapters themselves — so links are useful, not noisy. Verified: no

dead anchors across all 25 chapters. Plain black in print.

  • Synced all 25 Skirmish chapters from the Rules (Canonical) Notion DB into

src/content/rules/ (the committed version freeze). The full site builds — 25

routes under /skirmish/<slug>, tables and glossary rendering in First Bronze.

  • Deploy model settled: commit the synced content; Cloudflare Pages runs only

npm run build (no NOTION_TOKEN in CI). Setup steps in DEPLOY.md.

  • Added wrangler.toml (pages_build_output_dir = dist), bumped .nvmrc to 22,

and made npm run sync CI-safe (--env-file-if-exists).

  • Layout: Notion header-row tables (first row in <td>) now styled as headers.

Note: content was sourced through the Notion MCP because this build sandbox blocks

egress to api.notion.com. Re-running npm run sync where egress is open

reproduces the canonical freeze — review the diff, commit (the Phase D loop).

First commit. Phase B/C vertical slice stood up end-to-end: **sync → one rules

chapter rendering → committed**.

  • Minimal Astro project scaffolded (package.json, astro.config.mjs,

tsconfig.json).

  • rules content collection (src/content.config.ts) with the schema matching

the sync frontmatter: title, order, book, status, collections,

notion_id, synced.

  • Homebrew sync-notion.mjs (zero-dependency, built-in fetch) pulls chapters

from the Pilgrimage — Rules (Canonical) Notion database → Markdown in

src/content/rules/. npm sync script; NOTION_TOKEN read from gitignored

.env.

  • One dynamic route src/pages/skirmish/[...slug].astro +

RulesLayout.astro render the chapter in the First Bronze look, with a

first-pass B&W A5 print stylesheet and a version/status footer.

  • Proof chapter synced and rendering locally: Deployment & Turn Structure

(Book of Ordeals, Skirmish v1.0) at /skirmish/deployment-turn-structure.

Scope held to the slice deliberately — remaining Phase C pages come next, after

review.

No public release yet — the soft launch (site up, DriveThruRPG listed, version stated honestly) comes at the end of Phase C.