houserules/docs/user/custom-modifiers.md
Joey Yakimowich-Payne 6dd5eb17ce
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)
2026-04-19 21:09:24 -06:00

284 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.