@homie-rocks/studio 0.43.0 → 0.44.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -1
- package/README.md +16 -1
- package/bin/homie-studio.mjs +4 -0
- package/guides/game/REWRITE.md +78 -0
- package/guides/game/RULES.md +737 -0
- package/guides/game/SKILL.md +392 -0
- package/lib/authoring-guides.mjs +27 -0
- package/lib/rules-check-worker.mjs +4 -3
- package/lib/rules-guard.mjs +2 -1
- package/lib/scaffold.mjs +9 -2
- package/lib/template-history.json +83 -0
- package/lib/worker-build.mjs +39 -6
- package/lib/worker-config.mjs +2 -2
- package/netplay/netplay.ts +3 -0
- package/package.json +5 -3
- package/rules/collision.ts +6 -6
- package/rules/core.ts +33 -11
- package/rules/host.ts +2 -0
- package/rules/interest.mjs +81 -0
- package/rules/live.ts +73 -0
- package/rules/math.ts +10 -7
- package/rules/rules.ts +28 -6
- package/rules/types.ts +4 -3
- package/rules/view.ts +24 -4
- package/starters/hero-rush-3d/src/move.ts +4 -2
- package/worker/customer-shop.mjs +1 -1
- package/worker/room.mjs +15 -5
- package/worker/version.mjs +1 -1
package/rules/types.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The build specialises these faces from a game's declarations; the runtime still checks every write. */
|
|
2
|
-
import type { Vec3, BodyDef, Fields } from './rules.ts';
|
|
2
|
+
import type { Vec3, BodyDef, Fields, ColliderDef } from './rules.ts';
|
|
3
3
|
import type { math } from './math.ts';
|
|
4
4
|
|
|
5
5
|
export type Point = { readonly x: number; readonly y: number; readonly z?: number };
|
|
@@ -51,7 +51,7 @@ export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined:
|
|
|
51
51
|
export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
|
|
52
52
|
type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'guideLevel' | 'guideSeats' | 'kids' | 'levelSet' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
|
|
53
53
|
export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
|
|
54
|
-
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef;
|
|
54
|
+
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef;
|
|
55
55
|
tick?: (world: W, self: S) => void;
|
|
56
56
|
think?: (world: W, self: S) => Partial<I>;
|
|
57
57
|
on?: { [K in keyof (Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })]?: (world: W, self: S, event: EventData<(Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })[K]>) => void };
|
|
@@ -60,4 +60,5 @@ export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A =
|
|
|
60
60
|
guide?: { view: (world: GuideWorld<W>, self: ReadonlyState<Omit<S, 'input'>>) => V; floor?: (world: W, self: S, view: GuideView<V, G, A>) => D };
|
|
61
61
|
};
|
|
62
62
|
export interface MoveBody<M> { get pos(): Vec3; set pos(value: Point); get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); grounded: boolean; readonly motion: M }
|
|
63
|
-
export
|
|
63
|
+
export interface MoveCollision<M> { sweep(body: MoveBody<M>, delta: Point): Hit | undefined; support(body: MoveBody<M>, distance?: number): (Hit & {dist: number}) | undefined; overlaps(body: MoveBody<M>): boolean }
|
|
64
|
+
export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & MoveCollision<M>; readonly world: MoveCollision<M> };
|
package/rules/view.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { snapshotDecoder } from './interest.mjs';
|
|
2
|
+
import { collisionMap, revisionAt, type CollisionRevision } from './live.ts';
|
|
1
3
|
import { rulesOutput } from '../netplay/rules-output.mjs';
|
|
2
4
|
import { rulesCaps, rulesRates } from '../worker/limits.mjs';
|
|
3
5
|
/*
|
|
@@ -126,7 +128,7 @@ export interface Room<R = unknown> {
|
|
|
126
128
|
readonly __rules?: R;
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
interface Frame { k: number; e: number; at: number; round: [number, number, number]; ents: Map<string, Unpacked>; rows: number[][] }
|
|
131
|
+
interface Frame { k: number; e: number; at: number; round: [number, number, number]; ents: Map<string, Unpacked>; rows: number[][]; collision?: CollisionRevision }
|
|
130
132
|
const frames = new WeakMap<object, Frame | null>();
|
|
131
133
|
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
|
132
134
|
|
|
@@ -162,15 +164,16 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
162
164
|
|
|
163
165
|
/* ---------------------------------------------------------------- frames in */
|
|
164
166
|
|
|
167
|
+
const delivery = snapshotDecoder();
|
|
165
168
|
function frameOf(s: Snapshot<unknown> | null): Frame | null {
|
|
166
169
|
if (!s || typeof s !== 'object') return null;
|
|
167
170
|
if (frames.has(s)) return frames.get(s) ?? null;
|
|
168
171
|
let f: Frame | null = null;
|
|
169
|
-
const d = s
|
|
172
|
+
const d = delivery.decode(s)?.d as [unknown, unknown, CollisionRevision?] | null;
|
|
170
173
|
if (Array.isArray(d) && Array.isArray(d[0]) && Array.isArray(d[1])) {
|
|
171
174
|
const ents = new Map<string, Unpacked>();
|
|
172
175
|
for (const w of d[1] as unknown[]) { const e = unpackEntity(schema.kinds, w, dims); if (e) ents.set(e.id, e); }
|
|
173
|
-
f = { k: s.k, e: Number(s.e) || 0, at: clock(), round: [Number(d[0][0]) || 0, Number(d[0][1]) || 0, Number(d[0][2]) || 0], ents, rows: (Array.isArray(s.c) ? s.c : []) as unknown as number[][] };
|
|
176
|
+
f = { collision: d[2], k: s.k, e: Number(s.e) || 0, at: clock(), round: [Number(d[0][0]) || 0, Number(d[0][1]) || 0, Number(d[0][2]) || 0], ents, rows: (Array.isArray(s.c) ? s.c : []) as unknown as number[][] };
|
|
174
177
|
}
|
|
175
178
|
frames.set(s, f);
|
|
176
179
|
return f;
|
|
@@ -247,7 +250,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
247
250
|
const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period * speed : 0);
|
|
248
251
|
/** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
|
|
249
252
|
let moveTick = 0;
|
|
250
|
-
|
|
253
|
+
let collisionHistory: CollisionRevision[] = [];
|
|
254
|
+
let cachedCollision: CollisionRevision | undefined;
|
|
255
|
+
let geometry = map as import('./math.ts').MapShapes;
|
|
256
|
+
const geometryAt = () => {
|
|
257
|
+
const revision = revisionAt(collisionHistory, moveTick);
|
|
258
|
+
if (revision !== cachedCollision) { geometry = collisionMap(map, revision?.[2] ?? []); cachedCollision = revision; }
|
|
259
|
+
return geometry;
|
|
260
|
+
};
|
|
261
|
+
const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
|
|
251
262
|
function authorityRtt(): number {
|
|
252
263
|
// The helper's ping ends at the relay. A browser host adds another network leg in both directions.
|
|
253
264
|
// Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
|
|
@@ -297,6 +308,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
297
308
|
const fn = moves[kindName];
|
|
298
309
|
if (!fn) return body;
|
|
299
310
|
moveTick = t;
|
|
311
|
+
// Like snapshot unpacking, prepare the bounded geometry outside the handler
|
|
312
|
+
// quota. Authority debits projection once per tick, not once per mover.
|
|
313
|
+
geometryAt();
|
|
300
314
|
const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
|
|
301
315
|
const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
|
|
302
316
|
const height = kind?.height || 2 * r;
|
|
@@ -385,6 +399,12 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
385
399
|
const before = latest;
|
|
386
400
|
if (before && before.e === f.e && f.k <= before.k) return;
|
|
387
401
|
latest = f;
|
|
402
|
+
if (!before || before.e !== f.e) collisionHistory = [];
|
|
403
|
+
const collision = f.collision;
|
|
404
|
+
if (collision) {
|
|
405
|
+
collisionHistory.push(collision);
|
|
406
|
+
if (collisionHistory.length > 128) collisionHistory.shift();
|
|
407
|
+
}
|
|
388
408
|
if (before && before.e !== f.e) hostAges.length = 0;
|
|
389
409
|
hostAges.push(Math.max(0, net.now() - s.st)); if (hostAges.length > 40) hostAges.shift();
|
|
390
410
|
if (before && before.e !== f.e) fxQueue.length = 0;
|
|
@@ -5,15 +5,17 @@ import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
|
|
|
5
5
|
import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
|
|
6
6
|
|
|
7
7
|
function slide<M>(body: MoveBody<M>, delta: Vec3, ctx: MoveContext<unknown, M>) {
|
|
8
|
-
const before = body.pos, hit = ctx.
|
|
8
|
+
const before = body.pos, hit = ctx.world.sweep(body, delta);
|
|
9
9
|
if (!hit) return;
|
|
10
10
|
const M = ctx.math, remaining = M.sub(delta, M.sub(body.pos, before));
|
|
11
11
|
const into = M.dot(remaining, hit.normal);
|
|
12
|
-
if (into < 0) ctx.
|
|
12
|
+
if (into < 0) ctx.world.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export const move = defineMove({
|
|
16
16
|
runner(body, input, ctx) {
|
|
17
|
+
// Recheck support before jumping: a platform/cover may have moved or broken.
|
|
18
|
+
body.grounded = Boolean(ctx.world.support(body));
|
|
17
19
|
const M = ctx.math, m = body.motion, T = ctx.tune;
|
|
18
20
|
const dt = ctx.dt, knocked = m.knockAt > 0;
|
|
19
21
|
const g0 = 2 * T.jumpHeight / (T.jumpRise * T.jumpRise);
|
package/worker/customer-shop.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export async function quoteCart(env,cat,origin,caller,input,request) {
|
|
|
32
32
|
session = guest.session;
|
|
33
33
|
caller = {...caller,id:session.player.id};
|
|
34
34
|
}
|
|
35
|
-
const quote = await customerOffer(env,'cart',{name:lines.map(l=>`${l.item.name} × ${l.quantity}`).join(', '),lines,player:caller.id??null,shop, sale:serviceTerms({scope:'cart',amount,currency:shop.currency,automaticTax:shop.automaticTax===true,taxBehavior:shop.taxBehavior,refundWindowDays:shop.refundDays,refund
|
|
35
|
+
const quote = await customerOffer(env,'cart',{name:lines.map(l=>`${l.item.name} × ${l.quantity}`).join(', '),lines,player:caller.id??null,shop, sale:serviceTerms({scope:'cart',amount,currency:shop.currency,automaticTax:shop.automaticTax===true,taxBehavior:shop.taxBehavior,refundWindowDays:shop.refundDays,refund:Number.isFinite(shop.refundDays)?`Studio refund window: ${shop.refundDays} days.`:undefined})});
|
|
36
36
|
return {...quote,...(session?{account:{player:session.player,cookies:session.cookies}}:{})};
|
|
37
37
|
}
|
|
38
38
|
export async function fulfillCart(env,origin,order) {
|
package/worker/room.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { interestSnapshot, snapshotEncoder } from '../rules/interest.mjs';
|
|
1
2
|
/*
|
|
2
3
|
* room.mjs — the netplay v1 relay semantics, with no transport in it.
|
|
3
4
|
* =============================================================================
|
|
@@ -460,7 +461,7 @@ export class NetRoom {
|
|
|
460
461
|
hostSnap(c, m, now, text = null) {
|
|
461
462
|
// The server's stamp is this relay's own clock; a browser's is its estimate of it, and is checked.
|
|
462
463
|
const snap = { k: Number(m.k) || 0, st: c ? this.stamp(m.st) : Number(m.st) || now, d: m.d ?? null };
|
|
463
|
-
if (Array.isArray(m.c)) snap.c = m.c.slice(0, 64);
|
|
464
|
+
if (Array.isArray(m.c)) snap.c = m.c.slice(0, this.server ? this.seatCap : 64);
|
|
464
465
|
// Revision 10: a rules game's snapshot names the room's epoch.
|
|
465
466
|
if ((!c || this.rules && c.rules) && Number.isFinite(m.e)) snap.e = m.e;
|
|
466
467
|
this.lastSnap = snap;
|
|
@@ -477,10 +478,19 @@ export class NetRoom {
|
|
|
477
478
|
if (this.lite(o)) continue; // an agent with no game client draws nothing
|
|
478
479
|
// A congested socket skips a snapshot rather than queueing a stale one.
|
|
479
480
|
if (o.conn.buffered && o.conn.buffered() > 256 * 1024) { this.stats.drops += 1; continue; }
|
|
480
|
-
this.sendText(o.conn, out);
|
|
481
|
+
this.sendText(o.conn, this.server?.viewRadiusM != null ? JSON.stringify({ t: 'snap', from: null, ...this.playerSnapshot(o, snap) }) : out);
|
|
481
482
|
}
|
|
482
483
|
}
|
|
483
484
|
|
|
485
|
+
/** The same per-player delivery role will run in Gates. Keyframes belong to sockets, not saved seats. */
|
|
486
|
+
playerSnapshot(client, snap, welcome = false) {
|
|
487
|
+
if (!snap || this.server?.viewRadiusM == null) return snap;
|
|
488
|
+
const selected = interestSnapshot(snap, client.seat, this.server.viewRadiusM);
|
|
489
|
+
client.snapEncoder ??= snapshotEncoder(this.tickHz);
|
|
490
|
+
if (welcome) client.snapEncoder.reset();
|
|
491
|
+
return client.snapEncoder.encode(selected);
|
|
492
|
+
}
|
|
493
|
+
|
|
484
494
|
/** One key of the slow state channel from the host. `bytes`: the frame's size, counted against the room's cap for a browser host. */
|
|
485
495
|
hostState(c, m, bytes) {
|
|
486
496
|
const key = typeof m.k === 'string' ? m.k.slice(0, 64) : '';
|
|
@@ -530,7 +540,7 @@ export class NetRoom {
|
|
|
530
540
|
hostRoster(c, m) {
|
|
531
541
|
if (!Array.isArray(m.slots)) return;
|
|
532
542
|
// A host cannot hide an AI: a seat an agent holds is named and marked so, a slot with no seat is a bot.
|
|
533
|
-
this.lastRoster = this.labelRoster(m.slots.slice(0, 64));
|
|
543
|
+
this.lastRoster = this.labelRoster(m.slots.slice(0, this.server ? this.seatCap : 64));
|
|
534
544
|
this.persistDirty = true;
|
|
535
545
|
for (const o of (c && this.rules && c.rules ? this.live() : this.others(c))) this.send(o, { t: 'roster', slots: this.lastRoster });
|
|
536
546
|
this.tellWatchers();
|
|
@@ -1102,7 +1112,7 @@ export class NetRoom {
|
|
|
1102
1112
|
t: 'welcome', v: NET_VERSION, rev: NET_REVISION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
|
|
1103
1113
|
...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
|
|
1104
1114
|
role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
|
|
1105
|
-
round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.lastSnap, state: lite ? {} : this.stateObject(),
|
|
1115
|
+
round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.playerSnapshot(c, this.lastSnap, true), state: lite ? {} : this.stateObject(),
|
|
1106
1116
|
...(role === 'host' ? { ckpt: this.lastCkpt } : {}),
|
|
1107
1117
|
...(full ? { full: true } : {}),
|
|
1108
1118
|
...(this.liveAnnouncement() ? { announce: this.liveAnnouncement() } : {}),
|
|
@@ -1803,7 +1813,7 @@ export class NetRoom {
|
|
|
1803
1813
|
|
|
1804
1814
|
/** A round's results, labelled the same way (`agent: true` on every AI's row). */
|
|
1805
1815
|
labelResults(rows) {
|
|
1806
|
-
return rows.slice(0, 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
|
|
1816
|
+
return rows.slice(0, this.server ? this.seatCap : 64).map((raw) => this.labelOne(raw, true)).filter(Boolean);
|
|
1807
1817
|
}
|
|
1808
1818
|
|
|
1809
1819
|
labelOne(raw, result) {
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.44.1';
|