vhyl-dialogue.t
Back to reference
1 /* Optional host-driven conversations. Include after vhyl.t.
2 * Copyright (c) 2026 John Cunningham. MIT License; see LICENSE. */
3
4 class DialogueError: object;
5
6 relation dialogueActive(controller: Entity, conversation: Entity) one_to_one;
7 relation dialogueAsker(controller: Entity, asker: Entity) one_to_one;
8 relation dialogueUsed(asker: Entity, choice: Entity) many_to_many;
9 /** @api author
10 * Binds a private speaker to its owner; voiceOwner is the inverse. */
11 relation dialogueVoice(owner: Entity, voice: Entity) one_to_many reverse voiceOwner;
12
13 /* Not an Actor or Thing: never a physical or social target. */
14 /** @api author
15 * A private conversational voice bound to its asker through voiceOwner. */
16 class InternalSpeaker: object
17 knowsAbout(subject)
18 {
19 local owner = voiceOwner.get(self);
20 return owner != nil && owner.knowsAbout(subject);
21 }
22 ;
23
24 /** @api author
25 * The asker and active conversation passed to choice hooks. */
26 class DialogueContext: object
27 asker = nil
28 conversation = nil
29 ;
30
31 /** @api author
32 * A host-driven choice conversation with a stable id and target. */
33 class Conversation: object
34 /** @api author
35 * Stable nonempty identifier sent by a host to begin the conversation. */
36 id = nil
37 /** @api author
38 * Actor or private speaker offering the conversation. */
39 target = nil
40 /** @api author
41 * Authored DialogueChoice objects offered to the asker. */
42 choices = []
43 /** @api author
44 * Whether this conversation can currently be opened or continued. */
45 isActive = true
46 /** @api author
47 * Observational eligibility test for this asker. */
48 availableTo(asker) { return true; }
49 /** @api author
50 * Run after an accepted begin request. */
51 opening(c) { return nil; }
52 /** @api author
53 * Run when the goodbye choice closes this conversation. */
54 closing(c) { return nil; }
55 /* Range is policy; a radio conversation may override this method. */
56 /** @api author
57 * Decide whether the active conversation remains available to an asker. */
58 canContinue(asker)
59 {
60 if (target == nil || !isActive || !availableTo(asker)) return nil;
61 if (target.ofKind(InternalSpeaker)) return voiceOwner.get(target) == asker;
62 if (!target.isActor || !inScope(target)) return nil;
63 return target.curState == nil || !target.curState.ofKind(HermitActorState);
64 }
65 ;
66
67 /** @api author
68 * A selectable conversation option with stable identity and availability rules. */
69 class DialogueChoice: object
70 /** @api author
71 * Stable choice identifier sent by the host on selection. */
72 id = nil
73 /** @api author
74 * Message id used to display the choice label. */
75 labelId = nil
76 /** @api author
77 * Subject that the asker must know before this choice appears. */
78 subject = nil
79 /** @api author
80 * Fact tags required in the asker's knowledge. */
81 requiresKnowledge = []
82 /** @api author
83 * Consume this choice separately for each asker after selection. */
84 once = nil
85 /** @api author
86 * Whether this choice is currently eligible. */
87 isActive = true
88 /* A private option is available only to this voice's bound character. */
89 /** @api author
90 * Private speaker whose owner alone may see this choice. */
91 voice = nil
92 /** @api author
93 * Observational condition evaluated while producing choices. */
94 available(c) { return true; }
95 /** @api author
96 * Handle an accepted selection and any resulting world mutation. */
97 selected(c) { return nil; }
98 eligible(c)
99 {
100 if (!isActive || (once && dialogueUsed.contains(c.asker, self))) return nil;
101 if (voice != nil && (!voice.ofKind(InternalSpeaker) || voiceOwner.get(voice) != c.asker)) return nil;
102 if (subject != nil && !c.asker.knowsAbout(subject)) return nil;
103 foreach (local fact in requiresKnowledge)
104 if (!c.asker.knowsAbout(fact)) return nil;
105 return available(c);
106 }
107 ;
108
109 /* Explicit opt-in adapter; the entry retains its existing once/used semantics.
110 * Use DialogueChoice.once for per-asker consumption instead. */
111 /** @api author
112 * Explicitly adapts a TopicEntry into a choice conversation. */
113 class TopicDialogueChoice: DialogueChoice
114 /** @api author
115 * TopicEntry adapted by this choice. */
116 entry = nil
117 eligible(c)
118 {
119 if (entry == nil || entry.id == nil) throw new DialogueError();
120 return entry.actor == c.conversation.target
121 && topicAvailableTo(entry, c.asker) && inherited(c);
122 }
123 selected(c)
124 {
125 dialogueSay(entry.actor, entry.id);
126 if (entry.waits) hostAction(0);
127 entry.used = true;
128 return nil;
129 }
130 ;
131
132 /* Keys stay stable; optional literal Message.text is terminal/example fallback.
133 * Fallback text and message activity must be observational, like availability. */
134 dialogueText(key)
135 {
136 local m = messageFor(key);
137 return m == nil ? nil : m.text;
138 }
139
140 dialogueContext()
141 {
142 local c = new DialogueContext();
143 c.asker = dialogueAsker.get(dialogueUI);
144 c.conversation = dialogueActive.get(dialogueUI);
145 return c;
146 }
147
148 dialogueSay(speaker, key)
149 {
150 local c = dialogueContext();
151 if (c.conversation == nil) return nil;
152 local privateLine = speaker.ofKind(InternalSpeaker);
153 if (privateLine && voiceOwner.get(speaker) != c.asker)
154 throw new DialogueError();
155 event('dialogue.utterance');
156 eventValue(c.conversation.id);
157 eventValue(speaker);
158 eventValue(key);
159 eventValue(dialogueText(key));
160 eventValue(privateLine);
161 eventValue(c.asker);
162 eventValue(privateLine ? nil : speaker.name);
163 local text = dialogueText(key);
164 if (text != nil)"<<text>>\n";
165 return nil;
166 }
167
168 dialogueEnd(reason)
169 {
170 local conv = dialogueActive.get(dialogueUI);
171 if (conv == nil) return nil;
172 event('dialogue.end');
173 eventValue(conv.id);
174 eventValue(reason);
175 dialogueActive.unset(dialogueUI, conv);
176 local asker = dialogueAsker.get(dialogueUI);
177 if (asker != nil) dialogueAsker.unset(dialogueUI, asker);
178 return nil;
179 }
180
181 /** @api author
182 * Standard choice that closes a conversation. */
183 class GoodbyeChoice: DialogueChoice
184 id ='goodbye'
185 labelId ='dialogue.goodbye'
186 selected(c) { c.conversation.closing(c); return dialogueEnd('goodbye'); }
187 ;
188
189 /* The integer codes are transport verbs, not speech-act categories. */
190 vDialogueBegin: Verb code = 80 action = dialogueTurn label ='begin-conversation';
191 vDialogueSelect: Verb code = 81 action = dialogueTurn label ='select-dialogue-choice';
192 vDialogueRefresh: Verb code = 82 action = dialogueTurn label ='refresh-dialogue';
193 dialogueTurn: Action;
194
195 modify dialogueUI
196 accepts(verb) { return verb == 80 || verb == 81 || verb == 82; }
197 act(verb, args)
198 {
199 local n = args == nil ? 0 : args.length();
200 if (verb == 82) return publish();
201 if (gameOver.done) return refuse('game-ended');
202 if (verb == 80)
203 {
204 if (n != 1 || dataType(args[1]) != TypeSString) return refuse('invalid');
205 if (dialogueActive.get(self) != nil) return refuse('already-active');
206 local found = nil;
207 for (local conv = firstObj(Conversation); conv != nil; conv = nextObj(conv, Conversation))
208 {
209 if (conv.id == args[1])
210 {
211 if (found != nil) throw new DialogueError();
212 found = conv;
213 }
214 }
215 if (found == nil || !found.canContinue(player)) return refuse('unavailable');
216 dialogueActive.set(self, found);
217 dialogueAsker.set(self, player);
218 found.opening(dialogueContext());
219 return finishExchange();
220 }
221 /* Exact ID and token, never a label or list index. */
222 if (n != 3 || dataType(args[1]) != TypeSString
223 || dataType(args[2]) != TypeInt || dataType(args[3]) != TypeSString)
224 return refuse('invalid');
225 local c = dialogueContext();
226 if (c.conversation == nil || c.conversation.id != args[1]
227 || c.asker != player || args[2] <= 0 || !snapshotToken(args[2]))
228 return refuse('stale');
229 if (!c.conversation.canContinue(c.asker)) return refuse('unavailable');
230 local selected = nil;
231 foreach (local choice in c.conversation.choices)
232 {
233 if (choice.id == args[3] && choice.eligible(c))
234 {
235 if (selected != nil) throw new DialogueError();
236 selected = choice;
237 }
238 }
239 if (selected == nil) return refuse('unavailable');
240 if (!c.conversation.target.ofKind(InternalSpeaker)) c.conversation.target.talkedThisTurn = true;
241 if (selected.once) dialogueUsed.set(c.asker, selected);
242 selected.selected(c);
243 return finishExchange();
244 }
245 finishExchange()
246 {
247 local conv = dialogueActive.get(self);
248 if (conv != nil && !conv.target.ofKind(InternalSpeaker)) conv.target.talkedThisTurn = true;
249 return finishCommand(new Command(), dialogueTurn);
250 }
251 afterTurn()
252 {
253 local c = dialogueContext();
254 if (c.conversation != nil)
255 {
256 if (gameOver.done || c.asker != player || !c.conversation.canContinue(c.asker))
257 dialogueEnd(gameOver.done ?'game-ended' :'unavailable');
258 else publish();
259 }
260 return nil;
261 }
262 refuse(reason)
263 {
264 event('dialogue.refused'); eventValue(reason);
265 return publish();
266 }
267 publish()
268 {
269 local c = dialogueContext();
270 /* Refresh is observational: an invalid durable interaction is closed
271 * by the next real turn, but the host immediately receives an ending. */
272 if (c.conversation == nil || c.asker != player || gameOver.done
273 || !c.conversation.canContinue(c.asker))
274 {
275 event('dialogue.end');
276 eventValue(c.conversation == nil ? nil : c.conversation.id);
277 eventValue('unavailable');
278 return nil;
279 }
280 local eligible = new Vector(8);
281 local ids = new Vector(8);
282 foreach (local choice in c.conversation.choices)
283 {
284 if (dataType(choice.id) != TypeSString || choice.id ==''
285 || dataType(choice.labelId) != TypeSString || choice.labelId ==''
286 || ids.indexOf(choice.id) != nil)
287 throw new DialogueError();
288 ids.append(choice.id);
289 if (choice.eligible(c)) {
290 if (eligible.length() == 64) throw new DialogueError();
291 eligible.append(choice);
292 }
293 }
294 if (eligible.length() > 64) throw new DialogueError();
295 event('dialogue.choices');
296 eventValue(c.conversation.id);
297 eventValue(snapshotToken(0));
298 eventValue(c.asker);
299 eventValue(c.conversation.target);
300 eventValue(eligible.length());
301 foreach (local choice in eligible)
302 {
303 eventValue(choice.id);
304 eventValue(choice.labelId);
305 eventValue(dialogueText(choice.labelId));
306 eventValue(choice.voice != nil);
307 }
308 return nil;
309 }
310 ;