@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maka/maka-cli",
3
- "version": "5.378.0",
3
+ "version": "5.379.1",
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,318 @@
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 } from '../utilities/data-library.js';
11
+ import { NPC } from '../models/npc.js';
8
12
  /**
9
- * "download" -- WRITE A FILE OUT OF THE GRID AND ONTO SOMETHING YOU CAN
10
- * HAND OVER.
13
+ * "download" -- COPY A FILE OFF THE GRID INTO YOUR DEVICE'S LIBRARY.
11
14
  *
12
- * Two reports, one hole:
15
+ * DIGITAL INVENTORY (user ruling 2026-10-04) replaces the chip. A file
16
+ * used to come down as a Datachip in the pack (the 2026-08-30 ruling);
17
+ * now it is written to the library of the device carrying your persona
18
+ * (utilities/data-library.ts), and it is a COPY -- Edit File, p.239:
19
+ * "create, change, COPY, delete ... If you use this action to copy a
20
+ * file, you are the new file's owner." The host keeps its original, so a
21
+ * job that wants the file GONE is still "erase".
13
22
  *
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."
23
+ * WHERE FROM. Files inside the host you stand in; files loose on the grid
24
+ * where you are; dim icons out in the district (pp.217-218); and files
25
+ * your own compiled sprites are holding -- the one source nothing else
26
+ * could reach (aNeh5TELvdgeS2vpg, MxiKYFGeStiyvibJN). A sprite's file is
27
+ * already yours, so it MOVES to your device rather than copying.
17
28
  *
18
- * MxiKYFGeStiyvibJN (Maka): "there needs to be a way to move data from
19
- * matrix to meatworld in the form of 'download'."
29
+ * THE TEST (p.239): Computer + Logic [Data Processing] v. the host,
30
+ * Marks Required 1 -- the same roll erase makes (utilities/edit-file-test.ts).
31
+ * Two arms stay free of dice, both deliberate:
32
+ * - a CRACKED host (three marks, hack.ts): its architecture treats you
33
+ * as staff, and its own files open with it -- the line hack.ts draws;
34
+ * - a file not on any host (loose on the grid, or your sprite's): the
35
+ * defender would be the file's owner, and an unowned or self-owned
36
+ * file has nobody to roll. Canon names no defender here; none is
37
+ * invented.
38
+ * Before 2026-10-04 this verb never rolled and never asked for a crack,
39
+ * so it read an uncracked host's archive that `take` refused -- the two
40
+ * now share one path (take routes matrix-plane pickups here).
20
41
  *
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.
42
+ * THE TIME: a Complex Action (p.239) wherever an action economy exists.
26
43
  *
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.
44
+ * "download <file> to <device>" picks the library; without it the file
45
+ * lands on the persona's device. A runner with no data device at all is
46
+ * refused (p.250-251: a living persona has no storage) -- see pull().
51
47
  */
