@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
@@ -6,7 +6,7 @@ import { isOutdoorsPlace } from '../utilities/outdoors.js';
6
6
  import { exitContradictions } from '../utilities/district-plane.js';
7
7
  import { clampDeviceKind } from '../utilities/affordances.js';
8
8
  import { kitSlugForTier } from '../npc-kits.js';
9
- import { SIZE_SECURITY_BANDS, SIZE_HOST_BANDS, scopeFor, postsLieutenant, sizeForTier, highestOpposingPool, payFor, rollTwist, requirementsFor, DATA_RUN_SHARE, objectiveRoomFor, } from './job-order.js';
9
+ import { SIZE_SECURITY_BANDS, SIZE_HOST_BANDS, scopeFor, postsLieutenant, sizeForTier, highestOpposingPool, payFor, rollTwist, requirementsFor, DATA_RUN_SHARE, objectiveRoomFor, isDataCourier, courierMustBeUnseen, } from './job-order.js';
10
10
  export { DATA_RUN_SHARE } from './job-order.js';
11
11
  /**
12
12
  * CHUNKED scene generation (see SceneSeedGenerator.generate for the
@@ -221,7 +221,10 @@ export function settleSecurity(skeleton, campaignTier, _player, crew, order) {
221
221
  // which rolls Host Rating x 2 (p.247-248); a physical job's is the
222
222
  // tier's statblock, lieutenant included when posted.
223
223
  const winItem = skeleton.items.find(i => i.name === skeleton.winCondition?.item);
224
- const vault = winItem?.plane === 'matrix' ? skeleton.rooms.find(r => r.name === winItem.room) : undefined;
224
+ // A data courier's opposition is the DROP host's IC (its target), not
225
+ // the client the cargo starts with.
226
+ const vaultName = winItem?.plane === 'matrix' ? (skeleton.winCondition?.target ?? winItem.room) : undefined;
227
+ const vault = vaultName ? skeleton.rooms.find(r => r.name === vaultName) : undefined;
225
228
  const pool = vault ? 2 * (vault.hostRating ?? VAULT_HOST_RATING) : highestOpposingPool(settled, lieutenant);
226
229
  const heads = partyHeadcount(crew);
227
230
  const wrote = skeleton.winCondition?.payout;
@@ -280,7 +283,27 @@ export function buildSkeletonPrompt(tier, player, previousError, rumor, crew, ca
280
283
  // winCondition must therefore look like -- validateSkeleton holds the
281
284
  // model to exactly that.
282
285
  const kind = order?.kind ?? 'datasteal';
283
- const kindBlock = kind === 'delivery' ? `
286
+ // THE DATA COURIER (user ruling 2026-10-04): a delivery whose package is
287
+ // a file and whose drop is a host (win-condition 'upload').
288
+ const courier = isDataCourier(kind, shape);
289
+ const unseen = courierMustBeUnseen(size);
290
+ const kindBlock = courier ? `
291
+ THIS JOB IS A DATA COURIER RUN (SR5 p.338, delivery -- the package is DATA and the drop is a
292
+ HOST). Build it accordingly:
293
+ - The winCondition item is the CARGO FILE: role "winCondition", "plane": "matrix", category
294
+ Document, "heldBy" the CLIENT -- they push it onto the runner's device at the meet (the engine
295
+ does that the moment the meet is held). Never place it in a room, never in anyone else's hands.
296
+ - winCondition.target is the DROP: a rooms[].name with "hasNode": true and a "hostRating" of
297
+ ${hostBand.min === hostBand.max ? hostBand.min : `${hostBand.min}-${hostBand.max}`} (this is a ${size} job), and NOT the client's room. The runner must get a persona
298
+ INSIDE that host and upload the file there. The host defends itself with its own ice at its
299
+ rating -- write NO "matrix"-plane npc. There is no "toNpc": nobody is handed anything.
300
+ - WHY IT GOES THERE is the fiction: a plant (forged records, a worm, a confession, a backdoor,
301
+ evidence), a dead drop for someone who will come for it, a restore of something stolen.
302
+ ${unseen ? `- THIS IS A HARDER JOB: the plant must land UNSEEN -- the engine fails it if the host is on
303
+ alert when the file arrives. The site should make quiet hard: cameras, a spider who works
304
+ the host, staff who notice a runner where none should be.
305
+ ` : ''}- The client briefs the drop by its room name and says what the file is for.
306
+ ` : kind === 'delivery' ? `
284
307
  THIS JOB IS A DELIVERY (SR5 p.338: "take the thing to the place"). Build it accordingly:
285
308
  - The winCondition item is the CARGO: role "winCondition", "heldBy" the CLIENT -- they hand
286
309
  it over at the meet (the engine does that the moment the meet is held). Never place it in
@@ -340,7 +363,18 @@ ONE OBJECT.
340
363
  - There is NO winCondition item: omit "item" and "toNpc", and write NO items[] entry with
341
364
  role "winCondition".
342
365
  ` : '';
343
- const winConditionSchema = kind === 'extraction' ? `
366
+ const winConditionSchema = courier ? `
367
+ "winCondition": {
368
+ "item": string, // the CARGO FILE: the items[].name with role "winCondition", "plane": "matrix",
369
+ // heldBy the client (see THIS JOB IS A DATA COURIER RUN above)
370
+ "target": string, // the DROP: a rooms[].name with "hasNode": true -- the host it is uploaded to.
371
+ // No "toNpc".
372
+ "payout": number, // the job's pay in nuyen. THE ENGINE SETS THE REAL FIGURE from the
373
+ // site's securityTier (SR5 p.371: 3,000 per runner x the opposition's
374
+ // dice pool / 4, plus the danger modifiers); write ${payFor({ pool: highestOpposingPool(band.min, postsLieutenant(size)), heads: partyHeadcount(crew), hostiles: 0, securityTier: band.min })}
375
+ // here and it will be replaced. It is paid as one lump sum on the wrap.
376
+ "rewardItem"?: string // optional bonus: an items[].name with role "reward"
377
+ },` : kind === 'extraction' ? `
344
378
  "winCondition": {
345
379
  "target": string, // the PERSON: an npcs[].name, never the client -- see THIS JOB IS AN
346
380
  // EXTRACTION above. No "item".
@@ -389,7 +423,7 @@ the runner's own fixer back home, who brokered this meet and is NOT in this scen
389
423
  // fixer" possible at all and why the objective below can be a `give`.
390
424
  // The two halves have to agree about WHERE the file is, so this block
391
425
  // says host-or-offline-server in the same vocabulary room.ts uses.
392
- const dataRunBlock = shape !== 'data' ? '' : `
426
+ const dataRunBlock = shape !== 'data' || courier ? '' : `
393
427
  THIS JOB IS A DATA RUN -- a quiet one. The runner is being paid to STEAL A FILE, not to
394
428
  take ground. Build it accordingly:
395
429
  - The winCondition item is PAYDATA: a "matrix"-plane item, category Document, sitting
@@ -405,9 +439,9 @@ take ground. Build it accordingly:
405
439
  is a smash-and-grab wearing the wrong name. A site can still be WATCHED (cameras) --
406
440
  surveillance is pressure without being a gun.
407
441
  - Keep it SHORT and tight. One site, one host, one file.
408
- - The delivery still matters: the file comes out of the vault written to a chip, and the
409
- chip is a physical object the runner carries and hands over. A "give" objective is the
410
- natural shape.
442
+ - The delivery still matters: the file is copied out of the vault onto the runner's device,
443
+ and handed over from there (or burned to a chip first). A "give" objective is the natural
444
+ shape.
411
445
  `;
412
446
  // THE TWIST (SR5 p.478), epic only: rolled fresh per attempt, and
413
447
  // written onto the skeleton by settleSecurity from the same order so
@@ -711,13 +745,17 @@ Hard structural rules (validated mechanically -- a violation is rejected):
711
745
  ? 'winCondition.target names a real npcs[].name that is NOT "client" and NOT winCondition.toNpc; winCondition.toNpc names a real npcs[].name in a different room; "willing" is a boolean; there is no items[] entry with role "winCondition".'
712
746
  : kind === 'assassination' || kind === 'destruction'
713
747
  ? `winCondition.target names a real ${kind === 'assassination' ? 'npcs[].name that is NOT "client"' : 'devices[].name that holds no door and grants nothing'}; there is no items[] entry with role "winCondition".`
714
- : kind === 'delivery'
715
- ? 'The winCondition item is heldBy "client"; winCondition.toNpc is NOT the client and does not stand in the client\'s room.'
716
- : 'The winCondition item must not be heldBy winCondition.toNpc.'} If a matrix-plane item is the
748
+ : courier
749
+ ? 'The winCondition item is a "matrix"-plane file heldBy "client"; winCondition.target is a hasNode room that is NOT the client\'s room; there is no toNpc.'
750
+ : kind === 'delivery'
751
+ ? 'The winCondition item is heldBy "client"; winCondition.toNpc is NOT the client and does not stand in the client\'s room.'
752
+ : 'The winCondition item must not be heldBy winCondition.toNpc.'} If a matrix-plane item is the
717
753
  winCondition item, its room has hasNode true AND a "hostRating" of at least 4 -- the host
718
- defends its own files with its own ice; never write an NPC for that. ${shape === 'data'
719
- ? 'On THIS job (a data run) the winCondition item MUST be that matrix-plane item -- paydata lives on a host, never on a desk.'
720
- : 'A Datachip/Document winCondition item may be either: a file on a host (then the rule above applies) or a physical chip in the meat world.'}
754
+ defends its own files with its own ice; never write an NPC for that. ${courier
755
+ ? 'On THIS job the cargo is that file -- it starts in the client\'s hands, and the HOST it goes to is the drop.'
756
+ : shape === 'data'
757
+ ? 'On THIS job (a data run) the winCondition item MUST be that matrix-plane item -- paydata lives on a host, never on a desk.'
758
+ : 'A Datachip/Document winCondition item may be either: a file on a host (then the rule above applies) or a physical chip in the meat world.'}
721
759
  - ${isContinuation
722
760
  ? 'ZERO "starting"-role items -- the runner arrives with everything they own. 4-6'
723
761
  : '3-5 "starting"-role items (basics the runner doesn\'t already own -- see the dossier), 4-6'}
@@ -842,6 +880,9 @@ function objectiveBrief(skeleton) {
842
880
  if (skeleton.jobKind === 'extraction' && wc?.target) {
843
881
  return `the objective (the PERSON: "${wc.target}" -- who they are, where they are held, ${wc.willing ? 'that they want out' : 'that they will not come quietly'}, and that they are to be brought to "${wc.toNpc}" alive)`;
844
882
  }
883
+ if (skeleton.jobKind === 'delivery' && wc?.item && wc.target) {
884
+ return `the objective (the CARGO FILE "${wc.item}", which they push onto the runner's device at the meet, and the DROP: the host of "${wc.target}", where it is to be uploaded -- and what the file is for)`;
885
+ }
845
886
  if (skeleton.jobKind === 'delivery' && wc?.item) {
846
887
  return `the objective (the CARGO "${wc.item}", which they hand over at the meet, and the DROP: "${wc.toNpc}" and where to find them)`;
847
888
  }
@@ -877,11 +918,17 @@ function paydataBriefingRule(skeleton) {
877
918
  const win = skeleton.items.find(i => i.name === skeleton.winCondition.item);
878
919
  if (win?.plane !== 'matrix')
879
920
  return '';
921
+ if (skeleton.winCondition.target) {
922
+ return `
923
+ THE CARGO "${win.name}" IS A FILE -- the client pushes it onto the runner's commlink or deck at
924
+ the meet, over the table's PAN. Never describe handing over a physical chip, drive or case.
925
+ The drop is the HOST of "${skeleton.winCondition.target}": the runner gets inside and uploads it.`;
926
+ }
880
927
  const host = win.room ? ` the host of "${win.room}"` : ' a host';
881
928
  return `
882
929
  THE OBJECTIVE "${win.name}" IS PAYDATA -- a FILE held on${host}, not an object in a room.
883
930
  The client briefs it as data: a file to be found inside that host and copied out over the
884
- Matrix (onto a chip once it is downloaded, if you like). Never describe it as a physical
931
+ Matrix onto the runner's own device. Never describe it as a physical
885
932
  chip, drive or disk sitting in a room, on a shelf, or in a safe -- nothing in the meat
886
933
  world holds it until the runner downloads it.`;
887
934
  }
@@ -1328,6 +1375,12 @@ export function validateSkeleton(skeleton, _player, _crew, order) {
1328
1375
  if (prizes.length > 0)
1329
1376
  fail(`${word} has no prize item, but ${prizes.map(i => `"${i.name}"`).join(', ')} carries role "winCondition" -- make it loot or drop it.`);
1330
1377
  }
1378
+ else if (isDataCourier(jobKind, order?.runType)) {
1379
+ if (!skeleton.winCondition.item || !skeleton.winCondition.target)
1380
+ fail('winCondition needs "item" (the cargo file) and "target" (the drop: a hasNode room) -- this job is a data courier run.');
1381
+ if (skeleton.winCondition.toNpc)
1382
+ fail('winCondition on a data courier run carries no "toNpc" -- the drop is a host, named by "target". Drop "toNpc".');
1383
+ }
1331
1384
  else if (!skeleton.winCondition.item || !skeleton.winCondition.toNpc) {
1332
1385
  fail('winCondition needs "item" and "toNpc".');
1333
1386
  }
@@ -1541,7 +1594,25 @@ export function validateSkeleton(skeleton, _player, _crew, order) {
1541
1594
  if (!deviceNames.has(target))
1542
1595
  fail(`winCondition.target "${target}" is not in devices -- the object to wreck has to be a device in this scene.`);
1543
1596
  }
1544
- if (jobKind === 'delivery') {
1597
+ if (isDataCourier(jobKind, order?.runType)) {
1598
+ const cargo = skeleton.items.find(i => i.name === skeleton.winCondition.item);
1599
+ if (cargo && cargo.heldBy !== skeleton.client)
1600
+ fail(`the cargo file "${cargo.name}" must be heldBy the client "${skeleton.client}" -- they push it onto the runner's device at the meet. It is ${cargo.heldBy ? `heldBy "${cargo.heldBy}"` : cargo.room ? `in "${cargo.room}"` : 'unplaced'}.`);
1601
+ if (cargo && cargo.plane !== 'matrix')
1602
+ fail(`the cargo "${cargo.name}" is on the meat plane, and this job is a data courier run -- give it "plane": "matrix", category Document.`);
1603
+ const dropName = skeleton.winCondition.target;
1604
+ const drop = skeleton.rooms.find(r => r.name === dropName);
1605
+ const meet = skeleton.npcs.find(n => n.name === skeleton.client);
1606
+ if (!drop)
1607
+ fail(`winCondition.target "${dropName}" is not one of the rooms -- the drop has to be a room whose host takes the upload.`);
1608
+ else if (!drop.hasNode)
1609
+ fail(`winCondition.target "${dropName}" has no "hasNode": true -- the drop is a HOST. Mark that room hasNode.`);
1610
+ else if ((drop.hostRating ?? 0) < VAULT_HOST_RATING)
1611
+ fail(`the drop host "${dropName}" has hostRating ${drop.hostRating ?? 'unset'} -- it defends itself with its own ice, so give it a hostRating of ${VAULT_HOST_RATING} or more.`);
1612
+ if (drop && meet && meet.startLocation === drop.name)
1613
+ fail(`the drop "${dropName}" is the client's own room -- the transit is the job. Put the drop host somewhere the runner has to get into.`);
1614
+ }
1615
+ else if (jobKind === 'delivery') {
1545
1616
  const cargo = skeleton.items.find(i => i.name === skeleton.winCondition.item);
1546
1617
  if (cargo && cargo.heldBy !== skeleton.client)
1547
1618
  fail(`the cargo "${cargo.name}" must be heldBy the client "${skeleton.client}" -- they hand it over at the meet. It is ${cargo.heldBy ? `heldBy "${cargo.heldBy}"` : cargo.room ? `in "${cargo.room}"` : 'unplaced'}.`);
@@ -1567,7 +1638,7 @@ export function validateSkeleton(skeleton, _player, _crew, order) {
1567
1638
  // cannot add a hasNode flag. Worded to route to 'skeleton' (no
1568
1639
  // "items[", no "Win condition item").
1569
1640
  const winItem = skeleton.items.find(i => i.name === skeleton.winCondition.item);
1570
- if (winItem && order?.runType === 'data' && winItem.plane !== 'matrix') {
1641
+ if (winItem && order?.runType === 'data' && winItem.plane !== 'matrix' && !isDataCourier(jobKind, order?.runType)) {
1571
1642
  fail(`the winCondition item "${winItem.name}" is on the meat plane, and this job is a DATA RUN -- its prize is PAYDATA: give it "plane": "matrix", category Document, placed in a hasNode room (not heldBy anyone) with a hostRating of ${VAULT_HOST_RATING} or more. A chip lying on a desk is not a data run.`);
1572
1643
  }
1573
1644
  // The other half of the same rule, for ANY matrix-plane objective: the
@@ -1580,7 +1651,7 @@ export function validateSkeleton(skeleton, _player, _crew, order) {
1580
1651
  // which is how four programs ended up bargaining over a chip in Vex's
1581
1652
  // log. Canon has none of that: the host launches its own IC at its
1582
1653
  // rating (p.247), so what a vault needs is a rating worth the name.
1583
- if (winItem?.plane === 'matrix') {
1654
+ if (winItem?.plane === 'matrix' && !isDataCourier(jobKind, order?.runType)) {
1584
1655
  if (!winItem.room) {
1585
1656
  fail(`the winCondition item "${winItem.name}" is matrix-plane paydata but has no "room" -- data lives on a host, never in a pocket. Place it in a hasNode room.`);
1586
1657
  }
@@ -1626,7 +1697,10 @@ export function validateSkeleton(skeleton, _player, _crew, order) {
1626
1697
  fail(`room "${room.name}" has "hasNode": true but no "hostPurpose" -- every host is FOR something (what this place runs on it, and so what it protects). Give it a hostPurpose phrase, or drop hasNode if the place would run nothing worth taking.`);
1627
1698
  }
1628
1699
  const holds = skeleton.items.some(i => i.plane === 'matrix' && i.room === room.name);
1629
- if (!holds) {
1700
+ // A DATA COURIER'S DROP is empty until the runner fills it -- the file
1701
+ // coming in is what it is for (normalizeSkeleton keeps it too).
1702
+ const courierDrop = isDataCourier(jobKind, order?.runType) && skeleton.winCondition.target === room.name;
1703
+ if (!holds && !courierDrop) {
1630
1704
  fail(`room "${room.name}" is a host (${room.hostPurpose}) with NOTHING inside it -- no "matrix"-plane item placed in that room. A host holds what its business protects: put at least one matrix-plane item there (the ledger, the client list, the camera archive), or drop hasNode.`);
1631
1705
  }
1632
1706
  // "Ice stationed on it" used to be the third requirement. It is
@@ -2037,6 +2111,10 @@ export function normalizeSkeleton(skeleton) {
2037
2111
  const holds = (skeleton.items ?? []).some(i => i.plane === 'matrix' && i.room === room.name);
2038
2112
  if (holds)
2039
2113
  continue;
2114
+ // A DATA COURIER'S DROP is empty until the runner fills it (item +
2115
+ // target: win-condition 'upload'). Its purpose is the file coming in.
2116
+ if (skeleton.winCondition?.item && skeleton.winCondition?.target === room.name)
2117
+ continue;
2040
2118
  delete room.hasNode;
2041
2119
  delete room.hostRating;
2042
2120
  delete room.hostPurpose;
@@ -2057,8 +2135,11 @@ export function normalizeSkeleton(skeleton) {
2057
2135
  }
2058
2136
  }
2059
2137
  const winItem = (skeleton.items ?? []).find(i => i.name === skeleton.winCondition?.item);
2060
- if (winItem?.plane === 'matrix' && winItem.room) {
2061
- const vault = (skeleton.rooms ?? []).find(r => r.name === winItem.room);
2138
+ // The vault is where the paydata sits -- or, on a data courier run, the
2139
+ // host the cargo is going to (its target).
2140
+ const vaultRoom = winItem?.plane === 'matrix' ? (skeleton.winCondition?.target ?? winItem.room) : undefined;
2141
+ if (vaultRoom) {
2142
+ const vault = (skeleton.rooms ?? []).find(r => r.name === vaultRoom);
2062
2143
  if (vault?.hasNode && (vault.hostRating ?? 0) < VAULT_HOST_RATING) {
2063
2144
  const was = vault.hostRating === undefined ? 'no hostRating' : `hostRating ${vault.hostRating}`;
2064
2145
  vault.hostRating = VAULT_HOST_RATING;
@@ -3133,23 +3214,35 @@ export function assembleScene(skeleton, details, playerName) {
3133
3214
  lifestyleChoices: [{ type: 'residence', metadata: { tier: 'Street', location: startRoom } }],
3134
3215
  startingItems,
3135
3216
  },
3136
- winCondition: skeleton.winCondition.target
3217
+ winCondition: skeleton.jobKind === 'delivery' && skeleton.winCondition.item && skeleton.winCondition.target
3218
+ // THE DATA COURIER (win-condition 'upload'): a file to a host. The
3219
+ // engine -- never the model -- decides whether it must land unseen.
3137
3220
  ? {
3138
- type: skeleton.jobKind === 'destruction' ? 'destroy' : skeleton.jobKind === 'extraction' ? 'extract' : 'kill',
3221
+ type: 'upload',
3222
+ item: skeleton.winCondition.item,
3139
3223
  target: skeleton.winCondition.target,
3140
- ...(skeleton.jobKind === 'extraction' ? { toNpc: skeleton.winCondition.toNpc, willing: skeleton.winCondition.willing } : {}),
3224
+ ...(courierMustBeUnseen(skeleton.jobSize) ? { undetected: true } : {}),
3141
3225
  rewardItem: skeleton.winCondition.rewardItem,
3142
3226
  payout: skeleton.winCondition.payout,
3143
3227
  completionMessage: skeleton.completionMessage,
3144
3228
  }
3145
- : {
3146
- type: 'give',
3147
- item: skeleton.winCondition.item,
3148
- toNpc: skeleton.winCondition.toNpc,
3149
- rewardItem: skeleton.winCondition.rewardItem,
3150
- payout: skeleton.winCondition.payout,
3151
- completionMessage: skeleton.completionMessage,
3152
- },
3229
+ : skeleton.winCondition.target
3230
+ ? {
3231
+ type: skeleton.jobKind === 'destruction' ? 'destroy' : skeleton.jobKind === 'extraction' ? 'extract' : 'kill',
3232
+ target: skeleton.winCondition.target,
3233
+ ...(skeleton.jobKind === 'extraction' ? { toNpc: skeleton.winCondition.toNpc, willing: skeleton.winCondition.willing } : {}),
3234
+ rewardItem: skeleton.winCondition.rewardItem,
3235
+ payout: skeleton.winCondition.payout,
3236
+ completionMessage: skeleton.completionMessage,
3237
+ }
3238
+ : {
3239
+ type: 'give',
3240
+ item: skeleton.winCondition.item,
3241
+ toNpc: skeleton.winCondition.toNpc,
3242
+ rewardItem: skeleton.winCondition.rewardItem,
3243
+ payout: skeleton.winCondition.payout,
3244
+ completionMessage: skeleton.completionMessage,
3245
+ },
3153
3246
  client: skeleton.client,
3154
3247
  ...(skeleton.jobSize ? { jobSize: skeleton.jobSize } : {}),
3155
3248
  ...(skeleton.jobKind ? { jobKind: skeleton.jobKind } : {}),
@@ -662,6 +662,15 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
662
662
  throw new Error(`Win condition references NPC "${wc.toNpc}", which is not in "npcs".`);
663
663
  }
664
664
  }
665
+ // THE COURIER'S DROP IS A HOST (win-condition 'upload'): the
666
+ // target names a room, and that room must stand under one.
667
+ if (wc.type === 'upload') {
668
+ const drop = wc.target ? scene.getRooms()[wc.target] ?? Object.values(scene.getRooms()).find(r => r.name === wc.target) : undefined;
669
+ if (!drop)
670
+ throw new Error(`Win condition type "upload" requires "target", a room -- "${wc.target}" is not in "rooms".`);
671
+ if (!drop.host || drop.offlineServer)
672
+ throw new Error(`Win condition type "upload" targets "${wc.target}", which has no host to upload to -- mark it hasNode.`);
673
+ }
665
674
  }
666
675
  let rewardItem;
667
676
  if (wc.rewardItem) {
@@ -676,6 +685,7 @@ Key Locations: ${json.rooms.map(r => r.name).join(', ')}
676
685
  toNpc: wc.toNpc,
677
686
  target: wc.target,
678
687
  willing: wc.willing,
688
+ undetected: wc.undetected,
679
689
  rewardItem,
680
690
  completionMessage: wc.completionMessage,
681
691
  payout: wc.payout
@@ -914,6 +914,11 @@ export class SceneSeedGenerator {
914
914
  const wc = candidate.winCondition;
915
915
  if (!wc || !wc.item)
916
916
  return;
917
+ // A COURIER'S CARGO (win-condition 'upload') is a file the client
918
+ // HOLDS -- it starts in their hands, not on a host; the drop host is
919
+ // validated at the skeleton (scene-chunks.ts validateSkeleton).
920
+ if (wc.type === 'upload')
921
+ return;
917
922
  const item = (candidate.items ?? []).find(i => i.name === wc.item);
918
923
  if (!item || item.plane !== 'matrix')
919
924
  return;
@@ -78,6 +78,9 @@ import { TakeCommand } from './commands/take.js';
78
78
  import { MessageCommand } from './commands/message.js';
79
79
  import { MessagesCommand } from './commands/messages.js';
80
80
  import { DownloadCommand } from './commands/download.js';
81
+ import { UploadCommand } from './commands/upload.js';
82
+ import { TransferCommand } from './commands/transfer.js';
83
+ import { receiveData } from './utilities/data-library.js';
81
84
  import { LootCommand } from './commands/loot.js';
82
85
  import { NoteCommand } from './commands/note.js';
83
86
  import { BacklogCommand } from './commands/backlog.js';
@@ -4513,6 +4516,25 @@ export default class Game {
4513
4516
  Logger.getInstance().write(`Delivery: ${client.name} handed "${wc.item}" to ${this.player.name} at the meet.`);
4514
4517
  }
4515
4518
  }
4519
+ // THE DATA COURIER'S CARGO (win-condition 'upload', user ruling
4520
+ // 2026-10-04): the package is a file, so the Johnson does not slide
4521
+ // anything across the table -- they push it onto the runner's
4522
+ // device (data-library receiveData; a chip only for a runner who
4523
+ // carries no device at all). The drop is a HOST, and the meet says
4524
+ // which, on its own line for the same terse() reason as above.
4525
+ if (wc?.type === 'upload' && wc.item && johnson && johnson.inventory.hasItem(wc.item)) {
4526
+ const cargo = johnson.inventory.getItem(wc.item);
4527
+ johnson.inventory.removeItem(cargo.name);
4528
+ const { device } = receiveData(this.player, cargo);
4529
+ this.scene.updateInventory(this.player.inventory);
4530
+ lines.push(device
4531
+ ? `THE CARGO: ${client.name} pushes ${cargo.name} onto your ${device.name}.`
4532
+ : `THE CARGO: ${client.name} hands you ${cargo.name} on a chip -- you carry nothing with a library to push it to.`);
4533
+ if (wc.target) {
4534
+ lines.push(`THE DROP: the host at ${wc.target}. Get inside it and "upload ${cargo.name}"${wc.undetected ? ` -- and get it in before anyone knows you're there. A plant they watch land is worth nothing` : ''}.`);
4535
+ }
4536
+ Logger.getInstance().write(`Data courier: ${client.name} pushed "${cargo.name}" to ${this.player.name}${device ? `'s ${device.name}` : ' (chip)'} at the meet.`);
4537
+ }
4516
4538
  }
4517
4539
  // THE PERSON HANDED OVER TO THE CLIENT (win-condition 'extract'):
4518
4540
  // when the client is the delivery point, any face time with them
@@ -4721,11 +4743,11 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4721
4743
  const knows = `"${pending.name}" -- ${pending.story} ${meetRoom ? `The meet is at ${meetRoom}; name that place and no other.` : 'You will find the runner when they arrive; never name a venue.'}`;
4722
4744
  if (this.pendingRunAccepted) {
4723
4745
  this.openJohnsonLine(`${knows} The runner has ALREADY ACCEPTED; the deal stands and the cab is on your account.`);
4724
- return `${j} picks up. "We've shaken on it. The cab's on my account -- get moving."${hint(` ("call taxi" when you're ready -- "end call" hangs up.)`)}`;
4746
+ return this.johnsonPicksUp(`We've shaken on it. The cab's on my account -- get moving.`, ` ("call taxi" when you're ready -- "end call" hangs up.)`);
4725
4747
  }
4726
- const where = meetRoom ? ` "Meet me at ${meetRoom}."` : ` "I'll find you when you arrive."`;
4748
+ const where = meetRoom ? ` Meet me at ${meetRoom}.` : ` I'll find you when you arrive.`;
4727
4749
  this.openJohnsonLine(`${knows} The runner has NOT accepted yet: they say they accept, or they pass -- and passing ends your operation.`);
4728
- return `${j} picks up. "${pending.story}"${where} "Same as last time -- the number we settle face to face."${hint(`\n(You're on the line with ${j}: "accept" locks it in, "decline" passes -- and passing ends the operation.)`)}`;
4750
+ return this.johnsonPicksUp(`${pending.story}${where} Same as last time -- the number we settle face to face.`, `\n(You're on the line with ${j}: "accept" locks it in, "decline" passes -- and passing ends the operation.)`);
4729
4751
  }
4730
4752
  // THE CHAPTER WAITS IN THE DRAWER (it landed while another job held
4731
4753
  // the table): calling the Johnson brings it onto the table and they
@@ -4735,7 +4757,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4735
4757
  const refused = this.takeFromDrawer(waiting);
4736
4758
  if (refused) {
4737
4759
  this.openJohnsonLine(`Your next chapter is ready, but the runner is committed to another job tonight. Yours keeps until they are back.`);
4738
- return `${j} picks up. "You're committed elsewhere tonight. Mine keeps -- call me when you're back."${hint(` ("end call" hangs up.)`)}`;
4760
+ return this.johnsonPicksUp(`You're committed elsewhere tonight. Mine keeps -- call me when you're back.`, ` ("end call" hangs up.)`);
4739
4761
  }
4740
4762
  return this.callOperationJohnson(said);
4741
4763
  }
@@ -4743,7 +4765,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4743
4765
  if (!gate.allowed) {
4744
4766
  const busy = gate.message ?? 'Working on it.';
4745
4767
  this.openJohnsonLine(`You have nothing new for the runner right now: "${busy}"`);
4746
- return `${j} picks up. "${busy}"${hint(` ("end call" hangs up.)`)}`;
4768
+ return this.johnsonPicksUp(busy, ` ("end call" hangs up.)`);
4747
4769
  }
4748
4770
  const beat = op.beats[op.nextBeat];
4749
4771
  // THE SIZE IS STILL THE PLAYER'S (job-order.ts): "call Veil, something
@@ -4752,7 +4774,27 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4752
4774
  // client does not deal in milk runs. The SHAPE is the beat's.
4753
4775
  this.draftNextSceneInBackground({ size: parseJobSize(said) ?? 'large', runType: beat.kind, chapter: true });
4754
4776
  this.openJohnsonLine(`Chapter ${op.nextBeat + 1} of "${op.title}": ${beat.brief} You are putting the details together and will message the runner the where and when shortly.`);
4755
- return `${j} picks up. "Chapter ${op.nextBeat + 1}: ${beat.brief} Give me a minute -- I'll send the where and when."${hint(` ("end call" hangs up -- the job comes as a message.)`)}`;
4777
+ return this.johnsonPicksUp(`Chapter ${op.nextBeat + 1}: ${beat.brief} Give me a minute -- I'll send the where and when.`, ` ("end call" hangs up -- the job comes as a message.)`);
4778
+ }
4779
+ /**
4780
+ * THE JOHNSON SPEAKS FOR THEMSELVES (mYocnosxLvj24CuWt, Maka: "I'm still
4781
+ * getting canned text, this should be Mr. Halyard speaking"). 1.180.0
4782
+ * opened a real line, but the pickup was still a fixed quote. Krow's
4783
+ * business call already works this way (fixerForBusiness, call.ts): with
4784
+ * AI on, the voice picks up off a note and says the GIST in its own words;
4785
+ * the fixed quote is only the AI-off fallback. The gist is the same
4786
+ * sentence the quote was, and the hint (the verbs) stays out of fiction.
4787
+ */
4788
+ johnsonPicksUp(gist, hintText) {
4789
+ const voice = this._johnsonVoice;
4790
+ const me = this.player;
4791
+ const j = voice?.name ?? this._operation?.johnson.name ?? 'The client';
4792
+ if (this.aiOn && voice && me && me.activeCallPartner === voice) {
4793
+ // hear() never rethrows (models/npc.ts), so fire-and-forget is safe.
4794
+ void voice.hear(me, `[System note: ${me.name} is calling you and you have picked up. Tell them this in your own voice and words, and nothing more: "${gist}" Add no new facts, places or numbers.]`, { viaLink: true });
4795
+ return `${j} picks up.${hint(hintText)}`;
4796
+ }
4797
+ return `${j} picks up. "${gist}"${hint(hintText)}`;
4756
4798
  }
