tickcli-js 0.1.1 → 0.1.3
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 +8 -0
- package/README.md +13 -2
- package/client/game.js +1 -1
- package/dist/hub.d.ts +26 -3
- package/dist/hub.js +80 -11
- package/dist/hub.js.map +1 -1
- package/dist/node.d.ts +11 -0
- package/dist/node.js +10 -1
- package/dist/node.js.map +1 -1
- package/dist/remote.d.ts +6 -0
- package/dist/remote.js +47 -9
- package/dist/remote.js.map +1 -1
- package/dist/serve.js +7 -4
- package/dist/serve.js.map +1 -1
- package/package.json +3 -3
- package/src/hub.ts +77 -12
- package/src/node.ts +14 -1
- package/src/remote.ts +38 -9
- package/src/serve.ts +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.3
|
|
4
|
+
|
|
5
|
+
Flood limits. New sockets per client address (`connectsPerAddress`, 40 per 10 s) are checked before the server-wide accept budget, so one client can no longer spend it for everyone; IPv6 addresses count by their /64 (`addressKey`) for every per-address limit. `hardenServer(server)` in `tickcli-js/node` (used by `createGameServer` and `createRemoteServer`): request headers within 10 s, whole request 15 s, idle keep-alive 5 s, 100 headers. Remote: a deployment is looked up at most once per 15 s per id, found or not (`remote.forget(id)` drops one after an edit), fresh lookups are capped per address, and an upgrade over the address budget is refused before any lookup. `ws` 8.23.0 or later (GHSA-37g9-w4hg-h9qr, ping flood).
|
|
6
|
+
|
|
7
|
+
## 0.1.2
|
|
8
|
+
|
|
9
|
+
Top speed for player fields: a third number in a range (`player: { x: [0, 800, 300] }`) caps how far the field moves per second. The hub cuts a faster move to that distance (10% headroom, senders' sample times) instead of refusing it, so speed hacks and teleports look like normal running to the others and to the host; removing a field first does not reset it; a new match allows one free jump. Ranges without the third number work as before.
|
|
10
|
+
|
|
3
11
|
## 0.1.1
|
|
4
12
|
|
|
5
13
|
A "Leave" button in the top bar of multiplayer games: out of the room at any time (mid-match with others, a second tap within 3 s confirms) into a new private room of your own, and the seat is freed at once. With `Game({ exit: "/" })` or `<meta name="tickcli-exit" content="/">` it reads "Exit" and goes to that URL. Fix: after leaving, a page opened from an invite link (`#room=CODE`) no longer goes back to that room.
|
package/README.md
CHANGED
|
@@ -112,6 +112,7 @@ Game({
|
|
|
112
112
|
| Server | membership, host, order, match number, clock | - |
|
|
113
113
|
|
|
114
114
|
- Writes merge deeply: `{hp: {a: 80}}` changes one entry, `null` removes a key, lists are replaced whole. Values that did not change are not sent (room.js and the hub both drop them), so writing a whole object every step is fine; a write that changes nothing sends nothing.
|
|
115
|
+
- Top speed: a third number in a player field's range caps how fast it moves, `player: { x: [0, 800, 300] }` = at most 300 a second. The hub cuts a bigger jump to that distance (10% headroom, measured on the senders' sample times), so a speed hack or teleport shows as normal running to everyone else and to the host's rules; the writer is not refused. A new match lets each player jump once (to a start spot); a respawn mid-match shows as a quick run. Leave it out for fields that may jump.
|
|
115
116
|
- Things the host moves (monsters, minions, shots) ease smoothly on every other screen when their position fields are named `x`, `y` (also `z`, `angle`, `rot`, and a quaternion `qx, qy, qz, qw`). Players ease the same way. Others are drawn in the past by a delay measured per room (mean delay of the last 40 updates + 2 send intervals + its spread, 100 to 500 ms; usually 200 to 400), at their senders' sample times; the drawing clock follows it by running 2% fast or 4% slow; a late update: the last speed for 100 ms, then hold. Measured through the real code: 0% jump frames, speed within 1%, for 4 and 16 players with 30 ms jitter.
|
|
116
117
|
- Timers are end times on the shared clock: `g.shared.set({endsAt: g.now + 60})`. The clock: 4 pings 250 ms apart after connecting, then one every 5 s; the offset of the fastest of the last 8 (none older than 60 s) is used, reached over a second, never in one step.
|
|
117
118
|
- `g.shared` is read-only all the way down (frozen): a write into it throws in strict code and does nothing otherwise, and `list.sort()` on a list in it throws, so copy first (`g.shared.list.slice().sort()`). The page never copies the whole state: each write makes new objects only along the paths it changes, so a frame costs the same at 3 KB or 60 KB (0.07 ms at 60 KB with 32 moving things, was 1.4 ms). `structuredClone(g.shared)` works (plain frozen objects).
|
|
@@ -261,6 +262,14 @@ Or in your own server: `createRemote({ hub, deployment })` gives `upgrade(req, s
|
|
|
261
262
|
- A refused page stops and shows why ("This site is not allowed for deployment dep_ab12.").
|
|
262
263
|
- Not a guard: a script outside a browser can send any `Origin`. The caps, the per-address limits and revoking the id are what stop a misused deployment.
|
|
263
264
|
|
|
265
|
+
### Floods and DDoS
|
|
266
|
+
|
|
267
|
+
The hub and adapters cap what one client can cost (table below: sockets and new sockets per address, messages, bytes, lookups, slow headers). Keep `ws` at 8.23.0 or later (a peer that floods pings while not reading exhausted memory in earlier versions, GHSA-37g9-w4hg-h9qr); `WS_OPTIONS` keeps compression off and messages at 16 KB (ws's own default is 100 MiB). What the server cannot do alone:
|
|
268
|
+
|
|
269
|
+
- Volume (a full uplink, SYN floods): the host's network filter. Most VPS hosts include one; turn on the game or always-on profile if offered.
|
|
270
|
+
- Before Node: a per-address limit on new TCP connections in the firewall, generous enough for a school or office behind one address, e.g. nftables `tcp dport 443 ct state new meter tcli4 { ip saddr limit rate over 30/second burst 60 packets } drop` (and the same with `ip6 saddr`).
|
|
271
|
+
- In the proxy: short header timeouts (Caddy `servers { timeouts { read_header 5s } }`). Behind a proxy set `trustProxy` so limits count players, not the proxy.
|
|
272
|
+
|
|
264
273
|
## Protocol (version 1)
|
|
265
274
|
|
|
266
275
|
Every message is JSON with `v: 1`.
|
|
@@ -308,8 +317,10 @@ Saving needs a host app that gives the hub a storage port (`new RoomHub({ saves
|
|
|
308
317
|
|---|---|
|
|
309
318
|
| Players per room | 16 |
|
|
310
319
|
| Rooms on the server / per namespace | 500 / 50 |
|
|
311
|
-
| Sockets on the server / unverified / per address | 2000 / 200 / 20 |
|
|
312
|
-
| New sockets | 200 a second (over
|
|
320
|
+
| Sockets on the server / unverified / per address | 2000 / 200 / 20 (an IPv6 address counts by its /64, `addressKey`) |
|
|
321
|
+
| New sockets | 40 per address per 10 s (`connectsPerAddress`, checked first, so one client never spends the shared budget), then 200 a second for the whole server (over either: 1013 busy) |
|
|
322
|
+
| HTTP (serve, remote) | request headers within 10 s, whole request 15 s, idle keep-alive 5 s, 100 headers (`hardenServer`; Node's defaults are 60 s, 300 s, 5 s, 2000) |
|
|
323
|
+
| Deployment lookups (remote) | each id at most once per 15 s, found or not (`LOOKUP_MS`; `remote.forget(id)` after an edit); fresh lookups 20 per address per 10 s |
|
|
313
324
|
| Joins per address | 30 a minute |
|
|
314
325
|
| Message / player state / shared state | 16 KB / 2 KB / 64 KB (the client splits bigger host updates) |
|
|
315
326
|
| Shape | plain JSON, depth 4, 64 keys per object, 64 items per list, text 200 characters, keys 40 characters (`SHAPE`); a write that breaks one is refused whole |
|
package/client/game.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
if (g.state === "playing") g.me.set({x: hero.x, y: hero.y});
|
|
37
37
|
g.sync(function (p) { return addSprite(p); }, function (s, st) { s.x = st.x; s.y = st.y; }, function (s) { s.destroy(); });
|
|
38
38
|
|
|
39
|
-
Playing together (also load room.js before this file): pass multiplayer: {max: 4, player: {x: [0, 800], y: [0, 600]}}.
|
|
39
|
+
Playing together (also load room.js before this file): pass multiplayer: {max: 4, player: {x: [0, 800], y: [0, 600]}}. A third number is a top speed per second (x: [0, 800, 300]): the server cuts faster moves to it.
|
|
40
40
|
The shell then shows the lobby (room code, invite link, players, Start for the host), reconnects, and shows notices. Your code:
|
|
41
41
|
update(g, dt) every player: move yourself, g.me.set({x, y}) (sent 12 times a second at most, clamped to the declared ranges)
|
|
42
42
|
host(g, dt) runs on ONE player only (the host): rounds, timers, health, score, who won: g.shared.set({...}); g.gameOver() ends the match
|
package/dist/hub.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface Limits {
|
|
|
22
22
|
maxConnections: number;
|
|
23
23
|
maxPending: number;
|
|
24
24
|
maxPerAddress: number;
|
|
25
|
+
connectsPerAddress: number;
|
|
25
26
|
joinsPerMinute: number;
|
|
26
27
|
messageBytes: number;
|
|
27
28
|
playerStateBytes: number;
|
|
@@ -55,6 +56,9 @@ export interface Limits {
|
|
|
55
56
|
saveDays: number;
|
|
56
57
|
}
|
|
57
58
|
export declare const DEFAULT_LIMITS: Limits;
|
|
59
|
+
/** The key one client is counted by: an IPv4 address as is, an IPv6 address by its first 64 bits (a home, phone or server is handed a whole /64,
|
|
60
|
+
* so per-address limits on full IPv6 addresses would not hold anyone). IPv4-mapped IPv6 counts as the IPv4 address. */
|
|
61
|
+
export declare function addressKey(addr: string): string;
|
|
58
62
|
/** One open socket, as the hub sees it. */
|
|
59
63
|
export interface Conn {
|
|
60
64
|
send(data: string): void;
|
|
@@ -88,14 +92,17 @@ export interface Who {
|
|
|
88
92
|
name: string;
|
|
89
93
|
guest: boolean;
|
|
90
94
|
}
|
|
91
|
-
/** The page's declaration: room size and the number range of each player field. */
|
|
95
|
+
/** The page's declaration: room size and the number range of each player field, optionally with its top speed: [lo, hi] or [lo, hi, maxPerSecond]. */
|
|
92
96
|
/** saveVersion: the game's save format (whole number, default 1); a page edited to read another shape raises it (README: saved games). */
|
|
97
|
+
export type Range = [number, number] | [number, number, number];
|
|
93
98
|
export interface Spec {
|
|
94
99
|
max: number;
|
|
95
|
-
player: Record<string,
|
|
100
|
+
player: Record<string, Range>;
|
|
96
101
|
save: boolean;
|
|
97
102
|
saveVersion: number;
|
|
98
103
|
}
|
|
104
|
+
/** Headroom on a declared top speed, so an honest player moving at full speed whose update was clamped once catches up again. */
|
|
105
|
+
export declare const SPEED_SLACK = 1.1;
|
|
99
106
|
export interface PlayerView {
|
|
100
107
|
id: string;
|
|
101
108
|
name: string;
|
|
@@ -161,6 +168,11 @@ interface Seat {
|
|
|
161
168
|
profAt: number;
|
|
162
169
|
/** JSON size of state, kept up to date by each write (an exact count near the limit and every 256 writes) */
|
|
163
170
|
stateBytes: number;
|
|
171
|
+
/** fields with a top speed: the last value let through and its sample time (kept when the field is removed, so null then a far value is no teleport) */
|
|
172
|
+
moved?: Map<string, {
|
|
173
|
+
v: number;
|
|
174
|
+
t: number;
|
|
175
|
+
}>;
|
|
164
176
|
}
|
|
165
177
|
interface Room {
|
|
166
178
|
key: string;
|
|
@@ -427,6 +439,7 @@ export declare class RoomHub {
|
|
|
427
439
|
private sessions;
|
|
428
440
|
pending: number;
|
|
429
441
|
private perAddr;
|
|
442
|
+
private addrHits;
|
|
430
443
|
private joins;
|
|
431
444
|
private refused;
|
|
432
445
|
private timer;
|
|
@@ -489,10 +502,16 @@ export declare class RoomHub {
|
|
|
489
502
|
private refuse;
|
|
490
503
|
private count;
|
|
491
504
|
/** A new socket. Null = refused (the caller closes it with the code given). */
|
|
492
|
-
|
|
505
|
+
/** `counted`: the adapter already spent this socket's allowAddress before its upgrade, so it is not counted twice. */
|
|
506
|
+
connect(conn: Conn, address: string, o?: {
|
|
507
|
+
counted?: boolean;
|
|
508
|
+
}): Session | {
|
|
493
509
|
refused: number;
|
|
494
510
|
reason: string;
|
|
495
511
|
};
|
|
512
|
+
/** New-socket budget of one address (addressKey), connectsPerAddress per 10 s. Adapters may call it before costly work on an upgrade (a lookup):
|
|
513
|
+
* false = refuse now. The window map is pruned when it grows, so random addresses cannot grow it without end. */
|
|
514
|
+
allowAddress(address: string): boolean;
|
|
496
515
|
/** Meter: `n` copies of a text of `len` bytes (UTF-8, as on the wire) went out (to one room's sockets when given). */
|
|
497
516
|
sent(len: number, n: number, room?: Room | null): void;
|
|
498
517
|
receive(s: Session, text: string): void;
|
|
@@ -515,6 +534,10 @@ export declare class RoomHub {
|
|
|
515
534
|
private alive;
|
|
516
535
|
private vis;
|
|
517
536
|
private me;
|
|
537
|
+
/** Top speeds (a third number in a field's range): a declared field moves at most speed x the time since its last update (sample times, see stamp),
|
|
538
|
+
* with SPEED_SLACK headroom. A bigger jump is cut to that distance in the same direction: the others see the player run, not teleport, and the
|
|
539
|
+
* writer is not refused (a game that moves a player to a far spot, a respawn, shows a quick run there). No top speed declared: no change. */
|
|
540
|
+
private speedCap;
|
|
518
541
|
private sharedWrite;
|
|
519
542
|
/** The sender's sample time of a write (its shared clock, whole ms), trusted within 500 ms back and 50 ms ahead; else now. Pages draw others
|
|
520
543
|
* at these times, so updates sent 80 ms apart are drawn 80 ms apart whatever the fan-out tick did to them. */
|
package/dist/hub.js
CHANGED
|
@@ -19,11 +19,24 @@ export const CLOSE = {
|
|
|
19
19
|
restart: 1012, roomError: 1011, busy: 1013, silent: 4009, notAllowed: 4001, full: 4002, replaced: 4003, tooFast: 4004, badMessage: 4005, removed: 4006, gameUpdated: 4007, tooSlow: 4008, idle: 4010,
|
|
20
20
|
};
|
|
21
21
|
export const DEFAULT_LIMITS = {
|
|
22
|
-
maxPlayers: 16, maxRooms: 500, maxRoomsPerScope: 50, maxConnections: 2000, maxPending: 200, maxPerAddress: 20, joinsPerMinute: 30,
|
|
22
|
+
maxPlayers: 16, maxRooms: 500, maxRoomsPerScope: 50, maxConnections: 2000, maxPending: 200, maxPerAddress: 20, connectsPerAddress: 40, joinsPerMinute: 30,
|
|
23
23
|
messageBytes: 16 * 1024, playerStateBytes: 2048, sharedStateBytes: 64 * 1024, stateRate: 50, sharedRate: 20, actionRate: 5, timeRate: 2,
|
|
24
24
|
pendingMs: 5000, seatHoldMs: 30_000, evictAfterMs: 10_000, awayHoldMs: 300_000, hostSilenceMs: 5000, silentMs: 15_000, acceptRate: 200, roomOutBytes: 250 * 1024, maxOutMbit: 100, actionTimeoutMs: 5000, slowBytes: 64 * 1024, dropBytes: 256 * 1024, rateBytes: 16 * 1024, rateUpMs: 10_000, listRate: 1, idleMs: 15 * 60_000, idleWarnMs: 60_000, listIdleMs: 5 * 60_000, tickMs: 50,
|
|
25
25
|
profileBytes: 16 * 1024, profileEveryMs: 3000, worldEveryMs: 5000, saveDays: 90,
|
|
26
26
|
};
|
|
27
|
+
/** The key one client is counted by: an IPv4 address as is, an IPv6 address by its first 64 bits (a home, phone or server is handed a whole /64,
|
|
28
|
+
* so per-address limits on full IPv6 addresses would not hold anyone). IPv4-mapped IPv6 counts as the IPv4 address. */
|
|
29
|
+
export function addressKey(addr) {
|
|
30
|
+
const a = String(addr || "?").toLowerCase().replace(/^::ffff:(?=\d+\.)/, "").replace(/%.*$/, "");
|
|
31
|
+
if (!a.includes(":"))
|
|
32
|
+
return a;
|
|
33
|
+
const [head, tail = ""] = a.split("::");
|
|
34
|
+
const h = head ? head.split(":") : [], t = tail ? tail.split(":") : [];
|
|
35
|
+
const groups = a.includes("::") ? [...h, ...Array(Math.max(0, 8 - h.length - t.length)).fill("0"), ...t] : h;
|
|
36
|
+
return `${groups.slice(0, 4).map((g) => (g || "0").replace(/^0+(?=.)/, "")).join(":")}::/64`;
|
|
37
|
+
}
|
|
38
|
+
/** Headroom on a declared top speed, so an honest player moving at full speed whose update was clamped once catches up again. */
|
|
39
|
+
export const SPEED_SLACK = 1.1;
|
|
27
40
|
const CODE_ABC = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no 0/O, 1/I/L
|
|
28
41
|
const CODE_RE = /^[A-HJ-NP-Z2-9]{4,6}$/;
|
|
29
42
|
const NAME_RE = /^[a-z_][a-z0-9_-]{0,23}$/i; // a leading underscore is the game shell's own (_team, _seat, _shot)
|
|
@@ -87,12 +100,14 @@ export function parseSpec(raw, maxPlayers = DEFAULT_LIMITS.maxPlayers) {
|
|
|
87
100
|
return null;
|
|
88
101
|
for (const k of keys) {
|
|
89
102
|
const range = p[k];
|
|
90
|
-
if (!NAME_RE.test(k) || BAD_KEYS.has(k) || !Array.isArray(range) || range.length !== 2)
|
|
103
|
+
if (!NAME_RE.test(k) || BAD_KEYS.has(k) || !Array.isArray(range) || (range.length !== 2 && range.length !== 3))
|
|
91
104
|
return null;
|
|
92
|
-
const [lo, hi] = range.map(Number);
|
|
105
|
+
const [lo, hi, speed] = range.map(Number);
|
|
93
106
|
if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo >= hi || Math.abs(lo) > 1e9 || Math.abs(hi) > 1e9)
|
|
94
107
|
return null;
|
|
95
|
-
|
|
108
|
+
if (range.length === 3 && (!Number.isFinite(speed) || speed <= 0 || speed > 1e9))
|
|
109
|
+
return null;
|
|
110
|
+
player[k] = range.length === 3 ? [lo, hi, speed] : [lo, hi];
|
|
96
111
|
}
|
|
97
112
|
return { max, player, save: r.save === true, saveVersion: sv };
|
|
98
113
|
}
|
|
@@ -333,6 +348,7 @@ export class RoomHub {
|
|
|
333
348
|
sessions = new Set();
|
|
334
349
|
pending = 0;
|
|
335
350
|
perAddr = new Map();
|
|
351
|
+
addrHits = new Map();
|
|
336
352
|
joins = new Map();
|
|
337
353
|
refused = {};
|
|
338
354
|
timer = null;
|
|
@@ -391,14 +407,25 @@ export class RoomHub {
|
|
|
391
407
|
refuse(why) { this.refused[why] = (this.refused[why] || 0) + 1; }
|
|
392
408
|
count(what, n = 1) { this.counts[what] = (this.counts[what] || 0) + n; }
|
|
393
409
|
/** A new socket. Null = refused (the caller closes it with the code given). */
|
|
394
|
-
|
|
410
|
+
/** `counted`: the adapter already spent this socket's allowAddress before its upgrade, so it is not counted twice. */
|
|
411
|
+
connect(conn, address, o = {}) {
|
|
395
412
|
const L = this.limits;
|
|
413
|
+
const addr = addressKey(address);
|
|
396
414
|
if (this.closing)
|
|
397
415
|
return { refused: CLOSE.restart, reason: "server restarting" };
|
|
398
416
|
if (this.sessions.size >= L.maxConnections) {
|
|
399
417
|
this.refuse("connections");
|
|
400
418
|
return { refused: CLOSE.full, reason: "server is full" };
|
|
401
419
|
}
|
|
420
|
+
// one address first: a single client flooding new sockets is cut here and never spends the server-wide budgets below that everyone shares
|
|
421
|
+
if ((this.perAddr.get(addr) || 0) >= L.maxPerAddress) {
|
|
422
|
+
this.refuse("address");
|
|
423
|
+
return { refused: CLOSE.tooFast, reason: "too many connections" };
|
|
424
|
+
}
|
|
425
|
+
if (!o.counted && !this.allowAddress(addr)) {
|
|
426
|
+
this.refuse("address_rate");
|
|
427
|
+
return { refused: CLOSE.busy, reason: "too many new connections, try again" };
|
|
428
|
+
}
|
|
402
429
|
// busy (not full): the page tries again with its normal backoff, so a reconnect wave after a restart spreads out instead of ending on a "full" screen
|
|
403
430
|
if (this.pending >= L.maxPending) {
|
|
404
431
|
this.refuse("pending");
|
|
@@ -413,16 +440,29 @@ export class RoomHub {
|
|
|
413
440
|
this.refuse("accept_rate");
|
|
414
441
|
return { refused: CLOSE.busy, reason: "server is busy, try again" };
|
|
415
442
|
}
|
|
416
|
-
if ((this.perAddr.get(addr) || 0) >= L.maxPerAddress) {
|
|
417
|
-
this.refuse("address");
|
|
418
|
-
return { refused: CLOSE.tooFast, reason: "too many connections" };
|
|
419
|
-
}
|
|
420
443
|
const s = new Session(this, conn, addr);
|
|
421
444
|
this.sessions.add(s);
|
|
422
445
|
this.pending++;
|
|
423
446
|
this.perAddr.set(addr, (this.perAddr.get(addr) || 0) + 1);
|
|
424
447
|
return s;
|
|
425
448
|
}
|
|
449
|
+
/** New-socket budget of one address (addressKey), connectsPerAddress per 10 s. Adapters may call it before costly work on an upgrade (a lookup):
|
|
450
|
+
* false = refuse now. The window map is pruned when it grows, so random addresses cannot grow it without end. */
|
|
451
|
+
allowAddress(address) {
|
|
452
|
+
const key = addressKey(address), now = this.now();
|
|
453
|
+
let w = this.addrHits.get(key);
|
|
454
|
+
if (!w || now - w.at >= 10_000) {
|
|
455
|
+
if (this.addrHits.size >= 50_000)
|
|
456
|
+
for (const [k, v] of this.addrHits)
|
|
457
|
+
if (now - v.at >= 10_000)
|
|
458
|
+
this.addrHits.delete(k);
|
|
459
|
+
if (this.addrHits.size >= 50_000)
|
|
460
|
+
return false; // 50,000 addresses inside 10 s: refuse newcomers until the window turns
|
|
461
|
+
w = { at: now, n: 0 };
|
|
462
|
+
this.addrHits.set(key, w);
|
|
463
|
+
}
|
|
464
|
+
return ++w.n <= this.limits.connectsPerAddress;
|
|
465
|
+
}
|
|
426
466
|
/** Meter: `n` copies of a text of `len` bytes (UTF-8, as on the wire) went out (to one room's sockets when given). */
|
|
427
467
|
sent(len, n, room) {
|
|
428
468
|
this.meter.framesOut += n;
|
|
@@ -817,7 +857,8 @@ export class RoomHub {
|
|
|
817
857
|
if (range && v !== null && (typeof v !== "number" || v < range[0] || v > range[1]))
|
|
818
858
|
return this.err(s, "out_of_range", k);
|
|
819
859
|
}
|
|
820
|
-
const
|
|
860
|
+
const at = this.stamp(m.c);
|
|
861
|
+
const ch = changedOnly(seat.state, this.speedCap(room, seat, p, at));
|
|
821
862
|
if (!ch)
|
|
822
863
|
return; // nothing changed: nothing applied, counted or sent
|
|
823
864
|
const size = this.sized(seat.stateBytes + sizeDelta(seat.state, ch), this.limits.playerStateBytes, () => applyPatch(seat.state, ch), room);
|
|
@@ -829,7 +870,33 @@ export class RoomHub {
|
|
|
829
870
|
const pend = room.pendPlayers.get(seat.id) ?? {};
|
|
830
871
|
joinPatch(pend, ch);
|
|
831
872
|
room.pendPlayers.set(seat.id, pend);
|
|
832
|
-
room.pendPT.set(seat.id,
|
|
873
|
+
room.pendPT.set(seat.id, at);
|
|
874
|
+
}
|
|
875
|
+
/** Top speeds (a third number in a field's range): a declared field moves at most speed x the time since its last update (sample times, see stamp),
|
|
876
|
+
* with SPEED_SLACK headroom. A bigger jump is cut to that distance in the same direction: the others see the player run, not teleport, and the
|
|
877
|
+
* writer is not refused (a game that moves a player to a far spot, a respawn, shows a quick run there). No top speed declared: no change. */
|
|
878
|
+
speedCap(room, seat, p, at) {
|
|
879
|
+
let out = p;
|
|
880
|
+
for (const [k, v] of Object.entries(p)) {
|
|
881
|
+
const speed = room.spec.player[k]?.[2];
|
|
882
|
+
if (speed === undefined || typeof v !== "number")
|
|
883
|
+
continue;
|
|
884
|
+
const moved = (seat.moved ??= new Map());
|
|
885
|
+
const last = moved.get(k);
|
|
886
|
+
let w = v;
|
|
887
|
+
if (last) {
|
|
888
|
+
const reach = speed * SPEED_SLACK * Math.max(0, at - last.t) / 1000;
|
|
889
|
+
if (Math.abs(v - last.v) > reach) {
|
|
890
|
+
w = last.v + Math.sign(v - last.v) * reach;
|
|
891
|
+
if (out === p)
|
|
892
|
+
out = { ...p };
|
|
893
|
+
out[k] = w;
|
|
894
|
+
this.refuse("speed");
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
moved.set(k, { v: w, t: Math.max(at, last?.t ?? at) });
|
|
898
|
+
}
|
|
899
|
+
return out;
|
|
833
900
|
}
|
|
834
901
|
sharedWrite(s, room, seat, m) {
|
|
835
902
|
if (room.host !== seat)
|
|
@@ -942,6 +1009,8 @@ export class RoomHub {
|
|
|
942
1009
|
beginMatch(room, resume) {
|
|
943
1010
|
room.startWait = null;
|
|
944
1011
|
room.noSave = false;
|
|
1012
|
+
for (const seat of room.seats.values())
|
|
1013
|
+
seat.moved = undefined; // a new match: players may jump to their start spots once
|
|
945
1014
|
if (resume && room.saved) {
|
|
946
1015
|
// Continue: the saved world becomes the shared state (the shell's clock key excepted); the game's setup must not overwrite it
|
|
947
1016
|
this.flush(room);
|