lib/vhyl-rpg-lite-characters.t¶
Reference index · View on GitHub
1 /** Owned, parameterized advantage or disadvantage; scope identifies authored subjects or specialties. */ 2 class RpgLiteTrait: RpgTrait 3 id = nil 4 points = 0 5 level = 1 6 skills = [] 7 scope = nil 8 controlled = nil 9 active = true 10 mitigated = nil 11 nextCheck = 0 12 /* Gained during play (e.g. from a Fright Check); exempt from the creation quirk limit. */ 13 acquiredInPlay = nil 14 modifierFor(context) { return 0; } 15 /* Expanded cost hook; Lite callers retain their existing cost function. */ 16 creationCost() { return rpgLiteTraitCost(self); } 17 ; 18 19 /** Independent spoken and written comprehension (0–3); native language receives a six-point allowance. */ 20 class RpgLiteLanguage: object 21 id = nil 22 spoken = 3 23 written = 3 24 native = nil 25 cost() { return self.spoken + self.written - (self.native ? 6 : 0); } 26 ; 27 28 /** Catalogue skill with technology level and specialty identity. */ 29 class RpgLiteSkill: RpgSkill 30 id = nil 31 techLevel = nil 32 defaultAttribute = nil 33 alternateAttribute = nil 34 specialty = nil 35 influence = nil 36 voice = nil 37 interval = 0 38 ; 39 40 /** Lite character sheet. Injury and fatigue are deficits, not the legacy energy/body pools. */ 41 class RpgLiteProfile: RpgProfile 42 campaignTL = 3 43 techLevel = 3 44 languages = [] 45 equipment = [] 46 familiarSkills = nil 47 pointBudget = 100 48 disadvantageLimit = 50 49 creationComplete = nil 50 injury = 0 51 fatigue = 0 52 shock = 0 53 stunned = nil 54 mentalStun = nil 55 unconscious = nil 56 dead = nil 57 mortal = nil 58 apparentDeath = nil 59 posture = 'standing' 60 hunger = 0 61 thirst = 0 62 sleepDebt = 0 63 restClock = 0 64 healingClock = 0 65 unconsciousClock = 0 66 mortalClock = 0 67 airClock = 0 68 playSeconds = 0 69 luckReady = 0 70 sessionId = 0 71 unluckySession = -1 72 fearSeconds = 0 73 /* Penalty to skill rolls and attribute checks (not defenses) and its remaining seconds. */ 74 lingeringPenalty = 0 75 lingeringSeconds = 0 76 aidUsed = nil 77 money = 0 78 /* Optional modules advance their own conditions here; see rpgLiteHealthTime. */ 79 conditionTime(seconds) { return nil; } 80 hp() { return self.maxHP - self.injury; } 81 fp() { return self.maxEnergy - self.fatigue; } 82 basicSpeedQuarters() { return self.dx + self.ht; } 83 basicMove() { return self.basicSpeedQuarters() / 4; } 84 basicLiftHundredths() 85 { 86 local amount = self.st * self.st * 20; 87 return amount >= 1000 ? (amount + 50) / 100 * 100 : amount; 88 } 89 trait(id) 90 { 91 foreach (local item in self.traits) 92 if (item.ofKind(RpgLiteTrait) && item.id == id) return item; 93 return nil; 94 } 95 level(id) 96 { 97 if (id == 'bad-sight') return self.level('bad-sight-correctable') > 0 ? 1 : self.rawLevel(id); 98 if (id == 'resistant-disease') return self.level('resistant-disease-8') > 0 ? 8 : self.level('resistant-disease-3') > 0 ? 3 : 0; 99 if (id == 'resistant-poison') return self.level('resistant-poison-3') > 0 ? 3 : 0; 100 return self.rawLevel(id); 101 } 102 rawLevel(id) 103 { 104 local item = self.trait(id); 105 return item == nil || !item.active || item.mitigated ? 0 : item.level; 106 } 107 language(id, written) 108 { 109 foreach (local item in self.languages) 110 if (item.id == id) 111 { 112 local amount = written ? item.written : item.spoken; 113 if (amount > 0 && amount < 3 && self.level('language-talent') > 0) amount += 1; 114 return amount; 115 } 116 return 0; 117 } 118 loadHundredths() 119 { 120 local amount = 0; 121 foreach (local item in self.equipment) amount += item.weightHundredths(); 122 return amount; 123 } 124 encumbrance() 125 { 126 local weight = self.loadHundredths(); local lift = self.basicLiftHundredths(); 127 local factors = [1, 2, 3, 6, 10]; 128 for (local index = 1; index <= 5; index += 1) 129 if (weight <= lift * factors[index]) return index - 1; 130 return 5; 131 } 132 move() 133 { 134 local load = self.encumbrance(); 135 if (load == 5 || self.unconscious || self.dead) return 0; 136 local amount = self.basicMove() * (5 - load) / 5; 137 if (amount < 1) amount = 1; 138 if (self.hp() * 3 < self.maxHP) amount = (amount + 1) / 2; 139 if (self.fp() * 3 < self.maxEnergy) amount = (amount + 1) / 2; 140 if (self.posture == 'crouching') amount = amount * 2 / 3; 141 if (self.posture == 'kneeling' || self.posture == 'crawling') amount /= 3; 142 if (self.posture == 'sitting') amount = 0; 143 if (self.posture == 'lying') amount = 1; 144 return amount; 145 } 146 skillScore(skill) 147 { 148 if (!skill.ofKind(RpgLiteSkill)) return inherited(skill); 149 local trained = nil; 150 foreach (local training in self.skills) 151 if (training.skill == skill && training.points > 0) trained = true; 152 if (!trained && (skill.techLevel != nil && skill.techLevel > self.techLevel 153 || self.familiarSkills != nil && !holds(self.familiarSkills, skill))) return nil; 154 local score = inherited(skill); 155 if (!trained && skill.defaultPenalty != nil) 156 { 157 local attribute = skill.defaultAttribute == nil ? skill.attribute : skill.defaultAttribute; 158 local base = self.attributeScore(attribute); 159 if (base > 20) base = 20; 160 score = base + skill.defaultPenalty; 161 if (skill.alternateAttribute != nil) 162 { 163 base = self.attributeScore(skill.alternateAttribute); 164 if (base > 20) base = 20; 165 if (base + skill.defaultPenalty > score) score = base + skill.defaultPenalty; 166 } 167 } 168 return score; 169 } 170 ; 171 172 /** Check circumstances and optional immediate Luck decision; score queries do not roll. */ 173 class RpgLiteContext: object 174 kind = 'skill' 175 skill = nil 176 attribute = rpgIQ 177 modifier = 0 178 darkness = 0 179 someLight = true 180 offHand = nil 181 closeQuarters = 0 182 unnecessaryRisk = nil 183 understandsSpeech = nil 184 deceptive = nil 185 phobiaPresent = nil 186 language = nil 187 written = nil 188 resistedTrait = nil 189 scoreFor(profile) 190 { 191 return self.skill == nil ? profile.attributeScore(self.attribute) 192 : profile.skillScore(self.skill); 193 } 194 controllingAttribute() { return self.skill == nil ? self.attribute : self.skill.attribute; } 195 skillUseModifier(profile) { return 0; } 196 equipmentUseModifier(profile) { return 0; } 197 unavailableReason(profile) { return 'training-required'; } 198 reroll(profile, result) { return nil; } 199 ; 200 201 /** Calculate effective skill or attribute without consuming randomness; nil means no legal default. */ 202 rpgLiteScore(profile, context) 203 { 204 local score = context.scoreFor(profile); 205 if (score == nil) return nil; 206 local attribute = context.controllingAttribute(); 207 local skillId = context.skill != nil && context.skill.ofKind(RpgLiteSkill) ? context.skill.id : nil; 208 local useModifier = context.skillUseModifier(profile); 209 if (useModifier == nil) return nil; 210 local equipmentModifier = context.equipmentUseModifier(profile); 211 if (equipmentModifier == nil) return nil; 212 local adjustment = context.modifier + useModifier + equipmentModifier; 213 if (context.offHand && profile.level('ambidexterity') == 0 && skillId != 'karate') adjustment -= 4; 214 local darkness = context.darkness; 215 if (context.someLight) darkness -= profile.level('night-vision'); 216 if (darkness > 0) adjustment -= darkness; 217 if (profile.level('high-pain-threshold') == 0 && context.kind != 'defense' && (attribute == rpgDX || attribute == rpgIQ)) 218 adjustment -= profile.shock; 219 if (profile.sleepPenalty && (attribute == rpgDX || attribute == rpgIQ)) adjustment -= 2; 220 if (context.kind != 'defense') adjustment -= profile.lingeringPenalty; 221 if (attribute == rpgDX && context.kind != 'extinguish') adjustment -= profile.burningPenalty; 222 if (context.phobiaPresent && (attribute == rpgIQ || attribute == rpgDX || context.skill != nil)) 223 adjustment -= 2; 224 if (context.language != nil) 225 { 226 local comprehension = profile.language(context.language, context.written); 227 if (comprehension == 0) return nil; 228 adjustment += comprehension == 1 ? -3 : comprehension == 2 ? -1 : 0; 229 } 230 if (context.kind == 'vision') adjustment += profile.level('acute-vision') - (profile.level('bad-sight') > 0 ? 6 : 0); 231 if (context.kind == 'hearing' || context.understandsSpeech) 232 adjustment += (context.kind == 'hearing' ? profile.level('acute-hearing') : 0) 233 - (profile.level('hard-of-hearing') > 0 ? 4 : 0); 234 if (context.kind == 'taste-smell') adjustment += profile.level('acute-taste-smell'); 235 if (context.kind == 'touch') adjustment += profile.level('acute-touch'); 236 if (context.kind == 'fright') adjustment += profile.level('fearlessness') + 2 * profile.level('combat-reflexes'); 237 if (context.kind == 'intimidation-resistance') adjustment += profile.level('fearlessness'); 238 if (context.kind == 'death' || context.kind == 'mortal-wound') adjustment += profile.level('hard-to-kill'); 239 if (context.kind == 'major-wound' || context.kind == 'torture') adjustment += 3 * profile.level('high-pain-threshold'); 240 if (context.kind == 'wake' || context.kind == 'mental-stun') adjustment += 6 * profile.level('combat-reflexes'); 241 if (context.kind == 'disease') adjustment += profile.level('resistant-disease'); 242 if (context.kind == 'poison') adjustment += profile.level('resistant-poison'); 243 if (context.kind == 'balance') adjustment += 6 * profile.level('perfect-balance'); 244 if (context.kind == 'keep-feet') adjustment += 4 * profile.level('perfect-balance'); 245 if (context.kind == 'attack' && profile.level('bad-sight') > 0) adjustment -= 2; 246 if (context.skill != nil) 247 { 248 if (context.unnecessaryRisk && profile.level('daredevil') > 0) adjustment += 1; 249 foreach (local trait in profile.traits) 250 if (trait.ofKind(RpgLiteTrait) && trait.active && !trait.mitigated && holds(trait.skills, context.skill)) 251 adjustment += trait.level; 252 if (holds(['armoury','electronics-repair','engineer','mechanic'], skillId)) adjustment += profile.level('artificer'); 253 if (holds(['camouflage','naturalist','navigation','survival','tracking'], skillId)) adjustment += profile.level('outdoorsman'); 254 if (context.skill.ofKind(RpgLiteSkill) && (context.skill.influence 255 || holds(['acting','carousing','leadership','public-speaking'], skillId))) adjustment += profile.level('smooth-operator'); 256 if (skillId == 'climbing' || skillId == 'escape') 257 adjustment += profile.level('double-jointed') > 0 ? 5 : profile.level('flexibility') > 0 ? 3 : 0; 258 if (skillId == 'climbing' || skillId == 'acrobatics') adjustment += profile.level('perfect-balance'); 259 if (skillId == 'climbing' || skillId == 'stealth') adjustment -= profile.encumbrance(); 260 if (skillId == 'swimming') adjustment -= 2 * profile.encumbrance(); 261 if (profile.level('truthfulness') > 0 && (skillId == 'fast-talk' || skillId == 'acting' && context.deceptive)) adjustment -= 5; 262 if (context.skill.ofKind(RpgLiteSkill)) 263 { 264 if (context.skill.influence || skillId == 'leadership' || skillId == 'public-speaking') adjustment += profile.level('charisma'); 265 if (context.skill.voice) adjustment += 2 * profile.level('voice'); 266 } 267 } 268 local cramped = context.closeQuarters; 269 cramped -= profile.level('double-jointed') > 0 ? 5 : profile.level('flexibility') > 0 ? 3 : 0; 270 if (cramped > 0) adjustment -= cramped; 271 return score + adjustment; 272 } 273 274 /** Resolve a contextual success check, including applicable traits and immediate Luck. */ 275 rpgLiteCheck(profile, context) 276 { 277 local score = rpgLiteScore(profile, context); 278 if (score == nil) { local result = new RpgResult(); result.reason = context.unavailableReason(profile); return result; } 279 if (score < 3) return rpgCheck(score, 3); 280 local result = rpgCheck(score, rpgRandom.roll()); 281 if (context.reroll(profile, result)) result = rpgCheck(score, rpgLiteReroll(profile, result.roll, 3, nil)); 282 if (result.critical == 'failure' && context.unnecessaryRisk && profile.level('daredevil') > 0) 283 result = rpgCheck(score, rpgRandom.roll()); 284 profile.lastCheck = result; profile.lastCheckDraw = rpgRandom.draws; 285 return result; 286 } 287 288 /** Resolve an attribute check with a named situation and integer modifier. */ 289 rpgLiteAttributeCheck(profile, attribute, kind, modifier) 290 { 291 local context = new RpgLiteContext(); context.attribute = attribute; 292 context.kind = kind; context.modifier = modifier; 293 return rpgLiteCheck(profile, context); 294 } 295 296 /** Resolve written comprehension; semi-literacy needs an IQ roll and fluent reading is automatic. */ 297 rpgLiteRead(profile, languageId) 298 { 299 local comprehension = profile.language(languageId, true); 300 if (comprehension == 1) return rpgLiteAttributeCheck(profile, rpgIQ, 'reading', 0); 301 local result = new RpgResult(); 302 if (comprehension == 0) { result.reason = 'illiterate'; return result; } 303 result.status = 'resolved'; result.achieved = true; result.outcome = 'automatic'; return result; 304 } 305 306 /** Resolve a controlled disadvantage against 12, or voluntarily yield without rolling. */ 307 rpgLiteSelfControl(profile, trait, modifier, giveIn) 308 { 309 if (!holds(profile.traits, trait) || !trait.controlled || !trait.active) throw new RpgError(); 310 local result = giveIn ? new RpgResult() : rpgCheck(12 + modifier - (profile.sleepPenalty ? 2 : 0), rpgRandom.roll()); 311 if (giveIn) { result.status = 'resolved'; result.achieved = nil; result.outcome = 'yielded'; } 312 else { profile.lastCheck = result; profile.lastCheckDraw = rpgRandom.draws; } 313 return result; 314 } 315 316 /** Return the signed point cost of an owned trait. */ 317 rpgLiteTraitCost(trait) 318 { 319 if (trait.id == nil || trait.level < 1) throw new RpgError(); 320 return trait.points * trait.level; 321 } 322 323 /** Return total creation cost, or only negative components when negativeOnly is true. */ 324 rpgLiteCharacterCost(profile, negativeOnly) 325 { 326 local amounts = [(profile.st - 10) * 10, (profile.dx - 10) * 20, 327 (profile.iq - 10) * 20, (profile.ht - 10) * 10, (profile.techLevel - profile.campaignTL) * 5]; 328 foreach (local trait in profile.traits) amounts = amounts + [rpgLiteTraitCost(trait)]; 329 foreach (local language in profile.languages) amounts = amounts + [language.cost()]; 330 foreach (local training in profile.skills) amounts = amounts + [training.points]; 331 local total = 0; 332 foreach (local amount in amounts) if (!negativeOnly || amount < 0) total += amount; 333 return total; 334 } 335 336 /** Character validation failure with an author-readable reason. */ 337 class RpgLiteCharacterError: RpgError reason = 'invalid-character'; 338 339 /** Reject an invalid character declaration with a specific diagnostic. */ 340 rpgLiteInvalidCharacter(reason) 341 { 342 local error = new RpgLiteCharacterError(); error.reason = reason; throw error; 343 } 344 345 /** Validate Lite trait identities and mutually exclusive catalogue choices. */ 346 rpgLiteValidateTraits(profile) 347 { 348 local ids = []; local quirks = 0; 349 foreach (local trait in profile.traits) 350 { 351 if (!trait.ofKind(RpgLiteTrait) || trait.id == nil || holds(ids, trait.id) && trait.id != 'quirk') rpgLiteInvalidCharacter('duplicate-or-invalid-trait'); 352 ids = ids + [trait.id]; 353 if ((trait.id == 'quirk' || trait.scope == 'quirk') && !trait.acquiredInPlay) quirks += 1; 354 if (trait.id == 'night-vision' && trait.level > 9 355 || trait.skills.length() > 0 && trait.level > 4) rpgLiteInvalidCharacter('trait-level'); 356 } 357 if (quirks > 5 || profile.level('acute-hearing') > 0 && profile.level('hard-of-hearing') > 0 358 || profile.level('luck') > 0 && profile.level('unluckiness') > 0) rpgLiteInvalidCharacter('incompatible-traits'); 359 foreach (local group in [['hideous','ugly','unattractive','attractive','handsome','very-handsome'], 360 ['dead-broke','poor','struggling','comfortable','wealthy','very-wealthy','filthy-rich'], 361 ['luck','extraordinary-luck','ridiculous-luck','unluckiness'], ['flexibility','double-jointed'], 362 ['bad-sight','bad-sight-correctable'], ['status','low-status'], 363 ['resistant-disease-3','resistant-disease-8']]) 364 { 365 local count = 0; 366 foreach (local id in group) if (holds(ids, id)) count += 1; 367 if (count > 1) rpgLiteInvalidCharacter('exclusive-traits'); 368 } 369 return true; 370 } 371 372 /** Validate language identities, comprehension and the native-language allowance. */ 373 rpgLiteValidateLanguages(profile) 374 { 375 local ids = []; local natives = 0; 376 foreach (local language in profile.languages) 377 { 378 if (language.id == nil || holds(ids, language.id)) rpgLiteInvalidCharacter('duplicate-language'); 379 ids = ids + [language.id]; rpgInteger(language.spoken, 0, 3); rpgInteger(language.written, 0, 3); 380 if (language.native) { natives += 1; if (language.spoken != 3) rpgLiteInvalidCharacter('native-language'); } 381 } 382 if (natives > 1) rpgLiteInvalidCharacter('multiple-native-languages'); 383 return true; 384 } 385 386 /** Validate the point budget, disadvantage limit, trait combinations, languages and technology. */ 387 rpgLiteValidateCharacter(profile) 388 { 389 rpgInteger(profile.st, 1, 20); rpgInteger(profile.dx, 1, 100); 390 rpgInteger(profile.iq, 1, 100); rpgInteger(profile.ht, 1, 100); 391 rpgInteger(profile.techLevel, 0, 12); rpgInteger(profile.campaignTL, 0, 12); 392 rpgLiteValidateTraits(profile); 393 rpgLiteValidateLanguages(profile); 394 foreach (local training in profile.skills) 395 if (training.points > 0 && training.skill.ofKind(RpgLiteSkill) 396 && training.skill.techLevel != nil && training.skill.techLevel > profile.techLevel) 397 rpgLiteInvalidCharacter('skill-technology'); 398 if (rpgLiteCharacterCost(profile, nil) > profile.pointBudget 399 || -rpgLiteCharacterCost(profile, true) > profile.disadvantageLimit) rpgLiteInvalidCharacter('point-budget'); 400 return true; 401 } 402 403 /** Map an already-modified reaction total to its named category. */ 404 rpgLiteReaction(total) 405 { 406 if (total <= 0) return 'disastrous'; 407 if (total <= 3) return 'very-bad'; 408 if (total <= 6) return 'bad'; 409 if (total <= 9) return 'poor'; 410 if (total <= 12) return 'neutral'; 411 if (total <= 15) return 'good'; 412 if (total <= 18) return 'very-good'; 413 return 'excellent'; 414 } 415 416 /** Authored visibility, recognition, culture and attraction facts for reaction modifiers. */ 417 class RpgLiteSocialContext: object 418 attracted = nil 419 sees = true 420 hears = true 421 interaction = true 422 recognizes = true 423 sameCulture = true 424 noticesHabits = true 425 naive = nil 426 experienced = nil 427 modifier = 0 428 reroll(profile, roll) { return nil; } 429 ; 430 431 /** Calculate visible and applicable personal reaction modifiers. */ 432 rpgLiteReactionModifier(profile, context) 433 { 434 local amount = context.modifier; 435 if (context.interaction) amount += profile.level('charisma'); 436 if (context.hears) amount += 2 * profile.level('voice'); 437 if (context.recognizes) 438 { 439 local reputation = profile.trait('reputation'); 440 if (reputation != nil) amount += reputation.points < 0 ? -reputation.level : reputation.level; 441 amount -= profile.level('bad-reputation'); 442 } 443 if (context.sameCulture) 444 { 445 local status = profile.trait('status'); 446 if (status != nil) amount += status.points < 0 ? -status.level : status.level; 447 amount -= profile.level('low-status'); 448 } 449 if (context.noticesHabits) amount -= profile.level('odious-habits') + profile.level('delusions'); 450 if (context.sees) 451 { 452 amount -= 4 * profile.level('hideous') + 2 * profile.level('ugly') + profile.level('unattractive'); 453 amount += profile.level('attractive'); 454 if (profile.level('handsome') > 0) amount += context.attracted ? 4 : 2; 455 if (profile.level('very-handsome') > 0) amount += context.attracted ? 6 : 2; 456 } 457 if (profile.level('overconfidence') > 0) amount += context.naive ? 2 : context.experienced ? -2 : 0; 458 return amount; 459 } 460 461 /** Opposed influence outcome, reaction category and optional ordinary reaction roll. */ 462 class RpgLiteInfluenceResult: object 463 contest = nil 464 reaction = nil 465 reactionRoll = nil 466 ; 467 468 /** Resolve influence against Will, including Intimidation, Sex Appeal and Diplomacy exceptions. */ 469 rpgLiteInfluence(profile, target, context, social) 470 { 471 if (context.skill == nil || !context.skill.influence) throw new RpgError(); 472 local value = new RpgLiteInfluenceResult(); 473 local score = rpgLiteScore(profile, context); 474 if (score == nil || score < 3) return value; 475 local resistance = target.will; 476 if (context.skill.id == 'intimidation') 477 { resistance += target.level('fearlessness'); score -= target.level('fearlessness'); } 478 local ownRoll = rpgRandom.roll(); 479 if (context.reroll(profile, rpgCheck(score, ownRoll))) ownRoll = rpgLiteReroll(profile, ownRoll, 3, nil); 480 local otherRoll = rpgRandom.roll(); 481 value.contest = rpgEvaluate(score, ownRoll, resistance, otherRoll, 0); 482 value.reaction = value.contest.achieved ? (context.skill.id == 'sex-appeal' ? 'very-good' : 'good') 483 : context.skill.id == 'intimidation' ? 'very-bad' : 'bad'; 484 if (context.skill.id == 'diplomacy') 485 { 486 local reactionRoll = rpgRandom.roll(); 487 if (social.reroll(profile, reactionRoll)) reactionRoll = rpgLiteReroll(profile, reactionRoll, 3, true); 488 value.reactionRoll = reactionRoll + rpgLiteReactionModifier(profile, social); 489 local floor = value.contest.achieved ? 13 : 4; 490 if (value.reactionRoll >= floor) value.reaction = rpgLiteReaction(value.reactionRoll); 491 } 492 return value; 493 } 494 495 /** Persistent repeated success-versus-failure contest, with author-selected seconds per round. */ 496 class RpgLiteRegularContest: object 497 finished = nil 498 winner = nil 499 rounds = 0 500 seconds = 1 501 step(firstScore, secondScore) 502 { 503 if (self.finished) return self.winner; 504 if (firstScore < 3 || secondScore < 3) throw new RpgError(); 505 local first = rpgCheck(firstScore, rpgRandom.roll()); 506 local second = rpgCheck(secondScore, rpgRandom.roll()); 507 self.rounds += 1; rpgSpendTime(self.seconds); 508 if (first.achieved != second.achieved) 509 { self.finished = true; self.winner = first.achieved ? 1 : 2; } 510 return self.winner; 511 } 512 ;