@maka/maka-cli 5.204.0 → 5.206.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/commands/breach.js +90 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +72 -5
- package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +78 -13
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +16 -3
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +64 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +140 -6
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +21 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/narration-limits.js +73 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +76 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +11 -2
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.206.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
|
|
6
6
|
"description": "A command line tool for scaffolding Meteor 3.x applications using React.",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { BypassCommand } from './bypass-command.js';
|
|
2
|
+
import { Door } from '../models/door.js';
|
|
3
|
+
import { ensureAtExit } from '../utilities/spots.js';
|
|
2
4
|
import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
3
5
|
/**
|
|
4
6
|
* The FORCEFUL route past a physical barrier (player ruling: "solve" was
|
|
@@ -35,8 +37,96 @@ export class BreachCommand extends BypassCommand {
|
|
|
35
37
|
if (this.actor.plane === 'drone') {
|
|
36
38
|
return `The drone weighs less than the door. "jump" back to a body with shoulders.`;
|
|
37
39
|
}
|
|
40
|
+
// A DOOR IS A PHYSICAL BARRIER EVEN WHEN NO DEVICE HOLDS IT
|
|
41
|
+
// (Gfx5RFFpHJdKB87Bh: "I cant Breach East").
|
|
42
|
+
//
|
|
43
|
+
// Everything on BypassCommand is device-shaped: it lists
|
|
44
|
+
// room.openableDevices(), which knows only Device objects, and a
|
|
45
|
+
// plain keyed Door is not one. So a room whose east way out was a
|
|
46
|
+
// locked door and nothing else answered "There's nothing here to
|
|
47
|
+
// breach -- no physical barrier holds this room" while the map, one
|
|
48
|
+
// line above, printed "East: To Lucky Chummer (locked)". Two
|
|
49
|
+
// surfaces in the same room denying each other, and the player
|
|
50
|
+
// holding the verb that should work.
|
|
51
|
+
//
|
|
52
|
+
// Reproduced before fixing, in shipped scene1's Krow's Den: `map`
|
|
53
|
+
// advertised the locked east exit, `go east` named the key, and
|
|
54
|
+
// `breach east` said there was no barrier.
|
|
55
|
+
//
|
|
56
|
+
// A door HELD BY A DEVICE is left alone -- the maglock is the real
|
|
57
|
+
// thing standing in the way, it has its own prose and its own
|
|
58
|
+
// refusals, and the base class already routes it correctly.
|
|
59
|
+
const forced = await this.breachPlainDoor(args);
|
|
60
|
+
if (forced)
|
|
61
|
+
return forced;
|
|
38
62
|
return super.execute(args);
|
|
39
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The muscle route at an ordinary locked door. Returns undefined when
|
|
66
|
+
* this isn't that case, so the device path runs untouched.
|
|
67
|
+
*
|
|
68
|
+
* SAME TEST AS THE DEVICE ROUTE, deliberately. Canon prices barriers
|
|
69
|
+
* by Structure and Armor (p.197-198, Destroying Barriers) and resolves
|
|
70
|
+
* them as an attack -- but that machinery reads the DV of a WEAPON,
|
|
71
|
+
* and by RAW it also says barriers ignore Stun, which is the only
|
|
72
|
+
* damage a shoulder deals. Modelling a door the book's way would
|
|
73
|
+
* therefore make an unarmed breach impossible rather than merely
|
|
74
|
+
* hard, which is not what the book means and not what this verb is
|
|
75
|
+
* for. The engine's existing Strength + Body muscle test is a
|
|
76
|
+
* standing ruling with its own comment above; extending the verb's
|
|
77
|
+
* REACH is this item's fix, and re-pricing it is a separate question
|
|
78
|
+
* that should be asked out loud rather than smuggled in here.
|
|
79
|
+
*/
|
|
80
|
+
async breachPlainDoor(args) {
|
|
81
|
+
const room = this.actor.currentLocation;
|
|
82
|
+
if (!room)
|
|
83
|
+
return undefined;
|
|
84
|
+
// Only a bare "breach" or "breach <direction>" can mean a door --
|
|
85
|
+
// anything else is naming an object, which is the device path's job.
|
|
86
|
+
const raw = args.filter(w => !/^(at|the|a|an)$/i.test(w)).join(' ').trim().toLowerCase();
|
|
87
|
+
const plain = (dir) => {
|
|
88
|
+
const exit = room.exits.get(dir);
|
|
89
|
+
return exit instanceof Door && exit.isLocked && !exit.heldBy && !room.deviceOpening(dir)
|
|
90
|
+
? exit
|
|
91
|
+
: undefined;
|
|
92
|
+
};
|
|
93
|
+
let direction;
|
|
94
|
+
if (raw && this.isValidDirection(raw)) {
|
|
95
|
+
direction = raw;
|
|
96
|
+
}
|
|
97
|
+
else if (!raw || /^(door|lock|barrier|gate|hatch|way)$/.test(raw)) {
|
|
98
|
+
// Unambiguous only: with two locked doors and no direction, fall
|
|
99
|
+
// through so the base class asks which -- guessing one would pick
|
|
100
|
+
// a door by map order and call it the player's intent.
|
|
101
|
+
const locked = [...room.exits.keys()].filter(d => plain(d));
|
|
102
|
+
if (locked.length !== 1)
|
|
103
|
+
return undefined;
|
|
104
|
+
direction = locked[0];
|
|
105
|
+
}
|
|
106
|
+
if (!direction)
|
|
107
|
+
return undefined;
|
|
108
|
+
const door = plain(direction);
|
|
109
|
+
if (!door)
|
|
110
|
+
return undefined;
|
|
111
|
+
const reach = ensureAtExit(this.actor, direction);
|
|
112
|
+
if (reach.refusal)
|
|
113
|
+
return reach.refusal;
|
|
114
|
+
const crossNote = reach.line ? `${reach.line}\n` : '';
|
|
115
|
+
const gave = await this.determineOutcome();
|
|
116
|
+
const where = String(direction).toLowerCase();
|
|
117
|
+
if (!gave) {
|
|
118
|
+
this.actor.performAction('throws a shoulder at', `the ${where} door, and it holds`);
|
|
119
|
+
return `${crossNote}${this.failureFlavor}`;
|
|
120
|
+
}
|
|
121
|
+
door.isLocked = false;
|
|
122
|
+
this.actor.sneaking = false;
|
|
123
|
+
this.logger.write(`BreachCommand: ${this.actor.name} forced the ${where} door in ${room.name}.`);
|
|
124
|
+
this.actor.performAction('breaches', `the ${where} door`);
|
|
125
|
+
this.scene.raiseAlarm(`${this.actor.name} breached the ${where} door in ${room.name} -- the crash carried`);
|
|
126
|
+
this.scene.addWorldEvent(`${this.actor.name} breached the ${where} door in ${room.name} -- the crash carried.`);
|
|
127
|
+
this.game?.addHeat(2, `breached the ${where} door`);
|
|
128
|
+
return `${crossNote}${this.successFlavor}\n The crash CARRIES -- anyone nearby knows exactly where you are now.`;
|
|
129
|
+
}
|
|
40
130
|
async determineOutcome() {
|
|
41
131
|
// Raw muscle -- Strength + Body, 2 hits to give. No skill governs
|
|
42
132
|
// hitting a door hard; wounds and sustained workings still bleed the
|
|
@@ -168,6 +168,33 @@ export class MoveCommand extends Command {
|
|
|
168
168
|
// of metres -- METERS_PER_LEVEL is 3 -- so a single turn covers
|
|
169
169
|
// any of them outright, and the honest translation is one test
|
|
170
170
|
// that either lands you or does not. No clock invented to host it.
|
|
171
|
+
// THE LAST HALF-METRE IS A STEP, NOT A CLIMB (6SmvvRRtn9eEMZSbF,
|
|
172
|
+
// fifth rejection: "I would see the descend moved me towards the
|
|
173
|
+
// ground, but then I stopped ... 'the ground within reach' -- this
|
|
174
|
+
// is basically on the ground, I don't know why we have to split
|
|
175
|
+
// hairs").
|
|
176
|
+
//
|
|
177
|
+
// Unassisted UP is 1m per 2 hits, so a bank is typically half a
|
|
178
|
+
// metre. Descending that half metre with zero hits gained nothing,
|
|
179
|
+
// re-banked the identical figure, and printed the same two
|
|
180
|
+
// sentences -- a state byte-identical to the one before it, which
|
|
181
|
+
// is a loop with no exit but luck.
|
|
182
|
+
//
|
|
183
|
+
// CANON IS SILENT HERE and that silence is the ruling: p.134's
|
|
184
|
+
// Climbing Table prices metres per hit and has no row for a drop
|
|
185
|
+
// shorter than a person, and p.172 charges nothing for a fall of
|
|
186
|
+
// 3m or less (fallDamageDVMeters agrees -- it returns 0). So there
|
|
187
|
+
// is no test to run and no damage to deal. Inventing a roll to
|
|
188
|
+
// stand between a runner and a floor they can already touch is the
|
|
189
|
+
// deviation; letting go is not.
|
|
190
|
+
//
|
|
191
|
+
// Ahead of the rappel branch on purpose: nobody rigs a harness to
|
|
192
|
+
// get off a kerb either.
|
|
193
|
+
if (down && (meters - banked) < 1) {
|
|
194
|
+
actor.climbProgress = undefined;
|
|
195
|
+
this.actor.performAction('drops the last of the way down to', spotName);
|
|
196
|
+
return { arrived: true, note: `Under a metre left -- you let go and take it on your boots.` };
|
|
197
|
+
}
|
|
171
198
|
if (down && assisted) {
|
|
172
199
|
// Free-Fall in canon, and now in this engine too -- the comment
|
|
173
200
|
// here used to end "...and has no Free-Fall skill", which was the
|
|
@@ -276,7 +303,15 @@ export class MoveCommand extends Command {
|
|
|
276
303
|
// wall unclimbable rather than merely slow. Hanging on with
|
|
277
304
|
// progress banked is the book's own shape: another Complex
|
|
278
305
|
// Action, from where you got to.
|
|
279
|
-
|
|
306
|
+
//
|
|
307
|
+
// ...BUT ONLY IF THERE ARE METRES (6SmvvRRtn9eEMZSbF). A first
|
|
308
|
+
// attempt that buys nothing used to bank ZERO and still count as
|
|
309
|
+
// being on the wall -- which is a real place to this engine, so
|
|
310
|
+
// the runner could not "move" until they "descend"ed off a floor
|
|
311
|
+
// they had never left, and descend then narrated letting go from
|
|
312
|
+
// no height at all. You are on a wall when you are up it.
|
|
313
|
+
const banked = wall.progressFor(reached);
|
|
314
|
+
actor.climbProgress = banked.meters > 0 ? banked : undefined;
|
|
280
315
|
// THE TWO FIGURES HAVE TO ADD UP. Rounding each independently
|
|
281
316
|
// printed "about 2m up" and "about 2m still above you" on a
|
|
282
317
|
// 3m wall -- both are honest roundings of 1.5, and together
|
|
@@ -301,11 +336,36 @@ export class MoveCommand extends Command {
|
|
|
301
336
|
// climbing it. Measured on a bench, descending a 3m ledge:
|
|
302
337
|
// "You get about 1m up ... about 2m still above you."
|
|
303
338
|
// -- printed on the second of four `descend` commands.
|
|
339
|
+
// NEVER NARRATE A DISTANCE THAT WAS NOT TRAVELLED
|
|
340
|
+
// (6SmvvRRtn9eEMZSbF). `reached < 1` was true of ZERO, so a roll
|
|
341
|
+
// that bought nothing still announced "about a body length" --
|
|
342
|
+
// and the reporter, reasonably, read that as having moved. A
|
|
343
|
+
// failed climb makes no progress (p.134, Climbing Failures and
|
|
344
|
+
// Glitches); the prose has to say so instead of rounding nothing
|
|
345
|
+
// up into something.
|
|
346
|
+
const wentNowhere = reached <= 0;
|
|
347
|
+
const moved = reached < 1 ? 'a body length' : `${shownUp}m`;
|
|
348
|
+
const why = climb.glitch ? 'a hold shears away under your weight' : 'no purchase left within reach';
|
|
349
|
+
// AND NOBODY HANGS FROM THE FLOOR. With nothing banked the runner
|
|
350
|
+
// is standing in the yard with their hands on the wall, so
|
|
351
|
+
// "you hang on, boots wedged" is the same false picture the
|
|
352
|
+
// distance was painting. Off the wall entirely, say so.
|
|
353
|
+
const offTheWall = banked.meters <= 0;
|
|
354
|
+
const perch = down
|
|
355
|
+
? (left < 1 ? 'the ground within reach' : `about ${left}m still below you`)
|
|
356
|
+
: (left < 1 ? 'the lip within reach' : `about ${left}m still above you`);
|
|
357
|
+
const rest = offTheWall
|
|
358
|
+
? `, boots still on the ground.`
|
|
359
|
+
: `. You hang on, boots wedged, ${perch}.`;
|
|
304
360
|
return {
|
|
305
361
|
arrived: false,
|
|
306
362
|
text: down
|
|
307
|
-
?
|
|
308
|
-
|
|
363
|
+
? `${wentNowhere
|
|
364
|
+
? `You feel for the next hold and there isn't one -- you get no lower, ${why}`
|
|
365
|
+
: `You get about ${moved} down and it stops going anywhere -- ${why}`}${rest}${hint(` (Descend again to keep going${assisted ? '' : '; rope and a harness would let you rappel it'}.)`)}`
|
|
366
|
+
: `${wentNowhere
|
|
367
|
+
? `You feel for the next hold and there isn't one -- you get no higher, ${why}`
|
|
368
|
+
: `You get about ${moved} up and it stops going anywhere -- ${why}`}${rest}${hint(` (Climb again to keep going${assisted ? '' : '; rope and a harness would double your rate'}.)`)}`,
|
|
309
369
|
};
|
|
310
370
|
}
|
|
311
371
|
// THE SECOND CHANCE (p.134 Climbing Failures and Glitches;
|
|
@@ -344,11 +404,18 @@ export class MoveCommand extends Command {
|
|
|
344
404
|
const heightNow = Math.max(0, wall.heightAtStart + (down ? -reached : reached));
|
|
345
405
|
const shownHeight = heightNow < 1 ? 'a body length' : `${Math.round(heightNow)}m`;
|
|
346
406
|
if (catchIt.hits >= 1) {
|
|
347
|
-
|
|
407
|
+
// Same rule as the hold-on above (6SmvvRRtn9eEMZSbF): catching
|
|
408
|
+
// yourself with nothing banked is catching yourself ON THE FLOOR,
|
|
409
|
+
// and saying "you are still on the wall" about a runner standing
|
|
410
|
+
// in the yard is the sort of line that gets an item sent back.
|
|
411
|
+
const caught = wall.progressFor(reached);
|
|
412
|
+
actor.climbProgress = caught.meters > 0 ? caught : undefined;
|
|
348
413
|
this.actor.performAction('slips on', `the ${spotName} climb, and catches themselves`);
|
|
349
414
|
return {
|
|
350
415
|
arrived: false,
|
|
351
|
-
text:
|
|
416
|
+
text: caught.meters > 0
|
|
417
|
+
? `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.)`)}`
|
|
418
|
+
: `You come off almost as soon as you start -- and catch yourself, boots back on the ground, fingers screaming.${hint(` (${down ? 'Descend' : 'Climb'} again to try it.)`)}`,
|
|
352
419
|
};
|
|
353
420
|
}
|
|
354
421
|
// The grip goes: the wall takes back everything it gave.
|
|
@@ -4,14 +4,21 @@ import { rollPool, formatRoll } from '../utilities/dice.js';
|
|
|
4
4
|
import { ownedDrones } from '../utilities/owned-drones.js';
|
|
5
5
|
/**
|
|
6
6
|
* The free road to STUN recovery (stim patches are the fast, risky one --
|
|
7
|
-
* see use.ts): catch your breath somewhere safe. "Safe" means
|
|
8
|
-
* Residence-type room
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* see use.ts): catch your breath somewhere safe. "Safe" means somewhere
|
|
8
|
+
* that houses you -- a Residence-type room, or a room whose seed says it
|
|
9
|
+
* quarters your lifestyle tier -- or a room with no one else in it:
|
|
10
|
+
* nobody naps in front of an audience in the sprawl.
|
|
11
|
+
*
|
|
12
|
+
* Each rest is SR5 p.207's Stun half: Body + Willpower, one box per hit.
|
|
13
|
+
* A night in a bed that's yours is also the day of NATURAL RECOVERY for
|
|
14
|
+
* the physical track (p.207, built 2026-09-12 for vFMhSGwLhsfpiY7bs):
|
|
15
|
+
* Body x 2, each hit a box, only once the stun is gone. You can't rest
|
|
14
16
|
* mid-combat-exchange.
|
|
17
|
+
*
|
|
18
|
+
* Both halves of the stun line were wrong until YxFKFqmXMgAvxwpce: the
|
|
19
|
+
* pool was Body alone, and it paid out `hits + 1`. The free box is why
|
|
20
|
+
* the missing attribute went unmeasured for so long -- rest always did
|
|
21
|
+
* something, so nobody checked how much.
|
|
15
22
|
*/
|
|
16
23
|
export class RestCommand extends Command {
|
|
17
24
|
static verb = 'rest';
|
|
@@ -19,7 +26,24 @@ export class RestCommand extends Command {
|
|
|
19
26
|
async execute(_args = []) {
|
|
20
27
|
const actor = this.actor;
|
|
21
28
|
const room = actor.currentLocation;
|
|
22
|
-
|
|
29
|
+
// A FULL STUN TRACK IS THE REASON TO REST, NOT A BAR TO IT. This
|
|
30
|
+
// asked isIncapacitated(), which is isDown() || isUnconscious() --
|
|
31
|
+
// and isUnconscious() is exactly "the stun track is full", so the
|
|
32
|
+
// runner who most needed to sleep it off was the one who could not.
|
|
33
|
+
// Canon is the other way round: p.207 counts forced naps and
|
|
34
|
+
// unconsciousness AS rest.
|
|
35
|
+
//
|
|
36
|
+
// HONEST SCOPE (YxFKFqmXMgAvxwpce): this is a correction, not that
|
|
37
|
+
// item's fix, and it is defence in depth rather than a live path.
|
|
38
|
+
// A full stun track is claimed upstream long before any verb runs --
|
|
39
|
+
// settleUnresolvedHarm() opens every command and hands an
|
|
40
|
+
// unconscious runner to Scene.beginPlayerKnockout, which is what
|
|
41
|
+
// decides whether they come to. Measured, not assumed: with this
|
|
42
|
+
// gate opened, `rest` on a stun-full runner still answers "the world
|
|
43
|
+
// tilts and goes dark". Narrowed anyway, because a guard that
|
|
44
|
+
// refuses the one case canon explicitly permits is wrong whether or
|
|
45
|
+
// not anything currently reaches it.
|
|
46
|
+
if (actor.isDown()) {
|
|
23
47
|
return `You're past resting.`;
|
|
24
48
|
}
|
|
25
49
|
if (this.scene.isHumanControlled(actor) && this.scene.isPlayerExchangeActive(actor.name)) {
|
|
@@ -36,7 +60,25 @@ export class RestCommand extends Command {
|
|
|
36
60
|
.map(d => d.item)
|
|
37
61
|
.filter(i => i.droneDamage > 0);
|
|
38
62
|
const othersHere = this.scene.getActorsInRoom(room).filter(a => a !== actor);
|
|
39
|
-
|
|
63
|
+
// WHERE THIS RUNNER SLEEPS, NOT WHERE THE ENGINE KEEPS ITS HOME ROOM
|
|
64
|
+
// (YxFKFqmXMgAvxwpce). 'Residence' is the roomType of Game._homeRoom
|
|
65
|
+
// and of nothing else -- and _homeRoom is only ever added to the
|
|
66
|
+
// scene on a SOLO boot (game.ts: `if (!options.sharedRun && ...)`).
|
|
67
|
+
// headless.ts hard-codes sharedRun: true, so every browser session,
|
|
68
|
+
// hub included, ran with no residence room in the world at all:
|
|
69
|
+
// isResidence was permanently false, markSlept() could never fire,
|
|
70
|
+
// the sleep-locked fatigue floor in Player.healStun never lifted,
|
|
71
|
+
// and rest printed a roll and "0 stun recovered" with nothing to
|
|
72
|
+
// explain it.
|
|
73
|
+
//
|
|
74
|
+
// The seed already says where a runner of a given tier beds down --
|
|
75
|
+
// supportedLifestyles.residenceTypes, which the hub's own start room
|
|
76
|
+
// carries ("Street", "Squatter"). A room that houses your lifestyle
|
|
77
|
+
// IS your doss, whoever is hosting the session.
|
|
78
|
+
const tier = this.game?.lifestyleTier;
|
|
79
|
+
const housesMe = !!tier && (room.supportedLifestyles?.residenceTypes ?? [])
|
|
80
|
+
.some(t => t.toLowerCase() === tier.toLowerCase());
|
|
81
|
+
const isResidence = room.roomType?.toLowerCase() === 'residence' || housesMe;
|
|
40
82
|
if (actor.stunTaken === 0 && actor.damageTaken === 0 && damagedDecks.length === 0 && damagedDrones.length === 0) {
|
|
41
83
|
// Nothing to heal -- but sleeping is its own point now (needs
|
|
42
84
|
// system: edge refills only fed-and-slept, see Game.
|
|
@@ -90,18 +132,41 @@ export class RestCommand extends Command {
|
|
|
90
132
|
// MEDICINE aids natural recovery (canon p.207-208: Medicine hits
|
|
91
133
|
// add dice to healing tests) -- knowing what your body needs.
|
|
92
134
|
const medicineDice = actor.skillRating('medicine');
|
|
93
|
-
|
|
94
|
-
|
|
135
|
+
// BODY + WILLPOWER, EACH HIT A BOX (SR5 p.207, Stun Damage: "Make
|
|
136
|
+
// a Body + Willpower (1 hour) Extended Test ... Each hit heals 1
|
|
137
|
+
// box of Stun damage"). This rolled Body alone and then handed out
|
|
138
|
+
// `hits + 1`, so it was short a whole attribute and long a free
|
|
139
|
+
// box -- wrong in both directions at once, and the free box is
|
|
140
|
+
// what kept the shortfall from ever being noticed.
|
|
141
|
+
const roll = rollPool(Math.max(1, actor.body + actor.willpower + tierDice + healerDice + medicineDice));
|
|
142
|
+
let recovery = roll.hits;
|
|
95
143
|
// Insomnia (p.81): the sleep that comes is thin -- half the boxes.
|
|
144
|
+
// Halving nothing is still nothing: the old Math.max(1, ...) floor
|
|
145
|
+
// handed a box to a roll that bought none, which is the same
|
|
146
|
+
// invented generosity as the +1 above.
|
|
147
|
+
const halve = (n) => (n > 0 ? Math.max(1, Math.ceil(n / 2)) : 0);
|
|
96
148
|
const insomnia = actor.hasQuality('insomnia');
|
|
97
149
|
if (insomnia)
|
|
98
|
-
recovery =
|
|
150
|
+
recovery = halve(recovery);
|
|
99
151
|
if (inDebtAtHome)
|
|
100
|
-
recovery =
|
|
152
|
+
recovery = halve(recovery);
|
|
101
153
|
const recovered = actor.healStun(recovery);
|
|
102
154
|
this.logger.write(`RestCommand: ${actor.name} rested and recovered ${recovered} stun (${actor.stunTaken}/${actor.maxStunBoxes})${inDebtAtHome ? ' [halved: rent debt]' : ''}${insomnia ? ' [halved: insomnia]' : ''}${tierDice !== 0 ? ` [lifestyle ${tierDice > 0 ? '+' : ''}${tierDice}d]` : ''}${healerDice ? ` [quick healer +${healerDice}d]` : ''}.`);
|
|
103
155
|
lines.push(` Rest: ${formatRoll(roll)}${tierDice > 0 ? ` (+${tierDice} -- a real bed)` : tierDice < 0 ? ` (${tierDice} -- the Street sleeps badly)` : ''}${healerDice ? ` (+${healerDice} -- quick healer)` : ''}${insomnia ? ' (halved -- sleep never comes easy)' : ''}${inDebtAtHome ? ' (halved -- shivering is not sleeping)' : ''}`);
|
|
104
156
|
lines.push(` ${recovered} stun recovered: ${actor.stunSummary()}`);
|
|
157
|
+
// WHY NOTHING MOVED (YxFKFqmXMgAvxwpce). Fatigue boxes are locked
|
|
158
|
+
// to their need (p.172), so a runner whose stun IS hunger or sleep
|
|
159
|
+
// debt watched a roll happen and the track not budge, with no line
|
|
160
|
+
// anywhere connecting the two. Recovery that is refused has to say
|
|
161
|
+
// it is refused, and say what would lift it.
|
|
162
|
+
if (recovered < recovery && actor.lockedFatigue > 0) {
|
|
163
|
+
const needs = [];
|
|
164
|
+
if (actor.hungerFatigue > 0)
|
|
165
|
+
needs.push(`a real meal`);
|
|
166
|
+
if (actor.sleepFatigue > 0)
|
|
167
|
+
needs.push(`a real bed -- "rest" somewhere that houses you`);
|
|
168
|
+
lines.push(` ${actor.lockedFatigue} of those boxes won't shift: that's not damage, it's ${actor.hungerFatigue > 0 && actor.sleepFatigue > 0 ? 'hunger and exhaustion' : actor.hungerFatigue > 0 ? 'hunger' : 'exhaustion'}. It takes ${needs.join(' and ')}.`);
|
|
169
|
+
}
|
|
105
170
|
}
|
|
106
171
|
// NATURAL RECOVERY, PHYSICAL (vFMhSGwLhsfpiY7bs, 2026-09-12: "when I
|
|
107
172
|
// rest, my physical health track doesn't heal"). Until now rest said
|
|
@@ -227,7 +227,14 @@ export class TakeCommand extends Command {
|
|
|
227
227
|
// AFTER the loop, so the witnesses' event names what actually left
|
|
228
228
|
// the room (owners included) -- "everything in the room" gave a
|
|
229
229
|
// robbed NPC nothing to recognize as theirs.
|
|
230
|
-
|
|
230
|
+
// THIRD PERSON, because this line is read by somebody else
|
|
231
|
+
// (LHNMHKX5A95yeycbB: "Deditri take Trauma Patch Kit"). Player.see()
|
|
232
|
+
// prints `${actor.name} ${verb} ${details}` verbatim -- it conjugates
|
|
233
|
+
// nothing -- so the bare infinitive reads as broken prose on every
|
|
234
|
+
// other seat. Every sibling verb already passes third person,
|
|
235
|
+
// including this file's own container path above. NPC theft reactions
|
|
236
|
+
// match /take/i, so "takes" still trips them.
|
|
237
|
+
this.actor.performAction('takes', taken.length > 0 ? taken.join(', ') : 'everything in the room');
|
|
231
238
|
if (!searched) {
|
|
232
239
|
const note = hint(` (just what was lying in the open -- "search" to case the rest of the place)`);
|
|
233
240
|
if (note)
|
|
@@ -255,7 +262,13 @@ export class TakeCommand extends Command {
|
|
|
255
262
|
// its AI history, where ordering doesn't matter.
|
|
256
263
|
const isPlayer = this.scene.isHumanControlled(this.actor);
|
|
257
264
|
if (isPlayer) {
|
|
258
|
-
|
|
265
|
+
// SCOPED TO THE SWEEPER (LHNMHKX5A95yeycbB). Unscoped, this falls
|
|
266
|
+
// through to the ambient room scope -- so Deditri's first-person
|
|
267
|
+
// "You sweep Rattican Alley: took the Trauma Patch Kit" landed on
|
|
268
|
+
// Jynx's screen too, on top of the see() feed line she was already
|
|
269
|
+
// getting. A summary written in the second person belongs to the
|
|
270
|
+
// one person it is addressed to; everyone else has the feed.
|
|
271
|
+
this.logger.log(lines.join('\n'), { actor: this.actor.name });
|
|
259
272
|
}
|
|
260
273
|
if (!this.scene.isWinConditionTarget(this.actor.name)) {
|
|
261
274
|
for (const name of taken) {
|
|
@@ -356,7 +369,7 @@ export class TakeCommand extends Command {
|
|
|
356
369
|
// pocketed Mr. Krow's credstick AT HIS DESK and Krow's history
|
|
357
370
|
// read only "Maka take credstick": nothing marked it as his
|
|
358
371
|
// (NPC.see keys its theft reaction off this).
|
|
359
|
-
this.actor.performAction('
|
|
372
|
+
this.actor.performAction('takes', item.owner && item.owner !== this.actor.name ? `${item.name} (${item.owner}'s)` : item.name);
|
|
360
373
|
this.actor.addInventory(item);
|
|
361
374
|
this.logger.write(`Successfully added "${itemName}" to ${this.actor.name}'s inventory during 'Take' command.`);
|
|
362
375
|
// PAYDATA COMES HOME ON A CHIP (user ruling 2026-08-30, for the
|
|
@@ -780,5 +780,68 @@
|
|
|
780
780
|
// The name is still only a claim: the server verifies it against the
|
|
781
781
|
// account's seats and saves before stamping it, and an older client
|
|
782
782
|
// that sends none keeps the presence fallback.
|
|
783
|
-
|
|
783
|
+
// 1.67.0 (2026-09-16): SEVEN OPEN REPORTS, AND FOUR OF THEM WERE THE
|
|
784
|
+
// SAME SHAPE -- a thing wired at one end, agreeing with nothing at the
|
|
785
|
+
// other.
|
|
786
|
+
// - THE MAP NEVER LEARNED ABOUT A DOOR ADDED AFTER IT WAS DRAWN
|
|
787
|
+
// (BDFAZgcjZrtbXRNdn, "the D down door is gone, I can still go down
|
|
788
|
+
// but I can't see it"). Room.ensureGrid() memoizes for the life of
|
|
789
|
+
// the room; Room.addExit() never told it. Every runtime exit is
|
|
790
|
+
// affected and the hideout door is always one, because
|
|
791
|
+
// connectHomeRoom wires it AFTER seating the player -- which is
|
|
792
|
+
// itself what forces the first synthesis. addExit now patches the
|
|
793
|
+
// grid (room-grid.ts attachExitCell) rather than rebuilding it: a
|
|
794
|
+
// re-synthesis would re-seat the furniture under everyone standing
|
|
795
|
+
// on it. removeExit is the other half, so a home door that moves
|
|
796
|
+
// direction between jobs leaves no ghost.
|
|
797
|
+
// - REST WAS SHORT AN ATTRIBUTE AND LONG A FREE BOX
|
|
798
|
+
// (YxFKFqmXMgAvxwpce). p.207's Stun half is Body + Willpower, one
|
|
799
|
+
// box per hit; this rolled Body alone and paid hits + 1. The free
|
|
800
|
+
// box is why nobody measured the missing attribute. And 'Residence'
|
|
801
|
+
// is the roomType of Game._homeRoom alone, which is only added to
|
|
802
|
+
// the scene on a SOLO boot -- headless.ts hard-codes sharedRun, so
|
|
803
|
+
// every browser session had no bed in the world at all: no sleep, no
|
|
804
|
+
// unlock for sleep-locked fatigue (p.172), a roll that moved
|
|
805
|
+
// nothing, and no line saying why. A room whose seed quarters your
|
|
806
|
+
// lifestyle tier is now your doss, and blocked recovery names what
|
|
807
|
+
// is blocking it.
|
|
808
|
+
// - BREACH COULD NOT REACH A DOOR (Gfx5RFFpHJdKB87Bh, "I cant Breach
|
|
809
|
+
// East"). BypassCommand lists room.openableDevices(), which knows
|
|
810
|
+
// only Device objects, so a plain keyed Door matched nothing and
|
|
811
|
+
// got "there's nothing here to breach" -- in a room whose own map
|
|
812
|
+
// printed "East: ... (locked)" one line above. Reproduced in
|
|
813
|
+
// shipped scene1 before touching it. The muscle test is unchanged
|
|
814
|
+
// (re-pricing it against p.197's barrier table is a separate
|
|
815
|
+
// question); what changed is that it can find the door, and that
|
|
816
|
+
// the locked-move hint advertises the route it always had.
|
|
817
|
+
// - THE LAST HALF-METRE OF A CLIMB (6SmvvRRtn9eEMZSbF, fifth
|
|
818
|
+
// rejection: "this is basically on the ground, I don't know why we
|
|
819
|
+
// have to split hairs"). Unassisted UP buys 1m per two hits, so a
|
|
820
|
+
// bank is habitually 0.5m; descending it on a zero-hit roll gained
|
|
821
|
+
// nothing, re-banked the same figure, and narrated "about a body
|
|
822
|
+
// length down" for a distance of zero -- a state identical to the
|
|
823
|
+
// one before it. Under a metre now lands you, no test: p.134 has no
|
|
824
|
+
// row for it and p.172 charges nothing, so the roll was the
|
|
825
|
+
// invention. And no line claims a distance that was not travelled.
|
|
826
|
+
// - AN NPC MAY NOT SAY IT IS SOMEWHERE IT IS NOT (KwiMKNMN2e8KBqTY7).
|
|
827
|
+
// The brief's only location line was third-person, and the
|
|
828
|
+
// geography pin lists every room that EXISTS and demands exact
|
|
829
|
+
// names -- so a real alley was a blessed string for a pawnbroker at
|
|
830
|
+
// his own counter. A second-person pin, plus a PLACE rule in
|
|
831
|
+
// judgeNarration on BOTH channels (the claim was spoken, not
|
|
832
|
+
// narrated). Directions and "meet me at" stay legal; only
|
|
833
|
+
// present-tense self-location is refused.
|
|
834
|
+
// - BACKGROUND ACTORS STOP TAKING THE SCREEN (WQhacGNGuqoDzdPyM).
|
|
835
|
+
// `say` has been capped at one line per reply for ages; exposition
|
|
836
|
+
// never was, at any count or length. Capped to one beat, trimmed to
|
|
837
|
+
// whole sentences -- and an NPC walking through a room no longer
|
|
838
|
+
// wakes every NPC in it, which is where "3 large blocks of text"
|
|
839
|
+
// came from.
|
|
840
|
+
// - A SWEEP SUMMARY IS ADDRESSED TO ONE PERSON (LHNMHKX5A95yeycbB).
|
|
841
|
+
// take.ts logged "You sweep <room>" unscoped, so it reached every
|
|
842
|
+
// human in the room -- the other seat read a first-person line
|
|
843
|
+
// about someone else's haul, on top of the feed line it already
|
|
844
|
+
// had. Scoped to the sweeper; and the two performAction calls that
|
|
845
|
+
// passed a bare "take" now pass "takes", like every sibling verb.
|
|
846
|
+
export const ENGINE_VERSION = '1.67.0';
|
|
784
847
|
//# sourceMappingURL=engine-version.js.map
|
|
@@ -3165,7 +3165,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3165
3165
|
*/
|
|
3166
3166
|
connectHomeRoom(jobStartRoom) {
|
|
3167
3167
|
if (this._homeRoomExitDirection) {
|
|
3168
|
-
this._homeRoom.
|
|
3168
|
+
this._homeRoom.removeExit(this._homeRoomExitDirection);
|
|
3169
3169
|
}
|
|
3170
3170
|
// The OTHER half of the previous link: sweep any exit on the anchor
|
|
3171
3171
|
// room that already leads home before choosing a direction. Without
|
|
@@ -3178,7 +3178,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3178
3178
|
? ex.getOtherSide(jobStartRoom) === this._homeRoom
|
|
3179
3179
|
: ex === this._homeRoom.name;
|
|
3180
3180
|
if (leadsHome)
|
|
3181
|
-
jobStartRoom.
|
|
3181
|
+
jobStartRoom.removeExit(dir);
|
|
3182
3182
|
}
|
|
3183
3183
|
const direction = Game.HOME_DIRECTION_PRIORITY.find(d => !jobStartRoom.exits.has(d));
|
|
3184
3184
|
if (!direction) {
|
|
@@ -339,6 +339,38 @@ things you tell them afterwards.`;
|
|
|
339
339
|
* throws when a pattern stops matching, the line just prints, and the
|
|
340
340
|
* only detector is a player watching their fixer break character.
|
|
341
341
|
*/
|
|
342
|
+
/**
|
|
343
|
+
* The longest a background beat may run before it is cut back to its
|
|
344
|
+
* first sentence (WQhacGNGuqoDzdPyM). Sized off the player's own emote
|
|
345
|
+
* cap (commands/emote.ts MAX_LENGTH = 200) and given a little room,
|
|
346
|
+
* because an NPC beat carries a name where an emote does not.
|
|
347
|
+
*/
|
|
348
|
+
export const MAX_BEAT_LENGTH = 240;
|
|
349
|
+
/**
|
|
350
|
+
* Cuts an over-long NPC beat back to whole sentences.
|
|
351
|
+
*
|
|
352
|
+
* Keeps sentences while they fit, so a short two-sentence beat survives
|
|
353
|
+
* intact and only genuine paragraphs lose their tail. A single sentence
|
|
354
|
+
* longer than the cap is left ALONE rather than truncated: an ellipsis
|
|
355
|
+
* mid-clause reads as a bug to a player, where a slightly long line just
|
|
356
|
+
* reads as a long line. Exported and pure for the same reason
|
|
357
|
+
* isOutOfCharacter is -- the failure mode is silent.
|
|
358
|
+
*/
|
|
359
|
+
export function trimToBeat(line) {
|
|
360
|
+
const t = (line ?? '').trim();
|
|
361
|
+
if (t.length <= MAX_BEAT_LENGTH)
|
|
362
|
+
return t;
|
|
363
|
+
const sentences = t.match(/[^.!?]+[.!?]+(?:["'”’]?)\s*/g);
|
|
364
|
+
if (!sentences || sentences.length < 2)
|
|
365
|
+
return t;
|
|
366
|
+
let kept = '';
|
|
367
|
+
for (const s of sentences) {
|
|
368
|
+
if (kept.length > 0 && (kept + s).trim().length > MAX_BEAT_LENGTH)
|
|
369
|
+
break;
|
|
370
|
+
kept += s;
|
|
371
|
+
}
|
|
372
|
+
return kept.trim() || t;
|
|
373
|
+
}
|
|
342
374
|
export function isOutOfCharacter(line, npcName) {
|
|
343
375
|
const t = line.replace(/^[\s│|>*-]+/, '').trim();
|
|
344
376
|
if (t.length === 0)
|
|
@@ -1429,6 +1461,22 @@ description text.`;
|
|
|
1429
1461
|
// below the warmth bar quietly NOTES the arrival (their history
|
|
1430
1462
|
// keeps it for when you walk up) and never wakes. Unspotted rooms
|
|
1431
1463
|
// keep the old everyone-greets behavior (compat).
|
|
1464
|
+
// A PASSER-BY IS NOT AN ARRIVAL (WQhacGNGuqoDzdPyM: "there was an
|
|
1465
|
+
// NPC moving around that had 3 large blocks of text"). The whole
|
|
1466
|
+
// warmth ladder below is about who is worth hailing, and it was
|
|
1467
|
+
// reached only when a HUMAN walked in -- an NPC crossing the room
|
|
1468
|
+
// skipped it entirely, woke every non-bystander in earshot, and
|
|
1469
|
+
// each of them answered with a beat. One background actor walking
|
|
1470
|
+
// through a busy room was three blocks of prose about nothing.
|
|
1471
|
+
//
|
|
1472
|
+
// Their own crowd is not news to them: an NPC's arrival is noted
|
|
1473
|
+
// in history (the stimulus still lands) and answered by nobody.
|
|
1474
|
+
// A human's arrival is unchanged -- the ladder still runs, and
|
|
1475
|
+
// hostiles still get their challenge.
|
|
1476
|
+
if (/enter/i.test(verb) && actor instanceof NPC) {
|
|
1477
|
+
this.logger.write(`[DEBUG] ${this.name} notes ${actor.name} passing through -- an NPC arrival is not a greeting.`);
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1432
1480
|
let arrivalNote = '';
|
|
1433
1481
|
if (/enter/i.test(verb) && !(actor instanceof NPC)) {
|
|
1434
1482
|
const rel = this._scene?.ownerGame?.contacts?.get(this.name);
|
|
@@ -1902,7 +1950,29 @@ ${worldEventsSummary}
|
|
|
1902
1950
|
this.logger.error(`[ERROR][${this.name}] currentLocation is undefined in respondTo!`);
|
|
1903
1951
|
return;
|
|
1904
1952
|
}
|
|
1905
|
-
|
|
1953
|
+
// WHERE YOU ARE, SAID TO YOU (KwiMKNMN2e8KBqTY7: "Whisper told me to
|
|
1954
|
+
// go to Rain-Chewed Alley but Whisper is not in the rain chewed
|
|
1955
|
+
// ally, he says he is").
|
|
1956
|
+
//
|
|
1957
|
+
// This was the ONLY location statement in the brief and it is in the
|
|
1958
|
+
// third person -- "They are currently in" reads as the player's
|
|
1959
|
+
// whereabouts, not the speaker's. Nothing anywhere told an NPC where
|
|
1960
|
+
// IT was standing, and the geography pin below actively licensed the
|
|
1961
|
+
// mistake: it lists every room that exists and demands the names be
|
|
1962
|
+
// used exactly, so "Rain-Chewed Alley" was a valid, blessed string
|
|
1963
|
+
// for a pawnbroker who has never left his counter.
|
|
1964
|
+
//
|
|
1965
|
+
// Telling someone where to go is fine and often the point; claiming
|
|
1966
|
+
// to be there is the defect, so the pin forbids exactly that and
|
|
1967
|
+
// nothing more. judgeNarration's PLACE rule catches what the prompt
|
|
1968
|
+
// does not (utilities/narration-limits.ts) -- the prompt is the
|
|
1969
|
+
// request, the judge is the gate, and this file has learned twice
|
|
1970
|
+
// over that a request alone is not a gate.
|
|
1971
|
+
const roomDesc = [
|
|
1972
|
+
`YOU ARE IN: "${room.name}" — ${room.description}`,
|
|
1973
|
+
`You are in that room and nowhere else. You may tell someone to go somewhere, meet you somewhere, or where a place is -- but NEVER say or imply you are currently at any location other than "${room.name}".`,
|
|
1974
|
+
`They are currently in: "${room.name}" — ${room.description}`,
|
|
1975
|
+
].join('\n');
|
|
1906
1976
|
// "At" spots (utilities/spots.ts): WHERE everyone stands inside this
|
|
1907
1977
|
// room, so the AI greets an arrival at the door from across the room
|
|
1908
1978
|
// (or walks over) instead of assuming the newcomer is at its elbow.
|
|
@@ -2433,6 +2503,23 @@ Your objective is to drive the story line.`}
|
|
|
2433
2503
|
// command string, because "say X" and "say X, rephrased" are
|
|
2434
2504
|
// different strings and the same beat.
|
|
2435
2505
|
const said = commandText.replace(/^say\s+/i, '');
|
|
2506
|
+
// THE CLAIM WAS SPOKEN, NOT NARRATED (KwiMKNMN2e8KBqTY7:
|
|
2507
|
+
// "Whisper ... says he is"). The place judge has to sit on this
|
|
2508
|
+
// channel too, or it guards the one half of the reply the
|
|
2509
|
+
// reporter did not read. Only the PLACE rule applies here --
|
|
2510
|
+
// the agency rules are about narrating an act, and a line of
|
|
2511
|
+
// dialogue asserts nothing by being said. Refused speech is
|
|
2512
|
+
// dropped and corrected exactly like a refused beat; the NPC
|
|
2513
|
+
// gets its say back on the next stimulus.
|
|
2514
|
+
const placeVerdict = judgeNarration(said, this.name, [], {
|
|
2515
|
+
here: this.currentLocation?.name,
|
|
2516
|
+
worldRooms: Object.values(this._scene?.getRooms?.() ?? {}).map(r => r.name),
|
|
2517
|
+
});
|
|
2518
|
+
if (placeVerdict.refused) {
|
|
2519
|
+
this.logger.write(`${this.name} speech refused (claimed to be elsewhere): ${said.slice(0, 160)}`);
|
|
2520
|
+
if (placeVerdict.note)
|
|
2521
|
+
this._addToHistory(placeVerdict.note);
|
|
2522
|
+
}
|
|
2436
2523
|
// A COMPANION IS NEVER ECHO-GATED. Everything a bound shell says
|
|
2437
2524
|
// reaches its master over the link as a REPORT (the companion
|
|
2438
2525
|
// block in the brief: "work out loud... report what you find in
|
|
@@ -2443,11 +2530,15 @@ Your objective is to drive the story line.`}
|
|
|
2443
2530
|
const echo = this.allyOf
|
|
2444
2531
|
? { echo: false }
|
|
2445
2532
|
: judgeEcho(this.currentLocation, this.name, said);
|
|
2446
|
-
if (echo.echo) {
|
|
2447
|
-
|
|
2533
|
+
if (echo.echo || placeVerdict.refused) {
|
|
2534
|
+
if (echo.echo)
|
|
2535
|
+
this.logger.write(`${this.name} speech suppressed as an echo of ${echo.of}: ${said.slice(0, 120)}`);
|
|
2448
2536
|
// Counted as having spoken. The model made its one speech act
|
|
2449
2537
|
// for this stimulus; that it came out as a line the room had
|
|
2450
|
-
// just heard
|
|
2538
|
+
// just heard -- or a line placing the speaker in a room they
|
|
2539
|
+
// are not in -- does not buy it another go. The rest of the
|
|
2540
|
+
// reply still runs: a swallowed sentence must not cost this
|
|
2541
|
+
// NPC the commands it issued in the same breath.
|
|
2451
2542
|
this._spokeThisStimulus = true;
|
|
2452
2543
|
}
|
|
2453
2544
|
else {
|
|
@@ -2507,7 +2598,40 @@ Your objective is to drive the story line.`}
|
|
|
2507
2598
|
const bystanders = (this.currentLocation
|
|
2508
2599
|
? this._scene?.getActorsInRoom?.(this.currentLocation) ?? []
|
|
2509
2600
|
: []).map(a => a.name).filter(n => n && n !== this.name);
|
|
2510
|
-
|
|
2601
|
+
// ONE BEAT PER STIMULUS, THE WAY SPEECH ALREADY WORKS
|
|
2602
|
+
// (WQhacGNGuqoDzdPyM: "the pros for NPCs is rather verbose" ->
|
|
2603
|
+
// "there was an NPC moving around that had 3 large blocks of
|
|
2604
|
+
// text... none of which was really relevant to our run. I do want
|
|
2605
|
+
// there to be background actors, but it shouldn't take up the
|
|
2606
|
+
// player's game screen that much").
|
|
2607
|
+
//
|
|
2608
|
+
// `say` has been capped at one line per reply for as long as the
|
|
2609
|
+
// gate above has existed, and exposition never was: every
|
|
2610
|
+
// non-command line the model produced printed, at whatever length
|
|
2611
|
+
// it produced them. Same stimulus, same model, two different
|
|
2612
|
+
// policies -- and the uncapped half is the one that fills a
|
|
2613
|
+
// screen. The prompt has asked for "bare minimum" exposition all
|
|
2614
|
+
// along and is plainly not obeyed, which is the argument for a
|
|
2615
|
+
// limit in code rather than a firmer request.
|
|
2616
|
+
//
|
|
2617
|
+
// TRIMMED AT A SENTENCE, not mid-word: a background actor gets a
|
|
2618
|
+
// beat, not a paragraph, and a beat that runs long ends where its
|
|
2619
|
+
// first sentence does rather than in an ellipsis. The player's own
|
|
2620
|
+
// emote has carried a hard cap (commands/emote.ts MAX_LENGTH) since
|
|
2621
|
+
// long before this; NPCs simply never had the matching one.
|
|
2622
|
+
// ONE PRINTED, not one CONSIDERED. Slicing to the first line
|
|
2623
|
+
// before the gates would let a single out-of-character or refused
|
|
2624
|
+
// opener silence a perfectly good beat behind it -- the cap is
|
|
2625
|
+
// about how much reaches the screen, and a line that never prints
|
|
2626
|
+
// has not spent it. So the loop still walks every candidate and
|
|
2627
|
+
// stops the moment one lands.
|
|
2628
|
+
if (expositionLines.length > 1) {
|
|
2629
|
+
this.logger.write(`${this.name} produced ${expositionLines.length} exposition lines; printing at most one.`);
|
|
2630
|
+
}
|
|
2631
|
+
let beatPrinted = false;
|
|
2632
|
+
for (const exposition of expositionLines.map(trimToBeat)) {
|
|
2633
|
+
if (beatPrinted)
|
|
2634
|
+
break;
|
|
2511
2635
|
if (!exposition?.length)
|
|
2512
2636
|
continue;
|
|
2513
2637
|
if (isOutOfCharacter(exposition, this.name)) {
|
|
@@ -2520,7 +2644,16 @@ Your objective is to drive the story line.`}
|
|
|
2520
2644
|
// defence, no Combat Turn, no consequence. Suppressed, and the
|
|
2521
2645
|
// NPC is told why so the next beat can commit to it as a real
|
|
2522
2646
|
// command instead.
|
|
2523
|
-
|
|
2647
|
+
// AND WHERE THIS NPC IS (KwiMKNMN2e8KBqTY7): the same judge, one
|
|
2648
|
+
// more rule. The geography pin in the brief says which rooms
|
|
2649
|
+
// EXIST; it never said the speaker was in one of them, so a
|
|
2650
|
+
// pawnbroker at his own counter could name a real alley and
|
|
2651
|
+
// claim to be standing in it. The player walked there.
|
|
2652
|
+
const verdict = judgeNarration(exposition, this.name, bystanders, {
|
|
2653
|
+
holding: this.grappling,
|
|
2654
|
+
here: this.currentLocation?.name,
|
|
2655
|
+
worldRooms: Object.values(this._scene?.getRooms?.() ?? {}).map(r => r.name),
|
|
2656
|
+
});
|
|
2524
2657
|
if (verdict.refused) {
|
|
2525
2658
|
this.logger.write(`${this.name} narration refused (agency over ${verdict.subject}): ${exposition.slice(0, 160)}`);
|
|
2526
2659
|
if (verdict.note)
|
|
@@ -2549,6 +2682,7 @@ Your objective is to drive the story line.`}
|
|
|
2549
2682
|
continue;
|
|
2550
2683
|
}
|
|
2551
2684
|
this.logger.logWithColor(`${prefix}${exposition}`, inCall ? CALL_COLOR : 'blue', expositionScope);
|
|
2685
|
+
beatPrinted = true;
|
|
2552
2686
|
// DID, therefore remembered -- and only now, at the far end of
|
|
2553
2687
|
// the OOC filter, the prose gate, the withheld checks and the
|
|
2554
2688
|
// echo gate. A line that never printed is not a thing this NPC
|
|
@@ -11,7 +11,7 @@ import { AI } from '../../../../tools/ai/ai.class.js';
|
|
|
11
11
|
import { AbstractRoom } from '../types/shared/abstracts.js';
|
|
12
12
|
import { spotsActive, actorDistanceMeters, voiceBandFor, voiceTag, doorwayKindOf, exitDoorwayKind } from '../utilities/spots.js';
|
|
13
13
|
import { canHearSpeech, canHearShout } from '../utilities/earshot.js';
|
|
14
|
-
import { synthesizeGrid, } from '../utilities/room-grid.js';
|
|
14
|
+
import { synthesizeGrid, attachExitCell, detachExitCell, } from '../utilities/room-grid.js';
|
|
15
15
|
export class Room extends AbstractRoom {
|
|
16
16
|
logger = Logger.getInstance();
|
|
17
17
|
actors = new Set();
|
|
@@ -871,15 +871,33 @@ Keep it concise and dramatic.`;
|
|
|
871
871
|
// Doors, which findExistingDoor never collapses.
|
|
872
872
|
for (const [dir, d] of [...this.exits.entries()]) {
|
|
873
873
|
if (d === door && dir !== direction)
|
|
874
|
-
this.
|
|
874
|
+
this.removeExit(dir);
|
|
875
875
|
}
|
|
876
876
|
const back = this.getOppositeDirection(direction);
|
|
877
877
|
for (const [dir, d] of [...targetRoom.exits.entries()]) {
|
|
878
878
|
if (d === door && dir !== back)
|
|
879
|
-
targetRoom.
|
|
879
|
+
targetRoom.removeExit(dir);
|
|
880
880
|
}
|
|
881
881
|
this.exits.set(direction, door);
|
|
882
882
|
targetRoom.exits.set(back, door);
|
|
883
|
+
// A DOOR THE MAP CAN SEE (BDFAZgcjZrtbXRNdn). ensureGrid() caches
|
|
884
|
+
// for the life of the room, so an exit wired after the first
|
|
885
|
+
// synthesis -- which is every runtime exit, the hideout door above
|
|
886
|
+
// all -- existed only in `exits` and never got a cell. Both clients
|
|
887
|
+
// draw doorways from exitCells, so it was invisible on both while
|
|
888
|
+
// "go down" worked. Patch each side's grid if one has been built;
|
|
889
|
+
// rooms not yet rendered pick the exit up from synthesis as usual.
|
|
890
|
+
if (this._grid)
|
|
891
|
+
attachExitCell(this._grid, this.name, direction);
|
|
892
|
+
if (targetRoom._grid)
|
|
893
|
+
attachExitCell(targetRoom._grid, targetRoom.name, back);
|
|
894
|
+
}
|
|
895
|
+
/** Unwires an exit AND its doorway cell. Use this rather than a bare
|
|
896
|
+
* `exits.delete` -- see addExit's note on the memoized grid. */
|
|
897
|
+
removeExit(direction) {
|
|
898
|
+
this.exits.delete(direction);
|
|
899
|
+
if (this._grid)
|
|
900
|
+
detachExitCell(this._grid, direction);
|
|
883
901
|
}
|
|
884
902
|
attemptUnlockExit(direction, itemOrSolution) {
|
|
885
903
|
const exit = this.exits.get(direction);
|
|
@@ -95,6 +95,67 @@ const COMPELLED_MOVEMENT = new RegExp([
|
|
|
95
95
|
String.raw `\b(?:drag\w*|haul\w*|pull\w*|tug\w*|yank\w*|tows?|towed|towing|carr(?:y|ies|ied|ying)|lift\w*)\b`,
|
|
96
96
|
String.raw `\b(?:walk\w*|mov\w+|lead\w*|tak\w+)\s+\w+\s+(?:toward|towards|to|out|off|away|through|into|back)\b`,
|
|
97
97
|
].join('|'), 'i');
|
|
98
|
+
/**
|
|
99
|
+
* CLAIMING TO BE SOMEWHERE ELSE (KwiMKNMN2e8KBqTY7: "Whisper told me to
|
|
100
|
+
* go to Rain-Chewed Alley but Whisper is not in the rain chewed ally,
|
|
101
|
+
* but he says he is").
|
|
102
|
+
*
|
|
103
|
+
* PRESENT-TENSE SELF-LOCATION ONLY, and the narrowness is the whole
|
|
104
|
+
* design. "Meet me at the Undermarket" is legitimate -- he can walk
|
|
105
|
+
* there. "Go to Rain-Chewed Alley" is legitimate and often the point --
|
|
106
|
+
* directions are what a fixer is for. "I'm at Rain-Chewed Alley", said
|
|
107
|
+
* by a pawnbroker standing behind his own counter, is the only shape
|
|
108
|
+
* that is false on its face, so it is the only shape this matches.
|
|
109
|
+
*
|
|
110
|
+
* The capture group is the place, which the caller compares against the
|
|
111
|
+
* speaker's real room: a pattern cannot know which rooms exist, and one
|
|
112
|
+
* that refused every "I'm in the back" would gag ordinary fiction about
|
|
113
|
+
* the room the NPC is actually standing in.
|
|
114
|
+
*/
|
|
115
|
+
const SELF_LOCATION = new RegExp([
|
|
116
|
+
// "I'm at/in/over at X", "I am down at X", and the contracted forms.
|
|
117
|
+
String.raw `\bI(?:'|’)?m\s+(?:currently\s+|right\s+|over\s+|down\s+|up\s+|back\s+|still\s+|here\s+)*(?:at|in|inside|outside)\s+(?:the\s+)?([^.,;!?"'’]{2,48})`,
|
|
118
|
+
String.raw `\bI\s+am\s+(?:currently\s+|right\s+|over\s+|down\s+|up\s+|back\s+|still\s+|here\s+)*(?:at|in|inside|outside)\s+(?:the\s+)?([^.,;!?"'’]{2,48})`,
|
|
119
|
+
// "you'll find me at X", "find me over at X", "catch me at X".
|
|
120
|
+
String.raw `\b(?:find|catch|reach)\s+me\s+(?:over\s+|down\s+|up\s+)*(?:at|in|inside|outside)\s+(?:the\s+)?([^.,;!?"'’]{2,48})`,
|
|
121
|
+
// "I'm standing/sitting/waiting in X".
|
|
122
|
+
String.raw `\bI(?:'|’)?m\s+(?:stand|sitt|wait|post|hang|hold)\w*\s+(?:up\s+|out\s+|around\s+)*(?:at|in|by|outside)\s+(?:the\s+)?([^.,;!?"'’]{2,48})`,
|
|
123
|
+
].join('|'), 'i');
|
|
124
|
+
/** Every place a self-location claim names in one line. */
|
|
125
|
+
function placesClaimed(line) {
|
|
126
|
+
const out = [];
|
|
127
|
+
const re = new RegExp(SELF_LOCATION.source, 'gi');
|
|
128
|
+
for (const m of line.matchAll(re)) {
|
|
129
|
+
const place = m.slice(1).find(g => typeof g === 'string' && g.trim().length > 0);
|
|
130
|
+
if (place)
|
|
131
|
+
out.push(place.trim());
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Does a claimed place name a room that is NOT where the speaker stands?
|
|
137
|
+
*
|
|
138
|
+
* Compared against the world's real room names rather than judged from
|
|
139
|
+
* the prose, so "I'm in the back", "I'm in trouble" and "I'm in a hurry"
|
|
140
|
+
* -- none of which is a room -- all pass untouched. Matching is
|
|
141
|
+
* whole-name and case-insensitive in either direction, because a beat
|
|
142
|
+
* writes "I'm down at the Flooded Undermarket" for the room recorded as
|
|
143
|
+
* "Flooded Undermarket".
|
|
144
|
+
*/
|
|
145
|
+
function elsewhereClaimed(line, here, worldRooms) {
|
|
146
|
+
const norm = (s) => s.toLowerCase().replace(/^the\s+/, '').trim();
|
|
147
|
+
const mine = norm(here);
|
|
148
|
+
for (const claim of placesClaimed(line)) {
|
|
149
|
+
const c = norm(claim);
|
|
150
|
+
const room = worldRooms.find(r => {
|
|
151
|
+
const n = norm(r);
|
|
152
|
+
return n !== mine && (c === n || c.includes(n) || n.includes(c));
|
|
153
|
+
});
|
|
154
|
+
if (room)
|
|
155
|
+
return room;
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
98
159
|
const ALLOWED = { refused: false };
|
|
99
160
|
/**
|
|
100
161
|
* THE WORDS OF A NAME THAT CAN STAND FOR IT.
|
|
@@ -195,6 +256,18 @@ export function judgeNarration(line, speaker, others, opts = {}) {
|
|
|
195
256
|
const t = line.replace(/^[\s│|>*-]+/, '').trim();
|
|
196
257
|
if (t.length === 0)
|
|
197
258
|
return ALLOWED;
|
|
259
|
+
// BEFORE the agency rules, because it is about the SPEAKER's own body
|
|
260
|
+
// rather than anyone else's, and needs no subject resolution.
|
|
261
|
+
if (opts.here && opts.worldRooms?.length) {
|
|
262
|
+
const claimed = elsewhereClaimed(t, opts.here, opts.worldRooms);
|
|
263
|
+
if (claimed) {
|
|
264
|
+
return {
|
|
265
|
+
refused: true,
|
|
266
|
+
subject: speaker,
|
|
267
|
+
note: `YOU ARE IN ${opts.here.toUpperCase()}, NOT ${claimed.toUpperCase()}. Your last beat said you were at ${claimed}, so it did not happen and the player was not shown it -- and a player who believes you are somewhere you are not will go there and find nobody. Tell them where to go, offer to meet them somewhere, say where a place is: all fine. Just never say you are anywhere but ${opts.here}. If you want to be at ${claimed}, walk there first ("[COMMAND] go <direction>").`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
198
271
|
const contactAt = t.match(CONTACT);
|
|
199
272
|
const movedAt = t.match(COMPELLED_MOVEMENT);
|
|
200
273
|
if (!contactAt && !movedAt)
|
|
@@ -823,6 +823,82 @@ export function synthesizeGrid(input) {
|
|
|
823
823
|
// the module doc); Room.ensureGrid queries this cell by that same
|
|
824
824
|
// string. A rename on either side must update both.
|
|
825
825
|
export const OPEN_FLOOR_NAME = 'open floor';
|
|
826
|
+
/**
|
|
827
|
+
* Cuts a doorway into a grid that has ALREADY been synthesized, for an
|
|
828
|
+
* exit the room did not have when it was.
|
|
829
|
+
*
|
|
830
|
+
* THE MISSING "D" ON NEON STRIP (BDFAZgcjZrtbXRNdn: "the D down door is
|
|
831
|
+
* gone, I can still go down but I can't see it"). ensureGrid() memoizes
|
|
832
|
+
* for the life of the room and reads this.exits once; Room.addExit()
|
|
833
|
+
* mutated the exit map and left the grid alone. The hideout door is not
|
|
834
|
+
* in any seed -- Game.connectHomeRoom() wires it at runtime, DOWN first
|
|
835
|
+
* -- and it is wired AFTER the player is seated in the start room, which
|
|
836
|
+
* is itself what forces the first synthesis. So the grid was built one
|
|
837
|
+
* exit short and stayed that way. `go down` kept working because
|
|
838
|
+
* GoCommand reads room.exits; every map reads exitCells, on both
|
|
839
|
+
* clients (the CLI draws from it directly, the browser from
|
|
840
|
+
* serializeRoomGrid below).
|
|
841
|
+
*
|
|
842
|
+
* PATCHES RATHER THAN INVALIDATES, deliberately. Re-synthesizing would
|
|
843
|
+
* be one line, but synthesis re-seats spots, footprints and terrain from
|
|
844
|
+
* the RNG -- so a door appearing mid-session would slide the furniture
|
|
845
|
+
* out from under everyone standing on it. Cutting one cell cannot move
|
|
846
|
+
* anything that already exists.
|
|
847
|
+
*
|
|
848
|
+
* Occupancy is read back off the grid (footprints, existing doorways)
|
|
849
|
+
* instead of the closure synthesis used, so a late doorway still avoids
|
|
850
|
+
* the bar and the other exits. Deterministic per room+direction.
|
|
851
|
+
*/
|
|
852
|
+
export function attachExitCell(grid, roomName, direction) {
|
|
853
|
+
if (grid.exitCells.has(direction))
|
|
854
|
+
return;
|
|
855
|
+
const occupied = new Set();
|
|
856
|
+
for (const cells of grid.spotFootprints.values()) {
|
|
857
|
+
for (const c of cells)
|
|
858
|
+
occupied.add(key(c));
|
|
859
|
+
}
|
|
860
|
+
for (const c of grid.exitCells.values())
|
|
861
|
+
occupied.add(key(c));
|
|
862
|
+
const levelCells = LEVEL_CELLS.get(grid) ?? new Map();
|
|
863
|
+
const exists = (c) => cellExists(grid, c, levelCells);
|
|
864
|
+
const dims = grid.dims;
|
|
865
|
+
const rng = mulberry32(hashFnv1a(`${roomName}:${String(direction)}`));
|
|
866
|
+
const wallSide = { north: 'north', south: 'south', east: 'east', west: 'west' }[String(direction)];
|
|
867
|
+
let cell;
|
|
868
|
+
if (wallSide) {
|
|
869
|
+
const mid = {
|
|
870
|
+
north: () => ({ x: clamp(Math.floor(dims.x / 2) + jitter(rng), 0, dims.x - 1), y: 0, z: 0 }),
|
|
871
|
+
south: () => ({ x: clamp(Math.floor(dims.x / 2) + jitter(rng), 0, dims.x - 1), y: dims.y - 1, z: 0 }),
|
|
872
|
+
west: () => ({ x: 0, y: clamp(Math.floor(dims.y / 2) + jitter(rng), 0, dims.y - 1), z: 0 }),
|
|
873
|
+
east: () => ({ x: dims.x - 1, y: clamp(Math.floor(dims.y / 2) + jitter(rng), 0, dims.y - 1), z: 0 }),
|
|
874
|
+
};
|
|
875
|
+
cell = nearestFreeOnWall(mid[wallSide](), wallSide, occupied, dims, exists);
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
// up/down: no wall to sit on -- an open z=0 cell near the centroid,
|
|
879
|
+
// the same region synthesis uses for a floor hatch.
|
|
880
|
+
const region = WALL_REGIONS.center(dims);
|
|
881
|
+
const candidates = [];
|
|
882
|
+
for (let x = region.x0; x <= region.x1; x++) {
|
|
883
|
+
for (let y = region.y0; y <= region.y1; y++) {
|
|
884
|
+
const c = { x, y, z: 0 };
|
|
885
|
+
if (exists(c) && !occupied.has(key(c)))
|
|
886
|
+
candidates.push(c);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
cell = candidates.length > 0
|
|
890
|
+
? candidates[Math.floor(rng() * candidates.length)]
|
|
891
|
+
: nearestFree({ x: Math.floor(dims.x / 2), y: Math.floor(dims.y / 2), z: 0 }, occupied, dims, exists);
|
|
892
|
+
}
|
|
893
|
+
grid.exitCells.set(direction, cell);
|
|
894
|
+
}
|
|
895
|
+
/** The other half of attachExitCell: an exit that has been unwired must
|
|
896
|
+
* stop being drawn. Without this a home door that moves direction
|
|
897
|
+
* between jobs (Game.connectHomeRoom picks the first free one) leaves
|
|
898
|
+
* its old doorway on the map -- the ghost twin of the missing "D". */
|
|
899
|
+
export function detachExitCell(grid, direction) {
|
|
900
|
+
grid.exitCells.delete(direction);
|
|
901
|
+
}
|
|
826
902
|
/** Flattens a live IRoomGrid's Maps into ISerializedRoomGrid. Spots ship
|
|
827
903
|
* UNFILTERED (the caller resolves actor positions against the full
|
|
828
904
|
* list, open floor included -- see room-view.ts's seatingIn); only the
|
|
@@ -1281,10 +1281,19 @@ export function lockedRouteHint(room, direction, door) {
|
|
|
1281
1281
|
// naming a route that exists is not inventing one, and the silence
|
|
1282
1282
|
// read as a soft-lock in play. Scene-factory step 4.95 guarantees the
|
|
1283
1283
|
// named key is a real item somewhere in the scene.
|
|
1284
|
+
//
|
|
1285
|
+
// AND THE ROUTE THAT NEEDS NO KEY (Gfx5RFFpHJdKB87Bh). The key was
|
|
1286
|
+
// named as if it were the only way through, so a runner with muscle
|
|
1287
|
+
// and no datachip read a locked door as a wall. It never was: breach
|
|
1288
|
+
// works a door as well as a maglock -- it just could not REACH one
|
|
1289
|
+
// until this item, which is exactly why the hint had nothing to
|
|
1290
|
+
// offer. Hint and verb have to agree; that they did not is half of
|
|
1291
|
+
// what the report is.
|
|
1292
|
+
const forceHint = ` Or put a shoulder through it -- "breach ${String(direction).toLowerCase()}", and the whole floor hears you.`;
|
|
1284
1293
|
if (door.unlockItemOrSolution) {
|
|
1285
|
-
return ` It takes ${door.unlockItemOrSolution} -- "unlock ${String(direction).toLowerCase()}" with it on you
|
|
1294
|
+
return ` It takes ${door.unlockItemOrSolution} -- "unlock ${String(direction).toLowerCase()}" with it on you.${forceHint}`;
|
|
1286
1295
|
}
|
|
1287
|
-
return
|
|
1296
|
+
return forceHint;
|
|
1288
1297
|
}
|
|
1289
1298
|
/**
|
|
1290
1299
|
* WHAT KIND OF WAY THROUGH a spot's name describes, or undefined when
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.206.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
|
|
6
6
|
"description": "A command line tool for scaffolding Meteor 3.x applications using React.",
|