castle-web-cli 0.4.167 → 0.4.168

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 (35) hide show
  1. package/dist/castle-host/host.d.ts +2 -0
  2. package/dist/castle-host/host.js +17 -3
  3. package/dist/platformDoc.js +1 -1
  4. package/dist/save-deck.js +13 -1
  5. package/dist/serve.js +2 -0
  6. package/dist/shell/assets/index-CVHj503j.css +1 -0
  7. package/dist/shell/assets/index-D-qJ5VGp.js +425 -0
  8. package/dist/shell/index.html +2 -2
  9. package/kits/base/castle.json +1 -1
  10. package/kits/base/sdk/README.md +3 -1
  11. package/kits/base/sdk/castle.d.ts +1 -0
  12. package/kits/base/sdk/commands.d.ts +7 -0
  13. package/kits/base/sdk/multiplayer.d.ts +6 -0
  14. package/kits/base/sdk/multiplayer.js +6 -0
  15. package/kits/blank/.castle/agent/children.json +1 -0
  16. package/kits/blank/.castle/agent/messages.json +9 -0
  17. package/kits/blank/.castle/agent/pending-sends.json +1 -0
  18. package/kits/blank/.castle/agent/tasks/index.md +2 -0
  19. package/kits/blank/.castle/docs/platform.md +36 -0
  20. package/kits/blank/node_modules/.vite/deps/_metadata.json +8 -0
  21. package/kits/blank/node_modules/.vite/deps/package.json +3 -0
  22. package/kits/multiplayer-2d/CLAUDE.md +6 -0
  23. package/kits/multiplayer-2d/castle.json +3 -3
  24. package/kits/multiplayer-2d/code/systems/multiplayer.js +5 -0
  25. package/kits/multiplayer-3d/CLAUDE.md +6 -1
  26. package/kits/multiplayer-3d/castle.json +3 -3
  27. package/kits/multiplayer-3d/code/systems/multiplayer.js +5 -0
  28. package/kits/real-time/CLAUDE.md +5 -0
  29. package/kits/real-time/castle.json +2 -2
  30. package/kits/real-time/code/client/connection.js +4 -0
  31. package/kits/real-time/code/client/gameHooks.js +3 -0
  32. package/kits/real-time/code/client/soloBadge.js +71 -0
  33. package/package.json +1 -1
  34. package/dist/shell/assets/index-C8M-p00y.js +0 -444
  35. package/dist/shell/assets/index-CIRBt1qf.css +0 -1
@@ -10,8 +10,8 @@
10
10
  <link rel="icon" type="image/png" sizes="32x32" href="/__castle/ide/favicon-32x32.png" />
11
11
  <link rel="icon" type="image/png" sizes="16x16" href="/__castle/ide/favicon-16x16.png" />
12
12
  <link rel="icon" href="/__castle/ide/favicon.ico" sizes="any" />
13
- <script type="module" crossorigin src="/__castle/ide/assets/index-C8M-p00y.js"></script>
14
- <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-CIRBt1qf.css">
13
+ <script type="module" crossorigin src="/__castle/ide/assets/index-D-qJ5VGp.js"></script>
14
+ <link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-CVHj503j.css">
15
15
  </head>
16
16
  <body>
17
17
  <div id="root"></div>
@@ -69,7 +69,7 @@
69
69
  "title": "base",
70
70
  "deckId": "yRcmH4_aYllE",
71
71
  "cardId": "zkFdingmIm3m",
72
- "publishedVersion": "2026-09-05T00:07:27.320Z",
72
+ "publishedVersion": "2026-09-05T01:31:11.287Z",
73
73
  "provides": [
74
74
  "castle-web-sdk"
75
75
  ]
@@ -55,7 +55,9 @@ metadata distinguishes a server `bye`, a replaced server version, an explicit
55
55
 
56
56
  When Castle selects solo fallback, the returned connection has `isSolo === true`,
57
57
  a one-player roster, and no server process. Calls to `send` are accepted but do
