lib/vhyl-rpg-expanded-social.t¶
Reference index ยท View on GitHub
1 /* Include after expanded foundation. Source: full-2008 B28-B29. */ 2 3 /** Purchased social standing in one authored culture. Benefits require that the 4 * culture and the standing are recognized in the current encounter. */ 5 class RpgExpandedStatus: RpgExpandedContribution 6 culture = nil 7 level = 0 8 minimum = -2 9 maximum = 8 10 amounts(profile) { rpgInteger(self.level, -100, 100); return [self.level * 5]; } 11 validate(profile) 12 { 13 if (self.culture == nil) rpgExpandedReject('status-culture'); 14 rpgInteger(self.minimum, -100, 100); rpgInteger(self.maximum, self.minimum, 100); 15 rpgInteger(self.level, self.minimum, self.maximum); 16 foreach (local entry in profile.contributions) 17 if (entry != self && entry.ofKind(RpgExpandedStatus) && entry.culture == self.culture) 18 rpgExpandedReject('duplicate-cultural-status'); 19 foreach (local trait in profile.traits) 20 if (trait.ofKind(RpgLiteTrait) && holds(['status','low-status'],trait.id)) 21 rpgExpandedReject('mixed-status-profiles'); 22 return nil; 23 } 24 ; 25 26 /** Pure B29 relative-standing guideline. Resentment is an authored exception: 27 * do not turn it into an invented automatic negative bonus. */ 28 rpgExpandedStatusReaction(subjectStatus, observerStatus, recognized, friendly, resents) 29 { 30 rpgInteger(subjectStatus, -100, 100); rpgInteger(observerStatus, -100, 100); 31 if (!recognized || subjectStatus == observerStatus) return 0; 32 local difference = subjectStatus - observerStatus; 33 if (difference > 0) return resents ? 0 : difference; 34 if (subjectStatus < 0) return difference < -4 ? -4 : difference; 35 if (subjectStatus > 0 && friendly) return 0; 36 return difference; 37 } 38 39 /** An encounter's explicit social-standing facts. assess() rolls nothing and 40 * reports only this modifier, not a complete reaction or permission decision. */ 41 class RpgExpandedStatusEncounter: object 42 subject = nil 43 observer = nil 44 culture = nil 45 recognized = nil 46 friendly = nil 47 resents = nil 48 structure = nil 49 statusFor(profile) 50 { 51 if (profile == nil || !profile.ofKind(RpgExpandedProfile)) 52 rpgExpandedReject('status-profile'); 53 if (self.structure != nil) 54 { 55 if (!self.structure.ofKind(RpgExpandedSocialStructure) || self.structure.culture != self.culture) 56 rpgExpandedReject('status-structure-culture'); 57 return self.structure.standingFor(profile); 58 } 59 rpgExpandedValidate(profile); 60 foreach (local entry in profile.contributions) 61 if (entry.ofKind(RpgExpandedRank) && entry.culture == self.culture) 62 rpgExpandedReject('status-structure-required'); 63 foreach (local entry in profile.contributions) 64 if (entry.ofKind(RpgExpandedStatus) && entry.culture == self.culture) return entry.level; 65 return 0; 66 } 67 assess() 68 { 69 if (self.culture == nil) rpgExpandedReject('status-culture'); 70 local value = new RpgExpandedResult(); 71 value.modifier = rpgExpandedStatusReaction(self.statusFor(self.subject),self.statusFor(self.observer), 72 self.recognized,self.friendly,self.resents); 73 value.status = 'ready'; return value; 74 } 75 ; 76 77 78 /** Organizational membership and purchased authority, full-2008 B29-B30. 79 * Institutional admission, duties and privileges are separately authored facts. */ 80 class RpgExpandedRank: RpgExpandedContribution 81 culture = nil 82 institution = nil 83 level = 0 84 maximum = 8 85 replacesStatus = nil 86 admitted = nil 87 validate(profile) 88 { 89 if (self.culture == nil || self.institution == nil) rpgExpandedReject('rank-institution'); 90 rpgInteger(self.maximum, 0, 100); rpgInteger(self.level, 0, self.maximum); 91 if (!self.admitted) rpgExpandedReject('rank-admission-required'); 92 foreach (local entry in profile.contributions) 93 if (entry != self && entry.ofKind(RpgExpandedRank) && entry.institution == self.institution) 94 rpgExpandedReject('duplicate-institution-rank'); 95 return nil; 96 } 97 amounts(profile) 98 { 99 rpgInteger(self.level, 0, 100); 100 return [self.level * (self.replacesStatus ? 10 : 5)]; 101 } 102 statusBonus() 103 { 104 rpgInteger(self.level, 0, 100); 105 if (self.replacesStatus) return self.level; 106 return self.level < 2 ? 0 : self.level < 5 ? 1 : self.level < 8 ? 2 : 3; 107 } 108 ; 109 110 /** Explicit social structure. Wealth-linked standing is opt-in; replacement 111 * rank excludes ordinary status and all free standing bonuses. */ 112 class RpgExpandedSocialStructure: object 113 culture = nil 114 wealthLinked = nil 115 replacingInstitution = nil 116 purchasedMaximum = 8 117 standingFor(profile) 118 { 119 if (self.culture == nil || profile == nil || !profile.ofKind(RpgExpandedProfile)) 120 rpgExpandedReject('social-structure'); 121 rpgInteger(self.purchasedMaximum, 0, 100); rpgExpandedValidate(profile); 122 local total = 0; 123 foreach (local entry in profile.contributions) 124 { 125 if (entry.ofKind(RpgExpandedStatus) && entry.culture == self.culture) 126 { 127 if (self.replacingInstitution != nil) rpgExpandedReject('rank-replaces-purchased-status'); 128 if (entry.level > self.purchasedMaximum) rpgExpandedReject('social-purchased-status-limit'); 129 total += entry.level; 130 } 131 if (entry.ofKind(RpgExpandedRank) && entry.culture == self.culture) 132 { 133 if (entry.replacesStatus ? entry.institution != self.replacingInstitution 134 : entry.institution == self.replacingInstitution) 135 rpgExpandedReject('rank-status-policy-mismatch'); 136 if (self.replacingInstitution == nil || entry.institution == self.replacingInstitution) 137 total += entry.statusBonus(); 138 } 139 if (self.wealthLinked && self.replacingInstitution == nil) total += entry.wealthStatusBonus(); 140 } 141 rpgInteger(total, -100, 100); return total; 142 } 143 ; 144 145 /** Reputation uses its explicit downward rounding exception, B27-B28. */ 146 rpgExpandedReputationFraction(amount, numerator, denominator) 147 { 148 rpgInteger(amount, -1000000, 1000000); 149 rpgInteger(numerator, 0, 1000); rpgInteger(denominator, 1, 1000); 150 local product = amount * numerator; 151 return product < 0 ? -((-product + denominator - 1) / denominator) : product / denominator; 152 } 153 154 /** Facets are [audience-id, modifier, breadth]. Multifaceted audiences must be 155 * authored as disjoint. A zero-net reputation remains an owned contribution. */ 156 class RpgExpandedReputation: RpgExpandedContribution 157 details = nil 158 facets = [] 159 disjoint = nil 160 frequency = 'always' 161 recognitions = [] 162 validateDefinition() 163 { 164 if (self.details == nil || self.facets.length() == 0) rpgExpandedReject('reputation-description'); 165 if (!holds(['always','sometimes','occasionally'],self.frequency)) rpgExpandedReject('reputation-frequency'); 166 if (self.facets.length() > 1 && !self.disjoint) rpgExpandedReject('reputation-overlapping-facets'); 167 local ids = []; 168 foreach (local facet in self.facets) 169 { 170 if (facet.length() != 3 || facet[1] == nil || holds(ids,facet[1])) 171 rpgExpandedReject('reputation-facet'); 172 ids = ids + [facet[1]]; rpgInteger(facet[2], -4, 4); 173 if (!holds(['all','most','large','small','negligible'],facet[3])) 174 rpgExpandedReject('reputation-breadth'); 175 } 176 return nil; 177 } 178 amounts(profile) 179 { 180 self.validateDefinition(); local total = 0; 181 foreach (local facet in self.facets) 182 { 183 local cost = facet[2] * 5; 184 if (facet[3] == 'most') cost = rpgExpandedReputationFraction(cost,2,3); 185 if (facet[3] == 'large') cost = rpgExpandedReputationFraction(cost,1,2); 186 if (facet[3] == 'small') cost = rpgExpandedReputationFraction(cost,1,3); 187 if (facet[3] == 'negligible') cost = 0; 188 total += cost; 189 } 190 if (self.frequency == 'sometimes') total = rpgExpandedReputationFraction(total,1,2); 191 if (self.frequency == 'occasionally') total = rpgExpandedReputationFraction(total,1,3); 192 return [total]; 193 } 194 validate(profile) 195 { 196 self.validateDefinition(); 197 foreach (local trait in profile.traits) 198 if (trait.ofKind(RpgLiteTrait) && holds(['reputation','bad-reputation'],trait.id)) 199 rpgExpandedReject('mixed-reputation-profiles'); 200 return nil; 201 } 202 ; 203 204 /** Recognition is retained per reputation/audience, including failure. The 205 * author supplies audience membership, geographic scope and identity exposure. */ 206 class RpgExpandedReputationRecognition: object 207 reputation = nil 208 audience = nil 209 facet = nil 210 inArea = nil 211 identityExposed = nil 212 assess() 213 { 214 if (self.reputation == nil || !self.reputation.ofKind(RpgExpandedReputation) 215 || self.reputation.owner == nil || !self.reputation.owner.ofKind(RpgExpandedProfile) 216 || !holds(self.reputation.owner.contributions,self.reputation) || self.audience == nil) 217 rpgExpandedReject('reputation-recognition-target'); 218 rpgExpandedValidate(self.reputation.owner); 219 local value = new RpgExpandedResult(); local selected = nil; 220 foreach (local row in self.reputation.facets) if (row[1] == self.facet) selected = row; 221 if (selected == nil) rpgExpandedReject('reputation-audience-facet'); 222 foreach (local row in self.reputation.recognitions) 223 if (row[1] == self.audience) 224 { 225 if (row[2] != self.facet) rpgExpandedReject('reputation-audience-changed'); 226 value.status = 'reused'; value.check = row[3]; value.modifier = row[4]; return value; 227 } 228 if (!self.inArea) { value.reason = 'reputation-outside-area'; return value; } 229 if (!self.identityExposed) { value.reason = 'reputation-identity-hidden'; return value; } 230 value.modifier = selected[2]; value.status = 'ready'; return value; 231 } 232 resolve() 233 { 234 local value = self.assess(); if (value.status != 'ready') return value; 235 if (self.reputation.frequency != 'always') 236 { 237 value.check = rpgRandom.roll(); 238 if (value.check > (self.reputation.frequency == 'sometimes' ? 10 : 7)) value.modifier = 0; 239 } 240 self.reputation.recognitions = self.reputation.recognitions 241 + [[self.audience,self.facet,value.check,value.modifier]]; 242 value.status = 'resolved'; return value; 243 } 244 ; 245 246 /** Combine already-resolved reputations for one person/group, capped at +/-4. */ 247 rpgExpandedReputationModifier(recognitions) 248 { 249 local seen = []; local audience = nil; local subject = nil; local total = 0; 250 foreach (local recognition in recognitions) 251 { 252 if (!recognition.ofKind(RpgExpandedReputationRecognition)) rpgExpandedReject('reputation-recognition-list'); 253 local value = recognition.assess(); 254 if (value.status != 'reused') rpgExpandedReject('reputation-recognition-unresolved'); 255 if (holds(seen,recognition.reputation)) rpgExpandedReject('duplicate-reputation-recognition'); 256 if (seen.length() > 0 && (recognition.audience != audience || recognition.reputation.owner != subject)) 257 rpgExpandedReject('reputation-recognition-audience'); 258 seen = seen + [recognition.reputation]; audience = recognition.audience; 259 subject = recognition.reputation.owner; total += value.modifier; 260 } 261 return total > 4 ? 4 : total < -4 ? -4 : total; 262 } 263 264 /** Reaction bands from full-2008 B560-B561, ordered from 0 (worst) to 7. */ 265 rpgExpandedReactionBand(total) 266 { 267 rpgInteger(total, -10000, 10000); 268 return total <= 0 ? 0 : total <= 3 ? 1 : total <= 6 ? 2 : total <= 9 ? 3 269 : total <= 12 ? 4 : total <= 15 ? 5 : total <= 18 ? 6 : 7; 270 } 271 272 modify RpgExpandedProfile 273 reactionEncounters = [] 274 ; 275 276 /** Persistent private encounter record; consequences are resolved separately. 277 * The author registers it in subject.reactionEncounters with a unique id. */ 278 class RpgExpandedReactionEncounter: object 279 id = nil 280 subject = nil 281 audience = nil 282 worstBand = 0 283 bestBand = 7 284 predeterminedBand = nil 285 fighting = nil 286 history = [] 287 nuisanceCount = 0 288 validate() 289 { 290 if (self.id == nil || self.subject == nil || !self.subject.ofKind(RpgExpandedProfile) 291 || self.audience == nil || !holds(self.subject.reactionEncounters,self)) 292 rpgExpandedReject('reaction-encounter-owner'); 293 foreach (local entry in self.subject.reactionEncounters) 294 if (entry != self && entry.id == self.id) rpgExpandedReject('duplicate-reaction-encounter'); 295 rpgInteger(self.worstBand, 0, 7); rpgInteger(self.bestBand, self.worstBand, 7); 296 if (self.predeterminedBand != nil) rpgInteger(self.predeterminedBand,self.worstBand,self.bestBand); 297 rpgInteger(self.nuisanceCount,0,1000); return nil; 298 } 299 ; 300 301 /** Private reaction outcome: band, adjusted total and roll are trusted-author data. */ 302 class RpgExpandedReactionResult: RpgExpandedResult 303 band = nil 304 total = nil 305 ; 306 307 /** A distinct authored approach. assess() never exposes saved attitudes or dice. 308 * resolve() is an author-only operation: do not emit its private result directly. */ 309 class RpgExpandedReactionAttempt: object 310 encounter = nil 311 approach = nil 312 modifier = 0 313 approvedChange = nil 314 nuisance = nil 315 waitedReasonably = nil 316 assess() 317 { 318 if (self.encounter == nil || !self.encounter.ofKind(RpgExpandedReactionEncounter) || self.approach == nil) 319 rpgExpandedReject('reaction-attempt'); 320 self.encounter.validate(); rpgInteger(self.modifier,-1000,1000); 321 local value = new RpgExpandedReactionResult(); 322 foreach (local row in self.encounter.history) 323 if (row[1] == self.approach) { value.status = 'reused'; return value; } 324 if (self.encounter.history.length() > 0) 325 { 326 if (self.encounter.fighting) { value.reason = 'reaction-fight-started'; return value; } 327 if (self.encounter.predeterminedBand != nil) 328 { value.reason = 'reaction-predetermined'; return value; } 329 if (!self.approvedChange) { value.reason = 'reaction-approach-unchanged'; return value; } 330 } 331 if (self.encounter.nuisanceCount >= 1000) rpgExpandedReject('reaction-retry-limit'); 332 value.status = 'ready'; return value; 333 } 334 resolve() 335 { 336 local value = self.assess(); 337 if (value.status == 'reused') 338 { 339 foreach (local row in self.encounter.history) 340 if (row[1] == self.approach) 341 { value.check = row[2]; value.total = row[3]; value.band = row[4]; } 342 return value; 343 } 344 if (value.status != 'ready') return value; 345 local count = self.waitedReasonably ? 0 : self.encounter.nuisanceCount; 346 if (self.encounter.history.length() > 0 && self.nuisance && !self.waitedReasonably) count += 1; 347 if (self.encounter.predeterminedBand != nil) value.band = self.encounter.predeterminedBand; 348 else 349 { 350 value.check = rpgRandom.roll(); value.total = value.check + self.modifier - count * 2; 351 value.band = rpgExpandedReactionBand(value.total); 352 if (value.band < self.encounter.worstBand) value.band = self.encounter.worstBand; 353 if (value.band > self.encounter.bestBand) value.band = self.encounter.bestBand; 354 } 355 self.encounter.nuisanceCount = count; 356 self.encounter.history = self.encounter.history + [[self.approach,value.check,value.total,value.band]]; 357 value.status = 'resolved'; return value; 358 } 359 ; 360 361 /** Authored combat intent and follow-up conditions; no combat is executed by this result. */ 362 class RpgExpandedCombatReactionResult: RpgExpandedResult 363 intent = nil 364 aidModifier = 0 365 noQuarter = nil 366 leaveDemand = nil 367 ; 368 369 /** Context-specific potential combat/morale interpretation, B560-B561. 370 * Intent describes the next decision; movement and attacks still use combat. */ 371 class RpgExpandedCombatReaction: object 372 band = nil 373 inCombat = nil 374 outnumbered = nil 375 hopeless = nil 376 escapePossible = nil 377 visitorsRemain = nil 378 assess() 379 { 380 rpgInteger(self.band,0,7); 381 local value = new RpgExpandedCombatReactionResult(); value.status = 'ready'; 382 if (self.band == 0) { value.intent = 'attack'; value.noQuarter = true; } 383 if (self.band == 1) value.intent = self.hopeless ? 'flee' : 'attack'; 384 if (self.band == 2) value.intent = self.inCombat ? 'continue-fighting' : self.outnumbered ? 'flee' : 'attack'; 385 if (self.band == 3) 386 { 387 if (self.inCombat) value.intent = 'continue-fighting'; 388 else 389 { 390 value.leaveDemand = true; 391 value.intent = !self.visitorsRemain ? 'demand-departure' : self.outnumbered ? 'flee' : 'attack'; 392 } 393 } 394 if (self.band == 4) value.intent = self.inCombat ? 'disengage' : 'pass-peacefully'; 395 if (self.band == 5) 396 { value.intent = self.inCombat ? 'flee' : 'allow-request'; if (!self.inCombat) value.aidModifier = 1; } 397 if (self.band == 6) 398 { 399 value.intent = self.inCombat ? self.escapePossible ? 'flee' : 'surrender' : 'allow-request'; 400 if (!self.inCombat) value.aidModifier = 3; 401 } 402 if (self.band == 7) 403 { value.intent = self.inCombat ? 'surrender' : 'allow-request'; if (!self.inCombat) value.aidModifier = 5; } 404 return value; 405 } 406 ; 407 408 /** Combat-specific modifiers are explicit perceived facts, not actual stat 409 * comparisons. The strength assessment ranges from -5 through +5, B561. */ 410 rpgExpandedCombatReactionModifier(perceivedStrength, commonLanguage, intruders) 411 { 412 rpgInteger(perceivedStrength,-5,5); 413 return perceivedStrength - (commonLanguage ? 0 : 2) - (intruders ? 2 : 0); 414 } 415 416 /** Aid decision and any required follow-up, advice or retry conditions. */ 417 class RpgExpandedAidResult: RpgExpandedResult 418 granted = nil 419 extent = 'none' 420 advice = nil 421 volunteerInformation = nil 422 combatFollowup = nil 423 followupModifier = 0 424 followupBestBand = 7 425 retry = nil 426 retryModifier = 0 427 ; 428 429 /** Aid decisions, full-2008 B560-B562. A grant expresses willingness; it does 430 * not create resources or let the provider perform an impossible task. */ 431 class RpgExpandedAidReaction: object 432 band = nil 433 simple = nil 434 reasonableness = 'reasonable' 435 assess() 436 { 437 rpgInteger(self.band,0,7); 438 if (!holds(['reasonable','silly','totally-unreasonable'],self.reasonableness)) 439 rpgExpandedReject('aid-reasonableness'); 440 local value = new RpgExpandedAidResult(); value.status = 'ready'; 441 if (self.band == 0) { value.combatFollowup = true; value.followupModifier = -4; } 442 if (self.band == 1) { value.combatFollowup = true; value.followupBestBand = 4; } 443 if (self.band == 3) { value.retry = true; value.retryModifier = -2; } 444 if (self.band == 4) 445 { 446 value.granted = self.simple; 447 if (!self.simple) { value.retry = true; value.retryModifier = -2; } 448 } 449 if (self.band == 5) 450 { value.granted = self.reasonableness == 'reasonable'; value.advice = !value.granted; } 451 if (self.band == 6) 452 { value.granted = self.reasonableness != 'totally-unreasonable'; value.volunteerInformation = true; } 453 if (self.band == 7) { value.granted = true; value.extent = 'extra-within-capability'; } 454 else if (value.granted) value.extent = 'requested'; 455 return value; 456 } 457 ; 458 459 /** B562 aid modifiers. Variable risk penalties are explicit authored judgments. 460 * A nil risk means absent; a supplied job/status risk must be at least -2. */ 461 class RpgExpandedAidModifiers: object 462 verySimple = nil 463 complexity = 0 464 inconvenience = nil 465 standingRisk = nil 466 physicalRisk = nil 467 assess() 468 { 469 rpgInteger(self.complexity,-100,0); 470 if (self.standingRisk != nil) rpgInteger(self.standingRisk,-100,-2); 471 if (self.physicalRisk != nil) rpgInteger(self.physicalRisk,-100,-1); 472 local value = new RpgExpandedResult(); value.status = 'ready'; 473 value.modifier = (self.verySimple ? 1 : 0) + self.complexity - (self.inconvenience ? 1 : 0) 474 + (self.standingRisk == nil ? 0 : self.standingRisk) 475 + (self.physicalRisk == nil ? 0 : self.physicalRisk); 476 return value; 477 } 478 ; 479 480 /** Information response decision, including research and follow-up requirements. */ 481 class RpgExpandedInformationResult: RpgExpandedResult 482 answer = 'none' 483 related = nil 484 research = nil 485 combatFollowup = nil 486 followupModifier = 0 487 retry = nil 488 aidFollowup = nil 489 aidModifier = 0 490 aidWorstBand = 0 491 ; 492 493 /** Information-request decision, B560-B562. Truth means the respondent's 494 * belief, not privileged access to world truth. Answer content is separate. */ 495 class RpgExpandedInformationReaction: object 496 band = nil 497 complexity = 0 498 mode = 'enquiry' 499 adverseChoice = nil 500 paymentReceived = nil 501 bribeOffered = nil 502 knowledgeIncomplete = nil 503 offersHelp = nil 504 assess() { return self.assessPayment(self.paymentReceived); } 505 assessPayment(paid) 506 { 507 rpgInteger(self.band,0,7); rpgInteger(self.complexity,0,2); 508 local value = new RpgExpandedInformationResult(); 509 if (self.mode == 'professional') { value.reason = 'information-use-trade'; return value; } 510 if (self.mode == 'interrogation') { value.reason = 'information-use-interrogation'; return value; } 511 if (self.mode != 'enquiry') rpgExpandedReject('information-mode'); 512 if (self.band == 0) { value.combatFollowup = true; value.followupModifier = -2; } 513 if (self.band == 1) value.answer = 'malicious-lie'; 514 if (self.band == 2) 515 { 516 if (!holds(['lie','payment'],self.adverseChoice)) 517 { value.reason = 'information-adverse-choice-required'; return value; } 518 value.answer = self.adverseChoice == 'lie' ? 'malicious-lie' 519 : paid ? 'incomplete-belief' : 'demand-payment'; 520 } 521 if (self.band == 3) 522 { 523 if (!holds(['unknown','partial'],self.adverseChoice)) 524 { value.reason = 'information-adverse-choice-required'; return value; } 525 value.answer = self.adverseChoice == 'unknown' ? 'claims-ignorance' : 'incomplete-belief'; 526 value.retry = self.bribeOffered; 527 } 528 if (self.band == 4) value.answer = self.complexity == 0 ? 'requested-belief' : 'incomplete-belief'; 529 if (self.band >= 5) value.answer = 'requested-belief'; 530 if (self.band == 6) value.related = true; 531 if (self.band == 7) 532 { 533 value.research = self.knowledgeIncomplete; 534 if (self.offersHelp) { value.aidFollowup = true; value.aidModifier = 2; value.aidWorstBand = 3; } 535 } 536 value.status = 'ready'; return value; 537 } 538 ; 539 540 /** Information-specific modifiers, B562 and B44-B45. Bribe magnitude uses an 541 * authored income-equivalent interval; no universal calendar is assumed. */ 542 class RpgExpandedInformationModifiers: object 543 complexity = 0 544 intrusive = nil 545 danger = nil 546 knowledgeProfessionBonus = 0 547 bribeScale = 'none' 548 discreet = nil 549 appropriate = nil 550 assess() 551 { 552 rpgInteger(self.complexity,0,2); 553 if (self.danger != nil) rpgInteger(self.danger,-100,-3); 554 if (self.knowledgeProfessionBonus != 0) 555 rpgInteger(self.knowledgeProfessionBonus,2,4); 556 local scales = ['none','day','week','month','year']; local bonus = nil; 557 for (local index = 1; index <= scales.length(); index += 1) 558 if (scales[index] == self.bribeScale) bonus = index - 1; 559 if (bonus == nil) rpgExpandedReject('information-bribe-scale'); 560 local value = new RpgExpandedResult(); 561 if (bonus > 0 && (!self.discreet || !self.appropriate)) 562 { value.reason = 'information-bribe-inappropriate'; return value; } 563 value.modifier = -self.complexity - (self.intrusive ? 3 : 0) 564 + (self.danger == nil ? 0 : self.danger) + self.knowledgeProfessionBonus + bonus; 565 value.status = 'ready'; return value; 566 } 567 ; 568 569 modify RpgExpandedProfile 570 knowledge = nil 571 ; 572 573 /** Owned epistemic state. Statements are authored identifiers/objects, not 574 * implicit world facts. Hearing a claim does not automatically mean believing it. */ 575 class RpgExpandedKnowledge: object 576 owner = nil 577 beliefs = [] 578 fabrications = [] 579 received = [] 580 validate() 581 { 582 if (self.owner == nil || !self.owner.ofKind(RpgExpandedProfile) || self.owner.knowledge != self) 583 rpgExpandedReject('knowledge-owner'); 584 foreach (local statement in self.beliefs) 585 if (statement == nil || holds(self.fabrications,statement)) rpgExpandedReject('knowledge-belief-conflict'); 586 foreach (local statement in self.fabrications) 587 if (statement == nil) rpgExpandedReject('knowledge-fabrication'); 588 return nil; 589 } 590 ; 591 592 /** Committed statement selection and payment amount; previews do not disclose statements. */ 593 class RpgExpandedInformationDeliveryResult: RpgExpandedResult 594 statements = [] 595 money = 0 596 ; 597 598 /** Deliver an authored response once. Lists select existing belief/fabrication 599 * records, so a favorable reaction cannot manufacture an answer. */ 600 class RpgExpandedInformationDelivery: object 601 id = nil 602 provider = nil 603 recipient = nil 604 reaction = nil 605 requested = [] 606 partial = [] 607 related = [] 608 lies = [] 609 available = nil 610 price = 0 611 paymentApproved = nil 612 claimed = nil 613 boundProvider = nil 614 boundRecipient = nil 615 boundId = nil 616 validateSubset(selected, source) 617 { 618 local seen = []; 619 foreach (local statement in selected) 620 { 621 if (statement == nil || !holds(source,statement) || holds(seen,statement)) 622 rpgExpandedReject('information-statement-selection'); 623 seen = seen + [statement]; 624 } 625 return nil; 626 } 627 selectedStatements() 628 { 629 if (self.reaction == nil || !self.reaction.ofKind(RpgExpandedInformationReaction)) 630 rpgExpandedReject('information-delivery-reaction'); 631 local response = self.reaction.assessPayment(self.price > 0 && self.paymentApproved ? true : self.reaction.paymentReceived); 632 if (response.status != 'ready') return nil; 633 self.validateSubset(self.requested,self.provider.knowledge.beliefs); 634 self.validateSubset(self.partial,self.requested); 635 self.validateSubset(self.related,self.provider.knowledge.beliefs); 636 self.validateSubset(self.lies,self.provider.knowledge.fabrications); 637 if (response.answer == 'malicious-lie') 638 { 639 if (self.lies.length() == 0) rpgExpandedReject('information-lie-required'); 640 return self.lies; 641 } 642 if (response.answer == 'incomplete-belief') 643 { 644 if (self.requested.length() > 0 && self.partial.length() >= self.requested.length()) 645 rpgExpandedReject('information-partial-is-complete'); 646 return self.partial; 647 } 648 if (response.answer == 'requested-belief') 649 { 650 local result = self.requested; 651 if (response.related) 652 foreach (local statement in self.related) 653 if (!holds(result,statement)) result = result + [statement]; 654 return result; 655 } 656 return nil; 657 } 658 assess() 659 { 660 if (self.id == nil || self.provider == nil || !self.provider.ofKind(RpgExpandedProfile) 661 || self.recipient == nil || !self.recipient.ofKind(RpgExpandedProfile) 662 || self.provider.knowledge == nil || !self.provider.knowledge.ofKind(RpgExpandedKnowledge) 663 || self.recipient.knowledge == nil || !self.recipient.knowledge.ofKind(RpgExpandedKnowledge)) 664 rpgExpandedReject('information-delivery-target'); 665 if (self.claimed && (self.provider != self.boundProvider || self.recipient != self.boundRecipient || self.id != self.boundId)) 666 rpgExpandedReject('information-delivery-retargeted'); 667 self.provider.knowledge.validate(); self.recipient.knowledge.validate(); 668 local value = new RpgExpandedInformationDeliveryResult(); 669 foreach (local receipt in self.recipient.knowledge.received) 670 if (receipt[1] == self.id) 671 { 672 if (receipt[2] != self.provider) rpgExpandedReject('information-delivery-id-conflict'); 673 value.status = 'reused'; return value; 674 } 675 if (!self.available) { value.reason = 'information-delivery-unavailable'; return value; } 676 rpgInteger(self.price,0,2147483647); 677 if (self.price > 0) 678 { 679 if (self.provider == self.recipient) rpgExpandedReject('information-self-payment'); 680 if (self.reaction == nil || !self.reaction.ofKind(RpgExpandedInformationReaction) 681 || self.reaction.band != 2 || self.reaction.adverseChoice != 'payment' 682 || self.reaction.paymentReceived) 683 rpgExpandedReject('information-payment-contract'); 684 if (!self.paymentApproved) { value.reason = 'information-payment-unapproved'; return value; } 685 rpgInteger(self.recipient.money,0,2147483647); rpgInteger(self.provider.money,0,2147483647); 686 if (self.recipient.money < self.price) { value.reason = 'information-payment-funds'; return value; } 687 if (self.provider.money > 2147483647 - self.price) rpgExpandedReject('information-payment-limit'); 688 value.money = self.price; 689 } 690 local selected = self.selectedStatements(); 691 if (selected == nil) { value.reason = 'information-no-delivery'; return value; } 692 value.status = 'ready'; return value; 693 } 694 deliver() 695 { 696 local value = self.assess(); if (value.status != 'ready') return value; 697 value.statements = self.selectedStatements(); 698 self.recipient.money -= value.money; self.provider.money += value.money; 699 self.recipient.knowledge.received = self.recipient.knowledge.received 700 + [[self.id,self.provider,value.statements,value.money]]; 701 self.claimed = true; self.boundProvider = self.provider; self.boundRecipient = self.recipient; self.boundId = self.id; 702 value.status = 'resolved'; return value; 703 } 704 ; 705 706 modify RpgExpandedKnowledge 707 checks = [] 708 ; 709 710 /** Private knowledge-check result; a failed check does not fabricate a belief. */ 711 class RpgExpandedKnowledgeResult: RpgExpandedResult 712 known = nil 713 ; 714 715 /** Resolve uncertain prior knowledge once per statement, full-2008 B562. 716 * This establishes a belief; it does not perform research or prove world truth. */ 717 class RpgExpandedKnowledgeCheck: object 718 knowledge = nil 719 statement = nil 720 context = nil 721 plausible = nil 722 assess() 723 { 724 if (self.knowledge == nil || !self.knowledge.ofKind(RpgExpandedKnowledge) || self.statement == nil) 725 rpgExpandedReject('knowledge-check-target'); 726 self.knowledge.validate(); 727 local value = new RpgExpandedKnowledgeResult(); 728 if (holds(self.knowledge.beliefs,self.statement)) { value.status = 'reused'; return value; } 729 foreach (local receipt in self.knowledge.checks) 730 if (receipt[1] == self.statement) { value.status = 'reused'; return value; } 731 if (holds(self.knowledge.fabrications,self.statement)) rpgExpandedReject('knowledge-check-fabrication'); 732 if (self.context == nil || !self.context.ofKind(RpgLiteContext) || self.context.kind != 'knowledge' 733 || self.context.skill == nil && self.context.attribute != rpgIQ) 734 rpgExpandedReject('knowledge-check-context'); 735 rpgExpandedValidate(self.knowledge.owner); 736 if (!self.plausible) { value.reason = 'knowledge-check-implausible'; return value; } 737 if (self.knowledge.owner.dead || self.knowledge.owner.unconscious) 738 { value.reason = 'knowledge-check-unavailable'; return value; } 739 local score = rpgLiteScore(self.knowledge.owner,self.context); 740 if (score == nil) { value.reason = self.context.unavailableReason(self.knowledge.owner); return value; } 741 rpgInteger(score,-1000,1000); 742 value.status = 'ready'; return value; 743 } 744 resolve() 745 { 746 local value = self.assess(); 747 if (value.status == 'reused') 748 { 749 value.known = holds(self.knowledge.beliefs,self.statement); 750 foreach (local receipt in self.knowledge.checks) 751 if (receipt[1] == self.statement) value.check = receipt[2]; 752 return value; 753 } 754 if (value.status != 'ready') return value; 755 value.check = rpgLiteCheck(self.knowledge.owner,self.context); 756 value.known = value.check.achieved; 757 self.knowledge.checks = self.knowledge.checks + [[self.statement,value.check]]; 758 if (value.known) self.knowledge.beliefs = self.knowledge.beliefs + [self.statement]; 759 value.status = 'resolved'; return value; 760 } 761 ; 762 763 /** Exact whole-unit trade arithmetic. Fractional currency needs a separate 764 * explicit currency policy; do not silently round a quoted price. */ 765 rpgExpandedTradeFraction(amount, numerator, denominator) 766 { 767 rpgInteger(amount,0,2147483647); rpgInteger(numerator,1,100); rpgInteger(denominator,1,100); 768 local quotient = amount / denominator; local remainder = amount % denominator; 769 if ((remainder * numerator) % denominator != 0) rpgExpandedReject('trade-fractional-money'); 770 local tail = remainder * numerator / denominator; 771 if (quotient > (2147483647 - tail) / numerator) rpgExpandedReject('trade-price-limit'); 772 return quotient * numerator + tail; 773 } 774 775 /** Quoted terms and refusal/advice flags; this result does not transfer property. */ 776 class RpgExpandedTradeResult: RpgExpandedResult 777 price = 0 778 refused = nil 779 combatFollowup = nil 780 followupModifier = 0 781 offersAdvice = nil 782 ; 783 784 /** Trade table prices, full-2008 B560-B562. Direction is from the player party's 785 * perspective. Altered low-band counteroffers remain blocked by a source conflict. */ 786 class RpgExpandedTradeReaction: object 787 band = nil 788 direction = 'buy' 789 fairPrice = 0 790 offeredPrice = nil 791 assess() 792 { 793 rpgInteger(self.band,0,7); rpgInteger(self.fairPrice,1,2147483647); 794 if (!holds(['buy','sell'],self.direction)) rpgExpandedReject('trade-direction'); 795 local offer = self.offeredPrice == nil ? self.fairPrice : self.offeredPrice; 796 rpgInteger(offer,0,2147483647); 797 local value = new RpgExpandedTradeResult(); 798 if (self.band == 0) 799 { 800 value.refused = true; value.combatFollowup = true; value.followupModifier = -2; 801 value.status = 'ready'; return value; 802 } 803 if (self.band <= 5 && offer != self.fairPrice) 804 { value.reason = 'trade-counteroffer-source-conflict'; return value; } 805 if (self.band == 1) 806 value.price = rpgExpandedTradeFraction(self.fairPrice,self.direction == 'buy' ? 3 : 1,self.direction == 'buy' ? 1 : 3); 807 if (self.band == 2) 808 value.price = rpgExpandedTradeFraction(self.fairPrice,self.direction == 'buy' ? 2 : 1,self.direction == 'buy' ? 1 : 2); 809 if (self.band == 3) 810 value.price = rpgExpandedTradeFraction(self.fairPrice,self.direction == 'buy' ? 6 : 3,self.direction == 'buy' ? 5 : 4); 811 if (self.band == 4 || self.band == 5) value.price = self.fairPrice; 812 if (self.band >= 6) 813 { 814 local limit = self.band == 6 815 ? rpgExpandedTradeFraction(self.fairPrice,self.direction == 'buy' ? 4 : 3,self.direction == 'buy' ? 5 : 2) 816 : rpgExpandedTradeFraction(self.fairPrice,self.direction == 'buy' ? 1 : 2,self.direction == 'buy' ? 2 : 1); 817 value.price = self.direction == 'buy' ? (offer < limit ? limit : offer) : (offer > limit ? limit : offer); 818 } 819 value.offersAdvice = self.band >= 5; value.status = 'ready'; return value; 820 } 821 ; 822 823 /** Complete ten-percent steps by which an offer favors either party, B562. 824 * Quotient/remainder arithmetic avoids overflowing when multiplying prices. */ 825 rpgExpandedTradeOfferModifier(fairPrice, offeredPrice, direction) 826 { 827 rpgInteger(fairPrice,1,2147483647); rpgInteger(offeredPrice,0,2147483647); 828 if (!holds(['buy','sell'],direction)) rpgExpandedReject('trade-direction'); 829 local difference = offeredPrice - fairPrice; local sign = difference < 0 ? -1 : 1; 830 if (difference < 0) difference = -difference; 831 local whole = difference / fairPrice; local remainder = difference % fairPrice; 832 if (whole > 100) rpgExpandedReject('trade-modifier-limit'); 833 local steps = whole * 10; 834 for (local step = 1; step <= 9; step += 1) 835 { 836 local threshold = (fairPrice / 10) * step + ((fairPrice % 10) * step + 9) / 10; 837 if (remainder >= threshold) steps += 1; 838 } 839 if (steps > 1000) rpgExpandedReject('trade-modifier-limit'); 840 return steps * sign * (direction == 'buy' ? 1 : -1); 841 } 842 843 /** Transaction-specific offer and commerce skill bonuses. merchantLevel is the 844 * authored applicable skill rating; nil means no qualifying skill bonus. */ 845 class RpgExpandedTradeModifiers: object 846 fairPrice = 0 847 offeredPrice = 0 848 direction = 'buy' 849 merchantLevel = nil 850 assess() 851 { 852 local value = new RpgExpandedResult(); 853 value.modifier = rpgExpandedTradeOfferModifier(self.fairPrice,self.offeredPrice,self.direction); 854 if (self.merchantLevel != nil) 855 { 856 rpgInteger(self.merchantLevel,0,1000); 857 value.modifier += self.merchantLevel >= 20 ? 2 : 1; 858 } 859 value.status = 'ready'; return value; 860 } 861 ; 862 863 modify RpgExpandedProfile 864 loyalties = [] 865 ; 866 867 /** Private loyalty rating, roll and decision for trusted author interpretation. */ 868 class RpgExpandedLoyaltyResult: RpgExpandedResult 869 rating = nil 870 loyal = nil 871 ; 872 873 /** Private relationship, separate from competence, full-2008 B518-B519/B562. */ 874 class RpgExpandedLoyalty: object 875 id = nil 876 subject = nil 877 employer = nil 878 initialModifier = 0 879 preset = nil 880 initialized = nil 881 base = nil 882 initialRoll = nil 883 incidents = [] 884 dangerEffects = [] 885 payBonus = 0 886 payUntil = nil 887 payChanges = [] 888 rating() 889 { 890 self.validate(); 891 if (!self.initialized) rpgExpandedReject('loyalty-uninitialized'); 892 local value = self.base + (self.payUntil == nil || rpgLedger.seconds < self.payUntil ? self.payBonus : 0); 893 foreach (local effect in self.dangerEffects) 894 if (rpgLedger.seconds < effect[2]) value -= 1; 895 return value; 896 } 897 validate() 898 { 899 if (self.id == nil || self.subject == nil || !self.subject.ofKind(RpgExpandedProfile) 900 || self.employer == nil || !holds(self.subject.loyalties,self)) rpgExpandedReject('loyalty-owner'); 901 foreach (local entry in self.subject.loyalties) 902 if (entry != self && (entry.id == self.id || entry.employer == self.employer)) 903 rpgExpandedReject('duplicate-loyalty'); 904 rpgInteger(self.payBonus,0,1000); 905 if (self.payUntil != nil) rpgInteger(self.payUntil,0,2147483647); 906 rpgInteger(self.initialModifier,-1000,1000); 907 if (self.preset != nil) rpgInteger(self.preset,-1000,1000); 908 if (self.initialized) rpgInteger(self.base,-1000,1018); 909 return nil; 910 } 911 assess() 912 { 913 self.validate(); local value = new RpgExpandedLoyaltyResult(); 914 value.status = self.initialized ? 'reused' : 'ready'; return value; 915 } 916 initialize() 917 { 918 local value = self.assess(); 919 if (!self.initialized) 920 { 921 if (self.preset != nil) self.base = self.preset; 922 else { self.initialRoll = rpgRandom.roll(); self.base = self.initialRoll + self.initialModifier; } 923 self.initialized = true; value.status = 'resolved'; 924 } 925 value.rating = self.rating(); value.check = self.initialRoll; return value; 926 } 927 ; 928 929 /** Once-per-incident loyalty check. These are raw 3d comparisons, not success 930 * checks with automatic failures at 17/18. A rating of 20+ is automatic. */ 931 class RpgExpandedLoyaltyCheck: object 932 loyalty = nil 933 incident = nil 934 necessary = nil 935 modifier = 0 936 checkKind = 'ordinary' 937 assess() 938 { 939 if (self.loyalty == nil || !self.loyalty.ofKind(RpgExpandedLoyalty) || self.incident == nil) 940 rpgExpandedReject('loyalty-check-target'); 941 self.loyalty.validate(); rpgInteger(self.modifier,-1000,1000); 942 local value = new RpgExpandedLoyaltyResult(); 943 foreach (local receipt in self.loyalty.incidents) 944 if (receipt[1] == self.incident) 945 { 946 if (receipt[5] != self.checkKind) rpgExpandedReject('loyalty-incident-kind'); 947 value.status = 'reused'; return value; 948 } 949 if (!self.loyalty.initialized) { value.reason = 'loyalty-uninitialized'; return value; } 950 if (!self.necessary) { value.reason = 'loyalty-check-unnecessary'; return value; } 951 value.status = 'ready'; return value; 952 } 953 resolve() 954 { 955 local value = self.assess(); 956 if (value.status == 'reused') 957 { 958 foreach (local receipt in self.loyalty.incidents) 959 if (receipt[1] == self.incident) 960 { value.rating = receipt[2]; value.check = receipt[3]; value.loyal = receipt[4]; } 961 return value; 962 } 963 if (value.status != 'ready') return value; 964 value.rating = self.loyalty.rating() + self.modifier; 965 if (value.rating >= 20) value.loyal = true; 966 else { value.check = rpgRandom.roll(); value.loyal = value.check <= value.rating; } 967 self.loyalty.incidents = self.loyalty.incidents + [[self.incident,value.rating,value.check,value.loyal,self.checkKind]]; 968 value.status = 'resolved'; return value; 969 } 970 ; 971 972 973 /** Noncombatant combat exposure, full-2008 B519. One failed incident causes 974 * a one-week penalty; overlapping exposures require unresolved author policy. */ 975 class RpgExpandedLoyaltyDanger: RpgExpandedLoyaltyCheck 976 checkKind = 'noncombatant-danger' 977 noncombatant = nil 978 assess() 979 { 980 local value = inherited(); 981 if (value.status != 'ready') return value; 982 if (!self.noncombatant) { value.reason = 'loyalty-danger-noncombatant-required'; value.status = 'blocked'; return value; } 983 foreach (local effect in self.loyalty.dangerEffects) 984 if (rpgLedger.seconds < effect[2]) 985 { value.reason = 'loyalty-overlapping-danger-unresolved'; value.status = 'blocked'; return value; } 986 rpgInteger(rpgLedger.seconds,0,2146878847); 987 return value; 988 } 989 resolve() 990 { 991 local value = inherited(); 992 if (value.status == 'resolved' && !value.loyal) 993 self.loyalty.dangerEffects = self.loyalty.dangerEffects + [[self.incident,rpgLedger.seconds + 604800]]; 994 return value; 995 } 996 ; 997 998 modify RpgExpandedLoyalty 999 changes = [] 1000 ; 1001 1002 /** Recorded permanent loyalty change with its signed adjustment. */ 1003 class RpgExpandedLoyaltyChangeResult: RpgExpandedResult 1004 change = 0 1005 ; 1006 1007 /** Permanent changes from explicit completed-world events, full-2008 B519. 1008 * IDs identify an adventure or a previously checked incident, not an API call. */ 1009 class RpgExpandedLoyaltyChange: object 1010 loyalty = nil 1011 id = nil 1012 kind = 'performance' 1013 evidence = nil 1014 continuing = nil 1015 adventureComplete = nil 1016 performance = 'ordinary' 1017 forgiven = nil 1018 assess() 1019 { 1020 if (self.loyalty == nil || !self.loyalty.ofKind(RpgExpandedLoyalty) || self.id == nil) 1021 rpgExpandedReject('loyalty-change-target'); 1022 self.loyalty.validate(); 1023 if (!holds(['performance','forgiveness'],self.kind)) rpgExpandedReject('loyalty-change-kind'); 1024 local value = new RpgExpandedLoyaltyChangeResult(); 1025 foreach (local receipt in self.loyalty.changes) 1026 if (receipt[1] == self.kind && receipt[2] == self.id) { value.status = 'reused'; return value; } 1027 if (!self.loyalty.initialized) { value.reason = 'loyalty-uninitialized'; return value; } 1028 if (self.evidence == nil) { value.reason = 'loyalty-change-evidence'; return value; } 1029 if (self.kind == 'performance') 1030 { 1031 if (!holds(['ordinary','great-success','botched'],self.performance)) rpgExpandedReject('loyalty-performance'); 1032 if (!self.continuing || !self.adventureComplete) 1033 { value.reason = 'loyalty-adventure-incomplete'; return value; } 1034 } 1035 else 1036 { 1037 if (!self.forgiven) { value.reason = 'loyalty-not-forgiven'; return value; } 1038 local failed = nil; 1039 foreach (local receipt in self.loyalty.incidents) 1040 if (receipt[1] == self.id && !receipt[4]) failed = true; 1041 if (!failed) { value.reason = 'loyalty-no-failed-incident'; return value; } 1042 } 1043 /* Reserve room for either one-point outcome before committing anything. */ 1044 rpgInteger(self.loyalty.base,-999,1017); 1045 value.status = 'ready'; return value; 1046 } 1047 apply() 1048 { 1049 local value = self.assess(); if (value.status != 'ready') return value; 1050 if (self.kind == 'performance') 1051 value.change = self.performance == 'great-success' ? 1 : self.performance == 'botched' ? -1 : 0; 1052 else value.change = self.loyalty.rating() >= 16 ? 1 : 0; 1053 self.loyalty.base += value.change; 1054 self.loyalty.changes = self.loyalty.changes + [[self.kind,self.id,value.change,self.evidence]]; 1055 value.status = 'resolved'; return value; 1056 } 1057 ; 1058 1059 modify RpgExpandedLoyalty 1060 serviceYears = [] 1061 lastServiceYear = 0 1062 lastAnniversaryAt = 0 1063 ; 1064 1065 /** Service-anniversary result and any permanent loyalty increase. */ 1066 class RpgExpandedServiceResult: RpgExpandedLoyaltyResult 1067 change = 0 1068 ; 1069 1070 /** B519 annual service check. The campaign calendar supplies an absolute due 1071 * time; this operation never assumes 365 days or charges elapsed time again. */ 1072 class RpgExpandedServiceAnniversary: object 1073 loyalty = nil 1074 year = 1 1075 anniversaryAt = nil 1076 completedService = nil 1077 assess() 1078 { 1079 if (self.loyalty == nil || !self.loyalty.ofKind(RpgExpandedLoyalty)) 1080 rpgExpandedReject('service-loyalty'); 1081 self.loyalty.validate(); rpgInteger(self.year,1,10000); 1082 local value = new RpgExpandedServiceResult(); 1083 foreach (local receipt in self.loyalty.serviceYears) 1084 if (receipt[1] == self.year) { value.status = 'reused'; return value; } 1085 if (!self.loyalty.initialized) { value.reason = 'loyalty-uninitialized'; return value; } 1086 if (self.year != self.loyalty.lastServiceYear + 1) 1087 { value.reason = 'service-year-gap'; return value; } 1088 rpgInteger(self.anniversaryAt,1,2147483647); 1089 if (self.anniversaryAt <= self.loyalty.lastAnniversaryAt) 1090 rpgExpandedReject('service-anniversary-order'); 1091 if (!self.completedService) { value.reason = 'service-year-incomplete'; return value; } 1092 if (rpgLedger.seconds < self.anniversaryAt) { value.reason = 'service-anniversary-not-due'; return value; } 1093 rpgInteger(self.loyalty.base,-1000,1017); 1094 value.status = 'ready'; return value; 1095 } 1096 resolve() 1097 { 1098 local value = self.assess(); 1099 if (value.status == 'reused') 1100 { 1101 foreach (local receipt in self.loyalty.serviceYears) 1102 if (receipt[1] == self.year) 1103 { value.rating = receipt[3]; value.check = receipt[4]; value.loyal = receipt[5]; } 1104 return value; 1105 } 1106 if (value.status != 'ready') return value; 1107 value.rating = self.loyalty.rating(); 1108 if (value.rating >= 20) value.loyal = true; 1109 else { value.check = rpgRandom.roll(); value.loyal = value.check <= value.rating; } 1110 value.change = value.loyal ? 1 : 0; self.loyalty.base += value.change; 1111 self.loyalty.serviceYears = self.loyalty.serviceYears 1112 + [[self.year,self.anniversaryAt,value.rating,value.check,value.loyal]]; 1113 self.loyalty.lastServiceYear = self.year; self.loyalty.lastAnniversaryAt = self.anniversaryAt; 1114 value.status = 'resolved'; return value; 1115 } 1116 ; 1117 1118 modify RpgExpandedLoyalty 1119 rescues = [] 1120 ; 1121 1122 /** Rescue gratitude result and the resulting private loyalty rating. */ 1123 class RpgExpandedRescueResult: RpgExpandedLoyaltyResult 1124 total = nil 1125 change = 0 1126 ; 1127 1128 /** Rescue gratitude, full-2008 B519. World risk and a completed rescue are 1129 * explicit facts. A reaction below good never reduces existing loyalty. */ 1130 class RpgExpandedRescueLoyalty: object 1131 loyalty = nil 1132 id = nil 1133 evidence = nil 1134 rescued = nil 1135 riskedLifeOrMission = nil 1136 rescueBonus = 3 1137 otherModifier = 0 1138 sacrificeBonus = 0 1139 seriousSacrifice = nil 1140 assess() 1141 { 1142 if (self.loyalty == nil || !self.loyalty.ofKind(RpgExpandedLoyalty) || self.id == nil) 1143 rpgExpandedReject('rescue-loyalty-target'); 1144 self.loyalty.validate(); local value = new RpgExpandedRescueResult(); 1145 foreach (local receipt in self.loyalty.rescues) 1146 if (receipt[1] == self.id) { value.status = 'reused'; return value; } 1147 if (!self.loyalty.initialized) { value.reason = 'loyalty-uninitialized'; return value; } 1148 if (!self.rescued || !self.riskedLifeOrMission || self.evidence == nil) 1149 { value.reason = 'rescue-loyalty-evidence'; return value; } 1150 rpgInteger(self.rescueBonus,3,1000); rpgInteger(self.otherModifier,-1000,1000); 1151 rpgInteger(self.rescueBonus + self.otherModifier,-1000,1000); 1152 if (self.loyalty.rating() != self.loyalty.base) 1153 { value.reason = 'rescue-temporary-loyalty-unresolved'; return value; } 1154 rpgInteger(self.sacrificeBonus,0,1000); 1155 if (self.sacrificeBonus > 0 && !self.seriousSacrifice) rpgExpandedReject('rescue-sacrifice-evidence'); 1156 local possible = 18 + self.rescueBonus + self.otherModifier; 1157 if (possible < self.loyalty.base) possible = self.loyalty.base; 1158 if (possible > 1018 - self.sacrificeBonus) rpgExpandedReject('rescue-loyalty-limit'); 1159 value.status = 'ready'; return value; 1160 } 1161 resolve() 1162 { 1163 local value = self.assess(); 1164 if (value.status == 'reused') 1165 { 1166 foreach (local receipt in self.loyalty.rescues) 1167 if (receipt[1] == self.id) 1168 { value.check = receipt[2]; value.total = receipt[3]; value.rating = receipt[4]; } 1169 return value; 1170 } 1171 if (value.status != 'ready') return value; 1172 value.check = rpgRandom.roll(); value.total = value.check + self.rescueBonus + self.otherModifier; 1173 value.rating = self.loyalty.base; 1174 if (value.total >= 13 && value.total > value.rating) value.rating = value.total; 1175 value.rating += self.sacrificeBonus; 1176 value.change = value.rating - self.loyalty.base; self.loyalty.base = value.rating; 1177 self.loyalty.rescues = self.loyalty.rescues + [[self.id,value.check,value.total,value.rating,self.evidence,self.sacrificeBonus]]; 1178 value.status = 'resolved'; return value; 1179 } 1180 ; 1181 1182 1183 /** B519 higher-pay bonus. The job's normal and actual pay cover the same period. 1184 * Payment/loot transfer happens in its own operation; evidence identifies it. */ 1185 class RpgExpandedLoyaltyPayChange: object 1186 loyalty = nil 1187 id = nil 1188 mode = 'start' 1189 normalPay = 0 1190 actualPay = 0 1191 expiresAt = nil 1192 evidence = nil 1193 assess() 1194 { 1195 if (self.loyalty == nil || !self.loyalty.ofKind(RpgExpandedLoyalty) || self.id == nil) 1196 rpgExpandedReject('loyalty-pay-target'); 1197 self.loyalty.validate(); 1198 if (!holds(['start','stop'],self.mode)) rpgExpandedReject('loyalty-pay-mode'); 1199 local value = new RpgExpandedResult(); 1200 foreach (local receipt in self.loyalty.payChanges) 1201 if (receipt[1] == self.id) { value.status = 'reused'; return value; } 1202 if (self.evidence == nil) { value.reason = 'loyalty-pay-evidence'; return value; } 1203 if (self.mode == 'start') 1204 { 1205 rpgInteger(self.normalPay,1,2147483647); rpgInteger(self.actualPay,0,2147483647); 1206 if (self.actualPay <= self.normalPay) { value.reason = 'loyalty-no-extra-pay'; return value; } 1207 value.modifier = rpgExpandedTradeOfferModifier(self.normalPay,self.actualPay,'buy'); 1208 if (value.modifier == 0) { value.reason = 'loyalty-no-full-pay-step'; return value; } 1209 if (self.loyalty.payBonus > 0 && (self.loyalty.payUntil == nil || rpgLedger.seconds < self.loyalty.payUntil)) 1210 { value.reason = 'loyalty-pay-overlap-unresolved'; return value; } 1211 } 1212 else 1213 { 1214 if (self.loyalty.payBonus == 0 || self.loyalty.payUntil != nil) 1215 { value.reason = 'loyalty-extra-pay-not-active'; return value; } 1216 rpgInteger(self.expiresAt,1,2147483647); 1217 if (self.expiresAt <= rpgLedger.seconds) { value.reason = 'loyalty-pay-expiry'; return value; } 1218 } 1219 value.status = 'ready'; return value; 1220 } 1221 apply() 1222 { 1223 local value = self.assess(); if (value.status != 'ready') return value; 1224 if (self.mode == 'start') { self.loyalty.payBonus = value.modifier; self.loyalty.payUntil = nil; } 1225 else self.loyalty.payUntil = self.expiresAt; 1226 self.loyalty.payChanges = self.loyalty.payChanges 1227 + [[self.id,self.mode,self.loyalty.payBonus,self.loyalty.payUntil,self.evidence]]; 1228 value.status = 'resolved'; return value; 1229 } 1230 ;