lib/vhyl-rpg-expanded-fright.t¶
Reference index ยท View on GitHub
1 /* Fright Checks. Include after the expanded foundation and expanded traits. 2 * Source: full-2008, B360-B361 (procedure, modifiers, results); trait effects 3 * B127 (Combat Paralysis), B129 (Cowardice), B135 (Fearfulness), B162 (Xenophilia). 4 * 5 * Dice are drawn in a fixed order: the check, the results roll, then each row's 6 * own dice in the order its effects are listed in docs/expanded/fright.md. 7 * Effects that need an author decision (which quirk, which phobia, which 8 * physical change) are recorded as pending acquisitions, never invented. */ 9 10 /** Authored facts about one frightening encounter. All fields are optional. */ 11 class RpgFrightSituation: object 12 threat = nil 13 monster = 0 14 count = 1 15 body = nil 16 mutilation = 1 17 dependent = nil 18 inCombat = nil 19 touched = nil 20 viewing = 'present' 21 isolated = nil 22 dark = nil 23 alone = nil 24 experienced = nil 25 briefing = 0 26 physicalDanger = nil 27 charging = nil 28 swornFoe = nil 29 strange = nil 30 modifier = 0 31 ; 32 33 /** A mental or physical change the author must choose; points are the required total. */ 34 class RpgFrightAcquisition: object 35 kind = 'quirk' 36 points = -1 37 row = nil 38 ; 39 40 /** A timed fear condition. Only one is active per character; a new one replaces it. */ 41 class RpgFrightEpisode: object 42 kind = 'stun' 43 fixed = 0 44 interval = 1 45 clock = 0 46 recovery = 'automatic' 47 score = nil 48 extendSeconds = 0 49 elapsed = 0 50 days = 0 51 medicalCare = nil 52 penaltySeconds = 0 53 ; 54 55 /** Receipt for one Fright Check or one applied results row. */ 56 class RpgFrightResult: object 57 status = 'blocked' 58 reason = nil 59 check = nil 60 score = nil 61 modifier = 0 62 tableRoll = nil 63 total = nil 64 row = nil 65 episode = nil 66 acquisitions = [] 67 fatigue = 0 68 injury = 0 69 panicRoll = nil 70 ; 71 72 modify RpgLiteProfile 73 frightEpisode = nil 74 frightPending = [] 75 frightExposures = [] 76 panicked = nil 77 retching = nil 78 conditionTime(seconds) 79 { 80 /* A penalty that begins as an episode ends only counts the seconds after it. */ 81 local lingering = self.lingeringSeconds > 0; 82 local left = self.frightEpisode != nil ? rpgFrightAdvance(self, seconds) : seconds; 83 if (self.lingeringSeconds > 0) 84 { 85 self.lingeringSeconds -= lingering ? seconds : left; 86 if (self.lingeringSeconds <= 0) { self.lingeringSeconds = 0; self.lingeringPenalty = 0; } 87 } 88 return nil; 89 } 90 ; 91 92 /* Holds Lite's fixed-fear timer while an episode owns the character's recovery. */ 93 rpgFrightHold: object seconds = 2000000000; 94 95 /** Penalty for a horde: -1 at 5 monsters, -2 at 10, -3 at 20, -4 at 50, -5 at 100+. */ 96 rpgFrightHordePenalty(count) 97 { 98 rpgInteger(count, 1, 1000000); 99 return count >= 100 ? -5 : count >= 50 ? -4 : count >= 20 ? -3 : count >= 10 ? -2 : count >= 5 ? -1 : 0; 100 } 101 102 /** Cowardice and Xenophilia scale with self-control: 4, 3, 2 or 1 at 6, 9, 12 or 15. */ 103 rpgFrightControlStep(trait) 104 { 105 local rating = trait.ofKind(RpgExpandedDisadvantage) && trait.selfControlRating != nil 106 ? trait.selfControlRating : 12; 107 return rating <= 6 ? 4 : rating <= 9 ? 3 : rating <= 12 ? 2 : 1; 108 } 109 110 /** Earlier exposures to the same threat within the last 24 game hours. Pure. */ 111 rpgFrightExposures(profile, threat) 112 { 113 if (threat == nil) return 0; 114 local count = 0; 115 foreach (local entry in profile.frightExposures) 116 if (entry[1] == threat && rpgLedger.seconds - entry[2] < 86400) count += 1; 117 return count; 118 } 119 120 /** Situation and conditional trait modifiers. Fearlessness and Combat Reflexes 121 * are applied by the shared Lite score for kind 'fright'. Pure. */ 122 rpgFrightModifier(profile, situation) 123 { 124 local s = situation; 125 rpgInteger(s.monster, -10, 0); rpgInteger(s.briefing, 0, 3); rpgInteger(s.mutilation, 1, 3); 126 rpgInteger(s.modifier, -100, 100); 127 local amount = s.modifier + s.monster + rpgFrightHordePenalty(s.count); 128 if (s.body == 'prepared') amount += 6; 129 else if (s.body == 'peaceful') amount += 2; 130 else if (s.body == 'mutilated') amount -= s.mutilation; 131 else if (s.body != nil && s.body != 'violent') rpgExpandedReject('fright-body'); 132 if (s.dependent) 133 { 134 if (s.body == nil) rpgExpandedReject('fright-dependent-without-body'); 135 amount -= 6; 136 } 137 if (s.inCombat) amount += 5; 138 if (s.touched) amount -= 1; 139 if (s.viewing == 'distant') amount += 1; 140 else if (s.viewing == 'remote') amount += 3; 141 else if (s.viewing != 'present') rpgExpandedReject('fright-viewing'); 142 if (s.isolated) amount -= 1; 143 if (s.dark) amount -= 1; 144 if (s.alone) amount -= 2; 145 if (s.experienced) amount += 1; 146 amount += s.briefing + rpgFrightExposures(profile, s.threat); 147 amount -= profile.level('fearfulness') + 2 * profile.level('combat-paralysis'); 148 if (s.physicalDanger && profile.level('cowardice') > 0) 149 amount -= rpgFrightControlStep(profile.trait('cowardice')); 150 if (s.charging && profile.level('daredevil') > 0) amount += 1; 151 if (s.swornFoe && profile.level('higher-purpose') > 0) amount += 1; 152 if (s.strange && profile.level('xenophilia') > 0) 153 amount += rpgFrightControlStep(profile.trait('xenophilia')); 154 return amount; 155 } 156 157 /** Modified Will before the Rule of 14. Pure. */ 158 rpgFrightModifiedWill(profile, situation) 159 { 160 local context = new RpgLiteContext(); context.attribute = rpgWill; context.kind = 'fright'; 161 context.modifier = rpgFrightModifier(profile, situation); 162 return rpgLiteScore(profile, context); 163 } 164 165 /** Fright Check target: modified Will, reduced to 13 (the Rule of 14). Pure. */ 166 rpgFrightScore(profile, situation) 167 { 168 local score = rpgFrightModifiedWill(profile, situation); 169 return score > 13 ? 13 : score; 170 } 171 172 /** Roll 3d against a score. Below 3 the roll still sets a margin; 3 or 4 always succeeds. */ 173 rpgFrightRoll(profile, score) 174 { 175 local roll = rpgRandom.roll(); 176 local result; 177 if (score >= 3) result = rpgCheck(score, roll); 178 else 179 { 180 result = new RpgResult(); result.status = 'resolved'; result.score = score; 181 result.roll = roll; result.margin = score - roll; result.achieved = roll <= 4; 182 result.critical = roll <= 4 ? 'success' : roll == 18 || roll == 17 || roll - score >= 10 ? 'failure' : nil; 183 result.outcome = result.achieved ? 'success' : 'setback'; 184 } 185 profile.lastCheck = result; profile.lastCheckDraw = rpgRandom.draws; 186 return result; 187 } 188 189 /** Whether a fear condition currently prevents purposeful action. */ 190 rpgFrightIncapacitated(profile) 191 { 192 return profile.frightEpisode != nil; 193 } 194 195 /** Make a Fright Check and apply any failure. Call from Action.exec. */ 196 rpgFrightCheck(profile, situation) 197 { 198 rpgExpandedValidate(profile); 199 local result = new RpgFrightResult(); 200 if (profile.dead || profile.unconscious) { result.reason = 'incapacitated'; return result; } 201 if (profile.level('unfazeable') > 0) { result.status = 'exempt'; return result; } 202 local modified = rpgFrightModifiedWill(profile, situation); 203 result.modifier = rpgFrightModifier(profile, situation); 204 result.score = modified > 13 ? 13 : modified; 205 if (situation.threat != nil) 206 { 207 local kept = []; 208 foreach (local entry in profile.frightExposures) 209 if (rpgLedger.seconds - entry[2] < 86400) kept = kept + [entry]; 210 profile.frightExposures = kept + [[situation.threat, rpgLedger.seconds]]; 211 } 212 result.check = rpgFrightRoll(profile, result.score); 213 if (result.check.achieved) { result.status = 'resisted'; return result; } 214 result.tableRoll = rpgRandom.roll(); 215 rpgFrightApplyRow(profile, result, result.tableRoll - result.check.margin, modified); 216 return result; 217 } 218 219 /** Apply one results row directly, e.g. for an author-chosen substitute result. */ 220 rpgFrightApply(profile, total, modifiedWill) 221 { 222 rpgExpandedValidate(profile); 223 rpgInteger(total, 4, 1000); rpgInteger(modifiedWill, -1000, 1000); 224 local result = new RpgFrightResult(); 225 rpgFrightApplyRow(profile, result, total, modifiedWill); 226 return result; 227 } 228 229 /* Begin an episode, replacing any current one. */ 230 rpgFrightBegin(profile, result, kind, fixed, recovery, interval, score) 231 { 232 rpgFrightEnd(profile); 233 local episode = new RpgFrightEpisode(); 234 episode.kind = kind; episode.fixed = fixed; episode.recovery = recovery; 235 episode.interval = interval; episode.score = score; 236 profile.frightEpisode = episode; profile.fearSeconds = rpgFrightHold.seconds; 237 if (kind == 'stun') profile.stunned = true; 238 else if (kind == 'seizure') { profile.stunned = true; profile.posture = 'lying'; } 239 else if (kind == 'panic' || kind == 'total-panic') profile.panicked = true; 240 else if (kind == 'retch') profile.retching = true; 241 else { profile.unconscious = true; profile.posture = 'lying'; } 242 result.episode = episode; 243 return episode; 244 } 245 246 /* Clear the flags an episode set and start any lingering penalty. */ 247 rpgFrightEnd(profile) 248 { 249 local episode = profile.frightEpisode; 250 if (episode == nil) return nil; 251 profile.frightEpisode = nil; profile.fearSeconds = 0; 252 if (episode.kind == 'stun' || episode.kind == 'seizure') profile.stunned = nil; 253 else if (episode.kind == 'panic' || episode.kind == 'total-panic') profile.panicked = nil; 254 else if (episode.kind == 'retch') profile.retching = nil; 255 else if (profile.hp() > 0 && profile.fp() > 0) profile.unconscious = nil; 256 if (episode.penaltySeconds > 0) 257 { 258 profile.lingeringPenalty = 2; 259 if (episode.penaltySeconds > profile.lingeringSeconds) profile.lingeringSeconds = episode.penaltySeconds; 260 } 261 return nil; 262 } 263 264 /* Recovery roll at the end of a fixed period or interval. */ 265 rpgFrightRecovers(profile, episode) 266 { 267 if (episode.recovery == 'automatic') return true; 268 if (episode.recovery == 'will') return rpgFrightRoll(profile, profile.will).achieved; 269 if (episode.recovery == 'modified-will') return rpgFrightRoll(profile, episode.score).achieved; 270 return rpgFrightRoll(profile, profile.ht).achieved; 271 } 272 273 /** Advance the active episode by game seconds; called from rpgLiteHealthTime. 274 * Returns the seconds left over after the episode ended (0 while it continues). */ 275 rpgFrightAdvance(profile, seconds) 276 { 277 rpgInteger(seconds, 0, 86400); 278 while (seconds > 0 && profile.frightEpisode != nil && !profile.dead) 279 { 280 local episode = profile.frightEpisode; 281 if (episode.kind == 'total-panic') { seconds = 0; break; } 282 local step = episode.fixed > 0 ? episode.fixed : episode.interval - episode.clock; 283 if (step > seconds) step = seconds; 284 if (episode.kind == 'catatonia') 285 { 286 local toDay = 86400 - episode.elapsed % 86400; 287 if (step > toDay) step = toDay; 288 } 289 seconds -= step; episode.elapsed += step; 290 if (episode.kind == 'catatonia' && episode.elapsed % 86400 == 0) 291 { 292 episode.days += 1; 293 if (!episode.medicalCare) rpgLiteWound(profile, episode.days); 294 if (profile.dead) return 0; 295 } 296 if (episode.fixed > 0) 297 { 298 episode.fixed -= step; 299 if (episode.fixed > 0) continue; 300 if (episode.extendSeconds > 0) 301 { 302 if (rpgFrightRoll(profile, profile.ht).achieved) { rpgFrightFinish(profile, episode); continue; } 303 episode.fixed = rpgRandom.die() * episode.extendSeconds; continue; 304 } 305 if (episode.recovery == 'automatic') { rpgFrightFinish(profile, episode); continue; } 306 episode.clock = 0; continue; 307 } 308 episode.clock += step; 309 if (episode.clock < episode.interval) continue; 310 episode.clock = 0; 311 if (rpgFrightRecovers(profile, episode)) rpgFrightFinish(profile, episode); 312 } 313 if (profile.frightEpisode != nil) { profile.fearSeconds = rpgFrightHold.seconds; return 0; } 314 return seconds; 315 } 316 317 /* Finish an episode; catatonia's lingering penalty lasts as many days as it did. */ 318 rpgFrightFinish(profile, episode) 319 { 320 if (episode.kind == 'catatonia') episode.penaltySeconds = (episode.elapsed + 86399) / 86400 * 86400; 321 rpgFrightEnd(profile); 322 return nil; 323 } 324 325 /** After an author resolves a total-panic reaction, roll Will to come out of it. 326 * Returns the next 3d reaction roll, or nil when the panic ends. */ 327 rpgFrightPanicReaction(profile) 328 { 329 local episode = profile.frightEpisode; 330 if (episode == nil || episode.kind != 'total-panic') rpgExpandedReject('fright-no-total-panic'); 331 if (rpgFrightRoll(profile, profile.will).achieved) { rpgFrightEnd(profile); return nil; } 332 return rpgRandom.roll(); 333 } 334 335 /* Record a pending author choice. */ 336 rpgFrightPending(profile, result, kind, points, row) 337 { 338 local item = new RpgFrightAcquisition(); item.kind = kind; item.points = points; item.row = row; 339 profile.frightPending = profile.frightPending + [item]; 340 result.acquisitions = result.acquisitions + [item]; 341 return item; 342 } 343 344 /* Faint for 1d minutes, then HT each minute; an immediate HT roll risks 1 HP (row 18). */ 345 rpgFrightFaint(profile, result, row) 346 { 347 local minutes = rpgRandom.die(); 348 rpgFrightBegin(profile, result, 'faint', minutes * 60, 'ht', 60, nil); 349 if (!rpgFrightRoll(profile, profile.ht).achieved) result.injury += rpgLiteWound(profile, 1).injury; 350 return nil; 351 } 352 353 /* Coma: 1d hours, then HT; each failure adds 1d hours. Waking leaves -2 for 6 hours. */ 354 rpgFrightComa(profile, result) 355 { 356 local episode = rpgFrightBegin(profile, result, 'coma', rpgRandom.die() * 3600, 'ht', 1, nil); 357 episode.extendSeconds = 3600; episode.penaltySeconds = 21600; 358 return episode; 359 } 360 361 /* Lose 1d FP. */ 362 rpgFrightFatigue(profile, result) 363 { 364 local damage = rpgLiteFatigue(profile, rpgRandom.die(), 'fright'); 365 result.fatigue += damage.fatigue; result.injury += damage.injury; 366 return nil; 367 } 368 369 /* Apply the results row for a total of 3d plus the failure margin. */ 370 rpgFrightApplyRow(profile, result, total, modifiedWill) 371 { 372 result.status = 'failed'; result.total = total; 373 local row = total > 40 ? 40 : total; result.row = row; 374 if (row <= 5) rpgFrightBegin(profile, result, 'stun', 1, 'automatic', 1, nil); 375 else if (row <= 7) rpgFrightBegin(profile, result, 'stun', 1, 'will', 1, nil); 376 else if (row <= 9) rpgFrightBegin(profile, result, 'stun', 1, 'modified-will', 1, modifiedWill); 377 else if (row == 10) rpgFrightBegin(profile, result, 'stun', rpgRandom.die(), 'modified-will', 1, modifiedWill); 378 else if (row == 11) 379 rpgFrightBegin(profile, result, 'stun', rpgRandom.die() + rpgRandom.die(), 'modified-will', 1, modifiedWill); 380 else if (row == 12) 381 { 382 local fixed = 25 - profile.ht; 383 rpgFrightBegin(profile, result, 'retch', fixed > 0 ? fixed : 0, 'ht', 1, nil); 384 } 385 else if (row == 13) rpgFrightPending(profile, result, 'quirk', -1, row); 386 else if (row <= 15) 387 { 388 rpgFrightFatigue(profile, result); 389 rpgFrightBegin(profile, result, 'stun', rpgRandom.die(), 'modified-will', 1, modifiedWill); 390 } 391 else if (row == 16) 392 { 393 rpgFrightBegin(profile, result, 'stun', rpgRandom.die(), 'modified-will', 1, modifiedWill); 394 rpgFrightPending(profile, result, 'quirk', -1, row); 395 } 396 else if (row == 17) rpgFrightBegin(profile, result, 'faint', rpgRandom.die() * 60, 'ht', 60, nil); 397 else if (row == 18) rpgFrightFaint(profile, result, row); 398 else if (row == 19) 399 { 400 rpgFrightBegin(profile, result, 'faint', (rpgRandom.die() + rpgRandom.die()) * 60, 'ht', 60, nil); 401 result.injury += rpgLiteWound(profile, 1).injury; 402 } 403 else if (row == 20) 404 { 405 local dice = rpgRandom.die() + rpgRandom.die() + rpgRandom.die() + rpgRandom.die(); 406 rpgFrightBegin(profile, result, 'faint', dice * 60, 'automatic', 1, nil); 407 rpgFrightFatigue(profile, result); 408 } 409 else if (row == 21) rpgFrightBegin(profile, result, 'panic', rpgRandom.die() * 60, 'will', 60, nil); 410 else if (row == 22) rpgFrightPending(profile, result, 'delusion', -10, row); 411 else if (row == 23) rpgFrightPending(profile, result, 'mental', -10, row); 412 else if (row == 24) rpgFrightPending(profile, result, 'physical', -15, row); 413 else if (row == 25) rpgFrightPending(profile, result, 'worsen-or-mental', -10, row); 414 else if (row == 26) { rpgFrightFaint(profile, result, row); rpgFrightPending(profile, result, 'delusion', -10, row); } 415 else if (row == 27) { rpgFrightFaint(profile, result, row); rpgFrightPending(profile, result, 'mental', -10, row); } 416 else if (row == 28) 417 { 418 local episode = rpgFrightBegin(profile, result, 'light-coma', 0, 'ht', 1800, nil); 419 episode.penaltySeconds = 21600; 420 } 421 else if (row == 29) rpgFrightComa(profile, result); 422 else if (row == 30) 423 { 424 local episode = rpgFrightBegin(profile, result, 'catatonia', rpgRandom.die() * 86400, 'ht', 1, nil); 425 episode.extendSeconds = 86400; 426 } 427 else if (row == 31) 428 { 429 rpgFrightBegin(profile, result, 'seizure', rpgRandom.die() * 60, 'automatic', 1, nil); 430 rpgFrightFatigue(profile, result); 431 local check = rpgFrightRoll(profile, profile.ht); 432 if (!check.achieved) result.injury += rpgLiteWound(profile, rpgRandom.die()).injury; 433 if (check.critical == 'failure' && profile.ht > 1) profile.ht -= 1; 434 } 435 else if (row == 32) 436 { 437 profile.posture = 'lying'; 438 result.injury += rpgLiteWound(profile, rpgRandom.die() + rpgRandom.die()).injury; 439 } 440 else if (row == 33) 441 { 442 rpgFrightBegin(profile, result, 'total-panic', 0, 'will', 1, nil); 443 result.panicRoll = rpgRandom.roll(); 444 } 445 else if (row == 34) rpgFrightPending(profile, result, 'delusion', -15, row); 446 else if (row == 35) rpgFrightPending(profile, result, 'mental', -15, row); 447 else if (row == 36) rpgFrightPending(profile, result, 'physical', -20, row); 448 else if (row == 37) rpgFrightPending(profile, result, 'physical', -30, row); 449 else if (row == 38) { rpgFrightComa(profile, result); rpgFrightPending(profile, result, 'delusion', -15, row); } 450 else 451 { 452 rpgFrightComa(profile, result); rpgFrightPending(profile, result, 'mental', -15, row); 453 if (row == 40 && profile.iq > 1) profile.iq -= 1; 454 } 455 return result; 456 } 457 458 /** Resolve a pending acquisition with new traits whose costs total its points. 459 * For 'worsen-or-mental', pass one owned disadvantage with self-control 9-15 to 460 * worsen it one step instead. Traits gained this way may exceed the quirk limit. */ 461 rpgFrightAcquire(profile, acquisition, traits) 462 { 463 if (!holds(profile.frightPending, acquisition)) rpgExpandedReject('fright-acquisition-not-pending'); 464 if (acquisition.kind == 'worsen-or-mental' && traits.length() == 1 && holds(profile.traits, traits[1])) 465 { 466 local trait = traits[1]; 467 if (!trait.ofKind(RpgExpandedDisadvantage) || !holds([9, 12, 15], trait.selfControlRating)) 468 rpgExpandedReject('fright-cannot-worsen'); 469 trait.selfControlRating = trait.selfControlRating == 15 ? 12 : trait.selfControlRating == 12 ? 9 : 6; 470 } 471 else 472 { 473 local total = 0; 474 foreach (local trait in traits) 475 { 476 if (!trait.ofKind(RpgLiteTrait) || holds(profile.traits, trait)) rpgExpandedReject('fright-acquisition-trait'); 477 total += trait.creationCost(); 478 } 479 if (total != acquisition.points) rpgExpandedReject('fright-acquisition-points'); 480 foreach (local trait in traits) trait.acquiredInPlay = true; 481 profile.traits = profile.traits + traits; 482 } 483 local remaining = []; 484 foreach (local item in profile.frightPending) if (item != acquisition) remaining = remaining + [item]; 485 profile.frightPending = remaining; 486 return true; 487 }