@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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/archetypes.js +238 -146
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +27 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/climb.js +21 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +7 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +54 -11
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +381 -255
- package/bundle/typescript/src/commands/game/sideQuest/game.js +10 -0
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +8 -4
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +7 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +34 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/climb-state.js +51 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/matrix-intrusion.js +16 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-combat-brain.js +10 -6
- package/bundle/typescript/src/commands/game/sideQuest/utilities/pregen-worksheet.js +41 -10
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +31 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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 --
|