@maka/maka-cli 5.13.0 → 5.14.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 (133) hide show
  1. package/bundle/typescript/package.json +2 -2
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +2 -2
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js +5 -5
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/attack.js.map +1 -1
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +1 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +48 -37
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js +14 -14
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/cast.js.map +1 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/catalog.js +25 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/catalog.js.map +1 -1
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +6 -2
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.d.ts +43 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.js +259 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.js.map +1 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js +6 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/end-call.js.map +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js +15 -2
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/give.js.map +1 -1
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js +8 -6
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/go.js.map +1 -1
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js +3 -3
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/grapple.js.map +1 -1
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js +3 -3
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/hack.js.map +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.d.ts +5 -4
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js +45 -27
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/haggle.js.map +1 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js +12 -7
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/job-offer.js.map +1 -1
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.d.ts +5 -4
  35. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js +31 -17
  36. package/bundle/typescript/src/commands/game/sideQuest/commands/lead.js.map +1 -1
  37. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  39. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js +21 -10
  40. package/bundle/typescript/src/commands/game/sideQuest/commands/order.js.map +1 -1
  41. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js +1 -1
  42. package/bundle/typescript/src/commands/game/sideQuest/commands/palm.js.map +1 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/commands/perform.js +1 -1
  44. package/bundle/typescript/src/commands/game/sideQuest/commands/perform.js.map +1 -1
  45. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js +1 -1
  46. package/bundle/typescript/src/commands/game/sideQuest/commands/pick.js.map +1 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.js +1 -1
  48. package/bundle/typescript/src/commands/game/sideQuest/commands/reboot.js.map +1 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/commands/reload.js.map +1 -1
  51. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js +1 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/commands/rest.js.map +1 -1
  53. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js +21 -0
  54. package/bundle/typescript/src/commands/game/sideQuest/commands/say.js.map +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +1 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  57. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.d.ts +2 -1
  58. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +81 -3
  59. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
  60. package/bundle/typescript/src/commands/game/sideQuest/commands/source.js +1 -1
  61. package/bundle/typescript/src/commands/game/sideQuest/commands/source.js.map +1 -1
  62. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js +3 -3
  63. package/bundle/typescript/src/commands/game/sideQuest/commands/take.js.map +1 -1
  64. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +4 -1
  65. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js.map +1 -1
  66. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js +2 -0
  67. package/bundle/typescript/src/commands/game/sideQuest/commands/use.js.map +1 -1
  68. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +15 -2
  69. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +70 -35
  70. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  71. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.d.ts +16 -5
  72. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js +36 -8
  73. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-seed-generator.js.map +1 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +139 -4
  75. package/bundle/typescript/src/commands/game/sideQuest/game.js +591 -85
  76. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  77. package/bundle/typescript/src/commands/game/sideQuest/headless.d.ts +91 -0
  78. package/bundle/typescript/src/commands/game/sideQuest/headless.js +188 -0
  79. package/bundle/typescript/src/commands/game/sideQuest/headless.js.map +1 -0
  80. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +50 -4
  81. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  82. package/bundle/typescript/src/commands/game/sideQuest/models/scene.d.ts +60 -11
  83. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js +261 -109
  84. package/bundle/typescript/src/commands/game/sideQuest/models/scene.js.map +1 -1
  85. package/bundle/typescript/src/commands/game/sideQuest/scenes/scene2.json +11 -11
  86. package/bundle/typescript/src/commands/game/sideQuest/types/save-file.d.ts +22 -2
  87. package/bundle/typescript/src/commands/game/sideQuest/types/seed/scene-seed.d.ts +8 -0
  88. package/bundle/typescript/src/commands/game/sideQuest/ui.js +7 -5
  89. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  90. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.d.ts +6 -4
  91. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js +36 -24
  92. package/bundle/typescript/src/commands/game/sideQuest/utilities/auto-fight.js.map +1 -1
  93. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.d.ts +7 -1
  94. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js +59 -18
  95. package/bundle/typescript/src/commands/game/sideQuest/utilities/combat-exchange.js.map +1 -1
  96. package/bundle/typescript/src/commands/game/sideQuest/utilities/command-queue.d.ts +19 -0
  97. package/bundle/typescript/src/commands/game/sideQuest/utilities/command-queue.js +56 -0
  98. package/bundle/typescript/src/commands/game/sideQuest/utilities/command-queue.js.map +1 -0
  99. package/bundle/typescript/src/commands/game/sideQuest/utilities/crew-ledger.d.ts +21 -0
  100. package/bundle/typescript/src/commands/game/sideQuest/utilities/crew-ledger.js +25 -0
  101. package/bundle/typescript/src/commands/game/sideQuest/utilities/crew-ledger.js.map +1 -0
  102. package/bundle/typescript/src/commands/game/sideQuest/utilities/emitter.d.ts +44 -0
  103. package/bundle/typescript/src/commands/game/sideQuest/utilities/emitter.js +22 -0
  104. package/bundle/typescript/src/commands/game/sideQuest/utilities/emitter.js.map +1 -0
  105. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.d.ts +4 -1
  106. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js +5 -2
  107. package/bundle/typescript/src/commands/game/sideQuest/utilities/fuzzy-match.js.map +1 -1
  108. package/bundle/typescript/src/commands/game/sideQuest/utilities/hints.d.ts +4 -0
  109. package/bundle/typescript/src/commands/game/sideQuest/utilities/hints.js +24 -7
  110. package/bundle/typescript/src/commands/game/sideQuest/utilities/hints.js.map +1 -1
  111. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.d.ts +6 -5
  112. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.js +30 -6
  113. package/bundle/typescript/src/commands/game/sideQuest/utilities/logger.js.map +1 -1
  114. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-context.d.ts +28 -0
  115. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-context.js +9 -0
  116. package/bundle/typescript/src/commands/game/sideQuest/utilities/session-context.js.map +1 -0
  117. package/bundle/typescript/src/commands/game/sideQuest/utilities/social.d.ts +50 -0
  118. package/bundle/typescript/src/commands/game/sideQuest/utilities/social.js +166 -0
  119. package/bundle/typescript/src/commands/game/sideQuest/utilities/social.js.map +1 -0
  120. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +1 -1
  121. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  122. package/bundle/typescript/src/commands/generate.js +7 -0
  123. package/bundle/typescript/src/commands/generate.js.map +1 -1
  124. package/bundle/typescript/src/tools/mpi/mpi.class.js +46 -3
  125. package/bundle/typescript/src/tools/mpi/mpi.class.js.map +1 -1
  126. package/bundle/typescript/src/tools/mpi/sdk-runtime.d.ts +1 -0
  127. package/bundle/typescript/src/tools/mpi/sdk-runtime.js +23 -0
  128. package/bundle/typescript/src/tools/mpi/sdk-runtime.js.map +1 -1
  129. package/bundle/typescript/src/tools/pkg/pkg.class.js +11 -1
  130. package/bundle/typescript/src/tools/pkg/pkg.class.js.map +1 -1
  131. package/bundle/typescript/src/tools/scaffold/scaffold.class.js +8 -1
  132. package/bundle/typescript/src/tools/scaffold/scaffold.class.js.map +1 -1
  133. package/package.json +2 -2
