@homie-rocks/studio 0.46.2 → 0.46.5
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 +106 -0
- package/bin/homie-studio.mjs +29 -10
- package/guides/game/RULES.md +127 -3
- package/guides/game/SKILL.md +7 -0
- package/lib/check-site.mjs +85 -0
- package/lib/check.mjs +2 -1
- 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 +84 -0
- package/netplay/NETPLAY.md +12 -0
- package/netplay/netplay.ts +3 -1
- package/package.json +1 -1
- package/rules/core.ts +29 -4
- package/rules/host.ts +94 -14
- package/rules/pack.ts +1 -0
- package/rules/rules.ts +24 -7
- package/rules/types.ts +5 -0
- package/rules/view.ts +291 -33
- package/saves/SAVES.md +83 -3
- package/saves/saves.ts +2 -0
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +20 -2
- package/worker/pages.mjs +46 -1
- 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/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;
|
|
@@ -2547,6 +2547,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2547
2547
|
// An AI alone in a room (nobody seated): it knocks again only after a while (section 17).
|
|
2548
2548
|
if (code === 'agents-alone') aloneUntil = wall() + AGENTS_ALONE_WAIT_MS;
|
|
2549
2549
|
if (code === 'host-fault') { rulesNotice(String(m.message)); return; }
|
|
2550
|
+
// A result the server could not keep for this player (0.46.5): said on their screen, not dropped in silence.
|
|
2551
|
+
if (code === 'unsaved') { rulesNotice(String(m.message)); return; }
|
|
2550
2552
|
if (code === 'vote') { warnOnce('vote', m['message']); return; }
|
|
2551
2553
|
if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
|
|
2552
2554
|
// The room still runs another build of the game (section 23): not final. Its players were told to reload; this
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.46.
|
|
3
|
+
"version": "0.46.5",
|
|
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,27 @@ 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 historyTicks = Math.ceil((c.historySeconds ?? 0) * tickHz);
|
|
293
|
+
const rewindCap = c.rewindSeconds !== undefined ? Math.ceil(c.rewindSeconds * tickHz) : null;
|
|
294
|
+
const rewindTicks = (seat: number): number => {
|
|
295
|
+
const waited = Math.max(1, Math.min(lateTicks, queues.get(seat)?.early ?? 1));
|
|
296
|
+
const need = Math.ceil(((measuredRtt.get(seat) ?? 0) + 150) / period) + waited;
|
|
297
|
+
return rewindCap === null ? need : Math.min(rewindCap, need);
|
|
298
|
+
};
|
|
299
|
+
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
300
|
let epoch = core.epoch;
|
|
278
301
|
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
302
|
let lastSaveAt = o.clock.now();
|
|
@@ -415,9 +438,21 @@ export function createHost(o: HostOptions): Host {
|
|
|
415
438
|
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
439
|
for (const [seat, q] of saved?.inputs ?? []) queues.set(seat, q);
|
|
417
440
|
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; };
|
|
441
|
+
/*
|
|
442
|
+
* `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
|
|
443
|
+
* a tap's ticks is the tap. When an entry misses its tick, that tick has already run on the value before it (a
|
|
444
|
+
* release that arrives late ran one more whole tick of movement), and applying the entry a tick later as well would
|
|
445
|
+
* count the tap twice over. So the difference between what the missed ticks ran on and what they were meant to run
|
|
446
|
+
* on is owed, and paid on the next ticks, inside the range the control reads in. The body then ends where the
|
|
447
|
+
* browser predicted it, however the frames fell. Kept beside the queues and not saved: a restart forgets a debt.
|
|
448
|
+
*/
|
|
449
|
+
const heldNames = new Map<KindTable, string[]>();
|
|
450
|
+
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; };
|
|
451
|
+
const pulsesTaken = new Map<number, number>();
|
|
452
|
+
const heldBook = new Map<number, { ran: [number, Record<string, number>][]; debt: Record<string, number>; until: number }>();
|
|
418
453
|
function queueOf(seat: number, kind: KindTable): SeatIn {
|
|
419
454
|
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); }
|
|
455
|
+
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
456
|
return q;
|
|
422
457
|
}
|
|
423
458
|
const people = (): number => { let n = 0; for (const person of present.values()) if (person) n += 1; return n; };
|
|
@@ -461,9 +496,11 @@ export function createHost(o: HostOptions): Host {
|
|
|
461
496
|
}
|
|
462
497
|
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
498
|
q.stamps.push(j);
|
|
464
|
-
if (j > K) { q.entries.push({ at: j, values, presses, claim }); continue; }
|
|
499
|
+
if (j > K) { q.entries.push({ at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
|
|
465
500
|
// 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
501
|
stats.lateEntries += 1;
|
|
502
|
+
const book = heldBook.get(seat as number);
|
|
503
|
+
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
504
|
const at = (K + 1) >>> 0;
|
|
468
505
|
const keep = at - j <= lateTicks ? presses : [];
|
|
469
506
|
const same = q.entries.find((x) => x.at === at);
|
|
@@ -554,9 +591,15 @@ export function createHost(o: HostOptions): Host {
|
|
|
554
591
|
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
592
|
if(kind === 'pulse') {
|
|
556
593
|
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
|
|
594
|
+
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
595
|
const value=coerce({t:'pulse'},data[2],c.dims) as number,q=queueOf(m.from as number,body.kind);q.pulses??=[];
|
|
559
|
-
|
|
596
|
+
// 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
|
|
597
|
+
// stamped input, so what was predicted is what the tick's snapshot holds. A tick already run, or none: at once.
|
|
598
|
+
const at=Number.isInteger(data[3])&&(data[3] as number)>0?Math.min(data[3] as number,0xffffffff):0;
|
|
599
|
+
// Its number in this browser's own count (0.46.3): the control row says the newest one acted on, so a browser knows
|
|
600
|
+
// whether a pulse it predicted has happened yet, however late its message was.
|
|
601
|
+
const n=Number.isInteger(data[4])&&(data[4] as number)>0?Math.min(data[4] as number,0xffffffff):0;
|
|
602
|
+
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
603
|
}
|
|
561
604
|
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
605
|
// Speech keeps its payload, except the identity fields only the host may attach.
|
|
@@ -586,7 +629,9 @@ export function createHost(o: HostOptions): Host {
|
|
|
586
629
|
/* ---------------------------------------------------------------- frames out */
|
|
587
630
|
|
|
588
631
|
const hasHands = (seat: number, driver: Driver): boolean => driver === 'person' || (driver === 'ai' && [...peers.values()].some((p) => p.seat === seat && p.agent?.hands === 'self'));
|
|
589
|
-
|
|
632
|
+
// The game's own names for its bots (room.bots.names, 0.46.5), else the toolkit's.
|
|
633
|
+
const botNames = c.botNames?.length ? c.botNames : BOT_NAMES;
|
|
634
|
+
const nameOf = (seat: number, driver: Driver): string => (driver === 'bot' || (driver === 'ai' && (core.bodies().find((b) => b.seat === seat)?.owner === 'reserved' || !names.has(seat))) ? botNames[seat % botNames.length] : names.get(seat) || `Player ${seat + 1}`);
|
|
590
635
|
function sendRoster(): void {
|
|
591
636
|
const guides = new Map(agentSlots().map((s) => [s.slot, s.agent]));
|
|
592
637
|
const slots = core.bodies().sort((a, b) => a.seat - b.seat).map((b) => ({ slot: b.seat, seat: b.driver === 'bot' ? null : b.seat, name: nameOf(b.seat, b.driver), bot: b.driver === 'bot', ...(guides.has(b.seat) ? { agent: guides.get(b.seat) } : {}) }));
|
|
@@ -616,10 +661,27 @@ export function createHost(o: HostOptions): Host {
|
|
|
616
661
|
} });
|
|
617
662
|
} else if (x.t === 'ask') o.send({ t: 'decide', n: x.n, state: x.state, questions: x.questions });
|
|
618
663
|
else if (x.t === 'goalDone') agents?.done(x.seat, x.ok);
|
|
619
|
-
else if (x.t === 'fx')
|
|
664
|
+
else if (x.t === 'fx') {
|
|
665
|
+
// Effects with an audience (0.46.3) go to who may perceive them: one frame a person whose body is named or in
|
|
666
|
+
// range, with the tick's public effects in the order they were emitted. A watcher (no seat) and a seat with no
|
|
667
|
+
// body perceive the room: every effect with a range, never one addressed only `to` somebody.
|
|
668
|
+
// A tick with no audience at all is one frame for everybody, as before.
|
|
669
|
+
if (!x.list.some((row) => row[3])) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, x.list] });
|
|
670
|
+
else {
|
|
671
|
+
const all = x.list.map((row) => [row[0], row[1], row[2]]);
|
|
672
|
+
const bodies = new Map(core.listeners().map((l) => [l.seat, l]));
|
|
673
|
+
const watching = all.filter((_, n) => { const a = x.list[n][3]; return !a || a[0] >= 0; });
|
|
674
|
+
for (const seat of present.keys()) {
|
|
675
|
+
const l = bodies.get(seat);
|
|
676
|
+
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]); });
|
|
677
|
+
if (mine.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, mine], to: seat });
|
|
678
|
+
}
|
|
679
|
+
if (watching.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, watching], to: -1 });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
620
682
|
else if (x.t === 'seats') roster = true;
|
|
621
683
|
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 });
|
|
684
|
+
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
685
|
}
|
|
624
686
|
if (roster || !rosterText) sendRoster();
|
|
625
687
|
}
|
|
@@ -639,15 +701,26 @@ export function createHost(o: HostOptions): Host {
|
|
|
639
701
|
const presses: (string | [string, number])[] = [];
|
|
640
702
|
while (q.entries.length && q.entries[0].at <= t) {
|
|
641
703
|
const e = q.entries.shift() as Entry;
|
|
642
|
-
q.held = e.values; q.claim = e.claim;
|
|
704
|
+
q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
|
|
643
705
|
if (e.at === t) for (const p of e.presses) presses.push(p);
|
|
644
706
|
}
|
|
645
707
|
// Silence: no frame for a second. Its input is neutral until the next entry.
|
|
646
708
|
if (t - q.lastFrameTick > tickHz && !q.entries.length) { q.held = neutral(body.kind); q.claim = null; }
|
|
647
709
|
while (q.stamps.length && q.stamps[0] <= t) q.ack = q.stamps.shift() as number;
|
|
648
710
|
const values = { ...q.held };
|
|
711
|
+
const heldFields = heldOf(body.kind);
|
|
712
|
+
if (heldFields.length) {
|
|
713
|
+
let book = heldBook.get(seat);
|
|
714
|
+
if (!book) { book = { ran: [], debt: {}, until: 0 }; heldBook.set(seat, book); }
|
|
715
|
+
const level: Record<string, number> = {};
|
|
716
|
+
for (const name of heldFields) level[name] = Number(values[name]) || 0;
|
|
717
|
+
book.ran.push([t, level]); if (book.ran.length > lateTicks + 1) book.ran.shift();
|
|
718
|
+
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; } }
|
|
719
|
+
else book.debt = {};
|
|
720
|
+
}
|
|
649
721
|
for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
|
|
650
|
-
|
|
722
|
+
// 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.
|
|
723
|
+
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
724
|
inputs.set(seat, { values, claim: q.claim });
|
|
652
725
|
}
|
|
653
726
|
if (roundRebasePending) publishRebase(now);
|
|
@@ -677,7 +750,14 @@ export function createHost(o: HostOptions): Host {
|
|
|
677
750
|
for (const b of core.bodies()) {
|
|
678
751
|
if (!hasHands(b.seat, b.driver)) continue;
|
|
679
752
|
const q = queues.get(b.seat);
|
|
680
|
-
|
|
753
|
+
// A fifth number only for a seat that has sent a numbered pulse: the newest one acted on.
|
|
754
|
+
const taken = pulsesTaken.get(b.seat);
|
|
755
|
+
const row = [b.seat, b.r, q ? q.ack : 0, q && q.lead !== 127 ? q.lead : -128];
|
|
756
|
+
// A sixth number only in a game that rewinds (room.historySeconds, 0.46.5): how many ticks back this seat's own
|
|
757
|
+
// ray may look right now, so its browser can predict against the pose the server will judge, not a guess at it.
|
|
758
|
+
if (historyTicks) row.push(taken ?? 0, Math.min(historyTicks, rewindTicks(b.seat)));
|
|
759
|
+
else if (taken !== undefined) row.push(taken);
|
|
760
|
+
rows.push(row);
|
|
681
761
|
if (q) q.lead = 127;
|
|
682
762
|
}
|
|
683
763
|
// 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);
|
package/rules/rules.ts
CHANGED
|
@@ -54,7 +54,7 @@ export const QUEUE_MAX = 16_384;
|
|
|
54
54
|
|
|
55
55
|
/* ------------------------------------------------------------------ field types */
|
|
56
56
|
|
|
57
|
-
export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse';
|
|
57
|
+
export type FieldKind = 'u8' | 'u16' | 'u32' | 'i8' | 'i16' | 'i32' | 'bit' | 'fix' | 'vec3' | 'dir' | 'tick' | 'ticks' | 'ref' | 'text' | 'list' | 'map' | 'struct' | 'press' | 'pulse' | 'held';
|
|
58
58
|
export interface Field {
|
|
59
59
|
readonly t: FieldKind;
|
|
60
60
|
/** The value a new entity starts with. */
|
|
@@ -82,7 +82,8 @@ const plain = (t: FieldKind) => (o?: FieldOptions): Field => made(t, o);
|
|
|
82
82
|
* false; `fix` is a fixed-point number in steps of 1/4096; `vec3` is a position or a velocity in metres; `dir` is a
|
|
83
83
|
* unit vector in the declared dimensions; `tick` is a moment and `ticks` a length on the room's clock; `ref` is the id of an
|
|
84
84
|
* entity or a player; `text`, `list` and `map` name their largest size. `press` (in `input` only) is a button that
|
|
85
|
-
* fires once
|
|
85
|
+
* fires once; `held` (in `input` only) is a number the browser averages over each tick, so a tap shorter than a tick
|
|
86
|
+
* moves for the part of the tick it was down.
|
|
86
87
|
*/
|
|
87
88
|
export const f = brand(Object.freeze({
|
|
88
89
|
u8: plain('u8'), u16: plain('u16'), u32: plain('u32'), i8: plain('i8'), i16: plain('i16'), i32: plain('i32'),
|
|
@@ -93,6 +94,8 @@ export const f = brand(Object.freeze({
|
|
|
93
94
|
struct: (fields: Fields, o?: FieldOptions): Field => made('struct', o, { fields: Object.freeze({ ...fields }) }),
|
|
94
95
|
press: (): Field => made('press', undefined),
|
|
95
96
|
pulse: (): Field => made('pulse', undefined),
|
|
97
|
+
/** Input only (0.46.3): a number averaged over the tick. A key down for a third of a tick reads a third; a stick reads its mean. */
|
|
98
|
+
held: (): Field => made('held', undefined),
|
|
96
99
|
}));
|
|
97
100
|
|
|
98
101
|
/**
|
|
@@ -164,9 +167,12 @@ export interface RulesDef {
|
|
|
164
167
|
shared?: Fields;
|
|
165
168
|
room?: {
|
|
166
169
|
rounds?: { seconds: number; breakSeconds: number };
|
|
167
|
-
|
|
170
|
+
/** `names` (0.46.5): what the game's bots are called, by seat; the toolkit's own list without it. */
|
|
171
|
+
bots?: { keep: number; names?: readonly string[] };
|
|
168
172
|
records?: RoundRecords;
|
|
169
173
|
historySeconds?: number;
|
|
174
|
+
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
175
|
+
rewindSeconds?: number;
|
|
170
176
|
start?: RoomHandler;
|
|
171
177
|
join?: (ctx: any, player: { seat: number; driver: 'person' | 'bot' | 'ai'; owner: string; character?: Character | null }) => { kind: string; at: Vec3; heading?: Vec3; fields?: Record<string, unknown>; motion?: Record<string, unknown> };
|
|
172
178
|
on?: Record<string, RoomHandler>;
|
|
@@ -387,8 +393,11 @@ export interface Compiled {
|
|
|
387
393
|
view: FieldList;
|
|
388
394
|
records?: RoundRecords;
|
|
389
395
|
historySeconds?: number;
|
|
396
|
+
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
397
|
+
rewindSeconds?: number;
|
|
390
398
|
rounds: { seconds: number; breakSeconds: number } | null;
|
|
391
399
|
bots: number;
|
|
400
|
+
botNames?: readonly string[];
|
|
392
401
|
start: RoomHandler | null;
|
|
393
402
|
join: NonNullable<RulesDef['room']>['join'] | null;
|
|
394
403
|
roomOn: Record<string, RoomHandler>;
|
|
@@ -409,7 +418,7 @@ export const BUILT_IN_FIELDS = Object.freeze(['id', 'kind', 'pos', 'vel', 'headi
|
|
|
409
418
|
|
|
410
419
|
const NAME = /^[a-z][A-Za-z0-9_]{0,31}$/;
|
|
411
420
|
const INTS = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32']);
|
|
412
|
-
const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse']);
|
|
421
|
+
const TYPES = new Set(['u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'bit', 'fix', 'vec3', 'dir', 'tick', 'ticks', 'ref', 'text', 'list', 'map', 'struct', 'press', 'pulse', 'held']);
|
|
413
422
|
|
|
414
423
|
/**
|
|
415
424
|
* One declared field, checked, and made again as the runtime's own: a frozen record of its type, its sizes and its
|
|
@@ -436,7 +445,8 @@ function checkField(fd: unknown, where: string, inInput: boolean): void {
|
|
|
436
445
|
const x = fd as Field;
|
|
437
446
|
if (!x || typeof x !== 'object' || typeof x.t !== 'string' || !TYPES.has(x.t)) throw new Error(`${where} is not a field type: declare it with f (f.u16(), f.vec3(), f.list(f.ref(), 8)…)`);
|
|
438
447
|
if ((x.t === 'press' || x.t === 'pulse') && !inInput) throw new Error(`${where}: f.press() is for input only (a button that fires once)`);
|
|
439
|
-
if (
|
|
448
|
+
if (x.t === 'held' && !inInput) throw new Error(`${where}: f.held() is for input only (a control averaged over the tick it was held in)`);
|
|
449
|
+
if (inInput && !(INTS.has(x.t) || x.t === 'bit' || x.t === 'press' || x.t === 'pulse' || x.t === 'held' || x.t === 'fix')) throw new Error(`${where}: an input field is a whole number, f.fix(), f.held(), f.bit() or f.press()`);
|
|
440
450
|
if (x.durable !== undefined && (typeof x.durable !== 'boolean' || inInput || !/^(entities\.[^.]+\.fields|shared)\.[^.]+$/.test(where))) throw new Error(`${where}: durable belongs on a top-level state field`);
|
|
441
451
|
if (x.visibility !== undefined && !/^entities\.[^.]+\.fields\.[^.]+$/.test(where)) throw new Error(`${where}: visibility belongs on a top-level entity field`);
|
|
442
452
|
if (x.visibility !== undefined && (!['owner','server','results'].includes(x.visibility) || inInput)) throw new Error(`${where}: visibility is owner, server or results on state fields only`);
|
|
@@ -628,6 +638,8 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
628
638
|
}
|
|
629
639
|
const keep = room.bots === undefined ? 0 : typeof room.bots?.keep === 'number' ? Math.floor(room.bots.keep) : NaN;
|
|
630
640
|
if (!(keep >= 0 && keep <= SEATS_MAX)) throw new Error(`room.bots is { keep: n }, 0 to ${SEATS_MAX}`);
|
|
641
|
+
const botNames = (room.bots as { names?: unknown } | undefined)?.names;
|
|
642
|
+
if (botNames !== undefined && !(Array.isArray(botNames) && botNames.length >= 1 && botNames.length <= 64 && botNames.every((n) => typeof n === 'string' && n.trim().length >= 1 && n.length <= 24 && !/[\u0000-\u001f\u007f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/.test(n)))) throw new Error('room.bots.names is a list of 1 to 64 names, each 1 to 24 characters on one line');
|
|
631
643
|
if (room.start !== undefined && typeof room.start !== 'function') throw new Error('room.start is a function');
|
|
632
644
|
if (room.join !== undefined && typeof room.join !== 'function') throw new Error('room.join is a function');
|
|
633
645
|
const players = kinds.filter((k) => k.player);
|
|
@@ -646,13 +658,14 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
646
658
|
}
|
|
647
659
|
}
|
|
648
660
|
if (room.historySeconds !== undefined && (typeof room.historySeconds !== 'number' || !Number.isFinite(room.historySeconds) || room.historySeconds < 0 || room.historySeconds > 2)) throw new Error('room.historySeconds is from 0 to 2');
|
|
661
|
+
if (room.rewindSeconds !== undefined && (typeof room.rewindSeconds !== 'number' || !Number.isFinite(room.rewindSeconds) || room.rewindSeconds < 0 || room.rewindSeconds > (room.historySeconds ?? 0))) throw new Error('room.rewindSeconds is the most a shot is rewound for a slow connection: a number from 0 to room.historySeconds');
|
|
649
662
|
const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
|
|
650
663
|
const navigation:Record<string,readonly RouteNode[]> = Object.create(null);let graphNodes=0;
|
|
651
664
|
if(d.navigation){const entries=Object.entries(d.navigation);if(entries.length>8)throw new Error('navigation declares at most 8 graphs');for(const [name,graph] of entries){if(!/^[A-Za-z][A-Za-z0-9_]{0,47}$/.test(name))throw new Error('navigation graph names are identifiers up to 48 characters');navigation[name]=prepareGraph(graph);graphNodes+=navigation[name].length;if(graphNodes>8192)throw new Error('navigation declares at most 8192 nodes in total');}}
|
|
652
665
|
Object.freeze(navigation);
|
|
653
666
|
return {
|
|
654
667
|
navigation, contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
|
|
655
|
-
shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
668
|
+
shared: fieldList(d.shared, 'shared'), view, ...(room.records?{records:room.records}:{}), ...(room.historySeconds ? {historySeconds:room.historySeconds}:{}), ...(room.rewindSeconds !== undefined ? {rewindSeconds:room.rewindSeconds}:{}), rounds, ...(botNames ? { botNames: Object.freeze([...(botNames as string[])]) } : {}), bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,
|
|
656
669
|
tune, publicTune, map, settings, seats,
|
|
657
670
|
};
|
|
658
671
|
}
|
|
@@ -665,6 +678,8 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
|
|
|
665
678
|
*/
|
|
666
679
|
export interface Schema {
|
|
667
680
|
records?: true;
|
|
681
|
+
/** A game that rewinds (0.46.5): its window and its cap, for a browser predicting its own shots. */
|
|
682
|
+
rewind?: { historySeconds: number; capSeconds?: number };
|
|
668
683
|
contract: 2;
|
|
669
684
|
dims: 2 | 3;
|
|
670
685
|
seats: number;
|
|
@@ -674,6 +689,8 @@ export interface Schema {
|
|
|
674
689
|
commands: Record<string, FieldList>;
|
|
675
690
|
shared: FieldList;
|
|
676
691
|
historySeconds?: number;
|
|
692
|
+
/** The furthest a person's ray may be rewound, in seconds (0.46.3): a hard cap under `historySeconds`, whatever their connection measures. */
|
|
693
|
+
rewindSeconds?: number;
|
|
677
694
|
rounds: { seconds: number; breakSeconds: number } | null;
|
|
678
695
|
settings: RoomSettings;
|
|
679
696
|
}
|
|
@@ -687,7 +704,7 @@ function publicField(field: Field, hidden=false): Field {
|
|
|
687
704
|
}
|
|
688
705
|
export function schemaOf(c: Compiled): Schema {
|
|
689
706
|
return {
|
|
690
|
-
contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}),
|
|
707
|
+
contract: RULES_CONTRACT, dims: c.dims, seats: c.seats, ...(c.records?{records:true as const}:{}), ...(c.historySeconds ? { rewind: { historySeconds: c.historySeconds, ...(c.rewindSeconds !== undefined ? { capSeconds: c.rewindSeconds } : {}) } } : {}),
|
|
691
708
|
kinds: c.kinds.map((k) => ({ ...(k.query?{query:k.query}:{}), ...(k.collider?{collider:k.collider}:{}), ...(k.body?.terrain ? {terrain:k.body.terrain} : {}), name: k.name, player: Boolean(k.player), ...(k.player?.control ? {control:k.player.control}:{}), owner: Boolean(k.body?.owner), radius: k.body?.radius ?? 0, ...(c.dims === 3 && k.body ? { shape: k.body.shape, height: k.body.height || 2 * k.body.radius } : {}), maxSpeed: k.body?.maxSpeed ?? 0, score: k.score, fields: k.fields.map(([name,field])=>[name,publicField(field)]), motion: k.motion, input: k.input })),
|
|
692
709
|
effects: c.effects, effectNames: c.effectNames, commands: c.commands, shared: c.shared, rounds: c.rounds, settings: c.settings,
|
|
693
710
|
};
|
package/rules/types.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface RayOptions {
|
|
|
22
22
|
export type RayHit = Hit & { readonly dist: number };
|
|
23
23
|
export interface RaySnapshot {sweep(body:{pos:Point;grounded?:boolean},delta:Point,options?:{ground?:boolean}):Hit|undefined;support(body:{pos:Point},distance?:number):(Hit&{dist:number})|undefined;ray(from:Point,direction:Point,max:number):RayHit|undefined;rayAll(from:Point,direction:Point,max:number):readonly RayHit[]}
|
|
24
24
|
export interface RayQueries { rays(options?:RayOptions):RaySnapshot; ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined; rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[] }
|
|
25
|
+
export interface EffectAudience { readonly to?: string | readonly string[]; readonly within?: number }
|
|
25
26
|
export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
|
|
26
27
|
export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
|
|
27
28
|
export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
|
|
@@ -30,6 +31,8 @@ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
|
|
|
30
31
|
readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
|
|
31
32
|
readonly shared: Room extends true ? S : ReadonlyState<S>;
|
|
32
33
|
readonly round: Round & (Room extends true ? { end(): void; restart(): void } : {});
|
|
34
|
+
/** Budget units the running handler may still spend (0.46.3). */
|
|
35
|
+
readonly unitsLeft: number;
|
|
33
36
|
random(): number;
|
|
34
37
|
label(player:string):string;
|
|
35
38
|
send<K extends keyof E>(target: string, event: K, ...data: Payload<E[K]>): void;
|
|
@@ -38,6 +41,8 @@ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
|
|
|
38
41
|
sendArea<K extends keyof E>(area: Area, event: K, ...data: Payload<E[K]>): void;
|
|
39
42
|
after<K extends keyof E>(ticks: number, event: K, ...data: Payload<E[K]>): void;
|
|
40
43
|
emit<K extends keyof F>(effect: K, at: Point | string, ...data: Payload<F[K]>): void;
|
|
44
|
+
/** With an audience (0.46.3): only the people driving the entities named in `to`, and those whose own body is within `within` metres, receive it. A watcher receives the ones with a range, never one addressed only `to` somebody. */
|
|
45
|
+
emit<K extends keyof F>(effect: K, at: Point | string, data: WriteState<F[K]>, audience: EffectAudience): void;
|
|
41
46
|
spawn<K extends keyof C>(kind: K, at: Point, fields?: Partial<WriteState<C[K]>>): string;
|
|
42
47
|
near<K extends keyof V>(at: Point, radius: number, kind: K): readonly Query<V[K]>[];
|
|
43
48
|
near(at: Point, radius: number): readonly Query<V[keyof V]>[];
|