@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
@@ -43,7 +43,7 @@ export class SnoopCommand extends Command {
43
43
  return `No cameras in ${target.name} -- nothing to ride.`;
44
44
  }
45
45
  if (!canSnoopFeeds(allRooms, target, actor)) {
46
- return `The feeds in ${target.name} don't answer you -- own the site's host first: "mark" it (one key reads the network) or "hack" it open.`;
46
+ return `The feeds in ${target.name} don't answer you -- put a mark on the site's host first: "hack <host>" slips one in quiet (p.240), "force <host>" smashes one in (p.238). One key reads the network.`;
47
47
  }
48
48
  actor.performAction('rides a camera feed', target.name);
49
49
  this.logger.write(`Snoop: ${actor.name} watching ${target.name} via camera feed.`);
@@ -1,43 +1,124 @@
1
1
  import { AttackCommand } from './attack.js';
2
+ import { Category } from '../types/shared/item-enum.js';
3
+ import { hint } from '../utilities/hints.js';
2
4
  /**
3
- * Take them alive: the same exchange machinery as attack (opposed roll,
4
- * retaliation, pacing, movement lock), but the damage lands on the STUN
5
- * track -- a full track is a live capture, not a kill. The tradeoffs that
6
- * keep it honest: the strike ignores your gun entirely (bare-hands pool,
7
- * low base damage, so it's slower than shooting), the target's retaliation
8
- * is fully lethal, and beating someone far past unconsciousness overflows
9
- * into their body -- you can still kill someone you meant to capture.
5
+ * TAKE THEM ALIVE -- the non-lethal half of the meat fork, and since
6
+ * f79LLqTerep4nKWcA the ONLY way to ask for it.
7
+ *
8
+ * It used to be half a verb and half a setting: `subdue` was bare hands
9
+ * and nothing else, while a lethal weapon went Stun only if your standing
10
+ * STANCE happened to be non-lethal when you swung it. Two places to say
11
+ * one thing, one of them invisible. The stance is gone; the verb says it.
12
+ *
13
+ * WHAT CANON ALLOWS, and the whole of what this verb reaches for
14
+ * (RAG-checked p.186 "Changing Damage Types", p.170-171, p.434):
15
+ *
16
+ * BARE HANDS unarmed is Stun by default -- the fallback, and what
17
+ * you get with nothing drawn.
18
+ * STUN GEAR taser, stun baton, shock gloves: electricity is Stun
19
+ * in anyone's hands, in any frame of mind (p.170-171).
20
+ * STUN ROUNDS gel, stick-n-shock: whatever the magazine holds
21
+ * (p.434). The gun fires; the rounds are the mercy.
22
+ * FLAT OF THE BLADE a lethal melee weapon used as a club -- "Accuracy
23
+ * of 3", and "blades used as a club lose all Reach due
24
+ * to the need to hit with the pommel" (p.186).
25
+ *
26
+ * AND WHAT IT REFUSES. A lethal firearm with lethal rounds has no
27
+ * non-lethal door in the book short of a Called Shot, which this engine
28
+ * does not implement. That case used to FIRE and tag the line
29
+ * " (LETHAL -- no stun rounds loaded)" afterwards: the player asked to
30
+ * take someone alive, killed them, and read about it in the past tense.
31
+ * A refusal costs no action and names both ways out.
32
+ *
33
+ * THE TRADEOFFS THAT KEEP IT HONEST are unchanged: the target's
34
+ * retaliation is fully lethal (weaponProfile is never handed this verb's
35
+ * intent), and beating someone far past unconsciousness overflows into
36
+ * their body -- you can still kill someone you meant to capture.
37
+ *
38
+ * SR5 "SUBDUING" (p.195) IS A DIFFERENT ACTION and lives next door in
39
+ * commands/grapple.ts -- the grapple that immobilises and deals no
40
+ * damage, escaped with "struggle". This verb is the knockout.
10
41
  */
