@maka/maka-cli 5.207.0 → 5.209.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 (26) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/complete.js +125 -0
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/disable.js +14 -14
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/erase-mark.js +1 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/force.js +40 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +61 -54
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +2 -2
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/mark.js +59 -38
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +14 -4
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/snoop.js +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/subdue.js +98 -17
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/tap.js +16 -5
  13. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +85 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/game.js +49 -35
  15. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +3 -55
  16. package/bundle/typescript/src/commands/game/sideQuest/ui.js +32 -30
  17. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +20 -8
  18. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js +2 -3
  19. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +50 -17
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-view.js +56 -9
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/marks.js +3 -2
  22. package/bundle/typescript/src/commands/game/sideQuest/utilities/nameables.js +107 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +3 -7
  24. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +1 -1
  25. package/package.json +1 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/stance.js +0 -121
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.207.0",
3
+ "version": "5.209.0",
4
4
  "type": "module",
5
5
  "summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
6
6
  "description": "A command line tool for scaffolding Meteor 3.x applications using React.",
@@ -0,0 +1,125 @@
1
+ import { Command } from './command.js';
2
+ import { nameablesFor, whereAmI } from '../utilities/nameables.js';
3
+ import { normalizeLoose } from '../utilities/fuzzy-match.js';
4
+ /**
5
+ * TAB COMPLETION, ANSWERED BY THE ENGINE (f79LLqTerep4nKWcA: "How do I
6
+ * know which host/PAN/etc I'm hacking?").
7
+ *
8
+ * The player presses Tab; their client sends `complete <whatever is in
9
+ * the box>`; this answers. It is not an action -- no phase, no bill, no
10
+ * Overwatch, no world event -- and it never appears in the transcript as
11
+ * a turn. It is the input box asking a question.
12
+ *
13
+ * WHY A ROUND-TRIP AND NOT A LOCAL LIST. The clients cannot do this
14
+ * themselves: the web and Unity hold only the meat-plane actor roster
15
+ * from the condition beat, and the CLI on a shared run holds nothing but
16
+ * rendered text. Jacked in -- where the reporter actually got lost --
17
+ * none of them can name a single icon. The engine can name all of them,
18
+ * on every plane, and `stance next` already proved this exact route
19
+ * works from all three clients. See utilities/nameables.ts.
20
+ *
21
+ * WHAT COMES BACK, in three shapes:
22
+ * ONE MATCH the whole command with the name completed, on the
23
+ * marker line the clients read to refill the box.
24
+ * SEVERAL the candidates, glyphed, with nothing typed for you --
25
+ * an ambiguous guess that picks is worse than none.
26
+ * NONE what IS nameable from here, which is the roster the
27
+ * refusals print, from the same reader.
28
+ *
29
+ * THE MARKER LINE is the first line and looks like `{complete}<text>`.
30
+ * Clients strip it and set the box; anything that does not understand it
31
+ * simply shows a line telling the player what to type, which is a
32
+ * perfectly good fallback and the reason it is phrased as one.
33
+ */
34
+ /** What a client looks for to refill its input box. */
35
+ export const COMPLETE_MARKER = '{complete}';
36
+ export class CompleteCommand extends Command {
37
+ static verb = 'complete';
38
+ /** The input box is a human's. Nothing AI-driven types into one. */
39
+ static npcPolicy = 'human-only';
40
+ static description = 'What the Tab key sends: completes the name you are part-way through typing, against everything you could name from where you are standing -- hosts, personas, PANs, devices and files on the grid; people, devices, loot, your pack and the exits in the meat.';
41
+ /**
42
+ * Split the box into "the command so far" and "the fragment being
43
+ * typed". A trailing space means the fragment is empty and the player
44
+ * is asking what could come next, which is the single most useful
45
+ * thing Tab can answer: `hack ` lists every icon.
46
+ */
47
+ split(raw) {
48
+ if (raw.length === 0 || raw.endsWith(' '))
49
+ return { head: raw, fragment: '' };
50
+ const cut = raw.lastIndexOf(' ');
51
+ return cut < 0
52
+ ? { head: '', fragment: raw }
53
+ : { head: raw.slice(0, cut + 1), fragment: raw.slice(cut + 1) };
54
+ }
55
+ /**
56
+ * MATCH ON WHAT THE PLAYER HAS TYPED SO FAR, which is a prefix problem
57
+ * and not the fuzzy-nearest-name problem the verbs solve. Tab is being
58
+ * asked "finish this", so a name must CONTAIN what was typed -- by
59
+ * prefix first, then anywhere, so "garage" finds "Torque's Garage".
60
+ * Deliberately not fuzzyPickName: correcting a typo you are still in
61
+ * the middle of making would overwrite the letters under the cursor.
62
+ */
63
+ matches(all, fragment) {
64
+ const want = normalizeLoose(fragment);
65
+ if (want.length === 0)
66
+ return all;
67
+ const prefix = all.filter(n => normalizeLoose(n.name).startsWith(want));
68
+ if (prefix.length > 0)
69
+ return prefix;
70
+ // A word-start hit anywhere in the name -- how anyone refers to a
71
+ // long one out loud.
72
+ return all.filter(n => normalizeLoose(n.name).includes(want));
73
+ }
74
+ async execute(args = []) {
75
+ const actor = this.actor;
76
+ // The client sends the box VERBATIM, so the words rebuild it exactly;
77
+ // a trailing space is carried as an empty final word.
78
+ const raw = (args ?? []).join(' ');
79
+ const { head, fragment } = this.split(raw);
80
+ // A VERB ALONE IS NOT A NAME. With nothing typed after the verb the
81
+ // player wants the roster, which is the `fragment === ''` case below
82
+ // and handled by matches() returning everything.
83
+ const all = nameablesFor(this.scene, actor, this.rooms);
84
+ if (all.length === 0) {
85
+ return `Nothing to name from ${whereAmI(actor)}.`;
86
+ }
87
+ const hits = this.matches(all, fragment);
88
+ if (hits.length === 1) {
89
+ const only = hits[0];
90
+ return `${COMPLETE_MARKER}${head}${only.name}`;
91
+ }
92
+ if (hits.length === 0) {
93
+ return [
94
+ `Nothing at ${whereAmI(actor)} answers to "${fragment}". You could name:`,
95
+ ...all.slice(0, 12).map(n => ` ${n.glyph} ${n.name} -- ${n.what}`),
96
+ ...(all.length > 12 ? [` ...and ${all.length - 12} more.`] : []),
97
+ ].join('\n');
98
+ }
99
+ // SEVERAL. Complete as far as they AGREE -- the shell behaviour
100
+ // everyone already has in their fingers -- and show the field.
101
+ const common = CompleteCommand.commonPrefix(hits.map(h => h.name));
102
+ const grown = common.length > fragment.length ? `${COMPLETE_MARKER}${head}${common}\n` : '';
103
+ return [
104
+ `${grown}${hits.length} answer to "${fragment}":`,
105
+ ...hits.slice(0, 12).map(n => ` ${n.glyph} ${n.name} -- ${n.what}`),
106
+ ...(hits.length > 12 ? [` ...and ${hits.length - 12} more.`] : []),
107
+ ].join('\n');
108
+ }
109
+ /** The longest head every candidate shares, case-insensitively, but
110
+ * spelled the way the FIRST candidate spells it -- so completing into
111
+ * "Torque's" never changes the capitals already on screen. */
112
+ static commonPrefix(names) {
113
+ if (names.length === 0)
114
+ return '';
115
+ let end = names[0].length;
116
+ for (const n of names.slice(1)) {
117
+ let i = 0;
118
+ while (i < end && i < n.length && n[i].toLowerCase() === names[0][i].toLowerCase())
119
+ i++;
120
+ end = i;
121
+ }
122
+ return names[0].slice(0, end);
123
+ }
124
+ }
125
+ //# sourceMappingURL=complete.js.map
@@ -18,17 +18,20 @@ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-aud
18
18
  export const MARKS_TO_CONTROL = 2;
