houserules/docs/user/custom-modifiers.md
Joey Yakimowich-Payne 8605a22530
feat(ui): AttrCombobox declare vs. consume semantics
Attr-string fields in the Custom Modifier Editor now differentiate
between declare-sites (seed-attribute.attr) and consume-sites
(add-to-attribute.attr, multiply-attribute.attr, add-aura.targetAttr,
absorb-damage-with-attribute.attr):

- Consume-sites hide the illustrative 'User-defined examples' group
  (ArmorPlates/BloodStacks/ManaPool) because those names are fiction
  unless something seeds them.
- Consume-sites surface a new 'Seeded in this descriptor' group
  populated from seed-attribute primitives elsewhere in the current
  tree (including inside trigger children via childPrimitives).
- Badge states: emerald 'seeded' when the typed value matches an
  in-tree seed; red 'not seeded' when it's a non-schema name with no
  backing seed; amber 'user-defined' only in declare-mode for
  off-catalog names.

Declare-mode behaviour is unchanged — inventing ShieldCharges there
still works without warnings.
2026-04-21 13:07:27 -06:00

334 lines
12 KiB
Markdown
Raw Permalink 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. Hover any entry for a
full tooltip with the primitive's long description and a worked example.
- **Center**: the descriptor's primitive tree (the composition you're
building).
- **Right**: parameter inspector for the selected primitive. The top of the
inspector shows an in-editor docs panel — a longer behaviour explanation
plus one or more concrete example configurations you can copy. Click
**Hide docs & examples** to collapse it when you want only the form.
Each descriptor needs a **name** (1-40 chars) and an optional **description**
(0-200 chars). The header exposes **Templates**, **Load**, **Save**, and live
validation status.
### Templates — starter recipes
The **Templates** button opens a picker with pre-composed recipes drawn from
the examples below (Boosted Pawn, 3-Charge Shield, Aura King, Vampire,
Low-HP Fortress). Picking a template replaces the current draft with a fresh
copy — the id is regenerated so you can save the loaded template as your own
library entry without collisions.
### Attribute-name autocomplete
Primitives that target an attribute (`seed-attribute`, `add-to-attribute`,
`multiply-attribute`, `absorb-damage-with-attribute`, `add-aura`) render
their `attr` / `targetAttr` field as a **combobox** with grouped
suggestions:
- **Core numeric attributes** — Hp, HpBonus, RangeBonus, DamageResistance,
ReflectDamagePercent, AbsorbDamageRate, HalfmoveClock, FullmoveNumber.
Safe targets for add/multiply/aura/absorb primitives.
- **Core non-numeric attributes** — CaptureFlags, DirectionAdditions,
PromotionOverride, BlockedMoveTypes, AbsorbDamageAttr, HasMoved. Seed-only
for most; avoid add/multiply.
- **User-defined examples** — ShieldCharges, ArmorPlates, BloodStacks,
ManaPool. Illustrative names matching the recipes; invent your own.
The combobox is **free-form** — type any name and hit Enter to accept it,
even if it isn't in the list. Numeric-context primitives (add, multiply,
aura, absorb) rank numeric suggestions first.
**Declare vs. consume sites.** The combobox distinguishes between
primitives that *declare* a new attribute and primitives that *consume* an
existing one:
- **`seed-attribute.attr`** is a **declare-site** — any name is fine,
including inventing brand new counters. The illustrative "User-defined
examples" group is surfaced for inspiration. Typed values outside both
the schema and the examples catalog carry an amber `user-defined` badge.
- **`add-to-attribute.attr`**, **`multiply-attribute.attr`**,
**`absorb-damage-with-attribute.attr`**, and **`add-aura.targetAttr`**
are **consume-sites** — they read an attribute that must already exist.
The illustrative examples group is **hidden** because those names are
fiction until something seeds them. Instead, a "Seeded in this
descriptor" group surfaces attrs that `seed-attribute` primitives
elsewhere in the current tree actually declare. Typed names that
aren't built-in and aren't seeded carry a red `not seeded` warning;
names that are seeded carry an emerald `seeded` confirmation.
---
## 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.