@signe/room 2.9.4 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/chunk-EUXUH3YW.js +15 -0
- package/dist/chunk-EUXUH3YW.js.map +1 -0
- package/dist/cloudflare/index.d.ts +71 -0
- package/dist/cloudflare/index.js +320 -0
- package/dist/cloudflare/index.js.map +1 -0
- package/dist/index.d.ts +65 -188
- package/dist/index.js +742 -146
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +164 -0
- package/dist/node/index.js +786 -0
- package/dist/node/index.js.map +1 -0
- package/dist/party-dNs-hqkq.d.ts +175 -0
- package/examples/cloudflare/README.md +62 -0
- package/examples/cloudflare/node_modules/.bin/tsc +17 -0
- package/examples/cloudflare/node_modules/.bin/tsserver +17 -0
- package/examples/cloudflare/node_modules/.bin/wrangler +17 -0
- package/examples/cloudflare/node_modules/.bin/wrangler2 +17 -0
- package/examples/cloudflare/package.json +24 -0
- package/examples/cloudflare/public/index.html +443 -0
- package/examples/cloudflare/src/index.ts +28 -0
- package/examples/cloudflare/src/room.ts +44 -0
- package/examples/cloudflare/tsconfig.json +10 -0
- package/examples/cloudflare/wrangler.jsonc +25 -0
- package/examples/node/README.md +57 -0
- package/examples/node/node_modules/.bin/tsc +17 -0
- package/examples/node/node_modules/.bin/tsserver +17 -0
- package/examples/node/node_modules/.bin/tsx +17 -0
- package/examples/node/package.json +23 -0
- package/examples/node/public/index.html +443 -0
- package/examples/node/room.ts +44 -0
- package/examples/node/server.sqlite.ts +52 -0
- package/examples/node/server.ts +51 -0
- package/examples/node/tsconfig.json +10 -0
- package/examples/node-game/README.md +66 -0
- package/examples/node-game/package.json +23 -0
- package/examples/node-game/public/index.html +705 -0
- package/examples/node-game/room.ts +145 -0
- package/examples/node-game/server.sqlite.ts +54 -0
- package/examples/node-game/server.ts +53 -0
- package/examples/node-game/tsconfig.json +10 -0
- package/examples/node-shard/README.md +32 -0
- package/examples/node-shard/dev.ts +39 -0
- package/examples/node-shard/package.json +24 -0
- package/examples/node-shard/public/index.html +777 -0
- package/examples/node-shard/room-server.ts +68 -0
- package/examples/node-shard/room.ts +105 -0
- package/examples/node-shard/shared.ts +6 -0
- package/examples/node-shard/tsconfig.json +14 -0
- package/examples/node-shard/world-server.ts +169 -0
- package/package.json +14 -5
- package/readme.md +377 -11
- package/src/cloudflare/index.ts +474 -0
- package/src/mock.ts +29 -7
- package/src/node/index.ts +1112 -0
- package/src/server.ts +626 -90
- package/src/session.guard.ts +6 -2
- package/src/shard.ts +91 -23
- package/src/storage.ts +29 -5
- package/src/testing.ts +4 -3
- package/src/types/party.ts +4 -1
- package/src/world.guard.ts +23 -4
- package/src/world.ts +170 -79
- package/examples/game/.vscode/launch.json +0 -11
- package/examples/game/.vscode/settings.json +0 -11
- package/examples/game/README.md +0 -40
- package/examples/game/app/client.tsx +0 -15
- package/examples/game/app/components/Admin.tsx +0 -1089
- package/examples/game/app/components/Room.tsx +0 -162
- package/examples/game/app/styles.css +0 -31
- package/examples/game/package-lock.json +0 -225
- package/examples/game/package.json +0 -20
- package/examples/game/party/game.room.ts +0 -32
- package/examples/game/party/server.ts +0 -10
- package/examples/game/party/shard.ts +0 -5
- package/examples/game/partykit.json +0 -14
- package/examples/game/public/favicon.ico +0 -0
- package/examples/game/public/index.html +0 -27
- package/examples/game/public/normalize.css +0 -351
- package/examples/game/shared/room.schema.ts +0 -14
- package/examples/game/tsconfig.json +0 -109
package/src/world.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { signal } from "@signe/reactive";
|
|
2
|
-
import { Room, Guard, Request } from "./decorators";
|
|
2
|
+
import { Room, Action, Guard, Request } from "./decorators";
|
|
3
3
|
import { sync, id, persist } from "@signe/sync";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import * as Party from "./types/party";
|
|
6
6
|
import { guardManageWorld } from "./world.guard";
|
|
7
|
+
import { response } from "./utils";
|
|
7
8
|
import { RoomInterceptorPacket, RoomOnJoin } from "./interfaces";
|
|
8
9
|
import { ServerResponse } from "./request/response";
|
|
9
10
|
|
|
@@ -23,8 +24,17 @@ const RoomConfigSchema = z.object({
|
|
|
23
24
|
maxShards: z.number().int().positive().optional(),
|
|
24
25
|
});
|
|
25
26
|
|
|
27
|
+
const RegisterShardSchema = z.object({
|
|
28
|
+
shardId: z.string(),
|
|
29
|
+
roomId: z.string(),
|
|
30
|
+
worldId: z.string().optional(),
|
|
31
|
+
url: z.string().url(),
|
|
32
|
+
maxConnections: z.number().int().positive(),
|
|
33
|
+
});
|
|
34
|
+
|
|
26
35
|
const UpdateShardStatsSchema = z.object({
|
|
27
36
|
shardId: z.string(),
|
|
37
|
+
worldId: z.string().optional(),
|
|
28
38
|
connections: z.number().int().min(0),
|
|
29
39
|
status: z.enum(['active', 'maintenance', 'draining']).optional(),
|
|
30
40
|
});
|
|
@@ -52,6 +62,7 @@ class RoomConfig {
|
|
|
52
62
|
class ShardInfo {
|
|
53
63
|
@id() id: string;
|
|
54
64
|
@sync() roomId = signal("");
|
|
65
|
+
@sync() worldId = signal("");
|
|
55
66
|
@sync() url = signal("");
|
|
56
67
|
@sync({
|
|
57
68
|
persist: false
|
|
@@ -72,10 +83,10 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
72
83
|
// Synchronized state
|
|
73
84
|
@sync(RoomConfig) rooms = signal<Record<string, RoomConfig>>({});
|
|
74
85
|
@sync(ShardInfo) shards = signal<Record<string, ShardInfo>>({});
|
|
75
|
-
|
|
86
|
+
|
|
76
87
|
// Only persisted state (not synced to clients)
|
|
77
88
|
@persist() rrCounters = signal<Record<string, number>>({});
|
|
78
|
-
|
|
89
|
+
|
|
79
90
|
// Configuration
|
|
80
91
|
defaultShardUrlTemplate = signal("{shardId}");
|
|
81
92
|
defaultMaxConnectionsPerShard = signal(MAX_PLAYERS_PER_SHARD);
|
|
@@ -88,8 +99,7 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
88
99
|
if (!SHARD_SECRET) {
|
|
89
100
|
throw new Error("SHARD_SECRET env variable is not set");
|
|
90
101
|
}
|
|
91
|
-
|
|
92
|
-
//setTimeout(() => this.cleanupInactiveShards(), 60000);
|
|
102
|
+
this.scheduleInactiveShardCleanup();
|
|
93
103
|
}
|
|
94
104
|
|
|
95
105
|
async onJoin(user: any, conn: Party.Connection, ctx: Party.ConnectionContext) {
|
|
@@ -106,26 +116,43 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
106
116
|
}
|
|
107
117
|
return obj;
|
|
108
118
|
}
|
|
109
|
-
|
|
119
|
+
|
|
110
120
|
// Helper methods
|
|
121
|
+
private getWorldId() {
|
|
122
|
+
return this.room.id;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private scheduleInactiveShardCleanup() {
|
|
126
|
+
const timeoutId = setTimeout(() => this.cleanupInactiveShards(), 60000);
|
|
127
|
+
timeoutId?.unref?.();
|
|
128
|
+
}
|
|
129
|
+
|
|
111
130
|
private cleanupInactiveShards() {
|
|
112
131
|
const now = Date.now();
|
|
113
132
|
const timeout = 5 * 60 * 1000; // 5 minutes timeout
|
|
114
133
|
const shardsValue = this.shards();
|
|
115
|
-
|
|
134
|
+
|
|
116
135
|
let hasChanges = false;
|
|
117
136
|
Object.values(shardsValue).forEach(shard => {
|
|
118
137
|
if (now - shard.lastHeartbeat() > timeout) {
|
|
119
138
|
delete this.shards()[shard.id];
|
|
120
|
-
|
|
139
|
+
|
|
121
140
|
hasChanges = true;
|
|
122
141
|
}
|
|
123
142
|
});
|
|
124
|
-
|
|
143
|
+
|
|
125
144
|
// Schedule next cleanup
|
|
126
|
-
|
|
145
|
+
this.scheduleInactiveShardCleanup();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private removeShard(shardId: string) {
|
|
149
|
+
delete this.shards()[shardId];
|
|
127
150
|
}
|
|
128
|
-
|
|
151
|
+
|
|
152
|
+
private shouldCompleteDrain(shard: ShardInfo) {
|
|
153
|
+
return shard.status() === 'draining' && shard.currentConnections() === 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
129
156
|
// Actions
|
|
130
157
|
@Request({
|
|
131
158
|
path: 'register-room',
|
|
@@ -133,16 +160,20 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
133
160
|
})
|
|
134
161
|
@Guard([guardManageWorld])
|
|
135
162
|
async registerRoom(req: Party.Request, res?: ServerResponse) {
|
|
136
|
-
const
|
|
137
|
-
if (!
|
|
163
|
+
const parseResult = RoomConfigSchema.safeParse(await req.json());
|
|
164
|
+
if (!parseResult.success) {
|
|
138
165
|
return res?.badRequest("Invalid room configuration", {
|
|
139
|
-
details:
|
|
166
|
+
details: parseResult.error
|
|
140
167
|
});
|
|
141
168
|
}
|
|
142
169
|
|
|
143
|
-
const roomConfig =
|
|
170
|
+
const roomConfig = parseResult.data;
|
|
171
|
+
if (roomConfig.maxShards !== undefined && roomConfig.minShards > roomConfig.maxShards) {
|
|
172
|
+
return res?.badRequest("minShards cannot be greater than maxShards");
|
|
173
|
+
}
|
|
174
|
+
|
|
144
175
|
const roomId = roomConfig.name;
|
|
145
|
-
|
|
176
|
+
|
|
146
177
|
if (!this.rooms()[roomId]) {
|
|
147
178
|
const newRoom = new RoomConfig();
|
|
148
179
|
newRoom.id = roomId;
|
|
@@ -152,9 +183,9 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
152
183
|
newRoom.maxPlayersPerShard.set(roomConfig.maxPlayersPerShard);
|
|
153
184
|
newRoom.minShards.set(roomConfig.minShards);
|
|
154
185
|
newRoom.maxShards.set(roomConfig.maxShards);
|
|
155
|
-
|
|
186
|
+
|
|
156
187
|
this.rooms()[roomId] = newRoom;
|
|
157
|
-
|
|
188
|
+
|
|
158
189
|
// Ensure minimum shards are created
|
|
159
190
|
if (roomConfig.minShards > 0) {
|
|
160
191
|
for (let i = 0; i < roomConfig.minShards; i++) {
|
|
@@ -170,63 +201,73 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
170
201
|
room.minShards.set(roomConfig.minShards);
|
|
171
202
|
room.maxShards.set(roomConfig.maxShards);
|
|
172
203
|
}
|
|
204
|
+
|
|
205
|
+
await this.ensureMinShards(roomId);
|
|
173
206
|
}
|
|
174
|
-
|
|
207
|
+
|
|
175
208
|
@Request({
|
|
176
209
|
path: 'update-shard',
|
|
177
210
|
method: 'POST',
|
|
178
211
|
})
|
|
179
212
|
@Guard([guardManageWorld])
|
|
180
213
|
async updateShardStats(req: Party.Request, res: ServerResponse) {
|
|
181
|
-
const
|
|
182
|
-
if (!
|
|
183
|
-
return res.badRequest("Invalid shard
|
|
184
|
-
details:
|
|
214
|
+
const parseResult = UpdateShardStatsSchema.safeParse(await req.json());
|
|
215
|
+
if (!parseResult.success) {
|
|
216
|
+
return res.badRequest("Invalid shard stats", {
|
|
217
|
+
details: parseResult.error
|
|
185
218
|
});
|
|
186
219
|
}
|
|
187
220
|
|
|
188
|
-
const body =
|
|
221
|
+
const body = parseResult.data;
|
|
189
222
|
const { shardId, connections, status } = body;
|
|
190
223
|
const shard = this.shards()[shardId];
|
|
191
224
|
|
|
192
225
|
if (!shard) {
|
|
193
226
|
return res.notFound(`Shard ${shardId} not found`);
|
|
194
227
|
}
|
|
195
|
-
|
|
228
|
+
|
|
229
|
+
if (body.worldId && body.worldId !== this.getWorldId()) {
|
|
230
|
+
return res.badRequest(`Shard ${shardId} belongs to world ${body.worldId}, not ${this.getWorldId()}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
196
233
|
shard.currentConnections.set(connections);
|
|
197
234
|
if (status) {
|
|
198
235
|
shard.status.set(status);
|
|
199
236
|
}
|
|
200
237
|
shard.lastHeartbeat.set(Date.now());
|
|
238
|
+
|
|
239
|
+
if (this.shouldCompleteDrain(shard)) {
|
|
240
|
+
this.removeShard(shard.id);
|
|
241
|
+
}
|
|
201
242
|
}
|
|
202
|
-
|
|
243
|
+
|
|
203
244
|
@Request({
|
|
204
245
|
path: 'scale-room',
|
|
205
246
|
method: 'POST',
|
|
206
247
|
})
|
|
207
248
|
@Guard([guardManageWorld])
|
|
208
249
|
async scaleRoom(req: Party.Request, res: ServerResponse) {
|
|
209
|
-
const
|
|
210
|
-
if (!
|
|
211
|
-
return res.badRequest("Invalid scale request", {
|
|
212
|
-
details:
|
|
250
|
+
const parseResult = ScaleRoomSchema.safeParse(await req.json());
|
|
251
|
+
if (!parseResult.success) {
|
|
252
|
+
return res.badRequest("Invalid scale room request", {
|
|
253
|
+
details: parseResult.error
|
|
213
254
|
});
|
|
214
255
|
}
|
|
215
256
|
|
|
216
|
-
const data =
|
|
257
|
+
const data = parseResult.data;
|
|
217
258
|
const { targetShardCount, shardTemplate, roomId } = data;
|
|
218
|
-
|
|
259
|
+
|
|
219
260
|
// Validate room exists
|
|
220
261
|
const room = this.rooms()[roomId];
|
|
221
262
|
if (!room) {
|
|
222
263
|
return res.notFound(`Room ${roomId} does not exist`);
|
|
223
264
|
}
|
|
224
|
-
|
|
265
|
+
|
|
225
266
|
const roomShards = Object.values(this.shards())
|
|
226
267
|
.filter(shard => shard.roomId() === roomId);
|
|
227
|
-
|
|
268
|
+
|
|
228
269
|
const previousShardCount = roomShards.length;
|
|
229
|
-
|
|
270
|
+
|
|
230
271
|
// Check max shards constraint
|
|
231
272
|
if (room.maxShards() !== undefined && targetShardCount > room.maxShards()!) {
|
|
232
273
|
return res.badRequest(`Cannot scale beyond maximum allowed shards (${room.maxShards()})`, {
|
|
@@ -234,33 +275,36 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
234
275
|
currentShardCount: previousShardCount
|
|
235
276
|
});
|
|
236
277
|
}
|
|
237
|
-
|
|
278
|
+
|
|
238
279
|
// Handle scaling down
|
|
239
280
|
if (targetShardCount < previousShardCount) {
|
|
240
|
-
// Find candidates
|
|
241
|
-
const
|
|
281
|
+
// Find drain candidates (prioritize already-draining or low-connection shards)
|
|
282
|
+
const shardsToDrain = [...roomShards]
|
|
242
283
|
.sort((a, b) => {
|
|
243
284
|
// Prioritize draining status
|
|
244
285
|
if (a.status() === 'draining' && b.status() !== 'draining') return -1;
|
|
245
286
|
if (a.status() !== 'draining' && b.status() === 'draining') return 1;
|
|
246
|
-
|
|
287
|
+
|
|
247
288
|
// Then by connection count (ascending)
|
|
248
289
|
return a.currentConnections() - b.currentConnections();
|
|
249
290
|
})
|
|
250
291
|
.slice(0, previousShardCount - targetShardCount);
|
|
251
|
-
|
|
252
|
-
//
|
|
253
|
-
for (const shard of
|
|
254
|
-
|
|
292
|
+
|
|
293
|
+
// Complete empty drains immediately, otherwise stop routing new clients there.
|
|
294
|
+
for (const shard of shardsToDrain) {
|
|
295
|
+
shard.status.set('draining');
|
|
296
|
+
if (this.shouldCompleteDrain(shard)) {
|
|
297
|
+
this.removeShard(shard.id);
|
|
298
|
+
}
|
|
255
299
|
}
|
|
256
|
-
|
|
300
|
+
|
|
257
301
|
return;
|
|
258
302
|
}
|
|
259
|
-
|
|
303
|
+
|
|
260
304
|
// Handle scaling up
|
|
261
305
|
if (targetShardCount > previousShardCount) {
|
|
262
306
|
const newShards = [];
|
|
263
|
-
|
|
307
|
+
|
|
264
308
|
// Create new shards
|
|
265
309
|
for (let i = 0; i < targetShardCount - previousShardCount; i++) {
|
|
266
310
|
const newShard = await this.createShard(
|
|
@@ -268,7 +312,7 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
268
312
|
shardTemplate?.urlTemplate,
|
|
269
313
|
shardTemplate?.maxConnections
|
|
270
314
|
);
|
|
271
|
-
|
|
315
|
+
|
|
272
316
|
if (newShard) {
|
|
273
317
|
newShards.push(newShard);
|
|
274
318
|
}
|
|
@@ -284,35 +328,35 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
284
328
|
try {
|
|
285
329
|
// Extract request data
|
|
286
330
|
let data: { roomId: string; autoCreate?: boolean };
|
|
287
|
-
|
|
331
|
+
|
|
288
332
|
try {
|
|
289
333
|
// Handle potential empty body or malformed JSON
|
|
290
334
|
const body = await req.text();
|
|
291
335
|
if (!body || body.trim() === '') {
|
|
292
336
|
return res.badRequest("Request body is empty");
|
|
293
337
|
}
|
|
294
|
-
|
|
338
|
+
|
|
295
339
|
data = JSON.parse(body);
|
|
296
340
|
} catch (parseError) {
|
|
297
341
|
return res.badRequest("Invalid JSON in request body");
|
|
298
342
|
}
|
|
299
|
-
|
|
343
|
+
|
|
300
344
|
// Verify roomId is provided
|
|
301
345
|
if (!data.roomId) {
|
|
302
346
|
return res.badRequest("roomId parameter is required");
|
|
303
347
|
}
|
|
304
|
-
|
|
348
|
+
|
|
305
349
|
// Determine if auto-creation is enabled (default to true)
|
|
306
350
|
const autoCreate = data.autoCreate !== undefined ? data.autoCreate : true;
|
|
307
|
-
|
|
351
|
+
|
|
308
352
|
// Find optimal shard
|
|
309
353
|
const result = await this.findOptimalShard(data.roomId, autoCreate);
|
|
310
|
-
|
|
354
|
+
|
|
311
355
|
// Check for errors
|
|
312
356
|
if ('error' in result) {
|
|
313
357
|
return res.notFound(result.error);
|
|
314
358
|
}
|
|
315
|
-
|
|
359
|
+
|
|
316
360
|
// Return shard information to the client
|
|
317
361
|
return res.success({
|
|
318
362
|
success: true,
|
|
@@ -324,9 +368,9 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
324
368
|
return res.serverError();
|
|
325
369
|
}
|
|
326
370
|
}
|
|
327
|
-
|
|
371
|
+
|
|
328
372
|
private async findOptimalShard(
|
|
329
|
-
roomId: string,
|
|
373
|
+
roomId: string,
|
|
330
374
|
autoCreate: boolean = true
|
|
331
375
|
): Promise<{ shardId: string; url: string } | { error: string }> {
|
|
332
376
|
// Ensure room exists
|
|
@@ -343,11 +387,11 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
343
387
|
maxShards: undefined
|
|
344
388
|
})
|
|
345
389
|
} as Party.Request;
|
|
346
|
-
|
|
390
|
+
|
|
347
391
|
await this.registerRoom(mockRequest);
|
|
348
|
-
|
|
392
|
+
|
|
349
393
|
room = this.rooms()[roomId];
|
|
350
|
-
|
|
394
|
+
|
|
351
395
|
if (!room) {
|
|
352
396
|
return { error: `Failed to create room ${roomId}` };
|
|
353
397
|
}
|
|
@@ -355,11 +399,11 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
355
399
|
return { error: `Room ${roomId} does not exist` };
|
|
356
400
|
}
|
|
357
401
|
}
|
|
358
|
-
|
|
402
|
+
|
|
359
403
|
// Get shards for this room
|
|
360
404
|
const roomShards = Object.values(this.shards())
|
|
361
405
|
.filter(shard => shard.roomId() === roomId);
|
|
362
|
-
|
|
406
|
+
|
|
363
407
|
if (roomShards.length === 0) {
|
|
364
408
|
if (autoCreate) {
|
|
365
409
|
// Auto-create a shard
|
|
@@ -376,51 +420,76 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
376
420
|
return { error: `No shards available for room ${roomId}` };
|
|
377
421
|
}
|
|
378
422
|
}
|
|
379
|
-
|
|
380
|
-
// Get active shards
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
423
|
+
|
|
424
|
+
// Get active shards with available capacity
|
|
425
|
+
let activeShards = this.getAvailableShards(roomShards);
|
|
426
|
+
|
|
384
427
|
if (activeShards.length === 0) {
|
|
428
|
+
if (autoCreate && this.canCreateShard(room, roomShards.length)) {
|
|
429
|
+
const newShard = await this.createShard(roomId);
|
|
430
|
+
if (newShard) {
|
|
431
|
+
return {
|
|
432
|
+
shardId: newShard.id,
|
|
433
|
+
url: newShard.url()
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (roomShards.some(shard => shard.status() === 'active')) {
|
|
439
|
+
return { error: `No shard capacity available for room ${roomId}` };
|
|
440
|
+
}
|
|
441
|
+
|
|
385
442
|
return { error: `No active shards available for room ${roomId}` };
|
|
386
443
|
}
|
|
387
|
-
|
|
444
|
+
|
|
388
445
|
// Apply balancing strategy
|
|
389
446
|
const balancingStrategy = room.balancingStrategy();
|
|
390
447
|
let selectedShard: ShardInfo;
|
|
391
|
-
|
|
448
|
+
|
|
392
449
|
switch (balancingStrategy) {
|
|
393
450
|
case 'least-connections':
|
|
394
451
|
// Choose shard with fewest connections
|
|
395
452
|
selectedShard = activeShards.reduce(
|
|
396
|
-
(min, shard) =>
|
|
453
|
+
(min, shard) =>
|
|
397
454
|
shard.currentConnections() < min.currentConnections() ? shard : min,
|
|
398
455
|
activeShards[0]
|
|
399
456
|
);
|
|
400
457
|
break;
|
|
401
|
-
|
|
458
|
+
|
|
402
459
|
case 'random':
|
|
403
460
|
// Choose random shard
|
|
404
461
|
selectedShard = activeShards[Math.floor(Math.random() * activeShards.length)];
|
|
405
462
|
break;
|
|
406
|
-
|
|
463
|
+
|
|
407
464
|
case 'round-robin':
|
|
408
465
|
default:
|
|
409
466
|
// Round-robin selection
|
|
410
467
|
const counter = this.rrCounters()[roomId] || 0;
|
|
411
468
|
const nextCounter = (counter + 1) % activeShards.length;
|
|
412
469
|
this.rrCounters()[roomId] = nextCounter;
|
|
413
|
-
|
|
470
|
+
|
|
414
471
|
selectedShard = activeShards[counter];
|
|
415
472
|
break;
|
|
416
473
|
}
|
|
417
|
-
|
|
474
|
+
|
|
418
475
|
return {
|
|
419
476
|
shardId: selectedShard.id,
|
|
420
477
|
url: selectedShard.url()
|
|
421
478
|
};
|
|
422
479
|
}
|
|
423
|
-
|
|
480
|
+
|
|
481
|
+
private getAvailableShards(shards: ShardInfo[]) {
|
|
482
|
+
return shards.filter(shard =>
|
|
483
|
+
shard
|
|
484
|
+
&& shard.status() === 'active'
|
|
485
|
+
&& shard.currentConnections() < shard.maxConnections()
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private canCreateShard(room: RoomConfig, currentShardCount: number) {
|
|
490
|
+
return room.maxShards() === undefined || currentShardCount < room.maxShards()!;
|
|
491
|
+
}
|
|
492
|
+
|
|
424
493
|
// Private methods
|
|
425
494
|
private async createShard(
|
|
426
495
|
roomId: string,
|
|
@@ -432,29 +501,51 @@ export class WorldRoom implements RoomInterceptorPacket, RoomOnJoin {
|
|
|
432
501
|
console.error(`Cannot create shard for non-existent room: ${roomId}`);
|
|
433
502
|
return null;
|
|
434
503
|
}
|
|
435
|
-
|
|
504
|
+
|
|
436
505
|
// Generate shard ID
|
|
437
|
-
const
|
|
438
|
-
|
|
506
|
+
const worldId = this.getWorldId();
|
|
507
|
+
const shardId = `${roomId}:${worldId}:${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
508
|
+
|
|
439
509
|
// Generate URL from template
|
|
440
510
|
const template = urlTemplate || this.defaultShardUrlTemplate();
|
|
441
511
|
const url = template.replace('{shardId}', shardId).replace('{roomId}', roomId);
|
|
442
512
|
|
|
443
513
|
// Set max connections
|
|
444
514
|
const max = maxConnections || room.maxPlayersPerShard();
|
|
445
|
-
|
|
515
|
+
|
|
446
516
|
// Create the shard
|
|
447
517
|
const newShard = new ShardInfo();
|
|
448
518
|
newShard.id = shardId;
|
|
449
519
|
newShard.roomId.set(roomId);
|
|
520
|
+
newShard.worldId.set(worldId);
|
|
450
521
|
newShard.url.set(url);
|
|
451
522
|
newShard.maxConnections.set(max);
|
|
452
523
|
newShard.currentConnections.set(0);
|
|
453
524
|
newShard.status.set("active");
|
|
454
525
|
newShard.lastHeartbeat.set(Date.now());
|
|
455
|
-
|
|
526
|
+
|
|
456
527
|
// Update shards collection
|
|
457
528
|
this.shards()[shardId] = newShard;
|
|
458
529
|
return newShard;
|
|
459
530
|
}
|
|
531
|
+
|
|
532
|
+
private async ensureMinShards(roomId: string) {
|
|
533
|
+
const room = this.rooms()[roomId];
|
|
534
|
+
if (!room) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const currentShardCount = Object.values(this.shards())
|
|
539
|
+
.filter(shard => shard.roomId() === roomId)
|
|
540
|
+
.length;
|
|
541
|
+
const targetShardCount = room.minShards();
|
|
542
|
+
|
|
543
|
+
if (currentShardCount >= targetShardCount) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for (let i = currentShardCount; i < targetShardCount; i++) {
|
|
548
|
+
await this.createShard(roomId);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
460
551
|
}
|
package/examples/game/README.md
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# 🎈 game
|
|
2
|
-
|
|
3
|
-
Welcome to the party, pal!
|
|
4
|
-
|
|
5
|
-
This is a [Partykit](https://partykit.io) project, which lets you create real-time collaborative applications with minimal coding effort.
|
|
6
|
-
|
|
7
|
-
This is the **React starter** which pairs a PartyKit server with a React client.
|
|
8
|
-
|
|
9
|
-
Refer to our docs for more information: https://github.com/partykit/partykit/blob/main/README.md. For more help, reach out to us on [Discord](https://discord.gg/g5uqHQJc3z), [GitHub](https://github.com/partykit/partykit), or [Twitter](https://twitter.com/partykit_io).
|
|
10
|
-
|
|
11
|
-
## Usage
|
|
12
|
-
|
|
13
|
-
You can start developing by running `npm run dev` and opening [http://localhost:1999](http://localhost:1999) in your browser. When you're ready, you can deploy your application on to the PartyKit cloud with `npm run deploy`.
|
|
14
|
-
|
|
15
|
-
## Finding your way around
|
|
16
|
-
|
|
17
|
-
[`party/server.ts`](./party/server.ts) is the server-side code, which is responsible for handling WebSocket events and HTTP requests.
|
|
18
|
-
|
|
19
|
-
It implements a simple counter that can be incremented by any connected client. The latest state is broadcast to all connected clients.
|
|
20
|
-
|
|
21
|
-
> [!NOTE]
|
|
22
|
-
> The full Server API is available at [Party.Server in the PartyKit docs](https://docs.partykit.io/reference/partyserver-api/)
|
|
23
|
-
|
|
24
|
-
[`app/client.tsx`](./src/client.ts) is the entrypoint to client-side code.
|
|
25
|
-
|
|
26
|
-
[`app/components/Counter.tsx`](./src/components/Counter.tsx) connects to the server, sends `increment` events on the WebSocket, and listens for updates.
|
|
27
|
-
|
|
28
|
-
> [!NOTE]
|
|
29
|
-
> The client-side reference can be found at [PartySocket in the PartyKit docs](https://docs.partykit.io/reference/partysocket-api/)
|
|
30
|
-
|
|
31
|
-
As a client-side React app, the app could be hosted every. During development, for convenience, the server serves the client-side code as well.
|
|
32
|
-
|
|
33
|
-
This is achieved with the optional `serve` property in the [`partykit.json`](./partykit.json) config file.
|
|
34
|
-
|
|
35
|
-
> [!NOTE]
|
|
36
|
-
> Learn about PartyKit config under [Configuration in the PartyKit docs](https://docs.partykit.io/reference/partykit-configuration/)
|
|
37
|
-
|
|
38
|
-
## Next Steps
|
|
39
|
-
|
|
40
|
-
Learn about deploying PartyKit applications in the [Deployment guide of the PartyKit docs](https://docs.partykit.io/guides/deploying-your-partykit-server/).
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { createRoot } from "react-dom/client";
|
|
2
|
-
import Admin from "./components/Admin";
|
|
3
|
-
import "./styles.css";
|
|
4
|
-
import Room from "./components/Room";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
function App() {
|
|
8
|
-
return (
|
|
9
|
-
<main>
|
|
10
|
-
<Room />
|
|
11
|
-
</main>
|
|
12
|
-
);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
createRoot(document.getElementById("app")!).render(<App />);
|