19
19
  export class DisableCommand extends Command {
20
20
  static verb = 'disable';
21
- static description = 'Control Device (SR5 p.238): with 2 marks on a device, order it -- a maglock unlocks, a camera cluster loops, a smartgun\'s clip drops. Electronic Warfare + Intuition [Sleaze] v. Device Rating + Firewall. "disable loud <icon>" is a Data Spike instead (see "brick").';
22
- /** The Data Spike route: `brick` always, `disable loud` by the word. */
21
+ static description = 'Control Device (SR5 p.238): with 2 marks on a device, order it -- a maglock unlocks, a camera cluster loops, a smartgun\'s clip drops. Electronic Warfare + Intuition [Sleaze] v. Device Rating + Firewall. To wreck it instead of driving it, that is a different action and a different verb: "brick <icon>" is a Data Spike (p.239).';
22
+ /**
23
+ * WHICH ACTION THIS IS -- and since f79LLqTerep4nKWcA it is decided by
24
+ * the VERB alone. `disable` is Control Device; `brick` is Data Spike,
25
+ * and BrickCommand overrides this.
26
+ *
27
+ * `disable loud <icon>` used to be a second spelling of `brick`, and
28
+ * `disable quiet` a no-op word that just got eaten. That is the same
29
+ * shape the reporter met on the grid: an adverb sitting where a target
30
+ * name goes, silently choosing between two different book actions.
31
+ * Both words are gone; a device named "loud" is now simply findable.
32
+ */
23
33
  way(args) {
24
- const rest = [...args];
25
- if (rest[0]?.toLowerCase() === 'loud' || rest[0]?.toLowerCase() === 'brick') {
26
- rest.shift();
27
- return { way: 'spike', rest };
28
- }
29
- if (rest[0]?.toLowerCase() === 'quiet')
30
- rest.shift();
31
- return { way: 'control', rest };
34
+ return { way: 'control', rest: [...args] };
32
35
  }
33
36
  async execute(args = []) {
34
37
  const actor = this.actor;
@@ -326,10 +329,7 @@ export class BrickCommand extends DisableCommand {
326
329
  static verb = 'brick';
327
330
  static description = 'Data Spike (SR5 p.239): Cybercombat + Logic [Attack] v. Device Rating + Firewall, no marks needed, never subtle. Fill a device\'s Matrix condition monitor (8 + DR/2, p.228) and it BRICKS -- a camera goes dead, a smartgun sparks and dies; a lock stays locked. A miss burns your own deck.';
328
331
  way(args) {
329
- const rest = [...args];
330
- if (rest[0]?.toLowerCase() === 'loud')
331
- rest.shift();
332
- return { way: 'spike', rest };
332
+ return { way: 'spike', rest: [...args] };
333
333
  }
334
334
  }
335
335
  //# sourceMappingURL=disable.js.map
@@ -156,7 +156,7 @@ export class EraseMarkCommand extends Command {
156
156
  if (target.kind !== 'self' && held < MAX_MARKS) {
157
157
  return [
158
158
  `You hold ${held}/${MAX_MARKS} marks on ${label} -- Erase Mark needs THREE on the icon you are scrubbing (p.239), not on whoever placed the mark.`,
159
- hint(` ("hack" ${label} up to ${MAX_MARKS}. To clear marks off YOURSELF instead, "unmark me" -- or "reboot"/"jack out", which wipe every mark on you for free, p.242.)`),
159
+ hint(` ("hack ${label}" or "force ${label}" up to ${MAX_MARKS}. To clear marks off YOURSELF instead, "unmark me" -- or "reboot"/"jack out", which wipe every mark on you for free, p.242.)`),
160
160
  ].join('');
161
161
  }
162
162
  const wanted = Math.min(count, MAX_MARKS, placers.reduce((n, p) => n + p.count, 0));
@@ -0,0 +1,40 @@
1
+ import { HackCommand } from './hack.js';
2
+ /**
3
+ * BRUTE FORCE (SR5 p.238) -- the loud half of the intrusion fork, and its
4
+ * own verb since f79LLqTerep4nKWcA.
5
+ *
6
+ * THIS FILE IS A NAME, NOT A MECHANIC. Every line of the intrusion --
7
+ * the target ladder (host by name, PAN, slaved device, loose device,
8
+ * camera, the meat-side puzzle rail), the opposed roll, the declared-mark
9
+ * penalties, the GOD bill -- lives in hack.ts and is shared. The only
10
+ * thing that differs between the two verbs is which of the book's two
11
+ * actions they are, and that is one method.
12
+ *
13
+ * WHY IT IS A VERB AND NOT A WORD YOU ADD. It used to be a word you added:
14
+ * `hack loud` (or `force`, `brute`, `attack`) anywhere in the args flipped
15
+ * a hidden per-player posture for one action, and with no word at all you
16
+ * got whatever Tab had left the posture set to. The reporter met the other
17
+ * half of that switch and read it as a target -- "what is 'hack quiet' --
18
+ * I don't see a 'quiet', is it just 'mark'?" -- which is the correct
19
+ * reading of a bare adverb sitting where a name goes.
20
+ *
21
+ * So: one verb per action, and the verb says which action it is. There is
22
+ * nothing to override, nothing to discover, and every word the player
23
+ * types is part of the name of the thing they are naming.
24
+ *
25
+ * THE TWO ARE NOT INTERCHANGEABLE (p.236 "Noticing Hackers"), which is the
26
+ * whole reason the player must be able to pick on purpose:
27
+ * - `hack` (Hack on the Fly, Sleaze): succeed and nobody knows. MISS and
28
+ * the target gets a free mark on YOU -- alarm, ice hunting, spotted.
29
+ * - `force` (Brute Force, Attack): succeed and it knows it is under
30
+ * attack but has not spotted you. MISS and nothing is noticed at all;
31
+ * the firewall just bites you back.
32
+ */
33
+ export class ForceCommand extends HackCommand {
34
+ static verb = 'force';
35
+ static description = 'BRUTE FORCE (SR5 p.238) on a host, a PAN, or a device: Cybercombat + Logic [Attack] against it, and what it leaves behind is a MARK. The loud route -- a MISS goes unnoticed entirely (the firewall just bites you), but a HIT tells the target it is under attack. Always name what you are smashing: "force <host>", "force <person>" for their PAN, "force <device>". Its quiet twin is "hack <target>" (Hack on the Fly, p.240). Also "brute", "smash".';
36
+ verbName = 'force';
37
+ noDeviceMessage = "There's nothing here to force.";
38
+ mode() { return 'attack'; }
39
+ }
40
+ //# sourceMappingURL=force.js.map
@@ -10,6 +10,7 @@ import { fuzzyPickName } from '../utilities/fuzzy-match.js';
10
10
  import { isWatched, canSnoopFeeds } from '../utilities/surveillance.js';
11
11
  import { matchesCameraName, hostLabel } from '../utilities/grid-names.js';
12
12
  import { TapCommand } from './tap.js';
13
+ import { nameableNames } from '../utilities/nameables.js';
13
14
  import { sameHostSide } from '../models/player.js';
14
15
  import { MAX_MARKS } from '../utilities/marks.js';
15
16
  import { billAction } from '../utilities/action-cost.js';
@@ -30,56 +31,42 @@ const MARKS_TO_COMMAND_A_PAN = 2;
30
31
  * Anchored so it never fires on a name that merely contains one. */
31
32
  export class HackCommand extends BypassCommand {
32
33
  static verb = 'hack';
33
- static description = 'Hack a terminal, host, or PAN. TWO ROUTES, and your STANCE picks which (Tab flips it, "stance" shows it): ATTACK is Brute Force (p.238 -- Cybercombat + Logic [Attack]; a MISS goes unnoticed, the firewall just bites you). SLEAZE is Hack on the Fly (p.240 -- Hacking + Logic [Sleaze]; succeed and NOBODY knows; fail and you are MADE: alarm, ice hunting, walls up). "hack loud" / "hack quiet" override the stance for one action. The BRACKET IS A LIMIT: it caps how many HITS you can count, and is never a die in the pool -- so the Sleaze or Attack on your deck is a CEILING on what a run of luck can buy you, not a bonus ("deck" shows the array, and the Mechanics line says when the cap bit). "hack <name>" cracks a PAN the same two ways. Terminal-puzzle hacks are their own rail (a failed one locks you out for good).';
34
+ static description = 'HACK ON THE FLY (SR5 p.240) on a host, a PAN, or a device: Hacking + Logic [Sleaze] against it, and what it leaves behind is a MARK. The quiet route -- succeed and NOBODY knows; fail and you are MADE: alarm, ice hunting, and it holds a mark on YOU. Always name what you are cracking: "hack <host>", "hack <person>" for their PAN, "hack <device>". Its loud twin is a different verb -- "force <target>" is Brute Force (p.238). The BRACKET IS A LIMIT: your deck\'s Sleaze caps how many HITS you can count, and is never a die in the pool ("deck" shows the array, and the Mechanics line says when the cap bit). Terminal-puzzle hacks are their own rail (a failed one locks you out for good).';
34
35
  deviceKind = 'panel';
35
36
  verbName = 'hack';
36
37
  noDeviceMessage = "There's nothing here to hack.";
37
38
  /**
38
- * The Attack/Sleaze fork (canon p.236 "Noticing Hackers", player
39
- * ruling 2026-08-23): "loud"/"force"/"brute"/"attack" anywhere in the
40
- * words picks Brute Force; "quiet"/"sleaze"/"sneak" picks Hack on the
41
- * Fly explicitly; the default is the player's STANCE (Tab, see
42
- * MatrixStance), which ships as Brute Force.
43
- * The mode word is stripped so "hack loud mama wu" still finds Mama Wu.
39
+ * WHICH OF THE BOOK'S TWO INTRUSION ACTIONS THIS VERB IS.
40
+ *
41
+ * There used to be one verb and a hidden posture: `hack` read the
42
+ * player's Matrix stance (Tab), and "loud"/"quiet"/"sleaze"/"sneak"
43
+ * anywhere in the args overrode it for one action. The reporter of
44
+ * f79LLqTerep4nKWcA met that as `hack quiet` and reasonably read
45
+ * "quiet" as a target -- "I don't see a 'quiet', is it just 'mark'?"
46
+ *
47
+ * So the fork is a VERB now, one per book action, and this hook is the
48
+ * whole difference between them (commands/force.ts is the other half).
49
+ * Nothing is stripped from the args any more: every word the player
50
+ * types is part of the name of the thing they are naming.
44
51
  */
45
- static parseMode(args, stance) {
46
- const rest = [...(args ?? [])];
47
- // THE DEFAULT IS THE STANCE, NOT A LITERAL (uMQAhaAysgaKpWFkn,
48
- // mjmcee 2026-09-03). It used to be hardcoded 'sleaze' here and
49
- // again in mark.ts -- two copies of a decision the player could
50
- // not see or change. Tab now sets it once and both verbs read it;
51
- // the mode words below stay as a one-shot override for the action
52
- // you are about to take without moving your standing posture.
53
- let mode = stance;
54
- const loudIdx = rest.findIndex(w => /^(loud|force|brute|attack)$/i.test(w));
55
- if (loudIdx >= 0) {
56
- mode = 'attack';
57
- rest.splice(loudIdx, 1);
58
- }
59
- const quietIdx = rest.findIndex(w => /^(quiet|sleaze|sneak)$/i.test(w));
60
- // SETS the mode, it does not merely strip the word. While Sleaze was
61
- // the hardcoded default this branch could get away with only
62
- // splicing -- it was right for the wrong reason, and nothing could
63
- // tell the difference because both paths agreed. The moment the
64
- // default became the stance (uMQAhaAysgaKpWFkn) that omission turned
65
- // "hack quiet" into a Brute Force.
66
- if (quietIdx >= 0) {
67
- mode = 'sleaze';
68
- rest.splice(quietIdx, 1);
69
- }
70
- return { mode, rest };
52
+ mode() { return 'sleaze'; }
53
+ /** The action's name in the book, for every line this verb prints. */
54
+ actionName() {
55
+ return this.mode() === 'attack' ? 'Brute Force' : 'Hack on the Fly';
71
56
  }
72
57
  async execute(args = []) {
73
- const { mode, rest: afterMode } = HackCommand.parseMode(args, this.actor.matrixStance);
58
+ const mode = this.mode();
59
+ const afterMode = [...(args ?? [])];
74
60
  // HOW MANY MARKS YOU ARE REACHING FOR, declared before the roll
75
- // (p.238/p.240) -- "hack for 3", "hack 2 marks on the door". Stripped
76
- // here so a declaration never lands in a target name, the same way
77
- // the mode words are.
61
+ // (p.238/p.240) -- "hack for 3", "hack 2 marks on the door". This is
62
+ // the ONLY thing still stripped out of the words: it is a count, not
63
+ // a name, and parseMarkDeclaration will only take a digit that is
64
+ // spelled as one ("for 2", "2 marks", "x2").
78
65
  const { marks: declaredMarks, rest } = parseMarkDeclaration(afterMode);
79
66
  // BRUTE FORCE and HACK ON THE FLY are Complex Actions (p.238, p.240)
80
67
  // -- billed only inside a Combat Turn, where one intrusion is the
81
68
  // whole Action Phase.
82
- const bill = billAction(this.scene, this.actor, 'complex', mode === 'sleaze' ? 'Hack on the Fly' : 'Brute Force');
69
+ const bill = billAction(this.scene, this.actor, 'complex', this.actionName());
83
70
  if (bill)
84
71
  return bill;
85
72
  // PAN warfare: "hack <name>" cracks a meat actor's personal area
@@ -118,7 +105,7 @@ export class HackCommand extends BypassCommand {
118
105
  rooms: this.rooms,
119
106
  screen: this.screen,
120
107
  game: this.game,
121
- }).execute(args);
108
+ }).withMode(mode).execute();
122
109
  }
123
110
  // The Matrix branch: hacking FROM INSIDE a host node reaches the
124
111
  // room's physical systems -- the meatspace payoff of jacking in. A
@@ -142,8 +129,17 @@ export class HackCommand extends BypassCommand {
142
129
  ? resolveHostInReach(gridRooms, this.actor, named)
143
130
  : (this.actor.insideHost ?? hostOver(gridVicinity(this.actor)) ?? hostOver(this.actor.currentLocation));
144
131
  if (!room) {
145
- const overhead = hostsInReach(gridRooms, this.actor).map(h => hostLabel(h, { capital: true }));
146
- const roster = overhead.length > 0 ? ` Overhead: ${overhead.join(', ')} -- "hack <name>" cracks one from here.` : '';
132
+ // THE ROSTER AND TAB'S COMPLETION READ THE SAME LIST
133
+ // (utilities/nameables.ts). They are the same question asked two
134
+ // ways -- "what could I have typed here?" -- and a refusal that
135
+ // named an icon Tab cannot complete, or omitted one it can, is
136
+ // the same defect in two coats. It used to list hosts only,
137
+ // while the sentence above it promised to have looked for a PAN
138
+ // and a camera too.
139
+ const names = nameableNames(this.scene, this.actor, gridRooms);
140
+ const roster = names.length > 0
141
+ ? ` In reach: ${names.slice(0, 8).join(', ')}${names.length > 8 ? `, and ${names.length - 8} more` : ''} -- "${this.verbName} <name>" reaches one from here (Tab completes it).`
142
+ : '';
147
143
  return named
148
144
  ? `Nothing on the grid answers to "${named}" -- no host by that name, no PAN, no camera.${roster}`
149
145
  : `Thin grid out here -- no host node hangs over ${gridVicinity(this.actor).name}.${roster}`;
@@ -243,9 +239,16 @@ export class HackCommand extends BypassCommand {
243
239
  this.actor.sneaking = false;
244
240
  this.scene.addWorldEvent(`${hostLabel(room, { capital: true })} MADE ${this.actor.name} mid-intrusion -- alarm flagged, ice hunting.`, { plane: 'matrix' });
245
241
  this.scene.updateStatus();
242
+ // NAME THE ICON YOU JUST MISSED (f79LLqTerep4nKWcA: "How do I
243
+ // know which host/PAN/etc I'm hacking?"). The success lines
244
+ // below have always named the host; both failure lines said
245
+ // "the host" and left the player to guess which one -- the
246
+ // exact moment they most need to know, since a blown Sleaze is
247
+ // what hands a mark to something that did not have one.
248
+ const marksOnMe = room.hostMarksOn.get(this.actor.name) ?? 0;
246
249
  return [
247
- `The ice turns and LOOKS AT YOU -- your sleaze unravels mid-handshake.`,
248
- ` The host holds a MARK on your persona now: walls up (harder to crack until it falls), and everything guarding this place knows an intruder is on the grid.`,
250
+ `${hostLabel(room, { capital: true })} turns and LOOKS AT YOU -- your Hack on the Fly unravels mid-handshake.`,
251
+ ` It holds ${marksOnMe === 1 ? 'a mark' : `${marksOnMe} marks`} on your persona now, it can see you, and everything guarding this place knows someone is on the grid. ("unmark me" scrubs it; so do "reboot" and "jack out", p.242.)`,
249
252
  ...godLines,
250
253
  ].join('\n');
251
254
  }
@@ -258,7 +261,7 @@ export class HackCommand extends BypassCommand {
258
261
  if (showsMechanics(this.scene, this.actor)) {
259
262
  this.logger.meta(`${mechanicsActorPrefix(this.scene, this.actor)}Firewall: ${formatRoll(bite)}`);
260
263
  }
261
- const lines = [`The host's defenses snap shut around your intrusion --`];
264
+ const lines = [`${hostLabel(room, { capital: true })} snaps its defenses shut around your Brute Force --`];
262
265
  if (dealt > 0) {
263
266
  // Same routing as ice hits: hardware eats it when there's
264
267
  // hardware; a technomancer's living persona (and any hot-sim
@@ -321,7 +324,11 @@ export class HackCommand extends BypassCommand {
321
324
  lines.push(` {light-blue-fg}And you read it on the way past -- ${perception} Matrix Perception hit${perception === 1 ? '' : 's'} of what this node is (p.240).{/light-blue-fg}`);
322
325
  if (forcedDv > 0)
323
326
  lines.push(` The forcing burns ${forcedDv} DV into the architecture -- a host has no boards to brick, so it only announces you louder.`);
324
- lines.push(` ${MAX_MARKS - marksNow} more and its architecture stops asking who you are.${hint(` ("hack for ${MAX_MARKS - marksNow}" reaches for the rest at once -- ${declarationPenalty(MAX_MARKS - marksNow, this.actor.hasQuality(GO_BIG_QUALITY))} dice.)`)}`);
327
+ // NAME THE VERB THEY JUST USED. This said "hack for N" whichever
328
+ // route had run, so a Brute Force answered with a hint for the
329
+ // other action -- the same class of mix-up the whole item is
330
+ // about (f79LLqTerep4nKWcA).
331
+ lines.push(` ${MAX_MARKS - marksNow} more and its architecture stops asking who you are.${hint(` ("${this.verbName} for ${MAX_MARKS - marksNow}" reaches for the rest at once -- ${declarationPenalty(MAX_MARKS - marksNow, this.actor.hasQuality(GO_BIG_QUALITY))} dice.)`)}`);
325
332
  if (mode === 'attack') {
326
333
  this.actor.sneaking = false;
327
334
  room.hostAlert = true;
@@ -386,7 +393,7 @@ export class HackCommand extends BypassCommand {
386
393
  // NOT RELEASED -- NAMED. Telling the player these are here, and
387
394
  // that each is one more action, is the difference between a
388
395
  // decomposition and a thing that quietly stopped working.
389
- lines.push(` ${iconLocks.length} ${iconLocks.length === 1 ? 'lock hangs' : 'locks hang'} off it, still sealed -- each its own icon behind its own rating: ${iconLocks.map(d => d.name).join(', ')}.${hint(` ("hack <lock>" from in here keys one; the host's key is not the door's.)`)}`);
396
+ lines.push(` ${iconLocks.length} ${iconLocks.length === 1 ? 'lock hangs' : 'locks hang'} off it, still sealed -- each its own icon behind its own rating: ${iconLocks.map(d => d.name).join(', ')}.${hint(` ("${this.verbName} <lock>" from in here keys one; the host's key is not the door's.)`)}`);
390
397
  }
391
398
  if (vaultedFiles.length > 0) {
392
399
  lines.push(` {light-blue-fg}The data vault unseals -- ${vaultedFiles.map(i => i.name).join(', ')} spill${vaultedFiles.length === 1 ? 's' : ''} into the open.${hint(` Yours to "take".`)}{/light-blue-fg}`);
@@ -556,7 +563,7 @@ export class HackCommand extends BypassCommand {
556
563
  viaWan = true;
557
564
  const onHost = host.hostMarksBy.get(actor.name) ?? 0;
558
565
  if (onHost < 1) {
559
- return `${device.name} is on the map, but it hangs off ${hostLabel(host)}'s WAN -- slaved, and a host's devices answer nobody who holds no mark on the host itself (p.233).${hint(` ("hack ${hostLabel(host)}" for a mark first; "enter" once you have one and the lock defends with its own DR ${device.rating} alone.)`)}`;
566
+ return `${device.name} is on the map, but it hangs off ${hostLabel(host)}'s WAN -- slaved, and a host's devices answer nobody who holds no mark on the host itself (p.233).${hint(` ("${this.verbName} ${hostLabel(host)}" for a mark first; "enter" once you have one and the lock defends with its own DR ${device.rating} alone.)`)}`;
560
567
  }
561
568
  }
562
569
  else {
@@ -708,7 +715,7 @@ export class HackCommand extends BypassCommand {
708
715
  const command = hint(`"disable ${device.name}" (Control Device, p.238) ${device.opensExit ? `opens the ${device.opensExit} way` : `works it`} from right here.`);
709
716
  lines.push(now >= 2
710
717
  ? ` That is enough to command it${command ? `: ${command}` : '.'}`
711
- : ` One more mark and it takes orders.${hint(` ("hack ${device.name}" again, then "disable ${device.name}" -- Control Device wants 2 marks for a Simple device action, p.238.)`)}`);
718
+ : ` One more mark and it takes orders.${hint(` ("${this.verbName} ${device.name}" again, then "disable ${device.name}" -- Control Device wants 2 marks for a Simple device action, p.238.)`)}`);
712
719
  }
713
720
  this.scene.updateStatus();
714
721
  return [...lines, ...godLines].join('\n');
@@ -773,12 +780,12 @@ export class HackCommand extends BypassCommand {
773
780
  * Returns null when the args don't name a hackable actor here, so
774
781
  * barrier names and directions fall through to the puzzle path.
775
782
  *
776
- * The Attack/Sleaze fork applies here too (canon p.236): SLEAZE
777
- * (default) rides the deck's Sleaze edge -- fail and the target's
778
- * link pings them a probe warning (you're noticed); succeed and the
779
- * world events never name you. ATTACK ("hack loud <name>") rides the
780
- * Attack edge -- success is announced with your name on it, but a
781
- * MISS goes completely unnoticed and the firewall bites YOU instead.
783
+ * The Attack/Sleaze fork applies here too (canon p.236): "hack <name>"
784
+ * is Hack on the Fly and rides the deck's Sleaze edge -- fail and the
785
+ * target's link pings them a probe warning (you're noticed); succeed
786
+ * and the world events never name you. "force <name>" is Brute Force
787
+ * and rides the Attack edge -- success is announced with your name on
788
+ * it, but a MISS goes completely unnoticed and the firewall bites YOU.
782
789
  */
783
790
  async tryPanHack(args, mode) {
784
791
  const actor = this.actor;
@@ -363,7 +363,7 @@ export class LookCommand extends Command {
363
363
  return `That file is on the ${room.offlineServer} -- a box with no Matrix presence. Nothing out here reaches it; your body has to be in ${room.name} (p.233).`;
364
364
  }
365
365
  if (reach === 'enter') {
366
- return `That file is INSIDE the host -- from out on the grid you have its shell and nothing through it (p.246). ${(room.hostMarksBy.get(this.actor.name) ?? 0) > 0 || room.hostCracked ? `"enter" to go in.` : `A mark opens the door: "mark" it, then "enter".`}`;
366
+ return `That file is INSIDE the host -- from out on the grid you have its shell and nothing through it (p.246). ${(room.hostMarksBy.get(this.actor.name) ?? 0) > 0 || room.hostCracked ? `"enter" to go in.` : `A mark opens the door: "hack ${hostLabel(room)}" (quiet) or "force ${hostLabel(room)}" (loud), then "enter".`}`;
367
367
  }
368
368
  // The host's seal covers the HOST's archive. A file on an offline
369
369
  // server is not in there -- cracking the building's host does
@@ -519,7 +519,7 @@ export class LookCommand extends Command {
519
519
  return `${far.item.name} is meat -- a thing standing in ${far.room.name}, not an icon. Your eyes are in ${body.name}, and a persona has none.`;
520
520
  if (far.reach === 'offline')
521
521
  return `You remember ${far.item.name}: it sits on the ${far.room.offlineServer} in ${far.room.name} -- an offline server, air-gapped, off the grid entirely. No persona reaches that box; your BODY has to be in ${far.room.name} (p.233).`;
522
- return `You remember ${far.item.name}: it is inside ${hostLabel(far.room)}'s vault (p.246). ${hint(`"enter ${far.room.name}" on a mark, or "hack" it open.`)}`;
522
+ return `You remember ${far.item.name}: it is inside ${hostLabel(far.room)}'s vault (p.246). ${hint(`"enter ${far.room.name}" on a mark, or "hack ${hostLabel(far.room)}" to place one.`)}`;
523
523
  }
524
524
  }
525
525
  }
@@ -1,7 +1,5 @@
1
1
  import { Command } from './command.js';
2
- import { HackCommand } from './hack.js';
3
- import { describeMarks, MAX_MARKS } from '../utilities/marks.js';
4
- import { MATRIX_STANCE_ACTION } from '../models/player.js';
2
+ import { describeMarks, marksOnYou, MAX_MARKS } from '../utilities/marks.js';
5
3
  /**
6
4
  * THERE IS NO MARK ACTION (uMQAhaAysgaKpWFkn, pcampbell: "Marking is a
7
5
  * symptom of either Brute Force [Attack] or Hack on the Fly [Sleaze] ...
@@ -11,47 +9,70 @@ import { MATRIX_STANCE_ACTION } from '../models/player.js';
11
9
  * standalone action that places a mark. Brute Force (p.238) and Hack on
12
10
  * the Fly (p.240) are the only two, and a mark is what they leave behind.
13
11
  *
14
- * So this file no longer implements one. It used to carry its own copy of
15
- * the pool, the fork, the GOD bill and the outcome prose -- the same roll
16
- * hack.ts was making, kept in a second place where the two could drift.
17
- * They did: on a host, `hack` bought the entire node on that roll and
18
- * `mark` bought one increment, and which you got depended on the word you
19
- * typed. That was the report.
12
+ * So this verb stopped implementing one long ago. For a while it still
13
+ * ROUTED to the real action -- `mark <host>` quietly ran `hack` -- on the
14
+ * reasoning that a player who types it means to do the thing. That was
15
+ * the wrong kindness: it left two spellings for one action and no way to
16
+ * tell them apart, which is exactly what the reporter of
17
+ * f79LLqTerep4nKWcA hit -- "if we have 'hack quiet', then what is
18
+ * 'mark'?" Two names for one thing is not a convenience, it is the
19
+ * question.
20
20
  *
21
- * What survives is the INSPECTION -- "mark list" answers "what keys am I
22
- * holding", which is a reasonable thing to ask and not an action at all.
23
- * Everything else routes to the real verb and says its real name, so the
24
- * vocabulary teaches the book instead of hiding it.
21
+ * WHAT IT IS NOW: the ledger, and only the ledger. `mark` answers "who
22
+ * holds what", which is a reasonable thing to ask and not an action at
23
+ * all -- it costs nothing, bills nothing, and can be typed mid-fight.
24
+ *
25
+ * BOTH DIRECTIONS, which is the other half of the report ("when I have a
26
+ * mark on it, show my mark on it, and vice versa -- when it has a mark on
27
+ * me, show that"). The engine has always tracked the two separately --
28
+ * Host.marksBy / Device.marksBy / Player.panMarksBy for what you hold,
29
+ * Host.marksOn / the placer side of panMarksBy for what holds you -- and
30
+ * utilities/marks.ts has had a reader for each since the live sheet
31
+ * needed them. Only the web sheet ever printed both. This prints both.
25
32
  */
26
33
  export class MarkCommand extends Command {
27
34
  static verb = 'mark';
28
- static description = 'Show the marks you are holding ("mark list"). Placing one is NOT its own action in SR5 -- a mark is what Brute Force (p.238) or Hack on the Fly (p.240) leaves behind, so "mark <target>" runs the real action and says which one. Use "hack" directly; your stance (Tab) picks the route.';
35
+ static description = 'The MARK LEDGER, both directions: what you hold on other icons, and what other icons hold on YOU. Not an action -- it costs nothing and bills nothing. Placing a mark is not its own action in SR5 either: a mark is what "hack <target>" (Hack on the Fly, p.240) or "force <target>" (Brute Force, p.238) leaves behind, and "unmark" scrubs one off (Erase Mark, p.239). Also "marks".';
29
36
  async execute(args = []) {
30
- const words = [...(args ?? [])].map(w => w.toLowerCase());
31
- // THE INSPECTION, which is not an action and keeps its home here.
32
- if (words.length === 0 || words[0] === 'list' || words[0] === 'mine') {
33
- const standing = describeMarks(this.scene, this.actor.name);
34
- if (standing.length === 0) {
35
- return `No standing marks -- your persona holds no keys right now.${this.actor.plane === 'matrix' ? ` ("hack <target>" places one: it is what ${MATRIX_STANCE_ACTION[this.actor.matrixStance]} leaves behind.)` : ''}`;
36
- }
37
- return [
38
- `YOUR MARKS (max ${MAX_MARKS} per icon)`,
39
- ...standing.map(l => ` ${l}`),
40
- ].join('\n');
37
+ const actor = this.actor;
38
+ // SOMEONE WHO TYPED A TARGET MEANT TO DO THE THING, and the reporter
39
+ // of f79LLqTerep4nKWcA is on record expecting exactly that ("It's
40
+ // 'hack <target>' or 'mark <target>'"). This verb used to run the
41
+ // hack for them, which is how one action ended up with two spellings
42
+ // and no way to tell them apart. Saying so costs nothing -- there is
43
+ // no action to bill -- and the ledger still prints underneath, so
44
+ // the keystroke is never simply swallowed.
45
+ const named = (args ?? []).filter(w => !/^(list|mine|marks?)$/i.test(w)).join(' ').trim();
46
+ const aimed = named.length > 0
47
+ ? `{light-blue-fg}Marking is not an action in SR5 -- a mark is what an intrusion LEAVES BEHIND. For ${named}: "hack ${named}" is Hack on the Fly (p.240, quiet) and "force ${named}" is Brute Force (p.238, loud). Neither has been run; nothing has been spent.{/light-blue-fg}\n\n`
48
+ : '';
49
+ const held = describeMarks(this.scene, actor.name);
50
+ const on = marksOnYou(this.scene, actor);
51
+ // NOTHING EITHER WAY. Worth its own sentence rather than two empty
52
+ // headings: a clean ledger is a fact about your run, and on the grid
53
+ // it is the moment to say what places one.
54
+ if (held.length === 0 && on.length === 0) {
55
+ const teach = actor.plane === 'matrix'
56
+ ? ` ("hack <target>" is Hack on the Fly and "force <target>" is Brute Force -- a mark is what either one leaves behind.)`
57
+ : '';
58
+ return `${aimed}No marks either way -- you hold none, and nothing holds one on you.${teach}`;
59
+ }
60
+ const lines = [];
61
+ lines.push(`{light-blue-fg}YOUR MARKS -- what you hold (max ${MAX_MARKS} per icon){/light-blue-fg}`);
62
+ lines.push(...(held.length > 0
63
+ ? held.map(l => ` ${l}`)
64
+ : [` nothing -- you hold no keys right now.`]));
65
+ lines.push('');
66
+ // The mirror. Named per placer, not summed: a session log showed
67
+ // three marks across two hosts, and one number cannot say that.
68
+ lines.push(`{light-blue-fg}MARKS ON YOU -- what holds you{/light-blue-fg}`);
69
+ lines.push(...(on.length > 0
70
+ ? on.map(r => ` ◆ ${r.placer} -- ${r.count} mark${r.count === 1 ? '' : 's'}${r.count >= MAX_MARKS ? ' (it owns your icon)' : ''}`)
71
+ : [` nothing -- your icon is clean.`]));
72
+ if (on.length > 0) {
73
+ lines.push(` {light-blue-fg}("unmark me" scrubs them one at a time, Erase Mark p.239; "reboot" and "jack out" wipe them all for free, p.242.){/light-blue-fg}`);
41
74
  }
42
- // EVERYTHING ELSE IS THE REAL ACTION. Routed rather than refused: a
43
- // player who types `mark the host` means to do the thing, and
44
- // answering "no such action" would be pedantry that costs them a
45
- // turn. They get the action AND its right name.
46
- const action = MATRIX_STANCE_ACTION[this.actor.matrixStance];
47
- const result = await new HackCommand({
48
- actor: this.actor,
49
- scene: this.scene,
50
- rooms: this.rooms,
51
- screen: this.screen,
52
- game: this.game,
53
- }).execute(args);
54
- return `{light-blue-fg}Placing a mark IS ${action} -- running it (p.238/p.240).{/light-blue-fg}\n${result}`;
75
+ return aimed + lines.join('\n');
55
76
  }
56
77
  }
57
78
  //# sourceMappingURL=mark.js.map
@@ -48,9 +48,13 @@ export class ReloadCommand extends Command {
48
48
  }
49
49
  }
50
50
  else {
51
- // NON-LETHAL STANCE: stun rounds first if you carry any (gel,
52
- // stick-n-shock -- p.434); otherwise whatever is in the pack.
53
- ammo = (actor.autoStance === 'non-lethal' ? boxes.find(b => Item.isStunAmmo(b)) : undefined) ?? boxes[0];
51
+ // NO STANCE TO CONSULT ANY MORE (f79LLqTerep4nKWcA). A bare
52
+ // "reload" used to reach for stun rounds when the standing posture
53
+ // was non-lethal -- a magazine chosen by a setting the player could
54
+ // not see from here. It takes what is in the pack; the line below
55
+ // names it, and "reload gel" / "reload stick-n-shock" is how you
56
+ // ask for the merciful box.
57
+ ammo = boxes[0];
54
58
  }
55
59
  if (!ammo) {
56
60
  return `No ammunition in your pack -- the ${weapon.name} stays at ${weapon.ammo}/${weapon.magazineCapacity}. Find a spare clip.`;
@@ -74,7 +78,13 @@ export class ReloadCommand extends Command {
74
78
  this.actor.performAction('reloads', weapon.name);
75
79
  this.scene.updateInventory(this.scene.getPlayer().inventory);
76
80
  this.scene.updateStatus();
77
- return `You slap the ${ammo.name} home -- ${weapon.name} loaded, ${weapon.ammo}/${weapon.magazineCapacity}.${weapon.stunAmmo() ? ' Stun rounds: it drops people, it does not kill them.' : actor.autoStance === 'non-lethal' ? ' No stun rounds in the pack -- that gun is LETHAL until you find some.' : ''}${phaseHint(this.scene, actor)}`;
81
+ // WHAT WENT IN, ALWAYS -- the rounds are the whole of whether this
82
+ // gun can be used to take someone alive ("subdue"), so the line says
83
+ // which it is rather than leaving it to a posture readout.
84
+ const stunNote = weapon.stunAmmo()
85
+ ? ' Stun rounds: it drops people, it does not kill them -- "subdue" can use this.'
86
+ : ` Lethal rounds${boxes.some(b => Item.isStunAmmo(b)) ? ' -- "reload gel" or "reload stick-n-shock" if you want them alive' : ''}.`;
87
+ return `You slap the ${ammo.name} home -- ${weapon.name} loaded, ${weapon.ammo}/${weapon.magazineCapacity}.${stunNote}${phaseHint(this.scene, actor)}`;
78
88
  }
79
89
  }
80
90
  //# sourceMappingURL=reload.js.map