@homie-rocks/studio 0.48.0 → 0.48.1
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 +52 -0
- package/guides/game/RULES.md +49 -4
- package/guides/game/SKILL.md +9 -0
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +42 -0
- package/netplay/NETPLAY.md +20 -0
- package/package.json +1 -1
- package/rules/core.ts +49 -2
- package/rules/host.ts +37 -9
- package/rules/rules.ts +4 -3
- package/rules/types.ts +1 -1
- package/rules/view.ts +121 -40
- package/worker/build-id.mjs +1 -1
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,58 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.48.1] - 2026-10-13
|
|
21
|
+
|
|
22
|
+
**Plugin 0.48.1** · Input history under loss.
|
|
23
|
+
|
|
24
|
+
Rules views recover missing input and acknowledge late movement scheduling.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Snapshots that precede a predicted input's application receipt retain the live
|
|
29
|
+
movement prediction within the replay window; explicit placements still apply.
|
|
30
|
+
- Prediction clock steering is limited to 0.5% instead of 5%; it slews without
|
|
31
|
+
repeatedly rebasing stamped input when the jitter estimate changes.
|
|
32
|
+
- Rules input frames repeat a bounded history sized from measured latency and
|
|
33
|
+
jitter and missing application receipts, with input sequence numbers and sub-tick control changes. Snapshot
|
|
34
|
+
control rows report application ticks, including pulses. Retransmissions no
|
|
35
|
+
longer distort latency steering. A newer tick acknowledgement cannot stop
|
|
36
|
+
retries for an earlier missing input; only its own receipt does.
|
|
37
|
+
- Late movement replays up to one second of body history against historical
|
|
38
|
+
geometry, fenced by authoritative body writes and placements. Outside that
|
|
39
|
+
history an entry applies next tick. Entries with press handlers also apply
|
|
40
|
+
next tick, so historical replay cannot duplicate world actions. New views use application receipts instead
|
|
41
|
+
of negative held-input compensation; old wire entries retain their behaviour.
|
|
42
|
+
- Small visual corrections use `room.predict.blendMs` and expire within one
|
|
43
|
+
second (or an explicitly longer blend), including while stationary; `idleHold` no longer holds them indefinitely. Placement counters
|
|
44
|
+
still distinguish respawns from prediction corrections. Vertical travel cannot
|
|
45
|
+
erase a horizontal correction fast enough to reverse the held direction.
|
|
46
|
+
Remaining offsets drain over the final 250 ms rather than snapping at expiry.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- `room.input(sample, {at})` timestamps a control inside the current predicted
|
|
51
|
+
tick. `player.pulses: 'hold'` queues actions while control is taken;
|
|
52
|
+
`'drop'` remains the default.
|
|
53
|
+
- Seeded virtual-time coverage of 40/80/150/300 ms each way and 0.5/1/2/5% loss,
|
|
54
|
+
dropped inputs and ordered stall/burst delivery, in small rooms and through Gates.
|
|
55
|
+
- A seeded baked-physics-world regression at 80 ms each way and 2% loss checks
|
|
56
|
+
zero drawn warps, application receipts, 300 Hz substeps, predictor room reads
|
|
57
|
+
and exact settling for two moving bodies.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- Multiple pulse calls within one predicted tick queue instead of overwriting
|
|
62
|
+
one another. Their application ticks are distinct from the movement entry.
|
|
63
|
+
- Placements preserve the established prediction-clock phase, and the snapshot
|
|
64
|
+
body is advanced to its predicted tick instead of relabelling an old pose.
|
|
65
|
+
Projection is bounded to the server's one-second input window, and its controls
|
|
66
|
+
are stamped for that same path. Queued held state from an earlier placement
|
|
67
|
+
cannot later change the respawned body; discrete presses received before the
|
|
68
|
+
browser learns of the placement still belong to the player.
|
|
69
|
+
- A fresh control after a browser stall is recorded after resetting the stale
|
|
70
|
+
prediction clock, preventing a short press from moving by the stalled backlog.
|
|
71
|
+
|
|
20
72
|
## [0.48.0] - 2026-10-13
|
|
21
73
|
|
|
22
74
|
**Plugin 0.48.0** · Baked physics worlds for server rules and local prediction.
|
package/guides/game/RULES.md
CHANGED
|
@@ -1221,10 +1221,8 @@ occupied terrain nodes out of the graph, and choose reachable endpoints.
|
|
|
1221
1221
|
`@homie-rocks/nav` is the richer mesh/crowd API for applications that own that
|
|
1222
1222
|
runtime; rules graphs avoid importing mutable navigation state into handlers.
|
|
1223
1223
|
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
still snap to authority. Opt in only after measuring stop/reversal and delayed
|
|
1227
|
-
collision behavior for the game's mover.
|
|
1224
|
+
Small reconciliation offsets expire even while stationary; see “Input during loss
|
|
1225
|
+
and stalls” below for the visual correction settings.
|
|
1228
1226
|
|
|
1229
1227
|
For multi-cast movement or scratch trajectory checks, `ctx.world.sweep(body, delta, {ground:false})` (also captured-scene `sweep`) moves and returns the same collision but leaves `body.grounded` unchanged. Use it only when your mover computes support explicitly. Omitting the option retains the existing support query and grounding behavior. This avoids paying for identical support checks after horizontal and upward probes.
|
|
1230
1228
|
|
|
@@ -1385,3 +1383,50 @@ creates the same baked world and replays pending guarded movement against it;
|
|
|
1385
1383
|
substep drawing traces remain temporary. Physics worlds are disposed when their
|
|
1386
1384
|
host or view closes. A full solver save can exceed browser-host checkpoint caps;
|
|
1387
1385
|
the build measures this, and server-hosted rooms retain the full save.
|
|
1386
|
+
|
|
1387
|
+
## Input during loss and stalls (0.48.1)
|
|
1388
|
+
|
|
1389
|
+
Rules views resend recent input automatically: a 4–64 tick history follows the
|
|
1390
|
+
measured round trip, jitter and missing application receipts (loss or recovery
|
|
1391
|
+
delay). Each tick entry carries a sequence number; each
|
|
1392
|
+
control change carries a field index, sequence and fraction of the predicted tick.
|
|
1393
|
+
Duplicates neither execute again nor count as fresh latency measurements. Both
|
|
1394
|
+
missing application frames and ordered TCP stalls are covered.
|
|
1395
|
+
|
|
1396
|
+
The server retains up to one second of movement history and replays late movement
|
|
1397
|
+
against the geometry of those ticks. It never repeats commands or effects. An
|
|
1398
|
+
external body/motion write or placement fences that history. Beyond a fence or
|
|
1399
|
+
the retained history, input applies on the next tick instead. Entries carrying
|
|
1400
|
+
press handlers also apply next tick: world actions are never rerun as movement. Snapshot control
|
|
1401
|
+
rows report the application tick and the view uses it for replay. Within the
|
|
1402
|
+
replay window a snapshot still missing a predicted input does not replace live
|
|
1403
|
+
movement; an explicit placement always does. Legacy wire
|
|
1404
|
+
entries retain their old compensation; current views do not receive negative
|
|
1405
|
+
held values to repay late releases.
|
|
1406
|
+
|
|
1407
|
+
Held controls integrate their duration, sampled controls coalesce to the latest
|
|
1408
|
+
value per tick, presses coalesce per field per tick, and valued pulses execute
|
|
1409
|
+
in order, one per tick. More sampled input never buys extra simulation steps.
|
|
1410
|
+
The clock resets before a fresh control after a long browser stall, so the press
|
|
1411
|
+
cannot inherit ten seconds of elapsed time. The prediction clock slews by at
|
|
1412
|
+
most 0.5%, preserving the cadence for fixed-step renderers.
|
|
1413
|
+
|
|
1414
|
+
`room.input(sample, {at: event.timeStamp})` accepts the event's monotonic time
|
|
1415
|
+
(same origin as `performance.now()`), clamped to this predicted tick and now.
|
|
1416
|
+
Omit it to use the call time. A player can declare
|
|
1417
|
+
`player: {control: 'canMove', pulses: 'hold'}` to keep pulses during a knock or
|
|
1418
|
+
stun and deliver them when control returns. The default `'drop'` preserves the
|
|
1419
|
+
previous behaviour. The game's action handler still decides its cooldown.
|
|
1420
|
+
|
|
1421
|
+
Residual drawing corrections use `room.predict.blendMs` (100 ms by default),
|
|
1422
|
+
slowed on each moving axis to avoid reversing movement (jumping cannot spend
|
|
1423
|
+
horizontal correction faster), with a one-second maximum
|
|
1424
|
+
(or the explicitly longer blendMs). Any remainder drains over the final 250 ms
|
|
1425
|
+
rather than disappearing on the deadline. `snapM` selects the large-error threshold;
|
|
1426
|
+
placements still snap. A placement keeps the established clock phase and
|
|
1427
|
+
projects the new body from the snapshot tick to the own tick; it never labels an
|
|
1428
|
+
old pose as a future one. Projection is bounded to one second and sends the
|
|
1429
|
+
controls it used. Queued held state from an earlier placement cannot later
|
|
1430
|
+
change the new body. Newly received discrete presses still belong to the player
|
|
1431
|
+
when the browser has not yet learned of that placement. `idleHold` no longer keeps a stopped body displaced
|
|
1432
|
+
indefinitely. These drawing settings never alter server truth.
|
package/guides/game/SKILL.md
CHANGED
|
@@ -469,3 +469,12 @@ save contract are in that section. Prefer kernel collision for its primitive
|
|
|
469
469
|
maps; use physics for arbitrary static triangle levels. Initialization is awaited
|
|
470
470
|
before the first room tick, calls in handlers are synchronous, and the complete
|
|
471
471
|
physics snapshot is saved with the room. No opt-in means no physics load or cost.
|
|
472
|
+
|
|
473
|
+
### Input under loss
|
|
474
|
+
|
|
475
|
+
Rules games inherit redundant sequenced input, late movement replay and applied-tick
|
|
476
|
+
receipts. Do not add a game-side resend loop or interpret negative held values as
|
|
477
|
+
late-release repayment. Use `f.held()` for duration, sampled fields for latest
|
|
478
|
+
state and `f.pulse()` for ordered actions. Choose `player.pulses: 'hold'` when a
|
|
479
|
+
knock should buffer an action; the default drops it. See RULES.md, “Input during
|
|
480
|
+
loss and stalls”, for timestamps, replay boundaries and visual correction settings.
|
package/lib/rules-types.mjs
CHANGED
|
@@ -119,7 +119,7 @@ export type Room<R = never> = Omit<BaseRoom<R>, 'me' | 'each' | 'get' | 'on' | '
|
|
|
119
119
|
each<K extends keyof Entities>(kind: K, fn: (entity: Entities[K]) => void): void;
|
|
120
120
|
get(id: string): Entity | null;
|
|
121
121
|
on<K extends keyof Effects>(name: K, fn: (event: Effects[K]) => void): () => void;
|
|
122
|
-
input(sample: ${input}): void;
|
|
122
|
+
input(sample: ${input}, options?: {at?: number}): void;
|
|
123
123
|
command<K extends keyof Commands>(name: K, ...data: {} extends Commands[K] ? [data?: Commands[K]] : [data: Commands[K]]): void;
|
|
124
124
|
};
|
|
125
125
|
export declare function openRoom<R = never>(options?: OpenRoomOptions): Room<R>;
|
|
@@ -2985,6 +2985,48 @@
|
|
|
2985
2985
|
}
|
|
2986
2986
|
}
|
|
2987
2987
|
}
|
|
2988
|
+
},
|
|
2989
|
+
"0.48.1": {
|
|
2990
|
+
"files": {
|
|
2991
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2992
|
+
"README.md": "684b3cca8001673c",
|
|
2993
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2994
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2995
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2996
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2997
|
+
"posts/README.md": "37841aae476bd540",
|
|
2998
|
+
"site/README.md": "732894273a32dc3f",
|
|
2999
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3000
|
+
},
|
|
3001
|
+
"sections": {
|
|
3002
|
+
"AGENTS.md": {
|
|
3003
|
+
"(intro)": "394cbbe934e80722",
|
|
3004
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3005
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3006
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
3007
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
3008
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3009
|
+
"## Making games": "bb715272fd1a615b",
|
|
3010
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3011
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3012
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3013
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3014
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
3015
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3016
|
+
"## Rules": "27881d11d3ff3803",
|
|
3017
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3018
|
+
"## Beta": "9d02638207a26e73"
|
|
3019
|
+
}
|
|
3020
|
+
},
|
|
3021
|
+
"named": {
|
|
3022
|
+
"Homie Arcade": {
|
|
3023
|
+
"sections": {
|
|
3024
|
+
"AGENTS.md": {
|
|
3025
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
2988
3030
|
}
|
|
2989
3031
|
}
|
|
2990
3032
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -2329,3 +2329,23 @@ private round imports no room save, match score or simulation state into the onl
|
|
|
2329
2329
|
room. When a body is assigned, the private round closes and the view joins the
|
|
2330
2330
|
online match. Ember Vale retains its separate cloud hero: experience earned in
|
|
2331
2331
|
solo play still belongs to that hero, whose level is sent through the usual command.
|
|
2332
|
+
|
|
2333
|
+
### Rules input history trailer (studio 0.47.1)
|
|
2334
|
+
|
|
2335
|
+
A rules step keeps its `[tickOffset, ...fields, ...ownerClaim]` prefix. Current
|
|
2336
|
+
views append `[-471, sequence, changeCount, ...changes]`, where each change is
|
|
2337
|
+
`[fieldIndex, changeSequence, fractionOfTick, value]`. All components are numbers.
|
|
2338
|
+
Input frames repeat recent entries, sorted by tick; the retained window is
|
|
2339
|
+
4–64 ticks based on measured authority RTT and jitter. The host deduplicates
|
|
2340
|
+
entry sequences, including history received after a missing frame. Duplicate
|
|
2341
|
+
history is not a fresh lead observation. Frames without the trailer keep the
|
|
2342
|
+
legacy input semantics.
|
|
2343
|
+
|
|
2344
|
+
Control rows keep their first six columns and append
|
|
2345
|
+
`[471, sequence, appliedTick, ...]`. Positive sequences identify input entries;
|
|
2346
|
+
negative sequences identify the independently numbered reliable pulse stream.
|
|
2347
|
+
The last 64 receipts repeat in snapshots, including projected Gate snapshots.
|
|
2348
|
+
An on-time entry names its predicted tick; a safely rewound movement entry names
|
|
2349
|
+
that historical tick; an entry beyond retained movement history or an authority
|
|
2350
|
+
boundary names the next tick. Entry receipts describe coalesced per-tick input;
|
|
2351
|
+
command/effect handlers are not rerun in the past.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.48.
|
|
3
|
+
"version": "0.48.1",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/rules/core.ts
CHANGED
|
@@ -158,6 +158,7 @@ export interface Core {
|
|
|
158
158
|
readonly tick: number;
|
|
159
159
|
readonly epoch: number;
|
|
160
160
|
step(inputs?: ReadonlyMap<number, StepInput>, guides?: () => void): void;
|
|
161
|
+
reviseMovement(seat: number, at: number, values: Record<string, unknown>): boolean;
|
|
161
162
|
guide(seat: number, floor?: unknown): Record<string, unknown> | null;
|
|
162
163
|
vocabulary(value: Vocabulary | null): void;
|
|
163
164
|
goal(seat: number, value?: unknown): unknown;
|
|
@@ -177,7 +178,7 @@ export interface Core {
|
|
|
177
178
|
listeners(): { seat: number; id: string; pos: Vec3 }[];
|
|
178
179
|
/** The body in a seat, as the host runtime needs it for the input protocol. */
|
|
179
180
|
character(seat: number): import('./rules.ts').Character | null;
|
|
180
|
-
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
|
|
181
|
+
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; controlled: boolean; driver: Driver } | null;
|
|
181
182
|
/** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
|
|
182
183
|
drain(): CoreOut[];
|
|
183
184
|
save(): SavedCore;
|
|
@@ -1091,7 +1092,46 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1091
1092
|
// Inside the cone: the angle to its axis is at most half its opening, compared by cosine.
|
|
1092
1093
|
return (dx * a.dir.x + dy * a.dir.y + dz * a.dir.z) / l >= exact.cos(a.half);
|
|
1093
1094
|
}
|
|
1095
|
+
// Movement-only rewind never repeats effects or commands. External body writes fence history.
|
|
1096
|
+
type MovementState = {pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown>};
|
|
1097
|
+
type MovementFrame = {tick: number; r: number; before: MovementState; after: MovementState; input: Readonly<Record<string, unknown>>; geometry: typeof moveGeometry; map: typeof currentMap};
|
|
1098
|
+
const movementHistory = new Map<string, MovementFrame[]>();
|
|
1099
|
+
const movementState = (e: Ent): MovementState => ({pos:e.pos,vel:e.vel,heading:e.heading,grounded:e.grounded,motion:{...e.m}});
|
|
1100
|
+
const sameMovement = (a: MovementState,b: MovementState): boolean => JSON.stringify(a) === JSON.stringify(b);
|
|
1101
|
+
function putMovement(e: Ent, b: MovementState): void {
|
|
1102
|
+
e.pos=b.pos;e.vel=b.vel;e.heading=b.heading;e.grounded=b.grounded;
|
|
1103
|
+
Object.assign(e.m,b.motion);
|
|
1104
|
+
}
|
|
1105
|
+
function reviseMovement(seat: number, at: number, values: Record<string, unknown>): boolean {
|
|
1106
|
+
const id=seats.get(seat)?.id, e=id?ents.get(id):null;
|
|
1107
|
+
if(!e || !e.kind.move || e.kind.body?.owner || e.away || e.driver!=='person') return false;
|
|
1108
|
+
const frames=movementHistory.get(e.id), start=frames?.findIndex(f=>f.tick===at)??-1;
|
|
1109
|
+
if(!frames || start<0 || frames.at(-1)!.tick!==tick || frames[start].r!==e.r || !sameMovement(frames.at(-1)!.after,movementState(e)))return false;
|
|
1110
|
+
const control=e.kind.player?.control;
|
|
1111
|
+
if(control && frames.slice(start).some(f=>!f.before.motion[control] || !f.after.motion[control]))return false;
|
|
1112
|
+
for(let i=start+1;i<frames.length;i++)if(frames[i].r!==e.r || !sameMovement(frames[i-1].after,frames[i].before))return false;
|
|
1113
|
+
const saved=movementState(e), savedTick=tick, geometry=moveGeometry, map=currentMap;
|
|
1114
|
+
const old=frames.map(f=>({...f}));
|
|
1115
|
+
try {
|
|
1116
|
+
putMovement(e,frames[start].before);
|
|
1117
|
+
for(let i=start;i<frames.length;i++){
|
|
1118
|
+
const f=frames[i];tick=f.tick;moveGeometry=f.geometry;currentMap=f.map;moveShape=e.kind.body!;
|
|
1119
|
+
f.before=movementState(e);
|
|
1120
|
+
// An entry holds until the next explicitly different input.
|
|
1121
|
+
if(i===start || JSON.stringify(old[i].input)===JSON.stringify(old[i-1].input))f.input=Object.freeze(coerceFields(e.kind.input,values,dims));else values={...f.input};
|
|
1122
|
+
const b={pos:e.pos,vel:e.vel,heading:e.heading,grounded:e.grounded,motion:e.mself};
|
|
1123
|
+
const errors=stats.errors, stops=stats.budgetStops;
|
|
1124
|
+
if(!run(e.kind.name,'move',e,'move',()=>e.kind.move!(b,f.input,moveCtx),'body'))throw new Error('rewind budget');
|
|
1125
|
+
if(stats.errors!==errors || stats.budgetStops!==stops)throw new Error('rewind failed');
|
|
1126
|
+
e.pos=clampIn(V(own(b,'pos')),e.kind.body!.radius,e.kind.body!.height||2*e.kind.body!.radius);e.vel=V(own(b,'vel'));e.heading=dir(own(b,'heading'),dims);e.grounded=own(b,'grounded')===true;
|
|
1127
|
+
f.after=movementState(e);
|
|
1128
|
+
}
|
|
1129
|
+
return true;
|
|
1130
|
+
} catch {putMovement(e,saved);movementHistory.set(e.id,old);return false;}
|
|
1131
|
+
finally {tick=savedTick;moveGeometry=geometry;currentMap=map;}
|
|
1132
|
+
}
|
|
1094
1133
|
function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
|
|
1134
|
+
for(const id of movementHistory.keys())if(!ents.has(id))movementHistory.delete(id);
|
|
1095
1135
|
tick = (tick + 1) >>> 0;
|
|
1096
1136
|
if(pendingMap&&pendingMap[0]<=tick){currentMap=pendingGeometry!;mapRevision=pendingMap;currentMapCells=pendingMapCells;mapCells=currentMapCells;pendingMapCells=0;pendingMap=null;pendingGeometry=null;moveGeometry=currentMap;}
|
|
1097
1137
|
// The tick pays first for the state it sends: what the last snapshot packed. A room that holds more than a tick can send has nothing left for its handlers, and ends as any room does whose budget trips on every tick.
|
|
@@ -1205,6 +1245,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1205
1245
|
}
|
|
1206
1246
|
if (!k.move) continue;
|
|
1207
1247
|
const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
|
|
1248
|
+
const rewindBefore = k.player && e.driver === 'person' && !e.away ? movementState(e) : null;
|
|
1208
1249
|
const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
|
|
1209
1250
|
moveShape = body;
|
|
1210
1251
|
run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
|
|
@@ -1215,6 +1256,11 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1215
1256
|
naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius, body.height || 2 * body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
|
|
1216
1257
|
G.note = null;
|
|
1217
1258
|
e.grounded = own(b, 'grounded') === true;
|
|
1259
|
+
if (rewindBefore) {
|
|
1260
|
+
let frames=movementHistory.get(e.id);if(!frames){frames=[];movementHistory.set(e.id,frames);}
|
|
1261
|
+
frames.push({tick,r:e.r,before:rewindBefore,after:movementState(e),input:e.input,geometry:moveGeometry,map:currentMap});
|
|
1262
|
+
while(frames.length>tickHz)frames.shift();
|
|
1263
|
+
}
|
|
1218
1264
|
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } }, moveGeometry, e.id);
|
|
1219
1265
|
}
|
|
1220
1266
|
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
@@ -1492,6 +1538,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1492
1538
|
return {
|
|
1493
1539
|
round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
|
|
1494
1540
|
get tick() { return tick; },
|
|
1541
|
+
reviseMovement,
|
|
1495
1542
|
get epoch() { return epoch; },
|
|
1496
1543
|
step,
|
|
1497
1544
|
answer: (n, result) => { const a = pendingAsks.find((x) => x.n === n); if (a && !a.result) a.result = decisionResult(result); },
|
|
@@ -1568,7 +1615,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1568
1615
|
character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
|
|
1569
1616
|
bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
|
|
1570
1617
|
listeners: () => playerBodies().filter((e) => e.driver === 'person').map((e) => ({ seat: e.seat, id: e.id, pos: e.pos })),
|
|
1571
|
-
bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
|
|
1618
|
+
bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, controlled: !e.kind.player?.control || Boolean(e.m[e.kind.player.control]), driver: e.driver } : null; },
|
|
1572
1619
|
drain: () => { const o = out; out = []; return o; },
|
|
1573
1620
|
save,
|
|
1574
1621
|
dispose: () => physics?.dispose(),
|
package/rules/host.ts
CHANGED
|
@@ -135,8 +135,11 @@ export interface Host {
|
|
|
135
135
|
readonly core: Core;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
|
|
138
|
+
interface Entry { placement?: number; source?: number; at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
|
|
139
139
|
interface SeatIn {
|
|
140
|
+
heldAt?: number;
|
|
141
|
+
receipts?: [number, number][];
|
|
142
|
+
received?: number[];
|
|
140
143
|
/** A valued pulse, and the tick its browser predicted it on (0: none given, act at once). */
|
|
141
144
|
pulses?: [string,number,number?,number?][];
|
|
142
145
|
/** The wait of the entry now held, in ticks (see Entry.early). */
|
|
@@ -259,9 +262,11 @@ export function createHost(o: HostOptions): Host {
|
|
|
259
262
|
check(Array.isArray(saved.inputs) && saved.inputs.length <= c.seats);
|
|
260
263
|
for (const [seat, q] of saved.inputs) {
|
|
261
264
|
check(uint(seat) && seat < c.seats && q && valuesOk(seat, q.held) && claimOk(q.claim) && uint(q.newest) && uint(q.ack) && uint(q.lastFrameTick) && Number.isInteger(q.lead) && q.lead >= -127 && q.lead <= 127);
|
|
265
|
+
if(q.received!==undefined)check(Array.isArray(q.received)&&q.received.length<=256&&q.received.every(uint));
|
|
266
|
+
if(q.receipts!==undefined)check(Array.isArray(q.receipts)&&q.receipts.length<=64&&q.receipts.every(r=>Array.isArray(r)&&r.length===2&&Number.isSafeInteger(r[0])&&r[0]!==0&&uint(r[1])));
|
|
262
267
|
check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
|
|
263
268
|
if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&(p.length===2||p.length>=3&&p.length<=4&&uint(p[2])&&(p[3]===undefined||uint(p[3])))&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
|
|
264
|
-
for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
|
|
269
|
+
for (const e of q.entries) check((e.placement === undefined || uint(e.placement)) && uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
|
|
265
270
|
}
|
|
266
271
|
}
|
|
267
272
|
}
|
|
@@ -474,8 +479,8 @@ export function createHost(o: HostOptions): Host {
|
|
|
474
479
|
stats.ins += 1;
|
|
475
480
|
q.lastFrameTick = K;
|
|
476
481
|
// Lead: from this frame's arrival to the moment its first tick was due, in sixteenths of a tick. Negative is late.
|
|
477
|
-
const lead = Math.max(-127, Math.min(126, Math.round(((base.ms + (k - base.tick) * period - o.clock.now()) / period) * 16)));
|
|
478
|
-
|
|
482
|
+
const lead = Math.max(-127, Math.min(126, Math.round(((base.ms + (k + num(m.s.at(-1)?.[0]) - base.tick) * period - o.clock.now()) / period) * 16)));
|
|
483
|
+
|
|
479
484
|
const r = num(m.r) & 0xffff;
|
|
480
485
|
let before = -1;
|
|
481
486
|
for (const raw of (m.s as unknown[]).slice(0, 64)) {
|
|
@@ -485,10 +490,18 @@ export function createHost(o: HostOptions): Host {
|
|
|
485
490
|
before = off;
|
|
486
491
|
const j = (k + off) >>> 0;
|
|
487
492
|
// A duplicate: not above the newest stamp this seat has sent. It still counted as the player being there.
|
|
488
|
-
|
|
493
|
+
const marker = 1 + kind.input.length + (kind.body?.owner ? 9 : 0);
|
|
494
|
+
const seq = raw[marker] === -471 && Number.isSafeInteger(raw[marker+1]) && raw[marker+1] > 0 ? raw[marker+1] as number : 0;
|
|
495
|
+
// A discrete action sent before the client learns of a placement still
|
|
496
|
+
// belongs to its player. Held state from that old placement does not.
|
|
497
|
+
const action = kind.input.some(([, fd], index) => fd.t === 'press' && raw[index + 1] === 1);
|
|
498
|
+
if (seq && r !== body.r && !action) continue;
|
|
499
|
+
if (seq ? q.received?.includes(seq) : j <= q.newest) continue;
|
|
489
500
|
// Too early: more than a second ahead. This entry and the rest of the frame are dropped.
|
|
490
501
|
if (j > K + tickHz) { stats.dropped += 1; break; }
|
|
491
|
-
q.newest =
|
|
502
|
+
if(j >= q.newest && lead < q.lead)q.lead=lead;
|
|
503
|
+
q.newest = Math.max(q.newest,j);
|
|
504
|
+
if(seq){q.received??=[];q.received.push(seq);if(q.received.length>256)q.received.shift();}
|
|
492
505
|
const values: Record<string, unknown> = {};
|
|
493
506
|
const presses: (string | [string, number])[] = [];
|
|
494
507
|
let i = 1;
|
|
@@ -499,9 +512,22 @@ export function createHost(o: HostOptions): Host {
|
|
|
499
512
|
}
|
|
500
513
|
const claim = kind.body?.owner && raw.length >= i + 9 ? { pos: unpackVec([raw[i], raw[i + 1], raw[i + 2]], c.dims), vel: unpackVec([raw[i + 3], raw[i + 4], raw[i + 5]], c.dims), heading: unpackVec([raw[i + 6], raw[i + 7], raw[i + 8]], c.dims), r } : null;
|
|
501
514
|
q.stamps.push(j);
|
|
502
|
-
if
|
|
515
|
+
if(seq){q.receipts??=[];q.receipts.push([seq,j>K?j:K+1]);if(q.receipts.length>64)q.receipts.shift();}
|
|
516
|
+
if (j > K) { q.entries.push({ ...(seq ? { placement: body.r } : {}), source: j, at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
|
|
503
517
|
// Late: its values hold from the next tick, and its presses fire there if that is within a quarter of a second of its stamp.
|
|
504
518
|
stats.lateEntries += 1;
|
|
519
|
+
if(seq){
|
|
520
|
+
if(!presses.length && K-j<tickHz && core.reviseMovement(seat as number,j,values))q.receipts![q.receipts!.length-1][1]=j;
|
|
521
|
+
const at=K+1;
|
|
522
|
+
const same=q.entries.find(e=>e.at===at);
|
|
523
|
+
if(j >= (q.heldAt??0) && (!same || j >= (same.source??0))){
|
|
524
|
+
if(same){same.placement=body.r;same.source=j;same.values=values;same.claim=claim;same.presses.push(...presses);}else q.entries.push({placement:body.r,source:j,at,values,claim,presses});
|
|
525
|
+
} else if(presses.length){
|
|
526
|
+
if(same)same.presses.push(...presses);else q.entries.push({placement:body.r,source:q.heldAt,at,values:{...q.held},claim:q.claim,presses});
|
|
527
|
+
}
|
|
528
|
+
q.entries.sort((a,b)=>a.at-b.at);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
505
531
|
const book = heldBook.get(seat as number);
|
|
506
532
|
if (book) for (const row of book.ran) if (row[0] >= j) { for (const name of heldOf(kind)) { const meant = Number(values[name]) || 0; book.debt[name] = (book.debt[name] ?? 0) + meant - (row[1][name] ?? 0); row[1][name] = meant; } book.until = K + 1 + lateTicks; }
|
|
507
533
|
const at = (K + 1) >>> 0;
|
|
@@ -704,7 +730,8 @@ export function createHost(o: HostOptions): Host {
|
|
|
704
730
|
const presses: (string | [string, number])[] = [];
|
|
705
731
|
while (q.entries.length && q.entries[0].at <= t) {
|
|
706
732
|
const e = q.entries.shift() as Entry;
|
|
707
|
-
|
|
733
|
+
if (e.placement !== undefined && e.placement !== body.r) continue;
|
|
734
|
+
q.heldAt=e.source??e.at; q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
|
|
708
735
|
if (e.at === t) for (const p of e.presses) presses.push(p);
|
|
709
736
|
}
|
|
710
737
|
// Silence: no frame for a second. Its input is neutral until the next entry.
|
|
@@ -723,7 +750,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
723
750
|
}
|
|
724
751
|
for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
|
|
725
752
|
// One pulse a tick, in order: the head waits for its own tick when that is no more than a quarter of a second ahead.
|
|
726
|
-
const head=q.pulses?.[0];if(head
|
|
753
|
+
const head=q.pulses?.[0];if(head&&(body.controlled||body.kind.player?.pulses!=='hold')&&(head[2]??0)<=t){q.pulses!.shift();values[head[0]]=head[1];if(head[3]){pulsesTaken.set(seat,head[3]);q.receipts??=[];q.receipts.push([-head[3],t]);if(q.receipts.length>64)q.receipts.shift();}}
|
|
727
754
|
inputs.set(seat, { values, claim: q.claim });
|
|
728
755
|
}
|
|
729
756
|
if (roundRebasePending) publishRebase(now);
|
|
@@ -760,6 +787,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
760
787
|
// ray may look right now, so its browser can predict against the pose the server will judge, not a guess at it.
|
|
761
788
|
if (historyTicks) row.push(taken ?? 0, Math.min(historyTicks, rewindTicks(b.seat)));
|
|
762
789
|
else if (taken !== undefined) row.push(taken);
|
|
790
|
+
if(q?.receipts){while(row.length<6)row.push(0);row.push(471,...q.receipts.flat());}
|
|
763
791
|
rows.push(row);
|
|
764
792
|
if (q) q.lead = 127;
|
|
765
793
|
}
|
package/rules/rules.ts
CHANGED
|
@@ -144,7 +144,7 @@ export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radiu
|
|
|
144
144
|
export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
|
|
145
145
|
export interface RoundRecords {key:string; identity:string; eligible?:string; fields:Readonly<Record<string,string>>}
|
|
146
146
|
export interface EntityDef {
|
|
147
|
-
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; takeover?: string };
|
|
147
|
+
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; pulses?: 'drop' | 'hold'; takeover?: string };
|
|
148
148
|
fields?: Fields;
|
|
149
149
|
motion?: Fields;
|
|
150
150
|
input?: Fields;
|
|
@@ -388,7 +388,7 @@ export type FieldList = readonly (readonly [string, Field])[];
|
|
|
388
388
|
export interface KindTable {
|
|
389
389
|
name: string;
|
|
390
390
|
index: number;
|
|
391
|
-
player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot'; control?: string; takeover?: string };
|
|
391
|
+
player: null | { away: 'neutral' | 'think'; leave: 'despawn' | 'bot'; control?: string; pulses?: 'drop' | 'hold'; takeover?: string };
|
|
392
392
|
fields: FieldList;
|
|
393
393
|
motion: FieldList;
|
|
394
394
|
input: FieldList;
|
|
@@ -589,10 +589,11 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
589
589
|
if (!p || typeof p !== 'object') throw new Error(`${at}.player is true, or { away, leave }`);
|
|
590
590
|
if (p.away !== undefined && p.away !== 'neutral' && p.away !== 'think') throw new Error(`${at}.player.away is 'neutral' or 'think'`);
|
|
591
591
|
if (p.leave !== undefined && p.leave !== 'despawn' && p.leave !== 'bot') throw new Error(`${at}.player.leave is 'despawn' or 'bot'`);
|
|
592
|
+
if (p.pulses !== undefined && !['drop','hold'].includes(p.pulses)) throw new Error(`${at}.player.pulses is 'drop' or 'hold'`);
|
|
592
593
|
if (p.control !== undefined && !motion.some(([key, field]) => key === p.control && field.t === 'bit')) throw new Error(`${at}.player.control names a bit in motion`);
|
|
593
594
|
if (p.takeover !== undefined && !fields.some(([key, field]) => key === p.takeover && ['fix','i8','i16','i32','u8','u16','u32'].includes(field.t))) throw new Error(`${at}.player.takeover names a numeric field`);
|
|
594
595
|
if (p.control && typeof e.think !== 'function') throw new Error(`${at}.player.control needs think`);
|
|
595
|
-
player = { away: p.away ?? 'neutral', leave: p.leave ?? 'despawn', ...(p.control ? {control:p.control}:{}), ...(p.takeover ? {takeover:p.takeover}:{}) };
|
|
596
|
+
player = { ...(p.pulses ? {pulses:p.pulses}:{}), away: p.away ?? 'neutral', leave: p.leave ?? 'despawn', ...(p.control ? {control:p.control}:{}), ...(p.takeover ? {takeover:p.takeover}:{}) };
|
|
596
597
|
if ((player.away === 'think' || player.leave === 'bot') && typeof e.think !== 'function') throw new Error(`${at}: away: 'think' and leave: 'bot' need a think(world, self) handler to steer the body`);
|
|
597
598
|
}
|
|
598
599
|
let body: KindTable['body'] = null;
|
package/rules/types.ts
CHANGED
|
@@ -67,7 +67,7 @@ export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined:
|
|
|
67
67
|
export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
|
|
68
68
|
type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'guideLevel' | 'guideSeats' | 'kids' | 'levelSet' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
|
|
69
69
|
export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
|
|
70
|
-
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; takeover?: string }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef; query?: QueryDef;
|
|
70
|
+
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; pulses?: 'drop' | 'hold'; takeover?: string }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef; query?: QueryDef;
|
|
71
71
|
tick?: (world: W, self: S) => void;
|
|
72
72
|
think?: (world: W, self: S) => Partial<I>;
|
|
73
73
|
on?: { [K in keyof (Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })]?: (world: W, self: S, event: EventData<(Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })[K]>) => void };
|
package/rules/view.ts
CHANGED
|
@@ -150,7 +150,7 @@ export interface Room<R = unknown> extends RayQueries {
|
|
|
150
150
|
get(id: string): Entity | null;
|
|
151
151
|
on(name: string, fn: (e: any) => void): () => void;
|
|
152
152
|
readonly shared: Readonly<Record<string, unknown>>;
|
|
153
|
-
input(sample: Record<string, number | boolean
|
|
153
|
+
input(sample: Record<string, number | boolean>, options?: { at?: number }): void;
|
|
154
154
|
command(name: string, data?: Record<string, unknown>): void;
|
|
155
155
|
readonly round: RoomRound | null;
|
|
156
156
|
readonly roster: RosterRow[];
|
|
@@ -256,22 +256,29 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
256
256
|
let lastSent = '';
|
|
257
257
|
let lastSendAt = 0;
|
|
258
258
|
let entries: { t: number; row: number[] }[] = [];
|
|
259
|
+
let inputSequence = 0;
|
|
260
|
+
let controlChanges: number[][] = [];
|
|
261
|
+
let sentHistory: { t: number; row: number[] }[] = [];
|
|
259
262
|
let hiddenSent = false;
|
|
260
263
|
const history = new Map<number, Mine>();
|
|
261
|
-
let pending: { t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number> }[] = [];
|
|
264
|
+
let pending: { seq?: number; applied?: number; t: number; input: Readonly<Record<string, unknown>>; pulses?: Record<string, number>; pulseTicks?: Record<string, number> }[] = [];
|
|
262
265
|
/** Valued pulses sent, numbered: the server's control row names the newest it has acted on (0.46.3). */
|
|
263
266
|
let pulseCount = 0;
|
|
267
|
+
const pulseQueue: {name:string; value:number; n:number; t:number}[] = [];
|
|
264
268
|
let pulseNumber = new Map<string, number>();
|
|
265
269
|
let replayHeld: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
266
270
|
/** `f.held()` values as the server last had them: those of the newest entry it has acknowledged. */
|
|
267
271
|
let serverHeld: Record<string, number> = {};
|
|
268
272
|
let speed = 1;
|
|
269
273
|
let leadAfter = 0;
|
|
274
|
+
let calibratedRtt = 0;
|
|
275
|
+
let latencyCalibrated = false;
|
|
276
|
+
const applications = new Map<number, number>();
|
|
270
277
|
let observedLeadAt = 0;
|
|
271
278
|
let catchTick: number | null = null;
|
|
272
279
|
let catchAt = 0;
|
|
273
280
|
let headingBlend: { from: Vec3; at: number } | null = null;
|
|
274
|
-
let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number }[] = [];
|
|
281
|
+
let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number; end?: number }[] = [];
|
|
275
282
|
const predict = schema.settings.predict;
|
|
276
283
|
const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
|
|
277
284
|
const targetLead = (): number => 0.5 + Math.max(0, quantile(leads, 0.5) - quantile(leads, 0.05));
|
|
@@ -284,6 +291,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
284
291
|
const sum = { x: 0, y: 0, z: 0 };
|
|
285
292
|
let waiting = false;
|
|
286
293
|
for (const o of offsets) {
|
|
294
|
+
o.end ??= o.at + Math.max(1000, predict.blendMs);
|
|
295
|
+
const deadlineFade = Math.max(0, Math.min(1, (o.end - now) / 250));
|
|
287
296
|
if (path) {
|
|
288
297
|
// Loss can make even a small correction longer than the distance travelled in
|
|
289
298
|
// 100 ms. Bound its fade by actual progress, or it pulls steady movement backwards.
|
|
@@ -297,13 +306,22 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
297
306
|
// A knock may travel faster than normal movement, and catch-up already accelerates it.
|
|
298
307
|
// Fading the offset must not add another fraction of that accelerated speed.
|
|
299
308
|
const allowance = length ? (myKind()?.maxSpeed ?? 0) * elapsed / 1000 / length : 1;
|
|
300
|
-
const release = settled &&
|
|
301
|
-
|
|
302
|
-
|
|
309
|
+
const release = settled && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
|
|
310
|
+
let progress = length ? Math.min(allowance, distance(path, o.path) * 0.8 / length) : 1;
|
|
311
|
+
// Travel on one axis cannot pay for a correction on another. In particular,
|
|
312
|
+
// a jump must not erase a horizontal offset faster than running advances it.
|
|
313
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
314
|
+
const travelled = periodicDelta(o.path[axis], path[axis], map.wrap?.[axis]);
|
|
315
|
+
if (o.delta[axis] * travelled > 0) progress = Math.min(progress, .8 * Math.abs(travelled / o.delta[axis]));
|
|
316
|
+
}
|
|
317
|
+
const nominal = now >= o.end ? 0 : Math.max(0, Math.min(1, 1 - (now - o.at) / predict.blendMs));
|
|
303
318
|
o.fade = Math.max(nominal, o.fade - Math.max(release, progress));
|
|
304
319
|
}
|
|
305
320
|
o.path = path; o.last = now;
|
|
306
321
|
}
|
|
322
|
+
// Drain any remaining correction over the final quarter second, including
|
|
323
|
+
// queued offsets. Expiring it in one frame would itself create a warp.
|
|
324
|
+
o.fade = Math.min(o.fade, deadlineFade);
|
|
307
325
|
waiting = true;
|
|
308
326
|
sum.x += o.delta.x * o.fade; sum.y += o.delta.y * o.fade; sum.z += o.delta.z * o.fade;
|
|
309
327
|
}
|
|
@@ -339,7 +357,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
339
357
|
// The helper's ping ends at the relay. A browser host adds another network leg in both directions.
|
|
340
358
|
// Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
|
|
341
359
|
const relayRtt = net.stats().rtt ?? 100;
|
|
342
|
-
return net.offline ? 0 :
|
|
360
|
+
return net.offline ? 0 : Math.max(relayRtt, 2 * quantile(hostAges, 0.5));
|
|
343
361
|
}
|
|
344
362
|
function phaseAt(at: number): number {
|
|
345
363
|
let p = phases[0];
|
|
@@ -350,7 +368,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
350
368
|
function steer(now: number): void {
|
|
351
369
|
if (!base || !leads.length) return;
|
|
352
370
|
base = { tick: tickAt(now), at: now };
|
|
353
|
-
|
|
371
|
+
// Input replay absorbs delivery jitter. Keep the physical tick cadence close to
|
|
372
|
+
// wall time: aggressive steering makes fixed-step custom renderers bunch ticks.
|
|
373
|
+
speed = 1 + Math.max(-0.005, Math.min(0.005, (targetLead() - medianLead()) * 0.005));
|
|
354
374
|
if (phases[phases.length - 1]?.rate !== speed) phases.push({ ...base, rate: speed });
|
|
355
375
|
while (phases.length > 2 && phases[1].at < now - Math.max(1000, 2 * authorityRtt() + period)) phases.shift();
|
|
356
376
|
}
|
|
@@ -358,19 +378,41 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
358
378
|
// Recalibrating an input clock is not a placement. Keep the drawn pose while
|
|
359
379
|
// the new prediction catches up, just as for a snapshot reconciliation.
|
|
360
380
|
const shown = reason === 'lead' ? meNow()?.pos : null;
|
|
381
|
+
const continuousTick = reason === 'placement' && base && tickAt(clock()) >= k && tickAt(clock()) - k <= tickHz ? tickAt(clock()) : null;
|
|
382
|
+
if(reason==='placement' || reason==='epoch')pulseQueue.length=0;
|
|
361
383
|
correction.rebaseReason = reason; correction.rebaseError = medianLead() - targetLead();
|
|
362
|
-
const rtt = authorityRtt();
|
|
384
|
+
const rtt = authorityRtt(); calibratedRtt = rtt;
|
|
363
385
|
// Lead samples refer to the clock which stamped them. Do not mix its offset with this clock's jitter.
|
|
364
386
|
const target = targetLead();
|
|
365
387
|
leads.length = 0;
|
|
366
388
|
leadAfter = clock() + 2 * rtt + period;
|
|
367
|
-
base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
|
|
368
|
-
stepped = Math.floor(base.tick) - 1;
|
|
369
|
-
entries = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
|
|
389
|
+
base = { tick: continuousTick ?? k + Math.min(tickHz, Math.ceil(rtt / period + target) + sendEvery - 1), at: clock() };
|
|
390
|
+
stepped = Math.max(k, Math.floor(base.tick) - (continuousTick === null ? 1 : 0));
|
|
391
|
+
entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
|
|
370
392
|
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
|
|
371
393
|
const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
|
|
372
394
|
if (mine) {
|
|
395
|
+
// A snapshot is the body at k, not at the clock's future input tick.
|
|
396
|
+
// Project the intervening movement instead of relabelling that old pose;
|
|
397
|
+
// otherwise the next reconciliation suddenly adds a latency's worth of travel.
|
|
398
|
+
const kind = myKind()!;
|
|
399
|
+
const values: Record<string, unknown> = {};
|
|
400
|
+
for (const [name, fd] of kind.input) values[name] = fd.t === 'press' ? false : fd.t === 'pulse' ? 0 : coerce(fd, sample[name] ?? fd.init, dims);
|
|
401
|
+
held = replayHeld = Object.freeze(values);
|
|
402
|
+
history.set(k, copyMine(mine));
|
|
403
|
+
for (let t = k + 1; !kind.owner && t <= stepped; t++) {
|
|
404
|
+
Object.assign(mine, runMove(kind.name, mine, held, t));
|
|
405
|
+
runPredict(kind.name, mine, mine.fields, held, t, false, false);
|
|
406
|
+
history.set(t, copyMine(mine));
|
|
407
|
+
}
|
|
373
408
|
history.set(stepped, copyMine(mine));
|
|
409
|
+
if (!kind.owner && stepped > k) {
|
|
410
|
+
// The projection used these controls from k+1. Stamp that first input too,
|
|
411
|
+
// so authority can recover the identical post-placement path.
|
|
412
|
+
const row = kind.input.map(([name]) => Number(held[name]));
|
|
413
|
+
entries.push({ t: k + 1, row: [...row, -471, ++inputSequence, 0] });
|
|
414
|
+
pending.push({ seq: inputSequence, t: k + 1, input: held });
|
|
415
|
+
}
|
|
374
416
|
if (shown) offsets = [{ delta: { x: periodicDelta(mine.pos.x,shown.x,map.wrap?.x), y: periodicDelta(mine.pos.y,shown.y,map.wrap?.y), z: periodicDelta(mine.pos.z,shown.z,map.wrap?.z) }, at: clock(), path: mine.pos, fade: 1, last: clock() }];
|
|
375
417
|
}
|
|
376
418
|
}
|
|
@@ -519,6 +561,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
519
561
|
function step(t: number, fresh = true): void {
|
|
520
562
|
const kind = myKind();
|
|
521
563
|
if (!mine || !kind) return;
|
|
564
|
+
if(fresh && pulseQueue.length && pulseQueue[0].t<=t){const p=pulseQueue.shift()!;pressed.set(p.name,p.value);pulseNumber.set(p.name,p.n);}
|
|
522
565
|
const values: number[] = [];
|
|
523
566
|
const input: Record<string, unknown> = {};
|
|
524
567
|
let press = false;
|
|
@@ -536,7 +579,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
536
579
|
}
|
|
537
580
|
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
538
581
|
const sig = JSON.stringify([values, claim]);
|
|
539
|
-
if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held, ...(numbered ? { pulses: numbered } : {}) }); lastSent = sig; }
|
|
582
|
+
if (sig !== lastSent || press || controlChanges.length) { entries.push({ t, row: [...values, ...claim, -471, ++inputSequence, controlChanges.length, ...controlChanges.flat()] }); pending.push({ seq: inputSequence, t, input: held, ...(numbered ? { pulses: numbered } : {}) }); lastSent = sig; }
|
|
583
|
+
controlChanges = [];
|
|
540
584
|
history.set(t, copyMine(mine));
|
|
541
585
|
for (const k of history.keys()) if (k < t - tickHz && (catchTick === null || k < Math.floor(catchTick))) history.delete(k);
|
|
542
586
|
}
|
|
@@ -548,11 +592,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
548
592
|
function flush(now: number, t: number): void {
|
|
549
593
|
if (!mine) { entries = []; return; }
|
|
550
594
|
if (!entries.length && now - lastSendAt >= 250) keepalive(t);
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
595
|
+
const marker = myKind()!.input.length + (myKind()!.owner ? 9 : 0);
|
|
596
|
+
// A newer tick's acknowledgement cannot acknowledge a hole in the history.
|
|
597
|
+
if (!entries.length && !sentHistory.some(e => !applications.has(e.row[marker + 1]))) return;
|
|
598
|
+
const k = Math.min(entries[0]?.t ?? t, t - (t % sendEvery));
|
|
599
|
+
for (const entry of entries) {
|
|
600
|
+
const old = sentHistory.findIndex(e => e.t === entry.t);
|
|
601
|
+
if (old >= 0) sentHistory[old] = entry; else sentHistory.push(entry);
|
|
602
|
+
}
|
|
603
|
+
// Cover an authority round trip and jitter. Missing application receipts measure
|
|
604
|
+
// recovery delay (whether a dropped frame or a TCP stall): widen the history
|
|
605
|
+
// until they return, without allowing an unbounded packet or retained history.
|
|
606
|
+
const missing = sentHistory.find(e => e.t <= (latest?.k ?? 0) && !applications.has(e.row[marker + 1]));
|
|
607
|
+
const recoveryTicks = missing ? t - missing.t + 2 * sendEvery : 0;
|
|
608
|
+
const windowTicks = Math.min(64, Math.max(4, recoveryTicks, Math.ceil((2 * authorityRtt() + 250) / period + targetLead())));
|
|
609
|
+
sentHistory = sentHistory.filter(e => e.t >= t - windowTicks).slice(-64);
|
|
610
|
+
const first = Math.min(k, sentHistory[0]?.t ?? k);
|
|
611
|
+
const rows: StepEntry[] = sentHistory.map((e) => [e.t - first, ...e.row]);
|
|
554
612
|
entries = [];
|
|
555
|
-
if (net.steps(epoch,
|
|
613
|
+
if (net.steps(epoch, first, rows, mine.r)) lastSendAt = now;
|
|
556
614
|
}
|
|
557
615
|
/** The held values as an entry on tick `t`, without stepping the body again. */
|
|
558
616
|
function keepalive(t: number): void {
|
|
@@ -560,7 +618,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
560
618
|
if (!mine || !kind) return;
|
|
561
619
|
const values = kind.input.map(([name, fd]) => ((fd.t === 'press' || fd.t === 'pulse') ? 0 : fd.t === 'held' ? Number(coerce(fd, heldNumber(sample[name], fd.init), dims)) : Number(coerce(fd, sample[name] ?? fd.init, dims))));
|
|
562
620
|
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
563
|
-
entries = [{ t, row: [...values, ...claim] }]; pending.push({ t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
|
|
621
|
+
entries = [{ t, row: [...values, ...claim, -471, ++inputSequence, controlChanges.length, ...controlChanges.flat()] }]; pending.push({ seq: inputSequence, t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
|
|
564
622
|
}
|
|
565
623
|
let pumping = false;
|
|
566
624
|
function pump(): void {
|
|
@@ -628,13 +686,18 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
628
686
|
if (me) {
|
|
629
687
|
const placed = !mine || mine.id !== me.id || mine.r !== me.r || changedEpoch;
|
|
630
688
|
const row = f.rows.find((x) => x[0] === seat);
|
|
689
|
+
if(changedEpoch)applications.clear();
|
|
690
|
+
if(row?.[6]===471){for(let i=7;i+1<row.length;i+=2)applications.set(row[i],row[i+1]);while(applications.size>128)applications.delete(applications.keys().next().value!);}
|
|
631
691
|
if (placed || !base) {
|
|
632
|
-
adopt(me); rebase(f.k);
|
|
692
|
+
adopt(me); rebase(f.k, changedEpoch ? 'epoch' : 'placement');
|
|
633
693
|
if (placed) emit('placed', entityOf(me, true));
|
|
634
694
|
} else if (kindOf.get(me.kind)?.owner) {
|
|
635
695
|
mine!.motion = motionOf(me.kind, me.motion);
|
|
636
696
|
if (predictors[me.kind]) mine!.fields = thawFields(kindOf.get(me.kind)?.fields ?? [], me.fields);
|
|
637
|
-
} else {
|
|
697
|
+
} else if (!pending.some(entry => entry.seq !== undefined && entry.t <= f.k && f.k - entry.t < tickHz && (applications.get(entry.seq) ?? Infinity) > f.k)) {
|
|
698
|
+
// This snapshot predates an input we already predicted. Within the rewind
|
|
699
|
+
// window it is provisional movement truth: wait for its application receipt
|
|
700
|
+
// rather than briefly adopting the path made with the missing control.
|
|
638
701
|
const previous = mine!.pos;
|
|
639
702
|
const shownBody = meNow();
|
|
640
703
|
const shown = shownBody?.pos ?? previous;
|
|
@@ -643,8 +706,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
643
706
|
adopt(me);
|
|
644
707
|
history.set(f.k, copyMine(mine!));
|
|
645
708
|
const ack = row?.[2] ?? 0;
|
|
709
|
+
const scheduled = row?.[6] === 471;
|
|
710
|
+
if (scheduled) for(let i=7;i+1<row.length;i+=2){
|
|
711
|
+
if(row[i]>0){const entry=pending.find(e=>e.seq===row[i]);if(entry)entry.applied=row[i+1];}
|
|
712
|
+
else for(const entry of pending)for(const [name,n] of Object.entries(entry.pulses??{}))if(n===-row[i]){entry.pulseTicks??={};entry.pulseTicks[name]=row[i+1];}
|
|
713
|
+
}
|
|
714
|
+
const appliedAt = (entry: (typeof pending)[number]): number => entry.applied ?? entry.t;
|
|
646
715
|
// A numbered pulse is still to come while the server's row has not reached its number (0.46.3): its own message
|
|
647
716
|
// can be later than the input frame of its tick, so the input's acknowledgement says nothing about it.
|
|
717
|
+
while(pulseQueue.length && pulseQueue[0].n <= (row?.[4] ?? 0))pulseQueue.shift();
|
|
648
718
|
const taken = typeof row?.[4] === 'number' ? row[4] : null;
|
|
649
719
|
const owed = (entry: (typeof pending)[number], name: string): boolean => { const n = entry.pulses?.[name]; return taken !== null && n !== undefined ? n > taken : entry.t > ack; };
|
|
650
720
|
const fresh = (entry: (typeof pending)[number]): boolean => f.k + 1 - entry.t <= Math.ceil(tickHz / 4);
|
|
@@ -654,7 +724,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
654
724
|
// predicted where the server will bring it and not where a late frame left it for a moment.
|
|
655
725
|
const heldFields = myKind()!.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name);
|
|
656
726
|
const debt: Record<string, number> = {};
|
|
657
|
-
if (heldFields.length) {
|
|
727
|
+
if (heldFields.length && !scheduled) {
|
|
658
728
|
for (const entry of pending) if (entry.t <= ack) for (const name of heldFields) serverHeld[name] = Number(entry.input[name]) || 0;
|
|
659
729
|
const waiting = pending.filter((entry) => entry.t > ack && entry.t <= f.k && fresh(entry));
|
|
660
730
|
if (waiting.length) for (let i = waiting[0].t; i <= f.k; i++) {
|
|
@@ -663,9 +733,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
663
733
|
if (meant) for (const name of heldFields) debt[name] = (debt[name] ?? 0) + (Number(meant[name]) || 0) - (serverHeld[name] ?? 0);
|
|
664
734
|
}
|
|
665
735
|
}
|
|
666
|
-
for (const entry of pending) if (entry
|
|
736
|
+
for (const entry of pending) if (appliedAt(entry) <= f.k) {
|
|
667
737
|
replayHeld = entry.input;
|
|
668
|
-
if (fresh(entry))
|
|
738
|
+
if ((!scheduled || entry.applied === undefined) && fresh(entry))
|
|
669
739
|
for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name] && (fd.t === 'pulse' ? owed(entry, name) : entry.t > ack)) late.set(name, entry.input[name]);
|
|
670
740
|
}
|
|
671
741
|
// After a reset nothing is held yet: the replay runs on every field's own zero, never on an input with no fields
|
|
@@ -674,9 +744,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
674
744
|
if (!Object.keys(input).length) { const zero: Record<string, unknown> = {}; for (const [name, fd] of myKind()!.input) zero[name] = fd.t === 'press' ? false : fd.t === 'pulse' ? 0 : coerce(fd, fd.init, dims); input = Object.freeze(zero); }
|
|
675
745
|
for (let t = f.k + 1; t <= stepped; t++) {
|
|
676
746
|
const presses = new Map(t === f.k + 1 ? late : []);
|
|
677
|
-
for (const entry of pending)
|
|
678
|
-
input = entry.input;
|
|
679
|
-
for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name]) presses.set(name, entry.input[name]);
|
|
747
|
+
for (const entry of pending) {
|
|
748
|
+
if(appliedAt(entry)===t)input = entry.input;
|
|
749
|
+
for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name] && (fd.t==='pulse' ? (entry.pulseTicks?.[name]??entry.t)===t : appliedAt(entry)===t)) presses.set(name, entry.input[name]);
|
|
680
750
|
}
|
|
681
751
|
const stepInput = { ...input };
|
|
682
752
|
for (const [name, fd] of myKind()!.input) if (fd.t === 'press' || fd.t === 'pulse') stepInput[name] = presses.get(name) ?? (fd.t === 'press' ? false : 0);
|
|
@@ -685,7 +755,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
685
755
|
runPredict(me.kind, mine!, mine!.fields, stepInput, t, false, false);
|
|
686
756
|
history.set(t, copyMine(mine!));
|
|
687
757
|
}
|
|
688
|
-
pending = pending.filter(e => e
|
|
758
|
+
pending = pending.filter(e => appliedAt(e) > f.k || (scheduled && ((e.applied === undefined && f.k-e.t<tickHz) || Object.keys(e.pulses??{}).some(name=>owed(e,name)))) || !scheduled && fresh(e) && (e.t > ack || Object.keys(e.pulses ?? {}).some((name) => owed(e, name))));
|
|
689
759
|
if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
|
|
690
760
|
const error = distance(previous, mine!.pos);
|
|
691
761
|
correction.last = error;
|
|
@@ -724,6 +794,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
724
794
|
}
|
|
725
795
|
}
|
|
726
796
|
}
|
|
797
|
+
if (!latencyCalibrated && authorityRtt() > calibratedRtt + period && !Object.values(sample).some(Boolean) && !pending.some(e=>Object.values(e.input).some(Boolean))) { rebase(f.k, 'lead'); latencyCalibrated = true; }
|
|
727
798
|
if (row && row[3] !== -128 && base && clock() >= leadAfter) {
|
|
728
799
|
const now = clock(), rtt = authorityRtt();
|
|
729
800
|
// A reported lead describes an input sent roughly one authority round trip ago. Remove that
|
|
@@ -732,10 +803,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
732
803
|
observedLeadAt = now;
|
|
733
804
|
leads.push(row[3] / 16 - phaseAt(now - rtt)); if (leads.length > 40) leads.shift();
|
|
734
805
|
const med = medianLead(), target = targetLead();
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
806
|
+
// A changing jitter estimate must not discard already stamped input. Slew the
|
|
807
|
+
// clock; late movement is settled by the server's retained history.
|
|
808
|
+
steer(now);
|
|
739
809
|
}
|
|
740
810
|
previewKey = '';
|
|
741
811
|
settlePredictions(f, before && before.e === f.e ? before : null, row?.[2] ?? 0);
|
|
@@ -844,7 +914,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
844
914
|
// A held field previews as its mean over the part of the tick that has passed. The body is drawn that fraction of
|
|
845
915
|
// the way to the preview, so it stands exactly where the control has carried it: it starts from rest at a press
|
|
846
916
|
// and stops dead at a release, with nothing to blend away afterwards.
|
|
847
|
-
for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false), dims) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
917
|
+
for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'pulse' && pulseQueue[0]?.name===name ? pulseQueue[0].value : fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false), dims) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
848
918
|
const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
|
|
849
919
|
// A landing does not take the whole tick (0.46.5): the body met the ground part way through it. How far through is
|
|
850
920
|
// read from the fall it would have made at the speed it entered the tick with; it is drawn down to the ground by
|
|
@@ -971,7 +1041,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
971
1041
|
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
|
|
972
1042
|
peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
|
|
973
1043
|
movement: () => { const kinds = schema.kinds.filter(k => k.player); return kinds.length && kinds.every(k => k.maxSpeed === 0) ? 'stationary' : 'spatial'; },
|
|
974
|
-
prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length }),
|
|
1044
|
+
prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length, applications: [...applications] }),
|
|
975
1045
|
scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
|
|
976
1046
|
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
|
|
977
1047
|
});
|
|
@@ -1038,8 +1108,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1038
1108
|
rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
|
|
1039
1109
|
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
|
|
1040
1110
|
get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
|
1041
|
-
input(s) {
|
|
1042
|
-
|
|
1111
|
+
input(s, options) {
|
|
1112
|
+
pump();
|
|
1113
|
+
if (solo) { solo.input(s, options); return; }
|
|
1043
1114
|
const kind = myKind() ?? schema.kinds.find((k) => k.player) ?? null;
|
|
1044
1115
|
const next: Record<string, unknown> = {};
|
|
1045
1116
|
let changed = false;
|
|
@@ -1051,7 +1122,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1051
1122
|
// a jump). Keep the pose at the instant of the press; only subsequent time
|
|
1052
1123
|
// may advance the new path, rather than drawing elapsed time with new input.
|
|
1053
1124
|
// A held control changed NOW, before anything else here takes time: the steps this call runs must already know it.
|
|
1054
|
-
const
|
|
1125
|
+
const now = clock();
|
|
1126
|
+
const at = typeof options?.at === 'number' && Number.isFinite(options.at) ? Math.max(edgeOf(stepped), Math.min(now, options.at)) : now;
|
|
1127
|
+
for (const [index, [name, fd]] of (kind?.input ?? []).entries()) {
|
|
1128
|
+
const value = Number(coerce(fd, s[name] ?? fd.init, dims));
|
|
1129
|
+
if(value !== Number(coerce(fd, sample[name] ?? fd.init, dims)))controlChanges.push([index, ++inputSequence, Math.max(0,Math.min(1,tickAt(at)-stepped)), value]);
|
|
1130
|
+
}
|
|
1131
|
+
controlChanges=controlChanges.slice(-256);
|
|
1055
1132
|
for (const [name, fd] of kind?.input ?? []) if (fd.t === 'held') {
|
|
1056
1133
|
const v = heldNumber(next[name], fd.init), tr = heldTrack.get(name);
|
|
1057
1134
|
if (!tr) heldTrack.set(name, { from: at, changes: [[at, v]] });
|
|
@@ -1059,15 +1136,19 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1059
1136
|
}
|
|
1060
1137
|
if (changed) pump();
|
|
1061
1138
|
const shown = changed ? meNow()?.pos : null;
|
|
1062
|
-
for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'
|
|
1139
|
+
for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'){pulseCount+=1;const target=Math.max(stepped+1,(pulseQueue.at(-1)?.t??stepped)+1);pulseQueue.push({name,value:Number(value),n:pulseCount,t:target});net.send('pulse',[epoch,name,value,target,pulseCount]);} else pressed.set(name,value); }
|
|
1063
1140
|
sample = next;
|
|
1064
1141
|
if (shown) {
|
|
1065
1142
|
const now = clock(), nextShown = meNow()?.pos;
|
|
1066
1143
|
if (nextShown && distance(shown, nextShown) > 0.00001) {
|
|
1067
1144
|
const carried = visualOffset(now);
|
|
1068
|
-
//
|
|
1069
|
-
|
|
1070
|
-
|
|
1145
|
+
// A new control must not restart the lifetime of an older correction.
|
|
1146
|
+
const path = { x: nextShown.x-carried.x, y: nextShown.y-carried.y, z: nextShown.z-carried.z };
|
|
1147
|
+
for(const offset of offsets){offset.path=path;offset.last=now;}
|
|
1148
|
+
const delta={x:periodicDelta(nextShown.x,shown.x,map.wrap?.x),y:periodicDelta(nextShown.y,shown.y,map.wrap?.y),z:periodicDelta(nextShown.z,shown.z,map.wrap?.z)};
|
|
1149
|
+
const last=offsets.at(-1);
|
|
1150
|
+
if(last?.at===now && last.fade===1){last.delta={x:last.delta.x+delta.x,y:last.delta.y+delta.y,z:last.delta.z+delta.z};}
|
|
1151
|
+
else offsets.push({delta,at:now,last:now,fade:1,path});
|
|
1071
1152
|
}
|
|
1072
1153
|
}
|
|
1073
1154
|
},
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.48.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.48.1","sha256":"01670eff45e75c7fca0bee9df359442e097aa916cc837e2d8b772a452a105dea"});
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.48.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.48.1';
|