Skip to content

fix: escape exported player text, defer the blob revoke, collapse the success predicates - #71

Merged
philoserf merged 1 commit into
mainfrom
refactor/sweep
Sep 16, 2026
Merged

philoserf merged 1 commit into
mainfrom
refactor/sweep

Conversation

@philoserf

Copy link
Copy Markdown
Owner

Closes #32, #31, #36, #53. First of two PRs in milestone 7 — the doc deletion follows separately so it doesn't drown this diff.

A pipe in a flourish word broke the exported table (#32)

paragraphRow interpolated player text into a Markdown row with no escaping, and the flourish input has no pattern, length limit or sanitisation. bold | brazen shifted every cell after it; a pasted newline split the row and terminated the table early. All six existing export tests used the fixture adjective 'solemn', so nothing exercised a metacharacter.

Demonstrated through the real export, not only in tests — a letter whose flourishes carry a pipe and a newline, downloaded and read off disk:

| # | Word | Flourish | Heart | Language | Penmanship | Points |
| 1 | Scaling Oaks (superior) | bold \| brazen | 6 | 5 | 5 | 3 |
| 2 | Scaling Oaks (superior) | grand sweeping | 6 | 5 | 5 | 3 |

cell counts:  9  9  9  9 …   (every row matches the header)
scenario: "The Archduke"     (quoted scalar)

The frontmatter scenario: is now quoted too. All four bundled titles are plain, but a title with a colon wrote an invalid document — this is for the next one.

The download revoked its blob URL synchronously (#31)

a.click() only queues the download, so revoking in the same task races the fetch. The issue names Safari as where that has produced cancelled or zero-byte files, and notes it was filed from reading — "I did not drive a browser."

I did. Deferred by one turn of the event loop, clicked Download in Safari, and the file is 837 bytes on disk.

Three names for one rule (#36)

isSuperior and fineHand were character-for-character identical apart from the parameter name, and since #28 landed, flourishHeld was the same body plus a null guard. They collapse to succeeded(dice) in dice.ts, beside countSuccesses where the "5 or 6" rule already lives.

It takes number[] | null, so the third name collapses too — a roll that never happened did not succeed, which is exactly what the Heart roll is when the player writes plainly. Three exported names become one. The domain vocabulary isn't lost: it lives where 'Fine hand' and (superior) are rendered, and none of those strings change.

Tests that restated the file above them (#53)

Scope had already shrunk twice — the store cases were rewritten in milestone 3, the scenario ones deleted in milestone 2. Deleted here: the valid-rating test (tsc rejects anything else), the non-empty-flavour test, and the two lookup tests, which tested Array.prototype.find. The monk/courtier fixtures rebuilt in seven of rules.test.ts's tests are hoisted.

Two deliberate departures from #53, both kept rather than deleted:

  • The skills test, rewritten. The count was a literal restating itself, but "every attribute has exactly one skill that boosts it" is a domain invariant — two skills on Penmanship and none on Heart would leave an attribute the player can never help, and nothing else would notice.
  • diceForRating's poor/average/good mapping, which A quarter of the test suite asserts that literals are what the file above them says they are #53 files under the same heading while hedging ("cheap and I would not fight for them"). It's a rulebook rule and the only place that conversion is pinned.

Verification

bun run check:ci clean. 86 → 82 tests: four tautological ones deleted, three real ones added for the escaping.

One existing test caught the frontmatter change on its own — frontmatter contains character, skill, scenario… asserted the unquoted form and had to be updated, which is the assertion doing its job.

… success predicates

**A pipe in a flourish word broke the exported table (#32).** `paragraphRow`
interpolated player text into a Markdown row with no escaping, and the flourish
input has no pattern or sanitisation. `bold | brazen` shifted every cell after
it; a pasted newline split the row and terminated the table early. The two
cells carrying non-constant text are now escaped, and the frontmatter's
`scenario:` scalar is quoted — all four bundled titles are plain, but a title
with a colon wrote an invalid document.

Demonstrated through the real export rather than only in tests: a letter whose
flourishes carry a pipe and a newline downloads with `bold \| brazen`,
`grand sweeping`, and all seven rows at the header's cell count.

**The download revoked its blob URL synchronously (#31).** `a.click()` only
queues the download, so revoking in the same task races the fetch; the issue
names Safari as where that has produced cancelled or zero-byte files, and was
filed from reading rather than from driving a browser. Deferred by one turn of
the event loop, and checked in Safari: 837 bytes on disk.

**Three names for one rule (#36).** `isSuperior` and `fineHand` were
character-for-character identical apart from the parameter name, and since #28
`flourishHeld` was the same body plus a null guard. They collapse to
`succeeded(dice)` in `dice.ts`, beside `countSuccesses` where the "5 or 6" rule
already lives. It takes `number[] | null` so the third name collapses too — a
roll that never happened did not succeed, which is exactly what the Heart roll
is when the player writes plainly. The domain vocabulary is untouched: it lives
where "Fine hand" and "(superior)" are rendered, and none of those strings
change.

**Tests that restated the file above them (#53).** Its scope had already shrunk
twice — the store cases were rewritten in milestone 3 and the scenario ones
deleted in milestone 2. Deleted here: the valid-rating test (`tsc` rejects
anything else), the non-empty-flavour test, and the two lookup tests, which
tested `Array.prototype.find`. The `monk` and `courtier` fixtures rebuilt in
seven of `rules.test.ts`'s tests are hoisted to module scope.

Two deliberate departures from #53, both kept rather than deleted:

  - The skills test, rewritten. The *count* was a literal restating itself, but
    "every attribute has exactly one skill that boosts it" is a domain
    invariant — two skills on Penmanship and none on Heart would leave an
    attribute the player can never help, and nothing else would notice.
  - `diceForRating`'s poor/average/good mapping, which #53 files under the same
    heading while hedging. It is a rulebook rule, and the only place the
    rating-to-dice conversion is pinned.

Closes #32
Closes #31
Closes #36
Closes #53

Co-Authored-By: Claude
@philoserf
philoserf merged commit abe338d into main Sep 16, 2026
3 checks passed
@philoserf
philoserf deleted the refactor/sweep branch September 16, 2026 12:17
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.

A pipe character in the player's flourish word breaks the exported game-record table

1 participant