@maka/maka-cli 5.63.0 → 5.64.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 (82) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js +29 -8
  3. package/bundle/typescript/src/commands/game/sideQuest/commands/ar.js.map +1 -1
  4. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.d.ts +42 -0
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js +86 -3
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/ask.js.map +1 -1
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js +5 -0
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js.map +1 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js +29 -5
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/call.js.map +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js +22 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/contacts.js.map +1 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js +16 -9
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/jobs.js.map +1 -1
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/journal.d.ts +44 -0
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/journal.js +136 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/journal.js.map +1 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/look.d.ts +20 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js +99 -3
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/look.js.map +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/commands/players.js +8 -2
  22. package/bundle/typescript/src/commands/game/sideQuest/commands/players.js.map +1 -1
  23. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js +27 -4
  24. package/bundle/typescript/src/commands/game/sideQuest/commands/search.js.map +1 -1
  25. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +30 -1
  26. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
  27. package/bundle/typescript/src/commands/game/sideQuest/commands/silent.js +11 -1
  28. package/bundle/typescript/src/commands/game/sideQuest/commands/silent.js.map +1 -1
  29. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.d.ts +47 -0
  30. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js +91 -5
  31. package/bundle/typescript/src/commands/game/sideQuest/commands/talk.js.map +1 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/commands/time.d.ts +20 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/commands/time.js +61 -0
  34. package/bundle/typescript/src/commands/game/sideQuest/commands/time.js.map +1 -0
  35. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +45 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +150 -9
  37. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +104 -0
  39. package/bundle/typescript/src/commands/game/sideQuest/game.js +257 -11
  40. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  41. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.d.ts +78 -0
  42. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +188 -0
  43. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js.map +1 -0
  44. package/bundle/typescript/src/commands/game/sideQuest/headless.d.ts +5 -0
  45. package/bundle/typescript/src/commands/game/sideQuest/headless.js +54 -0
  46. package/bundle/typescript/src/commands/game/sideQuest/headless.js.map +1 -1
  47. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +62 -0
  48. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +121 -9
  49. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.d.ts +9 -0
  51. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js +28 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/models/puzzle.js.map +1 -1
  53. package/bundle/typescript/src/commands/game/sideQuest/models/room.d.ts +67 -7
  54. package/bundle/typescript/src/commands/game/sideQuest/models/room.js +116 -19
  55. package/bundle/typescript/src/commands/game/sideQuest/models/room.js.map +1 -1
  56. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.d.ts +22 -0
  57. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js +26 -0
  58. package/bundle/typescript/src/commands/game/sideQuest/utilities/ar.js.map +1 -1
  59. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.js +6 -0
  60. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.js.map +1 -1
  61. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.d.ts +6 -0
  62. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js.map +1 -1
  63. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.d.ts +22 -0
  64. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js +24 -0
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/log-style.js.map +1 -1
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js +30 -5
  67. package/bundle/typescript/src/commands/game/sideQuest/utilities/persistence.js.map +1 -1
  68. package/bundle/typescript/src/commands/game/sideQuest/utilities/playtester.d.ts +50 -0
  69. package/bundle/typescript/src/commands/game/sideQuest/utilities/playtester.js +43 -0
  70. package/bundle/typescript/src/commands/game/sideQuest/utilities/playtester.js.map +1 -0
  71. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.d.ts +11 -0
  72. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +42 -28
  73. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js.map +1 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +66 -8
  75. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js.map +1 -1
  76. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.d.ts +45 -3
  77. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +41 -6
  78. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js.map +1 -1
  79. package/bundle/typescript/src/commands/game/sideQuest/utilities/world-clock.d.ts +83 -0
  80. package/bundle/typescript/src/commands/game/sideQuest/utilities/world-clock.js +106 -0
  81. package/bundle/typescript/src/commands/game/sideQuest/utilities/world-clock.js.map +1 -0
  82. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import blessed from 'blessed';
2
2
  import { dirname } from 'path';
3
3
  import { capitalCase } from 'change-case';
