@maka/maka-cli 5.377.0 → 5.379.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 (29) 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 +248 -132
  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/treat.js +5 -3
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/upload.js +108 -0
  12. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +21 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/factories/job-order.js +28 -4
  14. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +125 -32
  15. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +10 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +5 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/game.js +50 -6
  18. package/bundle/typescript/src/commands/game/sideQuest/models/host.js +26 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/models/item.js +16 -0
  20. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +37 -0
  21. package/bundle/typescript/src/commands/game/sideQuest/utilities/data-library.js +158 -0
  22. package/bundle/typescript/src/commands/game/sideQuest/utilities/data-work.js +4 -0
  23. package/bundle/typescript/src/commands/game/sideQuest/utilities/edit-file-test.js +31 -0
  24. package/bundle/typescript/src/commands/game/sideQuest/utilities/fixer-menu.js +10 -8
  25. package/bundle/typescript/src/commands/game/sideQuest/utilities/gear-tree.js +13 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +4 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/utilities/street-doc.js +23 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/utilities/verb-index.js +1 -1
  29. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.377.0",
3
+ "version": "5.379.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.",
@@ -19,6 +19,7 @@ import { inLiveCombat } from '../utilities/action-cost.js';
19
19
  import { radioIsDark } from '../utilities/pan-devices.js';
20
20
  import { NPC } from '../models/npc.js';
21
21
  import { METERS_PER_LEVEL } from '../utilities/room-grid.js';
