@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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js +6 -6
- package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/command-registry.js +25 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/command.js +25 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/decompile.js +3 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +3 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/enter-host.js +5 -5
- package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +49 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +8 -8
- package/bundle/typescript/src/commands/game/sideQuest/commands/hide.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/hop.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/kill.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +5 -5
- package/bundle/typescript/src/commands/game/sideQuest/commands/map.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/mark.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +19 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/shout.js +69 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/snoop.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/sprites.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +3 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +7 -7
- package/bundle/typescript/src/commands/game/sideQuest/commands/thread.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +75 -1
- package/bundle/typescript/src/commands/game/sideQuest/factions.js +135 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +24 -0
- package/bundle/typescript/src/commands/game/sideQuest/game.js +66 -5
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +165 -14
- package/bundle/typescript/src/commands/game/sideQuest/models/room.js +90 -57
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +84 -8
- package/bundle/typescript/src/commands/game/sideQuest/types/repro.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/beat-echo.js +160 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +25 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/earshot.js +153 -10
- package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +14 -14
- package/bundle/typescript/src/commands/game/sideQuest/utilities/hostile-contact.js +48 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/matrix-style.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/narration-limits.js +235 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/npc-authorization.js +122 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/overwatch.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
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
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
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) {
|
|
@@ -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 {
|
|
12
|
+
import { spotsActive, actorDistanceMeters, voiceBandFor, voiceTag, doorwayKindOf, exitDoorwayKind } from '../utilities/spots.js';
|
|
13
|
+
import { canHearSpeech, canHearShout } 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,77 @@ 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 (
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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
|
|
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));
|
|
797
829
|
const player = [...this.actors].find(a => !(a instanceof NPC));
|
|
798
|
-
const
|
|
830
|
+
const targets = actor instanceof NPC
|
|
799
831
|
? listeners.filter(r => r === player)
|
|
800
832
|
: listeners;
|
|
801
|
-
const
|
|
802
|
-
for (const recipient of
|
|
833
|
+
const crowd = this.crowdNote(targets);
|
|
834
|
+
for (const recipient of targets) {
|
|
803
835
|
const band = voiceBandFor(actorDistanceMeters(this, actor, recipient));
|
|
804
836
|
// The crowd note goes LAST, after the voice tag: both are system
|
|
805
837
|
// dressing, and the tag describes the words while the note
|
|
806
838
|
// describes the room.
|
|
807
|
-
void Promise.resolve(recipient.hear(actor, `${message}${voiceTag(band)}${recipient instanceof NPC ?
|
|
839
|
+
void Promise.resolve(recipient.hear(actor, `${message}${voiceTag(band)}${recipient instanceof NPC ? crowd : ''}`, { broadcast: true })).catch(err => {
|
|
808
840
|
this.logger.error(`${recipient.name} failed to hear ${actor.name} shout: ${err}`);
|
|
809
841
|
});
|
|
810
842
|
}
|
|
843
|
+
return targets;
|
|
811
844
|
}
|
|
812
845
|
addExit(direction, targetRoom, keyItemOrSolution) {
|
|
813
846
|
let door = this.findExistingDoor(targetRoom);
|
|
@@ -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 =
|
|
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
|
-
|
|
1107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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${
|
|
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.
|
|
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
|
|
@@ -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 `{
|
|
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
|