castle-web-cli 0.4.170 → 0.4.171
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/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 +20 -9
- package/kits/multiplayer-2d/castle.json +2 -2
- 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 +45 -4
- package/kits/multiplayer-2d/package-lock.json +26 -1
- package/kits/multiplayer-2d/package.json +2 -0
- package/kits/multiplayer-3d/CLAUDE.md +28 -9
- package/kits/multiplayer-3d/castle.json +3 -3
- 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 +108 -6
- 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-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 +9 -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 +289 -19
- package/kits/real-time/package-lock.json +1139 -0
- package/kits/turn-based/CLAUDE.md +76 -20
- package/kits/turn-based/castle.json +1 -1
- package/kits/turn-based/code/server/index.js +16 -5
- package/kits/turn-based/package.json +2 -1
- package/kits/turn-based/room.js +162 -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
|
@@ -15,6 +15,10 @@ export const MOVE = 'i';
|
|
|
15
15
|
export const WORLD = 'w';
|
|
16
16
|
export const STATE = 's';
|
|
17
17
|
|
|
18
|
+
// A client reports that it removed a scene actor: `{ k: GONE, id }`. The
|
|
19
|
+
// session removes it for everyone, for good.
|
|
20
|
+
export const GONE = 'g';
|
|
21
|
+
|
|
18
22
|
// Movement reports run below the render rate and below the platform's roughly
|
|
19
23
|
// 30-message-per-second client cap. A second report inside one server tick would
|
|
20
24
|
// be overwritten before the simulation read it.
|
|
@@ -21,8 +21,12 @@ const bodyModules = {
|
|
|
21
21
|
// `session.js` receives this value from the dimensional kit's server entry and
|
|
22
22
|
// calls the hooks at the stages listed below.
|
|
23
23
|
//
|
|
24
|
+
// restore(sim, saved) the value the last `save` returned for this
|
|
25
|
+
// room, or null. Runs before `ready`
|
|
24
26
|
// ready(sim) after the world and its scene bodies exist,
|
|
25
27
|
// before the first server tick
|
|
28
|
+
// save(sim) JSON kept for this room, written with the
|
|
29
|
+
// world every ten seconds and at shutdown
|
|
26
30
|
// message(session, player, d) client data that is not a pose report. It
|
|
27
31
|
// receives no handover list, so a world change
|
|
28
32
|
// is kept and applied later by `place` or `step`
|
|
@@ -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,29 @@ 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.
|
|
84
|
+
server.building = server.world.createSimulation();
|
|
49
85
|
return {
|
|
86
|
+
onStart(session) {
|
|
87
|
+
attach(server, session);
|
|
88
|
+
},
|
|
89
|
+
|
|
50
90
|
// Joins are logged here; the simulation's players are reconciled from the
|
|
51
91
|
// full session roster on the next tick.
|
|
52
92
|
onPlayerJoin(session, player) {
|
|
93
|
+
attach(server, session);
|
|
53
94
|
console.log(`[${server.label}] join ${player.username} (${player.playerId})`);
|
|
95
|
+
|
|
96
|
+
// The saved pose is read now, so the body this player is given on a later
|
|
97
|
+
// tick can start there.
|
|
98
|
+
beginResume(server, player);
|
|
54
99
|
},
|
|
55
100
|
|
|
56
101
|
onPlayerLeave(session, player) {
|
|
57
102
|
// The next roster reconciliation removes the simulation player and body.
|
|
58
103
|
console.log(`[${server.label}] leave ${player.username}`);
|
|
104
|
+
savePlayer(server, player);
|
|
59
105
|
},
|
|
60
106
|
|
|
61
107
|
onMessage(session, player, data) {
|
|
@@ -63,6 +109,7 @@ export function makeSession(parts) {
|
|
|
63
109
|
},
|
|
64
110
|
|
|
65
111
|
onTick(session) {
|
|
112
|
+
attach(server, session);
|
|
66
113
|
tick(server, session);
|
|
67
114
|
},
|
|
68
115
|
|
|
@@ -70,27 +117,64 @@ export function makeSession(parts) {
|
|
|
70
117
|
// Tell the connected clients when Castle replaces this session process.
|
|
71
118
|
session.broadcast({ k: 'replaced' });
|
|
72
119
|
},
|
|
120
|
+
|
|
121
|
+
async onShutdown() {
|
|
122
|
+
await saveRoom(server);
|
|
123
|
+
},
|
|
73
124
|
};
|
|
74
125
|
}
|
|
75
126
|
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
function
|
|
79
|
-
server.
|
|
80
|
-
|
|
81
|
-
|
|
127
|
+
// Keep the first session a callback carries and build the world with it. The
|
|
128
|
+
// room the session persists under is not known before this.
|
|
129
|
+
function attach(server, session) {
|
|
130
|
+
if (server.session) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
server.session = session;
|
|
134
|
+
server.room = Persist.roomOf(session);
|
|
135
|
+
buildWorld(server);
|
|
136
|
+
}
|
|
82
137
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
138
|
+
// Finish the simulation: read the saved room, run the deck's restore and ready
|
|
139
|
+
// hooks, and apply the saved object poses. `server.sim` is assigned last, so the
|
|
140
|
+
// first WORLD snapshot anybody receives is the restored one.
|
|
141
|
+
async function buildWorld(server) {
|
|
142
|
+
try {
|
|
143
|
+
const created = await server.building;
|
|
144
|
+
const saved = await Persist.loadRoom(server.session, server.room);
|
|
145
|
+
server.game.restore?.(created, saved.game);
|
|
146
|
+
|
|
147
|
+
// Invoke the deck's ready hook with the built world before the first tick.
|
|
148
|
+
server.game.ready?.(created);
|
|
149
|
+
|
|
150
|
+
// Saved poses go on after both hooks, which is where a deck adds objects of
|
|
151
|
+
// its own. Ids the scene no longer has are skipped.
|
|
152
|
+
const applied = Array.isArray(saved.world?.poses)
|
|
153
|
+
? server.world.restorePoses(created, saved.world.poses)
|
|
154
|
+
: 0;
|
|
155
|
+
for (const id of saved.world?.gone ?? []) {
|
|
156
|
+
if (typeof id === 'string') {
|
|
157
|
+
server.gone.add(id);
|
|
158
|
+
server.world.removeActor?.(created, id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
server.sim = created;
|
|
162
|
+
console.log(
|
|
163
|
+
`[${server.label}] simulation ready, ${applied} object poses restored, ${server.gone.size} actors gone`,
|
|
164
|
+
);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.error(`[${server.label}] simulation failed: ${err?.message ?? err}`);
|
|
167
|
+
}
|
|
89
168
|
}
|
|
90
169
|
|
|
91
170
|
// Route one client message. Movement reports update the simulation, and other
|
|
92
171
|
// message kinds go to the deck hook.
|
|
93
172
|
function receive(server, session, player, data) {
|
|
173
|
+
if (data?.k === Messages.GONE) {
|
|
174
|
+
markGone(server, data.id);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
94
178
|
// Deck control messages can run before simulation initialization completes.
|
|
95
179
|
if (data?.k !== Messages.MOVE) {
|
|
96
180
|
server.game.message?.(session, player, data);
|
|
@@ -112,6 +196,18 @@ function receive(server, session, player, data) {
|
|
|
112
196
|
server.ownership.release(server.sim, player.playerId, data.r, server.handovers);
|
|
113
197
|
}
|
|
114
198
|
|
|
199
|
+
// Remove a scene actor for everyone. The id is a scene actor id; anything else
|
|
200
|
+
// is ignored. The world drops the actor's body when it has one.
|
|
201
|
+
function markGone(server, id) {
|
|
202
|
+
if (!server.sim || typeof id !== 'string' || id.length > 128 || server.gone.has(id)) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
server.gone.add(id);
|
|
206
|
+
server.newlyGone.push(id);
|
|
207
|
+
server.world.removeActor?.(server.sim, id);
|
|
208
|
+
server.moved = true;
|
|
209
|
+
}
|
|
210
|
+
|
|
115
211
|
// One session tick: reconcile players, sweep ownership, run the deck's hooks
|
|
116
212
|
// around physics, and send the join snapshots and the delta.
|
|
117
213
|
function tick(server, session) {
|
|
@@ -144,16 +240,22 @@ function tick(server, session) {
|
|
|
144
240
|
// A new player gets the whole world and the current ownerships. Sending the
|
|
145
241
|
// ownerships with that first snapshot stops the client from simulating an
|
|
146
242
|
// object somebody else already holds.
|
|
243
|
+
// A new player also gets every actor that is gone and their own saved block,
|
|
244
|
+
// which their client restores before it plays.
|
|
147
245
|
for (const playerId of server.waitingForWorld) {
|
|
246
|
+
const state = server.players.stateOf?.(server.sim, playerId) ?? null;
|
|
148
247
|
session.send(playerId, {
|
|
149
248
|
k: Messages.WORLD,
|
|
150
249
|
...snapshot,
|
|
151
250
|
own: server.ownership.ownerships(server.sim),
|
|
251
|
+
gone: [...server.gone],
|
|
252
|
+
...(state === null ? {} : { me: state }),
|
|
152
253
|
...server.game.join?.(server.sim),
|
|
153
254
|
});
|
|
154
255
|
}
|
|
155
256
|
server.waitingForWorld.clear();
|
|
156
257
|
broadcastDelta(server, session, snapshot);
|
|
258
|
+
maybeSave(server);
|
|
157
259
|
}
|
|
158
260
|
|
|
159
261
|
// Make the simulation's player map match Castle's roster. The roster covers the
|
|
@@ -165,9 +267,11 @@ function reconcilePlayers(server, session) {
|
|
|
165
267
|
continue;
|
|
166
268
|
}
|
|
167
269
|
|
|
168
|
-
// A newly observed roster id gets a body and a full WORLD on
|
|
169
|
-
|
|
170
|
-
server
|
|
270
|
+
// A newly observed roster id gets a body and a full WORLD on the tick its
|
|
271
|
+
// saved pose has been read.
|
|
272
|
+
if (admit(server, session, playerId)) {
|
|
273
|
+
server.waitingForWorld.add(playerId);
|
|
274
|
+
}
|
|
171
275
|
}
|
|
172
276
|
for (const playerId of [...server.sim.players.keys()]) {
|
|
173
277
|
if (present.has(playerId)) {
|
|
@@ -180,6 +284,159 @@ function reconcilePlayers(server, session) {
|
|
|
180
284
|
}
|
|
181
285
|
}
|
|
182
286
|
|
|
287
|
+
// Give one roster id a body. A gone player of the same account is taken over at
|
|
288
|
+
// once; otherwise the saved pose is waited for, up to `RESUME_WAIT_MS`.
|
|
289
|
+
function admit(server, session, playerId) {
|
|
290
|
+
const entry = server.joining.get(playerId);
|
|
291
|
+
const ghost = entry ? ghostOf(server, session, playerId, entry.userId) : null;
|
|
292
|
+
if (ghost === undefined) {
|
|
293
|
+
// Another connection of this account is still playing: a second character.
|
|
294
|
+
server.joining.delete(playerId);
|
|
295
|
+
server.players.addPlayer(server.sim, playerId, null);
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
if (ghost) {
|
|
299
|
+
server.joining.delete(playerId);
|
|
300
|
+
server.resumedUsers.set(entry.userId, playerId);
|
|
301
|
+
server.players.addPlayer(server.sim, playerId, poseOf(server, ghost));
|
|
302
|
+
server.players.setState?.(server.sim, playerId, server.players.stateOf?.(server.sim, ghost));
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
if (entry && !entry.done && Date.now() < entry.until) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
server.joining.delete(playerId);
|
|
309
|
+
const saved = resumeSaved(server, playerId, entry);
|
|
310
|
+
server.players.addPlayer(server.sim, playerId, saved?.pose ?? null);
|
|
311
|
+
server.players.setState?.(server.sim, playerId, saved?.state ?? null);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The playerId of a present player of the same account whose reports have
|
|
316
|
+
// stopped: a tab that was closed and not yet reaped. `undefined` when such a
|
|
317
|
+
// player is still reporting, null when there is none.
|
|
318
|
+
function ghostOf(server, session, playerId, userId) {
|
|
319
|
+
for (const player of session.players) {
|
|
320
|
+
if (player.playerId === playerId || player.userId !== userId) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const body = server.sim.players.get(player.playerId);
|
|
324
|
+
if (!body) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
return server.sim.timeMs - body.movedAt > GHOST_MS ? player.playerId : undefined;
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The saved row a joining player resumes from, or null for the scene's spawn
|
|
333
|
+
// with no state.
|
|
334
|
+
function resumeSaved(server, playerId, entry) {
|
|
335
|
+
if (!entry?.saved || server.resumedUsers.has(entry.userId)) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
server.resumedUsers.set(entry.userId, playerId);
|
|
339
|
+
return entry.saved;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Start reading a joining player's saved row.
|
|
343
|
+
function beginResume(server, player) {
|
|
344
|
+
if (!player.userId) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const entry = {
|
|
348
|
+
done: false,
|
|
349
|
+
saved: null,
|
|
350
|
+
userId: player.userId,
|
|
351
|
+
until: Date.now() + RESUME_WAIT_MS,
|
|
352
|
+
};
|
|
353
|
+
server.joining.set(player.playerId, entry);
|
|
354
|
+
void Persist.loadPlayer(server.session, server.room, player.userId).then((saved) => {
|
|
355
|
+
entry.saved = saved;
|
|
356
|
+
entry.done = true;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Write a leaving player's last pose. Their body is still in the simulation
|
|
361
|
+
// until the next reconciliation. A player another connection took over writes
|
|
362
|
+
// nothing: the newer body's pose is the one to keep.
|
|
363
|
+
function savePlayer(server, player) {
|
|
364
|
+
server.joining.delete(player.playerId);
|
|
365
|
+
const holder = server.resumedUsers.get(player.userId);
|
|
366
|
+
if (holder && holder !== player.playerId) {
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
server.resumedUsers.delete(player.userId);
|
|
370
|
+
const pose = poseOf(server, player.playerId);
|
|
371
|
+
if (!pose || !player.userId) {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
const state = server.players.stateOf?.(server.sim, player.playerId) ?? null;
|
|
375
|
+
void Persist.savePlayer(server.session, server.room, player.userId, { pose, state });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Write the world every `SAVE_INTERVAL_MS`, and only when a pose moved since the
|
|
379
|
+
// last write.
|
|
380
|
+
function maybeSave(server) {
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
if (!server.moved || server.saving || now - server.savedAt < SAVE_INTERVAL_MS) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
server.moved = false;
|
|
386
|
+
void saveRoom(server);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Write the world, the deck's block and every present player. A save already in
|
|
390
|
+
// flight is waited for, so a shutdown during the timer's write still lands its
|
|
391
|
+
// own.
|
|
392
|
+
async function saveRoom(server) {
|
|
393
|
+
if (!server.sim || !server.session) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (server.saving) {
|
|
397
|
+
await server.saving;
|
|
398
|
+
}
|
|
399
|
+
server.savedAt = Date.now();
|
|
400
|
+
server.saving = Persist.saveRoom(server.session, server.room, {
|
|
401
|
+
world: { poses: server.world.objectPoses(server.sim), gone: [...server.gone] },
|
|
402
|
+
game: server.game.save?.(server.sim) ?? null,
|
|
403
|
+
players: presentPlayers(server),
|
|
404
|
+
});
|
|
405
|
+
try {
|
|
406
|
+
await server.saving;
|
|
407
|
+
} finally {
|
|
408
|
+
server.saving = null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Every present player's pose and state, by user id.
|
|
413
|
+
function presentPlayers(server) {
|
|
414
|
+
const users = new Map(server.session.players.map((player) => [player.playerId, player.userId]));
|
|
415
|
+
const out = [];
|
|
416
|
+
for (const [playerId, ...pose] of server.players.playerPoses(server.sim)) {
|
|
417
|
+
const userId = users.get(playerId);
|
|
418
|
+
if (userId) {
|
|
419
|
+
const state = server.players.stateOf?.(server.sim, playerId) ?? null;
|
|
420
|
+
out.push([userId, { pose: pose.slice(0, server.poses.PLAYER_GEOMETRY), state }]);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// One player's current pose, without the `clamped` flag that belongs to a
|
|
427
|
+
// single tick.
|
|
428
|
+
function poseOf(server, playerId) {
|
|
429
|
+
if (!server.sim) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
for (const [id, ...pose] of server.players.playerPoses(server.sim)) {
|
|
433
|
+
if (id === playerId) {
|
|
434
|
+
return pose.slice(0, server.poses.PLAYER_GEOMETRY);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
|
|
183
440
|
// Broadcast what changed since the previous tick. A tick where nothing changed
|
|
184
441
|
// sends nothing at all.
|
|
185
442
|
function broadcastDelta(server, session, snapshot) {
|
|
@@ -202,14 +459,27 @@ function broadcastDelta(server, session, snapshot) {
|
|
|
202
459
|
// a new list for the next broadcast.
|
|
203
460
|
const own = server.handovers;
|
|
204
461
|
server.handovers = [];
|
|
462
|
+
const gone = server.newlyGone;
|
|
463
|
+
server.newlyGone = [];
|
|
464
|
+
|
|
465
|
+
// Only a moved pose makes the next save worth making.
|
|
466
|
+
server.moved = server.moved || ps.length > 0 || os.length > 0;
|
|
205
467
|
|
|
206
468
|
// A changed deck block is its own reason to broadcast, including on a tick
|
|
207
469
|
// where no pose and no ownership moved.
|
|
208
470
|
const block = server.game.delta?.(server.sim) ?? null;
|
|
209
|
-
if (ps.length === 0 && os.length === 0 && own.length === 0 && !block) {
|
|
471
|
+
if (ps.length === 0 && os.length === 0 && own.length === 0 && gone.length === 0 && !block) {
|
|
210
472
|
return;
|
|
211
473
|
}
|
|
212
|
-
session.broadcast({
|
|
474
|
+
session.broadcast({
|
|
475
|
+
k: Messages.STATE,
|
|
476
|
+
t: snapshot.t,
|
|
477
|
+
ps,
|
|
478
|
+
os,
|
|
479
|
+
own,
|
|
480
|
+
...(gone.length ? { gone } : {}),
|
|
481
|
+
...block,
|
|
482
|
+
});
|
|
213
483
|
}
|
|
214
484
|
|
|
215
485
|
// Whether one player's pose belongs in this delta, recording it as the new
|