@maka/maka-cli 5.200.0 → 5.201.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 (38) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +10 -0
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/compile.js +2 -2
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.js +7 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +7 -6
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js +10 -2
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +20 -2
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/erase-mark.js +237 -0
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +20 -11
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/hide.js +3 -2
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/jack.js +3 -2
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +3 -2
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +15 -5
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/overwatch.js +3 -2
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +36 -10
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/sprite-power.js +85 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/sprites.js +12 -3
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +3 -2
  19. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +139 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/game.js +90 -2
  21. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +8 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +19 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +8 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +44 -0
  25. package/bundle/typescript/src/commands/game/sideQuest/sprite-powers.js +48 -7
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/cleanup-errand.js +67 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/utilities/cleanup-hire.js +113 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/utilities/drone-prose.js +11 -3
  29. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-names.js +42 -0
  30. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +10 -5
  31. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +37 -10
  32. package/bundle/typescript/src/commands/game/sideQuest/utilities/host-combat.js +12 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.js +22 -0
  34. package/bundle/typescript/src/commands/game/sideQuest/utilities/mechanics-audience.js +71 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +7 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/utilities/programs.js +7 -1
  37. package/bundle/typescript/src/commands/game/sideQuest/utilities/sprite-power-actions.js +270 -0
  38. package/package.json +1 -1
@@ -27,18 +27,28 @@
27
27
  * sprite type, and the five types' assignments account for all nine
28
28
  * exactly once -- the book's own list closes.
29
29
  *
30
- * WHAT THIS IS NOT. These are REFERENCE, not mechanics: no power below
31
- * is wired to a die roll in this engine yet, and `sprites.ts` says so
32
- * on the panel rather than implying otherwise. Listing an ability the
33
- * game cannot actually perform, without saying so, is the same defect
34
- * as a refusal naming a route that does not exist -- the game has paid
35
- * for that shape more than once. The listing answers "what is this
36
- * thing FOR", which is what was asked; it does not promise a verb.
30
+ * THIS TABLE IS NOW THE CATALOGUE BEHIND REAL VERBS (2026-09-15). It
31
+ * shipped as reference only -- "no power below is wired to a die roll in
32
+ * this engine yet", and sprites.ts said so on the panel rather than
33
+ * implying otherwise. Eight of the nine are moves you can type now:
34
+ * utilities/sprite-power-actions.ts resolves them, keyed off the same
35
+ * five sprite keys, so the panel and the mechanics cannot drift.
36
+ *
37
+ * STABILITY IS DELIBERATELY STILL REFERENCE, and `runnable` below is how
38
+ * each entry says which it is. Stability demotes glitches, and glitches
39
+ * are read at sixteen scattered sites in this engine (rest, climbing,
40
+ * haggling, barrier combat, hiring...). Wiring it at three of them would
41
+ * produce a power that works where someone happened to look and silently
42
+ * does nothing everywhere else -- which is exactly the "wrong ability on
43
+ * a sprite is worse than a missing one" case (CLAUDE.md). It wants the
44
+ * glitch contract changed in one place first, and that is its own piece
45
+ * of work.
37
46
  */
38
47
  /** All nine, alphabetical as the book lists them (p.256). */
