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)
284 lines
9.2 KiB
Markdown
284 lines
9.2 KiB
Markdown
# 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.
|