lib/vhyl-rpg-expanded-social.t

Reference index ยท View on GitHub

   1  /* Include after expanded foundation. Source: full-2008 B28-B29. */
   2  
   3  /** Purchased social standing in one authored culture. Benefits require that the
   4   * culture and the standing are recognized in the current encounter. */
   5  class RpgExpandedStatus: RpgExpandedContribution
   6      culture = nil
   7      level = 0
   8      minimum = -2
   9      maximum = 8
  10      amounts(profile) { rpgInteger(self.level, -100, 100); return [self.level * 5]; }
  11      validate(profile)
  12      {
  13          if (self.culture == nil) rpgExpandedReject('status-culture');
  14          rpgInteger(self.minimum, -100, 100); rpgInteger(self.maximum, self.minimum, 100);
  15          rpgInteger(self.level, self.minimum, self.maximum);
  16          foreach (local entry in profile.contributions)
  17              if (entry != self && entry.ofKind(RpgExpandedStatus) && entry.culture == self.culture)
  18                  rpgExpandedReject('duplicate-cultural-status');
  19          foreach (local trait in profile.traits)
  20              if (trait.ofKind(RpgLiteTrait) && holds(['status','low-status'],trait.id))
  21                  rpgExpandedReject('mixed-status-profiles');
  22          return nil;
  23      }
  24  ;
  25  
  26  /** Pure B29 relative-standing guideline. Resentment is an authored exception:
  27   * do not turn it into an invented automatic negative bonus. */
  28  rpgExpandedStatusReaction(subjectStatus, observerStatus, recognized, friendly, resents)
  29  {
  30      rpgInteger(subjectStatus, -100, 100); rpgInteger(observerStatus, -100, 100);
  31      if (!recognized || subjectStatus == observerStatus) return 0;
  32      local difference = subjectStatus - observerStatus;
  33      if (difference > 0) return resents ? 0 : difference;
  34      if (subjectStatus < 0) return difference < -4 ? -4 : difference;
  35      if (subjectStatus > 0 && friendly) return 0;
  36      return difference;
  37  }
  38  
  39  /** An encounter's explicit social-standing facts. assess() rolls nothing and
  40   * reports only this modifier, not a complete reaction or permission decision. */
  41  class RpgExpandedStatusEncounter: object
  42      subject = nil
  43      observer = nil
  44      culture = nil
  45      recognized = nil
  46      friendly = nil
  47      resents = nil
  48      structure = nil
  49      statusFor(profile)
  50      {
  51          if (profile == nil || !profile.ofKind(RpgExpandedProfile))
  52              rpgExpandedReject('status-profile');
  53          if (self.structure != nil)
  54          {
  55              if (!self.structure.ofKind(RpgExpandedSocialStructure) || self.structure.culture != self.culture)
  56                  rpgExpandedReject('status-structure-culture');
  57              return self.structure.standingFor(profile);
  58          }
  59          rpgExpandedValidate(profile);
  60          foreach (local entry in profile.contributions)
  61              if (entry.ofKind(RpgExpandedRank) && entry.culture == self.culture)
  62                  rpgExpandedReject('status-structure-required');
  63          foreach (local entry in profile.contributions)
  64              if (entry.ofKind(RpgExpandedStatus) && entry.culture == self.culture) return entry.level;
  65          return 0;
  66      }
  67      assess()
  68      {
  69          if (self.culture == nil) rpgExpandedReject('status-culture');
  70          local value = new RpgExpandedResult();
  71          value.modifier = rpgExpandedStatusReaction(self.statusFor(self.subject),self.statusFor(self.observer),
  72              self.recognized,self.friendly,self.resents);
  73          value.status = 'ready'; return value;
  74      }
  75  ;
  76  
  77  
  78  /** Organizational membership and purchased authority, full-2008 B29-B30.
  79   * Institutional admission, duties and privileges are separately authored facts. */
  80  class RpgExpandedRank: RpgExpandedContribution
  81      culture = nil
  82      institution = nil
  83      level = 0
  84      maximum = 8
  85      replacesStatus = nil
  86      admitted = nil
  87      validate(profile)
  88      {
  89          if (self.culture == nil || self.institution == nil) rpgExpandedReject('rank-institution');
  90          rpgInteger(self.maximum, 0, 100); rpgInteger(self.level, 0, self.maximum);
  91          if (!self.admitted) rpgExpandedReject('rank-admission-required');
  92          foreach (local entry in profile.contributions)
  93              if (entry != self && entry.ofKind(RpgExpandedRank) && entry.institution == self.institution)
  94                  rpgExpandedReject('duplicate-institution-rank');
  95          return nil;
  96      }
  97      amounts(profile)
  98      {
  99          rpgInteger(self.level, 0, 100);
 100          return [self.level * (self.replacesStatus ? 10 : 5)];
 101      }
 102      statusBonus()
 103      {
 104          rpgInteger(self.level, 0, 100);
 105          if (self.replacesStatus) return self.level;
 106          return self.level < 2 ? 0 : self.level < 5 ? 1 : self.level < 8 ? 2 : 3;
 107      }
 108  ;
 109  
 110  /** Explicit social structure. Wealth-linked standing is opt-in; replacement
 111   * rank excludes ordinary status and all free standing bonuses. */
 112  class RpgExpandedSocialStructure: object
 113      culture = nil
 114      wealthLinked = nil
 115      replacingInstitution = nil
 116      purchasedMaximum = 8
 117      standingFor(profile)
 118      {
 119          if (self.culture == nil || profile == nil || !profile.ofKind(RpgExpandedProfile))
 120              rpgExpandedReject('social-structure');
 121          rpgInteger(self.purchasedMaximum, 0, 100); rpgExpandedValidate(profile);
 122          local total = 0;
 123          foreach (local entry in profile.contributions)
 124          {
 125              if (entry.ofKind(RpgExpandedStatus) && entry.culture == self.culture)
 126              {
 127                  if (self.replacingInstitution != nil) rpgExpandedReject('rank-replaces-purchased-status');
 128                  if (entry.level > self.purchasedMaximum) rpgExpandedReject('social-purchased-status-limit');
 129                  total += entry.level;
 130              }
 131              if (entry.ofKind(RpgExpandedRank) && entry.culture == self.culture)
 132              {
 133                  if (entry.replacesStatus ? entry.institution != self.replacingInstitution
 134                      : entry.institution == self.replacingInstitution)
 135                      rpgExpandedReject('rank-status-policy-mismatch');
 136                  if (self.replacingInstitution == nil || entry.institution == self.replacingInstitution)
 137                      total += entry.statusBonus();
 138              }
 139              if (self.wealthLinked && self.replacingInstitution == nil) total += entry.wealthStatusBonus();
 140          }
 141          rpgInteger(total, -100, 100); return total;
 142      }
 143  ;
 144  
 145  /** Reputation uses its explicit downward rounding exception, B27-B28. */
 146  rpgExpandedReputationFraction(amount, numerator, denominator)
 147  {
 148      rpgInteger(amount, -1000000, 1000000);
 149      rpgInteger(numerator, 0, 1000); rpgInteger(denominator, 1, 1000);
 150      local product = amount * numerator;
 151      return product < 0 ? -((-product + denominator - 1) / denominator) : product / denominator;
 152  }
 153  
 154  /** Facets are [audience-id, modifier, breadth]. Multifaceted audiences must be
 155   * authored as disjoint. A zero-net reputation remains an owned contribution. */
 156  class RpgExpandedReputation: RpgExpandedContribution
 157      details = nil
 158      facets = []
 159      disjoint = nil
 160      frequency = 'always'
 161      recognitions = []
 162      validateDefinition()
 163      {
 164          if (self.details == nil || self.facets.length() == 0) rpgExpandedReject('reputation-description');
 165          if (!holds(['always','sometimes','occasionally'],self.frequency)) rpgExpandedReject('reputation-frequency');
 166          if (self.facets.length() > 1 && !self.disjoint) rpgExpandedReject('reputation-overlapping-facets');
 167          local ids = [];
 168          foreach (local facet in self.facets)
 169          {
 170              if (facet.length() != 3 || facet[1] == nil || holds(ids,facet[1]))
 171                  rpgExpandedReject('reputation-facet');
 172              ids = ids + [facet[1]]; rpgInteger(facet[2], -4, 4);
 173              if (!holds(['all','most','large','small','negligible'],facet[3]))
 174                  rpgExpandedReject('reputation-breadth');
 175          }
 176          return nil;
 177      }
 178      amounts(profile)
 179      {
 180          self.validateDefinition(); local total = 0;
 181          foreach (local facet in self.facets)
 182          {
 183              local cost = facet[2] * 5;
 184              if (facet[3] == 'most') cost = rpgExpandedReputationFraction(cost,2,3);
 185              if (facet[3] == 'large') cost = rpgExpandedReputationFraction(cost,1,2);
 186              if (facet[3] == 'small') cost = rpgExpandedReputationFraction(cost,1,3);
 187              if (facet[3] == 'negligible') cost = 0;
 188              total += cost;
 189          }
 190          if (self.frequency == 'sometimes') total = rpgExpandedReputationFraction(total,1,2);
 191          if (self.frequency == 'occasionally') total = rpgExpandedReputationFraction(total,1,3);
 192          return [total];
 193      }
 194      validate(profile)
 195      {
 196          self.validateDefinition();
 197          foreach (local trait in profile.traits)
 198              if (trait.ofKind(RpgLiteTrait) && holds(['reputation','bad-reputation'],trait.id))
 199                  rpgExpandedReject('mixed-reputation-profiles');
 200          return nil;
 201      }
 202  ;
 203  
 204  /** Recognition is retained per reputation/audience, including failure. The
 205   * author supplies audience membership, geographic scope and identity exposure. */
 206  class RpgExpandedReputationRecognition: object
 207      reputation = nil
 208      audience = nil
 209      facet = nil
 210      inArea = nil
 211      identityExposed = nil
 212      assess()
 213      {
 214          if (self.reputation == nil || !self.reputation.ofKind(RpgExpandedReputation)
 215              || self.reputation.owner == nil || !self.reputation.owner.ofKind(RpgExpandedProfile)
 216              || !holds(self.reputation.owner.contributions,self.reputation) || self.audience == nil)
 217              rpgExpandedReject('reputation-recognition-target');
 218          rpgExpandedValidate(self.reputation.owner);
 219          local value = new RpgExpandedResult(); local selected = nil;
 220          foreach (local row in self.reputation.facets) if (row[1] == self.facet) selected = row;
 221          if (selected == nil) rpgExpandedReject('reputation-audience-facet');
 222          foreach (local row in self.reputation.recognitions)
 223              if (row[1] == self.audience)
 224              {
 225                  if (row[2] != self.facet) rpgExpandedReject('reputation-audience-changed');
 226                  value.status = 'reused'; value.check = row[3]; value.modifier = row[4]; return value;
 227              }
 228          if (!self.inArea) { value.reason = 'reputation-outside-area'; return value; }
 229          if (!self.identityExposed) { value.reason = 'reputation-identity-hidden'; return value; }
 230          value.modifier = selected[2]; value.status = 'ready'; return value;
 231      }
 232      resolve()
 233      {
 234          local value = self.assess(); if (value.status != 'ready') return value;
 235          if (self.reputation.frequency != 'always')
 236          {
 237              value.check = rpgRandom.roll();
 238              if (value.check > (self.reputation.frequency == 'sometimes' ? 10 : 7)) value.modifier = 0;
 239          }
 240          self.reputation.recognitions = self.reputation.recognitions
 241              + [[self.audience,self.facet,value.check,value.modifier]];
 242          value.status = 'resolved'; return value;
 243      }
 244  ;
 245  
 246  /** Combine already-resolved reputations for one person/group, capped at +/-4. */
 247  rpgExpandedReputationModifier(recognitions)
 248  {
 249      local seen = []; local audience = nil; local subject = nil; local total = 0;
 250      foreach (local recognition in recognitions)
 251      {
 252          if (!recognition.ofKind(RpgExpandedReputationRecognition)) rpgExpandedReject('reputation-recognition-list');
 253          local value = recognition.assess();
 254          if (value.status != 'reused') rpgExpandedReject('reputation-recognition-unresolved');
 255          if (holds(seen,recognition.reputation)) rpgExpandedReject('duplicate-reputation-recognition');
 256          if (seen.length() > 0 && (recognition.audience != audience || recognition.reputation.owner != subject))
 257              rpgExpandedReject('reputation-recognition-audience');
 258          seen = seen + [recognition.reputation]; audience = recognition.audience;
 259          subject = recognition.reputation.owner; total += value.modifier;
 260      }
 261      return total > 4 ? 4 : total < -4 ? -4 : total;
 262  }
 263  
 264  /** Reaction bands from full-2008 B560-B561, ordered from 0 (worst) to 7. */
 265  rpgExpandedReactionBand(total)
 266  {
 267      rpgInteger(total, -10000, 10000);
 268      return total <= 0 ? 0 : total <= 3 ? 1 : total <= 6 ? 2 : total <= 9 ? 3
 269          : total <= 12 ? 4 : total <= 15 ? 5 : total <= 18 ? 6 : 7;
 270  }
 271  
 272  modify RpgExpandedProfile
 273      reactionEncounters = []
 274  ;
 275  
 276  /** Persistent private encounter record; consequences are resolved separately.
 277   * The author registers it in subject.reactionEncounters with a unique id. */
 278  class RpgExpandedReactionEncounter: object
 279      id = nil
 280      subject = nil
 281      audience = nil
 282      worstBand = 0
 283      bestBand = 7
 284      predeterminedBand = nil
 285      fighting = nil
 286      history = []
 287      nuisanceCount = 0
 288      validate()
 289      {
 290          if (self.id == nil || self.subject == nil || !self.subject.ofKind(RpgExpandedProfile)
 291              || self.audience == nil || !holds(self.subject.reactionEncounters,self))
 292              rpgExpandedReject('reaction-encounter-owner');
 293          foreach (local entry in self.subject.reactionEncounters)
 294              if (entry != self && entry.id == self.id) rpgExpandedReject('duplicate-reaction-encounter');
 295          rpgInteger(self.worstBand, 0, 7); rpgInteger(self.bestBand, self.worstBand, 7);
 296          if (self.predeterminedBand != nil) rpgInteger(self.predeterminedBand,self.worstBand,self.bestBand);
 297          rpgInteger(self.nuisanceCount,0,1000); return nil;
 298      }
 299  ;
 300  
 301  /** Private reaction outcome: band, adjusted total and roll are trusted-author data. */
 302  class RpgExpandedReactionResult: RpgExpandedResult
 303      band = nil
 304      total = nil
 305  ;
 306  
 307  /** A distinct authored approach. assess() never exposes saved attitudes or dice.
 308   * resolve() is an author-only operation: do not emit its private result directly. */
 309  class RpgExpandedReactionAttempt: object
 310      encounter = nil
 311      approach = nil
 312      modifier = 0
 313      approvedChange = nil
 314      nuisance = nil
 315      waitedReasonably = nil
 316      assess()
 317      {
 318          if (self.encounter == nil || !self.encounter.ofKind(RpgExpandedReactionEncounter) || self.approach == nil)
 319              rpgExpandedReject('reaction-attempt');
 320          self.encounter.validate(); rpgInteger(self.modifier,-1000,1000);
 321          local value = new RpgExpandedReactionResult();
 322          foreach (local row in self.encounter.history)
 323              if (row[1] == self.approach) { value.status = 'reused'; return value; }
 324          if (self.encounter.history.length() > 0)
 325          {
 326              if (self.encounter.fighting) { value.reason = 'reaction-fight-started'; return value; }
 327              if (self.encounter.predeterminedBand != nil)
 328              { value.reason = 'reaction-predetermined'; return value; }
 329              if (!self.approvedChange) { value.reason = 'reaction-approach-unchanged'; return value; }
 330          }
 331          if (self.encounter.nuisanceCount >= 1000) rpgExpandedReject('reaction-retry-limit');
 332          value.status = 'ready'; return value;
 333      }
 334      resolve()
 335      {
 336          local value = self.assess();
 337          if (value.status == 'reused')
 338          {
 339              foreach (local row in self.encounter.history)
 340                  if (row[1] == self.approach)
 341                  { value.check = row[2]; value.total = row[3]; value.band = row[4]; }
 342              return value;
 343          }
 344          if (value.status != 'ready') return value;
 345          local count = self.waitedReasonably ? 0 : self.encounter.nuisanceCount;
 346          if (self.encounter.history.length() > 0 && self.nuisance && !self.waitedReasonably) count += 1;
 347          if (self.encounter.predeterminedBand != nil) value.band = self.encounter.predeterminedBand;
 348          else
 349          {
 350              value.check = rpgRandom.roll(); value.total = value.check + self.modifier - count * 2;
 351              value.band = rpgExpandedReactionBand(value.total);
 352              if (value.band < self.encounter.worstBand) value.band = self.encounter.worstBand;
 353              if (value.band > self.encounter.bestBand) value.band = self.encounter.bestBand;
 354          }
 355          self.encounter.nuisanceCount = count;
 356          self.encounter.history = self.encounter.history + [[self.approach,value.check,value.total,value.band]];
 357          value.status = 'resolved'; return value;
 358      }
 359  ;
 360  
 361  /** Authored combat intent and follow-up conditions; no combat is executed by this result. */
 362  class RpgExpandedCombatReactionResult: RpgExpandedResult
 363      intent = nil
 364      aidModifier = 0
 365      noQuarter = nil
 366      leaveDemand = nil
 367  ;
 368  
 369  /** Context-specific potential combat/morale interpretation, B560-B561.
 370   * Intent describes the next decision; movement and attacks still use combat. */
 371  class RpgExpandedCombatReaction: object
 372      band = nil
 373      inCombat = nil
 374      outnumbered = nil
 375      hopeless = nil
 376      escapePossible = nil
 377      visitorsRemain = nil
 378      assess()
 379      {
 380          rpgInteger(self.band,0,7);
 381          local value = new RpgExpandedCombatReactionResult(); value.status = 'ready';
 382          if (self.band == 0) { value.intent = 'attack'; value.noQuarter = true; }
 383          if (self.band == 1) value.intent = self.hopeless ? 'flee' : 'attack';
 384          if (self.band == 2) value.intent = self.inCombat ? 'continue-fighting' : self.outnumbered ? 'flee' : 'attack';
 385          if (self.band == 3)
 386          {
 387              if (self.inCombat) value.intent = 'continue-fighting';
 388              else
 389              {
 390                  value.leaveDemand = true;
 391                  value.intent = !self.visitorsRemain ? 'demand-departure' : self.outnumbered ? 'flee' : 'attack';
 392              }
 393          }
 394          if (self.band == 4) value.intent = self.inCombat ? 'disengage' : 'pass-peacefully';
 395          if (self.band == 5)
 396          { value.intent = self.inCombat ? 'flee' : 'allow-request'; if (!self.inCombat) value.aidModifier = 1; }
 397          if (self.band == 6)
 398          {
 399              value.intent = self.inCombat ? self.escapePossible ? 'flee' : 'surrender' : 'allow-request';
 400              if (!self.inCombat) value.aidModifier = 3;
 401          }
 402          if (self.band == 7)
 403          { value.intent = self.inCombat ? 'surrender' : 'allow-request'; if (!self.inCombat) value.aidModifier = 5; }
 404          return value;
 405      }
 406  ;
 407  
 408  /** Combat-specific modifiers are explicit perceived facts, not actual stat
 409   * comparisons. The strength assessment ranges from -5 through +5, B561. */
 410  rpgExpandedCombatReactionModifier(perceivedStrength, commonLanguage, intruders)
 411  {
 412      rpgInteger(perceivedStrength,-5,5);
 413      return perceivedStrength - (commonLanguage ? 0 : 2) - (intruders ? 2 : 0);
 414  }
 415  
 416  /** Aid decision and any required follow-up, advice or retry conditions. */
 417  class RpgExpandedAidResult: RpgExpandedResult
 418      granted = nil
 419      extent = 'none'
 420      advice = nil
 421      volunteerInformation = nil
 422      combatFollowup = nil
 423      followupModifier = 0
 424      followupBestBand = 7
 425      retry = nil
 426      retryModifier = 0
 427  ;
 428  
 429  /** Aid decisions, full-2008 B560-B562. A grant expresses willingness; it does
 430   * not create resources or let the provider perform an impossible task. */
 431  class RpgExpandedAidReaction: object
 432      band = nil
 433      simple = nil
 434      reasonableness = 'reasonable'
 435      assess()
 436      {
 437          rpgInteger(self.band,0,7);
 438          if (!holds(['reasonable','silly','totally-unreasonable'],self.reasonableness))
 439              rpgExpandedReject('aid-reasonableness');
 440          local value = new RpgExpandedAidResult(); value.status = 'ready';
 441          if (self.band == 0) { value.combatFollowup = true; value.followupModifier = -4; }
 442          if (self.band == 1) { value.combatFollowup = true; value.followupBestBand = 4; }
 443          if (self.band == 3) { value.retry = true; value.retryModifier = -2; }
 444          if (self.band == 4)
 445          {
 446              value.granted = self.simple;
 447              if (!self.simple) { value.retry = true; value.retryModifier = -2; }
 448          }
 449          if (self.band == 5)
 450          { value.granted = self.reasonableness == 'reasonable'; value.advice = !value.granted; }
 451          if (self.band == 6)
 452          { value.granted = self.reasonableness != 'totally-unreasonable'; value.volunteerInformation = true; }
 453          if (self.band == 7) { value.granted = true; value.extent = 'extra-within-capability'; }
 454          else if (value.granted) value.extent = 'requested';
 455          return value;
 456      }
 457  ;
 458  
 459  /** B562 aid modifiers. Variable risk penalties are explicit authored judgments.
 460   * A nil risk means absent; a supplied job/status risk must be at least -2. */
 461  class RpgExpandedAidModifiers: object
 462      verySimple = nil
 463      complexity = 0
 464      inconvenience = nil
 465      standingRisk = nil
 466      physicalRisk = nil
 467      assess()
 468      {
 469          rpgInteger(self.complexity,-100,0);
 470          if (self.standingRisk != nil) rpgInteger(self.standingRisk,-100,-2);
 471          if (self.physicalRisk != nil) rpgInteger(self.physicalRisk,-100,-1);
 472          local value = new RpgExpandedResult(); value.status = 'ready';
 473          value.modifier = (self.verySimple ? 1 : 0) + self.complexity - (self.inconvenience ? 1 : 0)
 474              + (self.standingRisk == nil ? 0 : self.standingRisk)
 475              + (self.physicalRisk == nil ? 0 : self.physicalRisk);
 476          return value;
 477      }
 478  ;
 479  
 480  /** Information response decision, including research and follow-up requirements. */
 481  class RpgExpandedInformationResult: RpgExpandedResult
 482      answer = 'none'
 483      related = nil
 484      research = nil
 485      combatFollowup = nil
 486      followupModifier = 0
 487      retry = nil
 488      aidFollowup = nil
 489      aidModifier = 0
 490      aidWorstBand = 0
 491  ;
 492  
 493  /** Information-request decision, B560-B562. Truth means the respondent's
 494   * belief, not privileged access to world truth. Answer content is separate. */
 495  class RpgExpandedInformationReaction: object
 496      band = nil
 497      complexity = 0
 498      mode = 'enquiry'
 499      adverseChoice = nil
 500      paymentReceived = nil
 501      bribeOffered = nil
 502      knowledgeIncomplete = nil
 503      offersHelp = nil
 504      assess() { return self.assessPayment(self.paymentReceived); }
 505      assessPayment(paid)
 506      {
 507          rpgInteger(self.band,0,7); rpgInteger(self.complexity,0,2);
 508          local value = new RpgExpandedInformationResult();
 509          if (self.mode == 'professional') { value.reason = 'information-use-trade'; return value; }
 510          if (self.mode == 'interrogation') { value.reason = 'information-use-interrogation'; return value; }
 511          if (self.mode != 'enquiry') rpgExpandedReject('information-mode');
 512          if (self.band == 0) { value.combatFollowup = true; value.followupModifier = -2; }
 513          if (self.band == 1) value.answer = 'malicious-lie';
 514          if (self.band == 2)
 515          {
 516              if (!holds(['lie','payment'],self.adverseChoice))
 517              { value.reason = 'information-adverse-choice-required'; return value; }
 518              value.answer = self.adverseChoice == 'lie' ? 'malicious-lie'
 519                  : paid ? 'incomplete-belief' : 'demand-payment';
 520          }
 521          if (self.band == 3)
 522          {
 523              if (!holds(['unknown','partial'],self.adverseChoice))
 524              { value.reason = 'information-adverse-choice-required'; return value; }
 525              value.answer = self.adverseChoice == 'unknown' ? 'claims-ignorance' : 'incomplete-belief';
 526              value.retry = self.bribeOffered;
 527          }
 528          if (self.band == 4) value.answer = self.complexity == 0 ? 'requested-belief' : 'incomplete-belief';
 529          if (self.band >= 5) value.answer = 'requested-belief';
 530          if (self.band == 6) value.related = true;
 531          if (self.band == 7)
 532          {
 533              value.research = self.knowledgeIncomplete;
 534              if (self.offersHelp) { value.aidFollowup = true; value.aidModifier = 2; value.aidWorstBand = 3; }
 535          }
 536          value.status = 'ready'; return value;
 537      }
 538  ;
 539  
 540  /** Information-specific modifiers, B562 and B44-B45. Bribe magnitude uses an
 541   * authored income-equivalent interval; no universal calendar is assumed. */
 542  class RpgExpandedInformationModifiers: object
 543      complexity = 0
 544      intrusive = nil
 545      danger = nil
 546      knowledgeProfessionBonus = 0
 547      bribeScale = 'none'
 548      discreet = nil
 549      appropriate = nil
 550      assess()
 551      {
 552          rpgInteger(self.complexity,0,2);
 553          if (self.danger != nil) rpgInteger(self.danger,-100,-3);
 554          if (self.knowledgeProfessionBonus != 0)
 555              rpgInteger(self.knowledgeProfessionBonus,2,4);
 556          local scales = ['none','day','week','month','year']; local bonus = nil;
 557          for (local index = 1; index <= scales.length(); index += 1)
 558              if (scales[index] == self.bribeScale) bonus = index - 1;
 559          if (bonus == nil) rpgExpandedReject('information-bribe-scale');
 560          local value = new RpgExpandedResult();
 561          if (bonus > 0 && (!self.discreet || !self.appropriate))
 562          { value.reason = 'information-bribe-inappropriate'; return value; }
 563          value.modifier = -self.complexity - (self.intrusive ? 3 : 0)
 564              + (self.danger == nil ? 0 : self.danger) + self.knowledgeProfessionBonus + bonus;
 565          value.status = 'ready'; return value;
 566      }
 567  ;
 568  
 569  modify RpgExpandedProfile
 570      knowledge = nil
 571  ;
 572  
 573  /** Owned epistemic state. Statements are authored identifiers/objects, not
 574   * implicit world facts. Hearing a claim does not automatically mean believing it. */
 575  class RpgExpandedKnowledge: object
 576      owner = nil
 577      beliefs = []
 578      fabrications = []
 579      received = []
 580      validate()
 581      {
 582          if (self.owner == nil || !self.owner.ofKind(RpgExpandedProfile) || self.owner.knowledge != self)
 583              rpgExpandedReject('knowledge-owner');
 584          foreach (local statement in self.beliefs)
 585              if (statement == nil || holds(self.fabrications,statement)) rpgExpandedReject('knowledge-belief-conflict');
 586          foreach (local statement in self.fabrications)
 587              if (statement == nil) rpgExpandedReject('knowledge-fabrication');
 588          return nil;
 589      }
 590  ;
 591  
 592  /** Committed statement selection and payment amount; previews do not disclose statements. */
 593  class RpgExpandedInformationDeliveryResult: RpgExpandedResult
 594      statements = []
 595      money = 0
 596  ;
 597  
 598  /** Deliver an authored response once. Lists select existing belief/fabrication
 599   * records, so a favorable reaction cannot manufacture an answer. */
 600  class RpgExpandedInformationDelivery: object
 601      id = nil
 602      provider = nil
 603      recipient = nil
 604      reaction = nil
 605      requested = []
 606      partial = []
 607      related = []
 608      lies = []
 609      available = nil
 610      price = 0
 611      paymentApproved = nil
 612      claimed = nil
 613      boundProvider = nil
 614      boundRecipient = nil
 615      boundId = nil
 616      validateSubset(selected, source)
 617      {
 618          local seen = [];
 619          foreach (local statement in selected)
 620          {
 621              if (statement == nil || !holds(source,statement) || holds(seen,statement))
 622                  rpgExpandedReject('information-statement-selection');
 623              seen = seen + [statement];
 624          }
 625          return nil;
 626      }
 627      selectedStatements()
 628      {
 629          if (self.reaction == nil || !self.reaction.ofKind(RpgExpandedInformationReaction))
 630              rpgExpandedReject('information-delivery-reaction');
 631          local response = self.reaction.assessPayment(self.price > 0 && self.paymentApproved ? true : self.reaction.paymentReceived);
 632          if (response.status != 'ready') return nil;
 633          self.validateSubset(self.requested,self.provider.knowledge.beliefs);
 634          self.validateSubset(self.partial,self.requested);
 635          self.validateSubset(self.related,self.provider.knowledge.beliefs);
 636          self.validateSubset(self.lies,self.provider.knowledge.fabrications);
 637          if (response.answer == 'malicious-lie')
 638          {
 639              if (self.lies.length() == 0) rpgExpandedReject('information-lie-required');
 640              return self.lies;
 641          }
 642          if (response.answer == 'incomplete-belief')
 643          {
 644              if (self.requested.length() > 0 && self.partial.length() >= self.requested.length())
 645                  rpgExpandedReject('information-partial-is-complete');
 646              return self.partial;
 647          }
 648          if (response.answer == 'requested-belief')
 649          {
 650              local result = self.requested;
 651              if (response.related)
 652                  foreach (local statement in self.related)
 653                      if (!holds(result,statement)) result = result + [statement];
 654              return result;
 655          }
 656          return nil;
 657      }
 658      assess()
 659      {
 660          if (self.id == nil || self.provider == nil || !self.provider.ofKind(RpgExpandedProfile)
 661              || self.recipient == nil || !self.recipient.ofKind(RpgExpandedProfile)
 662              || self.provider.knowledge == nil || !self.provider.knowledge.ofKind(RpgExpandedKnowledge)
 663              || self.recipient.knowledge == nil || !self.recipient.knowledge.ofKind(RpgExpandedKnowledge))
 664              rpgExpandedReject('information-delivery-target');
 665          if (self.claimed && (self.provider != self.boundProvider || self.recipient != self.boundRecipient || self.id != self.boundId))
 666              rpgExpandedReject('information-delivery-retargeted');
 667          self.provider.knowledge.validate(); self.recipient.knowledge.validate();
 668          local value = new RpgExpandedInformationDeliveryResult();
 669          foreach (local receipt in self.recipient.knowledge.received)
 670              if (receipt[1] == self.id)
 671              {
 672                  if (receipt[2] != self.provider) rpgExpandedReject('information-delivery-id-conflict');
 673                  value.status = 'reused'; return value;
 674              }
 675          if (!self.available) { value.reason = 'information-delivery-unavailable'; return value; }
 676          rpgInteger(self.price,0,2147483647);
 677          if (self.price > 0)
 678          {
 679              if (self.provider == self.recipient) rpgExpandedReject('information-self-payment');
 680              if (self.reaction == nil || !self.reaction.ofKind(RpgExpandedInformationReaction)
 681                  || self.reaction.band != 2 || self.reaction.adverseChoice != 'payment'
 682                  || self.reaction.paymentReceived)
 683                  rpgExpandedReject('information-payment-contract');
 684              if (!self.paymentApproved) { value.reason = 'information-payment-unapproved'; return value; }
 685              rpgInteger(self.recipient.money,0,2147483647); rpgInteger(self.provider.money,0,2147483647);
 686              if (self.recipient.money < self.price) { value.reason = 'information-payment-funds'; return value; }
 687              if (self.provider.money > 2147483647 - self.price) rpgExpandedReject('information-payment-limit');
 688              value.money = self.price;
 689          }
 690          local selected = self.selectedStatements();
 691          if (selected == nil) { value.reason = 'information-no-delivery'; return value; }
 692          value.status = 'ready'; return value;
 693      }
 694      deliver()
 695      {
 696          local value = self.assess(); if (value.status != 'ready') return value;
 697          value.statements = self.selectedStatements();
 698          self.recipient.money -= value.money; self.provider.money += value.money;
 699          self.recipient.knowledge.received = self.recipient.knowledge.received
 700              + [[self.id,self.provider,value.statements,value.money]];
 701          self.claimed = true; self.boundProvider = self.provider; self.boundRecipient = self.recipient; self.boundId = self.id;
 702          value.status = 'resolved'; return value;
 703      }
 704  ;
 705  
 706  modify RpgExpandedKnowledge
 707      checks = []
 708  ;
 709  
 710  /** Private knowledge-check result; a failed check does not fabricate a belief. */
 711  class RpgExpandedKnowledgeResult: RpgExpandedResult
 712      known = nil
 713  ;
 714  
 715  /** Resolve uncertain prior knowledge once per statement, full-2008 B562.
 716   * This establishes a belief; it does not perform research or prove world truth. */
 717  class RpgExpandedKnowledgeCheck: object
 718      knowledge = nil
 719      statement = nil
 720      context = nil
 721      plausible = nil
 722      assess()
 723      {
 724          if (self.knowledge == nil || !self.knowledge.ofKind(RpgExpandedKnowledge) || self.statement == nil)
 725              rpgExpandedReject('knowledge-check-target');
 726          self.knowledge.validate();
 727          local value = new RpgExpandedKnowledgeResult();
 728          if (holds(self.knowledge.beliefs,self.statement)) { value.status = 'reused'; return value; }
 729          foreach (local receipt in self.knowledge.checks)
 730              if (receipt[1] == self.statement) { value.status = 'reused'; return value; }
 731          if (holds(self.knowledge.fabrications,self.statement)) rpgExpandedReject('knowledge-check-fabrication');
 732          if (self.context == nil || !self.context.ofKind(RpgLiteContext) || self.context.kind != 'knowledge'
 733              || self.context.skill == nil && self.context.attribute != rpgIQ)
 734              rpgExpandedReject('knowledge-check-context');
 735          rpgExpandedValidate(self.knowledge.owner);
 736          if (!self.plausible) { value.reason = 'knowledge-check-implausible'; return value; }
 737          if (self.knowledge.owner.dead || self.knowledge.owner.unconscious)
 738          { value.reason = 'knowledge-check-unavailable'; return value; }
 739          local score = rpgLiteScore(self.knowledge.owner,self.context);
 740          if (score == nil) { value.reason = self.context.unavailableReason(self.knowledge.owner); return value; }
 741          rpgInteger(score,-1000,1000);
 742          value.status = 'ready'; return value;
 743      }
 744      resolve()
 745      {
 746          local value = self.assess();
 747          if (value.status == 'reused')
 748          {
 749              value.known = holds(self.knowledge.beliefs,self.statement);
 750              foreach (local receipt in self.knowledge.checks)
 751                  if (receipt[1] == self.statement) value.check = receipt[2];
 752              return value;
 753          }
 754          if (value.status != 'ready') return value;
 755          value.check = rpgLiteCheck(self.knowledge.owner,self.context);
 756          value.known = value.check.achieved;
 757          self.knowledge.checks = self.knowledge.checks + [[self.statement,value.check]];
 758          if (value.known) self.knowledge.beliefs = self.knowledge.beliefs + [self.statement];
 759          value.status = 'resolved'; return value;
 760      }
 761  ;
 762  
 763  /** Exact whole-unit trade arithmetic. Fractional currency needs a separate
 764   * explicit currency policy; do not silently round a quoted price. */
 765  rpgExpandedTradeFraction(amount, numerator, denominator)
 766  {
 767      rpgInteger(amount,0,2147483647); rpgInteger(numerator,1,100); rpgInteger(denominator,1,100);
 768      local quotient = amount / denominator; local remainder = amount % denominator;
 769      if ((remainder * numerator) % denominator != 0) rpgExpandedReject('trade-fractional-money');
 770      local tail = remainder * numerator / denominator;
 771      if (quotient > (2147483647 - tail) / numerator) rpgExpandedReject('trade-price-limit');
 772      return quotient * numerator + tail;
 773  }
 774  
 775  /** Quoted terms and refusal/advice flags; this result does not transfer property. */
 776  class RpgExpandedTradeResult: RpgExpandedResult
 777      price = 0
 778      refused = nil
 779      combatFollowup = nil
 780      followupModifier = 0
 781      offersAdvice = nil
 782  ;
 783  
 784  /** Trade table prices, full-2008 B560-B562. Direction is from the player party's
 785   * perspective. Altered low-band counteroffers remain blocked by a source conflict. */
 786  class RpgExpandedTradeReaction: object
 787      band = nil
 788      direction = 'buy'
 789      fairPrice = 0
 790      offeredPrice = nil
 791      assess()
 792      {
 793          rpgInteger(self.band,0,7); rpgInteger(self.fairPrice,1,2147483647);
 794          if (!holds(['buy','sell'],self.direction)) rpgExpandedReject('trade-direction');
 795          local offer = self.offeredPrice == nil ? self.fairPrice : self.offeredPrice;
 796          rpgInteger(offer,0,2147483647);
 797          local value = new RpgExpandedTradeResult();
 798          if (self.band == 0)
 799          {
 800              value.refused = true; value.combatFollowup = true; value.followupModifier = -2;
 801              value.status = 'ready'; return value;
 802          }
 803          if (self.band <= 5 && offer != self.fairPrice)
 804          { value.reason = 'trade-counteroffer-source-conflict'; return value; }
 805          if (self.band == 1)
 806              value.price = rpgExpandedTradeFraction(self.fairPrice,self.direction == 'buy' ? 3 : 1,self.direction == 'buy' ? 1 : 3);
 807          if (self.band == 2)
 808              value.price = rpgExpandedTradeFraction(self.fairPrice,self.direction == 'buy' ? 2 : 1,self.direction == 'buy' ? 1 : 2);
 809          if (self.band == 3)
 810              value.price = rpgExpandedTradeFraction(self.fairPrice,self.direction == 'buy' ? 6 : 3,self.direction == 'buy' ? 5 : 4);
 811          if (self.band == 4 || self.band == 5) value.price = self.fairPrice;
 812          if (self.band >= 6)
 813          {
 814              local limit = self.band == 6
 815                  ? rpgExpandedTradeFraction(self.fairPrice,self.direction == 'buy' ? 4 : 3,self.direction == 'buy' ? 5 : 2)
 816                  : rpgExpandedTradeFraction(self.fairPrice,self.direction == 'buy' ? 1 : 2,self.direction == 'buy' ? 2 : 1);
 817              value.price = self.direction == 'buy' ? (offer < limit ? limit : offer) : (offer > limit ? limit : offer);
 818          }
 819          value.offersAdvice = self.band >= 5; value.status = 'ready'; return value;
 820      }
 821  ;
 822  
 823  /** Complete ten-percent steps by which an offer favors either party, B562.
 824   * Quotient/remainder arithmetic avoids overflowing when multiplying prices. */
 825  rpgExpandedTradeOfferModifier(fairPrice, offeredPrice, direction)
 826  {
 827      rpgInteger(fairPrice,1,2147483647); rpgInteger(offeredPrice,0,2147483647);
 828      if (!holds(['buy','sell'],direction)) rpgExpandedReject('trade-direction');
 829      local difference = offeredPrice - fairPrice; local sign = difference < 0 ? -1 : 1;
 830      if (difference < 0) difference = -difference;
 831      local whole = difference / fairPrice; local remainder = difference % fairPrice;
 832      if (whole > 100) rpgExpandedReject('trade-modifier-limit');
 833      local steps = whole * 10;
 834      for (local step = 1; step <= 9; step += 1)
 835      {
 836          local threshold = (fairPrice / 10) * step + ((fairPrice % 10) * step + 9) / 10;
 837          if (remainder >= threshold) steps += 1;
 838      }
 839      if (steps > 1000) rpgExpandedReject('trade-modifier-limit');
 840      return steps * sign * (direction == 'buy' ? 1 : -1);
 841  }
 842  
 843  /** Transaction-specific offer and commerce skill bonuses. merchantLevel is the
 844   * authored applicable skill rating; nil means no qualifying skill bonus. */
 845  class RpgExpandedTradeModifiers: object
 846      fairPrice = 0
 847      offeredPrice = 0
 848      direction = 'buy'
 849      merchantLevel = nil
 850      assess()
 851      {
 852          local value = new RpgExpandedResult();
 853          value.modifier = rpgExpandedTradeOfferModifier(self.fairPrice,self.offeredPrice,self.direction);
 854          if (self.merchantLevel != nil)
 855          {
 856              rpgInteger(self.merchantLevel,0,1000);
 857              value.modifier += self.merchantLevel >= 20 ? 2 : 1;
 858          }
 859          value.status = 'ready'; return value;
 860      }
 861  ;
 862  
 863  modify RpgExpandedProfile
 864      loyalties = []
 865  ;
 866  
 867  /** Private loyalty rating, roll and decision for trusted author interpretation. */
 868  class RpgExpandedLoyaltyResult: RpgExpandedResult
 869      rating = nil
 870      loyal = nil
 871  ;
 872  
 873  /** Private relationship, separate from competence, full-2008 B518-B519/B562. */
 874  class RpgExpandedLoyalty: object
 875      id = nil
 876      subject = nil
 877      employer = nil
 878      initialModifier = 0
 879      preset = nil
 880      initialized = nil
 881      base = nil
 882      initialRoll = nil
 883      incidents = []
 884      dangerEffects = []
 885      payBonus = 0
 886      payUntil = nil
 887      payChanges = []
 888      rating()
 889      {
 890          self.validate();
 891          if (!self.initialized) rpgExpandedReject('loyalty-uninitialized');
 892          local value = self.base + (self.payUntil == nil || rpgLedger.seconds < self.payUntil ? self.payBonus : 0);
 893          foreach (local effect in self.dangerEffects)
 894              if (rpgLedger.seconds < effect[2]) value -= 1;
 895          return value;
 896      }
 897      validate()
 898      {
 899          if (self.id == nil || self.subject == nil || !self.subject.ofKind(RpgExpandedProfile)
 900              || self.employer == nil || !holds(self.subject.loyalties,self)) rpgExpandedReject('loyalty-owner');
 901          foreach (local entry in self.subject.loyalties)
 902              if (entry != self && (entry.id == self.id || entry.employer == self.employer))
 903                  rpgExpandedReject('duplicate-loyalty');
 904          rpgInteger(self.payBonus,0,1000);
 905          if (self.payUntil != nil) rpgInteger(self.payUntil,0,2147483647);
 906          rpgInteger(self.initialModifier,-1000,1000);
 907          if (self.preset != nil) rpgInteger(self.preset,-1000,1000);
 908          if (self.initialized) rpgInteger(self.base,-1000,1018);
 909          return nil;
 910      }
 911      assess()
 912      {
 913          self.validate(); local value = new RpgExpandedLoyaltyResult();
 914          value.status = self.initialized ? 'reused' : 'ready'; return value;
 915      }
 916      initialize()
 917      {
 918          local value = self.assess();
 919          if (!self.initialized)
 920          {
 921              if (self.preset != nil) self.base = self.preset;
 922              else { self.initialRoll = rpgRandom.roll(); self.base = self.initialRoll + self.initialModifier; }
 923              self.initialized = true; value.status = 'resolved';
 924          }
 925          value.rating = self.rating(); value.check = self.initialRoll; return value;
 926      }
 927  ;
 928  
 929  /** Once-per-incident loyalty check. These are raw 3d comparisons, not success
 930   * checks with automatic failures at 17/18. A rating of 20+ is automatic. */
 931  class RpgExpandedLoyaltyCheck: object
 932      loyalty = nil
 933      incident = nil
 934      necessary = nil
 935      modifier = 0
 936      checkKind = 'ordinary'
 937      assess()
 938      {
 939          if (self.loyalty == nil || !self.loyalty.ofKind(RpgExpandedLoyalty) || self.incident == nil)
 940              rpgExpandedReject('loyalty-check-target');
 941          self.loyalty.validate(); rpgInteger(self.modifier,-1000,1000);
 942          local value = new RpgExpandedLoyaltyResult();
 943          foreach (local receipt in self.loyalty.incidents)
 944              if (receipt[1] == self.incident)
 945              {
 946                  if (receipt[5] != self.checkKind) rpgExpandedReject('loyalty-incident-kind');
 947                  value.status = 'reused'; return value;
 948              }
 949          if (!self.loyalty.initialized) { value.reason = 'loyalty-uninitialized'; return value; }
 950          if (!self.necessary) { value.reason = 'loyalty-check-unnecessary'; return value; }
 951          value.status = 'ready'; return value;
 952      }
 953      resolve()
 954      {
 955          local value = self.assess();
 956          if (value.status == 'reused')
 957          {
 958              foreach (local receipt in self.loyalty.incidents)
 959                  if (receipt[1] == self.incident)
 960                  { value.rating = receipt[2]; value.check = receipt[3]; value.loyal = receipt[4]; }
 961              return value;
 962          }
 963          if (value.status != 'ready') return value;
 964          value.rating = self.loyalty.rating() + self.modifier;
 965          if (value.rating >= 20) value.loyal = true;
 966          else { value.check = rpgRandom.roll(); value.loyal = value.check <= value.rating; }
 967          self.loyalty.incidents = self.loyalty.incidents + [[self.incident,value.rating,value.check,value.loyal,self.checkKind]];
 968          value.status = 'resolved'; return value;
 969      }
 970  ;
 971  
 972  
 973  /** Noncombatant combat exposure, full-2008 B519. One failed incident causes
 974   * a one-week penalty; overlapping exposures require unresolved author policy. */
 975  class RpgExpandedLoyaltyDanger: RpgExpandedLoyaltyCheck
 976      checkKind = 'noncombatant-danger'
 977      noncombatant = nil
 978      assess()
 979      {
 980          local value = inherited();
 981          if (value.status != 'ready') return value;
 982          if (!self.noncombatant) { value.reason = 'loyalty-danger-noncombatant-required'; value.status = 'blocked'; return value; }
 983          foreach (local effect in self.loyalty.dangerEffects)
 984              if (rpgLedger.seconds < effect[2])
 985              { value.reason = 'loyalty-overlapping-danger-unresolved'; value.status = 'blocked'; return value; }
 986          rpgInteger(rpgLedger.seconds,0,2146878847);
 987          return value;
 988      }
 989      resolve()
 990      {
 991          local value = inherited();
 992          if (value.status == 'resolved' && !value.loyal)
 993              self.loyalty.dangerEffects = self.loyalty.dangerEffects + [[self.incident,rpgLedger.seconds + 604800]];
 994          return value;
 995      }
 996  ;
 997  
 998  modify RpgExpandedLoyalty
 999      changes = []