4757
4799
  /**
4758
4800
  * The cab that answers "call taxi" varies per job (player request: not
@@ -4959,6 +5001,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4959
5001
  CommandFactory.registerCommand('message', MessageCommand);
4960
5002
  CommandFactory.registerCommand('messages', MessagesCommand);
4961
5003
  CommandFactory.registerCommand('download', DownloadCommand);
5004
+ CommandFactory.registerCommand('upload', UploadCommand);
5005
+ CommandFactory.registerCommand('transfer', TransferCommand);
4962
5006
  CommandFactory.registerCommand('loot', LootCommand);
4963
5007
  CommandFactory.registerCommand('note', NoteCommand);
4964
5008
  CommandFactory.registerCommand('backlog', BacklogCommand);
@@ -221,6 +221,32 @@ export class Host {
221
221
  return [];
222
222
  return this.over.inventory.getAllItems().filter(i => i.plane === 'matrix');
223
223
  }
224
+ /**
225
+ * THE HOST'S DIGITAL INVENTORY (user ruling 2026-10-04), the write half
226
+ * of files(). Every write to a host's archive goes through these two --
227
+ * an upload lands here, an erase leaves from here -- so the storage
228
+ * underneath (the matrix-plane entries of the room it stands over, which
229
+ * the hub overlay already persists) is one place to change, not ten.
230
+ */
231
+ addFile(file) {
232
+ file.plane = 'matrix';
233
+ file.atSpot = undefined;
234
+ this.over.inventory.addItem(file);
235
+ }
236
+ /** Takes a file out of the archive by name; true when one was there. */
237
+ removeFile(name) {
238
+ const lower = name.toLowerCase();
239
+ if (!this.files().some(f => f.name.toLowerCase() === lower))
240
+ return false;
241
+ this.over.inventory.removeItem(name);
242
+ this.over.plainSightItems.delete(lower);
243
+ return true;
244
+ }
245
+ /** True when the archive holds a file of that name. */
246
+ hasFile(name) {
247
+ const lower = name.toLowerCase();
248
+ return this.files().some(f => f.name.toLowerCase() === lower);
249
+ }
224
250
  /** The locked doors of the room it stands over -- what a crack releases. */
