@maka/maka-cli 5.129.0 → 5.131.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.
Files changed (21) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +9 -0
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +28 -15
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/companions.js +18 -10
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/drone.js +14 -5
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js +6 -0
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js +8 -0
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/jump.js +17 -2
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +9 -3
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/spells.js +12 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +16 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/game.js +43 -12
  14. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +6 -3
  15. package/bundle/typescript/src/commands/game/sideQuest/models/player.js +106 -13
  16. package/bundle/typescript/src/commands/game/sideQuest/utilities/catalog.js +15 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +6 -3
  18. package/bundle/typescript/src/commands/game/sideQuest/utilities/drone-prose.js +289 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/utilities/planes.js +33 -6
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +44 -4
  21. package/package.json +1 -1
@@ -3631,7 +3631,7 @@ export class Player extends AbstractPlayer {
3631
3631
  return;
3632
3632
  this._sustainedArmor = next;
3633
3633
  if (next === 0)
3634
- this.sustainedForces.delete('armor');
3634
+ this.onSustainDropped('armor');
3635
3635
  // SUSTAINS BEAT ON FLIP (web report 2026-09-02: "I cast armor on myself
3636
3636
  // and don't see it in the sustained list"): the sheet only caught up on
3637
3637
  // the next unrelated beat. Every sustain mutator fires the hook now.
@@ -3682,8 +3682,27 @@ export class Player extends AbstractPlayer {
3682
3682
  * spells in `sustainedForces`.
3683
3683
  */
3684
3684
  sustainedWorkings() {
3685
+ const mine = this.wornWorkings().filter(w => w.caster === undefined).map(({ caster: _c, ...w }) => w);
3686
+ const onOthers = [];
3687
+ for (const bearer of this.sustainedBearers) {
3688
+ const held = bearer.wornWorkings().filter(w => w.caster === this);
3689
+ if (held.length === 0) {
3690
+ this.sustainedBearers.delete(bearer);
3691
+ continue;
3692
+ }
3693
+ for (const { caster: _c, ...w } of held)
3694
+ onOthers.push({ ...w, bearer: bearer.name });
3695
+ }
3696
+ return [...mine, ...onOthers];
3697
+ }
3698
+ /**
3699
+ * Every working whose STATE is on this character, with who holds it
3700
+ * up when that is somebody else. The bearer's view; sustainedWorkings
3701
+ * is the holder's.
3702
+ */
3703
+ wornWorkings() {
3685
3704
  const spell = (key, force) => ({
3686
- key, kind: 'spell', force, category: spellFor(key)?.category.toLowerCase(),
3705
+ key, kind: 'spell', force, category: spellFor(key)?.category.toLowerCase(), caster: this.sustainedCasters.get(key),
3687
3706
  });
3688
3707
  return [
3689
3708
  ...this.sustainedForms.map((f) => ({ key: f.key, kind: 'form', force: f.level })),
@@ -3749,15 +3768,22 @@ export class Player extends AbstractPlayer {
3749
3768
  }
3750
3769
  }
3751
3770
  releaseSustainedWorkings() {
3752
- const count = this.sustainedWorkings().length;
3753
- this.sustainedForms = [];
3754
- this._sustainedArmor = 0;
3755
- this.sustainedInvisibility = false;
3756
- this.sustainedReflexes = false;
3757
- this.sustainedBarrier = undefined;
3758
- this.sustainedManaBarrier = undefined;
3759
- this.sustainedForces.clear();
3760
- return count;
3771
+ // What this character HOLDS -- their own, and what they hold on
3772
+ // others (which ends on the bearer). A spell somebody else holds on
3773
+ // this character is that caster's concentration, and sleeping in
3774
+ // it does not break it.
3775
+ const held = this.sustainedWorkings();
3776
+ for (const w of held) {
3777
+ if (w.bearer === undefined) {
3778
+ this.dropSustainedWorking(w.key);
3779
+ continue;
3780
+ }
3781
+ for (const bearer of this.sustainedBearers) {
3782
+ if (bearer.name === w.bearer)
3783
+ bearer.dropSustainedWorking(w.key);
3784
+ }
3785
+ }
3786
+ return held.length;
3761
3787
  }
3762
3788
  // The other sustained spells (RAW gear/spell pass): invisibility bends
3763
3789
  // light around the bearer (+3 on sneak contests, npc.ts), increase
@@ -3770,7 +3796,7 @@ export class Player extends AbstractPlayer {
3770
3796
  return;
3771
3797
  this._sustainedInvisibility = v;
3772
3798
  if (!v)
3773
- this.sustainedForces.delete('invisibility');
3799
+ this.onSustainDropped('invisibility');
3774
3800
  this.noteConditionChanged();
3775
3801
  }
3776
3802
  _sustainedReflexes = false;
@@ -3780,9 +3806,72 @@ export class Player extends AbstractPlayer {
3780
3806
  return;
3781
3807
  this._sustainedReflexes = v;
3782
3808
  if (!v)
3783
- this.sustainedForces.delete('reflexes');
3809
+ this.onSustainDropped('reflexes');
3784
3810
  this.noteConditionChanged();
3785
3811
  }
3812
+ /**
3813
+ * WHO HOLDS A SPELL THAT IS ON THIS CHARACTER, when it is not them
3814
+ * (2026-09-06: "bill the caster, not the bearer"). SR5 p.282 puts the
3815
+ * -2 on whoever SUSTAINS the spell; the subject only wears it. Until
3816
+ * this the engine taxed the bearer -- a mundane street sam with an
3817
+ * ally's armor on them lost two dice off every pool, and the mage
3818
+ * holding it up paid nothing.
3819
+ *
3820
+ * The effect state stays on the bearer (that is what grants the
3821
+ * soak); this map is the other half, and the caster's ledger reads
3822
+ * it through `sustainedBearers`. Session-only, like every sustain.
3823
+ */
3824
+ sustainedCasters = new Map();
3825
+ /** The characters this one holds a spell on. Pruned at read time. */
3826
+ sustainedBearers = new Set();
3827
+ /** Record that `caster` is the one holding `key` up on this character. */
3828
+ sustainedBy(key, caster) {
3829
+ if (caster === this) {
3830
+ this.sustainedCasters.delete(key);
3831
+ return;
3832
+ }
3833
+ this.sustainedCasters.set(key, caster);
3834
+ caster.sustainedBearers.add(this);
3835
+ caster.noteConditionChanged();
3836
+ }
3837
+ /** The caster holding `key` on this character, when it is somebody else. */
3838
+ wornSpellCaster(key) {
3839
+ return this.sustainedCasters.get(key);
3840
+ }
3841
+ /** Bookkeeping when a spell on this character ends, whoever ended it. */
3842
+ onSustainDropped(key) {
3843
+ this.sustainedForces.delete(key);
3844
+ const caster = this.sustainedCasters.get(key);
3845
+ if (caster) {
3846
+ this.sustainedCasters.delete(key);
3847
+ caster.noteConditionChanged();
3848
+ }
3849
+ }
3850
+ /** End one working by key, whatever kind it is. */
3851
+ dropSustainedWorking(key) {
3852
+ switch (key) {
3853
+ case 'armor':
3854
+ this.setSustainedArmor(0);
3855
+ return;
3856
+ case 'invisibility':
3857
+ this.sustainedInvisibility = false;
3858
+ return;
3859
+ case 'reflexes':
3860
+ this.sustainedReflexes = false;
3861
+ return;
3862
+ case 'barrier':
3863
+ this.sustainedBarrier = undefined;
3864
+ return;
3865
+ case 'manabarrier':
3866
+ this.sustainedManaBarrier = undefined;
3867
+ return;
3868
+ default: {
3869
+ const i = this.sustainedForms.findIndex(f => f.key === key);
3870
+ if (i >= 0)
3871
+ this.sustainedForms.splice(i, 1);
3872
+ }
3873
+ }
3874
+ }
3786
3875
  /** The sustained PHYSICAL BARRIER spell (ruling 2026-08-24): seals
3787
3876
  * one exit of one room while held -- go.ts and move refuse passage
3788
3877
  * for everyone (the caster included; walls don't play favorites).
@@ -3793,6 +3882,8 @@ export class Player extends AbstractPlayer {
3793
3882
  if (this._sustainedBarrier === v)
3794
3883
  return;
3795
3884
  this._sustainedBarrier = v;
3885
+ if (!v)
3886
+ this.onSustainDropped('barrier');
3796
3887
  this.noteConditionChanged();
3797
3888
  }
3798
3889
  /** The MANA BARRIER twin (canon p.294, RAG-checked): blocks SPIRITS
@@ -3804,6 +3895,8 @@ export class Player extends AbstractPlayer {
3804
3895
  if (this._sustainedManaBarrier === v)
3805
3896
  return;
3806
3897
  this._sustainedManaBarrier = v;
3898
+ if (!v)
3899
+ this.onSustainDropped('manabarrier');
3807
3900
  this.noteConditionChanged();
3808
3901
  }
3809
3902
  // THREADED COMPLEX FORMS (M4.15, see commands/thread.ts): the
@@ -105,7 +105,18 @@ export function parseCatalogVersion(raw) {
105
105
  const n = Number(raw);
106
106
  return Number.isFinite(n) ? n : undefined;
107
107
  }
108
+ /**
109
+ * Set by resetCatalogForTest and never cleared: a suite that has reset
110
+ * the catalog wants ONLY what it applies. Found 2026-09-06, the day the
111
+ * developer's cache moved to v17 -- every fixture pinned at 16 lost the
112
+ * "highest version wins" contest to a file in the home directory, and
113
+ * eleven accessor tests went red on one machine and green on every
114
+ * other. Exactly the failure the note above says a test must not have.
115
+ */
116
+ let diskCacheDisabled = false;
108
117
  function readCache() {
118
+ if (diskCacheDisabled)
119
+ return undefined;
109
120
  try {
110
121
  const parsed = JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
111
122
  if (Array.isArray(parsed?.items) && parsed.items.length > 0)
@@ -571,6 +582,10 @@ export function resetCatalogForTest() {
571
582
  liveSource = undefined;
572
583
  resolved = undefined;
573
584
  fetchAttempted = false;
585
+ // The machine's cache is out of the contest from here on: a fixture
586
+ // applied after this is the whole catalog, whatever version it says.
587
+ // A test that wants a cache passes its own reader (CacheReader).
588
+ diskCacheDisabled = true;
574
589
  resetCatalogIndexes();
575
590
  }
576
591
  /**
@@ -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,289 @@
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
+ /** Recall / "order <drone> home": the frame comes back to you. */
117
+ export function droneRecallLine(item) {
118
+ const name = item.name;
119
+ const back = pick(item, {
120
+ 'flier-palm': `The ${name} loops back and settles onto your palm, rotors stilling.`,
121
+ flier: `The ${name} banks once and settles back onto your hand, rotors folding.`,
122
+ ground: `The ${name} rolls back to your side and powers down.`,
123
+ walker: `The ${name} picks its way back and folds its legs into your hand.`,
124
+ anthro: `The ${name} walks back and powers down at your side.`,
125
+ missile: `The ${name} comes around and settles onto its rail, turbine spooling down.`,
126
+ });
127
+ const dents = item.droneDamage > 0
128
+ ? ` The frame carries its dents (${item.droneSummary()})${hint(' -- rest repairs it')}.`
129
+ : '';
130
+ return `${back}${dents}`;
131
+ }
132
+ /** Pre-travel: how the frame gets to the cab, or does not need one. */
133
+ export function droneFoldForTravelLine(item) {
134
+ const name = item.name;
135
+ if (droneHandHeld(item)) {
136
+ return pick(item, {
137
+ 'flier-palm': `The ${name} tucks into your pocket for the ride.`,
138
+ flier: `The ${name} autopilots home to your hand for the ride.`,
139
+ ground: `The ${name} rides in the pack.`,
140
+ walker: `The ${name} folds its legs and rides in the pack.`,
141
+ anthro: `The ${name} climbs into the pack for the ride.`,
142
+ missile: `The ${name} rides racked for the trip.`,
143
+ });
144
+ }
145
+ return pick(item, {
146
+ 'flier-palm': `The ${name} flies alongside the cab -- it will meet you at the curb.`,
147
+ flier: `The ${name} flies alongside the cab on its own dog-brain -- it will meet you at the curb.`,
148
+ ground: `The ${name} falls in behind the cab on its own dog-brain -- it will meet you at the curb.`,
149
+ walker: `The ${name} takes its own way there on its own legs -- it will meet you at the curb.`,
150
+ anthro: `The ${name} takes its own way there on its own two legs -- it will meet you at the curb.`,
151
+ missile: `The ${name} shadows the cab from altitude -- it will meet you at the curb.`,
152
+ });
153
+ }
154
+ /** Post-arrival: the frame re-shells at the curb. */
155
+ export function droneUnfoldAtCurbLine(item) {
156
+ const name = item.name;
157
+ if (droneHandHeld(item)) {
158
+ return pick(item, {
159
+ 'flier-palm': `The ${name} whirs up off your palm at the curb.`,
160
+ flier: `The ${name}'s rotors spin back up at the curb.`,
161
+ ground: `The ${name} drops off its sling and idles at the curb.`,
162
+ walker: `The ${name} unfolds its legs at the curb.`,
163
+ anthro: `The ${name} hops down at the curb and stands.`,
164
+ missile: `The ${name} kicks off its rail at the curb.`,
165
+ });
166
+ }
167
+ return pick(item, {
168
+ 'flier-palm': `The ${name} is already hanging at the curb when you step out.`,
169
+ flier: `The ${name} is already hovering at the curb when you step out.`,
170
+ ground: `The ${name} pulls up at the curb behind the cab, motors ticking.`,
171
+ walker: `The ${name} is already waiting at the curb, legs folded under it.`,
172
+ anthro: `The ${name} is already waiting at the curb.`,
173
+ missile: `The ${name} drops out of its circle and picks up station over the curb.`,
174
+ });
175
+ }
176
+ /** The "jumps into" broadcast fragment after the verb. */
177
+ export function droneJumpInAction(item, mode) {
178
+ const wake = pick(item, {
179
+ 'flier-palm': 'the micro-rotors whine up',
180
+ flier: 'the rotors spin up',
181
+ ground: 'the drive motors wake',
182
+ walker: 'the legs take the weight',
183
+ anthro: 'the frame\'s limbs answer theirs',
184
+ missile: 'the turbine spools',
185
+ });
186
+ return `${item.name} -- body going slack as ${wake} (${mode}-sim)`;
187
+ }
188
+ /** The sensor-sight slot in the jump-in line ("...you ARE the X now, <this>, your body slumped..."). */
189
+ export function droneJumpInFragment(item) {
190
+ return pick(item, {
191
+ 'flier-palm': 'micro-rotors whining',
192
+ flier: 'rotors humming',
193
+ ground: 'drive motors humming under you',
194
+ walker: 'legs ticking under you',
195
+ anthro: 'servos whining in your limbs',
196
+ missile: 'turbine screaming',
197
+ });
198
+ }
199
+ /**
200
+ * Jump-out: the frame STAYS where it is on its dog-brain (SR5 p.266,
201
+ * p.268-269: the drone reverts to its Pilot program when the rigger
202
+ * leaves; nothing flies it home). Same room or another, the line says
203
+ * where it was left.
204
+ */
205
+ export function droneJumpOutLine(item, frameRoom, bodyRoom) {
206
+ const holds = pick(item, {
207
+ 'flier-palm': 'holds its hover',
208
+ flier: 'holds its hover',
209
+ ground: 'sits where it stopped, motors idling',
210
+ walker: 'stands where it is',
211
+ anthro: 'stands where it is, holding the pose',
212
+ missile: 'holds its circle overhead',
213
+ });
214
+ const where = frameRoom === bodyRoom ? 'where you left it' : `in ${frameRoom.name}`;
215
+ return `You're back in your body in ${bodyRoom.name}; the ${item.name} ${holds} ${where} -- dog-brain on the stick.${hint(' ("recall" brings it home.)')}`;
216
+ }
217
+ /** The recon sweep ("drone"): nowhere to go, launch, and return. */
218
+ export function droneReconNowhereLine(item) {
219
+ return pick(item, {
220
+ 'flier-palm': `The ${item.name} lifts, circles once -- nowhere to go from here.`,
221
+ flier: `The ${item.name} lifts, circles once -- nowhere to go from here.`,
222
+ ground: `The ${item.name} rolls a slow loop -- nowhere to go from here.`,
223
+ walker: `The ${item.name} picks a circle around your feet -- nowhere to go from here.`,
224
+ anthro: `The ${item.name} looks around -- nowhere to go from here.`,
225
+ missile: `The ${item.name} climbs, circles once -- nowhere to go from here.`,
226
+ });
227
+ }
228
+ export function droneReconLaunchLine(item, rollText, ok) {
229
+ const off = pick(item, {
230
+ 'flier-palm': `The ${item.name} whirs off your palm`,
231
+ flier: `The ${item.name} lifts off your hand`,
232
+ ground: `The ${item.name} rolls off`,
233
+ walker: `The ${item.name} scuttles off`,
234
+ anthro: `The ${item.name} walks off`,
235
+ missile: `The ${item.name} kicks off its rail`,
236
+ });
237
+ if (!ok)
238
+ return `${off} -- Piloting: ${rollText}`;
239
+ const threads = pick(item, {
240
+ 'flier-palm': 'threads the gaps',
241
+ flier: 'threads the gaps',
242
+ ground: 'noses through the doorways',
243
+ walker: 'picks through the gaps',
244
+ anthro: 'goes to look',
245
+ missile: 'makes a fast pass',
246
+ });
247
+ return `${off} and ${threads} -- Piloting: ${rollText}. Feed incoming:`;
248
+ }
249
+ export function droneReconReturnLine(item) {
250
+ return pick(item, {
251
+ 'flier-palm': `The ${item.name} loops home and folds back into your kit.`,
252
+ flier: `The ${item.name} banks home and folds onto your hand.`,
253
+ ground: `The ${item.name} rolls back and powers down at your feet.`,
254
+ walker: `The ${item.name} picks its way back into your hand.`,
255
+ anthro: `The ${item.name} walks back and powers down.`,
256
+ missile: `The ${item.name} comes around and settles onto its rail.`,
257
+ });
258
+ }
259
+ /** Can this frame get past a locked door on the recon sweep? Only the
260
+ * palm-sized fliers slip a vent; everything else is stopped by a door
261
+ * like anyone. */
262
+ export function droneSlipsVents(item) {
263
+ return droneLocomotion(item) === 'flier-palm';
264
+ }
265
+ /** What a wreck of this frame looks like: "bent rotors, dead boards". */
266
+ export function droneWreckFragment(item) {
267
+ return pick(item, {
268
+ 'flier-palm': 'crushed rotors, dead boards',
269
+ flier: 'bent rotors, dead boards',
270
+ ground: 'buckled frame, dead boards',
271
+ walker: 'snapped legs, dead boards',
272
+ anthro: 'dead servos, dead boards',
273
+ missile: 'torn fins, dead boards',
274
+ });
275
+ }
276
+ /** The moment of wrecking, in a fight. */
277
+ export function droneComesApartLine(item, roomName) {
278
+ const bits = pick(item, {
279
+ 'flier-palm': 'rotors, casing, sparks',
280
+ flier: 'rotors, casing, sparks',
281
+ ground: 'plating, wheels, sparks',
282
+ walker: 'legs, casing, sparks',
283
+ anthro: 'limbs, plating, sparks',
284
+ missile: 'fins, casing, fire',
285
+ });
286
+ const where = roomName ? ` skidding across ${roomName}` : '';
287
+ return `${item.name} comes apart -- ${bits}${where}. The frame is wrecked.`;
288
+ }
289
+ //# 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 home.`),
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,18 @@ 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
+ Logger.getInstance().write(`${drone.name} re-shelled on its dog-brain in ${frameRoom.name} after ${actor.name} jumped out.`);
365
+ }
366
+ lines.push(shell && drone
367
+ ? droneJumpOutLine(drone, frameRoom, body)
341
368
  : `You're back in your body in ${body.name}.`);
342
369
  }
343
370
  actor.performAction('jumps out', 'stirring awake as the rig releases');