@maka/maka-cli 5.378.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.
- package/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +16 -3
- package/bundle/typescript/src/commands/game/sideQuest/commands/download.js +248 -132
- package/bundle/typescript/src/commands/game/sideQuest/commands/edit-file.js +26 -31
- package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +24 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/inv.js +14 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +12 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +21 -128
- package/bundle/typescript/src/commands/game/sideQuest/commands/transfer.js +92 -0
- package/bundle/typescript/src/commands/game/sideQuest/commands/upload.js +108 -0
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +14 -1
- package/bundle/typescript/src/commands/game/sideQuest/factories/job-order.js +28 -4
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +125 -32
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +10 -0
- package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/game.js +24 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/host.js +26 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/item.js +16 -0
- package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +37 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/data-library.js +158 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/data-work.js +4 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/edit-file-test.js +31 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/fixer-menu.js +10 -8
- package/bundle/typescript/src/commands/game/sideQuest/utilities/gear-tree.js +13 -1
- package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +4 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/verb-index.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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(` •
|
|
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
|
-
|
|
283
|
-
|
|
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
|
-
|
|
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,18 @@
|
|
|
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
|
-
|
|
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
|
+
export const ENGINE_VERSION = '1.191.0';
|
|
3239
3252
|
//# 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
|
-
/**
|
|
22
|
-
*
|
|
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;
|