diff --git a/docs/user/custom-modifiers.md b/docs/user/custom-modifiers.md new file mode 100644 index 0000000..8be8b27 --- /dev/null +++ b/docs/user/custom-modifiers.md @@ -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.