52
48
  export class DownloadCommand extends Command {
53
49
  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.';
50
+ 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
51
  async execute(args = []) {
52
+ const actor = this.actor;
53
+ let words = args.join(' ').trim();
54
+ // "... to <device>": split on the LAST " to ", and only when the tail
55
+ // names a data device you carry -- a file called "Letter to Krow"
56
+ // still downloads whole.
57
+ let target;
58
+ const toMatch = words.match(/^(.*)\s+(?:to|onto|into)\s+(.+)$/i);
59
+ if (toMatch) {
60
+ const device = findDataDevice(actor, toMatch[2]);
61
+ if (device) {
62
+ target = device;
63
+ words = toMatch[1].trim();
64
+ }
65
+ }
66
+ const { pullable, reach } = this.inReach();
67
+ if (pullable.length === 0 && reach === 'connect') {
68
+ const room = gridVicinity(actor);
69
+ 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).`;
70
+ }
71
+ if (pullable.length === 0 && !words) {
72
+ 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.)`) : ''}`;
73
+ }
74
+ // Bare "download" is the LIST. A verb whose whole reason for existing
75
+ // is that nobody could find the file should say what it can see.
76
+ if (words.length === 0) {
77
+ const lines = [`{light-blue-fg}Within reach to pull down:{/light-blue-fg}`];
78
+ for (const p of pullable) {
79
+ const where = p.kind === 'host' ? `in ${hostLabel(p.host.over)}'s archive`
80
+ : p.kind === 'sprite' ? `held by ${p.npc.name}`
81
+ : p.kind === 'far' ? `a dim icon out on the grid (noise -${p.noise})`
82
+ : 'loose on the grid here';
83
+ lines.push(` ${planeTintItemName(p.item, actor.plane)} -- ${where}`);
84
+ }
85
+ const home = libraryDevice(actor);
86
+ lines.push(hint(home ? `("download <file>" copies it onto your ${home.name}.)` : `(You carry no device to copy onto -- a living persona has no storage, p.250-251.)`));
87
+ return lines.join('\n');
88
+ }
89
+ const picked = fuzzyPickName(words, pullable.map(p => p.item.name));
90
+ const source = picked ? pullable.find(p => p.item.name === picked) : undefined;
91
+ if (!source) {
92
+ // IN THE ARCHIVE, NOT YET FOUND: the host holds it, but nothing has
93
+ // turned the archive up for this persona (search, or a crack).
94
+ // OUT ON THE GRID, OUTSIDE THE HOST THAT HOLDS IT (p.246): the shell
95
+ // is all a persona reads from out here.
96
+ const here = actor.plane === 'matrix' ? gridVicinity(actor) : actor.currentLocation;
97
+ if (reach === 'enter' && here.host && fuzzyPickName(words, here.host.files().map(f => f.name))) {
98
+ 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.`}`;
99
+ }
100
+ const inside = actor.plane === 'matrix' ? actor.hostInside : undefined;
101
+ if (inside && fuzzyPickName(words, inside.files().map(f => f.name))) {
102
+ 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.`)}`;
103
+ }
104
+ const elsewhere = this.whereElse(words);
105
+ if (elsewhere)
106
+ return elsewhere;
107
+ return `No file called "${words}" within reach.${hint(` ("download" on its own lists what is.)`)}`;
108
+ }
109
+ const out = this.pull(source, target);
110
+ return out.text;
111
+ }
112
+ /**
113
+ * A FILE ELSEWHERE IN THE DISTRICT (matrix model): named from the grid,
114
+ * the answer is where it is and what stands in the way -- the offline
115
+ * server that needs your body, the host that needs a mark. Moved here
116
+ * from take.ts with the rest of the matrix pickup.
117
+ */
118
+ whereElse(query) {
119
+ const actor = this.actor;
120
+ if (actor.plane !== 'matrix' || actor.insideHost)
121
+ return undefined;
122
+ const room = gridVicinity(actor);
123
+ const far = whereIsThat(this.rooms ?? this.scene.getRooms(), actor, query);
124
+ if (!far || far.room === room || !(far.reach === 'open' || far.remembered))
125
+ return undefined;
126
+ if (far.reach === 'offline')
127
+ 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).`;
128
+ if (far.reach === 'host')
129
+ 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.`}`;
130
+ return undefined;
131
+ }
132
+ /**
133
+ * Every file this persona can pull from where it stands, with the
134
+ * reason the room's own files are or are not in reach.
135
+ */
136
+ inReach() {
56
137
  const actor = this.actor;
57
138
  // WHERE THE PERSONA STANDS (matrix model 2026-09-02): inside a host, the host's room; on the grid, the body's.
58
139
  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
140
  const reach = fileReach(actor, room);
67
- const loose = reach === 'open'
68
- ? room.inventory.getAllItems().filter(i => i.plane === 'matrix')
69
- : [];
141
+ const pullable = [];
142
+ // The room's own files honour fileReach (utilities/planes.ts): a host's
143
+ // contents are inside the host, and an offline server wants a body.
144
+ if (reach === 'open') {
145
+ const host = actor.hostInside && actor.hostInside.over === room && !room.offlineServer ? actor.hostInside : undefined;
146
+ if (host) {
147
+ // YOU CANNOT COPY WHAT YOU HAVE NOT SEEN (edit-file.ts's rule):
148
+ // the archive is listed once a Matrix Search turned it up, or the
149
+ // host is cracked.
150
+ if (host.searchedBy.has(actor.name) || host.cracked) {
151
+ for (const item of host.files())
152
+ pullable.push({ item, kind: 'host', room, host });
153
+ }
154
+ }
155
+ else {
156
+ for (const item of room.inventory.getAllItems()) {
157
+ if (item.plane === 'matrix')
158
+ pullable.push({ item, kind: 'loose', room });
159
+ }
160
+ }
161
+ }
70
162
  // FARTHER OUT (SR5 pp.217-218): data loose on the grid elsewhere in the
71
163
  // district is a dim icon in reach -- open where no host guards it.
72
- const far = [];
73
164
  if (actor.plane === 'matrix' && !actor.insideHost) {
74
165
  for (const { room: r, noise } of roomsInReach(this.rooms ?? this.scene.getRooms(), actor).slice(1)) {
75
- if (fileReach(actor, r) !== 'open')
166
+ if (fileReach(actor, r) !== 'open' || r.hasNode)
76
167
  continue;
77
168
  for (const item of r.inventory.getAllItems())
78
169
  if (item.plane === 'matrix')
79
- far.push({ item, room: r, noise });
170
+ pullable.push({ item, kind: 'far', room: r, noise });
80
171
  }
81
172
  }
82
173
  // 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) {
174
+ // shell, so ownership is "it is in YOUR companion list" -- the same
175
+ // thing the order/decompile verbs already mean by it.
176
+ for (const entry of (this.game?.companions ?? []).filter(c => c.kind === 'sprite')) {
88
177
  for (const item of entry.npc.inventory.getAllItems()) {
89
178
  if (item.plane === 'matrix')
90
- carried.push({ npc: entry.npc, item });
179
+ pullable.push({ item, kind: 'sprite', npc: entry.npc });
91
180
  }
92
181
  }
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.)`) : ''}`;
182
+ return { pullable, reach };
183
+ }
184
+ /**
185
+ * Pulls one file: the gates, the bill, the roll, the copy. `ok` is true
186
+ * only when a file actually landed -- take-all stops on a refusal that
187
+ * is about the ACTOR (out of actions), not the file.
188
+ */
189
+ pull(source, target) {
190
+ const actor = this.actor;
191
+ const item = source.item;
192
+ // A LOOSE END IS ERASED, NOT DOWNLOADED: the desk keeps its copy
193
+ // whatever comes down to yours (win-condition-seed.ts 'erase').
194
+ const wc = this.scene.getWinCondition?.();
195
+ if (wc?.type === 'erase' && wc.item?.toLowerCase() === item.name.toLowerCase()) {
196
+ 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
197
  }
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');
198
+ // PROTECTED FILES (p.239: "if the file you want to copy has protection
199
+ // on it, this action automatically fails"). The one protection the
200
+ // engine models is a sprite's Hash (p.257) -- your own sprite's file
201
+ // is yours to move.
202
+ if (item.hashedBy && source.kind !== 'sprite') {
203
+ 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
204
  }
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.)`)}`;
205
+ const device = target ?? libraryDevice(actor);
206
+ if (device && deviceHas(device, item.name)) {
207
+ return { ok: false, text: `Your ${device.name} already holds ${item.name}.` };
114
208
  }
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.
209
+ // NO STORAGE, NO COPY (SR5 p.250-251, RAG-checked 2026-10-04): "your
210
+ // living persona is just a persona, not a device, you don't have any
211
+ // on-board storage; ... you can store files in nearby devices." A
212
+ // persona with no device to write to has nowhere for the copy to go
213
+ // -- and a datachip in the pack has no wireless for a mind on the grid
214
+ // to write through. In practice this is the technomancer with no
215
+ // commlink: every other runner needed a device to jack in at all.
216
+ // Before 1.191.1 this fell back to a chip, which canon never allowed.
136
217
  //
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.
218
+ // An NPC persona (a sprite, an agent) carries what it takes in its own
219
+ // hands -- the shelf download.ts already reads from a sprite.
220
+ const npcHands = !device && actor instanceof NPC;
221
+ if (!device && !npcHands) {
222
+ return {
223
+ ok: false,
224
+ text: actor.isTechnomancer()
225
+ ? `Your living persona is a persona, not a device -- it has no storage to copy ${item.name} into (p.250-251). ${hint(`Carry a commlink, deck or RCC and the copy lands there; reading files is not riding the hardware, so your Resonance stays yours.`)}`
226
+ : `You carry no commlink, deck or RCC to copy ${item.name} onto.`,
227
+ };
228
+ }
229
+ // THE HOST'S GATE: cracked opens it (hack.ts); otherwise Edit File
230
+ // wants a mark (p.239) before it can be tried at all.
231
+ const host = source.kind === 'host' ? source.host : undefined;
232
+ const needsRoll = !!host && !host.cracked;
233
+ if (host && needsRoll && (host.marksBy.get(actor.name) ?? 0) < 1) {
234
+ return { ok: false, text: `Edit File needs a MARK (p.239) -- you hold none on ${hostLabel(host.over)}. ${hint(`"hack" the host for one.`)}` };
235
+ }
236
+ if (needsRoll) {
237
+ const burned = editFileBlocked(actor);
238
+ if (burned)
239
+ return { ok: false, text: burned, stop: true };
240
+ }
241
+ // COPYING A FILE IS EDIT FILE, A COMPLEX ACTION (p.239). billAction
242
+ // is a no-op outside an encounter; a refusal returns BEFORE anything
243
+ // moves, so a runner out of actions keeps the turn (jfjjTmJ7NYwDaFiyB:
244
+ // an archive emptied for free with ice mid-turn).
142
245
  const bill = billAction(this.scene, actor, 'complex', 'Edit File (copy)');
143
246
  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);
247
+ return { ok: false, text: bill, stop: true };
248
+ if (host && needsRoll) {
249
+ const roll = rollEditFile(this.scene, this.logger, actor, host, 'copy');
250
+ if (!roll.success) {
251
+ actor.performAction('edits a file', `${item.name} in ${hostLabel(host.over)}`, { quiet: this.scene.isHumanControlled(actor) });
252
+ this.logger.write(`${actor.name} failed to copy "${item.name}" from ${host.over.name}: ${roll.hits} v ${roll.defence}.`);
253
+ 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.)`)}` };
254
+ }
255
+ }
256
+ // THE MOVE. A sprite's file is already yours and moves; everything
257
+ // else is a copy you now own (p.239), the original left in place.
258
+ let landed;
259
+ if (source.kind === 'sprite') {
260
+ source.npc.inventory.removeItem(item.name);
261
+ landed = item;
150
262
  }
151
- catch {
152
- return `You can't carry any more -- ${item.name} stays where it is.`;
263
+ else {
264
+ landed = copyFile(item, actor.name);
265
+ // Copying a file off the floor of the grid leaves the icon where it
266
+ // was, but you have now SEEN it -- nothing about plainSight changes.
153
267
  }