@@ -7,7 +7,15 @@ import { AbstractScene } from '../types/shared/abstracts.js';
7
7
  import { rollPool, formatRoll } from '../utilities/dice.js';
8
8
  export class Scene extends AbstractScene {
9
9
  story;
10
- player;
10
+ // SYMMETRIC PLAYERS: the scene holds a LIST of human players --
11
+ // players[0] is the primary (the solo game's one human; the leader's
12
+ // character in shared runs). The `player` getter keeps the historical
13
+ // singular reads pointed at players[0]; code that can act for ANY
14
+ // human should take an explicit actor instead of reaching for it.
15
+ players;
16
+ get player() {
17
+ return this.players[0];
18
+ }
11
19
  logger;
12
20
  actors;
13
21
  game;
@@ -33,7 +41,7 @@ export class Scene extends AbstractScene {
33
41
  currency;
34
42
  constructor(config) {
35
43
  super();
36
- this.player = config.player;
44
+ this.players = [config.player];
37
45
  this.logger = Logger.getInstance();
38
46
  this.actors = new Map();
39
47
  this.currency = config.currency ?? {
@@ -84,15 +92,22 @@ export class Scene extends AbstractScene {
84
92
  // room between an NPC attack's narration beats, mid-exchange. Cleared
85
93
  // BEFORE the between-exchanges pause, which stays the legitimate window
86
94
  // to flee or heal. NPC-vs-NPC fights elsewhere never set this.
87
- _playerExchangeActive = false;
88
- beginPlayerExchange() {
89
- this._playerExchangeActive = true;
90
- }
91
- endPlayerExchange() {
92
- this._playerExchangeActive = false;
93
- }
94
- isPlayerExchangeActive() {
95
- return this._playerExchangeActive;
95
+ // Per-player (symmetric multiplayer: two humans can be in two
96
+ // different fights): the set holds the NAMES of human players whose
97
+ // exchange is mid-resolution. No-arg calls keep the historical solo
98
+ // meaning -- begin/end act on the primary player, the query asks
99
+ // "is ANY human's exchange live".
100
+ _playerExchangesActive = new Set();
101
+ beginPlayerExchange(playerName) {
102
+ this._playerExchangesActive.add(playerName ?? this.player.name);
103
+ }
104
+ endPlayerExchange(playerName) {
105
+ this._playerExchangesActive.delete(playerName ?? this.player.name);
106
+ }
107
+ isPlayerExchangeActive(playerName) {
108
+ if (playerName !== undefined)
109
+ return this._playerExchangesActive.has(playerName);
110
+ return this._playerExchangesActive.size > 0;
96
111
  }
97
112
  /**
98
113
  * CombatExchange.finishExchange() -> here -> the fight autopilot (see
@@ -102,13 +117,18 @@ export class Scene extends AbstractScene {
102
117
  */
103
118
  notifyExchangeSettled(combatants) {
104
119
  this.game?.autoFight?.onExchangeSettled?.(combatants);
105
- // While the player lies KO'd, every settling exchange is a beat of
120
+ // While a player lies KO'd, every settling exchange is a beat of
106
121
  // the fight still raging around them: learn its hostiles, then see
107
122
  // whether the room has settled (see maybeResolvePlayerKnockout).
108
- if (this.playerKnockout) {
109
- for (const c of combatants) {
110
- if (c !== this.player && c.allyOf !== this.player.name) {
111
- this.playerKnockout.hostiles.add(c.name);
123
+ if (this.playerKnockouts.size > 0) {
124
+ for (const [name, ko] of this.playerKnockouts) {
125
+ const p = this.getPlayerByName(name);
126
+ if (!p)
127
+ continue;
128
+ for (const c of combatants) {
129
+ if (c !== p && c.allyOf !== p.name) {
130
+ ko.hostiles.add(c.name);
131
+ }
112
132
  }
113
133
  }
114
134
  this.maybeResolvePlayerKnockout();
@@ -126,40 +146,64 @@ export class Scene extends AbstractScene {
126
146
  // player comes to with a recovery test; a conscious hostile left
127
147
  // standing over them with nobody fighting for them -> the street
128
148
  // decides, same as ever.
129
- playerKnockout;
130
- /** Fight-side entry: the player just dropped from stun. Hostile names
149
+ /** One pending knockout per HUMAN player, keyed by player name --
150
+ * two humans can be down in two different rooms (symmetric MP). */
151
+ playerKnockouts = new Map();
152
+ /** Solo-compat view: the PRIMARY player's pending knockout, in the
153
+ * historical single-record shape. Multiplayer-aware code should ask
154
+ * getKnockout(name) instead. */
155
+ get playerKnockout() {
156
+ return this.playerKnockouts.get(this.player.name);
157
+ }
158
+ getKnockout(playerName) {
159
+ return this.playerKnockouts.get(playerName);
160
+ }
161
+ /** Fight-side entry: a player just dropped from stun. Hostile names
131
162
  * seed the watch list (grows via notifyExchangeSettled); empty means a
132
163
  * self-inflicted collapse (drain, backlash) with nobody to fear. */
133
- beginPlayerKnockout(hostileNames) {
134
- if (this.playerKnockout) {
164
+ beginPlayerKnockout(hostileNames, player = this.player) {
165
+ const existing = this.playerKnockouts.get(player.name);
166
+ if (existing) {
135
167
  for (const n of hostileNames)
136
- this.playerKnockout.hostiles.add(n);
168
+ existing.hostiles.add(n);
137
169
  return;
138
170
  }
139
- this.playerKnockout = { hostiles: new Set(hostileNames), spurs: 0 };
140
- this.addWorldEvent(`${this.player.name} is DOWN -- out cold on the floor of ${this.player.currentLocation.name}.`);
141
- this.spurCompanionsForFallenMaster();
171
+ this.playerKnockouts.set(player.name, { hostiles: new Set(hostileNames), spurs: 0 });
172
+ this.addWorldEvent(`${player.name} is DOWN -- out cold on the floor of ${player.currentLocation.name}.`);
173
+ this.spurCompanionsForFallenMaster(player);
142
174
  // First reckoning comes after the collapse narration has landed --
143
175
  // synchronous evaluation here would print "you come to" (or the
144
176
  // game-over) BEFORE the knockout line the caller is still announcing.
145
- this.armKnockoutTimer(1500);
177
+ this.armKnockoutTimer(1500, player);
146
178
  }
147
- armKnockoutTimer(delayMs) {
148
- if (!this.playerKnockout)
179
+ armKnockoutTimer(delayMs, player) {
180
+ const ko = this.playerKnockouts.get(player.name);
181
+ if (!ko)
149
182
  return;
150
- if (this.playerKnockout.timer)
151
- clearTimeout(this.playerKnockout.timer);
152
- const t = setTimeout(() => this.maybeResolvePlayerKnockout(true), delayMs);
183
+ if (ko.timer)
184
+ clearTimeout(ko.timer);
185
+ // The reckoning is engine mutation -- with a second human's commands
186
+ // in flight it must take its turn in the queue like everything else
187
+ // (M1's deferral, landed here in M2). Solo: the queue is empty and
188
+ // this is effectively immediate.
189
+ const t = setTimeout(() => {
190
+ const q = this.game?.commandQueue;
191
+ const run = () => this.maybeResolvePlayerKnockout(true, player);
192
+ if (q)
193
+ void q.enqueue(run);
194
+ else
195
+ run();
196
+ }, delayMs);
153
197
  t.unref?.();
154
- this.playerKnockout.timer = t;
198
+ ko.timer = t;
155
199
  }
156
200
  /** Hostiles from the watch list still on their feet in the player's
157
201
  * room: present, conscious, and not surrendered. */
158
- standingHostiles() {
159
- const ko = this.playerKnockout;
202
+ standingHostiles(player) {
203
+ const ko = this.playerKnockouts.get(player.name);
160
204
  if (!ko)
161
205
  return [];
162
- const room = this.player.currentLocation;
206
+ const room = player.currentLocation;
163
207
  const out = [];
164
208
  for (const name of ko.hostiles) {
165
209
  const a = this.getActor(name);
@@ -170,8 +214,8 @@ export class Scene extends AbstractScene {
170
214
  return out;
171
215
  }
172
216
  /** Conscious companions in the player's room -- the reason judgment waits. */
173
- consciousAlliesInRoom() {
174
- const room = this.player.currentLocation;
217
+ consciousAlliesInRoom(player) {
218
+ const room = player.currentLocation;
175
219
  return (this.game?.companions ?? [])
176
220
  .map(c => c.npc)
177
221
  .filter(n => n.currentLocation === room && !n.isIncapacitated());
@@ -179,30 +223,40 @@ export class Scene extends AbstractScene {
179
223
  /**
180
224
  * The reckoning. Runs on every settled exchange, every surrender, and a
181
225
  * heartbeat timer (fights can stall on an idle AI). Quiet no-op unless a
182
- * knockout is actually pending and the player is still merely stunned.
226
+ * knockout is actually pending and that player is still merely stunned.
227
+ * With no `player` given, sweeps every pending knockout (settled
228
+ * exchanges and surrenders don't know whose fight just ended).
183
229
  */
184
- maybeResolvePlayerKnockout(fromTimer = false) {
185
- const ko = this.playerKnockout;
230
+ maybeResolvePlayerKnockout(fromTimer = false, player) {
231
+ if (!player) {
232
+ for (const name of [...this.playerKnockouts.keys()]) {
233
+ const p = this.getPlayerByName(name);
234
+ if (p)
235
+ this.maybeResolvePlayerKnockout(fromTimer, p);
236
+ }
237
+ return;
238
+ }
239
+ const ko = this.playerKnockouts.get(player.name);
186
240
  if (!ko)
187
241
  return;
188
- if (this.player.isDown()) {
242
+ if (player.isDown()) {
189
243
  // Died for real since the KO (overflow) -- the death flow owns this.
190
- this.clearPlayerKnockout();
244
+ this.clearPlayerKnockout(player);
191
245
  return;
192
246
  }
193
- if (!this.player.isUnconscious()) {
247
+ if (!player.isUnconscious()) {
194
248
  // Something else woke them (a companion's medkit, say).
195
- this.clearPlayerKnockout();
249
+ this.clearPlayerKnockout(player);
196
250
  return;
197
251
  }
198
- const standing = this.standingHostiles();
252
+ const standing = this.standingHostiles(player);
199
253
  if (standing.length === 0) {
200
- void this.recoverFromKnockout();
254
+ void this.recoverFromKnockout(player);
201
255
  return;
202
256
  }
203
- const allies = this.consciousAlliesInRoom();
257
+ const allies = this.consciousAlliesInRoom(player);
204
258
  if (allies.length === 0) {
205
- this.streetDecidesKnockout(standing.map(s => s.name));
259
+ this.streetDecidesKnockout(standing.map(s => s.name), player);
206
260
  return;
207
261
  }
208
262
  // Allies still stand for the player -- the fight goes on. On timer
@@ -211,22 +265,23 @@ export class Scene extends AbstractScene {
211
265
  if (fromTimer) {
212
266
  ko.spurs += 1;
213
267
  if (ko.spurs > 3) {
214
- this.streetDecidesKnockout(standing.map(s => s.name));
268
+ this.streetDecidesKnockout(standing.map(s => s.name), player);
215
269
  return;
216
270
  }
217
- this.spurCompanionsForFallenMaster();
271
+ this.spurCompanionsForFallenMaster(player);
218
272
  }
219
- this.armKnockoutTimer(10000);
273
+ this.armKnockoutTimer(10000, player);
220
274
  }
221
- clearPlayerKnockout() {
222
- if (this.playerKnockout?.timer)
223
- clearTimeout(this.playerKnockout.timer);
224
- this.playerKnockout = undefined;
275
+ clearPlayerKnockout(player) {
276
+ const ko = this.playerKnockouts.get(player.name);
277
+ if (ko?.timer)
278
+ clearTimeout(ko.timer);
279
+ this.playerKnockouts.delete(player.name);
225
280
  }
226
- spurCompanionsForFallenMaster() {
227
- const standing = this.standingHostiles().map(s => s.name).join(', ');
228
- for (const ally of this.consciousAlliesInRoom()) {
229
- void Promise.resolve(ally.hear(this.player, `[System note: your master ${this.player.name} is DOWN -- out cold on the floor, still breathing. END the threat NOW${standing ? `: ${standing} still stand${standing.includes(',') ? '' : 's'}` : ''}. Attack with [COMMAND] attack <name> and do not stop until every hostile is down, surrendered, or gone. Do NOT surrender, do NOT wander off.]`, { viaLink: true })).catch(err => {
281
+ spurCompanionsForFallenMaster(player) {
282
+ const standing = this.standingHostiles(player).map(s => s.name).join(', ');
283
+ for (const ally of this.consciousAlliesInRoom(player)) {
284
+ void Promise.resolve(ally.hear(player, `[System note: your master ${player.name} is DOWN -- out cold on the floor, still breathing. END the threat NOW${standing ? `: ${standing} still stand${standing.includes(',') ? '' : 's'}` : ''}. Attack with [COMMAND] attack <name> and do not stop until every hostile is down, surrendered, or gone. Do NOT surrender, do NOT wander off.]`, { viaLink: true })).catch(err => {
230
285
  this.logger.error(`spurCompanionsForFallenMaster: ${ally.name} failed to hear: ${err}`);
231
286
  });
232
287
  }
@@ -234,35 +289,47 @@ export class Scene extends AbstractScene {
234
289
  /** The room settled with the player's side upright: RAW natural
235
290
  * recovery (p.207), abstracted to the game's job-scale clock the same
236
291
  * way rest is -- unconsciousness itself counted as the rest. */
237
- async recoverFromKnockout() {
238
- const p = this.player;
239
- this.clearPlayerKnockout();
292
+ async recoverFromKnockout(player) {
293
+ const p = player;
294
+ this.clearPlayerKnockout(p);
240
295
  const pool = Math.max(1, p.body + p.willpower);
241
296
  const roll = rollPool(pool);
242
297
  const healed = p.healStun(Math.max(1, roll.hits));
243
298
  if (p.isUnconscious()) {
244
299
  // Locked fatigue holds the track full -- the body has nothing left.
245
- this.streetDecidesKnockout([]);
300
+ this.streetDecidesKnockout([], p);
246
301
  return;
247
302
  }
248
303
  this.logger.meta(`Recovery (Body ${p.body} + Willpower ${p.willpower}): ${formatRoll(roll)} -- ${healed} stun healed (unconsciousness counts as rest, p.207)`);
249
- const allies = this.consciousAlliesInRoom();
304
+ const allies = this.consciousAlliesInRoom(p);
250
305
  const watch = allies.length > 0 ? ` ${allies.map(a => a.name).join(' and ')} stand${allies.length === 1 ? 's' : ''} watch over you.` : '';
251
306
  this.logger.logWithColor(`\nTime blurs past on the cold floor... then the world seeps back in. You come to -- the fight is over.${watch}`, 'green');
252
307
  this.logger.logWithColor(`You're battered but breathing: ${healed} stun recovered. ${p.stunSummary()}`, 'yellow');
253
308
  this.addWorldEvent(`${p.name} came to in ${p.currentLocation.name} after the fight.`);
254
309
  this.updateStatus();
255
310
  }
256
- streetDecidesKnockout(standingNames) {
257
- const p = this.player;
258
- this.clearPlayerKnockout();
311
+ streetDecidesKnockout(standingNames, player) {
312
+ const p = player;
313
+ this.clearPlayerKnockout(p);
259
314
  // DOCWAGON (SR5e p.450, Game.tryDocwagonRescue): a contract's
260
315
  // wristband reads the flatline before the street gets its say --
261
- // the HTR team extracts the sleeping runner instead.
262
- if (this.game?.tryDocwagonRescue?.('htr', { standingHostiles: standingNames })) {
316
+ // the HTR team extracts the sleeping runner instead. The contract is
317
+ // the LOCAL save's (primary player); a partied guest's own DocWagon
318
+ // rides their own session (M3 settles this server-side).
319
+ if (p === this.players[0] && this.game?.tryDocwagonRescue?.('htr', { standingHostiles: standingNames })) {
263
320
  return;
264
321
  }
265
322
  const who = standingNames.length > 0 ? `with ${standingNames.join(', ')} still standing over you` : `and this time nobody is coming`;
323
+ this.markPlayerDead(p);
324
+ // The primary player's death ends the session (permadeath, the solo
325
+ // path). A non-primary human in a shared run dies for real too --
326
+ // their save burns via Game.onPlayerDeath -- but the run continues
327
+ // while any human still stands.
328
+ if (p !== this.players[0]) {
329
+ this.game?.onPlayerDeath?.(p);
330
+ this.logger.logWithColor(`💀 ${p.name} is out cold ${who} -- the street decides what happens to sleeping runners.`, 'red');
331
+ return;
332
+ }
266
333
  void this.endGame().then(() => {
267
334
  this.logger.logWithColor(`💀 GAME OVER: ${p.name} is out cold ${who} -- the street decides what happens to sleeping runners.`, 'red');
268
335
  this.logger.logWithColor(`Press ctrl-c to quit.`, 'red');
@@ -340,11 +407,17 @@ export class Scene extends AbstractScene {
340
407
  // PLAIN SIGHT (see Room.plainSightItems): the player just watched it
341
408
  // scatter, so "take all" must not demand a "search" to sweep it.
342
409
  const dropped = [];
410
+ // Spilled gear lands where the BODY fell -- an item's stale atSpot
411
+ // from wherever it was last shelved sent a real session hunting a
412
+ // "counter" that didn't exist in the room (the note had ridden a
413
+ // crew member's pack from a different scene entirely).
414
+ const fallSpot = actor.atSpot;
343
415
  if (room && actor.inventory) {
344
416
  const items = actor.inventory.getAllItems();
345
417
  for (const item of items) {
346
418
  const didDrop = actor.inventory.transferItemTo(item.name, room.inventory);
347
419
  if (didDrop) {
420
+ item.atSpot = fallSpot;
348
421
  this.logger.write(`${actor.name} dropped ${item.name} into ${room.name} upon removal.`);
349
422
  room.plainSightItems.add(item.name.toLowerCase());
350
423
  dropped.push(item.name);
@@ -362,6 +435,7 @@ export class Scene extends AbstractScene {
362
435
  continue;
363
436
  for (const [slot, item] of Object.entries(group)) {
364
437
  if (item) {
438
+ item.atSpot = fallSpot;
365
439
  room.inventory.addItem(item);
366
440
  group[slot] = null;
367
441
  this.logger.write(`${actor.name} dropped equipped ${item.name} into ${room.name} upon removal.`);
@@ -509,7 +583,39 @@ export class Scene extends AbstractScene {
509
583
  return this.getRoom(roomNames[0]);
510
584
  }
511
585
  getPlayer() {
512
- return this.player;
586
+ return this.players[0];
587
+ }
588
+ getPlayers() {
589
+ return this.players;
590
+ }
591
+ getPlayerByName(name) {
592
+ const n = name.toLowerCase();
593
+ return this.players.find(p => p.name.toLowerCase() === n);
594
+ }
595
+ /**
596
+ * True when `actor` is one of the scene's HUMAN players (vs an NPC or
597
+ * companion shell). The multiplayer-aware replacement for the old
598
+ * `actor === scene.getPlayer()` identity checks -- use this wherever
599
+ * the question is "is this a human" rather than "is this the local
600
+ * terminal's player".
601
+ */
602
+ isHumanControlled(actor) {
603
+ return this.players.includes(actor);
604
+ }
605
+ /** A second human joins the live scene (shared runs, late join):
606
+ * spawn them into `room` and register them like any actor. */
607
+ addPlayer(player, room) {
608
+ this.players.push(player);
609
+ player.currentLocation = room;
610
+ this.addActor(player);
611
+ room.addActor(player);
612
+ }
613
+ /** Human players who died in THIS scene. Solo play ends the game on
614
+ * the primary's death before this matters; shared runs continue
615
+ * while any human still stands. */
616
+ deadPlayers = new Set();
617
+ markPlayerDead(player) {
618
+ this.deadPlayers.add(player.name);
513
619
  }
514
620
  isCompleted() {
515
621
  return this._completed;
@@ -559,52 +665,34 @@ export class Scene extends AbstractScene {
559
665
  if (condition.item.toLowerCase() !== itemName.toLowerCase()) {
560
666
  return null;
561
667
  }
668
+ // 'possess' completes when ANY human player ends up holding the
669
+ // macguffin (symmetric MP: a partied runner's grab counts for the
670
+ // team); the reward lands in the hands that did the holding.
671
+ const possessorPlayer = this.getPlayerByName(possessorName);
562
672
  const satisfied = condition.type === 'give'
563
673
  ? condition.toNpc?.toLowerCase() === possessorName.toLowerCase()
564
- : possessorName.toLowerCase() === this.player.name.toLowerCase();
674
+ : possessorPlayer !== undefined;
565
675
  if (!satisfied)
566
676
  return null;
567
677
  this._completed = true;
568
678
  if (condition.rewardItem) {
569
- this.player.addInventory(condition.rewardItem);
679
+ const rewardee = possessorPlayer ?? this.player;
680
+ rewardee.addInventory(condition.rewardItem);
570
681
  this.updateInventory(this.player.inventory);
571
682
  }
572
- // The agreed pay actually lands -- what the fixer promised (his AI
573
- // prompt is told this exact figure, see npc.ts) is what gets paid.
574
- // Payments accumulate on ONE certified credstick across jobs: minting
575
- // a same-named stick per job would silently overwrite the previous one
576
- // in the name-keyed inventory.
683
+ // THE PAY (canon split, p.28-29): with a CLIENT in the scene, money
684
+ // moves at the MEET (advance) and the WRAP-UP (remainder) -- see
685
+ // Game.onClientInteraction -- so completion itself pays nothing
686
+ // here. Legacy/classic scenes (no client on the seed) keep the
687
+ // original instant payout.
577
688
  let paymentNote = '';
578
- if (condition.payout && condition.payout > 0) {
579
- const PAYSTICK_NAME = 'Certified Credstick (job payments)';
580
- let paystick = this.player.inventory.getAllItems().find(i => i.name === PAYSTICK_NAME);
581
- if (!paystick) {
582
- paystick = new Item({
583
- name: PAYSTICK_NAME,
584
- description: 'A matte-black certified credstick. Every job that pays out lands here, clean.',
585
- size: Size.Tiny, category: Category.Credstick,
586
- shape: 'Rectangle', color: 'Matte Black', texture: 'Smooth',
587
- rating: Rating.Standard, weight: 0.05, transferable: true,
588
- });
589
- try {
590
- this.player.addInventory(paystick);
591
- }
592
- catch {
593
- paystick = undefined; // over the carry cap somehow -- log, don't crash the win
594
- this.logger.error(`Could not hand the player their payment credstick (carry weight).`);
595
- }
596
- }
597
- if (paystick) {
598
- // SINner (p.84-85): a certified payment to a SIN on file gets its
599
- // cut skimmed before it ever lands -- the drain that makes the
600
- // negative quality's karma refund an honest trade.
601
- const taxRate = this.player.sinTaxRate;
602
- const tax = Math.floor(condition.payout * taxRate);
603
- const netPay = condition.payout - tax;
604
- paystick.addCurrency(netPay);
605
- this.updateInventory(this.player.inventory);
606
- this.logger.write(`Job payout: ${netPay} nuyen credited to "${PAYSTICK_NAME}"${tax > 0 ? ` (${tax} withheld: SIN tax ${Math.round(taxRate * 100)}%)` : ''}.`);
607
- paymentNote = `\nPayment received: ${netPay}¥, certified. It's on your stick.${tax > 0 ? `\nSIN on file: ${tax}¥ withheld at source (${Math.round(taxRate * 100)}% -- the system never forgets your number).` : ''}`;
689
+ const clientHandlesPay = !!this.game?.currentClient;
690
+ if (!clientHandlesPay && condition.payout && condition.payout > 0) {
691
+ // Legacy instant path still splits with the crew (canon team take).
692
+ const split = this.game?.applyCrewCut(condition.payout) ?? { share: condition.payout, line: '' };
693
+ const paid = this.payRunner(split.share, 'job payout');
694
+ if (paid) {
695
+ paymentNote = `\nPayment received: ${paid.net}¥, certified. It's on your stick.${split.line ? `\n${split.line}` : ''}${paid.tax > 0 ? `\nSIN on file: ${paid.tax}¥ withheld at source (${Math.round(this.player.sinTaxRate * 100)}% -- the system never forgets your number).` : ''}`;
608
696
  }
609
697
  }
610
698
  this.addWorldEvent(`${possessorName} ended up with ${itemName} -- the job is done.`);
@@ -626,14 +714,78 @@ export class Scene extends AbstractScene {
626
714
  const karmaNote = `\nKarma earned: ${karmaEarned} (${this.player.karma} banked${hint(` -- "advance" at your hideout to train`)}).${credNote}`;
627
715
  // Hub sessions go HOME between jobs (walking into the hideout takes
628
716
  // you back -- see go.ts); classic sessions get the next job from the
629
- // fixer over comms. No system verbs in either path.
717
+ // fixer over comms. No system verbs in either path. With a client
718
+ // holding the purse, the banner points at the WRAP-UP instead of the
719
+ // curb -- the remainder is theirs to hand over (p.29: "deliver...
720
+ // collect the remainder of your pay").
721
+ const client = this.game?.currentClient;
722
+ const settleLine = clientHandlesPay && client && !client.reported
723
+ ? `\nThe balance is with ${client.name} -- settle up before you skip town (talk to them, or call).`
724
+ : '';
630
725
  const nextStep = this.game?.hasHub
631
726
  ? hint(`"call taxi" from the curb when you're ready to head home.`)
632
727
  : hint(`Call your Johnson when you're ready for more work, or "quit" to stop here.`);
633
- const banner = `${condition.completionMessage}${paymentNote}${karmaNote}\n\n=== JOB COMPLETE ===\n${nextStep}`;
728
+ const banner = `${condition.completionMessage}${paymentNote}${karmaNote}${settleLine}\n\n=== ${clientHandlesPay ? 'OBJECTIVE COMPLETE' : 'JOB COMPLETE'} ===\n${nextStep}`;
634
729
  this.logger.logWithColor(banner, 'green');
635
730
  return banner;
636
731
  }
732
+ /**
733
+ * Money actually landing in the runner's hands: accumulates on ONE
734
+ * certified credstick across jobs (minting a same-named stick per job
735
+ * would silently overwrite in the name-keyed inventory), with the
736
+ * SINner tax (p.84-85) skimmed at source. Shared by the meet advance,
737
+ * the wrap-up remainder (Game.onClientInteraction), and the legacy
738
+ * instant payout above. Returns what moved, or undefined when the
739
+ * stick couldn't be handed over.
740
+ */
741
+ payRunner(amount, label) {
742
+ if (!amount || amount <= 0)
743
+ return undefined;
744
+ // CANON TEAM SPLIT (symmetric MP): the take divides evenly among the
745
+ // living human players -- each runner's share lands on their OWN
746
+ // certified stick, taxed at their own SIN rate. Solo: one human,
747
+ // whole take, byte-identical to the historical path. The return is
748
+ // the PRIMARY's movement (what the existing solo call sites print).
749
+ const humans = this.players.filter(p => !this.deadPlayers.has(p.name));
750
+ if (humans.length <= 1) {
751
+ return this.payOne(humans[0] ?? this.player, amount, label);
752
+ }
753
+ const share = Math.floor(amount / humans.length);
754
+ let primaryResult;
755
+ for (const p of humans) {
756
+ const paid = this.payOne(p, share, `${label} (team split, 1/${humans.length})`);
757
+ if (p === this.player)
758
+ primaryResult = paid;
759
+ }
760
+ return primaryResult;
761
+ }
762
+ payOne(player, amount, label) {
763
+ const PAYSTICK_NAME = 'Certified Credstick (job payments)';
764
+ let paystick = player.inventory.getAllItems().find(i => i.name === PAYSTICK_NAME);
765
+ if (!paystick) {
766
+ paystick = new Item({
767
+ name: PAYSTICK_NAME,
768
+ description: 'A matte-black certified credstick. Every job that pays out lands here, clean.',
769
+ size: Size.Tiny, category: Category.Credstick,
770
+ shape: 'Rectangle', color: 'Matte Black', texture: 'Smooth',
771
+ rating: Rating.Standard, weight: 0.05, transferable: true,
772
+ });
773
+ try {
774
+ player.addInventory(paystick);
775
+ }
776
+ catch {
777
+ this.logger.error(`Could not hand ${player.name} their payment credstick (carry weight).`);
778
+ return undefined;
779
+ }
780
+ }
781
+ const taxRate = player.sinTaxRate;
782
+ const tax = Math.floor(amount * taxRate);
783
+ const net = amount - tax;
784
+ paystick.addCurrency(net);
785
+ this.updateInventory(this.player.inventory);
786
+ this.logger.write(`${label}: ${net} nuyen credited to "${PAYSTICK_NAME}" (${player.name})${tax > 0 ? ` (${tax} withheld: SIN tax ${Math.round(taxRate * 100)}%)` : ''}.`);
787
+ return { net, tax };
788
+ }
637
789
  addWorldEvent(event) {
638
790
  this._worldEvents.push(event);
639
791
  if (this._worldEvents.length > this._maxWorldEvents) {
@@ -682,7 +834,7 @@ export class Scene extends AbstractScene {
682
834
  return Object.values(this.rooms).some(room => room.inventory.getAllItems().some(item => item.satisfiesLifestyle?.addictionTypes?.includes(addiction)));
683
835
  }
684
836
  validateLifestyleSupport() {
685
- const allActors = [this.player, ...this.allActors];
837
+ const allActors = [...this.players, ...this.allActors];
686
838
  for (const actor of allActors) {
687
839
  const lifestyle = actor.lifestyleChoices ?? [];
688
840
  const name = actor.name;