diff --git a/App.tsx b/App.tsx index e41a4fb..5936ce4 100644 --- a/App.tsx +++ b/App.tsx @@ -16,12 +16,21 @@ function MobileSimulation() { const refresh = () => setRevision(runtime.kernel.snapshot().revision); const choose = (choiceId: string) => { runtime.emit('scenario.choice.committed', { choiceId }); refresh(); }; const state = runtime.kernel.snapshot(); + return MERT Engine · Companion Open the web deployment on a WebXR headset for the immersive world. - World StateHR {state.vitals.heartRate} · RR {state.vitals.respiratoryRate} · SpO₂ {state.vitals.spo2}%AAC: {state.communication.available ? 'AVAILABLE' : 'UNAVAILABLE'}Revision: {revision} - choose('restore-access')}>Restore AAC + Position - choose('escalate')}>Escalate + + World State + HR {state.vitals.heartRate} · RR {state.vitals.respiratoryRate} · SpO₂ {state.vitals.spo2}% + AAC: {state.communication.available ? 'AVAILABLE' : 'UNAVAILABLE'} · {state.communication.reliable ? 'RELIABLE' : 'UNRELIABLE'} + Decision authority: {state.authority.decisionMaker} + Revision: {revision} + + choose('restore-access')}>Restore AAC + Position + choose('escalate')}>Escalate + choose('anchor-on-disability')}>Anchor on Baseline Disability ; } + const styles=StyleSheet.create({safeArea:{flex:1},container:{padding:20,gap:12,maxWidth:820,width:'100%',alignSelf:'center'},title:{fontSize:28,fontWeight:'700'},card:{borderWidth:1,borderRadius:12,padding:16,gap:6},heading:{fontSize:19,fontWeight:'700'},button:{borderWidth:2,borderRadius:10,padding:16},buttonText:{fontWeight:'700'}}); diff --git a/__tests__/liveWorldIntegration.test.ts b/__tests__/liveWorldIntegration.test.ts new file mode 100644 index 0000000..aed49a7 --- /dev/null +++ b/__tests__/liveWorldIntegration.test.ts @@ -0,0 +1,35 @@ +import { LiveWorldLoop } from '../src/world/liveWorldLoop'; +import { PersistentWorldStore, initialOpenWorldState, type PersonEntity } from '../src/world/worldModel'; +import { addInfrastructure, validateWorldDraft, type WorldStudioDraft } from '../src/world/scenarioStudio'; + +function memoryStore() { + return new PersistentWorldStore(initialOpenWorldState); +} + +describe('live disability world integration', () => { + test('autonomous travel changes position without changing person authority', () => { + const store = memoryStore(); + const before = store.entity('maya') as PersonEntity; + const start = [...before.position]; + const loop = new LiveWorldLoop(store); + loop.step(3); + const after = store.entity('maya') as PersonEntity; + expect(after.position).not.toEqual(start); + expect(after.authority).toBe('self'); + }); + + test('infrastructure failure remains an environmental event', () => { + const store = memoryStore(); + store.setInfrastructure('station-lift', false); + const event = store.snapshot().events.at(-1); + expect(event?.type).toBe('infrastructure.changed'); + expect(event?.detail).toContain('unavailable'); + expect((store.entity('maya') as PersonEntity).authority).toBe('self'); + }); + + test('Scenario Studio rejects silent authority reassignment', () => { + const draft: WorldStudioDraft = { title: 'Test', description: 'Test world', seed: initialOpenWorldState }; + const extended = addInfrastructure(draft, { id: 'test-ramp', label: 'Test ramp', position: [0,0,0], feature: 'ramp', accessibility: ['powered-wheelchair'] }); + expect(validateWorldDraft(extended).filter(issue => issue.severity === 'error')).toHaveLength(0); + }); +}); diff --git a/__tests__/liveWorldLoop.test.ts b/__tests__/liveWorldLoop.test.ts new file mode 100644 index 0000000..ed48f0b --- /dev/null +++ b/__tests__/liveWorldLoop.test.ts @@ -0,0 +1,30 @@ +import { defaultSchedules, runDailyLifeTick } from '../src/world/dailyLife'; +import { LiveWorldLoop } from '../src/world/liveWorldLoop'; +import { addInfrastructure, exportWorldDraft, validateWorldDraft } from '../src/world/scenarioStudio'; +import { initialOpenWorldState, PersistentWorldStore, type PersonEntity } from '../src/world/worldModel'; + +describe('live disability world integration', () => { + it('plans daily life without replacing person authority', () => { + const schedule = defaultSchedules[0]; + expect(schedule).toBeDefined(); + if (!schedule) return; + const tick = runDailyLifeTick(initialOpenWorldState, schedule); + expect(tick.activity?.personId).toBe('maya'); + const maya = initialOpenWorldState.entities.find((entity): entity is PersonEntity => entity.id === 'maya' && entity.kind === 'person'); + expect(maya?.authority).toBe('self'); + }); + + it('runs the bounded world loop and records dynamics events', () => { + const store = new PersistentWorldStore(initialOpenWorldState); + const loop = new LiveWorldLoop(store); + loop.step(6); + expect(store.snapshot().events.some((event) => event.type.startsWith('vnn.'))).toBe(true); + }); + + it('scenario studio validates duplicate IDs and authority invariants', () => { + const draft = { title: 'Test', description: '', seed: initialOpenWorldState }; + const withDuplicate = addInfrastructure(draft, { id: 'station-lift', label: 'Duplicate lift', position: [0,0,0], feature: 'lift', accessibility: ['powered-wheelchair'] }); + expect(validateWorldDraft(withDuplicate).some((issue) => issue.severity === 'error')).toBe(true); + expect(() => exportWorldDraft(withDuplicate)).toThrow(); + }); +}); diff --git a/__tests__/worldAdvanced.test.ts b/__tests__/worldAdvanced.test.ts new file mode 100644 index 0000000..953ed36 --- /dev/null +++ b/__tests__/worldAdvanced.test.ts @@ -0,0 +1,47 @@ +import { initialOpenWorldState } from '../src/world/worldModel'; +import { defaultSocialAgents, interact } from '../src/world/socialAgents'; +import { assessClinicalTrigger } from '../src/world/clinicalBridge'; +import { WorldTimeline } from '../src/world/timeline'; +import { equivalentAction, validateInteractionProfile } from '../src/world/vrInteraction'; + +describe('advanced disability world invariants', () => { + it('keeps an unavailable AAC answer unknown rather than transferring authority', () => { + const state = JSON.parse(JSON.stringify(initialOpenWorldState)); + const maya = state.entities.find((entity: any) => entity.id === 'maya'); + maya.communication.access = 'unavailable'; + const agent = defaultSocialAgents[0]; + expect(agent).toBeDefined(); + if (!agent) return; + const interaction = interact(state, agent, 'maya', 'ask'); + expect(interaction?.outcome).toBe('unknown'); + expect(maya.authority).toBe('self'); + }); + + it('does not launch a clinical scenario without an evidence-gated health transition', () => { + const assessment = assessClinicalTrigger(initialOpenWorldState, 'maya', []); + expect(assessment.status).toBe('continue-world'); + expect(assessment.preserve.authority).toBe(true); + }); + + it('creates counterfactual branches without mutating the captured original', () => { + const timeline = new WorldTimeline(); + const original = timeline.capture(initialOpenWorldState, 'original'); + const branch = timeline.branch(original.id, 'lift failure', (state) => { + const lift = state.entities.find((entity) => entity.id === 'station-lift') as any; + lift.operational = false; + }, 'station lift unavailable'); + expect((timeline.get(original.id)?.state.entities.find((entity) => entity.id === 'station-lift') as any).operational).toBe(true); + expect((branch?.state.entities.find((entity) => entity.id === 'station-lift') as any).operational).toBe(false); + }); + + it('treats VR and switch selection as equivalent semantic actions', () => { + expect(equivalentAction( + { actorId: 'maya', action: 'select', targetId: 'community-hub', modality: 'xr-controller' }, + { actorId: 'maya', action: 'select', targetId: 'community-hub', modality: 'switch' }, + )).toBe(true); + }); + + it('rejects reduced-motion smooth locomotion configuration', () => { + expect(validateInteractionProfile({ modalities: ['gaze'], locomotion: 'smooth', reducedMotion: true, dwellMilliseconds: 1200, responseTimeMultiplier: 2 })).toContain('reduced-motion profile should use teleport, seated snap turn, or non-spatial locomotion'); + }); +}); diff --git a/__tests__/worldAutonomy.test.ts b/__tests__/worldAutonomy.test.ts new file mode 100644 index 0000000..04d106a --- /dev/null +++ b/__tests__/worldAutonomy.test.ts @@ -0,0 +1,38 @@ +import { initialOpenWorldState } from '../src/world/worldModel'; +import { evaluateAffordance } from '../src/world/affordanceGraph'; +import { planNextActivity } from '../src/world/autonomy'; +import { proposeDynamics } from '../src/world/vnnDynamics'; + +describe('open world autonomy', () => { + test('AAC loss changes observability without changing authority', () => { + const state = structuredClone(initialOpenWorldState); + const maya = state.entities.find((entity) => entity.id === 'maya') as any; + maya.communication.access = 'unavailable'; + const activity = planNextActivity(state, 'maya')!; + const proposal = proposeDynamics(state, activity); + + expect(maya.authority).toBe('self'); + expect(proposal.signals.some((signal) => signal.node === 'communication' && signal.metric === 'observability')).toBe(true); + expect(proposal.invariantChecks).toContain('communication access does not infer incapacity'); + }); + + test('failed lift is represented as an environmental barrier', () => { + const state = structuredClone(initialOpenWorldState); + const maya = state.entities.find((entity) => entity.id === 'maya') as any; + const lift = state.entities.find((entity) => entity.id === 'station-lift') as any; + lift.operational = false; + + const result = evaluateAffordance(maya, lift, 'use'); + expect(result.available).toBe(false); + expect(result.reason).toBe('environmental infrastructure unavailable'); + expect(maya.mobility.independent).toBe(true); + }); + + test('planner preserves the goal by searching for alternatives', () => { + const state = structuredClone(initialOpenWorldState); + const activity = planNextActivity(state, 'maya'); + expect(activity).toBeDefined(); + expect(['travel', 'seek-alternative', 'rest']).toContain(activity!.kind); + expect(activity!.goal).toBe('move through the community'); + }); +}); diff --git a/__tests__/worldModel.test.ts b/__tests__/worldModel.test.ts new file mode 100644 index 0000000..71e1dd0 --- /dev/null +++ b/__tests__/worldModel.test.ts @@ -0,0 +1,21 @@ +import { InfrastructureEntity, PersonEntity, PersistentWorldStore, accessAssessment, initialOpenWorldState } from '../src/world/worldModel'; + +describe('persistent disability world', () => { + it('attributes a failed lift to the environment rather than person incapacity', () => { + const store = new PersistentWorldStore(initialOpenWorldState); + const person = store.entity('maya') as PersonEntity; + const lift = store.entity('station-lift') as InfrastructureEntity; + expect(accessAssessment(person, lift)).toEqual({ pass: true, cause: 'accessible' }); + store.setInfrastructure('station-lift', false); + expect(accessAssessment(person, store.entity('station-lift') as InfrastructureEntity)).toEqual({ pass: false, cause: 'environmental infrastructure unavailable' }); + expect(person.authority).toBe('self'); + }); + + it('does not change decision authority when AAC access degrades', () => { + const store = new PersistentWorldStore(initialOpenWorldState); + store.setCommunicationAccess('maya', 'degraded'); + const person = store.entity('maya') as PersonEntity; + expect(person.communication.access).toBe('degraded'); + expect(person.authority).toBe('self'); + }); +}); diff --git a/__tests__/worldSteps18to20.test.ts b/__tests__/worldSteps18to20.test.ts new file mode 100644 index 0000000..ca92099 --- /dev/null +++ b/__tests__/worldSteps18to20.test.ts @@ -0,0 +1,13 @@ +import { PersistentWorldStore, initialOpenWorldState, type PersonEntity } from '../src/world/worldModel'; +import { WorldRuntime } from '../src/world/liveWorldLoop'; +import { ensurePopulation, relationships } from '../src/world/population'; +import { createSpatialIntent, evaluateSpatialIntent, nearbyEntities, wayfind } from '../src/world/spatialInteraction'; + +describe('Steps 18-20 world integration',()=>{ + test('population seeds multiple self-authoritative people and relationships',()=>{const store=new PersistentWorldStore(initialOpenWorldState);ensurePopulation(store);const people=store.snapshot().entities.filter(e=>e.kind==='person') as PersonEntity[];expect(people.length).toBeGreaterThanOrEqual(4);expect(people.every(p=>p.authority==='self')).toBe(true);expect(relationships.length).toBeGreaterThan(0);}); + test('WorldRuntime is authoritative and advances population without changing authority',()=>{const store=new PersistentWorldStore(initialOpenWorldState);const runtime=new WorldRuntime(store);runtime.step(3);runtime.step(3);const maya=store.entity('maya') as PersonEntity;expect(maya.authority).toBe('self');expect(runtime.snapshot().activities.length).toBeGreaterThan(0);}); + test('semantic interaction is modality-independent',()=>{const store=new PersistentWorldStore(initialOpenWorldState);ensurePopulation(store);const state=store.snapshot();const touch=createSpatialIntent('maya','liam','communicate','touch');const xr=createSpatialIntent('maya','liam','communicate','xr-controller');expect(evaluateSpatialIntent(state,touch).allowed).toBe(evaluateSpatialIntent(state,xr).allowed);}); + test('AAC loss blocks immediate communication action but preserves intent and authority',()=>{const store=new PersistentWorldStore(initialOpenWorldState);ensurePopulation(store);store.setCommunicationAccess('maya','unavailable');const result=evaluateSpatialIntent(store.snapshot(),createSpatialIntent('maya','liam','communicate','switch'));expect(result.allowed).toBe(false);expect(result.reason).toContain('intent is preserved');expect((store.entity('maya') as PersonEntity).authority).toBe('self');}); + test('wayfinding attributes failed infrastructure to environment',()=>{const store=new PersistentWorldStore(initialOpenWorldState);store.setInfrastructure('station-lift',false);const route=wayfind(store.snapshot(),'maya','station-lift');expect(route?.accessible).toBe(false);expect(route?.reason).toContain('environmental infrastructure unavailable');}); + test('nearby discovery supports non-spatial equivalent of proximity interaction',()=>{const store=new PersistentWorldStore(initialOpenWorldState);ensurePopulation(store);const near=nearbyEntities(store.snapshot(),'maya',30);expect(near.length).toBeGreaterThan(0);}); +}); diff --git a/__tests__/worldSteps21to24.test.ts b/__tests__/worldSteps21to24.test.ts new file mode 100644 index 0000000..5a07549 --- /dev/null +++ b/__tests__/worldSteps21to24.test.ts @@ -0,0 +1,61 @@ +import { defaultNavigationNetwork, findAccessibleRoutes, navigationProfile } from '../src/world/accessibleNavigation'; +import { chargerPercent, ObjectInteractionEngine, smoothstep } from '../src/world/objectInteractions'; +import { encounterScore, SocialEncounterEngine } from '../src/world/socialEncounters'; +import { ContinuityEngine } from '../src/world/continuity'; +import { initialOpenWorldState, PersistentWorldStore, type InfrastructureEntity, type PersonEntity } from '../src/world/worldModel'; + +describe('Steps 21-24 Wolfram-grounded world algorithms', () => { + test('accessible routes are ordered by cost and failed infrastructure is environmental', () => { + const store = new PersistentWorldStore(initialOpenWorldState); + const maya = store.entity('maya') as PersonEntity; + expect(navigationProfile(maya).mobility).toBe('powered-wheelchair'); + const routes = findAccessibleRoutes(defaultNavigationNetwork, store.snapshot(), maya, 'plaza', 'transit', 3); + expect(routes.length).toBeGreaterThan(0); + for (let i = 1; i < routes.length; i++) expect(routes[i]!.cost).toBeGreaterThanOrEqual(routes[i-1]!.cost); + const lift = store.entity('station-lift') as InfrastructureEntity; + store.setInfrastructure(lift.id, false); + const blocked = findAccessibleRoutes(defaultNavigationNetwork, store.snapshot(), maya, 'plaza', 'transit', 3); + expect(blocked.every(route => !route.nodeIds.includes('station-lift'))).toBe(true); + expect((store.entity('maya') as PersonEntity).authority).toBe('self'); + }); + + test('smoothstep lift model has stationary endpoints', () => { + expect(smoothstep(0)).toBe(0); + expect(smoothstep(1)).toBe(1); + const engine = new ObjectInteractionEngine(); + expect(engine.requestLift(1, 'maya')).toBe(true); + engine.step(4); + expect(engine.snapshot().lift.positionMetres).toBeCloseTo(2.1, 5); + engine.step(4); + expect(engine.snapshot().lift.positionMetres).toBeCloseTo(4.2, 5); + }); + + test('Wolfram-derived charger curve reaches approximately 80 percent at computed time', () => { + expect(chargerPercent(30, 100, .0015, 835.1753123302453)).toBeCloseTo(80, 5); + }); + + test('relationship and proximity affect encounters while support remains explicit', () => { + expect(encounterScore(1.5, 1, 1, .2)).toBeGreaterThan(encounterScore(1.5, 0, 0, .2)); + const store = new PersistentWorldStore(initialOpenWorldState); + const engine = new SocialEncounterEngine(); + const before = engine.detect(store.snapshot()).encounters; + expect(before.every(encounter => encounter.supportRequested === false)).toBe(true); + engine.requestSupport('maya', 'liam'); + }); + + test('continuity preserves authority and rejects non-adjacent jumps', () => { + const store = new PersistentWorldStore(initialOpenWorldState); + const maya = store.entity('maya') as PersonEntity; + const engine = new ContinuityEngine(store); + engine.start(maya, 'educational test'); + expect(engine.episode('maya')?.preserved.authority).toBe('self'); + expect(engine.advance('maya', 'icu')).toBe(false); + expect(engine.advance('maya', 'ed')).toBe(true); + expect(engine.advance('maya', 'icu')).toBe(true); + expect(engine.advance('maya', 'mert-scenario')).toBe(true); + expect(engine.advance('maya', 'discharge')).toBe(true); + expect(engine.advance('maya', 'community-return')).toBe(true); + expect(engine.advance('maya', 'community-with-consequences')).toBe(true); + expect((store.entity('maya') as PersonEntity).authority).toBe('self'); + }); +}); diff --git a/src/world/OpenWorldWeb.tsx b/src/world/OpenWorldWeb.tsx index ada70fb..d435bd5 100644 --- a/src/world/OpenWorldWeb.tsx +++ b/src/world/OpenWorldWeb.tsx @@ -1,79 +1,78 @@ import React, { useEffect, useRef, useState } from 'react'; +import { InfrastructureEntity, PersonEntity, PersistentWorldStore, accessAssessment } from './worldModel'; +import { WorldRuntime } from './liveWorldLoop'; +import { nearbyEntities, createSpatialIntent, evaluateSpatialIntent } from './spatialInteraction'; +import { WOLFRAM_ROUTE_MODEL } from './accessibleNavigation'; +import { WOLFRAM_OBJECT_MODELS } from './objectInteractions'; +import { WOLFRAM_SOCIAL_MODEL } from './socialEncounters'; +import { WOLFRAM_CONTINUITY_MODEL } from './continuity'; export function OpenWorldWeb() { const canvasRef = useRef(null); - const [status, setStatus] = useState('Loading world…'); - const [xr, setXr] = useState('Checking WebXR…'); + const storeRef = useRef(null); + const runtimeRef = useRef(null); + if (!storeRef.current) storeRef.current = new PersistentWorldStore(); - useEffect(() => { - let disposed = false; - let engine: any; - let scene: any; - const boot = async () => { - const B = await import('@babylonjs/core'); - await import('@babylonjs/loaders'); - if (!canvasRef.current || disposed) return; - engine = new B.Engine(canvasRef.current, true, { preserveDrawingBuffer: true, stencil: true }); - scene = new B.Scene(engine); - scene.clearColor = new B.Color4(0.72, 0.86, 0.96, 1); - scene.collisionsEnabled = true; - - const camera = new B.UniversalCamera('player', new B.Vector3(0, 1.65, -16), scene); - camera.attachControl(canvasRef.current, true); - camera.speed = 0.45; - camera.angularSensibility = 3500; - camera.applyGravity = true; - camera.checkCollisions = true; - camera.ellipsoid = new B.Vector3(0.5, 0.9, 0.5); - camera.keysUp.push(87); camera.keysDown.push(83); camera.keysLeft.push(65); camera.keysRight.push(68); - - new B.HemisphericLight('sky', new B.Vector3(0, 1, 0), scene).intensity = 0.85; - const sun = new B.DirectionalLight('sun', new B.Vector3(-0.4, -1, 0.25), scene); sun.intensity = 0.7; - - const ground = B.MeshBuilder.CreateGround('accessible-campus', { width: 220, height: 220, subdivisions: 2 }, scene); - ground.checkCollisions = true; - const groundMat = new B.StandardMaterial('groundMat', scene); groundMat.diffuseColor = new B.Color3(0.38, 0.55, 0.35); ground.material = groundMat; - - const pathMat = new B.StandardMaterial('pathMat', scene); pathMat.diffuseColor = new B.Color3(0.62, 0.61, 0.57); - const path = B.MeshBuilder.CreateGround('wide-accessible-path', { width: 12, height: 190 }, scene); path.position.y = 0.015; path.material = pathMat; + const [status,setStatus]=useState('Loading world…'); + const [xr,setXr]=useState('Checking WebXR…'); + const [access,setAccess]=useState('ACCESS NETWORK ONLINE'); + const [life,setLife]=useState('DAILY LIFE ENGINE STARTING'); + const [clinical,setClinical]=useState('CLINICAL BRIDGE · STANDBY'); + const [nearby,setNearby]=useState('NEARBY · scanning'); + const [route,setRoute]=useState('ROUTE ENGINE · calculating'); + const [objects,setObjects]=useState('OBJECT ENGINE · online'); + const [encounters,setEncounters]=useState('SOCIAL ENCOUNTERS · scanning'); + const [continuity,setContinuity]=useState('CONTINUITY · community'); - const buildingMat = new B.StandardMaterial('buildingMat', scene); buildingMat.diffuseColor = new B.Color3(0.72, 0.76, 0.78); - const glassMat = new B.StandardMaterial('glassMat', scene); glassMat.diffuseColor = new B.Color3(0.25, 0.55, 0.7); glassMat.alpha = 0.72; - const makeBuilding = (name:string, x:number, z:number, w:number, d:number, h:number) => { - const body = B.MeshBuilder.CreateBox(name, { width:w, depth:d, height:h }, scene); body.position.set(x,h/2,z); body.material=buildingMat; body.checkCollisions=true; - const door = B.MeshBuilder.CreateBox(name+'-entry', { width:3.2, depth:0.12, height:2.7 }, scene); door.position.set(x,1.35,z-d/2-0.07); door.material=glassMat; - return body; - }; - makeBuilding('Clinical Simulation Centre', -24, 8, 28, 20, 10); - makeBuilding('Community Hub', 25, 22, 26, 18, 7); - makeBuilding('Rehabilitation Lab', 24, -28, 24, 18, 8); - makeBuilding('Accessible Transit Station', -25, -34, 30, 15, 6); + useEffect(()=>{ + let disposed=false,engine:any,scene:any,routeMesh:any; + const store=storeRef.current!; + const runtime=new WorldRuntime(store); + runtimeRef.current=runtime; - const treeMat = new B.StandardMaterial('tree', scene); treeMat.diffuseColor = new B.Color3(0.16,0.38,0.17); - for (let i=0;i<44;i++) { const a=i*2.399, r=42+(i%5)*8; const t=B.MeshBuilder.CreateCylinder('tree-'+i,{height:5,diameterTop:0.5,diameterBottom:2.4,tessellation:7},scene); t.position.set(Math.cos(a)*r,2.5,Math.sin(a)*r); t.material=treeMat; } - - const personMat = new B.StandardMaterial('people', scene); personMat.diffuseColor = new B.Color3(0.22,0.32,0.55); - for (let i=0;i<12;i++) { const p=B.MeshBuilder.CreateCapsule('community-member-'+i,{height:1.7,radius:0.32},scene); p.position.set(-5+(i%4)*4,0.85,4+Math.floor(i/4)*5); p.material=personMat; } - const chair = B.MeshBuilder.CreateBox('powered-wheelchair-user',{width:0.8,height:0.9,depth:1.05},scene); chair.position.set(5,0.48,9); chair.material=personMat; - - try { - const helper = await scene.createDefaultXRExperienceAsync({ floorMeshes: [ground, path], disableTeleportation: false }); - setXr(helper.baseExperience ? 'VR READY · use headset Enter VR control' : 'VR unavailable'); - } catch { setXr('WebXR not available on this browser/device'); } + const boot=async()=>{ + const B=await import('@babylonjs/core'); + await import('@babylonjs/loaders'); + if(!canvasRef.current||disposed)return; + engine=new B.Engine(canvasRef.current,true,{preserveDrawingBuffer:true,stencil:true}); + scene=new B.Scene(engine);scene.clearColor=new B.Color4(.72,.86,.96,1);scene.collisionsEnabled=true; + const camera=new B.UniversalCamera('player',new B.Vector3(0,1.65,-16),scene);camera.attachControl(canvasRef.current,true);camera.speed=.45;camera.angularSensibility=3500;camera.applyGravity=true;camera.checkCollisions=true;camera.ellipsoid=new B.Vector3(.5,.9,.5);camera.keysUp.push(87);camera.keysDown.push(83);camera.keysLeft.push(65);camera.keysRight.push(68); + new B.HemisphericLight('sky',new B.Vector3(0,1,0),scene).intensity=.85;new B.DirectionalLight('sun',new B.Vector3(-.4,-1,.25),scene).intensity=.7; + const material=(n:string,r:number,g:number,b:number)=>{const m=new B.StandardMaterial(n,scene);m.diffuseColor=new B.Color3(r,g,b);return m;}; + const ground=B.MeshBuilder.CreateGround('accessible-campus',{width:220,height:220},scene);ground.checkCollisions=true;ground.material=material('ground',.38,.55,.35); + const path=B.MeshBuilder.CreateGround('wide-accessible-path',{width:12,height:190},scene);path.position.y=.015;path.material=material('path',.62,.61,.57); + const buildingMat=material('building',.72,.76,.78),glass=material('glass',.25,.55,.7);glass.alpha=.72; + const doorMeshes=new Map(); + const makeBuilding=(id:string,label:string,x:number,z:number,w:number,d:number,h:number)=>{const body=B.MeshBuilder.CreateBox(label,{width:w,depth:d,height:h},scene);body.position.set(x,h/2,z);body.material=buildingMat;body.checkCollisions=true;const door=B.MeshBuilder.CreateBox(id,{width:3.2,depth:.12,height:2.7},scene);door.position.set(x,1.35,z-d/2-.07);door.material=glass;doorMeshes.set(id,{mesh:door,closedX:x});}; + makeBuilding('clinical-door','Clinical Simulation Centre',-24,8,28,20,10);makeBuilding('community-door','Community Hub',25,22,26,18,7);makeBuilding('rehab-door','Rehabilitation Lab',24,-28,24,18,8);makeBuilding('transit-door','Accessible Transit Station',-25,-34,30,15,6); + const personMat=material('people',.22,.32,.55),accessMat=material('access',.16,.48,.5),unavailableMat=material('unavailable',.48,.2,.2),meshes=new Map(); + const createEntityMesh=(entity:any)=>{if(meshes.has(entity.id))return;if(entity.kind==='person'){const p=entity as PersonEntity,body=B.MeshBuilder.CreateCapsule(entity.id,{height:1.35,radius:.3},scene);body.position.set(p.position[0],1.05,p.position[2]);body.material=personMat;meshes.set(entity.id,body);if(p.mobility.mode.includes('wheelchair')){const chair=B.MeshBuilder.CreateBox(entity.id+'-mobility',{width:.85,height:.55,depth:1.05},scene);chair.position.set(p.position[0],.38,p.position[2]);chair.material=personMat;meshes.set(entity.id+'-mobility',chair);}}else if(entity.kind==='infrastructure'){const i=entity as InfrastructureEntity,marker=B.MeshBuilder.CreateCylinder(entity.id,{height:1.5,diameter:.65},scene);marker.position.set(i.position[0],.75,i.position[2]);marker.material=i.operational?accessMat:unavailableMat;meshes.set(i.id,marker);}}; + for(const entity of store.snapshot().entities)createEntityMesh(entity); + const liftCabin=B.MeshBuilder.CreateBox('station-lift-cabin',{width:2.4,height:2.6,depth:2.4},scene);liftCabin.position.set(-20,1.3,-34);liftCabin.material=glass; + const charger=B.MeshBuilder.CreateCylinder('aac-charge-visual',{height:1.2,diameter:1.2},scene);charger.position.set(20,.6,20);charger.material=accessMat; + const shuttle=B.MeshBuilder.CreateBox('community-shuttle',{width:3,height:2.8,depth:7},scene);shuttle.position.set(-30,1.4,-39);shuttle.material=buildingMat; + for(let i=0;i<28;i++){const a=i*2.399,r=46+(i%4)*9,t=B.MeshBuilder.CreateCylinder('tree-'+i,{height:5,diameterTop:.5,diameterBottom:2.4,tessellation:7},scene);t.position.set(Math.cos(a)*r,2.5,Math.sin(a)*r);t.material=material('tree-'+i,.16,.38,.17);} - setStatus('WORLD ONLINE · explore with WASD / mouse'); - engine.runRenderLoop(()=>scene.render()); - const resize=()=>engine.resize(); window.addEventListener('resize',resize); - (scene as any).__cleanup=()=>window.removeEventListener('resize',resize); + let last=performance.now(),uiLast=0,lastRouteKey=''; + scene.onBeforeRenderObservable.add(()=>{const now=performance.now(),elapsed=Math.max(0,(now-last)/1000);last=now;const frame=runtime.step(elapsed),snapshot=store.snapshot();for(const entity of snapshot.entities){createEntityMesh(entity);const mesh=meshes.get(entity.id);if(mesh){mesh.position.x=entity.position[0];mesh.position.z=entity.position[2];if(entity.kind==='infrastructure')mesh.material=(entity as InfrastructureEntity).operational?accessMat:unavailableMat;}const mobility=meshes.get(entity.id+'-mobility');if(mobility){mobility.position.x=entity.position[0];mobility.position.z=entity.position[2];}}for(const door of frame.objects.doors){const record=doorMeshes.get(door.id);if(record)record.mesh.position.x=record.closedX+door.progress*2.7;}liftCabin.position.y=1.3+frame.objects.lift.positionMetres;const mayaCharge=frame.objects.chargers.find(session=>session.personId==='maya');if(mayaCharge)charger.scaling.y=.5+mayaCharge.currentPercent/100;shuttle.position.y=frame.objects.transport.passengers.includes('maya')?1.55:1.4;const mayaRoute=frame.routes.maya,routeKey=mayaRoute?.nodeIds.join('>')??'';if(routeKey!==lastRouteKey){lastRouteKey=routeKey;routeMesh?.dispose?.();if(mayaRoute&&mayaRoute.points.length>1)routeMesh=B.MeshBuilder.CreateLines('maya-accessible-route',{points:mayaRoute.points.map(p=>new B.Vector3(p[0],.08,p[2]))},scene);}if(now-uiLast>1000){uiLast=now;const maya=store.entity('maya') as PersonEntity|undefined,lift=store.entity('station-lift') as InfrastructureEntity|undefined;if(maya&&lift){const a=accessAssessment(maya,lift);setAccess(`TRANSIT ACCESS: ${a.pass?'AVAILABLE':'BARRIER'} · ${a.cause}`);const close=nearbyEntities(snapshot,'maya',6);setNearby(`NEARBY MAYA · ${close.length?close.map(x=>x.entity.label).join(' · '):'none within 6m'}`);}const activity=frame.activities.find(x=>x.personId==='maya');setLife(activity?`MAYA · ${activity.kind.toUpperCase()} · ${activity.rationale}`:'MAYA · NO ACTIVE PLAN');const bridge=frame.clinical.find(x=>x.personId==='maya');setClinical(`CLINICAL BRIDGE · ${(bridge?.status??'continue-world').toUpperCase().replace(/-/g,' ')}`);setRoute(mayaRoute?`ACCESSIBLE ROUTE · ${mayaRoute.nodeIds.join(' → ')} · cost ${mayaRoute.cost.toFixed(1)} · alternatives computed`:'ACCESSIBLE ROUTE · no viable route represented');const doorSummary=frame.objects.doors.map(d=>`${d.id}:${d.phase}`).join(' · ');setObjects(`OBJECTS · ${doorSummary} · lift ${frame.objects.lift.phase}@${frame.objects.lift.positionMetres.toFixed(1)}m · shuttle ${frame.objects.transport.passengers.length}/${frame.objects.transport.capacity}`);setEncounters(frame.encounters.length?`ENCOUNTERS · ${frame.encounters.map(e=>`${e.a}↔${e.b} ${e.status} ${e.score.toFixed(2)}${e.supportRequested?' support-requested':''}`).join(' · ')}`:'ENCOUNTERS · none above threshold');setContinuity(`CONTINUITY · ${frame.continuity.maya??'community'}`);}}); + try{const helper=await scene.createDefaultXRExperienceAsync({floorMeshes:[ground,path],disableTeleportation:false});setXr(helper.baseExperience?'VR READY · controller, gaze and teleport supported':'VR unavailable');}catch{setXr('WebXR not available on this browser/device');} + setStatus('WOLFRAM-GROUNDED OPEN WORLD ONLINE');engine.runRenderLoop(()=>scene.render());const resize=()=>engine.resize();window.addEventListener('resize',resize);(scene as any).__cleanup=()=>window.removeEventListener('resize',resize); }; - boot(); - return ()=>{ disposed=true; scene?.__cleanup?.(); scene?.dispose?.(); engine?.dispose?.(); }; + boot();return()=>{disposed=true;scene?.__cleanup?.();routeMesh?.dispose?.();scene?.dispose?.();engine?.dispose?.();}; },[]); - return
- -
- MERT · DISABILITY WORLD
{status}
{xr}
Clinical Simulation Centre · Community Hub · Rehab Lab · Accessible Transit -
-
; + const runtime=()=>runtimeRef.current!; + const toggleLift=()=>{const s=storeRef.current!,lift=s.entity('station-lift') as InfrastructureEntity;s.setInfrastructure('station-lift',!lift.operational);}; + const toggleAAC=()=>{const s=storeRef.current!,maya=s.entity('maya') as PersonEntity;s.setCommunicationAccess('maya',maya.communication.access==='available'?'degraded':'available');}; + const interactNearest=()=>{const s=storeRef.current!,state=s.snapshot(),target=nearbyEntities(state,'maya',8)[0];if(!target){setStatus('INTERACTION · no nearby target');return;}const intent=createSpatialIntent('maya',target.entity.id,target.entity.kind==='person'?'communicate':'use','touch'),result=evaluateSpatialIntent(state,intent);s.recordEvent('world.intent','maya',`${intent.action} ${target.entity.label}: ${result.reason}`);setStatus(`INTERACTION · ${target.entity.label} · ${result.allowed?'AVAILABLE':'BLOCKED'} · ${result.reason}`);}; + const openDoor=()=>runtime().objectEngine().requestDoor('community-door','maya'); + const moveLift=()=>{const lift=runtime().objectEngine().snapshot().lift;runtime().objectEngine().requestLift(lift.currentLevel===0?1:0,'maya');}; + const chargeAAC=()=>runtime().objectEngine().startCharging('maya',30); + const boardTransport=()=>{const result=runtime().objectEngine().boardTransport('maya');setStatus(`TRANSPORT · ${result.reason}`);}; + const requestSupport=()=>{runtime().requestSupport('maya','liam');setStatus('SUPPORT · Maya explicitly requested support from Liam');}; + const beginContinuity=()=>{runtime().beginContinuity('maya');setStatus('CONTINUITY · educational community deterioration episode started');}; + const advanceContinuity=()=>{runtime().advanceContinuity('maya');setStatus(`CONTINUITY · ${runtime().continuity().summary('maya')}`);}; + const resetWorld=()=>{storeRef.current!.reset();setStatus('WORLD RESET · reload to reseed population');}; + + return
MERT · WOLFRAM OPEN WORLD · STEPS 21–24
{status}
{xr}
{access}
{life}
{route}
{objects}
{nearby}
{encounters}
{clinical}
{continuity}
Wolfram computation layer
Route cost: {WOLFRAM_ROUTE_MODEL.formula}. Lift: {WOLFRAM_OBJECT_MODELS.liftSmoothstep}. Charger: {WOLFRAM_OBJECT_MODELS.charger}. Social encounters: {WOLFRAM_SOCIAL_MODEL.formula}. Continuity graph verified acyclic with {WOLFRAM_CONTINUITY_MODEL.unexpectedDeadEnds} unexpected dead ends. Simulation parameters are educational model values, not accessibility-code or clinical-treatment thresholds.
; } diff --git a/src/world/accessibleNavigation.ts b/src/world/accessibleNavigation.ts new file mode 100644 index 0000000..54d00dd --- /dev/null +++ b/src/world/accessibleNavigation.ts @@ -0,0 +1,45 @@ +import type { AccessModality, InfrastructureEntity, PersonEntity, PersistentWorldState, Vec3 } from './worldModel'; + +export const WOLFRAM_ROUTE_MODEL = { + formula: 'length*(1 + gradientWeight*abs(gradient) + surfacePenalty) + waitSeconds', + exampleBestCost: 91.52, + exampleAlternativeCost: 135.82, +}; + +export type RouteFeature = 'path' | 'door' | 'ramp' | 'lift' | 'crossing' | 'transport-link'; +export interface NavNode { id: string; position: Vec3; label: string; } +export interface NavEdge { from:string; to:string; length:number; gradient:number; surfacePenalty:number; waitSeconds:number; feature:RouteFeature; access:AccessModality[]; objectId?:string; } +export interface NavigationProfile { mobility:AccessModality; maxGradient:number; gradientWeight:number; blockedPenalty:number; } +export interface RouteResult { nodeIds:string[]; points:Vec3[]; cost:number; features:RouteFeature[]; blockedBy:string[]; } +export interface NavigationNetwork { nodes:NavNode[]; edges:NavEdge[]; } + +export const defaultNavigationNetwork:NavigationNetwork={ + nodes:[ + {id:'plaza',label:'Central plaza',position:[0,0,0]},{id:'community-door',label:'Community automatic door',position:[14,0,14]},{id:'community-hub',label:'Community Hub',position:[25,0,22]}, + {id:'clinical-ramp',label:'Clinical ramp',position:[-11,0,4]},{id:'clinical-door',label:'Clinical automatic door',position:[-18,0,8]},{id:'clinical-centre',label:'Clinical Simulation Centre',position:[-24,0,8]}, + {id:'crossing',label:'Accessible crossing',position:[-8,0,-15]},{id:'station-lift',label:'Station lift',position:[-20,0,-34]},{id:'transit',label:'Accessible Transit Station',position:[-25,0,-34]}, + {id:'rehab-ramp',label:'Rehabilitation ramp',position:[10,0,-13]},{id:'rehab-lab',label:'Rehabilitation Lab',position:[24,0,-28]}, + ], + edges:[ + {from:'plaza',to:'community-door',length:20,gradient:.02,surfacePenalty:.02,waitSeconds:0,feature:'path',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'community-door',to:'community-hub',length:14,gradient:0,surfacePenalty:0,waitSeconds:2,feature:'door',objectId:'community-door',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'plaza',to:'clinical-ramp',length:12,gradient:.04,surfacePenalty:.01,waitSeconds:0,feature:'ramp',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'clinical-ramp',to:'clinical-door',length:8,gradient:.06,surfacePenalty:0,waitSeconds:0,feature:'ramp',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'clinical-door',to:'clinical-centre',length:7,gradient:0,surfacePenalty:0,waitSeconds:2,feature:'door',objectId:'clinical-door',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'plaza',to:'crossing',length:18,gradient:.01,surfacePenalty:.03,waitSeconds:8,feature:'crossing',objectId:'crossing',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'crossing',to:'station-lift',length:25,gradient:.03,surfacePenalty:.02,waitSeconds:0,feature:'path',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'station-lift',to:'transit',length:6,gradient:0,surfacePenalty:0,waitSeconds:18,feature:'lift',objectId:'station-lift',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'plaza',to:'rehab-ramp',length:17,gradient:.035,surfacePenalty:.02,waitSeconds:0,feature:'ramp',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'rehab-ramp',to:'rehab-lab',length:22,gradient:.055,surfacePenalty:.01,waitSeconds:0,feature:'ramp',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'community-hub',to:'transit',length:62,gradient:.025,surfacePenalty:.04,waitSeconds:6,feature:'transport-link',access:['walk','powered-wheelchair','manual-wheelchair']}, + {from:'community-hub',to:'rehab-lab',length:54,gradient:.03,surfacePenalty:.03,waitSeconds:0,feature:'path',access:['walk','powered-wheelchair','manual-wheelchair']}, + ], +}; + +export function navigationProfile(person:PersonEntity):NavigationProfile{const mobility=person.mobility.mode;const maxGradient=mobility==='manual-wheelchair'?.07:mobility==='powered-wheelchair'?.09:.16;return{mobility,maxGradient,gradientWeight:4,blockedPenalty:1_000_000};} +function edgeOperational(edge:NavEdge,state:PersistentWorldState){if(!edge.objectId)return true;const entity=state.entities.find(item=>item.id===edge.objectId);if(!entity||entity.kind!=='infrastructure')return true;return (entity as InfrastructureEntity).operational;} +export function edgeCost(edge:NavEdge,person:PersonEntity,state:PersistentWorldState){const profile=navigationProfile(person);if(!edge.access.includes(profile.mobility))return Infinity;if(Math.abs(edge.gradient)>profile.maxGradient)return Infinity;if(!edgeOperational(edge,state))return Infinity;return edge.length*(1+profile.gradientWeight*Math.abs(edge.gradient)+edge.surfacePenalty)+edge.waitSeconds;} +function adjacency(network:NavigationNetwork){const map=new Map>();for(const edge of network.edges){map.set(edge.from,[...(map.get(edge.from)??[]),{edge,next:edge.to}]);map.set(edge.to,[...(map.get(edge.to)??[]),{edge,next:edge.from}]);}return map;} +export function nearestNavNode(network:NavigationNetwork,position:Vec3){return [...network.nodes].sort((a,b)=>Math.hypot(a.position[0]-position[0],a.position[2]-position[2])-Math.hypot(b.position[0]-position[0],b.position[2]-position[2]))[0];} +export function findAccessibleRoutes(network:NavigationNetwork,state:PersistentWorldState,person:PersonEntity,startId:string,goalId:string,limit=3):RouteResult[]{const adj=adjacency(network),results:RouteResult[]=[],maxDepth=network.nodes.length;const visit=(node:string,path:string[],cost:number,features:RouteFeature[])=>{if(path.length>maxDepth||results.length>200)return;if(node===goalId){const points=path.map(id=>network.nodes.find(n=>n.id===id)?.position).filter((point):point is Vec3=>Boolean(point));if(points.length===path.length)results.push({nodeIds:path,points,cost,features,blockedBy:[]});return;}for(const item of adj.get(node)??[]){if(path.includes(item.next))continue;const c=edgeCost(item.edge,person,state);if(!Number.isFinite(c))continue;visit(item.next,[...path,item.next],cost+c,[...features,item.edge.feature]);}};visit(startId,[startId],0,[]);return results.sort((a,b)=>a.cost-b.cost).slice(0,limit);} +export function routeForPerson(state:PersistentWorldState,person:PersonEntity,targetId:string,network=defaultNavigationNetwork){const start=nearestNavNode(network,person.position);const targetPosition=state.entities.find(e=>e.id===targetId)?.position;const goal=network.nodes.find(node=>node.id===targetId)??(targetPosition?nearestNavNode(network,targetPosition):undefined);return start&&goal?findAccessibleRoutes(network,state,person,start.id,goal.id,3):[];} diff --git a/src/world/affordanceGraph.ts b/src/world/affordanceGraph.ts new file mode 100644 index 0000000..e072b4c --- /dev/null +++ b/src/world/affordanceGraph.ts @@ -0,0 +1,85 @@ +import type { InfrastructureEntity, OpenWorldEntity, PersonEntity, PersistentWorldState } from './worldModel'; + +export type AffordanceAction = + | 'reach' + | 'enter' + | 'use' + | 'communicate' + | 'recharge' + | 'rest'; + +export interface AffordanceResult { + action: AffordanceAction; + entityId: string; + available: boolean; + reason: string; + requiresSupport: boolean; + cost: number; +} + +const supportsMobility = (person: PersonEntity, infrastructure: InfrastructureEntity) => + infrastructure.accessibility.includes(person.mobility.mode); + +export function evaluateAffordance( + person: PersonEntity, + target: OpenWorldEntity, + action: AffordanceAction, +): AffordanceResult { + if (target.kind === 'infrastructure') { + const infra = target as InfrastructureEntity; + const modalitySupported = supportsMobility(person, infra); + if (!infra.operational) { + return { action, entityId: target.id, available: false, reason: 'environmental infrastructure unavailable', requiresSupport: false, cost: 100 }; + } + if (!modalitySupported && action !== 'communicate') { + return { action, entityId: target.id, available: false, reason: 'current mobility modality is not supported by this feature', requiresSupport: !person.mobility.independent, cost: 80 }; + } + if (action === 'recharge' && infra.feature !== 'charging-point') { + return { action, entityId: target.id, available: false, reason: 'target does not provide charging', requiresSupport: false, cost: 50 }; + } + if (action === 'rest' && infra.feature !== 'quiet-space') { + return { action, entityId: target.id, available: false, reason: 'target is not a designated rest space', requiresSupport: false, cost: 40 }; + } + return { action, entityId: target.id, available: true, reason: 'accessible', requiresSupport: !person.mobility.independent, cost: 5 }; + } + + if (action === 'communicate') { + const available = person.communication.access !== 'unavailable'; + return { + action, + entityId: target.id, + available, + reason: available ? 'communication access available' : 'communication access unavailable', + requiresSupport: false, + cost: available ? person.communication.responseLatencySeconds : 100, + }; + } + + return { action, entityId: target.id, available: true, reason: 'no access barrier represented', requiresSupport: false, cost: 10 }; +} + +export interface RouteOption { + targetId: string; + affordances: AffordanceResult[]; + accessible: boolean; + totalCost: number; +} + +export function routeOptions(state: PersistentWorldState, personId: string): RouteOption[] { + const person = state.entities.find((entity): entity is PersonEntity => entity.id === personId && entity.kind === 'person'); + if (!person) return []; + + return state.entities + .filter((entity) => entity.id !== personId) + .map((target) => { + const reach = evaluateAffordance(person, target, 'reach'); + const use = evaluateAffordance(person, target, 'use'); + return { + targetId: target.id, + affordances: [reach, use], + accessible: reach.available && use.available, + totalCost: reach.cost + use.cost, + }; + }) + .sort((a, b) => a.totalCost - b.totalCost); +} diff --git a/src/world/autonomy.ts b/src/world/autonomy.ts new file mode 100644 index 0000000..11ac83f --- /dev/null +++ b/src/world/autonomy.ts @@ -0,0 +1,20 @@ +import { routeOptions } from './affordanceGraph'; +import type { PersonEntity, PersistentWorldState } from './worldModel'; + +export type ActivityKind = 'travel' | 'socialise' | 'communicate' | 'recharge' | 'rest' | 'seek-alternative'; +export interface PlannedActivity { personId:string; kind:ActivityKind; goal:string; targetId?:string; rationale:string; confidence:number; } + +function goalTarget(goal:string){const text=goal.toLowerCase();if(text.includes('community'))return'community-hub';if(text.includes('communicate'))return'community-hub';if(text.includes('decision'))return'clinical-centre';return undefined;} + +export function planNextActivity(state:PersistentWorldState,personId:string):PlannedActivity|undefined{ + const person=state.entities.find((entity):entity is PersonEntity=>entity.id===personId&&entity.kind==='person'); + if(!person)return undefined; + const goal=person.goals[0];if(!goal)return undefined; + const preferredTarget=goalTarget(goal),routes=routeOptions(state,personId),preferred=preferredTarget?routes.find(route=>route.targetId===preferredTarget):undefined; + if(preferredTarget&&preferred?.accessible)return{personId,kind:'travel',goal,targetId:preferredTarget,rationale:'goal-aligned accessible route is available',confidence:.95}; + const alternative=routes.find(route=>route.accessible); + if(alternative)return{personId,kind:'seek-alternative',goal,targetId:alternative.targetId,rationale:preferredTarget?'preferred route is inaccessible; preserve goal by seeking an accessible alternative':'accessible option selected from represented affordances',confidence:.8}; + return{personId,kind:'rest',goal,rationale:'no represented accessible route is currently available; do not attribute the barrier to disability',confidence:.75}; +} + +export function planPopulation(state:PersistentWorldState){return state.entities.filter((entity):entity is PersonEntity=>entity.kind==='person').map(person=>planNextActivity(state,person.id)).filter((activity):activity is PlannedActivity=>Boolean(activity));} diff --git a/src/world/clinicalBridge.ts b/src/world/clinicalBridge.ts new file mode 100644 index 0000000..dc03530 --- /dev/null +++ b/src/world/clinicalBridge.ts @@ -0,0 +1,89 @@ +import type { PersonEntity, PersistentWorldState, WorldEvent } from './worldModel'; +import type { DynamicsProposal } from './vnnDynamics'; + +export type ClinicalBridgeStatus = 'continue-world' | 'assessment-suggested' | 'scenario-eligible'; + +export interface ClinicalTriggerAssessment { + personId: string; + status: ClinicalBridgeStatus; + reasons: string[]; + scenarioId?: string; + preserve: { + authority: true; + baseline: true; + communicationState: true; + worldHistory: true; + }; +} + +export interface ClinicalRuntimePort { + emit(type: string, payload: unknown, source?: string): void; +} + +export function assessClinicalTrigger( + state: PersistentWorldState, + personId: string, + proposals: DynamicsProposal[] = [], +): ClinicalTriggerAssessment { + const person = state.entities.find((entity): entity is PersonEntity => entity.id === personId && entity.kind === 'person'); + if (!person) { + return { personId, status: 'continue-world', reasons: ['person not found'], preserve: { authority: true, baseline: true, communicationState: true, worldHistory: true } }; + } + + const personSignals = proposals.flatMap((proposal) => proposal.personId === personId ? proposal.signals : []); + const healthSignals = personSignals.filter((signal) => signal.node === 'health'); + const severeAccessCascade = personSignals.filter((signal) => signal.node === 'environment' && signal.metric === 'access-barrier').length >= 2; + + if (healthSignals.some((signal) => Math.abs(signal.delta) >= 1 && signal.confidence >= 0.8)) { + return { + personId, + status: 'scenario-eligible', + reasons: ['high-confidence health-state change proposed by bounded dynamics; requires scenario evidence gate'], + scenarioId: 'not-my-baseline', + preserve: { authority: true, baseline: true, communicationState: true, worldHistory: true }, + }; + } + + if (healthSignals.length > 0 || severeAccessCascade) { + return { + personId, + status: 'assessment-suggested', + reasons: severeAccessCascade ? ['repeated environmental barriers may increase burden; assess without pathologising disability'] : ['health signal present but threshold for scenario transition not met'], + preserve: { authority: true, baseline: true, communicationState: true, worldHistory: true }, + }; + } + + return { + personId, + status: 'continue-world', + reasons: ['no evidence-gated clinical transition'], + preserve: { authority: true, baseline: true, communicationState: true, worldHistory: true }, + }; +} + +export function bridgeToClinicalRuntime( + state: PersistentWorldState, + assessment: ClinicalTriggerAssessment, + runtime: ClinicalRuntimePort, +): WorldEvent | undefined { + if (assessment.status !== 'scenario-eligible' || !assessment.scenarioId) return undefined; + const person = state.entities.find((entity): entity is PersonEntity => entity.id === assessment.personId && entity.kind === 'person'); + if (!person) return undefined; + + runtime.emit('world.clinical-bridge.requested', { + scenarioId: assessment.scenarioId, + personId: person.id, + baselineAuthority: person.authority, + communication: person.communication, + worldSimulationSeconds: state.simulationSeconds, + reasons: assessment.reasons, + }, 'open-world'); + + return { + id: `${state.simulationSeconds}-clinical-bridge-${person.id}`, + at: state.simulationSeconds, + type: 'clinical.bridge.requested', + entityId: person.id, + detail: `scenario=${assessment.scenarioId}; world remains authoritative for identity, baseline, communication access and history`, + }; +} diff --git a/src/world/continuity.ts b/src/world/continuity.ts new file mode 100644 index 0000000..da75253 --- /dev/null +++ b/src/world/continuity.ts @@ -0,0 +1,57 @@ +import type { PersonEntity, PersistentWorldState, PersistentWorldStore, WorldEvent } from './worldModel'; + +export type ContinuityStage = 'community' | 'ambulance' | 'ed' | 'icu' | 'mert-scenario' | 'discharge' | 'community-return' | 'community-with-consequences'; + +export interface ContinuityEpisode { + id:string; + personId:string; + stage:ContinuityStage; + history:ContinuityStage[]; + preserved:{ authority:'self'; communication:true; worldHistory:true; baseline:true }; + consequences:{ fatigue:number; followupRequired:boolean; supportReviewRequired:boolean; accessChanges:string[] }; +} + +export const WOLFRAM_CONTINUITY_MODEL = { + verifiedAcyclic: true, + unexpectedDeadEnds: 0, + exampleReturnPath: ['icu','mert-scenario','discharge','community-return','community-with-consequences'] as ContinuityStage[], +}; + +const transitions:Record = { + community:['ambulance'], + ambulance:['ed'], + ed:['icu','discharge'], + icu:['mert-scenario'], + 'mert-scenario':['discharge'], + discharge:['community-return'], + 'community-return':['community-with-consequences'], + 'community-with-consequences':[], +}; + +export class ContinuityEngine { + private episodes=new Map(); + constructor(private store?:PersistentWorldStore){} + + episode(personId:string){const e=this.episodes.get(personId);return e?JSON.parse(JSON.stringify(e)) as ContinuityEpisode:undefined;} + + start(person:PersonEntity,reason:string){const existing=this.episodes.get(person.id);if(existing&&existing.stage!=='community-with-consequences')return existing;const episode:ContinuityEpisode={id:`episode-${person.id}-${Date.now()}`,personId:person.id,stage:'ambulance',history:['community','ambulance'],preserved:{authority:'self',communication:true,worldHistory:true,baseline:true},consequences:{fatigue:0,followupRequired:false,supportReviewRequired:false,accessChanges:[]}};this.episodes.set(person.id,episode);this.event('continuity.started',person.id,`community->ambulance; reason=${reason}; authority=self`);return episode;} + + canAdvance(personId:string,next:ContinuityStage){const episode=this.episodes.get(personId);return Boolean(episode&&transitions[episode.stage].includes(next));} + + advance(personId:string,next:ContinuityStage,detail='explicit simulation transition'){ + const episode=this.episodes.get(personId);if(!episode||!transitions[episode.stage].includes(next))return false; + const previous=episode.stage;episode.stage=next;episode.history.push(next); + if(next==='icu'||next==='mert-scenario')episode.consequences.fatigue=Math.max(episode.consequences.fatigue,1); + if(next==='discharge'){episode.consequences.followupRequired=true;episode.consequences.supportReviewRequired=true;} + if(next==='community-return')episode.consequences.accessChanges.push('reassess transport, communication and equipment access after discharge'); + this.event('continuity.transition',personId,`${previous}->${next}; ${detail}`);return true; + } + + advanceDemoPath(personId:string){const episode=this.episodes.get(personId);if(!episode)return false;const preferred:Partial>={ambulance:'ed',ed:'icu',icu:'mert-scenario','mert-scenario':'discharge',discharge:'community-return','community-return':'community-with-consequences'};const next=preferred[episode.stage];return next?this.advance(personId,next,'educational continuity demo'):false;} + + summary(personId:string){const e=this.episode(personId);return e?`${e.stage} · history ${e.history.join(' → ')}`:'community · no active clinical episode';} + + private event(type:string,entityId:string,detail:string):WorldEvent|undefined{return this.store?.recordEvent(type,entityId,detail);} +} + +export function preservedContinuityContext(state:PersistentWorldState,personId:string){const person=state.entities.find((e):e is PersonEntity=>e.kind==='person'&&e.id===personId);return person?{personId,authority:person.authority,communication:person.communication,worldEvents:state.events.slice(-50)}:undefined;} diff --git a/src/world/dailyLife.ts b/src/world/dailyLife.ts new file mode 100644 index 0000000..145f3e5 --- /dev/null +++ b/src/world/dailyLife.ts @@ -0,0 +1,16 @@ +import { planNextActivity, type PlannedActivity } from './autonomy'; +import type { PersonEntity, PersistentWorldState, WorldEvent } from './worldModel'; + +export interface DailyScheduleEntry { atHour:number; goal:string; destinationId?:string; } +export interface PersonSchedule { personId:string; entries:DailyScheduleEntry[]; } +export const defaultSchedules:PersonSchedule[]=[{personId:'maya',entries:[{atHour:8,goal:'move through the community',destinationId:'transit'},{atHour:10,goal:'communicate directly',destinationId:'community-hub'},{atHour:14,goal:'participate in decisions',destinationId:'clinical-centre'},{atHour:17,goal:'move through the community',destinationId:'community-hub'}]}]; +export interface DailyLifeTick { activity?:PlannedActivity; events:WorldEvent[]; } +export function simulatedHour(state:PersistentWorldState){return(8+Math.floor(state.simulationSeconds/3600))%24;} +export function scheduledGoal(state:PersistentWorldState,schedule:PersonSchedule){const hour=simulatedHour(state);return[...schedule.entries].reverse().find(entry=>entry.atHour<=hour)??schedule.entries[0];} +export function runDailyLifeTick(state:PersistentWorldState,schedule:PersonSchedule):DailyLifeTick{ + const person=state.entities.find((entity):entity is PersonEntity=>entity.kind==='person'&&entity.id===schedule.personId);if(!person)return{events:[]}; + const scheduled=scheduledGoal(state,schedule);if(!scheduled)return{events:[]}; + const planningState:PersistentWorldState={...state,entities:state.entities.map(entity=>entity.id===person.id&&entity.kind==='person'?{...entity,goals:[scheduled.goal,...person.goals.filter(goal=>goal!==scheduled.goal)]}:entity)}; + const activity=planNextActivity(planningState,person.id);if(!activity)return{events:[]}; + return{activity,events:[{id:`${state.simulationSeconds}-daily-${person.id}`,at:state.simulationSeconds,type:'daily-life.activity.planned',entityId:person.id,detail:`hour=${simulatedHour(state)}; goal=${scheduled.goal}; activity=${activity.kind}; target=${activity.targetId??scheduled.destinationId??'none'}`}]}; +} diff --git a/src/world/liveWorldLoop.ts b/src/world/liveWorldLoop.ts new file mode 100644 index 0000000..2394bd3 --- /dev/null +++ b/src/world/liveWorldLoop.ts @@ -0,0 +1,127 @@ +import { planPopulation } from './autonomy'; +import { assessClinicalTrigger, bridgeToClinicalRuntime, type ClinicalRuntimePort } from './clinicalBridge'; +import { defaultSchedules, runDailyLifeTick } from './dailyLife'; +import { ensurePopulation, populationSchedules } from './population'; +import { defaultSocialAgents, interact, socialEvents } from './socialAgents'; +import { dynamicsEvents, proposeDynamics, type DynamicsProposal } from './vnnDynamics'; +import { defaultNavigationNetwork, routeForPerson, type RouteResult } from './accessibleNavigation'; +import { ObjectInteractionEngine, type ObjectWorldSnapshot } from './objectInteractions'; +import { SocialEncounterEngine, type SocialEncounter } from './socialEncounters'; +import { ContinuityEngine, type ContinuityStage } from './continuity'; +import type { PersonEntity, PersistentWorldState, PersistentWorldStore, Vec3 } from './worldModel'; + +export interface LiveWorldFrame { + activities: ReturnType; + proposals: DynamicsProposal[]; + clinical: ReturnType[]; + routes: Record; + objects: ObjectWorldSnapshot; + encounters: SocialEncounter[]; + continuity: Record; +} + +export class WorldRuntime { + private accumulator=0; + private socialAccumulator=0; + private objects:ObjectInteractionEngine; + private encounterEngine=new SocialEncounterEngine(); + private continuityEngine:ContinuityEngine; + private lastFrame:LiveWorldFrame={activities:[],proposals:[],clinical:[],routes:{},objects:{doors:[],lift:{id:'station-lift',currentLevel:0,fromLevel:0,targetLevel:0,phase:'idle',elapsed:0,duration:8,positionMetres:0},chargers:[],transport:{id:'community-shuttle',atStop:true,capacity:6,passengers:[],doorsOpen:true}},encounters:[],continuity:{}}; + + constructor(private store:PersistentWorldStore,private clinicalRuntime?:ClinicalRuntimePort){ + ensurePopulation(store); + this.objects=new ObjectInteractionEngine(store); + this.continuityEngine=new ContinuityEngine(store); + } + + snapshot(){return this.lastFrame;} + objectEngine(){return this.objects;} + continuity(){return this.continuityEngine;} + + step(realSeconds:number):LiveWorldFrame{ + this.store.tick(realSeconds); + this.objects.step(realSeconds); + this.accumulator+=realSeconds; + this.socialAccumulator+=realSeconds; + if(this.accumulator<2)return {...this.lastFrame,objects:this.objects.snapshot()}; + + const stepSeconds=this.accumulator; + this.accumulator=0; + let state=this.store.snapshot(); + const schedules=[...defaultSchedules,...populationSchedules]; + this.store.appendEvents(schedules.flatMap(schedule=>runDailyLifeTick(state,schedule).events)); + state=this.store.snapshot(); + + const activities=planPopulation(state); + const proposals=activities.map(activity=>proposeDynamics(state,activity)); + this.store.appendEvents(proposals.flatMap(p=>dynamicsEvents(state,p))); + + const routes:Record={}; + for(const activity of activities){ + const person=state.entities.find((e):e is PersonEntity=>e.kind==='person'&&e.id===activity.personId); + if(!person||!activity.targetId)continue; + const route=routeForPerson(state,person,activity.targetId,defaultNavigationNetwork)[0]; + routes[person.id]=route; + this.advanceActivity(state,activity,route,stepSeconds); + } + + if(this.socialAccumulator>=15){ + this.socialAccumulator=0; + const people=state.entities.filter((e):e is PersonEntity=>e.kind==='person'); + for(let i=0;ie.kind==='person').map(p=>assessClinicalTrigger(state,p.id,proposals)); + for(const assessment of clinical){ + if(assessment.status==='scenario-eligible'){ + const person=state.entities.find((e):e is PersonEntity=>e.kind==='person'&&e.id===assessment.personId); + if(person&&!this.continuityEngine.episode(person.id))this.continuityEngine.start(person,assessment.reasons.join('; ')); + } + if(this.clinicalRuntime){const event=bridgeToClinicalRuntime(state,assessment,this.clinicalRuntime);if(event)this.store.appendEvents([event]);} + } + + const continuity:Record={}; + for(const person of state.entities.filter((e):e is PersonEntity=>e.kind==='person'))continuity[person.id]=this.continuityEngine.summary(person.id); + this.lastFrame={activities,proposals,clinical,routes,objects:this.objects.snapshot(),encounters:encounterResult.encounters,continuity}; + return this.lastFrame; + } + + requestSupport(from:string,to:string){this.encounterEngine.requestSupport(from,to);this.store.recordEvent('social.support.requested',from,`support requested from ${to}; support is not assumed`);} + beginContinuity(personId:string,reason='facilitator-triggered educational deterioration'){ + const person=this.store.entity(personId);if(!person||person.kind!=='person')return false;this.continuityEngine.start(person as PersonEntity,reason);return true; + } + advanceContinuity(personId:string,next?:ContinuityStage){return next?this.continuityEngine.advance(personId,next):this.continuityEngine.advanceDemoPath(personId);} + + private advanceActivity(state:PersistentWorldState,activity:ReturnType[number],route:RouteResult|undefined,seconds:number){ + if(!activity.targetId||(activity.kind!=='travel'&&activity.kind!=='seek-alternative'))return; + const person=state.entities.find((e):e is PersonEntity=>e.kind==='person'&&e.id===activity.personId); + if(!person)return; + if(!route){this.store.recordEvent('navigation.no-accessible-route',person.id,`goal preserved; target=${activity.targetId}; environmental/route barrier`);return;} + + const current=[person.position[0],person.position[1],person.position[2]] as Vec3; + const remaining=route.points.filter(point=>Math.hypot(point[0]-current[0],point[2]-current[2])>1.2); + const waypoint=remaining[0] ?? route.points[route.points.length-1]; + if(!waypoint)return; + + const waypointIndex=route.points.indexOf(waypoint); + const waypointId=route.nodeIds[waypointIndex]; + const dx=waypoint[0]-current[0],dz=waypoint[2]-current[2],distance=Math.hypot(dx,dz); + if(distance<2.5&&waypointId?.includes('-door'))this.objects.requestDoor(waypointId,person.id); + if(distance<2.5&&waypointId==='station-lift')this.objects.requestLift(0,person.id); + if(distance<.01)return; + + const speed=person.mobility.mode==='powered-wheelchair'?1.25:person.mobility.mode==='manual-wheelchair'?1.0:1.15; + const amount=Math.min(distance,speed*seconds); + this.store.move(person.id,[current[0]+dx/distance*amount,current[1],current[2]+dz/distance*amount] as Vec3); + } +} + +/** @deprecated Use WorldRuntime. */ +export class LiveWorldLoop extends WorldRuntime {} diff --git a/src/world/liveWorldRuntime.ts b/src/world/liveWorldRuntime.ts new file mode 100644 index 0000000..869b47f --- /dev/null +++ b/src/world/liveWorldRuntime.ts @@ -0,0 +1,2 @@ +export { WorldRuntime as LiveWorldRuntime } from './liveWorldLoop'; +export type { LiveWorldFrame } from './liveWorldLoop'; diff --git a/src/world/objectInteractions.ts b/src/world/objectInteractions.ts new file mode 100644 index 0000000..9d456a1 --- /dev/null +++ b/src/world/objectInteractions.ts @@ -0,0 +1,64 @@ +import type { PersistentWorldStore, WorldEvent } from './worldModel'; + +export const WOLFRAM_OBJECT_MODELS = { + liftSmoothstep: 's(u)=3u^2-2u^3', + liftExample: { distanceMetres: 4.2, durationSeconds: 8, maxVelocityMetresPerSecond: .7875 }, + charger: 'B(t)=C-(C-B0) exp(-k t)', + chargerExample: { initialPercent: 30, targetPercent: 80, kPerSecond: .0015, timeToTargetSeconds: 835.1753123302453 }, +}; + +export type DoorPhase = 'closed' | 'opening' | 'open' | 'closing'; +export interface DoorState { id:string; phase:DoorPhase; progress:number; holdSeconds:number; elapsed:number; } +export interface LiftState { id:string; currentLevel:number; fromLevel:number; targetLevel:number; phase:'idle'|'moving'|'doors-open'; elapsed:number; duration:number; positionMetres:number; } +export interface ChargerSession { personId:string; initialPercent:number; currentPercent:number; capacityPercent:number; kPerSecond:number; elapsed:number; active:boolean; } +export interface TransportState { id:string; atStop:boolean; capacity:number; passengers:string[]; doorsOpen:boolean; } + +export interface ObjectWorldSnapshot { + doors: DoorState[]; + lift: LiftState; + chargers: ChargerSession[]; + transport: TransportState; +} + +const clamp01=(x:number)=>Math.max(0,Math.min(1,x)); +export const smoothstep=(u:number)=>{const x=clamp01(u);return 3*x*x-2*x*x*x;}; +export const chargerPercent=(initial:number,capacity:number,k:number,t:number)=>capacity-(capacity-initial)*Math.exp(-k*Math.max(0,t)); + +export class ObjectInteractionEngine { + private doors = new Map([ + ['community-door',{id:'community-door',phase:'closed',progress:0,holdSeconds:4,elapsed:0}], + ['clinical-door',{id:'clinical-door',phase:'closed',progress:0,holdSeconds:4,elapsed:0}], + ['rehab-door',{id:'rehab-door',phase:'closed',progress:0,holdSeconds:4,elapsed:0}], + ['transit-door',{id:'transit-door',phase:'closed',progress:0,holdSeconds:4,elapsed:0}], + ]); + private lift:LiftState={id:'station-lift',currentLevel:0,fromLevel:0,targetLevel:0,phase:'idle',elapsed:0,duration:8,positionMetres:0}; + private chargers=new Map(); + private transport:TransportState={id:'community-shuttle',atStop:true,capacity:6,passengers:[],doorsOpen:true}; + + constructor(private store?:PersistentWorldStore){} + + snapshot():ObjectWorldSnapshot{return {doors:[...this.doors.values()].map(x=>({...x})),lift:{...this.lift},chargers:[...this.chargers.values()].map(x=>({...x})),transport:{...this.transport,passengers:[...this.transport.passengers]}};} + + requestDoor(id:string,personId?:string){const door=this.doors.get(id);if(!door)return false;if(door.phase==='closed'||door.phase==='closing'){door.phase='opening';door.elapsed=0;this.event('object.door.requested',personId,`${id} opening requested`);}return true;} + + requestLift(targetLevel:number,personId?:string){if(this.lift.phase==='moving')return false;if(targetLevel===this.lift.currentLevel){this.lift.phase='doors-open';this.event('object.lift.available',personId,`lift already at level ${targetLevel}`);return true;}this.lift.fromLevel=this.lift.currentLevel;this.lift.targetLevel=targetLevel;this.lift.elapsed=0;this.lift.phase='moving';this.event('object.lift.requested',personId,`lift moving ${this.lift.fromLevel}->${targetLevel}`);return true;} + + startCharging(personId:string,initialPercent:number,kPerSecond=.0015){this.chargers.set(personId,{personId,initialPercent,currentPercent:initialPercent,capacityPercent:100,kPerSecond,elapsed:0,active:true});this.event('object.charger.started',personId,`charging started at ${initialPercent.toFixed(1)}%`);} + stopCharging(personId:string){const session=this.chargers.get(personId);if(!session)return;session.active=false;this.event('object.charger.stopped',personId,`charging stopped at ${session.currentPercent.toFixed(1)}%`);} + + boardTransport(personId:string){if(!this.transport.atStop||!this.transport.doorsOpen)return {ok:false,reason:'transport not ready for boarding'};if(this.transport.passengers.includes(personId))return {ok:true,reason:'already boarded'};if(this.transport.passengers.length>=this.transport.capacity)return {ok:false,reason:'transport capacity reached'};this.transport.passengers.push(personId);this.event('object.transport.boarded',personId,'boarding completed after explicit request');return {ok:true,reason:'boarded'};} + alightTransport(personId:string){this.transport.passengers=this.transport.passengers.filter(id=>id!==personId);this.event('object.transport.alighted',personId,'alighted transport');} + + step(seconds:number){const dt=Math.max(0,seconds); + for(const door of this.doors.values()){ + if(door.phase==='opening'){door.elapsed+=dt;door.progress=smoothstep(door.elapsed/1.25);if(door.elapsed>=1.25){door.phase='open';door.progress=1;door.elapsed=0;}} + else if(door.phase==='open'){door.elapsed+=dt;if(door.elapsed>=door.holdSeconds){door.phase='closing';door.elapsed=0;}} + else if(door.phase==='closing'){door.elapsed+=dt;door.progress=1-smoothstep(door.elapsed/1.25);if(door.elapsed>=1.25){door.phase='closed';door.progress=0;door.elapsed=0;}} + } + if(this.lift.phase==='moving'){this.lift.elapsed+=dt;const u=this.lift.elapsed/this.lift.duration;const y0=this.lift.fromLevel*4.2,y1=this.lift.targetLevel*4.2;this.lift.positionMetres=y0+(y1-y0)*smoothstep(u);if(u>=1){this.lift.currentLevel=this.lift.targetLevel;this.lift.positionMetres=y1;this.lift.phase='doors-open';this.lift.elapsed=0;}} + else if(this.lift.phase==='doors-open'){this.lift.elapsed+=dt;if(this.lift.elapsed>=4){this.lift.phase='idle';this.lift.elapsed=0;}} + for(const session of this.chargers.values())if(session.active){session.elapsed+=dt;session.currentPercent=chargerPercent(session.initialPercent,session.capacityPercent,session.kPerSecond,session.elapsed);if(session.currentPercent>=99.5)session.active=false;} + } + + private event(type:string,entityId:string|undefined,detail:string):WorldEvent|undefined{return this.store?.recordEvent(type,entityId,detail);} +} diff --git a/src/world/population.ts b/src/world/population.ts new file mode 100644 index 0000000..a9b2cbe --- /dev/null +++ b/src/world/population.ts @@ -0,0 +1,27 @@ +import type { PersonEntity, PersistentWorldStore } from './worldModel'; +import type { PersonSchedule } from './dailyLife'; + +export type RelationshipKind = 'friend' | 'peer' | 'family' | 'support' | 'clinician' | 'colleague'; +export interface Relationship { from: string; to: string; kind: RelationshipKind; trust: number; } + +export const population: PersonEntity[] = [ + { id:'liam', kind:'person', label:'Liam', position:[-8,0,4], persistent:true, tags:['resident','peer'], authority:'self', communication:{primary:'speech',access:'available',responseLatencySeconds:2}, mobility:{mode:'manual-wheelchair',independent:true}, goals:['meet friends','use community services','travel independently'] }, + { id:'rohan', kind:'person', label:'Rohan', position:[18,0,-8], persistent:true, tags:['resident','student'], authority:'self', communication:{primary:'multimodal',access:'available',responseLatencySeconds:5}, mobility:{mode:'walk',independent:true}, goals:['attend rehabilitation','study','meet friends'] }, + { id:'aisha', kind:'person', label:'Aisha', position:[12,0,26], persistent:true, tags:['resident','worker'], authority:'self', communication:{primary:'sign',access:'available',responseLatencySeconds:4}, mobility:{mode:'walk',independent:true}, goals:['work','socialise','travel through community'] } +]; + +export const relationships: Relationship[] = [ + { from:'maya', to:'liam', kind:'friend', trust:.9 }, { from:'liam', to:'maya', kind:'friend', trust:.9 }, + { from:'maya', to:'aisha', kind:'peer', trust:.75 }, { from:'rohan', to:'liam', kind:'peer', trust:.7 } +]; + +export const populationSchedules: PersonSchedule[] = [ + { personId:'liam', entries:[{atHour:8,goal:'travel independently',destinationId:'transit'},{atHour:11,goal:'meet friends',destinationId:'community-hub'},{atHour:16,goal:'use community services',destinationId:'community-hub'}] }, + { personId:'rohan', entries:[{atHour:8,goal:'study',destinationId:'community-hub'},{atHour:13,goal:'attend rehabilitation',destinationId:'rehab-lab'},{atHour:17,goal:'meet friends',destinationId:'community-hub'}] }, + { personId:'aisha', entries:[{atHour:8,goal:'work',destinationId:'community-hub'},{atHour:12,goal:'travel through community',destinationId:'transit'},{atHour:17,goal:'socialise',destinationId:'community-hub'}] } +]; + +export function ensurePopulation(store: PersistentWorldStore) { + for (const person of population) if (!store.entity(person.id)) store.upsertEntity(person); + store.recordEvent('population.ready', undefined, `persistent population=${population.length + 1}; relationships=${relationships.length}`); +} diff --git a/src/world/scenarioStudio.ts b/src/world/scenarioStudio.ts new file mode 100644 index 0000000..7271e71 --- /dev/null +++ b/src/world/scenarioStudio.ts @@ -0,0 +1,10 @@ +import type { AccessModality, InfrastructureEntity, PersonEntity, PersistentWorldState, Vec3 } from './worldModel'; + +export interface WorldStudioDraft { title:string; description:string; seed:PersistentWorldState; } +export interface PlaceDraft { id:string; label:string; position:Vec3; tags?:string[]; } +export interface InfrastructureDraft { id:string; label:string; position:Vec3; feature:InfrastructureEntity['feature']; accessibility:AccessModality[]; operational?:boolean; } +export function addPlace(draft:WorldStudioDraft,place:PlaceDraft):WorldStudioDraft{return{...draft,seed:{...draft.seed,entities:[...draft.seed.entities,{...place,kind:'place',persistent:true,tags:place.tags??[]}]}};} +export function addInfrastructure(draft:WorldStudioDraft,infrastructure:InfrastructureDraft):WorldStudioDraft{const entity:InfrastructureEntity={...infrastructure,kind:'infrastructure',persistent:true,tags:['access'],operational:infrastructure.operational??true};return{...draft,seed:{...draft.seed,entities:[...draft.seed.entities,entity]}};} +export interface StudioValidationIssue { severity:'error'|'warning'; path:string; message:string; } +export function validateWorldDraft(draft:WorldStudioDraft):StudioValidationIssue[]{const issues:StudioValidationIssue[]=[],ids=new Set();for(const entity of draft.seed.entities){if(ids.has(entity.id))issues.push({severity:'error',path:`entities.${entity.id}`,message:'Entity IDs must be unique.'});ids.add(entity.id);if(entity.kind==='person'&&(entity as PersonEntity).authority!=='self')issues.push({severity:'error',path:`entities.${entity.id}.authority`,message:'Open-world person authority cannot be silently reassigned by a scenario draft.'});if(entity.kind==='infrastructure'&&(entity as InfrastructureEntity).accessibility.length===0)issues.push({severity:'warning',path:`entities.${entity.id}.accessibility`,message:'Infrastructure has no represented access modalities; verify this is intentional.'});}return issues;} +export function exportWorldDraft(draft:WorldStudioDraft){const issues=validateWorldDraft(draft);if(issues.some(issue=>issue.severity==='error'))throw new Error('World draft contains validation errors.');return JSON.stringify(draft,null,2);} diff --git a/src/world/schedules.ts b/src/world/schedules.ts new file mode 100644 index 0000000..86b7881 --- /dev/null +++ b/src/world/schedules.ts @@ -0,0 +1,35 @@ +import type { PersistentWorldState } from './worldModel'; + +export interface DailyScheduleEntry { + id: string; + personId: string; + startMinute: number; + endMinute: number; + activity: 'home' | 'travel' | 'community' | 'work' | 'study' | 'appointment' | 'social' | 'rest' | 'charge'; + targetId?: string; + priority: number; + flexible: boolean; +} + +export const defaultDailySchedules: DailyScheduleEntry[] = [ + { id: 'maya-community', personId: 'maya', startMinute: 9 * 60, endMinute: 11 * 60, activity: 'community', targetId: 'community-hub', priority: 0.8, flexible: true }, + { id: 'maya-charge', personId: 'maya', startMinute: 11 * 60, endMinute: 11 * 60 + 30, activity: 'charge', targetId: 'aac-charge', priority: 0.9, flexible: true }, + { id: 'maya-clinical', personId: 'maya', startMinute: 14 * 60, endMinute: 15 * 60, activity: 'appointment', targetId: 'clinical-centre', priority: 0.7, flexible: false }, +]; + +export function worldMinute(state: PersistentWorldState, dayStartMinute = 8 * 60) { + return (dayStartMinute + Math.floor(state.simulationSeconds / 60)) % (24 * 60); +} + +export function currentSchedule(state: PersistentWorldState, personId: string, entries = defaultDailySchedules) { + const minute = worldMinute(state); + return entries + .filter((entry) => entry.personId === personId && minute >= entry.startMinute && minute < entry.endMinute) + .sort((a, b) => b.priority - a.priority)[0]; +} + +export function scheduleSummary(state: PersistentWorldState, personId: string, entries = defaultDailySchedules) { + const active = currentSchedule(state, personId, entries); + if (!active) return 'No fixed activity: personal goals and accessible opportunities remain available.'; + return `${active.activity.toUpperCase()} · ${active.targetId ?? 'self-directed'} · ${active.flexible ? 'flexible timing' : 'time-specific'}`; +} diff --git a/src/world/socialAgents.ts b/src/world/socialAgents.ts new file mode 100644 index 0000000..f17dc67 --- /dev/null +++ b/src/world/socialAgents.ts @@ -0,0 +1,114 @@ +import type { PersonEntity, PersistentWorldState, WorldEvent } from './worldModel'; + +export type SocialRole = 'peer' | 'support-worker' | 'clinician' | 'transport-staff' | 'educator' | 'employer' | 'bystander'; +export type KnowledgeStatus = 'known' | 'unknown' | 'uncertain'; + +export interface SocialAgent { + id: string; + label: string; + role: SocialRole; + knowledge: Record; + communicationPractice: 'direct-first' | 'proxy-first' | 'mixed'; + respectsAuthority: boolean; + escalationThreshold: number; +} + +export interface SocialInteraction { + agentId: string; + personId: string; + intent: 'greet' | 'ask' | 'assist' | 'handover' | 'escalate'; + outcome: 'supported' | 'barrier' | 'unknown'; + effects: Array<{ + domain: 'communication' | 'trust' | 'access' | 'institutional'; + delta: number; + cause: string; + }>; + invariantChecks: string[]; +} + +export const defaultSocialAgents: SocialAgent[] = [ + { + id: 'community-peer-1', + label: 'Community peer', + role: 'peer', + knowledge: { aac: 'uncertain', baseline: 'unknown' }, + communicationPractice: 'direct-first', + respectsAuthority: true, + escalationThreshold: 0.9, + }, + { + id: 'support-worker-1', + label: 'Communication support worker', + role: 'support-worker', + knowledge: { aac: 'known', baseline: 'known' }, + communicationPractice: 'direct-first', + respectsAuthority: true, + escalationThreshold: 0.65, + }, + { + id: 'clinician-1', + label: 'Clinician', + role: 'clinician', + knowledge: { aac: 'uncertain', baseline: 'uncertain' }, + communicationPractice: 'mixed', + respectsAuthority: true, + escalationThreshold: 0.55, + }, + { + id: 'transit-staff-1', + label: 'Transit staff member', + role: 'transport-staff', + knowledge: { access: 'known', aac: 'uncertain' }, + communicationPractice: 'direct-first', + respectsAuthority: true, + escalationThreshold: 0.8, + }, +]; + +export function interact( + state: PersistentWorldState, + agent: SocialAgent, + personId: string, + intent: SocialInteraction['intent'], +): SocialInteraction | undefined { + const person = state.entities.find((entity): entity is PersonEntity => entity.id === personId && entity.kind === 'person'); + if (!person) return undefined; + + const effects: SocialInteraction['effects'] = []; + let outcome: SocialInteraction['outcome'] = 'supported'; + + if (!agent.respectsAuthority || agent.communicationPractice === 'proxy-first') { + outcome = 'barrier'; + effects.push({ domain: 'trust', delta: -0.4, cause: 'person was not treated as the primary decision-maker' }); + effects.push({ domain: 'communication', delta: -0.3, cause: 'communication was redirected away from the person' }); + } else if (person.communication.access === 'unavailable' && intent === 'ask') { + outcome = 'unknown'; + effects.push({ domain: 'communication', delta: -0.2, cause: 'communication access unavailable; answer remains unknown rather than inferred' }); + } else { + effects.push({ domain: 'trust', delta: 0.2, cause: 'direct, authority-preserving interaction' }); + } + + return { + agentId: agent.id, + personId, + intent, + outcome, + effects, + invariantChecks: [ + 'support worker does not automatically become substitute decision-maker', + 'no response remains unknown', + 'communication difference does not imply cognitive impairment', + 'social agent knowledge is bounded and explicit', + ], + }; +} + +export function socialEvents(state: PersistentWorldState, interaction: SocialInteraction): WorldEvent[] { + return interaction.effects.map((effect, index) => ({ + id: `${state.simulationSeconds}-social-${interaction.agentId}-${index}`, + at: state.simulationSeconds, + type: `social.${effect.domain}`, + entityId: interaction.personId, + detail: `${effect.cause}; delta=${effect.delta}; outcome=${interaction.outcome}`, + })); +} diff --git a/src/world/socialEncounters.ts b/src/world/socialEncounters.ts new file mode 100644 index 0000000..b392670 --- /dev/null +++ b/src/world/socialEncounters.ts @@ -0,0 +1,51 @@ +import { relationships } from './population'; +import type { PersonEntity, PersistentWorldState, WorldEvent } from './worldModel'; + +export const WOLFRAM_SOCIAL_MODEL = { + formula: 'sigmoid(2*relationship + sharedGoal - 0.35*distance - 1.5*contextLoad)', + examples: { + friendNearby: .8979819308108695, + strangerNearby: .3047033306524346, + friendFar: .24973989440488245, + }, +}; + +export interface SocialEncounter { + a: string; + b: string; + distance: number; + relationship: number; + sharedGoal: number; + contextLoad: number; + score: number; + status: 'recognised' | 'conversation-ready' | 'waiting-for-access'; + supportRequested: boolean; +} + +const sigmoid=(x:number)=>1/(1+Math.exp(-x)); +const distance2D=(a:PersonEntity,b:PersonEntity)=>Math.hypot(a.position[0]-b.position[0],a.position[2]-b.position[2]); + +function relationshipStrength(a:string,b:string){return relationships.find(r=>r.from===a&&r.to===b)?.trust ?? relationships.find(r=>r.from===b&&r.to===a)?.trust ?? 0;} +function sharedGoalScore(a:PersonEntity,b:PersonEntity){const A=new Set(a.goals.map(g=>g.toLowerCase()));return b.goals.some(g=>A.has(g.toLowerCase()))?1:0;} + +export function encounterScore(distance:number,relationship:number,sharedGoal:number,contextLoad:number){return sigmoid(2*relationship+sharedGoal-.35*distance-1.5*contextLoad);} + +export class SocialEncounterEngine { + private requests=new Set(); + private previous=new Set(); + + requestSupport(from:string,to:string){this.requests.add(`${from}->${to}`);} + clearSupportRequest(from:string,to:string){this.requests.delete(`${from}->${to}`);} + + detect(state:PersistentWorldState,contextLoad=.2):{encounters:SocialEncounter[];events:WorldEvent[]}{ + const people=state.entities.filter((e):e is PersonEntity=>e.kind==='person');const encounters:SocialEncounter[]=[];const events:WorldEvent[]=[];const current=new Set(); + for(let i=0;i6)continue; + const key=[a.id,b.id].sort().join('|');current.add(key);const accessAvailable=a.communication.access!=='unavailable'&&b.communication.access!=='unavailable';const status:SocialEncounter['status']=accessAvailable?(score>.75?'conversation-ready':'recognised'):'waiting-for-access';const supportRequested=this.requests.has(`${a.id}->${b.id}`)||this.requests.has(`${b.id}->${a.id}`); + encounters.push({a:a.id,b:b.id,distance,relationship,sharedGoal,contextLoad,score,status,supportRequested}); + if(!this.previous.has(key))events.push({id:`${state.simulationSeconds}-encounter-${key}`,at:state.simulationSeconds,type:'social.encounter.started',entityId:a.id,detail:`with=${b.id}; score=${score.toFixed(3)}; status=${status}; support=${supportRequested?'requested':'not-assumed'}`}); + } + for(const key of this.previous)if(!current.has(key))events.push({id:`${state.simulationSeconds}-encounter-end-${key}`,at:state.simulationSeconds,type:'social.encounter.ended',detail:key}); + this.previous=current;return {encounters,events}; + } +} diff --git a/src/world/spatialInteraction.ts b/src/world/spatialInteraction.ts new file mode 100644 index 0000000..961ff0e --- /dev/null +++ b/src/world/spatialInteraction.ts @@ -0,0 +1,38 @@ +import { evaluateAffordance } from './affordanceGraph'; +import { normalizeIntent, type InteractionModality, type WorldIntent } from './vrInteraction'; +import type { OpenWorldEntity, PersonEntity, PersistentWorldState, Vec3 } from './worldModel'; + +export interface NearbyEntity { entity: OpenWorldEntity; distance: number; } +const distance=(a:Vec3,b:Vec3)=>Math.hypot(a[0]-b[0],a[2]-b[2]); + +export function nearbyEntities(state: PersistentWorldState, actorId: string, radius=4): NearbyEntity[] { + const actor=state.entities.find((e):e is PersonEntity=>e.id===actorId&&e.kind==='person'); + if(!actor)return[]; + return state.entities.filter(e=>e.id!==actorId).map(entity=>({entity,distance:distance(actor.position,entity.position)})).filter(x=>x.distance<=radius).sort((a,b)=>a.distance-b.distance); +} + +export function createSpatialIntent(actorId:string,targetId:string,action:WorldIntent['action'],modality:InteractionModality):WorldIntent { + return normalizeIntent({actorId,targetId,action,modality}); +} + +export function evaluateSpatialIntent(state:PersistentWorldState,intent:WorldIntent){ + const actor=state.entities.find((e):e is PersonEntity=>e.id===intent.actorId&&e.kind==='person'); + const target=state.entities.find(e=>e.id===intent.targetId); + if(!actor||!target)return{allowed:false,reason:'actor or target not represented'}; + if(intent.action==='communicate'){ + if(target.kind!=='person')return{allowed:false,reason:'communication target is not a person'}; + if(actor.communication.access==='unavailable')return{allowed:false,reason:'communication access unavailable; intent is preserved and may be retried'}; + return{allowed:true,reason:'direct communication available'}; + } + if(intent.action==='inspect'||intent.action==='select')return{allowed:true,reason:'semantic target available'}; + const affordance=evaluateAffordance(actor,target,intent.action==='use'?'use':'reach'); + return{allowed:affordance.available,reason:affordance.reason,affordance}; +} + +export function wayfind(state:PersistentWorldState,actorId:string,targetId:string){ + const actor=state.entities.find((e):e is PersonEntity=>e.id===actorId&&e.kind==='person'); + const target=state.entities.find(e=>e.id===targetId); + if(!actor||!target)return undefined; + const direct=evaluateAffordance(actor,target,'reach'); + return{actorId,targetId,destination:target.position,distance:distance(actor.position,target.position),accessible:direct.available,reason:direct.reason}; +} diff --git a/src/world/timeline.ts b/src/world/timeline.ts new file mode 100644 index 0000000..bd7efc6 --- /dev/null +++ b/src/world/timeline.ts @@ -0,0 +1,18 @@ +import type { PersistentWorldState, WorldEvent } from './worldModel'; + +export interface TimelineSnapshot { id:string; label:string; at:number; state:PersistentWorldState; parentId?:string; branchReason?:string; } +export interface CounterfactualComparison { leftId:string; rightId:string; simulationDeltaSeconds:number; entityChanges:Array<{entityId:string;changedFields:string[]}>; eventTypesOnlyLeft:string[]; eventTypesOnlyRight:string[]; } +const clone=(value:T):T=>JSON.parse(JSON.stringify(value)) as T; + +export class WorldTimeline { + private snapshots=new Map(); + capture(state:PersistentWorldState,label:string,parentId?:string,branchReason?:string):TimelineSnapshot{ + const id=`snapshot-${state.simulationSeconds}-${this.snapshots.size+1}`; + const snapshot:TimelineSnapshot={id,label,at:state.simulationSeconds,state:clone(state),...(parentId?{parentId}:{}),...(branchReason?{branchReason}:{})}; + this.snapshots.set(id,snapshot);return clone(snapshot); + } + get(id:string):TimelineSnapshot|undefined{const snapshot=this.snapshots.get(id);return snapshot?clone(snapshot):undefined;} + branch(snapshotId:string,label:string,mutate:(state:PersistentWorldState)=>void,reason:string):TimelineSnapshot|undefined{const parent=this.snapshots.get(snapshotId);if(!parent)return undefined;const state=clone(parent.state);mutate(state);state.events.push({id:`${state.simulationSeconds}-counterfactual-${state.events.length}`,at:state.simulationSeconds,type:'timeline.counterfactual.branch',detail:reason});return this.capture(state,label,parent.id,reason);} + replay(snapshotId:string,events:WorldEvent[]):TimelineSnapshot|undefined{const parent=this.snapshots.get(snapshotId);if(!parent)return undefined;const state=clone(parent.state);state.events.push(...clone(events));if(events.length>0)state.simulationSeconds=Math.max(state.simulationSeconds,...events.map(event=>event.at));return this.capture(state,`${parent.label} replay`,parent.id,'deterministic event replay');} + compare(leftId:string,rightId:string):CounterfactualComparison|undefined{const left=this.snapshots.get(leftId),right=this.snapshots.get(rightId);if(!left||!right)return undefined;const rightById=new Map(right.state.entities.map(entity=>[entity.id,entity]));const entityChanges=left.state.entities.flatMap(leftEntity=>{const rightEntity=rightById.get(leftEntity.id);if(!rightEntity)return[{entityId:leftEntity.id,changedFields:['removed']}];const fields=new Set([...Object.keys(leftEntity),...Object.keys(rightEntity)]),changedFields=[...fields].filter(field=>JSON.stringify((leftEntity as any)[field])!==JSON.stringify((rightEntity as any)[field]));return changedFields.length?[{entityId:leftEntity.id,changedFields}]:[];});const leftTypes=new Set(left.state.events.map(event=>event.type)),rightTypes=new Set(right.state.events.map(event=>event.type));return{leftId,rightId,simulationDeltaSeconds:right.at-left.at,entityChanges,eventTypesOnlyLeft:[...leftTypes].filter(type=>!rightTypes.has(type)),eventTypesOnlyRight:[...rightTypes].filter(type=>!leftTypes.has(type))};} +} diff --git a/src/world/vnnDynamics.ts b/src/world/vnnDynamics.ts new file mode 100644 index 0000000..567ea81 --- /dev/null +++ b/src/world/vnnDynamics.ts @@ -0,0 +1,64 @@ +import type { PersonEntity, PersistentWorldState, WorldEvent } from './worldModel'; +import type { PlannedActivity } from './autonomy'; + +export type DynamicsNode = 'mobility' | 'communication' | 'fatigue' | 'environment' | 'social' | 'health' | 'institutional'; + +export interface DynamicsSignal { + node: DynamicsNode; + personId: string; + metric: string; + delta: number; + confidence: number; + cause: string; + provenance: 'deterministic-vnn'; +} + +export interface DynamicsProposal { + personId: string; + activity: PlannedActivity; + signals: DynamicsSignal[]; + invariantChecks: string[]; +} + +export function proposeDynamics(state: PersistentWorldState, activity: PlannedActivity): DynamicsProposal { + const person = state.entities.find((entity): entity is PersonEntity => entity.id === activity.personId && entity.kind === 'person'); + if (!person) return { personId: activity.personId, activity, signals: [], invariantChecks: ['person exists: FAIL'] }; + + const signals: DynamicsSignal[] = []; + if (activity.kind === 'travel') { + signals.push({ node: 'mobility', personId: person.id, metric: 'participation', delta: 1, confidence: 0.9, cause: 'goal-aligned accessible travel', provenance: 'deterministic-vnn' }); + signals.push({ node: 'fatigue', personId: person.id, metric: 'energy-demand', delta: 0.1, confidence: 0.65, cause: 'travel activity', provenance: 'deterministic-vnn' }); + } + if (activity.kind === 'seek-alternative') { + signals.push({ node: 'environment', personId: person.id, metric: 'access-barrier', delta: 1, confidence: 0.95, cause: 'preferred route unavailable', provenance: 'deterministic-vnn' }); + signals.push({ node: 'fatigue', personId: person.id, metric: 'energy-demand', delta: 0.2, confidence: 0.7, cause: 'rerouting burden', provenance: 'deterministic-vnn' }); + } + if (person.communication.access === 'degraded') { + signals.push({ node: 'communication', personId: person.id, metric: 'observability', delta: -0.35, confidence: 1, cause: 'AAC access degraded', provenance: 'deterministic-vnn' }); + } + if (person.communication.access === 'unavailable') { + signals.push({ node: 'communication', personId: person.id, metric: 'observability', delta: -1, confidence: 1, cause: 'communication access unavailable', provenance: 'deterministic-vnn' }); + } + + return { + personId: person.id, + activity, + signals, + invariantChecks: [ + 'person authority remains self', + 'communication access does not infer incapacity', + 'environmental barrier is not attributed to impairment', + 'VNN signals are proposals, not clinical facts', + ], + }; +} + +export function dynamicsEvents(state: PersistentWorldState, proposal: DynamicsProposal): WorldEvent[] { + return proposal.signals.map((signal, index) => ({ + id: `${state.simulationSeconds}-vnn-${proposal.personId}-${index}`, + at: state.simulationSeconds, + type: `vnn.${signal.node}.${signal.metric}`, + entityId: proposal.personId, + detail: `${signal.cause}; delta=${signal.delta}; confidence=${signal.confidence}`, + })); +} diff --git a/src/world/vrInteraction.ts b/src/world/vrInteraction.ts new file mode 100644 index 0000000..324f4da --- /dev/null +++ b/src/world/vrInteraction.ts @@ -0,0 +1,44 @@ +export type InteractionModality = 'xr-controller' | 'gaze' | 'switch' | 'keyboard' | 'touch' | 'voice' | 'companion'; +export type LocomotionMode = 'teleport' | 'smooth' | 'seated-snap-turn' | 'non-spatial'; + +export interface InteractionProfile { + modalities: InteractionModality[]; + locomotion: LocomotionMode; + reducedMotion: boolean; + dwellMilliseconds: number; + responseTimeMultiplier: number; + dominantHand?: 'left' | 'right' | 'none'; +} + +export interface WorldIntent { + actorId: string; + action: 'select' | 'move' | 'communicate' | 'inspect' | 'use' | 'pause'; + targetId?: string; + modality: InteractionModality; + payload?: unknown; +} + +export const defaultInteractionProfile: InteractionProfile = { + modalities: ['keyboard', 'touch', 'xr-controller'], + locomotion: 'teleport', + reducedMotion: false, + dwellMilliseconds: 1000, + responseTimeMultiplier: 1, +}; + +export function normalizeIntent(intent: WorldIntent): WorldIntent { + return { ...intent }; +} + +export function validateInteractionProfile(profile: InteractionProfile): string[] { + const issues: string[] = []; + if (profile.modalities.length === 0) issues.push('at least one interaction modality is required'); + if (profile.dwellMilliseconds < 0) issues.push('dwell time cannot be negative'); + if (profile.responseTimeMultiplier < 1) issues.push('response time multiplier must not shorten the user response window'); + if (profile.reducedMotion && profile.locomotion === 'smooth') issues.push('reduced-motion profile should use teleport, seated snap turn, or non-spatial locomotion'); + return issues; +} + +export function equivalentAction(a: WorldIntent, b: WorldIntent): boolean { + return a.actorId === b.actorId && a.action === b.action && a.targetId === b.targetId; +} diff --git a/src/world/worldModel.ts b/src/world/worldModel.ts new file mode 100644 index 0000000..30086c1 --- /dev/null +++ b/src/world/worldModel.ts @@ -0,0 +1,150 @@ +export type Vec3 = readonly [number, number, number]; +export type EntityKind = 'person' | 'assistive-tech' | 'infrastructure' | 'place' | 'service'; +export type AccessModality = 'walk' | 'powered-wheelchair' | 'manual-wheelchair' | 'eye-gaze' | 'switch' | 'speech' | 'touch'; + +export interface WorldEntity { + id: string; + kind: EntityKind; + label: string; + position: Vec3; + persistent: boolean; + tags: string[]; +} + +export interface PersonEntity extends WorldEntity { + kind: 'person'; + communication: { + primary: 'speech' | 'aac' | 'sign' | 'multimodal'; + access: 'available' | 'degraded' | 'unavailable'; + responseLatencySeconds: number; + }; + mobility: { + mode: AccessModality; + independent: boolean; + }; + goals: string[]; + authority: 'self'; +} + +export interface InfrastructureEntity extends WorldEntity { + kind: 'infrastructure'; + feature: 'lift' | 'ramp' | 'automatic-door' | 'crossing' | 'charging-point' | 'quiet-space'; + operational: boolean; + accessibility: AccessModality[]; +} + +export type OpenWorldEntity = WorldEntity | PersonEntity | InfrastructureEntity; + +export interface PersistentWorldState { + version: 1; + simulationSeconds: number; + entities: OpenWorldEntity[]; + events: WorldEvent[]; +} + +export interface WorldEvent { + id: string; + at: number; + type: string; + entityId?: string; + detail: string; +} + +export const initialOpenWorldState: PersistentWorldState = { + version: 1, + simulationSeconds: 0, + entities: [ + { id: 'clinical-centre', kind: 'place', label: 'Clinical Simulation Centre', position: [-24, 0, 8], persistent: true, tags: ['clinical', 'simulation'] }, + { id: 'community-hub', kind: 'place', label: 'Community Hub', position: [25, 0, 22], persistent: true, tags: ['community', 'social'] }, + { id: 'rehab-lab', kind: 'place', label: 'Rehabilitation Lab', position: [24, 0, -28], persistent: true, tags: ['rehabilitation'] }, + { id: 'transit', kind: 'place', label: 'Accessible Transit Station', position: [-25, 0, -34], persistent: true, tags: ['transport'] }, + { id: 'maya', kind: 'person', label: 'Maya', position: [5, 0, 9], persistent: true, tags: ['resident'], authority: 'self', communication: { primary: 'aac', access: 'available', responseLatencySeconds: 12 }, mobility: { mode: 'powered-wheelchair', independent: true }, goals: ['move through the community', 'communicate directly', 'participate in decisions'] }, + { id: 'station-lift', kind: 'infrastructure', label: 'Station lift', position: [-20, 0, -34], persistent: true, tags: ['transport', 'access'], feature: 'lift', operational: true, accessibility: ['powered-wheelchair', 'manual-wheelchair', 'walk'] }, + { id: 'aac-charge', kind: 'infrastructure', label: 'AAC and mobility charging point', position: [20, 0, 20], persistent: true, tags: ['power', 'access'], feature: 'charging-point', operational: true, accessibility: ['powered-wheelchair', 'manual-wheelchair', 'touch'] } + ], + events: [] +}; + +export class PersistentWorldStore { + private state: PersistentWorldState; + + constructor(seed: PersistentWorldState = initialOpenWorldState) { + this.state = JSON.parse(JSON.stringify(seed)) as PersistentWorldState; + this.restore(); + } + + snapshot(): PersistentWorldState { return JSON.parse(JSON.stringify(this.state)) as PersistentWorldState; } + entity(id: string) { return this.state.entities.find((entity) => entity.id === id); } + + tick(seconds: number) { + this.state.simulationSeconds += Math.max(0, seconds); + this.persist(); + } + + move(id: string, position: Vec3) { + const entity = this.entity(id); + if (!entity) return; + entity.position = position; + this.persist(); + } + + upsertEntity(entity: OpenWorldEntity) { + const index = this.state.entities.findIndex((item) => item.id === entity.id); + if (index >= 0) this.state.entities[index] = JSON.parse(JSON.stringify(entity)) as OpenWorldEntity; + else this.state.entities.push(JSON.parse(JSON.stringify(entity)) as OpenWorldEntity); + this.persist(); + } + + setInfrastructure(id: string, operational: boolean) { + const entity = this.entity(id); + if (!entity || entity.kind !== 'infrastructure') return; + (entity as InfrastructureEntity).operational = operational; + this.record('infrastructure.changed', id, `${entity.label} ${operational ? 'operational' : 'unavailable'}`); + } + + setCommunicationAccess(id: string, access: PersonEntity['communication']['access']) { + const entity = this.entity(id); + if (!entity || entity.kind !== 'person') return; + (entity as PersonEntity).communication.access = access; + this.record('communication.access.changed', id, `${entity.label} communication access: ${access}`); + } + + appendEvents(events: WorldEvent[]) { + this.state.events.push(...events); + this.state.events = this.state.events.slice(-250); + this.persist(); + } + + recordEvent(type: string, entityId: string | undefined, detail: string) { + const id = `${this.state.simulationSeconds}-${this.state.events.length}-${type}`; + const event: WorldEvent = { id, at: this.state.simulationSeconds, type, detail, ...(entityId ? { entityId } : {}) }; + this.appendEvents([event]); + return event; + } + + reset(seed: PersistentWorldState = initialOpenWorldState) { + this.state = JSON.parse(JSON.stringify(seed)) as PersistentWorldState; + this.persist(); + } + + private record(type: string, entityId: string, detail: string) { this.recordEvent(type, entityId, detail); } + + private persist() { + if (typeof window !== 'undefined') window.localStorage?.setItem('mert-open-world-v1', JSON.stringify(this.state)); + } + + private restore() { + if (typeof window === 'undefined') return; + const saved = window.localStorage?.getItem('mert-open-world-v1'); + if (!saved) return; + try { this.state = JSON.parse(saved) as PersistentWorldState; } catch { /* retain safe seed */ } + } +} + +export function accessAssessment(person: PersonEntity, infrastructure: InfrastructureEntity) { + const modalitySupported = infrastructure.accessibility.includes(person.mobility.mode); + return { + pass: infrastructure.operational && modalitySupported, + cause: !infrastructure.operational ? 'environmental infrastructure unavailable' : modalitySupported ? 'accessible' : 'mobility modality not supported' + }; +}