@maka/maka-cli 5.160.0 → 5.161.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.161.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
@@ -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
@@ -16,6 +16,7 @@ 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 { runWithAuthToken } from './utilities/cloud-saves.js';
19
20
  import { renderMapViewport, mapCaption } 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';
@@ -352,12 +353,12 @@ export async function createHeadlessSession(opts) {
352
353
  const session = {
353
354
  game,
354
355
  logger,
355
- command: (actorName, input) => runInSession(ctx, async () => {
356
+ command: (actorName, input, opts) => runInSession(ctx, () => runWithAuthToken(opts?.authToken, async () => {
356
357
  const res = await game.handleInputAs(actorName, input);
357
358
  session.pushStatus();
358
359
  pushMap();
359
360
  return res;
360
- }),
361
+ })),
361
362
  addPlayer: (save) => runInSession(ctx, async () => {
362
363
  // REJOIN IS NOT A JOIN (playtest 2026-08-27, Sherry: "got an
363
364
  // 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
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.160.0",
3
+ "version": "5.161.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.",