4
- import { Category } from './types/shared/item-enum.js';
4
+ import { Category, Size, Rating } from './types/shared/item-enum.js';
5
5
  import { Item } from './models/item.js';
6
6
  import { Room } from './models/_index.js';
7
7
  import { Door } from './models/door.js';
@@ -27,6 +27,9 @@ import { TakeCommand } from './commands/take.js';
27
27
  import { LootCommand } from './commands/loot.js';
28
28
  import { NoteCommand } from './commands/note.js';
29
29
  import { BacklogCommand } from './commands/backlog.js';
30
+ // Whether this account may actually USE the backlog -- gates what the
31
+ // help index offers, never what the verb accepts (item D).
32
+ import { isPlaytester, probePlaytesterStanding } from './utilities/playtester.js';
30
33
  import { RolesCommand } from './commands/roles.js';
31
34
  import { SinCommand } from './commands/sin.js';
32
35
  import { GoCommand } from './commands/go.js';
@@ -109,6 +112,7 @@ import { SellCommand } from './commands/sell.js';
109
112
  import { PayCommand } from './commands/pay.js';
110
113
  import { LifestyleCommand } from './commands/lifestyle.js';
111
114
  import { JobsCommand } from './commands/jobs.js';
115
+ import { JournalCommand } from './commands/journal.js';
112
116
  import { TreatCommand } from './commands/treat.js';
113
117
  import { WorkCommand } from './commands/work.js';
114
118
  import { EatCommand } from './commands/eat.js';
@@ -127,6 +131,10 @@ import { OverwatchCommand } from './commands/overwatch.js';
127
131
  import { MarkCommand } from './commands/mark.js';
128
132
  import { SnoopCommand } from './commands/snoop.js';
129
133
  import { TapCommand } from './commands/tap.js';
134
+ import { TimeCommand } from './commands/time.js';
135
+ // The HUD's clock -- read in the engine so a shared run reads the
136
+ // host's wall clock, never each client's own (item B).
137
+ import { clockTime, worldNow } from './utilities/world-clock.js';
130
138
  import { AutoFight } from './utilities/auto-fight.js';
131
139
  import { CommandFactory } from './factories/command-factory.js';
132
140
  import { SceneSynthesizer } from './factories/scene-factory.js';
