Add complete domain-specific language for authoring narrative content for agent simulations. Features: - Complete parser using LALRPOP + logos lexer - Template composition (includes + multiple inheritance) - Strict mode validation for templates - Reserved keyword protection - Semantic validators (trait ranges, schedule overlaps, life arcs, behaviors) - Name resolution and cross-reference tracking - CLI tool (validate, inspect, query commands) - Query API with filtering - 260 comprehensive tests (unit, integration, property-based) Implementation phases: - Phase 1 (Parser): Complete - Phase 2 (Resolution + Validation): Complete - Phase 3 (Public API + CLI): Complete BREAKING CHANGE: Initial implementation
1307 lines
56 KiB
Markdown
1307 lines
56 KiB
Markdown
# Storybook DSL — Design Plan
|
|
|
|
**Status:** Proposal
|
|
**Author:** Sienna + Lonni
|
|
**Date:** February 2026
|
|
**Scope:** Content authoring language, parser, resolver, tooling for Aspen agent simulation
|
|
|
|
---
|
|
|
|
## 1. What This Document Covers
|
|
|
|
This plan describes **Storybook** (`.sb`), a domain-specific language for authoring the content that drives Aspen's agent simulation: characters, life arcs, behavior trees, schedules, institutions, relationships, conditions, and the prose that gives them life. It covers the language design, the cross-referencing system, the implementation architecture, key design decisions and their rationale, and a phased build plan.
|
|
|
|
The primary user is Lonni, who is creative, intelligent, not a developer, and needs to build out an entire village's worth of characters, relationships, and narrative structure. The secondary user is Sienna, who will author behavior trees, schema definitions, and engine-facing content. The tertiary consumer is the Aspen game engine itself, which ingests compiled storybook data at runtime.
|
|
|
|
---
|
|
|
|
## 2. The Authoring Boundary
|
|
|
|
The most important design decision is **what Lonni authors vs. what the engine generates at runtime.** The RFC describes rich runtime state — bond strengths evolving through interaction, need levels decaying, schedules regenerating daily. Lonni doesn't author runtime state. She authors:
|
|
|
|
| Lonni Authors | Engine Generates |
|
|
|---|---|
|
|
| Character personas (personality, backstory, starting state) | Actual need levels (decaying over time) |
|
|
| Relationship templates (what "Spousal" means structurally) | Actual bond strengths (evolving through interaction) |
|
|
| Relationship instances (Martha and David are Spousal at sim start) | Life arc state transitions during play |
|
|
| Life arc definitions (the state machines themselves) | Schedule instances (generated daily from templates + context) |
|
|
| Schedule templates (daily patterns) | Behavior tree evaluation results |
|
|
| Behavior tree definitions (decision logic) | Substrate state |
|
|
| Institution definitions (governance, resources, roles) | Entity positions, pathfinding |
|
|
| Condition definitions (effects, contagion profiles) | Wake queue events |
|
|
| Locations, trait axes, capability sets | CRDT sync traffic |
|
|
| Prose (backstory, descriptions, flavor text) | Emergent interactions |
|
|
|
|
The storybook is a **world definition** — initial conditions plus the rules that govern how things evolve. The engine is the physics that runs those rules. This separation is clean, and the grammar should reinforce it: storybook files describe *what could be* and *what starts as*, never *what is happening right now*.
|
|
|
|
---
|
|
|
|
## 3. Language Design
|
|
|
|
### 3.1 Design Principles
|
|
|
|
**Narrative-first.** The language should read like a description of a world, not like configuration. Lonni should be able to read a `.sb` file aloud and it makes sense.
|
|
|
|
**One concept, one construct.** Each simulation concept (character, life arc, schedule, etc.) gets dedicated syntax. No shoehorning behavior trees into the same structure as character bios.
|
|
|
|
**References over duplication.** Define once, reference everywhere. Override locally when needed.
|
|
|
|
**Prose is a first-class citizen.** Backstory, descriptions, and flavor text aren't comments or afterthoughts — they're part of the data, surfaced in-game, and should be comfortable to write.
|
|
|
|
**Errors should be kind.** Lonni is not a developer. Error messages must be precise, plain-language, and suggest fixes.
|
|
|
|
### 3.2 Top-Level Constructs
|
|
|
|
Every `.sb` file contains one or more top-level blocks. The keyword at the start of each block disambiguates unambiguously (LL(1) at the top level).
|
|
|
|
```
|
|
-- File-level imports
|
|
use <path>
|
|
|
|
-- Definitions (one or more per file)
|
|
enum <Name> { ... }
|
|
traits { ... }
|
|
capset <Name> { ... }
|
|
capability <Name> { ... }
|
|
need_template <Name> { ... }
|
|
character <Name> { ... }
|
|
life_arc <Name> { ... }
|
|
schedule <Name> { ... }
|
|
behavior <Name> { ... }
|
|
subtree <n> { ... }
|
|
institution <Name> { ... }
|
|
condition <Name> { ... }
|
|
relationship <Name> { ... }
|
|
location <Name> { ... }
|
|
species <Name> { ... }
|
|
```
|
|
|
|
### 3.3 Value Types
|
|
|
|
The language has a small, fixed type system. No user-defined types beyond enums.
|
|
|
|
| Type | Syntax | Examples |
|
|
|---|---|---|
|
|
| Integer | bare number | `34`, `500`, `0` |
|
|
| Float | number with decimal | `0.85`, `1.0`, `0.0` |
|
|
| Boolean | keyword | `true`, `false` |
|
|
| String | double-quoted | `"hello"` |
|
|
| Identifier | bare word | `martha`, `work`, `romantic` |
|
|
| Qualified path | dotted or `::` separated | `institutions::Bakery::owner`, `bond::committed` |
|
|
| Time literal | `HH:MM` | `5:00`, `18:30`, `23:59` |
|
|
| Duration literal | number + unit suffix | `3d`, `7d`, `2h`, `30m`, `45s` |
|
|
| Range | value `..` value | `0.0 .. 1.0`, `3d .. 7d` |
|
|
| List | `[` comma-separated `]` | `[english, spanish]`, `[mon, tue, wed]` |
|
|
| Prose block | `---tag` ... `---` | See §3.4 |
|
|
|
|
**Duration units:** `d` (days), `h` (hours), `m` (minutes), `s` (seconds). These are game-time, not real-time. The engine's GameTime RFC defines the mapping.
|
|
|
|
**Ranges** are inclusive on both ends. They're used for trait bounds, duration ranges, severity ranges — anywhere a value varies within limits.
|
|
|
|
### 3.4 Prose Blocks
|
|
|
|
Prose blocks use triple-dash delimiters with a tag name:
|
|
|
|
```
|
|
---backstory
|
|
Martha grew up in the valley, apprenticing at the old bakery from
|
|
age fourteen. When old Henrik retired, she took over — not because
|
|
she had grand ambitions, but because the town needed bread and she
|
|
knew how to make it.
|
|
---
|
|
```
|
|
|
|
Inside a prose block, everything is raw text. No escaping needed. The only rule is that a line containing exactly `---` (and nothing else) ends the block. This means prose can contain dashes, em-dashes, markdown-like formatting, whatever — as long as no line is *only* three dashes.
|
|
|
|
Supported prose tags and their semantics are defined by the construct they appear in. A `character` block recognizes `backstory`, `description`, `notes`. An `institution` recognizes `description`, `history`. The validator checks that prose tags are valid for their context.
|
|
|
|
### 3.5 Comments
|
|
|
|
Line comments use `--`. There are no block comments. This is intentional — block comments interact poorly with prose blocks and add lexer complexity for minimal benefit.
|
|
|
|
```
|
|
-- This is a comment
|
|
character Martha { -- inline comment
|
|
age: 34
|
|
}
|
|
```
|
|
|
|
### 3.6 Enums
|
|
|
|
Enums define closed sets of valid values. They're used for bond types, coordination levels, autonomy levels, activity types — anything where the set of valid options is fixed.
|
|
|
|
```
|
|
enum BondType {
|
|
romantic
|
|
familial
|
|
friendship
|
|
professional
|
|
caretaking
|
|
}
|
|
|
|
enum CoordinationLevel {
|
|
none -- strangers, no coordination
|
|
ad_hoc -- can propose one-off activities
|
|
recurring -- can establish patterns
|
|
cohabiting -- share schedule generation context
|
|
dependent -- one party has authority over other
|
|
}
|
|
```
|
|
|
|
Enum variants are ordered — their declaration order defines a total ordering usable in expressions (`coordination >= cohabiting`). This matters for coordination level thresholds and bond strength gates.
|
|
|
|
Enums can also carry associated data for documentation purposes:
|
|
|
|
```
|
|
enum ActivityType {
|
|
work -- sustained productive activity
|
|
eat -- consuming food or drink
|
|
sleep -- resting, dormant
|
|
leisure -- self-directed free time
|
|
travel -- moving between locations
|
|
social -- interaction-focused activity
|
|
worship -- religious or spiritual practice
|
|
learn -- education, skill development
|
|
}
|
|
```
|
|
|
|
### 3.7 The Schema Layer
|
|
|
|
The engine ships a set of `.sb` files that define the **core schema** — the enums, trait axes, capability types, and need definitions that the simulation expects. These live in a `schema/` directory and are read-only from Lonni's perspective.
|
|
|
|
```
|
|
storybook/
|
|
├── schema/ -- shipped with engine, not edited by Lonni
|
|
│ ├── core.sb -- BondType, CoordinationLevel, AutonomyLevel, etc.
|
|
│ ├── needs.sb -- NeedType enum, base need_templates
|
|
│ ├── capabilities.sb -- CapabilityType enum, base capsets
|
|
│ └── activities.sb -- ActivityType enum
|
|
├── world/ -- Lonni's content
|
|
│ ├── characters/
|
|
│ ├── institutions/
|
|
│ └── ...
|
|
└── storybook.toml -- project metadata, schema version
|
|
```
|
|
|
|
Lonni's content references schema types but can also define new enums for content-specific categorization. The validator ensures her content is compatible with the engine's expectations.
|
|
|
|
The `storybook.toml` is the one non-`.sb` file. It contains project metadata that doesn't belong in the DSL:
|
|
|
|
```toml
|
|
[storybook]
|
|
name = "Aspen Village"
|
|
schema_version = "0.1"
|
|
engine_compat = "0.1"
|
|
|
|
[directories]
|
|
schema = "schema"
|
|
world = "world"
|
|
```
|
|
|
|
TOML is fine here because it's a single flat config file Lonni rarely touches.
|
|
|
|
---
|
|
|
|
## 4. Cross-Referencing System
|
|
|
|
### 4.1 Namespacing
|
|
|
|
Every top-level definition has a **qualified name** derived from its file path and block name. The directory structure under `world/` defines the namespace:
|
|
|
|
```
|
|
world/characters/martha.sb → defines character Martha
|
|
namespace: characters::Martha
|
|
|
|
world/institutions/bakery.sb → defines institution Bakery
|
|
namespace: institutions::Bakery
|
|
|
|
world/shared/traits.sb → defines traits PersonalityTraits
|
|
namespace: shared::PersonalityTraits
|
|
```
|
|
|
|
The namespace is `<directory_path_from_world_root>::<BlockName>`. Files can define multiple blocks, all sharing the directory prefix.
|
|
|
|
### 4.2 `use` Statements
|
|
|
|
`use` statements bring names into scope at file level. They go at the top of the file, before any definitions.
|
|
|
|
```
|
|
-- Bring a single name into scope
|
|
use shared::PersonalityTraits
|
|
|
|
-- Bring multiple names from the same directory
|
|
use conditions::{Flu, Cold, Heartbreak}
|
|
|
|
-- Wildcard: bring everything from a directory
|
|
use shared::*
|
|
|
|
-- Schema references (implicit prefix)
|
|
use schema::core::BondType
|
|
```
|
|
|
|
**Within the same directory, bare names resolve without `use`.** If `martha.sb` and `david.sb` are both in `characters/`, Martha can reference David by bare name. Cross-directory references require `use` or a fully qualified path.
|
|
|
|
**Schema types are always in scope** without explicit `use`. `BondType`, `CoordinationLevel`, `ActivityType`, etc. are globally visible. This keeps Lonni from needing boilerplate imports for core vocabulary.
|
|
|
|
### 4.3 Qualified Paths
|
|
|
|
Anywhere a name is expected, a qualified path works:
|
|
|
|
```
|
|
roles: [institutions::Bakery::owner, households::Miller::adult]
|
|
arc: archetypes::PersonArc at Adult::Partnered
|
|
susceptible: [conditions::Flu, conditions::Cold]
|
|
```
|
|
|
|
The `::` separator drills into definitions. `institutions::Bakery::owner` means "the `owner` role defined inside the `Bakery` institution in the `institutions/` directory." The resolver validates that each segment of the path exists and is the right kind of thing.
|
|
|
|
### 4.4 `link` — Relationship Instantiation
|
|
|
|
Relationships are the trickiest cross-reference because they're **bidirectional**, **involve two files**, and **feelings aren't always mutual**.
|
|
|
|
#### Basic Syntax
|
|
|
|
```
|
|
-- In characters/martha.sb
|
|
character Martha {
|
|
link David via Spousal {
|
|
coordination: cohabiting
|
|
started: year 3
|
|
bond: 0.82 -- symmetric: same value for both parties
|
|
}
|
|
}
|
|
```
|
|
|
|
This declares that Martha and David have a Spousal relationship. The resolver handles bidirectionality:
|
|
|
|
1. When processing Martha's file, it registers `Martha <-> David via Spousal`
|
|
2. When processing David's file, it checks whether David also declares a link to Martha
|
|
3. **If David doesn't declare it:** the relationship is automatically bidirectional. David inherits it.
|
|
4. **If David does declare it:** the resolver checks compatibility. Shared fields must agree; per-direction fields are merged (see below).
|
|
|
|
The recommendation is: **declare each relationship once, in whichever file feels natural.** For Martha and David, put it in Martha's file (or David's — just pick one). The resolver makes it visible from both sides.
|
|
|
|
#### Asymmetric Values
|
|
|
|
Feelings aren't always mutual. Martha might love David differently than David loves Martha. The `self`/`other` blocks let each party have distinct values:
|
|
|
|
```
|
|
character Martha {
|
|
link David via Spousal {
|
|
-- Shared (inherently bilateral, same for both parties)
|
|
coordination: cohabiting
|
|
started: year 3
|
|
|
|
-- Per-direction (Martha's feelings toward David)
|
|
self {
|
|
bond: 0.82
|
|
warmth: 0.9
|
|
tension: 0.1
|
|
}
|
|
|
|
-- Per-direction (David's feelings toward Martha)
|
|
other {
|
|
bond: 0.65
|
|
warmth: 0.7
|
|
tension: 0.25
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`self` always means "the character this link block appears inside" — Martha in this case. `other` is the other party — David. This is unambiguous regardless of which file the link lives in.
|
|
|
|
**Mixing symmetric and asymmetric:** Fields at the top level are shared. The moment a field appears inside `self` or `other`, it becomes per-direction. You can mix freely — `coordination` and `started` are shared (they're either cohabiting or they're not), while `bond`, `warmth`, and `tension` are per-direction because feelings are asymmetric.
|
|
|
|
**Shorthand:** If you don't use `self`/`other`, all values are symmetric — the same for both parties. This is the common case for simple relationships:
|
|
|
|
```
|
|
link Elena via Friendship {
|
|
bond: 0.5
|
|
coordination: recurring
|
|
}
|
|
```
|
|
|
|
#### Relationship Templates and Asymmetry
|
|
|
|
The relationship template declares which fields *support* asymmetry:
|
|
|
|
```
|
|
relationship Spousal {
|
|
bond_type: romantic
|
|
min_coordination: cohabiting
|
|
|
|
-- Fields that can differ per-party
|
|
asymmetric: [bond, warmth, tension]
|
|
|
|
-- Fields that are always shared (coordination, commitments,
|
|
-- started are inherently bilateral and cannot be asymmetric)
|
|
|
|
commitments {
|
|
dinner_together { daily at 18:00, location: home, activity: eat }
|
|
sleep_together { daily at bedtime, location: home, activity: sleep }
|
|
}
|
|
|
|
levels {
|
|
acquaintance (0.0 .. 0.2) { enables: [greet] }
|
|
friendly (0.2 .. 0.5) { enables: [greet, chat, share_meal] }
|
|
close (0.5 .. 0.7) { enables: [greet, chat, share_meal, visit, confide] }
|
|
intimate (0.7 .. 1.0) { enables: [all, shared_schedule, cohabitation] }
|
|
}
|
|
}
|
|
```
|
|
|
|
The validator uses the `asymmetric` declaration to catch mistakes — if Lonni puts `coordination` inside a `self` block, she gets a clear error: *"coordination is a shared field in Spousal and cannot differ between parties."* If she puts `bond` at the top level, that's fine — it just means both parties share the same bond strength.
|
|
|
|
#### Asymmetric Role Relationships
|
|
|
|
Some relationships are asymmetric by *structure*, not just by values — parent/child, employer/employee, mentor/apprentice. These use the `as` clause to assign roles:
|
|
|
|
```
|
|
character Martha {
|
|
link Tommy via Parental as parent {
|
|
coordination: dependent
|
|
started: year 0
|
|
|
|
self { -- Martha (parent) toward Tommy
|
|
bond: 0.95
|
|
warmth: 0.95
|
|
}
|
|
other { -- Tommy (child) toward Martha
|
|
bond: 0.9
|
|
warmth: 0.85
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
The `as parent` clause assigns Martha's role. Tommy gets the complementary role (`child`, defined in the `Parental` relationship template). The template defines which roles exist and what each role enables:
|
|
|
|
```
|
|
relationship Parental {
|
|
bond_type: familial
|
|
roles: [parent, child]
|
|
asymmetric: [bond, warmth, tension]
|
|
|
|
role parent {
|
|
authority_over: child
|
|
capabilities: [set_schedule, set_boundaries, provide_care]
|
|
}
|
|
|
|
role child {
|
|
autonomy: external -- schedule set by parent
|
|
capabilities: [receive_care, seek_comfort]
|
|
}
|
|
}
|
|
```
|
|
|
|
This handles parent-child, employer-employee, caretaker-dependent, mentor-apprentice — any relationship where the two parties have fundamentally different roles and affordances.
|
|
|
|
### 4.5 `override` — Template Customization
|
|
|
|
When referencing a template (schedule, needs, relationship), the `override` keyword selectively patches fields:
|
|
|
|
```
|
|
character Martha {
|
|
needs: HumanNeeds {
|
|
override sleep { decay: 0.9 } -- only changes decay rate
|
|
override social { urgent: 0.6 } -- Martha's more socially needy
|
|
}
|
|
|
|
schedule: BakerSchedule {
|
|
override block lunch { time: 12:30 - 13:00 }
|
|
remove block evening_walk
|
|
append block meditation { 5:00 - 5:15 activity: meditate at: home }
|
|
}
|
|
}
|
|
```
|
|
|
|
**Merge semantics:**
|
|
|
|
| Operation | Meaning | Syntax |
|
|
|---|---|---|
|
|
| `override <target> { fields }` | Recursive structural merge. Only specified fields are replaced; everything else inherits from the template. | `override sleep { decay: 0.9 }` |
|
|
| `remove <target>` | Delete an inherited item entirely. | `remove block evening_walk` |
|
|
| `append <kind> <name> { fields }` | Add a new item not present in the template. | `append block meditation { ... }` |
|
|
|
|
For nested structures, override is recursive. `override block lunch { time: 12:30 - 13:00 }` patches only the `time` field of the `lunch` block — `activity`, `at`, `priority` all keep their template values.
|
|
|
|
**Lists** are replaced wholesale by default. If a template defines `capabilities: [work, trade, parent]` and an override says `capabilities: [work, trade]`, the result is `[work, trade]`. For additive modification, use explicit operations:
|
|
|
|
```
|
|
override capabilities {
|
|
add mentor
|
|
remove parent
|
|
}
|
|
```
|
|
|
|
### 4.6 `include` — Composition
|
|
|
|
`include` is for composing capability sets, need templates, and other aggregates:
|
|
|
|
```
|
|
capset FullAdult {
|
|
include BasicCapabilities -- pull in everything from BasicCapabilities
|
|
work, trade, parent, travel -- add more
|
|
}
|
|
```
|
|
|
|
This is purely additive — include pulls in all items from the referenced set. It's distinct from `use` (which affects name visibility) and `override` (which patches inherited values). `include` is "copy these items into this definition."
|
|
|
|
### 4.7 Resolution Pipeline
|
|
|
|
Cross-referencing happens in a multi-pass pipeline after parsing:
|
|
|
|
```
|
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
|
│ 1. Parse │────▶│ 2. Register │────▶│ 3. Resolve │────▶│ 4. Merge │────▶│ 5. Validate │
|
|
│ all .sb │ │ names │ │ references │ │ overrides │ │ semantics │
|
|
│ files │ │ │ │ │ │ │ │ │
|
|
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
|
│ │ │ │ │
|
|
LALRPOP + Walk all Walk all Apply override Type-check,
|
|
logos produce top-level use stmts, /remove/append range-check,
|
|
per-file ASTs defs, build qualified to produce constraint
|
|
name table paths, link fully-resolved verification,
|
|
with kinds stmts. Check entities cross-entity
|
|
existence + consistency
|
|
kind matching.
|
|
Detect cycles.
|
|
```
|
|
|
|
**Pass 1 — Parse.** Each `.sb` file is independently parsed by LALRPOP + logos into an AST. Parse errors are reported with file, line, column. No cross-file awareness needed.
|
|
|
|
**Pass 2 — Register.** Walk all ASTs, collect every top-level definition into a name table: `{ qualified_path → (kind, file, AST node) }`. Detect duplicate definitions (same name in same namespace). This pass is $O(n)$ in total definitions.
|
|
|
|
**Pass 3 — Resolve.** Walk every reference (use statements, qualified paths, link targets, template references) and resolve them against the name table. Check that the referenced thing exists and is the right kind (you can't use a `condition` where a `schedule` is expected). Detect unresolved references with fuzzy-match suggestions: *"Martha references `conditions::Flue` but no condition named `Flue` exists. Did you mean `conditions::Flu`?"*
|
|
|
|
Also in this pass: detect circular includes (capset A includes capset B includes capset A). Circular links are fine (Martha links to David, David links to Martha) — they're just bidirectional relationships.
|
|
|
|
**Pass 4 — Merge.** For every entity with template references + overrides, produce a fully resolved version. Martha's `needs: HumanNeeds { override sleep { decay: 0.9 } }` becomes a concrete needs block with all values filled in. This pass produces the "flat" representation that the engine can consume directly.
|
|
|
|
**Pass 5 — Validate.** Semantic checks:
|
|
- Trait values are within declared ranges
|
|
- Schedule times don't have impossible overlaps (two mandatory blocks at the same time)
|
|
- Life arc transitions reference valid states
|
|
- Behavior tree nodes reference valid actions/conditions known to the engine
|
|
- Relationship bond values are in `0.0 .. 1.0`
|
|
- All roles referenced by characters exist in their institutions
|
|
- Shared commitments reference entities that exist
|
|
- Condition severity ranges are valid
|
|
- Required fields are present (every character needs at minimum an `age` and `species`)
|
|
|
|
Validation errors are reported with full context: which file, which block, which field, what's wrong, and what would fix it.
|
|
|
|
---
|
|
|
|
## 5. The Expression Language
|
|
|
|
Life arc transitions, behavior tree conditions, and constraint rules need a small expression language.
|
|
|
|
### 5.1 Scope
|
|
|
|
This is deliberately minimal. It's not a programming language. It handles:
|
|
|
|
- **Comparisons:** `age >= 13`, `bond >= 0.7`, `coordination >= cohabiting`
|
|
- **Logical combinators:** `and`, `or`, `not`
|
|
- **Field access:** `entity.age`, `bakery.stock`, `batch.phase`
|
|
- **Quantifiers:** `relationship.any(bond >= romantic_interest)`, `children.all(age >= 18)`
|
|
- **Event references:** `event::child_born`, `event::death`, `event::separation`
|
|
- **State checks:** `is` keyword for enum comparison (`batch.phase is rising`, `autonomy is full`)
|
|
|
|
### 5.2 Grammar
|
|
|
|
```
|
|
Expr = OrExpr
|
|
OrExpr = AndExpr ("or" AndExpr)*
|
|
AndExpr = NotExpr ("and" NotExpr)*
|
|
NotExpr = "not" Atom | Atom
|
|
Atom = Comparison | EventRef | Quantifier | "(" Expr ")"
|
|
Comparison = FieldAccess CompOp Value
|
|
CompOp = ">=" | "<=" | ">" | "<" | "==" | "!=" | "is"
|
|
FieldAccess = Ident ("." Ident)*
|
|
EventRef = "event" "::" Ident
|
|
Quantifier = FieldAccess "." ("any" | "all" | "none") "(" Expr ")"
|
|
Value = Number | Float | Ident | QualifiedPath | Duration | Time
|
|
```
|
|
|
|
### 5.3 Evaluation
|
|
|
|
Expressions are not evaluated at parse time. They're stored in the AST as expression trees and compiled into Rust predicates during engine loading. The validator checks that field paths reference real fields and that types are compatible (you can't compare an integer to an enum without `is`).
|
|
|
|
For behavior trees, the `!` condition nodes contain expressions:
|
|
|
|
```
|
|
! bakery.customer_queue > 0
|
|
! batch.phase is mixing
|
|
! need.any is urgent
|
|
```
|
|
|
|
For life arc transitions:
|
|
|
|
```
|
|
Child -> Adolescent when age >= 13
|
|
Partnered -> Parent when event::child_born
|
|
Parent -> Partnered when children.all(age >= 18)
|
|
```
|
|
|
|
The `when` clause is an expression. Compound conditions use `and`/`or`:
|
|
|
|
```
|
|
Single -> Courting when relationship.any(type is romantic and bond >= 0.3)
|
|
```
|
|
|
|
### 5.4 What the Expression Language Is NOT
|
|
|
|
It cannot:
|
|
- Assign values
|
|
- Call functions
|
|
- Loop or iterate
|
|
- Define variables
|
|
- Do arithmetic (no `a + b`, no `stock * 0.5`)
|
|
|
|
If something requires computation, it belongs in the engine, not in content. The expression language is purely for **predicates** — boolean questions about world state.
|
|
|
|
One exception worth considering: **simple arithmetic in need modifiers and capability modifiers**, like `rate: *1.5` or `threshold: -0.2`. These aren't general expressions — they're specific modifier syntax within condition effect blocks. They use prefix operators (`*` for multiply, `+`/`-` for add/subtract) applied to a single value. This is limited enough to stay in the grammar without opening the door to general computation.
|
|
|
|
---
|
|
|
|
## 6. Behavior Tree Syntax
|
|
|
|
Behavior trees deserve special attention because they're the most structurally complex construct and they bridge content authoring with engine code.
|
|
|
|
### 6.1 Node Types
|
|
|
|
| Sigil | Name | Semantics |
|
|
|---|---|---|
|
|
| `?` | Selector | Try children in order; succeed on first success |
|
|
| `>` | Sequence | Run children in order; fail on first failure |
|
|
| `!` | Condition | Evaluate an expression; succeed if true |
|
|
| `@` | Action | Execute a named engine action |
|
|
| `~` | Decorator | Modify child behavior (invert, repeat, cooldown) |
|
|
|
|
### 6.2 Syntax
|
|
|
|
```
|
|
behavior WorkAtBakery {
|
|
applies_to: activity(work) at institution(bakery)
|
|
|
|
tree {
|
|
? root {
|
|
> handle_urgent {
|
|
! need.any is urgent
|
|
@ handle_urgent_need
|
|
}
|
|
> serve_customers {
|
|
! bakery.customer_queue > 0
|
|
@ move_to(counter)
|
|
@ serve_next_customer
|
|
}
|
|
> continue_batch {
|
|
! bakery.active_batches > 0
|
|
? batch_phase {
|
|
> mixing { ! batch.phase is mixing; @ mix_dough }
|
|
> rising { ! batch.phase is rising; @ wait_for_timer }
|
|
> baking { ! batch.phase is baking; @ tend_oven }
|
|
> done { ! batch.phase is done; @ unload_and_stock }
|
|
}
|
|
}
|
|
? idle {
|
|
@ clean
|
|
@ organize
|
|
@ idle_wait
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Semicolons separate children on a single line. Newlines also separate children. This gives flexibility — simple sequences can be one-liners, complex trees can be multi-line.
|
|
|
|
### 6.3 Actions and the Engine Interface
|
|
|
|
Action names (`handle_urgent_need`, `mix_dough`, `serve_next_customer`) reference engine-defined behaviors. The engine publishes an **action registry** (another `.sb` file in `schema/`) that lists valid action names with their parameters:
|
|
|
|
```
|
|
-- schema/actions.sb
|
|
action move_to {
|
|
param target: Location
|
|
---description
|
|
Navigate the entity to the specified location using pathfinding.
|
|
---
|
|
}
|
|
|
|
action serve_next_customer {
|
|
requires: capability(work) at institution with customer_queue
|
|
---description
|
|
Dequeue the next customer and perform a service interaction.
|
|
---
|
|
}
|
|
|
|
action wait_for_timer {
|
|
---description
|
|
Do nothing until the current timed process completes.
|
|
---
|
|
}
|
|
```
|
|
|
|
The validator checks that every `@` action in a behavior tree matches a registered action. Unknown actions are errors with suggestions.
|
|
|
|
### 6.4 Who Authors Behavior Trees
|
|
|
|
Realistically, behavior trees are the most engine-facing construct. Sienna will author most of them. But the grammar makes them accessible enough that Lonni could modify existing trees — tweaking priorities, adding idle behaviors, adjusting conditions. The egui tool will represent BTs as a visual node graph with drag-and-drop editing, so she never needs to touch the text syntax directly unless she wants to.
|
|
|
|
### 6.5 Subtrees
|
|
|
|
Common behavior patterns appear across many trees — "handle urgent need" is in virtually every work behavior, "flee threat" is in every wildlife mode. Rather than duplicating these, the `subtree` construct defines a reusable fragment:
|
|
|
|
```
|
|
subtree HandleUrgentNeed {
|
|
> handle_urgent {
|
|
! need.any is urgent
|
|
@ handle_urgent_need
|
|
}
|
|
}
|
|
|
|
subtree FleeThreat {
|
|
> flee {
|
|
! threat.detected
|
|
@ flee_from(threat.source)
|
|
! distance_to(threat.source) >= safety_range
|
|
}
|
|
}
|
|
```
|
|
|
|
Subtrees are referenced inside behavior trees with `use_subtree`:
|
|
|
|
```
|
|
behavior WorkAtBakery {
|
|
applies_to: activity(work) at institution(bakery)
|
|
|
|
tree {
|
|
? root {
|
|
use_subtree HandleUrgentNeed
|
|
> serve_customers {
|
|
! bakery.customer_queue > 0
|
|
@ move_to(counter)
|
|
@ serve_next_customer
|
|
}
|
|
> continue_batch { ... }
|
|
? idle { ... }
|
|
}
|
|
}
|
|
}
|
|
|
|
behavior WorkAtClinic {
|
|
applies_to: activity(work) at institution(clinic)
|
|
|
|
tree {
|
|
? root {
|
|
use_subtree HandleUrgentNeed -- same subtree, different context
|
|
> treat_patients { ... }
|
|
? idle { ... }
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
`use_subtree` inlines the referenced subtree's children at that position in the tree. It's resolved during the merge pass (pass 4), not at parse time — the parser just records it as a reference. The validator checks that the referenced subtree exists and that inlining it doesn't create cycles.
|
|
|
|
Subtrees follow the same cross-referencing rules as everything else: they have qualified names, can be imported with `use`, and can live in any `.sb` file. A natural home is alongside the behavior trees that use them:
|
|
|
|
```
|
|
storybook/
|
|
└── world/
|
|
└── behaviors/
|
|
├── common.sb -- subtree HandleUrgentNeed, subtree FleeThreat, etc.
|
|
├── work.sb -- behavior WorkAtBakery, behavior WorkAtClinic
|
|
└── wildlife.sb -- behavior DeerBehavior, behavior BirdBehavior
|
|
```
|
|
|
|
Subtrees can reference other subtrees (`use_subtree` inside a `subtree`), enabling composition. The resolver detects circular references.
|
|
|
|
---
|
|
|
|
## 7. Species and Entity Archetypes
|
|
|
|
The RFC describes very different entity types — people, cats, deer, cars, buildings. Rather than having the grammar handle each specially, we use a **species** construct that defines what components an entity type has:
|
|
|
|
```
|
|
species Human {
|
|
components: [lifecycle, physical_presence, needs, instincts,
|
|
roles, autonomy, relationships, membership]
|
|
life_arc: PersonArc
|
|
needs: HumanNeeds
|
|
|
|
---description
|
|
Sapient beings with full agency, social bonds, and institutional
|
|
participation. The primary inhabitants of the village.
|
|
---
|
|
}
|
|
|
|
species Deer {
|
|
components: [lifecycle, physical_presence, needs, instincts]
|
|
behavior_mode: WildlifeModes -- simplified BT, not full behavior trees
|
|
herd: true
|
|
|
|
---description
|
|
Wild ungulates. No social structure beyond herding. Flight-driven
|
|
threat response. Grazes on foliage, interacts with substrate.
|
|
---
|
|
}
|
|
|
|
species Building {
|
|
components: [lifecycle, physical_presence, operable, stateful]
|
|
-- No agency, no needs, no behavior trees
|
|
|
|
---description
|
|
Static structures that provide shelter, services, and institutional
|
|
anchoring. Age and decay over time via lifecycle.
|
|
---
|
|
}
|
|
```
|
|
|
|
Then characters reference their species:
|
|
|
|
```
|
|
character Martha {
|
|
species: Human
|
|
age: 34
|
|
...
|
|
}
|
|
```
|
|
|
|
The validator uses the species definition to check that the character only includes components appropriate for their species. A Deer character can't have `roles` or `relationships`. A Building can't have `personality`.
|
|
|
|
---
|
|
|
|
## 8. Locations
|
|
|
|
Locations are referenced everywhere — schedules, behavior trees, relationship commitments. They need their own construct:
|
|
|
|
```
|
|
location TownSquare {
|
|
position: [50, 32] -- tile coordinates
|
|
size: [3, 3] -- in 10m tiles
|
|
tags: [public, outdoor, gathering]
|
|
|
|
activities: [social, eat, leisure, worship]
|
|
capacity: 50
|
|
|
|
---description
|
|
The heart of the village. A cobblestone square with a central fountain,
|
|
benches under old oaks, and a small stage for festivals. Market stalls
|
|
line the eastern edge on Saturdays.
|
|
---
|
|
}
|
|
|
|
location Bakery {
|
|
position: [48, 30]
|
|
size: [2, 1]
|
|
tags: [indoor, workplace, commercial]
|
|
institution: institutions::Bakery -- links to the institution definition
|
|
|
|
activities: [work, eat]
|
|
capacity: 8
|
|
|
|
substations {
|
|
counter { position: [0, 0], activities: [work] }
|
|
oven { position: [1, 0], activities: [work], capabilities: [heat] }
|
|
seating { position: [0, 1], activities: [eat, social], capacity: 4 }
|
|
}
|
|
}
|
|
```
|
|
|
|
Locations bridge the spatial world (tile positions, sizes) with the simulation world (what activities happen here, who works here, what institution owns this space). The `substations` block defines finer-grained positions within a location, referenced by behavior trees (`@ move_to(counter)`).
|
|
|
|
---
|
|
|
|
## 9. Implementation Architecture
|
|
|
|
### 9.1 Crate Structure
|
|
|
|
```
|
|
storybook/ -- separate repo (e.g. github.com/aspen-game/storybook)
|
|
├── Cargo.toml -- workspace root
|
|
├── storybook/ -- core crate: parser, resolver, API, CLI
|
|
│ ├── src/
|
|
│ │ ├── lib.rs
|
|
│ │ ├── syntax/
|
|
│ │ │ ├── mod.rs
|
|
│ │ │ ├── lexer.rs -- logos lexer with prose mode
|
|
│ │ │ ├── ast.rs -- AST node types
|
|
│ │ │ └── parser.lalrpop -- LALRPOP grammar
|
|
│ │ ├── resolve/
|
|
│ │ │ ├── mod.rs
|
|
│ │ │ ├── names.rs -- name table, qualified path resolution
|
|
│ │ │ ├── merge.rs -- override/remove/append merging
|
|
│ │ │ └── validate.rs -- semantic validation
|
|
│ │ ├── diagnostics.rs -- error formatting with miette/ariadne
|
|
│ │ ├── project.rs -- Project::load(path), filesystem handling
|
|
│ │ ├── query.rs -- query API (find characters, list relationships, etc.)
|
|
│ │ └── types.rs -- resolved types (what consumers see)
|
|
│ ├── src/bin/
|
|
│ │ └── sb.rs -- CLI binary: sb validate, sb query, sb watch
|
|
│ └── Cargo.toml
|
|
│
|
|
├── storybook-editor/ -- editor crate: egui-based visual editor
|
|
│ └── ... -- depends on storybook + eframe, egui, octocrab, git2
|
|
│
|
|
└── schema/ -- default schema .sb files shipped with the crate
|
|
├── core.sb
|
|
├── needs.sb
|
|
├── capabilities.sb
|
|
└── activities.sb
|
|
```
|
|
|
|
This is its own repository, separate from the Aspen game engine. The workspace contains two crates: the core `storybook` library+CLI and the `storybook-editor` GUI application.
|
|
|
|
The `syntax` and `resolve` modules are `pub` for internal organization but the public API surface is `Project::load()`, the query interface, and the resolved types. The CLI is a binary target within the core crate.
|
|
|
|
The editor stays in a separate workspace crate because it pulls in heavy GUI dependencies (`eframe`, `egui_node_graph`, `octocrab`, `git2`) that don't belong in the engine's dependency tree.
|
|
|
|
The Aspen engine depends on the `storybook` crate via a git dependency or path dependency (during development):
|
|
|
|
```toml
|
|
# In aspen's Cargo.toml
|
|
[dependencies]
|
|
storybook = { git = "https://github.com/aspen-game/storybook", branch = "main" }
|
|
```
|
|
|
|
If compile times become a problem later, the module boundaries make splitting the core crate into sub-crates a mechanical refactor — but at this scale, one library crate is simpler to maintain and faster to iterate on.
|
|
|
|
### 9.2 Lexer Design (logos)
|
|
|
|
The lexer needs two modes because of prose blocks:
|
|
|
|
**Normal mode:** Standard tokenization — keywords, identifiers, numbers, operators, punctuation.
|
|
|
|
**Prose mode:** Activated when the lexer encounters `---` followed by an identifier. Captures everything as raw text until a line containing only `---`. Emits a single `ProseBlock(tag, content)` token.
|
|
|
|
```rust
|
|
#[derive(Logos, Debug, PartialEq)]
|
|
#[logos(skip r"[ \t]+")] // skip whitespace (not newlines)
|
|
#[logos(skip r"--[^\n]*")] // skip line comments
|
|
enum Token {
|
|
// Keywords
|
|
#[token("use")] Use,
|
|
#[token("character")] Character,
|
|
#[token("life_arc")] LifeArc,
|
|
#[token("schedule")] Schedule,
|
|
#[token("behavior")] Behavior,
|
|
#[token("subtree")] Subtree,
|
|
#[token("use_subtree")] UseSubtree,
|
|
#[token("institution")] Institution,
|
|
#[token("condition")] Condition,
|
|
#[token("relationship")] Relationship,
|
|
#[token("location")] Location,
|
|
#[token("species")] Species,
|
|
#[token("enum")] Enum,
|
|
#[token("traits")] Traits,
|
|
#[token("capset")] Capset,
|
|
#[token("capability")] Capability,
|
|
#[token("need_template")] NeedTemplate,
|
|
#[token("link")] Link,
|
|
#[token("via")] Via,
|
|
#[token("as")] As,
|
|
#[token("self")] Self_,
|
|
#[token("other")] Other,
|
|
#[token("asymmetric")] Asymmetric,
|
|
#[token("override")] Override,
|
|
#[token("remove")] Remove,
|
|
#[token("append")] Append,
|
|
#[token("include")] Include,
|
|
#[token("when")] When,
|
|
#[token("at")] At,
|
|
#[token("on")] On,
|
|
#[token("to")] To,
|
|
#[token("from")] From,
|
|
#[token("and")] And,
|
|
#[token("or")] Or,
|
|
#[token("not")] Not,
|
|
#[token("is")] Is,
|
|
#[token("true")] True,
|
|
#[token("false")] False,
|
|
#[token("event")] Event,
|
|
#[token("tree")] Tree,
|
|
#[token("block")] Block,
|
|
#[token("state")] State,
|
|
#[token("substates")] Substates,
|
|
#[token("transitions")] Transitions,
|
|
|
|
// Sigils (behavior tree)
|
|
#[token("?")] Selector,
|
|
#[token("!")] Condition,
|
|
#[token("@")] Action,
|
|
#[token(">")] Sequence,
|
|
#[token("~")] Decorator,
|
|
|
|
// Punctuation
|
|
#[token("{")] LBrace,
|
|
#[token("}")] RBrace,
|
|
#[token("[")] LBracket,
|
|
#[token("]")] RBracket,
|
|
#[token("(")] LParen,
|
|
#[token(")")] RParen,
|
|
#[token(":")] Colon,
|
|
#[token("::")] PathSep,
|
|
#[token(",")] Comma,
|
|
#[token(";")] Semi,
|
|
#[token("..")] DotDot,
|
|
#[token(".")] Dot,
|
|
#[token("->")] Arrow,
|
|
#[token("<->")] BiArrow,
|
|
#[token("*")] Star,
|
|
#[token(">=")] Gte,
|
|
#[token("<=")] Lte,
|
|
#[token("==")] Eq,
|
|
#[token("!=")] Neq,
|
|
// Note: > and < overlap with Sequence sigil and other uses.
|
|
// The parser disambiguates by context (> at start of line in a tree
|
|
// block = sequence; > between expressions = comparison).
|
|
|
|
#[token("\n")] Newline,
|
|
|
|
// Literals
|
|
#[regex(r"[0-9]+:[0-9]{2}", |lex| lex.slice().to_string())]
|
|
TimeLiteral(String),
|
|
|
|
#[regex(r"[0-9]+[dhms]", |lex| lex.slice().to_string())]
|
|
DurationLiteral(String),
|
|
|
|
#[regex(r"[0-9]+\.[0-9]+", |lex| lex.slice().parse::<f64>().unwrap())]
|
|
Float(f64),
|
|
|
|
#[regex(r"[0-9]+", |lex| lex.slice().parse::<i64>().unwrap())]
|
|
Integer(i64),
|
|
|
|
#[regex(r#""[^"]*""#, |lex| lex.slice()[1..lex.slice().len()-1].to_string())]
|
|
StringLiteral(String),
|
|
|
|
#[regex(r"[a-zA-Z_][a-zA-Z0-9_]*", |lex| lex.slice().to_string())]
|
|
Ident(String),
|
|
|
|
// Prose blocks — handled by a wrapper around the logos lexer.
|
|
// When the wrapper sees `---` followed by an Ident on the same logical
|
|
// line, it switches to raw capture mode.
|
|
ProseBlock { tag: String, content: String },
|
|
}
|
|
```
|
|
|
|
The prose block handling can't be done purely in logos regex — it needs stateful scanning. The approach: write a thin wrapper `StorybookLexer` that drives `logos` for normal tokens but intercepts `---` sequences and manually scans ahead for prose blocks. LALRPOP's external lexer interface supports this cleanly.
|
|
|
|
### 9.3 LALRPOP Considerations
|
|
|
|
LALRPOP generates an LALR(1) parser. A few areas need care:
|
|
|
|
**The `>` ambiguity.** In behavior tree blocks, `>` is the sequence sigil. In expressions, `>` is a comparison operator. These contexts are syntactically separated — `>` as a sigil only appears at the start of a behavior tree child node, which is inside a `tree { ... }` block. The grammar can handle this by having separate productions for tree nodes vs. expressions.
|
|
|
|
**Newlines as separators.** The grammar uses both newlines and semicolons as statement separators within blocks. LALRPOP handles this if newlines are explicit tokens (not skipped). The grammar needs `Sep` productions that accept either.
|
|
|
|
**Error recovery.** LALRPOP supports error recovery via `!` in grammar rules, allowing the parser to skip to the next statement on error and continue parsing. This is important for the editor — Lonni wants to see all errors at once, not just the first one.
|
|
|
|
### 9.4 Diagnostics
|
|
|
|
Error reporting uses the `ariadne` or `miette` crate for rich, colorful error messages:
|
|
|
|
```
|
|
error[E0301]: unresolved reference
|
|
┌─ world/characters/martha.sb:14:17
|
|
│
|
|
14 │ susceptible: [Flue, Cold]
|
|
│ ^^^^ no condition named `Flue` exists
|
|
│
|
|
= help: did you mean `Flu`? (defined in world/conditions/illness.sb)
|
|
```
|
|
|
|
```
|
|
error[E0502]: type mismatch in override
|
|
┌─ world/characters/martha.sb:22:27
|
|
│
|
|
22 │ override sleep { decay: "fast" }
|
|
│ ^^^^^^ expected float, found string
|
|
│
|
|
= note: `decay` is defined as a float in `HumanNeeds`
|
|
(world/shared/needs.sb:4)
|
|
```
|
|
|
|
```
|
|
warning[W0101]: relationship declared in both files
|
|
┌─ world/characters/david.sb:8:3
|
|
│
|
|
8 │ link Martha via Spousal { bond: 0.82 }
|
|
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
│
|
|
= note: this relationship is already declared in
|
|
world/characters/martha.sb:15
|
|
= help: declare a relationship in one file only; it's automatically
|
|
visible from both sides. use self/other blocks for
|
|
asymmetric values.
|
|
```
|
|
|
|
The diagnostic system is a module within the `storybook` crate so it can be reused by both the CLI and the editor.
|
|
|
|
---
|
|
|
|
## 10. The Editor (storybook-editor)
|
|
|
|
### 10.1 Architecture
|
|
|
|
A standalone `eframe` application (not inside Bevy). Depends on the `storybook` crate for parsing, resolution, and validation. Uses the filesystem as the source of truth — every save writes `.sb` files and triggers a re-resolve.
|
|
|
|
```
|
|
┌────────────────────────────────────────────────┐
|
|
│ storybook-editor │
|
|
│ │
|
|
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
|
│ │ Character│ │ Life Arc│ │ Relationship │ │
|
|
│ │ Editor │ │ Editor │ │ Graph │ │
|
|
│ │ (forms) │ │ (nodes) │ │ (nodes) │ │
|
|
│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │
|
|
│ │ │ │ │
|
|
│ ┌────┴──────────────┴───────────────┴───────┐ │
|
|
│ │ storybook crate │ │
|
|
│ │ parse ──▶ resolve ──▶ validate ──▶ query │ │
|
|
│ └────────────────────┬──────────────────────┘ │
|
|
│ │ │
|
|
│ ┌────────────────────┴──────────────────────┐ │
|
|
│ │ filesystem (.sb files) │ │
|
|
│ │ + transparent git │ │
|
|
│ └───────────────────────────────────────────┘ │
|
|
└────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 10.2 Panels
|
|
|
|
| Panel | What It Shows | Primary Interaction |
|
|
|---|---|---|
|
|
| **Character Editor** | Form fields for structured data, embedded text area for prose, portrait slot | Fill in fields, write backstory, link relationships |
|
|
| **Life Arc Editor** | Node graph — states as nodes, transitions as edges with `when` conditions | Drag states, draw transition arrows, edit conditions |
|
|
| **Schedule Builder** | Timeline/calendar view with draggable time blocks | Drag blocks to resize/reposition, color by activity type |
|
|
| **Behavior Tree Editor** | Node graph — tree hierarchy with condition/action nodes | Drag to rearrange, click to edit conditions/actions |
|
|
| **Relationship Map** | Force-directed graph — characters as nodes, relationships as edges | Click edges to see/edit details, drag nodes to arrange |
|
|
| **Location Browser** | Grid map showing tile positions + detail panel | Click locations, edit properties, link to institutions |
|
|
| **Issues & PRs** | GitHub issues and pull requests, linked to storybook entities | Create, comment, close issues; review PRs; cross-link to characters/systems |
|
|
| **Diagnostics Panel** | Live error/warning list with click-to-navigate | Click an error to jump to the relevant panel + field |
|
|
|
|
### 10.3 Git Integration
|
|
|
|
The editor manages git transparently:
|
|
|
|
- **Every save** creates a commit with an auto-generated message: `"Update Martha: modified backstory and personality traits"`
|
|
- **Branch switching** is exposed as "versions" in the UI — Lonni can create a "what if" branch, make changes, and merge back
|
|
- **Undo** is just `git revert` under the hood
|
|
- **History** shows a timeline of changes with diffs rendered as "what changed" in plain language, not code diffs
|
|
|
|
The git operations use `git2` (libgit2 bindings for Rust). No shell-outs, no git CLI dependency.
|
|
|
|
### 10.4 GitHub Integration
|
|
|
|
The editor integrates with GitHub so Lonni never has to leave the app for project management:
|
|
|
|
| Feature | What It Does |
|
|
|---|---|
|
|
| **Issues** | Browse, create, edit, close issues. Tag issues to specific characters, locations, or systems. Filter by label, assignee, milestone. |
|
|
| **Pull Requests** | View open PRs, see diff summaries in plain language ("Martha's personality changed, new location added"), approve/comment. |
|
|
| **Comments** | Comment on issues and PRs inline. Mention characters or systems with `@`-style autocomplete linked to storybook entities. |
|
|
| **Labels & Milestones** | Manage labels (e.g., `character`, `institution`, `bug`, `story-beat`) and milestones from within the editor. |
|
|
|
|
The GitHub API integration uses `octocrab` (async GitHub client for Rust). Authentication is via a personal access token stored in the system keychain — configured once, never asked again.
|
|
|
|
The UI surfaces this as an **Issues** panel alongside the content panels. Issues can be cross-linked to storybook entities: clicking "Related: Martha" in an issue jumps to the character editor. Creating a character or relationship can auto-suggest creating a tracking issue.
|
|
|
|
Push/pull operations are exposed as "Sync" — a single button that pushes local commits and pulls remote changes. Merge conflicts (rare, since Lonni is typically the sole content author) are surfaced with plain-language descriptions and resolved in the editor.
|
|
|
|
### 10.5 Self-Updating
|
|
|
|
The editor binary is distributed as a single executable. Self-update checks a release endpoint on startup (configurable, could be a GitHub release or your own server) and downloads new versions in the background. The update applies on next launch.
|
|
|
|
---
|
|
|
|
## 11. Engine Integration
|
|
|
|
### 11.1 Loading
|
|
|
|
The game engine depends on the `storybook` crate. At startup (or when entering a save), it:
|
|
|
|
1. Loads the storybook directory
|
|
2. Parses, resolves, validates
|
|
3. Converts resolved types into Bevy ECS components and resources
|
|
4. Spawns entities with their initial state
|
|
|
|
The conversion from `storybook::types::Character` to Bevy components is a straightforward mapping — personality traits become a `Personality` component, needs become a `Needs` component, etc. Life arc definitions become state machine resources. Behavior tree definitions become `BehaviorTree` components. Schedules become `ScheduleTemplate` resources.
|
|
|
|
### 11.2 Hot Reloading (Development)
|
|
|
|
During development, the engine watches the storybook directory for changes (via `notify` crate). On change:
|
|
|
|
1. Re-parse only the changed files
|
|
2. Re-run resolution (full, since cross-references may have changed — this should be fast enough for hundreds of files)
|
|
3. Diff the resolved state against the current ECS state
|
|
4. Apply delta updates to live entities
|
|
|
|
This lets Sienna edit a character's personality in a text editor and see the behavior change in real-time without restarting the game.
|
|
|
|
### 11.3 Compiled Format
|
|
|
|
For release builds, the storybook is compiled into a binary format (MessagePack, bincode, or similar) that skips parsing and resolution entirely. The build pipeline is: `.sb` files → `storybook` crate → resolve → serialize → `.aspen` binary. The game ships the `.aspen` file, not the raw `.sb` files.
|
|
|
|
---
|
|
|
|
## 12. Open Design Questions
|
|
|
|
### 12.1 Versioning and Migration
|
|
|
|
The schema uses **major.minor versioning** with an **additive-only evolution policy**:
|
|
|
|
- **Minor versions** (0.1 → 0.2) add new constructs, fields, enum variants, or optional features. Old files remain valid — new fields have defaults, new constructs are simply unused.
|
|
- **Major versions** (0.x → 1.0) are reserved for if a breaking change ever becomes unavoidable. We avoid this as long as possible.
|
|
|
|
The schema version is tracked in `storybook.toml`:
|
|
|
|
```toml
|
|
[storybook]
|
|
schema_version = "0.1"
|
|
```
|
|
|
|
The validator checks compatibility: a storybook authored against schema 0.1 is valid against 0.2 (new features ignored). A storybook authored against 0.2 may use features not present in 0.1 (validator warns if downgrade is attempted).
|
|
|
|
If a breaking change becomes necessary in the future, it will ship with a migration guide and a `sb migrate` CLI command. But this is deferred — the schema is small enough and new enough that additive evolution should cover the foreseeable horizon.
|
|
|
|
### 12.2 Localization of Prose
|
|
|
|
Character backstories, descriptions, and institution flavor text — should they be localizable? If Aspen ships in multiple languages, the prose blocks need a localization story.
|
|
|
|
**Option:** Prose blocks become keys into a localization table. The `.sb` file contains the default language; translations live in companion files (`martha.sb.pt`, `martha.sb.es`). The editor shows the default language and flags untranslated content.
|
|
|
|
**Recommendation:** Defer. Build the grammar without localization support. Add it as a file-level concern later (companion files, not grammar changes).
|
|
|
|
### 12.3 Procedural Generation Integration
|
|
|
|
The RFC envisions up to 500 entities. Lonni isn't going to hand-author 500 characters. Some will be procedurally generated from templates. The grammar should support:
|
|
|
|
```
|
|
template VillagerTemplate {
|
|
species: Human
|
|
|
|
personality {
|
|
openness: 0.2 .. 0.8 -- random within range
|
|
conscientiousness: 0.3 .. 0.9
|
|
warmth: 0.4 .. 0.8
|
|
}
|
|
|
|
age: 18 .. 65
|
|
|
|
-- Engine generates a name, backstory, etc. from this template
|
|
-- Lonni defines the distributions; the engine instantiates
|
|
}
|
|
```
|
|
|
|
This is a natural extension of the range syntax. Templates define distributions; the engine samples from them. Hand-authored characters override specific values; procedural characters fill in the gaps.
|
|
|
|
**Recommendation:** Include `template` as a construct from the start. It uses the same syntax as `character` but allows ranges where `character` requires concrete values.
|
|
|
|
---
|
|
|
|
## 13. Build Plan
|
|
|
|
### Phase 1: Grammar + Parser (2 weeks)
|
|
|
|
- [ ] Define AST types in `syntax/ast.rs`
|
|
- [ ] Implement logos lexer with prose block handling
|
|
- [ ] Write LALRPOP grammar for all top-level constructs
|
|
- [ ] Write LALRPOP grammar for expression language
|
|
- [ ] Write LALRPOP grammar for behavior tree syntax
|
|
- [ ] Parse test suite: one `.sb` file per construct, edge cases for expressions and prose blocks
|
|
- [ ] Error recovery in parser for multi-error reporting
|
|
|
|
**Deliverable:** `syntax` module parses any `.sb` file into an AST. No resolution, no validation.
|
|
|
|
### Phase 2: Resolution + Validation (2 weeks)
|
|
|
|
- [ ] Name table construction from parsed ASTs
|
|
- [ ] `use` statement resolution with wildcard and group support
|
|
- [ ] Qualified path resolution with kind checking
|
|
- [ ] `link` resolution with bidirectional handling and asymmetric value merging
|
|
- [ ] Override merge engine (recursive structural merge, remove, append)
|
|
- [ ] `include` expansion for capsets and need templates
|
|
- [ ] `use_subtree` inlining with cycle detection
|
|
- [ ] Semantic validation (ranges, types, required fields, cross-entity consistency)
|
|
- [ ] Diagnostic formatting with `miette` or `ariadne`
|
|
- [ ] Fuzzy matching for unresolved name suggestions
|
|
|
|
**Deliverable:** `resolve` module. Feed it ASTs, get resolved types or rich error messages.
|
|
|
|
### Phase 3: Public API + CLI (1 week)
|
|
|
|
- [ ] `Project::load(path)` API
|
|
- [ ] Query interface: find characters, list relationships, filter by trait
|
|
- [ ] `sb` CLI binary: `sb validate`, `sb query`, `sb inspect`
|
|
- [ ] Watch mode for continuous validation (`sb watch`)
|
|
|
|
**Deliverable:** Working toolchain. Lonni can start writing `.sb` files and validate them from terminal (with Sienna's help).
|
|
|
|
### Phase 4: Schema + Seed Content (1 week)
|
|
|
|
- [ ] Write `schema/` files: core enums, need templates, capability sets, action registry
|
|
- [ ] Write 3-5 seed characters with full cross-referencing
|
|
- [ ] Write 2-3 institutions (bakery, school, household)
|
|
- [ ] Write example life arcs, schedules, behavior trees
|
|
- [ ] Write example relationships, conditions, locations
|
|
- [ ] Validate the whole thing end-to-end
|
|
|
|
**Deliverable:** A complete, small storybook that exercises every language feature. Proves the grammar works for real content.
|
|
|
|
### Phase 5: Editor MVP (3-4 weeks)
|
|
|
|
- [ ] `eframe` app scaffolding with panel layout
|
|
- [ ] Character editor: form fields + prose editing
|
|
- [ ] Filesystem watching + live re-resolve
|
|
- [ ] Diagnostics panel with click-to-navigate
|
|
- [ ] Transparent git (auto-commit on save)
|
|
- [ ] Relationship map (force-directed graph, click to edit)
|
|
- [ ] Life arc editor (node graph)
|
|
- [ ] Schedule builder (timeline view)
|
|
|
|
**Deliverable:** Lonni can create and edit characters, relationships, and schedules in a visual tool. Behavior trees and advanced constructs come in Phase 6.
|
|
|
|
### Phase 6: Editor Completeness + Engine Integration (2-3 weeks)
|
|
|
|
- [ ] Behavior tree visual editor (node graph)
|
|
- [ ] Location browser with tile map
|
|
- [ ] Cross-reference navigation (click a name → jump to its definition)
|
|
- [ ] GitHub integration: issues, PRs, comments via `octocrab`
|
|
- [ ] Entity cross-linking in issues (tag issues to characters/systems)
|
|
- [ ] Engine integration: `storybook` → Bevy ECS component mapping
|
|
- [ ] Hot-reload pipeline for development
|
|
- [ ] Compiled binary format for release builds
|
|
|
|
**Deliverable:** Full pipeline from content authoring to running simulation. Lonni never has to leave the editor for project management.
|
|
|
|
---
|
|
|
|
## 14. Risk Assessment
|
|
|
|
| Risk | Likelihood | Impact | Mitigation |
|
|
|---|---|---|---|
|
|
| LALRPOP `>` ambiguity between BT sigil and comparison op | Medium | Low | Separate grammar productions for tree context vs expression context |
|
|
| Prose block lexer mode causes edge cases | Medium | Medium | Extensive test suite for weird prose content (contains `---`, contains `{`, etc.) |
|
|
| Override merge semantics are underspecified for deep nesting | High | Medium | Define precise merge rules in a spec doc before implementing; property-test the merge engine |
|
|
| Lonni finds the file-based workflow frustrating | Medium | High | Prioritize the editor (Phase 5); the CLI is the fallback, not the primary tool |
|
|
| Schema evolution breaks existing content | Low (early) | High (later) | Start with additive-only evolution; write migration tools if needed |
|
|
| Behavior tree syntax is too terse for Lonni | Low | Low | She'll use the visual editor; text syntax is for Sienna and git diffs |
|
|
| 500-entity storybook is slow to resolve | Low | Medium | Profile early; resolution is O(n) in definitions, should be fine for thousands of files |
|
|
| Expression language needs arithmetic eventually | Medium | Medium | Design the grammar to be extensible; add operators later if needed |
|
|
|
|
---
|
|
|
|
## 15. Summary
|
|
|
|
Storybook is a domain-specific language designed around the specific data shapes of Aspen's agent simulation: state machines for life arcs, hierarchical trees for behavior, temporal blocks for schedules, graph structures for relationships, cross-cutting modifiers for conditions, and prose for the narrative soul of the world.
|
|
|
|
The cross-referencing system uses filesystem-derived namespaces, `use` imports, qualified paths, bidirectional `link` instantiation, and structural `override` merging. Everything resolves in a multi-pass pipeline that produces rich error messages when things go wrong.
|
|
|
|
The implementation is four Rust crates: syntax (parser), resolve (semantics), storybook (public API), and editor (egui tool). The engine consumes the same crate, ensuring the content pipeline is unified from authoring to runtime.
|
|
|
|
Lonni gets a visual editor that hides the grammar behind forms, node graphs, and timeline views. Sienna gets a powerful text format that diffs well in git, validates in CI, and maps directly to the simulation architecture. The village gets to live.
|