@homie-rocks/studio 0.51.0 → 0.51.2
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 +32 -0
- package/README.md +15 -4
- package/bin/homie-studio.mjs +7 -7
- package/guides/app/SKILL.md +14 -0
- package/guides/game/RULES.md +26 -0
- package/guides/office/SKILL.md +9 -0
- package/guides/studio-setup/SKILL.md +9 -1
- package/lib/app-preset-history.json +840 -0
- package/lib/build.mjs +4 -2
- package/lib/cloudflare.mjs +19 -10
- package/lib/dev.mjs +1 -1
- package/lib/directory.mjs +22 -5
- package/lib/giving-preset.mjs +9 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/office.mjs +2 -1
- package/lib/scaffold.mjs +7 -4
- package/lib/shop.mjs +2 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade-app-presets.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/mcp/ui/build.js +3 -2
- package/package.json +1 -1
- package/rules/correction-path.ts +35 -0
- package/rules/map-index.ts +4 -4
- package/rules/view.ts +196 -28
- package/site/SITE.md +13 -0
- package/tools/TOOLS.md +9 -0
- package/worker/arrival.mjs +2 -1
- package/worker/build-id.mjs +1 -1
- package/worker/directory-choice.mjs +9 -0
- package/worker/index.mjs +18 -9
- package/worker/mcp.mjs +2 -2
- package/worker/pages.mjs +3 -2
- package/worker/records-page.mjs +1 -1
- package/worker/signin-target.mjs +8 -0
- package/worker/site.mjs +2 -0
- package/worker/stats-page.mjs +6 -3
- package/worker/version.mjs +1 -1
package/rules/view.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { correctionPath } from './correction-path.ts';
|
|
1
2
|
import { disposeMoveContext, sweepMap } from './math.ts';
|
|
2
3
|
import { boundPosition, periodicDelta, blendPosition, relativePoint, wrapPoint } from './topology.mjs';
|
|
3
4
|
import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
|
|
@@ -108,6 +109,14 @@ export interface OpenRoomOptions {
|
|
|
108
109
|
predict?: Record<string, Predictor>;
|
|
109
110
|
/** Camera axes on the rules' x/y plane for the testing tools. Default: x right, y up. */
|
|
110
111
|
screenBasis?: PortProbeOptions['basis'];
|
|
112
|
+
/** Horizontal intent for box-bodied 2D visual corrections: -1 left, 0 neutral, +1 right. Does not affect simulation. */
|
|
113
|
+
correctionDirection?: (input: Readonly<Record<string, unknown>>, velocity: Vec3) => number;
|
|
114
|
+
/**
|
|
115
|
+
* After this browser's connection comes back, the server hands the own body back where it has it (a new placement
|
|
116
|
+
* revision). A box-bodied 2D runner is then drawn on from where it was shown, not cut across. Say here when that
|
|
117
|
+
* revision is a real placement made while the player was away (a respawn, a new round): it is then drawn at once.
|
|
118
|
+
*/
|
|
119
|
+
respawned?: (before: Entity, after: Entity) => boolean;
|
|
111
120
|
/** Tests: a clock in ms and a way to run without timers. */
|
|
112
121
|
now?: () => number;
|
|
113
122
|
timers?: boolean;
|
|
@@ -224,6 +233,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
224
233
|
/* ---------------------------------------------------------------- frames in */
|
|
225
234
|
|
|
226
235
|
const delivery = snapshotDecoder();
|
|
236
|
+
/** Until when a revision of the own body counts as the server handing it back after a reconnect. */
|
|
237
|
+
let returningUntil = 0;
|
|
238
|
+
let unpackedRows = new Map<string, {wire: unknown; value: Unpacked}>();
|
|
239
|
+
const sameRow = (a: unknown, b: unknown): boolean => {
|
|
240
|
+
if (a === b) return true;
|
|
241
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
242
|
+
for (let i = 0; i < a.length; i++) if (!sameRow(a[i], b[i])) return false;
|
|
243
|
+
return true;
|
|
244
|
+
};
|
|
227
245
|
function frameOf(s: Snapshot<unknown> | null): Frame | null {
|
|
228
246
|
if (!s || typeof s !== 'object') return null;
|
|
229
247
|
if (frames.has(s)) return frames.get(s) ?? null;
|
|
@@ -231,13 +249,29 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
231
249
|
const d = delivery.decode(s)?.d as [unknown, unknown, CollisionRevision?] | null;
|
|
232
250
|
if (Array.isArray(d) && Array.isArray(d[0]) && Array.isArray(d[1])) {
|
|
233
251
|
const ents = new Map<string, Unpacked>();
|
|
234
|
-
|
|
252
|
+
// Delta rows and repeated keyframe rows often describe unchanged scenery.
|
|
253
|
+
// Compare the immutable wire arrays before allocating typed vectors/fields.
|
|
254
|
+
// Retain only this frame's rows, not a growing history of transient keys.
|
|
255
|
+
const nextRows = new Map<string, {wire: unknown; value: Unpacked}>();
|
|
256
|
+
for (const w of d[1] as unknown[]) {
|
|
257
|
+
const id = Array.isArray(w) && typeof w[0] === 'string' ? w[0] : '';
|
|
258
|
+
const prior = unpackedRows.get(id);
|
|
259
|
+
const e = prior && sameRow(prior.wire, w) ? prior.value : unpackEntity(schema.kinds, w, dims);
|
|
260
|
+
if (e) { nextRows.set(e.id, prior?.value === e ? prior : {wire:w,value:e}); ents.set(e.id, e); }
|
|
261
|
+
}
|
|
262
|
+
unpackedRows = nextRows;
|
|
235
263
|
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[][] };
|
|
236
264
|
}
|
|
237
265
|
frames.set(s, f);
|
|
238
266
|
return f;
|
|
239
267
|
}
|
|
240
|
-
const
|
|
268
|
+
const entities = new WeakMap<Unpacked, Entity>();
|
|
269
|
+
const entityOf = (u: Unpacked, mine = false, cache = !mine): Entity => {
|
|
270
|
+
if (cache && entities.has(u)) return entities.get(u)!;
|
|
271
|
+
const entity = Object.freeze({ id: u.id, kind: u.kind, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, ...(u.seat !== undefined ? { seat: u.seat, owner: u.owner, driver: u.driver, away: u.away, goal: u.goal } : {}), ...(mine ? { mine: true } : {}), ...u.fields, motion: Object.freeze({ ...u.motion }) }) as Entity;
|
|
272
|
+
if (cache) entities.set(u, entity);
|
|
273
|
+
return entity;
|
|
274
|
+
};
|
|
241
275
|
|
|
242
276
|
/* ---------------------------------------------------------------- the own body, on this browser's copy of the clock */
|
|
243
277
|
|
|
@@ -281,7 +315,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
281
315
|
let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number; end?: number }[] = [];
|
|
282
316
|
const predict = schema.settings.predict;
|
|
283
317
|
const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
|
|
284
|
-
|
|
318
|
+
let leadTarget = .5, leadMedian = 0, hostMedian = 0;
|
|
319
|
+
const targetLead = (): number => leadTarget;
|
|
285
320
|
const copyMine = (m: Mine): Mine => ({ ...m, motion: motionOf(m.kind, m.motion) });
|
|
286
321
|
const distance = (a: Vec3, b: Vec3): number => Math.hypot(periodicDelta(b.x,a.x,map.wrap?.x),periodicDelta(b.y,a.y,map.wrap?.y),periodicDelta(b.z,a.z,map.wrap?.z));
|
|
287
322
|
const correction = { count: 0, max: 0, last: 0, catches: 0, snaps: 0, rebases: 0, rebaseReason: '', rebaseError: 0 };
|
|
@@ -357,14 +392,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
357
392
|
// The helper's ping ends at the relay. A browser host adds another network leg in both directions.
|
|
358
393
|
// Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
|
|
359
394
|
const relayRtt = net.stats().rtt ?? 100;
|
|
360
|
-
return net.offline ? 0 : Math.max(relayRtt, 2 *
|
|
395
|
+
return net.offline ? 0 : Math.max(relayRtt, 2 * hostMedian);
|
|
361
396
|
}
|
|
362
397
|
function phaseAt(at: number): number {
|
|
363
398
|
let p = phases[0];
|
|
364
399
|
for (let i = phases.length - 1; i >= 0; i--) if (phases[i].at <= at) { p = phases[i]; break; }
|
|
365
400
|
return p ? p.tick + (at - p.at) / period * p.rate - at / period : 0;
|
|
366
401
|
}
|
|
367
|
-
const medianLead = (): number => leads.length ?
|
|
402
|
+
const medianLead = (): number => leads.length ? leadMedian + phaseAt(clock()) : 0;
|
|
368
403
|
function steer(now: number): void {
|
|
369
404
|
if (!base || !leads.length) return;
|
|
370
405
|
base = { tick: tickAt(now), at: now };
|
|
@@ -384,11 +419,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
384
419
|
const rtt = authorityRtt(); calibratedRtt = rtt;
|
|
385
420
|
// Lead samples refer to the clock which stamped them. Do not mix its offset with this clock's jitter.
|
|
386
421
|
const target = targetLead();
|
|
387
|
-
leads.length = 0;
|
|
422
|
+
leads.length = 0; leadTarget = .5; leadMedian = 0;
|
|
388
423
|
leadAfter = clock() + 2 * rtt + period;
|
|
389
424
|
base = { tick: continuousTick ?? k + Math.min(tickHz, Math.ceil(rtt / period + target) + sendEvery - 1), at: clock() };
|
|
390
425
|
stepped = Math.max(k, Math.floor(base.tick) - (continuousTick === null ? 1 : 0));
|
|
391
|
-
entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
|
|
426
|
+
entries = []; sentHistory = []; controlChanges = []; pending = []; history.clear(); replayHeld = {}; serverHeld = {}; held = {}; lastSent = ''; leads.length = 0; leadTarget = .5; leadMedian = 0; pressed = new Map(); pulseNumber = new Map(); heldTrack.clear(); if (reason !== 'lead') dropPredictions();
|
|
392
427
|
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
|
|
393
428
|
const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
|
|
394
429
|
if (mine) {
|
|
@@ -419,22 +454,35 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
419
454
|
/** `motion` as this browser's own `move` may change it in place: what a snapshot carries is frozen, so every list, map and struct in it is copied. */
|
|
420
455
|
const motionOf = (kindName: string, motion: Record<string, unknown>): Record<string, unknown> => thawFields(kindOf.get(kindName)?.motion ?? [], motion);
|
|
421
456
|
function adopt(u: Unpacked): void {
|
|
457
|
+
presentedEntity = null;
|
|
422
458
|
mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion), fields: predictors[u.kind] ? thawFields(kindOf.get(u.kind)?.fields ?? [], u.fields) : u.fields };
|
|
423
459
|
}
|
|
424
460
|
/** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
|
|
425
461
|
const substepKinds = new Set<string>();
|
|
462
|
+
const movementCache = new Map<string, {scene: unknown; value: ReturnType<typeof stepMove>}>();
|
|
426
463
|
function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body & {steps?: readonly import('./substeps.ts').StepPose[]} {
|
|
427
464
|
const fn = moves[kindName];
|
|
428
465
|
if (!fn || (kindOf.get(kindName)?.control && !body.motion[kindOf.get(kindName)!.control!])) return body;
|
|
429
466
|
moveTick = t;
|
|
430
467
|
// Like snapshot unpacking, prepare the bounded geometry outside the handler
|
|
431
468
|
// quota. Authority debits projection once per tick, not once per mover.
|
|
432
|
-
geometryAt();
|
|
433
|
-
|
|
469
|
+
const scene = geometryAt();
|
|
470
|
+
// Reconciliation often replays the exact same deterministic movement. Cache
|
|
471
|
+
// its full arguments, including tick, self, motion and collision revision.
|
|
472
|
+
// Stateful physics worlds must execute their operations and are excluded.
|
|
473
|
+
const key = map.physics ? '' : JSON.stringify([mine?.id,kindName,t,body.pos,body.vel,body.heading,body.grounded,body.motion,input]);
|
|
474
|
+
const cached = key ? movementCache.get(key) : undefined;
|
|
475
|
+
if (cached?.scene === scene) return {...cached.value,motion:motionOf(kindName,cached.value.motion)};
|
|
476
|
+
let failed = false;
|
|
477
|
+
const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { failed = true; if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
|
|
434
478
|
if (out.steps?.length) substepKinds.add(kindName);
|
|
435
479
|
const kind = kindOf.get(kindName), r = kind?.radius ?? 0;
|
|
436
480
|
const height = kind?.height || 2 * r;
|
|
437
481
|
out.pos = vec3(boundPosition(out.pos,map,r,height,dims), dims);
|
|
482
|
+
if (key && !failed) {
|
|
483
|
+
movementCache.set(key,{scene,value:{...out,motion:motionOf(kindName,out.motion)}});
|
|
484
|
+
if(movementCache.size>128)movementCache.delete(movementCache.keys().next().value!);
|
|
485
|
+
}
|
|
438
486
|
return out;
|
|
439
487
|
}
|
|
440
488
|
/* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
|
|
@@ -705,8 +753,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
705
753
|
}
|
|
706
754
|
if (before && before.e !== f.e) hostAges.length = 0;
|
|
707
755
|
hostAges.push(Math.max(0, net.now() - s.st)); if (hostAges.length > 40) hostAges.shift();
|
|
756
|
+
hostMedian = quantile(hostAges, .5);
|
|
708
757
|
if (before && before.e !== f.e) fxQueue.length = 0;
|
|
709
758
|
const changedEpoch = f.e !== epoch;
|
|
759
|
+
if (changedEpoch) { presented=null; routing=null; route=[]; }
|
|
710
760
|
epoch = f.e;
|
|
711
761
|
const seat = net.seat;
|
|
712
762
|
let me: Unpacked | null = null;
|
|
@@ -716,6 +766,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
716
766
|
const row = f.rows.find((x) => x[0] === seat);
|
|
717
767
|
if(changedEpoch)applications.clear();
|
|
718
768
|
if(row?.[6]===471){for(let i=7;i+1<row.length;i+=2)applications.set(row[i],row[i+1]);while(applications.size>128)applications.delete(applications.keys().next().value!);}
|
|
769
|
+
// A return hands the body back at the server's pose with a new revision. It is the same body, still on the
|
|
770
|
+
// screen: keep drawing it from where it is shown unless the game says it was really placed meanwhile.
|
|
771
|
+
const prior = held2.at(-2)?.ents.get(me.id) ?? null;
|
|
772
|
+
if (placed && !changedEpoch && returningUntil > clock() && mine?.id === me.id && mine.r !== me.r && presented?.id === me.id && presented.r === mine.r) {
|
|
773
|
+
let respawn = false;
|
|
774
|
+
if (opts.respawned && prior) { try { respawn = Boolean(opts.respawned(entityOf(prior, true, false), entityOf(me, true, false))); } catch (err) { console.warn('[room] respawned', err); } }
|
|
775
|
+
if (!respawn) { presented.r = me.r; presented.at = clock(); }
|
|
776
|
+
returningUntil = 0;
|
|
777
|
+
}
|
|
719
778
|
if (placed || !base) {
|
|
720
779
|
adopt(me); rebase(f.k, changedEpoch ? 'epoch' : 'placement');
|
|
721
780
|
if (placed) emit('placed', entityOf(me, true));
|
|
@@ -830,6 +889,8 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
830
889
|
// our own +/-5% steering inflates the 40-sample jitter window and winds the clock ever further ahead.
|
|
831
890
|
observedLeadAt = now;
|
|
832
891
|
leads.push(row[3] / 16 - phaseAt(now - rtt)); if (leads.length > 40) leads.shift();
|
|
892
|
+
leadTarget = .5 + Math.max(0, quantile(leads, .5) - quantile(leads, .05));
|
|
893
|
+
leadMedian = quantile(leads.slice(-8), .5);
|
|
833
894
|
const med = medianLead(), target = targetLead();
|
|
834
895
|
// A changing jitter estimate must not discard already stamped input. Slew the
|
|
835
896
|
// clock; late movement is settled by the server's retained history.
|
|
@@ -876,7 +937,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
876
937
|
net.on('snapshot', onSnapshot);
|
|
877
938
|
net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline'); });
|
|
878
939
|
net.on('link', (e) => {
|
|
879
|
-
if (e.state === 'online') { base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
|
|
940
|
+
if (e.state === 'online') { returningUntil = clock() + 3000; base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
|
|
880
941
|
});
|
|
881
942
|
net.on('round', (r) => { if (r.results) { results = r.results; if (round) { round = { ...round, results }; emit('round', roundNow()); } } });
|
|
882
943
|
net.on('event', (e) => {
|
|
@@ -923,7 +984,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
923
984
|
* the two, by how much of the tick has passed, and the copy is thrown away. So the stick answers on the next
|
|
924
985
|
* drawn frame, not on the next tick.
|
|
925
986
|
*/
|
|
926
|
-
|
|
987
|
+
let routing: ReturnType<typeof correctionPath> | null = null;
|
|
988
|
+
let route: Vec3[] = [];
|
|
989
|
+
let routeDirection = 0;
|
|
990
|
+
let presentedEntity: Entity | null = null;
|
|
991
|
+
let presentationQueued = false;
|
|
992
|
+
let forecast: { mine: Mine; pos: Vec3; motion: Record<string, unknown>; tick: number; key: string; geometry: unknown; next: ReturnType<typeof runMove> } | null = null;
|
|
993
|
+
let presented: { id: string; r: number; pos: Vec3; vel: Vec3; at: number } | null = null;
|
|
994
|
+
function meNow(present = false): Entity | null {
|
|
995
|
+
if (present && presentedEntity) return presentedEntity;
|
|
927
996
|
if (predicting) { const u = mine && predictionFrame?.ents.get(mine.id); return u ? entityOf(u, true) : null; }
|
|
928
997
|
if (solo) return solo.me;
|
|
929
998
|
if (!mine || !latest) return null;
|
|
@@ -946,7 +1015,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
946
1015
|
// A recorded substep path needs the whole tick's forecast instead: include
|
|
947
1016
|
// the remaining duration of controls held now, then sample its actual steps.
|
|
948
1017
|
for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'pulse' && pulseQueue[0]?.name===name ? pulseQueue[0].value : fd.t === 'press' ? false : 0)) : fd.t === 'held' ? coerce(fd, heldMean(name, fd.init, Math.min(clock(), edgeOf(stepped + 1)), false, substepKinds.has(kind.name) ? edgeOf(stepped + 1) : Math.min(clock(), edgeOf(stepped + 1))), dims) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
949
|
-
const
|
|
1018
|
+
const forecastKey = JSON.stringify(now), scene = geometryAt();
|
|
1019
|
+
const reuse = !map.physics && substepKinds.has(kind.name) && forecast && forecast.mine === mine && forecast.pos === mine.pos && forecast.motion === mine.motion && forecast.tick === stepped && forecast.key === forecastKey && forecast.geometry === scene;
|
|
1020
|
+
const next = reuse ? forecast!.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);
|
|
1021
|
+
forecast = {mine, pos:mine.pos, motion:mine.motion, tick:stepped, key:forecastKey, geometry:scene, next};
|
|
950
1022
|
// A landing does not take the whole tick (0.46.5): the body met the ground part way through it. How far through is
|
|
951
1023
|
// read from the fall it would have made at the speed it entered the tick with; it is drawn down to the ground by
|
|
952
1024
|
// then and standing from then, not sinking until the tick's end.
|
|
@@ -995,26 +1067,115 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
995
1067
|
// permission to cross a floor. Sweep from a valid movement pose, never feed
|
|
996
1068
|
// the clipped drawing back into prediction or authority.
|
|
997
1069
|
const constrain = (from: Vec3, to: Vec3): Vec3 => {
|
|
998
|
-
if (dims !== 2 || !kind || kind.owner) return to;
|
|
1070
|
+
if (dims !== 2 || !kind || kind.owner || distance(from, to) < 1e-7) return to;
|
|
999
1071
|
const body = {pos: from};
|
|
1000
1072
|
sweepMap(geometryAt(), body, {x:periodicDelta(from.x,to.x,map.wrap?.x),y:periodicDelta(from.y,to.y,map.wrap?.y),z:periodicDelta(from.z,to.z,map.wrap?.z)}, kind.radius, dims,
|
|
1001
1073
|
{shape:kind.shape ?? 'circle',radius:kind.radius,height:kind.height || 2*kind.radius,terrain:kind.terrain});
|
|
1002
1074
|
return body.pos;
|
|
1003
1075
|
};
|
|
1004
|
-
|
|
1076
|
+
// An established box cursor is swept below from the actual displayed side.
|
|
1077
|
+
// Sweeping its target from a second origin first duplicates the same query
|
|
1078
|
+
// and does not add safety. New placements and other body types keep it.
|
|
1079
|
+
const hasCursor = present && dims === 2 && kind?.shape === 'box' && !kind.owner && presented?.id === mine.id && presented.r === mine.r;
|
|
1080
|
+
if (!hasCursor) pos = constrain(safeOrigin, pos);
|
|
1005
1081
|
const offset = visualOffset(clock(), pos, settled);
|
|
1006
|
-
|
|
1082
|
+
const offsetTarget = wrapPoint({ x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z },map.wrap);
|
|
1083
|
+
pos = hasCursor ? offsetTarget : constrain(pos, offsetTarget);
|
|
1084
|
+
if (present && dims === 2 && kind?.shape === 'box' && !kind.owner) {
|
|
1085
|
+
const now = clock();
|
|
1086
|
+
if (presented && presented.id === mine.id && presented.r === mine.r) {
|
|
1087
|
+
const from = presented.pos;
|
|
1088
|
+
let forward = velocity.x * presented.vel.x >= 0 ? Math.sign(velocity.x) : 0;
|
|
1089
|
+
if (opts.correctionDirection) {
|
|
1090
|
+
try {
|
|
1091
|
+
const intent = opts.correctionDirection(sample, mine.vel);
|
|
1092
|
+
// An explicit intent must not be overridden by stale replay velocity.
|
|
1093
|
+
// A game can return neutral during genuine braking/reversal inertia.
|
|
1094
|
+
if (Number.isFinite(intent)) forward = Math.sign(intent);
|
|
1095
|
+
}
|
|
1096
|
+
catch (err) { console.warn('[room] correctionDirection', err); }
|
|
1097
|
+
}
|
|
1098
|
+
if ((routing || route.length) && forward !== routeDirection) { routing=null; route=[]; }
|
|
1099
|
+
routeDirection = forward;
|
|
1100
|
+
const routeGoal = routing?.goal ?? route.at(-1);
|
|
1101
|
+
if (routeGoal && distance(routeGoal, pos) > Math.max(2,kind.radius*4)) { routing=null; route=[]; }
|
|
1102
|
+
if (route[0] && forward * periodicDelta(from.x,route[0].x,map.wrap?.x) < -.001) { routing=null; route=[]; }
|
|
1103
|
+
if (route.length) {
|
|
1104
|
+
while(route.length && distance(from,route[0])<.01)route.shift();
|
|
1105
|
+
}
|
|
1106
|
+
const destination = route[0] ?? pos;
|
|
1107
|
+
const elapsed = Math.max(0, now - presented.at);
|
|
1108
|
+
// Bound presentation by time, including expiring offsets and changed catch-up paths.
|
|
1109
|
+
const length = distance(from, destination);
|
|
1110
|
+
// Falling cannot spend a horizontal correction. Corrections add only
|
|
1111
|
+
// blend speed to the actual movement, not a body's declared speed cap.
|
|
1112
|
+
const blendSpeed = kind.radius / Math.max(.1,predict.blendMs/1000);
|
|
1113
|
+
// Include the whole recorded tick's velocities: a takeoff/landing can
|
|
1114
|
+
// change velocity abruptly between samples without being a correction.
|
|
1115
|
+
let verticalSpeed = Math.max(Math.abs(velocity.y),Math.abs(presented.vel.y),Math.abs(mine.vel.y),Math.abs(preview?.vel.y ?? 0));
|
|
1116
|
+
const recorded = preview?.steps;
|
|
1117
|
+
if (recorded) for (let i=0;i<recorded.length;i++) verticalSpeed = Math.max(verticalSpeed,Math.abs(recorded[i].vel.y));
|
|
1118
|
+
// Leave headroom for a substep entered part-way through a render interval.
|
|
1119
|
+
verticalSpeed *= 1.5;
|
|
1120
|
+
const advance = (axis: 'x' | 'y') => {
|
|
1121
|
+
const delta = periodicDelta(from[axis],destination[axis],map.wrap?.[axis]);
|
|
1122
|
+
// A landing can set velocity to zero before the sampled pose reaches
|
|
1123
|
+
// contact. The adjacent movement velocities retain that real travel.
|
|
1124
|
+
const travel = axis === 'y' ? verticalSpeed : Math.max(Math.abs(velocity.x),Math.abs(presented!.vel.x));
|
|
1125
|
+
const allowance = (travel + blendSpeed) * elapsed / 1000;
|
|
1126
|
+
return from[axis]+Math.max(-allowance,Math.min(allowance,delta));
|
|
1127
|
+
};
|
|
1128
|
+
const target = {...wrapPoint({x:advance('x'),y:advance('y'),z:0},map.wrap)};
|
|
1129
|
+
// A route is a correction too: it cannot pull against continuing motion.
|
|
1130
|
+
if (forward * periodicDelta(from.x,target.x,map.wrap?.x) < 0) target.x = from.x;
|
|
1131
|
+
// A stalled frame cannot spend its whole elapsed-time allowance in one
|
|
1132
|
+
// visible jump. Keep backlog travel below 1.4 body widths per drawing;
|
|
1133
|
+
// authority/prediction keep their real clock and placements bypass this.
|
|
1134
|
+
const frameDistance = distance(from,target), frameLimit = Math.max(.1,kind.radius*2.8);
|
|
1135
|
+
if (frameDistance > frameLimit) {
|
|
1136
|
+
const fraction = frameLimit / frameDistance;
|
|
1137
|
+
target.x = from.x + periodicDelta(from.x,target.x,map.wrap?.x)*fraction;
|
|
1138
|
+
target.y = from.y + periodicDelta(from.y,target.y,map.wrap?.y)*fraction;
|
|
1139
|
+
}
|
|
1140
|
+
let next = constrain(from, target);
|
|
1141
|
+
// Slide the residual along the contact, starting on the side actually drawn.
|
|
1142
|
+
if (distance(next, target) > .002) {
|
|
1143
|
+
next = constrain(next, {x:target.x,y:next.y,z:0});
|
|
1144
|
+
next = constrain(next, {x:next.x,y:target.y,z:0});
|
|
1145
|
+
}
|
|
1146
|
+
if (!route.length && distance(next, target) > .01 && distance(from,next) < .01 && length > .1) {
|
|
1147
|
+
routing ??= correctionPath(from,pos,Math.max(.25,2*kind.radius));
|
|
1148
|
+
const found=routing.advance((a,b)=>forward * periodicDelta(a.x,b.x,map.wrap?.x) >= -.001 && distance(constrain(a,b),b)<.002);
|
|
1149
|
+
if(found) { route=[...found]; routing=null; }
|
|
1150
|
+
}
|
|
1151
|
+
pos = next;
|
|
1152
|
+
}
|
|
1153
|
+
if (!presented || presented.id !== mine.id || presented.r !== mine.r) { routing=null;route=[]; }
|
|
1154
|
+
presented = {id:mine.id,r:mine.r,pos,vel:velocity,at:now};
|
|
1155
|
+
}
|
|
1007
1156
|
if (headingBlend) {
|
|
1008
1157
|
const t = predict.blendMs > 0 ? Math.min(1, (clock() - headingBlend.at) / predict.blendMs) : 1;
|
|
1009
1158
|
heading = blendHeading(headingBlend.from, heading, t, dims);
|
|
1010
1159
|
if (t >= 1) headingBlend = null;
|
|
1011
1160
|
}
|
|
1012
|
-
|
|
1161
|
+
const result = entityOf({ ...server, ...(kind && predictors[kind.name] ? { fields: (base ? previewFields : null) ?? mine.fields } : {}), pos, vel: velocity, heading, grounded, motion }, true);
|
|
1162
|
+
// Synchronous readers (HUD, scene, probes) share one presentation. Clear at
|
|
1163
|
+
// the microtask boundary: an rAF clear can run after the next drawing callback,
|
|
1164
|
+
// reusing an old pose and then spending two callbacks' travel at once.
|
|
1165
|
+
// Inputs and snapshots still invalidate immediately.
|
|
1166
|
+
if (present && typeof window !== 'undefined') {
|
|
1167
|
+
presentedEntity = result;
|
|
1168
|
+
if (!presentationQueued) {
|
|
1169
|
+
presentationQueued = true;
|
|
1170
|
+
const clear = () => { presentedEntity = null; presentationQueued = false; };
|
|
1171
|
+
queueMicrotask(clear);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return result;
|
|
1013
1175
|
}
|
|
1014
|
-
function drawn(id: string): Entity | null {
|
|
1176
|
+
function drawn(id: string, s = net.sample()): Entity | null {
|
|
1015
1177
|
if (solo) return solo.get(id);
|
|
1016
|
-
if (mine && id === mine.id) return meNow();
|
|
1017
|
-
const s = net.sample();
|
|
1178
|
+
if (mine && id === mine.id) return meNow(true);
|
|
1018
1179
|
const a = s ? frameOf(s.a) : latest;
|
|
1019
1180
|
const b = s ? frameOf(s.b) : latest;
|
|
1020
1181
|
const ea = a?.ents.get(id); const eb = b?.ents.get(id);
|
|
@@ -1022,8 +1183,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1022
1183
|
// A placed body jumps: nobody's view glides across a placement.
|
|
1023
1184
|
if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
|
|
1024
1185
|
const t = s.alpha;
|
|
1186
|
+
// Static pickups and scenery need no new vectors or wrappers for every HUD,
|
|
1187
|
+
// scene and probe traversal. Fields still follow the sampled snapshot.
|
|
1188
|
+
if (ea.grounded === eb.grounded && ['pos','vel','heading'].every(key => {
|
|
1189
|
+
const x=ea[key as 'pos'], y=eb[key as 'pos']; return x.x===y.x && x.y===y.y && x.z===y.z;
|
|
1190
|
+
})) return entityOf(t < 1 ? ea : eb);
|
|
1025
1191
|
const h = blendHeading(ea.heading, eb.heading, t, dims);
|
|
1026
|
-
|
|
1192
|
+
// This interpolated key exists for one read only; caching it retains useless weak entries.
|
|
1193
|
+
return entityOf({ ...(t < 1 ? ea : eb), grounded: t >= 1 ? eb.grounded : ea.grounded && (t === 0 || eb.grounded), pos: vec3(blendPosition(ea.pos, eb.pos, t, map.wrap), 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 }, false, false);
|
|
1027
1194
|
}
|
|
1028
1195
|
function get(id: string): Entity | null { const g = ghosts.get(id); return g ? ghostEntity(g) : drawn(id); }
|
|
1029
1196
|
function each(kind: string, fn: (e: Entity) => void): void {
|
|
@@ -1036,7 +1203,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1036
1203
|
// Until the newer snapshot is reached, what the older one holds is still drawn; what only the newer one holds waits for it.
|
|
1037
1204
|
for (const f of [a, s && s.alpha >= 1 ? b : null, a ? null : b]) if (f) for (const u of f.ents.values()) if (u.kind === kind) ids.add(u.id);
|
|
1038
1205
|
if (mine && mine.kind === kind) ids.add(mine.id);
|
|
1039
|
-
for (const id of ids) { const e = drawn(id); if (e) fn(e); }
|
|
1206
|
+
for (const id of ids) { const e = drawn(id, s); if (e) fn(e); }
|
|
1040
1207
|
// A confirmed ghost stands in until the server's own entity is drawn (it arrives a moment behind, interpolated).
|
|
1041
1208
|
for (const g of [...ghosts.values()]) if (g.kind === kind) { if (g.real && ids.has(g.real)) { ghosts.delete(g.id); emit('leave', ghostEntity(g)); } else fn(ghostEntity(g)); }
|
|
1042
1209
|
}
|
|
@@ -1076,25 +1243,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1076
1243
|
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');
|
|
1077
1244
|
emit('status', 'offline', true);
|
|
1078
1245
|
}
|
|
1079
|
-
|
|
1246
|
+
const extra: Record<string, () => unknown> = {};
|
|
1080
1247
|
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; };
|
|
1081
1248
|
const busy = (): boolean => solo ? !solo.me : status !== 'playing' || !mine || latest?.ents.get(mine.id)?.away === true;
|
|
1082
1249
|
// The probes the testing tools read (`check`, `shoot`, `perf`, the playtest judge): the same two objects a game written the old way publishes.
|
|
1083
1250
|
net.expose({
|
|
1084
|
-
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
|
|
1251
|
+
self: () => { const m = meNow(true); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
|
|
1085
1252
|
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; },
|
|
1086
1253
|
movement: () => { const kinds = schema.kinds.filter(k => k.player); return kinds.length && kinds.every(k => k.maxSpeed === 0) ? 'stationary' : 'spatial'; },
|
|
1087
|
-
prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge:
|
|
1254
|
+
prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: hostMedian, pending: pending.length, applications: [...applications] }),
|
|
1088
1255
|
scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
|
|
1089
1256
|
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => solo ? 'offline' : status,
|
|
1090
1257
|
});
|
|
1091
1258
|
if (typeof window !== 'undefined') {
|
|
1092
1259
|
exposePort(net as Netplay<unknown, unknown, unknown>, {
|
|
1093
1260
|
view: 'top', size: schema.kinds.find((k) => k.player)?.radius ?? 0.5,
|
|
1094
|
-
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y } : null; },
|
|
1261
|
+
self: () => { const m = meNow(true); return m ? { x: m.pos.x, y: m.pos.y } : null; },
|
|
1095
1262
|
basis: opts.screenBasis ?? (() => ({ right: [1, 0], up: [0, 1] })),
|
|
1096
1263
|
score: myScore, busy: () => (typeof extra.busy === 'function' ? Boolean(extra.busy()) : busy()),
|
|
1097
|
-
extra
|
|
1264
|
+
extra,
|
|
1098
1265
|
});
|
|
1099
1266
|
}
|
|
1100
1267
|
const timer = opts.timers === false ? null : setInterval(pump, Math.max(8, period / 2));
|
|
@@ -1143,7 +1310,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1143
1310
|
get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
|
|
1144
1311
|
get status() { return solo ? 'offline' : status; },
|
|
1145
1312
|
get seat() { return solo ? solo.seat : net.seat; },
|
|
1146
|
-
get me() { pump(); return meNow(); },
|
|
1313
|
+
get me() { pump(); return meNow(true); },
|
|
1147
1314
|
each, get,
|
|
1148
1315
|
ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
|
|
1149
1316
|
rays: options => solo ? solo.rays(options) : raySnapshot(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,options,mine?.id),
|
|
@@ -1180,6 +1347,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1180
1347
|
if (changed) pump();
|
|
1181
1348
|
const shown = changed ? meNow()?.pos : null;
|
|
1182
1349
|
for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'){pulseCount+=1;const target=Math.max(stepped+1,(pulseQueue.at(-1)?.t??stepped)+1);pulseQueue.push({name,value:Number(value),n:pulseCount,t:target});net.send('pulse',[epoch,name,value,target,pulseCount]);} else pressed.set(name,value); }
|
|
1350
|
+
if (changed) presentedEntity = null;
|
|
1183
1351
|
sample = next;
|
|
1184
1352
|
// A discrete action belongs to the present, even while an unseen impulse is
|
|
1185
1353
|
// being drawn from history. Resume the live prediction and let the existing
|
|
@@ -1204,7 +1372,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1204
1372
|
get round() { return roundNow(); },
|
|
1205
1373
|
get roster() { return roster(); },
|
|
1206
1374
|
follow(id) { const e = id ? latest?.ents.get(id) : null; net.follow(e && e.seat !== undefined ? e.seat : null); },
|
|
1207
|
-
probe(x) { extra
|
|
1375
|
+
probe(x) { Object.assign(extra, x); },
|
|
1208
1376
|
ask(id, askId, args = {}) { if (solo) return; const e = latest?.ents.get(id); if (e && e.seat !== undefined) agents?.ask(e.seat, askId, args); },
|
|
1209
1377
|
askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
|
|
1210
1378
|
tune, get map(){return solo ? solo.map : mapAt(latest?.k??0);}, net: net as Netplay,
|
package/site/SITE.md
CHANGED
|
@@ -591,3 +591,16 @@ credentials.
|
|
|
591
591
|
X decides what it shows, may cache tags, and may change or stop this behavior.
|
|
592
592
|
Local success does not prove that X will display a card. The studio can use the
|
|
593
593
|
off switch above if it does not want to offer a game through a video-oriented card.
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
## Canonical address and decorative app frames
|
|
597
|
+
|
|
598
|
+
Set `cloudflare.domain` for the canonical public address. The manifest uses that
|
|
599
|
+
address even when fetched from workers.dev; browser pages on the alias redirect,
|
|
600
|
+
while discovery and tool transports remain reachable. Listing requires an explicit
|
|
601
|
+
`homie.directory: true` or URL; existing claims remain valid.
|
|
602
|
+
|
|
603
|
+
An iframe inside `.hero-media` is decorative and lets the parent page scroll.
|
|
604
|
+
Add `data-interactive` only when people should interact with the hero. Ordinary app
|
|
605
|
+
frames permit Web Share and downloads; browser capability and a user gesture still
|
|
606
|
+
apply. App shells allow normal touch scrolling and text selection.
|
package/tools/TOOLS.md
CHANGED
|
@@ -179,3 +179,12 @@ and removes secrets directly; remote owner tools `studio_events_webhook_status`
|
|
|
179
179
|
and `studio_events_webhook_test` inspect delivery and test it. The receiver verifies
|
|
180
180
|
Standard Webhooks signatures, deduplicates events, then wakes the owner's AI to use
|
|
181
181
|
its own service connectors.
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
## Signed-in connector address
|
|
185
|
+
|
|
186
|
+
Use `/mcp/auth` for owner and staff connectors. Its anonymous 401 includes the
|
|
187
|
+
`WWW-Authenticate` resource metadata URL for `/.well-known/oauth-protected-resource/mcp`.
|
|
188
|
+
Both transports use the same `/mcp` OAuth resource; `/mcp` remains public without a
|
|
189
|
+
token. An approved connection retains the account’s current app grants, not a copy
|
|
190
|
+
of its old permissions. Manage connections at `/_studio/office/connections`.
|
package/worker/arrival.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {appWords} from './app-format.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* THE ARRIVAL (0.26.0): the play page and the big screen never open on a blank screen. From the page's first paint
|
|
3
4
|
* until the game says it is playable, an arrival card covers the game's frame in the game's own look: its title and
|
|
@@ -66,7 +67,7 @@ export function arrivalCard(cat, g, { screen = false, off = false } = {}) {
|
|
|
66
67
|
const pitch = String(L.pitch ?? '').trim() || firstSentence(g?.blurb);
|
|
67
68
|
const studio = cat?.studio?.name ?? '';
|
|
68
69
|
const keys = screen
|
|
69
|
-
? [['tv', L.controls?.tv ?? 'Phones are the controllers: scan the code to play.']]
|
|
70
|
+
? [['tv', L.controls?.tv ?? (g.kind === 'app' ? appWords(g).join : 'Phones are the controllers: scan the code to play.')]]
|
|
70
71
|
: [['phone', L.controls?.phone], ['desk', L.controls?.computer]].filter(([, v]) => v);
|
|
71
72
|
const art = wide || tall
|
|
72
73
|
? `<picture class="arrive-art">${wide && tall ? `<source media="(min-aspect-ratio: 3/4)" srcset="${esc(wide)}">` : ''}<img src="${esc(tall ?? wide)}" alt="" decoding="async" fetchpriority="high" style="object-position:${esc(focus)}"></picture>`
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.51.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.51.2","sha256":"bffe9d1a55a4cbe04cb14a1c1d20fe5d32e7a1aa2d2a637d81b45c10c4087b64"});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Only an explicit choice enables a new directory claim. */
|
|
2
|
+
export function directoryUrl(value) {
|
|
3
|
+
if (value === true) return 'https://homie.rocks';
|
|
4
|
+
if (typeof value !== 'string') return null;
|
|
5
|
+
try {
|
|
6
|
+
const url = new URL(value);
|
|
7
|
+
return url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname) ? url.origin : null;
|
|
8
|
+
} catch { return null; }
|
|
9
|
+
}
|
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { directoryUrl } from './directory-choice.mjs';
|
|
1
2
|
import {APP_STORAGE_JS} from './app-frame.mjs';
|
|
2
3
|
import {recordsPage} from './records-page.mjs';
|
|
3
4
|
import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
|
|
@@ -235,13 +236,7 @@ const repoOf = (env) => { const r = String(env.HOMIE_REPO ?? '').trim(); return
|
|
|
235
236
|
const repoSaid = new Map();
|
|
236
237
|
|
|
237
238
|
export function directoryOf(cat) {
|
|
238
|
-
|
|
239
|
-
if (d === null || d === false) return null;
|
|
240
|
-
const at = String(d ?? 'https://homie.rocks').replace(/\/+$/, '');
|
|
241
|
-
try {
|
|
242
|
-
const u = new URL(at);
|
|
243
|
-
return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(u.hostname)) ? u.origin : null;
|
|
244
|
-
} catch { return null; }
|
|
239
|
+
return directoryUrl(cat?.studio?.directory);
|
|
245
240
|
}
|
|
246
241
|
|
|
247
242
|
/** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
|
|
@@ -251,6 +246,7 @@ async function claimOf(env, cat, url) {
|
|
|
251
246
|
if (claimed.has(origin) && (!repo || repoSaid.get(origin) === repo)) return claimed.get(origin);
|
|
252
247
|
const kept = await storedClaim(env, origin);
|
|
253
248
|
if (kept?.own && (!repo || kept.repo === repo)) { claimed.set(origin, kept.own); if (repo) repoSaid.set(origin, repo); return kept.own; }
|
|
249
|
+
if (kept?.older) return kept.older;
|
|
254
250
|
const directory = directoryOf(cat);
|
|
255
251
|
const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
|
|
256
252
|
const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
|
|
@@ -543,7 +539,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
543
539
|
'cache-control': 'no-store, no-transform',
|
|
544
540
|
'access-control-allow-origin': '*',
|
|
545
541
|
// The game runs in an opaque origin: it cannot read this site's storage or cookies.
|
|
546
|
-
'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
|
|
542
|
+
'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${meta.kind === 'app' ? ' allow-downloads' : ''}; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
|
|
547
543
|
},
|
|
548
544
|
});
|
|
549
545
|
}
|
|
@@ -678,9 +674,18 @@ async function route(request, env, ctx) {
|
|
|
678
674
|
const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
|
|
679
675
|
const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
|
|
680
676
|
|
|
677
|
+
// Keep tool transports and discovery on the alias; redirect browser pages only.
|
|
678
|
+
if (read && url.hostname.endsWith('.workers.dev') && env.HOMIE_PREVIEW !== '1'
|
|
679
|
+
&& request.headers.get('upgrade')?.toLowerCase() !== 'websocket'
|
|
680
|
+
&& !/\/(?:api|__[a-z]+)(?:\/|$)/.test(path)
|
|
681
|
+
&& !/^\/(?:\.well-known(?:\/|$)|api(?:\/|$)|mcp(?:\/|$)|oauth(?:\/|$)|hooks(?:\/|$)|_studio(?:\/|$))/.test(path)) {
|
|
682
|
+
const canonical = (await getAll()).studio?.canonical;
|
|
683
|
+
if (canonical && canonical !== url.origin) return Response.redirect(canonical + url.pathname + url.search, 308);
|
|
684
|
+
}
|
|
685
|
+
|
|
681
686
|
if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
|
|
682
687
|
|
|
683
|
-
if (path === '/mcp' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
|
|
688
|
+
if (path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
|
|
684
689
|
const { remoteMcp } = await import('./mcp.mjs');
|
|
685
690
|
return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
|
|
686
691
|
}
|
|
@@ -726,6 +731,10 @@ async function route(request, env, ctx) {
|
|
|
726
731
|
}
|
|
727
732
|
if (path === '/.well-known/homie-studio.json') {
|
|
728
733
|
const cat = await getCat();
|
|
734
|
+
const canonical = cat.studio?.canonical;
|
|
735
|
+
if (canonical && env.HOMIE_PREVIEW !== '1' && url.protocol === 'https:') {
|
|
736
|
+
const address = new URL(canonical); url.protocol = address.protocol; url.host = address.host;
|
|
737
|
+
}
|
|
729
738
|
const sharing = Boolean(cat.studio?.stats?.share);
|
|
730
739
|
const [played, byGame] = sharing ? await Promise.all([playedThisWeek(env), playedByGame(env)]) : [null, null];
|
|
731
740
|
// studio.json `"rooms": { "share": false }` keeps the rooms off the hub: a manifest that names no `rooms` shares none.
|
package/worker/mcp.mjs
CHANGED
|
@@ -158,7 +158,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
158
158
|
});
|
|
159
159
|
return s;
|
|
160
160
|
};
|
|
161
|
-
return createMcpHandler(server)(request,env,ctx);
|
|
161
|
+
return createMcpHandler(server,{route:path === '/mcp/auth' ? '/mcp/auth' : '/mcp'})(request,env,ctx);
|
|
162
162
|
};
|
|
163
163
|
if(localIdentity)return api(request,scoped,ctx);
|
|
164
164
|
const provider=new OAuthProvider({
|
|
@@ -211,7 +211,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
211
211
|
const rows=(await env.DB.prepare(owner?'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person ORDER BY c.created DESC LIMIT 100 OFFSET ?1':'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person WHERE c.person=?1 ORDER BY c.created DESC LIMIT 100 OFFSET ?2').bind(...(owner?[offset]:[person,offset])).all()).results;
|
|
212
212
|
const events=owner?(await env.DB.prepare('SELECT a.*,p.name FROM mcp_audit a LEFT JOIN players p ON p.id=a.person ORDER BY a.id DESC LIMIT 100 OFFSET ?1').bind(offset).all()).results:[];
|
|
213
213
|
const names=new Map(await Promise.all([...new Set([...rows,...events].map(r=>r.client).filter(Boolean))].map(async id=>[id,(await oauth.lookupClient(id).catch(()=>null))?.clientName??id])));
|
|
214
|
-
return html(`<h1>Your studio's AI connections</h1><p>MCP address: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
|
|
214
|
+
return html(`<h1>Your studio's AI connections</h1><p>Signed-in MCP address: <code>${esc(origin)}/mcp/auth</code> · Public: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
|
|
215
215
|
}
|
|
216
216
|
return json({error:'not found'},404);
|
|
217
217
|
}},
|
package/worker/pages.mjs
CHANGED
|
@@ -200,6 +200,7 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
200
200
|
.notice .acts a.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
201
201
|
[hidden] { display: none !important; }
|
|
202
202
|
${arrive.css}
|
|
203
|
+
${g.kind === 'app' ? 'html,body,iframe.game{touch-action:auto;user-select:auto;-webkit-user-select:auto;-webkit-touch-callout:default}' : ''}
|
|
203
204
|
${embed ? `html,body{touch-action:auto}.room.at-top-center{transform:none;left:auto;right:max(8px,env(safe-area-inset-right))}.embed-open{display:flex;align-items:center;min-height:44px;color:inherit}.embed-note{font:12px/1.4 system-ui;overflow-wrap:anywhere}.sheet{position:fixed;inset:calc(max(8px,env(safe-area-inset-top)) + 48px) max(8px,env(safe-area-inset-right)) auto auto;max-width:calc(100vw - max(8px,env(safe-area-inset-left)) - max(8px,env(safe-area-inset-right)));max-height:calc(100dvh - max(8px,env(safe-area-inset-top)) - 48px - max(8px,env(safe-area-inset-bottom)));overflow:auto;overscroll-behavior:contain;touch-action:pan-y}.sheet.more::after{content:'▾ more below';position:sticky;bottom:-12px;display:block;margin:0 -12px -12px;padding:16px 0 5px;text-align:center;font:600 11px/1 system-ui;color:#cfd6e6;background:linear-gradient(transparent,rgba(8,12,22,.98) 60%);pointer-events:none}.sheet .embed-help{margin:8px 2px;font:500 12px/1.4 system-ui;color:#aab3c7}html:not(.player-frame) .frame-only{display:none}.sheet a,.sheet button{min-height:44px}.sheet .foot a,.sheet .foot button{display:inline-flex;align-items:center}.embed-open .frame-only{margin-left:4px}.player-top iframe.game{inset:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);width:calc(100% - env(safe-area-inset-left) - env(safe-area-inset-right));height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom))}.arrive-in{padding:12px}.arrive-title{font-size:clamp(24px,7vw,52px)}` : ''}
|
|
204
205
|
${shopControl ? `.shop-control{color:#eef1f8;font:600 13px/1.2 system-ui;position:fixed;top:env(safe-area-inset-top);left:max(8px,env(safe-area-inset-left));height:52px;display:flex;align-items:center;z-index:6}.shop-control button{min-height:44px;padding:0 18px}html body .room.room{flex-direction:column;align-items:flex-end;top:max(4px,env(safe-area-inset-top));right:max(8px,env(safe-area-inset-right));left:auto;bottom:auto;transform:none}html body iframe.game.game{top:calc(env(safe-area-inset-top) + 52px);height:calc(100% - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 52px)}` : ''}
|
|
205
206
|
${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHOP_SHELL_CSS : ''}${pillUi}`;
|
|
@@ -241,7 +242,7 @@ ${embed ? ` <p class="embed-help">Sound starts after a press. If pointer lock
|
|
|
241
242
|
</div>
|
|
242
243
|
<div class="toast" data-toast role="status" hidden></div>`;
|
|
243
244
|
return layoutless(`${g.name} · ${appWords(g).open}`, `
|
|
244
|
-
<iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe>
|
|
245
|
+
<iframe class="game" title="${esc(g.name)}" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${g.kind === 'app' ? ' allow-downloads' : ''}" allow="fullscreen *; autoplay *; gamepad *${g.kind === 'app' ? '; web-share *' : ''}"></iframe>
|
|
245
246
|
${arrive.html}
|
|
246
247
|
<div class="chip${arrive.html ? ' held' : ''}" data-chip><span data-status>finding a room…</span></div>
|
|
247
248
|
<div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
|
|
@@ -1178,7 +1179,7 @@ export function watchPage(cat, g, { origin = '', room = null, ticket = null, pol
|
|
|
1178
1179
|
<span class="eyes" data-eyes hidden title="Watching this room"><svg viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg><span data-eyes-n></span><span class="eyes-w"> watching</span></span>
|
|
1179
1180
|
<a class="playb" data-playb href="${esc(openPath(g))}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.8v14.4a1 1 0 0 0 1.5.86l12-7.2a1 1 0 0 0 0-1.72l-12-7.2A1 1 0 0 0 7 4.8Z" fill="currentColor"/></svg><span>${esc(appWords(g).open)}</span></a>
|
|
1180
1181
|
</header>
|
|
1181
|
-
<main class="stage"><iframe class="game" title="${esc(g.name)}, live" tabindex="-1" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups" allow="fullscreen *; autoplay *; gamepad *"></iframe></main>
|
|
1182
|
+
<main class="stage"><iframe class="game" title="${esc(g.name)}, live" tabindex="-1" sandbox="allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${g.kind === 'app' ? ' allow-downloads' : ''}" allow="fullscreen *; autoplay *; gamepad *${g.kind === 'app' ? '; web-share *' : ''}"></iframe></main>
|
|
1182
1183
|
<footer class="dock">
|
|
1183
1184
|
<div class="caption" data-caption role="status" aria-live="polite"><b data-cap-head>Joining the room…</b><span data-cap-sub></span></div>
|
|
1184
1185
|
<nav class="strip" data-strip role="toolbar" aria-label="Whose view to watch"></nav>
|