@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.204.0",
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
- actor.climbProgress = wall.progressFor(reached);
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
- ? `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'}.)`)}`,
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
- actor.climbProgress = wall.progressFor(reached);
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: `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.)`)}`,
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 a
8
- * Residence-type room (your Hideout qualifies) or a room with no one else
9
- * in it -- nobody naps in front of an audience in the sprawl. Each rest
10
- * rolls Body and recovers hits + 1 stun; repeat as needed. A night in a
11
- * bed that's yours is also the day of NATURAL RECOVERY for the physical
12
- * track (SR5 p.207, built 2026-09-12 for vFMhSGwLhsfpiY7bs): Body x 2,
13
- * each hit a box, only once the stun is gone. You can't rest
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
- if (actor.isIncapacitated()) {
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
- const isResidence = room.roomType?.toLowerCase() === 'residence';
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
- const roll = rollPool(Math.max(1, actor.body + tierDice + healerDice + medicineDice));
94
- let recovery = roll.hits + 1;
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 = Math.max(1, Math.ceil(recovery / 2));
150
+ recovery = halve(recovery);
99
151
  if (inDebtAtHome)
100
- recovery = Math.max(1, Math.ceil(recovery / 2));
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
- this.actor.performAction('take', taken.length > 0 ? taken.join(', ') : 'everything in the room');
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
- this.logger.log(lines.join('\n'));
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('take', item.owner && item.owner !== this.actor.name ? `${item.name} (${item.owner}'s)` : item.name);
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
- export const ENGINE_VERSION = '1.66.0';
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.exits.delete(this._homeRoomExitDirection);
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.exits.delete(dir);
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
- const roomDesc = `They are currently in: "${room.name}" ${room.description}`;
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
- this.logger.write(`${this.name} speech suppressed as an echo of ${echo.of}: ${said.slice(0, 120)}`);
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 does not buy it another go.
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
- for (const exposition of expositionLines) {
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
- const verdict = judgeNarration(exposition, this.name, bystanders, { holding: this.grappling });
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.exits.delete(dir);
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.exits.delete(dir);
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.204.0",
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.",