lib/vhyl-rpg-expanded-fright.t

Reference index ยท View on GitHub

   1  /* Fright Checks. Include after the expanded foundation and expanded traits.
   2   * Source: full-2008, B360-B361 (procedure, modifiers, results); trait effects
   3   * B127 (Combat Paralysis), B129 (Cowardice), B135 (Fearfulness), B162 (Xenophilia).
   4   *
   5   * Dice are drawn in a fixed order: the check, the results roll, then each row's
   6   * own dice in the order its effects are listed in docs/expanded/fright.md.
   7   * Effects that need an author decision (which quirk, which phobia, which
   8   * physical change) are recorded as pending acquisitions, never invented. */
   9  
  10  /** Authored facts about one frightening encounter. All fields are optional. */
  11  class RpgFrightSituation: object
  12      threat = nil
  13      monster = 0
  14      count = 1
  15      body = nil
  16      mutilation = 1
  17      dependent = nil
  18      inCombat = nil
  19      touched = nil
  20      viewing = 'present'
  21      isolated = nil
  22      dark = nil
  23      alone = nil
  24      experienced = nil
  25      briefing = 0
  26      physicalDanger = nil
  27      charging = nil
  28      swornFoe = nil
  29      strange = nil
  30      modifier = 0
  31  ;
  32  
  33  /** A mental or physical change the author must choose; points are the required total. */
  34  class RpgFrightAcquisition: object
  35      kind = 'quirk'
  36      points = -1
  37      row = nil
  38  ;
  39  
  40  /** A timed fear condition. Only one is active per character; a new one replaces it. */
  41  class RpgFrightEpisode: object
  42      kind = 'stun'
  43      fixed = 0
  44      interval = 1
  45      clock = 0
  46      recovery = 'automatic'
  47      score = nil
  48      extendSeconds = 0
  49      elapsed = 0
  50      days = 0
  51      medicalCare = nil
  52      penaltySeconds = 0
  53  ;
  54  
  55  /** Receipt for one Fright Check or one applied results row. */
  56  class RpgFrightResult: object
  57      status = 'blocked'
  58      reason = nil
  59      check = nil
  60      score = nil
  61      modifier = 0
  62      tableRoll = nil
  63      total = nil
  64      row = nil
  65      episode = nil
  66      acquisitions = []
  67      fatigue = 0
  68      injury = 0
  69      panicRoll = nil
  70  ;
  71  
  72  modify RpgLiteProfile
  73      frightEpisode = nil
  74      frightPending = []
  75      frightExposures = []
  76      panicked = nil
  77      retching = nil
  78      conditionTime(seconds)
  79      {
  80          /* A penalty that begins as an episode ends only counts the seconds after it. */
  81          local lingering = self.lingeringSeconds > 0;
  82          local left = self.frightEpisode != nil ? rpgFrightAdvance(self, seconds) : seconds;
  83          if (self.lingeringSeconds > 0)
  84          {
  85              self.lingeringSeconds -= lingering ? seconds : left;
  86              if (self.lingeringSeconds <= 0) { self.lingeringSeconds = 0; self.lingeringPenalty = 0; }
  87          }
  88          return nil;
  89      }
  90  ;
  91  
  92  /* Holds Lite's fixed-fear timer while an episode owns the character's recovery. */
  93  rpgFrightHold: object seconds = 2000000000;
  94  
  95  /** Penalty for a horde: -1 at 5 monsters, -2 at 10, -3 at 20, -4 at 50, -5 at 100+. */
  96  rpgFrightHordePenalty(count)
  97  {
  98      rpgInteger(count, 1, 1000000);
  99      return count >= 100 ? -5 : count >= 50 ? -4 : count >= 20 ? -3 : count >= 10 ? -2 : count >= 5 ? -1 : 0;
 100  }
 101  
 102  /** Cowardice and Xenophilia scale with self-control: 4, 3, 2 or 1 at 6, 9, 12 or 15. */
 103  rpgFrightControlStep(trait)
 104  {
 105      local rating = trait.ofKind(RpgExpandedDisadvantage) && trait.selfControlRating != nil
 106          ? trait.selfControlRating : 12;
 107      return rating <= 6 ? 4 : rating <= 9 ? 3 : rating <= 12 ? 2 : 1;
 108  }
 109  
 110  /** Earlier exposures to the same threat within the last 24 game hours. Pure. */
 111  rpgFrightExposures(profile, threat)
 112  {
 113      if (threat == nil) return 0;
 114      local count = 0;
 115      foreach (local entry in profile.frightExposures)
 116          if (entry[1] == threat && rpgLedger.seconds - entry[2] < 86400) count += 1;
 117      return count;
 118  }
 119  
 120  /** Situation and conditional trait modifiers. Fearlessness and Combat Reflexes
 121   * are applied by the shared Lite score for kind 'fright'. Pure. */
 122  rpgFrightModifier(profile, situation)
 123  {
 124      local s = situation;
 125      rpgInteger(s.monster, -10, 0); rpgInteger(s.briefing, 0, 3); rpgInteger(s.mutilation, 1, 3);
 126      rpgInteger(s.modifier, -100, 100);
 127      local amount = s.modifier + s.monster + rpgFrightHordePenalty(s.count);
 128      if (s.body == 'prepared') amount += 6;
 129      else if (s.body == 'peaceful') amount += 2;
 130      else if (s.body == 'mutilated') amount -= s.mutilation;
 131      else if (s.body != nil && s.body != 'violent') rpgExpandedReject('fright-body');
 132      if (s.dependent)
 133      {
 134          if (s.body == nil) rpgExpandedReject('fright-dependent-without-body');
 135          amount -= 6;
 136      }
 137      if (s.inCombat) amount += 5;
 138      if (s.touched) amount -= 1;
 139      if (s.viewing == 'distant') amount += 1;
 140      else if (s.viewing == 'remote') amount += 3;
 141      else if (s.viewing != 'present') rpgExpandedReject('fright-viewing');
 142      if (s.isolated) amount -= 1;
 143      if (s.dark) amount -= 1;
 144      if (s.alone) amount -= 2;
 145      if (s.experienced) amount += 1;
 146      amount += s.briefing + rpgFrightExposures(profile, s.threat);
 147      amount -= profile.level('fearfulness') + 2 * profile.level('combat-paralysis');
 148      if (s.physicalDanger && profile.level('cowardice') > 0)
 149          amount -= rpgFrightControlStep(profile.trait('cowardice'));
 150      if (s.charging && profile.level('daredevil') > 0) amount += 1;
 151      if (s.swornFoe && profile.level('higher-purpose') > 0) amount += 1;
 152      if (s.strange && profile.level('xenophilia') > 0)
 153          amount += rpgFrightControlStep(profile.trait('xenophilia'));
 154      return amount;
 155  }
 156  
 157  /** Modified Will before the Rule of 14. Pure. */
 158  rpgFrightModifiedWill(profile, situation)
 159  {
 160      local context = new RpgLiteContext(); context.attribute = rpgWill; context.kind = 'fright';
 161      context.modifier = rpgFrightModifier(profile, situation);
 162      return rpgLiteScore(profile, context);
 163  }
 164  
 165  /** Fright Check target: modified Will, reduced to 13 (the Rule of 14). Pure. */
 166  rpgFrightScore(profile, situation)
 167  {
 168      local score = rpgFrightModifiedWill(profile, situation);
 169      return score > 13 ? 13 : score;
 170  }
 171  
 172  /** Roll 3d against a score. Below 3 the roll still sets a margin; 3 or 4 always succeeds. */
 173  rpgFrightRoll(profile, score)
 174  {
 175      local roll = rpgRandom.roll();
 176      local result;
 177      if (score >= 3) result = rpgCheck(score, roll);
 178      else
 179      {
 180          result = new RpgResult(); result.status = 'resolved'; result.score = score;
 181          result.roll = roll; result.margin = score - roll; result.achieved = roll <= 4;
 182          result.critical = roll <= 4 ? 'success' : roll == 18 || roll == 17 || roll - score >= 10 ? 'failure' : nil;
 183          result.outcome = result.achieved ? 'success' : 'setback';
 184      }
 185      profile.lastCheck = result; profile.lastCheckDraw = rpgRandom.draws;
 186      return result;
 187  }
 188  
 189  /** Whether a fear condition currently prevents purposeful action. */
 190  rpgFrightIncapacitated(profile)
 191  {
 192      return profile.frightEpisode != nil;
 193  }
 194  
 195  /** Make a Fright Check and apply any failure. Call from Action.exec. */
 196  rpgFrightCheck(profile, situation)
 197  {
 198      rpgExpandedValidate(profile);
 199      local result = new RpgFrightResult();
 200      if (profile.dead || profile.unconscious) { result.reason = 'incapacitated'; return result; }
 201      if (profile.level('unfazeable') > 0) { result.status = 'exempt'; return result; }
 202      local modified = rpgFrightModifiedWill(profile, situation);
 203      result.modifier = rpgFrightModifier(profile, situation);
 204      result.score = modified > 13 ? 13 : modified;
 205      if (situation.threat != nil)
 206      {
 207          local kept = [];
 208          foreach (local entry in profile.frightExposures)
 209              if (rpgLedger.seconds - entry[2] < 86400) kept = kept + [entry];
 210          profile.frightExposures = kept + [[situation.threat, rpgLedger.seconds]];
 211      }
 212      result.check = rpgFrightRoll(profile, result.score);
 213      if (result.check.achieved) { result.status = 'resisted'; return result; }
 214      result.tableRoll = rpgRandom.roll();
 215      rpgFrightApplyRow(profile, result, result.tableRoll - result.check.margin, modified);
 216      return result;
 217  }
 218  
 219  /** Apply one results row directly, e.g. for an author-chosen substitute result. */
 220  rpgFrightApply(profile, total, modifiedWill)
 221  {
 222      rpgExpandedValidate(profile);
 223      rpgInteger(total, 4, 1000); rpgInteger(modifiedWill, -1000, 1000);
 224      local result = new RpgFrightResult();
 225      rpgFrightApplyRow(profile, result, total, modifiedWill);
 226      return result;
 227  }
 228  
 229  /* Begin an episode, replacing any current one. */
 230  rpgFrightBegin(profile, result, kind, fixed, recovery, interval, score)
 231  {
 232      rpgFrightEnd(profile);
 233      local episode = new RpgFrightEpisode();
 234      episode.kind = kind; episode.fixed = fixed; episode.recovery = recovery;
 235      episode.interval = interval; episode.score = score;
 236      profile.frightEpisode = episode; profile.fearSeconds = rpgFrightHold.seconds;
 237      if (kind == 'stun') profile.stunned = true;
 238      else if (kind == 'seizure') { profile.stunned = true; profile.posture = 'lying'; }
 239      else if (kind == 'panic' || kind == 'total-panic') profile.panicked = true;
 240      else if (kind == 'retch') profile.retching = true;
 241      else { profile.unconscious = true; profile.posture = 'lying'; }
 242      result.episode = episode;
 243      return episode;
 244  }
 245  
 246  /* Clear the flags an episode set and start any lingering penalty. */
 247  rpgFrightEnd(profile)
 248  {
 249      local episode = profile.frightEpisode;
 250      if (episode == nil) return nil;
 251      profile.frightEpisode = nil; profile.fearSeconds = 0;
 252      if (episode.kind == 'stun' || episode.kind == 'seizure') profile.stunned = nil;
 253      else if (episode.kind == 'panic' || episode.kind == 'total-panic') profile.panicked = nil;
 254      else if (episode.kind == 'retch') profile.retching = nil;
 255      else if (profile.hp() > 0 && profile.fp() > 0) profile.unconscious = nil;
 256      if (episode.penaltySeconds > 0)
 257      {
 258          profile.lingeringPenalty = 2;
 259          if (episode.penaltySeconds > profile.lingeringSeconds) profile.lingeringSeconds = episode.penaltySeconds;
 260      }
 261      return nil;
 262  }
 263  
 264  /* Recovery roll at the end of a fixed period or interval. */
 265  rpgFrightRecovers(profile, episode)
 266  {
 267      if (episode.recovery == 'automatic') return true;
 268      if (episode.recovery == 'will') return rpgFrightRoll(profile, profile.will).achieved;
 269      if (episode.recovery == 'modified-will') return rpgFrightRoll(profile, episode.score).achieved;
 270      return rpgFrightRoll(profile, profile.ht).achieved;
 271  }
 272  
 273  /** Advance the active episode by game seconds; called from rpgLiteHealthTime.
 274   * Returns the seconds left over after the episode ended (0 while it continues). */
 275  rpgFrightAdvance(profile, seconds)
 276  {
 277      rpgInteger(seconds, 0, 86400);
 278      while (seconds > 0 && profile.frightEpisode != nil && !profile.dead)
 279      {
 280          local episode = profile.frightEpisode;
 281          if (episode.kind == 'total-panic') { seconds = 0; break; }
 282          local step = episode.fixed > 0 ? episode.fixed : episode.interval - episode.clock;
 283          if (step > seconds) step = seconds;
 284          if (episode.kind == 'catatonia')
 285          {
 286              local toDay = 86400 - episode.elapsed % 86400;
 287              if (step > toDay) step = toDay;
 288          }
 289          seconds -= step; episode.elapsed += step;
 290          if (episode.kind == 'catatonia' && episode.elapsed % 86400 == 0)
 291          {
 292              episode.days += 1;
 293              if (!episode.medicalCare) rpgLiteWound(profile, episode.days);
 294              if (profile.dead) return 0;
 295          }
 296          if (episode.fixed > 0)
 297          {
 298              episode.fixed -= step;
 299              if (episode.fixed > 0) continue;
 300              if (episode.extendSeconds > 0)
 301              {
 302                  if (rpgFrightRoll(profile, profile.ht).achieved) { rpgFrightFinish(profile, episode); continue; }
 303                  episode.fixed = rpgRandom.die() * episode.extendSeconds; continue;
 304              }
 305              if (episode.recovery == 'automatic') { rpgFrightFinish(profile, episode); continue; }
 306              episode.clock = 0; continue;
 307          }
 308          episode.clock += step;
 309          if (episode.clock < episode.interval) continue;
 310          episode.clock = 0;
 311          if (rpgFrightRecovers(profile, episode)) rpgFrightFinish(profile, episode);
 312      }
 313      if (profile.frightEpisode != nil) { profile.fearSeconds = rpgFrightHold.seconds; return 0; }
 314      return seconds;
 315  }
 316  
 317  /* Finish an episode; catatonia's lingering penalty lasts as many days as it did. */
 318  rpgFrightFinish(profile, episode)
 319  {
 320      if (episode.kind == 'catatonia') episode.penaltySeconds = (episode.elapsed + 86399) / 86400 * 86400;
 321      rpgFrightEnd(profile);
 322      return nil;
 323  }
 324  
 325  /** After an author resolves a total-panic reaction, roll Will to come out of it.
 326   * Returns the next 3d reaction roll, or nil when the panic ends. */
 327  rpgFrightPanicReaction(profile)
 328  {
 329      local episode = profile.frightEpisode;
 330      if (episode == nil || episode.kind != 'total-panic') rpgExpandedReject('fright-no-total-panic');
 331      if (rpgFrightRoll(profile, profile.will).achieved) { rpgFrightEnd(profile); return nil; }
 332      return rpgRandom.roll();
 333  }
 334  
 335  /* Record a pending author choice. */
 336  rpgFrightPending(profile, result, kind, points, row)
 337  {
 338      local item = new RpgFrightAcquisition(); item.kind = kind; item.points = points; item.row = row;
 339      profile.frightPending = profile.frightPending + [item];
 340      result.acquisitions = result.acquisitions + [item];
 341      return item;
 342  }
 343  
 344  /* Faint for 1d minutes, then HT each minute; an immediate HT roll risks 1 HP (row 18). */
 345  rpgFrightFaint(profile, result, row)
 346  {
 347      local minutes = rpgRandom.die();
 348      rpgFrightBegin(profile, result, 'faint', minutes * 60, 'ht', 60, nil);
 349      if (!rpgFrightRoll(profile, profile.ht).achieved) result.injury += rpgLiteWound(profile, 1).injury;
 350      return nil;
 351  }
 352  
 353  /* Coma: 1d hours, then HT; each failure adds 1d hours. Waking leaves -2 for 6 hours. */
 354  rpgFrightComa(profile, result)
 355  {
 356      local episode = rpgFrightBegin(profile, result, 'coma', rpgRandom.die() * 3600, 'ht', 1, nil);
 357      episode.extendSeconds = 3600; episode.penaltySeconds = 21600;
 358      return episode;
 359  }
 360  
 361  /* Lose 1d FP. */
 362  rpgFrightFatigue(profile, result)
 363  {
 364      local damage = rpgLiteFatigue(profile, rpgRandom.die(), 'fright');
 365      result.fatigue += damage.fatigue; result.injury += damage.injury;
 366      return nil;
 367  }
 368  
 369  /* Apply the results row for a total of 3d plus the failure margin. */
 370  rpgFrightApplyRow(profile, result, total, modifiedWill)
 371  {
 372      result.status = 'failed'; result.total = total;
 373      local row = total > 40 ? 40 : total; result.row = row;
 374      if (row <= 5) rpgFrightBegin(profile, result, 'stun', 1, 'automatic', 1, nil);
 375      else if (row <= 7) rpgFrightBegin(profile, result, 'stun', 1, 'will', 1, nil);
 376      else if (row <= 9) rpgFrightBegin(profile, result, 'stun', 1, 'modified-will', 1, modifiedWill);
 377      else if (row == 10) rpgFrightBegin(profile, result, 'stun', rpgRandom.die(), 'modified-will', 1, modifiedWill);
 378      else if (row == 11)
 379          rpgFrightBegin(profile, result, 'stun', rpgRandom.die() + rpgRandom.die(), 'modified-will', 1, modifiedWill);
 380      else if (row == 12)
 381      {
 382          local fixed = 25 - profile.ht;
 383          rpgFrightBegin(profile, result, 'retch', fixed > 0 ? fixed : 0, 'ht', 1, nil);
 384      }
 385      else if (row == 13) rpgFrightPending(profile, result, 'quirk', -1, row);
 386      else if (row <= 15)
 387      {
 388          rpgFrightFatigue(profile, result);
 389          rpgFrightBegin(profile, result, 'stun', rpgRandom.die(), 'modified-will', 1, modifiedWill);
 390      }
 391      else if (row == 16)
 392      {
 393          rpgFrightBegin(profile, result, 'stun', rpgRandom.die(), 'modified-will', 1, modifiedWill);
 394          rpgFrightPending(profile, result, 'quirk', -1, row);
 395      }
 396      else if (row == 17) rpgFrightBegin(profile, result, 'faint', rpgRandom.die() * 60, 'ht', 60, nil);
 397      else if (row == 18) rpgFrightFaint(profile, result, row);
 398      else if (row == 19)
 399      {
 400          rpgFrightBegin(profile, result, 'faint', (rpgRandom.die() + rpgRandom.die()) * 60, 'ht', 60, nil);
 401          result.injury += rpgLiteWound(profile, 1).injury;
 402      }
 403      else if (row == 20)
 404      {
 405          local dice = rpgRandom.die() + rpgRandom.die() + rpgRandom.die() + rpgRandom.die();
 406          rpgFrightBegin(profile, result, 'faint', dice * 60, 'automatic', 1, nil);
 407          rpgFrightFatigue(profile, result);
 408      }
 409      else if (row == 21) rpgFrightBegin(profile, result, 'panic', rpgRandom.die() * 60, 'will', 60, nil);
 410      else if (row == 22) rpgFrightPending(profile, result, 'delusion', -10, row);
 411      else if (row == 23) rpgFrightPending(profile, result, 'mental', -10, row);
 412      else if (row == 24) rpgFrightPending(profile, result, 'physical', -15, row);
 413      else if (row == 25) rpgFrightPending(profile, result, 'worsen-or-mental', -10, row);
 414      else if (row == 26) { rpgFrightFaint(profile, result, row); rpgFrightPending(profile, result, 'delusion', -10, row); }
 415      else if (row == 27) { rpgFrightFaint(profile, result, row); rpgFrightPending(profile, result, 'mental', -10, row); }
 416      else if (row == 28)
 417      {
 418          local episode = rpgFrightBegin(profile, result, 'light-coma', 0, 'ht', 1800, nil);
 419          episode.penaltySeconds = 21600;
 420      }
 421      else if (row == 29) rpgFrightComa(profile, result);
 422      else if (row == 30)
 423      {
 424          local episode = rpgFrightBegin(profile, result, 'catatonia', rpgRandom.die() * 86400, 'ht', 1, nil);
 425          episode.extendSeconds = 86400;
 426      }
 427      else if (row == 31)
 428      {
 429          rpgFrightBegin(profile, result, 'seizure', rpgRandom.die() * 60, 'automatic', 1, nil);
 430          rpgFrightFatigue(profile, result);
 431          local check = rpgFrightRoll(profile, profile.ht);
 432          if (!check.achieved) result.injury += rpgLiteWound(profile, rpgRandom.die()).injury;
 433          if (check.critical == 'failure' && profile.ht > 1) profile.ht -= 1;
 434      }
 435      else if (row == 32)
 436      {
 437          profile.posture = 'lying';
 438          result.injury += rpgLiteWound(profile, rpgRandom.die() + rpgRandom.die()).injury;
 439      }
 440      else if (row == 33)
 441      {
 442          rpgFrightBegin(profile, result, 'total-panic', 0, 'will', 1, nil);
 443          result.panicRoll = rpgRandom.roll();
 444      }
 445      else if (row == 34) rpgFrightPending(profile, result, 'delusion', -15, row);
 446      else if (row == 35) rpgFrightPending(profile, result, 'mental', -15, row);
 447      else if (row == 36) rpgFrightPending(profile, result, 'physical', -20, row);
 448      else if (row == 37) rpgFrightPending(profile, result, 'physical', -30, row);
 449      else if (row == 38) { rpgFrightComa(profile, result); rpgFrightPending(profile, result, 'delusion', -15, row); }
 450      else
 451      {
 452          rpgFrightComa(profile, result); rpgFrightPending(profile, result, 'mental', -15, row);
 453          if (row == 40 && profile.iq > 1) profile.iq -= 1;
 454      }
 455      return result;
 456  }
 457  
 458  /** Resolve a pending acquisition with new traits whose costs total its points.
 459   * For 'worsen-or-mental', pass one owned disadvantage with self-control 9-15 to
 460   * worsen it one step instead. Traits gained this way may exceed the quirk limit. */
 461  rpgFrightAcquire(profile, acquisition, traits)
 462  {
 463      if (!holds(profile.frightPending, acquisition)) rpgExpandedReject('fright-acquisition-not-pending');
 464      if (acquisition.kind == 'worsen-or-mental' && traits.length() == 1 && holds(profile.traits, traits[1]))
 465      {
 466          local trait = traits[1];
 467          if (!trait.ofKind(RpgExpandedDisadvantage) || !holds([9, 12, 15], trait.selfControlRating))
 468              rpgExpandedReject('fright-cannot-worsen');
 469          trait.selfControlRating = trait.selfControlRating == 15 ? 12 : trait.selfControlRating == 12 ? 9 : 6;
 470      }
 471      else
 472      {
 473          local total = 0;
 474          foreach (local trait in traits)
 475          {
 476              if (!trait.ofKind(RpgLiteTrait) || holds(profile.traits, trait)) rpgExpandedReject('fright-acquisition-trait');
 477              total += trait.creationCost();
 478          }
 479          if (total != acquisition.points) rpgExpandedReject('fright-acquisition-points');
 480          foreach (local trait in traits) trait.acquiredInPlay = true;
 481          profile.traits = profile.traits + traits;
 482      }
 483      local remaining = [];
 484      foreach (local item in profile.frightPending) if (item != acquisition) remaining = remaining + [item];
 485      profile.frightPending = remaining;
 486      return true;
 487  }