39
48
  export const SPRITE_POWERS = [
40
49
  {
41
50
  key: 'camouflage',
51
+ runnable: true,
42
52
  name: 'Camouflage',
43
53
  effect: 'Hides a file inside another file, invisible to Matrix searches. Only a Matrix Perception Test specifically hunting the hidden file will turn it up -- and the sprite has to make that test too, to get its own file back out.',
44
54
  test: undefined,
@@ -46,6 +56,7 @@ export const SPRITE_POWERS = [
46
56
  },
47
57
  {
48
58
  key: 'cookie',
59
+ runnable: true,
49
60
  name: 'Cookie',
50
61
  effect: 'Tags a persona with a silent tracking file rated at the sprite\'s Level. It logs every host entered, every program used, and who was talked to and when (not what was said). Net hits set the depth: one hit is a bare outline, four or more a detailed report. At a time set when it was placed the file ships itself back to the sprite -- and deletes itself instead if the sprite has left the Matrix. A Matrix Perception Test on the carrier finds it; unprotecting it and deleting it removes it.',
51
62
  test: 'Hacking + Resonance [Sleaze] v. Intuition + Firewall',
@@ -53,6 +64,7 @@ export const SPRITE_POWERS = [
53
64
  },
54
65
  {
55
66
  key: 'diagnostics',
67
+ runnable: true,
56
68
  name: 'Diagnostics',
57
69
  effect: 'Reads a device down to its seams and helps somebody use or repair it, as a Teamwork Test. Any hits give +1 limit, and each hit adds a die to their pool. It takes the sprite\'s whole attention -- the bonus lasts until it drops the power or does anything else.',
58
70
  test: 'Simple Hardware + Level [Data Processing]',
@@ -60,6 +72,7 @@ export const SPRITE_POWERS = [
60
72
  },
61
73
  {
62
74
  key: 'electron-storm',
75
+ runnable: true,
63
76
  name: 'Electron Storm',
64
77
  effect: 'Engulfs a persona in a sustained barrage of corrupting datastreams. On the first hit and on every action the sprite spends sustaining it, the target takes (Resonance) DV Matrix damage, resisted as normal, plus 2 points of noise. If the sprite takes any Matrix damage of its own, every storm it is sustaining ends at once.',
65
78
  test: 'Cybercombat + Resonance [Attack] v. Intuition + Firewall',
@@ -67,6 +80,7 @@ export const SPRITE_POWERS = [
67
80
  },
68
81
  {
69
82
  key: 'gremlins',
83
+ runnable: true,
70
84
  name: 'Gremlins',
71
85
  effect: 'Makes a device misbehave -- a jammed control, a looped signal, a faulty reading. Success is a glitch; four or more net hits is a critical one, and the device crashes, burns out, or bites its user.',
72
86
  test: 'Hardware + Level [Attack] v. Device Rating + Firewall',
@@ -74,6 +88,7 @@ export const SPRITE_POWERS = [
74
88
  },
75
89
  {
76
90
  key: 'hash',
91
+ runnable: true,
77
92
  name: 'Hash',
78
93
  effect: 'Seals a file with a Resonance algorithm only that sprite can undo, for up to (Level x 10) Combat Turns. Put the file down and it reverts to normal. Destroy the sprite while it still carries the file and the file is corrupted for good.',
79
94
  test: undefined,
@@ -81,6 +96,7 @@ export const SPRITE_POWERS = [
81
96
  },
82
97
  {
83
98
  key: 'stability',
99
+ runnable: false,
84
100
  name: 'Stability',
85
101
  effect: 'Steadies any persona or device the sprite holds a mark on: standard glitches are ignored outright and critical glitches drop to standard. It stops induced accidents too, Gremlins included -- which is why a machine sprite can both cause and prevent them.',
86
102
  test: undefined,
@@ -88,6 +104,7 @@ export const SPRITE_POWERS = [
88
104
  },
89
105
  {
90
106
  key: 'suppression',
107
+ runnable: true,
91
108
  name: 'Suppression',
92
109
  effect: 'Baffles a host into hesitating. A sprite using this inside a host delays any IC it launches by (Level / 2) Combat Turns, and delayed IC can neither act nor be targeted.',
93
110
  test: undefined,
@@ -95,6 +112,7 @@ export const SPRITE_POWERS = [
95
112
  },
96
113
  {
97
114
  key: 'watermark',
115
+ runnable: true,
98
116
  name: 'Watermark',
99
117
  effect: 'Marks an icon invisibly, readable only by Resonance-driven things -- a way to leave messages on Matrix objects that nobody else can see. A new watermark overwrites an old one, and it lasts as long as the icon does unless somebody erases the Matrix signature.',
100
118
  test: undefined,
@@ -108,6 +126,29 @@ export const SPRITE_POWER_SETS = [
108
126
  { spriteKey: 'fault', powers: ['electron-storm'], skills: ['Computer', 'Cybercombat', 'Hacking'], page: 'p.259' },
109
127
  { spriteKey: 'machine', powers: ['diagnostics', 'gremlins', 'stability'], skills: ['Computer', 'Electronic Warfare', 'Hardware'], page: 'p.259' },
110
128
  ];
129
+ const MATRIX_BY_TYPE = {
130
+ data: l => ({ attack: l - 1, sleaze: l, dataProcessing: l + 4, firewall: l + 1 }),
131
+ fault: l => ({ attack: l + 3, sleaze: l, dataProcessing: l + 1, firewall: l + 2 }),
132
+ machine: l => ({ attack: l + 1, sleaze: l, dataProcessing: l + 3, firewall: l + 2 }),
133
+ crack: l => ({ attack: l, sleaze: l + 3, dataProcessing: l + 2, firewall: l + 1 }),
134
+ courier: l => ({ attack: l, sleaze: l + 3, dataProcessing: l + 1, firewall: l + 2 }),
135
+ };
136
+ export function spriteMatrix(spriteKey, level) {
137
+ const l = Math.max(1, level);
138
+ const build = MATRIX_BY_TYPE[spriteKey] ?? MATRIX_BY_TYPE.courier;
139
+ const attrs = build(l);
140
+ return {
141
+ attack: Math.max(0, attrs.attack),
142
+ sleaze: Math.max(0, attrs.sleaze),
143
+ dataProcessing: Math.max(0, attrs.dataProcessing),
144
+ firewall: Math.max(0, attrs.firewall),
145
+ resonance: l,
146
+ };
147
+ }
148
+ /** A sprite's Matrix condition monitor: 8 + (Level / 2) boxes (p.254). */
149
+ export function spriteMatrixBoxes(level) {
150
+ return 8 + Math.floor(Math.max(1, level) / 2);
151
+ }
111
152
  /** One power by key. */
112
153
  export function spritePower(key) {
113
154
  return SPRITE_POWERS.find(p => p.key === key);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * PAYING SOMEBODY ELSE TO SCRUB THE FOOTAGE (user 2026-09-15: "I'd like
3
+ * 'cleanup' jobs to allow hiring a crew. This is for situations where the
4
+ * runner (say, Jynx -- street samurai) cannot access the matrix").
5
+ *
6
+ * THE HOLE THIS FILLS. A run that ends with live cameras leaves an
7
+ * ILooseEnd, and the hub nags about it every homecoming -- "Doc Wu's
8
+ * Clinic still has your face from 'Ash and Ledger'" -- while the only
9
+ * route it advertises is closed to most runners. `hop` needs a persona
10
+ * (commands/hop.ts), `jack in` needs a cyberdeck AND a direct neural
11
+ * interface (commands/jack.ts), and the cleanup scene is deliberately
12
+ * crew-free: "NO CLIENT, NO PAYOUT, NO NPCS" (factories/cleanup-seed.ts).
13
+ * So a street samurai was handed a consequence with no lever, and a
14
+ * +1 Public Awareness on a timer.
15
+ *
16
+ * WHY AN ERRAND AND NOT A CREW SEAT. The playable scene stays exactly as
17
+ * it is. Hiring a shadow decker through a fixer is the SR5 idiom, it
18
+ * respects the no-payout design (you PAY here -- there is no client to
19
+ * bill), and it does not need enterCleanup un-wired from the player or
20
+ * a crew member taught to cross into a Matrix scene. What it costs is
21
+ * nuyen and a little control: you are not there, so you do not get to
22
+ * roll for it.
23
+ *
24
+ * IT RIDES THE LOOSE END ITSELF (ILooseEnd.hired), which is already on
25
+ * the save -- so a hired errand survives a reload with no new save field
26
+ * and no SAVE_VERSION bump, and settleLooseEnds resolves it at the next
27
+ * homecoming like everything else on that record.
28
+ */
29
+ /** What a scrub costs before haggling. */
30
+ export function cleanupQuote(le) {
31
+ // A harder host is a harder job: the decker has to get through the same
32
+ // ice the runner would have. Rooms are how much footage there is to
33
+ // find. Round to something a fixer would actually say out loud.
34
+ const base = 1500 + (le.host.rating * 400) + (Math.max(1, le.rooms.length) * 250);
35
+ return Math.round(base / 100) * 100;
36
+ }
37
+ /**
38
+ * The decker's own test, rolled at the HOMECOMING rather than at hire
39
+ * time -- you paid for a result you do not get to watch.
40
+ *
41
+ * The pool is the fixer's reach (Connection) plus how much they care
42
+ * (Loyalty): a well-connected fixer knows a better decker, and one who
43
+ * likes you sends them sober. Against the host's rating, which is what
44
+ * the decker has to beat.
45
+ *
46
+ * LEVERAGE IS NOT A FAILURE. The file is gone either way; the difference
47
+ * is that somebody else now has a copy, which is a hook rather than a
48
+ * penalty -- the shadows run on favours owed.
49
+ */
50
+ export function cleanupOutcome(netHits, criticalGlitch) {
51
+ if (criticalGlitch)
52
+ return 'botched';
53
+ if (netHits <= 0)
54
+ return 'botched';
55
+ return netHits >= 3 ? 'clean' : 'leverage';
56
+ }
57
+ /** The names a fixer's shadow decker answers to. Flavour, not mechanics. */
58
+ export const SHADOW_DECKERS = ['Static', 'Nyx', 'Palimpsest', 'Ghostwrite', 'Null', 'Sundog'];
59
+ export function pickShadowDecker(le) {
60
+ // Deterministic off the record's own id, so the quote and the job name
61
+ // the same person -- a fixer who offers "Static" does not send "Nyx".
62
+ let h = 0;
63
+ for (const ch of le.id)
64
+ h = (h * 31 + ch.charCodeAt(0)) >>> 0;
65
+ return SHADOW_DECKERS[h % SHADOW_DECKERS.length];
66
+ }
67
+ //# sourceMappingURL=cleanup-errand.js.map
@@ -0,0 +1,113 @@
1
+ import { rollPool, formatRoll } from './dice.js';
2
+ import { fuzzyPickName } from './fuzzy-match.js';
3
+ import { cleanupQuote, pickShadowDecker } from './cleanup-errand.js';
4
+ import { hint } from './hints.js';
5
+ /**
6
+ * "ask <fixer> about cleanup" and "hire cleanup [<site>]" -- the two
7
+ * halves of paying somebody else to scrub your footage. The rules and
8
+ * pricing live in cleanup-errand.ts; this is the player-facing surface,
9
+ * kept out of both crew.ts and ask.ts because it belongs to neither.
10
+ */
11
+ /** The best-placed contact to broker it: reach first, then how much they
12
+ * like you. A fixer with no reach knows no deckers. */
13
+ function brokerFor(game) {
14
+ let best;
15
+ for (const [name, c] of game.contacts.entries()) {
16
+ const score = c.connection * 2 + c.loyalty;
17
+ const bestScore = best ? best.connection * 2 + best.loyalty : -1;
18
+ if (score > bestScore)
19
+ best = { name, connection: c.connection, loyalty: c.loyalty };
20
+ }
21
+ return best;
22
+ }
23
+ /** The open loose end this request means -- named, or the freshest. */
24
+ function pickLooseEnd(game, query) {
25
+ const open = game.looseEnds.filter(le => !le.hired);
26
+ if (open.length === 0)
27
+ return undefined;
28
+ if (!query)
29
+ return open[open.length - 1];
30
+ const picked = fuzzyPickName(query, open.map(le => le.site));
31
+ return picked ? open.find(le => le.site === picked) : undefined;
32
+ }
33
+ /**
34
+ * THE QUOTE (reached from "ask <contact> about cleanup"). No dice, no
35
+ * commitment, and no per-homecoming budget spent: this is a price, not
36
+ * legwork.
37
+ */
38
+ export function cleanupQuoteFor(game, contactName, query = '') {
39
+ const le = pickLooseEnd(game, query);
40
+ if (!le) {
41
+ return game.looseEnds.length > 0
42
+ ? `"Already got someone on that one," ${contactName} says. "Sit tight."`
43
+ : `"Nothing of yours is sitting on anybody's host right now," ${contactName} says. "Enjoy it."`;
44
+ }
45
+ const contact = game.contacts.get(contactName);
46
+ if (!contact)
47
+ return undefined;
48
+ const decker = pickShadowDecker(le);
49
+ const price = cleanupQuote(le);
50
+ return [
51
+ `"${le.site}?" ${contactName} thinks about it. "Got a kid calls themselves ${decker} who scrubs feeds. `,
52
+ `Rating ${le.host.rating} host, ${le.rooms.length} room${le.rooms.length === 1 ? '' : 's'} of it -- ${price} nuyen and your face was never there."`,
53
+ hint(` ("hire cleanup${game.looseEnds.filter(l => !l.hired).length > 1 ? ` ${le.site}` : ''}" to send them in. Negotiation trims the price; the job lands when you next come home.)`),
54
+ ].join('');
55
+ }
56
+ /** THE HIRE: haggle, debit, stamp the record. The job itself resolves at
57
+ * the next homecoming (Game.settleHiredCleanup). */
58
+ export function hireCleanupErrand(actor, game, query, logger) {
59
+ const le = pickLooseEnd(game, query);
60
+ if (!le) {
61
+ const hiredAlready = game.looseEnds.some(le2 => le2.hired);
62
+ if (hiredAlready)
63
+ return `That one's already bought and paid for -- it lands when you next come home.`;
64
+ if (query)
65
+ return `Nothing of yours is sitting on a host called "${query}".`;
66
+ return `Nothing of yours is on anybody's host right now.${hint(` (A run that ends with the cameras still live leaves footage behind -- that is what this buys off.)`)}`;
67
+ }
68
+ const broker = brokerFor(game);
69
+ if (!broker) {
70
+ return `You don't know anybody who knows a decker.${hint(` (Contacts broker this -- trade with people, run their errands. "contacts" shows who talks to you.)`)}`;
71
+ }
72
+ const asking = cleanupQuote(le);
73
+ const decker = pickShadowDecker(le);
74
+ // ONE NEGOTIATION PASS, the same fence-counter school "hire" uses on a
75
+ // crew cut -- against the fixer's own Charisma-ish reach.
76
+ const negotiation = actor.skillRating('negotiation');
77
+ const mine = rollPool(Math.max(1, actor.charisma + (negotiation > 0 ? negotiation : -1)
78
+ + actor.socialRep + actor.bonus('social') + actor.woundModifier - actor.sustainingPenalty));
79
+ const theirs = rollPool(Math.max(1, broker.connection + 2));
80
+ const net = mine.hits - theirs.hits;
81
+ // A critical glitch talks the price UP -- you argued badly and they
82
+ // remembered it (the shape haggle.ts and hire already use).
83
+ const price = mine.criticalGlitch
84
+ ? Math.round(asking * 1.2 / 100) * 100
85
+ : Math.max(Math.round(asking * 0.6 / 100) * 100, asking - Math.max(0, net) * 200);
86
+ logger.meta(`Cleanup terms -- you (Cha + Negotiation): ${formatRoll(mine)}`);
87
+ logger.meta(` ${broker.name} (Connection ${broker.connection}): ${formatRoll(theirs)} -- ${price} nuyen (asked ${asking})`);
88
+ if (actor.currency < price) {
89
+ return `${broker.name} names ${price} nuyen for ${le.site}. You're carrying ${actor.currency}.${hint(` (Nobody scrubs a host on credit.)`)}`;
90
+ }
91
+ actor.adjustCurrency(-price);
92
+ game.hireCleanup(le, {
93
+ fixer: broker.name,
94
+ price,
95
+ decker,
96
+ // Banked now so a loyalty shift before the homecoming cannot
97
+ // retroactively change a job already bought.
98
+ pool: broker.connection + broker.loyalty,
99
+ });
100
+ actor.performAction('hires a scrub', `${decker} on ${le.site}'s feeds`);
101
+ game.requestSave?.('cleanup');
102
+ const haggled = price < asking
103
+ ? `talked down from ${asking}`
104
+ : price > asking
105
+ ? `talked UP from ${asking} -- they didn't like your tone`
106
+ : `at the asking price`;
107
+ return [
108
+ `{light-blue-fg}${broker.name} makes one call and hangs up. "${decker}'s on it."{/light-blue-fg}`,
109
+ `\nYou slide ${price} nuyen across (${haggled}). ${actor.currency} left.`,
110
+ hint(` (Nothing to watch -- you're not the one going in. ${decker} reports back when you next come home from a job.)`),
111
+ ].join('');
112
+ }
113
+ //# sourceMappingURL=cleanup-hire.js.map
@@ -246,7 +246,15 @@ export function droneReconNowhereLine(item) {
246
246
  missile: `The ${item.name} climbs, circles once -- nowhere to go from here.`,
247
247
  });
248
248
  }
249
- export function droneReconLaunchLine(item, rollText, ok) {
249
+ /**
250
+ * THE PROSE NO LONGER CARRIES THE DICE (wjFBY3zgyWzJP9adw sweep). This
251
+ * baked `Piloting: 8d6 -> 2 hits` into the world log while every other
252
+ * player test in the engine sends its anatomy to the Mechanics panel --
253
+ * the recon sweep was the one roll a rigger had to read out of a
254
+ * sentence. `rollText` is gone; commands/drone.ts emits the roll through
255
+ * logger.meta, and this line goes back to being prose.
256
+ */
257
+ export function droneReconLaunchLine(item, ok) {
250
258
  const off = pick(item, {
251
259
  'flier-palm': `The ${item.name} whirs off your palm`,
252
260
  flier: `The ${item.name} lifts off your hand`,
@@ -256,7 +264,7 @@ export function droneReconLaunchLine(item, rollText, ok) {
256
264
  missile: `The ${item.name} kicks off its rail`,
257
265
  });
258
266
  if (!ok)
259
- return `${off} -- Piloting: ${rollText}`;
267
+ return off;
260
268
  const threads = pick(item, {
261
269
  'flier-palm': 'threads the gaps',
262
270
  flier: 'threads the gaps',
@@ -265,7 +273,7 @@ export function droneReconLaunchLine(item, rollText, ok) {
265
273
  anthro: 'goes to look',
266
274
  missile: 'makes a fast pass',
267
275
  });
268
- return `${off} and ${threads} -- Piloting: ${rollText}. Feed incoming:`;
276
+ return `${off} and ${threads}. Feed incoming:`;
269
277
  }
270
278
  export function droneReconReturnLine(item) {
271
279
  return pick(item, {
@@ -77,6 +77,48 @@ export function hostLabel(room, opts = {}) {
77
77
  const bare = room.name.replace(/^the\s+/i, '');
78
78
  return `${opts.capital ? 'The' : 'the'} ${bare} host`;
79
79
  }
80
+ /**
81
+ * WHAT THE HOST RUNS ON, ONCE YOU HAVE PAID FOR IT (np6YBSPsdfAY7NcuZ,
82
+ * Maka: "data like this ... shouldn't be visible without a matrix search
83
+ * on the host").
84
+ *
85
+ * `Host.purpose` is seed content and the generator is told to fill it
86
+ * with "what this place runs on its host and so what is worth protecting
87
+ * inside it" (factories/scene-chunks.ts) -- so in practice it enumerates
88
+ * the archive: "patient intake logs, med supply orders, and the doc's
89
+ * private black book of who paid what for what". Four renderers printed
90
+ * that on the host's OUTSIDE icon with no roll, which handed a passer-by
91
+ * the inventory of a sealed host for free. The file NAMES were already
92
+ * gated (grid-view.ts, Host.searchedBy); only this phrase leaked.
93
+ *
94
+ * TWO TIERS, and both are rolls the engine already makes
95
+ * (commands/search.ts matrixSearch):
96
+ *
97
+ * tier 1 Room.searchedBy -- the threshold-1 public row out on the
98
+ * grid (MATRIX_SEARCH.public). What the place runs on its host
99
+ * is the kind of thing a public search turns up: canon has the
100
+ * icon look like the shop (p.219). This set is also flipped by
101
+ * a MEAT search of the room, and that is intended -- casing the
102
+ * clinic in the flesh is a fair way to learn it keeps a ledger.
103
+ * tier 2 Host.searchedBy -- the threshold-3 archive search from
104
+ * inside, which additionally NAMES the files (grid-view.ts).
105
+ *
106
+ * A cracked host spills it free, exactly as its files already do: the
107
+ * seal is broken, there is nothing left to hide behind.
108
+ *
109
+ * Returns undefined when the seed declared no purpose OR the persona has
110
+ * not earned it, so every caller can `??` it away.
111
+ */
112
+ export function hostPurposeFor(room, actor) {
113
+ const host = room.host;
114
+ if (!host?.purpose)
115
+ return undefined;
116
+ if (host.cracked)
117
+ return host.purpose;
118
+ return host.searchedBy.has(actor.name) || room.searchedBy.has(actor.name)
119
+ ? host.purpose
120
+ : undefined;
121
+ }
80
122
  export function hostNameHint(room) {
81
123
  return room.hasNode ? `the host here answers to "host" or "${room.name}"` : '';
82
124
  }
@@ -1,5 +1,5 @@
1
1
  import { hostConcealedFrom } from './jackpoint.js';
2
- import { matchesHostName, hostLabel, hostAliases } from './grid-names.js';
2
+ import { matchesHostName, hostLabel, hostAliases, hostPurposeFor } from './grid-names.js';
3
3
  import { fuzzyPickName, normalizeLoose } from './fuzzy-match.js';
4
4
  import { MAX_MARKS } from './marks.js';
5
5
  /**
@@ -193,10 +193,15 @@ export function hostLine(room, actor) {
193
193
  // A haven shows no rating and no eyes (jackpoint.ts): it is not a
194
194
  // thing you crack, so the numbers you would crack it with stay off.
195
195
  const rating = room.hostSanctioned ? '' : ` [HR ${room.hostRating}]`;
196
- // The host's business, where the seed declared one (Host.purpose): a
197
- // grid map that tells a clinic's records from a club's door list is
198
- // the map that lets a runner pick a target.
199
- const purpose = room.host?.purpose ? ` (${room.host.purpose})` : '';
196
+ // The host's business, where the seed declared one AND this persona has
197
+ // searched for it (hostPurposeFor, np6YBSPsdfAY7NcuZ). A grid map that
198
+ // tells a clinic's records from a club's door list is the map that lets
199
+ // a runner pick a target -- but it is a Matrix Search that draws it,
200
+ // not a glance. Map rows stay terse when it is unearned: the verb is
201
+ // taught on the host's own icon (grid-view.ts gridIcons), not once per
202
+ // row of a map that may list a dozen.
203
+ const found = hostPurposeFor(room, actor);
204
+ const purpose = found ? ` (${found})` : '';
200
205
  return `${inside ? '▣' : '●'} ${hostLabel(room, { capital: true })}${rating}${purpose}${onGrid} -- ${standing}${alert}${over}${inside ? ' -- INSIDE' : ''}`;
201
206
  }
202
207
  /** The Map dock's one-line host: icon, name, rating, standing -- cut to the dock. */
@@ -2,7 +2,7 @@ import { sameHostSide } from '../models/player.js';
2
2
  import { personasInside } from './matrix-roster.js';
3
3
  import { hostsInReach, hostLine, roomsInReach, gridVicinity } from './grid-reach.js';
4
4
  import { NPC } from '../models/npc.js';
5
- import { hostLabel } from './grid-names.js';
5
+ import { hostLabel, hostPurposeFor } from './grid-names.js';
6
6
  import { isLinked } from './comm-style.js';
7
7
  import { isWatched, camerasLive, canSnoopFeeds } from './surveillance.js';
8
8
  /**
@@ -65,10 +65,13 @@ export function gridIcons(scene, actor, room) {
65
65
  // books never gave it -- boilerplate describing the opposite of what
66
66
  // the place is. A member is standing in a room they belong to.
67
67
  const hr = ` {bold}[HR ${room.hostRating}]{/bold}`;
68
- // WHAT IT IS FOR rides the outside icon too: a host's business is
69
- // public knowledge -- "your local Stuffer Shack has a host icon"
70
- // (p.219) and the icon looks like the shop. The inside stays sealed.
71
- const purpose = room.host?.purpose ? ` (${room.host.purpose})` : '';
68
+ // WHAT IT IS FOR rides the outside icon -- but only once a Matrix
69
+ // Search has turned it up (hostPurposeFor, np6YBSPsdfAY7NcuZ). The
70
+ // icon looking like the shop (p.219) is the NAME, which this line
71
+ // always prints; the seed's purpose phrase is an inventory of the
72
+ // archive, and handing that over for free is the report.
73
+ const found = hostPurposeFor(room, actor);
74
+ const purpose = found ? ` (${found})` : '';
72
75
  if (room.hostSanctioned) {
73
76
  icons.push(`● ${hostLabel(room, { capital: true })} -- three spheres turning over rooms that were never sold to anyone. You're on the list; nothing here is hunting.`);
74
77
  }
@@ -82,7 +85,18 @@ export function gridIcons(scene, actor, room) {
82
85
  // anyone inside it are on the far side of a wall (p.246). The
83
86
  // marks line stays because a mark is the DOOR (p.239), so it is
84
87
  // the one fact about the inside that belongs on the outside.
85
- icons.push(`● ${hostLabel(room, { capital: true })}${hr}${purpose} -- sculpted ice geometry, sealed. Whatever it holds is inside it.${room.hostAlert ? ' Walls UP -- it holds a mark on an intruder.' : ''}${myMarks > 0 ? ` [your marks: ${myMarks}/3]` : ''} (${myMarks > 0 ? '"enter" walks in on your mark; ' : ''}"hack" slips in on sleaze; "hack loud" smashes; "mark" softens it first)`);
88
+ // The verb that EARNS the purpose phrase is offered only while it
89
+ // is still unearned -- a host that has already told you its
90
+ // business has nothing left to sell. Built as a list so a dropped
91
+ // entry cannot leave a dangling separator.
92
+ const ways = [
93
+ ...(myMarks > 0 ? ['"enter" walks in on your mark'] : []),
94
+ '"hack" slips in on sleaze',
95
+ '"hack loud" smashes',
96
+ '"mark" softens it first',
97
+ ...(found ? [] : ['"search" reads what it runs (p.241)']),
98
+ ];
99
+ icons.push(`● ${hostLabel(room, { capital: true })}${hr}${purpose} -- sculpted ice geometry, sealed. Whatever it holds is inside it.${room.hostAlert ? ' Walls UP -- it holds a mark on an intruder.' : ''}${myMarks > 0 ? ` [your marks: ${myMarks}/3]` : ''} (${ways.join('; ')})`);
86
100
  }
87
101
  }
88
102
  // WHERE THE HOST'S OWN ICON ENDS AND THE STREET BEGINS.
@@ -457,7 +471,11 @@ export function gridSculpt(actor, room) {
457
471
  // sculpting is shown as that -- the purpose on the header, the
458
472
  // sculpt where the boilerplate used to stand. The boilerplate stays
459
473
  // for a hand-authored host that declared neither.
460
- const purpose = room.host?.purpose;
474
+ // The purpose is still the searched-for fact in here (hostPurposeFor):
475
+ // standing inside a host is not the same as having read its index.
476
+ // The SCULPT is not gated -- it is the architecture around you, and
477
+ // you are looking at it.
478
+ const purpose = hostPurposeFor(room, actor);
461
479
  const sculpt = room.host?.sculpt;
462
480
  return [
463
481
  `{light-blue-fg}INSIDE :: ${hostLabel(room, { capital: true }).toUpperCase()} [HR ${room.hostRating}]${purpose ? ` -- ${purpose}` : ''}{/light-blue-fg}`,
@@ -559,8 +577,11 @@ export function hostInteriorPanel(scene, actor, host, rows, cols) {
559
577
  const cut = (raw) => `{light-blue-fg}${[...raw].slice(0, Math.max(4, cols)).join('')}{/light-blue-fg}`;
560
578
  const lines = [];
561
579
  lines.push(cut(`INSIDE ${host.name}`));
562
- if (host.purpose)
563
- lines.push(cut(host.purpose));
580
+ // Same gate as every other renderer (hostPurposeFor): the dock is a
581
+ // view, not a back door.
582
+ const purpose = hostPurposeFor(host.over, actor);
583
+ if (purpose)
584
+ lines.push(cut(purpose));
564
585
  const marks = host.marksBy.get(actor.name) ?? 0;
565
586
  lines.push(cut(host.sanctioned
566
587
  ? 'haven -- welcome here'
@@ -574,7 +595,13 @@ export function hostInteriorPanel(scene, actor, host, rows, cols) {
574
595
  continue;
575
596
  lines.push(cut(`◆ ${a.name}`));
576
597
  }
577
- const files = host.files().length;
598
+ // THE COUNT IS PART OF THE ARCHIVE (np6YBSPsdfAY7NcuZ). The icon list
599
+ // above gates the file NAMES on a Matrix Search; this line quietly
600
+ // published how many there were regardless, which is most of what the
601
+ // roll was protecting -- "three files sealed" tells a runner whether
602
+ // the trip is worth it. Same gate, same set.
603
+ const indexed = host.cracked || host.searchedBy.has(actor.name);
604
+ const files = indexed ? host.files().length : 0;
578
605
  if (files > 0)
579
606
  lines.push(cut(`◇ ${files} file${files === 1 ? '' : 's'} ${host.cracked ? 'open' : 'sealed'}`));
580
607
  return lines.slice(0, Math.max(1, rows));
@@ -47,6 +47,18 @@ async function tell(enc, scene, turn) {
47
47
  */
48
48
  export async function hostTurnStart(scene, host, enc) {
49
49
  const out = empty();
50
+ // SUPPRESSION (p.257, sprite-power-actions.ts): a sprite arguing with
51
+ // the host's launch paths delays what it brings up by (Level / 2)
52
+ // Combat Turns, and "delayed IC can't act or be targeted" -- which is
53
+ // exactly "the launch step does not happen this turn". Counted down
54
+ // here so the delay is spent whether or not the host wanted to launch.
55
+ if (host.icLaunchDelay > 0) {
56
+ host.icLaunchDelay -= 1;
57
+ out.lines.push(`{light-blue-fg}Something in ${host.name}'s architecture is arguing with itself -- whatever it meant to bring up does not arrive.{/light-blue-fg}`);
58
+ seatRunningIce(enc, host);
59
+ await tell(enc, scene, out);
60
+ return;
61
+ }
50
62
  hostLaunchStep(host.over, scene, out);
51
63
  seatRunningIce(enc, host);
52
64
  await tell(enc, scene, out);
@@ -18,6 +18,16 @@ export function clearMarksPlacedBy(scene, placerName) {
18
18
  for (const room of Object.values(scene.getRooms())) {
19
19
  if (room.hostMarksBy.delete(placerName))
20
20
  cleared++;
21
+ // A DEVICE'S MARKS ARE MARKS (f79LLqTerep4nKWcA sweep). Device.marksBy
22
+ // is the third ledger -- hack.ts writes it for every slaved and loose
23
+ // icon -- and no wipe had ever touched it, so the maglock you keyed
24
+ // last run still answered to a persona canon says was deleted on
25
+ // reboot (p.236). It also meant `disable` could drive a device on
26
+ // marks the sheet no longer showed you holding.
27
+ for (const device of room.allDevices()) {
28
+ if (device.marksBy.delete(placerName))
29
+ cleared++;
30
+ }
21
31
  }
22
32
  for (const a of scene.allActors) {
23
33
  if (a.panMarksBy.delete(placerName))
@@ -71,6 +81,13 @@ export function describeMarks(scene, placerName) {
71
81
  const n = room.hostMarksBy.get(placerName) ?? 0;
72
82
  if (n > 0)
73
83
  lines.push(`● ${hostLabel(room, { capital: true })} -- ${n} mark${n === 1 ? '' : 's'}${n >= MAX_MARKS ? ' (it thinks you belong)' : ''}`);
84
+ // Devices too -- the ledger `disable` reads to drive a maglock was
85
+ // invisible on the listing that is supposed to show what you hold.
86
+ for (const device of room.allDevices()) {
87
+ const d = device.marksBy.get(placerName) ?? 0;
88
+ if (d > 0)
89
+ lines.push(`▲ ${device.name} -- ${d} mark${d === 1 ? '' : 's'}`);
90
+ }
74
91
  }
75
92
  for (const a of scene.allActors) {
76
93
  const n = a.panMarksBy.get(placerName) ?? 0;
@@ -92,6 +109,11 @@ export function marksPlaced(scene, placerName) {
92
109
  const n = room.hostMarksBy.get(placerName) ?? 0;
93
110
  if (n > 0)
94
111
  out.push({ target: hostLabel(room, { capital: true }), count: n });
112
+ for (const device of room.allDevices()) {
113
+ const d = device.marksBy.get(placerName) ?? 0;
114
+ if (d > 0)
115
+ out.push({ target: device.name, count: d });
116
+ }
95
117
  }
96
118
  for (const a of scene.allActors) {
97
119
  const n = a.panMarksBy.get(placerName) ?? 0;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * WHO IS WATCHING THE DICE (wjFBY3zgyWzJP9adw, Deditri: "the sprite put a
3
+ * mark on the target but there was no output about the rolls in the
4
+ * logs").
5
+ *
6
+ * The sprite's dice WERE rolled. `order <sprite> mark <lock>` runs the
7
+ * real verb with the sprite as actor -- commands/order.ts hands the
8
+ * string to NPC.tryRunCommand, commands/mark.ts re-dispatches into
9
+ * HackCommand, and hack.ts rolls Hack on the Fly against the device
10
+ * exactly as it would for a decker. The roll was then thrown away at the
11
+ * REPORTING layer, by a guard that has been copied into ~30 commands:
12
+ *
13
+ * if (this.scene.isHumanControlled(actor)) this.logger.meta(...)
14
+ *
15
+ * That asks "is the ROLLER a human". The question the Mechanics panel
16
+ * actually wants is "is a HUMAN WATCHING" -- and the codebase already
17
+ * answers it correctly two files over, in CombatExchange.playerWitnesses
18
+ * (utilities/combat-exchange.ts), which is precisely why a sprite's
19
+ * COMBAT rolls have always shown up while the same sprite's ordered
20
+ * mark went silent. One question, asked two ways, in one engine.
21
+ *
22
+ * WHY OWNERSHIP AND NOT PROXIMITY. playerWitnesses is a bystander test:
23
+ * same place, perceivable plane. That is right for a fight, which is an
24
+ * event in a room. It is wrong for an ORDER, which is a thing the player
25
+ * deliberately did: a rigger whose drone is sweeping the floor above,
26
+ * or a technomancer whose sprite is working a host their body is nowhere
27
+ * near, typed that command and is owed its dice. So a companion's rolls
28
+ * are shown to its master wherever it is standing. Anything that is
29
+ * NOT a companion -- IC, a guard, a passer-by -- keeps the old silence
30
+ * and reports through the channels it already had (utilities/ic.ts
31
+ * accumulates into IICTurn.meta; a fight goes through announce).
32
+ *
33
+ * THE FORK MUST STAY EXCLUSIVE. Several commands route the roll to
34
+ * EITHER the Mechanics panel (player) OR their own return string, which
35
+ * for an NPC is its AI history:
36
+ *
37
+ * const isPlayer = scene.isHumanControlled(actor);
38
+ * if (isPlayer) this.logger.meta(`Perception: ${formatRoll(roll)}`);
39
+ * const rollPrefix = isPlayer ? '' : `Perception: ${formatRoll(roll)}\n`;
40
+ *
41
+ * Both halves must read the SAME predicate or an ordered companion
42
+ * prints its dice twice. Callers swapping to showsMechanics must swap
43
+ * both.
44
+ */
45
+ /** The companion ledger entry for this actor, if it is somebody's. */
46
+ function companionEntryFor(scene, actor) {
47
+ return scene.ownerGame?.companions?.find(c => c.npc === actor);
48
+ }
49
+ /**
50
+ * Should this actor's dice anatomy go to the Mechanics panel?
51
+ *
52
+ * True for a human-controlled actor (the old rule, unchanged), and for a
53
+ * companion a human is running -- a sprite, spirit, agent or drone whose
54
+ * master typed the order that got here.
55
+ */
56
+ export function showsMechanics(scene, actor) {
57
+ if (scene.isHumanControlled?.(actor) === true)
58
+ return true;
59
+ return companionEntryFor(scene, actor) !== undefined;
60
+ }
61
+ /**
62
+ * The name to hang on a Mechanics line when the roller is not the player
63
+ * -- "Crack Sprite -- Hack on the Fly (sleaze) on Hatch Maglock: ...".
64
+ * Empty for the player's own rolls, which stay exactly as they read
65
+ * today: a panel that started prefixing the player's own name on every
66
+ * line would be noisier for the common case to serve the rare one.
67
+ */
68
+ export function mechanicsActorPrefix(scene, actor) {
69
+ return scene.isHumanControlled?.(actor) === true ? '' : `${actor.name} -- `;
70
+ }
71
+ //# sourceMappingURL=mechanics-audience.js.map