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 ;