@maka/maka-cli 5.136.0 → 5.137.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.136.0",
3
+ "version": "5.137.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.",
@@ -1,3 +1,4 @@
1
+ import { standDown } from '../utilities/hostile-contact.js';
1
2
  import { Command } from './command.js';
2
3
  import { hint } from '../utilities/hints.js';
3
4
  import { sameSpot, spotOf, sealedRouteTo } from '../utilities/spots.js';
@@ -152,7 +153,11 @@ export class BluffCommand extends Command {
152
153
  return `${who.name} lets you finish, then doesn't move. "Nice try."`;
153
154
  }
154
155
  // They stand down -- for everyone, deliberately (see Player.guarding).
156
+ // Opposition too: "cleared the same way guarding is -- talked round"
157
+ // (Player.hostile), which nothing did until 2026-09-07.
155
158
  who.guarding = false;
159
+ if (who.hostile)
160
+ standDown(who);
156
161
  void Promise.resolve(who.hear(this.actor, `[System note: ${this.actor.name} just talked you round and you're STEPPING ASIDE -- you bought the story. One short line as you move, in your own voice. Don't re-block the way.]`)).catch(() => undefined);
157
162
  return [
158
163
  `${who.name} weighs it, decides you're somebody's problem but not theirs, and steps out of the way.`,
@@ -629,7 +629,10 @@ export class MoveCommand extends Command {
629
629
  // "Away" is wherever the room's empty ground currently IS -- re-seat
630
630
  // it on the loneliest reachable cell before measuring the crossing
631
631
  // or committing the move (see spots.ts reseatOpenFloor).
632
- if (spot.name === OPEN_FLOOR)
632
+ // ...except mid-fight: the anchor re-derivation moves every spotless
633
+ // body in the room, and during a Combat Turn nothing but the phase
634
+ // actor moves (utilities/ephemeral.ts has the ruling).
635
+ if (spot.name === OPEN_FLOOR && !this.scene.encounterIn?.(room))
633
636
  reseatOpenFloor(room, actor);
634
637
  // The route a body actually walks -- around the furniture and
635
638
  // around other people (spots.ts routeForMove). A crowded floor
@@ -1,3 +1,4 @@
1
+ import { standDown } from '../utilities/hostile-contact.js';
1
2
  import { Command } from './command.js';
2
3
  import { NPC } from '../models/npc.js';
3
4
  import { rollPool, formatRoll } from '../utilities/dice.js';
@@ -93,6 +94,10 @@ export class PersuadeCommand extends Command {
93
94
  // win). A strong win IS that pressure, by name.
94
95
  note = `[System note: ${actor.name}'s pitch genuinely LANDED -- you now actively want to help them this job: share what you know freely, drop any demands to a token gesture, and treat them as a friend. If you guard a secret, a pass, or a way through, you have just been MASTERFULLY PLAYED -- divulging it now is exactly in character.]`;
95
96
  verdict = `Something shifts -- ${target.name} is with you now.`;
97
+ // A strong win talks opposition round (Player.hostile: "cleared the
98
+ // same way guarding is"): the ladder in hostile-contact.ts stops.
99
+ if (target.hostile)
100
+ standDown(target);
96
101
  }
97
102
  else if (net >= 1) {
98
103
  note = `[System note: ${actor.name} is persuasive -- you're inclined to cooperate: be more forthcoming and reasonable with them than you'd otherwise be. If you guard a secret or a way through, you're wavering -- a little more pressure or goodwill would tip you.]`;
@@ -376,5 +376,13 @@
376
376
  // the damage type your next blow means (p.186 flat of the blade, stun
377
377
  // rounds on reload). SAVE SHAPE: autoStance is lethal | non-lethal;
378
378
  // "manual" loads as lethal.
379
- export const ENGINE_VERSION = '1.41.0';
379
+ // 1.42.0 (2026-09-07): HOSTILES OPEN COMBAT, AND NOTHING BUT THE PHASE ACTOR
380
+ // MOVES. Shared-scene semantics: a hostile NPC (gang mob or seeded) opens
381
+ // the Combat Turn itself -- on contact, on a witnessed draw/cast/summon,
382
+ // or when its patience runs out (utilities/hostile-contact.ts; SR5
383
+ // p.34-35, p.49, p.158) -- and a seeded hostile is no longer muted on
384
+ // arrival. During a live encounter the ephemeral governor, open-floor
385
+ // re-seating and doorway yielding are frozen: nobody moves off the
386
+ // player's keystrokes. bluff/persuade wins clear the hostile flag.
387
+ export const ENGINE_VERSION = '1.42.0';
380
388
  //# sourceMappingURL=engine-version.js.map
@@ -31,6 +31,7 @@ import { attachBrandMark, attachBrandMarkBottomRight } from './utilities/brand-m
31
31
  import { logoPanelLines } from './utilities/logo-mark.js';
32
32
  import { bestFakeSin, wandRead, processArrest } from './utilities/sin.js';
33
33
  import { tickEphemerals } from './utilities/ephemeral.js';
34
+ import { hostileContactBeat } from './utilities/hostile-contact.js';
34
35
  import { requestAlarmBeat, newAlarmBeatLatch, ALARM_STIMULUS } from './utilities/alarmed-staff.js';
35
36
  import { renderMapViewport, mapCaption } from './utilities/map-view.js';
36
37
  import { tallestRoomRows } from './utilities/room-grid.js';
@@ -5805,8 +5806,14 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5805
5806
  };
5806
5807
  const command = CommandFactory.createCommand(verb, context);
5807
5808
  if (command) {
5809
+ // THE BEAT CLOCK (Scene.beat) ticks BEFORE the command, so a body
5810
+ // that spawns or a guard that registers you during it carries this
5811
+ // beat's number, and the contact check after it can grant them the
5812
+ // one-beat grace the ephemeral governor already gives a mob.
5813
+ if (this.scene.isHumanControlled(actor))
5814
+ this.scene.beat += 1;
5808
5815
  const result = await command.execute(args);
5809
- const wrapped = this.withEphemeralTurn(actor, this.withAlarmTurn(actor, this.withICTurn(actor, result)));
5816
+ const wrapped = await this.withHostileContact(actor, this.withEphemeralTurn(actor, this.withAlarmTurn(actor, this.withICTurn(actor, result))));
5810
5817
  // The IC turn above may have filled a track -- settle it now, not
5811
5818
  // on the next keystroke.
5812
5819
  const settled = await settleUnresolvedHarm(this.scene, actor);
@@ -5923,6 +5930,23 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5923
5930
  const lines = tickEphemerals(this, actor);
5924
5931
  return lines.length === 0 ? result : `${result}\n\n${lines.join('\n')}`;
5925
5932
  }
5933
+ /**
5934
+ * HOSTILES OPEN COMBAT (utilities/hostile-contact.ts): after every
5935
+ * human command, every hostile in that human's room gets its beat --
5936
+ * contact, a provocation they witnessed, or patience run out opens
5937
+ * the Combat Turn with THEM as aggressor. Runs AFTER the ephemeral
5938
+ * tick so the step a mob just took toward you counts this beat. Any
5939
+ * human, not only the primary: a second runner on the table can be
5940
+ * the one a ganger is standing over.
5941
+ */
5942
+ async withHostileContact(actor, result) {
5943
+ if (result === null)
5944
+ return null;
5945
+ if (!this.scene.isHumanControlled(actor))
5946
+ return result;
5947
+ const lines = await hostileContactBeat(this.scene, actor, this.scene.beat);
5948
+ return lines.length === 0 ? result : `${result}\n\n${lines.join('\n')}`;
5949
+ }
5926
5950
  withICTurn(actor, result) {
5927
5951
  if (result === null)
5928
5952
  return null;
@@ -1,3 +1,4 @@
1
+ import { registerNotice, registerProvocation, PROVOCATION } from '../utilities/hostile-contact.js';
1
2
  import { capitalCase } from 'change-case';
2
3
  import { tableCrewNote, tableDeliveryNote } from '../utilities/table-notes.js';
3
4
  import { Player } from './player.js';
@@ -673,6 +674,15 @@ export class NPC extends Player {
673
674
  * none of the three.
674
675
  */
675
676
  ephemeral;
677
+ /**
678
+ * WHERE THIS HOSTILE IS ON THE LADDER that opens a fight
679
+ * (utilities/hostile-contact.ts, 2026-09-07): the beat they registered
680
+ * the player, the beat they watched a provocation, and why the fight
681
+ * opened once it has. Transient and never saved -- a hostile you left
682
+ * behind starts over when you come back. Written only by that module
683
+ * and by see() below (the witness); read only by that module.
684
+ */
685
+ hostileContact;
676
686
  /**
677
687
  * Every human seated at this scene's table. utilities/table-notes.ts
678
688
  * turns it into what a companion is told; in solo play it is just
@@ -1174,6 +1184,18 @@ description text.`;
1174
1184
  this.logger.logWithColor(`${this.name} clocks you slipping in -- so much for quiet.`, 'yellow');
1175
1185
  }
1176
1186
  }
1187
+ // HOSTILE CONTACT (utilities/hostile-contact.ts): a hostile who
1188
+ // just registered a human -- or watched them draw, cast, or call
1189
+ // a spirit -- is on the ladder that opens the fight. Marks only;
1190
+ // the beat after the command decides. Past the sneak contest on
1191
+ // purpose: a sneaking player who won it never reaches this line.
1192
+ if (this.hostile && !(actor instanceof NPC)) {
1193
+ const beat = this._scene?.beat ?? 0;
1194
+ if (PROVOCATION.test(verb))
1195
+ registerProvocation(this, beat);
1196
+ else
1197
+ registerNotice(this, beat);
1198
+ }
1177
1199
  // A jumped-in arrival is a MACHINE arriving -- the history should
1178
1200
  // say so, or the AI greets a rotor drone like the person's face.
1179
1201
  const form = actor.plane === 'drone'
@@ -1280,7 +1302,13 @@ description text.`;
1280
1302
  blendFailed = true;
1281
1303
  }
1282
1304
  }
1283
- if (spotsActive(this.currentLocation)
1305
+ // A HOSTILE IS NEVER "NOT WARM ENOUGH TO HAIL" (2026-09-07): the
1306
+ // warmth gate is about greetings, and opposition has something
1307
+ // to say to a stranger on its ground -- the challenge that
1308
+ // canon puts before the fight (p.34-35). Muting it here was why
1309
+ // a seeded guard in a spotted room watched you walk in and said
1310
+ // nothing until you drew.
1311
+ if (spotsActive(this.currentLocation) && !this.hostile
1284
1312
  && !(rel && rel.loyalty >= 3) && !knownByRep && !arrivedAtMySpot) {
1285
1313
  this.logger.write(`[DEBUG] ${this.name} quietly notes the arrival -- not warm enough to hail (loyalty ${rel?.loyalty ?? 'none'}).`);
1286
1314
  return;
@@ -144,6 +144,15 @@ export class Scene extends AbstractScene {
144
144
  const enc = this.encounters.get(room);
145
145
  return enc && !enc.ended ? enc : undefined;
146
146
  }
147
+ /**
148
+ * THE BEAT CLOCK: which human command the world is on. Game bumps it
149
+ * before each human command runs, so anything that happens DURING a
150
+ * command (a gang spawning, a guard registering you) carries that
151
+ * command's number, and the hostile-contact beat that runs after it
152
+ * can tell "noticed you this action" from "has been watching you for
153
+ * three". Transient; never saved.
154
+ */
155
+ beat = 0;
147
156
  /** The fight this actor is a participant in, if any. */
148
157
  encounterFor(actor) {
149
158
  const enc = this.encounterIn(actor.currentLocation);
@@ -173,11 +173,19 @@ function walkCells(npc) {
173
173
  * room, and each kind spends it differently.
174
174
  *
175
175
  * WHY THIS HANGS OFF THE COMMAND DISPATCH, which is the same choice
176
- * withICTurn made and for the same reason: an ephemeral is not a combat
177
- * actor, so there is no turn loop to live in, and hooking one verb
178
- * would give a mob permission to close only while you happened to be
179
- * walking. It closes while you look, while you talk, while you check
180
- * your gear. That is what makes it opposition rather than furniture.
176
+ * withICTurn made and for the same reason: OUTSIDE A FIGHT there is no
177
+ * turn loop to live in, and hooking one verb would give a mob
178
+ * permission to close only while you happened to be walking. It closes
179
+ * while you look, while you talk, while you check your gear. That is
180
+ * what makes it opposition rather than furniture.
181
+ *
182
+ * INSIDE A FIGHT THE GOVERNOR IS SILENT (playtest 2026-09-07: "if I
183
+ * move, they move -- not respecting the combat turn, which is mine").
184
+ * Since the Combat Turn (combat-turn.ts, engine 1.40.0) a room with a
185
+ * live encounter IS a turn loop, every hostile in it is a participant,
186
+ * and a participant moves on its own Action Phase through the verbs
187
+ * (npc-combat-brain.ts: "move to <spot>", then the swing) and never on
188
+ * the player's keystrokes. Nothing but the phase actor moves.
181
189
  *
182
190
  * WHAT THIS IS NOT: it is NOT what made the NPC seem to follow the
183
191
  * player. Nothing here existed when that was reported -- an ephemeral
@@ -198,10 +206,17 @@ export function tickEphemerals(host, player) {
198
206
  const room = player.currentLocation;
199
207
  if (!room)
200
208
  return lines;
209
+ // A Combat Turn is running here: initiative owns every body in the
210
+ // room, and the beat clock (mark.ticks) stops with it -- a mob's
211
+ // patience does not run out while the dice are rolling.
212
+ if (host.scene.encounterIn?.(room))
213
+ return lines;
201
214
  for (const [name, mark] of host.ephemerals) {
202
215
  const npc = host.scene.getActor(name);
203
216
  if (!(npc instanceof NPC) || npc.isIncapacitated())
204
217
  continue;
218
+ if (host.scene.encounterFor?.(npc))
219
+ continue;
205
220
  // A BODY IN ANOTHER ROOM IS NOT YOUR PROBLEM AND YOU ARE NOT ITS
206
221
  // BUSINESS. This is the line the report was really about: a mob you
207
222
  // left behind holds the block it was holding, and nothing here can
@@ -0,0 +1,104 @@
1
+ import { actorDistanceMeters, ADJACENT_METERS, wouldNotice } from './spots.js';
2
+ import { Logger } from './logger.js';
3
+ /**
4
+ * Beats after notice before a hostile stops waiting for you to leave.
5
+ * Three player actions is the length of a challenge and an answer;
6
+ * canon gives no number (the GM decides when "fighting breaks out"), so
7
+ * this is a documented ruling, kept short enough that "they were hostile
8
+ * and nothing happened" cannot recur.
9
+ */
10
+ export const PATIENCE_BEATS = 3;
11
+ /** What a hostile watching you do it will not let pass (performAction verbs). */
12
+ export const PROVOCATION = /^(draws\b|casts\b|chants a summoning)/;
13
+ /** The hostiles in `room` who could open on `player` -- structural, no LLM. */
14
+ export function hostilesIn(scene, room, player) {
15
+ const out = [];
16
+ for (const a of room.getActors()) {
17
+ if (a === player || scene.isHumanControlled(a))
18
+ continue;
19
+ const npc = a;
20
+ if (npc.hostile !== true || npc.allyOf || npc.surrendered)
21
+ continue;
22
+ if (npc.isIncapacitated() || !npc.sharesCombatPlane(player))
23
+ continue;
24
+ out.push(npc);
25
+ }
26
+ return out;
27
+ }
28
+ /** A hostile has registered the player (idempotent; the first beat wins). */
29
+ export function registerNotice(npc, beat) {
30
+ if (npc.hostile !== true)
31
+ return;
32
+ npc.hostileContact ??= { noticedBeat: beat };
33
+ }
34
+ /** A hostile watched the player do something it cannot ignore. */
35
+ export function registerProvocation(npc, beat) {
36
+ if (npc.hostile !== true)
37
+ return;
38
+ registerNotice(npc, beat);
39
+ npc.hostileContact.provokedBeat ??= beat;
40
+ }
41
+ /** Talked round, or beaten: opposition stands down. */
42
+ export function standDown(npc) {
43
+ npc.hostile = false;
44
+ npc.hostileContact = undefined;
45
+ }
46
+ function onPhysicalPlane(player) {
47
+ return player.plane === 'meat' || player.plane === 'drone';
48
+ }
49
+ /**
50
+ * One beat of every hostile in the player's room. Runs after every
51
+ * human command (Game.withHostileContact) and once on arrival after the
52
+ * ephemerals have spawned (go.ts describeArrival) -- the reported case
53
+ * is a gang that did not exist when the arrival stimulus fanned out.
54
+ * Returns narration; the encounter announces its own initiative.
55
+ */
56
+ export async function hostileContactBeat(scene, player, beat) {
57
+ const room = player.currentLocation;
58
+ if (!room || !onPhysicalPlane(player) || player.isIncapacitated())
59
+ return [];
60
+ if (scene.encounterIn?.(room))
61
+ return [];
62
+ for (const npc of hostilesIn(scene, room, player)) {
63
+ const c = npc.hostileContact;
64
+ if (!c) {
65
+ // A sneaking player is the arrival contest's call, not ours.
66
+ if (!player.sneaking && wouldNotice(npc, player))
67
+ registerNotice(npc, beat);
68
+ continue;
69
+ }
70
+ // NOBODY OPENS ON THE BEAT THEY NOTICED YOU -- the same grace the
71
+ // ephemeral governor gives a mob its first beat: a gang that squares
72
+ // up and swings in one breath is the ambush street-gang.ts says it
73
+ // is not. A provocation is the exception: you drew on them.
74
+ const provoked = c.provokedBeat !== undefined;
75
+ if (!provoked && beat <= c.noticedBeat)
76
+ continue;
77
+ const inReach = actorDistanceMeters(room, npc, player) <= ADJACENT_METERS;
78
+ const patienceOut = beat - c.noticedBeat >= PATIENCE_BEATS;
79
+ const why = provoked ? 'provocation' : inReach ? 'contact' : patienceOut ? 'patience' : undefined;
80
+ if (!why)
81
+ continue;
82
+ return open(scene, room, npc, player, why);
83
+ }
84
+ return [];
85
+ }
86
+ /** The commit line, then the Combat Turn, with the hostile as aggressor. */
87
+ async function open(scene, room, npc, player, why) {
88
+ npc.hostileContact.openedBy = why;
89
+ // The predicate go.ts's despawn and resolvePartingShot read for "a
90
+ // fight is on": a mob that opened on you stays with it through the
91
+ // doorway; one you walked away from before this beat let you go.
92
+ const now = Date.now();
93
+ npc.lastExchangeAt = now;
94
+ player.lastExchangeAt = now;
95
+ Logger.getInstance().write(`Hostile contact: ${npc.name} opens on ${player.name} in ${room.name} (${why}).`);
96
+ const line = why === 'provocation'
97
+ ? `{yellow-fg}${npc.name} saw that -- and that settles it.{/yellow-fg}`
98
+ : why === 'contact'
99
+ ? `{yellow-fg}${npc.name} is done looking at you.{/yellow-fg}`
100
+ : `{yellow-fg}${npc.name} has waited long enough.{/yellow-fg}`;
101
+ await scene.startEncounter(room, { aggressor: npc, target: player, ambush: false });
102
+ return [line];
103
+ }
104
+ //# sourceMappingURL=hostile-contact.js.map
@@ -367,7 +367,12 @@ function holdsGround(other, mover) {
367
367
  if (other.allyOf === mover.name)
368
368
  return false;
369
369
  const guarding = other.guarding === true;
370
- return (other.inExchange || guarding) && !other.surrendered;
370
+ // Two fight engines, one rule: the old exchange flag OR a place in a
371
+ // running Combat Turn (combat-turn.ts). A ganger in the doorway you
372
+ // back through holds it while the fight is on.
373
+ const fighting = other.inExchange
374
+ || other.combatPhaseLocked?.() === true;
375
+ return (fighting || guarding) && !other.surrendered;
371
376
  }
372
377
  /**
373
378
  * Move a non-hostile body off an exit so the way opens, returning their
@@ -1,3 +1,4 @@
1
+ import { registerNotice } from './hostile-contact.js';
1
2
  import { Logger } from './logger.js';
2
3
  import { isOutdoorsPlace } from './outdoors.js';
3
4
  import { spawnEphemeral, claimEphemeralName } from './ephemeral.js';
@@ -25,11 +26,16 @@ import { AI } from '../../../../tools/ai/ai.class.js';
25
26
  * Splintered State p.11 "law enforcement will not show up at any point
26
27
  * -- this is the Barrens"; Run Faster p.220 on Zone 0).
27
28
  *
28
- * THEY DO NOT ATTACK ON SIGHT. They are spawned `hostile`, which per
29
- * Player.hostile adds a REASON to the brief and nothing else -- the AI
30
- * still chooses, and challenging, shaking down, or sizing you up are
31
- * all likelier openings than shooting. That keeps the governor's
32
- * founding property intact: an ephemeral voices, it does not rule.
29
+ * THEY DO NOT ATTACK ON SIGHT. They are spawned `hostile`, and the
30
+ * spawn line is the challenge canon puts before the fight (p.34-35).
31
+ * WHERE THE CHALLENGE GOES (2026-09-07, playtest "the combat turn never
32
+ * started until I attacked"): utilities/hostile-contact.ts. A ganger
33
+ * closes on the beat clock (ephemeral.ts closeOn); when it reaches you,
34
+ * when it watches you draw, or when its patience runs out, the Combat
35
+ * Turn opens with the ganger as aggressor. Walk away first and it lets
36
+ * you go; talk it round ("bluff", "persuade") and it stands down. The
37
+ * governor's founding property holds: the engine computes the ladder,
38
+ * the ephemeral never decides anything.
33
39
  */
34
40
  /**
35
41
  * PROFESSIONAL RATING 1 GANGER, canon stats (SR5 p.382, "Gangers &
@@ -114,9 +120,12 @@ export function maybeStreetGang(host, actor, room) {
114
120
  });
115
121
  if (!npc)
116
122
  continue;
117
- // OPPOSITION, not an ambush (Player.hostile): this gives them a
118
- // reason, and the AI still chooses what to do with it.
123
+ // OPPOSITION, not an ambush (Player.hostile). A body that peeled
124
+ // off the wall watching you has, by definition, noticed you: it is
125
+ // on the contact ladder from this beat (hostile-contact.ts), with
126
+ // the one-beat grace every mob gets before it moves.
119
127
  npc.hostile = true;
128
+ registerNotice(npc, host.scene.beat ?? 0);
120
129
  spawned.push(npc.name);
121
130
  }
122
131
  if (spawned.length === 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.136.0",
3
+ "version": "5.137.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.",