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}