@maka/maka-cli 5.130.0 → 5.132.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.130.0",
3
+ "version": "5.132.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.",
@@ -72,6 +72,15 @@ export class CallCommand extends Command {
72
72
  // before NPC resolution ("taxi" is a service, not a person).
73
73
  const spoken = rawWords.map(w => w.toLowerCase()).filter(w => w !== 'a' && w !== 'the');
74
74
  if (this.game?.hasHub && (spoken[0] === 'taxi' || spoken[0] === 'cab')) {
75
+ // A CAB IS THE RUNNER'S CALL (rigger pass 2026-09-06: a deployed
76
+ // drone must never start a run). `call` itself stays open -- NPCs
77
+ // phone people (npc.ts advertises the verb) -- but the taxi branch
78
+ // launches the PLAYER's pending run off this.game, so only a
79
+ // person at the table may hail one. Latent until now because NPC
80
+ // command contexts carry no `game`; a rule now rather than luck.
81
+ if (!this.scene.isHumanControlled(this.actor)) {
82
+ return `The dispatcher's bored voice: "No fare on the books for ${this.actor.name}, chummer." A cab is the runner's call.`;
83
+ }
75
84
  if (this.actor.plane !== 'meat') {
76
85
  return this.actor.plane === 'matrix'
77
86
  ? `Cabs take bodies, not personas -- "jack out" first.`
@@ -5,14 +5,19 @@ import { agentRatingFromName } from '../utilities/programs.js';
5
5
  import { resonanceAction } from '../resonance-actions.js';
6
6
  import { FIREARM_SKILLS } from '../models/player.js';
7
7
  import { ownedDrones, droneListLines } from '../utilities/owned-drones.js';
8
+ import { droneLaunchAction, droneLaunchLine, droneRecallLine, droneStowLine, droneHandHeld, droneWreckFragment } from '../utilities/drone-prose.js';
9
+ import { HolsterCommand } from './brandish.js';
8
10
  /**
9
11
  * The rigger's autonomous frames (SR5e p.269-271): "deploy <drone>" puts
10
12
  * a carried frame in the air under its own DOG-BRAIN -- no control rig
11
- * needed, Pilot (2 + quality) standing in for every attribute it lacks.
12
- * It follows you, watches, and (armed frames only) shoots what threatens
13
- * you; damage lands on the HULL and a wreck is a wreck (rest to repair).
14
- * "recall" folds it back to your hand, and "jump" (jump.ts) seizes the
15
- * stick from the dog-brain wherever the frame flies.
13
+ * needed, Pilot (the row's, else 2 + quality: Item.pilotRating) standing
14
+ * in for every attribute it lacks. It follows you, watches, and (armed
15
+ * frames only) shoots what threatens you; damage lands on the HULL and a
16
+ * wreck is a wreck (rest to repair). "recall" folds it back to your
17
+ * hand, and "jump" (jump.ts) seizes the stick from the dog-brain
18
+ * wherever the frame flies. Every word about the frame moving comes
19
+ * from utilities/drone-prose.ts, classed by how it actually moves: a
20
+ * Steel Lynx rolls, a Fly-Spy whines up off your palm.
16
21
  */
17
22
  export class DeployCommand extends Command {
18
23
  static verb = 'deploy';
@@ -45,24 +50,28 @@ export class DeployCommand extends Command {
45
50
  if (!drone)
46
51
  return [`Which frame? ("deploy <name>")`, ...droneListLines(owned)].join('\n');
47
52
  if (drone.isWrecked)
48
- return `The ${drone.name} is a wreck -- it deploys nowhere until you rest and true the rotors.`;
53
+ return `The ${drone.name} is a wreck -- ${droneWreckFragment(drone)} -- it deploys nowhere until you rest and rebuild it.`;
49
54
  if (game.companions.some(c => c.boundDevice === drone)) {
50
55
  return `The ${drone.name} is already in the air.`;
51
56
  }
52
57
  const shell = game.deployDroneShell(drone, actor.currentLocation);
53
58
  if (!shell)
54
59
  return `The autopilot stutters -- something already answers to that designation here.`;
55
- const fromGarage = owned.find(d => d.item === drone)?.where === 'garage';
56
- actor.performAction('deploys', fromGarage ? `${drone.name} -- rolling out of the garage under its own power` : `${drone.name} -- rotors spinning up to a hover`);
60
+ const from = owned.find(d => d.item === drone)?.where === 'garage' ? 'garage' : 'hand';
61
+ actor.performAction('deploys', `${drone.name} -- ${droneLaunchAction(drone, from)}`);
57
62
  this.logger.write(`DeployCommand: ${actor.name} deployed ${drone.name} on autopilot.`);
58
63
  this.scene.updateStatus();
59
64
  // ANY firearm skill, not one of them. A drone's mount is a gun; after
60
65
  // the split there is no single `firearms` rating to ask for, and
61
66
  // picking one would call a shell with a shotgun rating unarmed.
62
67
  const armed = FIREARM_SKILLS.some(k => shell.skillRating(k) > 0);
68
+ // The Pilot printed is the Pilot the shell RUNS ON (deployDroneShell
69
+ // reads Item.pilotRating, the catalog row's): this line used to
70
+ // inline the price-tier fallback and disagreed with the HUD for
71
+ // every catalog frame.
63
72
  return [
64
- `The ${drone.name} lifts off your hand and settles into a watchful hover -- dog-brain running, Pilot ${2 + drone.qualityBonus}.`,
65
- ` It follows where you walk${armed ? ` and its mount tracks what threatens you` : ` -- cameras only; it will not fight`}. Hull ${drone.droneSummary()}.${hint(` ("order ${drone.name.toLowerCase()} ..." to direct it, "recall" to bring it home, "jump" to take the stick yourself, wherever it flies.)`)}`,
73
+ `${droneLaunchLine(drone, from)} -- dog-brain running, Pilot ${drone.pilotRating}.`,
74
+ ` It follows where you walk${armed ? ` and its mount tracks what threatens you` : ` -- cameras only; it will not fight`}. Hull ${drone.droneSummary()}.${hint(` ("order ${drone.name.toLowerCase()} ..." to direct it, "recall" to bring it to your side, "stow" to pack it away, "jump" to take the stick yourself, wherever it flies.)`)}`,
66
75
  ].join('\n');
67
76
  }
68
77
  }
@@ -129,15 +138,66 @@ export class AgentCommand extends Command {
129
138
  ].join('\n');
130
139
  }
131
140
  }
132
- /** "recall": fold a deployed autonomous drone back to hand, spin a
133
- * remote agent down into its deck, or work CALL/DISMISS SPRITE -- see
134
- * the sprite branch below. */
141
+ function pickDeployedDrone(game, args) {
142
+ if (!game || args.length === 0)
143
+ return undefined;
144
+ const drones = game.companions.filter(c => c.kind === 'drone');
145
+ const names = drones.map(c => c.boundDevice?.name ?? c.npc.name);
146
+ const picked = fuzzyPickName(args.join(' '), names);
147
+ return picked ? drones[names.indexOf(picked)] : undefined;
148
+ }
149
+ /**
150
+ * "stow <drone>": fold a deployed frame back into your pack -- what
151
+ * "recall" used to do before the 2026-09-06 ruling made recall "return
152
+ * to me". Hand-held frames only (drone-prose.ts droneHandHeld): a Steel
153
+ * Lynx is not a pocket job and stays deployed ("store" parks it in the
154
+ * garage at the hideout). The verb was already the holster's ("stow"
155
+ * = holster your weapon) and still is: only a deployed frame the words
156
+ * name -- or a bare "stow" with a frame out and no steel drawn -- goes
157
+ * to the drone; everything else holsters exactly as before.
158
+ */
159
+ export class StowCommand extends Command {
160
+ static verb = 'stow';
161
+ static description = 'Stow a deployed drone back into your pack ("stow <drone>"; frames too big to carry stay out), or holster your drawn weapon.';
162
+ async execute(args = []) {
163
+ const game = this.game;
164
+ const drones = game?.companions.filter(c => c.kind === 'drone') ?? [];
165
+ const named = pickDeployedDrone(game, args);
166
+ const bareForDrone = args.length === 0 && drones.length > 0 && !this.actor.weaponDrawn;
167
+ const entry = named ?? (bareForDrone ? drones[0] : undefined);
168
+ if (!entry) {
169
+ return new HolsterCommand({ actor: this.actor, scene: this.scene, rooms: this.rooms, game: this.game }).execute(args);
170
+ }
171
+ const item = entry.boundDevice;
172
+ const name = item?.name ?? entry.npc.name;
173
+ if (this.actor.plane !== 'meat') {
174
+ return `Stowing is a hands job -- come back to your body.`;
175
+ }
176
+ if (item && !droneHandHeld(item)) {
177
+ return `The ${name} is not a pocket job -- it stays deployed.${game?.homeRoom ? ` At the hideout, "store ${name.toLowerCase()}" parks it in the garage.` : ''} "recall" brings it to your side.`;
178
+ }
179
+ if (entry.npc.currentLocation !== this.actor.currentLocation) {
180
+ return `The ${name} isn't here to stow -- it's in ${entry.npc.currentLocation.name}. "recall" it first.`;
181
+ }
182
+ if (entry.npc.inExchange) {
183
+ return `The ${name} is fighting for its life -- let the exchange settle before folding it.`;
184
+ }
185
+ game.dismissCompanion(entry, 'stowed');
186
+ this.actor.performAction('stows', name);
187
+ this.logger.write(`StowCommand: ${this.actor.name} stowed ${name}.`);
188
+ this.scene.updateStatus();
189
+ return item ? droneStowLine(item) : `The ${name} folds into your pack.`;
190
+ }
191
+ }
192
+ /** "recall": bring a deployed drone to your side (it stays deployed and
193
+ * follows again -- "stow" is the fold), spin a remote agent down into
194
+ * its deck, or work CALL/DISMISS SPRITE -- see the sprite branch below. */
135
195
  export class RecallCommand extends Command {
136
196
  static verb = 'recall';
137
- static description = `Recall your deployed drone to your hand, fold a running agent back into its deck, or call a registered sprite out of the Resonance. "${resonanceAction('call-dismiss-sprite')?.name}" (${resonanceAction('call-dismiss-sprite')?.actionType}, SR5 p.250): "recall <name>" also sends a standing registered sprite back -- which releases every task it still owes you. To park one cheaply instead, "standby" spends a single task (p.256).`;
197
+ static description = `Recall your deployed drone to your side -- it comes to you and follows again ("stow <drone>" folds it into your pack) -- fold a running agent back into its deck, or call a registered sprite out of the Resonance. "${resonanceAction('call-dismiss-sprite')?.name}" (${resonanceAction('call-dismiss-sprite')?.actionType}, SR5 p.250): "recall <name>" also sends a standing registered sprite back -- which releases every task it still owes you. To park one cheaply instead, "standby" spends a single task (p.256).`;
138
198
  async execute(args = []) {
139
199
  const game = this.game;
140
- const entry = game?.companions.find(c => c.kind === 'drone');
200
+ const entry = pickDeployedDrone(game, args) ?? game?.companions.find(c => c.kind === 'drone');
141
201
  if (!entry) {
142
202
  // No drone aloft -- but an agent on the grid answers to the same
143
203
  // word (a real session tried every phrasing but the bare "agent"
@@ -155,11 +215,23 @@ export class RecallCommand extends Command {
155
215
  return sprite;
156
216
  return `Nothing of yours is in the air.`;
157
217
  }
218
+ // RECALL IS "RETURN TO ME" (player ruling 2026-09-06, after a
219
+ // playtest: recall folded the frame into the hand and "it
220
+ // disappeared, which doesn't make sense -- it should just return to
221
+ // me and begin following me again"). The frame comes to your side,
222
+ // stays deployed, and is back on heel; "stow" is the fold.
158
223
  const name = entry.boundDevice?.name ?? entry.npc.name;
159
- game.dismissCompanion(entry, 'recalled');
224
+ if (entry.npc.inExchange) {
225
+ return `The ${name} is fighting for its life -- let the exchange settle before calling it off.`;
226
+ }
227
+ const { moved } = game.heelCompanion(entry);
228
+ if (!moved)
229
+ return `The ${name} is already at your side.`;
230
+ entry.npc.performAction('returns', `to ${this.actor.name}'s side`);
160
231
  this.actor.performAction('recalls', name);
232
+ this.logger.write(`RecallCommand: ${name} recalled to ${this.actor.name}'s side in ${this.actor.currentLocation.name}.`);
161
233
  this.scene.updateStatus();
162
- return `The ${name} banks once and settles back onto your hand, rotors folding.${entry.boundDevice && entry.boundDevice.droneDamage > 0 ? ` The airframe carries its dents (${entry.boundDevice.droneSummary()})${hint(' -- rest repairs it')}.` : ''}`;
234
+ return entry.boundDevice ? droneRecallLine(entry.boundDevice) : `The ${name} comes to your side and follows again.`;
163
235
  }
164
236
  /**
165
237
  * CALL/DISMISS SPRITE (SR5 p.250, Simple Action), on this verb rather
@@ -2,6 +2,7 @@ import { Command } from './command.js';
2
2
  import { Door } from '../models/door.js';
3
3
  import { rollPool, formatRoll } from '../utilities/dice.js';
4
4
  import { ownedDrones } from '../utilities/owned-drones.js';
5
+ import { droneReconLaunchLine, droneReconNowhereLine, droneReconReturnLine, droneSlipsVents } from '../utilities/drone-prose.js';
5
6
  /**
6
7
  * The rigger's mechanic: launch a carried surveillance drone for a live
7
8
  * sweep of every room adjacent to this one -- who's there, what's locked,
@@ -32,7 +33,7 @@ export class DroneCommand extends Command {
32
33
  const room = actor.currentLocation;
33
34
  const exits = Array.from(room.exits.entries());
34
35
  if (exits.length === 0) {
35
- return `The ${drone.name} lifts, circles once -- nowhere to go from here.`;
36
+ return droneReconNowhereLine(drone);
36
37
  }
37
38
  actor.performAction('launches a drone', drone.name);
38
39
  this.logger.write(`${actor.name} launched ${drone.name} from ${room.name}.`);
@@ -43,18 +44,26 @@ export class DroneCommand extends Command {
43
44
  const roll = rollPool(Math.max(1, actor.reaction + (piloting > 0 ? piloting : -1) + actor.bonus('piloting') + actor.woundModifier - actor.sustainingPenalty));
44
45
  if (roll.hits === 0) {
45
46
  return [
46
- `The ${drone.name} whirs off your palm -- Piloting: ${formatRoll(roll)}`,
47
+ droneReconLaunchLine(drone, formatRoll(roll), false),
47
48
  ` It clips a railing, spins, and the feed dissolves into static. It limps home with nothing.`,
48
49
  ].join('\n');
49
50
  }
50
- const lines = [`The ${drone.name} whirs off your palm and threads the gaps -- Piloting: ${formatRoll(roll)}. Feed incoming:`];
51
+ const lines = [droneReconLaunchLine(drone, formatRoll(roll), true)];
51
52
  for (const [direction, exit] of exits) {
52
53
  let target;
53
54
  let lockNote = '';
54
55
  if (exit instanceof Door) {
55
56
  target = exit.getOtherSide(room);
56
- if (exit.checkIfLocked())
57
+ if (exit.checkIfLocked()) {
58
+ // Only a palm-sized flier slips a vent (drone-prose.ts): a
59
+ // locked door stops a Roto-Drone or a Steel Lynx like anyone,
60
+ // and the sweep says so instead of reading through it.
61
+ if (!droneSlipsVents(drone)) {
62
+ lines.push(` • ${direction}: ${target?.name ?? '?'} [door LOCKED -- no way through for a ${drone.name}]`);
63
+ continue;
64
+ }
57
65
  lockNote = ' [door LOCKED -- it slipped a vent]';
66
+ }
58
67
  }
59
68
  else if (typeof exit === 'string') {
60
69
  target = this.rooms?.[exit];
@@ -68,7 +77,7 @@ export class DroneCommand extends Command {
68
77
  const node = target.hasNode ? ' -- a Matrix host node hums here' : '';
69
78
  lines.push(` • ${direction}: ${target.name}${lockNote} -- ${who}${node}`);
70
79
  }
71
- lines.push(`The ${drone.name} loops home and folds back into your kit.`);
80
+ lines.push(droneReconReturnLine(drone));
72
81
  return lines.join('\n');
73
82
  }
74
83
  }
@@ -50,6 +50,23 @@ export class GoCommand extends Command {
50
50
  sprite: (plane) => plane === 'matrix',
51
51
  ally: () => false
52
52
  };
53
+ /**
54
+ * Does this companion follow the master's step? Hired crew never do
55
+ * (ruling 2026-08-25, above); a shell whose plane can't keep up does
56
+ * not; one that is down does not; and a drone HOLDING POSITION -- the
57
+ * frame a rigger jumped out of and left where it was (rigger pass
58
+ * 2026-09-06) -- waits there until "recall" brings it to heel. Pulled
59
+ * out of the loop so the rule is one function a test can ask.
60
+ */
61
+ static heels(c, plane) {
62
+ if (c.kind === 'ally')
63
+ return false;
64
+ if (c.holding)
65
+ return false;
66
+ if (c.npc.isIncapacitated())
67
+ return false;
68
+ return GoCommand.HEELS_ON[c.kind](plane);
69
+ }
53
70
  static verb = 'go';
54
71
  static description = 'Move in the specified direction ("go back" retraces your last move). Fleeing someone you just fought hands them one free parting shot. Jacked in, the grid sees no distance: "go <any room>" jumps your persona straight there ("map" lights the icons). Inside a host there is no distance to cross at all -- "exit" first.';
55
72
  async execute(args) {
@@ -470,10 +487,7 @@ export class GoCommand extends Command {
470
487
  // with their own feet: they hold whatever ground they're on
471
488
  // until they decide to move or you "order" them to, which is
472
489
  // also what makes sending one ahead to scout mean anything.
473
- if (c.kind === 'ally')
474
- continue;
475
- const canFollow = GoCommand.HEELS_ON[c.kind](this.actor.plane);
476
- if (!canFollow || c.npc.currentLocation === dest || c.npc.isIncapacitated())
490
+ if (!GoCommand.heels(c, this.actor.plane) || c.npc.currentLocation === dest)
477
491
  continue;
478
492
  c.npc.currentLocation = dest;
479
493
  c.npc.atSpot = this.actor.atSpot; // at their master's heel = at their spot
@@ -16,6 +16,12 @@ import { meetLocationFor } from '../utilities/meet-location.js';
16
16
  * with AI off it resolves immediately and the click is instant).
17
17
  */
18
18
  class JobOfferCommand extends Command {
19
+ // THE JOB IS THE PLAYER'S TO TAKE OR REFUSE (rigger pass 2026-09-06).
20
+ // Both verbs mutate this.game -- the player's run -- not the actor,
21
+ // so a companion shell ordered to "accept" would lock in the player's
22
+ // job. Declared once here: statics inherit, and Command.isHumanOnly()
23
+ // reads it off the subclass constructor (command-registry.ts).
24
+ static humanOnly = true;
19
25
  /** Bare "accept"/"decline" with NO job on the table but a PARTY
20
26
  * INVITE waiting obviously means the invite (real session typed
21
27
  * "accept" first and got the job-offer refusal) -- route it to the
@@ -3,6 +3,14 @@ import { hint } from '../utilities/hints.js';
3
3
  import { optionMark } from '../utilities/log-style.js';
4
4
  export class JobsCommand extends Command {
5
5
  static verb = 'jobs';
6
+ // THE JOB IS THE PLAYER'S TO TAKE (rigger pass 2026-09-06: "can a
7
+ // deployed drone take a job on its own? I don't think it should").
8
+ // "jobs take" acts on this.game -- the player's run -- not on the
9
+ // actor, so an ordered or AI-driven companion shell running it would
10
+ // put a job on the player's table. Latent until now only because NPC
11
+ // command contexts carry no `game`; now a rule, enforced and logged
12
+ // at command-registry.ts.
13
+ static humanOnly = true;
6
14
  static description = 'The job board: what\'s on offer, CREW vs LOCAL. "jobs take <n>" puts one on the table, "jobs dismiss <n>" scraps it.';
7
15
  async execute(args = []) {
8
16
  const game = this.game;
@@ -3,6 +3,7 @@ import { hint } from '../utilities/hints.js';
3
3
  import { enterDrone, leaveDrone } from '../utilities/planes.js';
4
4
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
5
  import { ownedDrones, droneListLines } from '../utilities/owned-drones.js';
6
+ import { droneWreckFragment } from '../utilities/drone-prose.js';
6
7
  /**
7
8
  * Jumped-in rigging (SR5e p.266): "jump" pours the rigger into a carried
8
9
  * drone -- full VR through an IMPLANTED CONTROL RIG (the book's hard
@@ -15,7 +16,7 @@ import { ownedDrones, droneListLines } from '../utilities/owned-drones.js';
15
16
  */
16
17
  export class JumpCommand extends Command {
17
18
  static verb = 'jump';
18
- static description = 'Jump INTO a drone (control rig required): fly it, see through it, fight with its mount. A deployed frame is seized wherever it flies. "jump hot" for hot-sim; "jump" again returns to your body.';
19
+ static description = 'Jump INTO a drone (control rig required): fly it, see through it, fight with its mount. A deployed frame is seized wherever it flies. "jump hot" for hot-sim; "jump" again returns to your body and leaves the frame out on its dog-brain where you left it ("recall" brings it to your side, "stow" packs it away).';
19
20
  async execute(args = []) {
20
21
  const actor = this.actor;
21
22
  // Already riding: this is the jump OUT.
@@ -58,7 +59,7 @@ export class JumpCommand extends Command {
58
59
  return [`Which airframe? ("jump <name>", add "hot" for hot-sim)`, ...droneListLines(owned)].join('\n');
59
60
  }
60
61
  if (drone.isWrecked) {
61
- return `The ${drone.name} is a wreck -- bent rotors and dead boards jump nowhere. It repairs while you rest somewhere safe.`;
62
+ return `The ${drone.name} is a wreck -- ${droneWreckFragment(drone)} -- and a wreck jumps nowhere. It repairs while you rest somewhere safe.`;
62
63
  }
63
64
  // Jumping into a DEPLOYED frame seizes it WHEREVER it flies (player
64
65
  // request -- jumping in rides the link, not your hands): the
@@ -66,15 +67,25 @@ export class JumpCommand extends Command {
66
67
  // position, not your body's. Only a frame mid-exchange refuses --
67
68
  // combat holds live references to the shell.
68
69
  let seizedRoom;
70
+ let seizedSpot;
71
+ let seizedCell;
69
72
  const flying = this.game?.companions.find(c => c.boundDevice === drone);
70
73
  if (flying) {
71
74
  if (flying.npc.inExchange) {
72
75
  return `The ${drone.name} is fighting for its life -- the link can't seize a bucking frame mid-exchange. Let it settle first.`;
73
76
  }
77
+ // The frame's CELL comes with its room (rigger pass 2026-09-06):
78
+ // seizing used to carry the room and drop the cell, so the rigger
79
+ // snapped into the frame's room at their body's cell -- across the
80
+ // room from where the frame actually hovered.
74
81
  seizedRoom = flying.npc.currentLocation;
82
+ seizedSpot = flying.npc.atSpot;
83
+ seizedCell = flying.npc.atCell;
75
84
  this.game.dismissCompanion(flying, 'jumped into -- dog-brain yields the stick');
76
85
  }
77
86
  this.logger.write(`JumpCommand: ${actor.name} jumping into ${drone.name} (${mode}-sim)${seizedRoom ? ` -- seizing the deployed frame in ${seizedRoom.name}` : ''}.`);
87
+ // enterDrone FIRST: it sets the body's spot and cell aside from the
88
+ // actor's current values, which must still be the body's.
78
89
  const lines = enterDrone(this.scene, actor, drone, mode);
79
90
  if (seizedRoom && seizedRoom !== actor.currentLocation) {
80
91
  actor.currentLocation = seizedRoom;
@@ -83,6 +94,10 @@ export class JumpCommand extends Command {
83
94
  else if (flying) {
84
95
  lines.push(`The dog-brain stands down -- the stick is yours.`);
85
96
  }
97
+ if (flying) {
98
+ actor.atSpot = seizedSpot;
99
+ actor.atCell = seizedCell;
100
+ }
86
101
  this.scene.updateStatus();
87
102
  return lines.join('\n');
88
103
  }
@@ -2,6 +2,7 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { fuzzyPickName, significantWords, normalizeLoose, wordsAreCloseEnough } from '../utilities/fuzzy-match.js';
4
4
  import { AI } from '../../../../tools/ai/ai.class.js';
5
+ import { DRONE_COLOUR, DRONE_ICON, droneRecallLine, droneStowLine, droneHandHeld } from '../utilities/drone-prose.js';
5
6
  /**
6
7
  * What "you have nobody" says, one phrase per COMPANION KIND.
7
8
  *
@@ -93,7 +94,7 @@ export class OrderCommand extends Command {
93
94
  const cmd = commandWords.join(' ');
94
95
  // The feed wears its plane's colors (player request: what the
95
96
  // companion SEES should read as coming through its senses) -- the
96
- // same visual language the HUD already speaks: yellow ✈ for a
97
+ // same visual language the HUD already speaks: amber ✈ for a
97
98
  // drone's sensor feed, magenta ✧ for a spirit's otherworldly report,
98
99
  // cyan ▣ for an agent on the grid.
99
100
  const feed = OrderCommand.FEED[entry.kind] ?? OrderCommand.FEED.ally;
@@ -106,8 +107,20 @@ export class OrderCommand extends Command {
106
107
  // body." (a real session's agent couldn't be brought home by any
107
108
  // natural phrasing: "order agent return", "return to deck"...).
108
109
  // ("return fire" is exempt -- that's a shooting order, not a fold.)
110
+ // A DRONE ORDERED TO COME comes -- and stays deployed, back on heel
111
+ // (player ruling 2026-09-06: recall is "return to me"). "fold" /
112
+ // "stow" is the pack, handled with the other kinds below.
113
+ if (entry.kind === 'drone'
114
+ && /^(return(?!\s+fire)|recall|come(\s+(back|home|here|to\s+me))?|follow(\s+me)?|heel|stand\s+down|go\s+home)\b/i.test(cmd)) {
115
+ const { moved } = game.heelCompanion(entry);
116
+ this.scene.updateStatus();
117
+ const line = !moved
118
+ ? `${entry.npc.name} is already at your heel.`
119
+ : entry.boundDevice ? droneRecallLine(entry.boundDevice) : `${entry.npc.name} comes to your side.`;
120
+ return `{${feed.color}-fg}${feed.icon} ${line}{/${feed.color}-fg}`;
121
+ }
109
122
  if (entry.kind !== 'ally'
110
- && /^(return(?!\s+fire)|recall|come\s+(back|home)|fold|dismiss|stand\s+down|go\s+home)\b/i.test(cmd)) {
123
+ && /^(return(?!\s+fire)|recall|come\s+(back|home)|fold|dismiss|stand\s+down|go\s+home|stow|pack)\b/i.test(cmd)) {
111
124
  // A SPRITE ORDERED HOME IS DISMISSED, NOT DESTROYED, and it is not
112
125
  // an agent. This branch had no sprite case at all: the ternary
113
126
  // fell through to the AGENT wording, so a technomancer who told
@@ -134,10 +147,21 @@ export class OrderCommand extends Command {
134
147
  : `${feed.icon} ${name} unravels into clean static -- an unregistered thread has no Resonance to wait in.`;
135
148
  return `{${feed.color}-fg}${line}{/${feed.color}-fg}`;
136
149
  }
150
+ // A frame you cannot carry has no pack to fold into; one across
151
+ // the district has to be recalled first -- stowing is hands-on.
152
+ if (entry.kind === 'drone' && entry.boundDevice && !droneHandHeld(entry.boundDevice)) {
153
+ return `{${feed.color}-fg}${feed.icon} The ${entry.boundDevice.name} is not a pocket job -- it stays deployed. "recall" brings it to your side.{/${feed.color}-fg}`;
154
+ }
155
+ if (entry.kind === 'drone' && entry.npc.currentLocation !== this.actor.currentLocation) {
156
+ return `{${feed.color}-fg}${feed.icon} The ${entry.boundDevice?.name ?? entry.npc.name} isn't here to stow -- "recall" it first.{/${feed.color}-fg}`;
157
+ }
137
158
  const foldLine = entry.kind === 'spirit'
138
159
  ? `${feed.icon} You speak the release. The spirit inclines its head -- the bargain closes${(entry.services ?? 0) > 0 ? `, ${entry.services} unspent service${entry.services === 1 ? '' : 's'} forfeit` : ''} -- and it thins back to its own plane.`
139
160
  : entry.kind === 'drone'
140
- ? `${feed.icon} The ${entry.boundDevice?.name ?? entry.npc.name} banks once and settles back onto your hand, rotors folding.${entry.boundDevice && entry.boundDevice.droneDamage > 0 ? ` The airframe carries its dents (${entry.boundDevice.droneSummary()})${hint(' -- rest repairs it')}.` : ''}`
161
+ // The same words "stow" speaks (drone-prose.ts), classed by
162
+ // how the frame moves -- this line used to be a verbatim copy
163
+ // of companions.ts's, and both said rotors to a Steel Lynx.
164
+ ? `${feed.icon} ${entry.boundDevice ? droneStowLine(entry.boundDevice) : `The ${entry.npc.name} folds into your pack.`}`
141
165
  : this.actor.plane === 'matrix'
142
166
  ? `${feed.icon} Your agent's icon collapses back into your persona -- folded into the deck.`
143
167
  : `${feed.icon} The deck spins its agent down -- the DECK IN AR light dies as the icon folds home.`;
@@ -211,7 +235,9 @@ export class OrderCommand extends Command {
211
235
  // kind fails the build here.
212
236
  static FEED = {
213
237
  spirit: { color: 'magenta', icon: '✧' },
214
- drone: { color: 'yellow', icon: '✈' },
238
+ // The rigger's amber (drone-prose.ts): the same constant the HUD
239
+ // and the room map paint with.
240
+ drone: { color: DRONE_COLOUR, icon: DRONE_ICON },
215
241
  agent: { color: 'cyan', icon: '▣' },
216
242
  // A sprite is a Matrix construct and reads cyan like the agent.
217
243
  // Without a row of its own it fell through to `ally` and rendered
@@ -321,5 +321,29 @@
321
321
  // blocks and is blocked like anyone, and the rigger's body keeps its own
322
322
  // spot for jump-out. Two tables on different versions disagree about
323
323
  // where a drone stands.
324
- export const ENGINE_VERSION = '1.35.0';
324
+ // 1.36.0 (2026-09-06): JUMP-OUT LEAVES THE FRAME DEPLOYED. COMMAND SEMANTICS
325
+ // in shared scenes: "jump" (out) no longer autopilots the frame home -- it
326
+ // re-shells on its dog-brain where it was (SR5 p.266/p.268-269: the drone
327
+ // reverts to Pilot on jump-out; nothing flies it back), at the frame's
328
+ // cell, whether it was deployed first or jumped from the hand; "recall"
329
+ // brings it home. A WRECKED frame (forced dump) does not re-shell.
330
+ // Jumping into a deployed frame now lands you at the frame's cell, not
331
+ // your body's. The room map draws a jumped-in rigger as an amber ✈ at the
332
+ // frame and the slumped body as 👤; drone feed/HUD lines are amber
333
+ // (#ffbf00). "jobs", "accept", "decline" and "call taxi" refuse a
334
+ // non-human actor. Deploy reports the frame's real Pilot; very-large
335
+ // frames get their cargo cap; a frame you cannot carry follows the cab
336
+ // under its own power; a locked door stops every frame but a palm-flier
337
+ // on the recon sweep. Two tables on different versions disagree about
338
+ // where a frame is after a jump-out.
339
+ // 1.37.0 (2026-09-06): RECALL IS "RETURN TO ME"; STOW PUTS IT AWAY. COMMAND
340
+ // SEMANTICS in shared scenes: "recall <drone>" now brings a deployed frame
341
+ // to your side and back on heel, still deployed (it used to fold the frame
342
+ // into your hand -- playtest: "it disappeared, which doesn't make sense");
343
+ // "stow <drone>" is the fold (hand-held frames only; the holster meaning
344
+ // of "stow" is untouched), and "order <drone> come/return/follow" heels
345
+ // while "order <drone> fold/stow" packs. A frame you jump out of HOLDS
346
+ // its position (Game.companions.holding) instead of teleporting to your
347
+ // heel on your next step; recall clears the hold. HUD shows "holding at".
348
+ export const ENGINE_VERSION = '1.37.0';
325
349
  //# sourceMappingURL=engine-version.js.map
@@ -132,7 +132,7 @@ import { SustainCommand } from './commands/sustain.js';
132
132
  import { AbandonCommand } from './commands/abandon.js';
133
133
  import { UnravelCommand } from './commands/unravel.js';
134
134
  import { OrderCommand } from './commands/order.js';
135
- import { DeployCommand, AgentCommand, RecallCommand } from './commands/companions.js';
135
+ import { DeployCommand, AgentCommand, RecallCommand, StowCommand } from './commands/companions.js';
136
136
  import { DeckCommand, LoadProgramCommand, UnloadProgramCommand } from './commands/deck.js';
137
137
  import { getProgram } from './utilities/programs.js';
138
138
  import { MoveCommand, RunCommand, SprintCommand } from './commands/move.js';
@@ -182,6 +182,8 @@ import { CALL_ICON, CALL_COLOR, END_CALL_SENTINEL } from './utilities/comm-style
182
182
  import { AI } from '../../../tools/ai/ai.class.js';
183
183
  import { BULLET } from './utilities/log-style.js';
184
184
  import { spotOf, resolveSpot } from './utilities/spots.js';
185
+ import { DRONE_COLOUR, DRONE_ICON, droneFoldForTravelLine, droneUnfoldAtCurbLine } from './utilities/drone-prose.js';
186
+ import { bringToHeel } from './utilities/companion-heel.js';
185
187
  export default class Game {
186
188
  initialized;
187
189
  static instance;
@@ -1413,13 +1415,18 @@ export default class Game {
1413
1415
  }
1414
1416
  /**
1415
1417
  * The autonomous-drone shell, shared by the deploy command and
1416
- * arrival re-shelling: the frame's dog-brain (Pilot = 2 + quality)
1417
- * stands in for every attribute it lacks (RAW p.269-271).
1418
+ * arrival re-shelling: the frame's dog-brain (Pilot: the row's, else
1419
+ * 2 + quality -- Item.pilotRating) stands in for every attribute it
1420
+ * lacks (RAW p.269-271).
1418
1421
  */
1419
1422
  // Cargo sling by frame class (player request: capacity enforced -- a
1420
- // palm-sized Fly-Spy is not a mule). Keyed by the frame Item's size.
1423
+ // palm-sized Fly-Spy is not a mule). Keyed by the frame Item's size --
1424
+ // the enum VALUE, and by the enum itself so a size the table forgets
1425
+ // fails the build: this was a string map keyed `huge`, which is not a
1426
+ // Size, while `'very large'` was missing, so every VeryLarge frame
1427
+ // (a 400 kg Kodiak) fell to the 2 kg default -- less than a Medium.
1421
1428
  static DRONE_CARGO_KG = {
1422
- tiny: 0.5, small: 2, medium: 8, large: 20, huge: 40,
1429
+ [Size.Tiny]: 0.5, [Size.Small]: 2, [Size.Medium]: 8, [Size.Large]: 20, [Size.VeryLarge]: 40,
1423
1430
  };
1424
1431
  deployDroneShell(item, room) {
1425
1432
  // The drone's own Pilot, Body and mount (catalog v13 vehicle block; the
@@ -1436,9 +1443,20 @@ export default class Game {
1436
1443
  // Take-command weight checks (Player.addInventory) enforce this cap
1437
1444
  // for the shell exactly as they do for a runner.
1438
1445
  if (shell)
1439
- shell.maxCarryingWeight = Game.DRONE_CARGO_KG[String(item.size)] ?? 2;
1446
+ shell.maxCarryingWeight = Game.DRONE_CARGO_KG[item.size] ?? 2;
1440
1447
  return shell;
1441
1448
  }
1449
+ /**
1450
+ * BRING A COMPANION TO HEEL (player ruling 2026-09-06: "recall" is
1451
+ * "return to me" -- the frame comes to your side and follows again;
1452
+ * putting it away is "stow"). The shell crosses to the master's room
1453
+ * and spot and any HOLDING flag clears, so the next "go" drags it
1454
+ * along like any companion. Returns whether it had anywhere to come
1455
+ * from.
1456
+ */
1457
+ heelCompanion(entry) {
1458
+ return bringToHeel(entry, this.player);
1459
+ }
1442
1460
  /** Drops a companion's shell and its roster entry. */
1443
1461
  dismissCompanion(entry, reason) {
1444
1462
  if (this.scene.getActor(entry.npc.name) === entry.npc) {
@@ -1622,7 +1640,12 @@ export default class Game {
1622
1640
  }
1623
1641
  }
1624
1642
  else if (entry.kind === 'drone') {
1625
- lines.push(`The ${entry.boundDevice?.name ?? 'drone'} autopilots home to your hand for the ride.`);
1643
+ // A frame you can carry rides with you; one you cannot (a 120 kg
1644
+ // Steel Lynx, a 400 kg Kodiak) follows the cab on its own
1645
+ // dog-brain and meets you at the curb (user ruling 2026-09-06;
1646
+ // SR5 p.269 the Pilot drives). Same stash either way -- the
1647
+ // shell re-lands at the arrival room -- only the words differ.
1648
+ lines.push(entry.boundDevice ? droneFoldForTravelLine(entry.boundDevice) : `The drone folds for the ride.`);
1626
1649
  if (entry.boundDevice)
1627
1650
  this._pendingRedeploys.push(entry.boundDevice);
1628
1651
  }
@@ -1644,7 +1667,7 @@ export default class Game {
1644
1667
  continue;
1645
1668
  const shell = this.deployDroneShell(item, this.player.currentLocation);
1646
1669
  if (shell)
1647
- lines.push(`The ${item.name}'s rotors spin back up at the curb.`);
1670
+ lines.push(droneUnfoldAtCurbLine(item));
1648
1671
  }
1649
1672
  // EVERY registered sprite re-shells from the PAN (register.ts).
1650
1673
  // splice(0) drains the stash exactly once, the way the drone loop
@@ -2497,7 +2520,10 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2497
2520
  CommandFactory.registerCommand('brandish', BrandishCommand);
2498
2521
  CommandFactory.registerCommand('draw', BrandishCommand);
2499
2522
  CommandFactory.registerCommand('holster', HolsterCommand);
2500
- CommandFactory.registerCommand('stow', HolsterCommand);
2523
+ // "stow" is the holster's word AND the drone fold's (2026-09-06):
2524
+ // StowCommand takes a deployed frame the words name and hands
2525
+ // everything else to HolsterCommand unchanged.
2526
+ CommandFactory.registerCommand('stow', StowCommand);
2501
2527
  // "end" and "hangup" both bounced in real sessions when players
2502
2528
  // wanted to end a call -- the intent is unambiguous mid-call.
2503
2529
  CommandFactory.registerCommand('end', EndCallCommand);
@@ -3609,6 +3635,10 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3609
3635
  endPersona(this.player);
3610
3636
  this.player.astralTicks = 0;
3611
3637
  this.player.activeDeck = undefined;
3638
+ // A frame ridden into the cab is an Item in the pack again, not a
3639
+ // companion (foldCompanionsForTravel ran first and never saw it), so
3640
+ // unlike planes.leaveDrone this path re-shells nothing: the curb
3641
+ // loop only re-lands what was folded.
3612
3642
  this.player.riggedDrone = undefined;
3613
3643
  this.player.astralPerceiving = false;
3614
3644
  this.player.sneaking = false;
@@ -5405,7 +5435,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5405
5435
  // PILOT rating, agents run at a RATING, and a spirit's Force is
5406
5436
  // already in its name.
5407
5437
  const grade = c.kind === 'drone' ? `Pilot ${c.force} -- ` : c.kind === 'agent' ? `Rating ${c.force} -- ` : c.kind === 'sprite' ? `Level ${c.force} -- ` : '';
5408
- lines.push(`{green-fg}👥 ${c.npc.name} -- ${grade}${track}{/green-fg}`);
5438
+ // A frame left on its dog-brain says where it waits.
5439
+ const hold = c.holding ? ` -- holding at ${c.npc.currentLocation.name}` : '';
5440
+ lines.push(`{green-fg}👥 ${c.npc.name} -- ${grade}${track}${hold}{/green-fg}`);
5409
5441
  }
5410
5442
  // DECK-AR INDICATOR (player request): "Link online" is the
5411
5443
  // COMMLINK's AR overlay; this is the DECK's -- lit when an agent
@@ -5443,7 +5475,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5443
5475
  const drone = player.riggedDrone;
5444
5476
  const hull = drone ? ` Hull ${drone.droneSummary()}` : '';
5445
5477
  const mount = player.droneArmed ? ' ⌖ armed' : ' (recon -- no mounts)';
5446
- lines.push(`{yellow-fg}✈ JUMPED IN: ${drone?.name ?? 'drone'} ${(player.simMode ?? 'cold').toUpperCase()}-SIM${mount}${hull} -- body at ${player.bodyRoom?.name ?? '???'}{/yellow-fg}`);
5478
+ // Amber, the rigger's colour (drone-prose.ts): the same hex the
5479
+ // room map paints the jumped-in icon with.
5480
+ lines.push(`{${DRONE_COLOUR}-fg}${DRONE_ICON} JUMPED IN: ${drone?.name ?? 'drone'} ${(player.simMode ?? 'cold').toUpperCase()}-SIM${mount}${hull} -- body at ${player.bodyRoom?.name ?? '???'}{/${DRONE_COLOUR}-fg}`);
5447
5481
  }
5448
5482
  return lines;
5449
5483
  }
@@ -5953,13 +5987,13 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
5953
5987
  */
5954
5988
  static HELP_CATEGORIES = [
5955
5989
  { title: 'moving', entries: [['look'], ['go'], ['move', 'walk', 'approach'], ['run'], ['sprint'], ['climb', 'mantle', 'scale', 'clamber'], ['descend'], ['sit', 'kneel'], ['lie', 'prone'], ['stand'], ['follow'], ['unfollow'], ['map', 'exits'], ['search'], ['sneak']] },
5956
- { title: 'gear', entries: [['inv', 'inventory'], ['equipment', 'eq'], ['take'], ['loot'], ['drop'], ['give'], ['store'], ['open'], ['close'], ['put'], ['equip'], ['unequip'], ['fit'], ['unfit'], ['brandish', 'draw'], ['holster', 'stow'], ['reload'], ['use'], ['read']] },
5990
+ { title: 'gear', entries: [['inv', 'inventory'], ['equipment', 'eq'], ['take'], ['loot'], ['drop'], ['give'], ['store'], ['open'], ['close'], ['put'], ['equip'], ['unequip'], ['fit'], ['unfit'], ['brandish', 'draw'], ['holster'], ['reload'], ['use'], ['read']] },
5957
5991
  { title: 'combat', entries: [['attack'], ['kill'], ['subdue'], ['grapple', 'restrain', 'clinch'], ['struggle'], ['release'], ['cover'], ['stance'], ['surrender'], ['edge'], ['rest'], ['heal', 'firstaid', 'bandage', 'patch']] },
5958
5992
  // The party layer: everyone who walks (or flies, or manifests) at
5959
5993
  // your side answers to these.
5960
5994
  // "players" folded back under crew after playtesting ("crew
5961
5995
  // players" / "crew street"); the bare verb still answers quietly.
5962
- { title: 'party', entries: [['crew', 'party'], ['hire'], ['dismiss'], ['train'], ['order'], ['lead'], ['command'], ['deploy'], ['recall']] },
5996
+ { title: 'party', entries: [['crew', 'party'], ['hire'], ['dismiss'], ['train'], ['order'], ['lead'], ['command'], ['deploy'], ['recall'], ['stow']] },
5963
5997
  { title: 'magic', entries: [['spells'], ['cast'], ['summon', 'conjure'], ['project', 'astral'], ['return'], ['assense']] },
5964
5998
  { title: 'matrix', entries: [['jack'], ['jackpoint'], ['deck'], ['load'], ['unload'], ['hack'], ['mark'], ['download'], ['enter'], ['exit-host'], ['hop'], ['tap', 'splice'], ['snoop'], ['overwatch', 'os'], ['pan'], ['ar'], ['aros'], ['silent'], ['reboot'], ['agent'], ['drone'], ['jump', 'rig']] },
5965
5999
  // The Emerged get their own shelf (player request: "there MUST be
@@ -10,6 +10,7 @@ import { spotsActive, spotOf, describeSpotRoster, spotDistanceMeters, spotCoverB
10
10
  import { classifyWeapon, rangePenalty, rangeBracketName } from '../utilities/range.js';
11
11
  import { priceOf } from '../utilities/commerce.js';
12
12
  import { catalogGear, ROLE_DOMAINS } from '../utilities/catalog.js';
13
+ import { DRONE_COLOUR, DRONE_ICON } from '../utilities/drone-prose.js';
13
14
  /**
14
15
  * BARRIERS AT YOUR POST (a real session: a beaten sentry announced he
15
16
  * was "unlocking" a spell-sealed hatch he could never open -- his AI had
@@ -703,7 +704,9 @@ export class NPC extends Player {
703
704
  // HUD and order.ts speak.
704
705
  static LINK_STYLE = {
705
706
  spirit: { color: 'magenta', icon: '✧' },
706
- drone: { color: 'yellow', icon: '✈' },
707
+ // The rigger's amber (drone-prose.ts): one constant for the feed,
708
+ // the HUD and the room map, so "amber" means one thing everywhere.
709
+ drone: { color: DRONE_COLOUR, icon: DRONE_ICON },
707
710
  agent: { color: 'cyan', icon: '▣' },
708
711
  // A sprite is a Matrix construct and relays cyan like the agent.
709
712
  // With no arm of its own it fell through to `ally` and a sprite
@@ -3,6 +3,7 @@ import { crashIC } from './ic-actors.js';
3
3
  import { samePlace } from './matrix-roster.js';
4
4
  import { NPC } from '../models/npc.js';
5
5
  import { rollPool, formatRoll, rollInitiativeScore, formatInitiative, INITIATIVE_PASS_DROP } from './dice.js';
6
+ import { droneComesApartLine } from './drone-prose.js';
6
7
  import { leaveMatrix, leaveDrone } from './planes.js';
7
8
  import { spotOf, spotCell, seatingIn, actorCell, OPEN_FLOOR, isInCover, coverAvailableFor, takeCoverHere } from './spots.js';
8
9
  import { heldBy } from './grapple.js';
@@ -619,7 +620,7 @@ export class CombatExchange {
619
620
  lines.push(` ${defender.name} takes ${dealt} box${dealt === 1 ? '' : 'es'} to the airframe: ${device.droneSummary()}`);
620
621
  if (device.isWrecked) {
621
622
  defender.takeDamage(defender.maxConditionBoxes);
622
- lines.push(` The frame comes apart -- rotors, casing, sparks.`);
623
+ lines.push(` ${droneComesApartLine(device)}`);
623
624
  }
624
625
  return { world: [...lines, ...dryWarning], meta };
625
626
  }
@@ -660,7 +661,7 @@ export class CombatExchange {
660
661
  }
661
662
  }
662
663
  if (drone.isWrecked) {
663
- lines.push(` ${drone.name} comes apart mid-air -- rotors, casing, sparks --`);
664
+ lines.push(` ${droneComesApartLine(drone)}`);
664
665
  lines.push(...leaveDrone(this.scene, defender, { forced: true, reason: `The airframe dies around you.` }));
665
666
  }
666
667
  return { world: [...lines, ...dryWarning], meta };
@@ -830,7 +831,9 @@ export class CombatExchange {
830
831
  : companionKind === 'agent'
831
832
  ? `${fallen.name} DEREZZES -- its icon shears into static and rains out of the grid, gone with the deck that ran it.`
832
833
  : companionKind === 'drone'
833
- ? `${fallen.name} comes apart -- rotors, plating, and sparks skidding across ${room.name}. The frame is wrecked.`
834
+ ? (fallen instanceof NPC && fallen.boundDevice
835
+ ? droneComesApartLine(fallen.boundDevice, room.name)
836
+ : `${fallen.name} comes apart -- plating and sparks skidding across ${room.name}. The frame is wrecked.`)
834
837
  : (opts?.fatal ?? true)
835
838
  ? `${fallen.name} goes down -- dead before they hit the floor. The body lies where it fell.`
836
839
  : `${fallen.name} collapses, out cold -- down for the count, but still breathing.`;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * BRING A COMPANION TO HEEL (player ruling 2026-09-06: "recall" is
3
+ * "return to me" -- the frame comes to your side and follows again;
4
+ * putting it away is "stow"). The shell crosses to the master's room
5
+ * and spot, its cell is released so seating finds it a place beside
6
+ * them, and any HOLDING flag clears so the next "go" drags it along
7
+ * like any companion. Returns whether it had anywhere to come from.
8
+ *
9
+ * One function, used by Game.heelCompanion and by the tests that pin
10
+ * recall, so a mock Game and the real one cannot disagree about it.
11
+ */
12
+ export function bringToHeel(entry, master) {
13
+ const here = master.currentLocation;
14
+ const moved = entry.npc.currentLocation !== here || entry.npc.atSpot !== master.atSpot || !!entry.holding;
15
+ if (entry.npc.currentLocation !== here)
16
+ entry.npc.currentLocation = here;
17
+ entry.npc.atSpot = master.atSpot;
18
+ entry.npc.atCell = undefined;
19
+ entry.holding = false;
20
+ return { moved };
21
+ }
22
+ //# sourceMappingURL=companion-heel.js.map
@@ -0,0 +1,310 @@
1
+ import { Size } from '../types/shared/item-enum.js';
2
+ import { hint } from './hints.js';
3
+ /**
4
+ * ONE VOICE FOR EVERY FRAME (rigger pass, 2026-09-06). Every drone verb
5
+ * used to speak rotorcraft: a 120 kg wheeled Steel Lynx "lifts off your
6
+ * hand and settles into a watchful hover", a 400 kg Kodiak "autopilots
7
+ * home to your hand for the ride" and "folds back into your kit". The
8
+ * catalog knows better -- every drone row carries `shape`, `size`,
9
+ * `weight` and a vehicle block -- so the words come from here, classed
10
+ * by how the frame actually moves, and every verb that says anything
11
+ * about a frame moving (deploy, recall, order home, travel fold and
12
+ * curb, jump in, jump out, the recon sweep, a wreck) says it through
13
+ * one of these. The same shape as comm-style.ts: icon, colour, and the
14
+ * wording, in one place.
15
+ *
16
+ * AMBER IS THE RIGGER'S COLOUR (player ruling 2026-09-06: "when jumped
17
+ * into the drone, I'd like the player icon to change to amber to match
18
+ * the color scheme"). The drone rows of the HUD and the order feed used
19
+ * plain yellow; this is the first named colour in the game tree, a hex
20
+ * blessed accepts in a tag ({#ffbf00-fg}, the same way logo-mark.ts
21
+ * paints the mark), so "amber" means one thing on the map, the HUD and
22
+ * the feed. ✈ is the drone icon the feed already spoke; it is a BMP
23
+ * dingbat, so unlike 👤 it actually takes a foreground colour.
24
+ */
25
+ export const DRONE_COLOUR = '#ffbf00';
26
+ export const DRONE_ICON = '✈';
27
+ export function droneLocomotion(item) {
28
+ const shape = (item.row?.shape ?? item.shape ?? '').toLowerCase();
29
+ if (shape === 'insect' || shape === 'disc')
30
+ return 'flier-palm';
31
+ if (shape === 'rotary' || shape === 'vtol' || shape === 'vstol')
32
+ return 'flier';
33
+ if (shape === 'missile')
34
+ return 'missile';
35
+ if (shape === 'walker')
36
+ return 'walker';
37
+ if (shape === 'anthroform' || item.vehicle?.anthro)
38
+ return 'anthro';
39
+ if (shape === 'wheeled' || shape === 'tracked' || shape === 'hull')
40
+ return 'ground';
41
+ if (item.size === Size.Tiny)
42
+ return 'flier-palm';
43
+ if (item.size === Size.Small && item.weight <= 5)
44
+ return 'flier';
45
+ return 'ground';
46
+ }
47
+ /**
48
+ * Whether the frame rides in your hands (or pocket, or pack) when you
49
+ * travel. Everything Large or bigger answers no, whatever its shape;
50
+ * fliers, walkers and the palm-sized answer yes; an anthroform or a
51
+ * missile only when it is Tiny/Small; a ground frame never. The rest
52
+ * follow a cab under their own dog-brain (SR5 p.269: the Pilot program
53
+ * drives; Rigger 5.0 p.174: the autopilot obeys traffic law) and meet
54
+ * you at the curb -- which is what foldCompanionsForTravel always did,
55
+ * it just used to claim a Kodiak fit in your hand.
56
+ */
57
+ export function droneHandHeld(item) {
58
+ if (item.size === Size.Large || item.size === Size.VeryLarge)
59
+ return false;
60
+ const loco = droneLocomotion(item);
61
+ if (loco === 'flier-palm' || loco === 'flier' || loco === 'walker')
62
+ return true;
63
+ if (loco === 'ground')
64
+ return false;
65
+ return item.size === Size.Tiny || item.size === Size.Small;
66
+ }
67
+ const big = (item) => item.size === Size.Large || item.size === Size.VeryLarge;
68
+ const pick = (item, lines) => lines[droneLocomotion(item)];
69
+ /** The third-person fragment after "<name> --" in the deploy broadcast. */
70
+ export function droneLaunchAction(item, from) {
71
+ if (from === 'garage') {
72
+ return pick(item, {
73
+ 'flier-palm': 'whirring up off the bench and out of the bay',
74
+ flier: 'lifting off the bench and out of the bay',
75
+ ground: 'rolling out of the garage under its own power',
76
+ walker: 'walking out of the bay on its own legs',
77
+ anthro: 'stepping out of the bay',
78
+ missile: 'kicking off its rack and circling the bay',
79
+ });
80
+ }
81
+ return pick(item, {
82
+ 'flier-palm': 'micro-rotors whining up off their palm',
83
+ flier: 'rotors spinning up to a hover',
84
+ ground: 'rolling off under its own power',
85
+ walker: 'unfolding its legs',
86
+ anthro: 'standing up and squaring its shoulders',
87
+ missile: 'kicking off its rail',
88
+ });
89
+ }
90
+ /** The deploy line itself, second person; the caller adds the Pilot. */
91
+ export function droneLaunchLine(item, from) {
92
+ const name = item.name;
93
+ if (from === 'garage') {
94
+ return pick(item, {
95
+ 'flier-palm': `The ${name} whirs up off the bench and out of the bay to hang at eye level`,
96
+ flier: `The ${name} lifts off the bench and out of the bay into a watchful hover`,
97
+ ground: `The ${name} wakes in the bay and rolls out under its own power, idling beside you`,
98
+ walker: `The ${name} walks out of the bay on its own legs and settles at your feet`,
99
+ anthro: `The ${name} steps out of the bay, squares its shoulders, and waits`,
100
+ missile: `The ${name} kicks off its rack and holds a tight circle over the bay`,
101
+ });
102
+ }
103
+ return pick(item, {
104
+ 'flier-palm': `The ${name} whines up off your palm and hangs at eye level`,
105
+ flier: `The ${name} lifts off your hand and settles into a watchful hover`,
106
+ ground: big(item)
107
+ ? `The ${name} wakes where it sits and rolls forward under its own power, idling beside you`
108
+ : `You set the ${name} down; it wakes and idles at your feet, motors ticking`,
109
+ walker: `The ${name} unfolds its legs off your hand and picks its way to the floor`,
110
+ anthro: big(item) || item.size === Size.Medium
111
+ ? `The ${name} stands up, squares its shoulders, and waits`
112
+ : `The ${name} climbs down off your pack and stands`,
113
+ missile: `The ${name} kicks off its rail and holds a tight circle overhead`,
114
+ });
115
+ }
116
+ /**
117
+ * Recall / "order <drone> come": the frame comes to your side and is
118
+ * back at heel, still deployed (player ruling 2026-09-06: "recall is
119
+ * just return to me"; putting it away is "stow" -- droneStowLine).
120
+ */
121
+ export function droneRecallLine(item) {
122
+ const name = item.name;
123
+ const back = pick(item, {
124
+ 'flier-palm': `The ${name} loops back and hangs at your shoulder, rotors whining.`,
125
+ flier: `The ${name} banks once and settles into a hover at your shoulder.`,
126
+ ground: `The ${name} rolls back to your side and idles, motors ticking.`,
127
+ walker: `The ${name} picks its way back and settles at your feet.`,
128
+ anthro: `The ${name} walks back and falls in at your side.`,
129
+ missile: `The ${name} comes around and picks up station overhead.`,
130
+ });
131
+ const dents = item.droneDamage > 0
132
+ ? ` The frame carries its dents (${item.droneSummary()})${hint(' -- rest repairs it')}.`
133
+ : '';
134
+ return `${back} It follows where you walk again.${dents}`;
135
+ }
136
+ /** Stow: the frame folds back into your pack -- hand-held frames only
137
+ * (droneHandHeld); a frame you cannot carry stays deployed. */
138
+ export function droneStowLine(item) {
139
+ const name = item.name;
140
+ const away = pick(item, {
141
+ 'flier-palm': `The ${name} settles onto your palm, rotors stilling, and goes back in your pocket.`,
142
+ flier: `The ${name} settles back onto your hand, rotors folding, and goes into the pack.`,
143
+ ground: `The ${name} powers down and you sling it.`,
144
+ walker: `The ${name} folds its legs into your hand and goes into the pack.`,
145
+ anthro: `The ${name} climbs into the pack and goes still.`,
146
+ missile: `The ${name} settles onto its rail, turbine spooling down.`,
147
+ });
148
+ const dents = item.droneDamage > 0
149
+ ? ` The frame carries its dents (${item.droneSummary()})${hint(' -- rest repairs it')}.`
150
+ : '';
151
+ return `${away}${dents}`;
152
+ }
153
+ /** Pre-travel: how the frame gets to the cab, or does not need one. */
154
+ export function droneFoldForTravelLine(item) {
155
+ const name = item.name;
156
+ if (droneHandHeld(item)) {
157
+ return pick(item, {
158
+ 'flier-palm': `The ${name} tucks into your pocket for the ride.`,
159
+ flier: `The ${name} autopilots home to your hand for the ride.`,
160
+ ground: `The ${name} rides in the pack.`,
161
+ walker: `The ${name} folds its legs and rides in the pack.`,
162
+ anthro: `The ${name} climbs into the pack for the ride.`,
163
+ missile: `The ${name} rides racked for the trip.`,
164
+ });
165
+ }
166
+ return pick(item, {
167
+ 'flier-palm': `The ${name} flies alongside the cab -- it will meet you at the curb.`,
168
+ flier: `The ${name} flies alongside the cab on its own dog-brain -- it will meet you at the curb.`,
169
+ ground: `The ${name} falls in behind the cab on its own dog-brain -- it will meet you at the curb.`,
170
+ walker: `The ${name} takes its own way there on its own legs -- it will meet you at the curb.`,
171
+ anthro: `The ${name} takes its own way there on its own two legs -- it will meet you at the curb.`,
172
+ missile: `The ${name} shadows the cab from altitude -- it will meet you at the curb.`,
173
+ });
174
+ }
175
+ /** Post-arrival: the frame re-shells at the curb. */
176
+ export function droneUnfoldAtCurbLine(item) {
177
+ const name = item.name;
178
+ if (droneHandHeld(item)) {
179
+ return pick(item, {
180
+ 'flier-palm': `The ${name} whirs up off your palm at the curb.`,
181
+ flier: `The ${name}'s rotors spin back up at the curb.`,
182
+ ground: `The ${name} drops off its sling and idles at the curb.`,
183
+ walker: `The ${name} unfolds its legs at the curb.`,
184
+ anthro: `The ${name} hops down at the curb and stands.`,
185
+ missile: `The ${name} kicks off its rail at the curb.`,
186
+ });
187
+ }
188
+ return pick(item, {
189
+ 'flier-palm': `The ${name} is already hanging at the curb when you step out.`,
190
+ flier: `The ${name} is already hovering at the curb when you step out.`,
191
+ ground: `The ${name} pulls up at the curb behind the cab, motors ticking.`,
192
+ walker: `The ${name} is already waiting at the curb, legs folded under it.`,
193
+ anthro: `The ${name} is already waiting at the curb.`,
194
+ missile: `The ${name} drops out of its circle and picks up station over the curb.`,
195
+ });
196
+ }
197
+ /** The "jumps into" broadcast fragment after the verb. */
198
+ export function droneJumpInAction(item, mode) {
199
+ const wake = pick(item, {
200
+ 'flier-palm': 'the micro-rotors whine up',
201
+ flier: 'the rotors spin up',
202
+ ground: 'the drive motors wake',
203
+ walker: 'the legs take the weight',
204
+ anthro: 'the frame\'s limbs answer theirs',
205
+ missile: 'the turbine spools',
206
+ });
207
+ return `${item.name} -- body going slack as ${wake} (${mode}-sim)`;
208
+ }
209
+ /** The sensor-sight slot in the jump-in line ("...you ARE the X now, <this>, your body slumped..."). */
210
+ export function droneJumpInFragment(item) {
211
+ return pick(item, {
212
+ 'flier-palm': 'micro-rotors whining',
213
+ flier: 'rotors humming',
214
+ ground: 'drive motors humming under you',
215
+ walker: 'legs ticking under you',
216
+ anthro: 'servos whining in your limbs',
217
+ missile: 'turbine screaming',
218
+ });
219
+ }
220
+ /**
221
+ * Jump-out: the frame STAYS where it is on its dog-brain (SR5 p.266,
222
+ * p.268-269: the drone reverts to its Pilot program when the rigger
223
+ * leaves; nothing flies it home). Same room or another, the line says
224
+ * where it was left.
225
+ */
226
+ export function droneJumpOutLine(item, frameRoom, bodyRoom) {
227
+ const holds = pick(item, {
228
+ 'flier-palm': 'holds its hover',
229
+ flier: 'holds its hover',
230
+ ground: 'sits where it stopped, motors idling',
231
+ walker: 'stands where it is',
232
+ anthro: 'stands where it is, holding the pose',
233
+ missile: 'holds its circle overhead',
234
+ });
235
+ const where = frameRoom === bodyRoom ? 'where you left it' : `in ${frameRoom.name}`;
236
+ return `You're back in your body in ${bodyRoom.name}; the ${item.name} ${holds} ${where} -- dog-brain on the stick, holding until you "recall" it.${hint(' ("recall" brings it to your side; "stow" packs it away.)')}`;
237
+ }
238
+ /** The recon sweep ("drone"): nowhere to go, launch, and return. */
239
+ export function droneReconNowhereLine(item) {
240
+ return pick(item, {
241
+ 'flier-palm': `The ${item.name} lifts, circles once -- nowhere to go from here.`,
242
+ flier: `The ${item.name} lifts, circles once -- nowhere to go from here.`,
243
+ ground: `The ${item.name} rolls a slow loop -- nowhere to go from here.`,
244
+ walker: `The ${item.name} picks a circle around your feet -- nowhere to go from here.`,
245
+ anthro: `The ${item.name} looks around -- nowhere to go from here.`,
246
+ missile: `The ${item.name} climbs, circles once -- nowhere to go from here.`,
247
+ });
248
+ }
249
+ export function droneReconLaunchLine(item, rollText, ok) {
250
+ const off = pick(item, {
251
+ 'flier-palm': `The ${item.name} whirs off your palm`,
252
+ flier: `The ${item.name} lifts off your hand`,
253
+ ground: `The ${item.name} rolls off`,
254
+ walker: `The ${item.name} scuttles off`,
255
+ anthro: `The ${item.name} walks off`,
256
+ missile: `The ${item.name} kicks off its rail`,
257
+ });
258
+ if (!ok)
259
+ return `${off} -- Piloting: ${rollText}`;
260
+ const threads = pick(item, {
261
+ 'flier-palm': 'threads the gaps',
262
+ flier: 'threads the gaps',
263
+ ground: 'noses through the doorways',
264
+ walker: 'picks through the gaps',
265
+ anthro: 'goes to look',
266
+ missile: 'makes a fast pass',
267
+ });
268
+ return `${off} and ${threads} -- Piloting: ${rollText}. Feed incoming:`;
269
+ }
270
+ export function droneReconReturnLine(item) {
271
+ return pick(item, {
272
+ 'flier-palm': `The ${item.name} loops home and folds back into your kit.`,
273
+ flier: `The ${item.name} banks home and folds onto your hand.`,
274
+ ground: `The ${item.name} rolls back and powers down at your feet.`,
275
+ walker: `The ${item.name} picks its way back into your hand.`,
276
+ anthro: `The ${item.name} walks back and powers down.`,
277
+ missile: `The ${item.name} comes around and settles onto its rail.`,
278
+ });
279
+ }
280
+ /** Can this frame get past a locked door on the recon sweep? Only the
281
+ * palm-sized fliers slip a vent; everything else is stopped by a door
282
+ * like anyone. */
283
+ export function droneSlipsVents(item) {
284
+ return droneLocomotion(item) === 'flier-palm';
285
+ }
286
+ /** What a wreck of this frame looks like: "bent rotors, dead boards". */
287
+ export function droneWreckFragment(item) {
288
+ return pick(item, {
289
+ 'flier-palm': 'crushed rotors, dead boards',
290
+ flier: 'bent rotors, dead boards',
291
+ ground: 'buckled frame, dead boards',
292
+ walker: 'snapped legs, dead boards',
293
+ anthro: 'dead servos, dead boards',
294
+ missile: 'torn fins, dead boards',
295
+ });
296
+ }
297
+ /** The moment of wrecking, in a fight. */
298
+ export function droneComesApartLine(item, roomName) {
299
+ const bits = pick(item, {
300
+ 'flier-palm': 'rotors, casing, sparks',
301
+ flier: 'rotors, casing, sparks',
302
+ ground: 'plating, wheels, sparks',
303
+ walker: 'legs, casing, sparks',
304
+ anthro: 'limbs, plating, sparks',
305
+ missile: 'fins, casing, fire',
306
+ });
307
+ const where = roomName ? ` skidding across ${roomName}` : '';
308
+ return `${item.name} comes apart -- ${bits}${where}. The frame is wrecked.`;
309
+ }
310
+ //# sourceMappingURL=drone-prose.js.map
@@ -7,6 +7,7 @@ import { fuzzyPickName } from './fuzzy-match.js';
7
7
  import { resetOverwatch } from './overwatch.js';
8
8
  import { rebootWipe } from './marks.js';
9
9
  import { hint } from './hints.js';
10
+ import { droneJumpInAction, droneJumpInFragment, droneJumpOutLine, droneWreckFragment } from './drone-prose.js';
10
11
  /**
11
12
  * Plane transitions and their tolls -- the one place that moves an actor
12
13
  * between meat, Matrix, and astral (see Player.plane for the model).
@@ -288,7 +289,7 @@ export function enterDrone(scene, actor, drone, mode) {
288
289
  actor.simMode = mode;
289
290
  actor.riggedDrone = drone;
290
291
  actor.sneaking = false;
291
- actor.performAction('jumps into', `${drone.name} -- body going slack as the rotors spin up (${mode}-sim)`);
292
+ actor.performAction('jumps into', droneJumpInAction(drone, mode));
292
293
  scene.addWorldEvent(`${actor.name} jumped into ${drone.name} in ${actor.bodyRoom.name}.`);
293
294
  Logger.getInstance().write(`${actor.name} jumped into ${drone.name} (${mode}-sim, hull ${drone.droneSummary()}) from ${actor.bodyRoom.name}.`);
294
295
  const hullNote = drone.droneDamage > 0 ? ` The airframe is already scarred: ${drone.droneSummary()}.` : '';
@@ -296,8 +297,8 @@ export function enterDrone(scene, actor, drone, mode) {
296
297
  ? ` Hot-sim: the machine answers like your own skin -- and every hit it takes bleeds PHYSICAL into yours.`
297
298
  : ` Cold-sim: hits on the hull sting through the link as stun, half strength.`;
298
299
  return [
299
- `The rig takes hold and the world snaps to sensor-sight -- you ARE the ${drone.name} now, rotors humming, your body slumped and empty in ${actor.bodyRoom.name}.${hotWarning}${hullNote}`,
300
- hint(`Move with "go" (you fly the meat world; locked doors still stop an airframe), "look" through the sensors, "jump" again to drop back into your body.`),
300
+ `The rig takes hold and the world snaps to sensor-sight -- you ARE the ${drone.name} now, ${droneJumpInFragment(drone)}, your body slumped and empty in ${actor.bodyRoom.name}.${hotWarning}${hullNote}`,
301
+ hint(`Move with "go" (you fly the meat world; locked doors still stop an airframe), "look" through the sensors, "jump" again to drop back into your body -- the frame stays out on its dog-brain where you leave it; "recall" brings it to your side, "stow" packs it away.`),
301
302
  ].filter(l => l.length > 0);
302
303
  }
303
304
  /**
@@ -308,11 +309,27 @@ export function enterDrone(scene, actor, drone, mode) {
308
309
  * Willpower-only resist -- the same softening the Matrix side had, minus
309
310
  * the Firewall term, plus the only correct half either copy had (the
310
311
  * p.229 disorientation, which the Matrix side was missing entirely).
312
+ *
313
+ * THE FRAME STAYS WHERE IT IS (user ruling 2026-09-06, and canon: SR5
314
+ * p.266 / p.268-269 -- when the rigger leaves, the drone reverts to its
315
+ * Pilot program at the next Combat Turn; nothing flies it home). This
316
+ * used to say the frame "autopilots home to your hand", which was both
317
+ * a deviation and a lie to a rigger who deployed a frame, seized it two
318
+ * rooms away and let go: the frame vanished. Now it re-shells on its
319
+ * dog-brain in the frame's room, at the frame's cell, whether you
320
+ * deployed it first or jumped straight from your hand, and "recall"
321
+ * brings it home. A WRECKED frame (the forced dump) never re-shells.
322
+ * Ordered AFTER the body is put back so seatingIn sees the rigger on
323
+ * bodyCell before the shell claims frameCell.
311
324
  */
312
325
  export function leaveDrone(scene, actor, opts) {
313
326
  const lines = [];
314
327
  const body = actor.bodyRoom;
315
328
  const drone = actor.riggedDrone;
329
+ // Where the FRAME is, captured before the body restore overwrites it.
330
+ const frameRoom = actor.currentLocation;
331
+ const frameSpot = actor.atSpot;
332
+ const frameCell = actor.atCell;
316
333
  if (opts?.forced) {
317
334
  lines.push(...applyDumpshock(actor, opts.reason ?? `The link dies with the airframe.`));
318
335
  }
@@ -320,7 +337,7 @@ export function leaveDrone(scene, actor, opts) {
320
337
  lines.push(`You let go of the machine and drop back down the link.`);
321
338
  }
322
339
  if (drone?.isWrecked) {
323
- lines.push(`The ${drone.name} is WRECKED -- bent rotors, dead boards. It repairs while you rest somewhere safe.`);
340
+ lines.push(`The ${drone.name} is WRECKED -- ${droneWreckFragment(drone)}. It repairs while you rest somewhere safe.`);
324
341
  }
325
342
  actor.plane = 'meat';
326
343
  actor.simMode = undefined;
@@ -336,8 +353,24 @@ export function leaveDrone(scene, actor, opts) {
336
353
  actor.atCell = actor.bodyCell;
337
354
  actor.bodySpot = undefined;
338
355
  actor.bodyCell = undefined;
339
- lines.push(drone && !drone.isWrecked
340
- ? `You're back in your body in ${body.name}; the ${drone.name} autopilots home to your hand.`
356
+ // The dog-brain takes the stick where the frame is (see above).
357
+ const game = scene.ownerGame;
358
+ const shell = drone && !drone.isWrecked && game && frameRoom
359
+ ? game.deployDroneShell(drone, frameRoom)
360
+ : null;
361
+ if (shell) {
362
+ shell.atSpot = frameSpot;
363
+ shell.atCell = frameCell;
364
+ // It HOLDS there (Game.companions.holding): a frame you left in
365
+ // the next room must not teleport to your heel on your next step
366
+ // -- "recall" fetches it.
367
+ const entry = game.companions.find(c => c.npc === shell);
368
+ if (entry)
369
+ entry.holding = true;
370
+ Logger.getInstance().write(`${drone.name} re-shelled on its dog-brain in ${frameRoom.name} after ${actor.name} jumped out.`);
371
+ }
372
+ lines.push(shell && drone
373
+ ? droneJumpOutLine(drone, frameRoom, body)
341
374
  : `You're back in your body in ${body.name}.`);
342
375
  }
343
376
  actor.performAction('jumps out', 'stirring awake as the rig releases');
@@ -2,6 +2,7 @@ import { capitalCase } from 'change-case';
2
2
  import { Direction } from '../types/shared/direction-enum.js';
3
3
  import { OPEN_FLOOR, spotOf, visibleActorsIn, exitSpot, seatingIn, theSpot } from './spots.js';
4
4
  import { placeName } from './log-style.js';
5
+ import { DRONE_COLOUR, DRONE_ICON } from './drone-prose.js';
5
6
  import { key, levelFootprint, dimsForSize, tallestRoomRows, METERS_PER_LEVEL, } from './room-grid.js';
6
7
  /**
7
8
  * THE ROOM LAYOUT (spatial-viz follow-up, 2026-08-25): an ASCII top-down
@@ -83,6 +84,24 @@ const HOSTILE_GLYPH = '⚔';
83
84
  * A star reads as "yours" without pretending to be a person you can
84
85
  * hand the controls to. */
85
86
  const CREW_GLYPH = '★';
87
+ /**
88
+ * YOU, JUMPED INTO A DRONE (player ruling 2026-09-06: "when jumped into
89
+ * the drone, I'd like the player icon to change to amber to match the
90
+ * color scheme. Also, the player's neutral color icon (meat body)
91
+ * should remain").
92
+ *
93
+ * While rigged, the viewer's presence is the FRAME, and it drew as the
94
+ * same uncoloured 👤 as their body would -- and the body, slumped in
95
+ * bodyRoom, did not draw at all (seatingIn seats an actor once, at the
96
+ * frame). So a rigger looking at a room holding both saw one figure and
97
+ * could not say which. Now the frame is the drone icon the feed and the
98
+ * HUD already speak (✈, drone-prose.ts), in the rigger's amber -- a
99
+ * BMP dingbat, so unlike the emoji it really takes the colour -- and
100
+ * the body keeps the plain 👤 at the cell it was left in. Shape AND
101
+ * colour differ, as this panel's rulings require. An autopilot frame
102
+ * (a companion shell) is still the crew's ★: it is yours, not you.
103
+ */
104
+ const RIGGED_GLYPH = DRONE_ICON;
86
105
  /** A CORPSE -- a body left where it fell, lootable (Room.bodies). */
87
106
  const DEAD_GLYPH = '💀';
88
107
  /**
@@ -203,7 +222,13 @@ function buildRenderMaps(grid, room, viewer) {
203
222
  const corpses = new Map();
204
223
  for (const b of room.bodies)
205
224
  corpses.set(key(b.cell), b.name);
206
- return { cellToSpot, cellToExit, verticalAt, cellTerrain, occupants, corpses };
225
+ // The rigger's body, where it slumped (see RIGGED_GLYPH): seatingIn
226
+ // seats the viewer once, at the FRAME, so the body has to be placed
227
+ // from the cell enterDrone set aside.
228
+ const slumpedBody = viewer.plane !== 'meat' && viewer.bodyRoom === room && viewer.bodyCell
229
+ ? key(viewer.bodyCell)
230
+ : undefined;
231
+ return { cellToSpot, cellToExit, verticalAt, cellTerrain, occupants, corpses, slumpedBody };
207
232
  }
208
233
  function paint(g) {
209
234
  const body = g.inverse ? `{inverse}${g.ch}{/inverse}` : g.ch;
@@ -258,8 +283,13 @@ function actorGlyph(a, viewer, isOtherPlayer, isTableAlly) {
258
283
  return { ch: DOWNED_GLYPH, color: 'yellow' };
259
284
  // YOU keep the emoji; a fellow runner is the one who changes. Both
260
285
  // branches existed already and returned the identical icon.
261
- if (a === viewer)
262
- return { ch: PLAYER_GLYPH, wide: true };
286
+ // ...unless you are riding a frame: then this cell is the DRONE, in
287
+ // the rigger's amber, and the emoji is your body (see RIGGED_GLYPH).
288
+ if (a === viewer) {
289
+ return viewer.plane === 'drone'
290
+ ? { ch: RIGGED_GLYPH, color: DRONE_COLOUR }
291
+ : { ch: PLAYER_GLYPH, wide: true };
292
+ }
263
293
  if (isOtherPlayer?.(a))
264
294
  return { ch: OTHER_PLAYER_GLYPH, color: OTHER_PLAYER_COLOR };
265
295
  // Your own people read as yours even mid-fight -- knowing which of
@@ -311,7 +341,10 @@ function glyphAt(c, maps, viewer, isOtherPlayer, isTableAlly) {
311
341
  ?? glyphs.find(g => g.ch === NEUTRAL_GLYPH)
312
342
  ?? glyphs[0];
313
343
  }
314
- // Nobody standing here -- but something may be lying here.
344
+ // Nobody standing here -- but something may be lying here: your own
345
+ // slumped body first (it is what you are looking for), then the dead.
346
+ if (maps.slumpedBody === k)
347
+ return { ch: PLAYER_GLYPH, wide: true };
315
348
  if (maps.corpses.has(k))
316
349
  return { ch: DEAD_GLYPH, wide: true };
317
350
  const terrain = maps.cellTerrain.get(k);
@@ -465,6 +498,13 @@ export function renderRoomLayout(room, viewer, isOtherPlayer, isTableAlly) {
465
498
  present.has(PLAYER_GLYPH)
466
499
  ? `${PLAYER_GLYPH} you (${NARROW_SUBSTITUTE[PLAYER_GLYPH]} in the side panel, where cells are one column)`
467
500
  : '',
501
+ // Jumped in: the amber frame is you, the plain figure is your body.
502
+ present.has(RIGGED_GLYPH)
503
+ ? `{${DRONE_COLOUR}-fg}${RIGGED_GLYPH}{/${DRONE_COLOUR}-fg} you, jumped in (${viewer.riggedDrone?.name ?? 'drone'})`
504
+ : '',
505
+ maps.slumpedBody !== undefined
506
+ ? `${PLAYER_GLYPH} your body, slumped (${NARROW_SUBSTITUTE[PLAYER_GLYPH]} in the side panel)`
507
+ : '',
468
508
  present.has(OTHER_PLAYER_GLYPH) ? `{${OTHER_PLAYER_COLOR}-fg}${OTHER_PLAYER_GLYPH}{/${OTHER_PLAYER_COLOR}-fg} runner` : '',
469
509
  present.has(CREW_GLYPH) ? `{green-fg}${CREW_GLYPH}{/green-fg} yours` : '',
470
510
  present.has(NEUTRAL_GLYPH) ? `{white-fg}${NEUTRAL_GLYPH}{/white-fg} local` : '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.130.0",
3
+ "version": "5.132.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.",