Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions OPEN-CORE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ Consulted in `handleConnection` when an unknown peer connects: if the auth provi
Default (no provider) keeps the whitelist + approval flow byte-identical. `authorize`
is a synchronous lookup against state the plugin maintains from its own async login.

**Automatic admissions are never silent (fix-live F-5).** Whenever `decide` answers
`'admit'` (or `authorize` returns true), core shows the HOST a toast and a
notification-history entry that keeps its buttons until answered: **Remove** (closes the
link, drops the roster row, refuses that peer's re-dials while the tab stays open) and,
when the provider offers it, a switch-off. The optional provider member
`admitNotice(peerId, cloudMeta) → {text?, turnOff?, turnOffLabel?} | null` supplies the
line (≤ 200 chars) and the switch-off; absent or null = a generic line, no switch-off.
`api.setConnectBadge({text, title?, onClick?} | null)` puts a small badge on the Connect
pill (`#connect-setting-badge`, ≤ 32 chars) for a standing setting such as "Public ·
guests join"; a press runs `onClick` or opens the drawer's Rooms tab.

### 3. UI mount points

Roles replicate over the **plugin message channel**: an admin broadcasts the roles
Expand Down Expand Up @@ -248,6 +259,9 @@ static|embed>`. Core never talks to a server about it.
| `loadRemoteScene({…, keepGameId?})` | `keepGameId: true` = the OWNER opening their own published scene (no fork); otherwise a community load is a remix |
| `onExportBuilt(fn)` → off | after an export zip is built: `fn({gameId, buildId, preset, title, template, parentGameId, appVersion})` |
| `onTemplateOpen(fn)` → off | a Games-tab template was started: `fn(slug)` (one "template open") |
| `toast(message, actions?, {keep?})` | fix-live F-2: with `keep: true` an action toast's buttons also stay on its notification-history entry until one is pressed (a role offer the user sees later) |
| `setPeerMenuItems(items)` | fix-live: `[{id, label, run(peerId, {name})}]` (≤ 8, labels ≤ 40) appended to the Users peer menu (right-click a peer) under core's own entries — the cloud's "Report…"; `[]` clears |
| `setConnectBadge(badge \| null)` | fix-live F-5: `{text, title?, onClick?}` — a small badge on the Connect pill for a standing setting (e.g. "Public · guests join"); `null` hides it. Probe with `typeof` |
| `setSceneHeart(info \| null)` | the heart on the play-link start card (`#embed-heart`) and a game's pause menu (`#game-shell-heart`): `{count, liked, toggle() → Promise<{liked, count} \| null>}`; `null` from `toggle` rolls the optimistic flip back |

The community provider gains three optional entry fields — `liked` (boolean, the viewer's own
Expand Down
30 changes: 29 additions & 1 deletion src/components/menu/Connect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { signalingRetry, formatElapsed, approvalStartedAt, approvalRemaining, APPROVAL_WINDOW_MS, joinRefusal, clearJoinRefusal, HARD_PEER_CAP } from '$lib/connectionState';
import { cancelOutboundRequest, requestConnect } from '$lib/peerApproval';
import { sessionHost } from '$lib/connectionState';
import { connectSlot, drawerSlot } from '$lib/cloudHooks';
import { connectSlot, drawerSlot, connectBadge } from '$lib/cloudHooks';
// 41 G21: the status is a DOT (grey / yellow pulsing / green / red); its words ride title + aria-label
import { connectionStatus } from '$lib/connectionStatus';
import CloudSlot from '../CloudSlot.svelte';
Expand Down Expand Up @@ -659,6 +659,19 @@
>
{/if}

{#if $connectBadge && !squeezedView}
<!-- F-5: a standing setting the host must never forget (the cloud plugin's "Public ·
guests join" while a listed room auto-admits guests). A press opens what it names. -->
<button
type="button"
id="connect-setting-badge"
class="cx-setting-badge"
data-testid="connect-setting-badge"
title={$connectBadge.title || $connectBadge.text}
onclick={() => ($connectBadge?.onClick ? $connectBadge.onClick() : openRooms())}>{$connectBadge.text}</button
>
{/if}

<!-- Rooms shortcut → opens the drawer on its Rooms tab. Shown only when the cloud
plugin provides room content ($drawerSlot) and Settings ▸ Show Rooms button is on. -->
{#if $drawerSlot && $showRoomsButton && !squeezedView}
Expand Down Expand Up @@ -864,6 +877,21 @@
background: color-mix(in srgb, var(--ink-bad) 16%, transparent);
cursor: pointer;
}
.cx-setting-badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 8px;
border: 0;
border-radius: var(--radius-pill);
font-size: var(--fs-badge);
font-weight: 600;
white-space: nowrap;
color: var(--ink-warn);
background: color-mix(in srgb, var(--ink-warn) 16%, transparent);
cursor: pointer;
}
.cx-refused[data-result='full'] {
color: var(--ink-warn);
background: color-mix(in srgb, var(--ink-warn) 16%, transparent);
Expand Down
37 changes: 36 additions & 1 deletion src/components/menu/NotificationCenter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// missed (or dismissed while a modal was open) is still recoverable. Placed in the
// top-right chrome next to the peers/profile cluster (Users.svelte), mirroring the
// peers popover pattern (absolute dropdown, click-catcher backdrop).
import { notifications, notificationsUnread, notificationCenterOpen } from '../../stores/appStore.js';
import { notifications, notificationsUnread, notificationCenterOpen, notificationActions } from '../../stores/appStore.js';

function toggle() {
const willOpen = !$notificationCenterOpen;
Expand All @@ -18,6 +18,16 @@
function clearAll() {
notifications.set([]);
notificationsUnread.set(0);
notificationActions.set({});
}

/** F-5: the newest entry of a group that still offers buttons @param {any[]} items */
function liveActionsOf(items) {
for (let i = items.length - 1; i >= 0; i--) {
const acts = $notificationActions[items[i].id];
if (acts?.length) return acts;
}
return null;
}

// NOTES-38 #13 (user): a message that repeats ("Cannot reach the peer server. Retrying…"
Expand Down Expand Up @@ -95,6 +105,13 @@
<span class="min-w-0">
<span class="notif-text">{g.text}</span>
<span class="notif-time">{ago(g.latest.ts)}</span>
{#if liveActionsOf(g.items)}
<span class="notif-actions">
{#each liveActionsOf(g.items) ?? [] as a (a.label)}
<button class="notif-act" onclick={() => a.action()}>{a.label}</button>
{/each}
</span>
{/if}
{#if g.items.length > 1 && open[g.text]}
<ul class="notif-times">
{#each [...g.items].reverse() as n (n.id)}
Expand Down Expand Up @@ -129,6 +146,24 @@
.notif-pop :global(.wc-body) {
max-height: 60vh;
}
.notif-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.notif-act {
font-size: var(--fs-badge);
padding: 2px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--accent);
cursor: pointer;
}
.notif-act:hover {
background: var(--surface-hover);
}
.notif-list {
margin: 0;
padding: 4px 0;
Expand Down
14 changes: 11 additions & 3 deletions src/components/menu/Users.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
import { toolbarSlots, slotOrder, TOP_DEFAULT } from '$lib/toolbarSlots';
import { phoneShell } from '$lib/ui/phoneShell.js';
import CloudSlot from '../CloudSlot.svelte';
import { usersSlot, profileSlot, rolesInfo, scenePresence } from '$lib/cloudHooks';
import { usersSlot, profileSlot, rolesInfo, scenePresence, peerMenuItems } from '$lib/cloudHooks';
import { safeStorage } from '$lib/safeStorage';
// 45 N4: a pinned / closed right sidebar moves the profile circle while its menu may be open
import { geometry as sidebarGeometry } from '$lib/sidebars';
Expand All @@ -122,7 +122,7 @@
// 53 A2: and its SHAPE, so the band never rides on colour alone ($lib/networkQuality)

let openDropdown = $state(false);
let muteMenu = $state(null);
let muteMenu = $state<{ x: number; y: number; peerId: string } | null>(null);
// 130: the peers overflow is a proper popover listing EVERY peer (none
// hidden by stacking) with a labeled Watch affordance
/** @param {string} id */
Expand Down Expand Up @@ -1120,7 +1120,15 @@
{
label: $mutedPeers.includes(muteMenu.peerId) ? 'Unmute voice' : 'Mute voice',
action: () => toggleMutePeer(muteMenu.peerId)
}
},
// fix-live: the plugin's entries (the cloud's "Report…"), with the name the roster shows
...$peerMenuItems.map((item) => {
const pid = muteMenu?.peerId ?? '';
return {
label: item.label,
action: () => item.run(pid, { name: String($userdata.find((e) => e[0] === pid)?.[1] ?? '') })
};
})
]}
on:close={() => (muteMenu = null)}
/>
Expand Down
25 changes: 25 additions & 0 deletions src/lib/admitNotice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// F-5 (roadmap 55 "Fixes for 54", test flight T14): an automatic admission is never silent.
// The cloud rooms plugin admits guests to a listed room with "Allow guests to join
// automatically" on, and the host saw nothing at all — no card, no toast, no history. Core
// now tells the host about EVERY automatic admission; the auth provider may say why and
// offer to switch the setting off (authProvider.admitNotice). Pure: no stores, no peers.

/** @param {string} peerId */
export function shortPeer(peerId) {
return String(peerId ?? '').slice(0, 6).toUpperCase();
}

/**
* @param {string} peerId
* @param {any} provided what `authProvider.admitNotice(peerId, cloudMeta)` returned (or threw → null)
* @returns {{ text: string, turnOff: (() => void) | null, turnOffLabel: string }}
*/
export function composeAdmitNotice(peerId, provided) {
const p = provided && typeof provided === 'object' ? provided : null;
const text =
String(p?.text ?? '').replace(/\s+/g, ' ').trim().slice(0, 200) ||
shortPeer(peerId) + ' joined automatically — your session settings let them in without approval.';
const turnOff = typeof p?.turnOff === 'function' ? p.turnOff : null;
const turnOffLabel = String(p?.turnOffLabel ?? '').trim().slice(0, 24) || 'Turn off';
return { text, turnOff, turnOffLabel };
}
24 changes: 24 additions & 0 deletions src/lib/ai/aiPhysicsDefault.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// fix-live (roadmap 55 "Fixes for 54" F-4, test flight T8): what the AI tools' create does
// about physics. The Add menu stamps standard primitives DYNAMIC, mass 1 (geometries.svelte.js,
// "fun by default": a cube you drop falls). Right for one cube a person places; wrong for a
// scene an AI builds out of 40 of them — every wall, rock and roof beam fell over the moment
// Play started a sim. AI-made objects are STATIC unless the request asks for physics
// (`physics: 'dynamic'` or a `mass`). Pure: the caller writes the patch through setPhysicsFor.

/**
* @param {any} spec one create_objects entry
* @param {any} stamped the `userData.physics` the builder stamped (or undefined)
* @returns {{mode: 'static'} | {mode: 'dynamic', mass: number} | null} the patch, or null for none
*/
export function aiCreatePhysics(spec, stamped) {
const mass = Number(spec?.mass);
const wantsMass = spec?.mass != null && Number.isFinite(mass) && mass > 0;
if (spec?.physics === 'dynamic' || wantsMass) {
const m = wantsMass ? Math.min(mass, 10000) : 1;
if (stamped?.mode === 'dynamic' && (stamped.mass ?? 1) === m) return null;
return { mode: 'dynamic', mass: m };
}
// nothing asked: only an object the builder made dynamic needs turning back
if (stamped?.mode === 'dynamic') return { mode: 'static' };
return null;
}
22 changes: 19 additions & 3 deletions src/lib/ai/tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
import { setObjectColor, switchMaterialType, setMaterialParam } from '$lib/materialsHandler';
import { notifyExternalMove } from '$lib/flowRuntime';
import { meshGenReady } from './meshProviders.js';
import { viewerToolRefusal } from './viewerGate.js';
import { aiCreatePhysics } from './aiPhysicsDefault.js';
import { setPhysicsFor } from '$lib/physics';
import { isViewer } from '$lib/objectPermissions';
import { graphOf, SCENE_GRAPH } from '../../stores/flowStore';
import { sceneJoints } from '$lib/joints';
import { graphToText } from '$lib/graphText';
Expand Down Expand Up @@ -321,7 +325,10 @@ function createOne(spec) {
const parent = objectOf(spec.parentUuid);
if (parent && parent.type === 'Group') moveObjectToGroup(uuid, spec.parentUuid);
}
return { uuid, name: created.name };
// fix-live F-4: static unless asked — a built scene must not fall apart in Play
const physics = aiCreatePhysics(spec, created.userData?.physics);
if (physics) setPhysicsFor(uuid, physics);
return { uuid, name: created.name, ...(created.userData?.physics?.mode ? { physics: created.userData.physics.mode } : {}) };
}

/** @param {any} raw @returns {string|null} */
Expand Down Expand Up @@ -562,6 +569,9 @@ const PHYSICS_DISABLED =
*/
export async function executeAiTool(rawName, rawArgs) {
const { name, args } = repairToolCall(rawName, rawArgs);
// F-1: a viewer's edits never reach anyone, so refuse instead of reporting success
const refusal = viewerToolRefusal(name, isViewer());
if (refusal) return refusal;
try {
switch (name) {
case 'list_scene':
Expand Down Expand Up @@ -729,7 +739,13 @@ export const AI_TOOLS = [
},
scale: { type: 'array', items: { type: 'number' }, description: '[x,y,z]' },
color: { type: 'string', description: 'hex like #ff8800 (meshes only)' },
parentUuid: { type: 'string', description: 'uuid of an existing group to nest under' }
parentUuid: { type: 'string', description: 'uuid of an existing group to nest under' },
physics: {
type: 'string',
enum: ['static', 'dynamic'],
description: 'default static (it stays put in Play). dynamic = it falls, collides and can be thrown — only when asked for'
},
mass: { type: 'number', description: 'kg; implies physics dynamic' }
},
required: ['kind']
}
Expand Down Expand Up @@ -947,7 +963,7 @@ const PHYSICS_AI_TOOLS = [
function: {
name: 'set_physics',
description:
'Set physics body params on objects (batch). NOTE: new primitives already spawn dynamic with mass 1 — use this mostly to TUNE bodies or make scenery immovable (mode "static" for ground/walls). mode "auto" reverts to scenery defaults.',
'Set physics body params on objects (batch). NOTE: objects made with create_objects are STATIC unless created with physics "dynamic" or a mass — use this to make existing objects dynamic, to TUNE bodies, or to pin scenery (mode "static"). mode "auto" reverts to scenery defaults.',
parameters: {
type: 'object',
properties: {
Expand Down
24 changes: 24 additions & 0 deletions src/lib/ai/viewerGate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// F-1 (roadmap 55 "Fixes for 54", test flight T1): a VIEWER's AI tool call must never
// report success for an edit nobody else will see. Their creations are kept local by the
// send-gate (objectPermissions.gateCreationBroadcast) and their changes to shared objects
// are dropped by every receiver, so `{ created: [...] }` was a lie the model (or an
// external agent) built on. Viewers get a refusal that says why and what to do instead.
// Pure: the caller passes the role, so this stays unit-testable without the scene.

/** tools that never change the scene or anything replicated. Simulation playback is
* local (viewers may press Play like anyone else), so it stays allowed. */
const VIEWER_SAFE_TOOLS = new Set(['list_scene', 'control_simulation']);

export const VIEWER_REFUSAL =
'view-only: you are a viewer in this session, so nothing you create or change would reach the other people here. ' +
'Ask the host or an admin for edit access, then try again. Reading the scene (list_scene) still works.';

/**
* @param {string} name the (repaired) tool name
* @param {boolean} viewer is the local user a viewer?
* @returns {{ error: string, viewOnly: true } | null} the refusal, or null to run the tool
*/
export function viewerToolRefusal(name, viewer) {
if (!viewer || VIEWER_SAFE_TOOLS.has(name)) return null;
return { error: VIEWER_REFUSAL, viewOnly: true };
}
55 changes: 54 additions & 1 deletion src/lib/cloudHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,16 @@ const ALWAYS_ALLOWED = new Set([
// reply and every update stay gateable, which is what keeps a viewer read-only.
'getytext',
// 60 Q5: the shared reference images REQUEST (the `underlay` replies stay gateable)
'getunderlays'
'getunderlays',
// fix-live (plans review #5, roadmap 49 follow-up): the last three `get*` requests off the
// floor. A viewer asking for the project manifest, a missing asset's bytes (or thumbnail) or
// one big material preset was DROPPED, so a cloud viewer's Explorer never filled in and a
// shared texture or preset stayed missing. Each only asks; the answerer sends what it already
// shares, and the replies (`manifest`, `assetfile`/`assetstart`/`assetchunk`/`assetmissing`,
// `matpresetpart`) stay gateable.
'getproject',
'getasset',
'getmatpreset'
]);

/** @type {((peerId: string, msgType: string) => boolean) | null} */
Expand Down Expand Up @@ -126,6 +135,10 @@ export function hasCapabilityProvider() {
* present it replaces `authorize`: `cloudMeta` is the joiner's dial data
* (cloudApi.connectToPeer's 2nd arg, null when absent); 'deny' refuses with no
* approval card; an object is the normal card, carrying `label`.
* admitNotice(peerId, cloudMeta): {text?, turnOff?, turnOffLabel?} | null — F-5 (fix-live,
* optional). Core tells the HOST about EVERY automatic admission (toast + notification
* history, with Remove); the provider says WHY in `text` and may offer `turnOff()` (e.g.
* switch "Allow guests to join automatically" off). Null/absent = the generic notice.
* @type {any}
*/
let authProvider = null;
Expand Down Expand Up @@ -413,3 +426,43 @@ export const sceneHeart = writable(null);
export function setSceneHeart(info) {
sceneHeart.set(info && typeof info === 'object' && typeof info.toggle === 'function' ? info : null);
}

/**
* F-5 (fix-live): A STANDING SETTING THAT IS NEVER INVISIBLE — a small badge on the Connect
* pill, e.g. "Public · guests join" while a listed room auto-admits guests. The plugin sets it
* (cloudApi.setConnectBadge) whenever the setting changes; null hides it. Shape:
* { text: string (≤ 32 chars), title?: string (tooltip, ≤ 200), onClick?: () => void }
* @type {import('svelte/store').Writable<{text: string, title: string, onClick: (() => void) | null} | null>} */
export const connectBadge = writable(null);

/** @param {any} badge */
export function setConnectBadge(badge) {
const text = badge && typeof badge === 'object' ? String(badge.text ?? '').trim().slice(0, 32) : '';
connectBadge.set(
text
? {
text,
title: String(badge.title ?? '').slice(0, 200),
onClick: typeof badge.onClick === 'function' ? badge.onClick : null
}
: null
);
}

/**
* fix-live (reports): entries a plugin adds to the Users PEER menu (right-click a peer), under
* core's own. `run(peerId, {name})` gets the peer's session id and the name the roster shows.
* The cloud plugin's "Report…" lives here. Set via cloudApi.setPeerMenuItems; [] clears.
* @type {import('svelte/store').Writable<{id: string, label: string, run: (peerId: string, info: {name: string}) => void}[]>} */
export const peerMenuItems = writable([]);

/** @param {any} items */
export function setPeerMenuItems(items) {
const list = Array.isArray(items) ? items : [];
peerMenuItems.set(
list
.filter((i) => i && typeof i.run === 'function' && String(i.label ?? '').trim())
.slice(0, 8)
.map((i, n) => ({ id: String(i.id ?? 'item-' + n).slice(0, 40), label: String(i.label).trim().slice(0, 40), run: i.run }))
);
}
Loading
Loading