@homie-rocks/studio 0.46.2 → 0.46.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 +61 -0
- package/guides/game/RULES.md +104 -3
- package/guides/game/SKILL.md +5 -0
- package/lib/rules-check-worker.mjs +1 -1
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +42 -0
- package/netplay/netplay.ts +1 -1
- package/package.json +1 -1
- package/rules/core.ts +29 -4
- package/rules/host.ts +85 -13
- package/rules/pack.ts +1 -0
- package/rules/rules.ts +16 -5
- package/rules/types.ts +5 -0
- package/rules/view.ts +229 -25
- package/saves/SAVES.md +83 -3
- package/saves/saves.ts +2 -0
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +2 -0
- package/worker/players.mjs +13 -15
- package/worker/room.mjs +28 -3
- package/worker/saves-shell.mjs +22 -2
- package/worker/saves.mjs +282 -19
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,67 @@ 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.46.3] - 2026-10-11
|
|
21
|
+
|
|
22
|
+
**Plugin 0.46.3**
|
|
23
|
+
|
|
24
|
+
A guest who signs in keeps both histories; a rival's sounds reach the players who can hear them; a shooter's own
|
|
25
|
+
actions answer at once; a tap moves as far as it was held; a client cannot stretch its own lag compensation.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
Every item here is opt-in: a game that declares none of them plays, costs and saves exactly as on 0.46.2.
|
|
30
|
+
|
|
31
|
+
- `mergeSaves({ '<game>': { saves: { '<key or prefix*>': rule }, stats: { … } } })` from
|
|
32
|
+
`@homie-rocks/studio/worker`: how a guest's saves combine with an account's when the guest signs in to an account
|
|
33
|
+
that already has the same key. Rules: `'account'`, `'guest'`, `'newer'`, `'larger'`, `'smaller'`, `'sum'`,
|
|
34
|
+
`'union'` (a list joined by id), or the game's own function, run on the server. It happens once, in the sign-in's
|
|
35
|
+
own transaction, guarded against a room writing a result at the same moment. The sign-in response says what
|
|
36
|
+
was merged, moved and kept (`adopted`), and a key no rule could combine reaches the game's `onConflict` with the
|
|
37
|
+
guest's value (saves/SAVES.md, "When a guest signs in to their account").
|
|
38
|
+
- Effects with an audience: `world.emit(effect, at, data, { within: metres, to: id | [ids] })` sends an effect only
|
|
39
|
+
to the players whose own body is in range or who are named; a watcher receives the ones with a range. For a sound a rival
|
|
40
|
+
could hear or a number only the shooter may see, in place of drawing it from state the rival should not have.
|
|
41
|
+
An effect with no audience is public and is sent as before.
|
|
42
|
+
- `f.held()`, an input type: the browser sends the average of the control over each tick, so a key held for part
|
|
43
|
+
of a tick moves the body for that part. Short taps no longer travel a whole tick or two by where the tick's
|
|
44
|
+
edges fell. An entry that reaches the server after its tick is settled on the next ticks, not counted twice.
|
|
45
|
+
- `openRoom({ predict: { <kind>(me, input, ctx) { … } } })`: the player's own actions predicted like their own
|
|
46
|
+
movement (run each predicted tick and between ticks, put right by every snapshot and re-run). `ctx.ghost` shows a
|
|
47
|
+
provisional entity and `ctx.effect` a provisional effect that the server's own then confirms (`confirms: true`)
|
|
48
|
+
or the room retracts (`room.on('retract', …)`). A valued pulse (`f.pulse()`) now names the tick it was pressed
|
|
49
|
+
on and the server acts on it in that tick; the control row says the newest pulse acted on, so a prediction
|
|
50
|
+
holds when the pulse's own message is late (as it can be through a Gate).
|
|
51
|
+
- `world.unitsLeft`: the budget units the running handler may still spend, so a handler that plans can stop early.
|
|
52
|
+
- `room.rewindSeconds` in rules: the game's hard cap on how far a person's ray is rewound for latency (from 0 to
|
|
53
|
+
`historySeconds`). Without it the cap is `historySeconds`, as before; `0.25` is a sensible value for a shooter.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
Each of these changes what happened before; none needs a change to a game.
|
|
58
|
+
|
|
59
|
+
- A guest with verified results who signed in to an account that also had some kept only the account's: the
|
|
60
|
+
guest's `server:` record was dropped, and nothing could combine the two afterwards because a browser cannot
|
|
61
|
+
write a `server:` key. Two server records are now joined by id when the guest signs in, with no rule declared.
|
|
62
|
+
- A result a room wrote for the guest a moment after they signed in was written under a player that no longer
|
|
63
|
+
existed. It now follows the guest to the account (`appendServerRecord`, at the same one read and one write).
|
|
64
|
+
- Lag compensation could be stretched by a client: the round trip was timed on a separate answer the client could
|
|
65
|
+
hold back, and only the smallest value ever seen was kept. It is now timed on the input frames themselves (each
|
|
66
|
+
names the newest snapshot its browser had), as a low percentile over the last ten seconds, and the allowance
|
|
67
|
+
counts the whole ticks an input waited after it arrived instead of a fixed one. A client still cannot look
|
|
68
|
+
faster than it is; one that pretends to be slower is bounded by `room.rewindSeconds`.
|
|
69
|
+
|
|
70
|
+
### Upgrade notes
|
|
71
|
+
|
|
72
|
+
- Nothing to do for a studio that declares none of the above. To use `mergeSaves`, add it to
|
|
73
|
+
`site/src/worker.mjs` beside `hostRules`.
|
|
74
|
+
- A page from before this version that is still open against the new Worker is timed the old way until it
|
|
75
|
+
reloads. Its rewind is bounded by the same cap.
|
|
76
|
+
- All of the above works the same through Gates (a room of more than 64 seats) as in a small room; each has a
|
|
77
|
+
seeded test both ways. Through a Gate a held input that misses its tick can be drawn a few centimetres short for
|
|
78
|
+
a tick or two before it settles.
|
|
79
|
+
- Version 0.46.3 is provisional. No deploy or merge is part of this PR.
|
|
80
|
+
|
|
20
81
|
## [0.46.2] - 2026-10-11
|
|
21
82
|
|
|
22
83
|
**Plugin 0.46.2** · Big rooms: the room runs the rules, its Gates do the rest.
|
package/guides/game/RULES.md
CHANGED
|
@@ -207,6 +207,7 @@ State belongs only in `fields`, `motion`, or `shared`; never a changing module v
|
|
|
207
207
|
| `f.fix()` | Multiples of 1/4096, ±2147483648; `fuel: f.fix({ init: 1 })` |
|
|
208
208
|
| `f.bit()` | Boolean; `alive: f.bit({ init: true })` |
|
|
209
209
|
| `f.press()` | Input-only one-shot boolean button; `fire: f.press()` |
|
|
210
|
+
| `f.held()` | Input-only number averaged over the tick (0.46.3); `ax: f.held()` |
|
|
210
211
|
| `f.tick()` | Absolute tick, 0–4294967295; `until: f.tick()` |
|
|
211
212
|
| `f.ticks()` | Tick duration, same range; `cooldown: f.ticks()` |
|
|
212
213
|
| `f.vec3()` | Float32 vector `{x,y,z}`; `target: f.vec3()` |
|
|
@@ -220,7 +221,7 @@ State belongs only in `fields`, `motion`, or `shared`; never a changing module v
|
|
|
220
221
|
Vector writes may omit z (it becomes zero); reads contain all three coordinates.
|
|
221
222
|
Integer writes round and clamp, not wrap. Guard against underflow explicitly:
|
|
222
223
|
`self.hp = Math.max(0, self.hp - damage)`. Inputs may contain only integer types,
|
|
223
|
-
fix, bit and press. Each field may hold at most 16,384 cells and a kind 65,536.
|
|
224
|
+
fix, held, bit and press. Each field may hold at most 16,384 cells and a kind 65,536.
|
|
224
225
|
A scalar is one cell; a vector four; a text is `1 + ceil(max/64)`;
|
|
225
226
|
a list is `1 + max * itemCells`; a map is `1 + max * (1 + itemCells)`;
|
|
226
227
|
a struct is one plus the cells of its fields. Map keys are at most 32 characters.
|
|
@@ -344,6 +345,7 @@ These costs bound work, not elapsed wall time.
|
|
|
344
345
|
| `sendArea(area,event,data?)` | Entity/room; 20 + 2N + 6C; 64 areas/tick, reach 64m | `world.sendArea({sphere:{at:self.pos,r:2}}, 'hit', {damage:1})` |
|
|
345
346
|
| `after(ticks,event,data?)` | Entity/room; 16 + 6C; wait clamped 1–2147483647; targets caller | `world.after(20, 'ready', {})` |
|
|
346
347
|
| `emit(effect,at,data?)` | Declared effect; 10 + 6C; at is position or ID; 256 effects a tick, and the next one throws | `world.emit('spark', self.pos, {})` |
|
|
348
|
+
| `emit(effect,at,data,audience)` | The same, plus 8, for who may perceive it (0.46.3): `{within: metres}`, `{to: id}` or `{to: [ids]}` (32 at most), or both | `world.emit('grunt', self.id, {}, {within: 45})` |
|
|
347
349
|
| `spawn(kind,at,fields?)` | Entity/room; 10 + copy cost; 2048 entities, non-player kinds only | `world.spawn('coin', self.pos, { value: 2 })` |
|
|
348
350
|
| `despawn(self)` | Own non-player entity; constant guard cost | `world.despawn(self)` |
|
|
349
351
|
| `place(self,at,options?)` | Own entity; 82; options vel/heading; clamps to bounds, changes movement revision | `world.place(self, world.map.spots('start')[0])` |
|
|
@@ -369,7 +371,8 @@ Queries return readonly views; send events to make recipients change themselves.
|
|
|
369
371
|
Math vectors are readonly too: replace `d.x += n` with
|
|
370
372
|
`d = { ...d, x: d.x + n }`, or use `world.math.add`/`scale`.
|
|
371
373
|
|
|
372
|
-
World properties: `tick`, `dt`, `
|
|
374
|
+
World properties: `tick`, `dt`, `unitsLeft` (0.46.3: budget units the running
|
|
375
|
+
handler may still spend, so a planner can stop early), `tune`, `shared`, `map`, `math`, `stage`, `level`,
|
|
373
376
|
`levelMax`, `round: {n,phase,endsAt}`. Phase is `live` or `over`.
|
|
374
377
|
`rounds.seconds: 0` means no automatic deadline: your room must call round.end.
|
|
375
378
|
A positive duration ends automatically. Round results arrive two ticks after the
|
|
@@ -941,7 +944,22 @@ Declare `room.historySeconds: 1` (maximum two seconds). The server retains and
|
|
|
941
944
|
saves non-collider body positions and placement revisions on its virtual clock.
|
|
942
945
|
The view's `room.viewTick` is the tick used to interpolate remote bodies. Send it
|
|
943
946
|
with an action, then use `world.ray(..., {atTick, ...filters})`. Fractional ticks
|
|
944
|
-
interpolate saved poses.
|
|
947
|
+
interpolate saved poses. A player's request is clamped to: the relay's own
|
|
948
|
+
measurement of that connection's round trip, plus 150 ms of presentation delay,
|
|
949
|
+
plus the whole ticks the input waited between arriving and running (at least one,
|
|
950
|
+
at most a quarter of a second). Without a measurement only that margin is allowed.
|
|
951
|
+
|
|
952
|
+
The measurement (0.46.3) is a low percentile of the last ten seconds of round
|
|
953
|
+
trips timed on the input frames themselves: each frame names the newest snapshot
|
|
954
|
+
its browser had, and the relay knows when that snapshot left. A client cannot
|
|
955
|
+
make it shorter than its real latency, and holding back a separate answer no
|
|
956
|
+
longer raises it. A modified client can still pretend to be further away than it
|
|
957
|
+
is, as it could by really being so; what that buys is bounded by the game's cap.
|
|
958
|
+
Set `room.rewindSeconds` (from 0 to `historySeconds`): the most any shot is
|
|
959
|
+
rewound, whatever the connection reports. `0.25` suits a shooter: a round trip
|
|
960
|
+
up to about 100 ms is compensated in full and a slower player leads the target a
|
|
961
|
+
little. Without it the cap is `historySeconds`, as before. Client-supplied
|
|
962
|
+
latency figures are ignored.
|
|
945
963
|
A placement or new round prevents rewinding through a teleport or reset.
|
|
946
964
|
|
|
947
965
|
Rewind changes actor poses only. Eligibility filters use current fields, so a
|
|
@@ -986,6 +1004,89 @@ skips the intermission. Use it for an authorized retry; validate eligibility in
|
|
|
986
1004
|
your room handler. Pending restart intent survives save/restore.
|
|
987
1005
|
|
|
988
1006
|
|
|
1007
|
+
### Held input: a tap moves for the time it was down (0.46.3)
|
|
1008
|
+
|
|
1009
|
+
An ordinary input field is read once a tick, at the tick's edge. A key held for
|
|
1010
|
+
60 ms of a 50 ms tick therefore moves the body for one tick or for two, by where
|
|
1011
|
+
the edges happened to fall: short taps travel too far and vary. Declare a
|
|
1012
|
+
movement axis `ax: f.held()` instead. The browser then sends the average of what
|
|
1013
|
+
the control read over each tick (a key down for a third of the tick reads a
|
|
1014
|
+
third of its value; an analogue stick reads its mean), prediction uses the same
|
|
1015
|
+
number, and `move` multiplies speed by it as before, so the body moves for the
|
|
1016
|
+
part of the tick the key was down. `room.input({ax: 1})` is called as usual,
|
|
1017
|
+
every frame or on change; a boolean counts as 1. `think` returns a plain number.
|
|
1018
|
+
Use it for movement axes, not for buttons: a button is `f.press()` or `f.pulse()`.
|
|
1019
|
+
Keep its values in -1 to 1. An entry that reaches the server after its tick is
|
|
1020
|
+
not counted twice: the tick it missed ran on the value before it, and the server
|
|
1021
|
+
settles the difference on the next ticks, so the body ends where the browser
|
|
1022
|
+
predicted.
|
|
1023
|
+
|
|
1024
|
+
### Effects with an audience: what a player may perceive (0.46.3)
|
|
1025
|
+
|
|
1026
|
+
Everything in a snapshot's public fields and every plain `world.emit` reaches
|
|
1027
|
+
every player. A sound or a flash must not be drawn from state a rival should not
|
|
1028
|
+
have (another player's exact health), and need not be sent to someone who could
|
|
1029
|
+
not perceive it. Emit the event instead, and say who perceives it:
|
|
1030
|
+
|
|
1031
|
+
```ts
|
|
1032
|
+
world.emit('grunt', self.id, {}, { within: 45 }); // earshot of where self stands
|
|
1033
|
+
world.emit('hitConfirm', target.id, { damage }, { to: shooter.id }); // the shooter alone
|
|
1034
|
+
world.emit('alarm', self.pos, {}, { within: 30, to: allies }); // either is enough
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
`within` is metres from the effect's place (the named entity's position at that
|
|
1038
|
+
moment) to the listener's own body; `to` names entities, and the people driving
|
|
1039
|
+
them receive it. A watcher, and a seat with no body yet, perceive the room: they
|
|
1040
|
+
receive every effect with a `within`, and never one addressed only `to` somebody.
|
|
1041
|
+
An effect with no audience is public, exactly as before. Delivery costs one
|
|
1042
|
+
frame a listener on ticks that have such effects and nothing on ticks that do
|
|
1043
|
+
not. Send what is perceivable (a hit landed, roughly how hard); send exact
|
|
1044
|
+
numbers only `to` the player entitled to them.
|
|
1045
|
+
|
|
1046
|
+
### Predicting the player's own actions (0.46.3)
|
|
1047
|
+
|
|
1048
|
+
The own position is predicted by `move`. Other own actions (a weapon swap, a
|
|
1049
|
+
build, a reload, a hit marker) otherwise wait for the server's round trip. Give
|
|
1050
|
+
the view a predictor for the kind:
|
|
1051
|
+
|
|
1052
|
+
```ts
|
|
1053
|
+
const room = openRoom<typeof rules>({ predict: { runner(me, input, ctx) {
|
|
1054
|
+
if (input.select) swap(me, input.select, ctx.tick * ctx.dt); // the rules' own pure helper
|
|
1055
|
+
if (input.build && me.builds > 0) { me.builds--; ctx.ghost('cover', spot(me), { size }); }
|
|
1056
|
+
if (input.fire && aimHits(ctx, me)) ctx.effect('hit', me.id, {});
|
|
1057
|
+
} } });
|
|
1058
|
+
```
|
|
1059
|
+
|
|
1060
|
+
The room runs it for the own body once a predicted tick, after `move`, with that
|
|
1061
|
+
tick's input, and on a copy between ticks, so a press answers on the next drawn
|
|
1062
|
+
frame. `me` holds the body's declared fields (and readonly `id`, `pos`, `vel`,
|
|
1063
|
+
`heading`, `grounded`, `motion`); what it writes is held to the declared types
|
|
1064
|
+
and read back through `room.me`. Every snapshot puts the server's values back
|
|
1065
|
+
and runs the still-pending inputs again, exactly as it does for the position, so
|
|
1066
|
+
a wrong guess corrects itself. Share pure helpers with the rules (no `world`):
|
|
1067
|
+
the server's handler remains the authority and the only place a result counts.
|
|
1068
|
+
|
|
1069
|
+
`ctx.ghost(kind, at, fields?, {match?, seconds?})` draws a provisional entity:
|
|
1070
|
+
`room.each` yields it with `provisional: true` until an entity of that kind that
|
|
1071
|
+
`match(entity)` accepts (default: within a metre) arrives, and then until that
|
|
1072
|
+
one is drawn, so there is never a gap or a double. `ctx.effect(name, at, data?,
|
|
1073
|
+
{match?})` fires the effect's listeners now with `predicted: true`; the server's
|
|
1074
|
+
own effect of that name then arrives with `confirms: true` (use its exact data,
|
|
1075
|
+
do not replay the sound). If the server ran the tick without confirming, the
|
|
1076
|
+
room emits `retract` (`{ghost, kind}` or `{effect, ...data}`) and the ghost
|
|
1077
|
+
leaves: take the marker down quietly. `ctx.ray/rays/rayAll` query what the
|
|
1078
|
+
player sees (`ctx.viewTick`); `ctx.preview` says the run is between ticks. A
|
|
1079
|
+
predictor runs often: keep it cheap, and keep per-entity state in `me`.
|
|
1080
|
+
|
|
1081
|
+
A tick's prediction runs more than once (between ticks, again when the input
|
|
1082
|
+
changes, then the tick itself) and shows each thing once. Things are told apart
|
|
1083
|
+
by their name and `key`: give two effects of one name that mean different things
|
|
1084
|
+
(`ctx.effect('audio', me.id, {name: 'jump'}, {key: 'jump'})` beside a `reload`)
|
|
1085
|
+
a `key` each, and the same for two ghosts of a kind. Read `me.id` into a constant
|
|
1086
|
+
before using it in a `match` closure: `me` is reused. A valued pulse
|
|
1087
|
+
(`f.pulse()`) carries the tick it was predicted on, and the server acts on it in
|
|
1088
|
+
that tick, so a predicted swap is in the snapshot of the tick it was drawn for.
|
|
1089
|
+
|
|
989
1090
|
### Discrete actions with a value
|
|
990
1091
|
|
|
991
1092
|
Use `select: f.pulse()` for a weapon slot or other byte-valued action. Call
|
package/guides/game/SKILL.md
CHANGED
|
@@ -243,6 +243,11 @@ world or inventory, use Lasting worlds above. Ordinary match rooms expire after
|
|
|
243
243
|
handle. Draw the character's name over its body and in the ranking (a declared command requests it; rules validate and keep it in the player entity), except on a kids server, where the others stay handles. Ember Vale does it.
|
|
244
244
|
- Show who is playing (`saves.player.name`, guest or signed in) and a small "Keep my progress" button that calls
|
|
245
245
|
`saves.signIn()`; the play page shows its passkey sheet. Pressing Play never needs an account.
|
|
246
|
+
- A guest who signs in to an account that already has the same key: say how the two combine, once, in
|
|
247
|
+
`site/src/worker.mjs`: `mergeSaves({ '<id>': { saves: { coins: 'sum', 'best-level': 'larger', settings: 'newer' },
|
|
248
|
+
stats: { kills: 'sum' } } })` from `@homie-rocks/studio/worker` (or a function for an inventory). Server records
|
|
249
|
+
are joined without a rule; any other key with no rule reaches the game's `onConflict` (SAVES.md, "When a guest
|
|
250
|
+
signs in to their account").
|
|
246
251
|
- Prove it: build, `npm run dev`, open `http://localhost:8787/<id>/play` (passkeys need `localhost`, not
|
|
247
252
|
`127.0.0.1`), play, reload: the progress is still there. The whole guide is
|
|
248
253
|
`node_modules/@homie-rocks/studio/saves/SAVES.md` (the limits, offline and conflicts, what is stored, privacy).
|
|
@@ -233,7 +233,7 @@ function play(c, companions, allow) {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
/* ---- what the people press. Each plays one of three ways, changing every 20 s; seat 1 never sends anything. */
|
|
236
|
-
const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
|
|
236
|
+
const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' || fd.t === 'held' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
|
|
237
237
|
const steering = new Map();
|
|
238
238
|
let moveTick = 0;
|
|
239
239
|
let moveSelf;
|
package/lib/rules-check.mjs
CHANGED
|
@@ -28,10 +28,10 @@ export const LONG_ALLOWANCE = Object.freeze({ ...ALLOWANCE, ticks: 8 * ALLOWANCE
|
|
|
28
28
|
|
|
29
29
|
/** The ordered declarations a save depends on. Functions contribute their presence, never their implementation. */
|
|
30
30
|
export function stateHash(c) {
|
|
31
|
-
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save, historySeconds } = c;
|
|
31
|
+
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save, historySeconds, rewindSeconds } = c;
|
|
32
32
|
// Keep existing 2D save compatibility hashes when the 3D map vocabulary grows.
|
|
33
33
|
const map = dims === 2 ? { name: geometry.name, bounds: geometry.bounds, boxes: geometry.boxes, circles: geometry.circles, spots: geometry.spots } : geometry;
|
|
34
|
-
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, ...(historySeconds?{historySeconds}:{}), tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
34
|
+
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, ...(historySeconds?{historySeconds}:{}), ...(rewindSeconds!==undefined?{rewindSeconds}:{}), tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
package/lib/rules-types.mjs
CHANGED
|
@@ -78,7 +78,7 @@ type W = World<${parameters}>;
|
|
|
78
78
|
type RW = World<${parameters}, true>;
|
|
79
79
|
type Definition = Omit<RulesDef, 'entities' | 'room' | 'move' | 'asks'> & {
|
|
80
80
|
entities: ${entities}; move?: Moves;
|
|
81
|
-
room?: { records?: {key:string;identity:string;eligible?:string;fields:Readonly<Record<string,string>>}; rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number }; historySeconds?: number;
|
|
81
|
+
room?: { records?: {key:string;identity:string;eligible?:string;fields:Readonly<Record<string,string>>}; rounds?: { seconds: number; breakSeconds: number }; bots?: { keep: number }; historySeconds?: number; rewindSeconds?: number;
|
|
82
82
|
start?: (world: RW) => void;
|
|
83
83
|
join?: (ctx: Pick<W, 'map' | 'shared' | 'tune' | 'math' | 'round'>, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: (${joins}) | null }) => ${joins};
|
|
84
84
|
on?: { [K in keyof (Omit<Events, keyof GameRoomEvents> & GameRoomEvents)]?: (world: RW, event: EventData<(Omit<Events, keyof GameRoomEvents> & GameRoomEvents)[K]>) => void };
|
|
@@ -2775,6 +2775,48 @@
|
|
|
2775
2775
|
}
|
|
2776
2776
|
}
|
|
2777
2777
|
}
|
|
2778
|
+
},
|
|
2779
|
+
"0.46.3": {
|
|
2780
|
+
"files": {
|
|
2781
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2782
|
+
"README.md": "684b3cca8001673c",
|
|
2783
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2784
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2785
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2786
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2787
|
+
"posts/README.md": "37841aae476bd540",
|
|
2788
|
+
"site/README.md": "732894273a32dc3f",
|
|
2789
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2790
|
+
},
|
|
2791
|
+
"sections": {
|
|
2792
|
+
"AGENTS.md": {
|
|
2793
|
+
"(intro)": "394cbbe934e80722",
|
|
2794
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2795
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2796
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2797
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
2798
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2799
|
+
"## Making games": "bb715272fd1a615b",
|
|
2800
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2801
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2802
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2803
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2804
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2805
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2806
|
+
"## Rules": "27881d11d3ff3803",
|
|
2807
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2808
|
+
"## Beta": "9d02638207a26e73"
|
|
2809
|
+
}
|
|
2810
|
+
},
|
|
2811
|
+
"named": {
|
|
2812
|
+
"Homie Arcade": {
|
|
2813
|
+
"sections": {
|
|
2814
|
+
"AGENTS.md": {
|
|
2815
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2778
2820
|
}
|
|
2779
2821
|
}
|
|
2780
2822
|
}
|
package/netplay/netplay.ts
CHANGED
|
@@ -1682,7 +1682,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1682
1682
|
else if (m.t === 'state' && typeof m.k === 'string') applyState(m.k, m.d);
|
|
1683
1683
|
else if (m.t === 'round') { const incoming = m.round as RoundInfo; roundInfo = m.retime && roundInfo?.n === incoming.n ? { ...roundInfo, ...incoming } : incoming; if (!m.retime) { emit('round', roundInfo); post?.({ what: 'round', round: roundInfo }); } }
|
|
1684
1684
|
else if (m.t === 'roster' && (offline || !connected)) { slots = m.slots as Slot[]; emit('roster', slots); post?.({ what: 'roster', slots }); }
|
|
1685
|
-
else if (m.t === 'ev' && (m.to === undefined || m.to === seat || m.to === id)) emit('event', { k: String(m.k), d: m.d, from: typeof m.from === 'number' ? m.from : null });
|
|
1685
|
+
else if (m.t === 'ev' && (m.to === undefined || m.to === seat || m.to === id || (m.to === -1 && seat === null))) emit('event', { k: String(m.k), d: m.d, from: typeof m.from === 'number' ? m.from : null });
|
|
1686
1686
|
}
|
|
1687
1687
|
let role: Role = 'replica';
|
|
1688
1688
|
let roleKnown = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.3",
|
|
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
|
@@ -69,6 +69,10 @@ export const SEND = 16;
|
|
|
69
69
|
export const THROWN = 512;
|
|
70
70
|
/** The most effects (`world.emit`) one tick takes. */
|
|
71
71
|
export const FX_MAX = 256;
|
|
72
|
+
/** The most entities one effect's audience names (`world.emit(effect, at, data, { to })`). */
|
|
73
|
+
export const FX_TO_MAX = 32;
|
|
74
|
+
/** Who may perceive an effect: within so many metres of where it happened (-1: no distance), the entities named, and where it happened. */
|
|
75
|
+
export type FxAudience = [within: number, to: string[], from: [number, number, number] | null];
|
|
72
76
|
/** The most area events (`world.sendArea`) one tick takes. */
|
|
73
77
|
export const AREAS_MAX = 64;
|
|
74
78
|
/** What the log names when a room ends because of what it holds, not because of a handler. */
|
|
@@ -85,7 +89,7 @@ export interface StepInput { values: Record<string, unknown>; claim?: { pos: Vec
|
|
|
85
89
|
export interface ResultRow { seat: number; id: string; driver: Driver; score: number; place: number }
|
|
86
90
|
export type CoreOut =
|
|
87
91
|
| { t: 'round'; n: number; phase: 'live' | 'over'; endsAt: number; startedAt: number; results?: ResultRow[] }
|
|
88
|
-
| { t: 'fx'; tick: number; list: [number, unknown, unknown][] }
|
|
92
|
+
| { t: 'fx'; tick: number; list: [number, unknown, unknown, FxAudience?][] }
|
|
89
93
|
| { t: 'ask'; n: string; state: unknown; questions: Record<string, unknown> }
|
|
90
94
|
| { t: 'goalDone'; seat: number; ok: boolean }
|
|
91
95
|
| { t: 'seats' }
|
|
@@ -162,6 +166,8 @@ export interface Core {
|
|
|
162
166
|
/** The current round, for a host rebasing wall-clock deadlines after a pause or restore. */
|
|
163
167
|
round(): Extract<CoreOut, { t: 'round' }>;
|
|
164
168
|
bodies(): Body[];
|
|
169
|
+
/** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
|
|
170
|
+
listeners(): { seat: number; id: string; pos: Vec3 }[];
|
|
165
171
|
/** The body in a seat, as the host runtime needs it for the input protocol. */
|
|
166
172
|
character(seat: number): import('./rules.ts').Character | null;
|
|
167
173
|
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
|
|
@@ -235,7 +241,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
235
241
|
let sharedROVer = -1;
|
|
236
242
|
const sharedChanged = (): void => { sharedDirty = true; sharedVer += 1; };
|
|
237
243
|
let out: CoreOut[] = [];
|
|
238
|
-
let fx: [number, unknown, unknown][] = [];
|
|
244
|
+
let fx: [number, unknown, unknown, FxAudience?][] = [];
|
|
239
245
|
/** What is left of the running tick's budget. A room's first `room.start` runs before any tick, with a whole one. */
|
|
240
246
|
let left = c.settings.budget.tick;
|
|
241
247
|
let trips = 0;
|
|
@@ -527,6 +533,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
527
533
|
const world = brand({} as Record<string, unknown>);
|
|
528
534
|
const getters: Record<string, () => unknown> = {
|
|
529
535
|
tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax, guideLevel: () => policy.guideLevel ?? policy.level, guideSeats: () => policy.guideSeats ?? EMPTY_GUIDE_SEATS, kids: () => Boolean(policy.kids), levelSet: () => Boolean(policy.levelSet),
|
|
536
|
+
// What the running handler may still spend (0.46.3): a handler that plans can stop short of its share instead of being stopped.
|
|
537
|
+
unitsLeft: () => Math.max(0, Math.min(2147483647, Math.floor(G.left))),
|
|
530
538
|
stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
|
|
531
539
|
};
|
|
532
540
|
for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
|
|
@@ -594,14 +602,30 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
594
602
|
const wait = w >= 1 ? Math.min(w, 0x7fffffff) : 1;
|
|
595
603
|
push({ due: tick + wait, to: cx.ent ? cx.ent.id : '', kind: cx.ent ? 'ev' : 'room', ev: ev as string, data: shapeData(c.events, ev, 'the event', data), at: tick });
|
|
596
604
|
},
|
|
597
|
-
emit: (effect: unknown, at: unknown, data?: unknown): void => {
|
|
605
|
+
emit: (effect: unknown, at: unknown, data?: unknown, audience?: unknown): void => {
|
|
598
606
|
charge(10);
|
|
599
607
|
if (cx.scope !== 'ent' && cx.scope !== 'room') throw new Error('world.emit is for handlers');
|
|
600
608
|
const i = typeof effect === 'string' ? c.effectNames.indexOf(effect) : -1;
|
|
601
609
|
if (i < 0) throw new Error(`the effect "${said(effect)}" is not declared in shapes.effects`);
|
|
602
610
|
if (fx.length >= FX_MAX) { G.note?.('effect', '', said(effect)); return; }
|
|
603
611
|
const shape = c.effects[c.effectNames[i]];
|
|
604
|
-
|
|
612
|
+
const row: [number, unknown, unknown, FxAudience?] = [i, typeof at === 'string' ? at.slice(0, 24) : packVec(V(at), dims), packFields(shape, shapeData(c.effects, effect, 'the effect', data), dims)];
|
|
613
|
+
// An audience (0.46.3): who may perceive this effect. Without one it is public, as before.
|
|
614
|
+
if (audience !== undefined && audience !== null) {
|
|
615
|
+
charge(8);
|
|
616
|
+
const to = own(audience, 'to'), within = own(audience, 'within');
|
|
617
|
+
const ids: string[] = [];
|
|
618
|
+
if (typeof to === 'string') ids.push(to.slice(0, 24));
|
|
619
|
+
else if (Array.isArray(to)) { if (to.length > FX_TO_MAX) throw new Error(`an effect's audience names at most ${FX_TO_MAX} entities`); for (let n = 0; n < to.length; n++) { const id = to[n]; if (typeof id === 'string') ids.push(id.slice(0, 24)); } }
|
|
620
|
+
else if (to !== undefined) throw new Error('an effect\'s audience is { to: id | [ids], within: metres }');
|
|
621
|
+
if (within !== undefined && (typeof within !== 'number' || !Number.isFinite(within) || within < 0)) throw new Error('an effect\'s audience `within` is a distance in metres');
|
|
622
|
+
if (typeof to === 'undefined' && within === undefined) throw new Error('an effect\'s audience is { to: id | [ids], within: metres }');
|
|
623
|
+
// The place it is heard from: where it was emitted, or where the entity it names stands now.
|
|
624
|
+
const e = typeof at === 'string' ? ents.get(at.slice(0, 24)) : undefined;
|
|
625
|
+
const from = typeof at === 'string' ? (e && !e.dead ? e.pos : null) : V(at);
|
|
626
|
+
row.push([typeof within === 'number' ? within : -1, ids, from ? [from.x, from.y, from.z] : null]);
|
|
627
|
+
}
|
|
628
|
+
fx.push(row);
|
|
605
629
|
},
|
|
606
630
|
spawn: (kind: unknown, at: unknown, fields?: unknown): string => {
|
|
607
631
|
charge(10);
|
|
@@ -1484,6 +1508,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1484
1508
|
shared: () => packFields(c.shared, shared, dims),
|
|
1485
1509
|
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; },
|
|
1486
1510
|
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 })),
|
|
1511
|
+
listeners: () => playerBodies().filter((e) => e.driver === 'person').map((e) => ({ seat: e.seat, id: e.id, pos: e.pos })),
|
|
1487
1512
|
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; },
|
|
1488
1513
|
drain: () => { const o = out; out = []; return o; },
|
|
1489
1514
|
save,
|
package/rules/host.ts
CHANGED
|
@@ -132,9 +132,12 @@ export interface Host {
|
|
|
132
132
|
readonly core: Core;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim'] }
|
|
135
|
+
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 }
|
|
136
136
|
interface SeatIn {
|
|
137
|
-
|
|
137
|
+
/** A valued pulse, and the tick its browser predicted it on (0: none given, act at once). */
|
|
138
|
+
pulses?: [string,number,number?,number?][];
|
|
139
|
+
/** The wait of the entry now held, in ticks (see Entry.early). */
|
|
140
|
+
early?: number;
|
|
138
141
|
held: Record<string, unknown>; claim: StepInput['claim']; entries: Entry[];
|
|
139
142
|
/** The newest stamp this seat has sent, the stamps not yet acknowledged, and the newest acknowledged. */
|
|
140
143
|
newest: number; stamps: number[]; ack: number;
|
|
@@ -254,7 +257,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
254
257
|
for (const [seat, q] of saved.inputs) {
|
|
255
258
|
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);
|
|
256
259
|
check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
|
|
257
|
-
if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&p.length===2&&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));
|
|
260
|
+
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));
|
|
258
261
|
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)))));
|
|
259
262
|
}
|
|
260
263
|
}
|
|
@@ -273,7 +276,26 @@ export function createHost(o: HostOptions): Host {
|
|
|
273
276
|
}
|
|
274
277
|
},
|
|
275
278
|
} : {};
|
|
276
|
-
|
|
279
|
+
/*
|
|
280
|
+
* How far back a person's ray may look (0.46.3). A shot is judged where the shooter saw the target, so the rewind is
|
|
281
|
+
* the age of that view when the input was RECEIVED, plus the time the input then waited for its tick:
|
|
282
|
+
*
|
|
283
|
+
* the view's age the relay's own measurement of this connection's round trip (a low percentile over a window of
|
|
284
|
+
* acknowledgements carried by the input frames themselves, worker/room.mjs), plus 150 ms of
|
|
285
|
+
* presentation delay. A client cannot bring the measurement under its real latency. It can
|
|
286
|
+
* pretend to be slower, by holding back what it sends, and then its input is late too.
|
|
287
|
+
* the wait whole ticks between an entry's arrival and the tick it was stamped for (at least one, at most a
|
|
288
|
+
* quarter of a second): tied to when the server received the input, not to what the client says.
|
|
289
|
+
*
|
|
290
|
+
* `room.rewindSeconds` is the game's hard cap on the sum. Without it the cap is `room.historySeconds`, as before.
|
|
291
|
+
*/
|
|
292
|
+
const rewindCap = c.rewindSeconds !== undefined ? Math.ceil(c.rewindSeconds * tickHz) : null;
|
|
293
|
+
const rewindTicks = (seat: number): number => {
|
|
294
|
+
const waited = Math.max(1, Math.min(lateTicks, queues.get(seat)?.early ?? 1));
|
|
295
|
+
const need = Math.ceil(((measuredRtt.get(seat) ?? 0) + 150) / period) + waited;
|
|
296
|
+
return rewindCap === null ? need : Math.min(rewindCap, need);
|
|
297
|
+
};
|
|
298
|
+
const core = createCore(c, { ...characterOptions, rewindTicks, label: (seat,driver)=>nameOf(seat,driver), moved: o.moved, observe: o.observe, noted: o.noted, seed: Math.floor(random() * 4294967296) >>> 0, epoch: saved ? undefined : (Math.floor(random() * 4294967295) >>> 0) + 1, restore: saved?.core ?? null, restoreEpoch: o.restoreEpoch, stage: o.stage, decisions: !o.check });
|
|
277
299
|
let epoch = core.epoch;
|
|
278
300
|
if (saved && (o.startPaused ?? o.restoreEpoch !== undefined)) for (const body of core.bodies()) if (body.driver !== 'bot' && body.owner !== 'reserved') core.seatAway(body.seat, true);
|
|
279
301
|
let lastSaveAt = o.clock.now();
|
|
@@ -415,9 +437,21 @@ export function createHost(o: HostOptions): Host {
|
|
|
415
437
|
for (const [seat, held, claim, newest, ack] of saved?.queues ?? []) queues.set(seat, { held, claim, entries: [], newest, stamps: [], ack, lead: 127, lastFrameTick: core.tick });
|
|
416
438
|
for (const [seat, q] of saved?.inputs ?? []) queues.set(seat, q);
|
|
417
439
|
const neutral = (kind: KindTable): Record<string, unknown> => { const v = initFields(kind.input, c.dims); for (const [name, fd] of kind.input) if (fd.t === 'press' || fd.t === 'pulse') v[name] = fd.t === 'press' ? false : 0; return v; };
|
|
440
|
+
/*
|
|
441
|
+
* `f.held()` inputs that arrive late (0.46.3). A held value is how much of ONE tick a control was down, so the sum over
|
|
442
|
+
* a tap's ticks is the tap. When an entry misses its tick, that tick has already run on the value before it (a
|
|
443
|
+
* release that arrives late ran one more whole tick of movement), and applying the entry a tick later as well would
|
|
444
|
+
* count the tap twice over. So the difference between what the missed ticks ran on and what they were meant to run
|
|
445
|
+
* on is owed, and paid on the next ticks, inside the range the control reads in. The body then ends where the
|
|
446
|
+
* browser predicted it, however the frames fell. Kept beside the queues and not saved: a restart forgets a debt.
|
|
447
|
+
*/
|
|
448
|
+
const heldNames = new Map<KindTable, string[]>();
|
|
449
|
+
const heldOf = (kind: KindTable): string[] => { let names = heldNames.get(kind); if (!names) { names = kind.input.filter(([, fd]) => fd.t === 'held').map(([name]) => name); heldNames.set(kind, names); } return names; };
|
|
450
|
+
const pulsesTaken = new Map<number, number>();
|
|
451
|
+
const heldBook = new Map<number, { ran: [number, Record<string, number>][]; debt: Record<string, number>; until: number }>();
|
|
418
452
|
function queueOf(seat: number, kind: KindTable): SeatIn {
|
|
419
453
|
let q = queues.get(seat);
|
|
420
|
-
if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); }
|
|
454
|
+
if (!q) { q = { held: neutral(kind), claim: null, entries: [], newest: 0, stamps: [], ack: 0, lead: 127, lastFrameTick: core.tick }; queues.set(seat, q); pulsesTaken.delete(seat); }
|
|
421
455
|
return q;
|
|
422
456
|
}
|
|
423
457
|
const people = (): number => { let n = 0; for (const person of present.values()) if (person) n += 1; return n; };
|
|
@@ -461,9 +495,11 @@ export function createHost(o: HostOptions): Host {
|
|
|
461
495
|
}
|
|
462
496
|
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;
|
|
463
497
|
q.stamps.push(j);
|
|
464
|
-
if (j > K) { q.entries.push({ at: j, values, presses, claim }); continue; }
|
|
498
|
+
if (j > K) { q.entries.push({ at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
|
|
465
499
|
// 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.
|
|
466
500
|
stats.lateEntries += 1;
|
|
501
|
+
const book = heldBook.get(seat as number);
|
|
502
|
+
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; }
|
|
467
503
|
const at = (K + 1) >>> 0;
|
|
468
504
|
const keep = at - j <= lateTicks ? presses : [];
|
|
469
505
|
const same = q.entries.find((x) => x.at === at);
|
|
@@ -554,9 +590,15 @@ export function createHost(o: HostOptions): Host {
|
|
|
554
590
|
if (agents && (kind === 'agent:do' || /^ask:/.test(kind) || (SPEECH.test(kind) && present.get(m.from as number) === false))) { if (agentInbox.length < 128) agentInbox.push(m); return; }
|
|
555
591
|
if(kind === 'pulse') {
|
|
556
592
|
const data=m.d,body=core.bodyOf(m.from as number);
|
|
557
|
-
if(!body || !hasHands(m.from as number,body.driver) || !Array.isArray(data) || data.length
|
|
593
|
+
if(!body || !hasHands(m.from as number,body.driver) || !Array.isArray(data) || data.length<3 || data.length>5 || data[0]!==epoch || typeof data[1]!=='string' || !body.kind.input.some(([name,fd])=>name===data[1]&&fd.t==='pulse'))return;
|
|
558
594
|
const value=coerce({t:'pulse'},data[2],c.dims) as number,q=queueOf(m.from as number,body.kind);q.pulses??=[];
|
|
559
|
-
|
|
595
|
+
// The browser names the tick it predicted the pulse on (0.46.3): the server acts on it in that tick, as it does for a
|
|
596
|
+
// stamped input, so what was predicted is what the tick's snapshot holds. A tick already run, or none: at once.
|
|
597
|
+
const at=Number.isInteger(data[3])&&(data[3] as number)>0?Math.min(data[3] as number,0xffffffff):0;
|
|
598
|
+
// Its number in this browser's own count (0.46.3): the control row says the newest one acted on, so a browser knows
|
|
599
|
+
// whether a pulse it predicted has happened yet, however late its message was.
|
|
600
|
+
const n=Number.isInteger(data[4])&&(data[4] as number)>0?Math.min(data[4] as number,0xffffffff):0;
|
|
601
|
+
if(value && q.pulses.length<16)q.pulses.push(n?[data[1],value,at,n]:at?[data[1],value,at]:[data[1],value]);return;
|
|
560
602
|
}
|
|
561
603
|
if (kind === 'cmd') { const d = m.d as unknown[]; if (Array.isArray(d) && typeof d[0] === 'string') core.command(m.from as number, d[0], d[1]); return; }
|
|
562
604
|
// Speech keeps its payload, except the identity fields only the host may attach.
|
|
@@ -616,10 +658,27 @@ export function createHost(o: HostOptions): Host {
|
|
|
616
658
|
} });
|
|
617
659
|
} else if (x.t === 'ask') o.send({ t: 'decide', n: x.n, state: x.state, questions: x.questions });
|
|
618
660
|
else if (x.t === 'goalDone') agents?.done(x.seat, x.ok);
|
|
619
|
-
else if (x.t === 'fx')
|
|
661
|
+
else if (x.t === 'fx') {
|
|
662
|
+
// Effects with an audience (0.46.3) go to who may perceive them: one frame a person whose body is named or in
|
|
663
|
+
// range, with the tick's public effects in the order they were emitted. A watcher (no seat) and a seat with no
|
|
664
|
+
// body perceive the room: every effect with a range, never one addressed only `to` somebody.
|
|
665
|
+
// A tick with no audience at all is one frame for everybody, as before.
|
|
666
|
+
if (!x.list.some((row) => row[3])) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, x.list] });
|
|
667
|
+
else {
|
|
668
|
+
const all = x.list.map((row) => [row[0], row[1], row[2]]);
|
|
669
|
+
const bodies = new Map(core.listeners().map((l) => [l.seat, l]));
|
|
670
|
+
const watching = all.filter((_, n) => { const a = x.list[n][3]; return !a || a[0] >= 0; });
|
|
671
|
+
for (const seat of present.keys()) {
|
|
672
|
+
const l = bodies.get(seat);
|
|
673
|
+
const mine = !l ? watching : all.filter((_, n) => { const a = x.list[n][3]; return !a || a[1].includes(l.id) || (a[0] >= 0 && a[2] !== null && Math.hypot(a[2][0] - l.pos.x, a[2][1] - l.pos.y, a[2][2] - l.pos.z) <= a[0]); });
|
|
674
|
+
if (mine.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, mine], to: seat });
|
|
675
|
+
}
|
|
676
|
+
if (watching.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, watching], to: -1 });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
620
679
|
else if (x.t === 'seats') roster = true;
|
|
621
680
|
else if (x.t === 'shared') o.send({ t: 'state', k: 'shared', d: core.shared() });
|
|
622
|
-
else if (x.t === 'epoch') { forceSave = true; epoch = x.epoch; queues.clear(); roster = true; } else if (x.t === 'fail') end('budget', { kind: x.kind, handler: x.handler });
|
|
681
|
+
else if (x.t === 'epoch') { forceSave = true; epoch = x.epoch; queues.clear(); heldBook.clear(); pulsesTaken.clear(); roster = true; } else if (x.t === 'fail') end('budget', { kind: x.kind, handler: x.handler });
|
|
623
682
|
}
|
|
624
683
|
if (roster || !rosterText) sendRoster();
|
|
625
684
|
}
|
|
@@ -639,15 +698,26 @@ export function createHost(o: HostOptions): Host {
|
|
|
639
698
|
const presses: (string | [string, number])[] = [];
|
|
640
699
|
while (q.entries.length && q.entries[0].at <= t) {
|
|
641
700
|
const e = q.entries.shift() as Entry;
|
|
642
|
-
q.held = e.values; q.claim = e.claim;
|
|
701
|
+
q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
|
|
643
702
|
if (e.at === t) for (const p of e.presses) presses.push(p);
|
|
644
703
|
}
|
|
645
704
|
// Silence: no frame for a second. Its input is neutral until the next entry.
|
|
646
705
|
if (t - q.lastFrameTick > tickHz && !q.entries.length) { q.held = neutral(body.kind); q.claim = null; }
|
|
647
706
|
while (q.stamps.length && q.stamps[0] <= t) q.ack = q.stamps.shift() as number;
|
|
648
707
|
const values = { ...q.held };
|
|
708
|
+
const heldFields = heldOf(body.kind);
|
|
709
|
+
if (heldFields.length) {
|
|
710
|
+
let book = heldBook.get(seat);
|
|
711
|
+
if (!book) { book = { ran: [], debt: {}, until: 0 }; heldBook.set(seat, book); }
|
|
712
|
+
const level: Record<string, number> = {};
|
|
713
|
+
for (const name of heldFields) level[name] = Number(values[name]) || 0;
|
|
714
|
+
book.ran.push([t, level]); if (book.ran.length > lateTicks + 1) book.ran.shift();
|
|
715
|
+
if (book.until >= t) for (const name of heldFields) { const owed = book.debt[name]; if (owed) { const range = Math.max(1, Math.abs(level[name])), want = level[name] + owed, paid = Math.max(-range, Math.min(range, want)); values[name] = paid; book.debt[name] = want - paid; } }
|
|
716
|
+
else book.debt = {};
|
|
717
|
+
}
|
|
649
718
|
for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
|
|
650
|
-
|
|
719
|
+
// 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.
|
|
720
|
+
const head=q.pulses?.[0];if(head&&!((head[2]??0)>t&&(head[2] as number)-t<=Math.ceil(tickHz/4))){q.pulses!.shift();values[head[0]]=head[1];if(head[3])pulsesTaken.set(seat,head[3]);}
|
|
651
721
|
inputs.set(seat, { values, claim: q.claim });
|
|
652
722
|
}
|
|
653
723
|
if (roundRebasePending) publishRebase(now);
|
|
@@ -677,7 +747,9 @@ export function createHost(o: HostOptions): Host {
|
|
|
677
747
|
for (const b of core.bodies()) {
|
|
678
748
|
if (!hasHands(b.seat, b.driver)) continue;
|
|
679
749
|
const q = queues.get(b.seat);
|
|
680
|
-
|
|
750
|
+
// A fifth number only for a seat that has sent a numbered pulse: the newest one acted on.
|
|
751
|
+
const taken = pulsesTaken.get(b.seat);
|
|
752
|
+
rows.push(taken === undefined ? [b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128] : [b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128, taken]);
|
|
681
753
|
if (q) q.lead = 127;
|
|
682
754
|
}
|
|
683
755
|
// A snapshot is stamped with the moment its tick was due, so ticks run in a burst to catch up are still a tick
|
package/rules/pack.ts
CHANGED
|
@@ -140,6 +140,7 @@ const made = <T extends object>(fd: Field, o: T): T => { Object.freeze(o); MADE.
|
|
|
140
140
|
export function coerce(fd: Field, v: unknown, dims: number): unknown {
|
|
141
141
|
switch (fd.t) {
|
|
142
142
|
case 'bit': case 'press': return v === true || v === 1;
|
|
143
|
+
case 'held':
|
|
143
144
|
case 'fix': { const x = num(v); const n = Math.round(x * FIX_STEP) / FIX_STEP; return Number.isFinite(n) ? Math.max(-FIX_MAX, Math.min(FIX_MAX, n)) + 0 : zeroed(v, x); }
|
|
144
145
|
case 'vec3': return vec3(v, dims);
|
|
145
146
|
case 'dir': return dir(v, dims);
|