154
- if (fromSprite) {
155
- fromSprite.npc.inventory.removeItem(item.name);
268
+ let onto;
269
+ if (device) {
270
+ fileOnto(device, landed);
271
+ actor.noteConditionChanged();
272
+ onto = device;
156
273
  }
157
274
  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}`}.`);
275
+ // An NPC persona's own hands (the refusal above covers everyone else).
276
+ landed.plane = 'matrix';
277
+ landed.atSpot = undefined;
278
+ actor.inventory.addItem(landed);
279
+ }
280
+ actor.performAction('copies a file', `${item.name}${onto ? ` onto ${onto.name}` : ''}`, { quiet: this.scene.isHumanControlled(actor) });
281
+ this.logger.write(`${actor.name} downloaded "${item.name}" (${source.kind}) ${onto ? `onto ${onto.name}` : 'into its own hands'}.`);
282
+ this.game?.requestSave('data');
166
283
  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');
284
+ const text = onto
285
+ ? source.kind === 'sprite'
286
+ ? `{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}`
287
+ : `{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.)`)}`
288
+ : `{light-blue-fg}You take hold of ${item.name}.{/light-blue-fg}`;
289
+ // 'possess' objectives complete in the runner's hands; a device library
290
+ // is the runner's hands for data (scene.ts checkWinCondition).
291
+ if (this.scene.isHumanControlled(actor))
292
+ this.logger.log(text, { actor: actor.name });
293
+ if (!this.scene.isWinConditionTarget?.(actor.name)) {
294
+ this.scene.checkWinCondition?.(item.name, actor.name);
295
+ }
296
+ return { ok: true, text: this.scene.isHumanControlled(actor) ? '' : text };
297
+ }
298
+ /** "take all" on the Matrix plane: every file in reach here, one Edit File each. */
299
+ pullAll() {
300
+ const { pullable, reach } = this.inReach();
301
+ const here = pullable.filter(p => p.kind === 'host' || p.kind === 'loose');
302
+ if (here.length === 0) {
303
+ return reach === 'enter'
304
+ ? `Those files are INSIDE the host, and you're out on the grid looking at its shell (p.246). "enter" first.`
305
+ : `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)` : ''}.`;
306
+ }
307
+ const lines = [];
308
+ for (const p of here) {
309
+ const r = this.pull(p);
310
+ if (r.text)
311
+ lines.push(r.text);
312
+ if (r.stop)
313
+ break;
314
+ }
315
+ return lines.join('\n');
176
316
  }
177
317
  }
178
318
  //# 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();