lib/vhyl-rpg-conflict.t¶
Reference index ยท View on GitHub
1 /* Optional bounded physical and social conflicts. Include after resilience. 2 * Copyright (c) 2026 John Cunningham. MIT License. */ 3 4 /** Authored NPC disposition. Permission to consider a request is separate from 5 * the chance of persuasion. A success never overrides an explicit refusal. */ 6 class RpgDisposition: object 7 profile = nil 8 attitude = 0 9 permits(actor, approach) { return nil; } 10 ; 11 12 /** Persistent social opposition using ordinary challenge retry records. 13 * New facts or concessions, rather than repeated wording, reopen the goal. */ 14 class RpgSocialApproach: RpgApproach 15 disposition = nil 16 requiresExertion = nil 17 oppositionAttribute = rpgWill 18 opponent = (disposition.profile) 19 successShift = 1 20 failureShift = -1 21 blockedReason(c) 22 { 23 local reason = inherited(c); 24 return reason != nil ? reason : self.disposition.permits(c.profile, self); 25 } 26 oppositionModifierFor(c) { return inherited(c) - self.disposition.attitude; } 27 apply(c, result) 28 { 29 inherited(c, result); 30 local attitude = self.disposition.attitude + (result.achieved ? self.successShift : self.failureShift); 31 self.disposition.attitude = attitude < -3 ? -3 : attitude > 3 ? 3 : attitude; 32 return nil; 33 } 34 ; 35 36 /** One owned combatant. Defaults describe a modest unarmed attack; games 37 * explicitly author weapons, armor and permission to use them. */ 38 class RpgCombatant: object 39 profile = nil 40 skill = nil 41 attribute = rpgDX 42 attackModifier = 0 43 defense = nil 44 armor = 0 45 damageDice = 1 46 damageBonus = -2 47 woundNumerator = 1 48 woundDenominator = 1 49 energyCost = 1 50 available(other) { return nil; } 51 defenseScore() 52 { 53 local score = self.defense == nil 54 ? (self.profile.attributeScore(rpgDX) + self.profile.attributeScore(rpgHT)) / 4 + 3 55 : self.defense; 56 local divisor = rpgBody(self.profile).mobilityDivisor(); 57 return (score + divisor - 1) / divisor; 58 } 59 ; 60 61 /** Two-sided authored scene. One player maneuver and one eligible reply form 62 * an exchange, not a parser-wide combat clock. No unconscious actor attacks. 63 * A bounded stalemate, escape and surrender keep nonlethal routes available. */ 64 class RpgConflict: object 65 id = nil 66 protagonist = nil 67 opposition = nil 68 active = nil 69 finished = nil 70 outcome = nil 71 exchanges = 0 72 maxExchanges = 6 73 secondsPerExchange = 2 74 discloseRolls = nil 75 available() { return nil; } 76 ended(result) { return nil; } 77 ; 78 79 /** Temporary strike details. Raw checks and damage dice are trusted data. */ 80 class RpgStrike: object 81 status = 'blocked' 82 reason = nil 83 outcome = nil 84 attack = nil 85 defense = nil 86 dice = [] 87 defenseDice = [] 88 damageDice = [] 89 damage = 0 90 injury = 0 91 ; 92 93 /** Temporary exchange outcome; publishing it is an explicit game decision. */ 94 class RpgConflictResult: object 95 conflict = nil 96 status = 'blocked' 97 reason = nil 98 maneuver = nil 99 strike = nil 100 reply = nil 101 escape = nil 102 recovery = nil 103 outcome = nil 104 ; 105 106 /** Begin a fresh authored conflict once. Resuming a finished scene requires a 107 * different conflict object, rather than erasing its recorded outcome. */ 108 rpgBeginConflict(conflict) 109 { 110 if (conflict == nil || !conflict.ofKind(RpgConflict)) throw new RpgError(); 111 local value = new RpgConflictResult(); value.conflict = conflict; 112 if (conflict.finished) { value.status = 'complete'; value.outcome = conflict.outcome; return value; } 113 if (conflict.active) { value.status = 'reused'; return value; } 114 value.reason = conflict.available(); 115 if (value.reason != nil) return value; 116 if (rpgBody(conflict.protagonist.profile).unconscious || rpgBody(conflict.protagonist.profile).dead 117 || rpgBody(conflict.opposition.profile).unconscious || rpgBody(conflict.opposition.profile).dead) 118 { value.reason = 'rpg.unavailable-combatant'; return value; } 119 conflict.active = true; value.status = 'resolved'; value.outcome = 'started'; return value; 120 } 121 122 rpgFinishConflict(value, outcome) 123 { 124 local conflict = value.conflict; 125 if (conflict.finished) return nil; 126 conflict.active = nil; conflict.finished = true; conflict.outcome = outcome; 127 value.outcome = outcome; conflict.ended(value); return nil; 128 } 129 130 rpgCombatMethod(combatant) 131 { 132 local method = new RpgApproach(); method.skill = combatant.skill; 133 method.attribute = combatant.attribute; method.modifier = combatant.attackModifier; 134 return method; 135 } 136 137 rpgStrike(attacker, defender, defenseBonus) 138 { 139 local value = new RpgStrike(); 140 local method = rpgCombatMethod(attacker); 141 local context = rpgContext(attacker.profile, method, nil); 142 value.reason = attacker.profile.blockedReason(context); 143 if (value.reason == nil) value.reason = attacker.available(defender); 144 if (value.reason != nil) return value; 145 if (attacker.profile.energy < attacker.energyCost) 146 { value.reason = 'insufficient-energy'; return value; } 147 local score = rpgCalculate(context).effectiveScore; 148 if (score == nil) { value.reason = 'training-required'; return value; } 149 if (score < 3) { value.reason = 'too-difficult'; return value; } 150 value.status = 'resolved'; value.dice = rpgRandom.dice(); 151 value.attack = rpgCheck(score, rpgDiceTotal(value.dice)); 152 attacker.profile.energy -= attacker.energyCost; 153 rpgBody(attacker.profile).endAction(); 154 value.outcome = 'miss'; 155 if (!value.attack.achieved) return value; 156 local body = rpgBody(defender.profile); 157 if (value.attack.critical != 'success' && !body.unconscious && !body.dead) 158 { 159 local defense = defender.defenseScore() + defenseBonus - (body.stunned ? 4 : 0); 160 value.defenseDice = rpgRandom.dice(); 161 value.defense = rpgCheck(defense, rpgDiceTotal(value.defenseDice)); 162 if (value.defense.achieved) { value.outcome = 'defended'; return value; } 163 } 164 local damage = attacker.damageBonus; 165 for (local i = 0; i < attacker.damageDice; i += 1) 166 { 167 local die = rpgRandom.die(); value.damageDice = value.damageDice + [die]; damage += die; 168 } 169 value.damage = damage < 0 ? 0 : damage; 170 local injury = rpgWound(defender.profile, value.damage, defender.armor, 171 attacker.woundNumerator, attacker.woundDenominator); 172 value.injury = injury.injury; value.outcome = 'hit'; return value; 173 } 174 175 /** Commit attack, defend, recover, escape or surrender. Only a resolved 176 * exchange spends time or advances the bounded encounter. Queries do neither. */ 177 rpgConflictStep(conflict, maneuver) 178 { 179 if (conflict == nil || !conflict.ofKind(RpgConflict) 180 || !holds(['attack', 'defend', 'recover', 'escape', 'surrender'], maneuver)) throw new RpgError(); 181 local value = new RpgConflictResult(); value.conflict = conflict; value.maneuver = maneuver; 182 if (conflict.finished) { value.status = 'complete'; value.outcome = conflict.outcome; return value; } 183 if (!conflict.active) { value.reason = 'rpg.no-conflict'; return value; } 184 value.reason = conflict.available(); 185 if (value.reason != nil) return value; 186 local hero = conflict.protagonist; local enemy = conflict.opposition; 187 local ownBody = rpgBody(hero.profile); local enemyBody = rpgBody(enemy.profile); 188 value.status = 'resolved'; 189 if (ownBody.unconscious || ownBody.dead) { rpgFinishConflict(value, 'defeated'); return value; } 190 if (enemyBody.unconscious || enemyBody.dead) { rpgFinishConflict(value, 'won'); return value; } 191 if (maneuver == 'surrender') { rpgFinishConflict(value, 'surrendered'); return value; } 192 if (maneuver == 'recover') 193 { 194 if (ownBody.stunned) 195 { 196 value.recovery = rpgCheck(hero.profile.attributeScore(rpgHT), rpgRandom.roll()); 197 if (value.recovery.achieved) ownBody.stunned = nil; 198 } 199 ownBody.endAction(); 200 } 201 else 202 { 203 local method = rpgCombatMethod(hero); 204 if (maneuver == 'defend') method.requiresExertion = nil; 205 value.reason = hero.profile.blockedReason(rpgContext(hero.profile, method, nil)); 206 if (value.reason != nil) { value.status = 'blocked'; return value; } 207 if (maneuver == 'attack') 208 { 209 value.strike = rpgStrike(hero, enemy, 0); 210 if (value.strike.status != 'resolved') 211 { value.status = 'blocked'; value.reason = value.strike.reason; return value; } 212 } 213 else if (maneuver == 'escape') 214 { 215 local escapeMethod = new RpgApproach(); escapeMethod.attribute = rpgDX; 216 local ownScore = rpgCalculate(rpgContext(hero.profile, escapeMethod, nil)).effectiveScore; 217 local otherScore = rpgCalculate(rpgContext(enemy.profile, escapeMethod, nil)).effectiveScore; 218 if (ownScore < 3 || otherScore < 3) 219 { value.status = 'blocked'; value.reason = 'too-difficult'; return value; } 220 local one = rpgRandom.roll(); local two = rpgRandom.roll(); 221 value.escape = rpgEvaluate(ownScore, one, otherScore, two, 0); 222 ownBody.endAction(); 223 if (value.escape.achieved) rpgFinishConflict(value, 'escaped'); 224 } 225 else ownBody.endAction(); 226 } 227 conflict.exchanges += 1; rpgSpendTime(conflict.secondsPerExchange); 228 if (!conflict.finished && (enemyBody.unconscious || enemyBody.dead)) rpgFinishConflict(value, 'won'); 229 if (!conflict.finished) 230 { 231 if (enemyBody.stunned) 232 { 233 local recovery = rpgCheck(enemy.profile.attributeScore(rpgHT), rpgRandom.roll()); 234 enemyBody.endAction(); 235 if (recovery.achieved) enemyBody.stunned = nil; 236 } 237 else value.reply = rpgStrike(enemy, hero, maneuver == 'defend' ? 2 : 0); 238 if (ownBody.unconscious || ownBody.dead) rpgFinishConflict(value, 'defeated'); 239 else if (conflict.exchanges >= conflict.maxExchanges) rpgFinishConflict(value, 'stalemate'); 240 } 241 if (value.outcome == nil) value.outcome = 'continuing'; 242 return value; 243 } 244 245 /** Explicit conflict report. Roll details are omitted unless authored opt-in 246 * permits them. No NPC health, defense rating or hidden calculation is inferred. */ 247 rpgPublishConflict(value) 248 { 249 local c = value.conflict; 250 event('rpg.conflict'); eventValue(1); eventValue(c.id); eventValue(value.status); 251 eventValue(value.reason); eventValue(value.maneuver); eventValue(value.outcome); 252 eventValue(c.exchanges); eventValue(c.active); 253 if (c.discloseRolls) 254 foreach (local strike in [value.strike, value.reply]) 255 { 256 if (strike == nil || strike.attack == nil) continue; 257 event('rpg.strike'); eventValue(1); eventValue(c.id); 258 eventValue(strike == value.strike ? 'acting' : 'reply'); eventValue(strike.outcome); 259 eventValue(strike.attack.score); eventValue(strike.attack.roll); 260 eventValue(strike.defense == nil ? nil : strike.defense.roll); 261 eventValue(strike.damage); eventValue(strike.injury); 262 eventValue(strike.dice.length()); eventValue(strike.defenseDice.length()); 263 eventValue(strike.damageDice.length()); 264 foreach (local die in strike.dice) eventValue(die); 265 foreach (local die in strike.defenseDice) eventValue(die); 266 foreach (local die in strike.damageDice) eventValue(die); 267 } 268 return nil; 269 } 270 271 rpgConflictValidation: InitObject 272 execute() 273 { 274 for (local s = firstObj(RpgDisposition); s != nil; s = nextObj(s, RpgDisposition)) 275 { 276 if (s.profile == nil || !s.profile.ofKind(RpgProfile)) throw new RpgError(); 277 rpgInteger(s.attitude, -3, 3); 278 } 279 for (local a = firstObj(RpgSocialApproach); a != nil; a = nextObj(a, RpgSocialApproach)) 280 { 281 if (a.disposition == nil || !a.disposition.ofKind(RpgDisposition)) throw new RpgError(); 282 rpgInteger(a.successShift, 0, 3); rpgInteger(a.failureShift, -3, 0); 283 } 284 for (local fighter = firstObj(RpgCombatant); fighter != nil; fighter = nextObj(fighter, RpgCombatant)) 285 { 286 rpgBody(fighter.profile); rpgValidateAttribute(fighter.attribute); 287 if (fighter.skill != nil && !fighter.skill.ofKind(RpgSkill)) throw new RpgError(); 288 rpgInteger(fighter.attackModifier, -500, 500); 289 if (fighter.defense != nil) rpgInteger(fighter.defense, 3, 100); 290 rpgInteger(fighter.armor, 0, 10000); rpgInteger(fighter.damageDice, 0, 10); 291 rpgInteger(fighter.damageBonus, -100, 100); 292 rpgInteger(fighter.energyCost, 0, 1000); 293 rpgInteger(fighter.woundNumerator, 1, 10); rpgInteger(fighter.woundDenominator, 1, 10); 294 } 295 local ids = []; 296 for (local c = firstObj(RpgConflict); c != nil; c = nextObj(c, RpgConflict)) 297 { 298 if (dataType(c.id) != TypeSString || c.id == '' || holds(ids, c.id) 299 || c.protagonist == nil || !c.protagonist.ofKind(RpgCombatant) 300 || c.opposition == nil || !c.opposition.ofKind(RpgCombatant) 301 || c.protagonist.profile == c.opposition.profile) throw new RpgError(); 302 ids = ids + [c.id]; rpgInteger(c.maxExchanges, 1, 100); 303 rpgInteger(c.secondsPerExchange, 1, 86400); 304 } 305 return nil; 306 } 307 ;