@maka/maka-cli 5.177.0 → 5.179.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.177.0",
3
+ "version": "5.179.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.",
@@ -100,7 +100,14 @@ export class SayCommand extends Command {
100
100
  // comms visually reads as a different "space" than in-person speech.
101
101
  const inCall = !!currentPlayer.activeCallPartner || !!currentPlayer.crewChannel;
102
102
  const prefix = inCall ? `${CALL_ICON} ` : '';
103
- this.logger.logWithColor(`${prefix}${message}`, inCall ? CALL_COLOR : 'yellow', { actor: currentPlayer.name });
103
+ const speech = {
104
+ speaker: currentPlayer.name,
105
+ text: message,
106
+ self: true,
107
+ ...(inCall ? { comms: true } : {}),
108
+ ...(currentPlayer.gender ? { gender: currentPlayer.gender } : {}),
109
+ };
110
+ this.logger.logWithColor(`${prefix}${message}`, inCall ? CALL_COLOR : 'yellow', { actor: currentPlayer.name }, { speech });
104
111
  }
105
112
  // "say" and "call" are deliberately similar -- once a call connects
106
113
  // (see call.ts), the two ends keep talking with the same "say" command
@@ -458,10 +458,13 @@ ${JSON_ONLY}
458
458
  "plane"?: "meat"|"matrix", // "matrix" = paydata: only in a hasNode room, category
459
459
  // Datachip/Document. A matrix-plane winCondition item makes
460
460
  // the job a datarun (its room MUST have ice).
461
- "forPuzzle"?: string // role "clue" only: which puzzle's passphrase this clue reveals.
462
- // EVERY puzzle needs exactly one clue item, category "Book",
463
- // placed in a DIFFERENT room than that puzzle (door puzzles: any
464
- // room). The clue's text is written in a later pass.
461
+ "forDevice"?: string // roles "clue" and "key" only: the devices[].name this item
462
+ // opens. A "clue" reveals that panel's code -- EVERY panel needs
463
+ // exactly one, category "Book", placed in a DIFFERENT room than
464
+ // the panel (door panels: any room); its text is written in a
465
+ // later pass. A "key" is what that lock takes -- EVERY lock
466
+ // needs exactly one, category "Key", and the lock opens to
467
+ // nothing else.
465
468
  // ${isContinuation ? 'Role "starting" is FORBIDDEN in this scene -- the runner arrives fully equipped (see dossier).' : 'Role "starting" items are the fresh runner\'s basic kit.'}
466
469
  }],
