diff --git a/client/src/components/eidoverse/EidoverseWorldDrawer.jsx b/client/src/components/eidoverse/EidoverseWorldDrawer.jsx new file mode 100644 index 000000000..ed0827617 --- /dev/null +++ b/client/src/components/eidoverse/EidoverseWorldDrawer.jsx @@ -0,0 +1,520 @@ +import { useState } from 'react'; +import { Compass, Database, Palette, RefreshCw } from 'lucide-react'; +import { Link } from 'react-router'; +import Drawer from '../Drawer'; +import useDrawerTab from '../../hooks/useDrawerTab'; +import { formatBytes } from '../../utils/formatters'; + +const TABS = [ + { id: 'experience', label: 'Experience', icon: Compass }, + { id: 'districts', label: 'Districts & Data', icon: Database }, + { id: 'appearance', label: 'Appearance & Assets', icon: Palette }, + { id: 'updates', label: 'Updates & Advanced', icon: RefreshCw }, +]; +const TAB_IDS = TABS.map(({ id }) => id); +const SOURCE_ROUTES = { + apps: '/apps', agents: '/cos/agents', tasks: '/cos/tasks', features: '/settings/features', + peers: '/instances', health: '/cos/health', productivity: '/cos/productivity', + activity: '/cos/productivity', goals: '/goals/list', memory: '/brain/memory', + storage: '/settings/database', jira: '/goals/list', operations: '/cos/health', +}; + +const fieldClass = 'mt-1 min-h-[42px] w-full rounded-lg border border-port-border bg-port-bg px-3 text-sm text-white focus:border-port-accent focus:outline-none'; +const secondaryButton = 'inline-flex min-h-[40px] items-center justify-center rounded-lg border border-port-border px-3 py-2 text-sm text-gray-200 transition-colors hover:border-port-accent hover:text-white disabled:cursor-wait disabled:opacity-50'; +const primaryButton = 'inline-flex min-h-[40px] items-center justify-center rounded-lg bg-port-accent px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:cursor-wait disabled:opacity-50'; + +const titleCase = (value) => String(value || '') + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (letter) => letter.toUpperCase()); + +const statusTone = (status) => { + if (status === 'complete') return 'border-port-success/40 bg-port-success/10 text-port-success'; + if (status === 'failed') return 'border-port-error/40 bg-port-error/10 text-port-error'; + return 'border-port-accent/40 bg-port-accent/10 text-port-accent'; +}; + +export default function EidoverseWorldDrawer({ + open, + onClose, + worldState, + worldName, + setWorldName, + humanName, + setHumanName, + recipeDraft, + assetOverridesDraft, + mutateRecipe, + mutateAssetOverride, + markDirty, + configStatus, + projectionStatus, + dirty, + onSave, + onProject, + onReset, + onRefreshAssets, +}) { + const [activeTab, setActiveTab] = useDrawerTab('eidoverseTab', 'experience', TAB_IDS); + const [resetArmed, setResetArmed] = useState(false); + const [numericDrafts, setNumericDrafts] = useState({}); + const design = worldState?.design || {}; + const reconciliation = design.reconciliation || {}; + const projectionSummary = worldState?.projection?.lastSummary || {}; + const busy = configStatus === 'saving' || projectionStatus === 'running'; + const projectionActionBlocked = busy || dirty; + const assetRecipes = recipeDraft?.assetRecipe?.slots || {}; + const assetRows = [ + ...Object.entries(assetRecipes).map(([slot, recipe]) => ({ slot, recipe, legacy: false })), + ...Object.keys(assetOverridesDraft || {}) + .filter((slot) => !Object.hasOwn(assetRecipes, slot)) + .sort() + .map((slot) => ({ slot, recipe: null, legacy: true })), + ]; + const numericValue = (key, fallback) => ( + Object.hasOwn(numericDrafts, key) ? numericDrafts[key] : fallback + ); + const updateNumericDraft = (key, raw, commit) => { + setNumericDrafts((current) => ({ ...current, [key]: raw })); + if (raw === '') return; + const number = Number(raw); + if (Number.isFinite(number)) commit(number); + }; + const finishNumericDraft = (key) => { + setNumericDrafts((current) => { + const next = { ...current }; + delete next[key]; + return next; + }); + }; + + const identityFields = ( +
+
+

World Design V{design.selectedVersion || recipeDraft?.version}

+

{design.name || recipeDraft?.name}

+

+ A luminous systems garden where PortOS apps, agents, goals, memory, data, peers, and activity each have a legible home. +

+
+ + +
+
+

World

+

{worldState?.world}

+
+
+

CoS presence

+

{worldState?.presence?.connected ? 'Connected' : 'Ready to reconnect'}

+
+
+

Live signal budget

+

{design.maxEntities ?? recipeDraft?.maxEntities ?? 48} maximum

+
+
+
+ ); + + const districtFields = ( +
+

+ PortOS projects bounded summaries, never raw records. Stable IDs keep each signal in its district across refreshes. +

+ {projectionSummary.truncated && ( +

+ The shared world cap omitted {' '} + {Object.entries(projectionSummary.droppedBySource || {}) + .filter(([, count]) => count > 0) + .map(([source, count]) => `${count} ${titleCase(source)}`) + .join(', ')} signal(s). PortOS distributes available space across sources before adding extra signals. +

+ )} + {(recipeDraft?.districts || []).map((district) => ( +
+
+
+
+
+

{district.direction} · {district.landmark}

+
+ +
+
+ {district.sources.map((source) => ( +
+ mutateRecipe((current) => ({ + ...current, + includes: { ...current.includes, [source]: event.target.checked }, + }))} + /> +
+ +
+ {projectionSummary.sourceAvailability?.[source] === false + ? 'Stale · last good held' + : (projectionSummary.sourceCounts?.[source] ?? 'Not projected')} + {projectionSummary.droppedBySource?.[source] > 0 + ? ` · ${projectionSummary.droppedBySource[source]} omitted by cap` + : ''} + {SOURCE_ROUTES[source] && Open in PortOS} +
+
+ +
+ ))} +
+
+ ))} +
+ ); + + const appearanceFields = ( +
+
+

Dawn atmosphere

+

Lightweight skymesh, three authored lights, restrained fog, and sparse wind grass.

+
+ {[ + ['hours', 'Sun hour', 0, 24, 0.1], + ['exposure', 'Exposure', 0.3, 1.8, 0.01], + ['fog', 'Fog', 0, 3, 0.01], + ].map(([key, label, min, max, step]) => ( + + ))} + +
+
+ +
+
+
+

Portable asset recipe

+

Paths and search terms ship; model bytes stay in Eidoverse.

+
+ +
+ {dirty &&

Save changes before refreshing asset matches.

} +
+ {assetRows.map(({ slot, recipe, legacy }) => { + const resolution = design.assetResolutions?.[slot]; + return ( +
+
+

{titleCase(slot)}

+ + {legacy ? 'legacy V1 override' : (resolution?.source || 'pending')} + +
+ {legacy ? ( + <> +

{assetOverridesDraft[slot]}

+

+ Preserved from World Design V1. Clear it to let the semantic V2 asset recipe choose this model. +

+ + + ) : ( + <> +

{resolution?.path || 'Will resolve on the next projection'}

+

Search: {recipe.fallbackQueries.join(' · ')}

+

+ {resolution?.bytes != null ? `${formatBytes(resolution.bytes)} selected · ` : ''} + Budget: {Math.round(recipe.maxBytes / 1_000_000)} MB · {recipe.sourcePolicy} +

+ + + )} +
+ ); + })} +
+
+
+ ); + + const updateFields = ( +
+
+
+
+

Reconciliation {reconciliation.status || 'pending'}

+

{reconciliation.checkpoint || 'Waiting for first projection'}

+
+ + V{design.lastAppliedVersion ?? '—'} → V{design.pendingVersion ?? design.selectedVersion} + +
+ {reconciliation.error &&

{reconciliation.error}

} + {reconciliation.errorContext?.missing?.length > 0 && ( +

Unresolved slots: {reconciliation.errorContext.missing.join(', ')}

+ )} + {reconciliation.errorContext?.remediation === '/apps' && ( + Update Eidoverse from Managed Apps + )} + {reconciliation.retiredOwnerCleanup?.failedCount > 0 && ( +

+ PortOS could not retire {reconciliation.retiredOwnerCleanup.failedCount} previous owner role(s), so this world continued. + {reconciliation.retiredOwnerCleanup.retryingCount > 0 + ? ` ${reconciliation.retiredOwnerCleanup.retryingCount} will retry on the next projection.` + : ''} + {reconciliation.retiredOwnerCleanup.droppedCount > 0 + ? ` ${reconciliation.retiredOwnerCleanup.droppedCount} reached the retry limit; review prior worlds manually if those roles matter.` + : ''} +

+ )} + {reconciliation.operationCount > 0 && ( +
+
+ {titleCase(reconciliation.checkpoint)} + {Math.min(reconciliation.appliedOperations || 0, reconciliation.operationCount)}/{reconciliation.operationCount} +
+
+
+
+
+ )} +
+ + {design.migrationReport && ( +
+

Migration report

+
+
Status
{design.migrationReport.status}
+
Preserved overrides
{design.migrationReport.preservedOverrides?.length || 0}
+
From design
V{design.migrationReport.fromDesignVersion || 1}
+
To design
V{design.migrationReport.toDesignVersion || 2}
+
+ {design.migrationReport.preservedOverrides?.length > 0 && ( + + )} + {design.migrationReport.adoptedDefaultChanges?.length > 0 && ( +
+ {design.migrationReport.adoptedDefaultChanges.map((change) => ( +
+ {change.area} + {change.from} → {change.to} +
+ ))} +
+ )} + {Object.keys(design.migrationReport.unsupportedOverrides || {}).length > 0 && ( +

+ Retained for manual review: {Object.keys(design.migrationReport.unsupportedOverrides).join(', ')} +

+ )} + {design.migrationReport.removedMachineDerivedIdentity && ( +

+ The old automatic machine-name identity was retired in favor of a private generated name. Explicitly configured names are never changed. +

+ )} +
+ )} + + {reconciliation.runtimeVersion && ( +
+

Runtime compatibility

+

Protocol preflight passed on build {reconciliation.runtimeVersion.sha}.

+

{reconciliation.runtimeVersion.commitTime}

+
+ )} + +
+

Apply and recover

+

Preflight every asset, build V2 under PortOS-managed IDs, then retire only stale managed entities. A failure remains pending and retryable.

+ {dirty && ( +

+ Save your world changes before applying an update or refreshing asset matches. +

+ )} +
+ + +
+
+ +
+

Reset PortOS world design

+

Clears install-local overrides and the asset lock. Eidoverse model bytes and non-PortOS world entities are untouched.

+ {!resetArmed ? ( + + ) : ( +
+ + +
+ )} +
+
+ ); + + const panel = activeTab === 'experience' + ? identityFields + : activeTab === 'districts' + ? districtFields + : activeTab === 'appearance' + ? appearanceFields + : updateFields; + + return ( + +
{ event.preventDefault(); onSave(); }}> + {panel} + {(activeTab !== 'updates' || dirty) && ( +
+ {configStatus && configStatus !== 'saving' && ( +

+ {configStatus === 'saved' ? 'Saved locally and queued for projection.' : configStatus} +

+ )} + + +
+ )} +
+
+ ); +} diff --git a/client/src/lib/README.md b/client/src/lib/README.md index 943793ab6..df38a029d 100644 --- a/client/src/lib/README.md +++ b/client/src/lib/README.md @@ -18,6 +18,7 @@ grep -i "what you want to do" client/src/lib/README.md ``` | `navFeatures.js` | `filterNavByFeatures(navEntries, isFeatureEnabled)` — drops nav-manifest entries whose optional instance feature (`post`, `datadog`, `jira`, `gsd`) is off. The single gate for BROWSE surfaces (sidebar, ⌘K); routes stay reachable by URL, bookmark, and voice. Pair with `useInstanceFeatures`. | +| `eidoverseWorldReset.js` | Client reset-reconciliation maps for Eidoverse source kinds and district asset slots; parity-tested against the authoritative server world-design contracts. | | `postQuickSession.js` | Pure Quick POST duration presets, local-observation estimator, deterministic budget composer, and preview metadata. | | `postRotation.js` | Pure deterministic day-based rotation for POST practice selection — `orderByRecencyRotation` sorts candidates fresh-before-recently-practiced, then by priority, and rotates equivalent ones by local day. Mirrored from `server/lib/postRotation.js`. | diff --git a/client/src/lib/eidoverseWorldReset.js b/client/src/lib/eidoverseWorldReset.js new file mode 100644 index 000000000..095bdfa24 --- /dev/null +++ b/client/src/lib/eidoverseWorldReset.js @@ -0,0 +1,35 @@ +export const EIDOVERSE_SOURCE_KIND = Object.freeze({ + apps: 'app', + agents: 'agent', + tasks: 'task', + features: 'feature', + peers: 'peer', + health: 'health', + productivity: 'productivity', + activity: 'activity', + goals: 'goal', + memory: 'memory', + storage: 'storage', + jira: 'jira', + operations: 'operations', +}); + +export const EIDOVERSE_RESET_ASSET_SLOTS = Object.freeze({ + nexus: Object.freeze(['nexus', 'health', 'operations', 'feature', 'district']), + apps: Object.freeze(['app']), + agents: Object.freeze(['agent', 'task']), + goals: Object.freeze(['goal', 'jira']), + memory: Object.freeze(['memory']), + data: Object.freeze(['storage']), + federation: Object.freeze(['peer']), + activity: Object.freeze(['activity', 'productivity']), +}); + +const CUSTOM_DISTRICT_ASSET_SLOTS = Object.freeze(['district']); + +export function eidoverseResetAssetSlotsForDistrict(districtId, sources = []) { + return [...new Set([ + ...(EIDOVERSE_RESET_ASSET_SLOTS[districtId] || CUSTOM_DISTRICT_ASSET_SLOTS), + ...sources.map((source) => EIDOVERSE_SOURCE_KIND[source]).filter(Boolean), + ])]; +} diff --git a/client/src/lib/index.js b/client/src/lib/index.js index 96ada92f8..f24f19c5a 100644 --- a/client/src/lib/index.js +++ b/client/src/lib/index.js @@ -28,6 +28,7 @@ export * from './catalogTypes.js'; export * from './creativeDirectorPlan.js'; export * from './creativeDirectorPreview.js'; export * from './editorialRoadmap.js'; +export * from './eidoverseWorldReset.js'; export * from './federatedMediaReadiness.js'; export * from './fableLoomReadiness.js'; export * from './glbFailure.js'; diff --git a/client/src/pages/Eidoverse.jsx b/client/src/pages/Eidoverse.jsx index d00718d64..bf96308a5 100644 --- a/client/src/pages/Eidoverse.jsx +++ b/client/src/pages/Eidoverse.jsx @@ -1,10 +1,24 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { ExternalLink, Orbit, RotateCcw, Settings } from 'lucide-react'; +import { + AlertTriangle, + ExternalLink, + Orbit, + RotateCcw, + Settings, + SlidersHorizontal, + Sparkles, +} from 'lucide-react'; import { Link } from 'react-router'; import PageHeader from '../components/PageHeader'; import BrailleSpinner from '../components/BrailleSpinner'; +import EidoverseWorldDrawer from '../components/eidoverse/EidoverseWorldDrawer'; +import { + EIDOVERSE_SOURCE_KIND as SOURCE_KIND, + eidoverseResetAssetSlotsForDistrict, +} from '../lib/eidoverseWorldReset'; import { getApp, + getEidoverseWorldProjectionStatus, getEidoverseWorldStatus, getInstanceFeatures, projectEidoverseWorld, @@ -15,6 +29,18 @@ import { const silent = { silent: true }; const RUNNING_STATUSES = new Set(['online', 'launching', 'unknown']); +const FRESH_WORLD_VISIBLE_CHECKPOINTS = new Set([ + 'environment-complete', + 'applying-infrastructure', + 'infrastructure-complete', + 'applying-live', + 'live-complete', + 'applying-ambient', + 'ambient-complete', + 'applying-reconciliation', + 'reconciliation-complete', + 'projection-committed', +]); const failedStart = (result) => Object.values(result?.results || {}) .find((entry) => entry?.success === false); @@ -38,54 +64,101 @@ export const hostUrlFor = (host, setup, location = window.location, identity = n return url.toString(); }; -const RECIPE_INCLUDE_KEYS = [ - 'apps', - 'agents', - 'tasks', - 'features', - 'peers', - 'health', - 'productivity', - 'activity', - 'goals', - 'memory', - 'storage', - 'jira', - 'operations', -]; - -const RECIPE_KIND_BY_SOURCE = { - apps: 'app', - agents: 'agent', - tasks: 'task', - features: 'feature', - peers: 'peer', - health: 'health', - productivity: 'productivity', - activity: 'activity', - goals: 'goal', - memory: 'memory', - storage: 'storage', - jira: 'jira', - operations: 'operations', -}; - -const RECIPE_LAYOUT_KEYS = ['spacing', 'laneGap', 'columns']; -const RECIPE_TERRAIN_KEYS = ['size', 'segments', 'amplitude', 'flatRadius']; -// Mirror the server's case-insensitive, slash-normalized path contract. HTML -// patterns have no flag syntax, so spell out the case-insensitive prefixes and -// accept either path separator explicitly. -const EIDOVERSE_ASSET_PATTERN = '(?:[Ee][Ii][Dd][Oo][Vv][Ee][Rr][Ss][Ee]|[Ss][Tt][Oo][Rr][Ee])[\\\\/](?!.*\\.\\.).*'; - const worldIdentityFor = (world) => ({ world: world?.world, name: world?.identity?.name || world?.human?.name, avatar: world?.identity?.avatar || world?.human?.avatar, }); +const statusTone = (status) => { + if (status === 'complete') return 'border-port-success/45 bg-port-success/10 text-port-success'; + if (status === 'failed') return 'border-port-error/45 bg-port-error/10 text-port-error'; + return 'border-port-accent/45 bg-port-accent/10 text-port-accent'; +}; + +const DELETE_DRAFT_VALUE = Symbol('delete-draft-value'); +const isDraftRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value); +const draftValuesEqual = (left, right) => Object.is(left, right) + || JSON.stringify(left) === JSON.stringify(right); +function mergeServerDraftChanges(current, submitted, before, after) { + if (draftValuesEqual(before, after)) return current; + if (isDraftRecord(current) && isDraftRecord(submitted) + && isDraftRecord(before) && isDraftRecord(after)) { + const merged = { ...current }; + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + for (const key of keys) { + if (draftValuesEqual(before[key], after[key])) continue; + const value = mergeServerDraftChanges(current[key], submitted[key], before[key], after[key]); + if (value === DELETE_DRAFT_VALUE) delete merged[key]; + else merged[key] = value; + } + return merged; + } + if (!draftValuesEqual(current, submitted)) return current; + return after === undefined ? DELETE_DRAFT_VALUE : structuredClone(after); +} + +const reconcileActionDraft = (current, submitted, before, after) => { + const merged = mergeServerDraftChanges(current, submitted, before, after); + return merged === DELETE_DRAFT_VALUE ? {} : merged; +}; + +function mergeSubmittedKeys(current = {}, submitted = {}, after = {}, keys = []) { + const merged = { ...current }; + for (const key of keys) { + if (!draftValuesEqual(current?.[key], submitted?.[key])) continue; + if (Object.hasOwn(after || {}, key)) merged[key] = structuredClone(after[key]); + else delete merged[key]; + } + return merged; +} + +function reconcileResetRecipe(current, submitted, after, reset) { + if (reset.scope === 'all') { + return reconcileActionDraft(current, submitted, submitted, after); + } + if (reset.scope === 'assets') { + const keys = new Set([ + ...Object.keys(current?.assets || {}), + ...Object.keys(submitted?.assets || {}), + ...Object.keys(after?.assets || {}), + ]); + return { + ...current, + assets: mergeSubmittedKeys(current?.assets, submitted?.assets, after?.assets, keys), + }; + } + const district = after?.districts?.find(({ id }) => id === reset.districtId); + const sources = district?.sources || []; + const kinds = sources.map((source) => SOURCE_KIND[source]).filter(Boolean); + const slots = eidoverseResetAssetSlotsForDistrict(reset.districtId, sources); + return { + ...current, + includes: mergeSubmittedKeys(current?.includes, submitted?.includes, after?.includes, sources), + limits: mergeSubmittedKeys(current?.limits, submitted?.limits, after?.limits, sources), + scale: mergeSubmittedKeys(current?.scale, submitted?.scale, after?.scale, kinds), + assets: mergeSubmittedKeys(current?.assets, submitted?.assets, after?.assets, slots), + }; +} + +function reconcileResetAssetOverrides(current, submitted, after, reset, sources = []) { + if (reset.scope === 'all' || reset.scope === 'assets') { + return reconcileActionDraft(current, submitted, submitted, after); + } + return mergeSubmittedKeys( + current, + submitted, + after, + eidoverseResetAssetSlotsForDistrict(reset.districtId, sources), + ); +} + export default function Eidoverse() { const requestGeneration = useRef(0); const configDraftRevision = useRef(0); + const savedDraftRevision = useRef(0); + const projectionPollGeneration = useRef(0); + const projectionPollTimer = useRef(null); const [phase, setPhase] = useState('loading'); const [error, setError] = useState(''); const [hostUrl, setHostUrl] = useState(''); @@ -96,9 +169,27 @@ export default function Eidoverse() { const [worldName, setWorldName] = useState(''); const [humanName, setHumanName] = useState(''); const [recipeDraft, setRecipeDraft] = useState(null); + const [assetOverridesDraft, setAssetOverridesDraft] = useState({}); const [projectionStatus, setProjectionStatus] = useState('idle'); const [projectionError, setProjectionError] = useState(''); const [configStatus, setConfigStatus] = useState(''); + const [draftDirty, setDraftDirty] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [iframeReady, setIframeReady] = useState(false); + + const applyWorldResponse = useCallback((updated, { replaceDraft = true } = {}) => { + setWorldState((current) => current + ? { ...current, ...updated, identity: updated.identity || updated.human || current.identity } + : updated); + if (replaceDraft) { + if (updated?.recipe) setRecipeDraft(updated.recipe); + setAssetOverridesDraft(updated?.design?.userOverrides?.assets || {}); + if (updated?.world) setWorldName(updated.world); + if (updated?.identity?.name || updated?.human?.name) setHumanName(updated.identity?.name || updated.human.name); + savedDraftRevision.current = configDraftRevision.current; + setDraftDirty(false); + } + }, []); const prepare = useCallback(() => { const generation = ++requestGeneration.current; @@ -109,13 +200,17 @@ export default function Eidoverse() { setError(''); setHostUrl(''); setHostInfo(null); + setIframeReady(false); setSetupState(null); setWorldState(null); setRecipeDraft(null); + setAssetOverridesDraft({}); setProjectionStatus('idle'); setProjectionError(''); setConfigStatus(''); + setDraftDirty(false); configDraftRevision.current = 0; + savedDraftRevision.current = 0; const load = async () => { const featureState = await getInstanceFeatures(silent); @@ -156,6 +251,7 @@ export default function Eidoverse() { setWorldName(result.world?.world || ''); setHumanName(result.world?.identity?.name || result.world?.human?.name || ''); setRecipeDraft(result.world?.recipe || null); + setAssetOverridesDraft(result.world?.design?.userOverrides?.assets || {}); setHostUrl(result.hostUrl || ''); }, (reason) => { if (!isCurrent()) return; @@ -167,23 +263,55 @@ export default function Eidoverse() { const runProjection = useCallback(async () => { setProjectionStatus('running'); setProjectionError(''); - try { - const result = await projectEidoverseWorld(silent); - setWorldState((current) => current - ? { + const submittedRevision = configDraftRevision.current; + const submittedDraftWasClean = submittedRevision === savedDraftRevision.current; + const pollGeneration = ++projectionPollGeneration.current; + const poll = () => { + if (projectionPollGeneration.current !== pollGeneration) return; + getEidoverseWorldProjectionStatus(silent).then((status) => { + if (projectionPollGeneration.current !== pollGeneration) return; + setWorldState((current) => current ? { ...current, - projection: result.projection || current.projection, - presence: result.presence || current.presence, + projection: status.projection || current.projection, + design: status.design ? { ...current.design, ...status.design } : current.design, + } : current); + }).catch(() => {}).finally(() => { + if (projectionPollGeneration.current === pollGeneration) { + projectionPollTimer.current = setTimeout(poll, 750); } - : current); + }); + }; + projectionPollTimer.current = setTimeout(poll, 750); + return projectEidoverseWorld(silent).then((result) => { + const replaceDraft = submittedDraftWasClean + && configDraftRevision.current === submittedRevision; + setWorldState((current) => current ? { + ...current, + projection: result.projection || current.projection, + presence: result.presence || current.presence, + design: result.design || current.design, + recipe: result.recipe || current.recipe, + } : current); + if (replaceDraft && result.recipe) { + setRecipeDraft(result.recipe); + setAssetOverridesDraft(result.design?.userOverrides?.assets || {}); + } setProjectionStatus('complete'); return result; - } catch (reason) { + }, async (reason) => { setProjectionStatus('error'); setProjectionError(reason?.message || 'PortOS could not project its current state into Eidoverse.'); + const failedStatus = await getEidoverseWorldStatus(silent).catch(() => null); + if (failedStatus) applyWorldResponse(failedStatus, { replaceDraft: false }); throw reason; - } - }, []); + }).finally(() => { + if (projectionPollGeneration.current === pollGeneration) { + projectionPollGeneration.current += 1; + clearTimeout(projectionPollTimer.current); + projectionPollTimer.current = null; + } + }); + }, [applyWorldResponse]); useEffect(() => { if (phase !== 'ready' || !hostUrl) return undefined; @@ -191,125 +319,125 @@ export default function Eidoverse() { return undefined; }, [phase, hostUrl, runProjection]); + useEffect(() => { + prepare(); + return () => { + requestGeneration.current += 1; + projectionPollGeneration.current += 1; + clearTimeout(projectionPollTimer.current); + }; + }, [prepare]); + + const markConfigDirty = useCallback(() => { + configDraftRevision.current += 1; + setDraftDirty(true); + setConfigStatus((current) => current === 'saving' ? current : ''); + }, []); + + const mutateRecipe = useCallback((mutator) => { + markConfigDirty(); + setRecipeDraft((current) => current ? mutator(current) : current); + }, [markConfigDirty]); + + const mutateAssetOverride = useCallback((slot, path) => { + markConfigDirty(); + setAssetOverridesDraft((current) => { + const next = { ...current }; + if (path.trim()) next[slot] = path; + else delete next[slot]; + return next; + }); + }, [markConfigDirty]); + const saveWorldConfig = useCallback(async () => { if (!recipeDraft) return; const submittedRevision = configDraftRevision.current; setConfigStatus('saving'); - let updated; - try { - updated = await updateEidoverseWorldConfig({ - world: worldName.trim(), - humanName: humanName.trim() || null, - recipe: recipeDraft, - }, silent); - setWorldState((current) => current - ? { ...current, ...updated, identity: updated.human } - : current); - if (configDraftRevision.current === submittedRevision) { - setWorldName(updated.world || ''); - setHumanName(updated.human?.name || ''); - setRecipeDraft(updated.recipe || recipeDraft); - setConfigStatus('saved'); - } else { - setConfigStatus(''); - } - } catch (reason) { + const updated = await updateEidoverseWorldConfig({ + world: worldName.trim(), + humanName: humanName.trim() || null, + recipe: recipeDraft, + assetOverrides: assetOverridesDraft, + }, silent).catch((reason) => { setConfigStatus(reason?.message || 'Could not save the Eidoverse world configuration.'); - return; - } + return null; + }); + if (!updated) return; + const draftIsCurrent = configDraftRevision.current === submittedRevision; + applyWorldResponse(updated, { replaceDraft: draftIsCurrent }); + setConfigStatus(draftIsCurrent ? 'saved' : ''); const nextHostUrl = hostInfo && setupState ? hostUrlFor(hostInfo, setupState, window.location, worldIdentityFor(updated)) : hostUrl; if (nextHostUrl !== hostUrl) setHostUrl(nextHostUrl); else void runProjection().catch(() => {}); - }, [humanName, hostInfo, hostUrl, recipeDraft, runProjection, setupState, worldName]); - - const markConfigDirty = () => { - configDraftRevision.current += 1; - setConfigStatus((current) => current === 'saving' ? current : ''); - }; - - const toggleRecipeInclude = (key) => { - markConfigDirty(); - setRecipeDraft((current) => current - ? { ...current, includes: { ...current.includes, [key]: !current.includes[key] } } - : current); - }; + }, [applyWorldResponse, assetOverridesDraft, hostInfo, hostUrl, humanName, recipeDraft, runProjection, setupState, worldName]); - const updateRecipeLimit = (key, value) => { - markConfigDirty(); - setRecipeDraft((current) => current - ? { ...current, limits: { ...current.limits, [key]: value === '' ? 0 : Number(value) } } - : current); - }; - - const updateRecipeNumber = (section, key, value) => { - markConfigDirty(); - setRecipeDraft((current) => current - ? { - ...current, - [section]: { - ...current[section], - [key]: value === '' ? '' : Number(value), - }, + const runConfigAction = useCallback(async (payload) => { + const submittedRevision = configDraftRevision.current; + const submittedDraftWasClean = submittedRevision === savedDraftRevision.current; + const submittedRecipeDraft = recipeDraft; + const submittedAssetOverrides = assetOverridesDraft; + const serverRecipeBeforeAction = worldState?.recipe; + const serverAssetOverridesBefore = worldState?.design?.userOverrides?.assets || {}; + setConfigStatus('saving'); + const updated = await updateEidoverseWorldConfig(payload, silent).catch((reason) => { + setConfigStatus(reason?.message || 'Could not update the Eidoverse world configuration.'); + return null; + }); + if (!updated) return; + const draftIsCurrent = configDraftRevision.current === submittedRevision; + const replaceDraft = draftIsCurrent + && (submittedDraftWasClean || payload.reset?.scope === 'all'); + if (replaceDraft) configDraftRevision.current += 1; + applyWorldResponse(updated, { replaceDraft }); + if (!replaceDraft && payload.reset) { + if (updated.recipe) { + setRecipeDraft((current) => reconcileResetRecipe( + current, + submittedRecipeDraft, + updated.recipe, + payload.reset, + )); } - : current); - }; - - const updateRecipeText = (section, key, value) => { - markConfigDirty(); - setRecipeDraft((current) => current - ? { ...current, [section]: { ...current[section], [key]: value } } - : current); - }; - - const updateRecipeOrigin = (index, value) => { - markConfigDirty(); - setRecipeDraft((current) => current - ? { - ...current, - layout: { - ...current.layout, - origin: current.layout?.origin?.map((part, partIndex) => partIndex === index - ? (value === '' ? '' : Number(value)) - : part) || [0, 0, 0], - }, + setAssetOverridesDraft((current) => reconcileResetAssetOverrides( + current, + submittedAssetOverrides, + updated.design?.userOverrides?.assets || {}, + payload.reset, + updated.recipe?.districts?.find(({ id }) => id === payload.reset.districtId)?.sources, + )); + } else if (!replaceDraft && payload.refreshAssets) { + if (updated.recipe) { + setRecipeDraft((current) => reconcileActionDraft( + current, + submittedRecipeDraft, + serverRecipeBeforeAction, + updated.recipe, + )); } - : current); - }; - - const updateRecipeAsset = (sourceKey, value) => { - const kind = RECIPE_KIND_BY_SOURCE[sourceKey]; - markConfigDirty(); - setRecipeDraft((current) => kind && current - ? { ...current, assets: { ...current.assets, [kind]: value } } - : current); - }; - - useEffect(() => { - prepare(); - return () => { requestGeneration.current += 1; }; - }, [prepare]); + setAssetOverridesDraft((current) => reconcileActionDraft( + current, + submittedAssetOverrides, + serverAssetOverridesBefore, + updated.design?.userOverrides?.assets || {}, + )); + } + setConfigStatus(replaceDraft ? 'saved' : ''); + void runProjection().catch(() => {}); + }, [applyWorldResponse, assetOverridesDraft, recipeDraft, runProjection, worldState]); const actions = ( <> {hostUrl && ( - + )} {appId && ( - +