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  }