@maka/maka-cli 5.195.0 → 5.197.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 (23) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/advance.js +6 -1
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/buy.js +6 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +8 -2
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/edge.js +5 -5
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +58 -9
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/initiate.js +6 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/install.js +6 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/quality-trade.js +6 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/register.js +6 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/sell.js +6 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +1 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +33 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/game.js +3 -3
  15. package/bundle/typescript/src/commands/game/sideQuest/headless.js +11 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +12 -1
  17. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +24 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/utilities/action-cost.js +36 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/utilities/commerce.js +10 -0
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/grid-reach.js +82 -5
  22. package/bundle/typescript/src/commands/game/sideQuest/utilities/shared-run.js +8 -0
  23. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.195.0",
3
+ "version": "5.197.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.",
@@ -2,6 +2,7 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { SKILL_NAMES } from '../models/player.js';
4
4
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
+ import { inLiveCombat } from '../utilities/action-cost.js';
5
6
  /**
6
7
  * Karma spending: "advance" lists every skill AND attribute with its
7
8
  * current rating and the cost to raise it; "advance <name>" pays and
@@ -27,7 +28,11 @@ export class AdvanceCommand extends Command {
27
28
  ];
28
29
  async execute(args = []) {
29
30
  const actor = this.actor;
30
- if (actor.inExchange) {
31
+ // THE WHOLE FIGHT, not one exchange (hHvR2kxrqAKuuXwED). See
32
+ // inLiveCombat: `inExchange` is a re-entrancy lock that is false for
33
+ // most of a Combat Turn, so this refusal said "firefight" and meant
34
+ // "for the half-second one trade of fire is resolving".
35
+ if (inLiveCombat(this.scene, actor)) {
31
36
  return `Mid-exchange is no time for training montages.`;
32
37
  }
33
38
  if (actor.currentLocation?.roomType !== 'Residence') {
@@ -4,6 +4,7 @@ import { priceOf, vendorsInRoom, waresOf, shelfNote } from '../utilities/commerc
4
4
  import { findCatalogItem, ROLE_DOMAINS } from '../utilities/catalog.js';
5
5
  import { optionMark } from '../utilities/log-style.js';
6
6
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
7
+ import { inLiveCombat } from '../utilities/action-cost.js';
7
8
  /**
8
9
  * Diegetic shopping: "browse" (or bare "buy") in a vendor's room reads
9
10
  * their shelves with live prices; "buy <item>" slides the nuyen across
@@ -20,7 +21,11 @@ export class BuyCommand extends Command {
20
21
  if (actor.plane !== 'meat') {
21
22
  return `Commerce is a meat-world ritual -- come back to your body first.`;
22
23
  }
23
- if (actor.inExchange) {
24
+ // THE WHOLE FIGHT, not one exchange (hHvR2kxrqAKuuXwED). See
25
+ // inLiveCombat: `inExchange` is a re-entrancy lock that is false for
26
+ // most of a Combat Turn, so this refusal said "firefight" and meant
27
+ // "for the half-second one trade of fire is resolving".
28
+ if (inLiveCombat(this.scene, actor)) {
24
29
  return `Nobody rings up a sale in a firefight.`;
25
30
  }
26
31
  const room = actor.currentLocation;
@@ -12,6 +12,7 @@ import { meetLocationFor } from '../utilities/meet-location.js';
12
12
  import { asksForDataWork, noDeckForDataWork } from '../utilities/data-work.js';
13
13
  import { partyCanDeck } from '../factories/scene-chunks.js';
14
14
  import { RecallCommand } from './companions.js';
15
+ import { inLiveCombat } from '../utilities/action-cost.js';
15
16
  export class CallCommand extends Command {
16
17
  static verb = 'call';
17
18
  static description = 'Call someone remotely over a commlink (no need to be in the same room; commlink must be equipped).';
@@ -86,8 +87,13 @@ export class CallCommand extends Command {
86
87
  ? `Cabs take bodies, not personas -- "jack out" first.`
87
88
  : `Cabs take bodies, not ghosts -- "return" first.`;
88
89
  }
89
- if (this.actor.inExchange) {
90
- return `Mid-fight is no time to hail a cab.`;
90
+ // THE WHOLE FIGHT, not one exchange (hHvR2kxrqAKuuXwED -- a cab
91
+ // hailed mid-firefight from the web client, with Initiative rolled
92
+ // and a Halloweener swinging). See inLiveCombat: `inExchange`
93
+ // alone let this through for every beat of a Combat Turn that was
94
+ // not a trade of fire resolving right that instant.
95
+ if (inLiveCombat(this.scene, this.actor)) {
96
+ return `Mid-fight is no time to hail a cab. Finish this, then call.`;
91
97
  }
92
98
  // THE FARE COMES FIRST (playtest 2026-08-26: "I was able to call a
93
99
  // taxi before calling Mr. Krow. I shouldn't be able to until I get
@@ -15,7 +15,7 @@ export class EdgeCommand extends Command {
15
15
  return `Luck has left the building, and so have you.`;
16
16
  }
17
17
  if (actor.edgeBoostArmed) {
18
- return `Your edge is already burning -- land the strike. (${actor.edgeRemaining}/${actor.edge} left after this.)`;
18
+ return `Your edge is already burning -- land the strike. (${actor.edgeRemaining}/${actor.edgeMax} left after this.)`;
19
19
  }
20
20
  if (actor.edgeRemaining <= 0) {
21
21
  return `You're out of luck -- literally. Edge refreshes when the next job starts.`;
@@ -26,16 +26,16 @@ export class EdgeCommand extends Command {
26
26
  if (actor.hasQuality('bad-luck')) {
27
27
  const die = 1 + Math.floor(Math.random() * 6);
28
28
  if (die === 1) {
29
- this.logger.write(`${actor.name} burned an edge point and BAD LUCK ate it (${actor.edgeRemaining}/${actor.edge} left).`);
29
+ this.logger.write(`${actor.name} burned an edge point and BAD LUCK ate it (${actor.edgeRemaining}/${actor.edgeMax} left).`);
30
30
  this.scene.updateStatus();
31
- return `You reach for the luck that got you this far -- and it CURDLES. The point is spent; nothing comes. (Bad Luck. ${actor.edgeRemaining}/${actor.edge} edge left this job.)`;
31
+ return `You reach for the luck that got you this far -- and it CURDLES. The point is spent; nothing comes. (Bad Luck. ${actor.edgeRemaining}/${actor.edgeMax} edge left this job.)`;
32
32
  }
33
33
  }
34
34
  actor.edgeBoostArmed = true;
35
- this.logger.write(`${actor.name} burned an edge point (${actor.edgeRemaining}/${actor.edge} left).`);
35
+ this.logger.write(`${actor.name} burned an edge point (${actor.edgeRemaining}/${actor.edgeMax} left).`);
36
36
  this.actor.performAction('pushes the limit', '');
37
37
  this.scene.updateStatus();
38
- return `You reach for the luck that got you this far. The next strike you throw rolls +${actor.edge} dice. (${actor.edgeRemaining}/${actor.edge} edge left this job.)`;
38
+ return `You reach for the luck that got you this far. The next strike you throw rolls +${actor.edge} dice. (${actor.edgeRemaining}/${actor.edgeMax} edge left this job.)`;
39
39
  }
40
40
  }
41
41
  //# sourceMappingURL=edge.js.map
@@ -1,6 +1,6 @@
1
1
  import { BypassCommand } from './bypass-command.js';
2
2
  import { gridActionModifier, gridNote } from '../utilities/grids.js';
3
- import { resolveHostInReach, hostsInReach, hostOver, gridVicinity, roomsInReach, noiseNote } from '../utilities/grid-reach.js';
3
+ import { resolveHostInReach, hostsInReach, hostOver, gridVicinity, roomsInReach, noiseNote, namesHostExactly } from '../utilities/grid-reach.js';
4
4
  import { accrueOverwatch } from '../utilities/overwatch.js';
5
5
  import { hint } from '../utilities/hints.js';
6
6
  import { dropActiveCall } from '../utilities/comm-style.js';
@@ -86,7 +86,20 @@ export class HackCommand extends BypassCommand {
86
86
  // working deck in hand (which is also how enemy deckers get YOU).
87
87
  // Tried first so a name never falls through to the barrier-bypass
88
88
  // path; misses fall through untouched.
89
- if (rest && rest.length > 0) {
89
+ // ...UNLESS THE PLAYER NAMED A HOST OUTRIGHT (WTLfRHxJEnPmN2ht9:
90
+ // "this keeps matching on Torque's PAN even though I gave it exactly
91
+ // the host name"). tryPanHack resolves with fuzzyPickName over every
92
+ // body in reach, whose tier 2 is substring containment in EITHER
93
+ // direction -- so any runner whose name is contained in the host's
94
+ // (or contains a word of it) swallowed the query before the host
95
+ // pass below ever ran, and no spelling of the host's name could get
96
+ // past them. An exact alias hit settles it here instead; anything
97
+ // less falls through and the old order stands, so a genuinely
98
+ // ambiguous word still reaches the PAN it always did.
99
+ const namedHost = rest.length > 0
100
+ ? namesHostExactly(this.rooms ?? this.scene.getRooms(), this.actor, rest.join(' '))
101
+ : undefined;
102
+ if (rest && rest.length > 0 && !namedHost) {
90
103
  const panResult = await this.tryPanHack(rest, mode);
91
104
  if (panResult)
92
105
  return panResult;
@@ -145,9 +158,34 @@ export class HackCommand extends BypassCommand {
145
158
  // before allowing the crack would leave an unmarked decker with no
146
159
  // legal move against a host at all. That is a playability ruling
147
160
  // this engine owns, not something SR5 says.
148
- if (room.hostCracked) {
149
- return `This host is already cracked open -- its security hangs in shreds. Whatever it guarded is yours to take.`;
150
- }
161
+ // A CRACKED HOST IS NOT A HOST YOU STILL HOLD MARKS ON
162
+ // (spcebZTKN24GYRHXW). What stood here was a blanket refusal --
163
+ // "already cracked open, whatever it guarded is yours to take" --
164
+ // keyed on the room-global crack flag and nothing else. That is
165
+ // wrong twice over.
166
+ //
167
+ // Canon first: marks are per-persona and "only last a single
168
+ // Matrix session ... deleted when you reboot" (p.236, Recognition
169
+ // Keys; p.242, Reboot Device erases them in both directions). A
170
+ // decker who takes a host to three marks and then reboots holds
171
+ // NOTHING on it -- "if they want access again, they must re-place
172
+ // the marks" via Brute Force or Hack on the Fly. The host is never
173
+ // permanently keyed to them.
174
+ //
175
+ // Playability second, and this is what the report actually hit: a
176
+ // reboot mid-run left the persona on 0 marks with the flag still
177
+ // up, so `mark`/`hack` refused, and every mark-gated device action
178
+ // on that node then refused for the opposite reason ("takes orders
179
+ // from 2 marks and you hold 0", Control Device p.238). No verb in
180
+ // the game could hand the marks back. The node was dead to that
181
+ // runner for the rest of the run.
182
+ //
183
+ // The crack's WORLD consequences stay permanent and stay here --
184
+ // maglocks released, vault unsealed, archive listed. Those are
185
+ // facts about the site, not about whose keys are in the lattice.
186
+ // Only the standing is per-persona, and the guard for that is the
187
+ // MAX_MARKS check below, which already says the right thing.
188
+ const alreadyCracked = room.hostCracked;
151
189
  // What the host masters, through the room it stands over (models/host.ts).
152
190
  const lockedDoors = room.host.maglocks();
153
191
  // MARKS ARE THE CURRENCY, AND THIS IS AN OPPOSED TEST NOW
@@ -314,7 +352,14 @@ export class HackCommand extends BypassCommand {
314
352
  // its feeds over. Computed AFTER hostCracked is set, so this is
315
353
  // the state the player is now standing in, not the one they left.
316
354
  const feedsNow = canSnoopFeeds(this.rooms ?? {}, room, this.actor);
317
- const lines = [`You're in. The host's sculpted security folds back layer by layer --`];
355
+ const lines = [alreadyCracked
356
+ // RE-KEYING A NODE THAT IS ALREADY IN SHREDS. Everything below
357
+ // still runs -- a door the last crack released is already
358
+ // unlocked, a file already spilled is already listed -- so the
359
+ // only thing that changes is the sentence that would otherwise
360
+ // claim you just broke in somewhere you had broken before.
361
+ ? `You're back in. The wreckage of the last break-in is still hanging where you left it, and your keys are in the lattice again --`
362
+ : `You're in. The host's sculpted security folds back layer by layer --`];
318
363
  // WHICH DOORS ARE ICONS, AND WHICH ARE JUST THE HOST'S OWN WALLS.
319
364
  //
320
365
  // A door held by a DEVICE has that device's back-reference on it
@@ -365,9 +410,13 @@ export class HackCommand extends BypassCommand {
365
410
  if (lockedDoors.length === 0 && vaultedFiles.length === 0 && !feedsNow) {
366
411
  lines.push(` This node guarded nothing -- no maglocks, no vault, no eyes.`);
367
412
  }
368
- // The crack PERSISTS, which nothing told the player either: they
369
- // could not know whether they had bought a moment or a state.
370
- lines.push(` The host stays open from here -- no cracking it twice.`);
413
+ // WHAT PERSISTS AND WHAT DOES NOT, said in one breath because the
414
+ // old line here said only the first half ("the host stays open
415
+ // from here -- no cracking it twice") and a player who then
416
+ // rebooted had been told, in as many words, that they would not
417
+ // need to do this again. They did. See the alreadyCracked comment
418
+ // above: the damage is permanent, the keys are not (p.236).
419
+ lines.push(` The damage stays done -- the vault does not re-seal behind you. Your MARKS do not last: reboot or jack out and the lattice forgets your keys (p.236), and you key back in from scratch.`);
371
420
  if (mode === 'attack') {
372
421
  // Brute-force success is ANNOUNCED (canon: the target knows it's
373
422
  // under attack and screams to its owner -- it just can't see
@@ -2,6 +2,7 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
4
4
  import { catalogMetamagics } from '../utilities/catalog.js';
5
+ import { inLiveCombat } from '../utilities/action-cost.js';
5
6
  // THE PICKS ARE CATALOG ROWS (kind 'metamagic' on maka-cli.com), read
6
7
  // through catalogMetamagics(); the shape is ../metamagics.ts. The four
7
8
  // metamagics whose rule is not expressible as dice (overclocking, the
@@ -15,7 +16,11 @@ export class InitiateCommand extends Command {
15
16
  static description = 'Initiation/submersion at your hideout (10 + grade x3 karma): each grade lifts Magic/Resonance past 6 and grants a metamagic (or echo). "initiate" lists; "initiate <pick>" pays.';
16
17
  async execute(args = []) {
17
18
  const actor = this.actor;
18
- if (actor.inExchange) {
19
+ // THE WHOLE FIGHT, not one exchange (hHvR2kxrqAKuuXwED). See
20
+ // inLiveCombat: `inExchange` is a re-entrancy lock that is false for
21
+ // most of a Combat Turn, so this refusal said "firefight" and meant
22
+ // "for the half-second one trade of fire is resolving".
23
+ if (inLiveCombat(this.scene, actor)) {
19
24
  return `Transcendence waits for the shooting to stop.`;
20
25
  }
21
26
  if (actor.currentLocation?.roomType !== 'Residence') {
@@ -4,6 +4,7 @@ import { WARE_GRADES, getGrade, gradedAug, STANDARD_GRADE, formatEssence, } from
4
4
  import { catalogAugmentations, findAug } from '../utilities/catalog.js';
5
5
  import { venueSinCheck, venueBustResponse } from '../utilities/sin.js';
6
6
  import { Category } from '../types/shared/item-enum.js';
7
+ import { inLiveCombat } from '../utilities/action-cost.js';
7
8
  /**
8
9
  * The chrome shop: "install" at a street clinic lists the augmentation
9
10
  * catalog against your essence and nuyen; "install <name>" pays, goes
@@ -21,7 +22,11 @@ export class InstallCommand extends Command {
21
22
  if (actor.plane !== 'meat') {
22
23
  return `Chrome goes into meat. Come back to your body first.`;
23
24
  }
24
- if (actor.inExchange) {
25
+ // THE WHOLE FIGHT, not one exchange (hHvR2kxrqAKuuXwED). See
26
+ // inLiveCombat: `inExchange` is a re-entrancy lock that is false for
27
+ // most of a Combat Turn, so this refusal said "firefight" and meant
28
+ // "for the half-second one trade of fire is resolving".
29
+ if (inLiveCombat(this.scene, actor)) {
25
30
  return `Nobody operates in a firefight.`;
26
31
  }
27
32
  if (!InstallCommand.isClinic(actor.currentLocation)) {
@@ -2,6 +2,7 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { catalogQualities } from '../utilities/catalog.js';
4
4
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
+ import { inLiveCombat } from '../utilities/action-cost.js';
5
6
  /** The qualities on sale: every row that is not retired (a retired one
6
7
  * is dormant on its holder's sheet, never on the board). */
