lib/vhyl-rpg.t

Reference index ยท View on GitHub

   1  /* Optional mundane character and challenge mechanics. Include after vhyl.t.
   2   * Copyright (c) 2026 John Cunningham. MIT License; see LICENSE. */
   3  
   4  enum rpgST, rpgDX, rpgIQ, rpgHT, rpgWill, rpgPerception;
   5  
   6  /** Invalid configuration or an invalid mechanics API argument. */
   7  class RpgError: object;
   8  
   9  /** Optional binding from a vhyl world entity to its authored profile. */
  10  modify Thing
  11      /* Optional authored profile; the resolver also accepts profiles directly. */
  12      rpgProfile = nil
  13  ;
  14  
  15  /** A skill's governing attribute, training difficulty and explicit default.
  16   * difficulty: 0, 1 or 2. defaultPenalty: a nonpositive integer, or nil for
  17   * training only. Do not infer a default from difficulty. */
  18  class RpgSkill: object
  19      labelId = nil
  20      attribute = rpgIQ
  21      difficulty = 1
  22      defaultPenalty = nil
  23      /* Extension point for profiles that support additional difficulty bands. */
  24      maximumDifficulty() { return 2; }
  25  ;
  26  
  27  /** Investment in one skill. Zero reserves an untrained slot for progression;
  28   * positive values buy training. The core never awards or spends points itself. */
  29  class RpgTraining: object
  30      skill = nil
  31      points = 1
  32  ;
  33  
  34  /** An authored benefit or limitation. The modifier hook is observational.
  35   * Use c.skill and c.isOpposition to keep its application appropriately narrow. */
  36  class RpgTrait: object
  37      labelId = nil
  38      modifierFor(c) { return 0; }
  39  ;
  40  
  41  /** Share this model between protagonists and supporting characters.
  42   * skills and traits are authored lists. Optional modules add injury, recovery
  43   * and progression; a core-only profile retains the simple energy pool. */
  44  class RpgProfile: object
  45      actor = nil
  46      st = 10
  47      dx = 10
  48      iq = 10
  49      ht = 10
  50      will = (iq)
  51      perception = (iq)
  52      maxHP = (st)
  53      maxEnergy = (ht)
  54      energy = (maxEnergy)
  55      skills = []
  56      traits = []
  57  
  58      /* Observational extension points. Optional modules supply condition state. */
  59      blockedReason(c) { return nil; }
  60      modifiersFor(c) { return []; }
  61      afterAttempt(c, result) { return nil; }
  62  
  63      attributeScore(which)
  64      {
  65          switch (which)
  66          {
  67          case rpgST: return self.st;
  68          case rpgDX: return self.dx;
  69          case rpgIQ: return self.iq;
  70          case rpgHT: return self.ht;
  71          case rpgWill: return self.will;
  72          case rpgPerception: return self.perception;
  73          }
  74          throw new RpgError();
  75      }
  76  
  77      skillScore(skill)
  78      {
  79          local base = self.attributeScore(skill.attribute);
  80          foreach (local training in self.skills)
  81          {
  82              if (training.skill != skill) continue;
  83              local points = training.points;
  84              if (points == 0) continue;
  85              if (points >= 8) return base + 3 - skill.difficulty + (points - 8) / 4;
  86              if (points >= 4) return base + 2 - skill.difficulty;
  87              if (points >= 2) return base + 1 - skill.difficulty;
  88              return base - skill.difficulty;
  89          }
  90          if (skill.defaultPenalty == nil) return nil;
  91          return (base > 20 ? 20 : base) + skill.defaultPenalty;
  92      }
  93  ;
  94  
  95  /** A shared goal. completed closes every approach for every character.
  96   * advance() permits fresh attempts after a meaningful, authored world change.
  97   * Never call advance merely because a command, preview or refresh occurred. */
  98  class RpgChallenge: object
  99      id = nil
 100      completed = nil
 101      revision = 0
 102      advance() { rpgInteger(self.revision, 0, 2147483646); self.revision += 1; return nil; }
 103  ;
 104  
 105  /** One method of pursuing a goal. Hooks use original game-specific content.
 106   * blockedReason, modifierFor and oppositionModifierFor must be observational.
 107   * apply runs once per fresh resolution, including failures and ties. */
 108  class RpgApproach: object
 109      id = nil
 110      challenge = nil
 111      skill = nil
 112      attribute = rpgIQ
 113      modifier = 0
 114      modifierLabelId = 'rpg.modifier.situation'
 115      contextLabelId = 'rpg.modifier.preparation'
 116      automatic = nil
 117      completesChallenge = true
 118      timeCost = 0
 119      energyCost = 0
 120      /* Ordinary failures within this margin can achieve the goal at a cost.
 121       * Zero disables this policy. It never promotes critical failures or ties. */
 122      costlyMargin = 0
 123      opponent = nil
 124      oppositionSkill = nil
 125      oppositionAttribute = rpgWill
 126      oppositionModifier = 0
 127      oppositionModifierLabelId = 'rpg.modifier.opposition'
 128      oppositionContextLabelId = 'rpg.modifier.opposition-preparation'
 129      blockedReason(c) { return nil; }
 130      modifierFor(c) { return 0; }
 131      oppositionModifierFor(c) { return 0; }
 132      apply(c, result) { return nil; }
 133  ;
 134  
 135  /** Temporary execution context. Do not retain it in persistent properties. */
 136  class RpgContext: object
 137      profile = nil
 138      actor = nil
 139      approach = nil
 140      challenge = nil
 141      skill = nil
 142      isOpposition = nil
 143  ;
 144  
 145  /** Temporary explanation, shared by assessment and execution. Only scalar
 146   * snapshot data is retained by attempts. Modifier triples are category, label,
 147   * amount; after 31 components, remaining adjustments are grouped as other. */
 148  class RpgCalculation: object
 149      abilityLabelId = nil
 150      attributeId = nil
 151      training = 'attribute'
 152      attributeScore = nil
 153      defaultPenalty = nil
 154      baseScore = nil
 155      effectiveScore = nil
 156      modifiers = []
 157      otherModifier = 0
 158      pack()
 159      {
 160          return [self.abilityLabelId, self.attributeId, self.training,
 161              self.attributeScore, self.defaultPenalty, self.baseScore,
 162              self.effectiveScore, self.modifiers];
 163      }
 164  ;
 165  
 166  /** Trusted, observational preparation. Reporting extensions can add odds to
 167   * this temporary value. ready is the only status that permits a fresh roll. */
 168  class RpgAssessment: object
 169      profile = nil
 170      approach = nil
 171      status = 'blocked'
 172      reason = nil
 173      kind = nil
 174      calculation = nil
 175      oppositionCalculation = nil
 176      timeCost = 0
 177      energyCost = 0
 178      costlyMargin = 0
 179      recorded = nil
 180  ;
 181  
 182  /** Temporary result. Scores and dice are private unless the author discloses
 183   * them. status: resolved, reused, blocked or complete. outcome: success,
 184   * costly-success, setback, complication or tie (nil when not resolved). */
 185  class RpgResult: object
 186      status = 'blocked'
 187      reason = nil
 188      profile = nil
 189      approach = nil
 190      outcome = nil
 191      achieved = nil
 192      score = nil
 193      roll = nil
 194      margin = nil
 195      critical = nil
 196      oppositionScore = nil
 197      oppositionRoll = nil
 198      oppositionMargin = nil
 199      oppositionCritical = nil
 200      timeSpent = 0
 201      energySpent = 0
 202      recordedTimeSpent = 0
 203      recordedEnergySpent = 0
 204      kind = nil
 205      calculation = nil
 206      oppositionCalculation = nil
 207      dice = []
 208      oppositionDice = []
 209      rollSucceeded = nil
 210      oppositionSucceeded = nil
 211  ;
 212  
 213  /** Predeclare one slot per permitted (profile, approach) pair. Scalar state is
 214   * journalled without allocating permanent entities during play. The slot is
 215   * also the retry authority shared by parser, structured and dialogue actions. */
 216  class RpgAttempt: object
 217      profile = nil
 218      approach = nil
 219      revision = -1
 220      outcome = nil
 221      achieved = nil
 222      score = nil
 223      roll = nil
 224      margin = nil
 225      critical = nil
 226      oppositionScore = nil
 227      oppositionRoll = nil
 228      oppositionMargin = nil
 229      oppositionCritical = nil
 230      kind = nil
 231      calculationData = nil
 232      oppositionCalculationData = nil
 233      recordedTimeSpent = 0
 234      recordedEnergySpent = 0
 235      dice = []
 236      oppositionDice = []
 237      rollSucceeded = nil
 238      oppositionSucceeded = nil
 239  
 240      remember(result)
 241      {
 242          self.revision = self.approach.challenge.revision;
 243          self.outcome = result.outcome; self.achieved = result.achieved;
 244          self.score = result.score; self.roll = result.roll; self.margin = result.margin;
 245          self.critical = result.critical; self.oppositionScore = result.oppositionScore;
 246          self.oppositionRoll = result.oppositionRoll;
 247          self.oppositionMargin = result.oppositionMargin;
 248          self.oppositionCritical = result.oppositionCritical;
 249          self.kind = result.kind;
 250          self.calculationData = result.calculation == nil ? nil : result.calculation.pack();
 251          self.oppositionCalculationData = result.oppositionCalculation == nil
 252              ? nil : result.oppositionCalculation.pack();
 253          self.dice = result.dice; self.oppositionDice = result.oppositionDice;
 254          self.rollSucceeded = result.rollSucceeded;
 255          self.oppositionSucceeded = result.oppositionSucceeded;
 256          self.recordedTimeSpent = result.recordedTimeSpent;
 257          self.recordedEnergySpent = result.recordedEnergySpent;
 258          return nil;
 259      }
 260      recall(result)
 261      {
 262          if (self.revision < 0) return result;
 263          result.status = 'reused'; result.outcome = self.outcome;
 264          result.achieved = self.achieved; result.score = self.score;
 265          result.roll = self.roll; result.margin = self.margin; result.critical = self.critical;
 266          result.oppositionScore = self.oppositionScore;
 267          result.oppositionRoll = self.oppositionRoll;
 268          result.oppositionMargin = self.oppositionMargin;
 269          result.oppositionCritical = self.oppositionCritical;
 270          result.kind = self.kind;
 271          result.calculation = rpgUnpackCalculation(self.calculationData);
 272          result.oppositionCalculation = rpgUnpackCalculation(self.oppositionCalculationData);
 273          result.dice = self.dice; result.oppositionDice = self.oppositionDice;
 274          result.rollSucceeded = self.rollSucceeded;
 275          result.oppositionSucceeded = self.oppositionSucceeded;
 276          result.recordedTimeSpent = self.recordedTimeSpent;
 277          result.recordedEnergySpent = self.recordedEnergySpent;
 278          return result;
 279      }
 280  ;
 281  
 282  /** Only accounted challenge time, in seconds; not a replacement world clock.
 283   * vhyl continues to schedule one cycle per accepted command. */
 284  rpgLedger: object seconds = 0;
 285  
 286  /** Charge explicit adventure time with a checked persistent counter. */
 287  rpgSpendTime(seconds)
 288  {
 289      rpgInteger(seconds, 0, 86400);
 290      rpgInteger(rpgLedger.seconds, 0, 2147483647 - seconds);
 291      rpgLedger.seconds += seconds; return nil;
 292  }
 293  
 294  /** Deterministic, journalled generator with overflow-safe arithmetic.
 295   * Set a seed from 1 through 2147483646 at game initialisation. */
 296  rpgRandom: object
 297      state = 1
 298      draws = 0
 299      seed(value)
 300      {
 301          rpgInteger(value, 1, 2147483646);
 302          self.state = value; self.draws = 0; return nil;
 303      }
 304      die()
 305      {
 306          local next = 16807 * (self.state % 127773) - 2836 * (self.state / 127773);
 307          if (next <= 0) next += 2147483647;
 308          self.state = next; self.draws += 1;
 309          return self.state % 6 + 1;
 310      }
 311      dice()
 312      {
 313          /* Separate calls: source argument evaluation order is irrelevant. */
 314          local one = self.die(); local two = self.die(); local three = self.die();
 315          return [one, two, three];
 316      }
 317      roll() { return rpgDiceTotal(self.dice()); }
 318  ;
 319  
 320  /** Pure numerical check; no randomness, world writes, output or time costs.
 321   * Scores below three cannot be attempted. critical is success, failure or nil. */
 322  rpgCheck(score, roll)
 323  {
 324      rpgInteger(score, -1000, 1000); rpgInteger(roll, 3, 18);
 325      local result = new RpgResult(); result.score = score;
 326      if (score < 3) { result.reason = 'too-difficult'; return result; }
 327      result.status = 'resolved'; result.roll = roll; result.margin = score - roll;
 328      if (roll == 18 || (roll == 17 && score <= 15) || roll - score >= 10)
 329          result.critical = 'failure';
 330      else if (roll <= 4 || (roll == 5 && score >= 15) || (roll == 6 && score >= 16))
 331          result.critical = 'success';
 332      result.achieved = (roll <= 4 || roll <= score) && roll < 17;
 333      result.rollSucceeded = result.achieved;
 334      result.outcome = result.achieved ? 'success'
 335          : result.critical == 'failure' ? 'complication' : 'setback';
 336      return result;
 337  }
 338  
 339  rpgInteger(value, low, high)
 340  {
 341      if (dataType(value) != TypeInt || value < low || value > high)
 342          throw new RpgError();
 343      return nil;
 344  }
 345  
 346  rpgContext(profile, approach, opposing)
 347  {
 348      local c = new RpgContext(); c.profile = profile; c.actor = profile.actor;
 349      c.approach = approach; c.challenge = approach.challenge;
 350      c.isOpposition = opposing;
 351      c.skill = opposing ? approach.oppositionSkill : approach.skill;
 352      return c;
 353  }
 354  
 355  rpgAttributeId(attribute)
 356  {
 357      switch (attribute)
 358      {
 359      case rpgST: return 'rpg.strength';
 360      case rpgDX: return 'rpg.coordination';
 361      case rpgIQ: return 'rpg.intelligence';
 362      case rpgHT: return 'rpg.health';
 363      case rpgWill: return 'rpg.will';
 364      case rpgPerception: return 'rpg.perception';
 365      }
 366      throw new RpgError();
 367  }
 368  
 369  rpgUnpackCalculation(data)
 370  {
 371      if (data == nil) return nil;
 372      local value = new RpgCalculation();
 373      value.abilityLabelId = data[1]; value.attributeId = data[2];
 374      value.training = data[3]; value.attributeScore = data[4];
 375      value.defaultPenalty = data[5]; value.baseScore = data[6];
 376      value.effectiveScore = data[7]; value.modifiers = data[8];
 377      return value;
 378  }
 379  
 380  rpgAddModifier(calculation, category, label, amount)
 381  {
 382      rpgInteger(amount, -1000, 1000);
 383      if (amount == 0) return nil;
 384      if (calculation.modifiers.length() < 93)
 385          calculation.modifiers = calculation.modifiers + [category, label, amount];
 386      else calculation.otherModifier += amount;
 387      return nil;
 388  }
 389  
 390  rpgCalculate(c)
 391  {
 392      local a = c.approach;
 393      local value = new RpgCalculation();
 394      local attribute = c.skill == nil
 395          ? c.isOpposition ? a.oppositionAttribute : a.attribute : c.skill.attribute;
 396      value.attributeId = rpgAttributeId(attribute);
 397      value.attributeScore = c.profile.attributeScore(attribute);
 398      value.abilityLabelId = c.skill == nil ? value.attributeId : c.skill.labelId;
 399      value.baseScore = c.skill == nil ? value.attributeScore : c.profile.skillScore(c.skill);
 400      if (c.skill != nil)
 401      {
 402          value.training = value.baseScore == nil ? 'unavailable' : 'default';
 403          foreach (local training in c.profile.skills)
 404              if (training.skill == c.skill && training.points > 0) value.training = 'trained';
 405          if (value.training == 'default')
 406          {
 407              value.defaultPenalty = c.skill.defaultPenalty;
 408              if (value.attributeScore > 20) value.attributeScore = 20;
 409          }
 410      }
 411      if (value.baseScore == nil) return value;
 412      local fixed = c.isOpposition ? a.oppositionModifier : a.modifier;
 413      local contextual = c.isOpposition ? a.oppositionModifierFor(c) : a.modifierFor(c);
 414      rpgAddModifier(value, 'situation', c.isOpposition
 415          ? a.oppositionModifierLabelId : a.modifierLabelId, fixed);
 416      rpgAddModifier(value, 'context', c.isOpposition
 417          ? a.oppositionContextLabelId : a.contextLabelId, contextual);
 418      local modifier = fixed + contextual;
 419      foreach (local trait in c.profile.traits)
 420      {
 421          local adjustment = trait.modifierFor(c); modifier += adjustment;
 422          rpgAddModifier(value, 'trait', trait.labelId, adjustment);
 423      }
 424      local extras = c.profile.modifiersFor(c);
 425      if (extras.length() % 3 != 0) throw new RpgError();
 426      for (local i = 1; i <= extras.length(); i += 3)
 427      {
 428          modifier += extras[i + 2];
 429          rpgAddModifier(value, extras[i], extras[i + 1], extras[i + 2]);
 430      }
 431      rpgInteger(modifier, -500, 500);
 432      if (value.otherModifier != 0)
 433          value.modifiers = value.modifiers + ['other', 'rpg.modifier.other', value.otherModifier];
 434      value.effectiveScore = value.baseScore + modifier;
 435      rpgInteger(value.effectiveScore, -1000, 1000);
 436      return value;
 437  }
 438  
 439  rpgScore(c) { return rpgCalculate(c).effectiveScore; }
 440  
 441  rpgDiceTotal(dice) { return dice[1] + dice[2] + dice[3]; }
 442  
 443  /** Pure outcome policy shared by actual attempts and probability enumeration. */
 444  rpgEvaluate(score, roll, oppositionScore, oppositionRoll, costlyMargin)
 445  {
 446      local result = rpgCheck(score, roll);
 447      if (result.status == 'blocked') return result;
 448      if (oppositionScore != nil)
 449      {
 450          local defense = rpgCheck(oppositionScore, oppositionRoll);
 451          if (defense.status == 'blocked')
 452          { result.status = 'blocked'; result.reason = 'opposition-unavailable'; return result; }
 453          result.oppositionScore = defense.score; result.oppositionRoll = defense.roll;
 454          result.oppositionMargin = defense.margin; result.oppositionCritical = defense.critical;
 455          result.oppositionSucceeded = defense.achieved;
 456          local won = result.achieved && !defense.achieved
 457              || result.achieved == defense.achieved && result.margin > defense.margin;
 458          local tied = result.achieved == defense.achieved && result.margin == defense.margin;
 459          result.achieved = won;
 460          result.outcome = won ? 'success' : tied ? 'tie'
 461              : result.critical == 'failure' ? 'complication' : 'setback';
 462      }
 463      if (result.outcome == 'setback' && costlyMargin > 0
 464          && result.margin < 0 && -result.margin <= costlyMargin && oppositionScore == nil)
 465      { result.outcome = 'costly-success'; result.achieved = true; }
 466      return result;
 467  }
 468  
 469  rpgAttemptFor(profile, approach)
 470  {
 471      local found = nil;
 472      for (local slot = firstObj(RpgAttempt); slot != nil; slot = nextObj(slot, RpgAttempt))
 473      {
 474          if (slot.profile != profile || slot.approach != approach) continue;
 475          if (found != nil) throw new RpgError();
 476          found = slot;
 477      }
 478      if (found == nil) throw new RpgError();
 479      return found;
 480  }
 481  
 482  /** Trusted observational preparation shared by assessment and resolution.
 483   * Configuration errors throw. Ordinary impossibility returns blocked without
 484   * rolling, charging or caching it. Prerequisite and modifier hooks must be pure. */
 485  rpgPrepare(profile, approach)
 486  {
 487      if (profile == nil || !profile.ofKind(RpgProfile)
 488          || approach == nil || !approach.ofKind(RpgApproach)) throw new RpgError();
 489      local slot = rpgAttemptFor(profile, approach);
 490      local result = new RpgAssessment(); result.profile = profile; result.approach = approach;
 491      local challenge = approach.challenge;
 492      if (challenge.completed) { result.status = 'complete'; return result; }
 493      local c = rpgContext(profile, approach, nil);
 494      result.reason = profile.blockedReason(c);
 495      if (result.reason != nil) return result;
 496      result.reason = approach.blockedReason(c);
 497      if (result.reason != nil) return result;
 498      if (slot.revision == challenge.revision)
 499      {
 500          result.status = 'settled';
 501          local recorded = new RpgResult(); recorded.profile = profile; recorded.approach = approach;
 502          result.recorded = slot.recall(recorded); result.kind = result.recorded.kind;
 503          result.calculation = result.recorded.calculation;
 504          result.oppositionCalculation = result.recorded.oppositionCalculation;
 505          return result;
 506      }
 507      if (profile.energy < approach.energyCost)
 508      { result.reason = 'insufficient-energy'; return result; }
 509  
 510      result.kind = approach.automatic ? 'automatic' : approach.opponent == nil ? 'check' : 'contest';
 511      if (!approach.automatic)
 512      {
 513          result.calculation = rpgCalculate(c);
 514          local score = result.calculation.effectiveScore;
 515          if (score == nil) { result.reason = 'training-required'; return result; }
 516          if (score < 3) { result.reason = 'too-difficult'; return result; }
 517          if (approach.opponent != nil)
 518          {
 519              local defense = rpgContext(approach.opponent, approach, true);
 520              if (approach.opponent.blockedReason(defense) != nil)
 521              { result.reason = 'opposition-unavailable'; return result; }
 522              result.oppositionCalculation = rpgCalculate(defense);
 523              local opposingScore = result.oppositionCalculation.effectiveScore;
 524              if (opposingScore == nil || opposingScore < 3)
 525              { result.reason = 'opposition-unavailable'; return result; }
 526          }
 527      }
 528      result.status = 'ready'; result.timeCost = approach.timeCost; result.energyCost = approach.energyCost;
 529      result.costlyMargin = approach.costlyMargin;
 530      return result;
 531  }
 532  
 533  /** Commit an attempt using the shared preparation. Call only from a gameplay
 534   * execution/selection hook. Exceptions roll back the enclosing turn. */
 535  rpgResolve(profile, approach)
 536  {
 537      local prepared = rpgPrepare(profile, approach);
 538      if (prepared.recorded != nil) return prepared.recorded;
 539      local result = new RpgResult(); result.profile = profile; result.approach = approach;
 540      result.reason = prepared.reason;
 541      if (prepared.status != 'ready') { result.status = prepared.status; return result; }
 542      if (prepared.kind == 'automatic')
 543      { result.status = 'resolved'; result.outcome = 'success'; result.achieved = true; }
 544      else
 545      {
 546          local dice = rpgRandom.dice(); local oppositionDice = [];
 547          local opposingScore = nil; local opposingRoll = nil;
 548          if (prepared.oppositionCalculation != nil)
 549          {
 550              opposingScore = prepared.oppositionCalculation.effectiveScore;
 551              oppositionDice = rpgRandom.dice(); opposingRoll = rpgDiceTotal(oppositionDice);
 552          }
 553          result = rpgEvaluate(prepared.calculation.effectiveScore, rpgDiceTotal(dice),
 554              opposingScore, opposingRoll, prepared.costlyMargin);
 555          result.dice = dice; result.oppositionDice = oppositionDice;
 556          result.profile = profile; result.approach = approach;
 557      }
 558      result.kind = prepared.kind; result.calculation = prepared.calculation;
 559      result.oppositionCalculation = prepared.oppositionCalculation;
 560      profile.energy -= approach.energyCost;
 561      rpgSpendTime(approach.timeCost);
 562      result.timeSpent = approach.timeCost; result.energySpent = approach.energyCost;
 563      result.recordedTimeSpent = result.timeSpent; result.recordedEnergySpent = result.energySpent;
 564      /* Cache before consequences, preventing a hook from resolving itself twice. */
 565      rpgAttemptFor(profile, approach).remember(result);
 566      if (result.achieved && approach.completesChallenge) approach.challenge.completed = true;
 567      profile.afterAttempt(rpgContext(profile, approach, nil), result);
 568      approach.apply(rpgContext(profile, approach, nil), result);
 569      return result;
 570  }
 571  
 572  /** Explicit, player-safe result envelope. No skill scores, rolls, opponent
 573   * identities, margins or critical flags are published automatically. */
 574  rpgPublish(result)
 575  {
 576      event('rpg.result'); eventValue(1);
 577      eventValue(result.approach.challenge.id); eventValue(result.approach.id);
 578      eventValue(result.profile.actor); eventValue(result.status);
 579      eventValue(result.outcome); eventValue(result.reason);
 580      eventValue(result.achieved); eventValue(result.timeSpent); eventValue(result.energySpent);
 581      return nil;
 582  }
 583  
 584  /** Validate declarations before play. Lists/slots are intentionally explicit:
 585   * misspelled training and duplicate retry slots must not silently change play. */
 586  rpgValidation: InitObject
 587      execute()
 588      {
 589          for (local skill = firstObj(RpgSkill); skill != nil; skill = nextObj(skill, RpgSkill))
 590          {
 591              rpgInteger(skill.difficulty, 0, skill.maximumDifficulty());
 592              rpgValidateAttribute(skill.attribute);
 593              if (skill.defaultPenalty != nil) rpgInteger(skill.defaultPenalty, -100, 0);
 594          }
 595          for (local p = firstObj(RpgProfile); p != nil; p = nextObj(p, RpgProfile))
 596          {
 597              foreach (local attribute in [rpgST, rpgDX, rpgIQ, rpgHT, rpgWill, rpgPerception])
 598                  rpgInteger(p.attributeScore(attribute), 1, 100);
 599              rpgInteger(p.maxHP, 1, 1000); rpgInteger(p.maxEnergy, 0, 1000);
 600              rpgInteger(p.energy, 0, p.maxEnergy);
 601              local seen = [];
 602              foreach (local training in p.skills)
 603              {
 604                  if (!training.ofKind(RpgTraining) || training.skill == nil
 605                      || !training.skill.ofKind(RpgSkill) || holds(seen, training.skill))
 606                      throw new RpgError();
 607                  rpgInteger(training.points, 0, 10000); seen = seen + [training.skill];
 608              }
 609              foreach (local trait in p.traits)
 610                  if (!trait.ofKind(RpgTrait)) throw new RpgError();
 611          }
 612          for (local a = firstObj(RpgApproach); a != nil; a = nextObj(a, RpgApproach))
 613          {
 614              if (a.challenge == nil || !a.challenge.ofKind(RpgChallenge)
 615                  || dataType(a.id) != TypeSString || a.id == ''
 616                  || dataType(a.challenge.id) != TypeSString || a.challenge.id == '')
 617                  throw new RpgError();
 618              rpgInteger(a.timeCost, 0, 86400); rpgInteger(a.energyCost, 0, 1000);
 619              rpgInteger(a.costlyMargin, 0, 100); rpgInteger(a.modifier, -500, 500);
 620              rpgInteger(a.oppositionModifier, -500, 500);
 621              rpgInteger(a.challenge.revision, 0, 2147483646);
 622              rpgValidateAttribute(a.attribute); rpgValidateAttribute(a.oppositionAttribute);
 623              if (a.skill != nil && !a.skill.ofKind(RpgSkill)) throw new RpgError();
 624              if (a.oppositionSkill != nil && !a.oppositionSkill.ofKind(RpgSkill))
 625                  throw new RpgError();
 626              if (a.opponent != nil && (!a.opponent.ofKind(RpgProfile) || a.automatic))
 627                  throw new RpgError();
 628              for (local other = firstObj(RpgApproach); other != nil; other = nextObj(other, RpgApproach))
 629                  if (other != a && other.challenge == a.challenge && other.id == a.id)
 630                      throw new RpgError();
 631          }
 632          for (local challenge = firstObj(RpgChallenge); challenge != nil;
 633               challenge = nextObj(challenge, RpgChallenge))
 634              for (local other = firstObj(RpgChallenge); other != nil;
 635                   other = nextObj(other, RpgChallenge))
 636                  if (other != challenge && other.id == challenge.id) throw new RpgError();
 637          for (local slot = firstObj(RpgAttempt); slot != nil; slot = nextObj(slot, RpgAttempt))
 638          {
 639              if (slot.profile == nil || !slot.profile.ofKind(RpgProfile)
 640                  || slot.approach == nil || !slot.approach.ofKind(RpgApproach))
 641                  throw new RpgError();
 642              rpgAttemptFor(slot.profile, slot.approach);
 643          }
 644          return nil;
 645      }
 646  ;
 647  
 648  rpgValidateAttribute(value)
 649  {
 650      if (!holds([rpgST, rpgDX, rpgIQ, rpgHT, rpgWill, rpgPerception], value))
 651          throw new RpgError();
 652      return nil;
 653  }