@homie-rocks/studio 0.39.0 → 0.42.0
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 +55 -1
- package/README.md +11 -0
- package/bin/homie-studio.mjs +8 -0
- package/functions/FUNCTIONS.md +99 -0
- package/functions/functions.ts +13 -0
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +9 -9
- package/lib/dev.mjs +2 -1
- package/lib/functions-build.mjs +36 -0
- package/lib/functions-cli.mjs +23 -0
- package/lib/port-check.mjs +6 -3
- package/lib/purchase-links.mjs +8 -0
- package/lib/rules-build.mjs +8 -1
- package/lib/rules-check-worker.mjs +3 -3
- package/lib/rules-check.mjs +3 -1
- package/lib/scaffold.mjs +16 -3
- package/lib/shop.mjs +1 -1
- package/lib/template-history.json +85 -0
- package/lib/tools-build.mjs +2 -0
- package/lib/upgrade.mjs +2 -0
- package/lib/worker-build.mjs +8 -20
- package/lib/worker-config.mjs +28 -0
- package/netplay/NETPLAY.md +74 -2
- package/package.json +7 -2
- package/rules/collision.ts +115 -0
- package/rules/core.ts +65 -25
- package/rules/host.ts +5 -1
- package/rules/math.ts +15 -5
- package/rules/rules.ts +33 -10
- package/rules/types.ts +2 -2
- package/rules/view.ts +126 -32
- package/shop/SHOP.md +4 -3
- package/starters/ember-vale/game.json +16 -4
- package/starters/ember-vale/map/main.json +66 -0
- package/starters/ember-vale/src/move.ts +31 -0
- package/starters/ember-vale/src/rules.ts +244 -0
- package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
- package/starters/ember-vale/tunables.json +100 -11
- package/starters/gem-rush/src/move.ts +9 -1
- package/starters/gem-rush/src/view.ts +2 -1
- package/starters/gem-rush-3d/game.json +23 -5
- package/starters/gem-rush-3d/map/main.json +153 -0
- package/starters/gem-rush-3d/src/move.ts +37 -0
- package/starters/gem-rush-3d/src/rules.ts +133 -0
- package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
- package/starters/gem-rush-3d/tunables.json +98 -11
- package/starters/hero-rush-3d/game.json +23 -5
- package/starters/hero-rush-3d/map/main.json +153 -0
- package/starters/hero-rush-3d/src/move.ts +49 -0
- package/starters/hero-rush-3d/src/rules.ts +200 -0
- package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
- package/starters/hero-rush-3d/tunables.json +260 -30
- package/tools/TOOLS.md +42 -2
- package/tools/tools.ts +3 -0
- package/worker/customer-mcp.mjs +77 -0
- package/worker/customer-resources.mjs +53 -0
- package/worker/customer-shop.mjs +54 -0
- package/worker/extensions.mjs +1 -0
- package/worker/functions-schema.mjs +55 -0
- package/worker/functions.mjs +93 -0
- package/worker/index.mjs +37 -2
- package/worker/machine-payments.mjs +1 -1
- package/worker/mcp-store.mjs +3 -2
- package/worker/mcp-tools.mjs +16 -4
- package/worker/mcp.mjs +31 -15
- package/worker/office-page.mjs +1 -1
- package/worker/players.mjs +5 -2
- package/worker/purchase-delivery.mjs +1 -0
- package/worker/purchase-hosted-readiness.mjs +0 -1
- package/worker/purchase-links.mjs +1 -1
- package/worker/purchase-machine.mjs +1 -0
- package/worker/purchase-office.mjs +1 -1
- package/worker/purchase-owner.mjs +0 -2
- package/worker/purchase-reconciliation.mjs +1 -0
- package/worker/purchase-routes.mjs +11 -17
- package/worker/purchase-store.mjs +1 -0
- package/worker/selling/runtime.mjs +4 -1
- package/worker/service-run.mjs +25 -0
- package/worker/service-terms.mjs +2 -0
- package/worker/shop-rules.mjs +9 -8
- package/worker/shop.mjs +22 -9
- package/worker/tool-webhook.mjs +1 -0
- package/worker/version.mjs +1 -1
package/rules/view.ts
CHANGED
|
@@ -46,10 +46,28 @@ import { BudgetError } from './guard.ts';
|
|
|
46
46
|
import { moveContext } from './math.ts';
|
|
47
47
|
import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
48
48
|
import type { Unpacked } from './pack.ts';
|
|
49
|
+
import { compileMap } from './rules.ts';
|
|
49
50
|
import type { FieldList, MoveFn, Schema, Vec3 } from './rules.ts';
|
|
50
51
|
|
|
52
|
+
/** Unit headings take the shortest arc, including an exact half turn. Linear
|
|
53
|
+
* interpolation passes through zero at a half turn and visibly flips a character. */
|
|
54
|
+
export function blendHeading(a: Vec3, b: Vec3, t: number, dims: 2 | 3): Vec3 {
|
|
55
|
+
if (t <= 0) return a;
|
|
56
|
+
if (t >= 1) return b;
|
|
57
|
+
const dot = Math.max(-1, Math.min(1, a.x * b.x + a.y * b.y + a.z * b.z));
|
|
58
|
+
if (dot > 0.9995) return dir({ x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), z: lerp(a.z, b.z, t) }, dims);
|
|
59
|
+
if (dot < -0.9995) {
|
|
60
|
+
const side = Math.abs(a.z) < 0.9 ? dir({ x: -a.y, y: a.x, z: 0 }, 3) : dir({ x: a.z, y: 0, z: -a.x }, 3);
|
|
61
|
+
const c = Math.cos(Math.PI * t), s = Math.sin(Math.PI * t);
|
|
62
|
+
return dir({ x: a.x * c + side.x * s, y: a.y * c + side.y * s, z: a.z * c + side.z * s }, dims);
|
|
63
|
+
}
|
|
64
|
+
const angle = Math.acos(dot), scale = Math.sin(angle);
|
|
65
|
+
const from = Math.sin((1 - t) * angle) / scale, to = Math.sin(t * angle) / scale;
|
|
66
|
+
return dir({ x: a.x * from + b.x * to, y: a.y * from + b.y * to, z: a.z * from + b.z * to }, dims);
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
/** What the build hands the view library for one game: its declarations, its public tunables and its map. No code. */
|
|
52
|
-
export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spots?: Record<string, unknown[]> } }
|
|
70
|
+
export interface GameData { vocab?: Vocabulary; id: string; schema: Schema; tune: Record<string, unknown>; map: { name?: string; bounds: { min: unknown; max: unknown }; boxes?: unknown[]; circles?: unknown[]; spheres?: unknown[]; capsules?: unknown[]; heightTiles?: unknown[]; spots?: Record<string, unknown[]> } }
|
|
53
71
|
let current: { game: GameData; move: Record<string, MoveFn>; load?: () => Promise<RulesHostFactory> } | null = null;
|
|
54
72
|
/** Called by the build's own two lines at the top of a view's bundle, before the view's code runs. */
|
|
55
73
|
export function setGame(game: GameData, move: Record<string, MoveFn> = {}, load?: () => Promise<RulesHostFactory>): void { current = { game, move, load }; }
|
|
@@ -67,6 +85,8 @@ export type RoomStatus = 'connecting' | 'playing' | 'offline' | 'closed';
|
|
|
67
85
|
export interface RoomRound { n: number; phase: 'live' | 'over'; endsAt: number; secondsLeft: number; results?: RoundInfo['results'] }
|
|
68
86
|
export interface RosterRow { seat: number; name: string; driver: 'person' | 'bot' | 'ai'; score: number; me: boolean }
|
|
69
87
|
export interface OpenRoomOptions {
|
|
88
|
+
/** Play a private rules round while an online room has no body for this player. */
|
|
89
|
+
fallback?: 'solo';
|
|
70
90
|
game?: GameData;
|
|
71
91
|
move?: Record<string, MoveFn>;
|
|
72
92
|
net?: NetplayOptions;
|
|
@@ -97,7 +117,7 @@ export interface Room<R = unknown> {
|
|
|
97
117
|
askButtons(id: string, offer?: Record<string, unknown>): AskButton[];
|
|
98
118
|
readonly tune: Readonly<Record<string, unknown>>;
|
|
99
119
|
/** The game's static map: its edges, its solid shapes and its named spots, in metres. */
|
|
100
|
-
readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
|
|
120
|
+
readonly map: { readonly bounds: { min: Vec3; max: Vec3 }; readonly boxes: readonly { min: Vec3; max: Vec3 }[]; readonly circles: readonly { at: Vec3; r: number }[]; readonly heightTiles: readonly import('./rules.ts').MapHeightTile[]; readonly spheres: readonly { at: Vec3; r: number }[]; readonly capsules: readonly { at: Vec3; r: number; height: number }[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> };
|
|
101
121
|
readonly net: Netplay;
|
|
102
122
|
/** Run the room's clock up to now (called by itself on a timer and whenever the view reads the room). */
|
|
103
123
|
pump(): void;
|
|
@@ -124,11 +144,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
124
144
|
const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true, viewOnly: true }) : null;
|
|
125
145
|
const kindOf = new Map(schema.kinds.map((k) => [k.name, k]));
|
|
126
146
|
const listeners = new Map<string, Set<(e: any) => void>>();
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
147
|
+
let solo: Room<R> | null = null, noBodyMs = 0, checkedAt = clock();
|
|
148
|
+
const soloListeners = new Map<string, () => void>();
|
|
149
|
+
const emit = (name: string, e: unknown, local = false): void => { if (Boolean(solo) !== local) return; for (const fn of listeners.get(name) ?? []) { try { fn(e); } catch (err) { console.warn(`[room] on('${name}')`, err); } } };
|
|
150
|
+
const map = compileMap(game.map, game.map.name ?? 'main');
|
|
151
|
+
const spots = map.spots;
|
|
132
152
|
const tune = Object.freeze({ ...(lab.rulesTune({ public: game.tune }).public as Record<string, unknown>) });
|
|
133
153
|
|
|
134
154
|
let status: RoomStatus = 'connecting';
|
|
@@ -183,6 +203,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
183
203
|
let observedLeadAt = 0;
|
|
184
204
|
let catchTick: number | null = null;
|
|
185
205
|
let catchAt = 0;
|
|
206
|
+
let headingBlend: { from: Vec3; at: number } | null = null;
|
|
186
207
|
let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number }[] = [];
|
|
187
208
|
const predict = schema.settings.predict;
|
|
188
209
|
const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
|
|
@@ -199,6 +220,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
199
220
|
if (path) {
|
|
200
221
|
// Loss can make even a small correction longer than the distance travelled in
|
|
201
222
|
// 100 ms. Bound its fade by actual progress, or it pulls steady movement backwards.
|
|
223
|
+
// A forced knock can travel much faster than running. It must not also
|
|
224
|
+
// erase a large offset at that speed and almost double the drawn impulse.
|
|
202
225
|
// Only one queued fade advances at a time. A stopped body finishes its correction.
|
|
203
226
|
if (waiting) o.at = now;
|
|
204
227
|
else {
|
|
@@ -224,7 +247,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
224
247
|
const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period * speed : 0);
|
|
225
248
|
/** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
|
|
226
249
|
let moveTick = 0;
|
|
227
|
-
const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, dims });
|
|
250
|
+
const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
|
|
228
251
|
function authorityRtt(): number {
|
|
229
252
|
// The helper's ping ends at the relay. A browser host adds another network leg in both directions.
|
|
230
253
|
// Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
|
|
@@ -257,7 +280,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
257
280
|
base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
|
|
258
281
|
stepped = Math.floor(base.tick) - 1;
|
|
259
282
|
entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Set();
|
|
260
|
-
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; correction.rebases += 1;
|
|
283
|
+
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
|
|
261
284
|
const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
|
|
262
285
|
if (mine) {
|
|
263
286
|
history.set(stepped, copyMine(mine));
|
|
@@ -275,8 +298,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
275
298
|
if (!fn) return body;
|
|
276
299
|
moveTick = t;
|
|
277
300
|
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); });
|
|
278
|
-
const
|
|
279
|
-
|
|
301
|
+
const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
|
|
302
|
+
const height = kind?.height || 2 * r;
|
|
303
|
+
out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: dims === 3 ? Math.max(map.bounds.min.z, Math.min(map.bounds.max.z - height, out.pos.z)) : 0 }, dims);
|
|
280
304
|
return out;
|
|
281
305
|
}
|
|
282
306
|
/** The input values the last step held (a press is never held). */
|
|
@@ -329,6 +353,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
329
353
|
if (closed) return;
|
|
330
354
|
if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline');
|
|
331
355
|
const now = clock();
|
|
356
|
+
checkSolo(now);
|
|
332
357
|
const hidden = typeof document !== 'undefined' && document.hidden === true;
|
|
333
358
|
// Effects play for everyone who draws the room: a watcher has no body and still sees them.
|
|
334
359
|
fire();
|
|
@@ -378,7 +403,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
378
403
|
mine!.motion = motionOf(me.kind, me.motion);
|
|
379
404
|
} else {
|
|
380
405
|
const previous = mine!.pos;
|
|
381
|
-
const
|
|
406
|
+
const shownBody = meNow();
|
|
407
|
+
const shown = shownBody?.pos ?? previous;
|
|
408
|
+
const oldHeading = mine!.heading;
|
|
382
409
|
const oldOffset = visualOffset(clock()), oldOffsets = offsets;
|
|
383
410
|
adopt(me);
|
|
384
411
|
history.set(f.k, copyMine(mine!));
|
|
@@ -402,6 +429,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
402
429
|
history.set(t, copyMine(mine!));
|
|
403
430
|
}
|
|
404
431
|
pending = pending.filter(e => e.t > f.k || e.t > ack && f.k + 1 - e.t <= Math.ceil(tickHz / 4));
|
|
432
|
+
if (shownBody && distance(oldHeading, mine!.heading) > 0.0001) headingBlend = { from: shownBody.heading, at: clock() };
|
|
405
433
|
const error = distance(previous, mine!.pos);
|
|
406
434
|
correction.last = error;
|
|
407
435
|
if (error > 0.00001) { correction.count++; correction.max = Math.max(correction.max, error); }
|
|
@@ -466,6 +494,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
466
494
|
setStatus(seat === null || me ? 'playing' : status);
|
|
467
495
|
}
|
|
468
496
|
function roundNow(): RoomRound | null {
|
|
497
|
+
if (solo) return solo.round;
|
|
469
498
|
if (!round || !latest) return null;
|
|
470
499
|
// The room's clock now, from the newest snapshot: a view never turns a tick into time itself.
|
|
471
500
|
const serverTick = latest.k + ((net.connected || net.rulesHosting) ? (clock() - latest.at) / period : 0);
|
|
@@ -527,21 +556,26 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
527
556
|
* drawn frame, not on the next tick.
|
|
528
557
|
*/
|
|
529
558
|
function meNow(): Entity | null {
|
|
559
|
+
if (solo) return solo.me;
|
|
530
560
|
if (!mine || !latest) return null;
|
|
531
561
|
const server = latest.ents.get(mine.id);
|
|
532
562
|
if (!server) return null;
|
|
533
563
|
const kind = kindOf.get(mine.kind);
|
|
534
564
|
let pos = mine.pos;
|
|
535
565
|
let heading = mine.heading;
|
|
566
|
+
let velocity = mine.vel, grounded = mine.grounded, motion = mine.motion;
|
|
536
567
|
let settled = true;
|
|
537
|
-
let preview:
|
|
568
|
+
let preview: ReturnType<typeof runMove> | null = null;
|
|
538
569
|
if (kind && base) {
|
|
539
570
|
const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
|
|
540
571
|
const now: Record<string, unknown> = {};
|
|
541
572
|
for (const [name, fd] of kind.input) now[name] = fd.t === 'press' ? pressed.has(name) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
542
573
|
const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
|
|
543
574
|
pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, a) }, dims);
|
|
544
|
-
heading = next.heading;
|
|
575
|
+
heading = blendHeading(mine.heading, next.heading, a, dims);
|
|
576
|
+
velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
|
|
577
|
+
// A takeoff is airborne as soon as it rises; a landing waits for its contact.
|
|
578
|
+
grounded = mine.grounded && (a === 0 || next.grounded);
|
|
545
579
|
preview = next;
|
|
546
580
|
settled = distance(next.pos, mine.pos) < 0.00001;
|
|
547
581
|
}
|
|
@@ -551,17 +585,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
551
585
|
if (catchTick >= tickAt(now)) catchTick = null;
|
|
552
586
|
else {
|
|
553
587
|
// Catch-up can reach the fractional current tick before its next whole step exists.
|
|
554
|
-
// Use the
|
|
588
|
+
// Use the normal drawing preview, including its 3D pose, instead of holding then jumping.
|
|
555
589
|
const t = Math.floor(catchTick), a = history.get(t), b = history.get(t + 1) ?? (t === stepped ? preview : null);
|
|
556
|
-
if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = b.heading;
|
|
557
|
-
|
|
590
|
+
if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = blendHeading(a.heading, b.heading, f, dims);
|
|
591
|
+
velocity = vec3({ x: lerp(a.vel.x, b.vel.x, f), y: lerp(a.vel.y, b.vel.y, f), z: lerp(a.vel.z, b.vel.z, f) }, dims);
|
|
592
|
+
grounded = a.grounded && (f === 0 || b.grounded); motion = a.motion;
|
|
593
|
+
} else if (a) { pos = a.pos; heading = a.heading; velocity = a.vel; grounded = a.grounded; motion = a.motion; }
|
|
558
594
|
}
|
|
559
595
|
}
|
|
560
596
|
const offset = visualOffset(clock(), pos, settled);
|
|
561
597
|
pos = { x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z };
|
|
562
|
-
|
|
598
|
+
if (headingBlend) {
|
|
599
|
+
const t = predict.blendMs > 0 ? Math.min(1, (clock() - headingBlend.at) / predict.blendMs) : 1;
|
|
600
|
+
heading = blendHeading(headingBlend.from, heading, t, dims);
|
|
601
|
+
if (t >= 1) headingBlend = null;
|
|
602
|
+
}
|
|
603
|
+
return entityOf({ ...server, pos, vel: velocity, heading, grounded, motion }, true);
|
|
563
604
|
}
|
|
564
605
|
function drawn(id: string): Entity | null {
|
|
606
|
+
if (solo) return solo.get(id);
|
|
565
607
|
if (mine && id === mine.id) return meNow();
|
|
566
608
|
const s = net.sample();
|
|
567
609
|
const a = s ? frameOf(s.a) : latest;
|
|
@@ -571,12 +613,13 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
571
613
|
// A placed body jumps: nobody's view glides across a placement.
|
|
572
614
|
if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
|
|
573
615
|
const t = s.alpha;
|
|
574
|
-
const h =
|
|
575
|
-
return entityOf({ ...eb, pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: eb.vel, heading: h });
|
|
616
|
+
const h = blendHeading(ea.heading, eb.heading, t, dims);
|
|
617
|
+
return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: vec3({ x: lerp(ea.vel.x, eb.vel.x, t), y: lerp(ea.vel.y, eb.vel.y, t), z: lerp(ea.vel.z, eb.vel.z, t) }, dims), heading: h });
|
|
576
618
|
}
|
|
577
619
|
function get(id: string): Entity | null { return drawn(id); }
|
|
578
620
|
function each(kind: string, fn: (e: Entity) => void): void {
|
|
579
621
|
pump();
|
|
622
|
+
if (solo) { solo.each(kind, fn); return; }
|
|
580
623
|
const s = net.sample();
|
|
581
624
|
const a = s ? frameOf(s.a) : latest;
|
|
582
625
|
const b = s ? frameOf(s.b) : latest;
|
|
@@ -587,20 +630,51 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
587
630
|
for (const id of ids) { const e = drawn(id); if (e) fn(e); }
|
|
588
631
|
}
|
|
589
632
|
function roster(): RosterRow[] {
|
|
633
|
+
if (solo) return solo.roster;
|
|
590
634
|
const scores = new Map<number, number>();
|
|
591
635
|
if (latest) for (const u of latest.ents.values()) { const k = kindOf.get(u.kind); if (u.seat !== undefined && k?.score) scores.set(u.seat, Number(u.fields[k.score]) || 0); }
|
|
592
636
|
return (net.slots ?? []).map((sl) => ({ seat: sl.slot, name: sl.name, driver: sl.agent ? 'ai' as const : sl.bot ? 'bot' as const : 'person' as const, score: scores.get(sl.slot) ?? 0, me: sl.seat !== null && sl.seat === net.seat }));
|
|
593
637
|
}
|
|
638
|
+
function forwardSolo(name: string): void {
|
|
639
|
+
if (solo && !soloListeners.has(name)) soloListeners.set(name, solo.on(name, e => emit(name, e, true)));
|
|
640
|
+
}
|
|
641
|
+
function stopSolo(): void {
|
|
642
|
+
for (const off of soloListeners.values()) off(); soloListeners.clear();
|
|
643
|
+
const old = solo; solo = null; old?.close();
|
|
644
|
+
}
|
|
645
|
+
function checkSolo(now: number): void {
|
|
646
|
+
if (opts.fallback !== 'solo') return;
|
|
647
|
+
const elapsed = Math.min(300, Math.max(0, now - checkedAt)); checkedAt = now;
|
|
648
|
+
if (net.offline || net.watching) { noBodyMs = 0; stopSolo(); return; }
|
|
649
|
+
if (net.link !== 'online') { noBodyMs = 0; return; }
|
|
650
|
+
if (mine && net.seat !== null && latest?.ents.has(mine.id)) {
|
|
651
|
+
noBodyMs = 0;
|
|
652
|
+
if (solo) { stopSolo(); net.line(null); emit('status', status); emit('placed', { id: mine.id }); }
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (solo || net.seat === null && !net.full) return;
|
|
656
|
+
noBodyMs += elapsed;
|
|
657
|
+
if (noBodyMs < 4000 || !(schema.settings.offline || schema.settings.host === 'browser')) return;
|
|
658
|
+
// Keep the online socket and its seat queue. The separate offline room emits no wire
|
|
659
|
+
// traffic, writes no room save to the server, and closes as soon as a body arrives.
|
|
660
|
+
const g = globalThis as unknown as Record<string, unknown>;
|
|
661
|
+
const probes = [g.__homieNet, g.__homiePort];
|
|
662
|
+
solo = openRoom<R>({ game, move: moves, now: opts.now, timers: opts.timers, net: { config: null, post: null, linkOverlay: false } });
|
|
663
|
+
[g.__homieNet, g.__homiePort] = probes;
|
|
664
|
+
for (const name of listeners.keys()) forwardSolo(name);
|
|
665
|
+
net.line(net.full ? 'This room is full · playing on your own until a seat is free' : 'Playing on your own until the next round');
|
|
666
|
+
emit('status', 'offline', true);
|
|
667
|
+
}
|
|
594
668
|
let extra: Record<string, () => unknown> = {};
|
|
595
|
-
const myScore = (): number | null => { const k = myKind(); const u = mine && latest ? latest.ents.get(mine.id) : null; return k?.score && u ? Number(u.fields[k.score]) || 0 : null; };
|
|
596
|
-
const busy = (): boolean => status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
|
|
669
|
+
const myScore = (): number | null => { if (solo) { const k = schema.kinds.find(k => k.player); return k?.score && solo.me ? Number(solo.me[k.score]) || 0 : null; } const k = myKind(); const u = mine && latest ? latest.ents.get(mine.id) : null; return k?.score && u ? Number(u.fields[k.score]) || 0 : null; };
|
|
670
|
+
const busy = (): boolean => solo ? !solo.me : status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
|
|
597
671
|
// The probes the testing tools read (`check`, `shoot`, `perf`, the playtest judge): the same two objects a game written the old way publishes.
|
|
598
672
|
net.expose({
|
|
599
673
|
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
|
|
600
674
|
peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
|
|
601
675
|
prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length }),
|
|
602
676
|
scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
|
|
603
|
-
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => status,
|
|
677
|
+
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
|
|
604
678
|
});
|
|
605
679
|
if (typeof window !== 'undefined') {
|
|
606
680
|
exposePort(net as Netplay<unknown, unknown, unknown>, {
|
|
@@ -616,27 +690,47 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
616
690
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
|
|
617
691
|
|
|
618
692
|
return {
|
|
619
|
-
get status() { return status; },
|
|
620
|
-
get seat() { return net.seat; },
|
|
693
|
+
get status() { return solo ? 'offline' : status; },
|
|
694
|
+
get seat() { return solo ? solo.seat : net.seat; },
|
|
621
695
|
get me() { pump(); return meNow(); },
|
|
622
696
|
each, get,
|
|
623
|
-
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); return () => { set?.delete(fn); }; },
|
|
624
|
-
get shared() { return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
|
697
|
+
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
|
|
698
|
+
get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
|
625
699
|
input(s) {
|
|
700
|
+
if (solo) { solo.input(s); return; }
|
|
626
701
|
const kind = myKind() ?? schema.kinds.find((k) => k.player) ?? null;
|
|
627
702
|
const next: Record<string, unknown> = {};
|
|
628
|
-
|
|
703
|
+
let changed = false;
|
|
704
|
+
for (const [name, fd] of kind?.input ?? []) {
|
|
705
|
+
if (fd.t === 'press') { if ((s[name] === true || s[name] === 1) && !pressed.has(name)) changed = true; }
|
|
706
|
+
else { if (s[name] !== undefined) next[name] = s[name]; if ((next[name] ?? fd.init) !== (sample[name] ?? fd.init)) changed = true; }
|
|
707
|
+
}
|
|
708
|
+
// A fresh control can replace a preview partway through a tick (especially
|
|
709
|
+
// a jump). Keep the pose at the instant of the press; only subsequent time
|
|
710
|
+
// may advance the new path, rather than drawing elapsed time with new input.
|
|
711
|
+
if (changed) pump();
|
|
712
|
+
const shown = changed ? meNow()?.pos : null;
|
|
713
|
+
for (const [name, fd] of kind?.input ?? []) if (fd.t === 'press' && (s[name] === true || s[name] === 1)) pressed.add(name);
|
|
629
714
|
sample = next;
|
|
715
|
+
if (shown) {
|
|
716
|
+
const now = clock(), nextShown = meNow()?.pos;
|
|
717
|
+
if (nextShown && distance(shown, nextShown) > 0.00001) {
|
|
718
|
+
const carried = visualOffset(now);
|
|
719
|
+
// Coalesce the carried blend: analogue controls may change every frame.
|
|
720
|
+
offsets = [{ delta: { x: carried.x + shown.x - nextShown.x, y: carried.y + shown.y - nextShown.y, z: carried.z + shown.z - nextShown.z },
|
|
721
|
+
at: now, last: now, fade: 1, path: { x: nextShown.x - carried.x, y: nextShown.y - carried.y, z: nextShown.z - carried.z } }];
|
|
722
|
+
}
|
|
723
|
+
}
|
|
630
724
|
},
|
|
631
|
-
command(name, data = {}) { if (schema.commands[name]) net.send('cmd', [name, data]); else console.warn(`[room] no command "${name}" is declared in shapes.commands`); },
|
|
725
|
+
command(name, data = {}) { if (solo) { solo.command(name, data); return; } if (schema.commands[name]) net.send('cmd', [name, data]); else console.warn(`[room] no command "${name}" is declared in shapes.commands`); },
|
|
632
726
|
get round() { return roundNow(); },
|
|
633
727
|
get roster() { return roster(); },
|
|
634
728
|
follow(id) { const e = id ? latest?.ents.get(id) : null; net.follow(e && e.seat !== undefined ? e.seat : null); },
|
|
635
729
|
probe(x) { extra = { ...extra, ...x }; },
|
|
636
|
-
ask(id, askId, args = {}) { const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
|
|
637
|
-
askButtons(id, offer = {}) { const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
|
|
730
|
+
ask(id, askId, args = {}) { if (solo) return; const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
|
|
731
|
+
askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
|
|
638
732
|
tune, map: Object.freeze({ ...map, spots: Object.freeze(spots) }), net: net as Netplay,
|
|
639
733
|
pump,
|
|
640
|
-
close() { closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
|
|
734
|
+
close() { stopSolo(); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
|
|
641
735
|
};
|
|
642
736
|
}
|
package/shop/SHOP.md
CHANGED
|
@@ -39,9 +39,10 @@ A kind is a label, not a recurring-billing, wallet, random-reward or fulfillment
|
|
|
39
39
|
omitted limits do not apply. `referralNewPlayersOnly: true` limits attribution to new players.
|
|
40
40
|
- `catalog`: modes (`test`, `live`) populated by `homie-studio shop catalog` for named Stripe Products.
|
|
41
41
|
Prices always come from your file, even when a Stripe Product exists.
|
|
42
|
-
-
|
|
43
|
-
`purchaseAttemptsPerAddressPerMinute
|
|
44
|
-
|
|
42
|
+
- Optional flood protection: `purchaseAttemptsPerMinute`,
|
|
43
|
+
`purchaseAttemptsPerAddressPerMinute`, `guestBuyersPerAddressPerHour`.
|
|
44
|
+
Omitted or null means no limit. Explicit positive integers apply per Worker instance.
|
|
45
|
+
An explicitly set `PLAYER_LIMIT_DAILY` also applies to new shop buyers.
|
|
45
46
|
Referral delivery uses `REFERRAL_STATEMENTS_PER_MINUTE`, `REFERRAL_STATEMENTS_GLOBAL_PER_MINUTE`
|
|
46
47
|
(both 30), and `REFERRAL_STATEMENT_BYTES` (65536). `requestBytes` overrides the catalog-sized shop request
|
|
47
48
|
allowance; `SHOP_WEBHOOK_BYTES` (524288) controls the webhook byte allowance.
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
"id": "ember-vale",
|
|
3
3
|
"name": "Ember Vale",
|
|
4
4
|
"blurb": "A hero who lasts for days. Hunt slimes with whoever is here tonight; your level, gold and name follow you to every device. Go hardcore and a death is forever.",
|
|
5
|
-
"players": {
|
|
5
|
+
"players": {
|
|
6
|
+
"min": 1,
|
|
7
|
+
"max": 8
|
|
8
|
+
},
|
|
6
9
|
"roundSeconds": 90,
|
|
7
|
-
"entry": "src/
|
|
8
|
-
"netplay": {
|
|
10
|
+
"entry": "src/view.ts",
|
|
11
|
+
"netplay": {
|
|
12
|
+
"v": 1,
|
|
13
|
+
"public": true
|
|
14
|
+
},
|
|
9
15
|
"saves": true,
|
|
10
16
|
"decide": false,
|
|
11
17
|
"landing": {
|
|
@@ -16,6 +22,12 @@
|
|
|
16
22
|
"Every night ends with a ranking; your hero keeps what it earned.",
|
|
17
23
|
"Hardcore: one life. Fall and you join the Hall of the Fallen."
|
|
18
24
|
],
|
|
19
|
-
"controls": {
|
|
25
|
+
"controls": {
|
|
26
|
+
"phone": "Drag on the left to move, STRIKE on the right.",
|
|
27
|
+
"computer": "WASD or arrows to move, Space to strike."
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"room": {
|
|
31
|
+
"host": "server"
|
|
20
32
|
}
|
|
21
33
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bounds": {
|
|
3
|
+
"min": [
|
|
4
|
+
0,
|
|
5
|
+
0
|
|
6
|
+
],
|
|
7
|
+
"max": [
|
|
8
|
+
32,
|
|
9
|
+
20
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"spots": {
|
|
13
|
+
"camp": [
|
|
14
|
+
[
|
|
15
|
+
16,
|
|
16
|
+
10
|
|
17
|
+
]
|
|
18
|
+
],
|
|
19
|
+
"king": [
|
|
20
|
+
[
|
|
21
|
+
16,
|
|
22
|
+
2.4
|
|
23
|
+
]
|
|
24
|
+
],
|
|
25
|
+
"east-woods": [
|
|
26
|
+
[
|
|
27
|
+
28,
|
|
28
|
+
10.6
|
|
29
|
+
]
|
|
30
|
+
],
|
|
31
|
+
"start": [
|
|
32
|
+
[
|
|
33
|
+
19.2,
|
|
34
|
+
10.0
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
12.8,
|
|
38
|
+
10.0
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
16.0,
|
|
42
|
+
12.4
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
16.0,
|
|
46
|
+
7.6
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
18.26274169979695,
|
|
50
|
+
11.697056274847714
|
|
51
|
+
],
|
|
52
|
+
[
|
|
53
|
+
13.737258300203047,
|
|
54
|
+
8.302943725152286
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
13.737258300203049,
|
|
58
|
+
11.697056274847714
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
18.26274169979695,
|
|
62
|
+
8.302943725152286
|
|
63
|
+
]
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineMove } from '@homie-rocks/studio/rules';
|
|
2
|
+
|
|
3
|
+
export const move = defineMove({
|
|
4
|
+
hero(body, input, ctx) {
|
|
5
|
+
const M = ctx.math, m = body.motion;
|
|
6
|
+
if (ctx.tick < m.downUntil) { body.vel = { x: 0, y: 0, z: 0 }; return; }
|
|
7
|
+
const v = M.scale(M.clampLen({ x: input.ax / 127, y: input.ay / 127, z: 0 }, 1), m.speed);
|
|
8
|
+
if (M.len(v) > 0.3) body.heading = M.norm(v);
|
|
9
|
+
if (input.strike && ctx.tick >= m.ready && m.live) {
|
|
10
|
+
m.attackTick = ctx.tick; m.ready = ctx.tick + ctx.ticks(ctx.tune.strikeMs / 1000);
|
|
11
|
+
m.lungeDir = body.heading; m.lungeDone = m.lunges ? 0 : 1;
|
|
12
|
+
}
|
|
13
|
+
let delta = M.scale(v, ctx.dt);
|
|
14
|
+
if (m.lungeDone < 1) {
|
|
15
|
+
const u = M.clamp((ctx.tick - m.attackTick + 1) * ctx.dt / 0.09, 0, 1), ease = 1 - (1 - u) * (1 - u);
|
|
16
|
+
delta = M.add(delta, M.scale(m.lungeDir, (ease - m.lungeDone) * ctx.tune.lunge)); m.lungeDone = ease;
|
|
17
|
+
}
|
|
18
|
+
ctx.map.sweep(body, delta); body.vel = v;
|
|
19
|
+
},
|
|
20
|
+
slime(body, input, ctx) {
|
|
21
|
+
const M = ctx.math, m = body.motion;
|
|
22
|
+
if (m.pushAt > 0) {
|
|
23
|
+
const u = M.clamp((ctx.tick - m.pushAt) * ctx.dt / (ctx.tune.pushMs / 1000), 0, 1);
|
|
24
|
+
const target = M.add(m.pushFrom, M.scale(m.pushDir, ctx.tune.slimePush * (1 - M.pow(1 - u, 3))));
|
|
25
|
+
ctx.map.sweep(body, M.sub(target, body.pos));
|
|
26
|
+
if (u >= 1) m.pushAt = 0;
|
|
27
|
+
body.vel = { x: 0, y: 0, z: 0 }; return;
|
|
28
|
+
}
|
|
29
|
+
body.vel = m.velocity; ctx.map.sweep(body, M.scale(body.vel, ctx.dt));
|
|
30
|
+
},
|
|
31
|
+
});
|