diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index fb9ec65..99680b6 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -33,15 +33,15 @@ efd1af2bcdd0939bda2432e6823f09135f6d8345796e8a6f9690886b0b104b05 site/assets/wo 27eaa1da60c9ae26c54512c6b986e2d568f5ec9a968d6a46d3d785f9117e5e4e site/cinematic-world.css 6c2c82fb1de7d8a0051edaa1e5f5711fc8881865ad8638fa0c41a709dce54696 site/data/sovereign-genesis.json 0670d7be3b270c8e627c5f0089e25ba065c3326f6bcf0f06ce9ae5cf3c5cf64b site/icon.svg -a2c950a45727b3527c5541f0347c74fa422ed103e073a2dabef6df37dfc7b6c0 site/index.html +37edda29fd3d7dc28535024733ec4e770cf16b8e2e8dc1ceeda73d1c1bc154b2 site/index.html a31f15ff86c87f9eafb1723fb3fc59186f27fb32a75b00d287fc7efbdeec5b1c site/medieval-art.js 49897b8d946b85c84a4e0f177a3c7773a0c6f9acfef0025d308ccec2ac3a7ef1 site/medieval-world.css 82197a81454575b5c941ec0c52924c0a53f1d92bf70371e9c16df0912a29dcf0 site/observer-connection.js -73d7b00031479ba5d4a2093567c4d9628b50cf3e9a8a6c4be646ad3d73497bd4 site/pixi-observer.js +f57bf13a527ada7c6c792760f6ab976412df64a4a125f305be7e86dda6a32cbf site/pixi-observer.js 5edab258856c44d89ea1df51472037697db95909c2d696607ef7c30a503a4838 site/preview.png -513b703d86b97ed70136c2c1a050f5810f89f14cfba8ec340fd055639413cbad site/sovereign-renderer.js +772ee873b012f6ddf0f36b79c8c95b737c1a45b4362f0b997afcb7a68b77b233 site/sovereign-renderer.js 59d83fee261298ef4c90a650ab3b350d4533ab61e7ad3f315e45cdd83443de74 site/sovereign-world.css -572984f1295862408b6e810619583bbb866ab3130daecbdd968c3865f863d9dd site/sovereign-world.js +d639481c2258cfabfaa97bead83e2009cffa3a5535fca4c8c14729dade54fce4 site/sovereign-world.js aaa5a63cab5926e0355e8257bd562ec2db4c8890904ef1894767063925ceadf1 site/terrain-model.js c98fca62534d141f285a6c92bfd6257c26d462a3daff72672a4d76f2399d1f21 site/transient-physics.js d179b80fb34aae0db437dfa72c1a6e6704209a8119b8d991ad49321d8584da8f tests/browser-art.mjs @@ -116,3 +116,9 @@ fba432ea0721d0c250d7b91aa9df1c914219740354dcd941f92ea65354d00a96 world/terrain. e56357ae087309b86d670288998026568373b7268778157b38612fb38d81eba7 worker/src/persistence.js 279876578ed24eade882ad165e12f0952f1ac985d4b22065f24067dd720c886e tests/test_sovereign_persistence_budget.mjs 1219637898a43e4d623897ea15f1ff495554b4b6f2279e6e2466eba072e7b59e tests/test_sovereign_ledger_compaction.mjs +9942697150f9cc3be949b4869f67c678731db377e284602df4faef8bc6c4fb5e site/observer-concepts.js +ab0d9a1e3aa3efbc09f241543d9a0714a196d997849049e62fb0a22eae73b1c9 site/citizen-animation.js +dab555575cd5a79e1e1be63a155033788d40995f6b5c69b3b82fe87132233009 site/spine-citizen-adapter.js +4fd200df9a9fc14a16eb12445665f4dfd58777fdba8426da4f00ca99b21d8fbe docs/SPINE_OBSERVER_INTEGRATION.md +164c56738f05469e36d0cb3c913656154381bda6a177505d5d2f06d87d1665ad tests/test_sovereign_observer_readability_upgrade.mjs +98d88f126a1bca9443485b2cbc2f4614dbc975f9f96d0dfb72ed37465e2c3e56 tests/test_sovereign_citizen_animation_upgrade.mjs diff --git a/docs/SPINE_OBSERVER_INTEGRATION.md b/docs/SPINE_OBSERVER_INTEGRATION.md new file mode 100644 index 0000000..cd2437d --- /dev/null +++ b/docs/SPINE_OBSERVER_INTEGRATION.md @@ -0,0 +1,42 @@ +# Optional Spine Citizen renderer + +CYMONIA's canonical world does not depend on animation middleware. Citizen animation is an Observer concern: canonical actions select a visual state, while the browser renderer may never create or modify world state. + +## Current integration + +The public MIT repository contains an action-to-animation contract, improved native PixiJS procedural Citizen motion, and `site/spine-citizen-adapter.js`, an optional adapter for a licensed Spine PixiJS v8 runtime and exported Citizen skeleton assets. The native renderer remains the automatic fallback. + +No Spine Runtime source, package, CDN script, skeleton export, atlas, or proprietary asset is included or enabled by default. + +## Licensing boundary + +Spine Runtimes are governed by the Spine Runtimes License, not CYMONIA's MIT license. A valid Spine license is required when integrating or distributing the runtime under Esoteric Software's published terms. Keep the optional adapter disabled unless the project owner has satisfied those terms. + +## Enabling a licensed rig + +Configure the Observer before `sovereign-world.js` starts: + +```js +window.CYMONIA_SPINE = { + Spine: LicensedSpineConstructor, + skeletonAlias: 'citizen-skeleton', + atlasAlias: 'citizen-atlas', + skeletonUrl: '/assets/licensed/citizen.skel', + atlasUrl: '/assets/licensed/citizen.atlas', + scale: 0.22, + animations: { + idle: 'idle', + walk: 'walk', + gather: 'gather', + build: 'build', + sleep: 'sleep', + communicate: 'communicate', + attack: 'attack', + defend: 'defend' + } +}; +``` + +The adapter preloads the aliases through `PIXI.Assets`, creates one Spine container per living Citizen, and maps only canonical `currentAction.type` values to animation tracks. Missing assets or animations fall back safely. + +A useful first rig should export at least `idle`, `walk`, `gather`, `build`, `sleep`, and `communicate`. Skins may represent Observer-side interpretations of canonical clothing, equipment and injuries, but they must never write back into the world. diff --git a/site/citizen-animation.js b/site/citizen-animation.js new file mode 100644 index 0000000..b1b5b23 --- /dev/null +++ b/site/citizen-animation.js @@ -0,0 +1,38 @@ +const ACTION_ANIMATION={ + MOVE:'walk',OBSERVE:'observe',REST:'idle',SLEEP:'sleep',EAT:'eat',DRINK:'drink', + GATHER:'gather',CARRY:'carry',CUT:'cut',DIG:'dig',HEAT:'work',COOL:'work',MIX:'work', + ASSEMBLE:'build',BUILD:'build',CARE:'care',TEACH:'communicate',COMMUNICATE:'communicate', + EXPERIMENT:'experiment',ATTACK:'attack',DEFEND:'defend',TRANSFER:'carry', + PROMISE:'communicate',CLAIM:'communicate',REPRODUCE:'idle', +}; +function hashUnit(value){ + let h=2166136261>>>0; + for(const ch of String(value||'')){h^=ch.charCodeAt(0);h=Math.imul(h,16777619)>>>0;} + return(h>>>0)/4294967295; +} +export function animationForCitizen(citizen){ + return ACTION_ANIMATION[citizen?.currentAction?.type]||'idle'; +} +export function citizenVisualPose(citizen,nowMs=Date.now()){ + const action=animationForCitizen(citizen); + const phase=Number(nowMs)/180+hashUnit(citizen?.id)*Math.PI*2; + const wave=Math.sin(phase),pulse=Math.sin(phase*.5); + let y=0,rotation=0,scaleX=1,scaleY=1,shadowScale=1; + if(action==='walk'){ + y=-Math.abs(wave)*1.6;rotation=wave*.035;scaleX=1+Math.abs(wave)*.025; + scaleY=1-Math.abs(wave)*.03;shadowScale=1-Math.abs(wave)*.08; + }else if(['gather','cut','dig','build','work'].includes(action)){ + y=Math.abs(wave)*.55;rotation=wave*.065;scaleY=1-Math.abs(wave)*.025;shadowScale=1.03; + }else if(action==='carry'){ + y=-Math.abs(wave)*.7;rotation=wave*.018;scaleY=.98; + }else if(action==='sleep'){ + y=2.2;rotation=-.11;scaleX=1.05;scaleY=.86;shadowScale=1.18; + }else if(['communicate','care','observe','experiment'].includes(action)){ + y=pulse*.25;rotation=wave*.025;scaleY=1+pulse*.015; + }else if(['attack','defend'].includes(action)){ + y=-Math.abs(wave)*.7;rotation=wave*.09;scaleX=1+Math.abs(wave)*.04;scaleY=.97; + }else{ + y=pulse*.18;scaleX=1-pulse*.006;scaleY=1+pulse*.012; + } + return {animation:action,y,rotation,scaleX,scaleY,shadowScale}; +} diff --git a/site/index.html b/site/index.html index 3fbe096..4a98e26 100644 --- a/site/index.html +++ b/site/index.html @@ -20,7 +20,7 @@
CYMONIASOVEREIGN WORLD
CONNECTINGCONNECTING
- —PEOPLE + —PEOPLE —STRUCTURES —GROUPS —CLIMATE @@ -46,6 +46,7 @@ + @@ -64,7 +65,7 @@
- +
CONNECTING TO CANONICAL WORLD
diff --git a/site/observer-concepts.js b/site/observer-concepts.js new file mode 100644 index 0000000..7101796 --- /dev/null +++ b/site/observer-concepts.js @@ -0,0 +1,93 @@ +const RESOURCE_LABELS={ + water:'Water source', + food:'Edible resource', + timber:'Timber source', + stone:'Stone deposit', + clay:'Clay deposit', + ore:'Ore deposit', +}; + +const titleCase=value=>String(value||'').replace(/[_:-]+/g,' ').replace(/\b\w/g,m=>m.toUpperCase()).trim(); +const clean=value=>String(value||'').replace(/\s+/g,' ').trim(); + +function provenanceRows(entry){ + return Array.isArray(entry?.provenance)?entry.provenance.filter(Boolean):[]; +} +function provenanceEntityId(entry){ + for(const p of provenanceRows(entry)){ + const id=p?.entityId||p?.evidence?.entityId||p?.sourceEntityId; + if(id)return String(id); + } + return null; +} +function findEntity(world,id){ + if(!world||!id)return null; + const resource=(world.resourceDeposits||[]).find(x=>x.id===id); + if(resource)return {kind:'resource',value:resource}; + const building=(world.buildings||[]).find(x=>x.id===id); + if(building)return {kind:'building',value:building}; + const project=(world.projects||[]).find(x=>x.id===id); + if(project)return {kind:'project',value:project}; + const object=(world.objects||[]).find(x=>x.id===id); + if(object)return {kind:'object',value:object}; + return null; +} +function entityLabel(entity){ + if(!entity)return null; + const value=entity.value||{}; + if(entity.kind==='resource')return RESOURCE_LABELS[value.type]||`${titleCase(value.type||'Natural')} resource`; + if(entity.kind==='building'){ + const form=value.form||value.kind||value.type; + return form?`${titleCase(form)} structure`:'Constructed structure'; + } + if(entity.kind==='project'){ + const form=value.form||value.kind||value.type; + return form?`${titleCase(form)} construction project`:'Construction project'; + } + if(entity.kind==='object'){ + const material=titleCase(value.material); + const kind=titleCase(value.kind||value.type||'object'); + return [material,kind].filter(Boolean).join(' ')||'Physical object'; + } + return null; +} +function evidenceLabel(entry){ + for(const p of provenanceRows(entry)){ + const evidence=p?.evidence||{}; + const appearance=clean(evidence.appearance); + if(appearance)return `Observed: ${appearance.slice(0,72)}`; + const touch=clean(evidence.touch); + if(touch)return `Observed material: ${touch.slice(0,64)}`; + if(p?.kind==='communication')return 'Shared concept'; + if(p?.kind==='teaching')return 'Taught concept'; + if(p?.kind==='experiment')return 'Experimental concept'; + } + return null; +} + +export function observerConceptView(world,citizen,entry={}){ + const canonicalId=String(entry?.concept||'unknown'); + const token=citizen?.language?.lexicon?.[canonicalId]||null; + const entityId=provenanceEntityId(entry); + const entity=findEntity(world,entityId); + let label=entityLabel(entity)||evidenceLabel(entry); + if(!label&&canonicalId.startsWith('kstruct:'))label='Observed structure'; + if(!label&&token)label=`Shared concept “${token}”`; + if(!label)label='Unknown observed concept'; + + const confidence=Number.isFinite(Number(entry?.confidence))?Number(entry.confidence):null; + const kinds=[...new Set(provenanceRows(entry).map(p=>p?.kind).filter(Boolean))]; + const tooltip=[ + `Observer interpretation: ${label}`, + `Canonical concept: ${canonicalId}`, + confidence===null?null:`Confidence: ${Math.round(confidence*100)}%`, + kinds.length?`Learned by: ${kinds.join(', ')}`:null, + entityId?`Entity: ${entityId}`:null, + token?`Citizen word: ${token}`:null, + ].filter(Boolean).join(' · '); + + return {label,canonicalId,confidence,token,entityId,sourceKinds:kinds,tooltip}; +} +export function observerConceptLabel(world,citizen,entry){ + return observerConceptView(world,citizen,entry).label; +} diff --git a/site/pixi-observer.js b/site/pixi-observer.js index 52f75b0..e7d4e27 100644 --- a/site/pixi-observer.js +++ b/site/pixi-observer.js @@ -1,6 +1,8 @@ import {terrainAtPublic,terrainDecoration,terrainPalette} from './terrain-model.js'; import {TransientMatterEffects} from './transient-physics.js'; import {isoPoint,sceneEntries,staticSceneKey,citizenFrame,artHash} from './medieval-art.js'; +import {citizenVisualPose} from './citizen-animation.js'; +import {SpineCitizenAdapter} from './spine-citizen-adapter.js'; const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); function renderWorldMinute(state,nowMs=Date.now()){const raw=state?.clock?.realEpochMs,epoch=Number(raw),canonical=Number(state?.clock?.worldMinute)||0;return raw!=null&&Number.isFinite(epoch)?Math.max(canonical,(nowMs-epoch)/1000):canonical;} @@ -14,7 +16,7 @@ export class PixiObserverLayer{ constructor(canvasFallback,art){ this.canvasFallback=canvasFallback;this.art=art;this.app=null;this.ready=false;this.failed=false;this.state=null;this.staticKey='';this.hits=[];this.citizenSprites=new Map();this.frames=[];this.groundSource=null;this.staticEntries=[]; // Logical object layers share a depth-sorted container, so trees can occlude people correctly. - this.vegetationLayer=[];this.resourceLayer=[];this.structureLayer=[];this.citizenLayer=null; + this.vegetationLayer=[];this.resourceLayer=[];this.structureLayer=[];this.citizenLayer=null;this.spineAdapter=null; this.init(); } async init(){ @@ -32,6 +34,8 @@ export class PixiObserverLayer{ if(!this.art.atlas)throw new Error('Art atlas unavailable'); const source=PIXI.Texture.from(this.art.atlas).source; this.frames=this.art.frames.map(f=>new PIXI.Texture({source,frame:new PIXI.Rectangle(f.x,f.y,f.w,f.h)})); + this.spineAdapter=new SpineCitizenAdapter(PIXI,globalThis.CYMONIA_SPINE); + await this.spineAdapter.preload(); this.ready=true; }catch(error){console.warn('Pixi observer unavailable; Canvas fallback remains active.',error);this.app?.destroy(true,{children:true});this.app=null;this.failed=true;} } @@ -60,9 +64,17 @@ export class PixiObserverLayer{ } ensureCitizens(){ const PIXI=globalThis.PIXI,alive=new Set(); - for(const c of this.state.citizens||[]){if(!c.alive)continue;alive.add(c.id);if(this.citizenSprites.has(c.id))continue; - const container=new PIXI.Container(),body=this.sprite(citizenFrame(c),19),shadow=new PIXI.Graphics();shadow.ellipse(1,1,6,2.8).fill({color:0x1d281c,alpha:.22}); - const task=new PIXI.Text({text:'',style:{fontFamily:'Georgia',fontSize:10,fill:0xf8e8b5,stroke:{color:0x283021,width:2}}});task.anchor.set(.5);task.position.set(0,-41);container.addChild(shadow,body,task);this.citizenLayer.addChild(container);this.citizenSprites.set(c.id,{container,body,task}); + for(const c of this.state.citizens||[]){ + if(!c.alive)continue;alive.add(c.id);if(this.citizenSprites.has(c.id))continue; + let entry=this.spineAdapter?.create(c)||null; + if(!entry){ + const container=new PIXI.Container(),body=this.sprite(citizenFrame(c),19),shadow=new PIXI.Graphics(); + shadow.ellipse(1,1,6,2.8).fill({color:0x1d281c,alpha:.22}); + const task=new PIXI.Text({text:'',style:{fontFamily:'Georgia',fontSize:10,fill:0xf8e8b5,stroke:{color:0x283021,width:2}}}); + task.anchor.set(.5);task.position.set(0,-41);container.addChild(shadow,body,task); + entry={container,body,task,shadow,baseScale:Math.abs(body.scale.x),spine:false}; + } + this.citizenLayer.addChild(entry.container);this.citizenSprites.set(c.id,entry); } for(const [id,e] of this.citizenSprites)if(!alive.has(id)){e.container.destroy({children:true});this.citizenSprites.delete(id);} } @@ -75,8 +87,16 @@ export class PixiObserverLayer{ const hits=[]; for(const e of this.staticEntries){if(!e.id)continue;const t=terrainAtPublic(state,e.position.x,e.position.y),p=this.screenPoint(e.position.x,e.position.y,t.elevation,camera);hits.push({...this.art.hitRecord(e,p,camera.zoom),depth:point(e.position.x,e.position.y,t.elevation).y});} for(const c of state.citizens||[]){if(!c.alive)continue;const e=this.citizenSprites.get(c.id),pos=citizenPosition(c,state),t=terrainAtPublic(state,pos.x,pos.y),p=point(pos.x,pos.y,t.elevation),a=c.currentAction,moving=a?.type==='MOVE'&&minute2)?ACTION_ICON[a.type]||'·':''; const sp=this.screenPoint(pos.x,pos.y,t.elevation,camera);hits.push({...this.art.hitRecord({id:c.id,kind:'citizen',frame:citizenFrame(c),size:19},{x:sp.x,y:sp.y+e.body.y*camera.zoom},camera.zoom,e.body.scale.x<0),depth:p.y}); } diff --git a/site/sovereign-renderer.js b/site/sovereign-renderer.js index 5e9ddda..9e56c7b 100644 --- a/site/sovereign-renderer.js +++ b/site/sovereign-renderer.js @@ -34,6 +34,28 @@ export class SovereignRenderer{ setSelected(id){this.selected=id;} setFollow(id){this.follow=id||null;} center(){this.camera.targetX=50;this.camera.targetY=50;this.camera.targetZoom=1.55;this.follow=null;} + fitPopulation(){ + const alive=(this.state?.citizens||[]).filter(c=>c.alive); + if(!alive.length)return false; + const positions=alive.map(c=>citizenPosition(c,this.state)); + const projected=positions.map(pos=>{const t=terrainAtPublic(this.state,pos.x,pos.y);return isoPoint(pos.x,pos.y,t.elevation);}); + const xs=positions.map(p=>p.x),ys=positions.map(p=>p.y),px=projected.map(p=>p.x),py=projected.map(p=>p.y); + this.camera.targetX=clamp((Math.min(...xs)+Math.max(...xs))/2,2,98); + this.camera.targetY=clamp((Math.min(...ys)+Math.max(...ys))/2,2,98); + const spanX=Math.max(20,Math.max(...px)-Math.min(...px)),spanY=Math.max(20,Math.max(...py)-Math.min(...py)); + const usableW=Math.max(240,this.camera.width-220),usableH=Math.max(180,this.camera.height-200); + this.camera.targetZoom=clamp(Math.min(usableW/spanX,usableH/spanY),.6,2.2); + this.follow=null;return true; + } + populationVisibility(){ + const alive=(this.state?.citizens||[]).filter(c=>c.alive);let visible=0; + const margin=28*Math.max(.7,this.camera.zoom); + for(const c of alive){ + const pos=citizenPosition(c,this.state),t=terrainAtPublic(this.state,pos.x,pos.y),p=this.project(pos.x,pos.y,t.elevation); + if(p.x>=-margin&&p.x<=this.camera.width+margin&&p.y>=-margin*2&&p.y<=this.camera.height+margin)visible++; + } + return {visible,total:alive.length}; + } panBy(dx,dy){this.follow=null;const delta=isoInverse(dx/this.camera.zoom,dy/this.camera.zoom);this.camera.targetX=clamp(this.camera.targetX-delta.x,2,98);this.camera.targetY=clamp(this.camera.targetY-delta.y,2,98);} zoomBy(factor){this.camera.targetZoom=clamp(this.camera.targetZoom*factor,.6,3.1);} toggleOverlay(name){this.overlay=this.overlay===name?null:name;} diff --git a/site/sovereign-world.js b/site/sovereign-world.js index a6f9ae4..d34ec27 100644 --- a/site/sovereign-world.js +++ b/site/sovereign-world.js @@ -1,5 +1,6 @@ import {SovereignRenderer,worldMinute,citizenPosition} from './sovereign-renderer.js'; import {ObserverConnection,CONNECTION} from './observer-connection.js'; +import {observerConceptView} from './observer-concepts.js'; const $=id=>document.getElementById(id); const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const ICON={MOVE:'→',OBSERVE:'◉',REST:'·',SLEEP:'z',EAT:'•',DRINK:'≈',GATHER:'⌁',CARRY:'▣',CUT:'╱',DIG:'⌄',BUILD:'⌂',CARE:'+',TEACH:'◇',COMMUNICATE:'◇',EXPERIMENT:'✦',ATTACK:'⚠',DEFEND:'◈',TRANSFER:'↔',PROMISE:'∞',CLAIM:'⌁',REPRODUCE:'◌'}; @@ -12,15 +13,26 @@ function goalLabel(g){if(!g)return 'No long-term goal exposed';if(typeof g==='st function modeLabel(mode){return({[CONNECTION.LIVE]:'LIVE · CANONICAL WORLD',[CONNECTION.DEGRADED]:'DEGRADED · LAST CANONICAL STATE',[CONNECTION.REPLAY]:'OFFLINE · GENESIS REPLAY',[CONNECTION.RECONNECTING]:'RECONNECTING TO CANONICAL WORLD',[CONNECTION.CONNECTING]:'CONNECTING TO CANONICAL WORLD'})[mode]||mode;} function setConnectionMode(mode,detail){state.mode=mode;const badge=document.querySelector('.truth-badge');if(badge)badge.dataset.mode=mode;$('truthText').textContent=modeLabel(mode);$('worldStatus').textContent=mode===CONNECTION.LIVE?'CANONICAL WORLD':mode;if(mode===CONNECTION.DEGRADED&&detail?.retryInMs)$('worldStatus').textContent=`RETRY ${Math.ceil(detail.retryInMs/1000)}s`;} function acceptWorld(world,meta={}){state.world=world;state.renderer?.setState(world);$('worldLoader')?.classList.add('ready');renderAll();} -function renderTop(){const w=state.world;if(!w)return;const living=w.citizens.filter(c=>c.alive).length,climate=climateState(w);$('populationValue').textContent=living;$('structureValue').textContent=(w.buildings||[]).length;$('organizationValue').textContent=(w.organizations||[]).length;$('environmentValue').textContent=`${Math.round(climate.temperature)}° ${climate.season}`;$('weatherValue').textContent=climate.rain>.08?`RAIN ${Math.round(climate.rain*100)}%`:climate.night?'NIGHT':'CLEAR';$('worldDate').textContent=dateLabel(worldMinute(w));$('miniMode').textContent=state.mode===CONNECTION.LIVE?'LIVE':'OBSERVER';} +function renderTop(){ + const w=state.world;if(!w)return; + const living=w.citizens.filter(c=>c.alive).length,climate=climateState(w),visibility=state.renderer?.populationVisibility?.()||{visible:living,total:living}; + $('populationValue').textContent=living; + if($('populationLabel'))$('populationLabel').textContent=`PEOPLE · ${visibility.visible} VISIBLE`; + $('structureValue').textContent=(w.buildings||[]).length; + $('organizationValue').textContent=(w.organizations||[]).length; + $('environmentValue').textContent=`${Math.round(climate.temperature)}° ${climate.season}`; + $('weatherValue').textContent=climate.rain>.08?`RAIN ${Math.round(climate.rain*100)}%`:climate.night?'NIGHT':'CLEAR'; + $('worldDate').textContent=dateLabel(worldMinute(w)); + $('miniMode').textContent=state.mode===CONNECTION.LIVE?'LIVE':'OBSERVER'; +} function significantHistory(w){const rows=[...(w.history||[])];const rank={conflict:9,life:8,construction:8,society:8,culture:8,language:7,knowledge:7,genesis:6};return rows.sort((a,b)=>(b.worldMinute-a.worldMinute)||(rank[b.category]||0)-(rank[a.category]||0)).slice(0,14);} function renderActivity(){const w=state.world;if(!w)return;const history=significantHistory(w);if(history.length){$('activityList').innerHTML=history.map(e=>``).join('');return;}const now=worldMinute(w),rows=w.citizens.filter(c=>c.alive&&c.currentAction).sort((a,b)=>{const am=a.currentAction.type==='MOVE'?1:0,bm=b.currentAction.type==='MOVE'?1:0;return am-bm||a.currentAction.endsWorldMinute-b.currentAction.endsWorldMinute;}).slice(0,14);$('activityList').innerHTML=rows.map(c=>{const a=c.currentAction,span=Math.max(1,a.endsWorldMinute-a.startedWorldMinute),progress=clamp((now-a.startedWorldMinute)/span*100,0,100);return ``;}).join('')||'
…
Quiet intervalNo significant canonical event right now.
';} const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); function objectLabel(id){const o=state.world?.objects?.find(x=>x.id===id);return o?`${o.kind} × ${Number(o.quantity||1).toFixed(Number(o.quantity)%1?1:0)}`:id;} function relationRows(c){return Object.entries(c.relationships||{}).sort(([,a],[,b])=>Math.max(Math.abs(b.trust||0),Math.abs(b.affection||0),Math.abs(b.fear||0))-Math.max(Math.abs(a.trust||0),Math.abs(a.affection||0),Math.abs(a.fear||0))).slice(0,10);} function renderInspector(){const w=state.world;if(!w)return;const c=w.citizens.find(x=>x.id===state.selected),b=w.buildings?.find(x=>x.id===state.selected),p=w.projects?.find(x=>x.id===state.selected),d=w.resourceDeposits?.find(x=>x.id===state.selected);if(!c&&!b&&!p&&!d){$('inspector').hidden=true;return}$('inspector').hidden=false;if(b){$('inspectorBody').innerHTML=`PHYSICAL STRUCTURE

${esc(b.id)}

Completed at world minute ${esc(b.createdWorldMinute)}. It exists only because its project consumed material and labor.

Provenance

${esc(b.provenance?.projectId||'unknown project')}${esc(b.designId||'unknown design')}
`;return}if(p){const progress=p.workRequiredMinutes?Math.round(p.workDoneMinutes/p.workRequiredMinutes*100):0;$('inspectorBody').innerHTML=`CONSTRUCTION PROJECT

${esc(p.id)}

${progress}%work
${esc(p.status)}status

This site is canonical. Completion requires the project's remaining work and reserved materials.

`;return}if(d){$('inspectorBody').innerHTML=`NATURAL RESOURCE

${esc(String(d.type).toUpperCase())}

${Math.round(d.quantity)}quantity
${Number(d.position.x).toFixed(1)}, ${Number(d.position.y).toFixed(1)}location

This marker represents a canonical resource deposit, not decorative scenery.

`;return} - const k=Array.isArray(c.knowledge)?c.knowledge:[],age=(c.body.ageMinutes/525600).toFixed(1),a=c.currentAction,rels=relationRows(c),poss=(c.possessions||[]).slice(0,12),psych=Object.entries(c.psychology||{}).filter(([,v])=>typeof v==='number').sort((a,b)=>b[1]-a[1]).slice(0,7); - $('inspectorBody').innerHTML=`${esc(c.kind)} · ${c.alive?'ALIVE':'DEAD'}

${esc(citizenName(c))}

${age}years
${Math.round(c.body.health)}%health
${Math.round(c.body.hydration)}%water
${Math.round(c.body.calories)}%energy
CURRENT ACTION

${esc(a?.type||'IDLE / THINKING')}

${esc(a?.purpose||'No public physical action recorded.')}

${a?``:''}

Current goal

${esc(goalLabel(c.activeGoal))}

Body / exposure

sleep ${Math.round(c.body.sleepPressure||0)}%temperature ${Number(c.body.temperatureC||36.6).toFixed(1)}°Cterrain ${esc(c.body.exposure?.terrain||'unknown')}rain exposure ${Math.round((c.body.exposure?.rainExposure||0)*100)}%injuries ${Number(c.body.injuries||0)}diseases ${Number(c.body.diseases||0)}

Mind / traits

${psych.map(([name,v])=>`${esc(name)} ${Math.round(v*100)}%`).join('')||'

No public trait state.

'}

Known concepts

${k.length?k.slice(0,28).map(x=>`${esc(x.concept)}`).join(''):`

${c.knowledge?.count!=null?`${c.knowledge.count} private concepts`:'No exposed concepts yet.'}

`}

Language

${Object.entries(c.language?.lexicon||{}).slice(0,18).map(([concept,token])=>`${esc(token)} ↔ ${esc(concept)}`).join('')||'

No stable coined lexicon yet.

'}

Relationships

${rels.map(([id,r])=>`${esc(id.replace('genesis:','C'))} · trust ${Math.round((r.trust||0)*100)} · affection ${Math.round((r.affection||0)*100)} · fear ${Math.round((r.fear||0)*100)}`).join('')||'

No significant public relationship data yet.

'}

Possessions

${poss.map(id=>`${esc(objectLabel(id))}`).join('')||'

No individually held objects.

'}
`;} + const k=Array.isArray(c.knowledge)?c.knowledge:[],conceptViews=k.map(entry=>observerConceptView(w,c,entry)),conceptById=new Map(conceptViews.map(v=>[v.canonicalId,v])),age=(c.body.ageMinutes/525600).toFixed(1),a=c.currentAction,rels=relationRows(c),poss=(c.possessions||[]).slice(0,12),psych=Object.entries(c.psychology||{}).filter(([,v])=>typeof v==='number').sort((a,b)=>b[1]-a[1]).slice(0,7); + $('inspectorBody').innerHTML=`${esc(c.kind)} · ${c.alive?'ALIVE':'DEAD'}

${esc(citizenName(c))}

${age}years
${Math.round(c.body.health)}%health
${Math.round(c.body.hydration)}%water
${Math.round(c.body.calories)}%energy
CURRENT ACTION

${esc(a?.type||'IDLE / THINKING')}

${esc(a?.purpose||'No public physical action recorded.')}

${a?``:''}

Current goal

${esc(goalLabel(c.activeGoal))}

Body / exposure

sleep ${Math.round(c.body.sleepPressure||0)}%temperature ${Number(c.body.temperatureC||36.6).toFixed(1)}°Cterrain ${esc(c.body.exposure?.terrain||'unknown')}rain exposure ${Math.round((c.body.exposure?.rainExposure||0)*100)}%injuries ${Number(c.body.injuries||0)}diseases ${Number(c.body.diseases||0)}

Mind / traits

${psych.map(([name,v])=>`${esc(name)} ${Math.round(v*100)}%`).join('')||'

No public trait state.

'}

Known concepts

${conceptViews.length?conceptViews.slice(0,28).map(v=>`${esc(v.label)}`).join(''):`

${c.knowledge?.count!=null?`${c.knowledge.count} private concepts`:'No exposed concepts yet.'}

`}

Language

${Object.entries(c.language?.lexicon||{}).slice(0,18).map(([concept,token])=>{const v=conceptById.get(concept)||observerConceptView(w,c,{concept,confidence:null,provenance:[]});return `${esc(token)} ↔ ${esc(v.label)}`;}).join('')||'

No stable coined lexicon yet.

'}

Relationships

${rels.map(([id,r])=>`${esc(id.replace('genesis:','C'))} · trust ${Math.round((r.trust||0)*100)} · affection ${Math.round((r.affection||0)*100)} · fear ${Math.round((r.fear||0)*100)}`).join('')||'

No significant public relationship data yet.

'}

Possessions

${poss.map(id=>`${esc(objectLabel(id))}`).join('')||'

No individually held objects.

'}
`;} function renderHistory(){const history=state.world?.history||[],q=$('historySearch').value.trim().toLowerCase(),cat=$('historyCategory').value,rows=history.filter(e=>(cat==='all'||e.category===cat)&&(!q||JSON.stringify(e).toLowerCase().includes(q))).sort((a,b)=>b.worldMinute-a.worldMinute);$('historyTimeline').innerHTML=rows.length?rows.map(e=>`
${esc(e.date?.label||dateLabel(e.worldMinute))}
${esc(e.label)}${esc(e.era||'Emergent history')} · ${esc(e.category)} · ${esc(e.actorId||'world')}
`).join(''):'

No canonical history matches this filter.

';} async function showWhy(id){const p=$('whyPanel');p.hidden=false;p.innerHTML='WHY?

Tracing canonical causes…

';try{if(String(id).startsWith('observer:')){const e=(state.world.history||[]).find(x=>x.eventId===id);p.innerHTML=`OBSERVER CLASSIFICATION

${esc(e?.label||'Derived historical label')}

${esc(e?.era||'')} ${e?.confidence!=null?`· confidence ${Math.round(e.confidence*100)}%`:''}

This label is descriptive. It never mutates world state.

${e?.payload?`
${esc(JSON.stringify(e.payload,null,2))}
`:''}${(e?.causes||[]).length} canonical cause(s)`;return}const d=await getJSON(`/api/v2/why/${encodeURIComponent(id)}`);p.innerHTML=`CANONICAL CAUSAL TRACE

${esc(d.why.event.type)}

World minute ${d.why.event.worldMinute} · actor ${esc(d.why.event.actorId)}

${esc(JSON.stringify(d.why.event.payload,null,2))}
${d.why.causes?.length||0} direct causal parent(s)`;}catch(e){p.innerHTML=`WHY?

Trace unavailable: ${esc(e.message)}

`;}} function renderAll(){renderTop();renderActivity();renderInspector();if(!$('societyHistory').hidden)renderHistory();} @@ -30,7 +42,7 @@ async function sendIntent(){const intent=$('humanIntent').value.trim();if(!inten function toggleFullscreen(){document.fullscreenElement?document.exitFullscreen?.():$('game').requestFullscreen?.();} function closeWindows(){let closed=false;for(const id of ['societyHistory','agentWindow']){const el=$(id);if(el&&!el.hidden){el.hidden=true;closed=true;}}$('whyPanel').hidden=true;if(!closed&&state.selected){state.selected=null;state.renderer.setSelected(null);renderInspector();}else if(!closed&&state.renderer.follow)state.renderer.setFollow(null);} function isTyping(el){return ['INPUT','TEXTAREA','SELECT'].includes(el?.tagName)||el?.isContentEditable;} -function wire(){const canvas=$('worldCanvas');canvas.addEventListener('worldselect',e=>{state.selected=e.detail.id;state.renderer.setSelected(state.selected);renderInspector();});canvas.addEventListener('dblclick',()=>state.renderer.center());document.addEventListener('click',e=>{const c=e.target.closest('[data-citizen]');if(c){state.selected=c.dataset.citizen;state.renderer.setSelected(state.selected);renderInspector();}const h=e.target.closest('[data-event]');if(h)showWhy(h.dataset.event);const why=e.target.closest('[data-why-action]');if(why)showWhy(why.dataset.whyAction);});$('closeInspector').onclick=()=>{state.selected=null;state.renderer.setSelected(null);renderInspector();};$('toggleRail').onclick=()=>$('activityRail').classList.toggle('collapsed');$('historyOpen').onclick=()=>{$('societyHistory').hidden=false;renderHistory();};$('historyClose').onclick=()=>{$('societyHistory').hidden=true;$('whyPanel').hidden=true;};$('historySearch').oninput=renderHistory;$('historyCategory').onchange=renderHistory;$('agentOpen').onclick=()=>$('agentWindow').hidden=false;$('agentClose').onclick=()=>$('agentWindow').hidden=true;$('sendIntent').onclick=sendIntent;$('centerWorld').onclick=()=>state.renderer.center();$('followSelected').onclick=()=>{state.renderer.setFollow(state.selected);toast(state.selected?'Following selected Citizen':'Select a Citizen first');};$('overlayKnowledge').onclick=()=>state.renderer.toggleOverlay('knowledge');$('overlayRelations').onclick=()=>state.renderer.toggleOverlay('relations');$('fullscreenWorld').onclick=toggleFullscreen;document.addEventListener('keydown',e=>{if(isTyping(e.target)){if(e.key==='Escape')e.target.blur();return}const k=e.key.toLowerCase();if(['arrowup','arrowdown','arrowleft','arrowright','w','a','s','d'].includes(k)){state.keys.add(k);e.preventDefault();}if(k==='f'){toggleFullscreen();e.preventDefault();}else if(k==='escape')closeWindows();else if(k==='h'){$('societyHistory').hidden=!$('societyHistory').hidden;if(!$('societyHistory').hidden)renderHistory();}else if(k==='c')state.renderer.center();else if(k==='+'||k==='=')state.renderer.zoomBy(1.12);else if(k==='-')state.renderer.zoomBy(.89);});document.addEventListener('keyup',e=>state.keys.delete(e.key.toLowerCase()));window.addEventListener('blur',()=>state.keys.clear());} +function wire(){const canvas=$('worldCanvas');canvas.addEventListener('worldselect',e=>{state.selected=e.detail.id;state.renderer.setSelected(state.selected);renderInspector();});canvas.addEventListener('dblclick',()=>state.renderer.center());document.addEventListener('click',e=>{const c=e.target.closest('[data-citizen]');if(c){state.selected=c.dataset.citizen;state.renderer.setSelected(state.selected);renderInspector();}const h=e.target.closest('[data-event]');if(h)showWhy(h.dataset.event);const why=e.target.closest('[data-why-action]');if(why)showWhy(why.dataset.whyAction);});$('closeInspector').onclick=()=>{state.selected=null;state.renderer.setSelected(null);renderInspector();};$('toggleRail').onclick=()=>$('activityRail').classList.toggle('collapsed');$('historyOpen').onclick=()=>{$('societyHistory').hidden=false;renderHistory();};$('historyClose').onclick=()=>{$('societyHistory').hidden=true;$('whyPanel').hidden=true;};$('historySearch').oninput=renderHistory;$('historyCategory').onchange=renderHistory;$('agentOpen').onclick=()=>$('agentWindow').hidden=false;$('agentClose').onclick=()=>$('agentWindow').hidden=true;$('sendIntent').onclick=sendIntent;$('centerWorld').onclick=()=>state.renderer.center();$('fitPopulation').onclick=()=>{const ok=state.renderer.fitPopulation();toast(ok?'Framing all living Citizens':'No living Citizens to frame');};$('followSelected').onclick=()=>{state.renderer.setFollow(state.selected);toast(state.selected?'Following selected Citizen':'Select a Citizen first');};$('overlayKnowledge').onclick=()=>state.renderer.toggleOverlay('knowledge');$('overlayRelations').onclick=()=>state.renderer.toggleOverlay('relations');$('fullscreenWorld').onclick=toggleFullscreen;document.addEventListener('keydown',e=>{if(isTyping(e.target)){if(e.key==='Escape')e.target.blur();return}const k=e.key.toLowerCase();if(['arrowup','arrowdown','arrowleft','arrowright','w','a','s','d'].includes(k)){state.keys.add(k);e.preventDefault();}if(k==='f'){toggleFullscreen();e.preventDefault();}else if(k==='escape')closeWindows();else if(k==='h'){$('societyHistory').hidden=!$('societyHistory').hidden;if(!$('societyHistory').hidden)renderHistory();}else if(k==='c')state.renderer.center();else if(k==='v'){state.renderer.fitPopulation();e.preventDefault();}else if(k==='+'||k==='=')state.renderer.zoomBy(1.12);else if(k==='-')state.renderer.zoomBy(.89);});document.addEventListener('keyup',e=>state.keys.delete(e.key.toLowerCase()));window.addEventListener('blur',()=>state.keys.clear());} function frame(now){const dt=Math.min(.05,(now-state.lastFrame)/1000||.016);state.lastFrame=now;const speed=260*dt;if(state.keys.has('w')||state.keys.has('arrowup'))state.renderer.panBy(0,speed);if(state.keys.has('s')||state.keys.has('arrowdown'))state.renderer.panBy(0,-speed);if(state.keys.has('a')||state.keys.has('arrowleft'))state.renderer.panBy(speed,0);if(state.keys.has('d')||state.keys.has('arrowright'))state.renderer.panBy(-speed,0);state.renderer.draw();if(now-state.lastHud>300){state.lastHud=now;renderTop();if(!$('inspector').hidden)renderInspector();}requestAnimationFrame(frame);} function installTestHooks(){window.render_game_to_text=()=>{if(!state.world)return JSON.stringify({status:'loading'});const w=state.world;return JSON.stringify({mode:state.mode,worldMinute:worldMinute(w),selectedId:state.selected,citizens:w.citizens.filter(c=>c.alive).map(c=>{const p=citizenPosition(c,w);return{id:c.id,x:Number(p.x.toFixed(3)),y:Number(p.y.toFixed(3)),action:c.currentAction?.type||null};}),structures:(w.buildings||[]).length,projects:(w.projects||[]).filter(p=>p.status==='construction').length});};} async function boot(){ diff --git a/site/spine-citizen-adapter.js b/site/spine-citizen-adapter.js new file mode 100644 index 0000000..975212c --- /dev/null +++ b/site/spine-citizen-adapter.js @@ -0,0 +1,57 @@ +import {animationForCitizen} from './citizen-animation.js'; + +const DEFAULT_ANIMATIONS={ + idle:'idle',walk:'walk',observe:'observe',sleep:'sleep',eat:'eat',drink:'drink', + gather:'gather',carry:'carry',cut:'cut',dig:'dig',work:'work',build:'build', + care:'care',communicate:'communicate',experiment:'experiment',attack:'attack',defend:'defend', +}; + +export class SpineCitizenAdapter{ + constructor(PIXI,config=globalThis.CYMONIA_SPINE){ + this.PIXI=PIXI;this.config=config||null;this.Spine=this.config?.Spine||null;this.ready=false; + this.animations={...DEFAULT_ANIMATIONS,...(this.config?.animations||{})}; + } + get enabled(){ + return Boolean(this.PIXI&&this.Spine&&this.config?.skeletonAlias&&this.config?.atlasAlias); + } + async preload(){ + if(!this.enabled)return false; + try{ + const {skeletonAlias,atlasAlias,skeletonUrl,atlasUrl}=this.config; + if(skeletonUrl){try{this.PIXI.Assets.add({alias:skeletonAlias,src:skeletonUrl});}catch{}} + if(atlasUrl){try{this.PIXI.Assets.add({alias:atlasAlias,src:atlasUrl});}catch{}} + await this.PIXI.Assets.load([skeletonAlias,atlasAlias]); + this.ready=true;return true; + }catch(error){ + console.warn('Optional Spine Citizen assets unavailable; using native CYMONIA sprites.',error); + this.ready=false;return false; + } + } + create(citizen){ + if(!this.ready)return null; + try{ + const PIXI=this.PIXI,container=new PIXI.Container(),shadow=new PIXI.Graphics(); + shadow.ellipse(1,1,6,2.8).fill({color:0x1d281c,alpha:.22}); + const body=new this.Spine({skeleton:this.config.skeletonAlias,atlas:this.config.atlasAlias,autoUpdate:true}); + const baseScale=Number(this.config.scale||.22);body.scale.set(baseScale); + const task=new PIXI.Text({text:'',style:{fontFamily:'Georgia',fontSize:10,fill:0xf8e8b5,stroke:{color:0x283021,width:2}}}); + task.anchor.set(.5);task.position.set(0,-41);container.addChild(shadow,body,task); + const entry={container,body,task,shadow,baseScale,spine:true,currentAnimation:null}; + this.update(entry,citizen,{flip:false});return entry; + }catch(error){ + console.warn('Optional Spine Citizen instance failed; using native CYMONIA sprite.',error);return null; + } + } + update(entry,citizen,{flip=false}={}){ + if(!entry?.spine)return; + const body=entry.body,semantic=animationForCitizen(citizen),requested=this.animations[semantic]||this.animations.idle||'idle'; + const available=body?.skeleton?.data?.findAnimation?.(requested); + if(available&&entry.currentAnimation!==requested){ + body.state?.setAnimation?.(0,requested,true);entry.currentAnimation=requested; + }else if(!available&&entry.currentAnimation!==this.animations.idle){ + const idle=this.animations.idle||'idle'; + if(body?.skeleton?.data?.findAnimation?.(idle)){body.state?.setAnimation?.(0,idle,true);entry.currentAnimation=idle;} + } + const scale=Math.abs(Number(entry.baseScale)||.22);body.scale.x=(flip?-1:1)*scale;body.scale.y=scale; + } +} diff --git a/tests/test_sovereign_citizen_animation_upgrade.mjs b/tests/test_sovereign_citizen_animation_upgrade.mjs new file mode 100644 index 0000000..96c6bfd --- /dev/null +++ b/tests/test_sovereign_citizen_animation_upgrade.mjs @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import {animationForCitizen,citizenVisualPose} from '../site/citizen-animation.js'; + +test('canonical actions map to Observer animation states only',()=>{ + assert.equal(animationForCitizen({currentAction:{type:'MOVE'}}),'walk'); + assert.equal(animationForCitizen({currentAction:{type:'BUILD'}}),'build'); + assert.equal(animationForCitizen({currentAction:{type:'SLEEP'}}),'sleep'); + assert.equal(animationForCitizen({currentAction:null}),'idle'); + const c={id:'c:1',currentAction:{type:'MOVE'}},before=JSON.stringify(c),pose=citizenVisualPose(c,1000); + assert.equal(pose.animation,'walk'); + for(const key of ['y','rotation','scaleX','scaleY','shadowScale'])assert.ok(Number.isFinite(pose[key]),key); + assert.equal(JSON.stringify(c),before); +}); + +test('Spine support is optional and does not vendor proprietary runtime into the MIT shell',()=>{ + const adapter=fs.readFileSync(new URL('../site/spine-citizen-adapter.js',import.meta.url),'utf8'); + const pixi=fs.readFileSync(new URL('../site/pixi-observer.js',import.meta.url),'utf8'); + const html=fs.readFileSync(new URL('../site/index.html',import.meta.url),'utf8'); + const docs=fs.readFileSync(new URL('../docs/SPINE_OBSERVER_INTEGRATION.md',import.meta.url),'utf8'); + assert.match(adapter,/CYMONIA_SPINE/);assert.match(adapter,/animationForCitizen/); + assert.match(pixi,/SpineCitizenAdapter/);assert.match(pixi,/citizenVisualPose/); + assert.doesNotMatch(html,/@esotericsoftware|spine-pixi-v8/i);assert.match(docs,/Spine Runtimes License/);assert.match(docs,/fallback/i); +}); diff --git a/tests/test_sovereign_observer_readability_upgrade.mjs b/tests/test_sovereign_observer_readability_upgrade.mjs new file mode 100644 index 0000000..5cee53f --- /dev/null +++ b/tests/test_sovereign_observer_readability_upgrade.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import {observerConceptView} from '../site/observer-concepts.js'; + +test('Observer translates opaque resource concepts without mutating canonical knowledge',()=>{ + const world={resourceDeposits:[{id:'dep:water',type:'water'}],buildings:[],projects:[],objects:[]}; + const citizen={language:{lexicon:{'k:water':'mira'}}}; + const entry={concept:'k:water',confidence:.91,provenance:[{kind:'observation',entityId:'dep:water',evidence:{appearance:'moving reflective fluid'}}]}; + const before=JSON.stringify({world,citizen,entry}),view=observerConceptView(world,citizen,entry); + assert.equal(view.label,'Water source');assert.equal(view.canonicalId,'k:water');assert.equal(view.token,'mira'); + assert.match(view.tooltip,/Canonical concept: k:water/);assert.equal(JSON.stringify({world,citizen,entry}),before); +}); + +test('Observer gives readable structure and sensory fallbacks without guessing canonical state',()=>{ + const world={resourceDeposits:[],buildings:[{id:'hut:1'}],projects:[],objects:[]}; + assert.equal(observerConceptView(world,{language:{lexicon:{}}},{concept:'kstruct:1',provenance:[{kind:'observation',entityId:'hut:1'}]}).label,'Constructed structure'); + assert.equal(observerConceptView(world,{language:{lexicon:{}}},{concept:'k:unknown',provenance:[{kind:'observation',evidence:{appearance:'fibrous rigid material'}}]}).label,'Observed: fibrous rigid material'); +}); + +test('Observer exposes population framing and readable concept UI',()=>{ + const renderer=fs.readFileSync(new URL('../site/sovereign-renderer.js',import.meta.url),'utf8'); + const world=fs.readFileSync(new URL('../site/sovereign-world.js',import.meta.url),'utf8'); + const html=fs.readFileSync(new URL('../site/index.html',import.meta.url),'utf8'); + assert.match(renderer,/fitPopulation\(\)/);assert.match(renderer,/populationVisibility\(\)/); + assert.match(world,/observerConceptView/);assert.match(world,/fitPopulation/); + assert.match(html,/id="fitPopulation"/);assert.match(html,/id="populationLabel"/); +});