7
8
  const onSale = () => catalogQualities().filter(q => !q.retired);
@@ -22,7 +23,11 @@ export class QualityTradeCommand extends Command {
22
23
  static description = 'Trade qualities with karma at your hideout (SR5e x2 rates): "qualities" lists, "qualities <name>" buys a positive or buys off a negative; "qualities <name> <rating>" buys or raises a rated one.';
23
24
  async execute(args = []) {
24
25
  const actor = this.actor;
25
- if (actor.inExchange) {
26
+ // THE WHOLE FIGHT, not one exchange (hHvR2kxrqAKuuXwED). See
27
+ // inLiveCombat: `inExchange` is a re-entrancy lock that is false for
28
+ // most of a Combat Turn, so this refusal said "firefight" and meant
29
+ // "for the half-second one trade of fire is resolving".
30
+ if (inLiveCombat(this.scene, actor)) {
26
31
  return `Reinventing yourself mid-firefight is called dying.`;
27
32
  }
28
33
  if (actor.currentLocation?.roomType !== 'Residence') {
@@ -1,6 +1,7 @@
1
1
  import { Command } from './command.js';
2
2
  import { rollPool, formatRoll } from '../utilities/dice.js';
3
3
  import { hint } from '../utilities/hints.js';
4
+ import { inLiveCombat } from '../utilities/action-cost.js';
4
5
  /**
5
6
  * Registering (SR5e p.254-256, engine-adapted): make a compiled sprite
6
7
  * PERMANENT. An opposed Resonance + REGISTERING test against TWICE the
@@ -45,7 +46,11 @@ export class RegisterCommand extends Command {
45
46
  }
46
47
  return `No sprite rides with you -- "compile" one first.`;
47
48
  }
48
- if (actor.inExchange) {
49
+ // THE WHOLE FIGHT, not one exchange (hHvR2kxrqAKuuXwED). See
50
+ // inLiveCombat: `inExchange` is a re-entrancy lock that is false for
51
+ // most of a Combat Turn, so this refusal said "firefight" and meant
52
+ // "for the half-second one trade of fire is resolving".
53
+ if (inLiveCombat(this.scene, actor)) {
49
54
  return `Registering takes more focus than the fight will give you.`;
50
55
  }
51
56
  // THE LOGIC CEILING (p.254-256): "You can register a number of
@@ -2,6 +2,7 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { priceOf, fenceValueOf, isSellable, isFence } from '../utilities/commerce.js';
4
4
  import { sameSpot, spotOf, spotRefusal } from '../utilities/spots.js';
5
+ import { inLiveCombat } from '../utilities/action-cost.js';
5
6
  /**
6
7
  * Fencing loot: "sell <item>" in front of a fence (Whisper's pawnshop, or
7
8
  * any generated NPC/room in the trade -- see commerce.isFence) quotes 40%
@@ -18,7 +19,11 @@ export class SellCommand extends Command {
18
19
  if (actor.plane !== 'meat') {
19
20
  return `The fence deals in things a hand can pass over a counter. Come back to your body.`;
20
21
  }
21
- if (actor.inExchange) {
22
+ // THE WHOLE FIGHT, not one exchange (hHvR2kxrqAKuuXwED). See
23
+ // inLiveCombat: `inExchange` is a re-entrancy lock that is false for
24
+ // most of a Combat Turn, so this refusal said "firefight" and meant
25
+ // "for the half-second one trade of fire is resolving".
26
+ if (inLiveCombat(this.scene, actor)) {
22
27
  return `Mid-firefight is a seller's market for exactly nothing.`;
23
28
  }
24
29
  const fence = this.scene.getActorsInRoom(actor.currentLocation)
@@ -105,7 +105,7 @@ export class SheetCommand extends Command {
105
105
  //
106
106
  // The rule is canon and stays. What changes is that the number now
107
107
  // states its own ceiling and explains its own shortfall.
108
- const edgeMax = a.edge + a.lifestyleEdgeBonus;
108
+ const edgeMax = a.edgeMax;
109
109
  const edgeSource = a.lifestyleEdgeBonus > 0
110
110
  ? ` -- ${a.edge} base +${a.lifestyleEdgeBonus} from where you sleep`
111
111
  : '';
@@ -471,5 +471,37 @@
471
471
  // taxi, no pay. Left one full homecoming, the desk reviews the feeds:
472
472
  // +1 Public Awareness. New SaveReason 'cleanup'; new win condition
473
473
  // type 'erase'. The site reads Game.footageLeft() off the live handle.
474
- export const ENGINE_VERSION = '1.55.0';
474
+ // 1.55.1 (2026-09-15): THE EDGE CEILING IS ONE NUMBER. Player.edgeMax
475
+ // (attribute + lifestyle bonus) replaces the sum four readers were
476
+ // writing out by hand, three of them dropping the bonus -- the `edge`
477
+ // command's denominator and the condition report's published `max`,
478
+ // which the play page's HUD rendered as "edge 4/3" on a FULL pool.
479
+ // Underneath them, buildPlayerFromSave stood up a bare Player with no
480
+ // Game to set lifestyleEdgeBonus, so its refreshEdge filled the pool to
481
+ // the plain attribute: every headless boot -- shared runs, the browser
482
+ // hub, characterSheet.hydrate -- silently ATE the lifestyle point of a
483
+ // runner who left fed and slept. The tier is on the save; it is read
484
+ // there now (commerce.lifestyleTierFor, Squatter when unnamed).
485
+ // 1.56.0 (2026-09-15): TWO FLAGS THAT WERE STANDING IN FOR STATE THEY
486
+ // DO NOT HOLD.
487
+ // - A CRACKED HOST IS NOT A HOST YOU HOLD MARKS ON
488
+ // (spcebZTKN24GYRHXW). `hack`/`mark` refused outright on the
489
+ // room-global hostCracked, so a persona that rebooted mid-run -- which
490
+ // canon wipes marks on (p.236, p.242) -- could never re-place one,
491
+ // while every device on the node went on refusing them for want of
492
+ // marks. Command semantics in shared scenes: the refusal is gone, the
493
+ // standing guard is 3 marks held, a repeat break-in is worded as one,
494
+ // and the host line prints the site's state and the persona's marks
495
+ // apart. The crack's world consequences stay permanent.
496
+ // - NO CAB MID-FIGHT (hHvR2kxrqAKuuXwED). The downtime verbs (buy,
497
+ // sell, install, register, initiate, quality-trade, advance, call
498
+ // taxi) gated on Player.inExchange, a re-entrancy lock that is false
499
+ // for most of a Combat Turn, so each refused for the half-second an
500
+ // exchange resolved and permitted itself the rest of the fight. They
501
+ // ask inLiveCombat (action-cost.ts) now. NEW EMBEDDER SURFACE:
502
+ // Scene.anyLiveEncounter(), which the site reads off the live handle
503
+ // to refuse sideQuest.settle -- on a shared run the cab is intercepted
504
+ // before the engine ever sees it, so the server is the only place
505
+ // that path can be stopped.
506
+ export const ENGINE_VERSION = '1.56.0';
475
507
  //# sourceMappingURL=engine-version.js.map
@@ -27,7 +27,7 @@ import { heartbeatPresence, heartbeatPresenceNow } from './utilities/social.js';
27
27
  import { presenceBeatAllowed } from './utilities/presence-gate.js';
28
28
  import { pushSave, burnCloudSaveFor, enqueueBoundPush, flushPushQueue, writeSpill, takeLastMemorial, authToken, clearSpill, hasPendingPush, } from './utilities/cloud-saves.js';
29
29
  import { DOCWAGON_TIERS, RESUSC_FEE, DEATH_COMP } from './utilities/docwagon.js';
30
- import { isFence, LIFESTYLE_TIERS, LIFESTYLE_OPTIONS, canonicalLifestyleTier, canonicalLifestyleOption, lifestyleOptionAllowed } from './utilities/commerce.js';
30
+ import { isFence, LIFESTYLE_TIERS, LIFESTYLE_OPTIONS, canonicalLifestyleTier, canonicalLifestyleOption, lifestyleOptionAllowed, lifestyleTierFor } from './utilities/commerce.js';
31
31
  import { reconcileGarage } from './models/home-containers.js';
32
32
  import { ownedDrones } from './utilities/owned-drones.js';
33
33
  import { attachBrandMark, attachBrandMarkBottomRight } from './utilities/brand-mark.js';
@@ -2024,7 +2024,7 @@ export default class Game {
2024
2024
  rentDebt = 0;
2025
2025
  /** The active lifestyle tier's row (falls back to Squatter). */
2026
2026
  get lifestyle() {
2027
- return LIFESTYLE_TIERS.find(t => t.name === this.lifestyleTier) ?? LIFESTYLE_TIERS[1];
2027
+ return lifestyleTierFor(this.lifestyleTier);
2028
2028
  }
2029
2029
  hasLifestyleOption(key) {
2030
2030
  return this.lifestyleOptions.includes(key);
@@ -4776,7 +4776,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4776
4776
  if (fed && slept) {
4777
4777
  // Good-news status with a rules lesson inside -- hint-gated
4778
4778
  // (player request); the HUD's edge count carries the number anyway.
4779
- const note = hint(`Fed and slept -- you head out SHARP. (Edge ${p.edgeRemaining}/${p.edge + p.lifestyleEdgeBonus}.)`);
4779
+ const note = hint(`Fed and slept -- you head out SHARP. (Edge ${p.edgeRemaining}/${p.edgeMax}.)`);
4780
4780
  if (note)
4781
4781
  lines.push(note);
4782
4782
  }
@@ -22,6 +22,7 @@ import { hostileByStance, enemyInFight, renderRoomLayoutCompact, crewOfTable, ro
22
22
  import { serializeRoomGrid } from './utilities/room-grid.js';
23
23
  import { entrySpotFor, spotOf } from './utilities/spots.js';
24
24
  import { equipIntoEmptySlots } from './archetypes.js';
25
+ import { lifestyleTierFor } from './utilities/commerce.js';
25
26
  /**
26
27
  * The session's Logger: same shape the whole engine already talks to,
27
28
  * but every player-visible line becomes a GameEvent through `sink`.
@@ -186,6 +187,16 @@ export function buildPlayerFromSave(save) {
186
187
  // has no arrival beat to narrate into; porting it would apply locked
187
188
  // fatigue to a rider on someone else's schedule. Per-job flags are the
188
189
  // half that is unambiguously wrong to skip.
190
+ // THE LIFESTYLE POINT SURVIVES THE BOOT (mjmcee, 2026-09-15, seen as
191
+ // "edge 4/3" on the web sheet). A Middle+ hideout banks a bonus edge
192
+ // each job, and `lifestyleEdgeBonus` is set by Game from the active
193
+ // tier -- but this path builds a BARE Player, with no Game, so it
194
+ // stood at 0 and the refreshEdge below filled the pool to the plain
195
+ // attribute. Every headless boot -- shared runs, the browser hub, the
196
+ // site's characterSheet.hydrate -- therefore QUIETLY ATE the lifestyle
197
+ // point of a runner who left fed and slept, and published a ceiling one
198
+ // short for everyone else. The tier is on the save; read it.
199
+ player.lifestyleEdgeBonus = lifestyleTierFor(save.game?.lifestyleTier).edgeBonus;
189
200
  player.refreshEdge(player.ateThisCycle && player.sleptThisCycle);
190
201
  return player;
191
202
  }
@@ -3591,6 +3591,17 @@ export class Player extends AbstractPlayer {
3591
3591
  // A comfortable home sharpens the luck: Middle+ lifestyles bank a bonus
3592
3592
  // edge each job (set by Game from the active tier; 0 for NPCs).
3593
3593
  lifestyleEdgeBonus = 0;
3594
+ /**
3595
+ * THE EDGE CEILING -- the pool's real maximum, attribute plus whatever
3596
+ * the lifestyle banks. ONE getter because the sum was being written out
3597
+ * by hand at every call site and three of them dropped the bonus: the
3598
+ * sheet said 4, the `edge` command said /3, and the condition report
3599
+ * published max 3 to the web HUD, which rendered a full pool as "4/3"
3600
+ * (mjmcee, 2026-09-15). refreshEdge fills to THIS, so anything printing
3601
+ * a ceiling must read THIS -- `edge` alone is the attribute, which is
3602
+ * what the burn adds as dice and what karma raises, not the cap.
3603
+ */
3604
+ get edgeMax() { return this.edge + this.lifestyleEdgeBonus; }
3594
3605
  // ==================== Needs (SR5e p.56 / p.172) ====================
3595
3606
  // Edge comes back from "a fulfilling meal and a good night's sleep" --
3596
3607
  // so eating (use.ts) and sleeping (rest.ts, at a Residence) are tracked
@@ -3644,7 +3655,7 @@ export class Player extends AbstractPlayer {
3644
3655
  */
3645
3656
  refreshEdge(wellRested = true) {
3646
3657
  if (wellRested) {
3647
- this.edgeRemaining = this.edge + this.lifestyleEdgeBonus;
3658
+ this.edgeRemaining = this.edgeMax;
3648
3659
  }
3649
3660
  this.edgeBoostArmed = false;
3650
3661
  this.haggledThisJob = false;
@@ -185,6 +185,30 @@ export class Scene extends AbstractScene {
185
185
  const enc = this.encounterAt(arenaOf(actor));
186
186
  return enc?.has(actor) ? enc : undefined;
187
187
  }
188
+ /**
189
+ * IS ANYBODY AT THIS TABLE IN A FIGHT, asked across every arena.
190
+ *
191
+ * For the EMBEDDER, not for a verb (hHvR2kxrqAKuuXwED: "I was able to
192
+ * call a taxi in the middle of a fight"). On a shared run the ride
193
+ * home is not an engine verb at all -- both the CLI
194
+ * (utilities/shared-run.ts) and the browser (the site's run-exit.ts)
195
+ * intercept "call taxi" before it reaches the table and call
196
+ * sideQuest.settle, which ends the run for EVERYONE. So the engine's
197
+ * own gate in commands/call.ts, correct as it is, never ran on the
198
+ * path the report came in on, and the site had no way to ask this
199
+ * question without reaching into `encounters`.
200
+ *
201
+ * Every arena, not just the leader's: settling the table yanks the
202
+ * whole party home, so a decker in a host trading with ice is reason
203
+ * enough to refuse even if the leader is standing in an empty street.
204
+ */
205
+ anyLiveEncounter() {
206
+ for (const enc of this.encounters.values()) {
207
+ if (!enc.ended)
208
+ return true;
209
+ }
210
+ return false;
211
+ }
188
212
  /**
189
213
  * Who is in the fight when `aggressor` opens on `target`: the two of
190
214
  * them, every human and bound shell in the room (the party fights as
@@ -3,6 +3,42 @@ import { hint } from './hints.js';
3
3
  export function encounterOf(scene, actor) {
4
4
  return scene.encounterFor?.(actor);
5
5
  }
6
+ /**
7
+ * IS THERE A FIGHT ON, for a verb that simply cannot happen during one.
8
+ *
9
+ * `Player.inExchange` is NOT this question, and a family of verbs was
10
+ * asking it as though it were (hHvR2kxrqAKuuXwED: "I was able to call a
11
+ * taxi in the middle of a fight"). That flag is a re-entrancy lock --
12
+ * true only while one exchange is mid-RESOLUTION, set by
13
+ * CombatExchange.beginExchange and cleared by finishExchange -- so it is
14
+ * false for almost all of a Combat Turn, including the whole of your own
15
+ * Action Phase. Every verb that gated on it alone was therefore refusing
16
+ * during the narration beats of a single trade of fire and permitting
17
+ * itself freely the rest of the fight, while its refusal line said
18
+ * "firefight".
19
+ *
20
+ * The Combat Turn (utilities/combat-turn.ts) is what actually knows a
21
+ * fight is on: an encounter exists for the actor's arena and has not
22
+ * ended. That is the primary test here. The two exchange checks stay
23
+ * underneath it because an exchange can resolve outside an encounter
24
+ * (an NPC AI loop, a scene with no Combat Turn running), and because
25
+ * NPCs have no encounter phase of their own to consult.
26
+ *
27
+ * This is the "no" answer, not the "not yet" answer. Use requirePhase
28
+ * for something that IS legal in a fight but only on your own phase
29
+ * (walking out of the room); use this for something that is not a combat
30
+ * action at all and has no phase that could pay for it -- hailing a cab,
31
+ * ringing up a sale, a training montage.
32
+ */
33
+ export function inLiveCombat(scene, actor) {
34
+ const enc = encounterOf(scene, actor);
35
+ if (enc && !enc.ended)
36
+ return true;
37
+ if (actor.inExchange)
38
+ return true;
39
+ return scene.isHumanControlled?.(actor) === true
40
+ && scene.isPlayerExchangeActive?.(actor.name) === true;
41
+ }
6
42
  /**
7
43
  * The refusal when it is not this actor's Action Phase -- the wording
8
44
  * every verb shares, so a player learns one sentence.
@@ -199,6 +199,16 @@ export function canonicalLifestyleTier(raw) {
199
199
  return 'Middle';
200
200
  return LIFESTYLE_TIERS.find(t => t.name.toLowerCase() === wanted)?.name;
201
201
  }
202
+ /**
203
+ * The tier a stored name names, Squatter when it names nothing -- the
204
+ * same default Game.lifestyle has always used, lifted out of that getter
205
+ * so a BARE Player (buildPlayerFromSave, with no Game around it) can
206
+ * resolve its own lifestyle instead of silently reading a zero bonus.
207
+ */
208
+ export function lifestyleTierFor(raw) {
209
+ const name = canonicalLifestyleTier(raw) ?? raw;
210
+ return LIFESTYLE_TIERS.find(t => t.name === name) ?? LIFESTYLE_TIERS[1];
211
+ }
202
212
  /** One-line flavor note per consumable category, shown on shop shelves. */
203
213
  export function shelfNote(item) {
204
214
  switch (item.category) {
@@ -220,7 +220,7 @@ export function buildConditionReport(player, deps) {
220
220
  // fields, verbatim; edge is BOTH halves or neither on the web side.
221
221
  karma: player.karma,
222
222
  nuyen: player.currency,
223
- edge: { remaining: player.edgeRemaining, max: player.edge },
223
+ edge: { remaining: player.edgeRemaining, max: player.edgeMax },
224
224
  // THE ROOM'S OTHER DOTS (user: the map becomes a tactical
225
225
  // readout). Current room only, read at fire time; spot-seated
226
226
  // actors carry `spot` and the web places them on the grid it
@@ -1,6 +1,7 @@
1
1
  import { hostConcealedFrom } from './jackpoint.js';
2
- import { matchesHostName, hostLabel } from './grid-names.js';
3
- import { fuzzyPickName } from './fuzzy-match.js';
2
+ import { matchesHostName, hostLabel, hostAliases } from './grid-names.js';
3
+ import { fuzzyPickName, normalizeLoose } from './fuzzy-match.js';
4
+ import { MAX_MARKS } from './marks.js';
4
5
  /**
5
6
  * THE GRID HAS NO STREETS (user canon check 2026-09-02; SR5 pp.217-218,
6
7
  * p.246). Jacked in and outside a host, a persona is on its grid: "a
@@ -88,6 +89,64 @@ export function resolveHostInReach(rooms, actor, query) {
88
89
  const picked = fuzzyPickName(bare, hosts.map(r => r.name));
89
90
  return picked ? hosts.find(r => r.name === picked) : undefined;
90
91
  }
92
+ /**
93
+ * DOES THIS QUERY NAME A HOST OUTRIGHT -- no fuzz, no guessing.
94
+ *
95
+ * resolveHostInReach ends in a fuzzy fallback, which is right for it:
96
+ * once a player has committed to meaning a host, a near miss should
97
+ * still find one. This is the other question, and it exists because
98
+ * `hack`/`mark` resolve PANs FIRST (WTLfRHxJEnPmN2ht9: "this keeps
99
+ * matching on Torque's PAN even though I gave it exactly the host
100
+ * name"). The PAN pass runs fuzzyPickName over the names of every body
101
+ * in reach, and that matcher's tier 2 is substring containment in
102
+ * either direction -- so a runner standing near anyone whose name is
103
+ * contained in the host's could never reach the host by typing its
104
+ * name, because the PAN pass answered before the host pass was asked.
105
+ *
106
+ * So the caller asks this first: an EXACT alias hit (grid-names.ts's
107
+ * hostAliases, loosely normalized -- case, punctuation and a trailing
108
+ * "host"/"node" all forgiven) means the player named a host and the PAN
109
+ * pass must not get to reinterpret it. Anything less falls through and
110
+ * the old order stands, so a genuinely ambiguous word still reaches the
111
+ * PAN it always did.
112
+ *
113
+ * The bare aliases ("host", "the host", "node", "ice") resolve to the
114
+ * host over the vicinity, exactly as resolveHostInReach resolves them
115
+ * -- one query, one answer, whichever door asks.
116
+ */
117
+ export function namesHostExactly(rooms, actor, query) {
118
+ const q = query.trim().toLowerCase();
119
+ if (q === '')
120
+ return undefined;
121
+ if (q === 'host' || q === 'the host')
122
+ return hostOver(gridVicinity(actor));
123
+ const near = hostOver(gridVicinity(actor));
124
+ if (near && hostNamed(near, q))
125
+ return near;
126
+ return hostsInReach(rooms, actor).find(r => hostNamed(r, q));
127
+ }
128
+ /**
129
+ * THE APOSTROPHE, which a player does not type and the matcher insisted
130
+ * on. normalizeLoose SPLITS punctuation rather than dropping it -- right,
131
+ * and load-bearing, because "Mr.Krow" has to become "mr krow" and not
132
+ * "mrkrow". The side effect is that "Torque's Garage" normalizes to
133
+ * "torque s garage", so a player typing the name they READ, minus the
134
+ * apostrophe they did not bother with, missed every alias.
135
+ *
136
+ * So the comparison is tried twice: matchesHostName first (the shared
137
+ * rule, unchanged and still the authority), then again with whitespace
138
+ * squashed out of both sides, which forgives exactly the punctuation the
139
+ * player dropped and nothing else. "torques garage" reaches the host;
140
+ * "torque" -- a whole word shorter -- still does not, which is the line
141
+ * that keeps this from stealing the PAN pass.
142
+ */
143
+ function hostNamed(room, q) {
144
+ if (matchesHostName(q, room))
145
+ return true;
146
+ const squash = (s) => normalizeLoose(s).replace(/\s+/g, '');
147
+ const squashed = squash(q);
148
+ return squashed.length > 0 && hostAliases(room).some(a => squash(a) === squashed);
149
+ }
91
150
  /** Noise between the persona and a room's icons: 0 for the vicinity (and inside a host), -DISTRICT_NOISE dice elsewhere. */
92
151
  export function noiseFor(actor, room) {
93
152
  if (actor.insideHost)
@@ -113,9 +172,20 @@ export function noiseNote(noise) {
113
172
  export function hostLine(room, actor) {
114
173
  const inside = actor.insideHost === room;
115
174
  const marks = room.hostMarksBy.get(actor.name) ?? 0;
175
+ // STANDING IS TWO FACTS, NOT ONE (spcebZTKN24GYRHXW: "it's saying
176
+ // I've already cracked open (hold 3 marks) but I don't").
177
+ //
178
+ // "cracked open" is the SITE's state -- somebody took this node to
179
+ // three marks and its vault has been hanging open ever since. The
180
+ // marks count is YOUR state, and canon wipes it on every reboot
181
+ // (p.236). The two used to be one string with the crack winning, so a
182
+ // rebooted persona holding nothing read a line that says the place is
183
+ // yours -- while every device on it refused them for want of marks.
184
+ // Print both whenever they disagree.
185
+ const held = `${marks}/${MAX_MARKS} marks`;
116
186
  const standing = room.hostSanctioned ? 'haven'
117
- : room.hostCracked ? 'cracked open'
118
- : marks > 0 ? `${marks}/3 marks`
187
+ : room.hostCracked ? (marks >= MAX_MARKS ? 'cracked open' : `cracked open, but you hold ${held}`)
188
+ : marks > 0 ? held
119
189
  : 'sealed';
120
190
  const alert = room.hostAlert && !room.hostSanctioned ? ', walls UP' : '';
121
191
  const over = hostOver(gridVicinity(actor)) === room ? ', over you' : '';
@@ -133,7 +203,14 @@ export function hostLine(room, actor) {
133
203
  export function hostDockLine(room, actor, width) {
134
204
  const inside = actor.insideHost === room;
135
205
  const marks = room.hostMarksBy.get(actor.name) ?? 0;
136
- const tag = room.hostCracked ? ' open' : marks > 0 ? ` ${marks}/3` : '';
206
+ // Same split as hostLine, cut to the dock's width: "open" alone is the
207
+ // site's state and was reading as the persona's. A crack you no longer
208
+ // hold marks on shows the marks, because that is the number every
209
+ // device on this node is about to check (spcebZTKN24GYRHXW).
210
+ const tag = room.hostCracked && marks >= MAX_MARKS ? ' open'
211
+ : room.hostCracked ? ` open ${marks}/${MAX_MARKS}`
212
+ : marks > 0 ? ` ${marks}/${MAX_MARKS}`
213
+ : '';
137
214
  const raw = `${inside ? '▣' : '●'} ${room.name} HR${room.hostRating}${tag}`;
138
215
  const cut = [...raw].slice(0, Math.max(4, width)).join('');
139
216
  return `{lightblue-fg}${cut}{/lightblue-fg}`;
@@ -1253,6 +1253,14 @@ export class SharedRunSession {
1253
1253
  catch { /* the name is a courtesy; the warning stands without it */ }
1254
1254
  return `The dispatcher picks up -- and you hesitate. You never settled with ${who}: ${owed}¥ still on the table. ("call taxi" again to leave it and go.)`;
1255
1255
  }
1256
+ // MID-FIGHT (hHvR2kxrqAKuuXwED). This shortcut skips the engine's
1257
+ // CallCommand exactly as the unclaimed-pay case above does, so
1258
+ // the server carries the refusal and this renders it in the
1259
+ // engine's own words. It arms NOTHING: unlike `unsettled`, no
1260
+ // second call gets you a cab.
1261
+ if (e.error === 'in-combat') {
1262
+ return `Mid-fight is no time to hail a cab. Finish this, then call.`;
1263
+ }
1256
1264
  if (e.error === 'bad-status' || e.error === 'interrupted') {
1257
1265
  await this.homecoming();
1258
1266
  return '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.195.0",
3
+ "version": "5.197.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.",