diff --git a/README.md b/README.md index 8fe85ab..2b2d717 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,47 @@ # MERT Engine -Cross-platform disability-inclusive medical simulation runtime for web, iOS and Android. +Cross-platform disability-inclusive medical simulation runtime for web, iOS and Android, with a Babylon.js/WebXR persistent open world. ## Architecture status -### Step 1 — Typed protocol + patient/world state -- Shared TypeScript contracts for physiology, AAC, authority, access, systems, uncertainty and evidence. -- Typed `SimulationEvent` transport. - -### Step 2 — Simulation kernel + safety invariants -- Central `SimulationKernel` owns world state, event audit and simulation time. -- AAC composition can pause simulation time. -- Protected invariants prevent communication failure from being converted into loss of self-authority. - -### Step 3 — React/React Native as view-controller -- UI sends semantic scenario events and renders snapshots. -- UI does not own clinical/world truth. - -### Step 4 — Scenario Engine -- `DeclarativeScenarioEngine` owns branch progression, delayed consequences and node state. - -### Step 5 — Declarative Scenario Rules -- Case logic is represented as data: trigger → conditions → delay → effects → transition. -- Rules can request domain-model dynamics instead of directly hard-coding every consequence. - -### Step 6 — World-State / VNN Dynamics Modules -- `VNNDynamicsCoordinator` routes `dynamics.requested` events to registered modules. -- Included example modules: - - `respiratory-dynamics` - - `access-dynamics` -- Dynamics return proposals with confidence, rationale and provenance before world-state events are committed through the kernel. - -## Core invariant +### Steps 1-6 — Simulation kernel +- Typed physiology, AAC, authority, access, systems, uncertainty and evidence state. +- Central `SimulationKernel` with audit history and simulation-clock control. +- React/React Native is view-controller only; it does not own clinical/world truth. +- Declarative scenario rules: trigger -> conditions -> delay -> effects -> transition. +- VNN dynamics modules return proposals with confidence, rationale and provenance before commit. + +### Steps 7-20 — Persistent disability world +- Persistent people, places, infrastructure, relationships and schedules. +- Affordance-based autonomy and environmental attribution of access barriers. +- Social agents with bounded knowledge and direct-first communication safeguards. +- Semantic interaction parity across WebXR controller, gaze, touch, keyboard and switch-style input. +- Counterfactual timeline and world -> clinical bridge. + +### Steps 21-24 — Accessible routes, objects and continuity +- Accessibility-weighted route planning across paths, ramps, crossings, lifts and transport. +- Stateful doors, lift motion, AAC charging and explicit transport boarding. +- Proximity + relationship social encounters where support is requested rather than assumed. +- World -> ambulance -> ED -> ICU -> MERT -> discharge -> community continuity. + +### Steps 25-28 — Procedural city runtime +- Deterministic procedural city with residential, community, health, education, employment and transport districts. +- Generated triangulated navmesh geometry with A* pathfinding and person-specific access constraints. +- Persistent homes with power/charging state plus an accessible city transport service with explicit capacity allocation and boarding. +- Autonomous emergency-service dispatch linked only to an active evidence-gated ambulance continuity stage. +- Babylon/WebXR rendering for city buildings, roads, navmesh overlay, accessible transport and emergency vehicles. + +## Core invariants ```text communication failure != incapacity disability != acute deterioration model proposal != clinical truth equipment availability != indication +friend/support worker != substitute authority +route failure != person failure +transport allocation != consent to board +emergency dispatch != treatment inference ``` ## Running @@ -59,15 +63,8 @@ The resulting `dist/` directory is configured for Vercel through `vercel.json`. ## Testing -The UI tests use React Native Testing Library v14 interaction patterns and query the interface by accessible roles and labels rather than implementation details. - -The runtime tests cover: -- AAC restoration without authority transfer -- simulation-clock pause during AAC composition -- delayed declarative deterioration -- VNN dynamics invocation -- protected authority invariants +The UI tests use React Native Testing Library interaction patterns and query the interface through accessible roles and names. Runtime tests cover authority preservation, AAC access, simulation timing, declarative deterioration, VNN invocation, persistent world state, route barriers, object state, social support non-assumption, clinical continuity, procedural-city determinism, navmesh generation, transport allocation and emergency dispatch gating. ## Scope -MERT Engine is educational simulation software. Scenario physiology and dynamics are authored educational models, not real-patient prediction, diagnosis, treatment guidance or device-control logic. +MERT Engine is educational simulation software. Scenario physiology, accessibility thresholds, route weights, vehicle speeds and emergency-response timings are authored simulation parameters, not real-patient prediction, diagnosis, treatment guidance, device-control logic or statutory accessibility standards. diff --git a/__tests__/worldSteps25to28.test.ts b/__tests__/worldSteps25to28.test.ts new file mode 100644 index 0000000..cb4c801 --- /dev/null +++ b/__tests__/worldSteps25to28.test.ts @@ -0,0 +1,59 @@ +import { initialOpenWorldState, PersistentWorldStore, type PersonEntity } from '../src/world/worldModel'; +import { applyProceduralCity, generateProceduralCity } from '../src/world/proceduralCity'; +import { buildCityNavMesh, findNavMeshPath } from '../src/world/navmeshGeometry'; +import { HomeTransportEngine } from '../src/world/homeTransport'; +import { ContinuityEngine } from '../src/world/continuity'; +import { EmergencyResponseEngine } from '../src/world/emergencyServices'; + +describe('Steps 25-28 procedural disability city', () => { + test('procedural city is deterministic and preserves person authority', () => { + const first = generateProceduralCity(42); + const second = generateProceduralCity(42); + expect(first.buildings.map(item => item.id)).toEqual(second.buildings.map(item => item.id)); + const store = new PersistentWorldStore(initialOpenWorldState); + applyProceduralCity(store, first); + const maya = store.entity('maya') as PersonEntity; + expect(maya.authority).toBe('self'); + expect(store.entity('home-maya')?.kind).toBe('place'); + expect(store.entity('hospital-main')?.kind).toBe('place'); + }); + + test('generates a triangulated navmesh and an accessible path', () => { + const plan = generateProceduralCity(42); + const store = new PersistentWorldStore(initialOpenWorldState); + applyProceduralCity(store, plan); + const mesh = buildCityNavMesh(plan, 10); + const maya = store.entity('maya') as PersonEntity; + const path = findNavMeshPath(mesh, store.snapshot(), maya, maya.position, [0,0,90]); + expect(mesh.triangles.length).toBeGreaterThan(100); + expect(path?.triangleIds.length).toBeGreaterThan(1); + expect(path?.cost).toBeGreaterThan(0); + }); + + test('homes and transport allocate accessible capacity without assuming support', () => { + const plan = generateProceduralCity(42); + const store = new PersistentWorldStore(initialOpenWorldState); + applyProceduralCity(store, plan); + const services = new HomeTransportEngine(store, plan); + expect(services.homeForPerson('maya')?.id).toBe('home-maya'); + const trip = services.requestTrip('maya', 'community-hub', 'hospital-main'); + expect(trip?.status).toBe('allocated'); + expect(trip?.supportRequested).toBe(false); + expect(store.entity('maya')?.kind === 'person' && (store.entity('maya') as PersonEntity).authority).toBe('self'); + }); + + test('autonomous emergency response starts only from an active ambulance continuity stage', () => { + const plan = generateProceduralCity(42); + const store = new PersistentWorldStore(initialOpenWorldState); + applyProceduralCity(store, plan); + const continuity = new ContinuityEngine(store); + const ems = new EmergencyResponseEngine(store, plan, continuity); + expect(ems.dispatchFromContinuity('maya', 'no episode')).toBe(false); + const maya = store.entity('maya') as PersonEntity; + continuity.start(maya, 'educational deterioration'); + expect(ems.dispatchFromContinuity('maya', 'evidence-gated educational deterioration')).toBe(true); + ems.step(1); + expect(ems.snapshot().activeResponses[0]?.status).toMatch(/en-route|on-scene/); + expect((store.entity('maya') as PersonEntity).authority).toBe('self'); + }); +}); diff --git a/src/world/OpenWorldWeb.tsx b/src/world/OpenWorldWeb.tsx index d435bd5..e1be04f 100644 --- a/src/world/OpenWorldWeb.tsx +++ b/src/world/OpenWorldWeb.tsx @@ -6,73 +6,239 @@ import { WOLFRAM_ROUTE_MODEL } from './accessibleNavigation'; import { WOLFRAM_OBJECT_MODELS } from './objectInteractions'; import { WOLFRAM_SOCIAL_MODEL } from './socialEncounters'; import { WOLFRAM_CONTINUITY_MODEL } from './continuity'; +import { createCityVisuals, type CityVisualController } from './cityRendering'; export function OpenWorldWeb() { const canvasRef = useRef(null); const storeRef = useRef(null); const runtimeRef = useRef(null); + const cityVisualRef = useRef(null); + const tripRef = useRef(null); if (!storeRef.current) storeRef.current = new PersistentWorldStore(); - 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'); - - useEffect(()=>{ - let disposed=false,engine:any,scene:any,routeMesh:any; - const store=storeRef.current!; - const runtime=new WorldRuntime(store); - runtimeRef.current=runtime; - - const boot=async()=>{ - const B=await import('@babylonjs/core'); + 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('NAVMESH · calculating'); + const [encounters, setEncounters] = useState('SOCIAL ENCOUNTERS · scanning'); + const [continuity, setContinuity] = useState('CONTINUITY · community'); + const [city, setCity] = useState('CITY · generating'); + const [services, setServices] = useState('HOMES + TRANSPORT · starting'); + const [ems, setEms] = useState('EMS · available'); + const [navVisible, setNavVisible] = useState(false); + + useEffect(() => { + let disposed = false; + let engine: any; + let scene: any; + let routeMesh: any; + const store = storeRef.current!; + const runtime = new WorldRuntime(store); + runtimeRef.current = runtime; + + 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);} - - 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); + 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 = .55; + 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 = (name: string, r: number, g: number, b: number) => { + const value = new B.StandardMaterial(name, scene); + value.diffuseColor = new B.Color3(r, g, b); + return value; + }; + + const ground = B.MeshBuilder.CreateGround('city-ground', { width: 560, height: 560 }, scene); + ground.checkCollisions = true; + ground.material = material('ground', .38, .55, .35); + const centralPath = B.MeshBuilder.CreateGround('central-accessible-path', { width: 12, height: 190 }, scene); + centralPath.position.y = .015; + centralPath.material = material('central-path', .62, .61, .57); + + const buildingMat = material('core-building', .72, .76, .78); + const makeBuilding = (label: string, x: number, z: number, width: number, depth: number, height: number) => { + const body = B.MeshBuilder.CreateBox(label, { width, depth, height }, scene); + body.position.set(x, height / 2, z); + body.material = buildingMat; + body.checkCollisions = true; + }; + 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); + + const cityVisuals = createCityVisuals(B, scene, runtime, material); + cityVisualRef.current = cityVisuals; + + const personMat = material('people', .22, .32, .55); + const personMeshes = new Map(); + const createPerson = (person: PersonEntity) => { + if (personMeshes.has(person.id)) return; + const body = B.MeshBuilder.CreateCapsule(person.id, { height: 1.35, radius: .3 }, scene); + body.position.set(person.position[0], 1.05, person.position[2]); + body.material = personMat; + personMeshes.set(person.id, body); + if (person.mobility.mode.includes('wheelchair')) { + const mobility = B.MeshBuilder.CreateBox(`${person.id}-mobility`, { width: .85, height: .55, depth: 1.05 }, scene); + mobility.position.set(person.position[0], .38, person.position[2]); + mobility.material = personMat; + personMeshes.set(`${person.id}-mobility`, mobility); + } + }; + for (const entity of store.snapshot().entities) if (entity.kind === 'person') createPerson(entity as PersonEntity); + + let last = performance.now(); + let uiLast = 0; + let lastRouteKey = ''; + scene.onBeforeRenderObservable.add(() => { + const now = performance.now(); + const elapsed = Math.max(0, (now - last) / 1000); + last = now; + const frame = runtime.step(elapsed); + const snapshot = store.snapshot(); + cityVisuals.update(frame); + + for (const entity of snapshot.entities) { + if (entity.kind !== 'person') continue; + const person = entity as PersonEntity; + createPerson(person); + const body = personMeshes.get(person.id); + const mobility = personMeshes.get(`${person.id}-mobility`); + if (body) { body.position.x = person.position[0]; body.position.z = person.position[2]; } + if (mobility) { mobility.position.x = person.position[0]; mobility.position.z = person.position[2]; } + } + + const mayaRoute = frame.navMeshRoutes.maya; + const routeKey = mayaRoute?.triangleIds.join('>') ?? ''; + if (routeKey !== lastRouteKey) { + lastRouteKey = routeKey; + routeMesh?.dispose?.(); + routeMesh = undefined; + if (mayaRoute && mayaRoute.points.length > 1) { + routeMesh = B.MeshBuilder.CreateLines('maya-city-route', { points: mayaRoute.points.map(point => new B.Vector3(point[0], .12, point[2])) }, scene); + } + } + + if (now - uiLast <= 1000) return; + uiLast = now; + const maya = store.entity('maya') as PersonEntity | undefined; + const lift = store.entity('station-lift') as InfrastructureEntity | undefined; + if (maya && lift) { + const assessment = accessAssessment(maya, lift); + setAccess(`TRANSIT ACCESS: ${assessment.pass ? 'AVAILABLE' : 'BARRIER'} · ${assessment.cause}`); + const close = nearbyEntities(snapshot, 'maya', 8); + setNearby(`NEARBY MAYA · ${close.length ? close.map(item => item.entity.label).join(' · ') : 'none within 8m'}`); + } + const activity = frame.activities.find(item => item.personId === 'maya'); + setLife(activity ? `MAYA · ${activity.kind.toUpperCase()} · ${activity.rationale}` : 'MAYA · NO ACTIVE PLAN'); + const bridge = frame.clinical.find(item => item.personId === 'maya'); + setClinical(`CLINICAL BRIDGE · ${(bridge?.status ?? 'continue-world').toUpperCase().replace(/-/g, ' ')}`); + setRoute(mayaRoute ? `CITY NAVMESH · ${mayaRoute.triangleIds.length} triangles · cost ${mayaRoute.cost.toFixed(1)} · ${mayaRoute.points.length} waypoints` : 'CITY NAVMESH · no viable route represented'); + setEncounters(frame.encounters.length ? `ENCOUNTERS · ${frame.encounters.map(item => `${item.a}↔${item.b} ${item.status} ${item.score.toFixed(2)}${item.supportRequested ? ' support-requested' : ''}`).join(' · ')}` : 'ENCOUNTERS · none above threshold'); + setContinuity(`CONTINUITY · ${frame.continuity.maya ?? 'community'}`); + setCity(`CITY · seed ${runtime.cityPlan().seed} · ${runtime.cityPlan().districts.length} districts · ${runtime.cityPlan().buildings.length} buildings · navmesh ${runtime.navMesh().triangles.length} triangles`); + const vehicle = frame.services.vehicles[0]; + const home = frame.services.homes.find(item => item.residentIds.includes('maya')); + const trip = frame.services.trips.find(item => item.id === tripRef.current); + setServices(`HOME + TRANSPORT · Maya home power ${home?.mainsPower ? 'ON' : 'OFF'} · shuttle ${vehicle?.status ?? 'unknown'} · trip ${trip?.status ?? 'none'}`); + const response = frame.emergency.activeResponses.find(item => item.personId === 'maya'); + const unit = response ? frame.emergency.units.find(item => item.id === response.unitId) : undefined; + setEms(response ? `EMS · ${response.unitId} ${response.status} · position ${unit?.position[0].toFixed(0)},${unit?.position[2].toFixed(0)}` : 'EMS · units available · no active Maya response'); + }); + + try { + const helper = await scene.createDefaultXRExperienceAsync({ floorMeshes: [ground, centralPath], disableTeleportation: false }); + setXr(helper.baseExperience ? 'VR READY · controller, gaze and teleport supported' : 'VR unavailable'); + } catch { + setXr('WebXR not available on this browser/device'); + } + setStatus('PROCEDURAL DISABILITY CITY 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?.();routeMesh?.dispose?.();scene?.dispose?.();engine?.dispose?.();}; - },[]); - - 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.
; + + void boot(); + return () => { + disposed = true; + scene?.__cleanup?.(); + routeMesh?.dispose?.(); + cityVisualRef.current?.dispose(); + cityVisualRef.current = null; + scene?.dispose?.(); + engine?.dispose?.(); + }; + }, []); + + const runtime = () => runtimeRef.current!; + const interactNearest = () => { + const store = storeRef.current!; + const state = store.snapshot(); + const 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'); + const result = evaluateSpatialIntent(state, intent); + store.recordEvent('world.intent', 'maya', `${intent.action} ${target.entity.label}: ${result.reason}`); + setStatus(`INTERACTION · ${target.entity.label} · ${result.allowed ? 'AVAILABLE' : 'BLOCKED'} · ${result.reason}`); + }; + const requestCityTrip = () => { + const trip = runtime().requestCityTrip('maya', 'community-hub', 'hospital-main'); + tripRef.current = trip?.id ?? null; + setStatus(`CITY TRIP · ${trip?.status ?? 'not created'} · ${trip?.reason ?? 'person unavailable'}`); + }; + const boardCityTrip = () => { + const id = tripRef.current; + if (!id) { setStatus('CITY TRIP · request a trip first'); return; } + setStatus(runtime().boardCityTrip(id) ? 'CITY TRIP · boarded by explicit action' : 'CITY TRIP · boarding unavailable at current stop/location'); + }; + const beginContinuity = () => { runtime().beginContinuity('maya'); setStatus('CONTINUITY + EMS · educational community deterioration episode started'); }; + const advanceContinuity = () => { runtime().advanceContinuity('maya'); setStatus(`CONTINUITY · ${runtime().continuity().summary('maya')}`); }; + const requestSupport = () => { runtime().requestSupport('maya', 'liam'); setStatus('SUPPORT · Maya explicitly requested support from Liam'); }; + const toggleLift = () => { const store = storeRef.current!; const lift = store.entity('station-lift') as InfrastructureEntity; store.setInfrastructure('station-lift', !lift.operational); }; + const toggleAAC = () => { const store = storeRef.current!; const maya = store.entity('maya') as PersonEntity; store.setCommunicationAccess('maya', maya.communication.access === 'available' ? 'degraded' : 'available'); }; + const toggleNavmesh = () => { const visible = cityVisualRef.current?.toggleNavMesh() ?? false; setNavVisible(visible); setStatus(`NAVMESH OVERLAY · ${visible ? 'ON' : 'OFF'}`); }; + const resetWorld = () => { storeRef.current!.reset(); setStatus('WORLD RESET · reload to reseed city and population'); }; + + return
+ +
+ MERT · PROCEDURAL DISABILITY CITY · STEPS 25–28
+ {status}
{xr}
{city}
{access}
{life}
{route}
{services}
{ems}
{nearby}
{encounters}
{clinical}
{continuity} +
+ + + + + + + + + + +
+
+
+ Simulation computation layer
Route weighting retains {WOLFRAM_ROUTE_MODEL.formula}. Lift: {WOLFRAM_OBJECT_MODELS.liftSmoothstep}. Charger: {WOLFRAM_OBJECT_MODELS.charger}. Social: {WOLFRAM_SOCIAL_MODEL.formula}. Continuity graph remains acyclic with {WOLFRAM_CONTINUITY_MODEL.unexpectedDeadEnds} unexpected dead ends. Procedural-city gradients, speeds and timings are simulation parameters, not statutory accessibility standards or clinical treatment guidance. +
+
; } diff --git a/src/world/cityRendering.ts b/src/world/cityRendering.ts new file mode 100644 index 0000000..aa52a14 --- /dev/null +++ b/src/world/cityRendering.ts @@ -0,0 +1,15 @@ +import type { LiveWorldFrame, WorldRuntime } from './liveWorldLoop'; + +export interface CityVisualController { update(frame:LiveWorldFrame):void; toggleNavMesh():boolean; dispose():void; } + +export function createCityVisuals(B:any,scene:any,runtime:WorldRuntime,material:(name:string,r:number,g:number,b:number)=>any):CityVisualController { + const plan=runtime.cityPlan(),navMesh=runtime.navMesh(); + const cityMat=material('procedural-city-buildings',.62,.68,.72),homeMat=material('procedural-city-homes',.55,.66,.62),roadMat=material('procedural-city-roads',.22,.25,.27),transportMat=material('city-transport',.22,.48,.66),emsMat=material('city-ems',.72,.76,.8),activeEmsMat=material('city-ems-active',.78,.38,.3); + const disposable:any[]=[]; + for(const road of plan.roads){const dx=road.to[0]-road.from[0],dz=road.to[2]-road.from[2],length=Math.hypot(dx,dz);const mesh=B.MeshBuilder.CreateGround(road.id,{width:road.width,height:length},scene);mesh.position.set((road.from[0]+road.to[0])/2,.025,(road.from[2]+road.to[2])/2);mesh.rotation.y=Math.atan2(dx,dz);mesh.material=roadMat;disposable.push(mesh);} + for(const building of plan.buildings){const mesh=B.MeshBuilder.CreateBox(`city-${building.id}`,{width:building.width,depth:building.depth,height:building.height},scene);mesh.position.set(building.position[0],building.height/2,building.position[2]);mesh.material=building.kind==='home'?homeMat:cityMat;mesh.checkCollisions=true;mesh.metadata={worldEntityId:building.id,kind:building.kind};disposable.push(mesh);} + const navVisual=new B.Mesh('city-navmesh',scene);const positions:number[]=[],indices:number[]=[];let cursor=0;for(const triangle of navMesh.triangles){for(const v of triangle.vertices)positions.push(v[0],.08,v[2]);indices.push(cursor,cursor+1,cursor+2);cursor+=3;}const vertexData=new B.VertexData();vertexData.positions=positions;vertexData.indices=indices;vertexData.applyToMesh(navVisual);const navMat=material('city-navmesh-material',.1,.55,.68);navMat.alpha=.18;navMat.wireframe=true;navVisual.material=navMat;navVisual.isVisible=false;navVisual.isPickable=false;disposable.push(navVisual); + const transportMesh=B.MeshBuilder.CreateBox('city-access-shuttle-visual',{width:3.2,height:2.8,depth:7.2},scene);transportMesh.material=transportMat;disposable.push(transportMesh);const unitMeshes=new Map();for(const unit of runtime.emergencyEngine().snapshot().units){const mesh=B.MeshBuilder.CreateBox(`${unit.id}-visual`,{width:2.6,height:2.5,depth:5.5},scene);mesh.position.set(unit.position[0],1.25,unit.position[2]);mesh.material=emsMat;unitMeshes.set(unit.id,mesh);disposable.push(mesh);} + const toggle=()=>{navVisual.isVisible=!navVisual.isVisible;return navVisual.isVisible;};const eventHandler=()=>toggle();if(typeof document!=='undefined')document.addEventListener('mert-toggle-navmesh',eventHandler); + return{update(frame){const vehicle=frame.services.vehicles.find(item=>item.id==='city-access-shuttle');if(vehicle)transportMesh.position.set(vehicle.position[0],1.4,vehicle.position[2]);for(const unit of frame.emergency.units){const mesh=unitMeshes.get(unit.id);if(mesh){mesh.position.set(unit.position[0],1.25,unit.position[2]);mesh.material=unit.status==='available'?emsMat:activeEmsMat;}}},toggleNavMesh:toggle,dispose(){if(typeof document!=='undefined')document.removeEventListener('mert-toggle-navmesh',eventHandler);for(const item of disposable)item.dispose?.();}}; +} diff --git a/src/world/emergencyServices.ts b/src/world/emergencyServices.ts new file mode 100644 index 0000000..cc4f5e0 --- /dev/null +++ b/src/world/emergencyServices.ts @@ -0,0 +1,85 @@ +import type { PersonEntity, PersistentWorldStore, Vec3 } from './worldModel'; +import type { ProceduralCityPlan } from './proceduralCity'; +import type { ContinuityEngine } from './continuity'; + +export type EmergencyUnitStatus='available'|'en-route'|'on-scene'|'transporting'|'at-hospital'|'clearing'; + +export interface EmergencyUnitState { + id:string; + label:string; + position:Vec3; + status:EmergencyUnitStatus; + assignedPersonId?:string; + target:Vec3; + speedMetresPerSecond:number; + sceneElapsedSeconds:number; +} + +export interface EmergencyResponseSnapshot { + units:EmergencyUnitState[]; + activeResponses:Array<{personId:string;unitId:string;status:EmergencyUnitStatus;reason:string}>; +} + +const clone=(value:T):T=>JSON.parse(JSON.stringify(value)) as T; + +export class EmergencyResponseEngine { + private units=new Map(); + private reasons=new Map(); + + constructor(private store:PersistentWorldStore,private city:ProceduralCityPlan,private continuity:ContinuityEngine){ + const base=this.city.buildings.find(building=>building.id==='ems-base')?.position??[135,0,-45] as Vec3; + this.units.set('ambulance-1',{id:'ambulance-1',label:'Ambulance 1',position:base,status:'available',target:base,speedMetresPerSecond:14,sceneElapsedSeconds:0}); + this.units.set('ambulance-2',{id:'ambulance-2',label:'Ambulance 2',position:[base[0]+8,base[1],base[2]+4],status:'available',target:base,speedMetresPerSecond:13,sceneElapsedSeconds:0}); + this.store.recordEvent('ems.ready',undefined,`units=${this.units.size}; autonomous dispatch follows evidence-gated continuity episodes`); + } + + snapshot():EmergencyResponseSnapshot { + const units=[...this.units.values()].map(clone); + const activeResponses=units.flatMap(unit=>unit.assignedPersonId?[{personId:unit.assignedPersonId,unitId:unit.id,status:unit.status,reason:this.reasons.get(unit.assignedPersonId)??'unspecified simulation trigger'}]:[]); + return {units,activeResponses}; + } + + dispatchFromContinuity(personId:string,reason:string){ + const episode=this.continuity.episode(personId);if(!episode||episode.stage!=='ambulance')return false; + if([...this.units.values()].some(unit=>unit.assignedPersonId===personId&&unit.status!=='available'))return true; + const entity=this.store.entity(personId);if(!entity||entity.kind!=='person')return false; + const person=entity as PersonEntity; + const available=[...this.units.values()].filter(unit=>unit.status==='available').sort((a,b)=>distance(a.position,person.position)-distance(b.position,person.position))[0]; + if(!available){this.store.recordEvent('ems.dispatch.unavailable',personId,'no emergency unit currently available; continuity episode preserved');return false;} + available.assignedPersonId=personId;available.status='en-route';available.target=person.position;available.sceneElapsedSeconds=0;this.reasons.set(personId,reason); + this.store.recordEvent('ems.dispatched',personId,`${available.id}; reason=${reason}; communication and authority context preserved from world state`);return true; + } + + step(seconds:number){ + const hospital=this.city.buildings.find(building=>building.id==='hospital-main')?.position??[135,0,-135] as Vec3; + const base=this.city.buildings.find(building=>building.id==='ems-base')?.position??[135,0,-45] as Vec3; + for(const unit of this.units.values()){ + if(unit.status==='available')continue; + const personId=unit.assignedPersonId; + if(unit.status==='en-route'&&personId){ + const person=this.store.entity(personId);if(person)unit.target=person.position; + if(this.move(unit,seconds)){unit.status='on-scene';unit.sceneElapsedSeconds=0;this.store.recordEvent('ems.on-scene',personId,`${unit.id}; no treatment inference generated by dispatch engine`);}continue; + } + if(unit.status==='on-scene'&&personId){ + unit.sceneElapsedSeconds+=seconds; + if(unit.sceneElapsedSeconds>=20){unit.status='transporting';unit.target=hospital;this.store.recordEvent('ems.transport.started',personId,`${unit.id}->hospital-main; continuity context retained`);}continue; + } + if(unit.status==='transporting'&&personId){ + unit.target=hospital; + if(this.move(unit,seconds)){unit.status='at-hospital';this.continuity.advance(personId,'ed','autonomous emergency transport arrived at ED');this.store.recordEvent('ems.hospital-arrival',personId,`${unit.id}; handover to ED continuity stage`);}continue; + } + if(unit.status==='at-hospital'){unit.sceneElapsedSeconds+=seconds;if(unit.sceneElapsedSeconds>=35){unit.status='clearing';unit.target=base;unit.sceneElapsedSeconds=0;}continue;} + if(unit.status==='clearing'){ + if(this.move(unit,seconds)){if(personId)this.reasons.delete(personId);unit.status='available';delete unit.assignedPersonId;unit.target=base;this.store.recordEvent('ems.unit.available',undefined,`${unit.id} returned to service`);} + } + } + } + + private move(unit:EmergencyUnitState,seconds:number){ + const [x,y,z]=unit.position,[tx,,tz]=unit.target,dx=tx-x,dz=tz-z,d=Math.hypot(dx,dz); + if(d<1){unit.position=unit.target;return true;} + const amount=Math.min(d,unit.speedMetresPerSecond*seconds);unit.position=[x+dx/d*amount,y,z+dz/d*amount];return amount>=d-.001; + } +} + +const distance=(a:Vec3,b:Vec3)=>Math.hypot(a[0]-b[0],a[2]-b[2]); diff --git a/src/world/homeTransport.ts b/src/world/homeTransport.ts new file mode 100644 index 0000000..4d5db23 --- /dev/null +++ b/src/world/homeTransport.ts @@ -0,0 +1,15 @@ +import type{PersonEntity,PersistentWorldStore,Vec3}from'./worldModel';import type{CityBuilding,ProceduralCityPlan}from'./proceduralCity'; +export interface HomeState{id:string;residentIds:string[];entryOperational:boolean;mainsPower:boolean;backupPower:boolean;chargingAvailable:boolean;quietSpace:boolean;} +export interface TransportVehicleState{id:string;label:string;position:Vec3;capacity:number;wheelchairSpaces:number;passengers:string[];reservedWheelchairSpaces:string[];route:string[];currentStopIndex:number;nextStopIndex:number;dwellSeconds:number;status:'dwelling'|'moving'|'out-of-service';speedMetresPerSecond:number;} +export interface TransportTripRequest{id:string;personId:string;originId:string;destinationId:string;status:'requested'|'allocated'|'boarded'|'completed'|'unavailable';vehicleId?:string;reason:string;supportRequested:boolean;} +export interface HomeTransportSnapshot{homes:HomeState[];vehicles:TransportVehicleState[];trips:TransportTripRequest[];} +const clone=(v:T):T=>JSON.parse(JSON.stringify(v))as T;function buildingPosition(plan:ProceduralCityPlan,id:string):Vec3|undefined{return plan.buildings.find(b=>b.id===id)?.position;} +export class HomeTransportEngine{private homes=new Map();private vehicles=new Map();private trips=new Map();private requestCounter=0; + constructor(private store:PersistentWorldStore,private plan:ProceduralCityPlan){for(const b of plan.buildings.filter(i=>i.kind==='home'))this.homes.set(b.id,this.homeFromBuilding(b));const route=['home-maya','community-hub','city-transport-hub','hospital-main','employment-centre','home-maya'];this.vehicles.set('city-access-shuttle',{id:'city-access-shuttle',label:'City accessible shuttle',position:buildingPosition(plan,'city-transport-hub')??[-180,0,-180],capacity:8,wheelchairSpaces:3,passengers:[],reservedWheelchairSpaces:[],route,currentStopIndex:2,nextStopIndex:3,dwellSeconds:12,status:'dwelling',speedMetresPerSecond:9});this.store.recordEvent('services.home-transport.ready',undefined,`homes=${this.homes.size}; vehicles=${this.vehicles.size}`);} + snapshot():HomeTransportSnapshot{return{homes:[...this.homes.values()].map(clone),vehicles:[...this.vehicles.values()].map(clone),trips:[...this.trips.values()].map(clone)};}homeForPerson(personId:string){return[...this.homes.values()].find(h=>h.residentIds.includes(personId));} + setHomePower(homeId:string,mainsPower:boolean){const h=this.homes.get(homeId);if(!h)return false;h.mainsPower=mainsPower;this.store.recordEvent('home.power.changed',homeId,`mains=${mainsPower}; backup=${h.backupPower}; charging=${h.chargingAvailable}`);return true;} + requestTrip(personId:string,originId:string,destinationId:string):TransportTripRequest|undefined{const e=this.store.entity(personId);if(!e||e.kind!=='person')return undefined;const p=e as PersonEntity,v=[...this.vehicles.values()].find(i=>i.status!=='out-of-service'),id=`trip-${++this.requestCounter}`;if(!v){const t:TransportTripRequest={id,personId,originId,destinationId,status:'unavailable',reason:'no represented transport vehicle available; preserve travel goal',supportRequested:false};this.trips.set(id,t);return clone(t);}const wc=p.mobility.mode==='powered-wheelchair'||p.mobility.mode==='manual-wheelchair',wcAvailable=v.reservedWheelchairSpaces.length${destinationId}; vehicle=${v.id}; support not assumed`);return clone(t);} + requestSupport(tripId:string){const t=this.trips.get(tripId);if(!t)return false;t.supportRequested=true;this.store.recordEvent('transport.support.requested',t.personId,`trip=${tripId}; explicit request`);return true;} + board(tripId:string){const t=this.trips.get(tripId);if(!t||t.status!=='allocated'||!t.vehicleId)return false;const v=this.vehicles.get(t.vehicleId);if(!v||v.status!=='dwelling')return false;const currentStop=v.route[v.currentStopIndex];if(currentStop!==t.originId)return false;const p=this.store.entity(t.personId);const stop=this.stopPosition(currentStop);if(!p||!stop||Math.hypot(p.position[0]-stop[0],p.position[2]-stop[2])>6){this.store.recordEvent('transport.boarding.wait',t.personId,`trip=${tripId}; person is not at boarding stop; intent preserved`);return false;}if(!v.passengers.includes(t.personId))v.passengers.push(t.personId);t.status='boarded';this.store.recordEvent('transport.boarded',t.personId,`vehicle=${v.id}; destination=${t.destinationId}; explicit boarding action`);return true;} + step(seconds:number){for(const v of this.vehicles.values()){if(v.status==='out-of-service')continue;if(v.status==='dwelling'){v.dwellSeconds-=seconds;if(v.dwellSeconds<=0){v.status='moving';v.nextStopIndex=(v.currentStopIndex+1)%v.route.length;}continue;}const targetId=v.route[v.nextStopIndex];if(!targetId)continue;const target=this.stopPosition(targetId);if(!target)continue;const[x,y,z]=v.position,dx=target[0]-x,dz=target[2]-z,d=Math.hypot(dx,dz);if(d<1){v.position=target;v.currentStopIndex=v.nextStopIndex;v.status='dwelling';v.dwellSeconds=12;this.completeTripsAtStop(v,targetId);continue;}const amount=Math.min(d,v.speedMetresPerSecond*seconds);v.position=[x+dx/d*amount,y,z+dz/d*amount];}} + private stopPosition(id:string):Vec3|undefined{return this.store.entity(id)?.position??buildingPosition(this.plan,id);}private completeTripsAtStop(v:TransportVehicleState,stopId:string){for(const t of this.trips.values())if(t.vehicleId===v.id&&t.status==='boarded'&&t.destinationId===stopId){t.status='completed';v.passengers=v.passengers.filter(id=>id!==t.personId);v.reservedWheelchairSpaces=v.reservedWheelchairSpaces.filter(id=>id!==t.personId);this.store.recordEvent('transport.trip.completed',t.personId,`arrived=${stopId}`);}}private homeFromBuilding(b:CityBuilding):HomeState{return{id:b.id,residentIds:[...b.residentIds],entryOperational:true,mainsPower:true,backupPower:b.access.charging,chargingAvailable:b.access.charging,quietSpace:b.access.quietSpace};}} diff --git a/src/world/liveWorldLoop.ts b/src/world/liveWorldLoop.ts index 2394bd3..0a7dd61 100644 --- a/src/world/liveWorldLoop.ts +++ b/src/world/liveWorldLoop.ts @@ -8,6 +8,10 @@ import { defaultNavigationNetwork, routeForPerson, type RouteResult } from './ac import { ObjectInteractionEngine, type ObjectWorldSnapshot } from './objectInteractions'; import { SocialEncounterEngine, type SocialEncounter } from './socialEncounters'; import { ContinuityEngine, type ContinuityStage } from './continuity'; +import { applyProceduralCity, generateProceduralCity, type ProceduralCityPlan } from './proceduralCity'; +import { buildCityNavMesh, findNavMeshPath, type CityNavMesh, type NavMeshPath } from './navmeshGeometry'; +import { HomeTransportEngine, type HomeTransportSnapshot, type TransportTripRequest } from './homeTransport'; +import { EmergencyResponseEngine, type EmergencyResponseSnapshot } from './emergencyServices'; import type { PersonEntity, PersistentWorldState, PersistentWorldStore, Vec3 } from './worldModel'; export interface LiveWorldFrame { @@ -15,9 +19,12 @@ export interface LiveWorldFrame { proposals: DynamicsProposal[]; clinical: ReturnType[]; routes: Record; + navMeshRoutes: Record; objects: ObjectWorldSnapshot; encounters: SocialEncounter[]; continuity: Record; + services: HomeTransportSnapshot; + emergency: EmergencyResponseSnapshot; } export class WorldRuntime { @@ -26,24 +33,43 @@ export class WorldRuntime { 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:{}}; + private cityPlanData:ProceduralCityPlan; + private navMeshData:CityNavMesh; + private homeTransport:HomeTransportEngine; + private emergencyResponse:EmergencyResponseEngine; + private lastFrame:LiveWorldFrame; constructor(private store:PersistentWorldStore,private clinicalRuntime?:ClinicalRuntimePort){ ensurePopulation(store); + this.cityPlanData=generateProceduralCity(); + applyProceduralCity(store,this.cityPlanData); + this.navMeshData=buildCityNavMesh(this.cityPlanData); this.objects=new ObjectInteractionEngine(store); this.continuityEngine=new ContinuityEngine(store); + this.homeTransport=new HomeTransportEngine(store,this.cityPlanData); + this.emergencyResponse=new EmergencyResponseEngine(store,this.cityPlanData,this.continuityEngine); + this.lastFrame={ + activities:[],proposals:[],clinical:[],routes:{},navMeshRoutes:{}, + objects:this.objects.snapshot(),encounters:[],continuity:{},services:this.homeTransport.snapshot(),emergency:this.emergencyResponse.snapshot(), + }; } snapshot(){return this.lastFrame;} objectEngine(){return this.objects;} continuity(){return this.continuityEngine;} + cityPlan(){return this.cityPlanData;} + navMesh(){return this.navMeshData;} + serviceEngine(){return this.homeTransport;} + emergencyEngine(){return this.emergencyResponse;} step(realSeconds:number):LiveWorldFrame{ this.store.tick(realSeconds); this.objects.step(realSeconds); + this.homeTransport.step(realSeconds); + this.emergencyResponse.step(realSeconds); this.accumulator+=realSeconds; this.socialAccumulator+=realSeconds; - if(this.accumulator<2)return {...this.lastFrame,objects:this.objects.snapshot()}; + if(this.accumulator<2)return {...this.lastFrame,objects:this.objects.snapshot(),services:this.homeTransport.snapshot(),emergency:this.emergencyResponse.snapshot()}; const stepSeconds=this.accumulator; this.accumulator=0; @@ -57,12 +83,16 @@ export class WorldRuntime { this.store.appendEvents(proposals.flatMap(p=>dynamicsEvents(state,p))); const routes:Record={}; + const navMeshRoutes: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); + const target=state.entities.find(entity=>entity.id===activity.targetId); + const navRoute=target?findNavMeshPath(this.navMeshData,state,person,person.position,target.position):undefined; + navMeshRoutes[person.id]=navRoute; + this.advanceActivity(state,activity,route,navRoute,stepSeconds); } if(this.socialAccumulator>=15){ @@ -82,41 +112,50 @@ export class WorldRuntime { 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(person&&!this.continuityEngine.episode(person.id)){ + this.continuityEngine.start(person,assessment.reasons.join('; ')); + this.emergencyResponse.dispatchFromContinuity(person.id,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}; + this.lastFrame={activities,proposals,clinical,routes,navMeshRoutes,objects:this.objects.snapshot(),encounters:encounterResult.encounters,continuity,services:this.homeTransport.snapshot(),emergency:this.emergencyResponse.snapshot()}; 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`);} + requestCityTrip(personId:string,originId:string,destinationId:string):TransportTripRequest|undefined{return this.homeTransport.requestTrip(personId,originId,destinationId);} + boardCityTrip(tripId:string){return this.homeTransport.board(tripId);} 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; + const person=this.store.entity(personId);if(!person||person.kind!=='person')return false; + this.continuityEngine.start(person as PersonEntity,reason); + this.emergencyResponse.dispatchFromContinuity(personId,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){ + private advanceActivity(state:PersistentWorldState,activity:ReturnType[number],route:RouteResult|undefined,navRoute:NavMeshPath|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;} + if(!route&&!navRoute){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]; + const points=navRoute?.points??route?.points??[]; + const remaining=points.filter(point=>Math.hypot(point[0]-current[0],point[2]-current[2])>1.2); + const waypoint=remaining[0]??points[points.length-1]; if(!waypoint)return; - const waypointIndex=route.points.indexOf(waypoint); - const waypointId=route.nodeIds[waypointIndex]; + if(route){ + const legacyWaypoint=route.points.find(point=>Math.hypot(point[0]-current[0],point[2]-current[2])>1.2); + if(legacyWaypoint){const waypointIndex=route.points.indexOf(legacyWaypoint),waypointId=route.nodeIds[waypointIndex],distance=Math.hypot(legacyWaypoint[0]-current[0],legacyWaypoint[2]-current[2]);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);} + } + 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); diff --git a/src/world/navmeshGeometry.ts b/src/world/navmeshGeometry.ts new file mode 100644 index 0000000..25aa4b4 --- /dev/null +++ b/src/world/navmeshGeometry.ts @@ -0,0 +1,135 @@ +import type { AccessModality, PersonEntity, PersistentWorldState, Vec3 } from './worldModel'; +import type { ProceduralCityPlan } from './proceduralCity'; + +export interface NavTriangle { + id: string; + vertices: readonly [Vec3,Vec3,Vec3]; + centroid: Vec3; + neighbours: string[]; + gradient: number; + surfacePenalty: number; + access: AccessModality[]; +} + +export interface CityNavMesh { + cellSize: number; + triangles: NavTriangle[]; + bounds: ProceduralCityPlan['bounds']; +} + +export interface NavMeshPath { + triangleIds: string[]; + points: Vec3[]; + cost: number; +} + +function pointSegmentDistance(x:number,z:number,a:Vec3,b:Vec3) { + const dx=b[0]-a[0],dz=b[2]-a[2]; + const len2=dx*dx+dz*dz; + if(len2===0)return Math.hypot(x-a[0],z-a[2]); + const t=Math.max(0,Math.min(1,((x-a[0])*dx+(z-a[2])*dz)/len2)); + return Math.hypot(x-(a[0]+dx*t),z-(a[2]+dz*t)); +} + +function insideBuilding(x:number,z:number,plan:ProceduralCityPlan) { + return plan.buildings.some(building=>Math.abs(x-building.position[0])({road,d:pointSegmentDistance(x,z,road.from,road.to)})) + .filter(item=>item.d<=item.road.walkableWidth/2) + .sort((a,b)=>a.d-b.d)[0]?.road; +} + +const vertexKey=(v:Vec3)=>`${v[0].toFixed(3)},${v[2].toFixed(3)}`; +const edgeKeys=(triangle:NavTriangle)=>{ + const [a,b,c]=triangle.vertices; + return [[a,b],[b,c],[c,a]].map(pair=>pair.map(vertexKey).sort().join('|')); +}; + +export function buildCityNavMesh(plan:ProceduralCityPlan,cellSize=8):CityNavMesh { + const triangles:NavTriangle[]=[]; + let index=0; + for(let x=plan.bounds.minX;x({ + id:`nav-${index++}`,vertices, + centroid:[(vertices[0][0]+vertices[1][0]+vertices[2][0])/3,0,(vertices[0][2]+vertices[1][2]+vertices[2][2])/3], + neighbours:[],gradient,surfacePenalty,access, + }); + triangles.push(mk([v00,v10,v11]),mk([v00,v11,v01])); + } + } + + const edges=new Map(); + for(const triangle of triangles)for(const key of edgeKeys(triangle))edges.set(key,[...(edges.get(key)??[]),triangle.id]); + const byId=new Map(triangles.map(t=>[t.id,t])); + for(const ids of edges.values())if(ids.length===2){const a=byId.get(ids[0]!),b=byId.get(ids[1]!);if(a&&b){a.neighbours.push(b.id);b.neighbours.push(a.id);}} + + // Stitch adjacent cells around road intersections where triangulation may meet only at a vertex. + for(const triangle of triangles){ + for(const other of triangles){ + if(triangle.id===other.id||triangle.neighbours.includes(other.id))continue; + const d=Math.hypot(triangle.centroid[0]-other.centroid[0],triangle.centroid[2]-other.centroid[2]); + if(d<=cellSize*.82)triangle.neighbours.push(other.id); + } + } + return {cellSize,triangles,bounds:plan.bounds}; +} + +function nearestTriangle(mesh:CityNavMesh,point:Vec3,person:PersonEntity){ + return mesh.triangles + .filter(t=>t.access.includes(person.mobility.mode)) + .sort((a,b)=>Math.hypot(a.centroid[0]-point[0],a.centroid[2]-point[2])-Math.hypot(b.centroid[0]-point[0],b.centroid[2]-point[2]))[0]; +} + +function triangleCost(a:NavTriangle,b:NavTriangle,person:PersonEntity){ + const maxGradient=person.mobility.mode==='manual-wheelchair'?.07:person.mobility.mode==='powered-wheelchair'?.09:.16; + if(Math.abs(b.gradient)>maxGradient||!b.access.includes(person.mobility.mode))return Infinity; + const distance=Math.hypot(a.centroid[0]-b.centroid[0],a.centroid[2]-b.centroid[2]); + return distance*(1+4*Math.abs(b.gradient)+b.surfacePenalty); +} + +export function findNavMeshPath(mesh:CityNavMesh,state:PersistentWorldState,person:PersonEntity,start:Vec3,end:Vec3):NavMeshPath|undefined { + void state; // reserved for dynamic triangle blockers and future crowd/closure costs. + const startTri=nearestTriangle(mesh,start,person),goalTri=nearestTriangle(mesh,end,person); + if(!startTri||!goalTri)return undefined; + const byId=new Map(mesh.triangles.map(t=>[t.id,t])); + const open=new Set([startTri.id]); + const came=new Map(); + const g=new Map([[startTri.id,0]]); + const f=new Map([[startTri.id,Math.hypot(startTri.centroid[0]-goalTri.centroid[0],startTri.centroid[2]-goalTri.centroid[2])]]); + + while(open.size){ + const current=[...open].sort((a,b)=>(f.get(a)??Infinity)-(f.get(b)??Infinity))[0]; + if(!current)break; + if(current===goalTri.id){ + const ids=[current];let cursor=current; + while(came.has(cursor)){cursor=came.get(cursor)!;ids.unshift(cursor);} + const points:Vec3[]=[start,...ids.slice(1,-1).map(id=>byId.get(id)!.centroid),end]; + return {triangleIds:ids,points,cost:g.get(current)??0}; + } + open.delete(current); + const tri=byId.get(current);if(!tri)continue; + for(const neighbourId of tri.neighbours){ + const neighbour=byId.get(neighbourId);if(!neighbour)continue; + const edge=triangleCost(tri,neighbour,person);if(!Number.isFinite(edge))continue; + const tentative=(g.get(current)??Infinity)+edge; + if(tentative<(g.get(neighbourId)??Infinity)){ + came.set(neighbourId,current);g.set(neighbourId,tentative); + const h=Math.hypot(neighbour.centroid[0]-goalTri.centroid[0],neighbour.centroid[2]-goalTri.centroid[2]); + f.set(neighbourId,tentative+h);open.add(neighbourId); + } + } + } + return undefined; +} diff --git a/src/world/proceduralCity.ts b/src/world/proceduralCity.ts new file mode 100644 index 0000000..777b121 --- /dev/null +++ b/src/world/proceduralCity.ts @@ -0,0 +1,47 @@ +import type { InfrastructureEntity, OpenWorldEntity, PersistentWorldStore, Vec3, WorldEntity } from './worldModel'; + +export type CityDistrictKind = 'residential' | 'community' | 'health' | 'education' | 'employment' | 'transport'; +export type CityBuildingKind = 'home' | 'community' | 'hospital' | 'clinic' | 'education' | 'workplace' | 'retail' | 'emergency-base' | 'transport-hub'; + +export interface CityDistrict { id:string; label:string; kind:CityDistrictKind; centre:Vec3; radius:number; } +export interface CityRoad { id:string; from:Vec3; to:Vec3; width:number; walkableWidth:number; gradient:number; surfacePenalty:number; } +export interface CityBuilding { + id:string; label:string; kind:CityBuildingKind; position:Vec3; width:number; depth:number; height:number; districtId:string; residentIds:string[]; + access:{ automaticDoor:boolean; stepFreeEntry:boolean; charging:boolean; quietSpace:boolean; }; +} +export interface ProceduralCityPlan { seed:number; bounds:{minX:number;maxX:number;minZ:number;maxZ:number}; districts:CityDistrict[]; roads:CityRoad[]; buildings:CityBuilding[]; } + +function rng(seed:number){let s=seed>>>0;return()=>{s=(Math.imul(s,1664525)+1013904223)>>>0;return s/4294967296;};} +function road(id:string,from:Vec3,to:Vec3):CityRoad{return{id,from,to,width:14,walkableWidth:10,gradient:.02,surfacePenalty:.02};} + +export function generateProceduralCity(seed=20260824):ProceduralCityPlan{ + const random=rng(seed); + const districts:CityDistrict[]=[ + {id:'district-west-residential',label:'West Residential',kind:'residential',centre:[-135,0,45],radius:85}, + {id:'district-east-residential',label:'East Residential',kind:'residential',centre:[135,0,45],radius:85}, + {id:'district-community',label:'Community and Civic',kind:'community',centre:[0,0,90],radius:90}, + {id:'district-health',label:'Health and Emergency',kind:'health',centre:[135,0,-135],radius:90}, + {id:'district-learning',label:'Learning and Work',kind:'education',centre:[-90,0,-135],radius:100}, + {id:'district-transport',label:'Transport Exchange',kind:'transport',centre:[-180,0,-180],radius:70}, + ]; + const roads:CityRoad[]=[];for(const z of[-180,-90,0,90,180])roads.push(road(`road-h-${z}`,[-230,0,z],[230,0,z]));for(const x of[-180,-90,0,90,180])roads.push(road(`road-v-${x}`,[x,0,-230],[x,0,230])); + const buildings:CityBuilding[]=[ + {id:'home-maya',label:'Maya home',kind:'home',position:[-135,0,45],width:22,depth:20,height:7,districtId:'district-west-residential',residentIds:['maya'],access:{automaticDoor:true,stepFreeEntry:true,charging:true,quietSpace:true}}, + {id:'home-liam',label:'Liam home',kind:'home',position:[-45,0,45],width:20,depth:18,height:7,districtId:'district-west-residential',residentIds:['liam'],access:{automaticDoor:true,stepFreeEntry:true,charging:false,quietSpace:true}}, + {id:'home-rohan',label:'Rohan home',kind:'home',position:[135,0,45],width:20,depth:18,height:7,districtId:'district-east-residential',residentIds:['rohan'],access:{automaticDoor:false,stepFreeEntry:true,charging:false,quietSpace:true}}, + {id:'home-aisha',label:'Aisha home',kind:'home',position:[135,0,135],width:20,depth:18,height:7,districtId:'district-east-residential',residentIds:['aisha'],access:{automaticDoor:true,stepFreeEntry:true,charging:false,quietSpace:true}}, + {id:'hospital-main',label:'Metropolitan Teaching Hospital',kind:'hospital',position:[135,0,-135],width:58,depth:42,height:18,districtId:'district-health',residentIds:[],access:{automaticDoor:true,stepFreeEntry:true,charging:true,quietSpace:true}}, + {id:'ems-base',label:'Emergency Response Base',kind:'emergency-base',position:[135,0,-45],width:34,depth:28,height:9,districtId:'district-health',residentIds:[],access:{automaticDoor:true,stepFreeEntry:true,charging:true,quietSpace:false}}, + {id:'learning-centre',label:'Inclusive Learning Centre',kind:'education',position:[-135,0,-135],width:42,depth:34,height:11,districtId:'district-learning',residentIds:[],access:{automaticDoor:true,stepFreeEntry:true,charging:true,quietSpace:true}}, + {id:'employment-centre',label:'Employment and Enterprise Centre',kind:'workplace',position:[-45,0,-135],width:42,depth:34,height:11,districtId:'district-learning',residentIds:[],access:{automaticDoor:true,stepFreeEntry:true,charging:true,quietSpace:true}}, + {id:'city-market',label:'Community Market',kind:'retail',position:[45,0,135],width:38,depth:30,height:8,districtId:'district-community',residentIds:[],access:{automaticDoor:true,stepFreeEntry:true,charging:false,quietSpace:false}}, + {id:'city-transport-hub',label:'City Mobility Exchange',kind:'transport-hub',position:[-180,0,-180],width:44,depth:34,height:10,districtId:'district-transport',residentIds:[],access:{automaticDoor:true,stepFreeEntry:true,charging:true,quietSpace:true}}, + ]; + const blockCentres:Array<[number,number]>=[[-135,135],[-45,135],[-135,-45],[45,-45],[45,-135]]; + for(let i=0;i.72?'retail':'home';buildings.push({id:`generated-${i}`,label:kind==='home'?`Accessible residence ${i+1}`:`Neighbourhood service ${i+1}`,kind,position:[x+(random()-.5)*18,0,z+(random()-.5)*18],width:18+random()*8,depth:16+random()*8,height:6+random()*5,districtId:x<0?'district-west-residential':'district-east-residential',residentIds:[],access:{automaticDoor:random()>.2,stepFreeEntry:true,charging:random()>.55,quietSpace:random()>.5}});} + return{seed,bounds:{minX:-250,maxX:250,minZ:-250,maxZ:250},districts,roads,buildings}; +} + +function buildingEntity(building:CityBuilding):WorldEntity{const tags=['city-building',building.kind,`district:${building.districtId}`];for(const resident of building.residentIds)tags.push(`resident:${resident}`);return{id:building.id,kind:'place',label:building.label,position:building.position,persistent:true,tags};} +function entranceEntity(building:CityBuilding):InfrastructureEntity{return{id:`${building.id}-entry`,kind:'infrastructure',label:`${building.label} entrance`,position:[building.position[0],0,building.position[2]-building.depth/2-2],persistent:true,tags:['city-entry',building.kind],feature:building.access.automaticDoor?'automatic-door':'ramp',operational:true,accessibility:['walk','powered-wheelchair','manual-wheelchair','touch']};} +export function applyProceduralCity(store:PersistentWorldStore,plan:ProceduralCityPlan){for(const building of plan.buildings){if(!store.entity(building.id))store.upsertEntity(buildingEntity(building) as OpenWorldEntity);const entry=entranceEntity(building);if(!store.entity(entry.id))store.upsertEntity(entry);if(building.access.charging){const charge:InfrastructureEntity={id:`${building.id}-charge`,kind:'infrastructure',label:`${building.label} accessible charging`,position:[building.position[0]+4,0,building.position[2]+4],persistent:true,tags:['city-charge',building.kind],feature:'charging-point',operational:true,accessibility:['powered-wheelchair','manual-wheelchair','touch']};if(!store.entity(charge.id))store.upsertEntity(charge);}}store.recordEvent('city.generated',undefined,`seed=${plan.seed}; districts=${plan.districts.length}; roads=${plan.roads.length}; buildings=${plan.buildings.length}`);}