castle-web-cli 0.4.167 → 0.4.169
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-prompts.js +17 -0
- package/dist/castle-host/host.d.ts +2 -0
- package/dist/castle-host/host.js +17 -3
- package/dist/platformDoc.js +1 -1
- package/dist/save-deck.js +24 -2
- package/dist/serve.js +2 -0
- package/dist/shell/assets/index-CVHj503j.css +1 -0
- package/dist/shell/assets/{index-C8M-p00y.js → index-x_QkP3Xq.js} +91 -91
- package/dist/shell/index.html +2 -2
- package/kits/base/castle.json +1 -1
- package/kits/base/sdk/README.md +3 -1
- package/kits/base/sdk/castle.d.ts +1 -0
- package/kits/base/sdk/commands.d.ts +7 -0
- package/kits/base/sdk/multiplayer.d.ts +6 -0
- package/kits/base/sdk/multiplayer.js +6 -0
- package/kits/multiplayer-2d/CLAUDE.md +6 -0
- package/kits/multiplayer-2d/castle.json +3 -3
- package/kits/multiplayer-2d/code/systems/multiplayer.js +5 -0
- package/kits/multiplayer-3d/CLAUDE.md +6 -1
- package/kits/multiplayer-3d/castle.json +3 -3
- package/kits/multiplayer-3d/code/systems/multiplayer.js +5 -0
- package/kits/real-time/CLAUDE.md +5 -0
- package/kits/real-time/castle.json +2 -2
- package/kits/real-time/code/client/connection.js +4 -0
- package/kits/real-time/code/client/gameHooks.js +3 -0
- package/kits/real-time/code/client/soloBadge.js +71 -0
- package/package.json +1 -1
- package/dist/shell/assets/index-CIRBt1qf.css +0 -1
package/dist/shell/index.html
CHANGED
|
@@ -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-
|
|
14
|
-
<link rel="stylesheet" crossorigin href="/__castle/ide/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/__castle/ide/assets/index-x_QkP3Xq.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>
|
package/kits/base/castle.json
CHANGED
package/kits/base/sdk/README.md
CHANGED
|
@@ -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.
|
|
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 = {
|
|
@@ -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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
package/kits/real-time/CLAUDE.md
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
+
}
|