11
42
  export class SubdueCommand extends AttackCommand {
12
43
  static verb = 'subdue';
13
44
  /** Touches another actor: authorized per-act for AI-driven actors
14
45
  * (utilities/npc-authorization.ts, aCErAfvEW7G23FWDM). */
15
46
  static npcPolicy = 'authorized';
16
- static description = 'Take someone down ALIVE -- stun damage with your bare hands. Slower than shooting, they fight back for real, and overdoing it can still kill them.';
47
+ static description = 'Take someone down ALIVE. Uses whatever you are holding that CAN go non-lethal: stun gear (taser, stun baton, shock gloves), stun rounds in the magazine, or a lethal melee weapon swung as a club -- Accuracy 3 and no Reach (p.186). Bare hands otherwise, which are Stun by default. A lethal gun with lethal rounds is refused, not fired. They fight back for real, and overdoing it can still kill them. Also "stun", "knock out". ("grapple" is the other thing: SR5 Subduing, p.195 -- hold them still, no damage.)';
48
+ /**
49
+ * How this actor can take someone alive with what is in their hands.
50
+ * One reader, used by the refusal, the draw decision and the profile,
51
+ * so the three can never disagree about what the weapon is for.
52
+ */
53
+ nonLethalRoute() {
54
+ const actor = this.actor;
55
+ const held = actor.weaponDrawn ? actor.getCarriedWeapon() : undefined;
56
+ if (!held)
57
+ return 'hands';
58
+ if (held.isStunWeapon())
59
+ return 'stun-weapon';
60
+ if (held.isFirearm())
61
+ return held.stunAmmo() ? 'stun-ammo' : 'lethal-gun';
62
+ if (held.category === Category.MeleeWeapon)
63
+ return 'club';
64
+ // Anything else in your hands is not a weapon this verb can pull --
65
+ // fall back to the hands themselves rather than inventing a rule.
66
+ return 'hands';
67
+ }
17
68
  async execute(args = []) {
18
- // A chokehold needs hands and a neck: no grappling as a Matrix persona
19
- // or an astral form. (Matrix "non-lethal" is meaningless anyway -- a
20
- // crashed ice program isn't a corpse; and astral entities are put down
21
- // with force, not wrestled.)
69
+ // "knock out <name>" / "knock down <name>": the verb is words[0]
70
+ // everywhere in this dispatcher, so the second word of a two-word
71
+ // spelling arrives as the first arg and would be read as part of the
72
+ // target's name.
73
+ const rest = [...(args ?? [])];
74
+ if (/^(out|down)$/i.test(rest[0] ?? ''))
75
+ rest.shift();
76
+ // A chokehold needs hands and a neck: no knockouts as a Matrix
77
+ // persona or an astral form. (Matrix "non-lethal" is meaningless
78
+ // anyway -- a crashed ice program isn't a corpse; and astral entities
79
+ // are put down with force, not wrestled.)
22
80
  if (this.actor.plane !== 'meat') {
23
- return `You can't grapple anything from here -- subduing takes real hands on a real body.`;
81
+ return `You can't take anything alive from here -- a knockout needs real hands on a real body.`;
82
+ }
83
+ // THE REFUSAL COMES BEFORE THE ROLL, so it costs nothing. See the
84
+ // class comment: this case used to fire and apologise afterwards.
85
+ if (this.nonLethalRoute() === 'lethal-gun') {
86
+ const gun = this.actor.getCarriedWeapon();
87
+ return [
88
+ `${gun?.name ?? 'That gun'} is loaded with lethal rounds -- there is no way to shoot someone alive with it (p.186: short of a called shot, a firearm deals what it is loaded with).`,
89
+ hint(` ("reload gel" or "reload stick-n-shock" if you are carrying either, "holster" and "subdue" bare-handed, or "attack" if you meant it.)`),
90
+ ].join('');
24
91
  }
25
- return super.execute(args);
92
+ return super.execute(rest);
26
93
  }
94
+ /** The weapon speaks unless the answer is your own hands. */
27
95
  usesEquippedWeapon() {
28
- return false;
96
+ return this.nonLethalRoute() !== 'hands';
29
97
  }
30
98
  actionVerb() {
31
99
  return 'moves to subdue';
32
100
  }
33
101
  strikeProfile(exchange, target) {
102
+ const actor = this.actor;
103
+ // WITH SOMETHING IN YOUR HANDS the normal weapon profile builds the
104
+ // strike -- it already knows electricity, stun rounds and the p.186
105
+ // club-swing (Accuracy 3, Reach gone); all it was ever missing was
106
+ // being told that this swing means to leave them breathing.
107
+ if (this.nonLethalRoute() !== 'hands') {
108
+ return exchange.weaponProfile(actor, target, { nonLethal: true });
109
+ }
34
110
  return {
35
111
  label: 'with a subduing hold',
36
112
  // Bare-hands work: rolled on the UNARMED skill regardless of what's
37
113
  // drawn -- a pistol's quality (or your firearms training) doesn't
38
114
  // help you choke someone out. See Player.getUnarmedAttackPool.
39
- pool: Math.max(0, this.actor.getUnarmedAttackPool() + this.actor.consumeEdgeBoost()),
40
- damageBase: 1 + Math.floor(this.actor.strength / 2),
115
+ pool: Math.max(0, actor.getUnarmedAttackPool() + actor.consumeEdgeBoost()),
116
+ // (STR)S, p.187 -- the same number combat-exchange.ts has always
117
+ // used for a bare fist. This said `1 + floor(STR/2)` for no reason
118
+ // the file could name, so a troll's chokehold was weaker than the
119
+ // identical troll's punch; fixed while the verb was being rebuilt
120
+ // around it (f79LLqTerep4nKWcA).
121
+ damageBase: actor.strength + actor.bonus('unarmed-damage'),
41
122
  soakPool: target.getSoakPool(),
42
123
  dealStun: true,
43
124
  };
@@ -54,14 +54,25 @@ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-aud
54
54
  */
55
55
  export class TapCommand extends Command {
56
56
  static verb = 'tap';
57
- static description = 'Cable into a camera you are standing next to -- a DIRECT CONNECTION (p.232). A slaved device can\'t hide behind its host\'s ratings against a cable, so the camera fights you with its own; and a mark on a slaved device is a mark on the host it answers to (p.233), which is your way in. Needs your BODY in the room and a deck in hand. "tap" is sleaze (fail = noticed); "tap loud" is brute force (fail = unnoticed, the firewall bites).';
58
- async execute(args = []) {
57
+ static description = 'Cable into a camera you are standing next to -- a DIRECT CONNECTION (p.232), run as HACK ON THE FLY (p.240). A slaved device can\'t hide behind its host\'s ratings against a cable, so the camera fights you with its own; and a mark on a slaved device is a mark on the host it answers to (p.233), which is your way in. Needs your BODY in the room and a deck in hand. Fail and you are noticed. To cable in loud instead, "force camera" is Brute Force over the same cable (p.238).';
58
+ /**
59
+ * WHICH INTRUSION ACTION THE CABLE CARRIES. Same rule as hack.ts:
60
+ * the verb decides, not a word inside the target name. `tap loud`
61
+ * used to scan the args for loud/force/brute/attack and flip -- so a
62
+ * camera named for any of those words could not be tapped quietly,
63
+ * and the word itself taught nothing. `force camera` routes here with
64
+ * the Attack mode set (commands/hack.ts), which is the same hop
65
+ * `hack camera` has always made.
66
+ */
67
+ modeOverride;
68
+ /** How `force camera` reaches the loud route over the same cable. */
69
+ withMode(m) { this.modeOverride = m; return this; }
70
+ mode() { return this.modeOverride ?? 'sleaze'; }
71
+ async execute() {
59
72
  const actor = this.actor;
60
73
  const room = actor.currentLocation;
61
74
  const rooms = this.rooms ?? {};
62
- // Same mode vocabulary as hack.ts and mark.ts -- one fork, learned once.
63
- const words = (args ?? []).map(w => w.toLowerCase());
64
- const mode = words.some(w => /^(loud|force|brute|attack)$/.test(w)) ? 'attack' : 'sleaze';
75
+ const mode = this.mode();
65
76
  if (!isWatched(room)) {
66
77
  return `No cameras in here to cable into.${hint(` (Your overlay tags them "◎ Camera cluster" when there are.)`)}`;
67
78
  }
@@ -843,5 +843,89 @@
843
843
  // about someone else's haul, on top of the feed line it already
844
844
  // had. Scoped to the sweeper; and the two performAction calls that
845
845
  // passed a bare "take" now pass "takes", like every sibling verb.
846
- export const ENGINE_VERSION = '1.67.0';
846
+ // 1.68.0 (2026-09-16): THERE IS NO STANCE (f79LLqTerep4nKWcA). The
847
+ // Erase Mark bench came back rejected without the reporter ever
848
+ // reaching Erase Mark: the vocabulary it walked through did not
849
+ // survive being read. "what is 'hack quiet' -- I don't see a 'quiet',
850
+ // is it just 'mark'? How do I know which host/PAN/etc I'm hacking?"
851
+ // ... "'sealed, walls UP, over you' means nothing, it's just jargon."
852
+ // ... and the ruling: "Let's get rid of the stance all together --
853
+ // it's now confusing as we have turn-based combat. The extra
854
+ // 'overrides' is confusing."
855
+ // - ONE VERB PER BOOK ACTION. `hack <target>` IS Hack on the Fly
856
+ // (p.240); `force <target>` (brute, smash) IS Brute Force (p.238) --
857
+ // commands/force.ts, which is HackCommand with one method
858
+ // overridden, so the entire target ladder is shared. The mode words
859
+ // loud/quiet/sleaze/sneak/force/brute/attack are gone from the
860
+ // parser: every word a player types is now part of a NAME.
861
+ // - `mark` STOPS BEING AN ACTION and becomes the ledger, BOTH
862
+ // directions -- what you hold, and what holds you. It routed into
863
+ // hack before, which is the "then what is mark?" in the report: two
864
+ // spellings of one action and no way to tell them apart. It costs
865
+ // nothing and bills nothing. Alias `marks`.
866
+ // - THE MEAT HALF MOVED ONTO ITS VERBS TOO. `attack`/`kill` deal what
867
+ // the gear was sold to deal; `subdue` (stun, knock out) takes them
868
+ // alive and now uses whatever is in your hands that CAN go Stun --
869
+ // stun gear, stun rounds, or a blade swung as a club at Accuracy 3
870
+ // with no Reach (p.186), bare hands otherwise. A lethal gun with
871
+ // lethal rounds is REFUSED before the dice instead of fired and
872
+ // labelled " (LETHAL -- no stun rounds loaded)" afterwards.
873
+ // combat-exchange.weaponProfile takes the intent as an argument.
874
+ // `grapple` is still SR5 Subduing (p.195) and is untouched.
875
+ // - SWEPT THE FAMILY, because a verb fixed alone reproduces the report
876
+ // one layer down: `tap loud` and `disable loud`/`disable quiet` were
877
+ // the same hidden adverb. `force <camera>` is the loud cable now,
878
+ // and `brick` is the only spelling of Data Spike.
879
+ // - SUBDUE'S DV WAS WRONG and is fixed while the file was open:
880
+ // `1 + floor(STR/2)` where RAW unarmed is (STR)S (p.187), the number
881
+ // combat-exchange has always used for a bare fist. A troll's
882
+ // chokehold was weaker than the same troll's punch.
883
+ // - "sealed, walls UP, over you" IS GONE from hostLine, and it was
884
+ // worse than jargon: `standing` read only the marks YOU hold, so a
885
+ // host holding three marks on your persona printed "sealed", the
886
+ // word for a node nobody has touched. Four facts, four plain
887
+ // clauses, swept across every host renderer (hostLine, hostDockLine,
888
+ // gridIcons, hostInteriorPanel): what you hold, what it holds on
889
+ // you, whether it knows someone is in (alert) or can see YOU
890
+ // (spotted), and whether it is the node overhead.
891
+ // - THE HUD'S FIRST LINE carried the stance so players would discover
892
+ // Tab. It carries the MARK LEDGER now, both counts -- the one
893
+ // always-visible surface, in the CLI and in the headless transcript.
894
+ // Only the web character sheet ever showed both directions.
895
+ // - TAB COMPLETES THE NAME YOU ARE TYPING, answered by the engine:
896
+ // Tab sends `complete <box text>` (commands/complete.ts, hidden
897
+ // verb, no action, no bill) and utilities/nameables.ts answers from
898
+ // the live scene. It has to be the engine: the condition beat
899
+ // carries meat-plane BODIES only, so jacked in no client knows a
900
+ // single icon's name, and the CLI on a shared run holds nothing but
901
+ // rendered text. The reply's first line is `{complete}<text>` and
902
+ // the client refills its box from it.
903
+ // SAVE SHAPE: autoStance and matrixStance are no longer written and
904
+ // no longer read; older saves carry them harmlessly. The condition
905
+ // report drops `stance` (the site and Unity drop it in the same
906
+ // release). REMOVED: the `stance` verb.
907
+ // 1.69.0 (2026-09-16): THE THREE THINGS WALKING THE REPORT FOUND. A real
908
+ // headless boot of f79LLqTerep4nKWcA's own path, read line by line.
909
+ // - "mark <target>" SWALLOWED THE KEYSTROKE. 1.68.0 made `mark` the
910
+ // ledger and stopped it routing into hack -- correctly -- but a
911
+ // player typing the spelling the reporter themselves expected ("It's
912
+ // 'hack <target>' or 'mark <target>'") got a ledger dump and no word
913
+ // about why nothing happened. It explains now, names both verbs
914
+ // against the target they typed, and prints the ledger underneath.
915
+ // Nothing is billed: there is no action here to spend.
916
+ // - FIVE HINTS IN hack.ts SAID "hack" WHATEVER RAN. force.ts shares
917
+ // the file, so a Brute Force answered with "hack for 2 reaches for
918
+ // the rest at once" -- the same mix-up the item is about, one layer
919
+ // in. They read this.verbName.
920
+ // - A HEADING IS NOT AN ICON. gridIcons splices a divider sentence
921
+ // between a host's icon and the icons near it; gridIconBlock's
922
+ // distance bands build names by stripping the glyph and cutting at
923
+ // " -- ", so `look` listed an icon called "and NEAR it, out on the
924
+ // open grid (not inside the host):". Pre-existing, and in the exact
925
+ // panel the report is about.
926
+ // Also: hack's no-such-target refusal and Tab's completion now read
927
+ // ONE list (utilities/nameables.ts), which is what that module always
928
+ // claimed. The refusal listed hosts only, under a sentence promising
929
+ // it had looked for a PAN and a camera too.
930
+ export const ENGINE_VERSION = '1.69.0';
847
931
  //# sourceMappingURL=engine-version.js.map
@@ -1,6 +1,5 @@
1
1
  import { sustainBurden, sustainTotal } from './utilities/sustaining.js';
2
2
  import { agentRatingFromName } from './utilities/programs.js';
3
- import { MATRIX_STANCE_ACTION } from './models/player.js';
4
3
  import { buildDossierUrl } from './utilities/web-character-generator.js';
5
4
  import { makaHost } from '../../../tools/https/maka-host.js';
6
5
  import { openBrowser } from '../../../tools/browser/open-browser.js';
@@ -115,9 +114,10 @@ import { EndCallCommand } from './commands/end-call.js';
115
114
  import { fuzzyPickName, guessVerb } from './utilities/fuzzy-match.js';
116
115
  import { PickCommand } from './commands/pick.js';
117
116
  import { HackCommand } from './commands/hack.js';
117
+ import { ForceCommand } from './commands/force.js';
118
+ import { CompleteCommand } from './commands/complete.js';
118
119
  import { DisableCommand, BrickCommand } from './commands/disable.js';
119
120
  import { CastCommand } from './commands/cast.js';
120
- import { StanceCommand } from './commands/stance.js';
121
121
  import { FollowCommand, UnfollowCommand } from './commands/follow.js';
122
122
  import { SurrenderCommand } from './commands/surrender.js';
123
123
  import { JackCommand } from './commands/jack.js';
@@ -2884,6 +2884,11 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2884
2884
  CommandFactory.registerCommand('fit', FitCommand);
2885
2885
  CommandFactory.registerCommand('unfit', UnfitCommand);
2886
2886
  CommandFactory.registerCommand('subdue', SubdueCommand);
2887
+ // The non-lethal intent is a VERB now, not a stance (f79LLqTerep4nKWcA).
2888
+ // `knock` carries "knock out <name>" / "knock down <name>"; subdue.ts
2889
+ // strips the second word, which the dispatcher hands it as an arg.
2890
+ CommandFactory.registerCommand('stun', SubdueCommand);
2891
+ CommandFactory.registerCommand('knock', SubdueCommand);
2887
2892
  // SUBDUING p.195 (see commands/grapple.ts): restrain without damage.
2888
2893
  // Canon skill hooks (see SkillName in models/player.ts).
2889
2894
  CommandFactory.registerCommand('palm', PalmCommand);
@@ -2923,10 +2928,14 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2923
2928
  CommandFactory.registerCommand('end-call', EndCallCommand);
2924
2929
  CommandFactory.registerCommand('pick', PickCommand);
2925
2930
  CommandFactory.registerCommand('hack', HackCommand);
2931
+ // BRUTE FORCE IS ITS OWN VERB (f79LLqTerep4nKWcA), not a word you add
2932
+ // to that one -- see commands/force.ts for why.
2933
+ CommandFactory.registerCommand('force', ForceCommand);
2934
+ CommandFactory.registerCommand('brute', ForceCommand);
2935
+ CommandFactory.registerCommand('smash', ForceCommand);
2926
2936
  CommandFactory.registerCommand('disable', DisableCommand);
2927
2937
  CommandFactory.registerCommand('brick', BrickCommand);
2928
2938
  CommandFactory.registerCommand('cast', CastCommand);
2929
- CommandFactory.registerCommand('stance', StanceCommand);
2930
2939
  CommandFactory.registerCommand('surrender', SurrenderCommand);
2931
2940
  CommandFactory.registerCommand('jack', JackCommand);
2932
2941
  CommandFactory.registerCommand('project', ProjectCommand);
@@ -3081,6 +3090,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3081
3090
  CommandFactory.registerCommand('wipe', EraseCommand);
3082
3091
  // MAtrix Recognition Keys (canon p.235-236).
3083
3092
  CommandFactory.registerCommand('mark', MarkCommand);
3093
+ CommandFactory.registerCommand('marks', MarkCommand);
3094
+ CommandFactory.registerCommand('complete', CompleteCommand);
3084
3095
  CommandFactory.registerCommand('unmark', EraseMarkCommand);
3085
3096
  // SPRITE POWERS (p.256-257): one verb per power, reached as
3086
3097
  // "order <sprite> <power> <target>" -- see commands/sprite-power.ts.
@@ -5892,40 +5903,41 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5892
5903
  }
5893
5904
  statusLinesFor(player) {
5894
5905
  const lines = [];
5895
- // Line 1: the stance -- lethal or non-lethal in the meat (the damage
5896
- // type your next blow means; models/player.ts FightStance), Brute
5897
- // Force or Hack on the Fly on the Matrix. It lives HERE -- the
5898
- // shortest HUD line -- because appended to the long readiness line it
5899
- // wrapped out of the box on narrower terminals and effectively only
5900
- // ever showed in the Tab announcement. Always shown: the HUD is how
5901
- // players discover Tab flips it at all. Red for the lethal routes,
5902
- // yellow for the careful ones -- never gray, which is ANSI "bright
5903
- // black" and illegible on a stock dark palette.
5906
+ // Line 1: what you are holding, and -- jacked in -- the MARK LEDGER,
5907
+ // both directions.
5908
+ //
5909
+ // THIS SLOT USED TO CARRY THE STANCE, and it carried it for a reason
5910
+ // that died with the stance: the HUD was how players discovered Tab
5911
+ // flipped it at all. With no posture left to advertise
5912
+ // (f79LLqTerep4nKWcA) the shortest HUD line is free, and the thing
5913
+ // most worth putting in it is the one the same report asked for --
5914
+ // "when I have a mark on it, show my mark on it, and vice versa --
5915
+ // when it has a mark on me, show that."
5916
+ //
5917
+ // Both counts, always, on the one surface a persona cannot miss.
5918
+ // Only the web character sheet ever printed the two ledgers side by
5919
+ // side; the CLI and the headless transcript (headless.ts status
5920
+ // events run through this very method) printed neither, so a runner
5921
+ // carrying three host marks read a grid map that said "sealed".
5922
+ // Red once anything holds a key on you -- that is the number that
5923
+ // decides whether the ice can find you -- yellow while only you hold
5924
+ // keys, and never gray, which is ANSI "bright black" and illegible
5925
+ // on a stock dark palette.
5904
5926
  //
5905
5927
  // WHERE YOU ARE MOVED OUT (player ruling 2026-08-25): the room name
5906
5928
  // now captions the Map box and the "@ spot" captions the Room box,
5907
5929
  // each under the picture it belongs to, so the HUD stops repeating
5908
- // what two panels already show. The breadcrumb went with it -- the
5909
- // district map draws the way you came far better than a text trail.
5910
- // THE STANCE THE HUD SHOWS IS THE ONE IN FORCE (uMQAhaAysgaKpWFkn,
5911
- // 2026-09-03). Jacked in, the fight stance is not what Tab moves
5912
- // and not what your next action obeys -- the Matrix stance is
5913
- // (Brute Force or Hack on the Fly). Showing the meat stance to
5914
- // a persona would be the same class of bug as the 2026-08-27
5915
- // report that the HUD did not follow Tab at all: a readout naming
5916
- // a setting the player cannot currently change.
5930
+ // what two panels already show.
5917
5931
  const onMatrix = player.plane === 'matrix';
5918
- const stanceWord = onMatrix ? MATRIX_STANCE_ACTION[player.matrixStance] : player.autoStance;
5919
- const stanceColor = onMatrix
5920
- ? (player.matrixStance === 'attack' ? 'red' : 'yellow')
5921
- : player.autoStance === 'lethal' ? 'red' : 'yellow';
5922
- // WHAT'S IN YOUR HANDS rides alongside the stance (player ruling
5923
- // 2026-08-25). The two answer the same question -- how ready are you
5924
- // -- and the stance line was left short and lonely once the location
5925
- // moved out to the map captions, while the readiness line below ran
5926
- // long. armedLine is built further down with the rest of the gear
5927
- // read; only its placement moved.
5928
- lines.push(`{${stanceColor}-fg}${onMatrix ? '◈' : '⚔'} ${stanceWord.toUpperCase()}{/${stanceColor}-fg} ${this.armedLine(player)}`);
5932
+ if (onMatrix) {
5933
+ const held = marksPlaced(this.scene, player.name).reduce((n, m) => n + m.count, 0);
5934
+ const on = marksOnYou(this.scene, player).reduce((n, m) => n + m.count, 0);
5935
+ const markColor = on > 0 ? 'red' : held > 0 ? 'yellow' : 'light-blue';
5936
+ lines.push(`{${markColor}-fg}◈ MARKS ${held} held · ${on} ON YOU{/${markColor}-fg} ${this.armedLine(player)}`);
5937
+ }
5938
+ else {
5939
+ lines.push(`{red-fg}⚔ ARMED{/red-fg} ${this.armedLine(player)}`);
5940
+ }
5929
5941
  // Line 2: both condition monitors -- in a game where a fight can kill
5930
5942
  // you and casting can knock you out, current damage on either track is
5931
5943
  // never something a player should have to ask for. Colors shift as
@@ -6738,14 +6750,14 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6738
6750
  static HELP_CATEGORIES = [
6739
6751
  { title: 'moving', entries: [['look'], ['go'], ['move', 'walk', 'approach'], ['run'], ['sprint'], ['climb', 'mantle', 'scale', 'clamber'], ['descend'], ['sit', 'kneel'], ['lie', 'prone'], ['stand'], ['follow'], ['unfollow'], ['map', 'exits'], ['search'], ['sneak']] },
6740
6752
  { title: 'gear', entries: [['inv', 'inventory'], ['equipment', 'eq'], ['take'], ['loot'], ['drop'], ['give'], ['store'], ['open'], ['close'], ['put'], ['equip'], ['unequip'], ['fit'], ['unfit'], ['brandish', 'draw'], ['holster'], ['reload'], ['use'], ['read']] },
6741
- { title: 'combat', entries: [['attack'], ['kill'], ['subdue'], ['grapple', 'restrain', 'clinch'], ['struggle'], ['release'], ['cover'], ['aim'], ['defend'], ['delay'], ['end-turn', 'endturn', 'done', 'pass'], ['initiative', 'tracker', 'turn'], ['nudge'], ['stance'], ['surrender'], ['edge'], ['rest'], ['heal', 'firstaid', 'bandage', 'patch']] },
6753
+ { title: 'combat', entries: [['attack'], ['kill'], ['subdue', 'stun', 'knock'], ['grapple', 'restrain', 'clinch'], ['struggle'], ['release'], ['cover'], ['aim'], ['defend'], ['delay'], ['end-turn', 'endturn', 'done', 'pass'], ['initiative', 'tracker', 'turn'], ['nudge'], ['surrender'], ['edge'], ['rest'], ['heal', 'firstaid', 'bandage', 'patch']] },
6742
6754
  // The party layer: everyone who walks (or flies, or manifests) at
6743
6755
  // your side answers to these.
6744
6756
  // "players" folded back under crew after playtesting ("crew
6745
6757
  // players" / "crew street"); the bare verb still answers quietly.
6746
6758
  { title: 'party', entries: [['crew', 'party'], ['hire'], ['dismiss'], ['train'], ['order'], ['lead'], ['command'], ['deploy'], ['recall'], ['stow']] },
6747
6759
  { title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project', 'astral'], ['return'], ['assense'], ['counterspell']] },
6748
- { title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['unmark'], ['disable'], ['brick'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['hide'], ['edit'], ['erase', 'wipe'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
6760
+ { title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['force', 'brute', 'smash'], ['mark', 'marks'], ['unmark'], ['disable'], ['brick'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['hide'], ['edit'], ['erase', 'wipe'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
6749
6761
  // The Emerged get their own shelf (player request: "there MUST be
6750
6762
  // resonance" -- matrix is the place, Resonance is the talent).
6751
6763
  // `sustain` shelves here and not under magic: the command is
@@ -6767,7 +6779,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
6767
6779
  // ("first aid", "apply first aid") that used to die at the parser. As
6768
6780
  // bare words they mean nothing, so they are claimed but not advertised
6769
6781
  // -- `heal` is the verb the help screen teaches.
6770
- static HIDDEN_VERBS = ['players', 'first', 'apply'];
6782
+ // `complete` is what Tab sends (commands/complete.ts) -- a question the
6783
+ // input box asks, not a verb anyone types, so it stays out of help.
6784
+ static HIDDEN_VERBS = ['players', 'first', 'apply', 'complete'];
6771
6785
  /** Session verbs that only a play-tester (or admin) can actually use
6772
6786
  * -- the server role-gates both and 403s everyone else. Listed in
6773
6787
  * help ONLY for a confirmed play-tester; see helpCategories. `roles`
@@ -16,38 +16,6 @@ import { physicalLimit } from '../utilities/grapple.js';
16
16
  import { astralLimit } from '../utilities/limits.js';
17
17
  import { Inventory } from './inventory.js';
18
18
  import { AbstractPlayer } from '../types/shared/abstracts.js';
19
- export const STANCE_CYCLE = ['lethal', 'non-lethal'];
20
- /** The old names, still accepted from saves and the stance command. */
21
- export const STANCE_ALIASES = {
22
- guarded: 'non-lethal', aggressive: 'lethal', manual: 'lethal',
23
- nonlethal: 'non-lethal', 'non lethal': 'non-lethal', stun: 'non-lethal', kill: 'lethal',
24
- };
25
- /** What each stance means, in one line -- the HUD's and the verb's. */
26
- export const STANCE_DESCRIPTION = {
27
- lethal: 'your weapons deal the damage they were sold to deal; "attack" and "kill" mean it',
28
- 'non-lethal': 'Stun wherever the gear allows -- stun rounds when you reload, the flat of the blade (p.186), bare hands -- and a lethal gun with lethal rounds says so before it fires',
29
- };
30
- export const MATRIX_STANCE_CYCLE = ['attack', 'sleaze'];
31
- /** What each stance is CALLED in the book -- printed everywhere the
32
- * engine used to say "loud"/"quiet", so the vocabulary teaches the
33
- * real action names (the whole point of uMQAhaAysgaKpWFkn). */
34
- export const MATRIX_STANCE_ACTION = {
35
- attack: 'Brute Force',
36
- sleaze: 'Hack on the Fly',
37
- };
38
- /** The words a player may type, including the engine's older ones. */
39
- export const MATRIX_STANCE_ALIASES = {
40
- loud: 'attack', force: 'attack', brute: 'attack', 'brute force': 'attack', bf: 'attack',
41
- quiet: 'sleaze', sneak: 'sleaze', 'hack on the fly': 'sleaze', hotf: 'sleaze', fly: 'sleaze',
42
- };
43
- export function normalizeMatrixStance(v) {
44
- const k = (v ?? 'attack').toLowerCase();
45
- return MATRIX_STANCE_ALIASES[k] ?? (MATRIX_STANCE_CYCLE.includes(k) ? k : 'attack');
46
- }
47
- export function normalizeStance(v) {
48
- const k = (v ?? 'lethal').toLowerCase();
49
- return STANCE_ALIASES[k] ?? (STANCE_CYCLE.includes(k) ? k : 'lethal');
50
- }
51
19
  /** Meat and a jumped-in drone share the physical world. */
52
20
  export function isPhysicalPlane(plane) {
53
21
  return plane === 'meat' || plane === 'drone';
@@ -194,29 +162,9 @@ export class Player extends AbstractPlayer {
194
162
  // session where an NPC's overlapping AI loops fired two exchanges at
195
163
  // once and the target died TWICE (two kill events, two narrations).
196
164
  inExchange = false;
197
- // The fight stance (see FightStance above): lethal or non-lethal, the
198
- // damage type your next blow is meant to deal. Tab cycles it in the UI;
199
- // "stance" sets it by name. Every action is still yours to type -- the
200
- // Combat Turn hands you the phase and waits for "end turn".
201
- // Accessor (live-sheet pass, 2026-09-01): stance is sheet-visible.
202
- _autoStance = 'lethal';
203
- get autoStance() { return this._autoStance; }
204
- set autoStance(v) {
205
- if (this._autoStance === v)
206
- return;
207
- this._autoStance = v;
208
- this.noteConditionChanged();
209
- }
210
- /** Which of the book's two intrusion actions the next hack uses
211
- * (see MatrixStance). Sheet-visible, like its meat-world twin. */
212
- _matrixStance = 'attack';
213
- get matrixStance() { return this._matrixStance; }
214
- set matrixStance(v) {
215
- if (this._matrixStance === v)
216
- return;
217
- this._matrixStance = v;
218
- this.noteConditionChanged();
219
- }
165
+ // (No stance lives here any more -- see the note at the top of this
166
+ // file. The damage type a blow deals is decided by the verb that threw
167
+ // it, and the intrusion action by the verb that ran it.)
220
168
  // SUBDUING (RAW p.195, the "restrain" ask from a real session's runaway
221
169
  // NPC): a grapple links two actors BY NAME -- grappledBy/grappling are
222
170
  // the two ends, grappleThreshold the escape test's bar (the original
@@ -7,6 +7,7 @@ import { Logger } from './utilities/logger.js';
7
7
  import { getArchetype, outfitArchetype } from './archetypes.js';
8
8
  import { attachLineEditor, attachArrowMovement } from './utilities/line-editor.js';
9
9
  import { attachInputHistory } from './utilities/input-history.js';
10
+ import { COMPLETE_MARKER } from './commands/complete.js';
10
11
  import { TERMINAL_RESET_SEQUENCE } from './utilities/terminal-reset.js';
11
12
  import { readSave } from './utilities/persistence.js';
12
13
  import { requireCatalog, CatalogUnavailableError } from './utilities/catalog.js';
@@ -322,10 +323,11 @@ export async function main(playerName, sceneSeed, options) {
322
323
  game.updateInventory(game.player.inventory, capitalCase(game.scene.getCurrencyType()));
323
324
  game.updateExits(game.player.currentLocation);
324
325
  game.updateStatus();
325
- // Tab flips the stance: lethal <-> non-lethal in the meat, Brute
326
- // Force <-> Hack on the Fly on the Matrix (models/player.ts; "stance"
327
- // is the typed twin). Bound on the input box because that's where
328
- // focus lives during play.
326
+ // TAB COMPLETES THE NAME YOU ARE TYPING (f79LLqTerep4nKWcA). It used
327
+ // to flip the stance; the stance is gone, and what the same report
328
+ // asked for in its place is a way to know which icon you are about
329
+ // to hit. Bound on the input box because that's where focus lives
330
+ // during play.
329
331
  game.inputBox.key(['tab'], () => {
330
332
  // Blessed's textarea reader appends the literal '\t' to the box
331
333
  // value on this same keypress (listener order isn't guaranteed
@@ -339,36 +341,36 @@ export async function main(playerName, sceneSeed, options) {
339
341
  };
340
342
  stripTab();
341
343
  setImmediate(() => { stripTab(); screen.render(); });
342
- // ONE ROUTE, BOTH PLANES, BOTH RUN SHAPES. This used to fork: a
343
- // shared run sent "stance next" to the server while a local run
344
- // mutated game.player.autoStance here by hand. That duplicate is
345
- // what made Tab plane-blind -- the Matrix stance
346
- // (uMQAhaAysgaKpWFkn, 2026-09-03) would have had to be added
347
- // twice, and the second copy is the one that gets forgotten.
344
+ // ONE ROUTE, BOTH PLANES, BOTH RUN SHAPES -- and the reason the
345
+ // completion is answered by the ENGINE rather than built here.
348
346
  //
349
- // SHARED RUN: THE STANCE LIVES ON THE SERVER (playtest
350
- // 2026-08-27: "the combat stance isn't updating in the menu
351
- // bar"). game.player here is the LOCAL HUB player -- in a shared
352
- // run the runner on the table is a different object in a
353
- // different process, so cycling this one moved nothing the
354
- // engine reads and nothing the HUD paints (updateStatus
355
- // early-returns on remoteSession; the status box belongs to the
356
- // server's events). Send the typed twin instead and let the
357
- // engine cycle from the ACTOR's real stance.
347
+ // Locally this process holds the whole scene and could list the
348
+ // names itself. In a SHARED RUN it holds nothing: game.player is
349
+ // the LOCAL HUB player, the runner on the table is a different
350
+ // object in a different process, and the room arrives as rendered
351
+ // text with the structured payload dropped (utilities/shared-run.ts).
352
+ // A completer built on what is in memory here would work solo and
353
+ // silently do nothing on the grid with three other people -- which
354
+ // is the same duplicate-path trap the old stance binding fell into.
355
+ // So: send the box, let the engine answer, on both run shapes.
358
356
  void (async () => {
359
- const res = await game.handleInput('stance next');
357
+ const box = game.inputBox.getValue().replace(/\t/g, '');
358
+ const res = await game.handleInput(`complete ${box}`);
360
359
  if (!res)
361
360
  return;
362
- // Red for the lethal routes, yellow for the careful ones (same
363
- // palette as the HUD stance in game.ts): Brute Force is the loud
364
- // one, Hack on the Fly the careful one. Never gray -- ANSI
365
- // bright-black is illegible on stock dark palettes.
366
- const named = res.match(/(?:Matrix s|S)tance:\s*([\w-]+)/i)?.[1]?.toLowerCase();
367
- const c = named === 'lethal' || named === 'brute' ? 'red'
368
- : named === 'non-lethal' || named === 'non' || named === 'hack' ? 'yellow'
369
- : 'white';
370
- gameLog.meta(`⚔ ${res.split('\n')[0]}`, c);
371
- game.updateStatus();
361
+ const lines = res.split('\n');
362
+ // THE MARKER LINE refills the box (see commands/complete.ts).
363
+ // Everything else is the candidate list, and goes to the ticker
364
+ // rather than the transcript: Tab is not a turn.
365
+ if (lines[0]?.startsWith(COMPLETE_MARKER)) {
366
+ const filled = lines[0].slice(COMPLETE_MARKER.length);
367
+ game.inputBox.setValue(filled);
368
+ lines.shift();
369
+ }
370
+ for (const l of lines) {
371
+ if (l.trim().length > 0)
372
+ gameLog.meta(l, 'white');
373
+ }
372
374
  screen.render();
373
375
  })();
374
376
  });