@maka/maka-cli 5.378.0 → 5.379.1

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 (27) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +16 -3
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +271 -131
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +26 -31
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +24 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +14 -0
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +12 -0
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +21 -128
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/transfer.js +92 -0
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/upload.js +108 -0
  11. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +19 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/factories/job-order.js +28 -4
  13. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +125 -32
  14. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +10 -0
  15. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +5 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/game.js +24 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +26 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +16 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +37 -0
  20. package/bundle/typescript/src/commands/game/sideQuest/utilities/data-library.js +158 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/data-work.js +4 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/utilities/edit-file-test.js +31 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/utilities/fixer-menu.js +10 -8
  24. package/bundle/typescript/src/commands/game/sideQuest/utilities/gear-tree.js +13 -1
  25. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +4 -0
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/verb-index.js +1 -1
  27. package/package.json +1 -1
@@ -2,6 +2,7 @@ import { Command } from './command.js';
2
2
  import { priceOf, merchantOf, findWare, mintWare, takeRecentSale } from '../utilities/commerce.js';
3
3
  import { sameSpot, spotOf, spotRefusal } from '../utilities/spots.js';
4
4
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
5
+ import { findDeviceFile, releaseHeld, receiveData } from '../utilities/data-library.js';
5
6
  export class GiveCommand extends Command {
6
7
  static verb = 'give';
7
8
  /** Touches another actor: authorized per-act for AI-driven actors
@@ -75,6 +76,18 @@ export class GiveCommand extends Command {
75
76
  }
76
77
  // === Try to give an item ===
77
78
  let item = this.actor.inventory.getItem(itemOrAmount);
79
+ // DATA ON A DEVICE IS IN YOUR HANDS TOO (digital inventory, user ruling
80
+ // 2026-10-04): a file in your commlink's library is beamed across the
81
+ // table, no chip required. Pack first -- a chip by that name wins --
82
+ // then the device libraries.
83
+ let fromDevice;
84
+ if (!item) {
85
+ const held = findDeviceFile(this.actor, itemOrAmount);
86
+ if (held) {
87
+ item = held.file;
88
+ fromDevice = held;
89
+ }
90
+ }
78
91
  // SERVING SHOP STOCK: a vendor's wares live in the ROOM (owner-tagged),
79
92
  // not in their pockets -- so "give bowl of laksa to Maka" from Mama Wu
80
93
  // used to fail silently while she stood next to her own wok. A real
@@ -203,7 +216,17 @@ export class GiveCommand extends Command {
203
216
  this.scene.checkWinCondition(item.name, recipient.name);
204
217
  return `You hand over the ${item.name}.${tillNote}`;
205
218
  }
206
- const didTransfer = this.actor.inventory.transferItemTo(item.name, recipient.inventory);
219
+ // A device file MOVES to the recipient -- onto their own device when
220
+ // they carry one (data-library receiveData), else a chip in hand.
221
+ let didTransfer;
222
+ if (fromDevice) {
223
+ didTransfer = item.transferable !== false && releaseHeld(this.actor, fromDevice);
224
+ if (didTransfer)
225
+ receiveData(recipient, item);
226
+ }
227
+ else {
228
+ didTransfer = this.actor.inventory.transferItemTo(item.name, recipient.inventory);
229
+ }
207
230
  if (!didTransfer) {
208
231
  this.logger.write(`Item "${item.name}" is not transferable.`);
209
232
  this.actor.performAction('did not give', `item cannot be given`);
@@ -2,6 +2,7 @@ import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { capitalCase } from 'change-case';
4
4
  import { planeTintItemName, objectiveMark } from '../utilities/planes.js';
5
+ import { dataDevices } from '../utilities/data-library.js';
5
6
  /**
6
7
  * On-demand inventory panel, replacing the always-on blessed sidebar box
7
8
  * (same freeing-up-the-log treatment as equipment.ts and map.ts -- the
@@ -122,6 +123,19 @@ export class InventoryCommand extends Command {
122
123
  lines.push(legend);
123
124
  }
124
125
  }
126
+ // THE DEVICE LIBRARIES (digital inventory, 2026-10-04): data you have
127
+ // copied lives on a device, not in the pack -- so the pack view says
128
+ // where it is, device by device, or it reads as lost.
129
+ const libraries = dataDevices(actor).filter(d => d.files.length > 0);
130
+ if (libraries.length > 0) {
131
+ lines.push('', `{light-blue-fg}On your devices:{/light-blue-fg}`);
132
+ for (const device of libraries) {
133
+ lines.push(` ${device.name}: ${device.files.map(f => `${objectiveMark(this.scene, f.name)}${planeTintItemName(f, 'meat')}`).join(', ')}`);
134
+ }
135
+ const legend = hint(`("transfer <file> to <device>" moves one; "transfer <file> to chip" writes it to physical media.)`);
136
+ if (legend)
137
+ lines.push(legend);
138
+ }
125
139
  return lines.join('\n');
126
140
  }
127
141
  }
@@ -25,6 +25,7 @@ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-aud
25
25
  import { spotHiddenIcons, spotLine, spotMechanics } from '../utilities/silent-icons.js';
26
26
  import { personasInside } from '../utilities/matrix-roster.js';
27
27
  import { crowdLine } from '../utilities/crowd.js';
28
+ import { findDeviceFile } from '../utilities/data-library.js';
28
29
  export class LookCommand extends Command {
29
30
  static verb = 'look';
30
31
  static description = 'Examine your surroundings';
@@ -135,6 +136,13 @@ export class LookCommand extends Command {
135
136
  this.logger.write(`Looking at equipped item '${worn.name}' on ${this.actor.name}.`);
136
137
  return this.describeItem(worn, target);
137
138
  }
139
+ else if (findDeviceFile(this.actor, target)) {
140
+ // A FILE ON YOUR DEVICE (digital inventory, 2026-10-04): your own
141
+ // library reads like your own bag.
142
+ const held = findDeviceFile(this.actor, target);
143
+ this.logger.write(`Looking at file '${held.file.name}' on ${held.device?.name}.`);
144
+ return this.describeItem(held.file, target);
145
+ }
138
146
  else if (this.actor.currentLocation.inventory.hasItem(target)) {
139
147
  this.logger.write(`Looking at item '${target}' in current room inventory.`);
140
148
  return this.inspectItem(target, this.actor.currentLocation.inventory);
@@ -495,6 +503,10 @@ export class LookCommand extends Command {
495
503
  if (carried?.plane === 'matrix') {
496
504
  return this.inspectItem(target, this.actor.inventory);
497
505
  }
506
+ // Your own device's library (digital inventory, 2026-10-04).
507
+ const onDevice = findDeviceFile(this.actor, target);
508
+ if (onDevice)
509
+ return this.describeItem(onDevice.file, target);
498
510
  const onGrid = room.inventory.getItem(target);
499
511
  if (onGrid?.plane === 'matrix') {
500
512
  const reach = fileReach(this.actor, room);
@@ -1,10 +1,6 @@
1
1
  import { Command } from './command.js';
2
- import { whereIsThat } from '../utilities/matrix-roster.js';
3
- import { gridVicinity, hostDoorOpenFor } from '../utilities/grid-reach.js';
4
- import { fileReach } from '../utilities/planes.js';
5
2
  import { canReach, ensureAtSpot, sealedRouteTo } from '../utilities/spots.js';
6
3
  import { hint } from '../utilities/hints.js';
7
- import { Category } from '../types/shared/item-enum.js';
8
4
  import { billAction } from '../utilities/action-cost.js';
9
5
  export class TakeCommand extends Command {
10
6
  static verb = 'take';
@@ -29,6 +25,21 @@ export class TakeCommand extends Command {
29
25
  screen: this.screen, game: this.game,
30
26
  }).execute();
31
27
  }
28
+ // A PERSONA HAS NO HANDS -- IT COPIES (digital inventory, user ruling
29
+ // 2026-10-04). On the Matrix plane "take" is the download: Edit File's
30
+ // copy (p.239) into your device's library, not Pick Up Object into the
31
+ // pack. One path, commands/download.ts, so the two verbs cannot drift
32
+ // (they had: download read an uncracked host's archive take refused).
33
+ if (this.actor.plane === 'matrix') {
34
+ const { DownloadCommand } = await import('./download.js');
35
+ const download = new DownloadCommand({
36
+ actor: this.actor, scene: this.scene, rooms: this.rooms,
37
+ screen: this.screen, game: this.game,
38
+ });
39
+ if (wanted === 'all' || wanted === 'everything')
40
+ return download.pullAll();
41
+ return download.execute(args);
42
+ }
32
43
  // PICK UP OBJECT (SR5 p.165): a Simple Action in a Combat Turn.
33
44
  const bill = billAction(this.scene, this.actor, 'simple', 'Pick Up Object');
34
45
  if (bill)
@@ -119,7 +130,8 @@ export class TakeCommand extends Command {
119
130
  * would.
120
131
  */
121
132
  takeAll() {
122
- const room = this.actor.plane === 'matrix' ? gridVicinity(this.actor) : this.actor.currentLocation;
133
+ // The Matrix plane never reaches here -- execute() routes it to download.
134
+ const room = this.actor.currentLocation;
123
135
  // Your plane's items only: an astral form can SEE the meat world's
124
136
  // gear but its hands pass straight through; a Matrix persona sweeps
125
137
  // files, not furniture.
@@ -131,26 +143,6 @@ export class TakeCommand extends Command {
131
143
  if (this.actor.plane === 'drone') {
132
144
  return `The ${this.actor.riggedDrone?.name ?? 'drone'} has no hands, and your pack is wherever your body is. Look all you want -- carrying is a meat job.`;
133
145
  }
134
- // A matrix-plane sweep only ever contains the host's files -- which
135
- // live INSIDE the host (p.246) and stay vaulted until it is cracked.
136
- // See the single-take gate below for the same rules one item at a
137
- // time. The air-gap arm that used to sit here is gone with the model
138
- // it belonged to (hhSWzLSXECFtfoAGa).
139
- if (this.actor.plane === 'matrix' && (room.hasNode || room.offlineServer)
140
- && room.inventory.getAllItems().some(i => i.plane === 'matrix')) {
141
- const reach = fileReach(this.actor, room);
142
- if (reach === 'connect') {
143
- return `Those files are on the ${room.offlineServer} -- off the network entirely. No persona reaches that box; your BODY has to be in ${room.name} (p.233).`;
144
- }
145
- if (reach === 'enter') {
146
- return `Those files are INSIDE the host, and you're out on the grid looking at its shell (p.246). ${hostDoorOpenFor(room, this.actor) ? `"enter" first.` : `Get a mark on it and "enter" -- or "hack" your way in.`}`;
147
- }
148
- // Same split as look.ts: the host's seal does not reach a box that
149
- // is not on the network. The air gap IS the server's security.
150
- if (room.hasNode && !room.offlineServer && !room.hostCracked) {
151
- return `The files here are sealed inside the host's data vault. Crack the node first ("hack") -- then sweep it clean.`;
152
- }
153
- }
154
146
  // You sweep what you've SEEN. Normally that means having searched this
155
147
  // room -- but gear that arrived in the open in front of you (a dead
156
148
  // NPC's scattered kit, a witnessed drop; see Room.plainSightItems)
@@ -188,25 +180,8 @@ export class TakeCommand extends Command {
188
180
  let tookAnything = false;
189
181
  const taken = [];
190
182
  for (const item of items) {
191
- // The sweep skips a file the objective says to ERASE -- see the
192
- // single-take refusal below for why a copy is not a fix.
193
- const wcAll = this.scene.getWinCondition?.();
194
- if (item.plane === 'matrix' && wcAll?.type === 'erase' && wcAll.item?.toLowerCase() === item.name.toLowerCase()) {
195
- lines.push(` • left the {bold}{underline}${item.name}{/underline}{/bold} -- copying it changes nothing; "erase" it`);
196
- continue;
197
- }
198
183
  try {
199
184
  this.actor.addInventory(item);
200
- // THE SWEEP DOWNLOADS TOO. Same rule as the single take below,
201
- // applied here because a family fixed on one verb and not its
202
- // sibling is how the other half of a mechanic goes missing:
203
- // "take all" over a cracked host would otherwise leave every
204
- // file plane 'matrix' in a pocket no meat hand can reach.
205
- const sweptPaydata = item.plane === 'matrix';
206
- if (sweptPaydata) {
207
- item.plane = 'meat';
208
- item.category = Category.Datachip;
209
- }
210
185
  room.inventory.removeItem(item.name);
211
186
  room.plainSightItems.delete(item.name.toLowerCase());
212
187
  tookAnything = true;
@@ -214,7 +189,7 @@ export class TakeCommand extends Command {
214
189
  // see the theft note on the single-item path below.
215
190
  taken.push(item.owner && item.owner !== this.actor.name ? `${item.name} (${item.owner}'s)` : item.name);
216
191
  this.logger.write(`take all: ${this.actor.name} took "${item.name}" from ${room.name}.`);
217
- lines.push(` • ${sweptPaydata ? `pulled down the {bold}{underline}${item.name}{/underline}{/bold} -- written to a chip` : `took the {bold}{underline}${item.name}{/underline}{/bold}`}`);
192
+ lines.push(` • took the {bold}{underline}${item.name}{/underline}{/bold}`);
218
193
  if (item.owner && item.owner !== this.actor.name) {
219
194
  this.scene.addWorldEvent(`${this.actor.name} took ${item.name} (belonging to ${item.owner}) from ${room.name}.`);
220
195
  }
@@ -279,23 +254,8 @@ export class TakeCommand extends Command {
279
254
  }
280
255
  take(args) {
281
256
  const itemName = args?.join(' ').toLowerCase();
282
- const room = this.actor.plane === 'matrix' ? gridVicinity(this.actor) : this.actor.currentLocation;
283
- // A FILE ELSEWHERE IN THE DISTRICT (matrix model): name it from the grid
284
- // and the answer is where it is and what stands in the way -- the
285
- // offline server that needs your body, the host that needs a mark.
286
- if (this.actor.plane === 'matrix' && !this.actor.insideHost && args.length > 0) {
287
- const query = args.join(' ');
288
- const here = room.inventory.getAllItems().some(i => i.name.toLowerCase() === query.toLowerCase());
289
- const far = here ? undefined : whereIsThat(this.rooms ?? this.scene.getRooms(), this.actor, query);
290
- if (far && far.room !== room && (far.reach === 'open' || far.remembered)) {
291
- if (far.reach === 'offline')
292
- return `${far.item.name} is on the ${far.room.offlineServer} in ${far.room.name} -- an offline server, off the network entirely. No persona reaches that box; your BODY has to be in ${far.room.name} (p.233).`;
293
- if (far.reach === 'host')
294
- return `${far.item.name} is INSIDE ${far.room.name}'s host, and you're out on the grid looking at its shell (p.246). ${hostDoorOpenFor(far.room, this.actor) ? `"enter ${far.room.name}" first.` : `Get a mark on it and "enter" -- or "hack" your way in.`}`;
295
- if (far.reach === 'open')
296
- return `${far.item.name} is loose on the grid -- a dim icon. ${hint(`"download ${far.item.name}" writes it to a chip from here.`)}`;
297
- }
298
- }
257
+ // The Matrix plane never reaches here -- execute() routes it to download.
258
+ const room = this.actor.currentLocation;
299
259
  // Check if the item exists in the room's inventory
300
260
  if (!room.inventory.hasItem(itemName)) {
301
261
  this.logger.write(`Item "${itemName}" not found in ${room.name} during 'Take' command.`);
@@ -321,39 +281,6 @@ export class TakeCommand extends Command {
321
281
  ? `The ${itemName} is DATA -- it only exists on the grid. Jack in to take it.`
322
282
  : `The ${itemName} isn't reachable from this plane.`;
323
283
  }
324
- // THE HOST IS THE VAULT, AND YOU HAVE TO BE IN IT. Two separate
325
- // gates, and they are not the same one: being INSIDE (p.246) is
326
- // where the file is, and CRACKED (a mark buys the door, p.239, not
327
- // the contents) is whether the seal is off. A persona who marked a
328
- // host and walked in still finds the archive locked.
329
- //
330
- // This take path is load-bearing for the data-run win condition (s3
331
- // builds on it): taking a matrix file is the DOWNLOAD, and the item
332
- // keeps its name on the way out because the objective matches on
333
- // that name. Do not rename items here.
334
- if (item.plane === 'matrix' && (room.hasNode || room.offlineServer)) {
335
- const reach = fileReach(this.actor, room);
336
- if (reach === 'connect') {
337
- return `The ${item.name} sits on the ${room.offlineServer} -- a box with no Matrix presence at all. No persona reaches it from anywhere: get your BODY into ${room.name} and cable in (p.233).`;
338
- }
339
- if (reach === 'enter') {
340
- return `The ${item.name} is INSIDE the host -- you're on the outside grid, reading its shell (p.246). ${hostDoorOpenFor(room, this.actor) ? `"enter" first.` : `Get a mark on it and "enter" -- or "hack" your way in.`}`;
341
- }
342
- // A LOOSE END IS ERASED, NOT CARRIED (win-condition-seed.ts 'erase',
343
- // 2026-09-14): pulling the footage down to a chip leaves the desk's
344
- // copy exactly where it was, and would let a runner believe the
345
- // night was dealt with. The objective's own verb is "erase" --
346
- // said before the seal, because it is the refusal that teaches.
347
- const wc = this.scene.getWinCondition?.();
348
- if (wc?.type === 'erase' && wc.item?.toLowerCase() === item.name.toLowerCase()) {
349
- return `Copying ${item.name} changes nothing -- the desk keeps its own. ${hint(`"erase ${item.name}" is what makes it go away (Edit File, p.239).`)}`;
350
- }
351
- // Same split as look.ts: the host's seal does not reach a box that
352
- // is not on the network. The air gap IS the server's security.
353
- if (room.hasNode && !room.offlineServer && !room.hostCracked) {
354
- return `The ${item.name} is sealed inside the host's data vault. Crack the node first ("hack") -- then it's yours.`;
355
- }
356
- }
357
284
  // "At" spots: hands need to be where the goods are -- out of combat
358
285
  // that's a free walk over (utilities/spots.ts ensureAtSpot).
359
286
  const reach = ensureAtSpot(this.actor, item.atSpot);
@@ -372,35 +299,6 @@ export class TakeCommand extends Command {
372
299
  this.actor.performAction('takes', item.owner && item.owner !== this.actor.name ? `${item.name} (${item.owner}'s)` : item.name);
373
300
  this.actor.addInventory(item);
374
301
  this.logger.write(`Successfully added "${itemName}" to ${this.actor.name}'s inventory during 'Take' command.`);
375
- // PAYDATA COMES HOME ON A CHIP (user ruling 2026-08-30, for the
376
- // data-run type ruoedrMTA6kCRNmEN).
377
- //
378
- // Lifting a file used to leave it plane 'matrix' IN YOUR POCKET:
379
- // carried by a persona, invisible to your meat hands the moment
380
- // you jacked out, and impossible to hand to a fixer who has never
381
- // been on the grid in his life. The data was yours and there was
382
- // no way to spend it.
383
- //
384
- // So the download is the take. The file is written to physical
385
- // media the instant it leaves the host, and what you carry out is
386
- // a chip -- which is what a fixer can be handed, and what a
387
- // winCondition can name.
388
- //
389
- // THE NAME IS DELIBERATELY UNCHANGED. A run's objective is matched
390
- // by item NAME (win-condition-seed.ts: "must match an items[].name")
391
- // so renaming the file on download would break the very run this
392
- // exists to complete -- the fiction changes, the identity does not.
393
- //
394
- // Category moves to Datachip because that is what it now is, and
395
- // it is safe: the enum has existed all along with no runtime
396
- // consumer -- only generation-time clue validation reads
397
- // categories, and that has already run by the time anyone takes
398
- // anything.
399
- const wasPaydata = item.plane === 'matrix';
400
- if (wasPaydata) {
401
- item.plane = 'meat';
402
- item.category = Category.Datachip;
403
- }
404
302
  if (item.owner && item.owner !== this.actor.name) {
405
303
  this.scene.addWorldEvent(`${this.actor.name} took ${item.name} (belonging to ${item.owner}) from ${room.name}.`);
406
304
  }
@@ -427,12 +325,7 @@ export class TakeCommand extends Command {
427
325
  // render BEFORE it -- print directly for the player, return the text
428
326
  // for an NPC actor's AI history.
429
327
  const isPlayer = this.scene.isHumanControlled(this.actor);
430
- // The download is worth SAYING, not just doing: a player who lifts
431
- // a file and is told "you have taken it" has no way to know the
432
- // thing in their pocket is now something a fixer can be handed.
433
- const taken = wasPaydata
434
- ? `You pull the ${itemName} down out of the host and write it to a chip -- physical media, in your hand, off the grid.`
435
- : `You have taken the ${itemName}.`;
328
+ const taken = `You have taken the ${itemName}.`;
436
329
  if (isPlayer) {
437
330
  this.logger.log(taken);
438
331
  }
@@ -0,0 +1,92 @@
1
+ import { Command } from './command.js';
2
+ import { hint } from '../utilities/hints.js';
3
+ import { fuzzyPickName } from '../utilities/fuzzy-match.js';
4
+ import { burnToChip, dataChips, dataDevices, deviceFiles, deviceHas, fileOnto, findDataDevice, findDeviceFile, loadChip, } from '../utilities/data-library.js';
5
+ /**
6
+ * "transfer <file> to <device|chip>" -- MOVE DATA AROUND YOUR OWN PAN.
7
+ *
8
+ * Digital inventory (user ruling 2026-10-04). Three moves, all FREE -- no
9
+ * action, no test:
10
+ * - device to device: canon is silent on whether an owner (four marks'
11
+ * worth, p.236) rolls Edit File against their own Firewall, and the
12
+ * user ruled the reshuffle free;
13
+ * - device to chip: the file is written to a physical datachip in the
14
+ * pack and leaves the library -- physical media in one move, never an
15
+ * extra step a delivery demands;
16
+ * - chip to device: the chip is read onto the device and consumed.
17
+ *
18
+ * These are moves, not copies: a file you shuffle between your own
19
+ * devices does not multiply. Copies come from Edit File against somebody
20
+ * else's archive (download / upload).
21
+ */
22
+ export class TransferCommand extends Command {
23
+ static verb = 'transfer';
24
+ static description = 'Move a file between your own devices, or onto a physical datachip and back -- "transfer <file> to <device>" / "transfer <file> to chip". Free: no action, no test (your own gear). "inv" shows what each device holds.';
25
+ async execute(args = []) {
26
+ const actor = this.actor;
27
+ const said = args.join(' ').trim();
28
+ const m = said.match(/^(.*)\s+(?:to|onto|into)\s+(?:a\s+|the\s+|my\s+)?(.+)$/i);
29
+ if (!m) {
30
+ const files = deviceFiles(actor).map(h => `${h.file.name} (${h.device.name})`);
31
+ const chips = dataChips(actor).map(c => `${c.name} (chip)`);
32
+ const all = [...files, ...chips];
33
+ return `Transfer what, to where? ${hint(`("transfer <file> to <device>" or "transfer <file> to chip".${all.length > 0 ? ` You hold: ${all.join(', ')}.` : ''})`)}`;
34
+ }
35
+ const query = m[1].trim();
36
+ const dest = m[2].trim();
37
+ // TO A CHIP: device file -> physical media in the pack.
38
+ if (/^(chip|datachip|data chip|a chip|optical chip|physical)$/i.test(dest)) {
39
+ const held = findDeviceFile(actor, query);
40
+ if (!held || !held.device) {
41
+ const already = dataChips(actor).find(c => c.name.toLowerCase() === query.toLowerCase());
42
+ if (already)
43
+ return `${already.name} is already on a chip.`;
44
+ return `No file called "${query}" on your devices.`;
45
+ }
46
+ try {
47
+ burnToChip(actor, held);
48
+ }
49
+ catch {
50
+ return `Your pack can't take the chip -- ${held.file.name} stays on your ${held.device.name}.`;
51
+ }
52
+ this.logger.write(`${actor.name} burned "${held.file.name}" from ${held.device.name} to a chip.`);
53
+ this.game?.requestSave('data');
54
+ this.scene.updateStatus();
55
+ return `You write ${held.file.name} out to a datachip -- physical media in your pack now, off your ${held.device.name}.`;
56
+ }
57
+ const device = findDataDevice(actor, dest);
58
+ if (!device) {
59
+ const devices = dataDevices(actor);
60
+ return `You carry no device called "${dest}".${devices.length > 0 ? hint(` (Yours: ${devices.map(d => d.name).join(', ')} -- or a chip.)`) : ''}`;
61
+ }
62
+ // FROM A DEVICE (or, failing that, a chip) TO THIS DEVICE.
63
+ const fromDevice = findDeviceFile(actor, query);
64
+ const chips = dataChips(actor);
65
+ const chipName = fromDevice ? undefined : fuzzyPickName(query, chips.map(c => c.name));
66
+ const chip = chipName ? chips.find(c => c.name === chipName) : undefined;
67
+ if (!fromDevice && !chip) {
68
+ return `No file called "${query}" on your devices or on a chip in your pack.`;
69
+ }
70
+ const file = fromDevice ? fromDevice.file : chip;
71
+ if (fromDevice?.device === device)
72
+ return `${file.name} is already on your ${device.name}.`;
73
+ if (deviceHas(device, file.name))
74
+ return `Your ${device.name} already holds a file called ${file.name}.`;
75
+ if (fromDevice && fromDevice.device) {
76
+ const at = fromDevice.device.files.indexOf(file);
77
+ fromDevice.device.files.splice(at, 1);
78
+ fileOnto(device, file);
79
+ actor.noteConditionChanged();
80
+ this.logger.write(`${actor.name} moved "${file.name}" from ${fromDevice.device.name} to ${device.name}.`);
81
+ this.game?.requestSave('data');
82
+ this.scene.updateStatus();
83
+ return `${file.name} slides across your PAN from the ${fromDevice.device.name} to the ${device.name}.`;
84
+ }
85
+ loadChip(actor, chip, device);
86
+ this.logger.write(`${actor.name} read chip "${chip.name}" onto ${device.name}.`);
87
+ this.game?.requestSave('data');
88
+ this.scene.updateStatus();
89
+ return `You slot the chip and read ${chip.name} onto your ${device.name} -- the chip is blank now, and you toss it.`;
90
+ }
91
+ }
92
+ //# sourceMappingURL=transfer.js.map
@@ -0,0 +1,108 @@
1
+ import { Command } from './command.js';
2
+ import { billAction } from '../utilities/action-cost.js';
3
+ import { hint } from '../utilities/hints.js';
4
+ import { hostLabel } from '../utilities/grid-names.js';
5
+ import { editFileBlocked, rollEditFile } from '../utilities/edit-file-test.js';
6
+ import { copyFile, deviceFiles, findDeviceFile, dataChips } from '../utilities/data-library.js';
7
+ /**
8
+ * "upload" -- WRITE A FILE FROM YOUR DEVICE ONTO THE HOST YOU STAND IN.
9
+ *
10
+ * Digital inventory (user ruling 2026-10-04): data moves between
11
+ * libraries, and a host's archive is one (models/host.ts addFile). The
12
+ * action is Edit File's CREATE -- SR5 p.239: "Edit File allows you to
13
+ * create, change, copy, delete, or protect any kind of file. The defender
14
+ * against this test is either the host holding the file or the owner" --
15
+ * Computer + Logic [Data Processing] v. the host, Marks Required 1, a
16
+ * Complex Action. The roll is utilities/edit-file-test.ts, the one erase
17
+ * and download make.
18
+ *
19
+ * The same two engine rulings download.ts names hold here: a persona
20
+ * edits a host's files from INSIDE it (the host keeps them; canon does
21
+ * not say how a file in a host is marked, only archive files carry marks,
22
+ * p.247), and a CRACKED host (three marks, hack.ts) treats you as staff,
23
+ * so writing to it takes no roll.
24
+ *
25
+ * Like the download it is a COPY: what lands on the host is a fresh file,
26
+ * and your device keeps its own (p.239's copy, run the other way).
27
+ *
28
+ * THE COURIER (win-condition 'upload'): a file planted on the right host
29
+ * is the delivery. scene.checkUploaded decides, and is told whether the
30
+ * host was on alert when the file arrived -- the harder jobs want it in
31
+ * unseen.
32
+ */
33
+ export class UploadCommand extends Command {
34
+ static verb = 'upload';
35
+ static description = 'Write a file from your device onto the host you are inside -- "upload <file>". Edit File (SR5 p.239, a Complex Action): Computer + Logic [Data Processing] v. the host, with a mark on it; no roll once the host is cracked. Your device keeps its copy. A courier job is done when its file lands on the right host.';
36
+ async execute(args = []) {
37
+ const actor = this.actor;
38
+ // "upload X to host" / "upload X into the host": the destination is
39
+ // always the host you are in, so the tail is decoration.
40
+ const query = args.join(' ').replace(/\s+(?:to|onto|into)\s+(?:the\s+)?host\b.*$/i, '').trim();
41
+ if (actor.plane !== 'matrix') {
42
+ return `Files go onto a host from the Matrix. ${hint(`"jack in", then "enter" the host.`)}`;
43
+ }
44
+ const host = actor.hostInside;
45
+ if (!host) {
46
+ return `You write to a host from INSIDE it (p.246). ${hint(`"enter" the host first.`)}`;
47
+ }
48
+ const room = host.over;
49
+ const mine = deviceFiles(actor);
50
+ if (!query) {
51
+ if (mine.length === 0) {
52
+ return dataChips(actor).length > 0
53
+ ? `Nothing on your devices to upload. ${hint(`(A chip in your pack has to go onto a device first: "transfer <file> to <device>".)`)}`
54
+ : `Nothing on your devices to upload.`;
55
+ }
56
+ return `Upload which file? ${hint(`(On your devices: ${mine.map(h => h.file.name).join(', ')}.)`)}`;
57
+ }
58
+ const held = findDeviceFile(actor, query);
59
+ if (!held || !held.device) {
60
+ const chip = dataChips(actor).find(c => c.name.toLowerCase() === query.toLowerCase());
61
+ if (chip)
62
+ return `${chip.name} is on a chip in your pack, not on a device. ${hint(`"transfer ${chip.name} to <device>" first -- it costs nothing.`)}`;
63
+ return `No file called "${query}" on your devices.${mine.length > 0 ? hint(` (They hold: ${mine.map(h => h.file.name).join(', ')}.)`) : ''}`;
64
+ }
65
+ const file = held.file;
66
+ if (host.hasFile(file.name)) {
67
+ return `${hostLabel(room, { capital: true })} already holds a file called ${file.name}.`;
68
+ }
69
+ const needsRoll = !host.cracked;
70
+ if (needsRoll && (host.marksBy.get(actor.name) ?? 0) < 1) {
71
+ return `Edit File needs a MARK (p.239) -- you hold none on ${hostLabel(room)}. ${hint(`"hack" the host for one.`)}`;
72
+ }
73
+ if (needsRoll) {
74
+ const burned = editFileBlocked(actor);
75
+ if (burned)
76
+ return burned;
77
+ }
78
+ const bill = billAction(this.scene, actor, 'complex', 'Edit File (create)');
79
+ if (bill)
80
+ return bill;
81
+ actor.performAction('edits a file', `${file.name} into ${hostLabel(room)}`, { quiet: this.scene.isHumanControlled(actor) });
82
+ if (needsRoll) {
83
+ const roll = rollEditFile(this.scene, this.logger, actor, host, 'create');
84
+ if (!roll.success) {
85
+ this.logger.write(`${actor.name} failed to upload "${file.name}" to ${room.name}: ${roll.hits} v ${roll.defence}.`);
86
+ return `The write bounces -- ${hostLabel(room)}'s file system refuses the new entry and ${file.name} stays on your ${held.device.name} alone. A failed edit is not a failed sleaze; nobody upstairs noticed.${hint(` (Try again; each attempt is a Complex Action.)`)}`;
87
+ }
88
+ }
89
+ // The host's copy; yours stays where it was (p.239).
90
+ const landed = copyFile(file);
91
+ host.addFile(landed);
92
+ // Fresh on the archive and written by you: you have seen it land.
93
+ host.searchedBy.add(actor.name);
94
+ this.logger.write(`${actor.name} uploaded "${file.name}" from ${held.device.name} to ${room.name}'s host${host.alert ? ' (host on alert)' : ''}.`);
95
+ this.game?.requestSave('data');
96
+ const text = `{light-blue-fg}You write {bold}{underline}${file.name}{/underline}{/bold} into ${hostLabel(room)} -- a new entry in its index, checksum agreeing. Your ${held.device.name} keeps its copy.{/light-blue-fg}`;
97
+ // ORDER, as take.ts does it: the objective check prints its own
98
+ // banner, so the write renders first and the verb returns nothing for
99
+ // a human; an NPC gets the text back for its AI history.
100
+ const isPlayer = this.scene.isHumanControlled(actor);
101
+ if (isPlayer)
102
+ this.logger.log(text, { actor: actor.name });
103
+ this.scene.checkUploaded?.(file.name, room.name, actor.name, host.alert);
104
+ this.scene.updateStatus();
105
+ return isPlayer ? '' : text;
106
+ }
107
+ }
108
+ //# sourceMappingURL=upload.js.map
@@ -3235,5 +3235,23 @@
3235
3235
  // - The street doc is told how the patient is in a body's words and is
3236
3236
  // barred from "boxes" and other screen terms, in the treat note and in
3237
3237
  // the brief (7G2avXprhjrawdc7L: "three boxes left ... breaks the fiction").
3238
- export const ENGINE_VERSION = '1.190.0';
3238
+ // 1.191.0 (2026-10-04): DIGITAL INVENTORY (user ruling 2026-10-04).
3239
+ // - Commlinks, decks and RCCs carry a data library (Item.files, saved);
3240
+ // matrix take/download COPY a file into it (Edit File, p.239) -- the
3241
+ // host keeps its original, and an uncracked host makes the copy an
3242
+ // Edit File test with a mark. No more chip on download.
3243
+ // - upload (Edit File create) writes a device file onto the host you
3244
+ // are in; transfer moves files between your devices or onto/off a
3245
+ // datachip, free. give / call deliver straight from a device.
3246
+ // - The data courier: delivery + data run = win-condition 'upload' (a
3247
+ // file to a host; large/epic must land unseen). The client pushes the
3248
+ // cargo onto the runner's device at the meet.
3249
+ // - The gear beat carries data (device libraries) and host (the found
3250
+ // archive of the host the persona is in).
3251
+ // 1.191.1 (2026-10-04): A LIVING PERSONA HAS NO STORAGE (SR5 p.250-251).
3252
+ // - A download with no commlink, deck or RCC to land on is refused, not
3253
+ // written to a chip -- a persona is not a device, and a datachip has no
3254
+ // wireless to write through. Reaches only the deviceless technomancer;
3255
+ // one with a commlink copies onto it and keeps their Resonance.
3256
+ export const ENGINE_VERSION = '1.191.1';
3239
3257
  //# sourceMappingURL=engine-version.js.map
@@ -18,17 +18,37 @@ export function pickJobKind(roll = Math.random(), coin = Math.random()) {
18
18
  return 'extraction';
19
19
  return 'delivery';
20
20
  }
21
- /** Only a datasteal can be a data run: the other kinds have no file to
22
- * take. A rolled or asked-for 'data' on any other kind is dropped. */
21
+ /** A datasteal or a delivery can be a data run: the steal's prize is a
22
+ * file on a host, the delivery's package is a file for one (the data
23
+ * courier, user ruling 2026-10-04 -- win-condition 'upload'). The other
24
+ * kinds have no file, so a rolled or asked-for 'data' is dropped. */
23
25
  export function shapeFor(kind, runType) {
24
- return kind === 'datasteal' ? runType : 'standard';
26
+ return kind === 'datasteal' || kind === 'delivery' ? runType : 'standard';
27
+ }
28
+ /**
29
+ * THE DATA COURIER (user ruling 2026-10-04): a delivery whose package is a
30
+ * FILE and whose drop is a HOST. The client pushes the file onto the
31
+ * runner's device at the meet; the runner gets inside the target host
32
+ * and uploads it (commands/upload.ts, Edit File's create, p.239).
33
+ */
34
+ export function isDataCourier(kind, runType) {
35
+ return kind === 'delivery' && runType === 'data';
36
+ }
37
+ /**
38
+ * Does a courier's plant have to land UNSEEN? The harder jobs say so
39
+ * (user ruling 2026-10-04: "1 for simpler jobs, and 2 for harder jobs"):
40
+ * large and epic want the file in before the host knows anyone is there;
41
+ * small and medium only want it there.
42
+ */
43
+ export function courierMustBeUnseen(size) {
44
+ return size === 'large' || size === 'epic';
25
45
  }
26
46
  const KIND_WORDS = [
27
47
  { kind: 'assassination', rx: /\b(wetwork|wet\s*work|wet\s*job|assassinat\w*|a hit|hit job|kill(ing)? (job|work|someone|somebody)|take (someone|somebody) out|put (someone|somebody) down)\b/i },
28
48
  { kind: 'destruction', rx: /\b(destroy\w*|destruction|demolition|demolish\w*|sabotage|wreck\w*|blow (something|it|it all) up|torch (job|work|something)|break (something|stuff|shit))\b/i },
29
49
  { kind: 'extraction', rx: /\b(extract\w*|pull (someone|somebody|a guy|a girl|them|him|her) out|get (someone|somebody|them|him|her) out|snatch (someone|somebody|a person)|kidnap\w*|defect\w*|exfil\w*)\b/i },
30
50
  { kind: 'extraction', rx: /\b(extract\w*|pull (someone|somebody|a guy|a girl|them|him|her) out|get (someone|somebody|them|him|her) out|snatch (someone|somebody|a person)|kidnap\w*|defect\w*|exfil\w*)\b/i },
31
- { kind: 'delivery', rx: /\b(deliver\w*|courier|smuggl\w*|run (a|the|this) package|package run|cargo|transport (job|work|run))\b/i },
51
+ { kind: 'delivery', rx: /\b(deliver\w*|courier|smuggl\w*|run (a|the|this) package|package run|cargo|transport (job|work|run)|upload\w*|plant\w*\s+(a\s+|the\s+|some\s+)?(file|data|worm|backdoor))\b/i },
32
52
  { kind: 'datasteal', rx: /\b(datasteal|data\s*steal|steal\w*|snatch|grab (job|work|a thing)|burglary|retrieval|paydata|data\s*(work|run|runs|job|jobs|dive|diving))\b/i },
33
53
  ];
34
54
  /** The job type a line of speech asks for, if it names one. */
@@ -252,6 +272,10 @@ export function objectiveRoomFor(skeleton) {
252
272
  const wc = skeleton.winCondition;
253
273
  if (!wc)
254
274
  return undefined;
275
+ // A courier's objective is the DROP host's room, not the client who
276
+ // holds the cargo at the meet.
277
+ if (wc.item && wc.target)
278
+ return wc.target;
255
279
  if (wc.item) {
256
280
  const win = skeleton.items.find(i => i.name === wc.item);
257
281
  return win?.room ?? skeleton.npcs.find(n => n.name === win?.heldBy)?.startLocation;