lib/vhyl-rpg-report.t¶
Reference index ยท View on GitHub
1 /* Optional assessments and disclosure-controlled mechanics events. 2 * Include after vhyl-rpg.t. Copyright (c) 2026 John Cunningham. MIT License. */ 3 4 /** An author ceiling or a player's requested level of detail. Every group is 5 * opt-in. A player preference can reduce, but never broaden, author permission. */ 6 class RpgDisclosure: object 7 visible = nil 8 showAbility = nil 9 showCalculation = nil 10 showChance = nil 11 showCosts = nil 12 showAvailability = nil 13 showOutcome = nil 14 showDice = nil 15 showOpposition = nil 16 /* Authored qualitative estimate when exact odds cannot be disclosed. */ 17 estimateLabelId = nil 18 ; 19 20 /** Opt in explicitly on an approach; never the implicit disclosure default. */ 21 rpgFullDisclosure: RpgDisclosure 22 visible = true showAbility = true showCalculation = true showChance = true 23 showCosts = true showAvailability = true showOutcome = true 24 showDice = true showOpposition = true 25 ; 26 27 modify RpgApproach 28 assessmentDisclosure = nil 29 rollDisclosure = nil 30 /* Observational override for knowledge-dependent disclosure. */ 31 disclosureFor(c, source) 32 { 33 return source == 'assessment' ? self.assessmentDisclosure : self.rollDisclosure; 34 } 35 ; 36 37 modify RpgAssessment 38 odds = nil 39 ; 40 41 /** Exact integer counts. Outcome categories are mutually exclusive. Raw roll 42 * success and critical flags overlap those categories, especially in contests. */ 43 class RpgOdds: object 44 total = 0 45 achieved = 0 46 success = 0 47 costlySuccess = 0 48 setback = 0 49 complication = 0 50 tie = 0 51 rollSuccess = 0 52 criticalSuccess = 0 53 criticalFailure = 0 54 add(result, weight) 55 { 56 self.total += weight; 57 if (result.achieved) self.achieved += weight; 58 if (result.outcome == 'success') self.success += weight; 59 else if (result.outcome == 'costly-success') self.costlySuccess += weight; 60 else if (result.outcome == 'setback') self.setback += weight; 61 else if (result.outcome == 'complication') self.complication += weight; 62 else if (result.outcome == 'tie') self.tie += weight; 63 if (result.rollSucceeded) self.rollSuccess += weight; 64 if (result.critical == 'success') self.criticalSuccess += weight; 65 if (result.critical == 'failure') self.criticalFailure += weight; 66 return nil; 67 } 68 /* Rounded hundredths of a percent; retain counts for exact arithmetic. */ 69 chanceBasisPoints() { return (self.achieved * 10000 + self.total / 2) / self.total; } 70 ; 71 72 rpgRollWeight(total) 73 { 74 return [1, 3, 6, 10, 15, 21, 25, 27, 27, 25, 21, 15, 10, 6, 3, 1][total - 2]; 75 } 76 77 /** Trusted assessment, not a player-safe disclosure. No dice are drawn, no 78 * attempts recorded, and no consequences or persistent writes occur. */ 79 rpgAssess(profile, approach) 80 { 81 local value = rpgPrepare(profile, approach); 82 value.odds = rpgOddsFor(value); 83 return value; 84 } 85 86 /** Enumerate exact weighted outcomes for a trusted prepared assessment. 87 * Returns nil unless ready. This helper does not apply disclosure policy. */ 88 rpgOddsFor(value) 89 { 90 if (value.status != 'ready') return nil; 91 local odds = new RpgOdds(); 92 if (value.kind == 'automatic') 93 { 94 odds.total = 1; odds.achieved = 1; odds.success = 1; 95 odds.rollSuccess = nil; odds.criticalSuccess = nil; odds.criticalFailure = nil; 96 } 97 else 98 { 99 for (local total = 3; total <= 18; total += 1) 100 { 101 if (value.oppositionCalculation == nil) 102 odds.add(rpgEvaluate(value.calculation.effectiveScore, total, nil, nil, 103 value.costlyMargin), rpgRollWeight(total)); 104 else 105 for (local other = 3; other <= 18; other += 1) 106 odds.add(rpgEvaluate(value.calculation.effectiveScore, total, 107 value.oppositionCalculation.effectiveScore, other, value.costlyMargin), 108 rpgRollWeight(total) * rpgRollWeight(other)); 109 } 110 } 111 return odds; 112 } 113 114 /** Trusted retrieval of the latest recorded attempt, including a completed 115 * goal. No current skill/prerequisite hooks are evaluated. nil means no record. */ 116 rpgRecordedResult(profile, approach) 117 { 118 local value = new RpgResult(); value.profile = profile; value.approach = approach; 119 rpgAttemptFor(profile, approach).recall(value); 120 return value.status == 'reused' ? value : nil; 121 } 122 123 rpgDisclosureFor(value, preferences, source) 124 { 125 if (preferences != nil && !preferences.ofKind(RpgDisclosure)) throw new RpgError(); 126 local policy = value.approach.disclosureFor(rpgContext(value.profile, value.approach, nil), source); 127 if (policy == nil) return nil; 128 if (!policy.ofKind(RpgDisclosure)) throw new RpgError(); 129 return policy.visible && (preferences == nil || preferences.visible) ? policy : nil; 130 } 131 132 rpgDisclosed(policy, preferences, flag) 133 { 134 return policy.(flag) && (preferences == nil || preferences.(flag)); 135 } 136 137 /* Existence of a contest is itself opposition information. */ 138 rpgDisclosedKind(value, policy, preferences) 139 { 140 if (value.kind == 'contest' && !rpgDisclosed(policy, preferences, &showOpposition)) return nil; 141 return value.kind; 142 } 143 144 rpgPublishCalculation(calculation, value, source, side, policy, preferences) 145 { 146 local ability = rpgDisclosed(policy, preferences, &showAbility); 147 local numbers = rpgDisclosed(policy, preferences, &showCalculation); 148 if (calculation == nil || (!ability && !numbers)) return nil; 149 event('rpg.calculation'); eventValue(1); eventValue(source); 150 eventValue(value.approach.challenge.id); eventValue(value.approach.id); 151 eventValue(value.profile.actor); eventValue(side); 152 eventValue(ability ? calculation.abilityLabelId : nil); 153 eventValue(ability ? calculation.attributeId : nil); 154 eventValue(ability ? calculation.training : nil); 155 eventValue(numbers ? calculation.attributeScore : nil); 156 eventValue(numbers ? calculation.defaultPenalty : nil); 157 eventValue(numbers ? calculation.baseScore : nil); 158 eventValue(numbers ? calculation.effectiveScore : nil); 159 local modifiers = numbers ? calculation.modifiers : []; 160 eventValue(modifiers.length() / 3); 161 foreach (local item in modifiers) eventValue(item); 162 return nil; 163 } 164 165 /** Emit a redacted assessment and optional calculation events. nil preferences 166 * means use the author's ceiling. Hidden checks emit nothing, including IDs. */ 167 rpgPublishAssessment(value, preferences) 168 { 169 local policy = rpgDisclosureFor(value, preferences, 'assessment'); 170 if (policy == nil) return nil; 171 local availability = rpgDisclosed(policy, preferences, &showAvailability); 172 local costs = rpgDisclosed(policy, preferences, &showCosts); 173 local chance = rpgDisclosed(policy, preferences, &showChance) 174 && (value.kind != 'contest' || rpgDisclosed(policy, preferences, &showOpposition)); 175 local odds = chance ? value.odds : nil; 176 local estimate = odds == nil && (preferences == nil || preferences.showChance) 177 ? policy.estimateLabelId : nil; 178 event('rpg.assessment'); eventValue(1); 179 eventValue(value.approach.challenge.id); eventValue(value.approach.id); 180 eventValue(value.profile.actor); 181 eventValue(availability ? value.status : nil); eventValue(availability ? value.reason : nil); 182 eventValue(rpgDisclosedKind(value, policy, preferences)); 183 eventValue(costs ? value.timeCost : nil); eventValue(costs ? value.energyCost : nil); 184 eventValue(estimate); 185 eventValue(odds == nil ? nil : odds.total); eventValue(odds == nil ? nil : odds.achieved); 186 eventValue(odds == nil ? nil : odds.success); eventValue(odds == nil ? nil : odds.costlySuccess); 187 eventValue(odds == nil ? nil : odds.setback); eventValue(odds == nil ? nil : odds.complication); 188 eventValue(odds == nil ? nil : odds.tie); eventValue(odds == nil ? nil : odds.rollSuccess); 189 eventValue(odds == nil ? nil : odds.criticalSuccess); eventValue(odds == nil ? nil : odds.criticalFailure); 190 rpgPublishCalculation(value.calculation, value, 'assessment', 'acting', policy, preferences); 191 if (rpgDisclosed(policy, preferences, &showOpposition)) 192 rpgPublishCalculation(value.oppositionCalculation, value, 'assessment', 'opposition', policy, preferences); 193 return nil; 194 } 195 196 /** Report recorded faces and calculation snapshots, never a fresh roll or a 197 * recalculation against today's attributes. Automatic attempts have no dice. */ 198 rpgPublishRoll(value, preferences) 199 { 200 if (value.status != 'resolved' && value.status != 'reused') return nil; 201 local policy = rpgDisclosureFor(value, preferences, 'roll'); 202 if (policy == nil) return nil; 203 local dice = rpgDisclosed(policy, preferences, &showDice); 204 local opposition = rpgDisclosed(policy, preferences, &showOpposition); 205 local numbers = rpgDisclosed(policy, preferences, &showCalculation); 206 local outcome = rpgDisclosed(policy, preferences, &showOutcome); 207 local costs = rpgDisclosed(policy, preferences, &showCosts); 208 local ownDice = dice ? value.dice : []; 209 local otherDice = dice && opposition ? value.oppositionDice : []; 210 event('rpg.roll'); eventValue(1); 211 eventValue(value.approach.challenge.id); eventValue(value.approach.id); 212 eventValue(value.profile.actor); 213 eventValue(rpgDisclosed(policy, preferences, &showAvailability) ? value.status : nil); 214 eventValue(rpgDisclosedKind(value, policy, preferences)); 215 eventValue(outcome ? value.outcome : nil); eventValue(outcome ? value.achieved : nil); 216 eventValue(costs ? value.recordedTimeSpent : nil); eventValue(costs ? value.recordedEnergySpent : nil); 217 eventValue(dice ? value.roll : nil); eventValue(dice && numbers ? value.margin : nil); 218 eventValue(dice && outcome ? value.critical : nil); 219 eventValue(dice && opposition ? value.oppositionRoll : nil); 220 eventValue(dice && opposition && numbers ? value.oppositionMargin : nil); 221 eventValue(dice && opposition && outcome ? value.oppositionCritical : nil); 222 eventValue(dice ? ownDice.length() : nil); 223 eventValue(dice && opposition ? otherDice.length() : nil); 224 foreach (local face in ownDice) eventValue(face); 225 foreach (local face in otherDice) eventValue(face); 226 rpgPublishCalculation(value.calculation, value, 'roll', 'acting', policy, preferences); 227 if (opposition) 228 rpgPublishCalculation(value.oppositionCalculation, value, 'roll', 'opposition', policy, preferences); 229 return nil; 230 }