@@ -156,6 +164,12 @@ export default class Game {
156
164
  _quitArmed = false;
157
165
  _escArmedAt = 0;
158
166
  _statusBox;
167
+ /** The clock gutter left of the prompt, its ticker, and the face it
168
+ * last painted -- compared so a repaint only happens on the minute
169
+ * rollover (see paintClock). */
170
+ _clockBox;
171
+ _clockTimer;
172
+ _clockFace = '';
159
173
  _inputBox;
160
174
  /** The floating icon-minimap (map-overlay wave 2026-08-24). Optional:
161
175
  * headless sessions never build widgets. */
@@ -172,6 +186,24 @@ export default class Game {
172
186
  _anonymous = false;
173
187
  _logFilePath = '';
174
188
  _tier = 1;
189
+ /**
190
+ * The run's difficulty tier. `activateRunScene` sets it from the tier
191
+ * the scene was generated at; a HEADLESS boot never calls that, so a
192
+ * shared run sat at the default 1 no matter what it was actually
193
+ * generated as (audit 2026-08-27).
194
+ *
195
+ * That does not sit still: the stored tier rides into the save
196
+ * snapshot and `continueToNextScene` reads `_tier + 1` for the next
197
+ * draft, so an under-recorded tier propagates forward into content
198
+ * that has not been generated yet. Exposed so the headless boot can
199
+ * state the tier it was handed instead of guessing.
200
+ */
201
+ get tier() {
202
+ return this._tier;
203
+ }
204
+ set tier(value) {
205
+ this._tier = value;
206
+ }
175
207
  // Guards against two overlapping continueToNextScene() calls -- found via
176
208
  // a real session where calling the fixer twice in a row (nothing stopped
177
209
  // it, and a 20-40+ second draft invites a second, impatient call) fired
@@ -865,6 +897,80 @@ export default class Game {
865
897
  * setCharacterLocation (the ONLY path that binds room observers), and
866
898
  * hand it the scene for its AI context.
867
899
  */
900
+ /**
901
+ * THE FIXER IS REACHABLE FROM THE FIELD (player go 2026-08-27: "on a
902
+ * multi-crew session, I should still be able to TALK to Mr. Krow if I
903
+ * need something").
904
+ *
905
+ * A shared run executes on a server whose Game has no hub scene, so
906
+ * `hubCast()` is empty and NOBODY from home is a call candidate --
907
+ * including the fixer. Solo mid-run they are (see call.ts's own note
908
+ * about "call Ratchet" bouncing while the roster promised a phone
909
+ * icon), so the same fiction behaved differently by mode, invisibly.
910
+ *
911
+ * The ruling that keeps the ROSTER home is untouched, because the
912
+ * roster and the fixer are different KINDS of thing. The roster is
913
+ * per-player relationship state -- your loyalty, your favours, your
914
+ * ledger -- which is not merely awkward on a shared server but
915
+ * meaningless there, since four players have four different Mama Wus.
916
+ * The fixer is world furniture: one fixed identity, the same Krow for
917
+ * everyone at the table, carrying no per-player state. A shared-run
918
+ * Krow is well-defined in a way a shared-run roster is not, so
919
+ * answering yes here costs nothing that ruling was protecting.
920
+ *
921
+ * Deliberately NOT the whole hub cast: that needs per-player contact
922
+ * identity on the server and would reopen the ruling.
923
+ *
924
+ * He is built lazily and kept OFF the scene -- he is a voice on a
925
+ * commlink, not a body in the room, so he must never appear in "look",
926
+ * be attacked, or be looted. Returns undefined in any session that
927
+ * already has a real one (a hub session, or a static scene that ships
928
+ * him as a cast member), so there is only ever one Krow.
929
+ */
930
+ remoteFixer() {
931
+ if (this.hasHub || !this.scene)
932
+ return undefined;
933
+ if (this.scene.allActors.some(a => isFixerName(a.name)))
934
+ return undefined;
935
+ if (this._remoteFixer)
936
+ return this._remoteFixer;
937
+ // His own private "room": never added to the scene, so nothing can
938
+ // walk into it. It exists because an NPC needs somewhere to stand
939
+ // for its own AI context to resolve.
940
+ const line = new Room({
941
+ name: 'On The Line',
942
+ description: 'Somewhere across the sprawl, a man takes a call he was expecting.',
943
+ roomType: 'void',
944
+ });
945
+ const npc = new NPC({
946
+ dialog: [
947
+ `Talk to me, chummer -- you're on the clock.`,
948
+ `I broker the work. What happens on site is your business.`,
949
+ ],
950
+ description: `The fixer who put this job together. He is not on site and never will be -- he brokers, he does not run. Reachable by commlink because that is the whole point of a fixer. He knows the JOB exists and who hired for it; he does NOT know the inside of the place, the codes, or what the runners are looking at right now.`,
951
+ playerConstructorConfig: {
952
+ playerName: FIXER_NAME,
953
+ startLocation: line,
954
+ combat: { body: 3, agility: 3, reaction: 3, willpower: 4, logic: 4, intuition: 4, charisma: 5, strength: 3, edge: 3 },
955
+ },
956
+ }, new CommandRegistry(this.scene));
957
+ // A fixer with no commlink is a contradiction, and call.ts gates on
958
+ // carrying one (hasCommlink) before it will connect.
959
+ // NOTE the enum VALUES here. A runtime Item validates against the
960
+ // enum's values ('tiny'); a SAVED item uses the enum KEYS ('Tiny').
961
+ // The two conventions are inverses and both are load-bearing.
962
+ npc.inventory.addItem(new Item({
963
+ name: 'Fixer\'s Commlink',
964
+ description: 'The number everyone has and nobody sells.',
965
+ size: Size.Tiny, category: Category.Commlink, shape: 'Rectangle',
966
+ color: 'Black', texture: 'Matte', rating: Rating.Common, weight: 0.1,
967
+ }));
968
+ npc.scene = this.scene;
969
+ this._remoteFixer = npc;
970
+ Logger.getInstance().write(`Remote fixer materialised for a hubless session: ${FIXER_NAME}.`);
971
+ return npc;
972
+ }
973
+ _remoteFixer;
868
974
  spawnCompanionShell(opts) {
869
975
  if (this.scene.getActor(opts.name)) {
870
976
  Logger.getInstance().write(`spawnCompanionShell: name "${opts.name}" already in scene -- refusing.`);
@@ -1466,6 +1572,24 @@ export default class Game {
1466
1572
  // state never is (runs are disposable); reconstructed fresh by
1467
1573
  // activateRunScene, settled into the roster by returnToHub.
1468
1574
  currentClient;
1575
+ /**
1576
+ * WHEN THE RUN STARTED -- the one fact the journal cannot derive.
1577
+ *
1578
+ * commands/journal.ts renders current state rather than recording
1579
+ * events, deliberately: a journal that logs what happened can drift
1580
+ * from the world it describes, and a derived one cannot disagree with
1581
+ * the thing it is reading. Client, pay and objective all already exist
1582
+ * elsewhere and are read live. Elapsed time is the exception -- its
1583
+ * ORIGIN is not recoverable from any current state -- so this is the
1584
+ * single new field the feature adds, said out loud rather than grown
1585
+ * quietly into a record.
1586
+ *
1587
+ * Not saved, and does not need to be: like currentClient above, mid-run
1588
+ * state never persists (runs are disposable -- a quit resumes to the
1589
+ * hub with the job back on offer), so there is no save-schema change
1590
+ * here. Stamped by activateRunScene, cleared by settleClientReputation.
1591
+ */
1592
+ runStartedAt;
1469
1593
  // Taxi warn-once (player ruling): leaving with the pay unclaimed
1470
1594
  // arms this; the second "call taxi" forfeits. Mirrors _quitArmed.
1471
1595
  forfeitArmed = false;
@@ -1541,6 +1665,7 @@ export default class Game {
1541
1665
  settleClientReputation(wrappedRun) {
1542
1666
  const client = this.currentClient;
1543
1667
  this.currentClient = undefined;
1668
+ this.runStartedAt = undefined;
1544
1669
  this.forfeitArmed = false;
1545
1670
  if (!client)
1546
1671
  return '';
@@ -1799,6 +1924,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
1799
1924
  CommandFactory.registerCommand('drink', DrinkCommand);
1800
1925
  CommandFactory.registerCommand('lifestyle', LifestyleCommand);
1801
1926
  CommandFactory.registerCommand('jobs', JobsCommand);
1927
+ CommandFactory.registerCommand('journal', JournalCommand);
1802
1928
  CommandFactory.registerCommand('treat', TreatCommand);
1803
1929
  CommandFactory.registerCommand('work', WorkCommand);
1804
1930
  CommandFactory.registerCommand('gig', WorkCommand);
@@ -1827,6 +1953,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
1827
1953
  // "jackpoint" are what players reach for when they mean the same act.
1828
1954
  CommandFactory.registerCommand('tap', TapCommand);
1829
1955
  CommandFactory.registerCommand('splice', TapCommand);
1956
+ // The wall clock (item B). "clock" too -- half of us type that.
1957
+ CommandFactory.registerCommand('time', TimeCommand);
1958
+ CommandFactory.registerCommand('clock', TimeCommand);
1830
1959
  }
1831
1960
  determineStartRoom() {
1832
1961
  return this.scene.determineStartRoom();
@@ -1928,6 +2057,12 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
1928
2057
  this._saveFilePath = options.saveFilePath;
1929
2058
  this._owner = options.owner;
1930
2059
  this._anonymous = options.anonymousRunner === true;
2060
+ // Ask the server, once, whether this account is a play-tester, so
2061
+ // the help index can stop advertising a backlog that will only 403
2062
+ // (item D). Fire-and-forget on purpose: it never blocks the boot,
2063
+ // never throws, and an unanswered probe just leaves the menu entry
2064
+ // hidden while the verb stays usable. See utilities/playtester.ts.
2065
+ void probePlaytesterStanding({ anonymous: this._anonymous });
1931
2066
  // ONE loud notice the moment the AI provider refuses on ACCOUNT
1932
2067
  // grounds (empty credits, bad key). Without it a dead key mutes
1933
2068
  // every NPC and makes every job "fall through" invisibly -- a real
@@ -2041,6 +2176,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2041
2176
  equipIntoEmptySlots(this.player);
2042
2177
  this.warmNpcAppearancesInBackground();
2043
2178
  this.startPursuitHeartbeat();
2179
+ this.startClockTicker();
2044
2180
  // The banner learns where we are only after the hub/scene bookkeeping
2045
2181
  // above settles.
2046
2182
  this.refreshLogLabel();
@@ -2761,7 +2897,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2761
2897
  // playtest note: being yanked into the run the instant generation
2762
2898
  // finished was jarring). The player invokes it on their own time:
2763
2899
  // "call Mr. Johnson" delivers the offer (haggle window included --
2764
- // see call.ts), and "travel" actually goes (launchPendingRun).
2900
+ // see call.ts), and "call taxi" actually goes (launchPendingRun).
2765
2901
  if (this.hasHub) {
2766
2902
  // The mid-generation race (see draftedForParty above): if the live
2767
2903
  // party changed while Krow was on the phone, this seed was quoted
@@ -2795,6 +2931,8 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
2795
2931
  this.camPingedRooms.clear();
2796
2932
  this.sweptRooms.clear();
2797
2933
  this.sceneSeed = seed;
2934
+ // The clock starts when the run does (commands/journal.ts).
2935
+ this.runStartedAt = worldNow();
2798
2936
  // A call partner from the old scene wouldn't exist in the new one --
2799
2937
  // hang up before moving on, rather than leave a dangling reference.
2800
2938
  if (this.player.activeCallPartner) {
@@ -3007,6 +3145,45 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3007
3145
  });
3008
3146
  }
3009
3147
  }
3148
+ /**
3149
+ * THE CLOCK TICKER (playtest 2026-08-27). The gutter rendered once,
3150
+ * whenever the pane happened to paint, and then showed that moment
3151
+ * forever. Parked beside a live prompt it is under the player's eye
3152
+ * for the whole run, so a stale one is worse than none at all -- and
3153
+ * the move made that matter MORE, not less.
3154
+ *
3155
+ * Repaints on the MINUTE ROLLOVER, not on every tick: it wakes each
3156
+ * second but only touches the screen when the rendered string
3157
+ * actually changes. A forced once-a-second render would flicker the
3158
+ * whole terminal and cost more than the feature is worth.
3159
+ */
3160
+ paintClock() {
3161
+ if (!this._clockBox)
3162
+ return;
3163
+ const face = clockTime();
3164
+ if (face === this._clockFace)
3165
+ return;
3166
+ this._clockFace = face;
3167
+ this._clockBox.setContent(`{center}{cyan-fg}${face}{/cyan-fg}{/center}`);
3168
+ this._screen?.render();
3169
+ }
3170
+ startClockTicker() {
3171
+ // No gutter, nothing to repaint. Headless sessions have no screen,
3172
+ // and a server hosting many of them should not spin one interval
3173
+ // per session to call a function that early-returns.
3174
+ if (!this._clockBox || this._clockTimer)
3175
+ return;
3176
+ this._clockTimer = setInterval(() => this.paintClock(), 1000);
3177
+ // Never hold the process open for a clock: a session that is
3178
+ // otherwise finished should exit, not linger for the next minute.
3179
+ this._clockTimer.unref?.();
3180
+ }
3181
+ stopClockTicker() {
3182
+ if (this._clockTimer) {
3183
+ clearInterval(this._clockTimer);
3184
+ this._clockTimer = undefined;
3185
+ }
3186
+ }
3010
3187
  stopPresenceHeartbeat() {
3011
3188
  if (this._presenceTimer) {
3012
3189
  clearInterval(this._presenceTimer);
@@ -3020,6 +3197,9 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3020
3197
  */
3021
3198
  async dispose() {
3022
3199
  this.stopPresenceHeartbeat();
3200
+ // A stray interval outliving a closed shared session would be a
3201
+ // nasty thing to find later, so it retires with the rest of them.
3202
+ this.stopClockTicker();
3023
3203
  if (this._presenceEquipTimer)
3024
3204
  clearTimeout(this._presenceEquipTimer);
3025
3205
  this.stopPursuitHeartbeat();
@@ -3033,10 +3213,39 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3033
3213
  }
3034
3214
  initInputBox(screen) {
3035
3215
  // Create an input box for user commands
3036
- const inputBox = blessed.textbox({
3216
+ // THE CLOCK GUTTER (playtest 2026-08-27: "if it makes sense, the
3217
+ // clock can be to the left of the input box"). A fixed-width pane
3218
+ // at left:0 with the input narrowed to match.
3219
+ //
3220
+ // This is also where the truncation bug goes to die rather than
3221
+ // move house. The clock used to be the LAST field on the Armor
3222
+ // line, so it was the first thing clipped when that pane got tight
3223
+ // -- which is why the player could not find it. A dedicated
3224
+ // fixed-width gutter cannot be squeezed by its neighbours: the
3225
+ // input field yields the columns instead, and long input already
3226
+ // scrolls horizontally. The cost is CLOCK_GUTTER_W columns of
3227
+ // typing room.
3228
+ //
3229
+ // A sibling widget does not touch the textbox's internal cursor --
3230
+ // it just gets a narrower width -- so there is no cursor maths here.
3231
+ const clockBox = blessed.box({
3232
+ id: 'clockBox',
3037
3233
  bottom: 0,
3038
3234
  left: 0,
3039
- width: '100%',
3235
+ width: Game.CLOCK_GUTTER_W,
3236
+ height: 3,
3237
+ tags: true,
3238
+ border: { type: 'line' },
3239
+ style: { border: { fg: 'cyan' } },
3240
+ content: '',
3241
+ });
3242
+ this._clockBox = clockBox;
3243
+ screen.append(clockBox);
3244
+ this.paintClock();
3245
+ const inputBox = blessed.textbox({
3246
+ bottom: 0,
3247
+ left: Game.CLOCK_GUTTER_W,
3248
+ width: `100%-${Game.CLOCK_GUTTER_W}`,
3040
3249
  height: 3,
3041
3250
  tags: true,
3042
3251
  border: { type: 'line' },
@@ -3307,6 +3516,10 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3307
3516
  * the inner width from it to right-justify the elevation bar. */
3308
3517
  static ROOM_DOCK_PCT = 0.23;
3309
3518
  /** Must track the mapBox's `width` below -- used to trim its caption. */
3519
+ /** Columns the clock gutter takes from the input field. Wide enough
3520
+ * for "HH:MM" inside a border; adjustable if it costs too much
3521
+ * typing room in play. */
3522
+ static CLOCK_GUTTER_W = 8;
3310
3523
  static MAP_DOCK_PCT = 0.12;
3311
3524
  initMapBox(screen) {
3312
3525
  const mapBox = blessed.box({
@@ -3554,6 +3767,14 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3554
3767
  // decide whether calls land while you are away from your body.
3555
3768
  const { overlay, link } = arStatusSlots(player);
3556
3769
  const arSlot = player.plane === 'meat' ? ` | ${overlay}` : '';
3770
+ // THE WALL CLOCK rides the readiness line (item B). Rendered HERE,
3771
+ // in the engine, which in a shared run is the server -- so every
3772
+ // player's HUD shows the host's clock and "after midnight" is one
3773
+ // moment for the whole table. utilities/world-clock.ts has the why.
3774
+ // Time only, not the date: the date is what "time" is for.
3775
+ // The clock LEFT this line for the gutter beside the prompt. It was
3776
+ // the last field here, so it was the first thing clipped whenever
3777
+ // the pane got tight -- the reason the player could not find it.
3557
3778
  lines.push(`${armorNote}${arSlot} | ${link}`);
3558
3779
  // Lines 4+: transient states, present only while active. Future
3559
3780
  // contexts (astral projection, VR hot-sim) join here the same way.
@@ -3913,10 +4134,10 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3913
4134
  // resonance" -- matrix is the place, Resonance is the talent).
3914
4135
  { title: 'resonance', entries: [['compile', 'decompile'], ['thread'], ['register']] },
3915
4136
  { title: 'social', entries: [['say'], ['emote', 'me'], ['talk-to'], ['call'], ['end-call', 'end', 'hangup'], ['accept'], ['decline', 'deny', 'refuse'], ['haggle', 'negotiate'], ['persuade', 'convince'], ['ask'], ['contacts']] },
3916
- { title: 'street', entries: [['browse', 'buy'], ['sell'], ['catalog'], ['source', 'procure'], ['work', 'gig'], ['jobs'], ['perform'], ['palm'], ['docwagon'], ['treat'], ['eat'], ['drink'], ['lifestyle'], ['pay']] },
4137
+ { title: 'street', entries: [['browse', 'buy'], ['sell'], ['catalog'], ['source', 'procure'], ['work', 'gig'], ['jobs'], ['journal'], ['perform'], ['palm'], ['docwagon'], ['treat'], ['eat'], ['drink'], ['lifestyle'], ['pay']] },
3917
4138
  { title: 'barriers', entries: [['solve'], ['pick'], ['breach'], ['dispel', 'dispell'], ['bluff'], ['unlock'], ['lock']] },
3918
4139
  { title: 'character', entries: [['sheet', 'stats'], ['advance'], ['qualities', 'quality'], ['sin', 'sins', 'papers'], ['initiate', 'submerge'], ['install', 'chrome']] },
3919
- { title: 'session', entries: [['help'], ['hints'], ['note'], ['backlog'], ['roles'], ['clear'], ['quit', 'exit']] },
4140
+ { title: 'session', entries: [['help'], ['hints'], ['time', 'clock'], ['note'], ['backlog'], ['roles'], ['clear'], ['quit', 'exit']] },
3920
4141
  ];
3921
4142
  /** Registered but deliberately UNLISTED: reachable as subcommands or
3922
4143
  * muscle-memory ("players" folded back under crew after playtesting
@@ -3924,6 +4145,11 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3924
4145
  * answers). Without this, the uncategorized sweep resurrects them
3925
4146
  * in an "other" bucket. */
3926
4147
  static HIDDEN_VERBS = ['players'];
4148
+ /** Session verbs that only a play-tester (or admin) can actually use
4149
+ * -- the server role-gates both and 403s everyone else. Listed in
4150
+ * help ONLY for a confirmed play-tester; see helpCategories. `roles`
4151
+ * rides along because it is admin-only, a strictly narrower group. */
4152
+ static PLAYTESTER_VERBS = new Set(['backlog', 'roles']);
3927
4153
  /** Descriptions for the verbs handled inline in handleInput() rather than registered. */
3928
4154
  static BUILTIN_DESCRIPTIONS = {
3929
4155
  help: 'This overview. "help <category>" for one group with full descriptions; "help <command>" for one command.',
@@ -3937,17 +4163,37 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3937
4163
  const CommandClass = CommandFactory.getAllRegisteredCommands()[verb];
3938
4164
  return CommandClass?.getDescription() ?? 'No description available.';
3939
4165
  }
4166
+ /**
4167
+ * The help index as THIS account should see it (backlog wave 2, item
4168
+ * D: "let's remove Seattle Backlog for non playtesters"). The backlog
4169
+ * is role-gated on the server and 403s everyone else, so listing it
4170
+ * for every player only advertises a refusal. Hidden unless the
4171
+ * account is a CONFIRMED play-tester -- see utilities/playtester.ts
4172
+ * for why "unknown" (offline, logged out) hides rather than shows,
4173
+ * and why the verb itself stays registered and usable regardless.
4174
+ */
4175
+ helpCategories() {
4176
+ if (isPlaytester())
4177
+ return Game.HELP_CATEGORIES;
4178
+ return Game.HELP_CATEGORIES.map(c => c.title !== 'session' ? c : {
4179
+ ...c,
4180
+ entries: c.entries.filter(e => !Game.PLAYTESTER_VERBS.has(e[0])),
4181
+ });
4182
+ }
3940
4183
  getHelpMessage(args = []) {
3941
4184
  const registered = Object.keys(CommandFactory.getAllRegisteredCommands());
4185
+ const helpCategories = this.helpCategories();
4186
+ // The uncategorized sweep must not resurrect what helpCategories
4187
+ // just hid, so the restricted verbs stay "covered" either way.
3942
4188
  const covered = new Set([...Game.HELP_CATEGORIES.flatMap(c => c.entries.flat()), ...Game.HIDDEN_VERBS]);
3943
4189
  const uncategorized = registered.filter(v => !covered.has(v));
3944
4190
  const query = args.join(' ').trim().toLowerCase();
3945
4191
  // "help <category>": that group, with full descriptions.
3946
4192
  if (query) {
3947
- const categoryNames = Game.HELP_CATEGORIES.map(c => c.title);
4193
+ const categoryNames = helpCategories.map(c => c.title);
3948
4194
  const pickedCategory = fuzzyPickName(query, categoryNames);
3949
4195
  if (pickedCategory) {
3950
- const category = Game.HELP_CATEGORIES.find(c => c.title === pickedCategory);
4196
+ const category = helpCategories.find(c => c.title === pickedCategory);
3951
4197
  const lines = [`${category.title.toUpperCase()}`];
3952
4198
  for (const [primary, ...aliases] of category.entries) {
3953
4199
  lines.push(`${BULLET}${primary}${aliases.length > 0 ? ` (${aliases.join(', ')})` : ''}: ${this.describeVerb(primary)}`);
@@ -3962,7 +4208,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
3962
4208
  const pickedVerb = fuzzyPickName(words[0], [...covered, ...uncategorized])
3963
4209
  ?? fuzzyPickName(query, [...covered, ...uncategorized]);
3964
4210
  if (pickedVerb) {
3965
- const entry = Game.HELP_CATEGORIES.flatMap(c => c.entries).find(e => e.includes(pickedVerb));
4211
+ const entry = helpCategories.flatMap(c => c.entries).find(e => e.includes(pickedVerb));
3966
4212
  const primary = entry?.[0] ?? pickedVerb;
3967
4213
  const aliases = entry?.slice(1) ?? [];
3968
4214
  const CommandClass = CommandFactory.getAllRegisteredCommands()[primary];
@@ -4018,7 +4264,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4018
4264
  // line outgrows the Mechanics column (help renders there now --
4019
4265
  // blessed's mid-list wrap lost the left-column alignment).
4020
4266
  const lines = ['COMMANDS'];
4021
- const pad = Math.max(...Game.HELP_CATEGORIES.map(c => c.title.length));
4267
+ const pad = Math.max(...helpCategories.map(c => c.title.length));
4022
4268
  const pushWrapped = (title, verbs) => {
4023
4269
  const rows = [];
4024
4270
  let cur = '';
@@ -4037,7 +4283,7 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
4037
4283
  for (const r of rows.slice(1))
4038
4284
  lines.push(`${' '.repeat(pad)} ${r}`);
4039
4285
  };
4040
- for (const category of Game.HELP_CATEGORIES) {
4286
+ for (const category of helpCategories) {
4041
4287
  pushWrapped(category.title, category.entries.map(e => e[0]));
4042
4288
  }
4043
4289
  if (uncategorized.length > 0) {