@maka/maka-cli 5.135.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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/move.js +4 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/persuade.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/stance.js +16 -15
- package/bundle/typescript/src/commands/game/sideQuest/commands/surrender.js +4 -3
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +16 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +36 -18
- package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +29 -1
- package/bundle/typescript/src/commands/game/sideQuest/models/player.js +17 -13
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +14 -6
- package/bundle/typescript/src/commands/game/sideQuest/ui.js +9 -12
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +2 -2
- package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-turn.js +4 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/ephemeral.js +20 -5
- package/bundle/typescript/src/commands/game/sideQuest/utilities/hostile-contact.js +104 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +6 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/street-gang.js +16 -7
- package/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +0 -240
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
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
|
-
|
|
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.]`;
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { Command } from './command.js';
|
|
2
|
-
import { STANCE_CYCLE, STANCE_ALIASES, MATRIX_STANCE_CYCLE, MATRIX_STANCE_ALIASES, MATRIX_STANCE_ACTION } from '../models/player.js';
|
|
3
|
-
import { AutoFight } from '../utilities/auto-fight.js';
|
|
2
|
+
import { STANCE_CYCLE, STANCE_ALIASES, STANCE_DESCRIPTION, MATRIX_STANCE_CYCLE, MATRIX_STANCE_ALIASES, MATRIX_STANCE_ACTION } from '../models/player.js';
|
|
4
3
|
import { fuzzyPickName } from '../utilities/fuzzy-match.js';
|
|
5
4
|
/**
|
|
6
|
-
* The typed twin of the Tab key: set (or inspect) the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* The typed twin of the Tab key: set (or inspect) the stance. On either
|
|
6
|
+
* plane the stance is what your next action DOES -- in the meat, the
|
|
7
|
+
* damage type your blow is meant to deal (lethal / non-lethal, see
|
|
8
|
+
* FightStance in models/player.ts); on the Matrix, which intrusion
|
|
9
|
+
* action a hack is (Brute Force / Hack on the Fly). It never decides
|
|
10
|
+
* whether you act: since the Combat Turn (2026-09-06) every phase is
|
|
11
|
+
* yours to play and "end turn" to commit, and the fight autopilot this
|
|
12
|
+
* verb used to set was retired with it (2026-09-07).
|
|
10
13
|
*/
|
|
11
14
|
export class StanceCommand extends Command {
|
|
12
15
|
static verb = 'stance';
|
|
13
|
-
static description = 'Set your stance. ON THE MATRIX it picks
|
|
16
|
+
static description = 'Set your stance -- what your next action does, never whether you take it. ON THE MATRIX it picks the intrusion action: attack (Brute Force, p.238) or sleaze (Hack on the Fly, p.240); "deck config" still sets the array. IN THE MEAT it picks the damage type you mean: lethal (the gear as sold) or non-lethal (Stun wherever the gear allows -- stun rounds when you reload, the flat of the blade at Accuracy 3 per p.186, bare hands; a lethal gun with lethal rounds warns you it stays lethal). Tab flips it.';
|
|
14
17
|
/**
|
|
15
18
|
* ON THE MATRIX, TAB FLIPS THE INTRUSION ACTION instead of the fight
|
|
16
19
|
* autopilot (mjmcee, uMQAhaAysgaKpWFkn). One verb and one key, two
|
|
@@ -40,11 +43,11 @@ export class StanceCommand extends Command {
|
|
|
40
43
|
if (onMatrix)
|
|
41
44
|
return this.matrixLines(actor);
|
|
42
45
|
const lines = [
|
|
43
|
-
`Stance: ${actor.autoStance.toUpperCase()} -- ${
|
|
46
|
+
`Stance: ${actor.autoStance.toUpperCase()} -- ${STANCE_DESCRIPTION[actor.autoStance]}.`,
|
|
44
47
|
'',
|
|
45
|
-
...STANCE_CYCLE.map(s => ` ${s.padEnd(10)} -- ${
|
|
48
|
+
...STANCE_CYCLE.map(s => ` ${s.padEnd(10)} -- ${STANCE_DESCRIPTION[s]}`),
|
|
46
49
|
'',
|
|
47
|
-
`Set with "stance <name>", or Tab to
|
|
50
|
+
`Set with "stance <name>", or Tab to flip. The stance is what a blow does, not who throws it -- every action is yours to type, and "end turn" commits the phase.`,
|
|
48
51
|
];
|
|
49
52
|
return lines.join('\n');
|
|
50
53
|
}
|
|
@@ -72,10 +75,9 @@ export class StanceCommand extends Command {
|
|
|
72
75
|
const cur = STANCE_CYCLE.indexOf(actor.autoStance);
|
|
73
76
|
const nextStance = STANCE_CYCLE[(cur + 1) % STANCE_CYCLE.length];
|
|
74
77
|
actor.autoStance = nextStance;
|
|
75
|
-
this.game?.autoFight?.cancelPending();
|
|
76
78
|
this.scene.updateStatus();
|
|
77
79
|
this.logger.write(`${actor.name} cycled stance to ${nextStance}.`);
|
|
78
|
-
return `Stance: ${nextStance.toUpperCase()} -- ${
|
|
80
|
+
return `Stance: ${nextStance.toUpperCase()} -- ${STANCE_DESCRIPTION[nextStance]}.`;
|
|
79
81
|
}
|
|
80
82
|
// A Matrix route by name, from either plane's vocabulary -- so
|
|
81
83
|
// "stance sleaze" works while jacked in and reads as a typo
|
|
@@ -100,13 +102,12 @@ export class StanceCommand extends Command {
|
|
|
100
102
|
}
|
|
101
103
|
const stance = picked;
|
|
102
104
|
if (stance === actor.autoStance) {
|
|
103
|
-
return `Already ${stance.toUpperCase()} -- ${
|
|
105
|
+
return `Already ${stance.toUpperCase()} -- ${STANCE_DESCRIPTION[stance]}.`;
|
|
104
106
|
}
|
|
105
107
|
actor.autoStance = stance;
|
|
106
|
-
this.game?.autoFight?.cancelPending();
|
|
107
108
|
this.scene.updateStatus();
|
|
108
109
|
this.logger.write(`${actor.name} set stance to ${stance}.`);
|
|
109
|
-
return `Stance: ${stance.toUpperCase()} -- ${
|
|
110
|
+
return `Stance: ${stance.toUpperCase()} -- ${STANCE_DESCRIPTION[stance]}.`;
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
//# sourceMappingURL=stance.js.map
|
|
@@ -3,9 +3,10 @@ import { billAction } from '../utilities/action-cost.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Throwing your hands up, as a real mechanical act instead of just talk.
|
|
5
5
|
* The wound-aware AI guidance already nudges a losing NPC toward
|
|
6
|
-
* surrender -- this gives that nudge teeth: the flag it sets is what
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* surrender -- this gives that nudge teeth: the flag it sets is what the
|
|
7
|
+
* Combat Turn reads to decide whether anyone is still hostile
|
|
8
|
+
* (CombatEncounter.stillHostile) and what "restrain" holds without a
|
|
9
|
+
* roll (p.195). Surrendering also breaks the fight:
|
|
9
10
|
* both sides' combat memory clears, so walking away afterwards invites no
|
|
10
11
|
* parting shot. The flag is revoked the moment the surrendered actor
|
|
11
12
|
* initiates violence again (CombatExchange.beginExchange).
|
|
@@ -369,5 +369,20 @@
|
|
|
369
369
|
// grapple, struggle, release, cast, summon, dispel, heal and movement
|
|
370
370
|
// are refused outside your Action Phase and spend its Free/Simple/
|
|
371
371
|
// Complex budget. NPC participants act by rule, not by the LLM chain.
|
|
372
|
-
|
|
372
|
+
// 1.41.0 (2026-09-07): THE STANCE IS INTENT, NOT AN AUTOPILOT. The fight
|
|
373
|
+
// autopilot (utilities/auto-fight.ts) is gone: a phase you commit with
|
|
374
|
+
// "end turn" is never played for you, in a hosted run or a local one.
|
|
375
|
+
// "stance" / Tab keep the half the rules read -- lethal or non-lethal,
|
|
376
|
+
// the damage type your next blow means (p.186 flat of the blade, stun
|
|
377
|
+
// rounds on reload). SAVE SHAPE: autoStance is lethal | non-lethal;
|
|
378
|
+
// "manual" loads as lethal.
|
|
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';
|
|
373
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';
|
|
@@ -179,7 +180,6 @@ import { TimeCommand } from './commands/time.js';
|
|
|
179
180
|
// The HUD's clock -- read in the engine so a shared run reads the
|
|
180
181
|
// host's wall clock, never each client's own (item B).
|
|
181
182
|
import { clockTime, worldNow } from './utilities/world-clock.js';
|
|
182
|
-
import { AutoFight } from './utilities/auto-fight.js';
|
|
183
183
|
import { CommandFactory } from './factories/command-factory.js';
|
|
184
184
|
import { SceneSynthesizer } from './factories/scene-factory.js';
|
|
185
185
|
import { SceneSeedGenerator, FIXER_NAME, isFixerName } from './factories/scene-seed-generator.js';
|
|
@@ -195,9 +195,6 @@ export default class Game {
|
|
|
195
195
|
sceneSeed;
|
|
196
196
|
scene;
|
|
197
197
|
player;
|
|
198
|
-
// The fight autopilot (Tab / "stance" command) -- created once, reads
|
|
199
|
-
// live game state at fire time so it survives scene transitions.
|
|
200
|
-
autoFight = new AutoFight(this);
|
|
201
198
|
// Just three surfaces: a full-width log, a HUD strip, and the input box.
|
|
202
199
|
// The old always-on Inventory/Equipment/Map sidebar boxes became the
|
|
203
200
|
// on-demand inv/equipment/map commands (see commands/inv.ts et al.) to
|
|
@@ -3662,7 +3659,6 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3662
3659
|
this.player.inExchange = false;
|
|
3663
3660
|
this.player.combatOpponent = undefined;
|
|
3664
3661
|
this.player.activeCallPartner = undefined;
|
|
3665
|
-
this.autoFight.cancelPending();
|
|
3666
3662
|
}
|
|
3667
3663
|
/** The panel refresh both errand legs need. */
|
|
3668
3664
|
/**
|
|
@@ -3785,7 +3781,6 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
3785
3781
|
this.player.inExchange = false;
|
|
3786
3782
|
this.player.combatOpponent = undefined;
|
|
3787
3783
|
this.player.activeCallPartner = undefined;
|
|
3788
|
-
this.autoFight.cancelPending();
|
|
3789
3784
|
this.scene = hub;
|
|
3790
3785
|
this.scene.addRoom(this._homeRoom);
|
|
3791
3786
|
this.connectHomeRoom(hub.determineStartRoom());
|
|
@@ -4617,7 +4612,6 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
4617
4612
|
if (this._presenceEquipTimer)
|
|
4618
4613
|
clearTimeout(this._presenceEquipTimer);
|
|
4619
4614
|
this.stopPursuitHeartbeat();
|
|
4620
|
-
this.autoFight.cancelPending();
|
|
4621
4615
|
for (const ko of this.scene?.playerKnockouts.values() ?? []) {
|
|
4622
4616
|
if (ko.timer)
|
|
4623
4617
|
clearTimeout(ko.timer);
|
|
@@ -5237,14 +5231,15 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
5237
5231
|
}
|
|
5238
5232
|
statusLinesFor(player) {
|
|
5239
5233
|
const lines = [];
|
|
5240
|
-
// Line 1: the
|
|
5234
|
+
// Line 1: the stance -- lethal or non-lethal in the meat (the damage
|
|
5235
|
+
// type your next blow means; models/player.ts FightStance), Brute
|
|
5236
|
+
// Force or Hack on the Fly on the Matrix. It lives HERE -- the
|
|
5241
5237
|
// shortest HUD line -- because appended to the long readiness line it
|
|
5242
5238
|
// wrapped out of the box on narrower terminals and effectively only
|
|
5243
|
-
// ever showed in the Tab announcement. Always shown
|
|
5244
|
-
//
|
|
5245
|
-
//
|
|
5246
|
-
//
|
|
5247
|
-
// is illegible enough that the stance looked absent entirely.
|
|
5239
|
+
// ever showed in the Tab announcement. Always shown: the HUD is how
|
|
5240
|
+
// players discover Tab flips it at all. Red for the lethal routes,
|
|
5241
|
+
// yellow for the careful ones -- never gray, which is ANSI "bright
|
|
5242
|
+
// black" and illegible on a stock dark palette.
|
|
5248
5243
|
//
|
|
5249
5244
|
// WHERE YOU ARE MOVED OUT (player ruling 2026-08-25): the room name
|
|
5250
5245
|
// now captions the Map box and the "@ spot" captions the Room box,
|
|
@@ -5252,9 +5247,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
5252
5247
|
// what two panels already show. The breadcrumb went with it -- the
|
|
5253
5248
|
// district map draws the way you came far better than a text trail.
|
|
5254
5249
|
// THE STANCE THE HUD SHOWS IS THE ONE IN FORCE (uMQAhaAysgaKpWFkn,
|
|
5255
|
-
// 2026-09-03). Jacked in, the fight
|
|
5256
|
-
//
|
|
5257
|
-
//
|
|
5250
|
+
// 2026-09-03). Jacked in, the fight stance is not what Tab moves
|
|
5251
|
+
// and not what your next action obeys -- the Matrix stance is
|
|
5252
|
+
// (Brute Force or Hack on the Fly). Showing the meat stance to
|
|
5258
5253
|
// a persona would be the same class of bug as the 2026-08-27
|
|
5259
5254
|
// report that the HUD did not follow Tab at all: a readout naming
|
|
5260
5255
|
// a setting the player cannot currently change.
|
|
@@ -5262,7 +5257,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
5262
5257
|
const stanceWord = onMatrix ? MATRIX_STANCE_ACTION[player.matrixStance] : player.autoStance;
|
|
5263
5258
|
const stanceColor = onMatrix
|
|
5264
5259
|
? (player.matrixStance === 'attack' ? 'red' : 'yellow')
|
|
5265
|
-
: player.autoStance === 'lethal' ? 'red' :
|
|
5260
|
+
: player.autoStance === 'lethal' ? 'red' : 'yellow';
|
|
5266
5261
|
// WHAT'S IN YOUR HANDS rides alongside the stance (player ruling
|
|
5267
5262
|
// 2026-08-25). The two answer the same question -- how ready are you
|
|
5268
5263
|
// -- and the stance line was left short and lonely once the location
|
|
@@ -5811,8 +5806,14 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
5811
5806
|
};
|
|
5812
5807
|
const command = CommandFactory.createCommand(verb, context);
|
|
5813
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;
|
|
5814
5815
|
const result = await command.execute(args);
|
|
5815
|
-
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))));
|
|
5816
5817
|
// The IC turn above may have filled a track -- settle it now, not
|
|
5817
5818
|
// on the next keystroke.
|
|
5818
5819
|
const settled = await settleUnresolvedHarm(this.scene, actor);
|
|
@@ -5929,6 +5930,23 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
5929
5930
|
const lines = tickEphemerals(this, actor);
|
|
5930
5931
|
return lines.length === 0 ? result : `${result}\n\n${lines.join('\n')}`;
|
|
5931
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
|
+
}
|
|
5932
5950
|
withICTurn(actor, result) {
|
|
5933
5951
|
if (result === null)
|
|
5934
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
|
-
|
|
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;
|
|
@@ -15,9 +15,17 @@ import { physicalLimit } from '../utilities/grapple.js';
|
|
|
15
15
|
import { astralLimit } from '../utilities/limits.js';
|
|
16
16
|
import { Inventory } from './inventory.js';
|
|
17
17
|
import { AbstractPlayer } from '../types/shared/abstracts.js';
|
|
18
|
-
export const STANCE_CYCLE = ['
|
|
18
|
+
export const STANCE_CYCLE = ['lethal', 'non-lethal'];
|
|
19
19
|
/** The old names, still accepted from saves and the stance command. */
|
|
20
|
-
export const STANCE_ALIASES = {
|
|
20
|
+
export const STANCE_ALIASES = {
|
|
21
|
+
guarded: 'non-lethal', aggressive: 'lethal', manual: 'lethal',
|
|
22
|
+
nonlethal: 'non-lethal', 'non lethal': 'non-lethal', stun: 'non-lethal', kill: 'lethal',
|
|
23
|
+
};
|
|
24
|
+
/** What each stance means, in one line -- the HUD's and the verb's. */
|
|
25
|
+
export const STANCE_DESCRIPTION = {
|
|
26
|
+
lethal: 'your weapons deal the damage they were sold to deal; "attack" and "kill" mean it',
|
|
27
|
+
'non-lethal': 'Stun wherever the gear allows -- stun rounds when you reload, the flat of the blade (p.186), bare hands -- and a lethal gun with lethal rounds says so before it fires',
|
|
28
|
+
};
|
|
21
29
|
export const MATRIX_STANCE_CYCLE = ['attack', 'sleaze'];
|
|
22
30
|
/** What each stance is CALLED in the book -- printed everywhere the
|
|
23
31
|
* engine used to say "loud"/"quiet", so the vocabulary teaches the
|
|
@@ -36,8 +44,8 @@ export function normalizeMatrixStance(v) {
|
|
|
36
44
|
return MATRIX_STANCE_ALIASES[k] ?? (MATRIX_STANCE_CYCLE.includes(k) ? k : 'attack');
|
|
37
45
|
}
|
|
38
46
|
export function normalizeStance(v) {
|
|
39
|
-
const k = (v ?? '
|
|
40
|
-
return STANCE_ALIASES[k] ?? (STANCE_CYCLE.includes(k) ? k : '
|
|
47
|
+
const k = (v ?? 'lethal').toLowerCase();
|
|
48
|
+
return STANCE_ALIASES[k] ?? (STANCE_CYCLE.includes(k) ? k : 'lethal');
|
|
41
49
|
}
|
|
42
50
|
/** Meat and a jumped-in drone share the physical world. */
|
|
43
51
|
export function isPhysicalPlane(plane) {
|
|
@@ -185,16 +193,12 @@ export class Player extends AbstractPlayer {
|
|
|
185
193
|
// session where an NPC's overlapping AI loops fired two exchanges at
|
|
186
194
|
// once and the target died TWICE (two kill events, two narrations).
|
|
187
195
|
inExchange = false;
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// guarded -- auto-fights, but finishes non-lethally (switches to
|
|
193
|
-
// subdue when the target is nearly down) and stands down
|
|
194
|
-
// if they surrender
|
|
195
|
-
// aggressive -- fights until they're dead; surrender means nothing
|
|
196
|
+
// The fight stance (see FightStance above): lethal or non-lethal, the
|
|
197
|
+
// damage type your next blow is meant to deal. Tab cycles it in the UI;
|
|
198
|
+
// "stance" sets it by name. Every action is still yours to type -- the
|
|
199
|
+
// Combat Turn hands you the phase and waits for "end turn".
|
|
196
200
|
// Accessor (live-sheet pass, 2026-09-01): stance is sheet-visible.
|
|
197
|
-
_autoStance = '
|
|
201
|
+
_autoStance = 'lethal';
|
|
198
202
|
get autoStance() { return this._autoStance; }
|
|
199
203
|
set autoStance(v) {
|
|
200
204
|
if (this._autoStance === v)
|
|
@@ -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);
|
|
@@ -192,7 +201,6 @@ export class Scene extends AbstractScene {
|
|
|
192
201
|
}
|
|
193
202
|
const enc = new CombatEncounter(this, this.logger, room);
|
|
194
203
|
this.encounters.set(room, enc);
|
|
195
|
-
enc.onHumanPhase = (p, e) => this.game?.autoFight?.onPhaseBegins?.(p, e);
|
|
196
204
|
await enc.start(this.encounterMembers(room, opts.aggressor, opts.target), opts);
|
|
197
205
|
return enc;
|
|
198
206
|
}
|
|
@@ -232,13 +240,13 @@ export class Scene extends AbstractScene {
|
|
|
232
240
|
return this._playerExchangesActive.size > 0;
|
|
233
241
|
}
|
|
234
242
|
/**
|
|
235
|
-
* CombatExchange.finishExchange()
|
|
236
|
-
* utilities/auto-fight.ts
|
|
237
|
-
*
|
|
238
|
-
*
|
|
243
|
+
* CombatExchange.finishExchange() and CombatEncounter.closePhase() ->
|
|
244
|
+
* here. This used to arm the fight autopilot (utilities/auto-fight.ts,
|
|
245
|
+
* retired 2026-09-07 with the Combat Turn: a phase you commit with
|
|
246
|
+
* "end turn" is not one the game plays for you). What remains is the
|
|
247
|
+
* KO'd player's view of the fight still raging around them.
|
|
239
248
|
*/
|
|
240
249
|
notifyExchangeSettled(combatants) {
|
|
241
|
-
this.game?.autoFight?.onExchangeSettled?.(combatants);
|
|
242
250
|
// While a player lies KO'd, every settling exchange is a beat of
|
|
243
251
|
// the fight still raging around them: learn its hostiles, then see
|
|
244
252
|
// whether the room has settled (see maybeResolvePlayerKnockout).
|
|
@@ -290,9 +290,10 @@ export async function main(playerName, sceneSeed, options) {
|
|
|
290
290
|
game.updateInventory(game.player.inventory, capitalCase(game.scene.getCurrencyType()));
|
|
291
291
|
game.updateExits(game.player.currentLocation);
|
|
292
292
|
game.updateStatus();
|
|
293
|
-
// Tab
|
|
294
|
-
//
|
|
295
|
-
// input box because that's where
|
|
293
|
+
// Tab flips the stance: lethal <-> non-lethal in the meat, Brute
|
|
294
|
+
// Force <-> Hack on the Fly on the Matrix (models/player.ts; "stance"
|
|
295
|
+
// is the typed twin). Bound on the input box because that's where
|
|
296
|
+
// focus lives during play.
|
|
296
297
|
game.inputBox.key(['tab'], () => {
|
|
297
298
|
// Blessed's textarea reader appends the literal '\t' to the box
|
|
298
299
|
// value on this same keypress (listener order isn't guaranteed
|
|
@@ -318,7 +319,7 @@ export async function main(playerName, sceneSeed, options) {
|
|
|
318
319
|
// bar"). game.player here is the LOCAL HUB player -- in a shared
|
|
319
320
|
// run the runner on the table is a different object in a
|
|
320
321
|
// different process, so cycling this one moved nothing the
|
|
321
|
-
//
|
|
322
|
+
// engine reads and nothing the HUD paints (updateStatus
|
|
322
323
|
// early-returns on remoteSession; the status box belongs to the
|
|
323
324
|
// server's events). Send the typed twin instead and let the
|
|
324
325
|
// engine cycle from the ACTOR's real stance.
|
|
@@ -326,10 +327,10 @@ export async function main(playerName, sceneSeed, options) {
|
|
|
326
327
|
const res = await game.handleInput('stance next');
|
|
327
328
|
if (!res)
|
|
328
329
|
return;
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
330
|
+
// Red for the lethal routes, yellow for the careful ones (same
|
|
331
|
+
// palette as the HUD stance in game.ts): Brute Force is the loud
|
|
332
|
+
// one, Hack on the Fly the careful one. Never gray -- ANSI
|
|
333
|
+
// bright-black is illegible on stock dark palettes.
|
|
333
334
|
const named = res.match(/(?:Matrix s|S)tance:\s*([\w-]+)/i)?.[1]?.toLowerCase();
|
|
334
335
|
const c = named === 'lethal' || named === 'brute' ? 'red'
|
|
335
336
|
: named === 'non-lethal' || named === 'non' || named === 'hack' ? 'yellow'
|
|
@@ -369,10 +370,6 @@ export async function main(playerName, sceneSeed, options) {
|
|
|
369
370
|
// still a command you typed and will want to edit and retry.
|
|
370
371
|
if (opts.recordHistory !== false)
|
|
371
372
|
history.remember(input);
|
|
372
|
-
// Typing takes over: any submitted command cancels a pending
|
|
373
|
-
// auto-fight action (the interruptible beat -- see
|
|
374
|
-
// utilities/auto-fight.ts). The next settled exchange re-arms it.
|
|
375
|
-
game.autoFight.cancelPending();
|
|
376
373
|
// Clear the box the moment the command is submitted, not after
|
|
377
374
|
// it resolves -- a slow command (combat narration beats, an AI
|
|
378
375
|
// round trip on a call) used to leave the typed text sitting in
|
|
@@ -995,8 +995,8 @@ export class CombatExchange {
|
|
|
995
995
|
this.releasePlayerLocks();
|
|
996
996
|
this.releaseExchange();
|
|
997
997
|
this.scene.updateStatus();
|
|
998
|
-
//
|
|
999
|
-
//
|
|
998
|
+
// A settled exchange: the scene lets a KO'd player's watch learn who
|
|
999
|
+
// is still fighting around them (Scene.notifyExchangeSettled).
|
|
1000
1000
|
this.scene.notifyExchangeSettled(this._combatants);
|
|
1001
1001
|
// A spirit whose services ran out departs once the dust settles
|
|
1002
1002
|
// (RAW p.302: the bargain is spent).
|
|
@@ -22,7 +22,10 @@ export class CombatEncounter {
|
|
|
22
22
|
pairs = new Map();
|
|
23
23
|
surrenderSeenAt = new Map();
|
|
24
24
|
running = false;
|
|
25
|
-
/** Every human phase that began
|
|
25
|
+
/** Every human phase that began -- a seam for tests and for any future
|
|
26
|
+
* phase clock; nothing plays the phase for a human (the fight
|
|
27
|
+
* autopilot was retired 2026-09-07: a phase you commit with "end
|
|
28
|
+
* turn" is yours to play). */
|
|
26
29
|
onHumanPhase;
|
|
27
30
|
constructor(scene, logger, room) {
|
|
28
31
|
this.scene = scene;
|
|
@@ -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:
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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
|
-
|
|
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`,
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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)
|
|
118
|
-
//
|
|
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.
|
|
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,240 +0,0 @@
|
|
|
1
|
-
import { Logger } from './logger.js';
|
|
2
|
-
/**
|
|
3
|
-
* Fight-or-flight autopilot -- the FIGHT half. The player sets a
|
|
4
|
-
* temperature (Tab in the UI, or the "stance" command) and the game keeps
|
|
5
|
-
* swinging for them; flight stays manual, always, because stopping YOUR
|
|
6
|
-
* swings never stops THEIRS -- the only real defense is the door, and
|
|
7
|
-
* that's the player's call.
|
|
8
|
-
*
|
|
9
|
-
* manual -- counters only. Struck, you strike back (the exchange
|
|
10
|
-
* engine answers every swing in EVERY stance -- see
|
|
11
|
-
* CombatExchange.resolveStrike's two-way resolution);
|
|
12
|
-
* but pressing the fight, chasing, or finishing is yours
|
|
13
|
-
* to type. (A real session read "manual" as fully
|
|
14
|
-
* passive, saw the built-in counters, and concluded the
|
|
15
|
-
* stances were redundant -- they never were; the old
|
|
16
|
-
* descriptions were wrong.)
|
|
17
|
-
* guarded -- keeps swinging FOR you between exchanges, with a
|
|
18
|
-
* conscience: switches to subdue when the target is
|
|
19
|
-
* nearly down, and stands down the moment they surrender
|
|
20
|
-
* aggressive -- keeps swinging until they're dead; surrender means
|
|
21
|
-
* nothing
|
|
22
|
-
*
|
|
23
|
-
* Wiring: CombatExchange.finishExchange() -> Scene.notifyExchangeSettled()
|
|
24
|
-
* -> onExchangeSettled() here. If the player was a combatant and the
|
|
25
|
-
* stance says fight, the next action is scheduled a short, interruptible
|
|
26
|
-
* beat later (REENGAGE_DELAY_MS). Submitting ANY command in the UI cancels
|
|
27
|
-
* the pending action and takes over (see ui.ts) -- the beat exists exactly
|
|
28
|
-
* so fleeing and healing stay possible mid-autofight. Everything is
|
|
29
|
-
* revalidated at fire time: the fight may have ended, moved, or been
|
|
30
|
-
* surrendered out of during the delay.
|
|
31
|
-
*
|
|
32
|
-
* Auto actions lead with whatever hits hardest: a caster whose manabolt
|
|
33
|
-
* out-damages their equipped weapon throws the bolt (and eats the drain);
|
|
34
|
-
* everyone else uses the gun/blade/fists they're holding. A dry gun's
|
|
35
|
-
* refusal (reload prompt) is surfaced to the player and auto simply waits
|
|
36
|
-
* for them to deal with it -- reloading is a decision, not a reflex.
|
|
37
|
-
*/
|
|
38
|
-
export class AutoFight {
|
|
39
|
-
game;
|
|
40
|
-
static REENGAGE_DELAY_MS = 2000;
|
|
41
|
-
// One pending timer per HUMAN player (symmetric MP: each human's
|
|
42
|
-
// autopilot runs independently).
|
|
43
|
-
pending = new Map();
|
|
44
|
-
constructor(game) {
|
|
45
|
-
this.game = game;
|
|
46
|
-
}
|
|
47
|
-
/** Pending auto actions, dropped. No name = all of them (the local
|
|
48
|
-
* player typed, or the stance changed -- ui.ts's call). */
|
|
49
|
-
cancelPending(playerName) {
|
|
50
|
-
if (playerName === undefined) {
|
|
51
|
-
for (const t of this.pending.values())
|
|
52
|
-
clearTimeout(t);
|
|
53
|
-
this.pending.clear();
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
const t = this.pending.get(playerName);
|
|
57
|
-
if (t) {
|
|
58
|
-
clearTimeout(t);
|
|
59
|
-
this.pending.delete(playerName);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Called after every combat exchange resolves (player-involved or not).
|
|
64
|
-
* Schedules the next action for every HUMAN combatant whose stance
|
|
65
|
-
* says fight.
|
|
66
|
-
*/
|
|
67
|
-
onExchangeSettled(combatants) {
|
|
68
|
-
for (const player of this.game.scene.getPlayers()) {
|
|
69
|
-
if (!combatants.includes(player))
|
|
70
|
-
continue;
|
|
71
|
-
if (player.autoStance === 'manual')
|
|
72
|
-
continue;
|
|
73
|
-
// A COMBAT TURN schedules from the phase (onPhaseBegins), never
|
|
74
|
-
// from a settled strike -- acting between phases is the thing the
|
|
75
|
-
// turn structure exists to stop.
|
|
76
|
-
if (this.game.scene.encounterFor?.(player))
|
|
77
|
-
continue;
|
|
78
|
-
const target = combatants.find(c => c !== player);
|
|
79
|
-
if (!target)
|
|
80
|
-
continue;
|
|
81
|
-
this.cancelPending(player.name);
|
|
82
|
-
const t = setTimeout(() => {
|
|
83
|
-
this.pending.delete(player.name);
|
|
84
|
-
void this.fire(player, target);
|
|
85
|
-
}, AutoFight.REENGAGE_DELAY_MS);
|
|
86
|
-
this.pending.set(player.name, t);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
/**
|
|
90
|
-
* A COMBAT TURN handed this human their Action Phase
|
|
91
|
-
* (utilities/combat-turn.ts). At any stance but manual the autopilot
|
|
92
|
-
* plays the phase after the same interruptible beat: draw if
|
|
93
|
-
* holstered, the stance's strike, then "end turn". Typing anything
|
|
94
|
-
* cancels it and takes over, exactly as between exchanges.
|
|
95
|
-
*/
|
|
96
|
-
onPhaseBegins(player, enc) {
|
|
97
|
-
if (player.autoStance === 'manual')
|
|
98
|
-
return;
|
|
99
|
-
this.cancelPending(player.name);
|
|
100
|
-
const t = setTimeout(() => {
|
|
101
|
-
this.pending.delete(player.name);
|
|
102
|
-
void this.playPhase(player, enc);
|
|
103
|
-
}, AutoFight.REENGAGE_DELAY_MS);
|
|
104
|
-
this.pending.set(player.name, t);
|
|
105
|
-
}
|
|
106
|
-
async playPhase(player, enc) {
|
|
107
|
-
const logger = Logger.getInstance();
|
|
108
|
-
if (player.autoStance === 'manual' || !enc.isPhaseOf(player) || player.isIncapacitated())
|
|
109
|
-
return;
|
|
110
|
-
const run = async (cmd) => {
|
|
111
|
-
if (!enc.isPhaseOf(player))
|
|
112
|
-
return;
|
|
113
|
-
logger.meta(`⚔ [${player.autoStance}] ${cmd}`, 'cyan');
|
|
114
|
-
const result = (await this.game.handleInputAs(player.name, cmd)).text;
|
|
115
|
-
if (result)
|
|
116
|
-
logger.log(result);
|
|
117
|
-
};
|
|
118
|
-
const target = enc.enemiesOf(player)[0];
|
|
119
|
-
if (!target) {
|
|
120
|
-
await run('end-turn');
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (player.autoStance === 'non-lethal' && target.surrendered) {
|
|
124
|
-
if (player.plane === 'meat' && target.plane === 'meat'
|
|
125
|
-
&& player.grappling !== target.name && !target.grappledBy) {
|
|
126
|
-
await run(`restrain ${target.name}`);
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
logger.logWithColor(`⚔ ${target.name} has surrendered -- you hold fire. (non-lethal)`, 'yellow');
|
|
130
|
-
}
|
|
131
|
-
await run('end-turn');
|
|
132
|
-
this.game.updateStatus();
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
const weapon = player.getCarriedWeapon();
|
|
136
|
-
if (weapon && !player.weaponDrawn && player.plane === 'meat')
|
|
137
|
-
await run('draw');
|
|
138
|
-
await run(this.chooseAction(player, target));
|
|
139
|
-
await run('end-turn');
|
|
140
|
-
this.game.updateStatus();
|
|
141
|
-
}
|
|
142
|
-
/**
|
|
143
|
-
* The beat elapsed uninterrupted -- act, if the fight still stands.
|
|
144
|
-
* Every guard here re-checks live state: the target may have died, fled,
|
|
145
|
-
* surrendered, or started another exchange during the delay.
|
|
146
|
-
*/
|
|
147
|
-
async fire(player, target) {
|
|
148
|
-
const logger = Logger.getInstance();
|
|
149
|
-
if (player.autoStance === 'manual')
|
|
150
|
-
return;
|
|
151
|
-
if (player.isIncapacitated() || target.isIncapacitated())
|
|
152
|
-
return;
|
|
153
|
-
if (player.currentLocation !== target.currentLocation)
|
|
154
|
-
return;
|
|
155
|
-
// Cross-plane fights don't exist: a dump or a return mid-beat (see
|
|
156
|
-
// utilities/planes.ts) ends the engagement, autopilot included.
|
|
157
|
-
if (player.plane !== target.plane)
|
|
158
|
-
return;
|
|
159
|
-
// A fresh exchange is already in flight -- when IT settles,
|
|
160
|
-
// onExchangeSettled reschedules us. No waiting here.
|
|
161
|
-
if (player.inExchange || target.inExchange)
|
|
162
|
-
return;
|
|
163
|
-
// Guarded honors a surrender -- but doesn't trust it: the final
|
|
164
|
-
// action is a RESTRAIN (player request; SR5e Subduing p.195, the
|
|
165
|
-
// surrendered branch is an automatic no-roll hold). Hands-up
|
|
166
|
-
// prisoners get zip-tied, not taken at their word. One-shot: an
|
|
167
|
-
// already-held target (by anyone) means truly stand down, and off
|
|
168
|
-
// the meat plane there are no hands to hold with.
|
|
169
|
-
if (player.autoStance === 'non-lethal' && target.surrendered) {
|
|
170
|
-
if (player.plane === 'meat' && target.plane === 'meat'
|
|
171
|
-
&& player.grappling !== target.name && !target.grappledBy) {
|
|
172
|
-
const cmd = `restrain ${target.name}`;
|
|
173
|
-
logger.meta(`⚔ [non-lethal] ${cmd}`, 'cyan');
|
|
174
|
-
const result = (await this.game.handleInputAs(player.name, cmd)).text;
|
|
175
|
-
if (result) {
|
|
176
|
-
logger.log(result);
|
|
177
|
-
}
|
|
178
|
-
this.game.updateStatus();
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
|
-
logger.logWithColor(`⚔ ${target.name} has surrendered -- you hold fire. (non-lethal)`, 'yellow');
|
|
182
|
-
this.game.updateStatus();
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
const cmd = this.chooseAction(player, target);
|
|
186
|
-
// Cyan, not gray: the autopilot announcing it acted must be legible on
|
|
187
|
-
// stock dark palettes (gray = ANSI bright-black, near-invisible there).
|
|
188
|
-
// Mechanics ticker, not the narrative -- the strike it triggers tells
|
|
189
|
-
// the story in the world log (readability pass).
|
|
190
|
-
logger.meta(`⚔ [${player.autoStance}] ${cmd}`, 'cyan');
|
|
191
|
-
const result = (await this.game.handleInputAs(player.name, cmd)).text;
|
|
192
|
-
// Player-initiated exchanges self-announce ('' result); anything else
|
|
193
|
-
// (a dry-gun reload prompt, a refusal) is information the player needs.
|
|
194
|
-
if (result) {
|
|
195
|
-
logger.log(result);
|
|
196
|
-
}
|
|
197
|
-
this.game.updateStatus();
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* Lead with what hits hardest -- except Guarded near the end, which
|
|
201
|
-
* finishes non-lethally: when the target is within a few boxes of death,
|
|
202
|
-
* the next auto action is a subdue (stun damage, bare hands) instead of
|
|
203
|
-
* another killing blow.
|
|
204
|
-
*/
|
|
205
|
-
chooseAction(player, target) {
|
|
206
|
-
// Subduing takes real hands (meat plane only); a guarded decker
|
|
207
|
-
// "killing" ice is just crashing a program, and astral entities go
|
|
208
|
-
// down by force -- no non-lethal switch off the meat plane.
|
|
209
|
-
if (player.autoStance === 'non-lethal' && player.plane === 'meat') {
|
|
210
|
-
if (target.maxConditionBoxes - target.damageTaken <= 3)
|
|
211
|
-
return `subdue ${target.name}`;
|
|
212
|
-
// A gun that can only kill is not what this stance reaches for:
|
|
213
|
-
// no stun rounds loaded means hands, not lead (p.434).
|
|
214
|
-
const gun = player.weaponDrawn ? player.getCarriedWeapon() : null;
|
|
215
|
-
if (gun?.isFirearm() && !gun.isStunWeapon() && !gun.stunAmmo())
|
|
216
|
-
return `subdue ${target.name}`;
|
|
217
|
-
}
|
|
218
|
-
// Magic doesn't compile in the Matrix (see cast.ts); everywhere else,
|
|
219
|
-
// lead with the harder hit. The autopilot casts at the mage's FULL
|
|
220
|
-
// safe Force (bolt damage = F - 2, drain billed, never overcast) --
|
|
221
|
-
// a bare "cast manabolt" would use the drain-neutral default (F3,
|
|
222
|
-
// damage 1: the Force pass silently gutted the old comparison).
|
|
223
|
-
// Same safe ceiling as cast.ts: a book-taught mundane works at 4.
|
|
224
|
-
const boltForce = player.magic > 0 ? player.magic : 4;
|
|
225
|
-
const boltDamage = Math.max(1, boltForce - 2);
|
|
226
|
-
if (player.plane !== 'matrix' && player.isCaster() && boltDamage > player.getWeaponProfile().damage) {
|
|
227
|
-
return `cast manabolt ${boltForce} at ${target.name}`;
|
|
228
|
-
}
|
|
229
|
-
return `attack ${target.name}`;
|
|
230
|
-
}
|
|
231
|
-
/** One-line HUD/announce description for a stance. */
|
|
232
|
-
static describe(stance) {
|
|
233
|
-
switch (stance) {
|
|
234
|
-
case 'manual': return 'counters when struck (every stance does) -- but pressing the fight is yours to type';
|
|
235
|
-
case 'non-lethal': return 'presses the fight for you with Stun wherever the gear allows (stun rounds, the flat of the blade, bare hands), subdues when they are nearly down, and RESTRAINS them when they surrender';
|
|
236
|
-
case 'lethal': return 'presses the fight to the death; surrender means nothing';
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
//# sourceMappingURL=auto-fight.js.map
|