@maka/maka-cli 5.196.0 → 5.198.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.
Files changed (41) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js +6 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js +6 -6
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js +6 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +8 -2
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +3 -3
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js +5 -5
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +62 -13
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/hide.js +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js +2 -2
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js +6 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/install.js +6 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +5 -5
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +1 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/mark.js +1 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/quality-trade.js +6 -1
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/register.js +6 -1
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +19 -3
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +2 -2
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/sell.js +6 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/shout.js +69 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/snoop.js +1 -1
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/sprites.js +1 -1
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +2 -2
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +7 -7
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/thread.js +1 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +45 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/game.js +13 -5
  30. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +80 -57
  31. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +24 -0
  32. package/bundle/typescript/src/commands/game/sideQuest/utilities/action-cost.js +36 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/utilities/earshot.js +116 -10
  35. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +83 -6
  36. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +14 -14
  37. package/bundle/typescript/src/commands/game/sideQuest/utilities/matrix-style.js +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/utilities/overwatch.js +2 -2
  39. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +1 -1
  40. package/bundle/typescript/src/commands/game/sideQuest/utilities/shared-run.js +8 -0
  41. package/package.json +1 -1
@@ -0,0 +1,69 @@
1
+ import { Command } from './command.js';
2
+ import { NPC } from '../models/npc.js';
3
+ import { hint } from '../utilities/hints.js';
4
+ import { SHOUT_METERS } from '../utilities/earshot.js';
5
+ import { speakOnTheGrid } from '../utilities/matrix-roster.js';
6
+ /**
7
+ * SHOUTING IS A VERB NOW (en3p6eCpiRuqsTSKm: "add 'shout' can be heard
8
+ * by anyone within a reasonable distance, 50m?").
9
+ *
10
+ * It used to be something that HAPPENED TO YOU. Room.say escalated to a
11
+ * room-wide shout whenever nobody shared your spot, so a runner standing
12
+ * on their own could not say anything quietly, and a runner at a crowded
13
+ * table could not deliberately call across the room. Both halves of the
14
+ * volume decision belonged to the furniture rather than to the player.
15
+ *
16
+ * Now `say` carries to whoever is close enough to hear it (earshot.ts)
17
+ * and this is how you reach everyone else. The range is this engine's
18
+ * number, not the book's -- see SHOUT_METERS -- and in practice it is
19
+ * room-wide, because the largest room the generator builds is tens of
20
+ * metres across.
21
+ *
22
+ * WHAT IT COSTS, and why that is not nothing: every hostile in the room
23
+ * hears you, and shouting is not sneaking. `sneaking` is dropped here
24
+ * the same way `say` drops it -- talking on comms is not a quiet
25
+ * activity, and bellowing across a warehouse is less so.
26
+ */
27
+ export class ShoutCommand extends Command {
28
+ static verb = 'shout';
29
+ static description = `Raise your voice so the whole place hears it (~${SHOUT_METERS}m), instead of just whoever is standing close. "say" is ordinary speech and only carries as far as a listener's ears reach -- roughly 5m, further for someone with the chrome for it. Shouting reaches everyone in the room, hostiles included, and drops any sneaking.`;
30
+ context;
31
+ constructor(context) {
32
+ super(context);
33
+ this.context = context;
34
+ }
35
+ async execute(args) {
36
+ if (!args || args.length === 0) {
37
+ return `What do you want to shout?${hint(` ("shout <message>" -- "say" keeps it between you and whoever is close.)`)}`;
38
+ }
39
+ const message = args.join(' ');
40
+ const actor = this.actor;
41
+ // THE PLANES, worded as say.ts words them -- one mechanic, one set
42
+ // of sentences. An astral voice never reaches meat ears and a
43
+ // persona's never leaves the grid, and volume changes neither: a
44
+ // louder voice on the wrong plane is still no voice at all.
45
+ if (actor.plane === 'astral') {
46
+ return `You can shout all you like -- the meat world hears nothing. Spirits and projections do.`;
47
+ }
48
+ // Talking is not sneaking (sneak.ts); shouting least of all.
49
+ actor.sneaking = false;
50
+ if (actor.plane === 'matrix') {
51
+ // ON THE GRID THERE IS NO DISTANCE (grid-reach.ts): personas hear
52
+ // each other or they do not, and no amount of volume crosses a
53
+ // host wall. So this routes to the same fan-out `say` uses rather
54
+ // than inventing a louder one.
55
+ const heard = speakOnTheGrid(this.scene, actor, message).map(a => a.name);
56
+ return heard.length > 0
57
+ ? `You push it out across the grid: "${message}"\n Heard by ${heard.join(', ')}.`
58
+ : `You push it out across the grid -- nothing out here is listening.`;
59
+ }
60
+ const heard = actor.currentLocation.shout(actor, message);
61
+ if (!(this.actor instanceof NPC)) {
62
+ this.actor.performAction('shouts', actor.currentLocation.name, { quiet: true });
63
+ }
64
+ return heard.length > 0
65
+ ? `You raise your voice: "${message}"`
66
+ : `You raise your voice: "${message}"\n Nothing answers -- there's nobody here to hear it.`;
67
+ }
68
+ }
69
+ //# sourceMappingURL=shout.js.map
@@ -49,7 +49,7 @@ export class SnoopCommand extends Command {
49
49
  this.logger.write(`Snoop: ${actor.name} watching ${target.name} via camera feed.`);
50
50
  const lines = [`CAMERA FEED: ${target.name.toUpperCase()}`];
51
51
  if (!camerasLive(allRooms, target)) {
52
- lines.push(`{lightblue-fg}(Security sees a loop of empty hallways -- these eyes answer only to you now.){/lightblue-fg}`);
52
+ lines.push(`{light-blue-fg}(Security sees a loop of empty hallways -- these eyes answer only to you now.){/light-blue-fg}`);
53
53
  }
54
54
  const seen = this.scene.getActorsInRoom(target)
55
55
  .filter(a => a !== actor && a.plane === 'meat' && !a.sneaking);
@@ -73,7 +73,7 @@ export class SpritesCommand extends Command {
73
73
  const name = type?.name ?? set.spriteKey;
74
74
  const isMine = mineType === set.spriteKey;
75
75
  const powers = powersForSprite(set.spriteKey);
76
- lines.push(`${name.toUpperCase()} SPRITE${isMine ? ' {lightblue-fg}(yours){/lightblue-fg}' : ''}${type ? ` -- ${type.blurb}` : ''}`);
76
+ lines.push(`${name.toUpperCase()} SPRITE${isMine ? ' {light-blue-fg}(yours){/light-blue-fg}' : ''}${type ? ` -- ${type.blurb}` : ''}`);
77
77
  lines.push(` Skills: ${skillsForSprite(set.spriteKey).join(', ')}${hint(` (${set.page})`)}`);
78
78
  if (!detailed) {
79
79
  // The names alone, which is the comparison view.
@@ -79,8 +79,8 @@ export function resolveSpokenTarget(parsedArgs, names) {
79
79
  return undefined;
80
80
  }
81
81
  export class TalkCommand extends Command {
82
- static verb = "talk-to";
83
- static description = "Speak privately to a person in the room.";
82
+ static verb = "tell";
83
+ static description = "Speak privately to ONE person standing with you -- only they hear it, however loud the room is. \"tell <name> <message>\". (\"talk-to\" is the old spelling and still works.) Compare \"say\", which carries to whoever is close enough to hear, and \"shout\", which reaches the whole place.";
84
84
  context;
85
85
  constructor(context) {
86
86
  super(context);
@@ -164,9 +164,9 @@ export class TapCommand extends Command {
164
164
  }
165
165
  this.logger.write(`${actor.name} now holds ${now} mark(s) on ${host.name} via the ${room.name} cameras.`);
166
166
  const lines = [
167
- `{lightblue-fg}The filament finds the connector and the camera stops being a camera -- it's a device, and it's talking to you.{/lightblue-fg}`,
168
- ` {lightblue-fg}It never gets to hide behind ${hostLabel(host)}: a cable doesn't ask the network for permission, so the lens has to defend itself with what a lens has. It doesn't have much.{/lightblue-fg}`,
169
- ` {lightblue-fg}MARK PLACED -- and it lands on the host too. The camera answers to ${host.name}, and anything ${host.name} trusts to speak for it just handed you a key. [${now}/${MAX_MARKS}]{/lightblue-fg}`,
167
+ `{light-blue-fg}The filament finds the connector and the camera stops being a camera -- it's a device, and it's talking to you.{/light-blue-fg}`,
168
+ ` {light-blue-fg}It never gets to hide behind ${hostLabel(host)}: a cable doesn't ask the network for permission, so the lens has to defend itself with what a lens has. It doesn't have much.{/light-blue-fg}`,
169
+ ` {light-blue-fg}MARK PLACED -- and it lands on the host too. The camera answers to ${host.name}, and anything ${host.name} trusts to speak for it just handed you a key. [${now}/${MAX_MARKS}]{/light-blue-fg}`,
170
170
  ];
171
171
  if (mode === 'attack') {
172
172
  lines.push(` The housing is visibly forced -- the host FELT that one, even if nothing saw your face.`);
@@ -175,15 +175,15 @@ export class TapCommand extends Command {
175
175
  lines.push(` Nothing logged, nothing flagged. You were never here.`);
176
176
  }
177
177
  if (host === room) {
178
- lines.push(` {lightblue-fg}${now >= MAX_MARKS
178
+ lines.push(` {light-blue-fg}${now >= MAX_MARKS
179
179
  ? `The host thinks you belong now -- "hack" walks straight in.`
180
- : `${hostLabel(host, { capital: true })} cracks easier with every key you hold${hint(` -- "jack in" and "hack", or come back for another lens`)}.`}{/lightblue-fg}`);
180
+ : `${hostLabel(host, { capital: true })} cracks easier with every key you hold${hint(` -- "jack in" and "hack", or come back for another lens`)}.`}{/light-blue-fg}`);
181
181
  }
182
182
  else {
183
- lines.push(` {lightblue-fg}These lenses ride the site network, so the key fits ${hostLabel(host)}${hint(` -- "jack in", "go ${host.name}", "hack"`)}.{/lightblue-fg}`);
183
+ lines.push(` {light-blue-fg}These lenses ride the site network, so the key fits ${hostLabel(host)}${hint(` -- "jack in", "go ${host.name}", "hack"`)}.{/light-blue-fg}`);
184
184
  }
185
185
  // A mark is all canSnoopFeeds ever wanted (surveillance.ts).
186
- lines.push(` {lightblue-fg}And the eyes are half yours already${hint(` -- jacked in, "snoop <room>" rides any feed on this network`)}.{/lightblue-fg}`);
186
+ lines.push(` {light-blue-fg}And the eyes are half yours already${hint(` -- jacked in, "snoop <room>" rides any feed on this network`)}.{/light-blue-fg}`);
187
187
  lines.push(...godLines);
188
188
  this.scene.updateStatus();
189
189
  return lines.join('\n');
@@ -67,7 +67,7 @@ export class ThreadCommand extends Command {
67
67
  const lines = [`COMPLEX FORMS (thread <form> <level>)`];
68
68
  for (const f of FORMS) {
69
69
  const held = actor.sustainedForms.some(s => s.key === f.key);
70
- lines.push(` ${f.name.padEnd(16)} ${f.kind === 'sustained' ? '[sustained]' : '[instant] '} ${f.blurb}${held ? ' {lightblue-fg}(HELD){/lightblue-fg}' : ''}`);
70
+ lines.push(` ${f.name.padEnd(16)} ${f.kind === 'sustained' ? '[sustained]' : '[instant] '} ${f.blurb}${held ? ' {light-blue-fg}(HELD){/light-blue-fg}' : ''}`);
71
71
  }
72
72
  if (actor.sustainedForms.length > 0) {
73
73
  lines.push('', ` Holding ${actor.sustainedForms.length} form${actor.sustainedForms.length === 1 ? '' : 's'} (${sustainTotal(actor.sustainedLedger().filter(w => w.kind === 'form'))}).${hint(` ("thread drop <form>" releases.)`)}`);
@@ -482,5 +482,49 @@
482
482
  // hub, characterSheet.hydrate -- silently ATE the lifestyle point of a
483
483
  // runner who left fed and slept. The tier is on the save; it is read
484
484
  // there now (commerce.lifestyleTierFor, Squatter when unnamed).
485
- export const ENGINE_VERSION = '1.55.1';
485
+ // 1.56.0 (2026-09-15): TWO FLAGS THAT WERE STANDING IN FOR STATE THEY
486
+ // DO NOT HOLD.
487
+ // - A CRACKED HOST IS NOT A HOST YOU HOLD MARKS ON
488
+ // (spcebZTKN24GYRHXW). `hack`/`mark` refused outright on the
489
+ // room-global hostCracked, so a persona that rebooted mid-run -- which
490
+ // canon wipes marks on (p.236, p.242) -- could never re-place one,
491
+ // while every device on the node went on refusing them for want of
492
+ // marks. Command semantics in shared scenes: the refusal is gone, the
493
+ // standing guard is 3 marks held, a repeat break-in is worded as one,
494
+ // and the host line prints the site's state and the persona's marks
495
+ // apart. The crack's world consequences stay permanent.
496
+ // - NO CAB MID-FIGHT (hHvR2kxrqAKuuXwED). The downtime verbs (buy,
497
+ // sell, install, register, initiate, quality-trade, advance, call
498
+ // taxi) gated on Player.inExchange, a re-entrancy lock that is false
499
+ // for most of a Combat Turn, so each refused for the half-second an
500
+ // exchange resolved and permitted itself the rest of the fight. They
501
+ // ask inLiveCombat (action-cost.ts) now. NEW EMBEDDER SURFACE:
502
+ // Scene.anyLiveEncounter(), which the site reads off the live handle
503
+ // to refuse sideQuest.settle -- on a shared run the cab is intercepted
504
+ // before the engine ever sees it, so the server is the only place
505
+ // that path can be stopped.
506
+ // 1.57.0 (2026-09-15): A VOICE CARRIES AS FAR AS THE EAR, and two
507
+ // spellings change with it.
508
+ // - `say` was resolved by SPOT NAME -- whoever shared your spot, plus
509
+ // anyone CIRCULATING, because "no position" was read as "every
510
+ // position". A patrol officer walking through a back room answered a
511
+ // question put to a Johnson at a table. It also ESCALATED on its own:
512
+ // alone at a spot, a murmur became a room-wide shout nobody asked
513
+ // for. Now `say` reaches whoever is inside their OWN hearing range
514
+ // (utilities/earshot.ts canHearSpeech; range is 5 m plus the
515
+ // listener's perception dice) and never escalates. Canon gives no
516
+ // distance for speech -- it is dice and limits, p.445/p.453-454 --
517
+ // so the metres are this engine's and are labelled as such.
518
+ // - NEW VERB `shout` (~50 m, in practice room-wide) is the old
519
+ // escalation, now deliberate.
520
+ // - `talk-to` is spelled `tell`. Command semantics in shared scenes:
521
+ // the old spelling stays registered as an alias, so nothing that
522
+ // learned it breaks.
523
+ // - COLOUR TAGS: the run-together "lightblue" spelling never parsed
524
+ // in blessed (its tag parser wants "light blue fg", hyphens turned
525
+ // to spaces) and was printed to CLI players as literal braces for
526
+ // months, while the web client rendered it perfectly. 134 tags are
527
+ // now {light-blue-fg}. The site's render-tags.ts maps that back
528
+ // to a real CSS keyword, so the pin must carry both halves.
529
+ export const ENGINE_VERSION = '1.57.0';
486
530
  //# sourceMappingURL=engine-version.js.map
@@ -73,6 +73,7 @@ import { LockCommand } from './commands/lock.js';
73
73
  import { SearchCommand } from './commands/search.js';
74
74
  import { SayCommand } from './commands/say.js';
75
75
  import { TalkCommand } from './commands/talk.js';
76
+ import { ShoutCommand } from './commands/shout.js';
76
77
  import { StoreCommand } from './commands/store.js';
77
78
  import { OpenCommand, CloseCommand, PutCommand } from './commands/container-verbs.js';
78
79
  import { GiveCommand } from './commands/give.js';
@@ -708,7 +709,7 @@ export default class Game {
708
709
  Logger.getInstance().write(`Loose end "${c.id}" left: ${erased ? 'ERASED' : 'still open'}; hub is the scene again.`);
709
710
  this.requestSave('cleanup');
710
711
  return [erased
711
- ? `\n{lightblue-fg}The grid closes behind you. ${le?.site ?? 'The site'}'s feeds run clean -- whatever they had of you is gone.{/lightblue-fg}`
712
+ ? `\n{light-blue-fg}The grid closes behind you. ${le?.site ?? 'The site'}'s feeds run clean -- whatever they had of you is gone.{/light-blue-fg}`
712
713
  : `\n{yellow-fg}The grid closes behind you. The footage is still up there.{/yellow-fg}${hint(` (Jack in and "hop" again while the desk hasn't reviewed it.)`)}`];
713
714
  }
714
715
  /** The HUD's read on the district (bands, never the number). */
@@ -2692,7 +2693,14 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2692
2693
  CommandFactory.registerCommand('open', OpenCommand);
2693
2694
  CommandFactory.registerCommand('close', CloseCommand);
2694
2695
  CommandFactory.registerCommand('put', PutCommand);
2696
+ // TELL IS THE VERB (en3p6eCpiRuqsTSKm: "let's adjust 'talk-to' to
2697
+ // 'tell' to be more concise"). `talk-to` stays registered as an
2698
+ // alias rather than being deleted: it is the spelling in every
2699
+ // transcript, every NPC brief and every backlog item written before
2700
+ // today, and a player who learned it should not be told it is gone.
2701
+ CommandFactory.registerCommand('tell', TalkCommand);
2695
2702
  CommandFactory.registerCommand('talk-to', TalkCommand);
2703
+ CommandFactory.registerCommand('shout', ShoutCommand);
2696
2704
  CommandFactory.registerCommand('give', GiveCommand);
2697
2705
  CommandFactory.registerCommand('read', ReadCommand);
2698
2706
  CommandFactory.registerCommand('kill', KillCommand);
@@ -5955,7 +5963,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5955
5963
  // works the grid while you're back in the meat (the AR-drop exit).
5956
5964
  const remoteAgent = this.companions.find(c => c.kind === 'agent');
5957
5965
  if (remoteAgent && player.plane !== 'matrix') {
5958
- lines.push(`{lightblue-fg}▣ DECK IN AR -- ${remoteAgent.npc.name} on the grid${remoteAgent.npc.objective ? ', working its directive' : ''}${hint(' ("jack in" to rejoin it, "recall" to fold it home)')}{/lightblue-fg}`);
5966
+ lines.push(`{light-blue-fg}▣ DECK IN AR -- ${remoteAgent.npc.name} on the grid${remoteAgent.npc.objective ? ', working its directive' : ''}${hint(' ("jack in" to rejoin it, "recall" to fold it home)')}{/light-blue-fg}`);
5959
5967
  }
5960
5968
  // Off-plane context: which reality you're in, your persona's Matrix
5961
5969
  // track (cold-sim damage lands there), and where your empty body lies.
@@ -5974,7 +5982,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5974
5982
  const track = jackedDeck
5975
5983
  ? ` Deck A${jackedDeck.attack} S${jackedDeck.sleaze} D${jackedDeck.dataProcessing} F${jackedDeck.firewall} ${jackedDeck.deckSummary()}${player.simMode === 'hot' ? ' +biofeedback' : ''}${pgms}`
5976
5984
  : ` (living persona -- damage is REAL stun)`;
5977
- lines.push(`{lightblue-fg}▣ MATRIX ${(player.simMode ?? 'cold').toUpperCase()}-SIM${track} -- body at ${player.bodyRoom?.name ?? '???'}{/lightblue-fg}`);
5985
+ lines.push(`{light-blue-fg}▣ MATRIX ${(player.simMode ?? 'cold').toUpperCase()}-SIM${track} -- body at ${player.bodyRoom?.name ?? '???'}{/light-blue-fg}`);
5978
5986
  }
5979
5987
  else if (player.plane === 'astral') {
5980
5988
  lines.push(`{magenta-fg}✧ ASTRAL -- ${player.astralTicks} move${player.astralTicks === 1 ? '' : 's'} out -- body at ${player.bodyRoom?.name ?? '???'}{/magenta-fg}`);
@@ -6066,7 +6074,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6066
6074
  const dockW = mapWidth ?? 15;
6067
6075
  const lines = hosts.length > 0
6068
6076
  ? hosts.map(h => hostDockLine(h, this.player, dockW))
6069
- : ['{lightblue-fg}nothing overhead{/lightblue-fg}'];
6077
+ : ['{light-blue-fg}nothing overhead{/light-blue-fg}'];
6070
6078
  lines.push(caption(this.player.insideHost ? `inside ${this.player.insideHost.name}` : `on the grid from ${gridVicinity(this.player).name}`, mapWidth));
6071
6079
  this.setMapOverlay(lines);
6072
6080
  if (onTheGrid) {
@@ -6575,7 +6583,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6575
6583
  // technomancer-gated (a sprite task, SR5 p.256) and refuses anyone
6576
6584
  // else, so the Emerged shelf is where its audience looks.
6577
6585
  { title: 'resonance', entries: [['compile'], ['decompile'], ['thread'], ['unravel'], ['register'], ['standby'], ['sustain'], ['sprites']] },
6578
- { title: 'social', entries: [['say'], ['emote', 'me'], ['talk-to'], ['call'], ['message'], ['end-call', 'end', 'hangup'], ['accept'], ['decline', 'deny', 'refuse'], ['haggle', 'negotiate'], ['persuade', 'convince'], ['ask'], ['contacts']] },
6586
+ { title: 'social', entries: [['say'], ['shout'], ['emote', 'me'], ['tell', 'talk-to'], ['call'], ['message'], ['end-call', 'end', 'hangup'], ['accept'], ['decline', 'deny', 'refuse'], ['haggle', 'negotiate'], ['persuade', 'convince'], ['ask'], ['contacts']] },
6579
6587
  { title: 'street', entries: [['abandon'], ['browse', 'buy'], ['sell'], ['catalog'], ['source', 'procure'], ['work', 'gig'], ['jobs'], ['journal'], ['perform'], ['palm'], ['docwagon'], ['treat'], ['eat'], ['drink'], ['lifestyle'], ['garage'], ['pay']] },
6580
6588
  { title: 'barriers', entries: [['pick'], ['breach'], ['dispel', 'dispell'], ['bluff'], ['unlock'], ['lock']] },
6581
6589
  { title: 'character', entries: [['sheet', 'stats'], ['advance'], ['qualities', 'quality'], ['sin', 'sins', 'papers'], ['initiate', 'submerge'], ['install', 'chrome']] },
@@ -9,7 +9,8 @@ import * as fuzzyMatch from '../utilities/fuzzy-match.js';
9
9
  import { sameHostSide } from './player.js';
10
10
  import { AI } from '../../../../tools/ai/ai.class.js';
11
11
  import { AbstractRoom } from '../types/shared/abstracts.js';
12
- import { spotOf, spotsActive, actorDistanceMeters, voiceBandFor, voiceTag, doorwayKindOf, exitDoorwayKind } from '../utilities/spots.js';
12
+ import { spotsActive, actorDistanceMeters, voiceBandFor, voiceTag, doorwayKindOf, exitDoorwayKind } from '../utilities/spots.js';
13
+ import { canHearSpeech } from '../utilities/earshot.js';
13
14
  import { synthesizeGrid, } from '../utilities/room-grid.js';
14
15
  export class Room extends AbstractRoom {
15
16
  logger = Logger.getInstance();
@@ -724,16 +725,37 @@ Keep it concise and dramatic.`;
724
725
  const others = mouths - 1;
725
726
  return ` [System note: ${others} other ${others === 1 ? 'person' : 'people'} in earshot heard this too, and each is answering in the same moment as you -- none of you can see what the others are about to say. Say YOUR piece, in your own voice, but not the same piece: if the obvious reply is one anyone here would give, make yours specific to you instead. Two people reporting one event reads as a joke.]`;
726
727
  }
728
+ /**
729
+ * ORDINARY SPEECH, which now carries as far as the LISTENER can hear
730
+ * and no further (en3p6eCpiRuqsTSKm: "'say' should only be perceived
731
+ * by anyone within 3-5m un-augmented. Really, it's the audible
732
+ * perception range of the listener").
733
+ *
734
+ * WHAT THIS REPLACES, and why the old shape produced the report.
735
+ * Speech used to be resolved by SPOT NAME: whoever shared your spot
736
+ * heard you, plus anyone CIRCULATING -- and if nobody shared your
737
+ * spot, the words escalated to a room-wide shout automatically. Two
738
+ * consequences, both reported in one session:
739
+ *
740
+ * - A circulating actor heard everything, because "no spot" was read
741
+ * as "everywhere". That is how Patrol Officer Kessler, `moving
742
+ * through` the back room, answered a question put to Ms. Quill at
743
+ * a table (CugfYsH8DWH8FcfgS).
744
+ * - Standing anywhere on your own turned every murmur into a shout
745
+ * without you asking for one, so there was no way to say something
746
+ * quietly and no verb that meant "I want the room to hear this".
747
+ *
748
+ * Now: earshot.ts owns the rule (canHearSpeech), distance is real
749
+ * metres between cells, and the range belongs to each listener's ears.
750
+ * The escalation is gone -- SHOUTING IS A VERB NOW (commands/shout.ts,
751
+ * Room.shout below). Saying something nobody is close enough to catch
752
+ * is a thing that can happen, and say.ts tells you so.
753
+ *
754
+ * Returns who actually heard it, so the caller can say "nobody did"
755
+ * without asking the same question a second way and risking a
756
+ * different answer.
757
+ */
727
758
  say(actor, message) {
728
- // "At" spots: speech is LOCAL (player decision: speech local, sight
729
- // room-wide -- walking into the bar had every NPC in the room
730
- // engaging at once). Same-spot and circulating listeners hear
731
- // normally. When NOBODY shares the speaker's spot but others are in
732
- // the room, the words carry as a SHOUT -- delivered room-wide with
733
- // a tag so an NPC AI reacts to being called across the room, not to
734
- // bar-side murmur. When same-spot listeners exist, cross-spot
735
- // actors hear nothing (v1: the murmur doesn't carry).
736
- const speakerSpot = spotOf(actor);
737
759
  // THE HOST WALL IS ALSO A WALL FOR VOICES (FhXadizkMCxAdMaJx).
738
760
  //
739
761
  // Every verb that PICKS a target honours sameHostSide -- that is what
@@ -748,66 +770,67 @@ Keep it concise and dramatic.`;
748
770
  // have insideHost undefined, so they compare equal and nothing about
749
771
  // ordinary speech changes.
750
772
  const listeners = [...this.actors].filter(r => r !== actor && sameHostSide(actor, r));
773
+ const audience = listeners.filter(r => canHearSpeech(actor, r));
751
774
  // broadcast: true -- room speech is overheard, not addressed. A
752
775
  // companion shell at heel uses that distinction to stay quiet (see
753
- // NPC.hear); targeted channels (talk/order/ask...) never set it.
754
- if (speakerSpot === undefined) {
755
- const crowd = this.crowdNote(listeners);
756
- // CAUGHT, like every other hear() fan-out in the game (say.ts,
757
- // planes.ts, game.ts heartbeat). These three were the only bare
758
- // ones left: a bare `void` on a rejecting promise is an
759
- // unhandledRejection, and ui.ts fatal handler kills the session
760
- // on one -- which is exactly how a 502 from the AI relay ended a
761
- // live game twice on 2026-08-29. Belt AND braces: NPC.hear now
762
- // swallows its own reaction failures too, so this is redundant
763
- // for NPCs on purpose -- it still guards any other Player
764
- // subclass that grows an async hear().
765
- for (const recipient of listeners) {
766
- void Promise.resolve(recipient.hear(actor, recipient instanceof NPC ? `${message}${crowd}` : message, { broadcast: true })).catch(err => {
767
- this.logger.error(`${recipient.name} failed to hear ${actor.name}: ${err}`);
768
- });
769
- }
770
- return;
771
- }
772
- const local = listeners.filter(r => {
773
- const rs = spotOf(r);
774
- return rs === undefined || rs === speakerSpot;
775
- });
776
- if (local.length > 0) {
777
- const crowd = this.crowdNote(local);
778
- for (const recipient of local) {
779
- void Promise.resolve(recipient.hear(actor, recipient instanceof NPC ? `${message}${crowd}` : message, { broadcast: true })).catch(err => {
780
- this.logger.error(`${recipient.name} failed to hear ${actor.name}: ${err}`);
781
- });
782
- }
783
- return;
776
+ // NPC.hear); targeted channels (tell/order/ask...) never set it.
777
+ const crowd = this.crowdNote(audience);
778
+ // CAUGHT, like every other hear() fan-out in the game (say.ts,
779
+ // planes.ts, game.ts heartbeat). These were the only bare ones
780
+ // left: a bare `void` on a rejecting promise is an
781
+ // unhandledRejection, and ui.ts fatal handler kills the session on
782
+ // one -- which is exactly how a 502 from the AI relay ended a live
783
+ // game twice on 2026-08-29. Belt AND braces: NPC.hear now swallows
784
+ // its own reaction failures too, so this is redundant for NPCs on
785
+ // purpose -- it still guards any other Player subclass that grows
786
+ // an async hear().
787
+ //
788
+ // NO VOICE TAG. Everyone here is inside their own hearing range by
789
+ // construction, which is what voiceBandFor calls 'conversation' --
790
+ // the band that has always rendered as no tag at all.
791
+ for (const recipient of audience) {
792
+ void Promise.resolve(recipient.hear(actor, recipient instanceof NPC ? `${message}${crowd}` : message, { broadcast: true })).catch(err => {
793
+ this.logger.error(`${recipient.name} failed to hear ${actor.name}: ${err}`);
794
+ });
784
795
  }
785
- // VOICE RANGE (spatial pass 2026-08-25): how hard you have to work
786
- // to be heard scales with the actual gap, not a flat "another spot"
787
- // -- since the room resize, a stallholder answering across a 32 m
788
- // market was tagged the same as a neighbour two tables over
789
- // (playtest: "he's basically screaming"). See spots.ts voiceBandFor.
790
- // The shout channel: a lone-at-spot PLAYER calling out reaches the
791
- // whole room. A lone-at-spot NPC reaches only the PLAYER -- the
792
- // camera, not the cast: when every NPC's murmur went room-wide, two
793
- // barflies at opposite ends conducted a shouted conversation about
794
- // the player's drink order (real session: Patch and Old Cray,
795
- // volleying "(calling across the room)" lines through each other's
796
- // AI). Same-spot NPC chat is untouched -- that's the local branch.
796
+ return audience;
797
+ }
798
+ /**
799
+ * SHOUTING, which used to happen to you and is now something you do
800
+ * (en3p6eCpiRuqsTSKm: "add 'shout' can be heard by anyone within a
801
+ * reasonable distance, 50m?").
802
+ *
803
+ * Reaches the whole room -- see earshot.ts SHOUT_METERS for why 50 m
804
+ * is in practice room-wide and why the number is this engine's rather
805
+ * than the book's. Each listener is told how far the words travelled
806
+ * (voiceTag), so an NPC's AI answers someone calling across a bar
807
+ * differently from someone at its elbow.
808
+ *
809
+ * THE NPC RESTRAINT IS KEPT from the old auto-escalation, because the
810
+ * reason for it has not changed: when every NPC's raised voice went
811
+ * room-wide, two barflies at opposite ends held a shouted conversation
812
+ * about the player's drink order through each other's AI (a real
813
+ * session -- Patch and Old Cray). An NPC shout reaches the PLAYER, the
814
+ * camera rather than the cast. A PLAYER's shout reaches everyone,
815
+ * which is the whole point of typing it.
816
+ */
817
+ shout(actor, message) {
818
+ const listeners = [...this.actors].filter(r => r !== actor && sameHostSide(actor, r));
797
819
  const player = [...this.actors].find(a => !(a instanceof NPC));
798
- const shoutTargets = actor instanceof NPC
820
+ const targets = actor instanceof NPC
799
821
  ? listeners.filter(r => r === player)
800
822
  : listeners;
801
- const shoutCrowd = this.crowdNote(shoutTargets);
802
- for (const recipient of shoutTargets) {
823
+ const crowd = this.crowdNote(targets);
824
+ for (const recipient of targets) {
803
825
  const band = voiceBandFor(actorDistanceMeters(this, actor, recipient));
804
826
  // The crowd note goes LAST, after the voice tag: both are system
805
827
  // dressing, and the tag describes the words while the note
806
828
  // describes the room.
807
- void Promise.resolve(recipient.hear(actor, `${message}${voiceTag(band)}${recipient instanceof NPC ? shoutCrowd : ''}`, { broadcast: true })).catch(err => {
829
+ void Promise.resolve(recipient.hear(actor, `${message}${voiceTag(band)}${recipient instanceof NPC ? crowd : ''}`, { broadcast: true })).catch(err => {
808
830
  this.logger.error(`${recipient.name} failed to hear ${actor.name} shout: ${err}`);
809
831
  });
810
832
  }
833
+ return targets;
811
834
  }
812
835
  addExit(direction, targetRoom, keyItemOrSolution) {
813
836
  let door = this.findExistingDoor(targetRoom);
@@ -185,6 +185,30 @@ export class Scene extends AbstractScene {
185
185
  const enc = this.encounterAt(arenaOf(actor));
186
186
  return enc?.has(actor) ? enc : undefined;
187
187
  }
188
+ /**
189
+ * IS ANYBODY AT THIS TABLE IN A FIGHT, asked across every arena.
190
+ *
191
+ * For the EMBEDDER, not for a verb (hHvR2kxrqAKuuXwED: "I was able to
192
+ * call a taxi in the middle of a fight"). On a shared run the ride
193
+ * home is not an engine verb at all -- both the CLI
194
+ * (utilities/shared-run.ts) and the browser (the site's run-exit.ts)
195
+ * intercept "call taxi" before it reaches the table and call
196
+ * sideQuest.settle, which ends the run for EVERYONE. So the engine's
197
+ * own gate in commands/call.ts, correct as it is, never ran on the
198
+ * path the report came in on, and the site had no way to ask this
199
+ * question without reaching into `encounters`.
200
+ *
201
+ * Every arena, not just the leader's: settling the table yanks the
202
+ * whole party home, so a decker in a host trading with ice is reason
203
+ * enough to refuse even if the leader is standing in an empty street.
204
+ */
205
+ anyLiveEncounter() {
206
+ for (const enc of this.encounters.values()) {
207
+ if (!enc.ended)
208
+ return true;
209
+ }
210
+ return false;
211
+ }
188
212
  /**
189
213
  * Who is in the fight when `aggressor` opens on `target`: the two of
190
214
  * them, every human and bound shell in the room (the party fights as
@@ -3,6 +3,42 @@ import { hint } from './hints.js';
3
3
  export function encounterOf(scene, actor) {
4
4
  return scene.encounterFor?.(actor);
5
5
  }
6
+ /**
7
+ * IS THERE A FIGHT ON, for a verb that simply cannot happen during one.
8
+ *
9
+ * `Player.inExchange` is NOT this question, and a family of verbs was
10
+ * asking it as though it were (hHvR2kxrqAKuuXwED: "I was able to call a
11
+ * taxi in the middle of a fight"). That flag is a re-entrancy lock --
12
+ * true only while one exchange is mid-RESOLUTION, set by
13
+ * CombatExchange.beginExchange and cleared by finishExchange -- so it is
14
+ * false for almost all of a Combat Turn, including the whole of your own
15
+ * Action Phase. Every verb that gated on it alone was therefore refusing
16
+ * during the narration beats of a single trade of fire and permitting
17
+ * itself freely the rest of the fight, while its refusal line said
18
+ * "firefight".
19
+ *
20
+ * The Combat Turn (utilities/combat-turn.ts) is what actually knows a
21
+ * fight is on: an encounter exists for the actor's arena and has not
22
+ * ended. That is the primary test here. The two exchange checks stay
23
+ * underneath it because an exchange can resolve outside an encounter
24
+ * (an NPC AI loop, a scene with no Combat Turn running), and because
25
+ * NPCs have no encounter phase of their own to consult.
26
+ *
27
+ * This is the "no" answer, not the "not yet" answer. Use requirePhase
28
+ * for something that IS legal in a fight but only on your own phase
29
+ * (walking out of the room); use this for something that is not a combat
30
+ * action at all and has no phase that could pay for it -- hailing a cab,
31
+ * ringing up a sale, a training montage.
32
+ */
33
+ export function inLiveCombat(scene, actor) {
34
+ const enc = encounterOf(scene, actor);
35
+ if (enc && !enc.ended)
36
+ return true;
37
+ if (actor.inExchange)
38
+ return true;
39
+ return scene.isHumanControlled?.(actor) === true
40
+ && scene.isPlayerExchangeActive?.(actor.name) === true;
41
+ }
6
42
  /**
7
43
  * The refusal when it is not this actor's Action Phase -- the wording
8
44
  * every verb shares, so a player learns one sentence.
@@ -256,7 +256,7 @@ export function aroBlock(scene, actor, room) {
256
256
  const tags = aroTags(scene, actor, room);
257
257
  if (tags.length === 0)
258
258
  return '';
259
- return `{lightblue-fg}AR OVERLAY:{/lightblue-fg}\n${tags.map(t => ` {lightblue-fg}${t}{/lightblue-fg}`).join('\n')}`;
259
+ return `{light-blue-fg}AR OVERLAY:{/light-blue-fg}\n${tags.map(t => ` {light-blue-fg}${t}{/light-blue-fg}`).join('\n')}`;
260
260
  }
261
261
  /** Gear that talks to the grid on its own, lying LOOSE in the room.
262
262
  * Weapons are absent here because a carried one is handled above, as