Skip to main content

delvewright_dsl/
cast.rs

1//! The cast ledger: where each body is, and what it says, at each point of the story.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::{AnchorId, DialogueId, FlagId, Mark, StateCompare};
7
8#[cfg(doc)]
9use crate::Quest;
10
11/// One NPC's entry in a quest's [`cast`](Quest::cast) ledger.
12///
13/// Three authored shapes, tried in order (untagged): the bare keyword `"dead"` /
14/// `"offstage"`, a single flat [`CastPlacement`], or a **list** of placements —
15/// per-branch casts, each gated by the flags that select its branch (spec-0020
16/// proof 4: where the effect history is branch-dependent, a single flat
17/// declaration cannot hold on every reachable branch, and `DW0462` says so).
18#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
19#[serde(untagged)]
20pub enum CastEntry {
21    /// The bare keyword form: `"npc/antiphos": "dead"`. Shorthand for a
22    /// placement whose `at` is that keyword and which carries no `doing` or
23    /// `dialogue` — a character who is not in the world has no business and
24    /// answers no right-click.
25    Absent(CastAbsence),
26    /// A single placement that must hold on every reachable branch.
27    Placement(CastPlacement),
28    /// Per-branch placements (spec-0020 proof 4). Each carries the
29    /// `requires_flags`/`forbids_flags` that select its branch.
30    Branches(Vec<CastPlacement>),
31}
32
33impl CastEntry {
34    /// Every placement this entry declares, in declared order. The bare-keyword
35    /// form yields none — there is no scene to check.
36    pub fn placements(&self) -> Vec<&CastPlacement> {
37        match self {
38            CastEntry::Absent(_) => Vec::new(),
39            CastEntry::Placement(p) => vec![p],
40            CastEntry::Branches(ps) => ps.iter().collect(),
41        }
42    }
43
44    /// Every placement this entry declares, mutably (the l10n traversal).
45    pub fn placements_mut(&mut self) -> Vec<&mut CastPlacement> {
46        match self {
47            CastEntry::Absent(_) => Vec::new(),
48            CastEntry::Placement(p) => vec![p],
49            CastEntry::Branches(ps) => ps.iter_mut().collect(),
50        }
51    }
52
53    /// The bare-keyword absence this entry declares, if it is that form.
54    pub fn absence(&self) -> Option<CastAbsence> {
55        match self {
56            CastEntry::Absent(a) => Some(*a),
57            _ => None,
58        }
59    }
60
61    /// True if this entry declares the NPC out of the world entirely — the bare
62    /// keyword, or every placement's `at` being a keyword.
63    pub fn is_absent(&self) -> bool {
64        match self {
65            CastEntry::Absent(_) => true,
66            CastEntry::Placement(p) => p.at.absence().is_some(),
67            CastEntry::Branches(ps) => {
68                !ps.is_empty() && ps.iter().all(|p| p.at.absence().is_some())
69            }
70        }
71    }
72}
73
74/// A declared absence: the NPC is deliberately not in the world.
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76#[serde(rename_all = "kebab-case")]
77pub enum CastAbsence {
78    /// Removed from the story for good (must match a `despawn-npc` with no later
79    /// `spawn-npc`).
80    Dead,
81    /// Not in the world for this quest, but may return (must match a
82    /// `despawn-npc`).
83    Offstage,
84}
85
86impl CastAbsence {
87    /// The authored keyword (`dead` / `offstage`).
88    pub fn token(self) -> &'static str {
89        match self {
90            CastAbsence::Dead => "dead",
91            CastAbsence::Offstage => "offstage",
92        }
93    }
94}
95
96/// Where a cast entry puts an NPC: a prefab anchor, a mark, or a declared
97/// absence.
98#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
99#[serde(untagged)]
100pub enum CastPlace {
101    /// `"dead"` or `"offstage"` — explicitly not in the world.
102    Absent(CastAbsence),
103    /// The anchor the NPC stands on for this quest's duration. Must equal the
104    /// position the effect history actually produces (`DW0461`): declaring an
105    /// anchor does not teleport anybody.
106    Anchor(AnchorId),
107    /// The mark the NPC stands on for this quest's duration (spec-0066): the
108    /// spelling for a body that stands at an offset from its anchor. `DW0461`
109    /// compares anchor and offset both.
110    Mark(Mark),
111}
112
113impl CastPlace {
114    /// The anchor this place names, if it names one.
115    pub fn anchor(&self) -> Option<&AnchorId> {
116        match self {
117            CastPlace::Anchor(a) => Some(a),
118            CastPlace::Mark(m) => Some(&m.anchor),
119            CastPlace::Absent(_) => None,
120        }
121    }
122
123    /// The mark this place names, if it names one: a bare anchor is the mark at
124    /// a zero offset.
125    pub fn mark(&self) -> Option<Mark> {
126        match self {
127            CastPlace::Anchor(a) => Some(Mark::at(a.clone())),
128            CastPlace::Mark(m) => Some(m.clone()),
129            CastPlace::Absent(_) => None,
130        }
131    }
132
133    /// The declared absence, if this place is one.
134    pub fn absence(&self) -> Option<CastAbsence> {
135        match self {
136            CastPlace::Absent(a) => Some(*a),
137            CastPlace::Anchor(_) | CastPlace::Mark(_) => None,
138        }
139    }
140
141    /// The authored token, for diagnostics.
142    pub fn token(&self) -> String {
143        match self {
144            CastPlace::Absent(a) => a.token().to_string(),
145            CastPlace::Anchor(a) => a.as_str().to_string(),
146            CastPlace::Mark(m) => m.display(),
147        }
148    }
149}
150
151/// One declared scene for one NPC in one quest.
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
153#[serde(deny_unknown_fields)]
154pub struct CastPlacement {
155    /// Where the NPC is: an anchor, or `"offstage"` / `"dead"`.
156    pub at: CastPlace,
157    /// What the character is *doing* — free prose, never machine-checked.
158    ///
159    /// This field is the forcing function, which is the whole reason it is
160    /// required (`DW0463`) despite being unverifiable: you cannot fill it in
161    /// without deciding the character's business in this beat, and stage 6
162    /// receives it as the context the NPC's lines are written against.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub doing: Option<String>,
165    /// What right-click offers during this quest. Required for an on-stage
166    /// placement (`DW0463`) — including the explicit `"none"`.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub dialogue: Option<CastDialogue>,
169    /// Branch gate (per-branch casts): this placement describes the world only
170    /// once every listed flag is set. Mirrors an option's `requires_flags`.
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub requires_flags: Vec<FlagId>,
173    /// Negative branch gate: this placement describes the world only while no
174    /// listed flag is set. Mirrors an option's `forbids_flags`.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub forbids_flags: Vec<FlagId>,
177    /// Numeric gate terms (DSL v0.10, spec-0031): every listed comparison must
178    /// hold for this gate to be open. The third field of the one gate, carried by
179    /// every gate consumer — never by the verb that first wanted it. Default
180    /// empty, so a pre-0.10 campaign is byte-identical.
181    #[serde(default, skip_serializing_if = "Vec::is_empty")]
182    pub requires_state: Vec<StateCompare>,
183}
184
185/// What an NPC's right-click offers for a quest's duration.
186///
187/// Untagged, tried in order: the keywords `"none"` / `"unchanged"`, a
188/// `{"barks": […]}` pool, then a dialogue root id.
189#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
190#[serde(untagged)]
191pub enum CastDialogue {
192    /// A keyword: `"none"` or `"unchanged"`.
193    Keyword(CastDialogueKeyword),
194    /// A bark pool: right-click yields one inconsequential in-character line —
195    /// no tree, no options, no consequences.
196    Barks(CastBarks),
197    /// A dialogue root id: right-click opens this node of the NPC's stage-6
198    /// tree. Must be a node of *that* NPC's tree (`DW0464`).
199    Root(DialogueId),
200}
201
202/// The keyword forms of [`CastDialogue`].
203#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
204#[serde(rename_all = "kebab-case")]
205pub enum CastDialogueKeyword {
206    /// Genuinely no reaction: the right-click is recorded and consumed, and
207    /// nothing opens. Legal, but a last resort — if a body is clickable, the
208    /// world should answer; prefer a bark.
209    None,
210    /// Carry forward whatever this NPC's dialogue was at its **previous**
211    /// appearance in the quest-DAG ordering.
212    ///
213    /// The point is that carrying dialogue forward is a *conscious, declared
214    /// act*. It is never an implicit default — an omitted `dialogue` is
215    /// `DW0463`, not a carry-forward — and writing `"unchanged"` states the
216    /// intent without re-spelling a root id that then drifts out of sync. It
217    /// resolves transitively (`unchanged` → `unchanged` → a root), and using it
218    /// at an NPC's first appearance is `DW0466`: there is nothing to carry.
219    ///
220    /// Emission is a **no-op** — no root swap is emitted for that NPC at that
221    /// quest — which is what makes the sugar cheap and byte-stable.
222    Unchanged,
223}
224
225/// A bark pool: inconsequential in-character lines, cycled deterministically.
226#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
227#[serde(deny_unknown_fields)]
228pub struct CastBarks {
229    /// The lines, in cycle order. At least one (`DW0464`). Player-visible, so
230    /// they enter the l10n inventory like any narrate text.
231    pub barks: Vec<String>,
232}