@maka/maka-cli 5.186.0 → 5.187.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.186.0",
3
+ "version": "5.187.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.",
@@ -556,6 +556,31 @@ export class HackCommand extends BypassCommand {
556
556
  this.logger.meta(`${defense.label} (DR ${device.rating}): ${formatRoll(defenseRoll)} -- net ${netHits >= 0 ? '+' : ''}${netHits}`);
557
557
  }
558
558
  const godLines = this.godBill(overwatchFromDefense(defenseRoll.hits), `device intrusion (${mode})`);
559
+ // WHY THE KEYS YOU ALREADY HOLD DID NOT OPEN THIS (u8u2koi3THQM9dzSz:
560
+ // "I _OWN_ the host with 3 marks, I should be able to hack the
561
+ // maglock, or simply disable it").
562
+ //
563
+ // Checked against the books rather than the report's own reading:
564
+ // marks share ONE WAY. "If you get a mark on a slave you also get a
565
+ // mark on the master ... This doesn't work both ways" (p.233). Three
566
+ // marks on the host are three marks on the HOST; the lock hanging off
567
+ // it is still its own icon with its own monitor, and Control Device
568
+ // (p.238) counts marks on the thing being controlled. So the engine
569
+ // was right to make them roll -- and said nothing at all about why,
570
+ // which is the part that earned the report. A rule the player cannot
571
+ // see is indistinguishable from a bug.
572
+ //
573
+ // The line also names the move that DOES cash those marks in: inside
574
+ // the host you are directly connected to everything on its WAN, and
575
+ // a directly-connected slave "cannot use its master's ratings to
576
+ // defend itself" (p.233) -- which is exactly the pool that just beat
577
+ // them. Only said when they actually hold host marks and are still
578
+ // outside; from in here it would be telling them to walk through the
579
+ // door they are standing in.
580
+ const hostMarksHeld = viaWan && host ? host.hostMarksBy.get(actor.name) ?? 0 : 0;
581
+ const wanNote = hostMarksHeld > 0
582
+ ? hint(` (Your ${hostMarksHeld} mark${hostMarksHeld === 1 ? '' : 's'} on ${hostLabel(host)} buy nothing against the lock itself -- marks run slave-to-master, never master-to-slave (p.233). "enter ${hostLabel(host)}": from inside its WAN you are directly connected, and ${device.name} has to defend on DR ${device.rating} alone.)`)
583
+ : '';
559
584
  if (netHits <= 0) {
560
585
  if (mode === 'sleaze') {
561
586
  // A blown Sleaze is NOTICED (p.236) -- and a slaved device that
@@ -565,14 +590,14 @@ export class HackCommand extends BypassCommand {
565
590
  host.hostAlert = true;
566
591
  this.scene.addWorldEvent(`${device.name} flags a bad handshake -- ${hostLabel(host, { capital: true })} is awake.`);
567
592
  this.scene.updateStatus();
568
- return [`${device.name} refuses the key and SAYS SO -- the node it answers to knows something is in here.`, ...godLines].join('\n');
593
+ return [`${device.name} refuses the key and SAYS SO -- the node it answers to knows something is in here.${wanNote}`, ...godLines].join('\n');
569
594
  }
570
595
  // A loose device has no host to wake; its owner still gets the
571
596
  // mark on you a blown Sleaze hands over (p.240).
572
597
  this.scene.updateStatus();
573
598
  return [`${device.name} refuses the key and SAYS SO -- whoever owns it has your icon on file now.`, ...godLines].join('\n');
574
599
  }
575
- return [`${device.name} holds against the smash -- its firewall never even flexes. Nobody upstairs noticed.`, ...godLines].join('\n');
600
+ return [`${device.name} holds against the smash -- its firewall never even flexes. Nobody upstairs noticed.${wanNote}`, ...godLines].join('\n');
576
601
  }
577
602
  const now = Math.min(MAX_MARKS, held + declared);
578
603
  device.marksBy.set(actor.name, now);