1000  ;
1001  
1002  /** Recorded permanent loyalty change with its signed adjustment. */
1003  class RpgExpandedLoyaltyChangeResult: RpgExpandedResult
1004      change = 0
1005  ;
1006  
1007  /** Permanent changes from explicit completed-world events, full-2008 B519.
1008   * IDs identify an adventure or a previously checked incident, not an API call. */
1009  class RpgExpandedLoyaltyChange: object
1010      loyalty = nil
1011      id = nil
1012      kind = 'performance'
1013      evidence = nil
1014      continuing = nil
1015      adventureComplete = nil
1016      performance = 'ordinary'
1017      forgiven = nil
1018      assess()
1019      {
1020          if (self.loyalty == nil || !self.loyalty.ofKind(RpgExpandedLoyalty) || self.id == nil)
1021              rpgExpandedReject('loyalty-change-target');
1022          self.loyalty.validate();
1023          if (!holds(['performance','forgiveness'],self.kind)) rpgExpandedReject('loyalty-change-kind');
1024          local value = new RpgExpandedLoyaltyChangeResult();
1025          foreach (local receipt in self.loyalty.changes)
1026              if (receipt[1] == self.kind && receipt[2] == self.id) { value.status = 'reused'; return value; }
1027          if (!self.loyalty.initialized) { value.reason = 'loyalty-uninitialized'; return value; }
1028          if (self.evidence == nil) { value.reason = 'loyalty-change-evidence'; return value; }
1029          if (self.kind == 'performance')
1030          {
1031              if (!holds(['ordinary','great-success','botched'],self.performance)) rpgExpandedReject('loyalty-performance');
1032              if (!self.continuing || !self.adventureComplete)
1033              { value.reason = 'loyalty-adventure-incomplete'; return value; }
1034          }
1035          else
1036          {
1037              if (!self.forgiven) { value.reason = 'loyalty-not-forgiven'; return value; }
1038              local failed = nil;
1039              foreach (local receipt in self.loyalty.incidents)
1040                  if (receipt[1] == self.id && !receipt[4]) failed = true;
1041              if (!failed) { value.reason = 'loyalty-no-failed-incident'; return value; }
1042          }
1043          /* Reserve room for either one-point outcome before committing anything. */
1044          rpgInteger(self.loyalty.base,-999,1017);
1045          value.status = 'ready'; return value;
1046      }
1047      apply()
1048      {
1049          local value = self.assess(); if (value.status != 'ready') return value;
1050          if (self.kind == 'performance')
1051              value.change = self.performance == 'great-success' ? 1 : self.performance == 'botched' ? -1 : 0;
1052          else value.change = self.loyalty.rating() >= 16 ? 1 : 0;
1053          self.loyalty.base += value.change;
1054          self.loyalty.changes = self.loyalty.changes + [[self.kind,self.id,value.change,self.evidence]];
1055          value.status = 'resolved'; return value;
1056      }
1057  ;
1058  
1059  modify RpgExpandedLoyalty
1060      serviceYears = []
1061      lastServiceYear = 0
1062      lastAnniversaryAt = 0
1063  ;
1064  
1065  /** Service-anniversary result and any permanent loyalty increase. */
1066  class RpgExpandedServiceResult: RpgExpandedLoyaltyResult
1067      change = 0
1068  ;
1069  
1070  /** B519 annual service check. The campaign calendar supplies an absolute due
1071   * time; this operation never assumes 365 days or charges elapsed time again. */
1072  class RpgExpandedServiceAnniversary: object
1073      loyalty = nil
1074      year = 1
1075      anniversaryAt = nil
1076      completedService = nil
1077      assess()
1078      {
1079          if (self.loyalty == nil || !self.loyalty.ofKind(RpgExpandedLoyalty))
1080              rpgExpandedReject('service-loyalty');
1081          self.loyalty.validate(); rpgInteger(self.year,1,10000);
1082          local value = new RpgExpandedServiceResult();
1083          foreach (local receipt in self.loyalty.serviceYears)
1084              if (receipt[1] == self.year) { value.status = 'reused'; return value; }
1085          if (!self.loyalty.initialized) { value.reason = 'loyalty-uninitialized'; return value; }
1086          if (self.year != self.loyalty.lastServiceYear + 1)
1087          { value.reason = 'service-year-gap'; return value; }
1088          rpgInteger(self.anniversaryAt,1,2147483647);
1089          if (self.anniversaryAt <= self.loyalty.lastAnniversaryAt)
1090              rpgExpandedReject('service-anniversary-order');
1091          if (!self.completedService) { value.reason = 'service-year-incomplete'; return value; }
1092          if (rpgLedger.seconds < self.anniversaryAt) { value.reason = 'service-anniversary-not-due'; return value; }
1093          rpgInteger(self.loyalty.base,-1000,1017);
1094          value.status = 'ready'; return value;
1095      }
1096      resolve()
1097      {
1098          local value = self.assess();
1099          if (value.status == 'reused')
1100          {
1101              foreach (local receipt in self.loyalty.serviceYears)
1102                  if (receipt[1] == self.year)
1103                  { value.rating = receipt[3]; value.check = receipt[4]; value.loyal = receipt[5]; }
1104              return value;
1105          }
1106          if (value.status != 'ready') return value;
1107          value.rating = self.loyalty.rating();
1108          if (value.rating >= 20) value.loyal = true;
1109          else { value.check = rpgRandom.roll(); value.loyal = value.check <= value.rating; }
1110          value.change = value.loyal ? 1 : 0; self.loyalty.base += value.change;
1111          self.loyalty.serviceYears = self.loyalty.serviceYears
1112              + [[self.year,self.anniversaryAt,value.rating,value.check,value.loyal]];
1113          self.loyalty.lastServiceYear = self.year; self.loyalty.lastAnniversaryAt = self.anniversaryAt;
1114          value.status = 'resolved'; return value;
1115      }
1116  ;
1117  
1118  modify RpgExpandedLoyalty
1119      rescues = []
1120  ;
1121  
1122  /** Rescue gratitude result and the resulting private loyalty rating. */
1123  class RpgExpandedRescueResult: RpgExpandedLoyaltyResult
1124      total = nil
1125      change = 0
1126  ;
1127  
1128  /** Rescue gratitude, full-2008 B519. World risk and a completed rescue are
1129   * explicit facts. A reaction below good never reduces existing loyalty. */
1130  class RpgExpandedRescueLoyalty: object
1131      loyalty = nil
1132      id = nil
1133      evidence = nil
1134      rescued = nil
1135      riskedLifeOrMission = nil
1136      rescueBonus = 3
1137      otherModifier = 0
1138      sacrificeBonus = 0
1139      seriousSacrifice = nil
1140      assess()
1141      {
1142          if (self.loyalty == nil || !self.loyalty.ofKind(RpgExpandedLoyalty) || self.id == nil)
1143              rpgExpandedReject('rescue-loyalty-target');
1144          self.loyalty.validate(); local value = new RpgExpandedRescueResult();
1145          foreach (local receipt in self.loyalty.rescues)
1146              if (receipt[1] == self.id) { value.status = 'reused'; return value; }
1147          if (!self.loyalty.initialized) { value.reason = 'loyalty-uninitialized'; return value; }
1148          if (!self.rescued || !self.riskedLifeOrMission || self.evidence == nil)
1149          { value.reason = 'rescue-loyalty-evidence'; return value; }
1150          rpgInteger(self.rescueBonus,3,1000); rpgInteger(self.otherModifier,-1000,1000);
1151          rpgInteger(self.rescueBonus + self.otherModifier,-1000,1000);
1152          if (self.loyalty.rating() != self.loyalty.base)
1153          { value.reason = 'rescue-temporary-loyalty-unresolved'; return value; }
1154          rpgInteger(self.sacrificeBonus,0,1000);
1155          if (self.sacrificeBonus > 0 && !self.seriousSacrifice) rpgExpandedReject('rescue-sacrifice-evidence');
1156          local possible = 18 + self.rescueBonus + self.otherModifier;
1157          if (possible < self.loyalty.base) possible = self.loyalty.base;
1158          if (possible > 1018 - self.sacrificeBonus) rpgExpandedReject('rescue-loyalty-limit');
1159          value.status = 'ready'; return value;
1160      }
1161      resolve()
1162      {
1163          local value = self.assess();
1164          if (value.status == 'reused')
1165          {
1166              foreach (local receipt in self.loyalty.rescues)
1167                  if (receipt[1] == self.id)
1168                  { value.check = receipt[2]; value.total = receipt[3]; value.rating = receipt[4]; }
1169              return value;
1170          }
1171          if (value.status != 'ready') return value;
1172          value.check = rpgRandom.roll(); value.total = value.check + self.rescueBonus + self.otherModifier;
1173          value.rating = self.loyalty.base;
1174          if (value.total >= 13 && value.total > value.rating) value.rating = value.total;
1175          value.rating += self.sacrificeBonus;
1176          value.change = value.rating - self.loyalty.base; self.loyalty.base = value.rating;
1177          self.loyalty.rescues = self.loyalty.rescues + [[self.id,value.check,value.total,value.rating,self.evidence,self.sacrificeBonus]];
1178          value.status = 'resolved'; return value;
1179      }
1180  ;
1181  
1182  
1183  /** B519 higher-pay bonus. The job's normal and actual pay cover the same period.
1184   * Payment/loot transfer happens in its own operation; evidence identifies it. */
1185  class RpgExpandedLoyaltyPayChange: object
1186      loyalty = nil
1187      id = nil
1188      mode = 'start'
1189      normalPay = 0
1190      actualPay = 0
1191      expiresAt = nil
1192      evidence = nil
1193      assess()
1194      {
1195          if (self.loyalty == nil || !self.loyalty.ofKind(RpgExpandedLoyalty) || self.id == nil)
1196              rpgExpandedReject('loyalty-pay-target');
1197          self.loyalty.validate();
1198          if (!holds(['start','stop'],self.mode)) rpgExpandedReject('loyalty-pay-mode');
1199          local value = new RpgExpandedResult();
1200          foreach (local receipt in self.loyalty.payChanges)
1201              if (receipt[1] == self.id) { value.status = 'reused'; return value; }
1202          if (self.evidence == nil) { value.reason = 'loyalty-pay-evidence'; return value; }
1203          if (self.mode == 'start')
1204          {
1205              rpgInteger(self.normalPay,1,2147483647); rpgInteger(self.actualPay,0,2147483647);
1206              if (self.actualPay <= self.normalPay) { value.reason = 'loyalty-no-extra-pay'; return value; }
1207              value.modifier = rpgExpandedTradeOfferModifier(self.normalPay,self.actualPay,'buy');
1208              if (value.modifier == 0) { value.reason = 'loyalty-no-full-pay-step'; return value; }
1209              if (self.loyalty.payBonus > 0 && (self.loyalty.payUntil == nil || rpgLedger.seconds < self.loyalty.payUntil))
1210              { value.reason = 'loyalty-pay-overlap-unresolved'; return value; }
1211          }
1212          else
1213          {
1214              if (self.loyalty.payBonus == 0 || self.loyalty.payUntil != nil)
1215              { value.reason = 'loyalty-extra-pay-not-active'; return value; }
1216              rpgInteger(self.expiresAt,1,2147483647);
1217              if (self.expiresAt <= rpgLedger.seconds) { value.reason = 'loyalty-pay-expiry'; return value; }
1218          }
1219          value.status = 'ready'; return value;
1220      }
1221      apply()
1222      {
1223          local value = self.assess(); if (value.status != 'ready') return value;
1224          if (self.mode == 'start') { self.loyalty.payBonus = value.modifier; self.loyalty.payUntil = nil; }
1225          else self.loyalty.payUntil = self.expiresAt;
1226          self.loyalty.payChanges = self.loyalty.payChanges
1227              + [[self.id,self.mode,self.loyalty.payBonus,self.loyalty.payUntil,self.evidence]];
1228          value.status = 'resolved'; return value;
1229      }
1230  ;