@maka/maka-cli 5.197.0 → 5.199.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 (54) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js +6 -6
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +3 -0
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +3 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/command-registry.js +25 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/command.js +25 -0
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/decompile.js +3 -3
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +3 -3
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +2 -2
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js +5 -5
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +3 -0
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +49 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +8 -8
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/hide.js +1 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js +2 -2
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/kill.js +3 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +3 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +5 -5
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/mark.js +1 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +3 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js +3 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +19 -3
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +2 -2
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/shout.js +69 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/snoop.js +1 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/sprites.js +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js +3 -0
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +3 -0
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +2 -2
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +7 -7
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/thread.js +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +75 -1
  35. package/bundle/typescript/src/commands/game/sideQuest/factions.js +135 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +24 -0
  37. package/bundle/typescript/src/commands/game/sideQuest/game.js +66 -5
  38. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +165 -14
  39. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +90 -57
  40. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +84 -8
  41. package/bundle/typescript/src/commands/game/sideQuest/types/repro.js +1 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js +1 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/utilities/beat-echo.js +160 -0
  44. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +25 -0
  45. package/bundle/typescript/src/commands/game/sideQuest/utilities/earshot.js +153 -10
  46. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +1 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +14 -14
  48. package/bundle/typescript/src/commands/game/sideQuest/utilities/hostile-contact.js +48 -2
  49. package/bundle/typescript/src/commands/game/sideQuest/utilities/matrix-style.js +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/utilities/narration-limits.js +235 -0
  51. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-authorization.js +122 -0
  52. package/bundle/typescript/src/commands/game/sideQuest/utilities/overwatch.js +2 -2
  53. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +1 -1
  54. package/package.json +1 -1
@@ -313,8 +313,8 @@ export class SearchCommand extends Command {
313
313
  // tells a player standing next to an offline server that it is
314
314
  // there and what it wants from them.
315
315
  description += room.offlineServer
316
- ? `\n{lightblue-fg}A ${room.offlineServer} sits here, dark and off the network -- no aerial, no marquee, nothing broadcasting. Whatever it holds comes out through a cable and no other way.${hint(` "jack in" RIGHT HERE to touch its files.`)}{/lightblue-fg}`
317
- : `\n{lightblue-fg}The node here hums with locked files -- whatever's worth taking lives on the grid, inside the host.${hint(` "jack in", then "enter" the host.`)}{/lightblue-fg}`;
316
+ ? `\n{light-blue-fg}A ${room.offlineServer} sits here, dark and off the network -- no aerial, no marquee, nothing broadcasting. Whatever it holds comes out through a cable and no other way.${hint(` "jack in" RIGHT HERE to touch its files.`)}{/light-blue-fg}`
317
+ : `\n{light-blue-fg}The node here hums with locked files -- whatever's worth taking lives on the grid, inside the host.${hint(` "jack in", then "enter" the host.`)}{/light-blue-fg}`;
318
318
  }
319
319
  description += describeContainers(room, this.actor);
320
320
  description += describeBarriers(room, this.actor);
@@ -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.
@@ -10,6 +10,9 @@ import { AttackCommand } from './attack.js';
10
10
  */
