lib/vhyl-rpg.t¶
Reference index ยท View on GitHub
1 /* Optional mundane character and challenge mechanics. Include after vhyl.t. 2 * Copyright (c) 2026 John Cunningham. MIT License; see LICENSE. */ 3 4 enum rpgST, rpgDX, rpgIQ, rpgHT, rpgWill, rpgPerception; 5 6 /** Invalid configuration or an invalid mechanics API argument. */ 7 class RpgError: object; 8 9 /** Optional binding from a vhyl world entity to its authored profile. */ 10 modify Thing 11 /* Optional authored profile; the resolver also accepts profiles directly. */ 12 rpgProfile = nil 13 ; 14 15 /** A skill's governing attribute, training difficulty and explicit default. 16 * difficulty: 0, 1 or 2. defaultPenalty: a nonpositive integer, or nil for 17 * training only. Do not infer a default from difficulty. */ 18 class RpgSkill: object 19 labelId = nil 20 attribute = rpgIQ 21 difficulty = 1 22 defaultPenalty = nil 23 /* Extension point for profiles that support additional difficulty bands. */ 24 maximumDifficulty() { return 2; } 25 ; 26 27 /** Investment in one skill. Zero reserves an untrained slot for progression; 28 * positive values buy training. The core never awards or spends points itself. */ 29 class RpgTraining: object 30 skill = nil 31 points = 1 32 ; 33 34 /** An authored benefit or limitation. The modifier hook is observational. 35 * Use c.skill and c.isOpposition to keep its application appropriately narrow. */ 36 class RpgTrait: object 37 labelId = nil 38 modifierFor(c) { return 0; } 39 ; 40 41 /** Share this model between protagonists and supporting characters. 42 * skills and traits are authored lists. Optional modules add injury, recovery 43 * and progression; a core-only profile retains the simple energy pool. */ 44 class RpgProfile: object 45 actor = nil 46 st = 10 47 dx = 10 48 iq = 10 49 ht = 10 50 will = (iq) 51 perception = (iq) 52 maxHP = (st) 53 maxEnergy = (ht) 54 energy = (maxEnergy) 55 skills = [] 56 traits = [] 57 58 /* Observational extension points. Optional modules supply condition state. */ 59 blockedReason(c) { return nil; } 60 modifiersFor(c) { return []; } 61 afterAttempt(c, result) { return nil; } 62 63 attributeScore(which) 64 { 65 switch (which) 66 { 67 case rpgST: return self.st; 68 case rpgDX: return self.dx; 69 case rpgIQ: return self.iq; 70 case rpgHT: return self.ht; 71 case rpgWill: return self.will; 72 case rpgPerception: return self.perception; 73 } 74 throw new RpgError(); 75 } 76 77 skillScore(skill) 78 { 79 local base = self.attributeScore(skill.attribute); 80 foreach (local training in self.skills) 81 { 82 if (training.skill != skill) continue; 83 local points = training.points; 84 if (points == 0) continue; 85 if (points >= 8) return base + 3 - skill.difficulty + (points - 8) / 4; 86 if (points >= 4) return base + 2 - skill.difficulty; 87 if (points >= 2) return base + 1 - skill.difficulty; 88 return base - skill.difficulty; 89 } 90 if (skill.defaultPenalty == nil) return nil; 91 return (base > 20 ? 20 : base) + skill.defaultPenalty; 92 } 93 ; 94 95 /** A shared goal. completed closes every approach for every character. 96 * advance() permits fresh attempts after a meaningful, authored world change. 97 * Never call advance merely because a command, preview or refresh occurred. */ 98 class RpgChallenge: object 99 id = nil 100 completed = nil 101 revision = 0 102 advance() { rpgInteger(self.revision, 0, 2147483646); self.revision += 1; return nil; } 103 ; 104 105 /** One method of pursuing a goal. Hooks use original game-specific content. 106 * blockedReason, modifierFor and oppositionModifierFor must be observational. 107 * apply runs once per fresh resolution, including failures and ties. */ 108 class RpgApproach: object 109 id = nil 110 challenge = nil 111 skill = nil 112 attribute = rpgIQ 113 modifier = 0 114 modifierLabelId = 'rpg.modifier.situation' 115 contextLabelId = 'rpg.modifier.preparation' 116 automatic = nil 117 completesChallenge = true 118 timeCost = 0 119 energyCost = 0 120 /* Ordinary failures within this margin can achieve the goal at a cost. 121 * Zero disables this policy. It never promotes critical failures or ties. */ 122 costlyMargin = 0 123 opponent = nil 124 oppositionSkill = nil 125 oppositionAttribute = rpgWill 126 oppositionModifier = 0 127 oppositionModifierLabelId = 'rpg.modifier.opposition' 128 oppositionContextLabelId = 'rpg.modifier.opposition-preparation' 129 blockedReason(c) { return nil; } 130 modifierFor(c) { return 0; } 131 oppositionModifierFor(c) { return 0; } 132 apply(c, result) { return nil; } 133 ; 134 135 /** Temporary execution context. Do not retain it in persistent properties. */ 136 class RpgContext: object 137 profile = nil 138 actor = nil 139 approach = nil 140 challenge = nil 141 skill = nil 142 isOpposition = nil 143 ; 144 145 /** Temporary explanation, shared by assessment and execution. Only scalar 146 * snapshot data is retained by attempts. Modifier triples are category, label, 147 * amount; after 31 components, remaining adjustments are grouped as other. */ 148 class RpgCalculation: object 149 abilityLabelId = nil 150 attributeId = nil 151 training = 'attribute' 152 attributeScore = nil 153 defaultPenalty = nil 154 baseScore = nil 155 effectiveScore = nil 156 modifiers = [] 157 otherModifier = 0 158 pack() 159 { 160 return [self.abilityLabelId, self.attributeId, self.training, 161 self.attributeScore, self.defaultPenalty, self.baseScore, 162 self.effectiveScore, self.modifiers]; 163 } 164 ; 165 166 /** Trusted, observational preparation. Reporting extensions can add odds to 167 * this temporary value. ready is the only status that permits a fresh roll. */ 168 class RpgAssessment: object 169 profile = nil 170 approach = nil 171 status = 'blocked' 172 reason = nil 173 kind = nil 174 calculation = nil 175 oppositionCalculation = nil 176 timeCost = 0 177 energyCost = 0 178 costlyMargin = 0 179 recorded = nil 180 ; 181 182 /** Temporary result. Scores and dice are private unless the author discloses 183 * them. status: resolved, reused, blocked or complete. outcome: success, 184 * costly-success, setback, complication or tie (nil when not resolved). */ 185 class RpgResult: object 186 status = 'blocked' 187 reason = nil 188 profile = nil 189 approach = nil 190 outcome = nil 191 achieved = nil 192 score = nil 193 roll = nil 194 margin = nil 195 critical = nil 196 oppositionScore = nil 197 oppositionRoll = nil 198 oppositionMargin = nil 199 oppositionCritical = nil 200 timeSpent = 0 201 energySpent = 0 202 recordedTimeSpent = 0 203 recordedEnergySpent = 0 204 kind = nil 205 calculation = nil 206 oppositionCalculation = nil 207 dice = [] 208 oppositionDice = [] 209 rollSucceeded = nil 210 oppositionSucceeded = nil 211 ; 212 213 /** Predeclare one slot per permitted (profile, approach) pair. Scalar state is 214 * journalled without allocating permanent entities during play. The slot is 215 * also the retry authority shared by parser, structured and dialogue actions. */ 216 class RpgAttempt: object 217 profile = nil 218 approach = nil 219 revision = -1 220 outcome = nil 221 achieved = nil 222 score = nil 223 roll = nil 224 margin = nil 225 critical = nil 226 oppositionScore = nil 227 oppositionRoll = nil 228 oppositionMargin = nil 229 oppositionCritical = nil 230 kind = nil 231 calculationData = nil 232 oppositionCalculationData = nil 233 recordedTimeSpent = 0 234 recordedEnergySpent = 0 235 dice = [] 236 oppositionDice = [] 237 rollSucceeded = nil 238 oppositionSucceeded = nil 239 240 remember(result) 241 { 242 self.revision = self.approach.challenge.revision; 243 self.outcome = result.outcome; self.achieved = result.achieved; 244 self.score = result.score; self.roll = result.roll; self.margin = result.margin; 245 self.critical = result.critical; self.oppositionScore = result.oppositionScore; 246 self.oppositionRoll = result.oppositionRoll; 247 self.oppositionMargin = result.oppositionMargin; 248 self.oppositionCritical = result.oppositionCritical; 249 self.kind = result.kind; 250 self.calculationData = result.calculation == nil ? nil : result.calculation.pack(); 251 self.oppositionCalculationData = result.oppositionCalculation == nil 252 ? nil : result.oppositionCalculation.pack(); 253 self.dice = result.dice; self.oppositionDice = result.oppositionDice; 254 self.rollSucceeded = result.rollSucceeded; 255 self.oppositionSucceeded = result.oppositionSucceeded; 256 self.recordedTimeSpent = result.recordedTimeSpent; 257 self.recordedEnergySpent = result.recordedEnergySpent; 258 return nil; 259 } 260 recall(result) 261 { 262 if (self.revision < 0) return result; 263 result.status = 'reused'; result.outcome = self.outcome; 264 result.achieved = self.achieved; result.score = self.score; 265 result.roll = self.roll; result.margin = self.margin; result.critical = self.critical; 266 result.oppositionScore = self.oppositionScore; 267 result.oppositionRoll = self.oppositionRoll; 268 result.oppositionMargin = self.oppositionMargin; 269 result.oppositionCritical = self.oppositionCritical; 270 result.kind = self.kind; 271 result.calculation = rpgUnpackCalculation(self.calculationData); 272 result.oppositionCalculation = rpgUnpackCalculation(self.oppositionCalculationData); 273 result.dice = self.dice; result.oppositionDice = self.oppositionDice; 274 result.rollSucceeded = self.rollSucceeded; 275 result.oppositionSucceeded = self.oppositionSucceeded; 276 result.recordedTimeSpent = self.recordedTimeSpent; 277 result.recordedEnergySpent = self.recordedEnergySpent; 278 return result; 279 } 280 ; 281 282 /** Only accounted challenge time, in seconds; not a replacement world clock. 283 * vhyl continues to schedule one cycle per accepted command. */ 284 rpgLedger: object seconds = 0; 285 286 /** Charge explicit adventure time with a checked persistent counter. */ 287 rpgSpendTime(seconds) 288 { 289 rpgInteger(seconds, 0, 86400); 290 rpgInteger(rpgLedger.seconds, 0, 2147483647 - seconds); 291 rpgLedger.seconds += seconds; return nil; 292 } 293 294 /** Deterministic, journalled generator with overflow-safe arithmetic. 295 * Set a seed from 1 through 2147483646 at game initialisation. */ 296 rpgRandom: object 297 state = 1 298 draws = 0 299 seed(value) 300 { 301 rpgInteger(value, 1, 2147483646); 302 self.state = value; self.draws = 0; return nil; 303 } 304 die() 305 { 306 local next = 16807 * (self.state % 127773) - 2836 * (self.state / 127773); 307 if (next <= 0) next += 2147483647; 308 self.state = next; self.draws += 1; 309 return self.state % 6 + 1; 310 } 311 dice() 312 { 313 /* Separate calls: source argument evaluation order is irrelevant. */ 314 local one = self.die(); local two = self.die(); local three = self.die(); 315 return [one, two, three]; 316 } 317 roll() { return rpgDiceTotal(self.dice()); } 318 ; 319 320 /** Pure numerical check; no randomness, world writes, output or time costs. 321 * Scores below three cannot be attempted. critical is success, failure or nil. */ 322 rpgCheck(score, roll) 323 { 324 rpgInteger(score, -1000, 1000); rpgInteger(roll, 3, 18); 325 local result = new RpgResult(); result.score = score; 326 if (score < 3) { result.reason = 'too-difficult'; return result; } 327 result.status = 'resolved'; result.roll = roll; result.margin = score - roll; 328 if (roll == 18 || (roll == 17 && score <= 15) || roll - score >= 10) 329 result.critical = 'failure'; 330 else if (roll <= 4 || (roll == 5 && score >= 15) || (roll == 6 && score >= 16)) 331 result.critical = 'success'; 332 result.achieved = (roll <= 4 || roll <= score) && roll < 17; 333 result.rollSucceeded = result.achieved; 334 result.outcome = result.achieved ? 'success' 335 : result.critical == 'failure' ? 'complication' : 'setback'; 336 return result; 337 } 338 339 rpgInteger(value, low, high) 340 { 341 if (dataType(value) != TypeInt || value < low || value > high) 342 throw new RpgError(); 343 return nil; 344 } 345 346 rpgContext(profile, approach, opposing) 347 { 348 local c = new RpgContext(); c.profile = profile; c.actor = profile.actor; 349 c.approach = approach; c.challenge = approach.challenge; 350 c.isOpposition = opposing; 351 c.skill = opposing ? approach.oppositionSkill : approach.skill; 352 return c; 353 } 354 355 rpgAttributeId(attribute) 356 { 357 switch (attribute) 358 { 359 case rpgST: return 'rpg.strength'; 360 case rpgDX: return 'rpg.coordination'; 361 case rpgIQ: return 'rpg.intelligence'; 362 case rpgHT: return 'rpg.health'; 363 case rpgWill: return 'rpg.will'; 364 case rpgPerception: return 'rpg.perception'; 365 } 366 throw new RpgError(); 367 } 368 369 rpgUnpackCalculation(data) 370 { 371 if (data == nil) return nil; 372 local value = new RpgCalculation(); 373 value.abilityLabelId = data[1]; value.attributeId = data[2]; 374 value.training = data[3]; value.attributeScore = data[4]; 375 value.defaultPenalty = data[5]; value.baseScore = data[6]; 376 value.effectiveScore = data[7]; value.modifiers = data[8]; 377 return value; 378 } 379 380 rpgAddModifier(calculation, category, label, amount) 381 { 382 rpgInteger(amount, -1000, 1000); 383 if (amount == 0) return nil; 384 if (calculation.modifiers.length() < 93) 385 calculation.modifiers = calculation.modifiers + [category, label, amount]; 386 else calculation.otherModifier += amount; 387 return nil; 388 } 389 390 rpgCalculate(c) 391 { 392 local a = c.approach; 393 local value = new RpgCalculation(); 394 local attribute = c.skill == nil 395 ? c.isOpposition ? a.oppositionAttribute : a.attribute : c.skill.attribute; 396 value.attributeId = rpgAttributeId(attribute); 397 value.attributeScore = c.profile.attributeScore(attribute); 398 value.abilityLabelId = c.skill == nil ? value.attributeId : c.skill.labelId; 399 value.baseScore = c.skill == nil ? value.attributeScore : c.profile.skillScore(c.skill); 400 if (c.skill != nil) 401 { 402 value.training = value.baseScore == nil ? 'unavailable' : 'default'; 403 foreach (local training in c.profile.skills) 404 if (training.skill == c.skill && training.points > 0) value.training = 'trained'; 405 if (value.training == 'default') 406 { 407 value.defaultPenalty = c.skill.defaultPenalty; 408 if (value.attributeScore > 20) value.attributeScore = 20; 409 } 410 } 411 if (value.baseScore == nil) return value; 412 local fixed = c.isOpposition ? a.oppositionModifier : a.modifier; 413 local contextual = c.isOpposition ? a.oppositionModifierFor(c) : a.modifierFor(c); 414 rpgAddModifier(value, 'situation', c.isOpposition 415 ? a.oppositionModifierLabelId : a.modifierLabelId, fixed); 416 rpgAddModifier(value, 'context', c.isOpposition 417 ? a.oppositionContextLabelId : a.contextLabelId, contextual); 418 local modifier = fixed + contextual; 419 foreach (local trait in c.profile.traits) 420 { 421 local adjustment = trait.modifierFor(c); modifier += adjustment; 422 rpgAddModifier(value, 'trait', trait.labelId, adjustment); 423 } 424 local extras = c.profile.modifiersFor(c); 425 if (extras.length() % 3 != 0) throw new RpgError(); 426 for (local i = 1; i <= extras.length(); i += 3) 427 { 428 modifier += extras[i + 2]; 429 rpgAddModifier(value, extras[i], extras[i + 1], extras[i + 2]); 430 } 431 rpgInteger(modifier, -500, 500); 432 if (value.otherModifier != 0) 433 value.modifiers = value.modifiers + ['other', 'rpg.modifier.other', value.otherModifier]; 434 value.effectiveScore = value.baseScore + modifier; 435 rpgInteger(value.effectiveScore, -1000, 1000); 436 return value; 437 } 438 439 rpgScore(c) { return rpgCalculate(c).effectiveScore; } 440 441 rpgDiceTotal(dice) { return dice[1] + dice[2] + dice[3]; } 442 443 /** Pure outcome policy shared by actual attempts and probability enumeration. */ 444 rpgEvaluate(score, roll, oppositionScore, oppositionRoll, costlyMargin) 445 { 446 local result = rpgCheck(score, roll); 447 if (result.status == 'blocked') return result; 448 if (oppositionScore != nil) 449 { 450 local defense = rpgCheck(oppositionScore, oppositionRoll); 451 if (defense.status == 'blocked') 452 { result.status = 'blocked'; result.reason = 'opposition-unavailable'; return result; } 453 result.oppositionScore = defense.score; result.oppositionRoll = defense.roll; 454 result.oppositionMargin = defense.margin; result.oppositionCritical = defense.critical; 455 result.oppositionSucceeded = defense.achieved; 456 local won = result.achieved && !defense.achieved 457 || result.achieved == defense.achieved && result.margin > defense.margin; 458 local tied = result.achieved == defense.achieved && result.margin == defense.margin; 459 result.achieved = won; 460 result.outcome = won ? 'success' : tied ? 'tie' 461 : result.critical == 'failure' ? 'complication' : 'setback'; 462 } 463 if (result.outcome == 'setback' && costlyMargin > 0 464 && result.margin < 0 && -result.margin <= costlyMargin && oppositionScore == nil) 465 { result.outcome = 'costly-success'; result.achieved = true; } 466 return result; 467 } 468 469 rpgAttemptFor(profile, approach) 470 { 471 local found = nil; 472 for (local slot = firstObj(RpgAttempt); slot != nil; slot = nextObj(slot, RpgAttempt)) 473 { 474 if (slot.profile != profile || slot.approach != approach) continue; 475 if (found != nil) throw new RpgError(); 476 found = slot; 477 } 478 if (found == nil) throw new RpgError(); 479 return found; 480 } 481 482 /** Trusted observational preparation shared by assessment and resolution. 483 * Configuration errors throw. Ordinary impossibility returns blocked without 484 * rolling, charging or caching it. Prerequisite and modifier hooks must be pure. */ 485 rpgPrepare(profile, approach) 486 { 487 if (profile == nil || !profile.ofKind(RpgProfile) 488 || approach == nil || !approach.ofKind(RpgApproach)) throw new RpgError(); 489 local slot = rpgAttemptFor(profile, approach); 490 local result = new RpgAssessment(); result.profile = profile; result.approach = approach; 491 local challenge = approach.challenge; 492 if (challenge.completed) { result.status = 'complete'; return result; } 493 local c = rpgContext(profile, approach, nil); 494 result.reason = profile.blockedReason(c); 495 if (result.reason != nil) return result; 496 result.reason = approach.blockedReason(c); 497 if (result.reason != nil) return result; 498 if (slot.revision == challenge.revision) 499 { 500 result.status = 'settled'; 501 local recorded = new RpgResult(); recorded.profile = profile; recorded.approach = approach; 502 result.recorded = slot.recall(recorded); result.kind = result.recorded.kind; 503 result.calculation = result.recorded.calculation; 504 result.oppositionCalculation = result.recorded.oppositionCalculation; 505 return result; 506 } 507 if (profile.energy < approach.energyCost) 508 { result.reason = 'insufficient-energy'; return result; } 509 510 result.kind = approach.automatic ? 'automatic' : approach.opponent == nil ? 'check' : 'contest'; 511 if (!approach.automatic) 512 { 513 result.calculation = rpgCalculate(c); 514 local score = result.calculation.effectiveScore; 515 if (score == nil) { result.reason = 'training-required'; return result; } 516 if (score < 3) { result.reason = 'too-difficult'; return result; } 517 if (approach.opponent != nil) 518 { 519 local defense = rpgContext(approach.opponent, approach, true); 520 if (approach.opponent.blockedReason(defense) != nil) 521 { result.reason = 'opposition-unavailable'; return result; } 522 result.oppositionCalculation = rpgCalculate(defense); 523 local opposingScore = result.oppositionCalculation.effectiveScore; 524 if (opposingScore == nil || opposingScore < 3) 525 { result.reason = 'opposition-unavailable'; return result; } 526 } 527 } 528 result.status = 'ready'; result.timeCost = approach.timeCost; result.energyCost = approach.energyCost; 529 result.costlyMargin = approach.costlyMargin; 530 return result; 531 } 532 533 /** Commit an attempt using the shared preparation. Call only from a gameplay 534 * execution/selection hook. Exceptions roll back the enclosing turn. */ 535 rpgResolve(profile, approach) 536 { 537 local prepared = rpgPrepare(profile, approach); 538 if (prepared.recorded != nil) return prepared.recorded; 539 local result = new RpgResult(); result.profile = profile; result.approach = approach; 540 result.reason = prepared.reason; 541 if (prepared.status != 'ready') { result.status = prepared.status; return result; } 542 if (prepared.kind == 'automatic') 543 { result.status = 'resolved'; result.outcome = 'success'; result.achieved = true; } 544 else 545 { 546 local dice = rpgRandom.dice(); local oppositionDice = []; 547 local opposingScore = nil; local opposingRoll = nil; 548 if (prepared.oppositionCalculation != nil) 549 { 550 opposingScore = prepared.oppositionCalculation.effectiveScore; 551 oppositionDice = rpgRandom.dice(); opposingRoll = rpgDiceTotal(oppositionDice); 552 } 553 result = rpgEvaluate(prepared.calculation.effectiveScore, rpgDiceTotal(dice), 554 opposingScore, opposingRoll, prepared.costlyMargin); 555 result.dice = dice; result.oppositionDice = oppositionDice; 556 result.profile = profile; result.approach = approach; 557 } 558 result.kind = prepared.kind; result.calculation = prepared.calculation; 559 result.oppositionCalculation = prepared.oppositionCalculation; 560 profile.energy -= approach.energyCost; 561 rpgSpendTime(approach.timeCost); 562 result.timeSpent = approach.timeCost; result.energySpent = approach.energyCost; 563 result.recordedTimeSpent = result.timeSpent; result.recordedEnergySpent = result.energySpent; 564 /* Cache before consequences, preventing a hook from resolving itself twice. */ 565 rpgAttemptFor(profile, approach).remember(result); 566 if (result.achieved && approach.completesChallenge) approach.challenge.completed = true; 567 profile.afterAttempt(rpgContext(profile, approach, nil), result); 568 approach.apply(rpgContext(profile, approach, nil), result); 569 return result; 570 } 571 572 /** Explicit, player-safe result envelope. No skill scores, rolls, opponent 573 * identities, margins or critical flags are published automatically. */ 574 rpgPublish(result) 575 { 576 event('rpg.result'); eventValue(1); 577 eventValue(result.approach.challenge.id); eventValue(result.approach.id); 578 eventValue(result.profile.actor); eventValue(result.status); 579 eventValue(result.outcome); eventValue(result.reason); 580 eventValue(result.achieved); eventValue(result.timeSpent); eventValue(result.energySpent); 581 return nil; 582 } 583 584 /** Validate declarations before play. Lists/slots are intentionally explicit: 585 * misspelled training and duplicate retry slots must not silently change play. */ 586 rpgValidation: InitObject 587 execute() 588 { 589 for (local skill = firstObj(RpgSkill); skill != nil; skill = nextObj(skill, RpgSkill)) 590 { 591 rpgInteger(skill.difficulty, 0, skill.maximumDifficulty()); 592 rpgValidateAttribute(skill.attribute); 593 if (skill.defaultPenalty != nil) rpgInteger(skill.defaultPenalty, -100, 0); 594 } 595 for (local p = firstObj(RpgProfile); p != nil; p = nextObj(p, RpgProfile)) 596 { 597 foreach (local attribute in [rpgST, rpgDX, rpgIQ, rpgHT, rpgWill, rpgPerception]) 598 rpgInteger(p.attributeScore(attribute), 1, 100); 599 rpgInteger(p.maxHP, 1, 1000); rpgInteger(p.maxEnergy, 0, 1000); 600 rpgInteger(p.energy, 0, p.maxEnergy); 601 local seen = []; 602 foreach (local training in p.skills) 603 { 604 if (!training.ofKind(RpgTraining) || training.skill == nil 605 || !training.skill.ofKind(RpgSkill) || holds(seen, training.skill)) 606 throw new RpgError(); 607 rpgInteger(training.points, 0, 10000); seen = seen + [training.skill]; 608 } 609 foreach (local trait in p.traits) 610 if (!trait.ofKind(RpgTrait)) throw new RpgError(); 611 } 612 for (local a = firstObj(RpgApproach); a != nil; a = nextObj(a, RpgApproach)) 613 { 614 if (a.challenge == nil || !a.challenge.ofKind(RpgChallenge) 615 || dataType(a.id) != TypeSString || a.id == '' 616 || dataType(a.challenge.id) != TypeSString || a.challenge.id == '') 617 throw new RpgError(); 618 rpgInteger(a.timeCost, 0, 86400); rpgInteger(a.energyCost, 0, 1000); 619 rpgInteger(a.costlyMargin, 0, 100); rpgInteger(a.modifier, -500, 500); 620 rpgInteger(a.oppositionModifier, -500, 500); 621 rpgInteger(a.challenge.revision, 0, 2147483646); 622 rpgValidateAttribute(a.attribute); rpgValidateAttribute(a.oppositionAttribute); 623 if (a.skill != nil && !a.skill.ofKind(RpgSkill)) throw new RpgError(); 624 if (a.oppositionSkill != nil && !a.oppositionSkill.ofKind(RpgSkill)) 625 throw new RpgError(); 626 if (a.opponent != nil && (!a.opponent.ofKind(RpgProfile) || a.automatic)) 627 throw new RpgError(); 628 for (local other = firstObj(RpgApproach); other != nil; other = nextObj(other, RpgApproach)) 629 if (other != a && other.challenge == a.challenge && other.id == a.id) 630 throw new RpgError(); 631 } 632 for (local challenge = firstObj(RpgChallenge); challenge != nil; 633 challenge = nextObj(challenge, RpgChallenge)) 634 for (local other = firstObj(RpgChallenge); other != nil; 635 other = nextObj(other, RpgChallenge)) 636 if (other != challenge && other.id == challenge.id) throw new RpgError(); 637 for (local slot = firstObj(RpgAttempt); slot != nil; slot = nextObj(slot, RpgAttempt)) 638 { 639 if (slot.profile == nil || !slot.profile.ofKind(RpgProfile) 640 || slot.approach == nil || !slot.approach.ofKind(RpgApproach)) 641 throw new RpgError(); 642 rpgAttemptFor(slot.profile, slot.approach); 643 } 644 return nil; 645 } 646 ; 647 648 rpgValidateAttribute(value) 649 { 650 if (!holds([rpgST, rpgDX, rpgIQ, rpgHT, rpgWill, rpgPerception], value)) 651 throw new RpgError(); 652 return nil; 653 }