58
- nothing. Client→server frames are limited to 16 KB on the wire.
58
+ nothing. `soloReason` says why: `private_deck` or `no_server_bundle` are the
59
+ creator's to fix (change the deck's visibility, or publish a deck that has a
60
+ server entry); anything else reports `unavailable`. Client→server frames are limited to 16 KB on the wire.
59
61
 
60
62
  ### Binary messages
61
63
 
@@ -9,6 +9,7 @@ export { Lifecycle } from "./lifecycle";
9
9
  export type { CastleLifecycleApi } from "./lifecycle";
10
10
  export { Multiplayer } from "./multiplayer";
11
11
  export type { CastleMultiplayerApi, MultiplayerConnection, MultiplayerConnectionState, MultiplayerJoinOptions, MultiplayerRoster, MultiplayerStateMetadata, } from "./multiplayer";
12
+ export type { MultiplayerSoloReason } from "./commands";
12
13
  export { MAX_CLIENT_FRAME_BYTES, MAX_PLAYERS_CAP } from "./multiplayerProtocol";
13
14
  export type { ClientByeFrame, ClientHelloFrame, ClientMessageFrame, ClientPingFrame, ClientPongFrame, ClientReplacedFrame, ClientRosterFrame, ClientToPlatformFrame, ClientToPlatformMessageFrame, MultiplayerPlayer, PlayerIdentity, PlatformToClientFrame, SessionPlayer, SessionType, } from "./multiplayerProtocol";
14
15
  export { Pass } from "./passes";
@@ -66,11 +66,18 @@ export interface MultiplayerGetSessionParams {
66
66
  mode: MultiplayerMode;
67
67
  key?: string | null;
68
68
  }
69
+ /**
70
+ * Why a join fell back to solo. `private_deck` and `no_server_bundle` are the
71
+ * creator's to fix; anything else the platform reports as `unavailable`.
72
+ */
73
+ export type MultiplayerSoloReason = "private_deck" | "no_server_bundle" | "unavailable";
69
74
  export interface MultiplayerGetSessionResult {
70
75
  status: "ok" | "solo" | "unavailable";
71
76
  url: string | null;
72
77
  nonce: string | null;
73
78
  sessionId: string | null;
79
+ /** Set when `status` is "solo"; null otherwise. */
80
+ soloReason: MultiplayerSoloReason | null;
74
81
  }