22
+ import { heldByName, releaseHeld, receiveData } from '../utilities/data-library.js';
22
23
  export class CallCommand extends Command {
23
24
  static verb = 'call';
24
25
  static description = 'Call someone remotely over a commlink (no need to be in the same room, or the same district; commlink must be equipped).';
@@ -612,9 +613,21 @@ export class CallCommand extends Command {
612
613
  let deliveredItemName = '';
613
614
  const wc = this.scene.getWinCondition();
614
615
  if (wc?.type === 'give' && wc.item && wc.toNpc?.toLowerCase() === target.name.toLowerCase()) {
615
- const item = this.actor.inventory.getItem(wc.item);
616
- if (item) {
617
- const delivered = this.actor.inventory.transferItemTo(item.name, target.inventory);
616
+ // A chip in the pack OR a file on a device (digital inventory,
617
+ // 2026-10-04): over a call the data is simply SENT, which is what a
618
+ // device library is for -- the chip was never required.
619
+ const held = heldByName(this.actor, wc.item);
620
+ const item = held?.file;
621
+ if (held && item) {
622
+ let delivered;
623
+ if (held.device) {
624
+ delivered = item.transferable !== false && releaseHeld(this.actor, held);
625
+ if (delivered)
626
+ receiveData(target, item);
627
+ }
628
+ else {
629
+ delivered = this.actor.inventory.transferItemTo(item.name, target.inventory);
630
+ }
618
631
  if (delivered) {
619
632
  this.scene.updateInventory(this.scene.getPlayer().inventory);
620
633
  this.scene.checkWinCondition(item.name, target.name);
@@ -1,178 +1,294 @@
1
1
  import { Command } from './command.js';
2
2
  import { billAction } from '../utilities/action-cost.js';
3
- import { roomsInReach, gridVicinity } from '../utilities/grid-reach.js';
3
+ import { roomsInReach, gridVicinity, hostDoorOpenFor } from '../utilities/grid-reach.js';
4
+ import { whereIsThat } from '../utilities/matrix-roster.js';
4
5
  import { hint } from '../utilities/hints.js';
5
- import { Category } from '../types/shared/item-enum.js';
6
6
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
7
7
  import { fileReach, planeTintItemName } from '../utilities/planes.js';
8
+ import { hostLabel } from '../utilities/grid-names.js';
9
+ import { editFileBlocked, rollEditFile } from '../utilities/edit-file-test.js';
10
+ import { copyFile, deviceHas, fileOnto, findDataDevice, libraryDevice, receiveData } from '../utilities/data-library.js';
8
11
  /**
9
- * "download" -- WRITE A FILE OUT OF THE GRID AND ONTO SOMETHING YOU CAN
10
- * HAND OVER.
12
+ * "download" -- COPY A FILE OFF THE GRID INTO YOUR DEVICE'S LIBRARY.
11
13
  *
12
- * Two reports, one hole:
14
+ * DIGITAL INVENTORY (user ruling 2026-10-04) replaces the chip. A file
15
+ * used to come down as a Datachip in the pack (the 2026-08-30 ruling);
16
+ * now it is written to the library of the device carrying your persona
17
+ * (utilities/data-library.ts), and it is a COPY -- Edit File, p.239:
18
+ * "create, change, COPY, delete ... If you use this action to copy a
19
+ * file, you are the new file's owner." The host keeps its original, so a
20
+ * job that wants the file GONE is still "erase".
13
21
  *
14
- * aNeh5TELvdgeS2vpg (Deditri): "paydata in this run is stuck with the
15
- * sprite, It won't give it to me and there is not way to get it to
16
- * Marrow."
22
+ * WHERE FROM. Files inside the host you stand in; files loose on the grid
23
+ * where you are; dim icons out in the district (pp.217-218); and files
24
+ * your own compiled sprites are holding -- the one source nothing else
25
+ * could reach (aNeh5TELvdgeS2vpg, MxiKYFGeStiyvibJN). A sprite's file is
26
+ * already yours, so it MOVES to your device rather than copying.
17
27
  *
18
- * MxiKYFGeStiyvibJN (Maka): "there needs to be a way to move data from
19
- * matrix to meatworld in the form of 'download'."
28
+ * THE TEST (p.239): Computer + Logic [Data Processing] v. the host,
29
+ * Marks Required 1 -- the same roll erase makes (utilities/edit-file-test.ts).
30
+ * Two arms stay free of dice, both deliberate:
31
+ * - a CRACKED host (three marks, hack.ts): its architecture treats you
32
+ * as staff, and its own files open with it -- the line hack.ts draws;
33
+ * - a file not on any host (loose on the grid, or your sprite's): the
34
+ * defender would be the file's owner, and an unowned or self-owned
35
+ * file has nobody to roll. Canon names no defender here; none is
36
+ * invented.
37
+ * Before 2026-10-04 this verb never rolled and never asked for a crack,
38
+ * so it read an uncracked host's archive that `take` refused -- the two
39
+ * now share one path (take routes matrix-plane pickups here).
20
40
  *
21
- * THE CONVERSION ALREADY EXISTED AND HAD NO NAME. take.ts has flipped a
22
- * matrix-plane file to `meat` and re-categorised it as a Datachip for a
23
- * while now -- "pulled down ... written to a chip". So the mechanic was
24
- * built; what was missing was a VERB anybody would think to type, and a
25
- * source other than the floor.
41
+ * THE TIME: a Complex Action (p.239) wherever an action economy exists.
26
42
  *
27
- * THE SPRITE IS THE CASE THAT HAD NO ROUTE AT ALL. `take` reads the
28
- * ROOM's inventory, and Deditri's chip was in a compiled sprite's hands.
29
- * `give` was no help from either end: it refuses whenever an actor is off
30
- * the meat plane, and a sprite is `plane: 'matrix'` by construction, so
31
- * "order sprite give me the chip" could never work. The only route left
32
- * was `decompile` -- destroying your own sprite to make it drop the
33
- * loot -- which is exactly the "hidden, non-obvious solution" the vetting
34
- * described. It is also a lousy trade the player has no way to know they
35
- * are making.
36
- *
37
- * SO THIS VERB READS BOTH SOURCES: files loose on the grid where you are
38
- * standing, and files carried by YOUR OWN compiled sprites. Somebody
39
- * else's sprite is not a shelf you may take from -- that is a hack, and
40
- * hack.ts owns it.
41
- *
42
- * DELIBERATELY NOT A TEST -- BUT IT IS AN ACTION. Pulling a file you
43
- * already have reach on is not an opposed action in this engine --
44
- * `take` does not roll for it either, and getting past whatever GUARDED
45
- * the file is what the host crack and the mark already charged for.
46
- * Adding dice here would price the same obstacle twice.
47
- *
48
- * The TIME is a separate bill, and it was going unpaid: copying a file
49
- * is Edit File, a COMPLEX ACTION (p.239). See the billAction call in
50
- * execute() for what that was costing in a fight.
43
+ * "download <file> to <device>" picks the library; without it the file
44
+ * lands on the persona's device. A runner with no data device at all
45
+ * gets the old chip, because something has to hold it.
51
46
  */
52
47
  export class DownloadCommand extends Command {
53
48
  static verb = 'download';
54
- static description = 'Write a file out of the Matrix onto a chip you carry -- "download <file>". Reads data loose on the grid where you stand AND data your own sprites are holding, which is the one place nothing else could reach it. A downloaded file is ordinary meat-world cargo: you can carry it, stash it, or hand it to somebody.';
49
+ static description = 'Copy a file off the Matrix into your device\'s library -- "download <file>" (or "download <file> to <device>"). 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, or for data loose on the grid or held by your own sprites. The host keeps its original. "inv" lists your libraries; "transfer" moves files between devices or onto a chip.';
55
50
  async execute(args = []) {
51
+ const actor = this.actor;
52
+ let words = args.join(' ').trim();
53
+ // "... to <device>": split on the LAST " to ", and only when the tail
54
+ // names a data device you carry -- a file called "Letter to Krow"
55
+ // still downloads whole.
56
+ let target;
57
+ const toMatch = words.match(/^(.*)\s+(?:to|onto|into)\s+(.+)$/i);
58
+ if (toMatch) {
59
+ const device = findDataDevice(actor, toMatch[2]);
60
+ if (device) {
61
+ target = device;
62
+ words = toMatch[1].trim();
63
+ }
64
+ }
65
+ const { pullable, reach } = this.inReach();
66
+ if (pullable.length === 0 && reach === 'connect') {
67
+ const room = gridVicinity(actor);
68
+ 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} and cabled in (p.233).`;
69
+ }
70
+ if (pullable.length === 0 && !words) {
71
+ return `Nothing to pull down -- no data on the grid here, and nothing your sprites are carrying.${reach === 'enter' ? hint(` (This room's files live INSIDE its host -- "enter" first.)`) : ''}`;
72
+ }
73
+ // Bare "download" is the LIST. A verb whose whole reason for existing
74
+ // is that nobody could find the file should say what it can see.
75
+ if (words.length === 0) {
76
+ const lines = [`{light-blue-fg}Within reach to pull down:{/light-blue-fg}`];
77
+ for (const p of pullable) {
78
+ const where = p.kind === 'host' ? `in ${hostLabel(p.host.over)}'s archive`
79
+ : p.kind === 'sprite' ? `held by ${p.npc.name}`
80
+ : p.kind === 'far' ? `a dim icon out on the grid (noise -${p.noise})`
81
+ : 'loose on the grid here';
82
+ lines.push(` ${planeTintItemName(p.item, actor.plane)} -- ${where}`);
83
+ }
84
+ const home = libraryDevice(actor);
85
+ lines.push(hint(`("download <file>" copies it ${home ? `onto your ${home.name}` : 'to a chip'}.)`));
86
+ return lines.join('\n');
87
+ }
88
+ const picked = fuzzyPickName(words, pullable.map(p => p.item.name));
89
+ const source = picked ? pullable.find(p => p.item.name === picked) : undefined;
90
+ if (!source) {
91
+ // IN THE ARCHIVE, NOT YET FOUND: the host holds it, but nothing has
92
+ // turned the archive up for this persona (search, or a crack).
93
+ // OUT ON THE GRID, OUTSIDE THE HOST THAT HOLDS IT (p.246): the shell
94
+ // is all a persona reads from out here.
95
+ const here = actor.plane === 'matrix' ? gridVicinity(actor) : actor.currentLocation;
96
+ if (reach === 'enter' && here.host && fuzzyPickName(words, here.host.files().map(f => f.name))) {
97
+ return `That file is INSIDE the host -- you're on the outside grid, reading its shell (p.246). ${hostDoorOpenFor(here, actor) ? `"enter" first.` : `Get a mark on it and "enter" -- or "hack" your way in.`}`;
98
+ }
99
+ const inside = actor.plane === 'matrix' ? actor.hostInside : undefined;
100
+ if (inside && fuzzyPickName(words, inside.files().map(f => f.name))) {
101
+ return `You haven't found ${hostLabel(inside.over)}'s archive yet -- there is nothing on your display to copy. ${hint(`"search" runs a Matrix Search inside the host (p.241); a crack ("hack") opens it outright.`)}`;
102
+ }
103
+ const elsewhere = this.whereElse(words);
104
+ if (elsewhere)
105
+ return elsewhere;
106
+ return `No file called "${words}" within reach.${hint(` ("download" on its own lists what is.)`)}`;
107
+ }
108
+ const out = this.pull(source, target);
109
+ return out.text;
110
+ }
111
+ /**
112
+ * A FILE ELSEWHERE IN THE DISTRICT (matrix model): named from the grid,
113
+ * the answer is where it is and what stands in the way -- the offline
114
+ * server that needs your body, the host that needs a mark. Moved here
115
+ * from take.ts with the rest of the matrix pickup.
116
+ */
117
+ whereElse(query) {
118
+ const actor = this.actor;
119
+ if (actor.plane !== 'matrix' || actor.insideHost)
120
+ return undefined;
121
+ const room = gridVicinity(actor);
122
+ const far = whereIsThat(this.rooms ?? this.scene.getRooms(), actor, query);
123
+ if (!far || far.room === room || !(far.reach === 'open' || far.remembered))
124
+ return undefined;
125
+ if (far.reach === 'offline')
126
+ 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).`;
127
+ if (far.reach === 'host')
128
+ 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, actor) ? `"enter ${far.room.name}" first.` : `Get a mark on it and "enter" -- or "hack" your way in.`}`;
129
+ return undefined;
130
+ }
131
+ /**
132
+ * Every file this persona can pull from where it stands, with the
133
+ * reason the room's own files are or are not in reach.
134
+ */
135
+ inReach() {
56
136
  const actor = this.actor;
57
137
  // WHERE THE PERSONA STANDS (matrix model 2026-09-02): inside a host, the host's room; on the grid, the body's.
58
138
  const room = actor.plane === 'matrix' ? gridVicinity(actor) : actor.currentLocation;
59
- // WHAT IS IN REACH, from both sources.
60
- //
61
- // The room's own files honour fileReach (utilities/planes.ts) -- a
62
- // host's contents are inside the host and an offline server wants a
63
- // body in the room -- because a download that ignored the boundary
64
- // would be a way to read a sealed host from the street, which is the
65
- // very thing eJANPpm7qCaCAJBJw was filed about.
66
139
  const reach = fileReach(actor, room);
67
- const loose = reach === 'open'
68
- ? room.inventory.getAllItems().filter(i => i.plane === 'matrix')
69
- : [];
140
+ const pullable = [];
141
+ // The room's own files honour fileReach (utilities/planes.ts): a host's
142
+ // contents are inside the host, and an offline server wants a body.
143
+ if (reach === 'open') {
144
+ const host = actor.hostInside && actor.hostInside.over === room && !room.offlineServer ? actor.hostInside : undefined;
145
+ if (host) {
146
+ // YOU CANNOT COPY WHAT YOU HAVE NOT SEEN (edit-file.ts's rule):
147
+ // the archive is listed once a Matrix Search turned it up, or the
148
+ // host is cracked.
149
+ if (host.searchedBy.has(actor.name) || host.cracked) {
150
+ for (const item of host.files())
151
+ pullable.push({ item, kind: 'host', room, host });
152
+ }
153
+ }
154
+ else {
155
+ for (const item of room.inventory.getAllItems()) {
156
+ if (item.plane === 'matrix')
157
+ pullable.push({ item, kind: 'loose', room });
158
+ }
159
+ }
160
+ }
70
161
  // FARTHER OUT (SR5 pp.217-218): data loose on the grid elsewhere in the
71
162
  // district is a dim icon in reach -- open where no host guards it.
72
- const far = [];
73
163
  if (actor.plane === 'matrix' && !actor.insideHost) {
74
164
  for (const { room: r, noise } of roomsInReach(this.rooms ?? this.scene.getRooms(), actor).slice(1)) {
75
- if (fileReach(actor, r) !== 'open')
165
+ if (fileReach(actor, r) !== 'open' || r.hasNode)
76
166
  continue;
77
167
  for (const item of r.inventory.getAllItems())
78
168
  if (item.plane === 'matrix')
79
- far.push({ item, room: r, noise });
169
+ pullable.push({ item, kind: 'far', room: r, noise });
80
170
  }
81
171
  }
82
172
  // YOUR sprites, not everyone's. `compiledBy` is not modelled on the
83
- // shell, so ownership is "it is in YOUR companion list" -- which is
84
- // the same thing the order/decompile verbs already mean by it.
85
- const mine = (this.game?.companions ?? []).filter(c => c.kind === 'sprite');
86
- const carried = [];
87
- for (const entry of mine) {
173
+ // shell, so ownership is "it is in YOUR companion list" -- the same
174
+ // thing the order/decompile verbs already mean by it.
175
+ for (const entry of (this.game?.companions ?? []).filter(c => c.kind === 'sprite')) {
88
176
  for (const item of entry.npc.inventory.getAllItems()) {
89
177
  if (item.plane === 'matrix')
90
- carried.push({ npc: entry.npc, item });
178
+ pullable.push({ item, kind: 'sprite', npc: entry.npc });
91
179
  }
92
180
  }
93
- if (loose.length === 0 && carried.length === 0 && far.length === 0) {
94
- return `Nothing to pull down -- no data on the grid here, and nothing your sprites are carrying.${reach === 'enter' ? hint(` (This room's files live INSIDE its host -- "enter" first.)`) : ''}`;
181
+ return { pullable, reach };
182
+ }
183
+ /**
184
+ * Pulls one file: the gates, the bill, the roll, the copy. `ok` is true
185
+ * only when a file actually landed -- take-all stops on a refusal that
186
+ * is about the ACTOR (out of actions), not the file.
187
+ */
188
+ pull(source, target) {
189
+ const actor = this.actor;
190
+ const item = source.item;
191
+ // A LOOSE END IS ERASED, NOT DOWNLOADED: the desk keeps its copy
192
+ // whatever comes down to yours (win-condition-seed.ts 'erase').
193
+ const wc = this.scene.getWinCondition?.();
194
+ if (wc?.type === 'erase' && wc.item?.toLowerCase() === item.name.toLowerCase()) {
195
+ return { ok: false, text: `Copying ${item.name} changes nothing -- the desk keeps its own. ${hint(`"erase ${item.name}" is what makes it go away (Edit File, p.239).`)}` };
95
196
  }
96
- // Bare "download" is the LIST. A verb whose whole reason for existing
97
- // is that nobody could find the file should say what it can see.
98
- const query = args.join(' ').trim();
99
- if (query.length === 0) {
100
- const lines = [`{light-blue-fg}Within reach to pull down:{/light-blue-fg}`];
101
- for (const i of loose)
102
- lines.push(` ${planeTintItemName(i, actor.plane)} -- loose on the grid here`);
103
- for (const c of carried)
104
- lines.push(` ${planeTintItemName(c.item, actor.plane)} -- held by ${c.npc.name}`);
105
- for (const f of far)
106
- lines.push(` ${planeTintItemName(f.item, actor.plane)} -- a dim icon out on the grid (noise -${f.noise})`);
107
- lines.push(hint(`("download <file>" writes it to a chip you carry.)`));
108
- return lines.join('\n');
197
+ // PROTECTED FILES (p.239: "if the file you want to copy has protection
198
+ // on it, this action automatically fails"). The one protection the
199
+ // engine models is a sprite's Hash (p.257) -- your own sprite's file
200
+ // is yours to move.
201
+ if (item.hashedBy && source.kind !== 'sprite') {
202
+ return { ok: false, text: `${item.name} is wrapped in a Resonance algorithm -- a protected file, and Edit File fails on one outright (p.239). ${hint(`(Only ${item.hashedBy} unseals it.)`)}` };
109
203
  }
110
- const names = [...loose.map(i => i.name), ...carried.map(c => c.item.name), ...far.map(f => f.item.name)];
111
- const picked = fuzzyPickName(query, names);
112
- if (!picked) {
113
- return `No file called "${query}" within reach.${hint(` ("download" on its own lists what is.)`)}`;
204
+ const device = target ?? libraryDevice(actor);
205
+ if (device && deviceHas(device, item.name)) {
206
+ return { ok: false, text: `Your ${device.name} already holds ${item.name}.` };
114
207
  }
115
- const fromSprite = carried.find(c => c.item.name === picked);
116
- const fromFar = !fromSprite && !loose.some(i => i.name === picked) ? far.find(f => f.item.name === picked) : undefined;
117
- const item = fromSprite ? fromSprite.item : fromFar ? fromFar.item : loose.find(i => i.name === picked);
118
- // A LOOSE END IS ERASED, NOT DOWNLOADED (take.ts has the same gate):
119
- // the desk keeps its copy whatever comes down to your chip.
120
- const wc = this.scene.getWinCondition?.();
121
- if (wc?.type === 'erase' && wc.item?.toLowerCase() === item.name.toLowerCase()) {
122
- return `Pulling ${item.name} down changes nothing -- the desk keeps its own. ${hint(`"erase ${item.name}" is what makes it go away (Edit File, p.239).`)}`;
123
- }
124
- // COPYING A FILE IS EDIT FILE, AND EDIT FILE IS A COMPLEX ACTION
125
- // (p.239: "Edit File allows you to create, change, COPY, delete, or
126
- // protect any kind of file"). The no-dice ruling above stands -- the
127
- // host crack already charged for the obstacle -- but "not a test"
128
- // was silently read as "not an action", and in a Combat Turn that is
129
- // a different claim entirely. Found in jfjjTmJ7NYwDaFiyB's own
130
- // bench: walking into a rating-3 host wakes Patrol IC, `search` is
131
- // correctly refused ("a Matrix Search takes minutes ... a Combat
132
- // Turn is three seconds") and `download` then emptied the archive
133
- // for FREE with the ice mid-turn on the runner. That makes the fight
134
- // the report asked for optional -- stroll in, take the paydata, and
135
- // the ice never mattered.
136
- //
137
- // billAction is a no-op outside an encounter, so the quiet case --
138
- // an empty host, a file loose on the grid -- is untouched: still one
139
- // verb, no roll, no friction. It bills only where an action economy
140
- // exists to bill against, and a refusal returns BEFORE the item
141
- // moves, so a runner out of actions keeps both the file and the turn.
208
+ // THE HOST'S GATE: cracked opens it (hack.ts); otherwise Edit File
209
+ // wants a mark (p.239) before it can be tried at all.
210
+ const host = source.kind === 'host' ? source.host : undefined;
211
+ const needsRoll = !!host && !host.cracked;
212
+ if (host && needsRoll && (host.marksBy.get(actor.name) ?? 0) < 1) {
213
+ return { ok: false, text: `Edit File needs a MARK (p.239) -- you hold none on ${hostLabel(host.over)}. ${hint(`"hack" the host for one.`)}` };
214
+ }
215
+ if (needsRoll) {
216
+ const burned = editFileBlocked(actor);
217
+ if (burned)
218
+ return { ok: false, text: burned, stop: true };
219
+ }
220
+ // COPYING A FILE IS EDIT FILE, A COMPLEX ACTION (p.239). billAction
221
+ // is a no-op outside an encounter; a refusal returns BEFORE anything
222
+ // moves, so a runner out of actions keeps the turn (jfjjTmJ7NYwDaFiyB:
223
+ // an archive emptied for free with ice mid-turn).
142
224
  const bill = billAction(this.scene, actor, 'complex', 'Edit File (copy)');
143
225
  if (bill)
144
- return bill;
145
- // THE CONVERSION, identical to take.ts's. Same two fields, because a
146
- // file that came down by one verb and a file that came down by the
147
- // other must be the same object afterwards -- a chip you can `give`.
148
- try {
149
- actor.addInventory(item);
226
+ return { ok: false, text: bill, stop: true };
227
+ if (host && needsRoll) {
228
+ const roll = rollEditFile(this.scene, this.logger, actor, host, 'copy');
229
+ if (!roll.success) {
230
+ actor.performAction('edits a file', `${item.name} in ${hostLabel(host.over)}`, { quiet: this.scene.isHumanControlled(actor) });
231
+ this.logger.write(`${actor.name} failed to copy "${item.name}" from ${host.over.name}: ${roll.hits} v ${roll.defence}.`);
232
+ return { ok: false, text: `The copy stalls -- ${hostLabel(host.over)}'s checksum rejects the read and nothing comes down. A failed edit is not a failed sleaze; nobody upstairs noticed.${hint(` (Try again; each attempt is a Complex Action.)`)}` };
233
+ }
234
+ }
235
+ // THE MOVE. A sprite's file is already yours and moves; everything
236
+ // else is a copy you now own (p.239), the original left in place.
237
+ let landed;
238
+ if (source.kind === 'sprite') {
239
+ source.npc.inventory.removeItem(item.name);
240
+ landed = item;
150
241
  }
151
- catch {
152
- return `You can't carry any more -- ${item.name} stays where it is.`;
242
+ else {
243
+ landed = copyFile(item, actor.name);
244
+ // Copying a file off the floor of the grid leaves the icon where it
245
+ // was, but you have now SEEN it -- nothing about plainSight changes.
153
246
  }
154
- if (fromSprite) {
155
- fromSprite.npc.inventory.removeItem(item.name);
247
+ let onto;
248
+ if (device) {
249
+ fileOnto(device, landed);
250
+ actor.noteConditionChanged();
251
+ onto = device;
156
252
  }
157
253
  else {
158
- const source = fromFar ? fromFar.room : room;
159
- source.inventory.removeItem(item.name);
160
- source.plainSightItems.delete(item.name.toLowerCase());
161
- }
162
- item.plane = 'meat';
163
- item.category = Category.Datachip;
164
- actor.performAction('pulls a file down', `${item.name} writing to a chip`);
165
- this.logger.write(`${actor.name} downloaded "${item.name}"${fromSprite ? ` from ${fromSprite.npc.name}` : ` from ${room.name}`}.`);
254
+ onto = receiveData(actor, landed).device;
255
+ }
256
+ actor.performAction('copies a file', `${item.name}${onto ? ` onto ${onto.name}` : ' to a chip'}`, { quiet: this.scene.isHumanControlled(actor) });
257
+ this.logger.write(`${actor.name} downloaded "${item.name}" (${source.kind}) ${onto ? `onto ${onto.name}` : 'to a chip'}.`);
258
+ this.game?.requestSave('data');
166
259
  this.scene.updateStatus();
167
- return fromSprite
168
- ? [
169
- `{light-blue-fg}${fromSprite.npc.name} opens its hands and the data pours out of them.{/light-blue-fg}`,
170
- `You pull {bold}{underline}${item.name}{/underline}{/bold} down onto a chip -- meat-world cargo now.${hint(` (You can "give ${item.name}" to somebody standing in front of you.)`)}`,
171
- ].join('\n')
172
- : [
173
- `{light-blue-fg}You pull {bold}{underline}${item.name}{/underline}{/bold} down off the grid -- written to a chip.{/light-blue-fg}`,
174
- hint(`(Meat-world cargo now: carry it, stash it, or "give" it to somebody.)`),
175
- ].join('\n');
260
+ const text = onto
261
+ ? source.kind === 'sprite'
262
+ ? `{light-blue-fg}${source.npc.name} opens its hands and the data pours out of them -- {bold}{underline}${item.name}{/underline}{/bold} is on your ${onto.name} now.{/light-blue-fg}`
263
+ : `{light-blue-fg}You copy {bold}{underline}${item.name}{/underline}{/bold} onto your ${onto.name}${host ? ` -- ${hostLabel(host.over)} keeps its original` : ''}.{/light-blue-fg}${hint(` ("give" or "call" hands it over straight from the device; "transfer ${item.name} to chip" writes it to physical media.)`)}`
264
+ : `{light-blue-fg}You pull {bold}{underline}${item.name}{/underline}{/bold} down onto a chip -- you carry no device with a library to hold it.{/light-blue-fg}`;
265
+ // 'possess' objectives complete in the runner's hands; a device library
266
+ // is the runner's hands for data (scene.ts checkWinCondition).
267
+ if (this.scene.isHumanControlled(actor))
268
+ this.logger.log(text, { actor: actor.name });
269
+ if (!this.scene.isWinConditionTarget?.(actor.name)) {
270
+ this.scene.checkWinCondition?.(item.name, actor.name);
271
+ }
272
+ return { ok: true, text: this.scene.isHumanControlled(actor) ? '' : text };
273
+ }
274
+ /** "take all" on the Matrix plane: every file in reach here, one Edit File each. */
275
+ pullAll() {
276
+ const { pullable, reach } = this.inReach();
277
+ const here = pullable.filter(p => p.kind === 'host' || p.kind === 'loose');
278
+ if (here.length === 0) {
279
+ return reach === 'enter'
280
+ ? `Those files are INSIDE the host, and you're out on the grid looking at its shell (p.246). "enter" first.`
281
+ : `Nothing here to copy${this.actor.hostInside && !this.actor.hostInside.cracked && !this.actor.hostInside.searchedBy.has(this.actor.name) ? ` that you have found -- "search" runs a Matrix Search (p.241)` : ''}.`;
282
+ }
283
+ const lines = [];
284
+ for (const p of here) {
285
+ const r = this.pull(p);
286
+ if (r.text)
287
+ lines.push(r.text);
288
+ if (r.stop)
289
+ break;
290
+ }
291
+ return lines.join('\n');
176
292
  }
177
293
  }
178
294
  //# sourceMappingURL=download.js.map
@@ -1,12 +1,9 @@
1
1
  import { Command } from './command.js';
2
- import { gridActionModifier } from '../utilities/grids.js';
3
2
  import { hint } from '../utilities/hints.js';
4
- import { rollPool, formatRoll } from '../utilities/dice.js';
5
3
  import { billAction } from '../utilities/action-cost.js';
6
- import { hostDefensePool } from '../utilities/matrix-intrusion.js';
4
+ import { editFileBlocked, rollEditFile } from '../utilities/edit-file-test.js';
7
5
  import { hostLabel } from '../utilities/grid-names.js';
8
6
  import { fuzzyPickName } from '../utilities/fuzzy-match.js';
9
- import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-audience.js';
10
7
  /**
11
8
  * EDIT FILE (SR5 p.239, RAG-checked 2026-09-14): a Complex Action, Marks
12
9
  * Required 1, Computer + Logic [Data Processing] v. Intuition + Firewall.
@@ -21,9 +18,10 @@ import { showsMechanics, mechanicsActorPrefix } from '../utilities/mechanics-aud
21
18
  *
22
19
  * WHAT SHIPS (loose ends, 2026-09-14): DELETE. The cleanup's objective
23
20
  * is a file the site keeps -- camera footage with the runner's face --
24
- * and copying it (the engine's `take` / `download`, which already
25
- * converts a matrix file to a chip) changes nothing about what the desk
26
- * still holds. Create, change and protect are named here as not built;
21
+ * and copying it changes nothing about what the desk still holds. COPY
22
+ * and CREATE shipped 2026-10-04 as "download" and "upload" (digital
23
+ * inventory), on the same roll (utilities/edit-file-test.ts); this verb
24
+ * routes "edit copy" / "edit create" to them. Change and protect are not built;
27
25
  * Crack File and the Data Bomb family wait on a per-file protection
28
26
  * rating (models/item.ts) and are their own item.
29
27
  *
@@ -64,9 +62,21 @@ export class EditFileCommand extends Command {
64
62
  }).execute(args);
65
63
  }
66
64
  const { op, word, rest } = this.operation(args);
65
+ // COPY and CREATE are built, under the verbs a player reaches for
66
+ // (digital inventory, 2026-10-04): copy is "download" (host to your
67
+ // device), create is "upload" (your device to the host). Same roll.
68
+ if (word === 'copy' || word === 'create') {
69
+ const ctx = { actor: this.actor, scene: this.scene, rooms: this.rooms, screen: this.screen, game: this.game };
70
+ if (word === 'copy') {
71
+ const { DownloadCommand } = await import('./download.js');
72
+ return new DownloadCommand(ctx).execute(rest);
73
+ }
74
+ const { UploadCommand } = await import('./upload.js');
75
+ return new UploadCommand(ctx).execute(rest);
76
+ }
67
77
  if (op !== 'delete') {
68
78
  const named = word ? `"${word}"` : 'that';
69
- return `Edit File (p.239) can create, change, copy, delete or protect a file -- and this engine builds DELETE: "edit delete <file>" (or "erase <file>"). ${word === 'copy' ? 'Copying is "take" or "download" -- the file comes down to a chip.' : `${named} isn't built yet.`}`;
79
+ return `Edit File (p.239) can create, change, copy, delete or protect a file -- and this engine builds DELETE ("erase <file>"), COPY ("download <file>") and CREATE ("upload <file>"). ${named} isn't built yet.`;
70
80
  }
71
81
  if (actor.plane !== 'matrix') {
72
82
  return `Files are edited by a persona. ${hint(`"jack in" first.`)}`;
@@ -103,37 +113,22 @@ export class EditFileCommand extends Command {
103
113
  return `Edit File needs a MARK (p.239) -- you hold none on ${hostLabel(room)}. ${hint(`"hack" the host for one.`)}`;
104
114
  }
105
115
  // Binder IC eats Data Processing; a bracket of 0 permits no hits.
106
- const burned = actor.burnedAttributeRefusal('dataProcessing');
116
+ const burned = editFileBlocked(actor);
107
117
  if (burned)
108
118
  return burned;
109
119
  const bill = billAction(this.scene, actor, 'complex', 'Edit File');
110
120
  if (bill)
111
121
  return bill;
112
- const computer = actor.skillRating('computer');
113
- // Inside a host no grid penalty applies (p.246); silence still taxes
114
- // every Matrix action (p.235-236, Player.matrixActionPenalty).
115
- // The grid tax was NAMED here and never charged -- gridActionModifier
116
- // is the helper that answers both halves, returning 0 inside a host
117
- // and the public grid's -2 out on it (p.233).
118
- const grid = gridActionModifier(actor.currentGrid, !!actor.hostInside, actor.currentGrid);
119
- const pool = Math.max(1, actor.logic + (computer > 0 ? computer : -1)
120
- + actor.matrixActionPenalty + grid.total + actor.woundModifier - actor.sustainingPenalty);
121
- const limit = actor.matrixAttribute('dataProcessing');
122
- const defence = hostDefensePool(host);
123
- const attempt = rollPool(pool, limit, { gremlins: actor.deckGremlins });
124
- const resist = rollPool(defence.pool);
125
- if (showsMechanics(this.scene, actor)) {
126
- this.logger.meta(`${mechanicsActorPrefix(this.scene, actor)}Edit File (delete) -- Computer + Logic [Data Processing]: ${formatRoll(attempt)}`);
127
- this.logger.meta(` v. ${hostLabel(room)} (${defence.label}, rating standing in for Intuition): ${formatRoll(resist)}`);
128
- }
122
+ // The roll is utilities/edit-file-test.ts, shared with download (copy)
123
+ // and upload (create) so the family cannot drift apart.
124
+ const attempt = rollEditFile(this.scene, this.logger, actor, host, 'delete');
129
125
  actor.performAction('edits a file', `${file.name} in ${hostLabel(room)}`, { quiet: this.scene.isHumanControlled(actor) });
130
- if (attempt.hits <= resist.hits) {
131
- this.logger.write(`${actor.name} failed to delete "${file.name}" in ${room.name}: ${attempt.hits} v ${resist.hits}.`);
126
+ if (!attempt.success) {
127
+ this.logger.write(`${actor.name} failed to delete "${file.name}" in ${room.name}: ${attempt.hits} v ${attempt.defence}.`);
132
128
  return `The file's checksum holds -- your edit rolls off it and the archive keeps ${file.name}. Nothing upstairs noticed: a failed edit is not a failed sleaze.${hint(` (Try again; each attempt is a Complex Action.)`)}`;
133
129
  }
134
- room.inventory.removeItem(file.name);
135
- room.plainSightItems.delete(file.name.toLowerCase());
136
- this.logger.write(`${actor.name} deleted "${file.name}" from ${room.name}'s host: ${attempt.hits} v ${resist.hits}.`);
130
+ host.removeFile(file.name);
131
+ this.logger.write(`${actor.name} deleted "${file.name}" from ${room.name}'s host: ${attempt.hits} v ${attempt.defence}.`);
137
132
  this.scene.addWorldEvent(`${file.name} was erased from ${host.name}'s archive.`, { plane: 'matrix' });
138
133
  const banner = this.scene.checkErased?.(file.name, actor.name);
139
134
  this.scene.updateStatus();
@@ -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`);