@maka/maka-cli 5.167.0 → 5.170.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
16
16
  import { trailMaster } from '../utilities/companion-heel.js';
17
17
  import { spendMovementMeters, paceNote } from '../utilities/movement-cost.js';
18
18
  import { requirePhase } from '../utilities/action-cost.js';
19
+ import { onTheWall, midClimbRefusal } from '../utilities/climb-state.js';
19
20
  /**
20
21
  * "walk to <spot>" / "run to <spot>" / "sprint to <spot>" -- crossing
21
22
  * the CURRENT room to one of its "at" spots (see utilities/spots.ts;
@@ -103,6 +104,339 @@ export class MoveCommand extends Command {
103
104
  }
104
105
  return `Spots are a meat-world concern -- on the grid there is no distance at all, so "go <room>" puts your persona anywhere by name.`;
105
106
  }
107
+ /**
108
+ * ONE WALL, ONE SET OF DICE, WHICHEVER WAY YOU ARE GOING ON IT.
109
+ *
110
+ * Everything a climb can do -- the Gymnastics + Strength test, the
111
+ * banked metres, the rappel row, the hold-on, the second chance, the
112
+ * fall -- lives here and nowhere else. execute() calls it for a
113
+ * crossing that climbs, and retreatFromClimb() calls it to come back
114
+ * off a wall part-way up (6SmvvRRtn9eEMZSbF: "when I'm 1m up, and I
115
+ * use 'descend', it says I'm already on the floor"). Two callers, one
116
+ * body: the reversal could not have been a second copy, because the
117
+ * copy that drifted would be the one that forgot the fall.
118
+ *
119
+ * `wall` describes THIS action's stretch of wall: the spot it is
120
+ * toward, its length, how much of it is already banked, which way
121
+ * gravity points, how high off the ground the actor is at the start,
122
+ * and how a partial result is recorded -- a forward climb banks
123
+ * toward its target, a retreat unwinds the bank toward the original
124
+ * one, and only the caller knows which.
125
+ */
126
+ resolveClimb(wall) {
127
+ const actor = this.actor;
128
+ const room = actor.currentLocation;
129
+ const { down, meters, banked } = wall;
130
+ const spotName = wall.spot;
131
+ // STRENGTH, NOT AGILITY (SR5 p.134, Climbing: the test is
132
+ // "Gymnastics + Strength [Physical]"). This rolled Agility, which
133
+ // is the reflex attribute every other Athletics use leans on --
134
+ // plausible, and wrong. Climbing is the one athletic act the book
135
+ // hangs on how much of your own weight you can haul.
136
+ //
137
+ // The SKILL stays `athletics`: this engine carries skill GROUPS
138
+ // where the book has members (see models/player.ts -- Summoning/
139
+ // Binding/Banishing collapse to `conjuring` the same way), and
140
+ // Gymnastics is the Athletics group's climbing member. That is a
141
+ // pre-existing, documented modelling choice, not something this
142
+ // roll invented; the attribute was a straightforward error.
143
+ // ASSISTED CLIMBING (p.134 Climbing Table: +2 dice, and 1 metre
144
+ // per hit instead of per two). The gear is the p.449 kit -- rope,
145
+ // harness, carabiners, crampons -- which this game has SOLD in the
146
+ // catalog all along (`climbing-gear`) while nothing read it. It
147
+ // was buyable and inert, the same shape as the hostility flag:
148
+ // wired at one end only.
149
+ //
150
+ // Carried, not worn: a kit in the pack is a kit you can rig.
151
+ const assisted = actor.inventory.getAllItems().some(i => /climbing gear|climbing kit|\brope\b|harness|carabiner|crampon|grapple gun/i.test(`${i.name} ${i.description ?? ''}`));
152
+ // SURFACE AND CONDITION (p.134), read off the room's own prose and
153
+ // the level's name -- see climbSurfaceModifier. Zero unless the
154
+ // fiction says otherwise, which is why adding this could not
155
+ // re-break the scenes the banking fix just made climbable.
156
+ const surface = climbSurfaceModifier(`${room.name} ${room.description ?? ''} ${(room.levels ?? []).map(l => `${l.name} ${l.kind ?? ''}`).join(' ')} ${spotName}`);
157
+ // RAPPELLING (p.134) IS ITS OWN ROW: "assisted climbing down" is
158
+ // not a scaled climb, it is Free-Fall + Body [Physical] (2) on a
159
+ // rope, descending 20 metres per Combat Turn. Routed here rather
160
+ // than folded into the climb rate, because folding it in would
161
+ // have made it a faster climb instead of a different act.
162
+ //
163
+ // THE CLOCK IS THE BOOK'S BOOKKEEPING, NOT THE RULE. Canon quotes
164
+ // the rate per Combat Turn and this engine has no combat-turn
165
+ // clock (the same gap named in getInitiativeDice and in the sprite
166
+ // sustain task). The RATE is what is canon: 20m per turn, +1m per
167
+ // net hit. Every vertical drop this engine can build is a handful
168
+ // of metres -- METERS_PER_LEVEL is 3 -- so a single turn covers
169
+ // any of them outright, and the honest translation is one test
170
+ // that either lands you or does not. No clock invented to host it.
171
+ if (down && assisted) {
172
+ // Free-Fall in canon, and now in this engine too -- the comment
173
+ // here used to end "...and has no Free-Fall skill", which was the
174
+ // reason it settled for `athletics`. The skill exists as of the
175
+ // athletics split, so the site rolls what it always wanted.
176
+ const ffSkill = actor.skillRating('free-fall');
177
+ const ffPool = Math.max(1, actor.body + (ffSkill > 0 ? ffSkill : -1) + actor.bonus('athletics') + surface.mod + actor.woundModifier - actor.sustainingPenalty);
178
+ const ff = rollPool(ffPool);
179
+ this.logger.meta(` Rappel ${meters}m -- Free-Fall + Body (2)${surface.label ? ` [${surface.label}]` : ''}: ${formatRoll(ff)}`);
180
+ if (ff.hits >= 2) {
181
+ actor.climbProgress = undefined;
182
+ this.actor.performAction('rappels down to', spotName);
183
+ return { arrived: true, note: '' };
184
+ }
185
+ // "On an unsuccessful test you don't slow down" (p.134): the
186
+ // rope runs and you arrive hard rather than not at all.
187
+ const dv = fallDamageDVMeters(meters);
188
+ let descentNote = `You kick off and the line runs faster than you meant -- you come down the rope in a rush.`;
189
+ // Carried out to the arrival line, so a rushed rappel still
190
+ // lands you and the story of it rides WITH the arrival rather
191
+ // than replacing it. Lint caught this built-and-dropped -- the
192
+ // player would have taken the damage and been told nothing
193
+ // about where it came from.
194
+ if (dv > 0) {
195
+ const soak = rollPool(actor.getSoakPool());
196
+ const dealt = Math.max(0, dv - soak.hits);
197
+ this.logger.meta(` Hard landing: DV ${dv}, Soak: ${formatRoll(soak)}`);
198
+ if (dealt > 0) {
199
+ actor.takeDamage(dealt);
200
+ descentNote += ` The landing costs you ${dealt} box${dealt === 1 ? '' : 'es'}: ${actor.conditionSummary()}`;
201
+ }
202
+ else {
203
+ descentNote += ` You hit hard and the armor eats it.`;
204
+ }
205
+ }
206
+ else {
207
+ descentNote += ` It is not far enough to hurt.`;
208
+ }
209
+ actor.climbProgress = undefined;
210
+ this.actor.performAction('rappels down to', spotName);
211
+ // A rushed rappel still ARRIVES -- you are down either way.
212
+ return { arrived: true, note: descentNote };
213
+ }
214
+ // A RAPPEL IS NOT A CLIMB, so it does not run one. Everything
215
+ // below belongs to going UP, or to going down the hard way.
216
+ //
217
+ // GYMNASTICS: p.134 tests climbing as Gymnastics + Strength, which
218
+ // is the pairing this pool already had -- only the skill name was
219
+ // standing in for it.
220
+ const athleticsSkill = actor.skillRating('gymnastics');
221
+ const pool = Math.max(1, actor.strength + (athleticsSkill > 0 ? athleticsSkill : -1) + actor.bonus('athletics') + (assisted ? 2 : 0) + surface.mod + actor.woundModifier - actor.sustainingPenalty);
222
+ const climb = rollPool(pool);
223
+ // HITS BUY DISTANCE -- there is no threshold (p.134, Climbing
224
+ // Table: "Unassisted climbing upward, 1 meter per 2 hits"). This
225
+ // used to be a flat "2 hits or you fall", which is a rule the book
226
+ // does not have: it made a one-metre scramble and a three-storey
227
+ // face equally hard, and it made a strong climber's extra hits
228
+ // buy nothing.
229
+ //
230
+ // BANKED PROGRESS (p.134): hits buy metres per TEST, and a wall is
231
+ // climbed over successive actions. Anything already banked toward
232
+ // THIS spot counts; progress toward a different one does not
233
+ // transfer, or a player could bank a metre on one wall and cash it
234
+ // in across the room.
235
+ const remaining = Math.max(0, meters - banked);
236
+ // DOWN IS TWICE AS FAST (p.134: "Unassisted climbing down, 1 metre
237
+ // per hit"). The engine climbed down at the upward rate, so a
238
+ // runner picking their way off a catwalk paid the same toll as
239
+ // getting onto it -- the item's third gap.
240
+ const needed = climbHitsFor(remaining, assisted, down);
241
+ const gained = climbMetersFrom(climb.hits, assisted, down);
242
+ const reachedTotal = Math.min(meters, banked + gained);
243
+ // ARRIVAL IS DECIDED IN METRES, not in hits, because metres are
244
+ // what the rule buys. The two agree almost always and diverged on
245
+ // the rounding: `needed` is a CEILING, so a roll could carry you
246
+ // the full remaining distance and still fall a fraction short of
247
+ // the integer threshold. On a bench that printed "you get about
248
+ // 3m up" on a 3m wall and then, next attempt, "3m already banked,
249
+ // 1m to go" -- arrived and not arrived at once.
250
+ //
251
+ // `needed` stays for the ticker, where a whole number of hits is
252
+ // the useful thing to read.
253
+ const gotUp = !climb.glitch && (banked + gained) >= meters;
254
+ // Same one-rounding rule as the prose below: round the banked
255
+ // figure once and derive the remainder from it, or the ticker
256
+ // says "2m banked, 2m to go" on a 3m wall.
257
+ const shownBanked = Math.round(banked);
258
+ this.logger.meta(` ${down ? 'Descend' : 'Climb'} ${meters}m${banked > 0 ? ` (${shownBanked}m already banked, ${Math.max(0, meters - shownBanked)}m to go)` : ''}${assisted ? ' -- ASSISTED (+2, 1m/hit)' : ''}${down && !assisted ? ' -- DOWN (1m/hit)' : ''}${surface.label ? ` [${surface.label}]` : ''} -- Gymnastics + Strength: ${formatRoll(climb)} (${needed} hits needed)`);
259
+ if (gotUp) {
260
+ // Over the lip: the wall is done with you.
261
+ actor.climbProgress = undefined;
262
+ return { arrived: true, note: '' };
263
+ }
264
+ // A FAILED CLIMB IS NOT A FALL (p.134, Climbing Failures and
265
+ // Glitches). Progress halts and you make a Reaction + Strength
266
+ // test to HOLD ON; only failing THAT drops you. Falling
267
+ // immediately was the engine's own invention, and it is the
268
+ // half of this that made climbing feel arbitrary -- one bad
269
+ // roll and gravity, with nothing in between.
270
+ const hold = rollPool(Math.max(1, actor.reaction + actor.strength + actor.woundModifier - actor.sustainingPenalty));
271
+ this.logger.meta(` Hold on -- Reaction + Strength: ${formatRoll(hold)} (1 hit holds)`);
272
+ const reached = reachedTotal;
273
+ if (hold.hits >= 1) {
274
+ // YOU KEEP THE METRES. This used to end "and climb back down",
275
+ // throwing the whole attempt away -- which is what made a tall
276
+ // wall unclimbable rather than merely slow. Hanging on with
277
+ // progress banked is the book's own shape: another Complex
278
+ // Action, from where you got to.
279
+ actor.climbProgress = wall.progressFor(reached);
280
+ // THE TWO FIGURES HAVE TO ADD UP. Rounding each independently
281
+ // printed "about 2m up" and "about 2m still above you" on a
282
+ // 3m wall -- both are honest roundings of 1.5, and together
283
+ // they describe a 4m climb. Caught on a repro bench, where a
284
+ // reviewer would rightly have filed it as a maths bug.
285
+ //
286
+ // So the SHOWN height is rounded once and the remainder is
287
+ // derived from it. The banked progress keeps the exact value:
288
+ // the display is where the rounding belongs, not the state.
289
+ const shownUp = Math.round(reached);
290
+ const left = Math.max(0, meters - shownUp);
291
+ this.actor.performAction(down ? 'climbs partway down toward' : 'climbs partway toward', `the ${spotName}, and holds there`);
292
+ // A DESCENT IS NOT AN ASCENT WITH A DIFFERENT RATE, and this
293
+ // block is shared by both -- the rappel is routed away above,
294
+ // so an UNASSISTED climb down falls through to here.
295
+ //
296
+ // `reached` is distance TRAVELLED, so going down it measures
297
+ // how far you have come FROM THE TOP; height above the ground
298
+ // is the remainder. Every sentence here read `reached` as
299
+ // height and said "up" and "still above you" out loud, so a
300
+ // runner picking their way off a catwalk was told they were
301
+ // climbing it. Measured on a bench, descending a 3m ledge:
302
+ // "You get about 1m up ... about 2m still above you."
303
+ // -- printed on the second of four `descend` commands.
304
+ return {
305
+ arrived: false,
306
+ text: down
307
+ ? `You get about ${reached < 1 ? 'a body length' : `${shownUp}m`} down and it stops going anywhere -- ${climb.glitch ? 'a hold shears away under your weight' : 'no purchase left within reach'}. You hang on, boots wedged, ${left < 1 ? 'the ground within reach' : `about ${left}m still below you`}.${hint(` (Descend again to keep going${assisted ? '' : '; rope and a harness would let you rappel it'}.)`)}`
308
+ : `You get about ${reached < 1 ? 'a body length' : `${shownUp}m`} up and it stops going anywhere -- ${climb.glitch ? 'a hold shears away under your weight' : 'no purchase left within reach'}. You hang on, boots wedged, ${left < 1 ? 'the lip within reach' : `about ${left}m still above you`}.${hint(` (Climb again to keep going${assisted ? '' : '; rope and a harness would double your rate'}.)`)}`,
309
+ };
310
+ }
311
+ // THE SECOND CHANCE (p.134 Climbing Failures and Glitches;
312
+ // 6SmvvRRtn9eEMZSbF). Canon: the hold-on fails, you start to
313
+ // fall, "and during the next Action Phase the character may
314
+ // attempt to stop with a Reaction + Strength Test at a -2 dice
315
+ // pool modifier". Two tests, not one, and this engine only ever
316
+ // rolled the first.
317
+ //
318
+ // COMPRESSED, AND SAYING SO. The book puts a phase between them
319
+ // because a fall takes time -- 20m per Combat Turn. Every drop
320
+ // this engine can build is a few metres (METERS_PER_LEVEL is 3),
321
+ // so there is no phase to wait for: the catch resolves in the
322
+ // same breath as the slip. The MECHANIC is canon -- a second
323
+ // attempt, at -2 -- and only the gap between them is missing,
324
+ // which is the same translation the rappel makes above.
325
+ //
326
+ // THE FILED ITEM'S FRAMING WAS WRONG and the correction belongs
327
+ // here: there is no general "arrest any fall" rule in SR5. The
328
+ // RAG is explicit that a mid-fall save exists in exactly two
329
+ // places, this one and a failed rappel. A character who walks
330
+ // off a roof does not get it, and giving them one would be
331
+ // inventing a rule the book does not have.
332
+ const catchIt = rollPool(Math.max(1, actor.reaction + actor.strength - 2 + actor.woundModifier - actor.sustainingPenalty));
333
+ this.logger.meta(` Catch yourself -- Reaction + Strength -2: ${formatRoll(catchIt)} (1 hit stops the fall)`);
334
+ // HOW HIGH YOU ACTUALLY ARE -- the one figure both the prose and
335
+ // the damage below have to agree on, and the reason this is a
336
+ // named variable rather than `reached` inline in three places.
337
+ //
338
+ // Measured from where this stretch STARTED, in the direction it
339
+ // goes: a forward climb starts on the ground and `reached` is the
340
+ // height; a descent starts at the top and `reached` is what has
341
+ // been given up; a retreat starts wherever the bank left you. The
342
+ // caller supplies the start so this line does not have to know
343
+ // which of those it is.
344
+ const heightNow = Math.max(0, wall.heightAtStart + (down ? -reached : reached));
345
+ const shownHeight = heightNow < 1 ? 'a body length' : `${Math.round(heightNow)}m`;
346
+ if (catchIt.hits >= 1) {
347
+ actor.climbProgress = wall.progressFor(reached);
348
+ this.actor.performAction('slips on', `the ${spotName} climb, and catches themselves`);
349
+ return {
350
+ arrived: false,
351
+ text: `You come off -- and catch yourself a body length down, fingers screaming. You are still on the wall, about ${shownHeight} up.${hint(` (${down ? 'Descend' : 'Climb'} again to keep going.)`)}`,
352
+ };
353
+ }
354
+ // The grip goes: the wall takes back everything it gave.
355
+ actor.climbProgress = undefined;
356
+ // Fall from where you ACTUALLY ARE, not from the top -- and not
357
+ // from the distance travelled either. Canon buys distance with
358
+ // hits whether or not you finish, so a runner who barely left
359
+ // the floor does not take a storey's worth of landing.
360
+ //
361
+ // THIS READ `reached` AND WAS INVERTED ON THE WAY DOWN, which is
362
+ // the mechanical half of the descent bug: a runner 2m down a 3m
363
+ // wall is 1m up and was billed for a 2m fall, while one who
364
+ // slipped on the FIRST action of a descent was nearly a full
365
+ // storey up and billed for nothing. Invisible on a 3m ledge --
366
+ // both figures soak to zero damage, which is exactly how it
367
+ // survived -- and real the moment the drop is worth any DV.
368
+ const dv = fallDamageDVMeters(heightNow);
369
+ let fallNote = `Your grip goes, and there's nothing to catch -- you come off the wall about ${shownHeight} up.`;
370
+ if (dv > 0) {
371
+ const soak = rollPool(actor.getSoakPool());
372
+ const dealt = Math.max(0, dv - soak.hits);
373
+ this.logger.meta(` Fall damage: DV ${dv} (${Math.round(heightNow)}m, AP -4), Soak: ${formatRoll(soak)}`);
374
+ if (dealt > 0) {
375
+ actor.takeDamage(dealt);
376
+ fallNote += ` The landing costs you ${dealt} box${dealt === 1 ? '' : 'es'}: ${actor.conditionSummary()}`;
377
+ }
378
+ else {
379
+ fallNote += ` You hit hard but the armor eats it.`;
380
+ }
381
+ }
382
+ else {
383
+ fallNote += ` It's a short drop -- you land badly and nothing breaks.`;
384
+ }
385
+ this.actor.performAction('falls from', `a climb toward the ${spotName}`);
386
+ return { arrived: false, text: fallNote };
387
+ }
388
+ /**
389
+ * BACK OFF THE WALL (6SmvvRRtn9eEMZSbF: "when I'm 1m up, and I use
390
+ * 'descend', it says I'm already on the floor").
391
+ *
392
+ * climb.ts picks destinations by STOREY -- "descend" wants a spot on
393
+ * a level below yours -- and a runner a metre up a wall is still, by
394
+ * spot, on the floor they started from. So the verb looked below the
395
+ * floor, found nothing, and reported the ground floor. True, and the
396
+ * opposite of useful: the one thing a person a metre up a sheer wall
397
+ * most wants is to get back down.
398
+ *
399
+ * This is that. The stretch to climb is the bank itself -- the
400
+ * metres already travelled, back toward the spot you left -- at
401
+ * canon's rate for that direction (down is 1m per hit, p.134; a rope
402
+ * makes it a rappel). It is the same resolveClimb as the forward
403
+ * climb, so a retreat can slip, hold, catch and fall exactly as an
404
+ * ascent can; what differs is only the bookkeeping. A PARTIAL retreat
405
+ * unwinds the bank rather than replacing it, so the elevation bar
406
+ * and the next "climb" both still know which wall you are on and how
407
+ * far up it.
408
+ */
409
+ async retreatFromClimb() {
410
+ const actor = this.actor;
411
+ const wall = onTheWall(actor);
412
+ if (!wall)
413
+ return `You're not on a wall.`;
414
+ // THE COMBAT TURN: coming back down is a Complex Action like going
415
+ // up (p.134), and it happens on your own Action Phase.
416
+ const phase = requirePhase(this.scene, actor);
417
+ if (phase)
418
+ return phase;
419
+ const down = wall.up; // retreating from an ascent is a descent
420
+ const target = wall.target;
421
+ const outcome = this.resolveClimb({
422
+ spot: wall.from,
423
+ meters: wall.travelled,
424
+ banked: 0,
425
+ down,
426
+ heightAtStart: wall.height,
427
+ // Unwind the bank toward the ORIGINAL target: part-way back down
428
+ // a wall you were climbing, you are still on that wall, lower.
429
+ progressFor: reached => ({ target, meters: Math.max(0, wall.travelled - reached) }),
430
+ });
431
+ if (!outcome.arrived)
432
+ return outcome.text;
433
+ actor.climbProgress = undefined;
434
+ this.logger.write(`MoveCommand: ${actor.name} climbs back ${down ? 'down' : 'up'} to ${wall.from} in ${actor.currentLocation.name}.`);
435
+ const note = outcome.note ? `${outcome.note} ` : '';
436
+ return down
437
+ ? `${note}You pick your way back down to the ${wall.from} -- boots on solid ground again.`
438
+ : `${note}You haul yourself back up onto the ${wall.from}.`;
439
+ }
106
440
  /** Your shells trail you across the room (companion-heel.ts
107
441
  * trailMaster): after every in-room move they re-place themselves
108
442
  * beside you. An NPC mover has no Game in its context and drags
@@ -162,6 +496,12 @@ export class MoveCommand extends Command {
162
496
  if (!room.ensureGrid()) {
163
497
  return `There's no ground to pace off here.${hint(` ("go ${direction}" to leave.)`)}`;
164
498
  }
499
+ // ON A WALL, NOT ON THE FLOOR (6SmvvRRtn9eEMZSbF: "I'm able to
500
+ // move around the room while still climbing"). See the named-
501
+ // destination gate below for the whole of it.
502
+ const wall = midClimbRefusal(actor);
503
+ if (wall)
504
+ return wall;
165
505
  return await this.stepAcross(direction, count);
166
506
  }
167
507
  // GRAPPLED (p.195): held fast means no crossing the room either --
@@ -190,6 +530,28 @@ export class MoveCommand extends Command {
190
530
  }
191
531
  return [`Places in ${room.name}:`, ...describeSpotRoster(room, actor).map(l => ` • ${l}`), ...ways].join('\n');
192
532
  }
533
+ // ON A WALL, NOT ON THE FLOOR (6SmvvRRtn9eEMZSbF: "I'm able to move
534
+ // around the room while still climbing, I should have to descend
535
+ // first to move again").
536
+ //
537
+ // A climb banks its metres in climbProgress and leaves the SPOT
538
+ // where it was, so to this verb a runner a metre up a sheer wall
539
+ // was standing on the dock, free to stroll to the door -- and the
540
+ // walk quietly threw the bank away on arrival. The only crossing
541
+ // that makes sense from a wall is the one you are on: more of the
542
+ // same climb (the target spot, or the person standing on it).
543
+ // Everything else -- another spot, a doorway, a body on the floor
544
+ // -- is refused with both ways off the wall named. Coming back
545
+ // down is climb.ts's "descend", which routes to retreatFromClimb.
546
+ {
547
+ const wall = onTheWall(actor);
548
+ if (wall) {
549
+ const wanted = resolveSpot(room, words.join(' '))?.name
550
+ ?? spotOf(this.resolveActorTarget(room, words.join(' ')) ?? actor);
551
+ if (wanted !== wall.target)
552
+ return midClimbRefusal(actor);
553
+ }
554
+ }
193
555
  // A DOORWAY THE PLAYER NAMED BY DIRECTION COMES FIRST
194
556
  // (TqKCyEZ23ppb4w5BP -- "there's no location in Krow's Den to move
195
557
  // to the locked door").
@@ -356,267 +718,31 @@ export class MoveCommand extends Command {
356
718
  let descentNote = '';
357
719
  const climbSteps = mine !== undefined ? spotClimbSteps(room, mine, spot.name) : undefined;
358
720
  if (climbSteps !== undefined) {
359
- // STRENGTH, NOT AGILITY (SR5 p.134, Climbing: the test is
360
- // "Gymnastics + Strength [Physical]"). This rolled Agility, which
361
- // is the reflex attribute every other Athletics use leans on --
362
- // plausible, and wrong. Climbing is the one athletic act the book
363
- // hangs on how much of your own weight you can haul.
364
- //
365
- // The SKILL stays `athletics`: this engine carries skill GROUPS
366
- // where the book has members (see models/player.ts -- Summoning/
367
- // Binding/Banishing collapse to `conjuring` the same way), and
368
- // Gymnastics is the Athletics group's climbing member. That is a
369
- // pre-existing, documented modelling choice, not something this
370
- // roll invented; the attribute was a straightforward error.
371
- // ASSISTED CLIMBING (p.134 Climbing Table: +2 dice, and 1 metre
372
- // per hit instead of per two). The gear is the p.449 kit -- rope,
373
- // harness, carabiners, crampons -- which this game has SOLD in the
374
- // catalog all along (`climbing-gear`) while nothing read it. It
375
- // was buyable and inert, the same shape as the hostility flag:
376
- // wired at one end only.
377
- //
378
- // Carried, not worn: a kit in the pack is a kit you can rig.
379
- // Narration from a rappel that got away, prepended to the arrival.
380
- const assisted = actor.inventory.getAllItems().some(i => /climbing gear|climbing kit|\brope\b|harness|carabiner|crampon|grapple gun/i.test(`${i.name} ${i.description ?? ''}`));
381
- // WHICH WAY. pathClimbSteps returns a magnitude, so direction is
721
+ // WHICH WAY. spotClimbSteps returns a magnitude, so direction is
382
722
  // read off the cells: canon prices down differently from up
383
723
  // (unassisted down is 1m per HIT, twice the rate of going up),
384
724
  // and rappelling is a different test entirely.
385
725
  const grid = room.ensureGrid();
386
- const fromZ = mine !== undefined ? (grid?.spotCells.get(mine)?.z ?? 0) : 0;
726
+ const fromZ = grid?.spotCells.get(mine)?.z ?? 0;
387
727
  const toZ = grid?.spotCells.get(spot.name)?.z ?? 0;
388
728
  const down = toZ < fromZ;
389
- // SURFACE AND CONDITION (p.134), read off the room's own prose and
390
- // the level's name -- see climbSurfaceModifier. Zero unless the
391
- // fiction says otherwise, which is why adding this could not
392
- // re-break the scenes the banking fix just made climbable.
393
- const surface = climbSurfaceModifier(`${room.name} ${room.description ?? ''} ${(room.levels ?? []).map(l => `${l.name} ${l.kind ?? ''}`).join(' ')} ${spot.name}`);
394
- // RAPPELLING (p.134) IS ITS OWN ROW: "assisted climbing down" is
395
- // not a scaled climb, it is Free-Fall + Body [Physical] (2) on a
396
- // rope, descending 20 metres per Combat Turn. Routed here rather
397
- // than folded into the climb rate, because folding it in would
398
- // have made it a faster climb instead of a different act.
399
- //
400
- // THE CLOCK IS THE BOOK'S BOOKKEEPING, NOT THE RULE. Canon quotes
401
- // the rate per Combat Turn and this engine has no combat-turn
402
- // clock (the same gap named in getInitiativeDice and in the sprite
403
- // sustain task). The RATE is what is canon: 20m per turn, +1m per
404
- // net hit. Every vertical drop this engine can build is a handful
405
- // of metres -- METERS_PER_LEVEL is 3 -- so a single turn covers
406
- // any of them outright, and the honest translation is one test
407
- // that either lands you or does not. No clock invented to host it.
408
- if (down && assisted) {
409
- // Free-Fall in canon, and now in this engine too -- the comment
410
- // here used to end "...and has no Free-Fall skill", which was the
411
- // reason it settled for `athletics`. The skill exists as of the
412
- // athletics split, so the site rolls what it always wanted.
413
- const ffSkill = actor.skillRating('free-fall');
414
- const ffPool = Math.max(1, actor.body + (ffSkill > 0 ? ffSkill : -1) + actor.bonus('athletics') + surface.mod + actor.woundModifier - actor.sustainingPenalty);
415
- const ff = rollPool(ffPool);
416
- this.logger.meta(` Rappel ${climbSteps * METERS_PER_LEVEL}m -- Free-Fall + Body (2)${surface.label ? ` [${surface.label}]` : ''}: ${formatRoll(ff)}`);
417
- if (ff.hits >= 2) {
418
- actor.climbProgress = undefined;
419
- this.actor.performAction('rappels down to', spot.name);
420
- // Falls through to the normal arrival below.
421
- }
422
- else {
423
- // "On an unsuccessful test you don't slow down" (p.134): the
424
- // rope runs and you arrive hard rather than not at all.
425
- const dv = fallDamageDVMeters(climbSteps * METERS_PER_LEVEL);
426
- descentNote = `You kick off and the line runs faster than you meant -- you come down the rope in a rush.`;
427
- // Carried out to the arrival line below: a rushed rappel still
428
- // lands you, so the story of it has to ride WITH the arrival
429
- // rather than replacing it. Lint caught this built-and-dropped
430
- // -- the player would have taken the damage and been told
431
- // nothing about where it came from.
432
- if (dv > 0) {
433
- const soak = rollPool(actor.getSoakPool());
434
- const dealt = Math.max(0, dv - soak.hits);
435
- this.logger.meta(` Hard landing: DV ${dv}, Soak: ${formatRoll(soak)}`);
436
- if (dealt > 0) {
437
- actor.takeDamage(dealt);
438
- descentNote += ` The landing costs you ${dealt} box${dealt === 1 ? '' : 'es'}: ${actor.conditionSummary()}`;
439
- }
440
- else {
441
- descentNote += ` You hit hard and the armor eats it.`;
442
- }
443
- }
444
- else {
445
- descentNote += ` It is not far enough to hurt.`;
446
- }
447
- actor.climbProgress = undefined;
448
- this.actor.performAction('rappels down to', spot.name);
449
- // A rushed rappel still ARRIVES -- you are down either way.
450
- }
451
- // A RAPPEL IS NOT A CLIMB, so it does not run one. Everything
452
- // below belongs to going UP, or to going down the hard way.
453
- }
454
- else {
455
- // GYMNASTICS: p.134 tests climbing as Gymnastics + Strength, which
456
- // is the pairing this pool already had -- only the skill name was
457
- // standing in for it.
458
- const athleticsSkill = actor.skillRating('gymnastics');
459
- const pool = Math.max(1, actor.strength + (athleticsSkill > 0 ? athleticsSkill : -1) + actor.bonus('athletics') + (assisted ? 2 : 0) + surface.mod + actor.woundModifier - actor.sustainingPenalty);
460
- const climb = rollPool(pool);
461
- // HITS BUY DISTANCE -- there is no threshold (p.134, Climbing
462
- // Table: "Unassisted climbing upward, 1 meter per 2 hits"). This
463
- // used to be a flat "2 hits or you fall", which is a rule the book
464
- // does not have: it made a one-metre scramble and a three-storey
465
- // face equally hard, and it made a strong climber's extra hits
466
- // buy nothing.
467
- const meters = climbSteps * METERS_PER_LEVEL;
468
- // BANKED PROGRESS (p.134): hits buy metres per TEST, and a wall is
469
- // climbed over successive actions. Anything already banked toward
470
- // THIS spot counts; progress toward a different one does not
471
- // transfer, or a player could bank a metre on one wall and cash it
472
- // in across the room.
473
- const banked = actor.climbProgress?.target === spot.name ? actor.climbProgress.meters : 0;
474
- const remaining = Math.max(0, meters - banked);
475
- // DOWN IS TWICE AS FAST (p.134: "Unassisted climbing down, 1 metre
476
- // per hit"). The engine climbed down at the upward rate, so a
477
- // runner picking their way off a catwalk paid the same toll as
478
- // getting onto it -- the item's third gap.
479
- const needed = climbHitsFor(remaining, assisted, down);
480
- const gained = climbMetersFrom(climb.hits, assisted, down);
481
- const reachedTotal = Math.min(meters, banked + gained);
482
- // ARRIVAL IS DECIDED IN METRES, not in hits, because metres are
483
- // what the rule buys. The two agree almost always and diverged on
484
- // the rounding: `needed` is a CEILING, so a roll could carry you
485
- // the full remaining distance and still fall a fraction short of
486
- // the integer threshold. On a bench that printed "you get about
487
- // 3m up" on a 3m wall and then, next attempt, "3m already banked,
488
- // 1m to go" -- arrived and not arrived at once.
489
- //
490
- // `needed` stays for the ticker, where a whole number of hits is
491
- // the useful thing to read.
492
- const gotUp = !climb.glitch && (banked + gained) >= meters;
493
- // Same one-rounding rule as the prose below: round the banked
494
- // figure once and derive the remainder from it, or the ticker
495
- // says "2m banked, 2m to go" on a 3m wall.
496
- const shownBanked = Math.round(banked);
497
- this.logger.meta(` ${down ? 'Descend' : 'Climb'} ${meters}m${banked > 0 ? ` (${shownBanked}m already banked, ${Math.max(0, meters - shownBanked)}m to go)` : ''}${assisted ? ' -- ASSISTED (+2, 1m/hit)' : ''}${down && !assisted ? ' -- DOWN (1m/hit)' : ''}${surface.label ? ` [${surface.label}]` : ''} -- Gymnastics + Strength: ${formatRoll(climb)} (${needed} hits needed)`);
498
- if (gotUp) {
499
- // Over the lip: the wall is done with you.
500
- actor.climbProgress = undefined;
501
- }
502
- if (!gotUp) {
503
- // A FAILED CLIMB IS NOT A FALL (p.134, Climbing Failures and
504
- // Glitches). Progress halts and you make a Reaction + Strength
505
- // test to HOLD ON; only failing THAT drops you. Falling
506
- // immediately was the engine's own invention, and it is the
507
- // half of this that made climbing feel arbitrary -- one bad
508
- // roll and gravity, with nothing in between.
509
- const hold = rollPool(Math.max(1, actor.reaction + actor.strength + actor.woundModifier - actor.sustainingPenalty));
510
- this.logger.meta(` Hold on -- Reaction + Strength: ${formatRoll(hold)} (1 hit holds)`);
511
- const reached = reachedTotal;
512
- if (hold.hits >= 1) {
513
- // YOU KEEP THE METRES. This used to end "and climb back down",
514
- // throwing the whole attempt away -- which is what made a tall
515
- // wall unclimbable rather than merely slow. Hanging on with
516
- // progress banked is the book's own shape: another Complex
517
- // Action, from where you got to.
518
- actor.climbProgress = { target: spot.name, meters: reached };
519
- // THE TWO FIGURES HAVE TO ADD UP. Rounding each independently
520
- // printed "about 2m up" and "about 2m still above you" on a
521
- // 3m wall -- both are honest roundings of 1.5, and together
522
- // they describe a 4m climb. Caught on a repro bench, where a
523
- // reviewer would rightly have filed it as a maths bug.
524
- //
525
- // So the SHOWN height is rounded once and the remainder is
526
- // derived from it. The banked progress keeps the exact value:
527
- // the display is where the rounding belongs, not the state.
528
- const shownUp = Math.round(reached);
529
- const left = Math.max(0, meters - shownUp);
530
- this.actor.performAction(down ? 'climbs partway down toward' : 'climbs partway toward', `the ${spot.name}, and holds there`);
531
- // A DESCENT IS NOT AN ASCENT WITH A DIFFERENT RATE, and this
532
- // block is shared by both -- the rappel is routed away above,
533
- // so an UNASSISTED climb down falls through to here.
534
- //
535
- // `reached` is distance TRAVELLED, so going down it measures
536
- // how far you have come FROM THE TOP; height above the ground
537
- // is the remainder. Every sentence here read `reached` as
538
- // height and said "up" and "still above you" out loud, so a
539
- // runner picking their way off a catwalk was told they were
540
- // climbing it. Measured on a bench, descending a 3m ledge:
541
- // "You get about 1m up ... about 2m still above you."
542
- // -- printed on the second of four `descend` commands.
543
- return down
544
- ? `You get about ${reached < 1 ? 'a body length' : `${shownUp}m`} down and it stops going anywhere -- ${climb.glitch ? 'a hold shears away under your weight' : 'no purchase left within reach'}. You hang on, boots wedged, ${left < 1 ? 'the ground within reach' : `about ${left}m still below you`}.${hint(` (Descend again to keep going${assisted ? '' : '; rope and a harness would let you rappel it'}.)`)}`
545
- : `You get about ${reached < 1 ? 'a body length' : `${shownUp}m`} up and it stops going anywhere -- ${climb.glitch ? 'a hold shears away under your weight' : 'no purchase left within reach'}. You hang on, boots wedged, ${left < 1 ? 'the lip within reach' : `about ${left}m still above you`}.${hint(` (Climb again to keep going${assisted ? '' : '; rope and a harness would double your rate'}.)`)}`;
546
- }
547
- // THE SECOND CHANCE (p.134 Climbing Failures and Glitches;
548
- // 6SmvvRRtn9eEMZSbF). Canon: the hold-on fails, you start to
549
- // fall, "and during the next Action Phase the character may
550
- // attempt to stop with a Reaction + Strength Test at a -2 dice
551
- // pool modifier". Two tests, not one, and this engine only ever
552
- // rolled the first.
553
- //
554
- // COMPRESSED, AND SAYING SO. The book puts a phase between them
555
- // because a fall takes time -- 20m per Combat Turn. Every drop
556
- // this engine can build is a few metres (METERS_PER_LEVEL is 3),
557
- // so there is no phase to wait for: the catch resolves in the
558
- // same breath as the slip. The MECHANIC is canon -- a second
559
- // attempt, at -2 -- and only the gap between them is missing,
560
- // which is the same translation the rappel makes above.
561
- //
562
- // THE FILED ITEM'S FRAMING WAS WRONG and the correction belongs
563
- // here: there is no general "arrest any fall" rule in SR5. The
564
- // RAG is explicit that a mid-fall save exists in exactly two
565
- // places, this one and a failed rappel. A character who walks
566
- // off a roof does not get it, and giving them one would be
567
- // inventing a rule the book does not have.
568
- const catchIt = rollPool(Math.max(1, actor.reaction + actor.strength - 2 + actor.woundModifier - actor.sustainingPenalty));
569
- this.logger.meta(` Catch yourself -- Reaction + Strength -2: ${formatRoll(catchIt)} (1 hit stops the fall)`);
570
- // HOW HIGH YOU ACTUALLY ARE -- the one figure both the prose and
571
- // the damage below have to agree on, and the reason this is a
572
- // named variable rather than `reached` inline in three places.
573
- //
574
- // Going UP it is the distance climbed. Going DOWN it is what is
575
- // LEFT: `reached` counts travel from the top, so a runner two
576
- // metres down a three-metre wall is one metre off the deck, not
577
- // two. See the descent note in the hold-on branch above.
578
- const heightNow = down ? Math.max(0, meters - reached) : reached;
579
- const shownHeight = heightNow < 1 ? 'a body length' : `${Math.round(heightNow)}m`;
580
- if (catchIt.hits >= 1) {
581
- actor.climbProgress = { target: spot.name, meters: reached };
582
- this.actor.performAction('slips on', `the ${spot.name} climb, and catches themselves`);
583
- return `You come off -- and catch yourself a body length down, fingers screaming. You are still on the wall, about ${shownHeight} up.${hint(` (${down ? 'Descend' : 'Climb'} again to keep going.)`)}`;
584
- }
585
- // The grip goes: the wall takes back everything it gave.
586
- actor.climbProgress = undefined;
587
- // Fall from where you ACTUALLY ARE, not from the top -- and not
588
- // from the distance travelled either. Canon buys distance with
589
- // hits whether or not you finish, so a runner who barely left
590
- // the floor does not take a storey's worth of landing.
591
- //
592
- // THIS READ `reached` AND WAS INVERTED ON THE WAY DOWN, which is
593
- // the mechanical half of the descent bug: a runner 2m down a 3m
594
- // wall is 1m up and was billed for a 2m fall, while one who
595
- // slipped on the FIRST action of a descent was nearly a full
596
- // storey up and billed for nothing. Invisible on a 3m ledge --
597
- // both figures soak to zero damage, which is exactly how it
598
- // survived -- and real the moment the drop is worth any DV.
599
- const dv = fallDamageDVMeters(heightNow);
600
- let fallNote = `Your grip goes, and there's nothing to catch -- you come off the wall about ${shownHeight} up.`;
601
- if (dv > 0) {
602
- const soak = rollPool(actor.getSoakPool());
603
- const dealt = Math.max(0, dv - soak.hits);
604
- this.logger.meta(` Fall damage: DV ${dv} (${Math.round(heightNow)}m, AP -4), Soak: ${formatRoll(soak)}`);
605
- if (dealt > 0) {
606
- actor.takeDamage(dealt);
607
- fallNote += ` The landing costs you ${dealt} box${dealt === 1 ? '' : 'es'}: ${actor.conditionSummary()}`;
608
- }
609
- else {
610
- fallNote += ` You hit hard but the armor eats it.`;
611
- }
612
- }
613
- else {
614
- fallNote += ` It's a short drop -- you land badly and nothing breaks.`;
615
- }
616
- this.actor.performAction('falls from', `a climb toward the ${spot.name}`);
617
- return fallNote;
618
- }
619
- }
729
+ const meters = climbSteps * METERS_PER_LEVEL;
730
+ // BANKED PROGRESS (p.134): anything already banked toward THIS
731
+ // spot counts; progress toward a different one does not transfer.
732
+ const banked = actor.climbProgress?.target === spot.name ? actor.climbProgress.meters : 0;
733
+ // The dice, the hold-on, the second chance and the fall all live
734
+ // in resolveClimb -- shared with retreatFromClimb, so coming back
735
+ // off a wall cannot drift from going up it.
736
+ const outcome = this.resolveClimb({
737
+ spot: spot.name, meters, banked, down,
738
+ // A forward climb starts on the ground; a descent starts at
739
+ // the top of the wall it is coming down.
740
+ heightAtStart: down ? meters : 0,
741
+ progressFor: reached => ({ target: spot.name, meters: reached }),
742
+ });
743
+ if (!outcome.arrived)
744
+ return outcome.text;
745
+ descentNote = outcome.note;
620
746
  }
621
747
  // BANKED CLIMB PROGRESS BELONGS TO ONE WALL. Anything that gets the
622
748
  // actor to a spot -- this move, a step, a walk across the floor --