lib/vhyl-rpg-progression.t

Reference index ยท View on GitHub

   1  /* Optional milestone awards and constrained development opportunities.
   2   * Include after vhyl-rpg.t. Copyright (c) 2026 John Cunningham. MIT License. */
   3  
   4  modify RpgProfile
   5      earnedPoints = 0
   6      unspentPoints = 0
   7      developmentOpen = nil
   8      developmentRevision = 0
   9      /* Optional profile rules may reject learning before any resources are spent. */
  10      skillLearningReason(skill) { return nil; }
  11      skillLearningWithTeacherReason(skill, teacher) { return self.skillLearningReason(skill); }
  12  ;
  13  
  14  /** Authored objective award. Eligibility is observational; apply can unlock
  15   * opportunities after the receipt has been claimed. Never award for retries. */
  16  class RpgMilestone: object
  17      id = nil
  18      points = 1
  19      available(profile) { return nil; }
  20      apply(profile) { return nil; }
  21  ;
  22  
  23  /** Predeclared receipt per allowed profile/milestone pair; saved and undoable. */
  24  class RpgAward: object
  25      profile = nil
  26      milestone = nil
  27      claimed = nil
  28  ;
  29  
  30  /** An owned, authored purchase. kind is skill, attribute, trait or remove-trait.
  31   * Skill/attribute costs are differences; trait costs are explicit positive
  32   * prices. Unlocks and downtime are separate gates. Reopened goals are explicit. */
  33  class RpgDevelopment: object
  34      id = nil
  35      profile = nil
  36      kind = 'skill'
  37      training = nil
  38      teacher = nil
  39      attribute = nil
  40      trait = nil
  41      target = 1
  42      cost = 1
  43      unlocked = nil
  44      requiresDowntime = true
  45      purchased = nil
  46      reopens = []
  47      available(profile) { return nil; }
  48      apply(profile) { return nil; }
  49  ;
  50  
  51  /** Temporary award or purchase assessment/result. No output is implicit. */
  52  class RpgProgressResult: object
  53      profile = nil
  54      id = nil
  55      kind = nil
  56      status = 'blocked'
  57      reason = nil
  58      points = 0
  59  ;
  60  
  61  rpgAwardSlot(profile, milestone)
  62  {
  63      local found = nil;
  64      for (local receipt = firstObj(RpgAward); receipt != nil; receipt = nextObj(receipt, RpgAward))
  65          if (receipt.profile == profile && receipt.milestone == milestone)
  66          {
  67              if (found != nil) throw new RpgError();
  68              found = receipt;
  69          }
  70      if (found == nil) throw new RpgError();
  71      return found;
  72  }
  73  
  74  /** Commit an eligible milestone exactly once per profile. Receipt, bank and
  75   * unlock consequences are in the same gameplay transaction. */
  76  rpgAwardMilestone(profile, milestone)
  77  {
  78      if (profile == nil || !profile.ofKind(RpgProfile)
  79          || milestone == nil || !milestone.ofKind(RpgMilestone)) throw new RpgError();
  80      local receipt = rpgAwardSlot(profile, milestone);
  81      local value = new RpgProgressResult(); value.profile = profile;
  82      value.id = milestone.id; value.kind = 'award';
  83      if (receipt.claimed) { value.status = 'reused'; return value; }
  84      value.reason = milestone.available(profile);
  85      if (value.reason != nil) return value;
  86      rpgInteger(profile.earnedPoints, 0, 1000000 - milestone.points);
  87      receipt.claimed = true; profile.earnedPoints += milestone.points;
  88      profile.unspentPoints += milestone.points;
  89      value.status = 'resolved'; value.points = milestone.points;
  90      milestone.apply(profile); return value;
  91  }
  92  
  93  rpgDevelopmentCost(offer)
  94  {
  95      local p = offer.profile;
  96      if (offer.kind == 'skill') return offer.target - offer.training.points;
  97      if (offer.kind == 'attribute')
  98      {
  99          local price = offer.attribute == rpgDX || offer.attribute == rpgIQ ? 20
 100              : offer.attribute == rpgST || offer.attribute == rpgHT ? 10 : 5;
 101          return (offer.target - p.attributeScore(offer.attribute)) * price;
 102      }
 103      return offer.cost;
 104  }
 105  
 106  /** Observational purchase query. It consumes no points, randomness or history. */
 107  rpgAssessDevelopment(offer)
 108  {
 109      if (offer == nil || !offer.ofKind(RpgDevelopment)) throw new RpgError();
 110      local p = offer.profile;
 111      local value = new RpgProgressResult(); value.profile = p;
 112      value.id = offer.id; value.kind = 'purchase';
 113      value.points = rpgDevelopmentCost(offer);
 114      if (offer.purchased || value.points <= 0
 115          || offer.kind == 'trait' && holds(p.traits, offer.trait)
 116          || offer.kind == 'remove-trait' && !holds(p.traits, offer.trait))
 117      { value.status = 'reused'; value.points = 0; return value; }
 118      if (!offer.unlocked) { value.reason = 'rpg.development-locked'; return value; }
 119      if (offer.requiresDowntime && !p.developmentOpen)
 120      { value.reason = 'rpg.downtime-required'; return value; }
 121      if (offer.kind == 'attribute' && offer.attribute == rpgIQ)
 122      {
 123          local delta = offer.target - p.iq;
 124          if (p.will + delta > 100 || p.perception + delta > 100)
 125          { value.reason = 'rpg.attribute-limit'; return value; }
 126      }
 127      if (offer.kind == 'skill')
 128      {
 129          value.reason = p.skillLearningWithTeacherReason(offer.training.skill, offer.teacher);
 130          if (value.reason != nil) return value;
 131      }
 132      value.reason = offer.available(p);
 133      if (value.reason != nil) return value;
 134      if (value.points > p.unspentPoints) { value.reason = 'rpg.insufficient-points'; return value; }
 135      value.status = 'ready'; return value;
 136  }
 137  
 138  /** Commit a current affordable offer, with no refunds or automatic rerolls.
 139   * Attribute purchases preserve Will/Perception offsets and energy deficits. */
 140  rpgDevelop(offer)
 141  {
 142      local value = rpgAssessDevelopment(offer);
 143      if (value.status != 'ready') return value;
 144      local p = offer.profile;
 145      rpgInteger(p.developmentRevision, 0, 2147483646);
 146      offer.purchased = true; p.unspentPoints -= value.points;
 147      if (offer.kind == 'skill') offer.training.points = offer.target;
 148      else if (offer.kind == 'attribute')
 149      {
 150          local energy = p.energy; local capacity = p.maxEnergy;
 151          local delta = offer.target - p.attributeScore(offer.attribute);
 152          if (offer.attribute == rpgST) p.st = offer.target;
 153          else if (offer.attribute == rpgDX) p.dx = offer.target;
 154          else if (offer.attribute == rpgHT) p.ht = offer.target;
 155          else if (offer.attribute == rpgWill) p.will = offer.target;
 156          else if (offer.attribute == rpgPerception) p.perception = offer.target;
 157          else
 158          {
 159              local will = p.will; local perception = p.perception;
 160              p.iq = offer.target; p.will = will + delta; p.perception = perception + delta;
 161          }
 162          p.energy = energy + p.maxEnergy - capacity;
 163      }
 164      else if (offer.kind == 'trait') p.traits = p.traits + [offer.trait];
 165      else
 166      {
 167          local kept = [];
 168          foreach (local trait in p.traits) if (trait != offer.trait) kept = kept + [trait];
 169          p.traits = kept;
 170      }
 171      p.developmentRevision += 1;
 172      foreach (local goal in offer.reopens) goal.advance();
 173      value.status = 'resolved'; offer.apply(p); return value;
 174  }
 175  
 176  /** Explicit publication of an award or purchase result. Call only for a
 177   * player-visible character; the library never reveals NPC development itself. */
 178  rpgPublishProgress(value)
 179  {
 180      event('rpg.progress'); eventValue(1); eventValue(value.profile.actor);
 181      eventValue(value.kind); eventValue(value.id); eventValue(value.status);
 182      eventValue(value.reason); eventValue(value.points);
 183      eventValue(value.profile.unspentPoints); eventValue(value.profile.earnedPoints);
 184      eventValue(value.profile.developmentRevision); return nil;
 185  }
 186  
 187  rpgProgressionValidation: InitObject
 188      execute()
 189      {
 190          for (local p = firstObj(RpgProfile); p != nil; p = nextObj(p, RpgProfile))
 191          {
 192              rpgInteger(p.earnedPoints, 0, 1000000); rpgInteger(p.unspentPoints, 0, p.earnedPoints);
 193              rpgInteger(p.developmentRevision, 0, 2147483646);
 194          }
 195          local ids = [];
 196          for (local m = firstObj(RpgMilestone); m != nil; m = nextObj(m, RpgMilestone))
 197          {
 198              if (dataType(m.id) != TypeSString || m.id == '' || holds(ids, m.id)) throw new RpgError();
 199              ids = ids + [m.id]; rpgInteger(m.points, 1, 10000);
 200          }
 201          for (local a = firstObj(RpgAward); a != nil; a = nextObj(a, RpgAward))
 202          {
 203              if (a.profile == nil || !a.profile.ofKind(RpgProfile)
 204                  || a.milestone == nil || !a.milestone.ofKind(RpgMilestone)
 205                  || rpgAwardSlot(a.profile, a.milestone) != a) throw new RpgError();
 206          }
 207          ids = [];
 208          for (local o = firstObj(RpgDevelopment); o != nil; o = nextObj(o, RpgDevelopment))
 209          {
 210              if (dataType(o.id) != TypeSString || o.id == '' || holds(ids, o.id)
 211                  || o.profile == nil || !o.profile.ofKind(RpgProfile)) throw new RpgError();
 212              ids = ids + [o.id];
 213              if (o.kind == 'skill')
 214              {
 215                  if (o.training == nil || !o.training.ofKind(RpgTraining)
 216                      || !holds(o.profile.skills, o.training)) throw new RpgError();
 217                  local owners = 0;
 218                  for (local p = firstObj(RpgProfile); p != nil; p = nextObj(p, RpgProfile))
 219                      if (holds(p.skills, o.training)) owners += 1;
 220                  if (owners != 1) throw new RpgError();
 221                  rpgInteger(o.target, 1, 10000);
 222              }
 223              else if (o.kind == 'attribute')
 224              { rpgValidateAttribute(o.attribute); rpgInteger(o.target, 1, 100); }
 225              else if (o.kind == 'trait' || o.kind == 'remove-trait')
 226              {
 227                  if (o.trait == nil || !o.trait.ofKind(RpgTrait)) throw new RpgError();
 228                  rpgInteger(o.cost, 1, 10000);
 229              }
 230              else throw new RpgError();
 231              local goals = [];
 232              foreach (local goal in o.reopens)
 233              {
 234                  if (!goal.ofKind(RpgChallenge) || holds(goals, goal)) throw new RpgError();
 235                  goals = goals + [goal];
 236              }
 237          }
 238          return nil;
 239      }
 240  ;