@maka/maka-cli 5.178.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.178.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.",
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.178.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.",