467
470
  "winCondition": {
@@ -829,6 +832,60 @@ export function parseJsonReply(reply) {
829
832
  * tokens building on it. Deeper semantics (reachability, clue coverage,
830
833
  * matrix defense...) are still enforced by the assembled-scene validators.
831
834
  */
835
+ /**
836
+ * WHICH KEY OPENS THIS LOCK -- the one answer the validator, the
837
+ * normalizer and assembleScene all have to agree on, so it lives once.
838
+ *
839
+ * In order of how much the skeleton actually SAID:
840
+ * 1. a key-role item whose `forDevice` names the lock (the field the
841
+ * prompt asks for);
842
+ * 2. the `keyRequired` of the exit this lock holds (the older way of
843
+ * saying the same thing);
844
+ * 3. a key whose name shares a real word with the lock's name or
845
+ * concept ("Warehouse Fob" for "Warehouse Maglock") -- accepted
846
+ * only when exactly one does;
847
+ * 4. the one unclaimed key in a scene with one unkeyed lock: the
848
+ * model wrote "one key per lock" and simply never joined them.
849
+ *
850
+ * `claimed` lets a caller walking several locks keep one key from
851
+ * opening two of them by rule 3 or 4. Nothing here invents a key --
852
+ * that is normalizeSkeleton's call, capped, with a log line.
853
+ */
854
+ export function resolveLockKey(skeleton, device, claimed = new Set()) {
855
+ const items = skeleton.items ?? [];
856
+ const keys = items.filter(i => i.role === 'key');
857
+ const declared = keys.find(i => i.forDevice === device.name);
858
+ if (declared)
859
+ return declared.name;
860
+ for (const room of skeleton.rooms ?? []) {
861
+ for (const e of room.exits ?? []) {
862
+ if (e.device !== device.name || !e.keyRequired)
863
+ continue;
864
+ const wanted = e.keyRequired.toLowerCase();
865
+ const hit = items.find(i => i.name.toLowerCase() === wanted);
866
+ if (hit)
867
+ return hit.name;
868
+ }
869
+ }
870
+ const free = keys.filter(k => !claimed.has(k.name) && !k.forDevice);
871
+ const words = (s) => new Set(s.toLowerCase().replace(/'s\b/g, '').split(/[^a-z0-9]+/).filter(w => w.length >= 4 && !GENERIC_LOCK_WORDS.has(w)));
872
+ const lockWords = new Set([...words(device.name), ...words(device.concept ?? '')]);
873
+ const named = free.filter(k => [...words(k.name)].some(w => lockWords.has(w)));
874
+ if (named.length === 1)
875
+ return named[0].name;
876
+ const locks = (skeleton.devices ?? []).filter(d => (d.kind ?? 'panel') === 'lock');
877
+ const unkeyed = locks.filter(l => !keys.some(k => k.forDevice === l.name)
878
+ && !(skeleton.rooms ?? []).some(r => (r.exits ?? []).some(e => e.device === l.name && e.keyRequired)));
879
+ if (free.length === 1 && unkeyed.length === 1 && unkeyed[0].name === device.name)
880
+ return free[0].name;
881
+ return undefined;
882
+ }
883
+ /** Words every lock and every key share, which therefore prove nothing
884
+ * about which key opens which lock. */
885
+ const GENERIC_LOCK_WORDS = new Set([
886
+ 'lock', 'maglock', 'door', 'gate', 'grate', 'hatch', 'safe', 'vault', 'cage', 'shutter', 'padlock', 'cabinet', 'locker',
887
+ 'keycard', 'card', 'passkey', 'access', 'security', 'back', 'front', 'side', 'room', 'office', 'main', 'inner', 'outer',
888
+ ]);
832
889
  export function validateSkeleton(skeleton, player, crew) {
833
890
  const fail = (msg) => { throw new Error(`Skeleton invalid: ${msg}`); };
834
891
  if (!Array.isArray(skeleton?.rooms) || skeleton.rooms.length === 0)
@@ -1004,17 +1061,19 @@ export function validateSkeleton(skeleton, player, crew) {
1004
1061
  }
1005
1062
  }
1006
1063
  else if (kind === 'lock') {
1007
- // A lock's findable way through is its KEY. Either the door it
1008
- // holds names one, or a key-role item exists for it.
1064
+ // A lock's findable way through is its KEY -- and it has to be
1065
+ // THIS lock's key, resolvable by resolveLockKey, because that is
1066
+ // the exact question assembleScene asks when it writes the
1067
+ // device's `keyItem`. "Some key-role item exists somewhere" used
1068
+ // to pass here (8XDR2BCZoSmpqdkXG: Corvid's Fob existed, Yuri's
1069
+ // Maglock shipped with no keyItem, and the fob opened nothing).
1009
1070
  const holdsDoor = refs > 0;
1010
- const keyed = skeleton.rooms.some(r => (r.exits ?? []).some(e => e.device === device.name && e.keyRequired));
1011
- const looseKey = skeleton.items.some(i => i.role === 'key');
1012
1071
  // The same floor for a lock that holds a PRIZE rather than a
1013
1072
  // door (a safe): its findable way in is a key, and "holdsDoor"
1014
1073
  // used to exempt it -- so a keyless safe passed here and then
1015
1074
  // stood in play with only breach options (bPiyyWu9TQ4pBfGbM).
1016
- if (!keyed && !looseKey) {
1017
- 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.`);
1075
+ if (!resolveLockKey(skeleton, device)) {
1076
+ fail(`lock "${device.name}" ${holdsDoor ? 'holds a door' : 'holds a prize'} and no "key"-role item opens it -- add one with "forDevice": "${device.name}", so a runner without locksmith has a way through.`);
1018
1077
  }
1019
1078
  }
1020
1079
  // A WARD is the exception no clue can rescue: there is no findable
@@ -1673,6 +1732,56 @@ export function normalizeSkeleton(skeleton) {
1673
1732
  synthesized++;
1674
1733
  notes.push(`normalized: panel "${device.name}" had no clue item; synthesized Book clue "${name}" in "${safe[0]}" (reachable without passing the barrier).`);
1675
1734
  }
1735
+ // EVERY LOCK HAS ITS KEY, BY NAME (8XDR2BCZoSmpqdkXG: "the Barrier
1736
+ // explicitly states to use Corvid's Fob, I have Corvid's Fob, but
1737
+ // doesn't unlock the maglock").
1738
+ //
1739
+ // The skeleton prompt has always asked for "one key per lock", and
1740
+ // the model has always delivered one -- but nothing ever wrote WHICH
1741
+ // lock a key opened onto the key, and assembleScene never wrote a
1742
+ // `keyItem` onto the lock. The validator only asked whether a
1743
+ // key-role item existed at all, so the pair shipped unrelated: a fob
1744
+ // that opened nothing beside a maglock that took nothing, with every
1745
+ // line of prose in between insisting they matched. The same shape as
1746
+ // the panel clue above, one loop later: resolve the pair here,
1747
+ // record it on the key so every later pass (and the log) can see it,
1748
+ // and invent a key only when the model left a lock with none.
1749
+ const SYNTHESIZED_KEY_CAP = 2;
1750
+ let synthesizedKeys = 0;
1751
+ const claimedKeys = new Set();
1752
+ for (const device of skeleton.devices ?? []) {
1753
+ if ((device.kind ?? 'panel') !== 'lock')
1754
+ continue;
1755
+ const items = skeleton.items ?? [];
1756
+ const keyName = resolveLockKey(skeleton, device, claimedKeys);
1757
+ if (keyName) {
1758
+ claimedKeys.add(keyName);
1759
+ const key = items.find(i => i.name === keyName);
1760
+ if (key && key.forDevice !== device.name) {
1761
+ const was = key.forDevice ? `pointed at "${key.forDevice}"` : 'named no device';
1762
+ key.forDevice = device.name;
1763
+ notes.push(`normalized: key "${key.name}" ${was}; it is the key to lock "${device.name}".`);
1764
+ }
1765
+ continue;
1766
+ }
1767
+ const safe = safeRoomsFor(skeleton, device);
1768
+ if (safe.length === 0) {
1769
+ notes.push(`could not synthesize a key for lock "${device.name}": nowhere is reachable without passing it.`);
1770
+ continue;
1771
+ }
1772
+ if (synthesizedKeys >= SYNTHESIZED_KEY_CAP) {
1773
+ notes.push(`declined to synthesize a key for lock "${device.name}": already invented ${SYNTHESIZED_KEY_CAP} this skeleton -- a fresh draft is the better bet.`);
1774
+ continue;
1775
+ }
1776
+ let name = `${device.name} Key`;
1777
+ for (let i = 2; items.some(it => it.name === name); i++)
1778
+ name = `${device.name} Key ${i}`;
1779
+ skeleton.items = items;
1780
+ skeleton.items.push({ name, role: 'key', category: 'Key', forDevice: device.name, room: safe[0] });
1781
+ claimedKeys.add(name);
1782
+ synthesizedKeys++;
1783
+ notes.push(`normalized: lock "${device.name}" had no key; synthesized Key "${name}" in "${safe[0]}" (reachable without passing the barrier).`);
1784
+ }
1676
1785
  // REWARD-REFERENCE reconciliation. A rewardItem (a puzzle's, or the
1677
1786
  // winCondition's) must point at an UNPLACED role-"reward" item, but
1678
1787
  // models regularly point it at a placed loot/clue item instead -- and
@@ -2335,8 +2444,17 @@ export function assembleScene(skeleton, details, playerName) {
2335
2444
  heldExit.set(canonical, e.direction);
2336
2445
  }
2337
2446
  }
2447
+ // WHICH KEY EACH LOCK TAKES -- the field Device.offer actually reads.
2448
+ // Same ruling as heldExit just above: the skeleton says it once (on
2449
+ // the key's forDevice, or the held exit's keyRequired) and this is
2450
+ // the only place it becomes the device's own `keyItem`. Without it
2451
+ // the door never opens to its key (8XDR2BCZoSmpqdkXG).
2452
+ const claimedKeys = new Set();
2338
2453
  const devices = (skeleton.devices ?? []).map(device => {
2339
2454
  const detail = deviceDetail.get(device.name);
2455
+ const keyItem = (device.kind ?? 'panel') === 'lock' ? resolveLockKey(skeleton, device, claimedKeys) : undefined;
2456
+ if (keyItem)
2457
+ claimedKeys.add(keyItem);
2340
2458
  const guardsExit = device.room && detail?.guardsExit && roomExitDirsByName.get(device.room)?.has(detail.guardsExit.toLowerCase())
2341
2459
  ? detail.guardsExit.toLowerCase()
2342
2460
  : undefined;
@@ -2357,6 +2475,7 @@ export function assembleScene(skeleton, details, playerName) {
2357
2475
  opensExit: heldExit.get(device.name),
2358
2476
  description: detail?.description ?? device.concept,
2359
2477
  code,
2478
+ keyItem,
2360
2479
  grantsItem: device.grantsItem,
2361
2480
  openMessage: detail?.openMessage ?? 'It opens.',
2362
2481
  refuseMessage: detail?.refuseMessage ?? 'Nothing happens.',
@@ -334,6 +334,47 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
334
334
  logger.write(`Scene "${json.name}": "${name}" resolved to item "${fuzzy}" by fuzzy match -- names generated apart drifted.`);
335
335
  return itemMap.get(fuzzy);
336
336
  };
337
+ // A LOCK THE SEED NEVER GAVE A KEY, WHOSE OWN PROSE NAMES ONE
338
+ // (8XDR2BCZoSmpqdkXG: "the Barrier explicitly states to use
339
+ // Corvid's Fob, I have Corvid's Fob, but doesn't unlock the
340
+ // maglock"). Until 2026-09-13 the generator never wrote `keyItem`
341
+ // onto a lock at all, so every generated run on the board carries a
342
+ // maglock with no key beside a Key-category item whose details say
343
+ // which door it opens. Those seeds are already stored on live
344
+ // sessions and will be rebuilt from as-is, so the repair has to
345
+ // happen HERE, on the shipped content, exactly like the fuzzy
346
+ // keyItem repair above: read the prose the player reads. A Key
347
+ // item whose text names the device, or whose name the device's
348
+ // description uses, is its key -- when exactly one does. Failing
349
+ // that, the one Key in a scene with one lock is that lock's key.
350
+ // Anything less certain stays unrepaired and logged, because
351
+ // guessing wrong hands a player a key that opens the wrong door.
352
+ const allDevices = json.rooms.flatMap(r => (r.devices ?? []).map(d => ({ ...d, roomName: r.name })));
353
+ const inferKeyItem = (d) => {
354
+ if (d.kind !== 'lock' || d.open)
355
+ return undefined;
356
+ const keys = [...itemMap.values()].filter(i => i.category === Category.Key);
357
+ const deviceName = d.name.toLowerCase();
358
+ const prose = (d.description ?? '').toLowerCase();
359
+ const mentions = keys.filter(k => `${k.description ?? ''} ${k.details ?? ''}`.toLowerCase().includes(deviceName)
360
+ || prose.includes(k.name.toLowerCase()));
361
+ let hit;
362
+ let how = '';
363
+ if (mentions.length === 1) {
364
+ hit = mentions[0];
365
+ how = 'its prose names';
366
+ }
367
+ else if (mentions.length === 0 && keys.length === 1 && allDevices.filter(x => x.kind === 'lock' && !x.keyItem && !x.open).length === 1) {
368
+ hit = keys[0];
369
+ how = 'the only key in the scene beside the only lock is';
370
+ }
371
+ if (!hit) {
372
+ logger.write(`Scene "${json.name}": lock "${d.name}" declares no keyItem and no single Key item can be read as its key -- it opens only to force.`);
373
+ return undefined;
374
+ }
375
+ logger.write(`Scene "${json.name}": lock "${d.name}" declared no keyItem; ${how} "${hit.name}", which now opens it.`);
376
+ return hit;
377
+ };
337
378
  // Step 4.9: DEVICES, and the doors they hold shut.
338
379
  // After items, because a device may hold a key or a reward.
339
380
  //
@@ -372,7 +413,7 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
372
413
  name: d.name,
373
414
  description: d.description,
374
415
  code: d.code,
375
- keyItem: d.keyItem ? findItem(d.keyItem) : undefined,
416
+ keyItem: d.keyItem ? findItem(d.keyItem) : inferKeyItem(d),
376
417
  grantsItem: d.grantsItem ? findItem(d.grantsItem) : undefined,
377
418
  atSpot: d.atSpot,
378
419
  opensExit: d.opensExit,
@@ -84,13 +84,14 @@ export class EmitterLogger extends Logger {
84
84
  const actor = g?.scene.getPlayerByName(d.actorName);
85
85
  return actor?.currentLocation ? { room: actor.currentLocation.name } : { actor: d.actorName };
86
86
  }
87
- log(message, scope) {
87
+ log(message, scope, data) {
88
88
  this.emit('log', message, scope ?? this.ambient('log'), {
89
89
  actor: currentSession()?.dispatch?.actorName,
90
+ ...(data ? { data } : {}),
90
91
  });
91
92
  }
92
- logWithColor(message, color = 'white', scope) {
93
- this.log(`{${color}-fg}${message}{/${color}-fg}`, scope);
93
+ logWithColor(message, color = 'white', scope, data) {
94
+ this.log(`{${color}-fg}${message}{/${color}-fg}`, scope, data);
94
95
  }
95
96
  meta(message, color, scope) {
96
97
  const text = color ? `{${color}-fg}${message}{/${color}-fg}` : message;
@@ -766,7 +766,8 @@ export class NPC extends Player {
766
766
  // SPEECH STAYS IN THE STORY. Someone on the other end of the bond
767
767
  // talking to you is fiction, and burying it in the Mechanics
768
768
  // ticker would mean missing "Rattle's not budging, need your call".
769
- this.logger.logWithColor(`${style.icon} ${this.name}: "${spoken}"`, style.color);
769
+ const speech = { speaker: this.name, text: spoken, link: true, ...(this.gender ? { gender: this.gender } : {}) };
770
+ this.logger.logWithColor(`${style.icon} ${this.name}: "${spoken}"`, style.color, undefined, { speech });
770
771
  }
771
772
  else {
772
773
  // TELEMETRY GOES TO MECHANICS (player ruling 2026-08-25: the
@@ -2821,11 +2821,22 @@ export class Player extends AbstractPlayer {
2821
2821
  // room -- a cross-room say over a call would render to the wrong
2822
2822
  // side, and two humans sharing a room would each see the line
2823
2823
  // twice. Solo Logger ignores the scope (inert third arg).
2824
+ // SPOKEN LINES AS DATA (emitter.ts SpeechData): the words ride beside
2825
+ // the text so the browser can hand them to a voice. Inert solo.
2826
+ const speech = (comms) => ({
2827
+ speech: {
2828
+ speaker: actor.name,
2829
+ text: message,
2830
+ ...(actor === this ? { self: true } : {}),
2831
+ ...(comms ? { comms: true } : {}),
2832
+ ...(actor.gender ? { gender: actor.gender } : {}),
2833
+ },
2834
+ });
2824
2835
  if (this.activeCallPartner === actor && actor.currentLocation !== this.currentLocation) {
2825
- this.logger.logWithColor(`${CALL_ICON} ${actor.name} (over comms): ${message}`, CALL_COLOR, { actor: this.name });
2836
+ this.logger.logWithColor(`${CALL_ICON} ${actor.name} (over comms): ${message}`, CALL_COLOR, { actor: this.name }, speech(true));
2826
2837
  return;
2827
2838
  }
2828
- this.logger.logWithColor(`{white-fg}${actor.name}:{/white-fg} ${message}`, (actor === this) ? 'yellow' : 'green', { actor: this.name });
2839
+ this.logger.logWithColor(`{white-fg}${actor.name}:{/white-fg} ${message}`, (actor === this) ? 'yellow' : 'green', { actor: this.name }, speech(false));
2829
2840
  }
2830
2841
  see(actor, verb, details, quiet) {
2831
2842
  if (actor !== this && this.canPerceive(actor)) {
@@ -88,7 +88,7 @@ export class Logger {
88
88
  // solo screen shows the local player everything its callers already
89
89
  // gated for. A headless session's EmitterLogger reads it (or applies
90
90
  // the ambient dispatch default) to stamp per-player visibility.
91
- log(message, _scope) {
91
+ log(message, _scope, _data) {
92
92
  const logBox = this.screen?.children.find(child => child.type === 'box' && child.options.id === 'logBox');
93
93
  if (logBox) {
94
94
  logBox.insertBottom(message); // Supports color tags
@@ -106,9 +106,11 @@ export class Logger {
106
106
  // not the system log (player request: two files).
107
107
  this.transcript(message);
108
108
  }
109
- logWithColor(message, color = 'white', scope) {
109
+ // `data` rides the same way as `scope`: inert on the solo screen, stamped
110
+ // onto the event by a headless session's EmitterLogger (GameEvent.data).
111
+ logWithColor(message, color = 'white', scope, data) {
110
112
  const coloredMessage = `{${color}-fg}${message}{/${color}-fg}`;
111
- this.log(coloredMessage, scope);
113
+ this.log(coloredMessage, scope, data);
112
114
  }
113
115
  /**
114
116
  * The MECHANICS ticker (readability pass): dice anatomy and resource
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.177.0",
3
+ "version": "5.179.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.",