docs(adr): T3 custom modifier DSL architecture decisions

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 17:16:17 -06:00
commit 60b89d8c5e
No known key found for this signature in database
2 changed files with 343 additions and 0 deletions

View file

@ -0,0 +1,18 @@
# Modifier Profiles T3 — Decisions Log
## [2026-04-19 17:12] Task: T1
- Read `docs/adr/modifier-profiles.md` fully before writing. Existing tone is
concise, decision-heavy, and uses `Decision`/`Rationale`/`Rejected
Alternatives` blocks with practical implementation wording.
- Existing ADR file has mixed historical structure:
- Early ADRs (T1/T2 base) mostly use `Decision`, `Rationale`, `Rejected
Alternatives`.
- Some entries include extra sections like `Semantics` and retrospectives.
- No strict global template is enforced across all sections.
- For T3 append, used a consistent five-part structure per request:
`Context`, `Decision`, `Rationale`, `Rejected Alternatives`, `Consequences`.
- Added one concrete example to each T3 ADR, including required aura example
(`radius=2`, `targetAttr=HpBonus`, `delta=+1` king-aura scenario).
- `decisions.md` did not previously exist; created it and appended this entry
without modifying existing notepad artifacts.

View file

@ -479,3 +479,328 @@ Capped at 50 snapshots to bound memory. Cleared on Save because the saved state
### Deviations
- None — implementation matched ADRs as written.
---
## T3 Architecture Decisions
## T3-ADR-1: Custom modifier DSL is data-only (no runtime code execution)
### Context
T3 introduces user-authored modifier categories. The core design question is
whether user-authored behavior should be represented as executable code or as
structured data composed from engine-shipped operations.
### Decision
Custom modifiers are expressed as validated, structured JSON descriptors
composed from a fixed primitive catalog. The descriptor itself is the DSL.
T3 does not execute user-provided scripts, evaluate strings, or parse a
free-form mini-language.
### Rationale
- **Security first.** Data validation is materially safer than executing
untrusted code in the game server or client.
- **Deterministic behavior.** A finite primitive set gives explicit,
testable semantics and removes runtime ambiguity.
- **Authoring ergonomics.** A visual editor can expose primitives without
requiring users to write or debug code.
- **Operational simplicity.** No sandbox runtime, resource metering, or
script lifecycle management is required in T3.
### Rejected Alternatives
- **Embedded script runtime (e.g., QuickJS) in T3.** Too large a security and
maintenance surface for this milestone.
- **AST-backed custom language in T3.** Similar complexity class to scripting,
but with fewer ecosystem benefits.
- **String-template rule snippets.** Too weak to represent planned behavior,
yet still introduces parsing edge cases.
### Consequences
- Custom modifiers are fully serializable, inspectable, and diff-friendly.
- Validation becomes schema-driven and server-enforceable.
- T3 scope stays focused; script extensibility is intentionally deferred.
- **Example:** A "Shield" modifier is represented as two primitives,
`seed-attribute(attr="ShieldCharges", value=3)` and
`absorb-damage-with-attribute(attr="ShieldCharges", rate=1)`, with no
user code execution path.
---
## T3-ADR-2: Primitive catalog v1 contains 15 composable primitives
### Context
If the DSL is data-only (T3-ADR-1), the primitive catalog defines the
expressive boundary of T3. The catalog must cover existing modifier behavior
plus key new capabilities (events, conditionals, aura-like effects).
### Decision
T3 v1 ships exactly this 15-primitive catalog:
1. `seed-attribute`
2. `add-to-attribute`
3. `multiply-attribute`
4. `add-direction`
5. `set-capture-flag`
6. `absorb-damage-with-attribute`
7. `reflect-damage`
8. `block-move-type`
9. `add-aura`
10. `on-turn-start`
11. `on-capture`
12. `on-damaged`
13. `conditional`
14. `modify-movement-range`
15. `override-promotion`
Each primitive has a typed parameter schema, registry entry, and engine
integration path.
### Rationale
- **Coverage.** The set spans additive stats, movement controls, damage
behavior, event-driven effects, and branching logic.
- **Extensibility by addition.** New primitives can be added as isolated files
without redesigning the DSL contract.
- **Predictable validation.** Primitive-specific schemas allow clear bounds and
error reporting.
### Rejected Alternatives
- **Smaller initial catalog.** Reduced immediate utility; users would quickly
hit expressiveness gaps.
- **Open-ended primitive parameters (`Record<string, unknown>`).** Weak type
guarantees and poor editor UX.
- **Monolithic "do-everything" primitive.** Hard to validate, reason about,
or compose safely.
### Consequences
- T3 ships with a bounded but practical authoring surface.
- Engine and UI both rely on one canonical primitive registry.
- Future primitives are additive and backward-compatible.
- **Example:** A low-HP panic behavior can be authored with
`conditional(condition: hp<2, then:[modify-movement-range(+1)], else:[])`.
---
## T3-ADR-3: Authoring scope is per-room runtime, per-user library, and embedded sharing
### Context
Custom modifiers must be reusable for authors, isolated for active games, and
portable when profiles are shared.
### Decision
- **Runtime registration scope:** per-room via WebSocket payloads.
- **Author storage scope:** per-user local library at
`houserules:custom-modifiers:v1`.
- **Sharing model:** profiles embedding custom kinds include full descriptor
payloads (not by-reference ids only).
- **Versioning model:** descriptor has `version: 1`; incompatible evolution is
represented as a new modifier id/versioned descriptor.
- **Validation guards:** server validates primitive membership, parameter
bounds, nesting depth `<= 3`, and total primitive count `<= 50`.
### Rationale
- **Isolation.** Per-room runtime registration prevents cross-room leakage.
- **Usability.** Local library supports iterative authoring without server
persistence dependency.
- **Portability.** Embedding descriptors makes shared profiles self-contained.
- **Safety.** Guardrails cap complexity and reduce abuse/DoS risk.
### Rejected Alternatives
- **Global process-wide custom registry.** Risks leaking user-defined behavior
between unrelated rooms.
- **By-reference sharing only (id lookup).** Breaks when recipient does not
already have that descriptor in their library.
- **Unbounded nesting/size.** Opens denial-of-service and validation-time
blowups.
### Consequences
- Room creation/join paths must synchronize embedded custom descriptors.
- Validation errors must be protocol-visible and actionable.
- Descriptor transport payload size is larger but deterministic.
- **Example:** Profile `aggressive-pack-v1` includes `shield-v1` inline; when
imported by another user, the room can register and use `shield-v1` even if
that user had no prior local copy.
---
## T3-ADR-4: Custom descriptor registry is per-engine (room), not global
### Context
Built-in modifier descriptors are static and safe to keep in the global
registry. User-authored descriptors are dynamic and room-specific.
### Decision
Keep built-ins in module-level `MODIFIER_REGISTRY`. Add a per-engine custom
registry (`customModifiers: Map<string, CustomModifierDescriptor>`) and make
descriptor resolution consult per-engine custom entries when global built-ins
miss.
### Rationale
- **Correct scoping.** Room-specific rules remain room-specific.
- **Zero regression for built-ins.** Existing built-in lookup remains stable.
- **Compatibility with existing architecture.** Resolution still flows through
the same descriptor interface.
### Rejected Alternatives
- **Process-global custom registration.** Causes rule contamination across
sessions.
- **Separate custom-only lookup path everywhere.** Increases branching and
duplicates integration logic.
- **Namespace-prefix hacks in a single map.** Couples isolation to naming
discipline rather than architecture.
### Consequences
- Engine APIs that resolve modifiers need engine context.
- Room teardown naturally drops custom descriptors with engine lifecycle.
- Debugging remains straightforward: built-ins global, customs room-local.
- **Example:** Room A registers `berserk-v1`; Room B does not. `get("berserk-v1")`
succeeds in Room A context and fails in Room B context without any global
side effect.
---
## T3-ADR-5: T4 forward design preserves one integration seam
### Context
T3 explicitly avoids runtime scripting, but the architecture should not require
an overhaul if T4 introduces scripted modifiers.
### Decision
Define T3 custom descriptors as `type: "data"` and reserve a parallel
`type: "scripted"` branch for T4. Both forms target the same high-level
descriptor integration seam (`apply`-style engine contract), while validation
branches by descriptor type.
### Rationale
- **Future-proofing without scope creep.** T3 remains data-only while enabling
a clean T4 extension path.
- **Minimized migration risk.** Existing plumbing (registry, protocol,
profile embedding) remains reusable.
- **Separation of concerns.** Script security and sandboxing can be handled in
T4-specific validation/execution modules.
### Rejected Alternatives
- **Hardcode T3 as the only forever model.** Forces breaking redesign for T4.
- **Partially ship script hooks in T3.** Expands attack surface before policy,
sandbox, and permissions are ready.
- **Completely separate scripted architecture.** Duplicates profile and
registry plumbing.
### Consequences
- T3 descriptors and validators stay simple and strict.
- T4 can be additive via new descriptor type and validator branch.
- Docs can communicate a clear migration path from data to scripted forms.
- **Example:** A future `type:"scripted"` modifier may implement the same
combat buff as today's `add-to-attribute`, but still enters the engine via
the same descriptor-resolution and apply integration seam.
---
## T3-ADR-6: Multiple active profiles stack in explicit order
### Context
T1/T2 assume one active profile at a time. T3 primitives and custom modifiers
increase composition use cases where users want layered rule bundles.
### Decision
Engine state moves from one active profile to an ordered
`activeProfiles: readonly ModifierProfile[]`. Profile effects stack across
the list using existing per-modifier stacking semantics (additive, union,
multiplicative, or precedence-wins as defined by prior ADRs). Default order is
registration order; UI can expose explicit reordering.
### Rationale
- **Composability.** Users can combine orthogonal profile concepts without
pre-merging into a single artifact.
- **Reuse.** Small focused profiles become building blocks.
- **Determinism.** Explicit ordering eliminates ambiguity for override-style
collisions.
### Rejected Alternatives
- **Single-profile only forever.** Limits expressiveness and reusability.
- **Implicit/unordered merge.** Nondeterministic outcomes for override kinds.
- **Auto-merge into synthetic profile client-side.** Harder to debug and easy
to desync with server authority.
### Consequences
- Apply/reconcile APIs must accept arrays, not single profile objects.
- UI must communicate stack order and precedence implications clearly.
- Validator and diagnostics need to report cross-profile conflicts.
- **Example:** Profile A gives `HpBonus +2`, Profile B gives `HpBonus +1`; with
stacking active, affected pieces resolve to `+3` total HP bonus.
---
## T3-ADR-7: Aura primitive semantics are derived and recomputed
### Context
`add-aura` introduces cross-piece effects whose targets vary with board
position. Static one-time application is insufficient because piece movement
changes who is in range.
### Decision
Aura effects are treated as **derived facts**:
- Source piece declares `add-aura(radius, targetAttr, delta)`.
- Engine computes affected pieces by distance at evaluation time.
- Derived aura contributions are recomputed after each move and reconciled with
current board state.
- Aura-derived facts feed into normal effective-attribute resolution and stack
with other modifiers using existing rules.
### Rationale
- **Correctness over time.** Moving pieces in/out of range updates outcomes
immediately and deterministically.
- **Single mental model.** Aura outputs become just another attribute source in
the resolved fact set.
- **Implementation locality.** Recompute hook can live in a focused aura
integration path without mutating core descriptor contracts.
### Rejected Alternatives
- **Apply aura once at profile load.** Becomes stale as soon as pieces move.
- **Event-driven partial updates only.** Harder to guarantee correctness for
all movement/capture transitions.
- **Dedicated aura-only buff subsystem.** Duplicates stacking/resolution logic.
### Consequences
- Post-move recomputation is required for correctness.
- Performance budget must account for board-wide aura evaluation.
- Tooling/debug UI should expose which aura sources contribute to each piece.
- **Example (king aura):** A king with `add-aura(radius=2, targetAttr=HpBonus,
delta=+1)` grants `HpBonus +1` to all allied pieces within two squares. If a
knight moves from distance 1 to distance 3 from that king, the bonus is
removed on the next recomputation pass.