Arrow keys / WASD to walk · Walk into doors · E to talk · F for fullscreen · Click to inspect
-
DETAILS Close
+
EVIDENCE Close
Click a cottage to inspect its work, or walk into its doorway.
diff --git a/src/town.mjs b/src/town.mjs
index 9ee5765..b2f8fba 100644
--- a/src/town.mjs
+++ b/src/town.mjs
@@ -2,8 +2,8 @@ import { normalizePr, prStage, hasOutstandingPr } from "./pr.mjs";
import { createTownLayout, advanceDuck, normalizeTown } from "./world.mjs";
import { renderResident } from "./interiors.mjs";
import { createObservatory, apprenticeResidentTarget } from "./observatory.mjs";
-import { normalizeCottage, isCottageVisible, layoutCottages, feedStatusNote } from "./feed-client.mjs";
-import { blankFeedState, createLatestRefresh, readCurrentFeed, snapshotForEndpoint } from "./live-feed.mjs";
+import { normalizeCottage, isCottageVisible, layoutCottages, feedStatusNote, staleFeedPr } from "./feed-client.mjs";
+import { blankFeedState, createLatestRefresh, createFeedNotifications, readCurrentFeed, snapshotForEndpoint } from "./live-feed.mjs";
import { lettersOf } from "./occupancy.mjs";
import {
hauntStage,
@@ -22,6 +22,15 @@ import {MAP_REFERENCE_WIDTH,townWidthBudget,townActivity,overviewBounds} from '.
import { PUBLIC_DEMO } from "./runtime.mjs";
import { createBedtimeRoutine, paintCoop, routineForAgent, statusBubbleAnchor, villageLifeLabel, visibleBedtimeKids } from "./bedtime.mjs";
import { residentTargets } from "./interaction.mjs";
+import { commandSnapshot, fleetView } from "./command-model.mjs";
+import { createCommandCenter } from "./command-center.mjs";
+import { projectBounds } from "./command-workbench.mjs";
+import { createHeartbeat } from "./heartbeat.mjs";
+import { createTownTrades } from "./town-trades.mjs";
+import { drawTradeLife, tradeSites } from "./trade-art.mjs";
+import { parentPaths } from "./parent-paths.mjs";
+const heartbeat=createHeartbeat(), townTrades=createTownTrades();
+let currentTrades=townTrades.snapshot(), currentTradeSites=[];
/* =======================================================================
@@ -37,16 +46,27 @@ let LIVE = false, feedStale = false, lastSnapshot = null, lastEndpoint = null;
let activeFeedAbort = null;
let feedRevision = 0;
let FEED_META = {source:"demo",relationships:[],handoffs:[]};
-let observatory;
+let observatory, commandCenter;
+let commandEvidence = {agents:[]}, lastCommandClock = 0;
let stableLayout;
let historyLayout = null, historyLiveSignature = '';
let sceneSolids = [], scenePonds = [], sceneDistricts = [], sceneRoads = [], townCounts = new Map();
let villageSquare = null;
+let familyPaths = [];
let showSettled = false;
-function clearFeedState(){
+function clearFeedState(clearView=false){
const next=blankFeedState();
lastSnapshot=next.snapshot;lastEndpoint=next.endpoint;FEED_META=next.meta;
+ if(clearView){
+ heartbeat.reset();townTrades.reset();currentTradeSites=[];
+ familyPaths=[];layoutSignature="";
+ // A new endpoint must never inherit the old source's question or transport.
+ feedStale=true;LIVE=false;agents=[];selectedId=null;filter=null;
+ commandEvidence={agents:[],stale:true};
+ observatory?.update([],{...FEED_META,stale:true});
+ updateCommandCenter();renderPanel();
+ }
}
function townKey(ag){
@@ -126,7 +146,7 @@ async function fetchAgents(){
try{const u=new URL(endpoint,location.href);bundledEmpty=!data.stale&&!data.agents.length&&u.origin===location.origin&&u.pathname==="/agents"&&(!data.source||data.source==="none");}catch{}
if(bundledEmpty){builtInDemo=true;LIVE=false;feedStale=false;clearFeedState();FEED_META=DEMO_META;feedNote("local feed empty. demo townmap until cottages show up.");return SIM.snapshot();}
builtInDemo=false;LIVE=true;feedStale=!!data.stale;FEED_META=data;
- const list=data.agents.filter(a=>a&&typeof a==="object").map((a,i)=>normalizeCottage(data.stale?{...a,pr:{...a.pr,stale:true,reason:"The feed is stale; PR readiness is unverified."}}:a,i,{town:normalizeTown,model:shortModel,occupancy:classifyOccupancy}));
+ const list=data.agents.filter(a=>a&&typeof a==="object").map((a,i)=>normalizeCottage(data.stale?{...a,pr:staleFeedPr(a.pr,"The feed is stale; PR readiness is unverified.")}:a,i,{town:normalizeTown,model:shortModel,occupancy:classifyOccupancy}));
lastSnapshot=list;lastEndpoint=endpoint;
feedNote(feedStatusNote(list,{source:data.source,stale:feedStale}),feedStale?"#e7b778":"#94c99e");
return list;
@@ -134,7 +154,7 @@ async function fetchAgents(){
if(!current())return null;
feedStale=true;
const staleSnapshot=snapshotForEndpoint({snapshot:lastSnapshot,endpoint:lastEndpoint},endpoint);
- if(staleSnapshot){LIVE=true;feedNote("Connection interrupted · showing last live snapshot. Retrying…","#e7b778");return staleSnapshot.map(a=>({...a,pr:normalizePr({...a.pr,stale:true,reason:"Feed connection interrupted; PR readiness is unverified."})}));}
+ if(staleSnapshot){LIVE=true;feedNote("Connection interrupted · showing last live snapshot. Retrying…","#e7b778");return staleSnapshot.map(a=>({...a,pr:normalizePr(staleFeedPr(a.pr,"Feed connection interrupted; PR readiness is unverified."))}));}
if(builtInDemo){LIVE=false;feedStale=false;FEED_META=DEMO_META;feedNote("local feed unavailable. demo townmap until cottages show up.","#e7b778");return SIM.snapshot();}
LIVE=false;feedNote("Feed unavailable · "+err.message+". Retrying…","#e7b778");
return [];
@@ -235,6 +255,7 @@ const SIM = (() => {
}));
agents.forEach(a=>{
a.cost = a.tokens * RATE;
+ if(a.status==='working'&&Number(a.id.slice(1))%3===0)a.toolActivity={name:'Read',state:'succeeded',at:now,source:'demo',stale:false};
a.taskId="demo-task-"+a.id;
a.originalAsk="Please "+a.task+". Check the existing behavior, make the smallest useful change, and leave a clear result I can review.";
a.taskStartedAt=a.startedAt;a.sessionStartedAt=a.startedAt-8*60000;a.updatedAt=now;
@@ -262,6 +283,7 @@ const SIM = (() => {
a.branch = mum ? mum.branch : "main";
}
});
+ let rehearsal=0;
const FOLKLORE_PINNED = new Set([
"Moss", "Sable", "Reed", "Vera", "Cass", "Wren", "Ruth", "Kip", "Gus", "Marge", "Otto",
]);
@@ -471,6 +493,25 @@ const SIM = (() => {
);
a.events=a.events.slice(-100);return true;
},
+ rehearseGrowth(town='HubTown'){
+ // Authored simulation only: never writes to a connected task or telemetry feed.
+ if(rehearsal>=6)return false;
+ const parent=agents.find(a=>a.town===town&&!a.parent)||agents[0],stamp=Date.now();rehearsal++;
+ for(let i=0;i<3;i++){
+ const id=`demo-growth-${rehearsal}-${i}`,name=['Flint','Fern','Cobble'][i]+(rehearsal>1?' '+rehearsal:'');
+ const a={...parent,id,name,parent:i===2?parent.id:null,roommates:[],worktreePath:null,
+ branch:`demo/growth-${rehearsal}-${i}`,taskId:'demo-task-'+id,sessionId:'demo-session-'+id,
+ status:'working',occupancy:'live',endedAt:0,inputRequest:null,attention:'',tokens:0,cost:0,
+ task:['Read the neighbourhood plans','Search for the new garden plots','Connect the new workshop'][i],
+ originalAsk:'Illustrative village growth rehearsal.',activity:'Illustrative resident gathering material.',
+ startedAt:stamp,taskStartedAt:stamp,sessionStartedAt:stamp,lastProgressAt:stamp,updatedAt:stamp,
+ toolActivity:{name:i===1?'Grep':'Read',state:'succeeded',at:stamp,source:'demo',stale:false},
+ pr:{state:'none',source:'demo',checkedAt:stamp},events:[]};
+ a.todos=demoTodos(a,stamp);agents.push(a);
+ }
+ return true;
+ },
+ get canRehearseGrowth(){return rehearsal<6;},
setLive(v){ live = v; }
};
})();
@@ -542,7 +583,9 @@ const mapViewport = createMapViewport({canvas:cv,viewport:document.getElementByI
surface:document.getElementById('map-surface'),referenceWidth:MAP_REFERENCE_WIDTH,getObscured:()=>mapDisplay?.obscured()||{}});
const packingWidth=townWidthBudget({windowWidth:window.innerWidth,standardWidth:document.getElementById('map-viewport').clientWidth});
stableLayout=createTownLayout({maxWidth:packingWidth});
-mapDisplay = createMapDisplay({camera:mapViewport,canvas:cv,getMode:()=>observatory?.mode||'town',onOverview:()=>{
+mapDisplay = createMapDisplay({camera:mapViewport,canvas:cv,getMode:()=>observatory?.mode||'town',
+ getWorkspaceView:()=>commandCenter?.state.view||'town',
+ focusWorkspace:()=>document.getElementById('command-view').focus({preventScroll:true}),onOverview:()=>{
observatory?.unfollow();
return mapViewport.overview(overviewBounds(plots.filter(p=>(!filter||filter===townKey(p.agent))&&(!observatory||observatory.visible(p.agent)))));
}});
@@ -714,6 +757,34 @@ function dirtPath(p, pts, w){
}
}
+function drawParentPaths({highlight=false,night=false}={}){
+ for(const path of familyPaths){
+ if(filter && (townKey(path.parent)!==filter || townKey(path.child)!==filter))continue;
+ if(observatory && (!observatory.visible(path.parent) || !observatory.visible(path.child)))continue;
+ const selected=[path.parent.id,path.child.id,path.parentHost,path.childHost].includes(selectedId);
+ const hovered=[path.parent.id,path.child.id].includes(hoverId);
+ if(highlight && !selected && !hovered)continue;
+ const pts=path.points.map(p=>[p.x,p.y]);
+ if(!highlight && path.kind==='yard'){
+ dirtPath(px,pts,3);
+ continue;
+ }
+ // Sparse paving stays quiet across the wider town. A selected family gains a
+ // continuous warm thread that remains readable after the night palette.
+ ctx.globalAlpha=highlight?(selected ? .8 : .5):.65;
+ const color=highlight?(night?'#cbb985':'#9a713d'):C.stone;
+ for(let i=1;i
[p.agent.id,p.x,p.y,(p.agent.roommates||[]).map(m=>m.id),p.kids.map(k=>k.agent.id)]))+W+":"+H;
- if(signature!==layoutSignature){paintBackground();layoutSignature=signature;}
+ const signature=JSON.stringify(plots.map(p=>[p.agent.id,p.agent.parent,p.x,p.y,(p.agent.roommates||[]).map(m=>[m.id,m.parent]),p.kids.map(k=>[k.agent.id,k.agent.parent])]))+W+":"+H;
+ if(signature!==layoutSignature){
+ familyPaths=parentPaths(plots,world,{houseHeight:HOUSE_H,laneOffset:LANE_Y+(LANE_H>>1)});
+ paintBackground();layoutSignature=signature;
+ }else{
+ // Geometry is stable, but filters must evaluate the latest endpoint states.
+ const current=new Map(vis.map(a=>[a.id,a]));
+ familyPaths=familyPaths.map(p=>({...p,parent:current.get(p.parent.id)||p.parent,child:current.get(p.child.id)||p.child}));
+ }
if(!fauna.length)stockTown();
placeJack();
}
@@ -1671,11 +1749,16 @@ function placeJack(){
function draw(){
t = performance.now()/1000;
frameDt=Math.min(.05,lastDrawAt?t-lastDrawAt:1/60);lastDrawAt=t;
+ // Clock labels age independently from feed polling, including a paused feed.
+ if(t-lastCommandClock>=1){lastCommandClock=t;updateCommandCenter();}
const villageLight=observatory?.lightAt(t);
bedtimeFrames=bedtime.update(plots,villageLight,{time:t,reduce,present:agents,source:layoutSource||feedNamespace(ENDPOINT,builtInDemo)});
const bedtimeLabel=villageLifeLabel(villageLight,bedtimeFrames);
if(bedtimeLabel!==lastBedtimeLabel){const note=document.getElementById('village-life');if(note)note.textContent=bedtimeLabel;cv.setAttribute('aria-description',activityLabel()+'. '+bedtimeLabel+'. Warm windows mark agents still working.');lastBedtimeLabel=bedtimeLabel;}
ctx.drawImage(bg, 0, 0);
+ const heartbeatScene={districts:sceneDistricts,plots:plots.filter(p=>!observatory||observatory.visible(p.agent)),tasks:commandCenter?.tasks||[],time:t,reduce,stale:feedStale||paused,townFilter:filter};
+ heartbeat.drawGround(ctx,heartbeatScene);
+ drawParentPaths();
TOWNS.forEach((d, di)=>{
const r = DR[di];
@@ -1795,6 +1878,9 @@ function draw(){
ctx.strokeRect(p.x-1.5, p.y-1.5, HOUSE_W+3, HOUSE_H+3);
}
});
+ heartbeat.drawLife(ctx,heartbeatScene);
+ const tradeScene={...heartbeatScene,trades:currentTrades,roads:sceneRoads};
+ drawTradeLife(ctx,tradeScene);currentTradeSites=tradeSites(tradeScene);
moveFauna();
drawFauna();
observatory?.drawAtmosphere(ctx,{width:W,height:H,ponds:scenePonds,trees:sceneSolids.filter(r=>r.w===6&&r.h===8),plots:plots.map(p=>{
@@ -1819,6 +1905,7 @@ function draw(){
ctx.globalAlpha=1;
}
drawTownCounts(nightInk>0);
+ drawParentPaths({highlight:true,night:nightInk>0});
// Operational colors are drawn after the blue palette, without daylight holes.
for(const p of plots){
ctx.globalAlpha=(filter&&filter!==townKey(p.agent))||!observatory?.visible(p.agent)?0.3:1;
@@ -1844,6 +1931,7 @@ function draw(){
drawThought(Math.round(a.x)+5, a.indoors?p.y+20:Math.round(a.y)-2, thought, revealed);
});
+ commandCenter?.drawLens(ctx,plots,{selectedId,hoverId,townFilter:filter,visible:a=>!observatory||observatory.visible(a),time:t,reduce});
observatory?.draw(t);
requestAnimationFrame(draw);
}
@@ -1862,6 +1950,11 @@ function hit(evt){
}
return null;
}
+function hitTrade(evt){
+ if(observatory?.mode==='room')return null;
+ const {x,y}=mapViewport.pointAtEvent(evt);
+ return currentTradeSites.find(site=>x>=site.x&&x=site.y&&y{ hoverId = hit(e); cv.style.cursor = hoverId ? "pointer":"default"; });
+cv.addEventListener("mousemove", e=>{ hoverId = hit(e); cv.style.cursor = hoverId||hitTrade(e) ? "pointer":"default"; });
cv.addEventListener("mouseleave", ()=>{ hoverId = null; });
cv.addEventListener("click", e=>{
+ const site=hitTrade(e);if(site){commandCenter?.inspectTrade(site);return;}
if(observatory?.handleClick(e))return;
const id=hit(e);if(id){selectedId=id;renderPanel();mapDisplay.showDetails();}
});
cv.addEventListener("dblclick", e=>{
+ if(hitTrade(e)){e.preventDefault();return;}
const id = hit(e);
if(!id || id === JACK_ID) return;
e.preventDefault();
@@ -1908,11 +2003,12 @@ function clock(ms){
return new Date(ms).toLocaleTimeString([], {hour:"2-digit", minute:"2-digit"});
}
function money(n){
+ if(!Number.isFinite(n))return 'Unavailable';
return n >= 1 ? `$${n.toFixed(2)}` : `${(n*100).toFixed(1)}c`;
}
function burnRate(ag){
const mins = ag.taskStartedAt ? (Date.now() - ag.taskStartedAt) / 60000 : 0;
- return mins > 0.5 ? money(ag.cost/mins) + "/min" : "—";
+ return Number.isFinite(ag.cost)&&mins > 0.5 ? money(ag.cost/mins) + "/min" : "—";
}
function renderJackPanel(){
const pile = letters();
@@ -1949,6 +2045,7 @@ function launchHtml(ag){
}
function renderPanel(){
+ commandCenter?.select(selectedId);
if(observatory?.renderPanel(selectedId))return;
panel.dataset.view='fallback:'+selectedId;
if(selectedId === JACK_ID){
@@ -1957,7 +2054,7 @@ function renderPanel(){
}
const ag = agents.find(a=>a.id===selectedId);
if(!ag){
- panel.innerHTML = `Click a cottage to inspect, or focus the map and use arrow keys to walk. E enters a nearby door.
`;
+ panel.innerHTML = `Click a cottage to inspect, or focus the map and use arrow keys to walk. Walk into a doorway to enter.
`;
return;
}
const d = townStyle(ag);
@@ -1969,6 +2066,13 @@ function renderPanel(){
? `${pr.state === "merged" ? "merged" : "open"} ${pr.number ? "#"+pr.number : ""} ${pr.title || ""}`.trim()
: "none we can see";
const startedOk = ag.startedAt && ag.startedAt > Date.parse("2020-01-01");
+ const sessionUsage=ag.usage?.scope==='session'?ag.usage:null;
+ const sessionTotals=!!sessionUsage||ag.conversationTarget?.recordKind==='external_session';
+ const displayTokens=sessionUsage?sessionUsage.tokens?.totalTokens:ag.tokens;
+ const displayCost=sessionUsage?sessionUsage.cost?.usd:ag.cost;
+ const costNote=sessionTotals?(sessionUsage?.cost?.complete?'recorded session':'partial session coverage'):(live?burnRate(ag):'this run');
+ const tokenNote=sessionUsage&&(ag.usageCoverage?.reason==='partial'||sessionUsage.incompleteFields?.length||sessionUsage.lowerBound?.length)?' · partial':'';
+ const context=ag.contextUsage;
panel.innerHTML = `
${esc(ag.name)}
${mum ? `shed in ${esc(mum.name)}'s yard` : (d.key === "HubTown" ? "HubTown · home" : esc(d.key))}
@@ -1983,8 +2087,10 @@ function renderPanel(){
Model ▮ ${esc(ag.model)}
Sent by ${mum ? `${esc(mum.name)} (agent) ` : esc(ag.dispatchedBy)}
Started ${startedOk ? `${clock(ag.startedAt)} · ${uptime(ag.startedAt)} ago ` : "-"}
- Cost ${money(ag.cost)} · ${live ? burnRate(ag) : "this run"}
- Tokens ${ag.tokens.toLocaleString()}
+ ${sessionTotals?'Session cost':'Cost'} ${money(displayCost)}${Number.isFinite(displayCost)?` · ${costNote} `:''}
+ ${sessionTotals?'Session tokens':'Tokens'} ${Number.isFinite(displayTokens)?displayTokens.toLocaleString()+tokenNote:'Unavailable'}
+ ${ag.usage?.scope==='attempt'?`Attempt tokens ${Number.isFinite(ag.usage.tokens?.totalTokens)?ag.usage.tokens.totalTokens.toLocaleString():'Unavailable'}${ag.usageCoverage?.reason==='partial'?' · partial':''} `:''}
+ ${context?`Context ${Number.isFinite(context.usedTokens)?context.usedTokens.toLocaleString():'?'} / ${Number.isFinite(context.windowTokens)?context.windowTokens.toLocaleString():'?'} tokens${context.stale?' · last recorded ':''} `:''}
${kids.length ? `Spawned ${kids.map(k=>`● ${esc(k.name)} ${money(k.cost)} `).join(" ")} ` : ""}
${esc(ag.result || ag.lastLine || "")}
@@ -2019,9 +2125,10 @@ function renderTally(){
const n = vis.filter(a=>townKey(a)===d.key).length;
const busy = vis.filter(a=>townKey(a)===d.key && a.status==="working").length;
const b = document.createElement("button");
+ b.dataset.townScope=d.key;
b.setAttribute("aria-pressed", filter===d.key ? "true":"false");
b.innerHTML = ` ${esc(d.key === "HubTown" ? "HubTown home" : d.key)}${busy}/${n} `;
- b.onclick = ()=>{ filter = (filter===d.key) ? null : d.key; renderTally(); };
+ b.onclick = ()=>{ focusTown(filter===d.key?null:d.key); };
tally.appendChild(b);
});
const mailN = letters().length;
@@ -2033,8 +2140,10 @@ function renderTally(){
const busy = vis.filter(a=>a.status==="working").length;
const spend = vis.filter(a=>a.occupancy==="live" || a.occupancy==="recent").reduce((n,a)=>n + (a.cost||0), 0);
const settledN = agents.filter(a=>a.occupancy==="settled").length;
- document.querySelector(".sub").textContent =
- `${busy} working · ${money(spend)} this window` +
+ const summary=document.querySelector(".sub");
+ summary.title='Recorded cost for visible live/recent tasks. Running usage and sources without pricing may be missing; this is not spend within a time interval.';
+ summary.textContent =
+ `${busy} working · ${money(spend)} recorded · partial` +
(mailN ? ` · ${mailN} letter${mailN===1?"":"s"}` : "") +
(settledN ? ` · ${settledN} settled` : "");
const sb = document.getElementById("settled");
@@ -2044,6 +2153,7 @@ function renderTally(){
}
const folkloreHint = document.getElementById("folklore-hint");
if(folkloreHint) folkloreHint.hidden = !(builtInDemo || PUBLIC_DEMO);
+ updateCommandCenter();
}
const noteEl = document.getElementById("note");
function feedNote(msg, color){ noteEl.textContent = msg; noteEl.style.color = color || "#93a1b0"; }
@@ -2057,9 +2167,9 @@ document.getElementById("connect").onclick = ()=>{
const v = document.getElementById("endpoint").value.trim();
if(!v){
if(ENDPOINT!==null){
- activeFeedAbort?.abort();feedRevision++;clearFeedState();
+ activeFeedAbort?.abort();feedRevision++;clearFeedState(true);
}
- ENDPOINT=null;builtInDemo=true;stableLayout.reset();historyLayout=null;layoutSignature="";feedNote("Back on the demo townmap.");return refresh({latest:true});
+ ENDPOINT=null;syncFeedNotifications();builtInDemo=true;stableLayout.reset();historyLayout=null;layoutSignature="";feedNote("Back on the demo townmap.");return refresh({latest:true});
}
if(!isAllowedFeedUrl(v)){
feedNote("feed URL must be http(s). data: and other schemes are blocked.", "#e2504a");
@@ -2067,9 +2177,9 @@ document.getElementById("connect").onclick = ()=>{
}
const nextEndpoint=new URL(v,location.href).href;
if(nextEndpoint!==ENDPOINT){
- activeFeedAbort?.abort();feedRevision++;clearFeedState();
+ activeFeedAbort?.abort();feedRevision++;clearFeedState(true);
}
- ENDPOINT=nextEndpoint;builtInDemo=false;stableLayout.reset();historyLayout=null;layoutSignature="";feedNote("connecting...");refresh({latest:true});
+ ENDPOINT=nextEndpoint;syncFeedNotifications();builtInDemo=false;stableLayout.reset();historyLayout=null;layoutSignature="";feedNote("connecting...");refresh({latest:true});
};
document.getElementById("endpoint").addEventListener("keydown", e=>{
if(e.key==="Enter") document.getElementById("connect").click();
@@ -2083,20 +2193,35 @@ document.getElementById("pause").onclick = (e)=>{
paused = !paused; SIM.setLive(!paused);
e.target.setAttribute("aria-pressed", String(paused));
e.target.textContent = paused ? "resume feed" : "pause feed";
+ syncFeedNotifications();
+ if (!paused) refresh({latest:true});
};
+const feedNotifications=createFeedNotifications({origin:location.origin,onChange(){
+ if(!paused&&!PUBLIC_DEMO)refresh({latest:true});
+}});
+function syncFeedNotifications(){feedNotifications.sync(ENDPOINT,{enabled:!paused&&!PUBLIC_DEMO});}
+addEventListener("pagehide",()=>feedNotifications.stop());
+addEventListener("pageshow",syncFeedNotifications);
+
const refresh=createLatestRefresh(async ()=>{
const incoming=await fetchAgents();
if(incoming===null)return true;
agents=incoming;
const nextSource=feedNamespace(ENDPOINT,builtInDemo);
if(nextSource!==layoutSource){layoutSource=nextSource;stableLayout.reset();historyLayout=null;layoutSignature="";fauna.length=0;}
+ commandEvidence=commandSnapshot(agents,FEED_META,{stale:feedStale});
+ const canonicalPrs=new Map(commandEvidence.agents.map(agent=>[agent.id,agent.pr]));
+ agents=agents.map(agent=>({...agent,pr:canonicalPrs.get(agent.id)}));
+ townTrades.observeSnapshot({tasks:fleetView(commandEvidence,{includeSettled:true}).tasks,relationships:FEED_META.relationships,handoffs:FEED_META.handoffs},{sourceKey:nextSource,stale:feedStale});
layout();observatory?.update(agents,{...FEED_META,stale:feedStale});renderTally();renderPanel();
return false;
});
observatory=createObservatory({
canvas:cv,ctx,getAgents:()=>agents,getPlots:()=>plots,getEndpoint:()=>ENDPOINT,isBuiltInDemo:()=>builtInDemo,getSourceKey:()=>feedNamespace(ENDPOINT,builtInDemo),
+ getEvidence:id=>commandEvidence.agents.find(a=>a.id===id),
+ activateTown:()=>commandCenter?.setView('town'),
getBedtimeRoutine:id=>routineForAgent(bedtimeFrames,agents.find(agent=>agent.id===id)),
getResidents:()=>residentTargets(actors,plots).concat([...bedtimeFrames].flatMap(([parentId,r])=>{
if(!plots.some(plot=>plot.agent.id===parentId))return [];
@@ -2107,11 +2232,57 @@ observatory=createObservatory({
getWorld:()=>({width:W,height:H,solids:sceneSolids,ponds:scenePonds,districts:sceneDistricts,roadX:VERT_ROAD,roadYs:HORZ_ROADS,roads:sceneRoads,road:ROAD,square:villageSquare,jack:jackPlot,showSettled}),
select(id){selectedId=id;renderPanel();},drawJack,
answerDemo:(id,text,requestId)=>SIM.answer(id,text,requestId),refresh,
+ onMilestones(events,meta){heartbeat.observe(events,{...meta,now:performance.now()/1000});},
viewport:mapViewport,showDetails:mapDisplay.showDetails,onModeChange:mapDisplay.syncZoom,
isMapFullscreen:mapDisplay.isActive,toggleMapFullscreen:mapDisplay.toggle,exitMapFullscreen:mapDisplay.exit
});
+function focusTown(town){
+ filter=town||null;
+ renderTally();
+ if(observatory?.mode==='room')observatory.leave({activate:false});
+ observatory?.unfollow();
+ const bounds=projectBounds(plots.filter(p=>!observatory||observatory.visible(p.agent)),filter);
+ if(bounds)mapViewport.overview(bounds);
+ mapDisplay.syncZoom();
+ if(filter){
+ const candidates=(commandCenter?.tasks||[]).filter(task=>(task.raw.town||task.raw.role)===filter);
+ if(!candidates.some(task=>task.id===selectedId)){
+ const first=candidates.find(task=>task.input)||candidates.find(task=>task.status==='working')||candidates[0];
+ if(first)observatory.inspectCottage(first.id,{tab:first.input?'talk':'overview'});
+ }
+ }
+}
+function updateCommandCenter(){
+ currentTrades=townTrades.snapshot({tasks:fleetView(commandEvidence,{includeSettled:showSettled}).tasks,stale:feedStale});
+ commandCenter?.update({snapshot:commandEvidence,endpoint:ENDPOINT,sourceKey:feedNamespace(ENDPOINT,builtInDemo),
+ builtInDemo,publicDemo:PUBLIC_DEMO,stale:feedStale,selectedId,townFilter:filter,showSettled,trades:currentTrades,canRehearseGrowth:SIM.canRehearseGrowth});
+}
+commandCenter=createCommandCenter({root:document.getElementById('map-shell'),
+ onInspect(id,tab){observatory.inspectCottage(id,{tab});},
+ onLocate(id){
+ commandCenter.setView('town');
+ if(observatory.mode==='room')observatory.leave({activate:false});
+ mapViewport.setZoom(100);mapDisplay.syncZoom();observatory.focusCottage(id);
+ },
+ onTownFilter:focusTown,
+ onRehearseGrowth(){if(!(builtInDemo||PUBLIC_DEMO))return;if(SIM.rehearseGrowth(filter||'HubTown'))refresh({latest:true});},
+ onBalanceChange(){mapViewport.capture();requestAnimationFrame(()=>mapViewport.resize());},
+ onViewChange(view){
+ const alternate=view!=='town',viewport=document.getElementById('map-viewport');
+ mapViewport.capture();
+ observatory.setSuspended(false);
+ viewport.inert=false;viewport.style.visibility='';
+ viewport.setAttribute('aria-hidden','false');
+ document.getElementById('command-view').hidden=!alternate;
+ document.getElementById('map-shell').dataset.workspaceView=view;
+ requestAnimationFrame(()=>mapViewport.resize());
+ mapDisplay.syncZoom();
+ if(alternate)document.getElementById('command-view').focus({preventScroll:true});
+ if(!alternate)document.getElementById(observatory.mode==='room'?'room-canvas':'town').focus({preventScroll:true});
+ }
+});
window.cottageObservatory=observatory;
-window.cottageState=()=>({agents,plots:plots.map(p=>({id:p.agent.id,x:p.x,y:p.y,blockId:p.blockId,roommates:(p.agent.roommates||[]).map(m=>m.id),kids:p.kids})),districts:sceneDistricts,roads:sceneRoads,townCounts:Object.fromEntries(townCounts),packingWidth,solids:sceneSolids,ponds:scenePonds,fauna,bedtime:[...bedtimeFrames].map(([id,routine])=>({id,...routine})),live:LIVE,stale:feedStale,width:W,height:H,scene:observatory.state,viewport:mapViewport.state,display:mapDisplay.state});
+window.cottageState=()=>({agents,plots:plots.map(p=>({id:p.agent.id,x:p.x,y:p.y,blockId:p.blockId,roommates:(p.agent.roommates||[]).map(m=>m.id),kids:p.kids})),districts:sceneDistricts,roads:sceneRoads,townCounts:Object.fromEntries(townCounts),packingWidth,solids:sceneSolids,ponds:scenePonds,fauna,bedtime:[...bedtimeFrames].map(([id,routine])=>({id,...routine})),live:LIVE,stale:feedStale,width:W,height:H,scene:observatory.state,viewport:mapViewport.state,display:mapDisplay.state,command:commandCenter.state,heartbeat:heartbeat.state,trades:currentTrades,tradeSites:currentTradeSites});
(function boot(){
const previewNight=()=>{
@@ -2155,6 +2326,7 @@ window.cottageState=()=>({agents,plots:plots.map(p=>({id:p.agent.id,x:p.x,y:p.y,
previewNight();
}
})();
+syncFeedNotifications();
refresh();
setInterval(()=>{ if(!paused) refresh(); }, POLL_MS);
requestAnimationFrame(draw);
diff --git a/src/viewport.mjs b/src/viewport.mjs
index 70107bf..60c293a 100644
--- a/src/viewport.mjs
+++ b/src/viewport.mjs
@@ -3,15 +3,19 @@ export const ZOOM_LEVELS = Object.freeze([50, 75, 100, 125, 150, 200]);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
export function nextZoom(current, direction) {
- const index = ZOOM_LEVELS.indexOf(current);
- return ZOOM_LEVELS[clamp((index < 0 ? 2 : index) + Math.sign(direction), 0, ZOOM_LEVELS.length - 1)];
+ if (!direction) return current;
+ if (direction > 0) return ZOOM_LEVELS.find(level => level > current) ?? ZOOM_LEVELS.at(-1);
+ // An automatic Overview fit may sit below the manual zoom range. Keep it
+ // there on minus; plus returns to the first ordinary step without jumping.
+ if (current < ZOOM_LEVELS[0]) return current;
+ return ZOOM_LEVELS.filter(level => level < current).at(-1) ?? ZOOM_LEVELS[0];
}
-/** Overview never enlarges cottages beyond their normal scale. */
+/** Overview fits separated districts too, without enlarging beyond normal scale. */
export function overviewZoom(bounds, {width,height,right=0,bottom=0,baseScale,padding=24}) {
const fit = Math.min(Math.max(1,width-right-padding*2)/Math.max(1,bounds.w),
Math.max(1,height-bottom-padding*2)/Math.max(1,bounds.h))/baseScale*100;
- return ZOOM_LEVELS.filter(level => level <= Math.min(100,fit)).at(-1) || ZOOM_LEVELS[0];
+ return ZOOM_LEVELS.filter(level => level <= Math.min(100,fit)).at(-1) || Math.max(.01,Math.floor(fit*100)/100);
}
export function zoomKey(event) {
diff --git a/test/command-observatory.test.mjs b/test/command-observatory.test.mjs
new file mode 100644
index 0000000..198719f
--- /dev/null
+++ b/test/command-observatory.test.mjs
@@ -0,0 +1,193 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {evidenceOverviewHtml,replyPresentation,reconcileReplyReceipt,cottageSelection} from '../src/observatory.mjs';
+import {createReplyClient} from '../src/command-replies.mjs';
+import {inputRequestVersion} from '../src/input-request.mjs';
+
+const endpoint='http://127.0.0.1:8790/agents';
+const now=Date.parse('2026-11-04T10:00:00Z');
+const agent={id:'selected-evidence',taskId:'task-selected',status:'blocked',terminal:false,
+ inputRequest:{id:'q1',prompt:'Which scope?',options:['Small','Full']},
+ conversation:{available:true,mode:'respond',messageUrl:'/agents/selected-evidence/messages',checkedAt:now}};
+const storage=()=>{const records=new Map();return {records,getItem:key=>records.get(key)??null,setItem:(key,value)=>records.set(key,value)};};
+
+test('evidence clocks distinguish task age, execution, session, observation and progress',()=>{
+ const html=evidenceOverviewHtml({...agent,task:'Ship ',harness:'codex',model:'gpt-6.1-sol-full-source-name',
+ taskStartedAt:now-7200000,sessionStartedAt:now-600000,durationMs:180000,
+ updatedAt:now-5000,lastProgressAt:now-120000},{now,endpoint});
+ for(const value of ['Task age2h 0m','Recorded execution3m 0s',
+ 'Session age10m 0s','Last observed5s ago','Last progress2m 0s ago',
+ 'Harnesscodex','gpt-6.1-sol-full-source-name','data-action="talk"','Ship <feature>'])assert.ok(html.includes(value),value);
+ assert.doesNotMatch(html,/Ship /);
+});
+
+test('absent execution and progress are not inferred from observation or model',()=>{
+ const html=evidenceOverviewHtml({id:'a',status:'working',model:'codex-model',updatedAt:now},{now,endpoint});
+ assert.doesNotMatch(html,/Recorded execution/);
+ assert.match(html,/Harness<\/dt>Unknown/);
+ assert.match(html,/Task age<\/dt>Unavailable/);
+ assert.match(html,/Last progress<\/dt>Unavailable/);
+});
+test('current turn clocks stay separate from unavailable task and session starts',()=>{
+ const observed={id:'turn',status:'working',inventorySource:'codex_rollout',turnStartedAt:now-60000,taskStartedAt:null,sessionStartedAt:null};
+ const html=evidenceOverviewHtml(observed,{now,endpoint});
+ assert.match(html,/Current turn age<\/dt>1m 0s/);assert.match(html,/Current turn started<\/dt>/);
+ assert.match(html,/Task age<\/dt>Unavailable/);assert.match(html,/Session age<\/dt>Unavailable/);
+ const done=evidenceOverviewHtml({...observed,status:'done',terminal:true,endedAt:now-30000},{now:now+60000,endpoint});
+ assert.match(done,/Current turn age<\/dt>30s/,'finished turn age does not keep advancing');
+ const missing=evidenceOverviewHtml({...observed,turnStartedAt:null},{now,endpoint});
+ assert.match(missing,/Current turn age<\/dt>Unavailable/);assert.match(missing,/Current turn started<\/dt>Unavailable/);
+});
+
+test('observed usage labels cumulative session totals and keeps unpriced cost unavailable',()=>{
+ const html=evidenceOverviewHtml({...agent,inventorySource:'codex_rollout',tokens:null,cost:null,
+ usage:{scope:'session',tokens:{totalTokens:210},incompleteFields:[],cost:{usd:null,sources:[],complete:false},measuredAt:null},
+ contextUsage:{usedTokens:100,windowTokens:200000,stale:true}},{now,endpoint});
+ assert.match(html,/Session tokens<\/dt>210/);
+ assert.match(html,/Session cost<\/dt>Unavailable/);
+ assert.match(html,/Cumulative across this session/);
+ assert.match(html,/Usage measurement time is unavailable/);
+ assert.match(html,/Context<\/dt>100 \/ 200,000 tokens · last recorded/);
+ assert.doesNotMatch(html,/\$0\.00|0\.0c/);
+ const missing=evidenceOverviewHtml({...agent,inventorySource:'codex_rollout'},{now,endpoint});
+ assert.match(missing,/Session tokens<\/dt>Unavailable/);
+});
+
+test('attempt subtotals retain partial and estimated qualifiers including a measured zero',()=>{
+ const html=evidenceOverviewHtml({...agent,usage:{scope:'attempt',tokens:{totalTokens:0},incompleteFields:['totalTokens'],
+ cost:{usd:0,sources:['estimate'],complete:false},measuredAt:null}},{now,endpoint});
+ assert.match(html,/Attempt tokens<\/dt>0 · partial/);
+ assert.match(html,/Attempt cost<\/dt>\$0\.00 · estimated · partial/);
+ assert.match(html,/Current attempt totals/);
+});
+test('a lower-bound usage bucket also marks the displayed session total partial',()=>{
+ const html=evidenceOverviewHtml({...agent,usageCoverage:{available:true,reason:'partial'},
+ usage:{scope:'session',tokens:{totalTokens:210},incompleteFields:['outputTokens'],lowerBound:['output'],cost:{usd:null}}},{now,endpoint});
+ assert.match(html,/Session tokens<\/dt>210 · partial/);
+});
+test('context-only evidence retains its attempt scope when usage is unavailable',()=>{
+ const html=evidenceOverviewHtml({...agent,usage:null,contextUsage:{scope:'attempt',usedTokens:100,windowTokens:200000}},{now,endpoint});
+ assert.match(html,/Attempt tokens<\/dt>Unavailable/);
+ assert.match(html,/Current attempt totals/);
+ assert.doesNotMatch(html,/Cumulative across this session/);
+});
+
+test('uncertain and active durable receipts remain composer locks regardless of draft text',()=>{
+ for(const phase of ['checking','sending','uncertain']){
+ const result=replyPresentation(agent,{phase,notice:'Check delivery'});
+ assert.equal(result.locked,true);
+ assert.equal(result.phase,phase==='uncertain'?'unconfirmed':'sending');
+ }
+ const sent={phase:'sent',inputVersion:inputRequestVersion(agent.inputRequest)};
+ assert.equal(replyPresentation(agent,sent).locked,true);
+ assert.equal(replyPresentation({...agent,inputRequest:{...agent.inputRequest,prompt:'Changed question'}},sent).locked,false);
+ assert.equal(replyPresentation({...agent,conversation:{mode:'redirect'},inputRequest:null},sent).locked,false);
+});
+
+test('a lost reply receipt remains locked after recreating the client and editing the answer',async()=>{
+ const saved=storage();let posts=0;
+ const fetchImpl=async (_url,options)=>{
+ if(options.method==='POST'){posts++;throw new Error('Lost connection');}
+ return {ok:true,json:async()=>({agents:[agent]})};
+ };
+ const first=createReplyClient({endpoint,storage:saved,fetchImpl,now:()=>now});
+ const result=await first.send(agent,'Small');
+ assert.equal(result.phase,'uncertain');assert.equal(posts,1);
+ const revisited=createReplyClient({endpoint,storage:saved,fetchImpl,now:()=>now});
+ assert.deepEqual(replyPresentation(agent,revisited.state(agent.taskId)),{
+ phase:'unconfirmed',locked:true,notice:result.notice});
+ assert.equal((await revisited.send(agent,'Different text')).phase,'uncertain');
+ assert.equal(posts,1,'editing never creates a second write after uncertain delivery');
+});
+
+test('latest preflight rejects changed input before an inspector answer can be posted',async()=>{
+ let posts=0;
+ const client=createReplyClient({endpoint,storage:storage(),now:()=>now,fetchImpl:async(_url,options)=>{
+ if(options.method==='POST')posts++;
+ return {ok:true,json:async()=>({agents:[{...agent,inputRequest:{...agent.inputRequest,prompt:'A new question'}}]})};
+ }});
+ const result=await client.send(agent,'Small');
+ assert.equal(result.phase,'error');assert.match(result.notice,/question or its choices changed/i);assert.equal(posts,0);
+});
+
+test('polling preserves a selected evidence task while Jack occupies a different room',()=>{
+ const agents=[{id:'room-host',status:'working'},{...agent}];
+ assert.equal(cottageSelection(agents,{selected:agent.id,interiorId:'room-host'}),agent.id);
+ assert.equal(cottageSelection(agents.slice(0,1),{selected:agent.id,interiorId:'room-host'}),'room-host');
+});
+
+test('a new question draft unlocks when the preceding revision finishes preflight or sending',async t=>{
+ for(const outcome of ['error','sent'])await t.test(outcome,async()=>{
+ let release,postStarted;
+ const held=new Promise(resolve=>{release=resolve;});
+ const started=new Promise(resolve=>{postStarted=resolve;});
+ const nextAgent={...agent,inputRequest:{...agent.inputRequest,prompt:'A different question'}};
+ const client=createReplyClient({endpoint,storage:storage(),now:()=>now,fetchImpl:async(_url,options)=>{
+ if(options.method==='POST'){
+ postStarted();await held;
+ return {ok:true,json:async()=>({ok:true,delivery:'accepted',requestId:JSON.parse(options.body).requestId})};
+ }
+ if(outcome==='error')await held;
+ return {ok:true,json:async()=>({agents:[outcome==='error'?nextAgent:agent]})};
+ }});
+ const pending=client.send(agent,'Small');
+ if(outcome==='sent')await started;
+ const nextDraft={text:'My next answer',phase:'idle',notice:''};
+ reconcileReplyReceipt(nextDraft,nextAgent,client.state(agent.taskId));
+ assert.equal(nextDraft.phase,'sending');assert.equal(nextDraft.deliveryLocked,true);
+ release();assert.equal((await pending).phase,outcome);
+ reconcileReplyReceipt(nextDraft,nextAgent,client.state(agent.taskId));
+ assert.equal(nextDraft.deliveryLocked,false);
+ assert.equal(nextDraft.phase,outcome==='error'?'error':'idle');
+ assert.equal(nextDraft.text,'My next answer');
+ if(outcome==='error')assert.match(nextDraft.notice,/question or its choices changed/i);
+ });
+});
+
+test('receipt reconciliation preserves real uncertainty and accepted current-question locks',()=>{
+ const draft={text:'Preserve me',phase:'sending',receiptPending:true};
+ reconcileReplyReceipt(draft,agent,{phase:'uncertain',notice:'Check delivery'});
+ assert.equal(draft.phase,'unconfirmed');assert.equal(draft.deliveryLocked,true);
+ reconcileReplyReceipt(draft,agent,{phase:'idle'});
+ assert.equal(draft.phase,'unconfirmed','missing later receipt evidence cannot silently clear uncertainty');
+ const accepted={phase:'sent',notice:'Accepted',inputVersion:inputRequestVersion(agent.inputRequest)};
+ reconcileReplyReceipt(draft,agent,accepted);
+ assert.equal(draft.phase,'sent');assert.equal(draft.deliveryLocked,true);
+});
+
+test('inspector replies POST the exact normalized advertised same-origin custom route and save receipts',async t=>{
+ for(const [label,messageUrl,expected] of [
+ ['relative','/api/tasks/task-selected/messages','http://127.0.0.1:8790/api/tasks/task-selected/messages'],
+ ['absolute same-origin','http://127.0.0.1:8790/custom/task-selected/reply','http://127.0.0.1:8790/custom/task-selected/reply'],
+ ['query-bearing','/api/tasks/task-selected/messages?channel=review','http://127.0.0.1:8790/api/tasks/task-selected/messages?channel=review'],
+ ])await t.test(label,async()=>{
+ const shown={...agent,conversation:{...agent.conversation,messageUrl}},saved=storage(),posts=[];
+ const fetchImpl=async(url,options={})=>{
+ if(options.method==='POST'){
+ posts.push({url,options});
+ return {ok:true,json:async()=>({ok:true,delivery:'accepted',requestId:JSON.parse(options.body).requestId})};
+ }
+ return {ok:true,json:async()=>({agents:[shown]})};
+ };
+ const client=createReplyClient({endpoint,storage:saved,fetchImpl,now:()=>now});
+ const result=await client.send(shown,'Small');
+ assert.equal(result.phase,'sent');assert.equal(posts.length,1);
+ assert.equal(posts[0].url,expected);
+ assert.equal(client.state(shown.taskId).phase,'sent');
+ const stored=JSON.parse(saved.records.get(`cottagecode:command-center:reply:${new URL(endpoint).href}:task-selected`));
+ assert.equal(stored.phase,'sent');assert.equal(stored.receipt.requestId,posts[0].options.body&&JSON.parse(posts[0].options.body).requestId);
+ });
+});
+
+test('inspector preflight rejects a changed custom route or query before POST',async t=>{
+ for(const changedUrl of ['/api/tasks/task-selected/other','/api/tasks/task-selected/messages?channel=changed'])await t.test(changedUrl,async()=>{
+ const shown={...agent,conversation:{...agent.conversation,messageUrl:'/api/tasks/task-selected/messages?channel=review'}};let posts=0;
+ const latest={...shown,conversation:{...shown.conversation,messageUrl:changedUrl}};
+ const client=createReplyClient({endpoint,storage:storage(),now:()=>now,fetchImpl:async(_url,options={})=>{
+ if(options.method==='POST'){posts++;return {ok:true,json:async()=>({ok:true,delivery:'accepted',requestId:JSON.parse(options.body).requestId})};}
+ return {ok:true,json:async()=>({agents:[latest]})};
+ }});
+ const result=await client.send(shown,'Small');
+ assert.equal(result.phase,'error');assert.match(result.notice,/route changed/i);assert.equal(posts,0);
+ });
+});
diff --git a/test/observatory.test.mjs b/test/observatory.test.mjs
index d719376..94cb549 100644
--- a/test/observatory.test.mjs
+++ b/test/observatory.test.mjs
@@ -25,7 +25,8 @@ test('explicit history inspection and the current room survive inventory updates
];
assert.equal(cottageSelection(agents,{selected:'history',showSettled:true}),'history');
assert.equal(cottageSelection(agents,{selected:'history',showSettled:false}),'history');
- assert.equal(cottageSelection(agents,{selected:'working',interiorId:'history'}),'history');
+ assert.equal(cottageSelection(agents,{selected:'working',interiorId:'history'}),'working','inspected evidence stays independent of the occupied room');
+ assert.equal(cottageSelection(agents,{selected:'removed',interiorId:'history'}),'history','the room host remains a fallback when inspected evidence disappears');
assert.equal(cottageSelection(agents.slice(1),{selected:'history',interiorId:'history'}),'working');
assert.equal(cottageSelection([],{selected:'working'}),null);
});
diff --git a/test/public-demo.test.mjs b/test/public-demo.test.mjs
index e8a72b2..07a9413 100644
--- a/test/public-demo.test.mjs
+++ b/test/public-demo.test.mjs
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
import { dirname, join, posix } from 'node:path';
import { fileURLToPath } from 'node:url';
import vm from 'node:vm';
+import { createFeedNotifications } from '../src/live-feed.mjs';
import { buildDemo, normalizeBase } from '../scripts/build-demo.mjs';
import { isPublicDemoDocument, PUBLIC_DEMO } from '../src/runtime.mjs';
import { isPracticeDemo } from '../src/observatory.mjs';
@@ -64,7 +65,7 @@ test('default build contains the complete browser graph and no backend or local
for (const id of ['endpoint', 'connect']) assert.match(html, new RegExp('<[^>]+id="' + id + '"[^>]+disabled[^>]*>'));
assert.match(html, /src="\.\/modules\/town\.mjs"/);
assert.doesNotMatch(html, /(?:src|href)="\/modules\//);
- for (const name of ['town', 'runtime', 'observatory', 'interiors', 'world', 'pr', 'history', 'sound', 'occupancy', 'feed-client', 'village-extras', 'atmosphere', 'music', 'music-output', 'music-tracks', 'postcard'])
+ for (const name of ['town', 'runtime', 'observatory', 'interiors', 'world', 'pr', 'history', 'sound', 'occupancy', 'feed-client', 'village-extras', 'atmosphere', 'music', 'music-output', 'music-tracks', 'postcard', 'command-center', 'command-model', 'command-replies', 'command-styles', 'command-render'])
assert.ok(result.files.includes(name + '.mjs'), name + ' should be included');
const all = await listFiles(outDir), bundled = new Set(result.files);
assert.deepEqual(all.filter(path => path.includes('/modules/')).map(path => path.slice('cottagecode/modules/'.length)).sort(), result.files);
@@ -203,7 +204,7 @@ function controllerHarness({ publicDemo = true, search = '', endpoint = 'https:/
const feed = { hidden: false }, listeners = new Map();
const box = { value: endpoint, disabled: false, closest: () => feed, addEventListener: (name, fn) => listeners.set(name, fn) };
const connect = { disabled: false, onclick: null, click() { this.onclick(); } };
- const snapshot = [{ id: 'sample-resident' }], notes = [], requests = [], refreshes = [], resets = [];
+ const snapshot = [{ id: 'sample-resident' }], notes = [], requests = [], refreshes = [], resets = [], streams = [];
const context = vm.createContext({
PUBLIC_DEMO: publicDemo, ENDPOINT: endpoint, LIVE: true, feedStale: true, builtInDemo: false,
FEED_META: {}, DEMO_META: { source: 'demo' }, SIM: { snapshot: () => snapshot },
@@ -215,6 +216,12 @@ function controllerHarness({ publicDemo = true, search = '', endpoint = 'https:/
isAllowedFeedUrl: value => /^https?:\/\//.test(value), stableLayout: { reset: () => resets.push('reset') },
snapshotForEndpoint: ({ snapshot, endpoint: observed }, requested) => observed === requested ? snapshot : null,
lastSnapshot: null, lastEndpoint: null, layoutSignature: 'preserved', agents: [],
+ paused: false, addEventListener() {},
+ createFeedNotifications: options => createFeedNotifications({ ...options, EventSourceClass: class {
+ constructor(url) { streams.push(url); }
+ addEventListener() {}
+ close() {}
+ } }),
});
context.clearFeedState = () => {
context.lastSnapshot = null;
@@ -227,9 +234,11 @@ function controllerHarness({ publicDemo = true, search = '', endpoint = 'https:/
const connectStart = TOWN.indexOf('document.getElementById("connect").onclick'), connectEnd = TOWN.indexOf('document.getElementById("settled").onclick', connectStart);
assert.ok(connectStart >= 0 && connectEnd > connectStart, 'Connect section should be testable');
vm.runInContext(TOWN.slice(connectStart, connectEnd), context);
+ const pushStart = TOWN.indexOf('const feedNotifications='), pushEnd = TOWN.indexOf('const refresh=createLatestRefresh', pushStart);
+ vm.runInContext(TOWN.slice(pushStart, pushEnd), context);
const bootStart = TOWN.indexOf('(function boot(){'), bootEnd = TOWN.indexOf('\nrefresh();', bootStart);
assert.ok(bootStart >= 0 && bootEnd > bootStart, 'Boot section should be testable');
- return { context, feed, box, connect, snapshot, notes, requests, refreshes, resets, listeners,
+ return { context, feed, box, connect, snapshot, notes, requests, refreshes, resets, listeners, streams,
boot() { vm.runInContext(TOWN.slice(bootStart, bootEnd), context); } };
}
@@ -254,6 +263,7 @@ test('public boot, connection controls, and fetch are locked to sample data desp
assert.equal(h.context.feedStale, false);
assert.equal(h.context.FEED_META.source, 'demo');
assert.equal(h.requests.length, 0);
+ assert.equal(h.streams.length, 0, 'public sample never opens a live notification stream');
assert.equal(h.refreshes.length, 0);
assert.equal(h.resets.length, 0);
}
@@ -267,6 +277,7 @@ test('local builds retain explicit connect, query prefill, and their default loc
assert.equal(normal.feed.hidden, false);
await normal.context.fetchAgents();
assert.equal(normal.requests.length, 1);
+ assert.deepEqual(normal.streams, ['https://sample.example/agents/events']);
const prefilled = controllerHarness({ publicDemo: false, search: '?endpoint=https://custom.example/agents' });
prefilled.boot();
assert.equal(prefilled.context.ENDPOINT, null);
@@ -275,10 +286,12 @@ test('local builds retain explicit connect, query prefill, and their default loc
prefilled.connect.onclick();
assert.equal(prefilled.context.ENDPOINT, 'https://custom.example/agents');
assert.equal(prefilled.refreshes.length, 1);
+ assert.equal(prefilled.streams.length, 0, 'prefilled and custom feeds do not auto-subscribe');
const demo = controllerHarness({ publicDemo: false, search: '?demo=1' });
demo.boot();
assert.equal(demo.context.ENDPOINT, null);
assert.equal(await demo.context.fetchAgents(), demo.snapshot);
+ assert.equal(demo.streams.length, 0);
});
test('an empty bundled local feed keeps its endpoint while enabling demo conversations', async () => {
diff --git a/test/viewport.test.mjs b/test/viewport.test.mjs
index 22679c9..514bec8 100644
--- a/test/viewport.test.mjs
+++ b/test/viewport.test.mjs
@@ -99,13 +99,13 @@ test('camera retains its initial scale through delayed feeds, room visits, and w
near(camera.state.center.y,center.y);
});
-test('Overview fits active bounds around desktop and mobile details within the zoom limits', () => {
+test('Overview fits active bounds around desktop and mobile details', () => {
const bounds={x:0,y:0,w:1000,h:500};
assert.equal(overviewZoom(bounds,{width:1100,height:700,baseScale:1}),100);
assert.equal(overviewZoom(bounds,{width:1100,height:700,right:352,baseScale:1}),50);
assert.equal(overviewZoom(bounds,{width:1100,height:700,bottom:300,baseScale:1}),50);
assert.equal(overviewZoom(bounds,{width:850,height:600,baseScale:1}),75);
- assert.equal(overviewZoom(bounds,{width:300,height:200,baseScale:2}),50);
+ assert.equal(overviewZoom(bounds,{width:300,height:200,baseScale:2}),12.6);
});
test('a wider packed world retains the reference cottage scale; Overview is explicit', t => {
@@ -122,3 +122,25 @@ test('a wider packed world retains the reference cottage scale; Overview is expl
camera.setWorld(1700,1600);near(camera.state.scale,.75);
camera.setIndoors(true);assert.equal(camera.overview({x:0,y:0,w:50,h:50}),null);
});
+
+test('Overview fits a town with a distant annex below the manual zoom floor', t => {
+ const original=globalThis.ResizeObserver;
+ globalThis.ResizeObserver=class{observe(){}};
+ t.after(()=>{if(original)globalThis.ResizeObserver=original;else delete globalThis.ResizeObserver;});
+ const viewport={clientWidth:1084,clientHeight:473,scrollLeft:0,scrollTop:0,classList:{toggle(){}},
+ scrollTo({left=0,top=0}){this.scrollLeft=left;this.scrollTop=top;}};
+ const camera=createMapViewport({viewport,surface:{style:{}},canvas:{style:{}},referenceWidth:768});
+ camera.setWorld(1000,1600);
+ const result=camera.overview({x:108,y:60,w:320,h:1256});
+ assert.ok(result.zoom<50);assert.equal(result.allVisible,true);
+ assert.equal(camera.state.zoom,result.zoom);
+ const g=camera.state.geometry,scroll={left:viewport.scrollLeft,top:viewport.scrollTop};
+ const top=screenPoint({x:108,y:60},g,scroll),bottom=screenPoint({x:428,y:1316},g,scroll);
+ assert.ok(top.y>=0&&bottom.y<=473);
+ assert.equal(nextZoom(result.zoom,-1),result.zoom);
+ assert.equal(nextZoom(result.zoom,1),50);
+ camera.setZoom(nextZoom(result.zoom,1));assert.equal(camera.state.zoom,50);
+ camera.setZoom(nextZoom(50,1));assert.equal(camera.state.zoom,75);
+ const small=camera.overview({x:108,y:60,w:320,h:200});
+ assert.equal(small.zoom,100);assert.equal(small.allVisible,true);
+});
diff --git a/test/village-controls.test.mjs b/test/village-controls.test.mjs
new file mode 100644
index 0000000..bfd5123
--- /dev/null
+++ b/test/village-controls.test.mjs
@@ -0,0 +1,55 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {createVillageExtras} from '../src/village-extras.mjs';
+
+// Exercise the DOM controller as well as the separate music lifecycle model.
+function controls(t) {
+ const nodes=new Map(),listeners=new Map();
+ const node=id=>{
+ if(!nodes.has(id))nodes.set(id,{
+ id,textContent:'',value:'',hidden:false,open:false,attributes:new Map(),
+ setAttribute(name,value){this.attributes.set(name,value);},
+ removeAttribute(name){this.attributes.delete(name);},
+ addEventListener(){},
+ });
+ return nodes.get(id);
+ };
+ const originals=['document','window'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]);
+ t.after(()=>{for(const [key,descriptor] of originals){if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}});
+ globalThis.document={getElementById:node,hidden:false,
+ addEventListener:(name,listener)=>listeners.set(name,listener)};
+ globalThis.window={addEventListener:(name,listener)=>listeners.set(name,listener)};
+ const controller=createVillageExtras({getPostcard:()=>null,clearKeys:()=>{}});
+ return {node,controller,close(){listeners.get('pagehide')({persisted:false});}};
+}
+
+test('music range input updates its percentage and controller without enabling playback',t=>{
+ const f=controls(t),range=f.node('music-volume');
+ for(const value of ['0','31','50','16']){
+ range.value=value;range.oninput({target:range});
+ assert.equal(f.controller.state.music.volume,Number(value)/100);
+ assert.equal(f.node('music-volume-value').textContent,value+'%');
+ assert.equal(f.controller.state.music.enabled,false);
+ assert.equal(f.controller.state.music.playing,false);
+ }
+ f.controller.update({player:{x:0,y:0},world:{districts:[]},indoors:false,talking:false},10);
+ assert.equal(f.controller.state.music.volume,.16,'a scene refresh preserves the chosen volume');
+ f.close();
+});
+
+test('time select previews each phase immediately and preserves it across scene refreshes',t=>{
+ const f=controls(t),select=f.node('village-time');
+ for(const phase of ['morning','day','dusk','night']){
+ select.value=phase;select.onchange({target:select});
+ assert.equal(f.controller.state.timeMode,phase);
+ assert.equal(f.controller.state.phase,phase);
+ assert.match(f.node('village-clock').textContent,/preview/);
+ f.controller.update({player:{x:0,y:0},world:{districts:[]},indoors:false,talking:false},10);
+ assert.equal(f.controller.state.phase,phase);
+ }
+ select.value='auto';select.onchange({target:select});
+ assert.equal(f.controller.state.timeMode,'auto');
+ assert.match(f.node('village-clock').textContent,/local time/);
+ assert.equal(f.controller.state.music.enabled,false);
+ f.close();
+});