lib/vhyl-rpg-resilience.t¶
Reference index ยท View on GitHub
1 /* Optional injury, effort and recovery for authored adventures. 2 * Include after vhyl-rpg.t. Copyright (c) 2026 John Cunningham. MIT License. */ 3 4 modify RpgProfile 5 resilience = nil 6 blockedReason(c) 7 { 8 local reason = inherited(c); 9 if (reason != nil || self.resilience == nil) return reason; 10 local body = self.resilience; 11 if (body.dead) return 'rpg.dead'; 12 if (body.unconscious) return 'rpg.unconscious'; 13 if (body.stunned) return 'rpg.stunned'; 14 if (c.approach.requiresExertion && self.maxEnergy > 0 && self.energy == 0) 15 return 'rpg.exhausted'; 16 return nil; 17 } 18 modifiersFor(c) 19 { 20 local values = inherited(c); 21 if (self.resilience == nil) return values; 22 local attribute = c.skill != nil ? c.skill.attribute 23 : c.isOpposition ? c.approach.oppositionAttribute : c.approach.attribute; 24 if ((attribute == rpgDX || attribute == rpgIQ) && self.resilience.shock > 0) 25 values = values + ['condition', 'rpg.modifier.shock', -self.resilience.shock]; 26 if (attribute == rpgST && self.maxEnergy > 0 && self.energy * 3 < self.maxEnergy) 27 values = values + ['condition', 'rpg.modifier.exhaustion', 28 (self.attributeScore(rpgST) + 1) / 2 - self.attributeScore(rpgST)]; 29 return values; 30 } 31 afterAttempt(c, result) 32 { 33 inherited(c, result); 34 if (self.resilience != nil) self.resilience.endAction(); 35 return nil; 36 } 37 ; 38 39 modify RpgApproach 40 requiresExertion = true 41 ; 42 43 /** Authored persistent body, bound one-to-one to a profile. Injury is a deficit 44 * from current capacity. Defeat at zero HP is deterministic; lethal outcomes 45 * require an explicit author opt-in. No parser-turn death or healing timers. */ 46 class RpgBody: object 47 profile = nil 48 injury = 0 49 shock = 0 50 stunned = nil 51 unconscious = nil 52 dead = nil 53 lethal = nil 54 aidRemaining = 0 55 restSeconds = 0 56 healingSeconds = 0 57 wakingSeconds = 0 58 hp() { return self.profile.maxHP - self.injury; } 59 mobilityDivisor() 60 { 61 return self.hp() * 3 < self.profile.maxHP 62 || self.profile.maxEnergy > 0 && self.profile.energy * 3 < self.profile.maxEnergy ? 2 : 1; 63 } 64 condition() 65 { 66 if (self.dead) return 'dead'; 67 if (self.unconscious) return 'unconscious'; 68 if (self.stunned) return 'stunned'; 69 if (self.hp() * 3 < self.profile.maxHP) return 'badly-hurt'; 70 if (self.profile.maxEnergy > 0 && self.profile.energy * 3 < self.profile.maxEnergy) 71 return 'exhausted'; 72 return self.injury > 0 ? 'wounded' : 'well'; 73 } 74 endAction() 75 { 76 self.shock = 0; self.restSeconds = 0; self.healingSeconds = 0; 77 self.wakingSeconds = 0; 78 return nil; 79 } 80 ; 81 82 /** Temporary injury/recovery result. Trusted code chooses whether to publish it. */ 83 class RpgCareResult: object 84 status = 'blocked' 85 reason = nil 86 injury = 0 87 healed = 0 88 energyRestored = 0 89 seconds = 0 90 check = nil 91 ; 92 93 /** Original bounded care procedure: includes bandaging, then one skill check. 94 * One charge and the declared duration buy one attempt. Healing is capped by 95 * untreated injury; new minor wounds cannot reopen old treatment budgets. */ 96 class RpgTreatment: object 97 skill = nil 98 modifier = 0 99 seconds = 660 100 charges = 1 101 healingBonus = 0 102 available(healer, patient) { return nil; } 103 ; 104 105 rpgBody(profile) 106 { 107 if (profile == nil || !profile.ofKind(RpgProfile) || profile.resilience == nil 108 || !profile.resilience.ofKind(RpgBody) || profile.resilience.profile != profile) 109 throw new RpgError(); 110 return profile.resilience; 111 } 112 113 /** Pure armor and wounding arithmetic. Positive penetration always wounds at 114 * least one HP. Ratios are explicit authored numbers, not weapon taxonomies. */ 115 rpgInjuryFor(damage, armor, numerator, denominator) 116 { 117 rpgInteger(damage, 0, 10000); rpgInteger(armor, 0, 10000); 118 rpgInteger(numerator, 1, 10); rpgInteger(denominator, 1, 10); 119 local penetration = damage - armor; 120 if (penetration <= 0) return 0; 121 local injury = penetration * numerator / denominator; 122 return injury < 1 ? 1 : injury; 123 } 124 125 /** Commit injury after armor. Major wounds make one health check; zero HP 126 * incapacitates. A lethal body dies at minus five times its maximum HP. */ 127 rpgWound(profile, damage, armor, numerator, denominator) 128 { 129 local body = rpgBody(profile); 130 local amount = rpgInjuryFor(damage, armor, numerator, denominator); 131 local value = new RpgCareResult(); 132 if (body.dead) { value.reason = 'rpg.dead'; return value; } 133 value.status = 'resolved'; 134 if (amount == 0) return value; 135 local capacity = profile.maxHP; 136 local room = capacity * 6 - body.injury; 137 if (amount > room) amount = room; 138 if (amount == 0) return value; 139 body.injury += amount; body.aidRemaining += amount; 140 local shock = body.shock + amount; 141 body.endAction(); 142 body.shock = shock > 4 ? 4 : shock; 143 value.injury = amount; 144 if (amount * 2 > capacity && !body.unconscious) 145 { 146 local health = profile.attributeScore(rpgHT); local roll = rpgRandom.roll(); 147 value.check = rpgCheck(health, roll); 148 if (!value.check.achieved) 149 { 150 body.stunned = true; 151 if (health - roll <= -5 || value.check.critical == 'failure') 152 body.unconscious = true; 153 } 154 } 155 if (body.hp() <= 0) body.unconscious = true; 156 if (body.lethal && body.hp() <= -5 * capacity) body.dead = true; 157 return value; 158 } 159 160 /** Treat only untreated injury. Ordinary failure still supplies one HP of 161 * bandaging; critical failure adds two HP instead. Never grants a free retry. */ 162 rpgTreat(healer, patient, treatment) 163 { 164 local body = rpgBody(patient); 165 if (healer == nil || !healer.ofKind(RpgProfile) 166 || treatment == nil || !treatment.ofKind(RpgTreatment)) throw new RpgError(); 167 local value = new RpgCareResult(); 168 if (body.dead) { value.reason = 'rpg.dead'; return value; } 169 if (healer.resilience != nil && (healer.resilience.dead || healer.resilience.unconscious 170 || healer.resilience.stunned)) { value.reason = 'rpg.unable-to-treat'; return value; } 171 if (body.injury == 0) { value.status = 'complete'; return value; } 172 if (body.aidRemaining == 0) { value.reason = 'rpg.already-treated'; return value; } 173 value.reason = treatment.available(healer, patient); 174 if (value.reason != nil) return value; 175 if (treatment.charges <= 0) { value.reason = 'rpg.no-medical-supplies'; return value; } 176 local method = new RpgApproach(); method.skill = treatment.skill; 177 method.modifier = treatment.modifier; method.requiresExertion = nil; 178 local score = rpgCalculate(rpgContext(healer, method, nil)).effectiveScore; 179 if (score == nil) { value.reason = 'training-required'; return value; } 180 if (score < 3) { value.reason = 'too-difficult'; return value; } 181 value.status = 'resolved'; value.seconds = treatment.seconds; 182 value.check = rpgCheck(score, rpgRandom.roll()); 183 treatment.charges -= 1; rpgSpendTime(treatment.seconds); 184 if (healer.resilience != nil) healer.resilience.endAction(); 185 if (value.check.critical == 'failure') 186 { 187 local injury = rpgWound(patient, 2, 0, 1, 1); 188 value.injury = injury.injury; 189 } 190 else 191 { 192 local amount = 1; 193 if (value.check.achieved) 194 amount = value.check.critical == 'success' ? 6 : rpgRandom.die(); 195 amount += value.check.achieved ? treatment.healingBonus : 0; 196 if (amount < 1) amount = 1; 197 if (amount > body.aidRemaining) amount = body.aidRemaining; 198 if (amount > body.injury) amount = body.injury; 199 body.injury -= amount; value.healed = amount; 200 if (body.hp() > 0 && (patient.maxEnergy == 0 || patient.energy > 0)) body.unconscious = nil; 201 } 202 body.aidRemaining = 0; 203 return value; 204 } 205 206 /** Explicit uninterrupted quiet rest, charged in seconds. One energy per ten 207 * minutes; one health check per full day of rest for one HP. No WAIT hook. */ 208 rpgRest(profile, seconds) 209 { 210 local body = rpgBody(profile); rpgInteger(seconds, 1, 86400); 211 local value = new RpgCareResult(); 212 if (body.dead) { value.reason = 'rpg.dead'; return value; } 213 value.status = 'resolved'; value.seconds = seconds; rpgSpendTime(seconds); 214 body.shock = 0; 215 local needed = profile.maxEnergy - profile.energy; 216 local effortTime = body.restSeconds + seconds; 217 local restored = effortTime / 600; 218 if (restored > needed) restored = needed; 219 profile.energy += restored; value.energyRestored = restored; 220 body.restSeconds = profile.energy < profile.maxEnergy ? effortTime % 600 : 0; 221 if (body.injury > 0) 222 { 223 body.healingSeconds += seconds; 224 if (body.healingSeconds >= 86400) 225 { 226 body.healingSeconds -= 86400; 227 value.check = rpgCheck(profile.attributeScore(rpgHT), rpgRandom.roll()); 228 if (value.check.achieved) 229 { 230 body.injury -= 1; value.healed = 1; 231 if (body.aidRemaining > body.injury) body.aidRemaining = body.injury; 232 } 233 } 234 } 235 else body.healingSeconds = 0; 236 if (body.hp() > 0 && (profile.maxEnergy == 0 || profile.energy > 0)) 237 { 238 body.wakingSeconds += seconds; 239 if (body.wakingSeconds >= 900) 240 { body.wakingSeconds = 900; body.unconscious = nil; body.stunned = nil; } 241 } 242 return value; 243 } 244 245 /** One committed recovery maneuver. A failed health check leaves stun in place. */ 246 rpgRecoverStun(profile) 247 { 248 local body = rpgBody(profile); local value = new RpgCareResult(); 249 if (body.dead || body.unconscious) { value.reason = 'rpg.unconscious'; return value; } 250 if (!body.stunned) { value.status = 'complete'; return value; } 251 value.status = 'resolved'; value.seconds = 1; rpgSpendTime(1); 252 value.check = rpgCheck(profile.attributeScore(rpgHT), rpgRandom.roll()); 253 body.endAction(); 254 if (value.check.achieved) body.stunned = nil; 255 return value; 256 } 257 258 /** Explicit player-visible condition summary. Calling this is a disclosure 259 * decision; hidden NPC bodies must not be published automatically. */ 260 rpgPublishBody(profile) 261 { 262 local body = rpgBody(profile); 263 event('rpg.body'); eventValue(1); eventValue(profile.actor); 264 eventValue(body.condition()); eventValue(body.hp()); eventValue(profile.maxHP); 265 eventValue(profile.energy); eventValue(profile.maxEnergy); eventValue(body.mobilityDivisor()); 266 return nil; 267 } 268 269 rpgResilienceValidation: InitObject 270 execute() 271 { 272 for (local body = firstObj(RpgBody); body != nil; body = nextObj(body, RpgBody)) 273 { 274 if (rpgBody(body.profile) != body) throw new RpgError(); 275 rpgInteger(body.injury, 0, body.profile.maxHP * 6); 276 rpgInteger(body.aidRemaining, 0, body.injury); 277 rpgInteger(body.shock, 0, 4); 278 rpgInteger(body.restSeconds, 0, 599); rpgInteger(body.healingSeconds, 0, 86399); 279 rpgInteger(body.wakingSeconds, 0, 900); 280 if (body.hp() <= 0 || body.dead) body.unconscious = true; 281 if (body.lethal && body.hp() <= -5 * body.profile.maxHP) body.dead = true; 282 } 283 for (local p = firstObj(RpgProfile); p != nil; p = nextObj(p, RpgProfile)) 284 if (p.resilience != nil) rpgBody(p); 285 for (local care = firstObj(RpgTreatment); care != nil; care = nextObj(care, RpgTreatment)) 286 { 287 if (care.skill == nil || !care.skill.ofKind(RpgSkill)) throw new RpgError(); 288 rpgInteger(care.modifier, -500, 500); rpgInteger(care.seconds, 60, 86400); 289 rpgInteger(care.charges, 0, 10000); rpgInteger(care.healingBonus, -5, 10); 290 } 291 return nil; 292 } 293 ;