Skip to content

refactor: collapse the skill predicates and give the reroll a home - #69

Merged
philoserf merged 1 commit into
mainfrom
refactor/play-screen-reductions
Sep 16, 2026
Merged

philoserf merged 1 commit into
mainfrom
refactor/play-screen-reductions

Conversation

@philoserf

Copy link
Copy Markdown
Owner

Closes #50, #44, #43, #39 — all of milestone 5, one PR, since all four land in the same ~40 lines.

One predicate instead of two (#50, #44)

canSpendSkill and canSpendSkillButton were the same lines with === flipped to !==, called one after the other in the same render. There's one underlying fact — this skill applies to this attribute and hasn't been spent — and one variable on top of it: has the player already pressed the button this paragraph. skillAvailableFor is the fact; the call site derives the rest.

makeSkillButton inlines — eight lines with one caller that always passed () => v.repaint(), so the parameter never varied.

That collapse is what deletes #44. if (skillBonusActive) v.state.draft.skillUsedHere = attr; could never change anything, because skillBonusActive already required skillUsedHere === attr. It's a symptom of the split rather than a defect of its own, so it disappears with the restructure rather than as a separate edit — which is what both issues predicted.

The reroll gets a home and its first test (#43)

planRoll set rerollPolicy: 'highest' and stopped; the mechanic lived 400 lines away inside the roll button's callback. Neither file answered the question a maintainer actually has — rules.ts didn't say what a reroll costs, play.ts didn't say which scenarios trigger it.

applyReroll(dice, policy, rng?) now sits in rules.ts beside the RollPlan that requests it, and the roll callback drops to two lines. roll already took an injectable RNG, so it's deterministically testable — and nothing tested the splice before: tests/rules.test.ts had two reroll_highest cases and both asserted on the flag.

Six cases added, including the one that matters:

test('replaces a highest die that was already a success', () => {
  const after = applyReroll([6, 2], 'highest', always(1));
  expect(after).toEqual([1, 2]);
  expect(countSuccesses(after)).toBe(0);
});

That's what makes reroll_highest a hazard rather than a bonus, and it was precisely the half that couldn't be tested where the code sat. Probed by making the reroll "helpfully" spare a successful die — three tests fail.

#39 finishes

The hand-written 'penmanship' | 'language' | 'heart' union is gone from the file; Attribute is imported. Two of its five original sites had already gone — one with the draft's move to src/paragraph.ts, one with #46 — which is why this lands as a two-line change rather than five.

Verification

bun run check:ci clean. 77 → 83 tests. src/screens/play.ts 695 → 677.

Smoke-tested on The Art Dealer — the only scenario carrying reroll_highest — with Illumination, so the skill bonus and the reroll land on the same Penmanship roll:

Step Info line
Penmanship step Roll Penmanship (3 dice, re-roll the highest) for a fine hand.
after spending the skill Roll Penmanship (4 dice, re-roll the highest, skill applied) …
paragraph II Roll Penmanship (3 dice, re-roll the highest) …

Button disappears on spend, reference card reads The Monk — Illumination spent, and no button reappears on paragraph II. That's the check #50 asked for, plus the interaction with the reroll.

Note

Two of these issues were smaller than filed, because milestone 4 had already taken part of them: #50's complaint about a triple skillById lookup was resolved by #46, and #39 was down from five sites to two. What remained was the genuine twin collapse and the mechanical import.

THEORY.md and WALKTHROUGH.md untouched.

**One predicate instead of two (#50, #44).** `canSpendSkill` and
`canSpendSkillButton` were the same lines with `===` flipped to `!==`, called
one after the other in the same render. There is one underlying fact — this
skill applies to this attribute and has not been spent — and one variable on
top of it: whether the player has already pressed the button this paragraph.
`skillAvailableFor` is the fact; the call site derives the rest.

`makeSkillButton` inlines: eight lines with one caller that always passed
`() => v.repaint()`, so the parameter never varied.

That collapse is what deletes `if (skillBonusActive) v.state.draft.skillUsedHere
= attr;` (#44). It could never change anything, because `skillBonusActive`
already required `skillUsedHere === attr` — a symptom of the split rather than a
defect of its own, so it disappears with the restructure.

**The reroll gets a home and its first test (#43).** `planRoll` set
`rerollPolicy: 'highest'` and stopped; the mechanic lived 400 lines away inside
the roll button's callback. Neither file answered the question a maintainer has
— `rules.ts` did not say what a reroll costs, `play.ts` did not say which
scenarios trigger it.

`applyReroll(dice, policy, rng?)` now sits in `rules.ts` beside the `RollPlan`
that requests it, and the roll callback is two lines. `roll` already took an
injectable RNG, so it is deterministically testable — and nothing tested the
splice before: `tests/rules.test.ts` had two `reroll_highest` cases and both
asserted on the flag.

Six cases added, including the one that matters: a highest die that was already
a success is replaced anyway. That is what makes the rule a hazard rather than a
bonus, and it was the untestable half. Confirmed by probe — making the reroll
spare a successful die fails three of them.

**#39 finishes.** The hand-written `'penmanship' | 'language' | 'heart'` union
is gone from the file; `Attribute` is imported. Two of its five original sites
had already gone with the draft's move to `src/paragraph.ts` and with #46.

Verified in the browser on The Art Dealer, the only scenario carrying
`reroll_highest`, with Illumination so the skill and the reroll land on the same
Penmanship roll: `3 dice, re-roll the highest` becomes `4 dice, re-roll the
highest, skill applied` on spending, the button disappears, the reference card
reads "spent", and paragraph II is back to 3 dice with no button.

`src/screens/play.ts` 695 → 677 lines.

Closes #50
Closes #44
Closes #43
Closes #39

Co-Authored-By: Claude
@philoserf
philoserf merged commit 90441fc into main Sep 16, 2026
3 checks passed
@philoserf
philoserf deleted the refactor/play-screen-reductions branch September 16, 2026 12:01
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.

canSpendSkill and canSpendSkillButton are the same four lines with one comparison flipped

1 participant