@maka/maka-cli 5.160.0 → 5.162.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.160.0",
3
+ "version": "5.162.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.",
@@ -125,7 +125,7 @@ export class EquipCommand extends Command {
125
125
  // hear which wetware that is (user, 2026-09-02).
126
126
  const dni = actor.dniSource();
127
127
  const link = dni?.kind === 'datajack' ? `It cables straight into your datajack -- VR ready.`
128
- : dni?.kind === 'trodes' ? `It rides your ${dni.item?.name ?? 'trode net'}${dni.worn ? '' : ' (in the bag -- "equip" it to wear it)'} -- VR ready.`
128
+ : dni?.kind === 'trodes' ? `It rides your ${dni.item?.name ?? 'trode net'}${dni.host ? ` built into your ${dni.host.name}${dni.worn ? '' : ' (in the bag -- "equip" the helmet to wear it)'}` : dni.worn ? '' : ' (in the bag -- "equip" it to wear it)'} -- VR ready.`
129
129
  : dni?.kind === 'technomancer' ? `Your living persona is the interface.`
130
130
  : `No direct neural interface yet: AR only until you wear a trode net or install a datajack.`;
131
131
  return `The ${name} doesn't wear -- it's a slab of hardware in your kit. ${link} "jack in" when you're ready to ride it${hint(`, "pan" to see your network`)}.`;
@@ -75,7 +75,7 @@ export class EquipmentCommand extends Command {
75
75
  ? `none -- AR only${hint(' (a trode net from any electronics shelf, or a datajack at the clinic)')}`
76
76
  : dni.kind === 'technomancer' ? 'living persona -- VR ready'
77
77
  : dni.kind === 'datajack' ? 'datajack (implanted) -- VR ready'
78
- : `${dni.item?.name ?? 'trode net'} (${dni.worn ? 'worn' : 'in the bag'}) -- VR ready`;
78
+ : `${dni.item?.name ?? 'trode net'} (${dni.host ? `built into your ${dni.host.name}${dni.worn ? '' : ', in the bag'}` : dni.worn ? 'worn' : 'in the bag'}) -- VR ready`;
79
79
  lines.push(` Interface: ${dniLine}`);
80
80
  if (link) {
81
81
  lines.push(` Commlink: ${link.name}${actor.runningSilent ? ' -- running silent' : ''}`);
@@ -5,6 +5,8 @@ import { matchesHostName, matchesCameraName, hostLabel } from '../utilities/grid
5
5
  import { Door } from '../models/door.js';
6
6
  import { Item } from '../models/item.js';
7
7
  import { NPC } from '../models/npc.js';
8
+ import { Player } from '../models/player.js';
9
+ import { readDetails } from '../utilities/clue-reading.js';
8
10
  import { Category } from '../types/shared/item-enum.js';
9
11
  import { Command } from './command.js';
10
12
  import { shellsInRoom, planeTintItemName, emphasizeFind, fileReach } from '../utilities/planes.js';
@@ -579,7 +581,16 @@ export class LookCommand extends Command {
579
581
  if (!item) {
580
582
  return `You don't see any ${itemName} here.`;
581
583
  }
582
- const prose = item.details || item.description || `It's just a regular ${itemName}.`;
584
+ // A CODE ON THE PAGE IS A PERCEPTION TEST (Xxbwtgffkyq8uCHfR): a
585
+ // scrap of paper with a combination on it is read here, not by
586
+ // "read" (which is the Book verb), so it takes the same test -- one
587
+ // gate for one credential, whichever verb reaches it.
588
+ const details = readDetails(this.scene, item, {
589
+ reader: this.actor instanceof Player ? this.actor : undefined,
590
+ logger: this.logger,
591
+ isPlayer: this.scene.isHumanControlled(this.actor),
592
+ });
593
+ const prose = details || item.description || `It's just a regular ${itemName}.`;
583
594
  if (item.category == Category.Book) {
584
595
  // A book's payload IS its detail -- "read" is the verb that opens
585
596
  // it, and saying so beats a stat block on a paperback.
@@ -1,5 +1,7 @@
1
1
  import { Command } from './command.js';
2
2
  import { Category } from '../types/shared/item-enum.js';
3
+ import { Player } from '../models/player.js';
4
+ import { readDetails } from '../utilities/clue-reading.js';
3
5
  export class ReadCommand extends Command {
4
6
  static verb = 'read';
5
7
  static description = 'Read a book from your surroundings.';
@@ -21,7 +23,16 @@ export class ReadCommand extends Command {
21
23
  // "details" is where clue/note content is meant to live, but "description"
22
24
  // is a reasonable fallback if a scene ever puts it there instead -- better
23
25
  // to show something real than a dead "blank pages" non-answer.
24
- return item.details || item.description || `You flip through "${item.name}" but it's mostly blank pages.`;
26
+ //
27
+ // A CODE ON THE PAGE IS A PERCEPTION TEST (Xxbwtgffkyq8uCHfR):
28
+ // utilities/clue-reading.ts rolls it, and hands back the prose with
29
+ // the credential in or out of it.
30
+ const details = readDetails(this.scene, item, {
31
+ reader: this.actor instanceof Player ? this.actor : undefined,
32
+ logger: this.logger,
33
+ isPlayer: this.scene.isHumanControlled(this.actor),
34
+ });
35
+ return details || item.description || `You flip through "${item.name}" but it's mostly blank pages.`;
25
36
  }
26
37
  }
27
38
  //# sourceMappingURL=read.js.map
@@ -392,5 +392,9 @@
392
392
  // aggressor and their target; the world event carries it; "initiative"
393
393
  // marks them. Hostiles are red on the room map by stance or by live
394
394
  // enmity, not by the retired exchange flag.
395
- export const ENGINE_VERSION = '1.44.0';
395
+ // 1.45.0 (2026-09-11): THE MAPS AS DATA. The condition beat carries the
396
+ // room's live grid, every body's engine-resolved square, and the room
397
+ // panel's own hostile test; the map event carries the fog-applied
398
+ // district layout (data.district). Envelope and beat shape both grew.
399
+ export const ENGINE_VERSION = '1.45.0';
396
400
  //# sourceMappingURL=engine-version.js.map
@@ -419,7 +419,9 @@ ${JSON_ONLY}
419
419
  // exit's "guard"; people are got past socially, not picked.
420
420
  "grantsItem"?: string // an items[].name with role "reward" -- what opening it hands
421
421
  // over. Omit for a device that holds a door: the open way IS
422
- // the reward.
422
+ // the reward. REQUIRED for a device on no exit (a safe, a
423
+ // lockbox, a cabinet): a device is a promise of something
424
+ // behind it, and a safe that opens onto nothing is refused.
423
425
  }],
424
426
  "items": [{
425
427
  "name": string,
@@ -929,6 +931,20 @@ export function validateSkeleton(skeleton, player, crew) {
929
931
  // record -- and which looked like random flakiness precisely
930
932
  // because it varied with the character.
931
933
  const kind = device.kind ?? 'panel';
934
+ // A DEVICE IS A PROMISE OF SOMETHING BEHIND IT (bPiyyWu9TQ4pBfGbM,
935
+ // Jynx: "it looks like the safe is open, but it never dropped
936
+ // anything"). The Blood Ledger's skeleton declared a floor safe as
937
+ // a bare "lock" in the back office -- on no exit, granting no item
938
+ // -- while the prize sat in another room and the details pass,
939
+ // taking the safe at its word, wrote a combination clue for it.
940
+ // Every rule below checks that a device's way THROUGH is findable;
941
+ // none checked that there was anything on the other side. A safe
942
+ // that opens onto nothing is not a puzzle, it is a lie the room
943
+ // description tells, and the only honest fix is to refuse the
944
+ // skeleton before four detail passes build on it.
945
+ if (refs === 0 && !device.grantsItem) {
946
+ fail(`device "${device.name}" holds no door and grants nothing -- a device is a promise of something behind it. Declare an exit from "${device.room}" with "device": "${device.name}", or give it "grantsItem" naming a "reward"-role item.`);
947
+ }
932
948
  if (kind === 'panel') {
933
949
  const clue = skeleton.items.find(i => i.role === 'clue' && i.forDevice === device.name);
934
950
  if (!clue) {
@@ -955,8 +971,12 @@ export function validateSkeleton(skeleton, player, crew) {
955
971
  const holdsDoor = refs > 0;
956
972
  const keyed = skeleton.rooms.some(r => (r.exits ?? []).some(e => e.device === device.name && e.keyRequired));
957
973
  const looseKey = skeleton.items.some(i => i.role === 'key');
958
- if (holdsDoor && !keyed && !looseKey) {
959
- fail(`lock "${device.name}" holds a door with no "keyRequired" and the scene has no "key"-role item -- a runner without locksmith has no way through it. Add a key.`);
974
+ // The same floor for a lock that holds a PRIZE rather than a
975
+ // door (a safe): its findable way in is a key, and "holdsDoor"
976
+ // used to exempt it -- so a keyless safe passed here and then
977
+ // stood in play with only breach options (bPiyyWu9TQ4pBfGbM).
978
+ if (!keyed && !looseKey) {
979
+ fail(`lock "${device.name}" ${holdsDoor ? 'holds a door with no "keyRequired"' : 'holds a prize'} and the scene has no "key"-role item -- a runner without locksmith has no way through it. Add a key.`);
960
980
  }
961
981
  }
962
982
  // A WARD is the exception no clue can rescue: there is no findable
@@ -2401,7 +2401,11 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2401
2401
  if (!this._conditionSink && process.env.MAKA_NO_DDP === '1')
2402
2402
  return;
2403
2403
  void import('./utilities/condition-report.js')
2404
- .then(m => m.reportConditionSoon(this.player, this._conditionSink))
2404
+ .then(m => m.reportConditionSoon(this.player, this._conditionSink, {
2405
+ // The room panel's own red (updateExits below): stance OR live
2406
+ // enmity in the viewer's encounter. Same predicate, same map.
2407
+ isHostile: a => hostileByStance(a) || enemyInFight(this.scene, this.player, a),
2408
+ }))
2405
2409
  .catch(() => undefined);
2406
2410
  };
2407
2411
  // A ROOM CHANGE IS A SAVE BEAT (player request 2026-09-02): where you
@@ -16,7 +16,8 @@ import { Player, Room } from './models/_index.js';
16
16
  import { Logger } from './utilities/logger.js';
17
17
  import { currentSession, runInSession } from './utilities/session-context.js';
18
18
  import { restorePlayer, serializePlayer } from './utilities/persistence.js';
19
- import { renderMapViewport, mapCaption } from './utilities/map-view.js';
19
+ import { runWithAuthToken } from './utilities/cloud-saves.js';
20
+ import { renderMapViewport, mapCaption, serializeDistrictMap } from './utilities/map-view.js';
20
21
  import { hostileByStance, enemyInFight, renderRoomLayoutCompact, crewOfTable, roomCaption } from './utilities/room-view.js';
21
22
  import { serializeRoomGrid } from './utilities/room-grid.js';
22
23
  import { entrySpotFor, spotOf } from './utilities/spots.js';
@@ -296,7 +297,18 @@ export async function createHeadlessSession(opts) {
296
297
  const mapLines = renderMapViewport(rooms, here, sealing, eyes);
297
298
  if (mapLines.length > 0)
298
299
  mapLines.push(mapCaption(here.name));
299
- logger.system(mapLines.join('\n'), { actor: p.name }, 'map');
300
+ // STRUCTURED DISTRICT MAP, ADDITIVE (2026-09-11): the same layout
301
+ // as data, on this same event, so the browser client draws it in
302
+ // its own style instead of reparsing the text (the `data.grid`
303
+ // pattern below). A decoration must never kill the push.
304
+ let district;
305
+ try {
306
+ district = serializeDistrictMap(rooms, here, sealing, eyes);
307
+ }
308
+ catch (err) {
309
+ logger.write(`District map serialize failed for ${p.name} in ${here.name}: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
310
+ }
311
+ logger.system(mapLines.join('\n'), { actor: p.name }, 'map', district ? { district } : undefined);
300
312
  // THE ROOM BOX'S TWIN (playtest 2026-08-27, reported twice:
301
313
  // "the room minimap isn't updating in the shared run, I only
302
314
  // see @open floor"). Game.updateExits early-returns on
@@ -352,12 +364,12 @@ export async function createHeadlessSession(opts) {
352
364
  const session = {
353
365
  game,
354
366
  logger,
355
- command: (actorName, input) => runInSession(ctx, async () => {
367
+ command: (actorName, input, opts) => runInSession(ctx, () => runWithAuthToken(opts?.authToken, async () => {
356
368
  const res = await game.handleInputAs(actorName, input);
357
369
  session.pushStatus();
358
370
  pushMap();
359
371
  return res;
360
- }),
372
+ })),
361
373
  addPlayer: (save) => runInSession(ctx, async () => {
362
374
  // REJOIN IS NOT A JOIN (playtest 2026-08-27, Sherry: "got an
363
375
  // internal server error"). A dropped DDP connection -- a
@@ -867,6 +867,9 @@ export class Item extends AbstractItem {
867
867
  papers = [];
868
868
  /** On a license: the name of the fake SIN it was bought for. */
869
869
  linkedSin;
870
+ /** Who has already found the credential written in this item's
871
+ * details (utilities/clue-reading.ts): a found code stays found. */
872
+ revealedTo = [];
870
873
  isPaper() {
871
874
  return this.category === Category.FakeSIN || this.category === Category.License || this.category === Category.SIN;
872
875
  }
@@ -703,9 +703,17 @@ export class Player extends AbstractPlayer {
703
703
  return true;
704
704
  return this.dniSource() !== null;
705
705
  }
706
+ /** Failed reads of a clue, by item name, for p.49's Trying Again
707
+ * (-2 per retry). In memory only: a save and reload is the "break"
708
+ * the rule says clears it. See utilities/clue-reading.ts. */
709
+ readAttempts = new Map();
706
710
  /** True for the one Electronics item that is a trode net (p.439-440). */
707
711
  static isTrodeNet(item) {
708
- if (item.hasCapability('dni'))
712
+ // The item's OWN row, not Item.effects: that getter folds fitted
713
+ // mods in, so a helmet with a net built into it would read as a
714
+ // trode net itself and be named as the interface instead of the
715
+ // net inside it (dniSource reports the net, and the helmet as host).
716
+ if ((item.row?.effects ?? []).some(e => e.type === 'flag' && e.property === 'dni' && e.value > 0))
709
717
  return true;
710
718
  if (item.row)
711
719
  return false;
@@ -726,8 +734,33 @@ export class Player extends AbstractPlayer {
726
734
  const worn = this._equipment.head?.trodes;
727
735
  if (worn)
728
736
  return { kind: 'trodes', worn: true, item: worn };
729
- const bagged = this._inventory.getAllItems().find(i => Player.isTrodeNet(i));
730
- return bagged ? { kind: 'trodes', worn: false, item: bagged } : null;
737
+ // TRODES BUILT INTO HEADGEAR (koz9FKuH8Q685ogcy: "I see my helmet
738
+ // can hold trodes, this should be where my interface to the
739
+ // commlink is, right?"). Right: "Adding this to headgear takes two
740
+ // slots of Capacity" (p.439-440), and the helmet row has advertised
741
+ // a trodes slot all along -- so a net fitted into a WORN helmet is
742
+ // a worn net, and reads here as one. Any worn slot is searched, not
743
+ // just the skull: what the catalog lets hold trodes is the
744
+ // catalog's business.
745
+ for (const group of Object.values(this._equipment)) {
746
+ if (!group)
747
+ continue;
748
+ for (const host of Object.values(group)) {
749
+ if (!host)
750
+ continue;
751
+ const built = host.fitted.find(m => Player.isTrodeNet(m));
752
+ if (built)
753
+ return { kind: 'trodes', worn: true, item: built, host };
754
+ }
755
+ }
756
+ for (const carried of this._inventory.getAllItems()) {
757
+ if (Player.isTrodeNet(carried))
758
+ return { kind: 'trodes', worn: false, item: carried };
759
+ const built = carried.fitted.find(m => Player.isTrodeNet(m));
760
+ if (built)
761
+ return { kind: 'trodes', worn: false, item: built, host: carried };
762
+ }
763
+ return null;
731
764
  }
732
765
  /**
733
766
  * THE PRICE OF SILENCE (canon p.235-236 "Running Silent",
@@ -3,6 +3,7 @@ import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import * as http from 'http';
5
5
  import * as https from 'https';
6
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
7
  import { Logger } from './logger.js';
7
8
  import { saveSlug, readSave, writeSaveAtomic, tombstonePathFor } from './persistence.js';
8
9
  /**
@@ -90,7 +91,29 @@ export function siteRequest(method, apiPath, token, body) {
90
91
  return request(method, apiPath, token, body);
91
92
  }
92
93
  const GLOBAL_CONFIG_PATH = path.join(os.homedir(), '.meteor', '.maka', 'global-config.json');
94
+ /**
95
+ * A LOGIN SCOPED TO ONE ASYNC FLOW, for the site's in-process engine
96
+ * (user 2026-09-10: "I can't record to the backlog while in the web
97
+ * client -- I AM logged in", answered with "Your login's expired").
98
+ *
99
+ * authToken() reads the machine's `maka login` -- right on a terminal,
100
+ * wrong on www.maka-cli.com, where one Node process hosts every
101
+ * browser table and the box's own global config holds whichever stale
102
+ * token last ran `maka` there. The site knows who typed the command;
103
+ * it hands that user's token to HeadlessSession.command, which runs
104
+ * the verb inside this store, and every transport that asks
105
+ * authToken() -- backlog, social, telemetry -- answers as THEM for the
106
+ * length of that one command. No token, no store: a terminal's
107
+ * behavior is byte-for-byte what it was.
108
+ */
109
+ const scopedToken = new AsyncLocalStorage();
110
+ export function runWithAuthToken(token, fn) {
111
+ return token ? scopedToken.run(token, fn) : fn();
112
+ }
93
113
  export function authToken() {
114
+ const scoped = scopedToken.getStore();
115
+ if (scoped)
116
+ return scoped;
94
117
  try {
95
118
  const cfg = JSON.parse(fs.readFileSync(GLOBAL_CONFIG_PATH, 'utf8'));
96
119
  const token = cfg?.token?.authToken;
@@ -0,0 +1,84 @@
1
+ import { perceptionTest, PERCEPTION_THRESHOLD } from './perception.js';
2
+ import { rollPool, formatRoll } from './dice.js';
3
+ import { hint } from './hints.js';
4
+ /**
5
+ * READING A CLUE IS A PERCEPTION TEST (Xxbwtgffkyq8uCHfR, Maka:
6
+ * 'reading codes on "books" should use a perception check to get
7
+ * critical information').
8
+ *
9
+ * A clue's body is plain prose anyone can read; the CREDENTIAL in it
10
+ * -- a maglock code in a margin, a combination on a folded page -- is
11
+ * the detail that "isn't obvious", and p.135 says that is exactly what
12
+ * a Perception Test is for: Perception + Intuition [Mental], threshold
13
+ * 2 for something Normal ("street sign, conversation", p.136). Hits at
14
+ * the threshold and the page gives up the code; short of it the reader
15
+ * gets the prose and not the number.
16
+ *
17
+ * TRYING AGAIN (p.49): a re-read is allowed at a cumulative -2 per
18
+ * retry, tracked on the reader for the session. A found code stays
19
+ * found (Item.revealedTo, saved with the item) -- nobody re-rolls for a
20
+ * number they have already read out loud.
21
+ *
22
+ * WHAT COUNTS AS A CODE: the scene's own device codes, matched
23
+ * verbatim against the item's details -- the same fact ensureClueDetails
24
+ * guarantees is written there (scene-chunks.ts). No code on the page
25
+ * means no test: a diary is just a diary.
26
+ */
27
+ /** The p.49 penalty per failed retry. */
28
+ export const TRYING_AGAIN_PENALTY = 2;
29
+ /** Every device code the scene knows that this item's details spell out. */
30
+ export function codesOnPage(scene, item) {
31
+ const text = (item.details ?? '').toLowerCase();
32
+ if (!text)
33
+ return [];
34
+ const codes = new Set();
35
+ for (const room of Object.values(scene.getRooms())) {
36
+ for (const device of room.devices) {
37
+ const code = (device.code ?? '').trim();
38
+ if (code && text.includes(code.toLowerCase()))
39
+ codes.add(code);
40
+ }
41
+ }
42
+ return [...codes];
43
+ }
44
+ /** The details with every sentence that spells a code taken out. */
45
+ export function redactCodes(details, codes) {
46
+ const lower = codes.map(c => c.toLowerCase());
47
+ const kept = details
48
+ .split(/(?<=[.!?]["'\u201d\u2019)]?)\s+/)
49
+ .filter(sentence => !lower.some(c => sentence.toLowerCase().includes(c)));
50
+ return kept.join(' ').trim();
51
+ }
52
+ /**
53
+ * The text a reader gets from an item's details: everything, when the
54
+ * page holds no code or the reader already found it; otherwise the
55
+ * Perception Test decides whether the credential is on the page.
56
+ */
57
+ export function readDetails(scene, item, opts = {}) {
58
+ const details = item.details ?? '';
59
+ const reader = opts.reader;
60
+ if (!details || !reader)
61
+ return details;
62
+ const codes = codesOnPage(scene, item);
63
+ if (codes.length === 0)
64
+ return details;
65
+ if (item.revealedTo.includes(reader.name))
66
+ return details;
67
+ const attempts = reader.readAttempts.get(item.name) ?? 0;
68
+ const { pool, limit } = perceptionTest(reader);
69
+ const penalty = attempts * TRYING_AGAIN_PENALTY;
70
+ const roll = rollPool(Math.max(1, pool - penalty), limit);
71
+ const rollLine = `Perception: ${formatRoll(roll)} (threshold ${PERCEPTION_THRESHOLD.normal}${penalty ? `, trying again -${penalty}` : ''})`;
72
+ if (opts.isPlayer)
73
+ opts.logger?.meta(rollLine);
74
+ const prefix = opts.isPlayer ? '' : `${rollLine}\n`;
75
+ if (roll.hits >= PERCEPTION_THRESHOLD.normal) {
76
+ item.revealedTo.push(reader.name);
77
+ reader.readAttempts.delete(item.name);
78
+ return `${prefix}${details}`;
79
+ }
80
+ reader.readAttempts.set(item.name, attempts + 1);
81
+ const body = redactCodes(details, codes) || item.description || `You go through "${item.name}" page by page.`;
82
+ return `${prefix}${body}\nSomething in it is written to be missed -- you have the sense you skimmed past it.${hint(` (read it again: -${TRYING_AGAIN_PENALTY} dice per retry, p.49)`)}`;
83
+ }
84
+ //# sourceMappingURL=clue-reading.js.map
@@ -1,6 +1,8 @@
1
1
  import { Logger } from './logger.js';
2
- import { visibleActorsIn } from './spots.js';
2
+ import { visibleActorsIn, seatingIn } from './spots.js';
3
3
  import { hostLabel } from './grid-names.js';
4
+ import { serializeRoomGrid } from './room-grid.js';
5
+ import { hostileByStance } from './room-view.js';
4
6
  /**
5
7
  * THE LIVE CONDITION EMITTER -- the CLI half of the realtime sheet lane
6
8
  * (contract agreed with maka-cli.com, 2026-09-01). On every condition
@@ -71,13 +73,56 @@ export function setMarksPlacedResolver(fn) {
71
73
  export function setActorRoleResolver(fn) {
72
74
  actorRole = fn;
73
75
  }
76
+ /**
77
+ * THE ROOM'S GEOMETRY, ON THE BEAT (2026-09-11, 8bZXzNXLgKwE5wxgh: "no
78
+ * icons being drawn on the character sheet in the active run"). The web
79
+ * sheet rebuilt its room from the SAVE's hub seed, and a generated run
80
+ * room is not in it -- so in a run the sheet had no furniture, no doors,
81
+ * and no cell to seat a spot-sitting NPC on. The beat already fires on
82
+ * every position change and is the one channel both the sheet and the
83
+ * play page read, so the live grid rides it: the SAME wire shape the
84
+ * room event carries for the browser client (headless.ts pushMap ->
85
+ * serializeRoomGrid). Physical planes only -- jacked in, the meat map is
86
+ * a lie the sheet already refuses to draw. A decoration never kills the
87
+ * beat: any throw here is an absent field, not a lost report.
88
+ */
89
+ function roomGridFor(player) {
90
+ const room = player.currentLocation;
91
+ if (!room || (player.plane !== 'meat' && player.plane !== 'drone'))
92
+ return undefined;
93
+ try {
94
+ const grid = room.ensureGrid();
95
+ return grid ? serializeRoomGrid(grid, room.name) : undefined;
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ }
74
101
  export function buildConditionReport(player, deps) {
75
- const { saveSlug, arSightUp } = deps;
102
+ const { saveSlug, arSightUp, isHostile } = deps;
76
103
  // BRICKED INCLUDED (web report 2026-09-02: "my deck got bricked, but its
77
104
  // status still says 4/12"): a forced dump clears activeDeck and
78
105
  // getCyberdeck() hides a bricked deck, so the beat read nothing and the
79
106
  // sheet froze on the last number. Status reads getAnyCyberdeck.
80
107
  const deck = player.activeDeck ?? player.getAnyCyberdeck();
108
+ // WHERE EVERYONE ACTUALLY STANDS (2026-09-11, user: "movement isn't
109
+ // snappy" on the web). The beat sent `atCell` alone, and `atCell` is
110
+ // the EXCEPTION -- set by a tactical step, cleared by "move to <spot>"
111
+ // and by most arrivals -- so after a plain move the beat carried no
112
+ // cell, the server kept the last one it had, and the dot on the web
113
+ // sat still at a square the runner had left. seatingIn is the engine's
114
+ // one answer to "which square" (the room panel, cover and ranges all
115
+ // read it), so the web now gets the same square the CLI draws. Read at
116
+ // fire time like everything else here; a throw means "no seat", never
117
+ // a lost beat.
118
+ const room = player.currentLocation;
119
+ let seats = new Map();
120
+ if (room && (player.plane === 'meat' || player.plane === 'drone')) {
121
+ try {
122
+ seats = seatingIn(room);
123
+ }
124
+ catch { /* keep the empty map */ }
125
+ }
81
126
  return {
82
127
  slug: saveSlug(player.name),
83
128
  damageTaken: player.damageTaken,
@@ -85,7 +130,8 @@ export function buildConditionReport(player, deps) {
85
130
  overflow: 0,
86
131
  room: player.currentLocation?.name,
87
132
  spot: player.atSpot,
88
- cell: player.atCell,
133
+ cell: player.atCell ?? seats.get(player.name),
134
+ grid: roomGridFor(player),
89
135
  plane: player.plane,
90
136
  // Where the meat sits while the persona rides the grid (web accepts
91
137
  // since 2026-09-02): "jacked in from Your Hideout". Absent in the meat.
@@ -196,11 +242,14 @@ export function buildConditionReport(player, deps) {
196
242
  .slice(0, 24)
197
243
  .map(a => ({
198
244
  name: a.name,
245
+ // THE SAME RED THE ROOM PANEL USES (see IConditionReportDeps
246
+ // .isHostile): stance flag OR live enmity, and never a
247
+ // surrendered one -- hostileByStance owns that clause.
199
248
  kind: a.allyOf ? 'ally'
200
- : a.hostile ? 'hostile' : 'neutral',
249
+ : (isHostile?.(a) ?? hostileByStance(a)) ? 'hostile' : 'neutral',
201
250
  role: actorRole(a),
202
251
  spot: a.atSpot,
203
- cell: a.atCell,
252
+ cell: a.atCell ?? seats.get(a.name),
204
253
  })),
205
254
  };
206
255
  }
@@ -227,7 +276,7 @@ function noteRoster(report) {
227
276
  const ADDITIVE_FIELDS = [
228
277
  'ar', 'actors', 'deckLine', 'deckDamage', 'astralPerceiving', 'bodyRoom', 'sustainingPenalty',
229
278
  'matrix', 'panMarks', 'hostMarksOnYou', 'marksPlaced', 'marksOnYou', 'deckStored', 'droneDamage', 'companions', 'pools', 'initiative',
230
- 'karma', 'nuyen', 'edge',
279
+ 'karma', 'nuyen', 'edge', 'grid',
231
280
  ];
232
281
  /** Refusal reasons already written to the session log -- one line each. */
233
282
  const announcedRefusals = new Map();
@@ -242,7 +291,10 @@ function noteRefusal(why) {
242
291
  catch { /* no session */ }
243
292
  }
244
293
  }
245
- export function reportConditionSoon(player, sink) {
294
+ export function reportConditionSoon(player, sink,
295
+ /** The Scene-side half of the beat (IConditionReportDeps.isHostile);
296
+ * the caller that owns a Scene supplies it, nobody else needs to. */
297
+ extra) {
246
298
  // Before the TIMER, not just before the call: a scheduled beat firing
247
299
  // after a Jest file finished lazy-imported into a torn-down
248
300
  // environment and failed CI with every test green (2026-09-01). Under
@@ -269,7 +321,7 @@ export function reportConditionSoon(player, sink) {
269
321
  // stance and brandish flow through their accessors' own reports;
270
322
  // sustained and deck programs are composed here and ride whatever
271
323
  // beat fires next. All read at FIRE time, like position.
272
- const report = buildConditionReport(player, { saveSlug, arSightUp });
324
+ const report = buildConditionReport(player, { saveSlug, arSightUp, isHostile: extra?.isHostile });
273
325
  noteRoster(report);
274
326
  if (sink) {
275
327
  // IN-PROCESS: no HubLink, no MAKA_NO_DDP gate, no version-skew
@@ -220,6 +220,39 @@ export function renderMapViewport(rooms, here, sealing, eyes, viewW = 21, viewH
220
220
  out[halfH] = out[halfH].slice(0, halfW) + `{inverse}${out[halfH][halfW] === ' ' ? '▣' : out[halfH][halfW]}{/inverse}` + out[halfH].slice(halfW + 1);
221
221
  return out;
222
222
  }
223
+ export function serializeDistrictMap(rooms, here, sealing, eyes) {
224
+ const floors = layoutFloors(rooms, here, sealing);
225
+ if (floors.length === 0)
226
+ return undefined;
227
+ const eye = fogEye(rooms, eyes?.visited);
228
+ const ar = eyes?.arEyes !== false;
229
+ const out = floors.map(floor => {
230
+ const linkSeen = (k) => {
231
+ const a = floor.roomAt.get(`${k.ax},${k.ay}`);
232
+ const b = floor.roomAt.get(`${k.bx},${k.by}`);
233
+ return !!a && !!b && eye.sees(a) && eye.sees(b) && (eye.knows(a) || eye.knows(b));
234
+ };
235
+ const seen = [...floor.posByRoom.entries()].filter(([r]) => eye.sees(r));
236
+ return {
237
+ rooms: seen.map(([r, p]) => {
238
+ const known = eye.knows(r);
239
+ return {
240
+ x: p.x, y: p.y,
241
+ ...(known ? { name: r.name } : {}),
242
+ known,
243
+ here: r === here,
244
+ node: known && ar && !!r.hasNode,
245
+ up: known && hasWay(r, 'up'),
246
+ down: known && hasWay(r, 'down'),
247
+ };
248
+ }),
249
+ links: floor.links.filter(linkSeen).map(k => ({ ...k })),
250
+ };
251
+ });
252
+ const current = Math.max(0, floors.findIndex(f => f.posByRoom.has(here)));
253
+ const notes = fogNotes(floors[current]?.notes ?? [], rooms, eye);
254
+ return { current, floors: out, notes };
255
+ }
223
256
  /**
224
257
  * The DETAILED view (the "map" text command): named cells, box-drawing
225
258
  * connections, and annotations -- the renderer shipped in 5.48.0, now
@@ -178,6 +178,8 @@ export function serializeItem(item) {
178
178
  out.linkedSin = item.linkedSin;
179
179
  if (item.fitted.length > 0)
180
180
  out.fitted = item.fitted.map(serializeItem);
181
+ if (item.revealedTo.length > 0)
182
+ out.revealedTo = [...item.revealedTo];
181
183
  if (item.isCyberdeck() && item.deckOrder.join() !== '0,1,2,3')
182
184
  out.deckOrder = item.deckOrder;
183
185
  return out;
@@ -231,6 +233,8 @@ export function restoreItem(json) {
231
233
  item.linkedSin = json.linkedSin;
232
234
  if (json.fitted)
233
235
  item.fitted = json.fitted.map(restoreItem);
236
+ if (json.revealedTo)
237
+ item.revealedTo = [...json.revealedTo];
234
238
  // A running program is in storage by definition -- saves from before
235
239
  // storage existed carried only the running set.
236
240
  for (const k of item.loadedPrograms)
@@ -822,6 +822,14 @@ export function serializeRoomGrid(grid, roomName) {
822
822
  const [x, y, z] = cellKey.split(',').map(Number);
823
823
  return { kind: link.kind, x, y, z };
824
824
  });
825
+ const terrain = [...grid.terrain.values()].map(r => ({
826
+ id: r.id, label: r.label, cover: r.cover, blocking: r.blocking, cleared: r.cleared,
827
+ cells: r.cells.map(c => ({ x: c.x, y: c.y, z: c.z })),
828
+ }));
829
+ const levels = [];
830
+ for (let z = 1; z < grid.dims.z; z++) {
831
+ levels.push({ z, cells: levelFootprint(grid, z).map(c => ({ x: c.x, y: c.y })) });
832
+ }
825
833
  return {
826
834
  name: roomName,
827
835
  dims: { x: grid.dims.x, y: grid.dims.y, z: grid.dims.z },
@@ -829,6 +837,8 @@ export function serializeRoomGrid(grid, roomName) {
829
837
  doors,
830
838
  footprints,
831
839
  vertical,
840
+ terrain,
841
+ levels,
832
842
  };
833
843
  }
834
844
  function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.160.0",
3
+ "version": "5.162.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.",