docs(user): custom modifier DSL user guide

T3 Wave 5 (T31). New user-facing guide at docs/user/custom-modifiers.md
covering:
- Opening the editor + 3-column workspace
- All 15 effect primitives (state / mechanic / advanced) with one
  example per primitive
- Composing primitives (simple boosted-pawn → medium shield → complex
  aura king)
- Saving + loading from the local library
- Using a custom modifier in a profile (panel kind dropdown)
- Multi-profile stacking (solo-only T3 limitation noted)
- Aura semantics (Chebyshev distance, recompute cadence)
- Limits and DoS guards (50 primitives, depth 3, 20 per library, 10
  per multiplayer room)
- T3 limitations called out inline (trigger primitives seed but don't
  yet fire; AuraContributions written but not yet consumed)
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 21:09:24 -06:00
commit 6dd5eb17ce
No known key found for this signature in database

View file

@ -0,0 +1,284 @@
# Custom Modifiers — User Guide
Houserules ships with six built-in modifiers (HP Bonus, Range Bonus, Direction
Additions, Capture Flags, Promotion Override, Damage Resistance). Custom
modifiers let you author your own from a fixed catalog of 15 reusable effect
primitives — no programming required.
A custom modifier is a named, reusable bundle of primitive effects that any
modifier profile can reference, exactly the same way it references a built-in.
---
## Opening the editor
1. Open the **Modifier Profile** editor (`+ Modifier Profile` from the rules
drawer, or via the layout's modifier-profile picker).
2. In the editor's header, click **+ Custom Modifier**. The Custom Modifier
editor opens as a nested modal.
3. The Custom Modifier editor is a 3-column workspace:
- **Left**: primitive palette, grouped by category.
- **Center**: the descriptor's primitive tree (the composition you're
building).
- **Right**: parameter inspector for the selected primitive.
Each descriptor needs a **name** (1-40 chars) and an optional **description**
(0-200 chars). The header also exposes **Save**, **Load from library**, and
live validation status.
---
## The 15 effect primitives
Primitives are atomic, composable, and pure data. Click any palette entry to
add it to the current descriptor's tree.
### State primitives
These mutate facts on the piece at apply time.
#### `seed-attribute`
Seeds a fact `{ attr, value }` on the piece. Overwrites any existing value.
> **Example**: `attr=Hp, value=5` — gives the piece 5 HP regardless of the
> baseline.
#### `add-to-attribute`
Reads the existing numeric value of `attr` (treats absent as 0) and writes
`existing + delta`.
> **Example**: `attr=HpBonus, delta=2` — adds +2 to whatever HpBonus is
> already there.
#### `multiply-attribute`
Reads the existing numeric value of `attr` (no-op if absent) and writes
`existing * factor`.
> **Example**: `attr=Hp, factor=2` — doubles HP.
#### `add-direction`
Appends named directions into `DirectionAdditions`. Composes additively with
the built-in Direction Additions modifier — both write to the same fact and
deduplicate by direction name.
> **Example**: `directions=[backward]` — adds backward movement.
#### `set-capture-flag`
ORs a `CaptureFlag` bitflag into the piece's `CaptureFlags`.
> **Example**: `flag=CANNOT_BE_CAPTURED` — makes the piece untargetable.
### Mechanic primitives
These plug into the engine's existing pipelines (damage, movement, promotion).
#### `absorb-damage-with-attribute`
Seeds `{ AbsorbDamageAttr, AbsorbDamageRate }`. Each incoming damage point
consumes `rate` of `attr` instead of HP, until `attr` is exhausted.
> **Example**: `attr=ShieldCharges, rate=1` — paired with a `seed-attribute`
> for `ShieldCharges=3` produces a 3-charge shield.
#### `reflect-damage`
Seeds `ReflectDamagePercent`. When this piece takes damage, `percentage%` is
reflected back to the attacker.
> **Example**: `percentage=50` — half-reflective armour.
#### `modify-movement-range`
Composes additively with the built-in Range Bonus.
> **Example**: `delta=1` — adds +1 to the piece's range.
#### `block-move-type`
Filters out moves matching the given type (`capture` / `step` / `slide`).
> **Example**: `moveType=capture` — pacifist piece, can move but not capture.
#### `override-promotion`
Sets `PromotionOverride` to a target piece type. Mirrors the built-in
Promotion Override modifier.
> **Example**: `target=knight` — pawns promote to knights only.
### Advanced primitives
These compose other primitives.
#### `add-aura`
Seeds an `AuraSpec` entry. Every piece within `radius` (Chebyshev / king-move
distance) gets `delta` added to `targetAttr`.
> **Example**: `radius=2, targetAttr=HpBonus, delta=1` — every piece within 2
> squares gets +1 HP.
Auras recompute after every move. A piece moving INTO range picks up the
contribution; a piece moving OUT loses it on the next pass.
#### `on-turn-start`
Wraps a list of nested primitives that run when this piece's color begins a
turn.
> **Example**: `primitives=[{kind: 'add-to-attribute', params: {attr: 'Hp', delta: 1}}]`
> — heals 1 HP each turn.
#### `on-capture`
Wraps a list of nested primitives that run when this piece captures another.
> **Example**: `primitives=[{kind: 'add-to-attribute', params: {attr: 'Hp', delta: 1}}]`
> — vampire piece, heals on capture.
#### `on-damaged`
Wraps a list of nested primitives that run when this piece takes damage.
> **Example**: `primitives=[{kind: 'reflect-damage', params: {percentage: 25}}]`
> — auto-reflects on damage.
#### `conditional`
Branches on a condition and runs the matching primitive list.
Supported condition types:
- `attr-lt` / `attr-gt` — numeric comparison
- `attr-eq` — exact match (string / number / boolean / null)
- `always` — runs `then`
- `never` — runs `else` (or no-op if no else)
> **Example**:
> ```
> condition: { type: 'attr-lt', attr: 'Hp', value: 2 }
> then: [{ kind: 'set-capture-flag', params: { flag: 'CANNOT_BE_CAPTURED' }}]
> ```
> — when low on HP, becomes invulnerable.
> **Note (T3 limitation)**: Trigger primitives (`on-turn-start`, `on-capture`,
> `on-damaged`) and `conditional` currently SEED their hook facts on the piece
> but the engine pipeline that fires them at the corresponding game phase is
> not yet wired in T3. The data is correct; the runtime trigger evaluation
> ships in a follow-up.
---
## Composing primitives — simple to complex
### Simple: a "boosted pawn"
One `add-to-attribute` primitive with `attr=HpBonus, delta=2`. Save as
"Boosted Pawn". Reference from a profile's per-type entry to give every white
pawn +2 HP.
### Medium: a "shield"
Two primitives:
1. `seed-attribute`: `attr=ShieldCharges, value=3`
2. `absorb-damage-with-attribute`: `attr=ShieldCharges, rate=1`
Pieces start with 3 shield charges; each damage point depletes one charge
before HP.
### Complex: an "aura king"
One primitive:
1. `add-aura`: `radius=2, targetAttr=HpBonus, delta=1`
Apply to the king's per-type entry. Every piece within 2 squares of the king
gets +1 HP.
---
## Saving and loading
The editor's **Save** button writes the current descriptor to your local
**custom modifier library** (storage key `houserules:custom-modifiers:v1`).
**Load from library** opens a picker to recall any saved descriptor.
The library is local to your browser. To share, paste the JSON shape of a
saved descriptor — or use the modifier profile sharing flow (which embeds the
custom descriptor in the share payload).
### Library limits
- **20 descriptors per library** — same FIFO eviction rule as profiles
(oldest non-starred entry is evicted; star to protect).
- **10 descriptors per multiplayer room** — a room can register up to 10
custom descriptors. Servers reject the 11th.
---
## Using a custom modifier in a profile
Open the Modifier Profile editor, add a **Per-Type** or **Per-Instance** entry
(left or center panel), and pick your custom descriptor from the **Kind**
dropdown. Custom descriptors appear in the dropdown under a **Custom (from
library)** group.
Custom modifiers don't take a per-instance value — the descriptor's primitive
list is the entire payload. The editor displays a small summary card
("Custom modifier — N primitives") instead of a value input.
---
## Multi-profile stacking
The lobby's profile picker stays single-select for the primary profile. Below
it, a **Stacked (solo only)** list lets you append additional profiles in
order. Use the up/down arrows to reorder.
When multiple profiles are active, contributions stack across profiles per the
same per-kind rules used within a single profile. The "priority-wins" rule
(promotion override, capture flags) gives the LAST profile in the list
precedence.
> **Limitation (T3)**: Multi-profile stacking is solo-only on the wire.
> Multiplayer rooms still send one profile per `room.create`. Wire-level
> stacking is a follow-up.
---
## Aura effects in detail
`add-aura` measures distance with the **Chebyshev metric** (king-move
distance): two squares are within radius R when `max(|file_a file_b|,
|rank_a rank_b|) ≤ R`. Radius 1 covers the 8 neighbours; radius 2 covers a
5×5 box minus the source square.
Auras recompute after every successful move. If a source piece moves OUT of
range of a target, the contribution is retracted on the next compute. Multiple
auras to the same `targetAttr` accumulate additively.
Self-application is skipped — an aura's source piece never affects itself.
> **Limitation (T3)**: `AuraContributions` are written correctly, but most
> attribute consumers (HP/Range read points) don't yet layer the aura
> contribution on top of the directly-seeded value. The data is there;
> consumer wiring is a follow-up.
---
## Limits and DoS guards
- **50 primitives total per descriptor** (counted recursively across nested
trees).
- **Recursion depth ≤ 3**`on-turn-start` containing `on-capture`
containing `conditional` is the maximum nesting depth allowed.
- **20 descriptors per library** with starred-aware FIFO eviction.
- **10 descriptors per multiplayer room**, server-enforced.
- **Name 1-40 chars, description 0-200 chars** — descriptor metadata.
The editor's footer shows live validation against all of these. A descriptor
that fails any check is rejected on Save with the failing rule highlighted.