225
251
  maglocks() {
226
252
  return Array.from(this.over.exits.values())
@@ -971,6 +971,18 @@ export class Item extends AbstractItem {
971
971
  * here; utilities/sin.ts reads them from here.
972
972
  */
973
973
  papers = [];
974
+ /**
975
+ * THE DEVICE'S DATA LIBRARY (digital inventory, user ruling 2026-10-04).
976
+ * A file copied off a host (Edit File, SR5 p.239: "you are the new
977
+ * file's owner") lives on the device that carried the persona -- "all
978
+ * of it fits on your commlink (or cyberdeck if you prefer)", p.222-223
979
+ * -- not as a chip in the pack. Each entry stays plane 'matrix'. A
980
+ * commlink, a deck or an RCC holds one (holdsData); a file moves
981
+ * between them, onto a host (upload) or onto a datachip ("transfer
982
+ * <file> to chip") -- utilities/data-library.ts. Canon gives storage no
983
+ * practical cap (p.222), so neither does this.
984
+ */
985
+ files = [];
974
986
  /** On a license: the name of the fake SIN it was bought for. */
975
987
  linkedSin;
976
988
  /** On a license: the TYPE it permits, by `license` row slug (v24,
@@ -990,6 +1002,10 @@ export class Item extends AbstractItem {
990
1002
  holdsPapers() {
991
1003
  return this.category === Category.Commlink || this.isCyberdeck() || this.isRcc();
992
1004
  }
1005
+ /** A device with a data library (files): the same three that hold papers. */
1006
+ holdsData() {
1007
+ return this.holdsPapers();
1008
+ }
993
1009
  /** A data-soft chip: Mapsoft, Shopsoft, Datasoft (utilities/programs.ts DATA_SOFTS). */
994
1010
  get softKey() {
995
1011
  return this.category === Category.Software ? findSoft(this.name)?.key : undefined;
@@ -1539,6 +1539,43 @@ export class Scene extends AbstractScene {
1539
1539
  this.logger.logWithColor(banner, 'green');
1540
1540
  return banner;
1541
1541
  }
1542
+ /**
1543
+ * THE COURIER'S PLANT (win-condition 'upload', user ruling 2026-10-04):
1544
+ * met when the named file is written onto the named room's host by
1545
+ * Edit File's create (commands/upload.ts). A delivery whose package is
1546
+ * data (p.338): nothing changes hands in person, so this is a state
1547
+ * check on the host, like erase -- but it is a CLIENT job and pays like
1548
+ * one: completeObjective leaves the money with the Johnson until the
1549
+ * runner reports in (Game.onClientInteraction).
1550
+ *
1551
+ * `undetected` (the harder jobs): a host on alert has its spider and
1552
+ * its Patrol looking (host.ts `alert`, which canon never stands down),
1553
+ * and a file that lands under those eyes is found. The upload still
1554
+ * happens -- the runner made the edit -- but the plant is blown and the
1555
+ * objective is not met; the line returned says so. Returns null when
1556
+ * this is not the objective, it is already met, or the file or host do
1557
+ * not match.
1558
+ */
1559
+ checkUploaded(itemName, roomName, byName, hostAlert) {
1560
+ const condition = this._winCondition;
1561
+ if (!condition || this._completed || condition.type !== 'upload')
1562
+ return null;
1563
+ if (!condition.item || condition.item.toLowerCase() !== itemName.toLowerCase())
1564
+ return null;
1565
+ if (!condition.target || condition.target.toLowerCase() !== roomName.toLowerCase())
1566
+ return null;
1567
+ if (condition.undetected && hostAlert) {
1568
+ this.addWorldEvent(`${byName} planted ${itemName} on an alerted host -- the spider saw it land.`, { plane: 'matrix' });
1569
+ this.logger.write(`Scene upload objective blown: "${itemName}" landed on ${roomName}'s host while it was on alert.`);
1570
+ const blown = `The file lands -- and the host is awake. Its spider watches ${itemName} arrive in real time, and a plant somebody saw land is no plant at all. The job wanted it in clean.`;
1571
+ this.logger.logWithColor(blown, 'yellow');
1572
+ return blown;
1573
+ }
1574
+ const possessorPlayer = this.getPlayerByName(byName);
1575
+ this.addWorldEvent(`${byName} planted ${itemName} on ${roomName}'s host -- the job is done.`, { plane: 'matrix' });
1576
+ this.logger.write(`Scene upload objective met: "${byName}" uploaded "${itemName}" to ${roomName}'s host.`);
1577
+ return this.completeObjective(condition, possessorPlayer);
1578
+ }
1542
1579
  /**
1543
1580
  * THE ERASE OBJECTIVE (loose ends, 2026-09-14): met when the named file
1544
1581
  * is DELETED by Edit File (commands/edit-file.ts) -- a state change on