@@ -733,6 +733,15 @@ For EVERY item in the skeleton, expand it into a full item ("name" and "category
733
733
  Clue requirements (mechanically validated -- the exact phrase must appear in "details"):
734
734
  ${clueSpecs || '- (no clue items in this scene)'}
735
735
 
736
+ THE ONLY PRIZE IN THIS WORLD is "${skeleton.winCondition?.item ?? ''}" -- the item
737
+ "winCondition" is for. No other item may present itself as the thing the client wants: not
738
+ "it's what ${skeleton.winCondition?.toNpc || skeleton.client || 'the Johnson'} is looking
739
+ for", not "this is what the job is about", not a second set of goods that reads like the
740
+ objective wearing a different name. The engine stars exactly one item and pays out on
741
+ exactly one; a second item that CLAIMS the role sends the runner home with the wrong thing
742
+ in their pack, believing they blew the job they just finished. Loot is loot -- say what it
743
+ IS, never what it is worth to the client.
744
+
736
745
  THE ONLY CODES IN THIS WORLD are the clue phrases listed just above. No other item may
737
746
  carry a code-shaped string -- no serial numbers, no model numbers, no unit or locker or
738
747
  apartment numbers, no "access code" on a scrap of paper that isn't a listed clue. A player
@@ -2046,6 +2055,112 @@ export function ensureCredentialKindAgrees(details) {
2046
2055
  }
2047
2056
  return notes;
2048
2057
  }