11
11
  export class SubdueCommand extends AttackCommand {
12
12
  static verb = 'subdue';
13
+ /** Touches another actor: authorized per-act for AI-driven actors
14
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
15
+ static npcPolicy = 'authorized';
13
16
  static description = 'Take someone down ALIVE -- stun damage with your bare hands. Slower than shooting, they fight back for real, and overdoing it can still kill them.';
14
17
  async execute(args = []) {
15
18
  // A chokehold needs hands and a neck: no grappling as a Matrix persona
@@ -8,6 +8,9 @@ import { Category } from '../types/shared/item-enum.js';
8
8
  import { billAction } from '../utilities/action-cost.js';
9
9
  export class TakeCommand extends Command {
10
10
  static verb = 'take';
11
+ /** Touches another actor: authorized per-act for AI-driven actors
12
+ * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
13
+ static npcPolicy = 'authorized';
11
14
  static description = 'Pick up an item. "take all" sweeps everything here -- but only after you have searched the room.';
12
15
  async execute(args) {
13
16
  if (!args)
@@ -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.)`)}`);
@@ -503,5 +503,79 @@
503
503
  // to refuse sideQuest.settle -- on a shared run the cab is intercepted
504
504
  // before the engine ever sees it, so the server is the only place
505
505
  // that path can be stopped.
506
- export const ENGINE_VERSION = '1.56.0';
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
+ // 1.58.0 (2026-09-15): THE AI PROPOSES, THE ENGINE DISPOSES
530
+ // (aCErAfvEW7G23FWDM, reporter: "the only way to solve these is with
531
+ // engine enforcement against AI allowed actions"). An NPC beat was
532
+ // free prose: any line of it beginning "[COMMAND] " went to the SAME
533
+ // registry the player types into with NO filter on the command text,
534
+ // and every other line was printed to the player as fact. So the
535
+ // engine could neither refuse an act nor even SEE one asserted.
536
+ // - WHAT AN NPC MAY DO. Command.npcPolicy, the same declarative shape
537
+ // as humanOnly, enforced once in CommandRegistry.parse and backed by
538
+ // utilities/npc-authorization.ts. A hands-on verb against a body
539
+ // whose owner is jacked in, projecting, or already down is refused
540
+ // -- SR5 resolves Subduing (p.195) as an OPPOSED melee attack, and
541
+ // there is nothing there to oppose. parse() grew a third outcome:
542
+ // null still means "no such verb", a REFUSAL carries a reason, and
543
+ // NPC.act feeds that reason back so the model hears the boundary.
544
+ // - WHAT A BEAT MAY CLAIM. utilities/narration-limits.ts: narration
545
+ // may do anything with the NPC's own body and nothing with anybody
546
+ // else's. A refused line is suppressed AND answered, so the next
547
+ // beat can commit to "[COMMAND] grapple X" and let the dice decide.
548
+ // - A GRAB IS A FIGHT. commands/grapple.ts never called startEncounter
549
+ // -- it billed through billAction, a no-op outside combat -- so the
550
+ // most hostile act in the engine was also its cheapest, and left the
551
+ // target held with no turn in which to struggle. Every outcome now
552
+ // opens a Combat Turn. `grapples` joins the PROVOCATION ladder, and
553
+ // turnHostile() is the runtime mirror standDown never had: `hostile`
554
+ // was written at BOOT and nowhere else, so an NPC who was not
555
+ // opposition when the scene was built had no path to becoming it.
556
+ // - WHAT AN NPC MAY KNOW. Seven blocks of the brief were scene-globals
557
+ // handed to everybody. World events now carry WHERE they happened
558
+ // and Matrix-plane events reach only the Matrix (a bartender could
559
+ // read "the host MADE the intruder -- ice hunting"); ephemerals get
560
+ // no grapevine at all; the run's payout goes to the parties to the
561
+ // deal. The raw, unparsed model reply is no longer written into the
562
+ // NPC's memory -- only what actually reached the world is.
563
+ // - NOBODY SAYS IT TWICE. utilities/beat-echo.ts, the engine's first
564
+ // dedupe of emitted text of any kind. Containment rather than
565
+ // Jaccard, with a lower bar when a speaker opens the same way twice;
566
+ // companion link traffic is never judged (two drones clearing two
567
+ // rooms say near-identical things on purpose).
568
+ // - FACTIONS, catalog-backed (site v20, kind 'faction'). factions.ts
569
+ // holds the rules; the rows say who exists. First rule reading them:
570
+ // a Johnson will not lay out terms in front of a badge she can SEE.
571
+ // An unknown faction is no opinion, so a catalog without the rows
572
+ // behaves exactly as this engine did before them.
573
+ // - NEW CATALOG LANE `hearing`: hearingRangeMeters read the flat
574
+ // `perception` pool, so CYBEREYES extended how far you could hear.
575
+ // Audio Enhancement I-III and Spatial Recognizer are rows at last
576
+ // (p.445, p.454; Chrome Flesh p.223).
577
+ // - BENCH: repro schema 2.6.0 adds npcs[].hostile. A fixture could not
578
+ // express a hostile PERSON -- only `ice` -- so no meat-side
579
+ // hostility report could be benched at all.
580
+ export const ENGINE_VERSION = '1.58.0';
507
581
  //# sourceMappingURL=engine-version.js.map
@@ -0,0 +1,135 @@
1
+ import { factionFor } from './utilities/catalog.js';
2
+ /**
3
+ * THE SHADOW ECONOMY'S OWN SLUG. A Johnson, a fixer and a runner are
4
+ * not an organisation, but they are a SIDE -- the deniable one -- and
5
+ * the rules below need a name for it. Matches the catalog row.
6
+ */
7
+ export const SHADOWS = 'shadows';
8
+ /**
9
+ * WHAT FACTION IS THIS ACTOR, if the engine can tell.
10
+ *
11
+ * An explicit `faction` on the NPC wins, always -- a seed or a
12
+ * generator that says so is the author speaking. Failing that, the one
13
+ * thing the engine already knew about strangers is read: the ephemeral
14
+ * KIND that spawned them (utilities/ephemeral.ts). A sweep officer is
15
+ * an 'authority' body and was already, in every way that mattered, the
16
+ * law; this just gives the engine the word for it.
17
+ *
18
+ * DERIVING IS DELIBERATELY SHALLOW. It maps the three spawn archetypes
19
+ * the engine already has and stops. Guessing a faction from a name or a
20
+ * description would be the kind of inference that looks clever in a
21
+ * demo and puts a gang tattoo on a bartender in play.
22
+ */
23
+ export function factionOf(actor) {
24
+ const npc = actor;
25
+ if (npc.faction)
26
+ return npc.faction;
27
+ switch (npc.ephemeral?.kind) {
28
+ case 'authority': return 'knight-errant';
29
+ case 'mob': return 'ancients';
30
+ case 'bystander': return 'civilians';
31
+ default: return undefined;
32
+ }
33
+ }
34
+ /**
35
+ * HOW `a` REGARDS `b`: -3 (blood) .. +3 (family), 0 for no opinion.
36
+ *
37
+ * ASYMMETRIC BY DESIGN. A gang can hate a corp that has never heard of
38
+ * it, so this reads the standing of the faction doing the JUDGING and
39
+ * never averages the two. Where a faction is silent about another, the
40
+ * SORTS answer instead -- the law and the shadows are structurally
41
+ * opposed whether or not anybody wrote a row about it, and two gangs
42
+ * are rivals until someone says otherwise.
43
+ */
44
+ export function standingBetween(a, b) {
45
+ if (!a || !b)
46
+ return 0;
47
+ if (a === b)
48
+ return 3;
49
+ const rowA = factionFor(a);
50
+ const explicit = rowA?.standing?.[b];
51
+ if (typeof explicit === 'number')
52
+ return explicit;
53
+ // A DIVISION IS ITS PARENT'S. Knight Errant IS Ares Macrotechnology's
54
+ // security arm (p.38), so the two are one house unless a row says
55
+ // otherwise above -- and nobody writes "we like ourselves" into a
56
+ // standing table, which is exactly why this cannot be left to the
57
+ // declared numbers.
58
+ if (rowA?.parent === b)
59
+ return 3;
60
+ // And its opinion of a THIRD party is the house's, where it has not
61
+ // formed its own.
62
+ if (rowA?.parent) {
63
+ const inherited = factionFor(rowA.parent)?.standing?.[b];
64
+ if (typeof inherited === 'number')
65
+ return inherited;
66
+ }
67
+ const rowB = factionFor(b);
68
+ if (!rowA || !rowB)
69
+ return 0;
70
+ if (rowA.sort === 'law' && (rowB.sort === 'shadow' || rowB.sort === 'gang' || rowB.sort === 'syndicate'))
71
+ return -2;
72
+ if ((rowA.sort === 'shadow' || rowA.sort === 'gang' || rowA.sort === 'syndicate') && rowB.sort === 'law')
73
+ return -2;
74
+ if (rowA.sort === 'gang' && rowB.sort === 'gang')
75
+ return -1;
76
+ return 0;
77
+ }
78
+ /**
79
+ * WOULD BUSINESS BE DISCUSSED IN FRONT OF THIS ACTOR?
80
+ *
81
+ * The Johnson's question, and the one the reported session turned on.
82
+ * Three ways to be safe to talk in front of, and a stranger with no
83
+ * faction at all is one of them -- an unknown face in a bar is the
84
+ * normal condition of a bar, and treating every unplaceable body as a
85
+ * threat would mean no deal was ever struck anywhere.
86
+ */
87
+ export function wouldTalkBusinessNear(dealer, witness) {
88
+ const theirs = factionOf(witness);
89
+ if (!theirs)
90
+ return true;
91
+ const row = factionFor(theirs);
92
+ // A faction that keeps its mouth shut is not a problem to talk in
93
+ // front of, whatever it thinks of you: the syndicates and the gangs
94
+ // are IN the deniable economy, not auditing it.
95
+ if (row?.discreet)
96
+ return true;
97
+ return standingBetween(dealer ?? SHADOWS, theirs) > -2;
98
+ }
99
+ /**
100
+ * THE FIRST BODY IN THIS ROOM THE DEAL CANNOT BE STRUCK IN FRONT OF,
101
+ * or undefined when the room will do.
102
+ *
103
+ * Lives here rather than in game.ts so it can be tested against a
104
+ * catalog the way every other faction rule is -- and because "who
105
+ * spoils a deal" is a faction question, not a Game one.
106
+ *
107
+ * SIGHT IS THE TEST, NOT EARSHOT, and getting that backwards was the
108
+ * first thing tried. Earshot is wrong twice over: a badge who cannot
109
+ * make out the WORDS can still make out the MEETING, and canHearSpeech
110
+ * deliberately refuses an unplaced listener -- which is precisely the
111
+ * patrol officer WORKING the room, the exact witness the report is
112
+ * about. The reporter's own words are about presence: "there wouldn't
113
+ * be a 'meet' if there was a police officer STANDING THERE."
114
+ *
115
+ * @param sees the engine's own "can this observer see that actor"
116
+ * (utilities/spots.ts wouldNotice), passed in so this
117
+ * module stays free of the spatial layer.
118
+ */
119
+ export function businessWitness(client, others, sees) {
120
+ const dealer = factionOf(client) ?? SHADOWS;
121
+ for (const other of others) {
122
+ if (other === client)
123
+ continue;
124
+ if (other.isIncapacitated?.())
125
+ continue;
126
+ if (!client.canPerceive(other))
127
+ continue;
128
+ if (!sees(client, other))
129
+ continue;
130
+ if (!wouldTalkBusinessNear(dealer, other))
131
+ return other;
132
+ }
133
+ return undefined;
134
+ }
135
+ //# sourceMappingURL=factions.js.map
@@ -162,6 +162,23 @@ export function buildReproScene(fixture) {
162
162
  throw new ReproFixtureError(`"${misplacedIce.name}" is ice, so it cannot also stand in a doorway, guard, circulate or carry a gun. `
163
163
  + `Ice lives in the host, not on the floor -- drop the meat-side flags, or drop "ice".`);
164
164
  }
165
+ // HOSTILITY SAID TWICE IS DECORATION ONCE (aCErAfvEW7G23FWDM). Ice is
166
+ // opposition BY CONSTRUCTION -- scene-factory makes a matrix-plane
167
+ // actor hostile unless the seed says otherwise -- so a fixture
168
+ // declaring both is asserting the flag rather than the rule, which is
169
+ // the objection the `ice` comment in repro.ts raises and is right
170
+ // about. A street doc who opens fire on the patient is not a fixture
171
+ // either; it is two fixtures wearing one name.
172
+ const hostileIce = npcs.find(n => n.hostile && n.ice);
173
+ if (hostileIce) {
174
+ throw new ReproFixtureError(`"${hostileIce.name}" is asked to be both ice and hostile. Ice is opposition by construction -- `
175
+ + `drop "hostile", or drop "ice" if you meant a hostile person.`);
176
+ }
177
+ const hostileDoc = npcs.find(n => n.hostile && n.doc);
178
+ if (hostileDoc) {
179
+ throw new ReproFixtureError(`"${hostileDoc.name}" is asked to be both a street doc and hostile. A doc who opens fire on the `
180
+ + `patient is two fixtures -- split them, or pick one.`);
181
+ }
165
182
  // A SEED HAS ONE PLANE. astral maps to 'astral' the same way ice maps
166
183
  // to 'matrix' (npc-seed.ts) -- asking for both is asking the builder
167
184
  // to pick a lie.
@@ -379,6 +396,13 @@ export function buildReproScene(fixture) {
379
396
  // string, which would read as a spot whose name is nothing.
380
397
  ...(npc.circulating ? {} : { startSpot: inDoorway ? REPRO_DOORWAY_SPOT : REPRO_FLOOR_SPOT }),
381
398
  guarding: npc.guarding === true,
399
+ // OPPOSITION, IN THE MEAT (aCErAfvEW7G23FWDM). Straight onto
400
+ // the seed's own `hostile`, which scene-factory reads and the
401
+ // hostile-contact ladder then drives by itself -- notice,
402
+ // contact, provocation, patience. Emitted only when asked, so
403
+ // a fixture that says nothing builds the same peaceable NPC it
404
+ // always did.
405
+ ...(npc.hostile ? { hostile: true } : {}),
382
406
  lifestyleChoices: [],
383
407
  combat: {
384
408
  body: 4, agility: 4, reaction: 4, combatSkill: 3, strength: 4,
@@ -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';
@@ -151,6 +152,8 @@ import { MoveCommand, RunCommand, SprintCommand } from './commands/move.js';
151
152
  import { ClimbCommand, DescendCommand } from './commands/climb.js';
152
153
  import { HintsCommand } from './commands/hints.js';
153
154
  import { hint } from './utilities/hints.js';
155
+ import { businessWitness } from './factions.js';
156
+ import { wouldNotice } from './utilities/spots.js';
154
157
  import { camerasLive, liveFootage } from './utilities/surveillance.js';
155
158
  import { InstallCommand } from './commands/install.js';
156
159
  import { BuyCommand } from './commands/buy.js';
@@ -708,7 +711,7 @@ export default class Game {
708
711
  Logger.getInstance().write(`Loose end "${c.id}" left: ${erased ? 'ERASED' : 'still open'}; hub is the scene again.`);
709
712
  this.requestSave('cleanup');
710
713
  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}`
714
+ ? `\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
715
  : `\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
716
  }
714
717
  /** The HUD's read on the district (bands, never the number). */
@@ -2399,6 +2402,38 @@ export default class Game {
2399
2402
  * Completing the objective before ever meeting them still collapses
2400
2403
  * cleanly -- the wrap-up is the only beat that moves money.
2401
2404
  */
2405
+ /**
2406
+ * ANYBODY IN THIS ROOM THE JOHNSON WOULD NOT TALK IN FRONT OF
2407
+ * (aCErAfvEW7G23FWDM). Returns the first, or undefined when the room
2408
+ * is safe to do business in.
2409
+ *
2410
+ * SIGHT IS THE TEST, NOT EARSHOT, and getting that backwards is worth
2411
+ * recording because it was the first thing tried. Earshot is the
2412
+ * wrong question twice over: a badge who cannot make out the words
2413
+ * can still make out the MEETING, and -- the part that actually broke
2414
+ * it -- canHearSpeech refuses an unplaced listener, so a patrol
2415
+ * officer WORKING THE ROOM is exactly the witness it excludes. The
2416
+ * reporter's own words are about presence: "there wouldn't be a
2417
+ * 'meet' if there was a police officer STANDING THERE."
2418
+ *
2419
+ * wouldNotice is the engine's existing "can this observer see that
2420
+ * actor" -- distance, cover and all -- so a cop at the far end of a
2421
+ * warehouse still counts and one behind a wall does not.
2422
+ */
2423
+ witnessesUnsafeForBusiness(clientName) {
2424
+ const client = this.scene?.getActor?.(clientName);
2425
+ if (!client)
2426
+ return undefined;
2427
+ const room = client.currentLocation;
2428
+ if (!room)
2429
+ return undefined;
2430
+ // THE RUNNER AND THEIR OWN CREW ARE WHO THE DEAL IS WITH, so they
2431
+ // are filtered out here rather than inside the faction rule -- that
2432
+ // is a fact about this Game, not about factions.
2433
+ const others = (this.scene?.getActorsInRoom?.(room) ?? [])
2434
+ .filter(a => !this.scene?.isHumanControlled?.(a) && !a.allyOf);
2435
+ return businessWitness(client, others, wouldNotice);
2436
+ }
2402
2437
  onClientInteraction(npcName) {
2403
2438
  const client = this.currentClient;
2404
2439
  if (!client || !this.scene || this.scene === this._hubScene)
@@ -2410,6 +2445,25 @@ export default class Game {
2410
2445
  const payout = this.scene.getWinCondition()?.payout ?? 0;
2411
2446
  const lines = [];
2412
2447
  if (!client.met) {
2448
+ // A MEET WITH A BADGE IN IT IS NOT A MEET (aCErAfvEW7G23FWDM:
2449
+ // "there wouldn't be a 'meet' if there was a police officer
2450
+ // standing there").
2451
+ //
2452
+ // The reported session, 10:57. Ms. Quill laid out 8,500 nuyen
2453
+ // across a table with Patrol Officer Kessler working the same
2454
+ // room, and the WRITING knew -- she clocked him on arrival and
2455
+ // told the runner to keep his questions off the ledger. The
2456
+ // ENGINE had no idea, because the Meet is a name match on
2457
+ // currentClient and nothing else, and the scene generator demands
2458
+ // staff for any bar.
2459
+ //
2460
+ // Held, not cancelled: the job is still there and the Johnson is
2461
+ // still willing. Deal with the room, or take it somewhere else.
2462
+ const overheard = this.witnessesUnsafeForBusiness(client.name);
2463
+ if (overheard) {
2464
+ Logger.getInstance().write(`Client meet held off: ${overheard.name} is within earshot of ${client.name}.`);
2465
+ return `${client.name} sees ${overheard.name} in the room and lets the sentence die. Not here.${hint(` (Somewhere quieter, or without them.)`)}`;
2466
+ }
2413
2467
  client.met = true;
2414
2468
  lines.push(`THE MEET: ${client.name} lays out the terms -- ${payout}¥ certified, in full, on delivery.${hint(` (The rate is negotiable exactly once: "haggle" while you have them.)`)}`);
2415
2469
  Logger.getInstance().write(`Client meet: ${this.player.name} met ${client.name}, terms ${payout} lump sum on delivery.`);
@@ -2692,7 +2746,14 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2692
2746
  CommandFactory.registerCommand('open', OpenCommand);
2693
2747
  CommandFactory.registerCommand('close', CloseCommand);
2694
2748
  CommandFactory.registerCommand('put', PutCommand);
2749
+ // TELL IS THE VERB (en3p6eCpiRuqsTSKm: "let's adjust 'talk-to' to
2750
+ // 'tell' to be more concise"). `talk-to` stays registered as an
2751
+ // alias rather than being deleted: it is the spelling in every
2752
+ // transcript, every NPC brief and every backlog item written before
2753
+ // today, and a player who learned it should not be told it is gone.
2754
+ CommandFactory.registerCommand('tell', TalkCommand);
2695
2755
  CommandFactory.registerCommand('talk-to', TalkCommand);
2756
+ CommandFactory.registerCommand('shout', ShoutCommand);
2696
2757
  CommandFactory.registerCommand('give', GiveCommand);
2697
2758
  CommandFactory.registerCommand('read', ReadCommand);
2698
2759
  CommandFactory.registerCommand('kill', KillCommand);
@@ -5955,7 +6016,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5955
6016
  // works the grid while you're back in the meat (the AR-drop exit).
5956
6017
  const remoteAgent = this.companions.find(c => c.kind === 'agent');
5957
6018
  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}`);
6019
+ 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
6020
  }
5960
6021
  // Off-plane context: which reality you're in, your persona's Matrix
5961
6022
  // track (cold-sim damage lands there), and where your empty body lies.
@@ -5974,7 +6035,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5974
6035
  const track = jackedDeck
5975
6036
  ? ` Deck A${jackedDeck.attack} S${jackedDeck.sleaze} D${jackedDeck.dataProcessing} F${jackedDeck.firewall} ${jackedDeck.deckSummary()}${player.simMode === 'hot' ? ' +biofeedback' : ''}${pgms}`
5976
6037
  : ` (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}`);
