@maka/maka-cli 5.198.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 (32) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +3 -0
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +3 -0
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/command-registry.js +25 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/command.js +25 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +1 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/decompile.js +3 -3
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +1 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +3 -0
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +49 -0
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +4 -4
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/kill.js +3 -0
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +3 -0
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +3 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js +3 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js +3 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +3 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +52 -1
  19. package/bundle/typescript/src/commands/game/sideQuest/factions.js +135 -0
  20. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +24 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/game.js +53 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +165 -14
  23. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +12 -2
  24. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +84 -8
  25. package/bundle/typescript/src/commands/game/sideQuest/types/repro.js +1 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/beat-echo.js +160 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +25 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/utilities/earshot.js +38 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/utilities/hostile-contact.js +48 -2
  30. package/bundle/typescript/src/commands/game/sideQuest/utilities/narration-limits.js +235 -0
  31. package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-authorization.js +122 -0
  32. package/package.json +1 -1
@@ -152,6 +152,8 @@ import { MoveCommand, RunCommand, SprintCommand } from './commands/move.js';
152
152
  import { ClimbCommand, DescendCommand } from './commands/climb.js';
153
153
  import { HintsCommand } from './commands/hints.js';
154
154
  import { hint } from './utilities/hints.js';
155
+ import { businessWitness } from './factions.js';
156
+ import { wouldNotice } from './utilities/spots.js';
155
157
  import { camerasLive, liveFootage } from './utilities/surveillance.js';
156
158
  import { InstallCommand } from './commands/install.js';
157
159
  import { BuyCommand } from './commands/buy.js';
@@ -2400,6 +2402,38 @@ export default class Game {
2400
2402
  * Completing the objective before ever meeting them still collapses
2401
2403
  * cleanly -- the wrap-up is the only beat that moves money.
2402
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
+ }
2403
2437
  onClientInteraction(npcName) {
2404
2438
  const client = this.currentClient;
2405
2439
  if (!client || !this.scene || this.scene === this._hubScene)
@@ -2411,6 +2445,25 @@ export default class Game {
2411
2445
  const payout = this.scene.getWinCondition()?.payout ?? 0;
2412
2446
  const lines = [];
2413
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
+ }
2414
2467
  client.met = true;
2415
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.)`)}`);
2416
2469
  Logger.getInstance().write(`Client meet: ${this.player.name} met ${client.name}, terms ${payout} lump sum on delivery.`);
@@ -1,5 +1,7 @@
1
1
  import { registerNotice, registerProvocation, PROVOCATION } from '../utilities/hostile-contact.js';
2
2
  import { hint } from '../utilities/hints.js';
3
+ import { judgeNarration } from '../utilities/narration-limits.js';
4
+ import { judgeEcho } from '../utilities/beat-echo.js';
3
5
  import { WOUND_TALK, isClinicRoom, isStreetDoc, streetDocBrief, treatHint } from '../utilities/street-doc.js';
4
6
  import { capitalCase } from 'change-case';
5
7
  import { tableCrewNote, tableDeliveryNote } from '../utilities/table-notes.js';
