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  ;