6038
+ lines.push(`{light-blue-fg}▣ MATRIX ${(player.simMode ?? 'cold').toUpperCase()}-SIM${track} -- body at ${player.bodyRoom?.name ?? '???'}{/light-blue-fg}`);
5978
6039
  }
5979
6040
  else if (player.plane === 'astral') {
5980
6041
  lines.push(`{magenta-fg}✧ ASTRAL -- ${player.astralTicks} move${player.astralTicks === 1 ? '' : 's'} out -- body at ${player.bodyRoom?.name ?? '???'}{/magenta-fg}`);
@@ -6066,7 +6127,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6066
6127
  const dockW = mapWidth ?? 15;
6067
6128
  const lines = hosts.length > 0
6068
6129
  ? hosts.map(h => hostDockLine(h, this.player, dockW))
6069
- : ['{lightblue-fg}nothing overhead{/lightblue-fg}'];
6130
+ : ['{light-blue-fg}nothing overhead{/light-blue-fg}'];
6070
6131
  lines.push(caption(this.player.insideHost ? `inside ${this.player.insideHost.name}` : `on the grid from ${gridVicinity(this.player).name}`, mapWidth));
6071
6132
  this.setMapOverlay(lines);
6072
6133
  if (onTheGrid) {
@@ -6575,7 +6636,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6575
6636
  // technomancer-gated (a sprite task, SR5 p.256) and refuses anyone
6576
6637
  // else, so the Emerged shelf is where its audience looks.
6577
6638
  { 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']] },
6639
+ { 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
6640
  { title: 'street', entries: [['abandon'], ['browse', 'buy'], ['sell'], ['catalog'], ['source', 'procure'], ['work', 'gig'], ['jobs'], ['journal'], ['perform'], ['palm'], ['docwagon'], ['treat'], ['eat'], ['drink'], ['lifestyle'], ['garage'], ['pay']] },
6580
6641
  { title: 'barriers', entries: [['pick'], ['breach'], ['dispel', 'dispell'], ['bluff'], ['unlock'], ['lock']] },
6581
6642
  { title: 'character', entries: [['sheet', 'stats'], ['advance'], ['qualities', 'quality'], ['sin', 'sins', 'papers'], ['initiate', 'submerge'], ['install', 'chrome']] },