lib/vhyl-rpg-lite-characters.t

Reference index · View on GitHub

   1  /** Owned, parameterized advantage or disadvantage; scope identifies authored subjects or specialties. */
   2  class RpgLiteTrait: RpgTrait
   3      id = nil
   4      points = 0
   5      level = 1
   6      skills = []
   7      scope = nil
   8      controlled = nil
   9      active = true
  10      mitigated = nil
  11      nextCheck = 0
  12      /* Gained during play (e.g. from a Fright Check); exempt from the creation quirk limit. */
  13      acquiredInPlay = nil
  14      modifierFor(context) { return 0; }
  15      /* Expanded cost hook; Lite callers retain their existing cost function. */
  16      creationCost() { return rpgLiteTraitCost(self); }
  17  ;
  18  
  19  /** Independent spoken and written comprehension (0–3); native language receives a six-point allowance. */
  20  class RpgLiteLanguage: object
  21      id = nil
  22      spoken = 3
  23      written = 3
  24      native = nil
  25      cost() { return self.spoken + self.written - (self.native ? 6 : 0); }
  26  ;
  27  
  28  /** Catalogue skill with technology level and specialty identity. */
  29  class RpgLiteSkill: RpgSkill
  30      id = nil
  31      techLevel = nil
  32      defaultAttribute = nil
  33      alternateAttribute = nil
  34      specialty = nil
  35      influence = nil
  36      voice = nil
  37      interval = 0
  38  ;
  39  
  40  /** Lite character sheet. Injury and fatigue are deficits, not the legacy energy/body pools. */
  41  class RpgLiteProfile: RpgProfile
  42      campaignTL = 3
  43      techLevel = 3
  44      languages = []
  45      equipment = []
  46      familiarSkills = nil
  47      pointBudget = 100
  48      disadvantageLimit = 50
  49      creationComplete = nil
  50      injury = 0
  51      fatigue = 0
  52      shock = 0
  53      stunned = nil
  54      mentalStun = nil
  55      unconscious = nil
  56      dead = nil
  57      mortal = nil
  58      apparentDeath = nil
  59      posture = 'standing'
  60      hunger = 0
  61      thirst = 0
  62      sleepDebt = 0
  63      restClock = 0
  64      healingClock = 0
  65      unconsciousClock = 0
  66      mortalClock = 0
  67      airClock = 0
  68      playSeconds = 0
  69      luckReady = 0
  70      sessionId = 0
  71      unluckySession = -1
  72      fearSeconds = 0
  73      /* Penalty to skill rolls and attribute checks (not defenses) and its remaining seconds. */
  74      lingeringPenalty = 0
  75      lingeringSeconds = 0
  76      aidUsed = nil
  77      money = 0
  78      /* Optional modules advance their own conditions here; see rpgLiteHealthTime. */
  79      conditionTime(seconds) { return nil; }
  80      hp() { return self.maxHP - self.injury; }
  81      fp() { return self.maxEnergy - self.fatigue; }
  82      basicSpeedQuarters() { return self.dx + self.ht; }
  83      basicMove() { return self.basicSpeedQuarters() / 4; }
  84      basicLiftHundredths()
  85      {
  86          local amount = self.st * self.st * 20;
  87          return amount >= 1000 ? (amount + 50) / 100 * 100 : amount;
  88      }
  89      trait(id)
  90      {
  91          foreach (local item in self.traits)
  92              if (item.ofKind(RpgLiteTrait) && item.id == id) return item;
  93          return nil;
  94      }
  95      level(id)
  96      {
  97          if (id == 'bad-sight') return self.level('bad-sight-correctable') > 0 ? 1 : self.rawLevel(id);
  98          if (id == 'resistant-disease') return self.level('resistant-disease-8') > 0 ? 8 : self.level('resistant-disease-3') > 0 ? 3 : 0;
  99          if (id == 'resistant-poison') return self.level('resistant-poison-3') > 0 ? 3 : 0;
 100          return self.rawLevel(id);
 101      }
 102      rawLevel(id)
 103      {
 104          local item = self.trait(id);
 105          return item == nil || !item.active || item.mitigated ? 0 : item.level;
 106      }
 107      language(id, written)
 108      {
 109          foreach (local item in self.languages)
 110              if (item.id == id)
 111              {
 112                  local amount = written ? item.written : item.spoken;
 113                  if (amount > 0 && amount < 3 && self.level('language-talent') > 0) amount += 1;
 114                  return amount;
 115              }
 116          return 0;
 117      }
 118      loadHundredths()
 119      {
 120          local amount = 0;
 121          foreach (local item in self.equipment) amount += item.weightHundredths();
 122          return amount;
 123      }
 124      encumbrance()
 125      {
 126          local weight = self.loadHundredths(); local lift = self.basicLiftHundredths();
 127          local factors = [1, 2, 3, 6, 10];
 128          for (local index = 1; index <= 5; index += 1)
 129              if (weight <= lift * factors[index]) return index - 1;
 130          return 5;
 131      }
 132      move()
 133      {
 134          local load = self.encumbrance();
 135          if (load == 5 || self.unconscious || self.dead) return 0;
 136          local amount = self.basicMove() * (5 - load) / 5;
 137          if (amount < 1) amount = 1;
 138          if (self.hp() * 3 < self.maxHP) amount = (amount + 1) / 2;
 139          if (self.fp() * 3 < self.maxEnergy) amount = (amount + 1) / 2;
 140          if (self.posture == 'crouching') amount = amount * 2 / 3;
 141          if (self.posture == 'kneeling' || self.posture == 'crawling') amount /= 3;
 142          if (self.posture == 'sitting') amount = 0;
 143          if (self.posture == 'lying') amount = 1;
 144          return amount;
 145      }
 146      skillScore(skill)
 147      {
 148          if (!skill.ofKind(RpgLiteSkill)) return inherited(skill);
 149          local trained = nil;
 150          foreach (local training in self.skills)
 151              if (training.skill == skill && training.points > 0) trained = true;
 152          if (!trained && (skill.techLevel != nil && skill.techLevel > self.techLevel
 153              || self.familiarSkills != nil && !holds(self.familiarSkills, skill))) return nil;
 154          local score = inherited(skill);
 155          if (!trained && skill.defaultPenalty != nil)
 156          {
 157              local attribute = skill.defaultAttribute == nil ? skill.attribute : skill.defaultAttribute;
 158              local base = self.attributeScore(attribute);
 159              if (base > 20) base = 20;
 160              score = base + skill.defaultPenalty;
 161              if (skill.alternateAttribute != nil)
 162              {
 163                  base = self.attributeScore(skill.alternateAttribute);
 164                  if (base > 20) base = 20;
 165                  if (base + skill.defaultPenalty > score) score = base + skill.defaultPenalty;
 166              }
 167          }
 168          return score;
 169      }
 170  ;
 171  
 172  /** Check circumstances and optional immediate Luck decision; score queries do not roll. */
 173  class RpgLiteContext: object
 174      kind = 'skill'
 175      skill = nil
 176      attribute = rpgIQ
 177      modifier = 0
 178      darkness = 0
 179      someLight = true
 180      offHand = nil
 181      closeQuarters = 0
 182      unnecessaryRisk = nil
 183      understandsSpeech = nil
 184      deceptive = nil
 185      phobiaPresent = nil
 186      language = nil
 187      written = nil
 188      resistedTrait = nil
 189      scoreFor(profile)
 190      {
 191          return self.skill == nil ? profile.attributeScore(self.attribute)
 192              : profile.skillScore(self.skill);
 193      }
 194      controllingAttribute() { return self.skill == nil ? self.attribute : self.skill.attribute; }
 195      skillUseModifier(profile) { return 0; }
 196      equipmentUseModifier(profile) { return 0; }
 197      unavailableReason(profile) { return 'training-required'; }
 198      reroll(profile, result) { return nil; }
 199  ;
 200  
 201  /** Calculate effective skill or attribute without consuming randomness; nil means no legal default. */
 202  rpgLiteScore(profile, context)
 203  {
 204      local score = context.scoreFor(profile);
 205      if (score == nil) return nil;
 206      local attribute = context.controllingAttribute();
 207      local skillId = context.skill != nil && context.skill.ofKind(RpgLiteSkill) ? context.skill.id : nil;
 208      local useModifier = context.skillUseModifier(profile);
 209      if (useModifier == nil) return nil;
 210      local equipmentModifier = context.equipmentUseModifier(profile);
 211      if (equipmentModifier == nil) return nil;
 212      local adjustment = context.modifier + useModifier + equipmentModifier;
 213      if (context.offHand && profile.level('ambidexterity') == 0 && skillId != 'karate') adjustment -= 4;
 214      local darkness = context.darkness;
 215      if (context.someLight) darkness -= profile.level('night-vision');
 216      if (darkness > 0) adjustment -= darkness;
 217      if (profile.level('high-pain-threshold') == 0 && context.kind != 'defense' && (attribute == rpgDX || attribute == rpgIQ))
 218          adjustment -= profile.shock;
 219      if (profile.sleepPenalty && (attribute == rpgDX || attribute == rpgIQ)) adjustment -= 2;
 220      if (context.kind != 'defense') adjustment -= profile.lingeringPenalty;
 221      if (attribute == rpgDX && context.kind != 'extinguish') adjustment -= profile.burningPenalty;
 222      if (context.phobiaPresent && (attribute == rpgIQ || attribute == rpgDX || context.skill != nil))
 223          adjustment -= 2;
 224      if (context.language != nil)
 225      {
 226          local comprehension = profile.language(context.language, context.written);
 227          if (comprehension == 0) return nil;
 228          adjustment += comprehension == 1 ? -3 : comprehension == 2 ? -1 : 0;
 229      }
 230      if (context.kind == 'vision') adjustment += profile.level('acute-vision') - (profile.level('bad-sight') > 0 ? 6 : 0);
 231      if (context.kind == 'hearing' || context.understandsSpeech)
 232          adjustment += (context.kind == 'hearing' ? profile.level('acute-hearing') : 0)
 233              - (profile.level('hard-of-hearing') > 0 ? 4 : 0);
 234      if (context.kind == 'taste-smell') adjustment += profile.level('acute-taste-smell');
 235      if (context.kind == 'touch') adjustment += profile.level('acute-touch');
 236      if (context.kind == 'fright') adjustment += profile.level('fearlessness') + 2 * profile.level('combat-reflexes');
 237      if (context.kind == 'intimidation-resistance') adjustment += profile.level('fearlessness');
 238      if (context.kind == 'death' || context.kind == 'mortal-wound') adjustment += profile.level('hard-to-kill');
 239      if (context.kind == 'major-wound' || context.kind == 'torture') adjustment += 3 * profile.level('high-pain-threshold');
 240      if (context.kind == 'wake' || context.kind == 'mental-stun') adjustment += 6 * profile.level('combat-reflexes');
 241      if (context.kind == 'disease') adjustment += profile.level('resistant-disease');
 242      if (context.kind == 'poison') adjustment += profile.level('resistant-poison');
 243      if (context.kind == 'balance') adjustment += 6 * profile.level('perfect-balance');
 244      if (context.kind == 'keep-feet') adjustment += 4 * profile.level('perfect-balance');
 245      if (context.kind == 'attack' && profile.level('bad-sight') > 0) adjustment -= 2;
 246      if (context.skill != nil)
 247      {
 248          if (context.unnecessaryRisk && profile.level('daredevil') > 0) adjustment += 1;
 249          foreach (local trait in profile.traits)
 250              if (trait.ofKind(RpgLiteTrait) && trait.active && !trait.mitigated && holds(trait.skills, context.skill))
 251                  adjustment += trait.level;
 252          if (holds(['armoury','electronics-repair','engineer','mechanic'], skillId)) adjustment += profile.level('artificer');
 253          if (holds(['camouflage','naturalist','navigation','survival','tracking'], skillId)) adjustment += profile.level('outdoorsman');
 254          if (context.skill.ofKind(RpgLiteSkill) && (context.skill.influence
 255              || holds(['acting','carousing','leadership','public-speaking'], skillId))) adjustment += profile.level('smooth-operator');
 256          if (skillId == 'climbing' || skillId == 'escape')
 257              adjustment += profile.level('double-jointed') > 0 ? 5 : profile.level('flexibility') > 0 ? 3 : 0;
 258          if (skillId == 'climbing' || skillId == 'acrobatics') adjustment += profile.level('perfect-balance');
 259          if (skillId == 'climbing' || skillId == 'stealth') adjustment -= profile.encumbrance();
 260          if (skillId == 'swimming') adjustment -= 2 * profile.encumbrance();
 261          if (profile.level('truthfulness') > 0 && (skillId == 'fast-talk' || skillId == 'acting' && context.deceptive)) adjustment -= 5;
 262          if (context.skill.ofKind(RpgLiteSkill))
 263          {
 264              if (context.skill.influence || skillId == 'leadership' || skillId == 'public-speaking') adjustment += profile.level('charisma');
 265              if (context.skill.voice) adjustment += 2 * profile.level('voice');
 266          }
 267      }
 268      local cramped = context.closeQuarters;
 269      cramped -= profile.level('double-jointed') > 0 ? 5 : profile.level('flexibility') > 0 ? 3 : 0;
 270      if (cramped > 0) adjustment -= cramped;
 271      return score + adjustment;
 272  }
 273  
 274  /** Resolve a contextual success check, including applicable traits and immediate Luck. */
 275  rpgLiteCheck(profile, context)
 276  {
 277      local score = rpgLiteScore(profile, context);
 278      if (score == nil) { local result = new RpgResult(); result.reason = context.unavailableReason(profile); return result; }
 279      if (score < 3) return rpgCheck(score, 3);
 280      local result = rpgCheck(score, rpgRandom.roll());
 281      if (context.reroll(profile, result)) result = rpgCheck(score, rpgLiteReroll(profile, result.roll, 3, nil));
 282      if (result.critical == 'failure' && context.unnecessaryRisk && profile.level('daredevil') > 0)
 283          result = rpgCheck(score, rpgRandom.roll());
 284      profile.lastCheck = result; profile.lastCheckDraw = rpgRandom.draws;
 285      return result;
 286  }
 287  
 288  /** Resolve an attribute check with a named situation and integer modifier. */
 289  rpgLiteAttributeCheck(profile, attribute, kind, modifier)
 290  {
 291      local context = new RpgLiteContext(); context.attribute = attribute;
 292      context.kind = kind; context.modifier = modifier;
 293      return rpgLiteCheck(profile, context);
 294  }
 295  
 296  /** Resolve written comprehension; semi-literacy needs an IQ roll and fluent reading is automatic. */
 297  rpgLiteRead(profile, languageId)
 298  {
 299      local comprehension = profile.language(languageId, true);
 300      if (comprehension == 1) return rpgLiteAttributeCheck(profile, rpgIQ, 'reading', 0);
 301      local result = new RpgResult();
 302      if (comprehension == 0) { result.reason = 'illiterate'; return result; }
 303      result.status = 'resolved'; result.achieved = true; result.outcome = 'automatic'; return result;
 304  }
 305  
 306  /** Resolve a controlled disadvantage against 12, or voluntarily yield without rolling. */
 307  rpgLiteSelfControl(profile, trait, modifier, giveIn)
 308  {
 309      if (!holds(profile.traits, trait) || !trait.controlled || !trait.active) throw new RpgError();
 310      local result = giveIn ? new RpgResult() : rpgCheck(12 + modifier - (profile.sleepPenalty ? 2 : 0), rpgRandom.roll());
 311      if (giveIn) { result.status = 'resolved'; result.achieved = nil; result.outcome = 'yielded'; }
 312      else { profile.lastCheck = result; profile.lastCheckDraw = rpgRandom.draws; }
 313      return result;
 314  }
 315  
 316  /** Return the signed point cost of an owned trait. */
 317  rpgLiteTraitCost(trait)
 318  {
 319      if (trait.id == nil || trait.level < 1) throw new RpgError();
 320      return trait.points * trait.level;
 321  }
 322  
 323  /** Return total creation cost, or only negative components when negativeOnly is true. */
 324  rpgLiteCharacterCost(profile, negativeOnly)
 325  {
 326      local amounts = [(profile.st - 10) * 10, (profile.dx - 10) * 20,
 327          (profile.iq - 10) * 20, (profile.ht - 10) * 10, (profile.techLevel - profile.campaignTL) * 5];
 328      foreach (local trait in profile.traits) amounts = amounts + [rpgLiteTraitCost(trait)];
 329      foreach (local language in profile.languages) amounts = amounts + [language.cost()];
 330      foreach (local training in profile.skills) amounts = amounts + [training.points];
 331      local total = 0;
 332      foreach (local amount in amounts) if (!negativeOnly || amount < 0) total += amount;
 333      return total;
 334  }
 335  
 336  /** Character validation failure with an author-readable reason. */
 337  class RpgLiteCharacterError: RpgError reason = 'invalid-character';
 338  
 339  /** Reject an invalid character declaration with a specific diagnostic. */
 340  rpgLiteInvalidCharacter(reason)
 341  {
 342      local error = new RpgLiteCharacterError(); error.reason = reason; throw error;
 343  }
 344  
 345  /** Validate Lite trait identities and mutually exclusive catalogue choices. */
 346  rpgLiteValidateTraits(profile)
 347  {
 348      local ids = []; local quirks = 0;
 349      foreach (local trait in profile.traits)
 350      {
 351          if (!trait.ofKind(RpgLiteTrait) || trait.id == nil || holds(ids, trait.id) && trait.id != 'quirk') rpgLiteInvalidCharacter('duplicate-or-invalid-trait');
 352          ids = ids + [trait.id];
 353          if ((trait.id == 'quirk' || trait.scope == 'quirk') && !trait.acquiredInPlay) quirks += 1;
 354          if (trait.id == 'night-vision' && trait.level > 9
 355              || trait.skills.length() > 0 && trait.level > 4) rpgLiteInvalidCharacter('trait-level');
 356      }
 357      if (quirks > 5 || profile.level('acute-hearing') > 0 && profile.level('hard-of-hearing') > 0
 358          || profile.level('luck') > 0 && profile.level('unluckiness') > 0) rpgLiteInvalidCharacter('incompatible-traits');
 359      foreach (local group in [['hideous','ugly','unattractive','attractive','handsome','very-handsome'],
 360          ['dead-broke','poor','struggling','comfortable','wealthy','very-wealthy','filthy-rich'],
 361          ['luck','extraordinary-luck','ridiculous-luck','unluckiness'], ['flexibility','double-jointed'],
 362          ['bad-sight','bad-sight-correctable'], ['status','low-status'],
 363          ['resistant-disease-3','resistant-disease-8']])
 364      {
 365          local count = 0;
 366          foreach (local id in group) if (holds(ids, id)) count += 1;
 367          if (count > 1) rpgLiteInvalidCharacter('exclusive-traits');
 368      }
 369      return true;
 370  }
 371  
 372  /** Validate language identities, comprehension and the native-language allowance. */
 373  rpgLiteValidateLanguages(profile)
 374  {
 375      local ids = []; local natives = 0;
 376      foreach (local language in profile.languages)
 377      {
 378          if (language.id == nil || holds(ids, language.id)) rpgLiteInvalidCharacter('duplicate-language');
 379          ids = ids + [language.id]; rpgInteger(language.spoken, 0, 3); rpgInteger(language.written, 0, 3);
 380          if (language.native) { natives += 1; if (language.spoken != 3) rpgLiteInvalidCharacter('native-language'); }
 381      }
 382      if (natives > 1) rpgLiteInvalidCharacter('multiple-native-languages');
 383      return true;
 384  }
 385  
 386  /** Validate the point budget, disadvantage limit, trait combinations, languages and technology. */
 387  rpgLiteValidateCharacter(profile)
 388  {
 389      rpgInteger(profile.st, 1, 20); rpgInteger(profile.dx, 1, 100);
 390      rpgInteger(profile.iq, 1, 100); rpgInteger(profile.ht, 1, 100);
 391      rpgInteger(profile.techLevel, 0, 12); rpgInteger(profile.campaignTL, 0, 12);
 392      rpgLiteValidateTraits(profile);
 393      rpgLiteValidateLanguages(profile);
 394      foreach (local training in profile.skills)
 395          if (training.points > 0 && training.skill.ofKind(RpgLiteSkill)
 396              && training.skill.techLevel != nil && training.skill.techLevel > profile.techLevel)
 397              rpgLiteInvalidCharacter('skill-technology');
 398      if (rpgLiteCharacterCost(profile, nil) > profile.pointBudget
 399          || -rpgLiteCharacterCost(profile, true) > profile.disadvantageLimit) rpgLiteInvalidCharacter('point-budget');
 400      return true;
 401  }
 402  
 403  /** Map an already-modified reaction total to its named category. */
 404  rpgLiteReaction(total)
 405  {
 406      if (total <= 0) return 'disastrous';
 407      if (total <= 3) return 'very-bad';
 408      if (total <= 6) return 'bad';
 409      if (total <= 9) return 'poor';
 410      if (total <= 12) return 'neutral';
 411      if (total <= 15) return 'good';
 412      if (total <= 18) return 'very-good';
 413      return 'excellent';
 414  }
 415  
 416  /** Authored visibility, recognition, culture and attraction facts for reaction modifiers. */
 417  class RpgLiteSocialContext: object
 418      attracted = nil
 419      sees = true
 420      hears = true
 421      interaction = true
 422      recognizes = true
 423      sameCulture = true
 424      noticesHabits = true
 425      naive = nil
 426      experienced = nil
 427      modifier = 0
 428      reroll(profile, roll) { return nil; }
 429  ;
 430  
 431  /** Calculate visible and applicable personal reaction modifiers. */
 432  rpgLiteReactionModifier(profile, context)
 433  {
 434      local amount = context.modifier;
 435      if (context.interaction) amount += profile.level('charisma');
 436      if (context.hears) amount += 2 * profile.level('voice');
 437      if (context.recognizes)
 438      {
 439          local reputation = profile.trait('reputation');
 440          if (reputation != nil) amount += reputation.points < 0 ? -reputation.level : reputation.level;
 441          amount -= profile.level('bad-reputation');
 442      }
 443      if (context.sameCulture)
 444      {
 445          local status = profile.trait('status');
 446          if (status != nil) amount += status.points < 0 ? -status.level : status.level;
 447          amount -= profile.level('low-status');
 448      }
 449      if (context.noticesHabits) amount -= profile.level('odious-habits') + profile.level('delusions');
 450      if (context.sees)
 451      {
 452          amount -= 4 * profile.level('hideous') + 2 * profile.level('ugly') + profile.level('unattractive');
 453          amount += profile.level('attractive');
 454          if (profile.level('handsome') > 0) amount += context.attracted ? 4 : 2;
 455          if (profile.level('very-handsome') > 0) amount += context.attracted ? 6 : 2;
 456      }
 457      if (profile.level('overconfidence') > 0) amount += context.naive ? 2 : context.experienced ? -2 : 0;
 458      return amount;
 459  }
 460  
 461  /** Opposed influence outcome, reaction category and optional ordinary reaction roll. */
 462  class RpgLiteInfluenceResult: object
 463      contest = nil
 464      reaction = nil
 465      reactionRoll = nil
 466  ;
 467  
 468  /** Resolve influence against Will, including Intimidation, Sex Appeal and Diplomacy exceptions. */
 469  rpgLiteInfluence(profile, target, context, social)
 470  {
 471      if (context.skill == nil || !context.skill.influence) throw new RpgError();
 472      local value = new RpgLiteInfluenceResult();
 473      local score = rpgLiteScore(profile, context);
 474      if (score == nil || score < 3) return value;
 475      local resistance = target.will;
 476      if (context.skill.id == 'intimidation')
 477      { resistance += target.level('fearlessness'); score -= target.level('fearlessness'); }
 478      local ownRoll = rpgRandom.roll();
 479      if (context.reroll(profile, rpgCheck(score, ownRoll))) ownRoll = rpgLiteReroll(profile, ownRoll, 3, nil);
 480      local otherRoll = rpgRandom.roll();
 481      value.contest = rpgEvaluate(score, ownRoll, resistance, otherRoll, 0);
 482      value.reaction = value.contest.achieved ? (context.skill.id == 'sex-appeal' ? 'very-good' : 'good')
 483          : context.skill.id == 'intimidation' ? 'very-bad' : 'bad';
 484      if (context.skill.id == 'diplomacy')
 485      {
 486          local reactionRoll = rpgRandom.roll();
 487          if (social.reroll(profile, reactionRoll)) reactionRoll = rpgLiteReroll(profile, reactionRoll, 3, true);
 488          value.reactionRoll = reactionRoll + rpgLiteReactionModifier(profile, social);
 489          local floor = value.contest.achieved ? 13 : 4;
 490          if (value.reactionRoll >= floor) value.reaction = rpgLiteReaction(value.reactionRoll);
 491      }
 492      return value;
 493  }
 494  
 495  /** Persistent repeated success-versus-failure contest, with author-selected seconds per round. */
 496  class RpgLiteRegularContest: object
 497      finished = nil
 498      winner = nil
 499      rounds = 0
 500      seconds = 1
 501      step(firstScore, secondScore)
 502      {
 503          if (self.finished) return self.winner;
 504          if (firstScore < 3 || secondScore < 3) throw new RpgError();
 505          local first = rpgCheck(firstScore, rpgRandom.roll());
 506          local second = rpgCheck(secondScore, rpgRandom.roll());
 507          self.rounds += 1; rpgSpendTime(self.seconds);
 508          if (first.achieved != second.achieved)
 509          { self.finished = true; self.winner = first.achieved ? 1 : 2; }
 510          return self.winner;
 511      }
 512  ;