75
82
  export interface CommandParams {
76
83
  "deckStorage.load": Record<string, never>;
@@ -1,3 +1,4 @@
1
+ import type { MultiplayerSoloReason } from "./commands";
1
2
  import { CastleError } from "./errors";
2
3
  import { type MultiplayerPlayer } from "./multiplayerProtocol";
3
4
  export type MultiplayerJoinOptions = {
@@ -31,6 +32,11 @@ export interface MultiplayerConnection {
31
32
  readonly playerId: string;
32
33
  readonly sessionId: string;
33
34
  readonly isSolo: boolean;
35
+ /**
36
+ * Why the join fell back to solo; null while multiplayer is live.
37
+ * `private_deck` and `no_server_bundle` are the creator's to fix.
38
+ */
39
+ readonly soloReason: MultiplayerSoloReason | null;
34
40
  /** Sends byte payloads as binary frames and all other payloads as JSON. */
35
41
  send(data: unknown): void;
36
42
  /** Receives binary payloads as Uint8Array values and JSON payloads otherwise. */
@@ -25,6 +25,7 @@ class MultiplayerConnectionImpl {
25
25
  currentPlayerId = "";
26
26
  currentSessionId = "";
27
27
  solo = false;
28
+ currentSoloReason = null;
28
29
  baseUrl = "";
29
30
  reconnectToken = "";
30
31
  socket = null;
@@ -52,6 +53,9 @@ class MultiplayerConnectionImpl {
52
53
  get isSolo() {
53
54
  return this.solo;
54
55
  }
56
+ get soloReason() {
57
+ return this.currentSoloReason;
58
+ }
55
59
  async start() {
56
60
  try {
57
61
  await this.acquire();
@@ -114,12 +118,14 @@ class MultiplayerConnectionImpl {
114
118
  throw multiplayerError("MULTIPLAYER_UNAVAILABLE", "Multiplayer is unavailable for this deck.");
115
119
  }
116
120
  this.solo = false;
121
+ this.currentSoloReason = null;
117
122
  this.baseUrl = result.url;
118
123
  this.transition("connecting");
119
124
  await this.openSocket("nonce", result.nonce);
120
125
  }
121
126
  connectSolo(result) {
122
127
  this.solo = true;
128
+ this.currentSoloReason = result.soloReason ?? "unavailable";
123
129
  this.currentPlayerId = "solo";
124
130
  this.currentSessionId = result.sessionId ?? "solo";
125
131
  const you = {
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,9 @@
1
+ [
2
+ {
3
+ "id": "YzUYA2mw",
4
+ "role": "assistant",
5
+ "text": "Welcome to an early test of Castle's new engine! You're starting with a blank deck, without the official art or scene editors, but if HTML and JavaScript can do it then I can help you build it.\n\nDo you already know what you want to make, or do you want to figure it out together?",
6
+ "at": "2026-08-27T19:05:49.289Z",
7
+ "status": "done"
8
+ }
9
+ ]
@@ -0,0 +1,2 @@
1
+ # tasks
2
+ # <task-id> | <item> | <title> | <created> | <status> | <touching>
@@ -0,0 +1,36 @@
1
+ # Castle platform
2
+
3
+ How every Castle deck works, from the Castle CLI. Written fresh at every serve
4
+ start; the deck's own guide (its kit's CLAUDE.md under imports/) adds what its
5
+ kit provides on top of this.
6
+
7
+ ## Imports are read-only
8
+
9
+ - **`imports/` holds other decks' files and is locked read-only on disk.** Files are mode 0444 and directories 0555, so any shell command that writes, moves, or deletes inside `imports/` fails with `EACCES` / "Permission denied" — that is the lock working, not a broken checkout. Do not `chmod` around it, do not `sudo`, and do not retry the command a different way.
10
+ - **Change an import through the CLI, never the filesystem.** `castle-web add-import <deckIdOrUrl>` adds one and `castle-web update-import [alias]` re-fetches it (`--check` to see if it is outdated, `--revert` to undo). These handle the unlock/relock themselves.
11
+ - **`castle-web list-decks [--kind mine|saved|kits]` is how you find out WHAT can be imported** — one deck per line, starting with the id `add-import` takes, and marked when this deck already has it. `imports/` only shows what is here already; it is not a catalogue.
12
+ - **To change one file of an import, FORK it**: `castle-web fork-import-file <path>` takes this deck's own version of that file, and every reference to the imported path uses your version from then on (`castle-web revert-import-file <path>` drops it and goes back to the import's). Fork only what you actually need to change, and as few files as you can -- a forked file stops taking the import's updates, so the more you fork the less an `update-import` can give you. To build something of your own rather than change theirs, write an ordinary file in this deck instead -- and check first whether the kit already lets a deck file register what you want (its CLAUDE.md names those seams), since that path keeps taking updates and a fork does not. When you do fork, it is often worth forking only to add a hook or registration point and keeping the logic itself in a deck file, so later changes need no further forks; for a quick one-off tweak a plain direct fork is fine.
13
+ - **Deleting the deck directory itself needs the lock released first** (`chmod -R u+w` on the deck dir) — that is the one legitimate reason to touch the modes, and only for a directory being thrown away.
14
+
15
+ ## Assets
16
+
17
+ - **Load static assets (drawings, audio, etc.) through the bundler — never runtime-`fetch` a loose file path.** Use a static `import`, `import.meta.glob('./drawings/*.svg', { eager: true, import: 'default' })`, or inline the asset directly. The dev serve happens to serve loose files over HTTP, so `fetch('drawings/qb.svg')` looks like it works locally — but `save-deck` bundles the whole deck into a single file, loose files are no longer served, and the fetch silently fails on every platform. Kit decks: use the kit's own drawing/asset-loading APIs instead of a raw `fetch`.
18
+
19
+ ## Touch controls
20
+
21
+ - **Playable on a touchscreen, with only the controls the game actually needs.** Castle decks are played on phones, so whatever input a game does use must work by touch — direct tap/drag on the game itself wherever possible, and on-screen buttons only where the mechanics genuinely call for them. Do NOT add controls a game doesn't need: never drop in a generic d-pad or movement overlay by default. Prefer touching the game directly over an overlay that just mirrors keyboard keys. Keyboard input is fine to support on top for desktop play. Match the controls to the actual mechanics — a game with no directional movement should have no movement controls at all.
22
+
23
+ ## Fit the card
24
+
25
+ - **The deck plays inside a fixed 5:7 portrait card, not the full window.** The card is sized to fit the screen (at most about 450x630px), clips overflow, and does not scroll. Design the whole layout to fit inside that portrait box: size UI relative to the card with percentages, flex/grid, `min()`, `clamp()`, or viewport-relative units instead of fixed tall panels. Let playfields scale down on smaller cards rather than overflowing; anything outside the card edges is cut off. The SDK exports `CARD_RATIO` (= 5 / 7) if you need the exact ratio.
26
+ - **Hand-rolled `<canvas>` elements must account for devicePixelRatio, or the game looks blurry on phones.** Size the backing store to the CSS layout size times `devicePixelRatio` (e.g. `canvas.width = rect.width * dpr`), keep the CSS width/height as the layout size, and scale the 2D context (`ctx.scale(dpr, dpr)`) so drawing code stays in CSS units — re-apply on resize. Kit decks don't need to do this by hand; the kit's engine already configures its canvas for DPR.
27
+ - Exception: deliberate pixel art wants a fixed low-resolution backing store with `image-rendering: pixelated` CSS instead — don't DPR-scale that; the crisp chunky look is the point.
28
+
29
+ ## Formats, reloading, editors
30
+
31
+ - **Supported media formats are exactly**: images `.png` `.jpg` `.jpeg` `.gif` `.webp` `.svg`, audio `.mp3` `.wav` `.m4a`, video `.mp4` (H.264). Nothing else — a published deck inlines every asset as a `data:` URI, where the browser trusts the declared type instead of sniffing the bytes, so a format that merely works while serving (`.mov` is the classic case) can be dead once published. Convert, don't improvise.
32
+ - To add an npm package, put it in `package.json` `dependencies` and run `castle-web install .` (the install `init` ran; it uses whichever package manager made the lockfile).
33
+ - Do not reload the deck after an edit. Nothing auto-reloads: the person applies your changes when they are ready, from the Play panel's restart button or a panel's own reload control. `npm run restart` reloads every open panel at once and breaks their flow — only when asked.
34
+ - The deck can define its own editors: declare a file type in `castle.json` (`editor.fileTypes`: ext, label, icon, and an `editors/` module) and the Files panel opens that type in it — whole recipe under `## Editors` in the deck's guide (its kit's `CLAUDE.md` under `imports/`). Editors under `imports/` are read-only; add or fork, don't edit.
35
+ - An editor is for content FILES the creator keeps coming back to (catalogs, dialogue, level tables) that nothing already opens. One-off or generated data stays code.
36
+ - Unprompted, an editor is an offer, never a build: when the same data keeps getting edited by hand, propose one and build on the user's yes. Never during a first build.
@@ -0,0 +1,8 @@
1
+ {
2
+ "hash": "39e56a5d",
3
+ "configHash": "5ff7cbd8",
4
+ "lockfileHash": "78d11399",
5
+ "browserHash": "61f2844b",
6
+ "optimized": {},
7
+ "chunks": {}
8
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -137,6 +137,12 @@ and then, all optional, all found by root-anchored glob in
137
137
  | `code/server/game.js` | the game, as six hooks on a default export |
138
138
  | `code/server/deckComponents.js` | `export const componentDefaults = { Cargo: {...} }` |
139
139
 
140
+ The kit draws no HUD, with one exception: when the join fell back to solo,
141
+ `castle.real-time`'s `soloBadge.js` shows a small fixed badge naming why
142
+ (`status().soloReason`: a private deck, no published server, or plain
143
+ unavailable), so a creator testing their own deck is told rather than left to
144
+ guess. A deck's `soloBadge(status)` client hook hides (`false`) or rewords it.
145
+
140
146
  The six server hooks: `ready(sim)`, `message(session, player, data)`,
141
147
  `place(sim, handovers)`, `step(sim, handovers)`, `join(sim)`, `delta(sim)`.
142
148
  `place` runs BEFORE the physics step, which is where a write that only reaches a
@@ -15,7 +15,7 @@
15
15
  "imports": {
16
16
  "castle.real-time": {
17
17
  "deckId": "xFVr-afOLuUQ",
18
- "version": "2026-09-01T20:54:44.750Z"
18
+ "version": "2026-09-05T01:31:28.578Z"
19
19
  },
20
20
  "castle.physics-2d": {
21
21
  "deckId": "ckRZGFW4iPrx",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "castle.base": {
25
25
  "deckId": "yRcmH4_aYllE",
26
- "version": "2026-09-01T01:02:34.326Z"
26
+ "version": "2026-09-05T01:31:11.287Z"
27
27
  }
28
28
  },
29
29
  "autoUpdateWhenImported": true,
@@ -35,5 +35,5 @@
35
35
  },
36
36
  "deckId": "oiFu96LkwZ09",
37
37
  "cardId": "nkJgrvrCby3H",
38
- "publishedVersion": "2026-09-05T00:07:29.791Z"
38
+ "publishedVersion": "2026-09-05T01:32:45.261Z"
39
39
  }
@@ -14,6 +14,7 @@ import * as Ownership from '@imports/castle.real-time/code/client/ownership.js';
14
14
  import * as Smoothing from '@imports/castle.real-time/code/client/smooth.js';
15
15
  import * as Poses from '../client/poses.js';
16
16
  import { connectSession } from '@imports/castle.real-time/code/client/connection.js';
17
+ import { showSoloBadge, updateSoloBadge } from '@imports/castle.real-time/code/client/soloBadge.js';
17
18
  import { STATE, WORLD } from '@imports/castle.real-time/code/client/messages.js';
18
19
  import { game } from '@imports/castle.real-time/code/client/gameHooks.js';
19
20
 
@@ -111,6 +112,7 @@ class MultiplayerSystem {
111
112
  }
112
113
  game.afterFrame?.(scene, dt, this);
113
114
  this.publish(scene, local);
115
+ updateSoloBadge(this.net.status(), game);
114
116
  }
115
117
 
116
118
  // Join the public session once. Scene resets retain this transport.
@@ -378,6 +380,9 @@ class MultiplayerSystem {
378
380
  this.reset(scene);
379
381
  this.net?.close();
380
382
  this.net = null;
383
+ // The badge is a fixed element outside the scene, so a disposed runtime
384
+ // (an editor preview closing, say) has to take it down explicitly.
385
+ showSoloBadge(null);
381
386
  }
382
387
  }
383
388
 
@@ -238,7 +238,12 @@ Map())` — and the next `showPlayer` respawns it.
238
238
  - **The player blueprint itself.** The kit spawns a scene from the deck's own
239
239
  root; the deck ships it. `blueprints/other-player.scene` is only the name it
240
240
  looks for when the deck names none.
241
- - **Any HUD.** Five decks wrote five HUDs with no shared lines.
241
+ - **Any HUD.** Five decks wrote five HUDs with no shared lines. The one
242
+ on-screen element the kit does draw is the solo badge
243
+ (`castle.real-time`'s `soloBadge.js`): when the join fell back to solo it
244
+ names why (`status().soloReason`), so a creator testing a private or
245
+ unpublished deck is told rather than left to guess. A deck's
246
+ `soloBadge(status)` client hook hides or rewords it.
242
247
  - **Gameplay verbs** — carry, vehicle, round, lobby. Measured across six decks
243
248
  and none of them survived contact; see the audit.
244
249
 
@@ -14,7 +14,7 @@
14
14
  "imports": {
15
15
  "castle.real-time": {
16
16
  "deckId": "xFVr-afOLuUQ",
17
- "version": "2026-09-01T20:54:44.750Z"
17
+ "version": "2026-09-05T01:31:28.578Z"
18
18
  },
19
19
  "castle.physics-3d": {
20
20
  "deckId": "JH0SclbPVP0y",
@@ -27,13 +27,13 @@
27
27
  },
28
28
  "castle.base": {
29
29
  "deckId": "yRcmH4_aYllE",
30
- "version": "2026-09-01T01:02:34.326Z"
30
+ "version": "2026-09-05T01:31:11.287Z"
31
31
  }
32
32
  },
33
33
  "autoUpdateWhenImported": true,
34
34
  "deckId": "ZwA_P_-VO-Qh",
35
35
  "cardId": "bvRmCzyUpt_a",
36
- "publishedVersion": "2026-09-05T00:07:32.934Z",
36
+ "publishedVersion": "2026-09-05T01:32:53.625Z",
37
37
  "main": "main.jsx",
38
38
  "server": {
39
39
  "main": "code/server/index.js",
@@ -9,6 +9,7 @@ import * as Ownership from '@imports/castle.real-time/code/client/ownership.js';
9
9
  import * as Smoothing from '@imports/castle.real-time/code/client/smooth.js';
10
10
  import * as Poses from '../client/poses.js';
11
11
  import { connectSession } from '@imports/castle.real-time/code/client/connection.js';
12
+ import { showSoloBadge, updateSoloBadge } from '@imports/castle.real-time/code/client/soloBadge.js';
12
13
  import { STATE, WORLD } from '@imports/castle.real-time/code/client/messages.js';
13
14
  import { game } from '@imports/castle.real-time/code/client/gameHooks.js';
14
15
 
@@ -115,6 +116,7 @@ class MultiplayerSystem {
115
116
  };
116
117
  }
117
118
  this.publish(scene, local);
119
+ updateSoloBadge(this.net.status(), game);
118
120
  }
119
121
 
120
122
  // Join the Castle public session once and retain the transport across scene loads.
@@ -394,6 +396,9 @@ class MultiplayerSystem {
394
396
  this.reset(scene);
395
397
  this.net?.close();
396
398
  this.net = null;
399
+ // The badge is a fixed element outside the scene, so a disposed runtime
400
+ // (an editor preview closing, say) has to take it down explicitly.
401
+ showSoloBadge(null);
397
402
  }
398
403
  }
399
404
 
@@ -59,6 +59,11 @@ never learns the format.
59
59
  values of a player pose are geometry before the `clamped` flag, and `epsilonAt`,
60
60
  which a kit whose pose values are all one unit leaves undefined.
61
61
 
62
+ `soloBadge.js` is the one file here that touches the DOM: a fixed badge that
63
+ names why a session fell back to solo (`status().soloReason` from
64
+ `connection.js`), drawn by both kits each frame through `updateSoloBadge`. It
65
+ stays dependency-free and hides itself when a deck's `soloBadge` hook says so.
66
+
62
67
  ## Rules for editing this kit
63
68
 
64
69
  **Root-anchor anything that reaches out.** Once imported these files live at
@@ -9,11 +9,11 @@
9
9
  "imports": {
10
10
  "castle.base": {
11
11
  "deckId": "yRcmH4_aYllE",
12
- "version": "2026-09-01T01:02:34.326Z"
12
+ "version": "2026-09-05T01:31:11.287Z"
13
13
  }
14
14
  },
15
15
  "autoUpdateWhenImported": true,
16
16
  "deckId": "xFVr-afOLuUQ",
17
17
  "cardId": "w6G45sy3_P_m",
18
- "publishedVersion": "2026-09-05T00:07:38.909Z"
18
+ "publishedVersion": "2026-09-05T01:31:28.578Z"
19
19
  }
@@ -90,6 +90,10 @@ export async function connectSession({ onLog = () => {} } = {}) {
90
90
  status: () => ({
91
91
  state: connection.state,
92
92
  solo: connection.isSolo,
93
+
94
+ // Why the join fell back to solo (`private_deck`, `no_server_bundle`, or
95
+ // `unavailable`), null while multiplayer is live. Drives soloBadge.js.
96
+ soloReason: connection.soloReason ?? null,
93
97
  others: Math.max(0, net.roster.size - 1),
94
98
 
95
99
  // The SDK derives `you` by finding the local id in its player list. Use
@@ -37,6 +37,9 @@ const modules = {
37
37
  // for one frame
38
38
  // afterFrame(scene, dt, system) after reporting
39
39
  // presence(status) the presence text a kit HUD shows
40
+ // soloBadge(status) the solo fallback badge (soloBadge.js):
41
+ // false hides it, a string replaces its text.
42
+ // Called every frame, so keep it a lookup
40
43
  // reset(scene, system) a scene load, after spawned actors are gone
41
44
  // and while the connection is still open
42
45
  // dispose(scene, system) runtime disposal, before reset and close
@@ -0,0 +1,71 @@
1
+ // A small on-screen badge for a session that fell back to solo, so a creator
2
+ // testing their own deck learns why nobody can join instead of guessing that
3
+ // multiplayer is broken. The two reasons a creator can fix name the fix; every
4
+ // other reason is a plain "unavailable" (the platform does not say more).
5
+ //
6
+ // Dependency-free DOM: the 2D and 3D kits draw their scenes differently, and
7
+ // neither has a shared HUD, so the badge lives outside both. A deck that wants
8
+ // its own treatment exports `soloBadge` from `code/client/game.js`: `false`
9
+ // suppresses it, a string replaces the text (see gameHooks.js).
10
+
11
+ const TEXT = {
12
+ private_deck: 'Solo: this deck is private',
13
+ no_server_bundle: 'Solo: publish the deck to enable multiplayer',
14
+ unavailable: 'Multiplayer unavailable',
15
+ };
16
+
17
+ // The badge text for a `status()` result, or null while multiplayer is live.
18
+ export function soloBadgeText(status) {
19
+ if (!status?.solo) {
20
+ return null;
21
+ }
22
+ return TEXT[status.soloReason] ?? TEXT.unavailable;
23
+ }
24
+
25
+ let element = null;
26
+ let shown = null;
27
+
28
+ // Show, update, or hide the badge to match `text`. Idempotent and cheap enough
29
+ // to call every frame: nothing touches the DOM unless the text changed.
30
+ export function showSoloBadge(text) {
31
+ if (text === shown || typeof document === 'undefined') {
32
+ return;
33
+ }
34
+ if (text === null) {
35
+ element?.remove();
36
+ element = null;
37
+ shown = null;
38
+ return;
39
+ }
40
+ if (!element) {
41
+ element = document.createElement('div');
42
+ element.setAttribute('data-castle-solo-badge', '');
43
+ element.style.cssText = [
44
+ 'position:fixed',
45
+ 'left:12px',
46
+ 'bottom:12px',
47
+ 'max-width:80vw',
48
+ 'padding:6px 10px',
49
+ 'border-radius:6px',
50
+ 'background:rgba(0,0,0,0.72)',
51
+ 'color:#fff',
52
+ 'font:500 12px/1.4 system-ui,sans-serif',
53
+ 'z-index:2147483647',
54
+ 'pointer-events:none',
55
+ ].join(';');
56
+ document.body.appendChild(element);
57
+ }
58
+ element.textContent = text;
59
+ // Recorded only once the DOM holds it, so a failed append is retried next
60
+ // frame rather than suppressing the badge for the rest of the session.
61
+ shown = text;
62
+ }
63
+
64
+ // The per-frame call a kit makes: the deck hook decides, then the badge follows.
65
+ export function updateSoloBadge(status, game) {
66
+ const custom = game?.soloBadge?.(status);
67
+ if (custom === false) {
68
+ return showSoloBadge(null);
69
+ }
70
+ showSoloBadge(typeof custom === 'string' ? custom : soloBadgeText(status));
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castle-web-cli",
3
- "version": "0.4.167",
3
+ "version": "0.4.168",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "castle-web": "./dist/index.js"