Sign in to embody one persistent Citizen inside CYMONIA. Your Citizen receives no Earth knowledge or privilege.
Enter the world with GitHub
diff --git a/site/sovereign-world.css b/site/sovereign-world.css
index 77b7ee0..e36d5ab 100644
--- a/site/sovereign-world.css
+++ b/site/sovereign-world.css
@@ -25,3 +25,23 @@
.avatar-current-action p{margin:0}
.activity-row{grid-template-columns:42px 1fr}
@media(max-width:720px){.action-image{width:28px;height:28px}.activity-row{grid-template-columns:36px 1fr}}
+
+/* The public roster is available to guests as well as avatar owners. */
+.agent-window{height:min(680px,80vh);display:flex;flex-direction:column}
+.agent-copy{min-height:0;overflow-y:auto}
+.github-citizens{margin-top:0;border-top:0;padding-top:0;padding-bottom:12px}
+.github-citizens p{font-size:10px}
+.github-citizen-row{display:flex;align-items:center;gap:10px;width:100%;padding:9px 8px;margin:4px 0;border:1px solid #876b3a55;background:#e7d7aa;color:#2b2417;text-align:left;cursor:pointer}
+.github-citizen-row>span:nth-child(2){flex:1;min-width:0}
+.github-citizen-row b,.github-citizen-row small{display:block;overflow-wrap:anywhere}
+.github-citizen-row b{font-size:12px}.github-citizen-row small,.github-citizen-row>span:last-child{font-size:9px}
+.github-citizen-row:hover,.github-citizen-row:focus-visible{background:#f4e8c5;outline:2px solid #39708e}
+.github-citizen-marker{width:9px;height:9px;flex:none;border-radius:50%;background:#397da9}
+.github-citizen-marker[data-alive="false"]{background:#8b7661}
+
+.game-dock .avatar-owner-status{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
+
+.game-dock #agentOpen{display:flex;flex-direction:column;align-items:center;justify-content:center}
+.game-dock #agentOpen>span{line-height:26px}
+.game-dock #agentOpen>b{line-height:10px}
+.game-dock #githubCitizenCount{line-height:8px;white-space:nowrap}
diff --git a/site/sovereign-world.js b/site/sovereign-world.js
index 18d9f2e..9b8020f 100644
--- a/site/sovereign-world.js
+++ b/site/sovereign-world.js
@@ -265,6 +265,7 @@ function renderMyAvatar(){
if(!guest||!dash)return;
const linked=Boolean(state.myAvatar?.authenticated&&state.myAvatar?.citizenId);
guest.hidden=linked;dash.hidden=!linked;renderMyAvatarStatus();
+ renderGitHubCitizens();
if(!linked)return;
const c=currentOwnedCitizen(),actor=state.myAvatar.actor||{};
if(!c){$('avatarIdentityName').textContent=actor.displayName||actor.githubLogin||'Linked Citizen';$('avatarIdentityStatus').textContent='Synchronizing canonical Citizen…';return;}
@@ -283,6 +284,21 @@ function renderMyAvatar(){
const events=avatarRecentEvents(c);
$('avatarTimeline').innerHTML=events.length?events.map(e=>`
${esc(dateLabel(e.worldMinute||0))}${esc(e.label||e.type||'Canonical event')}
`).join(''):'
No recent public canonical events for this Citizen yet.
';
}
+function renderGitHubCitizens(){
+ const list=$('githubCitizenList');if(!list||!state.world)return;
+ const citizens=state.world.citizens.filter(c=>c.kind==='HUMAN_LINKED');
+ const living=citizens.filter(c=>c.alive).length;
+ $('githubCitizenCount').textContent=`${living} ${living===1?'CITIZEN':'CITIZENS'}`;
+ $('githubCitizenSummary').textContent=`${living} living · ${citizens.length} embodied · blue citizens on the map`;
+ list.innerHTML=citizens.length?citizens.map(c=>`
`).join(''):'
No GitHub citizens have been embodied in this world yet.
';
+}
+function locateGitHubCitizen(id){
+ const citizen=state.world?.citizens.find(c=>c.id===id&&c.kind==='HUMAN_LINKED');
+ if(!citizen)return;
+ state.selected=id;state.renderer.setSelected(id);
+ if(citizen.alive)state.renderer.locateCitizen(id);
+ $('agentWindow').hidden=true;renderInspector();
+}
async function refreshMyAvatar({quiet=false}={}){
try{
const d=await getJSON('/api/v2/avatar',{
@@ -328,7 +344,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=async()=>{$('agentWindow').hidden=false;await refreshMyAvatar({quiet:true});renderMyAvatar();};$('agentClose').onclick=()=>$('agentWindow').hidden=true;$('avatarLocate').onclick=()=>locateMyAvatar();$('avatarFollow').onclick=()=>locateMyAvatar({follow:true});$('avatarInspect').onclick=()=>locateMyAvatar({inspect: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 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 github=e.target.closest('[data-github-citizen]');if(github){locateGitHubCitizen(github.dataset.githubCitizen);return;}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=async()=>{$('agentWindow').hidden=false;await refreshMyAvatar({quiet:true});renderMyAvatar();};$('agentClose').onclick=()=>$('agentWindow').hidden=true;$('avatarLocate').onclick=()=>locateMyAvatar();$('avatarFollow').onclick=()=>locateMyAvatar({follow:true});$('avatarInspect').onclick=()=>locateMyAvatar({inspect: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/tests/browser-sovereign.mjs b/tests/browser-sovereign.mjs
index a555d1b..27a7e5b 100644
--- a/tests/browser-sovereign.mjs
+++ b/tests/browser-sovereign.mjs
@@ -265,11 +265,41 @@ try {
await page.clock.fastForward(121000);
assert.equal(await page.locator('#worldDate').innerText(),'YEAR 2 · DAY 1 · 00:00');
+ // Guests can discover existing GitHub citizens without creating one or
+ // treating dead citizens as missing. Names remain text, not account HTML.
+ await page.route('**/api/v2/avatar',route=>route.fulfill({status:401,contentType:'application/json',body:'{"ok":false}'}));
+ liveWorld.citizens.push(
+ {...structuredClone(liveWorld.citizens[0]),id:'human:living',kind:'HUMAN_LINKED',observerDisplayName:'GitHub
',position:{x:70,y:70},currentAction:null},
+ {...structuredClone(liveWorld.citizens[0]),id:'human:dead',kind:'HUMAN_LINKED',observerDisplayName:'Past member',alive:false,deathWorldMinute:30,currentAction:null}
+ );
+ await page.reload({waitUntil:'domcontentloaded'});
+ await page.waitForFunction(()=>document.querySelector('#populationValue')?.textContent==='2');
+ assert.equal(await page.locator('#githubCitizenCount').innerText(),'1 CITIZEN');
+ await page.locator('#agentOpen').click();
+ assert.ok(await page.locator('#avatarGuest').isVisible());
+ assert.equal(await page.locator('#githubCitizenList button').count(),2);
+ assert.ok((await page.locator('#githubCitizenList').innerText()).includes('GitHub '));
+ assert.equal(await page.locator('#githubCitizenList member').count(),0);
+ await page.locator('[data-github-citizen="human:living"]').click();
+ assert.equal(await page.locator('#agentWindow').isVisible(),false);
+ assert.equal(JSON.parse(await page.evaluate(()=>window.render_game_to_text())).selectedId,'human:living');
+ assert.ok((await page.locator('#inspectorBody').innerText()).includes('GitHub '));
+ await page.locator('#agentOpen').click();
+ await page.locator('[data-github-citizen="human:dead"]').click();
+ assert.ok((await page.locator('#inspectorBody').innerText()).includes('DEAD'));
+ assert.equal(JSON.parse(await page.evaluate(()=>window.render_game_to_text())).selectedId,'human:dead');
+ await page.locator('#closeInspector').click();
+
await page.setViewportSize({
width: 390,
height: 844
});
+ await page.locator('#agentOpen').click();
+ assert.ok(await page.locator('[data-github-citizen="human:living"]').isVisible());
+ await page.locator('[data-github-citizen="human:living"]').click();
+ assert.ok(await page.locator('#inspector').isVisible());
+
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth > innerWidth
@@ -284,7 +314,7 @@ try {
);
console.log(
- 'PASS: canonical state rendering, fractional movement, durable year rollover, game-only shell, history, mobile containment and zero page errors.'
+ 'PASS: canonical state rendering, fractional movement, durable year rollover, guest GitHub roster and locate/inspect, game-only shell, history, mobile containment and zero page errors.'
);
} finally {
await browser.close();
diff --git a/tests/test_sovereign_routine_starvation.mjs b/tests/test_sovereign_routine_starvation.mjs
new file mode 100644
index 0000000..7a3e878
--- /dev/null
+++ b/tests/test_sovereign_routine_starvation.mjs
@@ -0,0 +1,106 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {createSovereignGenesis} from '../world/genesis.js';
+import {executePhysicalOperations} from '../world/physical-operations.js';
+import {rememberProcedure,procedureProposal,PROCEDURE_LIMITS} from '../world/procedures.js';
+import {localDeliberation,applyAcceptedPlan} from '../world/cognition.js';
+import {recordAffordanceOutcome} from '../world/cognition-state.js';
+import {acceptAIStrategy,sanitizeAIStrategy} from '../world/strategy.js';
+import {learn} from '../world/epistemics.js';
+import {stableId} from '../world/rng.js';
+import {advanceWorldTo,publicWorld} from '../world/engine.js';
+import {enumerateAffordances} from '../world/affordances.js';
+
+function matureCitizen(at=120){
+ const world=createSovereignGenesis({seed:20260915,realEpochMs:0}),citizen=world.citizens[0];
+ for(const person of world.citizens)person.position={x:50,y:50};
+ citizen.psychology.curiosity=1;citizen.psychology.noveltySeeking=1;citizen.psychology.socialDrive=0;
+ citizen.body.hydration=95;citizen.body.calories=95;citizen.body.sleepPressure=10;
+ const object={id:'held:remembered-clay',kind:'raw_material',material:'clay',quantity:2,massPerUnitKg:1,holderId:citizen.id,position:{x:50,y:50},condition:1,properties:{hardness:.25},provenance:{type:'TEST_INPUT'}};
+ world.objects.push(object);citizen.possessions.push(object.id);citizen.knownEntityIds.push(object.id);
+ learn(citizen,stableId('kprop',object.id,'hardness'),{kind:'observation',eventId:'seen:clay',entityId:object.id,evidence:{entityId:object.id,property:'hardness',value:.25}});
+ for(let i=0;i{
+ const {world,citizen}=matureCitizen();
+ assert.equal(citizen.procedureKnowledge.length,24);
+ assert.ok(procedureProposal(world,citizen,120),'the regression requires a runnable remembered procedure');
+ acceptAIStrategy(world,citizen.id,sanitizeAIStrategy({intent:'explore',actionBias:['MOVE'],horizonMinutes:4320,confidence:.8}),120);
+ const selected=localDeliberation(world,citizen,120);
+ assert.equal(selected.affordanceFamily,'explore');
+ assert.equal(selected.actions[0].type,'MOVE');
+ assert.ok(Math.hypot(selected.actions[0].targetPosition.x-50,selected.actions[0].targetPosition.y-50)>20);
+});
+
+test('recent practice applies to the activity family even when another remembered procedure is runnable',()=>{
+ const {world,citizen}=matureCitizen();
+ recordAffordanceOutcome(citizen,'experiment',{ok:true},120);
+ assert.ok(procedureProposal(world,citizen,121));
+ assert.notEqual(localDeliberation(world,citizen,121).actions[0].type,'EXPERIMENT');
+});
+
+test('a reliable remembered skill can win deliberation instead of always inventing another hypothesis',()=>{
+ const {world,citizen}=matureCitizen();
+ citizen.psychology.curiosity=.2;citizen.psychology.noveltySeeking=.2;
+ for(const other of world.citizens.slice(1))other.position={x:95,y:95};
+ const entry=citizen.procedureKnowledge[0],object=world.objects.find(o=>o.holderId===citizen.id);
+ const record=world.procedures.find(p=>p.id===entry.procedureId);
+ for(let at=1;at<=5;at++){
+ const receipt=executePhysicalOperations(world,citizen,record.operations,{inputObjectIds:[object.id],at,procedureId:record.id,workMinutes:1});
+ rememberProcedure(world,citizen,receipt,at);
+ }
+ const selected=localDeliberation(world,citizen,240);
+ assert.equal(selected.source,'personal-procedure');
+ assert.equal(selected.affordanceFamily,'experiment');
+ assert.equal(selected.actions[0].payload.procedureId,record.id);
+});
+
+test('a legacy practice description cannot bias a mature citizen into repeating experiments forever',()=>{
+ const {world,citizen}=matureCitizen();
+ citizen.activeGoal={source:'personal-procedure',concepts:[],actionTypes:['EXPERIMENT']};
+ recordAffordanceOutcome(citizen,'experiment',{ok:true},120);
+ assert.notEqual(localDeliberation(world,citizen,121).actions[0].type,'EXPERIMENT');
+});
+
+test('practicing a personal procedure preserves the persistent strategy that selected it',()=>{
+ const {world,citizen}=matureCitizen();
+ acceptAIStrategy(world,citizen.id,sanitizeAIStrategy({intent:'understand',actionBias:['EXPERIMENT'],horizonMinutes:4320,confidence:.8}),120);
+ const before=structuredClone(citizen.activeGoal);
+ applyAcceptedPlan(world,citizen,procedureProposal(world,citizen,120),120);
+ assert.deepEqual(citizen.activeGoal,before);
+});
+
+test('a mature crowded citizen physically leaves the center without an AI call or position reset',()=>{
+ // A warm-season fixture isolates routine starvation from the legitimate
+ // winter shelter reflex that keeps a cold citizen near shelter.
+ const start=196560,{world,citizen}=matureCitizen(start),ids=world.citizens.map(c=>c.id);
+ let furthest=0;
+ for(let minute=start+10;minute<=start+240;minute+=10){
+ advanceWorldTo(world,minute*1000);
+ const position=publicWorld(world).citizens.find(c=>c.id===citizen.id).position;
+ furthest=Math.max(furthest,Math.hypot(position.x-50,position.y-50));
+ }
+ assert.ok(furthest>12,`citizen remained within ${furthest.toFixed(2)} units of the central cluster`);
+ assert.ok(citizen.plans.some(plan=>plan.affordanceFamily==='explore'));
+ assert.deepEqual(world.citizens.map(c=>c.id),ids);
+ assert.ok(citizen.alive);
+});
+
+test('dense social deliberation stays bounded and rotates attention without permanently excluding citizens',()=>{
+ const world=createSovereignGenesis({seed:20260915,realEpochMs:0}),citizen=world.citizens[0];
+ learn(citizen,'shared:observed',{kind:'observation',eventId:'shared'});
+ for(const person of world.citizens){person.position={x:50,y:50};citizen.knownEntityIds.push(person.id);}
+ const attended=new Set();
+ for(let at=0;at<1200;at+=120){
+ const contacts=enumerateAffordances(world,citizen,at).filter(candidate=>candidate.family==='communicate');
+ assert.ok(contacts.length<=12,'one deliberation cannot compare every concept with every nearby mind');
+ for(const candidate of contacts)attended.add(candidate.targetId);
+ }
+ assert.equal(attended.size,99,'bounded attention must eventually cover every known neighbor');
+});
diff --git a/world/affordances.js b/world/affordances.js
index 2332c39..a47c50c 100644
--- a/world/affordances.js
+++ b/world/affordances.js
@@ -8,6 +8,7 @@ import {terrainAt,nearestDryLandPoint,isWaterTerrainKind,isSleepUnsafeTerrainKin
import {constructionDemand,nightPressure,rememberedCrowding,structureUseSummary} from './living-world.js';
import {grievancePressure,strongestGrievance} from './destruction.js';
import {activeConcepts,knowledgeForEntity as storedKnowledgeForEntity} from './knowledge-storage.js';
+import {procedureProposal} from './procedures.js';
const clamp01=value=>Math.max(0,Math.min(1,Number(value)||0));
const dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
@@ -35,6 +36,12 @@ function knowledgeForEntity(citizen,entityId){
return storedKnowledgeForEntity(citizen,entityId);
}
+function attentionWindow(values,limit,citizen,at){
+ if(values.length<=limit)return values;
+ const start=(hash32(citizen.id)+Math.floor(at/120)*limit)%values.length;
+ return Array.from({length:limit},(_,i)=>values[(start+i)%values.length]);
+}
+
function hasUnknownObservableProperty(citizen,target){
const properties=target.properties||MATERIAL_PROPERTIES[target.material||target.type]||{};
return Object.keys(properties).some(property=>!knows(citizen,stableId('kprop',target.id,property)));
@@ -199,7 +206,29 @@ export function enumerateAffordances(world,citizen,at=world.clock.worldMinute){
if(!citizen?.alive||citizen.currentActionId)return [];
const candidates=[],held=heldObjects(world,citizen);
const knownResources=world.resourceDeposits.filter(deposit=>deposit.quantity>0&&knowsEntity(citizen,deposit.id)&&knows(citizen,resourceConceptId(deposit)));
- const nearby=world.citizens.filter(other=>other.id!==citizen.id&&other.alive&&knowsEntity(citizen,other.id)&&dist(citizen.position,other.position)<=10);
+ // Match the cognitive context's 12 people / 32 concepts attention window.
+ // Rotating it avoids a lifetime all-concepts × all-neighbors comparison when
+ // mature citizens return to affordance deliberation after learned practice.
+ const nearby=attentionWindow(world.citizens.filter(other=>other.id!==citizen.id&&other.alive&&knowsEntity(citizen,other.id)&&dist(citizen.position,other.position)<=10),12,citizen,at);
+
+ // Remembered skills are opportunities, not an unconditional decision. A
+ // full memory must still compete with exploration, needs and ongoing work.
+ // Use the same activity family as a fresh experiment/demonstration so that
+ // switching procedure IDs cannot bypass outcome learning and cooldowns.
+ const remembered=procedureProposal(world,citizen,at);
+ if(remembered){
+ const action=remembered.actions[0],teaching=action.type==='TEACH',family=teaching?'teach':'experiment';
+ const confidence=clamp01(citizen.procedureKnowledge?.find(entry=>entry.procedureId===action.payload.procedureId)?.confidence);
+ candidates.push({
+ family,key:`${family}:procedure:${action.payload.procedureId}`,targetId:action.targetId,
+ // Confidence gives proven practice a value distinct from novelty. It
+ // can win for a cautious mind without becoming unconditional priority.
+ utility:teaching?.16:.24+.7*confidence,social:teaching?.8:0,
+ inventoryFit:teaching?0:.65,knowledgeGap:teaching?.65:0,
+ novelty:teaching?.25:0,effort:teaching?.08:.12,risk:teaching?clamp01(relation(citizen,action.targetId).fear):.12,
+ proposal:{...remembered,affordanceFamily:family},
+ });
+ }
for(const deposit of knownResources){
const distance=dist(citizen.position,deposit.position),heldSame=held.reduce((sum,object)=>sum+(object.material===deposit.type?object.quantity:0),0);
@@ -219,7 +248,7 @@ export function enumerateAffordances(world,citizen,at=world.clock.worldMinute){
if(knowledgeForEntity(citizen,object.id).length)candidates.push({family:'transform',key:`transform:${object.id}`,targetId:object.id,utility:.24,inventoryFit:.9,knowledgeGap:.2,novelty:.55,effort:.2,risk:.12,proposal:proposal('transform',knowledgeForEntity(citizen,object.id).map(entry=>entry.concept),[{type:'ASSEMBLE',durationMinutes:35,purpose:'experiment',concepts:knowledgeForEntity(citizen,object.id).map(entry=>entry.concept),payload:{inputObjectIds:[object.id],quantities:[object.quantity],form:'bundle'}}])});
}
- const concepts=activeConcepts(citizen);
+ const concepts=nearby.length?attentionWindow(activeConcepts(citizen),32,citizen,at):[];
for(const other of nearby){
const gap=concepts.find(concept=>!knows(other,concept));
if(gap){
@@ -363,7 +392,7 @@ export function enumerateAffordances(world,citizen,at=world.clock.worldMinute){
export function scoreAffordance(world,citizen,candidate,at=world.clock.worldMinute){
const state=ensureCognitionState(citizen,at),psychology=citizen.psychology||{},rel=relation(citizen,candidate.targetId);
- const persistent=activeStrategy(citizen,at,world),legacy=citizen.activeGoal?.kind==='strategy-v1'?null:citizen.activeGoal;
+ const persistent=activeStrategy(citizen,at,world),legacy=citizen.activeGoal?.kind==='strategy-v1'||citizen.activeGoal?.source==='personal-procedure'?null:citizen.activeGoal;
const desired=new Set(persistent?.actionBias||legacy?.actionTypes||[]),actions=candidate.proposal.actions||[];
const actionAligned=actions.some(action=>desired.has(action.type));
const partnerAligned=Boolean(persistent?.partnerIds?.includes(candidate.targetId));
diff --git a/world/cognition.js b/world/cognition.js
index 7c9ae16..e587c30 100644
--- a/world/cognition.js
+++ b/world/cognition.js
@@ -10,7 +10,7 @@ import {chooseAffordance} from './affordances.js';
import {programProposal,programSummary} from './programming.js';
import {nearestDryLandPoint,isSleepUnsafeTerrainKind,terrainAt} from './terrain.js';
import {validatePhysicalAction,cleanPhysicalPayload} from './physical-actions.js';
-import {procedureSummary,procedureProposal} from './procedures.js';
+import {procedureSummary} from './procedures.js';
function heldObjects(world,citizen){return world.objects.filter(o=>o.holderId===citizen.id&&o.quantity>0);}
export function buildCognitiveContext(world,citizen,at=world.clock.worldMinute){
@@ -42,14 +42,14 @@ function preparePlannedAction(world,citizen,plan,action){
return escape;
}
-export function applyAcceptedPlan(world,citizen,proposal,at=world.clock.worldMinute){const validation=validateCognitiveProposal(world,citizen,proposal);if(!validation.ok)throw new Error(validation.reason);for(const b of proposal.beliefUpdates||[])adoptBelief(world,citizen,b,{kind:'reflection'},at);const current=activeAction(world,citizen);if(current&&['OBSERVE','REST'].includes(current.type))interruptAction(world,citizen,'cognitive_plan',at);else if(current)throw new Error('citizen_busy');const sequence=Math.max(Number(citizen.planSequence)||0,citizen.plans.length);const plan={id:stableId('plan',citizen.id,sequence,at),createdWorldMinute:at,concepts:[...(proposal.concepts||[])],actions:proposal.actions,status:'active',cursor:0,source:proposal.source||'cognition',affordanceFamily:proposal.affordanceFamily||null};citizen.planSequence=sequence+1;if(!['reflex','local','self-program'].includes(plan.source))citizen.activeGoal={concepts:plan.concepts,actionTypes:plan.actions.map(a=>a.type),source:plan.source,createdWorldMinute:at};citizen.plans.push(plan);citizen.cognition.pending=false;if(plan.actions.length){const a=preparePlannedAction(world,citizen,plan,plan.actions[0]);startAction(world,citizen,{...a,planId:plan.id,durationMinutes:a.durationMinutes||10},at);}else plan.status='completed';return plan;}
+export function applyAcceptedPlan(world,citizen,proposal,at=world.clock.worldMinute){const validation=validateCognitiveProposal(world,citizen,proposal);if(!validation.ok)throw new Error(validation.reason);for(const b of proposal.beliefUpdates||[])adoptBelief(world,citizen,b,{kind:'reflection'},at);const current=activeAction(world,citizen);if(current&&['OBSERVE','REST'].includes(current.type))interruptAction(world,citizen,'cognitive_plan',at);else if(current)throw new Error('citizen_busy');const sequence=Math.max(Number(citizen.planSequence)||0,citizen.plans.length);const plan={id:stableId('plan',citizen.id,sequence,at),createdWorldMinute:at,concepts:[...(proposal.concepts||[])],actions:proposal.actions,status:'active',cursor:0,source:proposal.source||'cognition',affordanceFamily:proposal.affordanceFamily||null};citizen.planSequence=sequence+1;if(!['reflex','local','self-program','personal-procedure'].includes(plan.source))citizen.activeGoal={concepts:plan.concepts,actionTypes:plan.actions.map(a=>a.type),source:plan.source,createdWorldMinute:at};citizen.plans.push(plan);citizen.cognition.pending=false;if(plan.actions.length){const a=preparePlannedAction(world,citizen,plan,plan.actions[0]);startAction(world,citizen,{...a,planId:plan.id,durationMinutes:a.durationMinutes||10},at);}else plan.status='completed';return plan;}
export function continuePlan(world,citizen,completedAction,at){if(!completedAction.planId)return false;const plan=citizen.plans.find(p=>p.id===completedAction.planId);if(!plan||plan.status!=='active')return false;plan.cursor++;if(plan.cursor>=plan.actions.length){plan.status='completed';if(citizen.activeGoal)citizen.activeGoal.lastPlanCompletedWorldMinute=at;citizen.cognition.pending=true;return false;}const a=preparePlannedAction(world,citizen,plan,plan.actions[plan.cursor]);startAction(world,citizen,{...a,planId:plan.id,durationMinutes:a.durationMinutes||10},at);return true;}
export function survivalFallback(world,citizen){if(!citizen.alive||citizen.currentActionId)return null;const b=citizen.body,ambient=Number(world.environment?.temperatureC??18);if(ambient<5||ambient>36){const structures=[...(world.buildings||[]),...(world.objects||[]).filter(o=>o.kind==='temporary_shelter'&&o.quantity>0)].filter(x=>knowsEntity(citizen,x.id)).sort((a,b)=>dist(citizen.position,a.position)-dist(citizen.position,b.position));const s=structures[0];if(s&&dist(citizen.position,s.position)>1)return {source:'reflex',concepts:[],actions:[{type:'MOVE',durationMinutes:Math.max(2,Math.ceil(dist(citizen.position,s.position)*2)),targetId:s.id,targetPosition:s.position,purpose:'survival',concepts:[]},{type:'REST',durationMinutes:30,purpose:'survival'}]};}const infant=b.ageMinutes<2*525600;if(infant&&(b.hydration<55||b.calories<55||b.sleepPressure>75)){const caregiver=Object.keys(citizen.relationships||{})[0]||citizen.knownEntityIds.find(id=>id!==citizen.id);return caregiver?{source:'reflex',concepts:[],actions:[{type:'COMMUNICATE',durationMinutes:2,targetId:caregiver,purpose:'survival',payload:{primitiveSignal:'need'}},{type:'REST',durationMinutes:8,purpose:'survival'}]}:{source:'reflex',concepts:[],actions:[{type:'REST',durationMinutes:8,purpose:'survival'}]};}if(b.sleepPressure>85){const shelters=[...(world.buildings||[]),...(world.objects||[]).filter(o=>o.kind==='temporary_shelter'&&o.quantity>0)].filter(x=>x.position&&knowsEntity(citizen,x.id)).sort((a,b)=>dist(citizen.position,a.position)-dist(citizen.position,b.position));const shelter=shelters[0];if(shelter&&dist(citizen.position,shelter.position)>1)return {source:'reflex',concepts:[],actions:[{type:'MOVE',durationMinutes:Math.max(2,Math.ceil(dist(citizen.position,shelter.position)*2)),targetId:shelter.id,targetPosition:shelter.position,purpose:'survival',concepts:[]},{type:'SLEEP',durationMinutes:240,purpose:'survival'}]};if(isSleepUnsafeTerrainKind(terrainAt(world,citizen.position.x,citizen.position.y).kind)){const dry=nearestDryLandPoint(world,citizen.position,10,{sleepSafe:true});if(dist(citizen.position,dry)>.1)return {source:'reflex',concepts:[],actions:[{type:'MOVE',durationMinutes:Math.max(2,Math.ceil(dist(citizen.position,dry)*2)),targetPosition:dry,purpose:'survival',concepts:[]},{type:'SLEEP',durationMinutes:240,purpose:'survival'}]};}return {source:'reflex',concepts:[],actions:[{type:'SLEEP',durationMinutes:240,purpose:'survival'}]};}if(b.hydration<35)return nearestKnownResourcePlan(world,citizen,'water','DRINK');if(b.calories<30)return nearestKnownResourcePlan(world,citizen,'food','EAT');return null;}
function nearestKnownResourcePlan(world,citizen,type,terminal){const ds=world.resourceDeposits.filter(d=>d.type===type&&knows(citizen,resourceConceptId(d))).sort((a,b)=>dist(citizen.position,a.position)-dist(citizen.position,b.position));const d=ds[0];if(!d)return {source:'reflex',concepts:[],actions:[{type:'OBSERVE',durationMinutes:10,purpose:'orientation'}]};const distance=dist(citizen.position,d.position),concept=resourceConceptId(d);return {source:'reflex',concepts:[concept],actions:distance>1?[{type:'MOVE',durationMinutes:Math.max(2,Math.ceil(distance*2)),targetPosition:d.position,targetId:d.id,purpose:'biological_need',concepts:[concept]},{type:terminal,durationMinutes:5,targetId:d.id,purpose:'biological_need',concepts:[concept]}]:[{type:terminal,durationMinutes:5,targetId:d.id,purpose:'biological_need',concepts:[concept]}]};}
function dist(a,b){return Math.hypot(a.x-b.x,a.y-b.y);}
export function localDeliberation(world,citizen,at=world.clock.worldMinute){
- return programProposal(world,citizen,at)||procedureProposal(world,citizen,at)||chooseAffordance(world,citizen,at);
+ return programProposal(world,citizen,at)||chooseAffordance(world,citizen,at);
}
function safePayload(type,p={}){