From 60b89d8c5ebf00c9702a6206e3a2b04086397818 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 17:16:17 -0600 Subject: [PATCH] docs(adr): T3 custom modifier DSL architecture decisions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../modifier-profiles-t3/decisions.md | 18 + docs/adr/modifier-profiles.md | 325 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 .sisyphus/notepads/modifier-profiles-t3/decisions.md diff --git a/.sisyphus/notepads/modifier-profiles-t3/decisions.md b/.sisyphus/notepads/modifier-profiles-t3/decisions.md new file mode 100644 index 0000000..91a9118 --- /dev/null +++ b/.sisyphus/notepads/modifier-profiles-t3/decisions.md @@ -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. diff --git a/docs/adr/modifier-profiles.md b/docs/adr/modifier-profiles.md index ce8e8ef..67aba02 100644 --- a/docs/adr/modifier-profiles.md +++ b/docs/adr/modifier-profiles.md @@ -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`).** 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`) 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.