@@ -687,6 +689,22 @@ export class NPC extends Player {
687
689
  // an agent's deck) that combat-exchange routes damage into;
688
690
  // companionKind flavors the prompt and the HUD.
689
691
  allyOf;
692
+ /**
693
+ * WHO THIS NPC ANSWERS TO (aCErAfvEW7G23FWDM) -- a faction slug, and
694
+ * a catalog row on maka-cli.com holds what that faction is. See
695
+ * factions.ts.
696
+ *
697
+ * DELIBERATELY NOT `allyOf`, which is the master of a bound companion
698
+ * shell and a relationship with the PLAYER. This is a relationship
699
+ * with the WORLD: the thing the engine could never say, so it could
700
+ * never work out that a badge at the next table changes what a
701
+ * Johnson will discuss out loud.
702
+ *
703
+ * Optional, and an NPC without one is the normal case. factionOf()
704
+ * falls back to the ephemeral spawn kind and otherwise answers
705
+ * "nobody in particular", which every rule reads as no opinion.
706
+ */
707
+ faction;
690
708
  /**
691
709
  * SET ONLY BY THE EPHEMERAL GOVERNOR (utilities/ephemeral.ts): this
692
710
  * body is a passing face, not a character. Its presence trims the
@@ -729,6 +747,12 @@ export class NPC extends Player {
729
747
  const parsed = await this.commandRegistry.parse(commandString, this);
730
748
  if (!parsed)
731
749
  return null;
750
+ // A refused verb is a REAL verb the engine would not let this actor
751
+ // run (utilities/npc-authorization.ts). It is not the "unknown
752
+ // command, treat it as a directive" case, so it must not return
753
+ // null -- the refusal itself is the outcome.
754
+ if (!parsed.command)
755
+ return parsed.refusal;
732
756
  return await parsed.command.execute(parsed.args);
733
757
  }
734
758
  async runCommand(commandString) {
@@ -1021,6 +1045,13 @@ description text.`;
1021
1045
  this.logger.error(`${this.name} (combat) tried to perform unknown command: ${commandString}`);
1022
1046
  return '';
1023
1047
  }
1048
+ if (!parsed.command) {
1049
+ // The deterministic combat brain reads refusals as results (see
1050
+ // the header above), so this needs no special case beyond not
1051
+ // executing anything.
1052
+ this._addToHistory(parsed.refusal);
1053
+ return parsed.refusal;
1054
+ }
1024
1055
  const result = await parsed.command.execute(parsed.args);
1025
1056
  if (result)
1026
1057
  this._addToHistory(result);
@@ -1045,7 +1076,16 @@ description text.`;
1045
1076
  return;
1046
1077
  }
1047
1078
  const parsed = await this.commandRegistry.parse(commandString, this);
1048
- if (parsed) {
1079
+ if (parsed && !parsed.command) {
1080
+ // THE MODEL HEARS THE BOUNDARY. Reflecting on a refusal is the
1081
+ // whole point: the reflect loop's own breakers (_triedThisStimulus,
1082
+ // FAILED_RESULT) then stop a second attempt at the same refused
1083
+ // act, so a refusal costs one beat and teaches, instead of
1084
+ // becoming a grind the player watches.
1085
+ this._addToHistory(parsed.refusal);
1086
+ await this.recursiveReflect(commandString, parsed.refusal);
1087
+ }
1088
+ else if (parsed?.command) {
1049
1089
  // Capture the result of the command
1050
1090
  const result = await parsed.command.execute(parsed.args);
1051
1091
  this.relayToMaster(commandString, result);
@@ -1541,7 +1581,7 @@ description text.`;
1541
1581
  }
1542
1582
  }
1543
1583
  const historySummary = this.getHistorySummary();
1544
- const worldEventsSummary = this._scene?.getWorldEventsSummary() ?? 'Nothing notable has happened elsewhere yet.';
1584
+ const worldEventsSummary = this._scene?.getWorldEventsSummary(this) ?? 'Nothing notable has happened elsewhere yet.';
1545
1585
  const objectiveSummary = this._objective
1546
1586
  ? `Your current objective: ${this._objective}`
1547
1587
  : `You don't have a specific objective right now.`;
@@ -1640,7 +1680,12 @@ ${worldEventsSummary}
1640
1680
  this._spokeThisStimulus = true;
1641
1681
  }
1642
1682
  const parsed = await this.commandRegistry.parse(nextCommand, this);
1643
- if (parsed) {
1683
+ if (parsed && !parsed.command) {
1684
+ this._addToHistory(parsed.refusal);
1685
+ this.relayToMaster(nextCommand, parsed.refusal);
1686
+ await this.recursiveReflect(nextCommand, parsed.refusal, recursionDepth + 1, maxRecursion);
1687
+ }
1688
+ else if (parsed?.command) {
1644
1689
  const nextResult = await parsed.command.execute(parsed.args);
1645
1690
  if (nextResult) {
1646
1691
  this._addToHistory(nextResult);
@@ -1744,7 +1789,7 @@ ${worldEventsSummary}
1744
1789
  ? `The overall storyline for this scene is: ${this._scene.story}`
1745
1790
  : `This scene has no overall storyline`;
1746
1791
  const historySummary = this.getHistorySummary();
1747
- const worldEventsSummary = this._scene?.getWorldEventsSummary() ?? 'Nothing notable has happened elsewhere yet.';
1792
+ const worldEventsSummary = this._scene?.getWorldEventsSummary(this) ?? 'Nothing notable has happened elsewhere yet.';
1748
1793
  const objectiveSummary = this._objective
1749
1794
  ? `Your current objective: ${this._objective}`
1750
1795
  : `You don't have a specific objective yet -- if this event gives you one, set it.`;
@@ -1759,7 +1804,26 @@ ${worldEventsSummary}
1759
1804
  // winCondition.payout on completion, so a freely-invented figure in
1760
1805
  // dialogue becomes a broken promise -- found via a real session where
1761
1806
  // the fixer offered "50k" and the job actually paid 2000.
1762
- const payout = this._scene?.getWinCondition?.()?.payout;
1807
+ // Who would have been told the terms: the Johnson who set them, and
1808
+ // the runner's own contacts -- the people a runner actually talks
1809
+ // shop with. Everyone else is a stranger in a bar.
1810
+ const partyToTheDeal = this._scene?.ownerGame?.currentClient?.name === this.name
1811
+ || !!this._scene?.ownerGame?.contacts?.get(this.name);
1812
+ // WHOSE DEAL IS IT (aCErAfvEW7G23FWDM, the reporter's question:
1813
+ // "Do they just 'have all the information' all the time?").
1814
+ //
1815
+ // The figure below is the RUN'S AGREED PRICE, and it was handed to
1816
+ // every non-ephemeral NPC in the scene -- the bartender, the guard
1817
+ // on the door, the fixer three rooms away. Each of them could quote
1818
+ // what the runner is being paid by a Johnson they have never met.
1819
+ //
1820
+ // The BLOCK stays exactly as it was, because the reason for it has
1821
+ // not changed: an invented figure becomes a broken promise, found
1822
+ // in a real session where a fixer offered "50k" against a job that
1823
+ // paid 2000. What changes is WHO IS TOLD -- the parties to the deal
1824
+ // (the client, and anyone on the runner's own contact list, who are
1825
+ // the people a runner would actually have discussed terms with).
1826
+ const payout = partyToTheDeal ? this._scene?.getWinCondition?.()?.payout : undefined;
1763
1827
  const payoutSummary = payout
1764
1828
  ? `The agreed payout for the current job is EXACTLY ${payout} nuyen, paid as ONE LUMP SUM on delivery -- there is no advance and no half up front; never speak as if part has already been paid. Any time you mention payment, reward, or what the job is worth, use exactly this figure -- never invent a different one. Payment transfers automatically when the job completes; you never need to hand it over.`
1765
1829
  : '';
@@ -2265,7 +2329,23 @@ Your objective is to drive the story line.`}
2265
2329
  // ---------- AI call + parsing (defensive) ----------
2266
2330
  try {
2267
2331
  const reply = await AI.ask(prompt);
2268
- this._addToHistory(reply);
2332
+ // WHAT AN NPC REMEMBERS IS WHAT IT DID, NOT WHAT IT DRAFTED
2333
+ // (aCErAfvEW7G23FWDM).
2334
+ //
2335
+ // The RAW, UNPARSED model reply used to be pushed into _history
2336
+ // right here, before anything had looked at it -- so an NPC's
2337
+ // memory filled up with its own "[COMMAND] say ..." scaffolding,
2338
+ // with out-of-character lines the OOC filter went on to suppress,
2339
+ // with beats withheld because the player was not in the room, and
2340
+ // now with narration the prose gate refuses. The model was then
2341
+ // shown all of it as things that happened, and told not to repeat
2342
+ // itself.
2343
+ //
2344
+ // _history is the ONE genuinely perception-filtered channel in
2345
+ // the brief -- hear() and see() write to it, behind every gate in
2346
+ // this file. Letting an undelivered draft in through the side
2347
+ // door is what made it unreliable. What gets recorded now is what
2348
+ // actually reached the world, recorded where that is known.
2269
2349
  const lines = toLines(reply);
2270
2350
  const repaired = splitSpokenLines(lines);
2271
2351
  lines.splice(0, lines.length, ...repaired);
@@ -2325,15 +2405,46 @@ Your objective is to drive the story line.`}
2325
2405
  if (spokenText) {
2326
2406
  const commandText = spokenText.replace('[COMMAND]', '').trim();
2327
2407
  this.logger.write(`${this.name} attempts to say: ${commandText}`);
2328
- try {
2329
- if (commandText.length > 0) {
2330
- this._spokeThisStimulus = true;
2331
- await this.act(commandText);
2332
- }
2408
+ // THE SAME LINE TWICE, ONE STIMULUS LATER. _spokeThisStimulus
2409
+ // allows one speech per stimulus and says nothing about the
2410
+ // NEXT one a second later -- which is how Ms. Quill delivered
2411
+ // the Drowned Choir hook twice in two phrasings at 10:57:18 and
2412
+ // :19 (aCErAfvEW7G23FWDM). Judged on the SPOKEN words, not the
2413
+ // command string, because "say X" and "say X, rephrased" are
2414
+ // different strings and the same beat.
2415
+ const said = commandText.replace(/^say\s+/i, '');
2416
+ // A COMPANION IS NEVER ECHO-GATED. Everything a bound shell says
2417
+ // reaches its master over the link as a REPORT (the companion
2418
+ // block in the brief: "work out loud... report what you find in
2419
+ // ONE short line"), and two drones clearing two rooms say
2420
+ // near-identical things on purpose. The DONE:/FAILED: protocol
2421
+ // rides this channel too, and a swallowed DONE strands a
2422
+ // standing directive forever.
2423
+ const echo = this.allyOf
2424
+ ? { echo: false }
2425
+ : judgeEcho(this.currentLocation, this.name, said);
2426
+ if (echo.echo) {
2427
+ this.logger.write(`${this.name} speech suppressed as an echo of ${echo.of}: ${said.slice(0, 120)}`);
2428
+ // Counted as having spoken. The model made its one speech act
2429
+ // for this stimulus; that it came out as a line the room had
2430
+ // just heard does not buy it another go.
2431
+ this._spokeThisStimulus = true;
2333
2432
  }
2334
- catch (err) {
2335
- this.logger.error(`${this.name} failed to say "${commandText}": ${err}`);
2336
- // continue; don't throw so we can process remaining lines
2433
+ else {
2434
+ try {
2435
+ if (commandText.length > 0) {
2436
+ this._spokeThisStimulus = true;
2437
+ await this.act(commandText);
2438
+ // SAID, therefore remembered. This is the replacement for
2439
+ // the raw-reply write above: what reached the room, in the
2440
+ // words the room heard.
2441
+ this._addToHistory(`${this.name} said: ${said}`);
2442
+ }
2443
+ }
2444
+ catch (err) {
2445
+ this.logger.error(`${this.name} failed to say "${commandText}": ${err}`);
2446
+ // continue; don't throw so we can process remaining lines
2447
+ }
2337
2448
  }
2338
2449
  }
2339
2450
  // Log exposition. Tagged distinctly while this NPC is mid-call (see
@@ -2371,6 +2482,11 @@ Your objective is to drive the story line.`}
2371
2482
  // stop wasting swings and hold position, report clean" -- which
2372
2483
  // is planning, not fiction, and was the bulk of that same bleed.
2373
2484
  const isCompanion = !!this.allyOf;
2485
+ // WHO THE BEAT COULD BE CLAIMING AGENCY OVER -- everyone else in
2486
+ // the room, by name. Read once rather than per line.
2487
+ const bystanders = (this.currentLocation
2488
+ ? this._scene?.getActorsInRoom?.(this.currentLocation) ?? []
2489
+ : []).map(a => a.name).filter(n => n && n !== this.name);
2374
2490
  for (const exposition of expositionLines) {
2375
2491
  if (!exposition?.length)
2376
2492
  continue;
@@ -2378,11 +2494,46 @@ Your objective is to drive the story line.`}
2378
2494
  this.logger.write(`${this.name} out-of-character exposition suppressed: ${exposition.slice(0, 160)}`);
2379
2495
  continue;
2380
2496
  }
2497
+ // NARRATION MAY NOT DO WHAT DICE DECIDE (aCErAfvEW7G23FWDM).
2498
+ // A beat describing hands on another actor, or walking them
2499
+ // somewhere, is asserting an act the engine never ran -- no
2500
+ // defence, no Combat Turn, no consequence. Suppressed, and the
2501
+ // NPC is told why so the next beat can commit to it as a real
2502
+ // command instead.
2503
+ const verdict = judgeNarration(exposition, this.name, bystanders, { holding: this.grappling });
2504
+ if (verdict.refused) {
2505
+ this.logger.write(`${this.name} narration refused (agency over ${verdict.subject}): ${exposition.slice(0, 160)}`);
2506
+ if (verdict.note)
2507
+ this._addToHistory(verdict.note);
2508
+ continue;
2509
+ }
2381
2510
  if (isCompanion || !witnessed) {
2382
2511
  this.logger.write(`${this.name} exposition withheld (${isCompanion ? 'companion: link protocol only' : 'not co-located with the player'}): ${exposition.slice(0, 160)}`);
2383
2512
  continue;
2384
2513
  }
2514
+ // THE SAME BEAT REWRITTEN. Tallow polished the same glass in two
2515
+ // consecutive beats at 11:05:10 (aCErAfvEW7G23FWDM) -- not
2516
+ // identical text, so nothing short of a similarity check could
2517
+ // have caught it, and the engine had none.
2518
+ //
2519
+ // LAST OF THE GATES, DELIBERATELY. It must only ever judge a
2520
+ // line that is actually about to PRINT. Above the withheld
2521
+ // checks it also read companion link traffic and beats from
2522
+ // rooms the player is not in -- and those are FUNCTION, not
2523
+ // flavour: two crew members reporting the same clear room is
2524
+ // two reports, and suppressing the second loses information the
2525
+ // player needs. Flavour repeats; reports coincide.
2526
+ const echoed = judgeEcho(this.currentLocation, this.name, exposition);
2527
+ if (echoed.echo) {
2528
+ this.logger.write(`${this.name} exposition suppressed as an echo of ${echoed.of}: ${exposition.slice(0, 160)}`);
2529
+ continue;
2530
+ }
2385
2531
  this.logger.logWithColor(`${prefix}${exposition}`, inCall ? CALL_COLOR : 'blue', expositionScope);
2532
+ // DID, therefore remembered -- and only now, at the far end of
2533
+ // the OOC filter, the prose gate, the withheld checks and the
2534
+ // echo gate. A line that never printed is not a thing this NPC
2535
+ // did, and must not come back as one in the next brief.
2536
+ this._addToHistory(exposition);
2386
2537
  }
2387
2538
  // Execute other commands
2388
2539
  for (const commandLine of commandLines) {
@@ -10,7 +10,7 @@ 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
12
  import { spotsActive, actorDistanceMeters, voiceBandFor, voiceTag, doorwayKindOf, exitDoorwayKind } from '../utilities/spots.js';
13
- import { canHearSpeech } from '../utilities/earshot.js';
13
+ import { canHearSpeech, canHearShout } from '../utilities/earshot.js';
14
14
  import { synthesizeGrid, } from '../utilities/room-grid.js';
15
15
  export class Room extends AbstractRoom {
16
16
  logger = Logger.getInstance();
@@ -815,7 +815,17 @@ Keep it concise and dramatic.`;
815
815
  * which is the whole point of typing it.
816
816
  */
817
817
  shout(actor, message) {
818
- const listeners = [...this.actors].filter(r => r !== actor && sameHostSide(actor, r));
818
+ const listeners = [...this.actors].filter(r => r !== actor
819
+ && sameHostSide(actor, r)
820
+ // SHOUT_METERS BECOMES A RULE RATHER THAN A COMMENT
821
+ // (aCErAfvEW7G23FWDM). It was declared, documented and never
822
+ // read: this method reached the whole room and computed a
823
+ // distance only to DESCRIBE the shout (voiceTag), never to bound
824
+ // it. In practice 50 m is wider than any room this generator
825
+ // builds, so today this changes nothing -- and that is exactly
826
+ // why it is worth closing now, while it is free, rather than
827
+ // discovering it the first time a scene has a courtyard in it.
828
+ && canHearShout(actor, r));
819
829
  const player = [...this.actors].find(a => !(a instanceof NPC));
820
830
  const targets = actor instanceof NPC
821
831
  ? listeners.filter(r => r === player)
@@ -7,6 +7,45 @@ import { Category, Size, Rating } from '../types/shared/item-enum.js';
7
7
  import { AbstractScene } from '../types/shared/abstracts.js';
8
8
  import { rollPool, formatRoll } from '../utilities/dice.js';
9
9
  import { CombatEncounter, arenaKey, arenaOf, inArena } from '../utilities/combat-turn.js';
10
+ /**
11
+ * CAN THIS READER HAVE HEARD ABOUT IT?
12
+ *
13
+ * Generous by construction, and deliberately so: word on the street is
14
+ * SUPPOSED to travel, and an engine that made every NPC an eyewitness
15
+ * would be a duller and quieter game than the one that shipped. Two
16
+ * rules, both from the report.
17
+ */
18
+ function reaches(event, reader) {
19
+ if (!reader)
20
+ return true;
21
+ const npc = reader;
22
+ // A PASSING FACE DOES NOT GET THE WORLD'S NEWS. An ephemeral is not
23
+ // in the plot and cannot create obligations (utilities/ephemeral.ts
24
+ // says so in as many words) -- and this ring reached them anyway,
25
+ // because it sits outside the `eph` trim in the NPC brief. A
26
+ // bystander who has been on screen for two beats reciting what
27
+ // happened three rooms ago is the ephemeral governor's whole premise
28
+ // inverted.
29
+ if (npc.ephemeral)
30
+ return false;
31
+ // THE MATRIX IS NOT THE STREET. `canPerceive` gates hear() and see()
32
+ // so a meat NPC never WITNESSES a Matrix act, and this channel walked
33
+ // straight around that: a bartender polishing a glass could read
34
+ // "the host MADE the intruder mid-intrusion -- ice hunting", which is
35
+ // a fact about the inside of a host nobody outside it can have.
36
+ //
37
+ // WHICH EVENTS ARE MATRIX-ONLY IS A JUDGEMENT AT THE CALL SITE, and
38
+ // the tagging is deliberately conservative. A bricked camera, a
39
+ // jammed gun and a crash that "carried" are all Matrix ACTIONS with
40
+ // meat-world consequences, and they stay street noise. So does GOD
41
+ // convergence, which is not a leak at all: the whole point of it is
42
+ // that your physical location is reported outward (p.231), so the
43
+ // block hearing about it is the rule working.
44
+ if (event.plane && event.plane !== 'meat') {
45
+ return reader.plane === event.plane;
46
+ }
47
+ return true;
48
+ }
10
49
  export class Scene extends AbstractScene {
11
50
  story;
12
51
  // SYMMETRIC PLAYERS: the scene holds a LIST of human players --
@@ -705,11 +744,16 @@ export class Scene extends AbstractScene {
705
744
  this.logger.write(`removeActorQuietly: ${name} (dead in the save).`);
706
745
  }
707
746
  // ---------------- Persistence accessors (utilities/persistence.ts) ----
747
+ /** THE SAVE FORMAT DOES NOT CHANGE. Where an event happened is
748
+ * in-memory colour for the beat that reads it, not history worth
749
+ * persisting -- a reloaded event is street noise like any other, and
750
+ * flattening here is what keeps save-file.ts's `worldEvents:
751
+ * string[]` true without a migration. */
708
752
  get worldEvents() {
709
- return this._worldEvents;
753
+ return this._worldEvents.map(e => e.text);
710
754
  }
711
755
  restoreWorldEvents(events) {
712
- this._worldEvents = [...events];
756
+ this._worldEvents = events.map(text => ({ text }));
713
757
  }
714
758
  /**
715
759
  * THE ALARM SURVIVES A RELOAD, and it took a peer asking to notice.
@@ -1103,12 +1147,34 @@ export class Scene extends AbstractScene {
1103
1147
  this.logger.write(`${label}: ${net} nuyen credited to "${PAYSTICK_NAME}" (${player.name})${tax > 0 ? ` (${tax} withheld: SIN tax ${Math.round(taxRate * 100)}%)` : ''}.`);
1104
1148
  return { net, tax };
1105
1149
  }
1106
- addWorldEvent(event) {
1107
- this._worldEvents.push(event);
1150
+ /**
1151
+ * WORD ON THE STREET -- and since aCErAfvEW7G23FWDM, word that knows
1152
+ * WHICH street.
1153
+ *
1154
+ * THE DEFECT. This ring is handed to every NPC identically
1155
+ * (getWorldEventsSummary, read by models/npc.ts), and it was
1156
+ * unfiltered by room, by plane, by presence, and by whether the
1157
+ * reader was even a character. `canPerceive` gates hear() and see()
1158
+ * so a meat NPC never witnesses a Matrix act -- but nothing gated
1159
+ * this, and ~30 command files write to it, hack.ts and disable.ts and
1160
+ * edit-file.ts among them.
1161
+ *
1162
+ * The reported session is full of NPCs narrating a runner's Matrix
1163
+ * business back at his slumped body, and this ring is the channel
1164
+ * that fed them: a bartender could read "the host MADE the intruder
1165
+ * mid-intrusion -- ice hunting", which is a fact about the inside of
1166
+ * a host and cannot be known from a bar.
1167
+ *
1168
+ * `where` is optional and an event without it stays global, which is
1169
+ * both the compatibility story for ~30 existing call sites and the
1170
+ * honest default: most of these genuinely ARE street noise.
1171
+ */
1172
+ addWorldEvent(event, where) {
1173
+ this._worldEvents.push({ text: event, ...(where?.plane ? { plane: where.plane } : {}), ...(where?.room ? { room: where.room } : {}) });
1108
1174
  if (this._worldEvents.length > this._maxWorldEvents) {
1109
1175
  this._worldEvents.shift();
1110
1176
  }
1111
- this.logger.write(`World event: ${event}`);
1177
+ this.logger.write(`World event${where?.plane ? ` (${where.plane})` : ''}: ${event}`);
1112
1178
  }
1113
1179
  /**
1114
1180
  * THE SITE HAS GONE LOUD. Idempotent in the sense that matters: a
@@ -1131,7 +1197,16 @@ export class Scene extends AbstractScene {
1131
1197
  ? `THE SITE IS ALARMED -- it has gone loud ${this._alarm.count} times tonight, most recently: ${this._alarm.reason}`
1132
1198
  : `THE SITE IS ALARMED -- ${this._alarm.reason}`;
1133
1199
  }
1134
- getWorldEventsSummary() {
1200
+ /**
1201
+ * WHAT THIS READER HAS HEARD ABOUT (aCErAfvEW7G23FWDM).
1202
+ *
1203
+ * `reader` is optional and omitting it returns everything, which is
1204
+ * what every non-NPC caller wants and what the old signature did.
1205
+ * models/npc.ts passes itself, and that one argument is the whole
1206
+ * difference between a bartender who reads the Matrix and one who
1207
+ * does not.
1208
+ */
1209
+ getWorldEventsSummary(reader) {
1135
1210
  // THE ALARM IS STANDING CONTEXT, NOT A HEADLINE THAT SCROLLS.
1136
1211
  //
1137
1212
  // World events are a 100-entry ring, so on a long run the sentence
@@ -1143,10 +1218,11 @@ export class Scene extends AbstractScene {
1143
1218
  const alarm = this.alarmSummary;
1144
1219
  const head = alarm ? `${alarm}
1145
1220
  ` : '';
1146
- if (this._worldEvents.length === 0) {
1221
+ const heard = this._worldEvents.filter(e => reaches(e, reader));
1222
+ if (heard.length === 0) {
1147
1223
  return `${head}Nothing notable has happened elsewhere yet.`;
1148
1224
  }
1149
- return `${head}Word on the street (things that happened elsewhere in the world):\n${this._worldEvents.map((e, i) => ` ${i + 1}. ${e}`).join('\n')}`;
1225
+ return `${head}Word on the street (things that happened elsewhere in the world):\n${heard.map((e, i) => ` ${i + 1}. ${e.text}`).join('\n')}`;
1150
1226
  }
1151
1227
  getRooms() {
1152
1228
  return this.rooms;
@@ -27,7 +27,7 @@
27
27
  * 2.5.0 adds IReproHost.purpose / .sculpt and IReproItem.file (engine
28
28
  * 1.50.0, hosts with a purpose) -- additive.
29
29
  */
30
- export const REPRO_SCHEMA_VERSION = '2.5.0';
30
+ export const REPRO_SCHEMA_VERSION = '2.6.0';
31
31
  /** THE LIVE REASON, from the field or the card's own words.
32
32
  *
33
33
  * `repro.live` is authoritative -- but the server's validator must