lib/vhyl-rpg-resilience.t

Reference index ยท View on GitHub

   1  /* Optional injury, effort and recovery for authored adventures.
   2   * Include after vhyl-rpg.t. Copyright (c) 2026 John Cunningham. MIT License. */
   3  
   4  modify RpgProfile
   5      resilience = nil
   6      blockedReason(c)
   7      {
   8          local reason = inherited(c);
   9          if (reason != nil || self.resilience == nil) return reason;
  10          local body = self.resilience;
  11          if (body.dead) return 'rpg.dead';
  12          if (body.unconscious) return 'rpg.unconscious';
  13          if (body.stunned) return 'rpg.stunned';
  14          if (c.approach.requiresExertion && self.maxEnergy > 0 && self.energy == 0)
  15              return 'rpg.exhausted';
  16          return nil;
  17      }
  18      modifiersFor(c)
  19      {
  20          local values = inherited(c);
  21          if (self.resilience == nil) return values;
  22          local attribute = c.skill != nil ? c.skill.attribute
  23              : c.isOpposition ? c.approach.oppositionAttribute : c.approach.attribute;
  24          if ((attribute == rpgDX || attribute == rpgIQ) && self.resilience.shock > 0)
  25              values = values + ['condition', 'rpg.modifier.shock', -self.resilience.shock];
  26          if (attribute == rpgST && self.maxEnergy > 0 && self.energy * 3 < self.maxEnergy)
  27              values = values + ['condition', 'rpg.modifier.exhaustion',
  28                  (self.attributeScore(rpgST) + 1) / 2 - self.attributeScore(rpgST)];
  29          return values;
  30      }
  31      afterAttempt(c, result)
  32      {
  33          inherited(c, result);
  34          if (self.resilience != nil) self.resilience.endAction();
  35          return nil;
  36      }
  37  ;
  38  
  39  modify RpgApproach
  40      requiresExertion = true
  41  ;
  42  
  43  /** Authored persistent body, bound one-to-one to a profile. Injury is a deficit
  44   * from current capacity. Defeat at zero HP is deterministic; lethal outcomes
  45   * require an explicit author opt-in. No parser-turn death or healing timers. */
  46  class RpgBody: object
  47      profile = nil
  48      injury = 0
  49      shock = 0
  50      stunned = nil
  51      unconscious = nil
  52      dead = nil
  53      lethal = nil
  54      aidRemaining = 0
  55      restSeconds = 0
  56      healingSeconds = 0
  57      wakingSeconds = 0
  58      hp() { return self.profile.maxHP - self.injury; }
  59      mobilityDivisor()
  60      {
  61          return self.hp() * 3 < self.profile.maxHP
  62              || self.profile.maxEnergy > 0 && self.profile.energy * 3 < self.profile.maxEnergy ? 2 : 1;
  63      }
  64      condition()
  65      {
  66          if (self.dead) return 'dead';
  67          if (self.unconscious) return 'unconscious';
  68          if (self.stunned) return 'stunned';
  69          if (self.hp() * 3 < self.profile.maxHP) return 'badly-hurt';
  70          if (self.profile.maxEnergy > 0 && self.profile.energy * 3 < self.profile.maxEnergy)
  71              return 'exhausted';
  72          return self.injury > 0 ? 'wounded' : 'well';
  73      }
  74      endAction()
  75      {
  76          self.shock = 0; self.restSeconds = 0; self.healingSeconds = 0;
  77          self.wakingSeconds = 0;
  78          return nil;
  79      }
  80  ;
  81  
  82  /** Temporary injury/recovery result. Trusted code chooses whether to publish it. */
  83  class RpgCareResult: object
  84      status = 'blocked'
  85      reason = nil
  86      injury = 0
  87      healed = 0
  88      energyRestored = 0
  89      seconds = 0
  90      check = nil
  91  ;
  92  
  93  /** Original bounded care procedure: includes bandaging, then one skill check.
  94   * One charge and the declared duration buy one attempt. Healing is capped by
  95   * untreated injury; new minor wounds cannot reopen old treatment budgets. */
  96  class RpgTreatment: object
  97      skill = nil
  98      modifier = 0
  99      seconds = 660
 100      charges = 1
 101      healingBonus = 0
 102      available(healer, patient) { return nil; }
 103  ;
 104  
 105  rpgBody(profile)
 106  {
 107      if (profile == nil || !profile.ofKind(RpgProfile) || profile.resilience == nil
 108          || !profile.resilience.ofKind(RpgBody) || profile.resilience.profile != profile)
 109          throw new RpgError();
 110      return profile.resilience;
 111  }
 112  
 113  /** Pure armor and wounding arithmetic. Positive penetration always wounds at
 114   * least one HP. Ratios are explicit authored numbers, not weapon taxonomies. */
 115  rpgInjuryFor(damage, armor, numerator, denominator)
 116  {
 117      rpgInteger(damage, 0, 10000); rpgInteger(armor, 0, 10000);
 118      rpgInteger(numerator, 1, 10); rpgInteger(denominator, 1, 10);
 119      local penetration = damage - armor;
 120      if (penetration <= 0) return 0;
 121      local injury = penetration * numerator / denominator;
 122      return injury < 1 ? 1 : injury;
 123  }
 124  
 125  /** Commit injury after armor. Major wounds make one health check; zero HP
 126   * incapacitates. A lethal body dies at minus five times its maximum HP. */
 127  rpgWound(profile, damage, armor, numerator, denominator)
 128  {
 129      local body = rpgBody(profile);
 130      local amount = rpgInjuryFor(damage, armor, numerator, denominator);
 131      local value = new RpgCareResult();
 132      if (body.dead) { value.reason = 'rpg.dead'; return value; }
 133      value.status = 'resolved';
 134      if (amount == 0) return value;
 135      local capacity = profile.maxHP;
 136      local room = capacity * 6 - body.injury;
 137      if (amount > room) amount = room;
 138      if (amount == 0) return value;
 139      body.injury += amount; body.aidRemaining += amount;
 140      local shock = body.shock + amount;
 141      body.endAction();
 142      body.shock = shock > 4 ? 4 : shock;
 143      value.injury = amount;
 144      if (amount * 2 > capacity && !body.unconscious)
 145      {
 146          local health = profile.attributeScore(rpgHT); local roll = rpgRandom.roll();
 147          value.check = rpgCheck(health, roll);
 148          if (!value.check.achieved)
 149          {
 150              body.stunned = true;
 151              if (health - roll <= -5 || value.check.critical == 'failure')
 152                  body.unconscious = true;
 153          }
 154      }
 155      if (body.hp() <= 0) body.unconscious = true;
 156      if (body.lethal && body.hp() <= -5 * capacity) body.dead = true;
 157      return value;
 158  }
 159  
 160  /** Treat only untreated injury. Ordinary failure still supplies one HP of
 161   * bandaging; critical failure adds two HP instead. Never grants a free retry. */
 162  rpgTreat(healer, patient, treatment)
 163  {
 164      local body = rpgBody(patient);
 165      if (healer == nil || !healer.ofKind(RpgProfile)
 166          || treatment == nil || !treatment.ofKind(RpgTreatment)) throw new RpgError();
 167      local value = new RpgCareResult();
 168      if (body.dead) { value.reason = 'rpg.dead'; return value; }
 169      if (healer.resilience != nil && (healer.resilience.dead || healer.resilience.unconscious
 170          || healer.resilience.stunned)) { value.reason = 'rpg.unable-to-treat'; return value; }
 171      if (body.injury == 0) { value.status = 'complete'; return value; }
 172      if (body.aidRemaining == 0) { value.reason = 'rpg.already-treated'; return value; }
 173      value.reason = treatment.available(healer, patient);
 174      if (value.reason != nil) return value;
 175      if (treatment.charges <= 0) { value.reason = 'rpg.no-medical-supplies'; return value; }
 176      local method = new RpgApproach(); method.skill = treatment.skill;
 177      method.modifier = treatment.modifier; method.requiresExertion = nil;
 178      local score = rpgCalculate(rpgContext(healer, method, nil)).effectiveScore;
 179      if (score == nil) { value.reason = 'training-required'; return value; }
 180      if (score < 3) { value.reason = 'too-difficult'; return value; }
 181      value.status = 'resolved'; value.seconds = treatment.seconds;
 182      value.check = rpgCheck(score, rpgRandom.roll());
 183      treatment.charges -= 1; rpgSpendTime(treatment.seconds);
 184      if (healer.resilience != nil) healer.resilience.endAction();
 185      if (value.check.critical == 'failure')
 186      {
 187          local injury = rpgWound(patient, 2, 0, 1, 1);
 188          value.injury = injury.injury;
 189      }
 190      else
 191      {
 192          local amount = 1;
 193          if (value.check.achieved)
 194              amount = value.check.critical == 'success' ? 6 : rpgRandom.die();
 195          amount += value.check.achieved ? treatment.healingBonus : 0;
 196          if (amount < 1) amount = 1;
 197          if (amount > body.aidRemaining) amount = body.aidRemaining;
 198          if (amount > body.injury) amount = body.injury;
 199          body.injury -= amount; value.healed = amount;
 200          if (body.hp() > 0 && (patient.maxEnergy == 0 || patient.energy > 0)) body.unconscious = nil;
 201      }
 202      body.aidRemaining = 0;
 203      return value;
 204  }
 205  
 206  /** Explicit uninterrupted quiet rest, charged in seconds. One energy per ten
 207   * minutes; one health check per full day of rest for one HP. No WAIT hook. */
 208  rpgRest(profile, seconds)
 209  {
 210      local body = rpgBody(profile); rpgInteger(seconds, 1, 86400);
 211      local value = new RpgCareResult();
 212      if (body.dead) { value.reason = 'rpg.dead'; return value; }
 213      value.status = 'resolved'; value.seconds = seconds; rpgSpendTime(seconds);
 214      body.shock = 0;
 215      local needed = profile.maxEnergy - profile.energy;
 216      local effortTime = body.restSeconds + seconds;
 217      local restored = effortTime / 600;
 218      if (restored > needed) restored = needed;
 219      profile.energy += restored; value.energyRestored = restored;
 220      body.restSeconds = profile.energy < profile.maxEnergy ? effortTime % 600 : 0;
 221      if (body.injury > 0)
 222      {
 223          body.healingSeconds += seconds;
 224          if (body.healingSeconds >= 86400)
 225          {
 226              body.healingSeconds -= 86400;
 227              value.check = rpgCheck(profile.attributeScore(rpgHT), rpgRandom.roll());
 228              if (value.check.achieved)
 229              {
 230                  body.injury -= 1; value.healed = 1;
 231                  if (body.aidRemaining > body.injury) body.aidRemaining = body.injury;
 232              }
 233          }
 234      }
 235      else body.healingSeconds = 0;
 236      if (body.hp() > 0 && (profile.maxEnergy == 0 || profile.energy > 0))
 237      {
 238          body.wakingSeconds += seconds;
 239          if (body.wakingSeconds >= 900)
 240          { body.wakingSeconds = 900; body.unconscious = nil; body.stunned = nil; }
 241      }
 242      return value;
 243  }
 244  
 245  /** One committed recovery maneuver. A failed health check leaves stun in place. */
 246  rpgRecoverStun(profile)
 247  {
 248      local body = rpgBody(profile); local value = new RpgCareResult();
 249      if (body.dead || body.unconscious) { value.reason = 'rpg.unconscious'; return value; }
 250      if (!body.stunned) { value.status = 'complete'; return value; }
 251      value.status = 'resolved'; value.seconds = 1; rpgSpendTime(1);
 252      value.check = rpgCheck(profile.attributeScore(rpgHT), rpgRandom.roll());
 253      body.endAction();
 254      if (value.check.achieved) body.stunned = nil;
 255      return value;
 256  }
 257  
 258  /** Explicit player-visible condition summary. Calling this is a disclosure
 259   * decision; hidden NPC bodies must not be published automatically. */
 260  rpgPublishBody(profile)
 261  {
 262      local body = rpgBody(profile);
 263      event('rpg.body'); eventValue(1); eventValue(profile.actor);
 264      eventValue(body.condition()); eventValue(body.hp()); eventValue(profile.maxHP);
 265      eventValue(profile.energy); eventValue(profile.maxEnergy); eventValue(body.mobilityDivisor());
 266      return nil;
 267  }
 268  
 269  rpgResilienceValidation: InitObject
 270      execute()
 271      {
 272          for (local body = firstObj(RpgBody); body != nil; body = nextObj(body, RpgBody))
 273          {
 274              if (rpgBody(body.profile) != body) throw new RpgError();
 275              rpgInteger(body.injury, 0, body.profile.maxHP * 6);
 276              rpgInteger(body.aidRemaining, 0, body.injury);
 277              rpgInteger(body.shock, 0, 4);
 278              rpgInteger(body.restSeconds, 0, 599); rpgInteger(body.healingSeconds, 0, 86399);
 279              rpgInteger(body.wakingSeconds, 0, 900);
 280              if (body.hp() <= 0 || body.dead) body.unconscious = true;
 281              if (body.lethal && body.hp() <= -5 * body.profile.maxHP) body.dead = true;
 282          }
 283          for (local p = firstObj(RpgProfile); p != nil; p = nextObj(p, RpgProfile))
 284              if (p.resilience != nil) rpgBody(p);
 285          for (local care = firstObj(RpgTreatment); care != nil; care = nextObj(care, RpgTreatment))
 286          {
 287              if (care.skill == nil || !care.skill.ofKind(RpgSkill)) throw new RpgError();
 288              rpgInteger(care.modifier, -500, 500); rpgInteger(care.seconds, 60, 86400);
 289              rpgInteger(care.charges, 0, 10000); rpgInteger(care.healingBonus, -5, 10);
 290          }
 291          return nil;
 292      }
 293  ;