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:
parent
6e0479703d
commit
60b89d8c5e
2 changed files with 343 additions and 0 deletions
18
.sisyphus/notepads/modifier-profiles-t3/decisions.md
Normal file
18
.sisyphus/notepads/modifier-profiles-t3/decisions.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue