Fix two bugs in the Custom Modifier Editor's live preview pane that
surfaced when loading thressgame-100 epic recipes:
BUG 1 \u2014 'unknown primitive: <kind>'
The narrator dispatcher (KIND_NARRATORS) was missing entries for the 9
new primitives shipped across the thressgame-100 epic. Loading any recipe
using them showed 'unknown primitive: add-resource' (etc.) in the preview.
Added 9 narrators (alphabetically inserted into KIND_NARRATORS):
- on-attr-expire (W2) \u2014 'When the <attr> attr on <target> expires: <children>'
- decrement-attr-each-turn (W2) \u2014 'each turn-end, subtract 1 from <attr> on <target>'
- set-board-topology (W4) \u2014 'set board topology to <value>'
- link-pieces (W4) \u2014 'link the pieces at <a> and <b>'
- unlink-pieces (W4) \u2014 'unlink the pieces at <a> and <b>'
- on-piece-pair-link-broken (W4) \u2014 'When a linked partner is destroyed: <children>'
- add-resource (W5) \u2014 'give <player> +<amount> score' (or 'subtract from')
- spend-resource (W5) \u2014 'If <player> has <amount> score, spend it, then: <then>; otherwise: <else>'
- on-resource-changed (W5) \u2014 'When <player>'s score crosses <threshold> <direction>: <children>'
BUG 2 \u2014 '[object Object]' for V3 resolver shapes
fmtPieceTarget (line 125) and inline square handling fell through to
String(t) when the field carried a V3 resolver shape ({$var}, {ctx-self-id},
{ctx-self-marker-id}, {ctx-attr}, {ctx-build}, {add/sub/mul/mod}).
User-reported case: tpl-treasure-chest's destroy-marker arm rendered as
'destroy [object Object]'.
Added fmtResolverShape(v) helper rendering the 9 V3 shapes as plain English:
- {$var: 'p'} \u2192 'p'
- {ctx-self-id: null} \u2192 'this piece'
- {ctx-self-marker-id: null} \u2192 'this marker'
- {ctx-attr: {entity, attr}} \u2192 "<entity>'s <attr>" (recursive)
- {ctx-build: {col, row}} \u2192 'e4' (algebraic if both literal) or
'the square at column <c>, row <r>'
- {add: [a, b]} \u2192 '<a> + <b>' (recursive)
- {sub/mul/mod: [a, b]} \u2192 same pattern, '-' / '\u00d7' / 'mod'
Added fmtSquareValue(v) helper for square-typed fields. fmtPieceTarget
extended to call fmtResolverShape before falling back to String.
Updated existing narrators to use fmtSquareValue for square fields:
- spawn-marker.square, spawn-marker-pair.{squareA,squareB}
- move-piece.to, place-piece.square, must-class.square
- destroy-marker.target now routes through fmtResolverShape
Test surface:
- narrate.test.ts: 69 \u2192 90 tests (+21)
- V3 resolver shapes describe block: 8 tests
- thressgame-100 primitives describe block: 12 tests
- tpl-treasure-chest end-to-end regression test: 1 test
asserts the user-reported failure is fixed (no '[object Object]',
no 'unknown primitive', expected English phrases present)
bun run check: 3291 tests pass (was 3270, +21). 0 regressions.
USER-REPORTED CASE NOW RENDERS:
Before: 'When this piece captures: spawn a treasure marker on <square>
(permanent). When a piece enters a treasure marker: unknown
primitive: add-resource; destroy [object Object].'
After: 'When this piece captures: spawn a treasure marker on this
piece's Position (permanent). When a piece enters a treasure
marker: give white +5 score; destroy this marker.'
Spot-check on 5 recipes (all render sensible English):
- tpl-treasure-chest \u2713 (see above)
- tpl-time-bomb \u2713
- tpl-down-with-the-ship \u2713
- tpl-summoning-ritual \u2713
- tpl-bouncing-ricochet \u2713
CALLOUT FOR FUTURE EPICS (recorded in learnings.md):
narrate.ts has zero engine/registry imports by design. When a new primitive
or resolver shape lands, the narrator must be updated MANUALLY \u2014 there
is no static link to detect drift. Add a registry-count-style sentinel
test in a follow-up to catch this earlier (e.g. compare KIND_NARRATORS
keys against PRIMITIVE_REGISTRY.list()).
Wave 5 of thressgame-100 epic complete \u2014 paradigm-break wave per oracle.
User-authorized despite the cost. Coverage: 47/51 \u2192 50/51 = 98 %.
PARADIGM-BREAK CONTEXT (oracle pre-flagged):
Oracle classified Wave 5 as 'paradigm break \u2014 game-level mutable state'. User
explicitly authorized via 'no time/cost limit, no backward-compat constraint,
just fucking get it all done'. Design choice: scores live alongside RngStream
on GAME_ENTITY \u2014 same pattern as existing global state (RngStream,
ChoiceTimeoutPolicy, BoardTopology, BlockAllExceptKing), not new architecture.
ENGINE WORK (W5.0\u2013W5.4):
Score subsystem (locked decision F):
- WhiteScore + BlackScore attrs on GAME_ENTITY \u2014 type 'number', default 0
- Initialized at engine construction (engine.ts:632\u2013634) so fact-log inclusion
is mechanical \u2014 state-hash auto-includes scores via existing infrastructure
- N=100 byte-identical replay determinism verified
3 NEW PRIMITIVES:
- add-resource(player, amount) imperative; synchronous fire of
on-resource-changed hooks
- spend-resource(player, amount, imperative; selfRecurse:true \u2014 only the
then, else) chosen arm runs (walker doesn't auto-recurse
into both); cascade-depth-limited (8)
- on-resource-changed(player, trigger; fires on threshold crossing in
threshold, direction up/down/any; snapshot-before-iterate
direction, so hook arm registering more hooks doesn't
primitives) fire on the same crossing
WS PROTOCOL (W5.5) \u2014 Scenario A chosen: ZERO protocol changes:
- broadcast.ts uses session.allFacts() which iterates ALL facts on ALL entities
including GAME_ENTITY
- WhiteScore/BlackScore mutations land in game.state and game.delta frames
automatically; client PredictionManager receives them out-of-the-box
- Zod FactSchema has untyped .unknown() value field on the wire \u2014 number values
serialize implicitly
UI WORK (W5.6) \u2014 GameView.tsx score chips:
- Two chips: data-testid='score-white' (\u26aa W) and data-testid='score-black'
(\u26ab B), bordered chip styling matching ActionMenu sibling vocabulary
- HIDE-WHEN-ZERO rule: chips hidden unless either score !== 0 OR
OnResourceChangedHooks contains entries (pure-chess games visually unchanged)
- Real-time updates via existing useMultiplayerGame hook \u2014 broadcasts trigger
re-render, scores update without page reload
3 NEW RECIPES (W5.7):
Batch M \u2014 economy:
- tpl-treasure-chest \u2014 capture spawns treasure marker; entering treasure
awards white +5 score and consumes marker
(SIMPLIFIED: arm 2 hardcodes player='white' \u2014
no chooser-color resolver; LastModifierChooser
semantics = rule applier not current mover)
- tpl-cash-grab \u2014 every turn-end, white+1 + black+1 (passive income)
(SIMPLIFIED: V3 has no eq shape \u2014 random-pick result
can't be compared against piece Position; ship the
dual-add-resource pattern instead of canonical
random-square-rewards-piece-owner semantics)
- tpl-summoning-ritual \u2014 spend 5 score to summon white knight at e4
(SIMPLIFIED: single-shot at activation; repeatable
summoning needs request-choice loop \u2014 W6+ scope)
TEST SURFACE:
- add-resource.test.ts: ~10 unit tests
- spend-resource.test.ts: ~12 unit tests
- on-resource-changed.test.ts: ~15 unit tests
- economy-integration.test.ts: 8 integration tests
(incl. N=100 determinism)
- wave5-recipes-real.test.ts: 14 runtime tests
- recipes.test.ts: 5 \u00d7 62 = 513 expect calls
- wave5-economy.spec.ts (Playwright): 5 e2e tests
(3 load + summoning-ritual
success-path runtime +
cash-grab turn-end driven)
bun run check: 3270 tests pass (was 3192, +78). 0 regressions.
e2e: 5/5 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.
KEY FINDINGS (recorded in learnings.md):
- spend-resource selfRecurse:true correctly gates walker auto-recursion \u2014
place-piece inside 'then' only runs when spend succeeds
- ctx-attr.entity:'self' resolves cleanly to ctx.pieceId in resolver
- fireOnTurnEndHooks (and other per-piece dispatchers) iterate pieces \u2014 hooks
on GAME_ENTITY are dead-seeded; apply-descriptor needs targetSquare for any
recipe rooted at a per-piece trigger
- Snapshot-before-iterate in fireOnResourceChangedHooks prevents reentrant
cascade fires on the same crossing
BACKWARD-INCOMPAT TESTS UPDATED (per locked decision J):
- registry-count.test.ts: 56 \u2192 59 (3 new primitives)
- ParamField.snapshot.test.tsx: SAMPLE_PARAMS exhaustiveness for 3 new kinds
- GameView snapshot: regenerated for new score-chip section
Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave5.txt (gitignored, 1074 lines)
Wave 4 of thressgame-100 epic complete \u2014 highest-risk wave (move-gen
topology) survived with 0 regression. Coverage: 44/51 \u2192 47/51 = 92 %.
ENGINE WORK (W4.0\u2013W4.7):
Topology subsystem (locked decision G \u2014 opt-in via BoardTopology attr):
- New attr: BoardTopology on GAME_ENTITY \u2014 'standard' | 'wrap-files' | 'wrap-all'
- New helper: util/topology.ts \u2014 wrapSquare(rawCol, rawRow, topology) +
readBoardTopology(session)
- coord.ts threads optional topology through slidingSquares / knightSquares /
kingSquares (default 'standard' preserves all pre-W4 output bit-identical)
- rules/primitives.ts threads topology through rookCandidates, bishopCandidates,
queenCandidates, knightCandidates, kingCandidates, pawn{Single,Double}Advance,
pawnCaptureSqares
- rules/{sliding,knight,king,pawn}.ts read topology via readBoardTopology and
thread through candidate generators
- presets/core-piece-types.ts pawn attackProbe reads topology
- New imperative: set-board-topology(value) \u2014 modifies the GAME_ENTITY attr
Pairing subsystem (locked decision E):
- New attrs: PieceLink (per-piece, EntityId[]), OnPiecePairLinkBrokenHooks
(game-level)
- New imperative: link-pieces(a, b) \u2014 symmetric (adds B to A's links AND
A to B's links); self-link no-op
- New imperative: unlink-pieces(a, b) \u2014 symmetric removal
- New trigger: on-piece-pair-link-broken(target, primitives) \u2014 fires when
a linked partner is destroyed; introduces 'linkedPieceId' binding
- Cascade integration: engine.ts:dealDamage and destroy-piece.ts both fire
the on-piece-pair-link-broken cascade when a linked piece is destroyed.
PieceLink + the partner's link entry are auto-unlinked BEFORE the trigger
fires (prevents reentrant infinite loop \u2014 the cascade-loop fix).
Two critical bugs found and fixed (full walkthrough in evidence file):
1. CASCADE LOOP: original draft fired hooks BEFORE auto-unlinking; reentrant
destroy-piece(survivor) re-fired the hook on original \u2192 infinite loop.
Fix: clear dying piece's PieceLink AND prune partners' lists FIRST, then
fire hooks. Pinned by cascade test in on-piece-pair-link-broken.test.ts.
2. TOROIDAL CYCLE: wrap-all sliding rays cycle every 8 squares (queen on a1
loops back to a1 going horizontally). Added 7-cell cycle guard in
slidingSquares matching BASE_RANGE; king's extended-reach mode uses a
Set<Square> per ray to detect re-visits.
5 NEW RECIPES (W4.8\u2013W4.9):
Batch K \u2014 topology:
- tpl-pacman-style-cross-ref \u2014 modifier-shaped equivalent of wrap-board preset;
cross-references RULES.md#wrap-board in summary
- tpl-bouncing-ricochet \u2014 first multi-armed recipe in codebase: activation
seeds wrap-files, expire arm reverts to standard
Batch L \u2014 pairing:
- tpl-down-with-the-ship \u2014 white king linked to all white rooks; killing
the king cascade-destroys both rooks
- tpl-soul-link \u2014 cross-color knight linking; either dies, both
die (SIMPLIFIED: link ALL white knights to ALL
black knights since recipe library has no
per-piece pick mechanism in activation arms)
- tpl-hot-drop \u2014 spawn 2 white queens at e4+d4, link them;
killing one cascade-destroys the other
(SIMPLIFIED: hardcoded queens at d4/e4 \u2014
place-piece pieceType/color/square strict)
TEST SURFACE:
- util/topology.test.ts: 14 unit tests
- rules/topology.test.ts: 30 boundary tests across 6 piece
types \u00d7 3 topologies
- set-board-topology.test.ts: 9 unit tests
- link-pieces.test.ts: 9 unit tests
- unlink-pieces.test.ts: 8 unit tests
- on-piece-pair-link-broken.test.ts: 11 unit tests
- wave4-recipes-real.test.ts: 22 runtime tests
- wave4-topology-pairing.spec.ts e2e: 7 tests (5 load + 2 runtime,
including PredictionManager attr
probe + DOM piece visibility for
the linked-queens spawn)
bun run check: 3192 tests pass (was 3081, +111). 0 regressions.
e2e: 7/7 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.
BACKWARD-INCOMPAT TESTS UPDATED (per locked decision J):
- registry-count.test.ts: 52 \u2192 56 (4 new primitives)
- ParamField.snapshot.test.tsx: SAMPLE_PARAMS exhaustiveness for 4 new kinds
- wave3-recipes-real.test.ts: count assertion widened toBe \u2192 toBeGreaterThanOrEqual
Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave4.txt (gitignored, 1076 lines)
Wave 3 of thressgame-100 epic complete. 85 % MILESTONE HIT.
Coverage: 37/51 \u2192 44-45/51 = 86-88 % (depending on overlap accounting; both
clear the 85 % gate).
W3.0 AUDIT \u2014 request-choice capabilities verified:
- 6 supported kinds (Zod schema): rps | piece | square | column | row | coin-flip
- (NOT yes-no, NOT number \u2014 plan brief was inaccurate; updated learnings.md)
- All 6 wired through schema \u2192 apply() \u2192 RequestChoiceModal.tsx \u2192 unit tests
- No gaps to fix.
8 NEW RECIPES (W3.2\u2013W3.5):
Batch G \u2014 choice-driven spawn (kind: square):
- tpl-bottomless-pit \u2014 pick a square; permanent pit there
- tpl-call-down-lightning \u2014 pick a square; death-square spawns there
(SIMPLIFIED: lethality moved to consumer arm
\u2014 destroy-piece needs entity-id not square)
- tpl-portal-storm \u2014 pick 2 squares; spawn a linked portal pair
Batch H \u2014 choice-driven swap:
- tpl-anti-camping-choice \u2014 pick victim + swapper; swap them
(SIMPLIFIED: random swap not expressible \u2014
with-probability gates per iteration not
picks one)
- tpl-two-kids-trenchcoat \u2014 sacrifice 2 pieces; bishop@e4
(SIMPLIFIED: place-piece pieceType/color/square
hardcoded \u2014 strict literal enums)
Batch I \u2014 choice-driven self-modification:
- tpl-blood-sacrifice \u2014 sacrifice one piece; +5 Hp to another
(uses W1.6's add-to-attribute.target redirect)
- tpl-summoning-ritual-light \u2014 sacrifice + 50/50 knight-or-bishop@e4
(SIMPLIFIED: hardcoded type/color/square +
no resource cost \u2014 W5 territory)
Batch J \u2014 sophie's-choice:
- tpl-sophies-choice \u2014 both players pick own piece; both die
(forPlayer:'both' verified working as in
tpl-mr-freeze)
FOUR DOCUMENTED SIMPLIFICATIONS (full rationale in evidence file Section 4):
- tpl-call-down-lightning: lethality dropped (no Position-comparison primitive)
- tpl-anti-camping-choice: random-swap dropped (with-probability per-iteration
semantics)
- tpl-two-kids-trenchcoat: place-piece hardcoded (strict literal enums)
- tpl-summoning-ritual-light: hardcoded place + RNG branch (no resource yet)
KEY RUNTIME DISCOVERY (documented in learnings.md):
- runPrimitives catches SuspendedExecution INTERNALLY and returns; does NOT
re-throw. Test pattern is to read PendingChoices off GAME_ENTITY after the
call rather than asserting throw.
- For multi-step request-choice e2e: poll on data-choice-id flip rather than
visibility (modal close+reopen is sub-frame). Canonical idiom for future waves.
TEST SURFACE:
- wave3-recipes-real.test.ts: 26 unit tests (60 expect calls)
- recipes.test.ts: 5 \u00d7 54 = 444 expect calls
- wave3-choices.spec.ts (Playwright): 11 e2e tests (8 load + 3 runtime,
including FIRST multi-step request-choice
runtime test \u2014 portal-storm 2-step
square picker with poll-on-data-choice-id
assertion idiom)
bun run check: 3081 tests pass (was 3055, +26). 0 regressions.
e2e: 11/11 green via .sisyphus/scripts/run-pw.sh against docker compose dev stack.
ANTI-CAMPING OVERLAP NOTE:
Both tpl-anti-camping (W2 dormant variant) and tpl-anti-camping-choice (W3
choice variant) map to the single upstream ThressGame rule `anti_camping`.
This is intentional \u2014 two different mechanical interpretations of the same
rule name. Documented in evidence file with dual coverage accounting:
- 45/51 = 88 % (recipe-vs-denominator convention, matches plan target)
- 44/51 = 86 % (strict unique-rule convention)
Both clear the 85 % milestone.
Plan: .sisyphus/plans/thressgame-100.md
Notepads: .sisyphus/notepads/thressgame-100/
Evidence: .sisyphus/evidence/thressgame-100-wave3.txt (gitignored, 832 lines)
T85: Wired Wave 12 move-gen attrs into engine.ts:getAllLegalMoves (the path the drag UI actually uses):
- BlockAllExceptKing (game-level early-return)
- BlockedPieceTypes (game-level early-return)
- MovesAs (per-piece substitution via lookupMoveGenerator)
- MovesAlsoAs (additive; deduped via dedupeMoves helper)
- MoveClassRestriction (post-filter on the entire move set)
Previously these attrs only filtered rules/turn.ts:getLegalMovesForPiece, but the production drag path goes through engine.ts. Now both paths apply identical filters.
T86: engine.ts:applyMove now honors isPawnPush:
- pushedPieceId moved to pushedTo (defender shoved forward)
- pawn moves to diagonal target square (no capture retract)
- HasMoved set on pawn
- Hook firing + turn advancement preserved
5 fixmes lifted in move-gen-attrs.spec.ts (MovesAs, MovesAlsoAs, BlockedPieceTypes, MoveClassRestriction, PawnPushesPiecesEnabled).
1 fixme lifted in orphan-primitives.spec.ts (must-class consumer now active).
E2E status: 30/30 thressgame-coverage tests PASS. 0 fixmes. 0 skips.
Unit tests: 2866 -> 2868 (+2 from new applyMove unit tests). bun run check exit 0.
Closes critical gaps surfaced by oracle gap audit:
- T70 (C1): server WS submit-choice handler now calls submitChoiceAndResume(engine, choiceId, value); both submit and timeout-default paths thread the value through the resume helper. Game state delta broadcast after resume.
- T71 (C3): fireOnRuleExpireHooks dispatcher implemented (mirrors fireOnRuleActivatedHooks pattern); engine.detachCustomDescriptor(descriptorId) method; WS custom-modifier.remove action with full integration; on-rule-expire hooks now actually fire on detach (was a public stub).
- T72 (C4): real cancel-capture via snapshot+restore. applyMove now snapshots defender's facts as LastCaptureSnapshot on GAME_ENTITY before retract; integration preset checks CaptureCancelled flag after fireOnCapturedHooks; if true, re-inserts defender facts and reverts mover's Position. Parry rule now actually undoes a capture in real play.
- T73: client-side request-choice plumbing. GameClient.dispatchServerMessage routes request-choice WS frames to a new GameClientEvent; sendSubmitChoice convenience method; useMultiplayerGame hook exposes pendingChoice + submitChoice; GameView renders RequestChoiceModal when pendingChoice is set.
Tests: 2744 -> 2774 (+30). bun run check exit 0.
Final Verification Wave found two real blockers:
1. T58 RequestChoiceModal.tsx was marked complete but did NOT exist on disk.
2. request-choice locked 6-kind enum was shipped as 5 (missing 'coin-flip').
Remediation:
- Build RequestChoiceModal.tsx with role=dialog, aria-modal=true, ESC/backdrop close, kind-specific input UI for all 6 kinds (rps / coin-flip / piece / square / column / row); 4 tests
- Add 'coin-flip' to:
- request-choice primitive paramsSchema enum
- PendingChoice.kind union (schema.ts + util/pending-choices.ts)
- WS protocol ChoiceKindSchema (server/protocol.ts)
- choice-timeout.firstDefaultForKind (defaults to 'heads')
- broadcast.isValidChoiceValue (accepts 'heads' | 'tails')
- AutoChoiceResolver: deterministic alternating heads/tails for coin-flip
T68 e2e tests remain .skip()'d pending UI integration (modal-into-GameView wiring + activate-descriptor UI) — a follow-up task. The sentinel test asserts the gap exists so when integration lands, skips lift in the documented order.
Tests: 2740 -> 2744 (+4). bun run check exit 0.
Introduces a pluggable StartingLayout abstraction so the engine can
open from positions other than FIDE without per-caller special casing.
- layouts/{types,registry,index}.ts: StartingLayout + LAYOUT_REGISTRY,
mirroring the PRESET_REGISTRY / PIECE_TYPE_REGISTRY pattern.
- starting-position.ts: CLASSIC_LAYOUT + applyLayout(session, layout)
as the parametrized spawn path. generateStartingPosition stays as
a thin back-compat wrapper so no existing call site changes.
- layouts/{classic,empty}.ts: first two premades registered via
side-effect imports from the barrel.
- engine.ts: ChessEngine constructor now accepts either legacy
(activePresets) positional or new options-bag form
({ activePresets?, layout? }). Detection uses a method-shape
probe rather than instanceof so both overload forms compose
cleanly under strict TS.
- Tests: 11 new tests in starting-position.test.ts + engine-presets
cover applyLayout ordering, hasMoved pre-revocation, empty
layout, classic equivalence, options-bag equivalence with legacy.
Also lands the full execution plans for starting-layouts and
rule-variants under .sisyphus/plans/ (both Momus-reviewed OKAY).
941 tests passing; bun run check clean.
Decouples five cross-cutting concerns from engine core so new presets
compose without special-casing:
- Piece attributes: CORE_PIECE_ATTRS + PresetDef.pieceAttributes;
engine.effectivePieceAttrs unions them. HP is now preset-owned.
- Spawn pipeline: engine.spawnPiece() + onPieceSpawn hook replaces
hand-rolled materialization. Queen-splits seeds HP via the hook,
not via direct knowledge of piece-hp.
- Hook signatures: all hooks now take single context objects
(LifecycleContext, MoveHookContext, CaptureHookContext, DamageHookContext,
SelfCheckFilterContext, GameResultHookContext, PieceSpawnContext,
BeforeMoveContext, TurnStartContext, DescribeMoveEffectContext).
- Piece-type registry: PIECE_TYPE_REGISTRY + core-piece-types.ts
replaces hardcoded FIDE switch-tables in engine/check/checkmate/
stalemate/Piece.tsx. Custom piece types plug in without engine edits.
- Preset-scoped state: engine.presetState<T>(id) backed by facts on
PRESET_STATE_ENTITY. Auto-cleared on deactivate. capture-to-win
migrated off GAME_ENTITY.Winner.
- Move log: engine.moveLog + MoveRecord + describeMoveEffect hook.
- Visual effects: engine.emitEffect/subscribeEffects + VisualEffect +
VisualEffectLayer. Explosion, heal, poison renderers. No-op when
no subscribers.
- Phase hooks: onBeforeMove (with cancel), onTurnStart. Scope-aware
dispatch routes onDamage/onBeforeMove/onTurnStart by target/mover
color.
All 5 production presets migrated. 4 prototype presets (Shield, Cannon,
Berserker, PawnStamina) in integration.test.ts exercise every hook.
Added test-utils.ts and PRESET-API.md for preset authors.
930 tests passing; bun run check clean.