2058
+ /**
2059
+ * ONE PRIZE, AND THE PROSE HAS TO AGREE (aSpARWJXXieMAL4eM, 2026-09-14:
2060
+ * "the item marked with the star as the mission objective was 'The
2061
+ * Scout's Contact Sheet'. But the mission was clearly about camera
2062
+ * data ... When I returned to the Hub, I had 'Developed Negatives',
2063
+ * which state it's what Mr. Leger is looking for. It's like the two
2064
+ * items got mis labeld as to what was important.").
2065
+ *
2066
+ * Nothing was mislabelled in the STATE. winCondition.item was the
2067
+ * contact sheet, the star followed it (planes.ts objectiveMark), the
2068
+ * completion check accepted it, and the payout landed. What went wrong
2069
+ * is that the items pass wrote a SECOND item's flavour as though that
2070
+ * one were the job -- so the run had one prize by the engine's reckoning
2071
+ * and a different one by its own prose, and the player believed the
2072
+ * prose. That is worse than a wrong star: they finished the job
2073
+ * convinced they had failed it.
2074
+ *
2075
+ * Same family as payoutSummary and heatSummary in npc.ts -- the model
2076
+ * inventing a fact the engine already owns -- and the same answer:
2077
+ * state the fact in the prompt (buildItemsPrompt, "THE ONLY PRIZE IN
2078
+ * THIS WORLD"), and enforce it here so a draft that ignores the rule
2079
+ * costs nothing.
2080
+ *
2081
+ * SURGERY, narrowly, and this file's own preference is against it:
2082
+ * ensureCredentialKindAgrees corrects "by ADDING a plain sentence rather
2083
+ * than rewriting generated prose". That works when the generated line is
2084
+ * merely incomplete. It cannot work here, because the line is FALSE --
2085
+ * appending "but the client doesn't want this" under "this is what the
2086
+ * client wants" leaves prose that argues with itself, which is exactly
2087
+ * the confusion the report describes. So the false sentence comes out,
2088
+ * whole, and everything around it is left byte-identical.
2089
+ *
2090
+ * THE BAR IS THREE THINGS IN ONE SENTENCE, because two is not enough to
2091
+ * tell a claim from colour: someone the job is ABOUT (the client by
2092
+ * name, or a generic for them), a verb of WANTING, and a reference back
2093
+ * to THIS item. "Ledger drinks alone and wants nothing from anybody"
2094
+ * keeps all three words apart and survives; "it's what Mr. Ledger is
2095
+ * looking for" does not.
2096
+ */
2097
+ const PRIZE_WANTING = /\b(look(?:s|ing|ed)?\s+for|after\s+(?:this|it|these|them)|want(?:s|ed|ing)?|need(?:s|ed|ing)?|ask(?:s|ed|ing)?\s+for|pay(?:s|ing)?\s+for|hir(?:ed|ing)\s+\w+\s+for|sent\s+\w+\s+for|came\s+for|is\s+the\s+(?:job|prize|objective|target))\b/i;
2098
+ const PRIZE_SELF = /\b(this|these|those|it|its|it's|the\s+one)\b/i;
2099
+ const PRIZE_GENERIC_CLIENT = /\b(the\s+client|the\s+johnson|mr\.?\s+johnson|your\s+employer|the\s+employer|the\s+buyer|the\s+job|the\s+run|the\s+contract)\b/i;
2100
+ /**
2101
+ * Sentence-ish split that keeps the terminator with its sentence.
2102
+ *
2103
+ * IT DOES HAVE TO HANDLE "Mr." -- this comment first said it didn't,
2104
+ * and the first test written against it failed. Every client in this
2105
+ * game is "a Mr. Johnson" (canon p.27-28), so the one sentence this
2106
+ * function exists to read -- "It's what Mr. Ledger is looking for." --
2107
+ * is precisely the one a naive split cuts in half, into two fragments
2108
+ * neither of which claims anything. The titles below are the ones a
2109
+ * Shadowrun scene actually writes; a single capital initial ("J.
2110
+ * Ledger") goes with them.
2111
+ */
2112
+ const SENTENCE_BREAK = /(?<!\b(?:Mr|Mrs|Ms|Dr|Sr|Jr|St|Prof|Rev|Inc|Ltd|Co|vs|No|[A-Z])\.)(?<=[.!?])\s+/;
2113
+ function prizeSentences(text) {
2114
+ return text.split(SENTENCE_BREAK).filter(s => s.length > 0);
2115
+ }
2116
+ function claimsThePrize(sentence, clientNames) {
2117
+ if (!PRIZE_WANTING.test(sentence) || !PRIZE_SELF.test(sentence))
2118
+ return false;
2119
+ const named = clientNames.some(n => n.length > 0 && sentence.toLowerCase().includes(n.toLowerCase()));
2120
+ return named || PRIZE_GENERIC_CLIENT.test(sentence);
2121
+ }
2122
+ export function ensureOnePrize(skeleton, details) {
2123
+ const notes = [];
2124
+ const prize = (skeleton.winCondition?.item ?? '').trim().toLowerCase();
2125
+ if (prize.length === 0)
2126
+ return notes;
2127
+ // The people a claim can be made on behalf of: the client who holds
2128
+ // the meet, and whoever the goods are owed to (usually the same
2129
+ // person, sometimes not -- a courier job delivers to a third party).
2130
+ // Last names alone are how these lines actually read ("Ledger takes
2131
+ // the sleeve"), so each name contributes its words too.
2132
+ const clientNames = [skeleton.client ?? '', skeleton.winCondition?.toNpc ?? '']
2133
+ .flatMap(n => [n, ...n.split(/\s+/)])
2134
+ .map(n => n.replace(/[^\w\s'-]/g, '').trim())
2135
+ .filter(n => n.length >= 4);
2136
+ for (const item of details.items ?? []) {
2137
+ if ((item.name ?? '').trim().toLowerCase() === prize)
2138
+ continue;
2139
+ for (const field of ['description', 'details']) {
2140
+ const text = (item[field] ?? '').trim();
2141
+ if (text.length === 0)
2142
+ continue;
2143
+ const kept = prizeSentences(text).filter(s => !claimsThePrize(s, clientNames));
2144
+ if (kept.length === prizeSentences(text).length)
2145
+ continue;
2146
+ const rebuilt = kept.join(' ').trim();
2147
+ // A description is always-visible flavour and cannot be blank --
2148
+ // an item with no line at all reads as a generator failure. A
2149
+ // "details" that empties out simply goes away, which is what an
2150
+ // item with nothing more to say should do.
2151
+ if (field === 'description') {
2152
+ item.description = rebuilt.length > 0
2153
+ ? rebuilt
2154
+ : `Someone's property once, and worth carrying if nobody's looking.`;
2155
+ }
2156
+ else {
2157
+ item.details = rebuilt.length > 0 ? rebuilt : undefined;
2158
+ }
2159
+ notes.push(`normalized: item "${item.name}" claimed in its ${field} to be what the job is for; "${skeleton.winCondition.item}" is, so the claim was cut.`);
2160
+ }
2161
+ }
2162
+ return notes;
2163
+ }
2049
2164
  /**
2050
2165
  * RESOLVE A NAME ONE LIST WROTE AGAINST THE LIST THAT OWNS IT.
2051
2166
  *
@@ -2110,6 +2225,7 @@ export function assembleScene(skeleton, details, playerName) {
2110
2225
  // The answer has to be legible before anything is assembled from it.
2111
2226
  ensureClueDetails(skeleton, details);
2112
2227
  ensureCredentialKindAgrees(details);
2228
+ ensureOnePrize(skeleton, details);
2113
2229
  const roomDetail = new Map(details.rooms.map(r => [r.name, r]));
2114
2230
  const npcDetail = new Map(details.npcs.map(n => [n.name, n]));
2115
2231
  const deviceDetail = new Map(details.devices.map(d => [d.name, d]));
@@ -458,6 +458,28 @@ export function splitSpokenLines(lines) {
458
458
  rebuilt.push(`[COMMAND] say ${bracketed[1]}`);
459
459
  continue;
460
460
  }
461
+ // ...AND SO IS ONE FILED UNDER A TAG THE MODEL INVENTED
462
+ // (zQTWGJFWDXoLtiJX6, 2026-09-13: Whisper's whole sales line
463
+ // arrived as `[TALK] say Depends what you're buying or
464
+ // confessing. The credstick's hot and priced like it...` and
465
+ // printed into the world log verbatim, marker and all -- the
466
+ // reporter first read it as his OWN typing echoed back).
467
+ //
468
+ // Third sighting of one root: the model reaches for the bracket
469
+ // convention and gets the WORD inside it wrong. [say] was the
470
+ // second; this is the general case, and generalising beats
471
+ // adding [TALK] to a list that would need [DIALOGUE], [ACTION]
472
+ // and whatever the next model invents.
473
+ //
474
+ // The bound that holds every repair in this function holds here
475
+ // too: the VERB after the bracket must be `say`. `[go] north`
476
+ // stays exactly what it was -- only `say` carries free prose, so
477
+ // only `say` can be promoted without risking a silent act.
478
+ const tagged = t.match(/^\[\s*[A-Za-z][A-Za-z ._-]*\s*\]\s*say\s+(\S.*)$/i);
479
+ if (tagged) {
480
+ rebuilt.push(`[COMMAND] say ${tagged[1]}`);
481
+ continue;
482
+ }
461
483
  // ...AND A LINE THAT IS NOTHING BUT SPEECH MARKS IS SPEECH
462
484
  // (playtest 2026-08-27, reported twice in one session: 'the
463
485
  // "Take a seat..." comment isn't showing as he said it. It's in
@@ -1,5 +1,6 @@
1
1
  import { hostConcealedFrom } from './jackpoint.js';
2
2
  import { matchesHostName, hostLabel } from './grid-names.js';
3
+ import { fuzzyPickName } from './fuzzy-match.js';
3
4
  /**
4
5
  * THE GRID HAS NO STREETS (user canon check 2026-09-02; SR5 pp.217-218,
5
6
  * p.246). Jacked in and outside a host, a persona is on its grid: "a
@@ -44,6 +45,28 @@ export function hostOver(room) {
44
45
  * anywhere on the grid. Bare "host" (or nothing) means the one over the
45
46
  * persona's vicinity, else nothing -- with several overhead, a bare word
46
47
  * cannot pick.
48
+ *
49
+ * THE ALIAS LIST IS EXACT, AND EXACT IS NOT ENOUGH (qHwKnqEGrJFn3tmF7).
50
+ * grid-names.ts made the printer and the resolver agree, which fixed
51
+ * the lie -- but it left the player typing the label back BYTE FOR
52
+ * BYTE. A run whose room was called "The Grinning Idol -- Back Room"
53
+ * refused `mark Back Room host` and accepted only
54
+ * `mark The Grinning Idol -- Back Room host`; the reporter's word for
55
+ * it was "ridiculous", and they are right. A name you can read off the
56
+ * screen but not shorten is a name you have to transcribe.
57
+ *
58
+ * So: aliases first, unchanged -- an exact hit still resolves exactly,
59
+ * and the vicinity still wins ties. Only when nothing answers does the
60
+ * shared fuzzy matcher get a turn, over the names the map actually
61
+ * printed. It is the same resolver items and NPCs go through, with the
62
+ * same discipline: an AMBIGUOUS guess misses rather than picking a host
63
+ * at random, which matters more here than anywhere -- cracking the
64
+ * wrong node spends the action, wakes the wrong ice and cannot be
65
+ * taken back.
66
+ *
67
+ * The trailing "host"/"node" comes off first: it is part of the LABEL
68
+ * ("the Back Room host"), never part of the room's name, and leaving it
69
+ * on drags every score down by a word.
47
70
  */
48
71
  export function resolveHostInReach(rooms, actor, query) {
49
72
  const q = query.trim().toLowerCase();
@@ -56,7 +79,14 @@ export function resolveHostInReach(rooms, actor, query) {
56
79
  const near = hostOver(gridVicinity(actor));
57
80
  if (near && matchesHostName(q, near))
58
81
  return near;
59
- return hosts.find(r => matchesHostName(q, r));
82
+ const exact = hosts.find(r => matchesHostName(q, r));
83
+ if (exact)
84
+ return exact;
85
+ const bare = q.replace(/\s*\b(hosts?|nodes?)\s*$/, '').trim();
86
+ if (bare.length === 0)
87
+ return undefined;
88
+ const picked = fuzzyPickName(bare, hosts.map(r => r.name));
89
+ return picked ? hosts.find(r => r.name === picked) : undefined;
60
90
  }
61
91
  /** Noise between the persona and a room's icons: 0 for the vicinity (and inside a host), -DISTRICT_NOISE dice elsewhere. */
62
92
  export function noiseFor(actor, room) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.186.0",
3
+ "version": "5.187.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.",