castle-web-cli 0.4.170 → 0.4.172
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.d.ts +1 -0
- package/dist/agent-prompts.js +3 -0
- package/dist/agent.js +47 -11
- package/dist/castle-host/host.js +71 -0
- package/dist/devSessionServer.js +9 -5
- package/dist/ide.d.ts +10 -0
- package/dist/ide.js +5 -5
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-6odVZQSZ.css +1 -0
- package/dist/shell/assets/index-Ws0WrCbi.js +445 -0
- package/dist/shell/index.html +3 -3
- package/kits/base/castle.json +1 -1
- package/kits/base/sdk/README.md +17 -1
- package/kits/base/sdk/commands.d.ts +14 -0
- package/kits/base/sdk/user.d.ts +4 -0
- package/kits/base/sdk/user.js +36 -1
- package/kits/multiplayer-2d/CLAUDE.md +24 -9
- package/kits/multiplayer-2d/behaviors/Box.jsx +32 -9
- package/kits/multiplayer-2d/castle.json +3 -3
- package/kits/multiplayer-2d/code/client/avatars.js +6 -1
- package/kits/multiplayer-2d/code/server/players.js +27 -6
- package/kits/multiplayer-2d/code/server/world.js +24 -1
- package/kits/multiplayer-2d/code/systems/multiplayer.js +47 -4
- package/kits/multiplayer-2d/package-lock.json +26 -1
- package/kits/multiplayer-2d/package.json +2 -0
- package/kits/multiplayer-3d/CLAUDE.md +32 -9
- package/kits/multiplayer-3d/castle.json +4 -4
- package/kits/multiplayer-3d/code/client/avatars.js +15 -11
- package/kits/multiplayer-3d/code/client/nameTags.js +35 -16
- package/kits/multiplayer-3d/code/server/players.js +54 -5
- package/kits/multiplayer-3d/code/server/world.js +45 -1
- package/kits/multiplayer-3d/code/systems/multiplayer.js +112 -7
- package/kits/multiplayer-3d/package-lock.json +26 -1
- package/kits/multiplayer-3d/package.json +2 -0
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/editors/PxArtEditor.jsx +2 -2
- package/kits/physics-2d/engine/avatarArt.js +91 -0
- package/kits/physics-3d/behaviors/Pickup.jsx +5 -3
- package/kits/physics-3d/castle.json +1 -1
- package/kits/real-time/CLAUDE.md +23 -4
- package/kits/real-time/castle.json +1 -1
- package/kits/real-time/code/client/connection.js +15 -4
- package/kits/real-time/code/client/joinOverlay.js +46 -0
- package/kits/real-time/code/client/messages.js +4 -0
- package/kits/real-time/code/server/gameHooks.js +4 -0
- package/kits/real-time/code/server/persist.js +105 -0
- package/kits/real-time/code/server/session.js +295 -19
- package/kits/real-time/package-lock.json +1139 -0
- package/kits/turn-based/CLAUDE.md +80 -20
- package/kits/turn-based/castle.json +2 -2
- package/kits/turn-based/code/behaviors/Board.jsx +15 -5
- package/kits/turn-based/code/server/index.js +17 -5
- package/kits/turn-based/package.json +2 -1
- package/kits/turn-based/room.js +164 -13
- package/kits/turn-based/testing.js +276 -92
- package/package.json +1 -2
- package/dist/shell/assets/index-BvQmVwlO.css +0 -1
- package/dist/shell/assets/index-CV5sBby1.js +0 -445
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Deck storage for a real-time session: the world, the players, and the deck's
|
|
2
|
+
// own block.
|
|
3
|
+
//
|
|
4
|
+
// world:<room> { poses, gone }: object poses and the ids of scene
|
|
5
|
+
// actors removed for good
|
|
6
|
+
// game:<room> whatever the deck's `save` hook returned
|
|
7
|
+
// player:<room>:<userId> { pose, state }: where the player was and the
|
|
8
|
+
// block their client reported
|
|
9
|
+
//
|
|
10
|
+
// Storage rows are keyed by deck and not by session, so every key here carries
|
|
11
|
+
// the room the session belongs to. A named session and a party each have their
|
|
12
|
+
// own room; every public shard shares one.
|
|
13
|
+
//
|
|
14
|
+
// The server owns `deck` scope once a deck publishes a server. An unpublished
|
|
15
|
+
// deck has no storage at all, so a failure is reported and the session runs on
|
|
16
|
+
// without persistence.
|
|
17
|
+
|
|
18
|
+
// The room a session persists under.
|
|
19
|
+
export function roomOf(session) {
|
|
20
|
+
return session.mode === 'public' ? 'public' : session.sessionId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Read one room's world and deck block. A world saved before the gone list
|
|
24
|
+
// existed is a bare pose array.
|
|
25
|
+
export async function loadRoom(session, room) {
|
|
26
|
+
const saved = await read(session, [`world:${room}`, `game:${room}`], 'loadRoom');
|
|
27
|
+
const world = saved[`world:${room}`];
|
|
28
|
+
return {
|
|
29
|
+
world: Array.isArray(world) ? { poses: world, gone: [] } : (world ?? null),
|
|
30
|
+
game: saved[`game:${room}`] ?? null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Read one user's saved pose and state in this room, or null. A row saved
|
|
35
|
+
// before state existed is a bare pose array.
|
|
36
|
+
export async function loadPlayer(session, room, userId) {
|
|
37
|
+
const key = playerKey(room, userId);
|
|
38
|
+
const saved = await read(session, [key], 'loadPlayer');
|
|
39
|
+
const row = saved[key];
|
|
40
|
+
if (Array.isArray(row)) {
|
|
41
|
+
return { pose: row, state: null };
|
|
42
|
+
}
|
|
43
|
+
return Array.isArray(row?.pose) ? { pose: row.pose, state: row.state ?? null } : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Write a room's world, deck block and present players in one request.
|
|
47
|
+
// `players` is a list of `[userId, { pose, state }]`.
|
|
48
|
+
export async function saveRoom(session, room, { world, game, players }) {
|
|
49
|
+
const values = { [`world:${room}`]: world };
|
|
50
|
+
if (game !== null && game !== undefined) {
|
|
51
|
+
values[`game:${room}`] = game;
|
|
52
|
+
}
|
|
53
|
+
for (const [userId, saved] of players) {
|
|
54
|
+
values[playerKey(room, userId)] = saved;
|
|
55
|
+
}
|
|
56
|
+
const stored = await write(session, values, 'saveRoom');
|
|
57
|
+
|
|
58
|
+
// Logged once; the same line every ten seconds says nothing more.
|
|
59
|
+
if (stored && !announced) {
|
|
60
|
+
announced = true;
|
|
61
|
+
console.log(`[persist] saving ${Object.keys(values).length} keys under ${room}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Write one user's pose and state, which is where a later session starts them.
|
|
66
|
+
export async function savePlayer(session, room, userId, saved) {
|
|
67
|
+
await write(session, { [playerKey(room, userId)]: saved }, 'savePlayer');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function playerKey(room, userId) {
|
|
71
|
+
return `player:${room}:${userId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function read(session, keys, operation) {
|
|
75
|
+
try {
|
|
76
|
+
return await session.storage.deck.get(keys);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
report(operation, error);
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function write(session, values, operation) {
|
|
84
|
+
try {
|
|
85
|
+
await session.storage.deck.set(values);
|
|
86
|
+
return true;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
report(operation, error);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let announced = false;
|
|
94
|
+
|
|
95
|
+
// Operations that have already reported a failure, so a broken setup is named
|
|
96
|
+
// once.
|
|
97
|
+
const reported = new Set();
|
|
98
|
+
|
|
99
|
+
function report(operation, error) {
|
|
100
|
+
if (reported.has(operation)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
reported.add(operation);
|
|
104
|
+
console.error(`[persist] ${operation} failed: ${error?.message ?? error}`);
|
|
105
|
+
}
|
|
@@ -7,20 +7,44 @@
|
|
|
7
7
|
// kit modules or through the callbacks the kit's pose codec exports.
|
|
8
8
|
|
|
9
9
|
import * as Messages from '../client/messages.js';
|
|
10
|
+
import * as Persist from './persist.js';
|
|
10
11
|
|
|
11
12
|
// Maximum wall-clock duration passed to physics for one session tick.
|
|
12
13
|
const MAX_TICK_SECONDS = 0.25;
|
|
13
14
|
|
|
15
|
+
// How often a moved world and its players are written to storage.
|
|
16
|
+
const SAVE_INTERVAL_MS = 10000;
|
|
17
|
+
|
|
18
|
+
// How long a joining player waits for their saved pose before starting at the
|
|
19
|
+
// scene's spawn.
|
|
20
|
+
const RESUME_WAIT_MS = 3000;
|
|
21
|
+
|
|
22
|
+
// A player whose last report is older than this has gone: a closed tab whose
|
|
23
|
+
// connection the platform has not reaped yet.
|
|
24
|
+
const GHOST_MS = 2000;
|
|
25
|
+
|
|
14
26
|
// The state one session process keeps, beside the modules it was built with:
|
|
15
27
|
//
|
|
16
28
|
// label prefix for this process in log messages
|
|
17
29
|
// poses the kit's pose codec, for `PLAYER_GEOMETRY` and the
|
|
18
30
|
// per-index `epsilonAt` the delta comparison reads
|
|
19
|
-
// world createSimulation, step, objectPoses
|
|
31
|
+
// world createSimulation, step, objectPoses, restorePoses
|
|
20
32
|
// players addPlayer, removePlayer, setPose, playerPoses
|
|
21
33
|
// ownership claim, release, setObjectPoses, sweepOwners, ownerships
|
|
22
34
|
// game the deck's hooks, from `gameHooks.js`
|
|
23
35
|
// sim the simulation, null until it is built
|
|
36
|
+
// building the pending simulation, from `createSimulation`
|
|
37
|
+
// session the Castle session, from the first callback carrying one
|
|
38
|
+
// room the storage room this session persists under
|
|
39
|
+
// joining playerId -> { done, saved, userId, until } while a saved
|
|
40
|
+
// player row is being read
|
|
41
|
+
// gone ids of scene actors removed for good, by any client
|
|
42
|
+
// newlyGone ids removed since the last broadcast
|
|
43
|
+
// resumedUsers userId -> playerId, for bodies that started from a saved
|
|
44
|
+
// pose or took over a gone player's
|
|
45
|
+
// moved whether a pose moved since the last save
|
|
46
|
+
// savedAt wall clock of the last save
|
|
47
|
+
// saving the save in flight, or null
|
|
24
48
|
// lastTickAt wall clock at the previous tick
|
|
25
49
|
// sent the last pose broadcast for each id, so a later tick can
|
|
26
50
|
// send only what changed
|
|
@@ -31,6 +55,16 @@ function newServer(parts) {
|
|
|
31
55
|
return {
|
|
32
56
|
...parts,
|
|
33
57
|
sim: null,
|
|
58
|
+
building: null,
|
|
59
|
+
session: null,
|
|
60
|
+
room: '',
|
|
61
|
+
joining: new Map(),
|
|
62
|
+
gone: new Set(),
|
|
63
|
+
newlyGone: [],
|
|
64
|
+
resumedUsers: new Map(),
|
|
65
|
+
moved: false,
|
|
66
|
+
savedAt: 0,
|
|
67
|
+
saving: null,
|
|
34
68
|
lastTickAt: 0,
|
|
35
69
|
sent: { players: new Map(), objects: new Map() },
|
|
36
70
|
waitingForWorld: new Set(),
|
|
@@ -45,17 +79,35 @@ function newServer(parts) {
|
|
|
45
79
|
// and messages between them.
|
|
46
80
|
export function makeSession(parts) {
|
|
47
81
|
const server = newServer(parts);
|
|
48
|
-
|
|
82
|
+
|
|
83
|
+
// Castle's handlers remain available while `createSimulation` is pending. A
|
|
84
|
+
// build that fails is logged here; `buildWorld` sees the same rejection when
|
|
85
|
+
// the first tick awaits it.
|
|
86
|
+
server.building = server.world.createSimulation();
|
|
87
|
+
server.building.catch((err) => {
|
|
88
|
+
console.error(`[${server.label}] simulation failed to build: ${err?.stack ?? err}`);
|
|
89
|
+
});
|
|
49
90
|
return {
|
|
91
|
+
// The world is built from the first tick or join, not here: `onStart` runs
|
|
92
|
+
// while the process is still booting, and a storage read made then can go
|
|
93
|
+
// unanswered.
|
|
94
|
+
onStart() {},
|
|
95
|
+
|
|
50
96
|
// Joins are logged here; the simulation's players are reconciled from the
|
|
51
97
|
// full session roster on the next tick.
|
|
52
98
|
onPlayerJoin(session, player) {
|
|
99
|
+
attach(server, session);
|
|
53
100
|
console.log(`[${server.label}] join ${player.username} (${player.playerId})`);
|
|
101
|
+
|
|
102
|
+
// The saved pose is read now, so the body this player is given on a later
|
|
103
|
+
// tick can start there.
|
|
104
|
+
beginResume(server, player);
|
|
54
105
|
},
|
|
55
106
|
|
|
56
107
|
onPlayerLeave(session, player) {
|
|
57
108
|
// The next roster reconciliation removes the simulation player and body.
|
|
58
109
|
console.log(`[${server.label}] leave ${player.username}`);
|
|
110
|
+
savePlayer(server, player);
|
|
59
111
|
},
|
|
60
112
|
|
|
61
113
|
onMessage(session, player, data) {
|
|
@@ -63,6 +115,7 @@ export function makeSession(parts) {
|
|
|
63
115
|
},
|
|
64
116
|
|
|
65
117
|
onTick(session) {
|
|
118
|
+
attach(server, session);
|
|
66
119
|
tick(server, session);
|
|
67
120
|
},
|
|
68
121
|
|
|
@@ -70,27 +123,64 @@ export function makeSession(parts) {
|
|
|
70
123
|
// Tell the connected clients when Castle replaces this session process.
|
|
71
124
|
session.broadcast({ k: 'replaced' });
|
|
72
125
|
},
|
|
126
|
+
|
|
127
|
+
async onShutdown() {
|
|
128
|
+
await saveRoom(server);
|
|
129
|
+
},
|
|
73
130
|
};
|
|
74
131
|
}
|
|
75
132
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
function
|
|
79
|
-
server.
|
|
80
|
-
|
|
81
|
-
|
|
133
|
+
// Keep the first session a tick or join carries and build the world with it.
|
|
134
|
+
// The room the session persists under is not known before this.
|
|
135
|
+
function attach(server, session) {
|
|
136
|
+
if (server.session) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
server.session = session;
|
|
140
|
+
server.room = Persist.roomOf(session);
|
|
141
|
+
buildWorld(server);
|
|
142
|
+
}
|
|
82
143
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
144
|
+
// Finish the simulation: read the saved room, run the deck's restore and ready
|
|
145
|
+
// hooks, and apply the saved object poses. `server.sim` is assigned last, so the
|
|
146
|
+
// first WORLD snapshot anybody receives is the restored one.
|
|
147
|
+
async function buildWorld(server) {
|
|
148
|
+
try {
|
|
149
|
+
const created = await server.building;
|
|
150
|
+
const saved = await Persist.loadRoom(server.session, server.room);
|
|
151
|
+
server.game.restore?.(created, saved.game);
|
|
152
|
+
|
|
153
|
+
// Invoke the deck's ready hook with the built world before the first tick.
|
|
154
|
+
server.game.ready?.(created);
|
|
155
|
+
|
|
156
|
+
// Saved poses go on after both hooks, which is where a deck adds objects of
|
|
157
|
+
// its own. Ids the scene no longer has are skipped.
|
|
158
|
+
const applied = Array.isArray(saved.world?.poses)
|
|
159
|
+
? server.world.restorePoses(created, saved.world.poses)
|
|
160
|
+
: 0;
|
|
161
|
+
for (const id of saved.world?.gone ?? []) {
|
|
162
|
+
if (typeof id === 'string') {
|
|
163
|
+
server.gone.add(id);
|
|
164
|
+
server.world.removeActor?.(created, id);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
server.sim = created;
|
|
168
|
+
console.log(
|
|
169
|
+
`[${server.label}] simulation ready, ${applied} object poses restored, ${server.gone.size} actors gone`,
|
|
170
|
+
);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`[${server.label}] simulation failed: ${err?.message ?? err}`);
|
|
173
|
+
}
|
|
89
174
|
}
|
|
90
175
|
|
|
91
176
|
// Route one client message. Movement reports update the simulation, and other
|
|
92
177
|
// message kinds go to the deck hook.
|
|
93
178
|
function receive(server, session, player, data) {
|
|
179
|
+
if (data?.k === Messages.GONE) {
|
|
180
|
+
markGone(server, data.id);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
94
184
|
// Deck control messages can run before simulation initialization completes.
|
|
95
185
|
if (data?.k !== Messages.MOVE) {
|
|
96
186
|
server.game.message?.(session, player, data);
|
|
@@ -112,6 +202,18 @@ function receive(server, session, player, data) {
|
|
|
112
202
|
server.ownership.release(server.sim, player.playerId, data.r, server.handovers);
|
|
113
203
|
}
|
|
114
204
|
|
|
205
|
+
// Remove a scene actor for everyone. The id is a scene actor id; anything else
|
|
206
|
+
// is ignored. The world drops the actor's body when it has one.
|
|
207
|
+
function markGone(server, id) {
|
|
208
|
+
if (!server.sim || typeof id !== 'string' || id.length > 128 || server.gone.has(id)) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
server.gone.add(id);
|
|
212
|
+
server.newlyGone.push(id);
|
|
213
|
+
server.world.removeActor?.(server.sim, id);
|
|
214
|
+
server.moved = true;
|
|
215
|
+
}
|
|
216
|
+
|
|
115
217
|
// One session tick: reconcile players, sweep ownership, run the deck's hooks
|
|
116
218
|
// around physics, and send the join snapshots and the delta.
|
|
117
219
|
function tick(server, session) {
|
|
@@ -144,16 +246,22 @@ function tick(server, session) {
|
|
|
144
246
|
// A new player gets the whole world and the current ownerships. Sending the
|
|
145
247
|
// ownerships with that first snapshot stops the client from simulating an
|
|
146
248
|
// object somebody else already holds.
|
|
249
|
+
// A new player also gets every actor that is gone and their own saved block,
|
|
250
|
+
// which their client restores before it plays.
|
|
147
251
|
for (const playerId of server.waitingForWorld) {
|
|
252
|
+
const state = server.players.stateOf?.(server.sim, playerId) ?? null;
|
|
148
253
|
session.send(playerId, {
|
|
149
254
|
k: Messages.WORLD,
|
|
150
255
|
...snapshot,
|
|
151
256
|
own: server.ownership.ownerships(server.sim),
|
|
257
|
+
gone: [...server.gone],
|
|
258
|
+
...(state === null ? {} : { me: state }),
|
|
152
259
|
...server.game.join?.(server.sim),
|
|
153
260
|
});
|
|
154
261
|
}
|
|
155
262
|
server.waitingForWorld.clear();
|
|
156
263
|
broadcastDelta(server, session, snapshot);
|
|
264
|
+
maybeSave(server);
|
|
157
265
|
}
|
|
158
266
|
|
|
159
267
|
// Make the simulation's player map match Castle's roster. The roster covers the
|
|
@@ -165,9 +273,11 @@ function reconcilePlayers(server, session) {
|
|
|
165
273
|
continue;
|
|
166
274
|
}
|
|
167
275
|
|
|
168
|
-
// A newly observed roster id gets a body and a full WORLD on
|
|
169
|
-
|
|
170
|
-
server
|
|
276
|
+
// A newly observed roster id gets a body and a full WORLD on the tick its
|
|
277
|
+
// saved pose has been read.
|
|
278
|
+
if (admit(server, session, playerId)) {
|
|
279
|
+
server.waitingForWorld.add(playerId);
|
|
280
|
+
}
|
|
171
281
|
}
|
|
172
282
|
for (const playerId of [...server.sim.players.keys()]) {
|
|
173
283
|
if (present.has(playerId)) {
|
|
@@ -180,6 +290,159 @@ function reconcilePlayers(server, session) {
|
|
|
180
290
|
}
|
|
181
291
|
}
|
|
182
292
|
|
|
293
|
+
// Give one roster id a body. A gone player of the same account is taken over at
|
|
294
|
+
// once; otherwise the saved pose is waited for, up to `RESUME_WAIT_MS`.
|
|
295
|
+
function admit(server, session, playerId) {
|
|
296
|
+
const entry = server.joining.get(playerId);
|
|
297
|
+
const ghost = entry ? ghostOf(server, session, playerId, entry.userId) : null;
|
|
298
|
+
if (ghost === undefined) {
|
|
299
|
+
// Another connection of this account is still playing: a second character.
|
|
300
|
+
server.joining.delete(playerId);
|
|
301
|
+
server.players.addPlayer(server.sim, playerId, null);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
if (ghost) {
|
|
305
|
+
server.joining.delete(playerId);
|
|
306
|
+
server.resumedUsers.set(entry.userId, playerId);
|
|
307
|
+
server.players.addPlayer(server.sim, playerId, poseOf(server, ghost));
|
|
308
|
+
server.players.setState?.(server.sim, playerId, server.players.stateOf?.(server.sim, ghost));
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
if (entry && !entry.done && Date.now() < entry.until) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
server.joining.delete(playerId);
|
|
315
|
+
const saved = resumeSaved(server, playerId, entry);
|
|
316
|
+
server.players.addPlayer(server.sim, playerId, saved?.pose ?? null);
|
|
317
|
+
server.players.setState?.(server.sim, playerId, saved?.state ?? null);
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// The playerId of a present player of the same account whose reports have
|
|
322
|
+
// stopped: a tab that was closed and not yet reaped. `undefined` when such a
|
|
323
|
+
// player is still reporting, null when there is none.
|
|
324
|
+
function ghostOf(server, session, playerId, userId) {
|
|
325
|
+
for (const player of session.players) {
|
|
326
|
+
if (player.playerId === playerId || player.userId !== userId) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
const body = server.sim.players.get(player.playerId);
|
|
330
|
+
if (!body) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
return server.sim.timeMs - body.movedAt > GHOST_MS ? player.playerId : undefined;
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The saved row a joining player resumes from, or null for the scene's spawn
|
|
339
|
+
// with no state.
|
|
340
|
+
function resumeSaved(server, playerId, entry) {
|
|
341
|
+
if (!entry?.saved || server.resumedUsers.has(entry.userId)) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
server.resumedUsers.set(entry.userId, playerId);
|
|
345
|
+
return entry.saved;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Start reading a joining player's saved row.
|
|
349
|
+
function beginResume(server, player) {
|
|
350
|
+
if (!player.userId) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const entry = {
|
|
354
|
+
done: false,
|
|
355
|
+
saved: null,
|
|
356
|
+
userId: player.userId,
|
|
357
|
+
until: Date.now() + RESUME_WAIT_MS,
|
|
358
|
+
};
|
|
359
|
+
server.joining.set(player.playerId, entry);
|
|
360
|
+
void Persist.loadPlayer(server.session, server.room, player.userId).then((saved) => {
|
|
361
|
+
entry.saved = saved;
|
|
362
|
+
entry.done = true;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Write a leaving player's last pose. Their body is still in the simulation
|
|
367
|
+
// until the next reconciliation. A player another connection took over writes
|
|
368
|
+
// nothing: the newer body's pose is the one to keep.
|
|
369
|
+
function savePlayer(server, player) {
|
|
370
|
+
server.joining.delete(player.playerId);
|
|
371
|
+
const holder = server.resumedUsers.get(player.userId);
|
|
372
|
+
if (holder && holder !== player.playerId) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
server.resumedUsers.delete(player.userId);
|
|
376
|
+
const pose = poseOf(server, player.playerId);
|
|
377
|
+
if (!pose || !player.userId) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const state = server.players.stateOf?.(server.sim, player.playerId) ?? null;
|
|
381
|
+
void Persist.savePlayer(server.session, server.room, player.userId, { pose, state });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Write the world every `SAVE_INTERVAL_MS`, and only when a pose moved since the
|
|
385
|
+
// last write.
|
|
386
|
+
function maybeSave(server) {
|
|
387
|
+
const now = Date.now();
|
|
388
|
+
if (!server.moved || server.saving || now - server.savedAt < SAVE_INTERVAL_MS) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
server.moved = false;
|
|
392
|
+
void saveRoom(server);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Write the world, the deck's block and every present player. A save already in
|
|
396
|
+
// flight is waited for, so a shutdown during the timer's write still lands its
|
|
397
|
+
// own.
|
|
398
|
+
async function saveRoom(server) {
|
|
399
|
+
if (!server.sim || !server.session) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (server.saving) {
|
|
403
|
+
await server.saving;
|
|
404
|
+
}
|
|
405
|
+
server.savedAt = Date.now();
|
|
406
|
+
server.saving = Persist.saveRoom(server.session, server.room, {
|
|
407
|
+
world: { poses: server.world.objectPoses(server.sim), gone: [...server.gone] },
|
|
408
|
+
game: server.game.save?.(server.sim) ?? null,
|
|
409
|
+
players: presentPlayers(server),
|
|
410
|
+
});
|
|
411
|
+
try {
|
|
412
|
+
await server.saving;
|
|
413
|
+
} finally {
|
|
414
|
+
server.saving = null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Every present player's pose and state, by user id.
|
|
419
|
+
function presentPlayers(server) {
|
|
420
|
+
const users = new Map(server.session.players.map((player) => [player.playerId, player.userId]));
|
|
421
|
+
const out = [];
|
|
422
|
+
for (const [playerId, ...pose] of server.players.playerPoses(server.sim)) {
|
|
423
|
+
const userId = users.get(playerId);
|
|
424
|
+
if (userId) {
|
|
425
|
+
const state = server.players.stateOf?.(server.sim, playerId) ?? null;
|
|
426
|
+
out.push([userId, { pose: pose.slice(0, server.poses.PLAYER_GEOMETRY), state }]);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// One player's current pose, without the `clamped` flag that belongs to a
|
|
433
|
+
// single tick.
|
|
434
|
+
function poseOf(server, playerId) {
|
|
435
|
+
if (!server.sim) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
for (const [id, ...pose] of server.players.playerPoses(server.sim)) {
|
|
439
|
+
if (id === playerId) {
|
|
440
|
+
return pose.slice(0, server.poses.PLAYER_GEOMETRY);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
183
446
|
// Broadcast what changed since the previous tick. A tick where nothing changed
|
|
184
447
|
// sends nothing at all.
|
|
185
448
|
function broadcastDelta(server, session, snapshot) {
|
|
@@ -202,14 +465,27 @@ function broadcastDelta(server, session, snapshot) {
|
|
|
202
465
|
// a new list for the next broadcast.
|
|
203
466
|
const own = server.handovers;
|
|
204
467
|
server.handovers = [];
|
|
468
|
+
const gone = server.newlyGone;
|
|
469
|
+
server.newlyGone = [];
|
|
470
|
+
|
|
471
|
+
// Only a moved pose makes the next save worth making.
|
|
472
|
+
server.moved = server.moved || ps.length > 0 || os.length > 0;
|
|
205
473
|
|
|
206
474
|
// A changed deck block is its own reason to broadcast, including on a tick
|
|
207
475
|
// where no pose and no ownership moved.
|
|
208
476
|
const block = server.game.delta?.(server.sim) ?? null;
|
|
209
|
-
if (ps.length === 0 && os.length === 0 && own.length === 0 && !block) {
|
|
477
|
+
if (ps.length === 0 && os.length === 0 && own.length === 0 && gone.length === 0 && !block) {
|
|
210
478
|
return;
|
|
211
479
|
}
|
|
212
|
-
session.broadcast({
|
|
480
|
+
session.broadcast({
|
|
481
|
+
k: Messages.STATE,
|
|
482
|
+
t: snapshot.t,
|
|
483
|
+
ps,
|
|
484
|
+
os,
|
|
485
|
+
own,
|
|
486
|
+
...(gone.length ? { gone } : {}),
|
|
487
|
+
...block,
|
|
488
|
+
});
|
|
213
489
|
}
|
|
214
490
|
|
|
215
491
|
// Whether one player's pose belongs in this delta, recording it as the new
|