diff --git a/src/command-replies.mjs b/src/command-replies.mjs index 16a2e46..1da8bfc 100644 --- a/src/command-replies.mjs +++ b/src/command-replies.mjs @@ -30,7 +30,6 @@ export function createReplyClient({endpoint='http://127.0.0.1:8790/agents',fetch } function fail(taskId,notice){const result={phase:'error',notice};states.set(taskId,result);return {...result};} function capability(agent,stale=false){return conversationCapability(agent,feed.href,{now:now(),stale:Boolean(stale||agent?.isStale||agent?.freshness?.isStale)});} - function exactRoute(agent,cap){try{const route=new URL(cap.url);return route.origin===feed.origin&&route.pathname===`/agents/${encodeURIComponent(agent.id)}/messages`&&!route.search&&!route.hash;}catch{return false;}} async function send(agent,message){ const taskId=agent?.taskId; if(!taskId)return {phase:'error',notice:'The source has not identified a supported task.'}; @@ -42,7 +41,7 @@ export function createReplyClient({endpoint='http://127.0.0.1:8790/agents',fetch const value=typeof message==='string'?message.trim():''; if(!value||value.length>MAX_MESSAGE_LENGTH)return fail(taskId,`Write a message of 1–${MAX_MESSAGE_LENGTH} characters.`); const shownCapability=capability(agent); - if(!shownCapability.available||!exactRoute(agent,shownCapability))return fail(taskId,shownCapability.reason||'The task has no supported local message route.'); + if(!shownCapability.available)return fail(taskId,shownCapability.reason||'The task has no supported local message route.'); active.add(taskId);states.set(taskId,{phase:'checking',notice:'Checking the current task and question…'}); let reserved=false,requestId; try{ @@ -56,7 +55,7 @@ export function createReplyClient({endpoint='http://127.0.0.1:8790/agents',fetch if((currentInput?.id??null)!==(shownInput?.id??null)||inputRequestVersion(currentInput)!==version)return fail(taskId,'The question or its choices changed. Refresh before replying.'); const supported=capability(latest,snapshot.stale); if(!supported.available)return fail(taskId,supported.reason); - if(!exactRoute(latest,supported)||supported.mode!==shownCapability.mode||supported.url!==shownCapability.url)return fail(taskId,'The supported message route changed. Refresh before sending.'); + if(supported.mode!==shownCapability.mode||supported.url!==shownCapability.url)return fail(taskId,'The supported message route changed. Refresh before sending.'); if(supported.mode==='respond'&&!currentInput)return fail(taskId,'The source has not supplied an exact current question.'); if(supported.mode==='redirect'&¤tInput)return fail(taskId,'The task has a pending question. Refresh before replying.'); // Check persistent state again after the read preflight, before reserving the write. diff --git a/src/map-display.mjs b/src/map-display.mjs index 1019bca..502a2be 100644 --- a/src/map-display.mjs +++ b/src/map-display.mjs @@ -2,7 +2,7 @@ import {fullscreenButtonCopy} from './fullscreen.mjs'; import {nextZoom, zoomKey, ZOOM_LEVELS} from './viewport.mjs'; /** Fullscreen and panel chrome live outside the inspector's streaming content. */ -export function createMapDisplay({camera, canvas, getMode, onOverview}) { +export function createMapDisplay({camera, canvas, getMode, onOverview, getWorkspaceView=()=> 'town', focusWorkspace}) { const $ = id => document.getElementById(id); const root = $('map-shell'), viewport = $('map-viewport'), details = $('details-shell'); const panel = $('panel'); @@ -13,15 +13,15 @@ export function createMapDisplay({camera, canvas, getMode, onOverview}) { let frame = 0; let fullscreenReading = null; const isActive = () => active; - const focusMap = () => $(getMode() === 'room' ? 'room-canvas' : 'town').focus({preventScroll: true}); + const focusMap = () => getWorkspaceView()!=='town' ? focusWorkspace?.() : $(getMode() === 'room' ? 'room-canvas' : 'town').focus({preventScroll: true}); function positionPanel() { frame = 0; if (active) { const r = root.getBoundingClientRect(), v = viewport.getBoundingClientRect(); root.style.setProperty('--details-top', Math.max(12, v.top - r.top + 12) + 'px'); - root.style.setProperty('--details-right', Math.max(12, r.right - v.right + 12) + 'px'); - root.style.setProperty('--details-bottom', Math.max(12, r.bottom - v.bottom + 12) + 'px'); + root.style.setProperty('--details-right', (getWorkspaceView()==='town'?Math.max(12, r.right - v.right + 12):12) + 'px'); + root.style.setProperty('--details-bottom', (getWorkspaceView()==='town'?Math.max(12, r.bottom - v.bottom + 12):12) + 'px'); } camera.resize(); } @@ -30,8 +30,10 @@ export function createMapDisplay({camera, canvas, getMode, onOverview}) { observer.observe(viewport); observer.observe(details); function syncDetails() { - details.hidden = active && !detailsOpen; - toggle.hidden = !active; + camera.capture(); + details.hidden = !detailsOpen; + root.classList.toggle('details-closed',!detailsOpen); + toggle.hidden = false; toggle.setAttribute('aria-expanded', String(detailsOpen)); toggle.setAttribute('aria-pressed', String(detailsOpen)); positionPanel(); @@ -67,7 +69,7 @@ export function createMapDisplay({camera, canvas, getMode, onOverview}) { function syncZoom() { const zoom = camera.state.zoom; zoomControls.hidden = getMode() === 'room'; - minus.disabled = zoom === ZOOM_LEVELS[0]; plus.disabled = zoom === ZOOM_LEVELS.at(-1); + minus.disabled = zoom <= ZOOM_LEVELS[0]; plus.disabled = zoom === ZOOM_LEVELS.at(-1); reset.textContent = zoom + '%'; reset.setAttribute('aria-label', 'Map zoom ' + zoom + '%. Reset to 100%'); } function changeZoom(action) { diff --git a/src/observatory.mjs b/src/observatory.mjs index 59e2a25..9f02ac5 100644 --- a/src/observatory.mjs +++ b/src/observatory.mjs @@ -5,11 +5,13 @@ import {movePoint,inside,normalizeRelationships,normalizedHandoffs} from './worl import {createDistrictRouter} from './town-layout.mjs'; import {createHistory} from './history.mjs'; import {createSound} from './sound.mjs'; -import {activityAddress,mergeActivity,elapsedMs,validTime,isCottageVisible} from './feed-client.mjs'; +import {activityAddress,mergeActivity,validTime,isCottageVisible} from './feed-client.mjs'; import {cottageDoors,crossedDoor} from './interaction.mjs'; import {normalizeTodos} from './todos.mjs'; import {conversationCapability,MAX_MESSAGE_LENGTH} from './conversation.mjs'; import {normalizeInputRequest,inputRequestVersion} from './input-request.mjs'; +import {taskView,durationLabel} from './command-model.mjs'; +import {createReplyClient,isTerminalForMessage} from './command-replies.mjs'; import {PUBLIC_DEMO} from './runtime.mjs'; import {createVillageExtras} from './village-extras.mjs'; import {roomRest,paintBed} from './bedtime.mjs'; @@ -31,7 +33,6 @@ const esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'< const safeUrl=raw=>{try{const u=new URL(raw);return ['http:','https:'].includes(u.protocol)?u.href:'';}catch{return '';}}; export const safeHttpsUrl=raw=>{try{const u=new URL(raw);return u.protocol==='https:'?u.href:'';}catch{return '';}}; const clock=value=>{const ms=validTime(value);return ms?new Date(ms).toLocaleString([], {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'Unavailable';}; -const elapsed=agent=>{const duration=elapsedMs(agent);if(duration===null)return 'Unavailable';const sec=Math.floor(duration/1000);return Math.floor(sec/60)+'m '+(sec%60)+'s';}; const distance=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y); const cottageReturnPoint=plot=>({x:plot.x+(plot.parentPlot?8:27),y:plot.y+(plot.parentPlot?22:76)}); export const button=(action,label,extra='')=>''; @@ -88,8 +89,8 @@ export function activityCacheFor(cache,a={}){ } /** Keep an intentional inspection; automatic choices start in the visible village. */ export function cottageSelection(agents,{selected=null,interiorId=null,showSettled=false}={}){ - if(interiorId&&agents.some(agent=>agent.id===interiorId))return interiorId; if(selected&&agents.some(agent=>agent.id===selected))return selected; + if(interiorId&&agents.some(agent=>agent.id===interiorId))return interiorId; const visible=agents.filter(agent=>isCottageVisible(agent,{showSettled})); const hosts=visible.filter(agent=>!agent.parent); return (hosts.find(agent=>['working','blocked'].includes(agent.status))||hosts[0]||visible[0])?.id||null; @@ -130,6 +131,54 @@ export function isPracticeDemo(agent, builtInDemo = false){ return builtInDemo && agent?.source==='demo' && !!normalizeInputRequest(agent.inputRequest); } +/** Durable task receipts outrank editable drafts, including after a source revisit. */ +export function replyPresentation(agent,receipt={}){ + const pending=['checking','sending'].includes(receipt.phase),uncertain=receipt.phase==='uncertain'; + const answered=receipt.phase==='sent'&&agent.conversation?.mode==='respond'&& + !!normalizeInputRequest(agent.inputRequest)&&receipt.inputVersion===inputRequestVersion(agent.inputRequest); + return {phase:uncertain?'unconfirmed':pending?'sending':receipt.phase||'idle',locked:pending||uncertain||answered,notice:receipt.notice||''}; +} + +/** Question drafts can temporarily inherit another revision's in-flight receipt. */ +export function reconcileReplyReceipt(conversation,agent,receipt){ + const delivery=replyPresentation(agent,receipt); + const inheritedPending=conversation.receiptPending&&conversation.phase==='sending'; + conversation.deliveryLocked=delivery.locked; + if(delivery.locked){conversation.phase=delivery.phase;conversation.notice=delivery.notice;} + else if(inheritedPending){ + conversation.phase=delivery.phase==='error'?'error':'idle'; + conversation.notice=delivery.phase==='error'?delivery.notice:''; + } + conversation.receiptPending=delivery.locked&&delivery.phase==='sending'; + return conversation; +} + +export function evidenceOverviewHtml(agent,{now=Date.now(),stale=false,endpoint,activity=''}={}){ + const view=taskView(agent,{now,stale,endpoint}); + const age=value=>value===null?'Unavailable':durationLabel(value)+' ago'; + const turnStart=validTime(agent.turnStartedAt); + const turnTerminal=agent.terminal===true||['done','offline','completed','failed','cancelled','interrupted'].includes(view.status); + const turnEnd=turnTerminal?validTime(agent.endedAt):now; + const turnAge=turnStart!==null&&turnEnd!==null&&turnEnd>=turnStart&&turnEnd<=now?turnEnd-turnStart:null; + const turnClocks=turnStart!==null||agent.inventorySource==='codex_rollout'?'
Current turn age
'+esc(durationLabel(turnAge))+'
Current turn started
'+esc(clock(turnStart))+'
':''; + const input=view.input?'

Needs your input

'+esc(view.input.prompt.slice(0,420))+'

'+button('talk','Open Talk to reply')+'
':''; + const usage=agent.usage,context=agent.contextUsage; + const usageScope=(usage?.scope??context?.scope)==='attempt'?'Attempt':'Session'; + const count=value=>Number.isFinite(value)&&value>=0?value.toLocaleString():'Unavailable'; + const partial=agent.usageCoverage?.reason==='partial'||usage?.incompleteFields?.length||usage?.lowerBound?.length?' · partial':''; + const cost=usage?.cost; + const usageHtml=usage||context||agent.inventorySource==='codex_rollout'?'

Recorded usage

'+usageScope+' tokens
'+count(usage?.tokens?.totalTokens)+partial+'
'+usageScope+' cost
'+ + (Number.isFinite(cost?.usd)&&cost.usd>=0?'$'+cost.usd.toFixed(2)+(cost.sources?.includes('estimate')?' · estimated':'')+(cost.complete?'':' · partial'):'Unavailable')+'
'+ + (context?'
Context
'+count(context.usedTokens)+' / '+count(context.windowTokens)+' tokens'+(context.stale||stale?' · last recorded':'')+'
':'')+ + '

'+(usageScope==='Session'?'Cumulative across this session. ':'Current attempt totals. ')+(usage?.measuredAt==null?'Usage measurement time is unavailable. ':'')+'

':''; + return '

Today’s work

'+esc(view.title)+'

'+esc(activity||view.activity||'No current activity supplied.')+'
'+lifecycleHtml(agent)+input+ + '
Harness
'+esc(view.harness)+'
Source model
'+esc(view.model)+'
Task age
'+esc(durationLabel(view.taskAgeMs))+'
Task started
'+esc(clock(agent.taskStartedAt))+'
'+ + turnClocks+ + (view.recordedExecutionMs!==null?'
Recorded execution
'+esc(durationLabel(view.recordedExecutionMs))+'
':'')+ + '
Session age
'+esc(durationLabel(view.sessionAgeMs))+'
Session started
'+esc(clock(agent.sessionStartedAt))+'
Last observed
'+esc(age(view.observedAgeMs))+(view.sourceStale?' · stale':'')+'
Last progress
'+esc(age(view.progressAgeMs))+'
Branch
'+esc(agent.branch||'Unavailable')+'

Observation records when the source last saw this task. Progress records its last reported work.

'+ + usageHtml+'

Pull request

'+prSnapshotHtml(view.pr)+'

Pinned request

'+esc(agent.originalAsk?agent.originalAsk.slice(0,230)+(agent.originalAsk.length>230?'…':''):'Original request not supplied.')+'

'+button('tab:request','Read the pinned note'); +} + function activeArrivalEntry(arrival, time, duration=8){ const age=time-arrival?.start; return Number.isFinite(age)&&age>=0&&age{reduce=e.matches;if(reduce)transition=1;}); let storage=null;try{storage=localStorage;}catch{} const byId=id=>latestAgents.find(a=>a.id===id); + const evidenceFor=id=>api.getEvidence?.(id)||byId(id); + const replyClientFor=a=>{ + if(PUBLIC_DEMO||api.isBuiltInDemo?.()||!a?.taskId||!api.getEndpoint())return null; + const endpoint=api.getEndpoint(),key=JSON.stringify([sourceKey,endpoint]); + if(!replyClients.has(key))replyClients.set(key,createReplyClient({endpoint,storage})); + return replyClients.get(key); + }; const plotFor=id=>{ for(const p of api.getPlots()){ if(p.agent.id===id)return p; @@ -206,9 +262,11 @@ export function createObservatory(api){ const key=taskKey+'|'+(inputRequestVersion(request)||'conversation'); if(!messageReceipts.has(taskKey))messageReceipts.set(taskKey,[]); if(!conversations.has(key))conversations.set(key,{text:'',phase:'idle',notice:'',sent:messageReceipts.get(taskKey),answers:{},requestId:null,payload:''}); - return conversations.get(key); + const conversation=conversations.get(key),client=replyClientFor(a); + if(client)reconcileReplyReceipt(conversation,a,client.state(a.taskId)); + return conversation; }; - const capabilityFor=a=>isPracticeDemo(a,api.isBuiltInDemo?.())?{available:true,mode:'respond',source:'demo',demo:true}:PUBLIC_DEMO?{available:false,reason:'This is a sample village.'}:conversationCapability(a,api.getEndpoint(),{stale:feedStale}); + const capabilityFor=a=>isPracticeDemo(a,api.isBuiltInDemo?.())?{available:true,mode:'respond',source:'demo',demo:true}:PUBLIC_DEMO?{available:false,reason:'This is a sample village.'}:isTerminalForMessage(a)?{available:false,reason:'This task has finished. Start any follow-up in its original workflow.'}:conversationCapability(a,api.getEndpoint(),{stale:feedStale}); const visible=a=>{ if(!prFilter) return true; if(prStage(a.pr)===prFilter) return true; @@ -234,6 +292,7 @@ export function createObservatory(api){ api.onModeChange?.(); } function enter(id){ + api.activateTown?.(); const a=byId(id),p=plotFor(id);if(!a||!p)return; api.showDetails?.(); selected=id;api.select(id);interiorId=id;room=roomFor(a); @@ -241,12 +300,17 @@ export function createObservatory(api){ selectedObject=null;tab='overview';transition=reduce?1:0; followId=null;setMode('room');sound.play('door');renderPanel(id);roomCanvas.focus(); } - function leave(){ + function leave({activate=true}={}){ + if(activate)api.activateTown?.(); if(mode!=='room')return; player=returnTo||player;interiorId=null;room=null;roomPlayer=null;selectedObject=null; - setMode('town');sound.play('door');renderPanel(selected);canvas.focus();scrollToPlayer(true); + setMode('town');renderPanel(selected); + // Feed cleanup must not interrupt an evidence view or steal composer focus. + if(activate){sound.play('door');canvas.focus();scrollToPlayer(true);} } function follow(id){ + api.activateTown?.(); + if(suspended)return; const p=plotFor(id);if(!p)return; api.showDetails?.(); if(mode==='room')leave(); @@ -255,6 +319,7 @@ export function createObservatory(api){ setMode('town');renderPanel(selected); } function focusCottage(id){ + api.activateTown?.(); if(!byId(id))return; api.showDetails?.(); if(mode==='room'&&id!==interiorId)leave(); @@ -263,7 +328,15 @@ export function createObservatory(api){ const p=plotFor(id);if(p)scrollTo(p.x,p.y,true); renderPanel(id);ensureActivity(byId(id)); } + function inspectCottage(id,{tab:nextTab='overview'}={}){ + const a=evidenceFor(id);if(!a)return false; + if(mode==='board'||mode==='scrapbook'){replaying=false;setMode('town');} + selected=id;tab=['overview','request','journal','todos','review','artifacts','talk'].includes(nextTab)?nextTab:'overview'; + selectedObject=null;api.select(id);api.showDetails?.(); + renderPanel(id);ensureActivity(a);return true; + } function interact(){ + if(suspended)return; if(mode==='room'){ if(distance(roomPlayer,roomHost())<30){talk(interiorId);return;} const nearest=room.objects.filter(o=>o.interactable&&o.id!=='exit').sort((a,b)=>nearRect(roomPlayer,a)-nearRect(roomPlayer,b))[0]; @@ -287,7 +360,7 @@ export function createObservatory(api){ }else hint('Walk into a doorway to enter. Press E near an agent to talk.'); } function talk(id){ - const a=byId(id);if(!a)return; + const a=evidenceFor(id);if(!a)return; api.showDetails?.(); if(mode==='board'||mode==='scrapbook')setMode('town'); selected=id;tab='talk';selectedObject=null;talkingId=id;talkingUntil=performance.now()+1500; @@ -295,14 +368,15 @@ export function createObservatory(api){ } function inspect(id){ api.showDetails?.(); - selectedObject=id;tab=({request:'request',clock:'overview',workbench:'journal',review:'review',shelf:'artifacts'})[id]||'overview'; + selected=interiorId;api.select(interiorId);selectedObject=id;tab=({request:'request',clock:'overview',workbench:'journal',review:'review',shelf:'artifacts'})[id]||'overview'; sound.play('talk');renderPanel(interiorId); } function scrollTo(x,y,immediate=false){ + if(suspended)return; api.viewport.focus({x,y},immediate); } function scrollToPlayer(immediate=false){ - if(!player||mode==='room')return; + if(suspended||!player||mode==='room')return; if(immediate)scrollTo(player.x,player.y,true); else api.viewport.ensureVisible(player); } @@ -313,6 +387,7 @@ export function createObservatory(api){ !(w.solids||[]).some(r=>inside({x,y},r,2)); } function move(dt){ + if(suspended)return false; let dx=(keys.has('ArrowRight')||keys.has('d')?1:0)-(keys.has('ArrowLeft')||keys.has('a')?1:0); let dy=(keys.has('ArrowDown')||keys.has('s')?1:0)-(keys.has('ArrowUp')||keys.has('w')?1:0); if(!dx&&!dy)return false; @@ -331,6 +406,7 @@ export function createObservatory(api){ sound.play('step');return true; } function onKey(e){ + if(suspended)return; const key=e.key.length===1?e.key.toLowerCase():e.key; if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','w','a','s','d'].includes(key)){ if(mode==='board'||mode==='scrapbook')setMode('town'); @@ -352,7 +428,7 @@ export function createObservatory(api){ window.addEventListener('blur',()=>keys.clear()); canvas.addEventListener('blur',()=>keys.clear());roomCanvas.addEventListener('blur',()=>keys.clear()); roomCanvas.addEventListener('click',e=>{ - if(!room||transition<1)return; + if(suspended||!room||transition<1)return; const r=roomCanvas.getBoundingClientRect(),area=api.viewport.visibleArea(),scale=Math.min(area.width/room.width,area.height/room.height); const p={x:(e.clientX-r.left-(area.width-room.width*scale)/2)/scale,y:(e.clientY-r.top-(area.height-room.height*scale)/2)/scale}; const host=roomHost(); @@ -361,6 +437,7 @@ export function createObservatory(api){ if(obj){if(obj.id==='exit')leave();else inspect(obj.id);} }); function handleClick(e){ + if(suspended)return false; const p=api.viewport.pointAtEvent(e); const resident=(api.getResidents?.()||[]).find(a=>visible(byId(a.id)||{})&&inside(p,{x:a.x-7,y:a.y-18,w:14,h:20})); if(resident){talk(resident.id);return true;} @@ -452,11 +529,11 @@ export function createObservatory(api){ function talkHtml(a,cache){ const conversation=conversationFor(a),capability=capabilityFor(a),request=normalizeInputRequest(a.inputRequest); const pending=conversation.phase==='sending',uncertain=conversation.phase==='unconfirmed'; - const enabled=capability.available&&!pending&&!uncertain&&!!conversation.text.trim()&&conversation.text.trim()!==conversation.uncertainPayload; + const enabled=capability.available&&!conversation.deliveryLocked&&!pending&&!uncertain&&!!conversation.text.trim()&&conversation.text.trim()!==conversation.uncertainPayload; const receipts=conversation.sent.map(message=>'
  • '+esc(message.text)+'

    '+esc(clock(message.timestamp))+' · '+esc(message.label)+'
  • ').join(''); let input=''; if(request){ - input='

    '+(['question','permission','plan'].includes(request.kind)?esc(a.name)+' needs your input':'Needs attention')+'

    '+esc(request.prompt)+'

    '+(request.detail&&request.detail!==request.prompt?'
    '+esc(request.detail)+'
    ':'')+request.questions.map((q,qi)=>'
    '+esc(q.prompt)+''+(q.multiSelect?'

    Choose any that apply.

    ':'')+'
    '+q.options.map((option,oi)=>button('input-choice:'+qi+':'+oi,''+esc(option.label)+''+(option.description?''+esc(option.description)+'':''),'class="input-option" aria-pressed="'+!!conversation.answers[q.id]?.includes(option.label)+'" '+(pending?'disabled':''))).join('')+'
    ').join('')+'

    '+esc(request.source)+' · '+esc(clock(request.updatedAt))+(request.stale||feedStale?' · stale':'')+'

    '; + input='

    '+(['question','permission','plan'].includes(request.kind)?esc(a.name)+' needs your input':'Needs attention')+'

    '+esc(request.prompt)+'

    '+(request.detail&&request.detail!==request.prompt?'
    '+esc(request.detail)+'
    ':'')+request.questions.map((q,qi)=>'
    '+esc(q.prompt)+''+(q.multiSelect?'

    Choose any that apply.

    ':'')+'
    '+q.options.map((option,oi)=>button('input-choice:'+qi+':'+oi,''+esc(option.label)+''+(option.description?''+esc(option.description)+'':''),'class="input-option" aria-pressed="'+!!conversation.answers[q.id]?.includes(option.label)+'" '+(pending||conversation.deliveryLocked?'disabled':''))).join('')+'
    ').join('')+'

    '+esc(request.source)+' · '+esc(clock(request.updatedAt))+(request.stale||feedStale?' · stale':'')+'

    '; }else if(a.status==='blocked')input='

    Needs attention

    '+esc(a.attention||'The source marked this agent blocked, but has not supplied its question or blocker.')+'

    A blocked status can indicate a dependency or failure. A reply is available when the task source supplies a supported input route.

    '; else if(prStage(a.pr)==='blocked')input='

    The PR needs attention

    '+esc(a.pr?.reason||'The pull request is blocked. Its review desk shows the available evidence.')+'

    '+button('tab:review','Open PR desk')+'
    '; const composer=PUBLIC_DEMO&&!capability.demo?'

    This is a sample village. Find a resident marked “needs input” to try a practice conversation.

    ': @@ -467,9 +544,9 @@ export function createObservatory(api){ '

    From the workbench

    '+activityPagingButtons(cache)+'

    '+esc(cache.source)+(cache.stale?' · stale — '+esc(cache.error||'connection interrupted'):' · recorded activity')+'

      '+eventHtml(cache.events)+'
    '; } async function sendMessage(){ - const a=byId(interiorId||selected);if(!a)return; + const a=evidenceFor(selected);if(!a)return; const conversation=conversationFor(a),capability=capabilityFor(a),inputRequest=normalizeInputRequest(a.inputRequest); - if(!capability.available||['sending','unconfirmed'].includes(conversation.phase)||!conversation.text.trim()||conversation.text.trim()===conversation.uncertainPayload)return; + if(!capability.available||conversation.deliveryLocked||['sending','unconfirmed'].includes(conversation.phase)||!conversation.text.trim()||conversation.text.trim()===conversation.uncertainPayload)return; const text=conversation.text.trim();if(text.length>MAX_MESSAGE_LENGTH)return; if(capability.demo){ if(api.answerDemo?.(a.id,text,inputRequest.id)){ @@ -478,29 +555,27 @@ export function createObservatory(api){ } return; } - conversation.requestId ||= crypto.randomUUID();conversation.payload=text;conversation.phase='sending';conversation.notice='Submitting your message…'; - const epoch=sourceKey,controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),20000);renderPanel(selected); + const client=replyClientFor(a);if(!client)return; + conversation.payload=text;conversation.phase='sending';conversation.notice='Checking the current task and question…'; + const epoch=sourceKey,pending=client.send({...a,isStale:feedStale||a.isStale},text);renderPanel(selected); try{ - const response=await fetch(capability.url,{method:'POST',credentials:'omit',redirect:'error',signal:controller.signal,headers:{'content-type':'application/json','x-cottagecode-request':'user-message'},body:JSON.stringify({taskId:a.taskId,message:text,requestId:conversation.requestId,...(capability.mode==='respond'&&inputRequest?{inputRequestId:inputRequest.id,inputRequestVersion:inputRequestVersion(inputRequest)}:{})})}); - const result=await response.json(); - if(result.delivery==='unconfirmed')throw new Error('Delivery is unconfirmed. Check the task before sending this message again.'); - if(!response.ok){ - if(result.delivery!=='not_sent')throw new Error('Delivery is unconfirmed. Check the task before sending again.'); - conversation.phase='error';conversation.notice=String(result.error||'The source declined this message.');conversation.requestId=null;return; + const result=await pending,delivery=replyPresentation(a,result); + conversation.phase=delivery.phase;conversation.notice=delivery.notice;conversation.deliveryLocked=delivery.locked; + if(result.phase==='uncertain')conversation.uncertainPayload=text; + if(result.phase==='sent'){ + const label=result.receipt?.delivery==='accepted'?'Accepted by task source':'Submitted to agent terminal'; + conversation.sent.push({text,timestamp:Date.now(),label});conversation.sent.splice(0,Math.max(0,conversation.sent.length-20)); + conversation.text='';conversation.answers={}; + if(epoch===sourceKey)ensureActivity(a); } - if(!result.ok||!['submitted','accepted'].includes(result.delivery))throw new Error('The source did not confirm delivery. Check the task before sending again.'); - const label=result.delivery==='submitted'?'Submitted to agent terminal':'Accepted by task source'; - conversation.sent.push({text,timestamp:Date.now(),label});conversation.sent.splice(0,Math.max(0,conversation.sent.length-20)); - conversation.text='';conversation.requestId=null;conversation.phase='sent';conversation.notice=label+'. Replies appear in the recorded activity when the source supplies them.'; - if(epoch===sourceKey)ensureActivity(a); - }catch(error){conversation.phase='unconfirmed';conversation.uncertainPayload=text;conversation.notice=error.name==='AbortError'?'Delivery is unconfirmed after a timeout. Check the task before sending again.':error.message||'Delivery is unconfirmed. Check the task before sending again.';} - finally{clearTimeout(timeout);if(epoch===sourceKey&&(selected===a.id||interiorId===a.id))renderPanel(selected);} + }finally{if(epoch===sourceKey&&selected===a.id)renderPanel(selected);} } + function renderPanel(id){ selected=id; if(mode==='board'||mode==='scrapbook'){renderHistory();return true;} - const a=byId(mode==='room'?interiorId:id);if(!a)return false; - const pr=normalizePr(a.pr),stage=pr.stage,s=STAGES[stage],cache=cacheFor(a); + const a=evidenceFor(id);if(!a)return false; + const pr=taskView(a,{stale:feedStale,endpoint:api.getEndpoint()}).pr,stage=pr.stage,s=STAGES[stage],cache=cacheFor(a); let content=''; if(tab==='talk'){ content=talkHtml(a,cache); @@ -520,16 +595,17 @@ export function createObservatory(api){ content='

    On the shelves

    '+(a.result?'
    '+esc(a.result)+'
    ':'')+(rows.length?'':'

    Artifacts appear here when the feed supplies them.

    '); if(stage==='merged')content='

    A keepsake from this work

    Merged · '+esc(clock(pr.mergedAt))+'

    '+content; }else{ - content='

    Today’s work

    '+esc(a.task&&a.task!=='-'?a.task:'Task description unavailable')+'

    '+esc(latestLine(a)||'No current activity supplied.')+'
    '+lifecycleHtml(a)+'

    Pull request

    '+prSnapshotHtml(pr)+'
    Task started
    '+esc(clock(a.taskStartedAt))+'
    Elapsed
    '+esc(elapsed(a))+'
    Session started
    '+esc(clock(a.sessionStartedAt))+'
    Last signal
    '+esc(clock(a.updatedAt))+'
    Model
    '+esc(a.model||'Unavailable')+'
    Branch
    '+esc(a.branch||'Unavailable')+'

    Pinned request

    '+esc(a.originalAsk?a.originalAsk.slice(0,230)+(a.originalAsk.length>230?'…':''):'Original request not supplied.')+'

    '+button('tab:request','Read the pinned note'); + content=evidenceOverviewHtml(a,{stale:feedStale,endpoint:api.getEndpoint(),activity:latestLine(a)}); } const nav=[['overview','Clock'],['request','Request'],['journal','Journal'],['todos','To-do'],['review','PR desk'],['artifacts','Shelves']].map(([key,label])=>button('tab:'+key,label,'aria-pressed="'+(tab===key)+'"')).join(''); - const header='
    '+esc(a.town)+' · '+(mode==='room'?'INSIDE':'COTTAGE')+'

    '+esc(a.name)+'

    '+esc(a.status)+''+s.symbol+' '+s.label+'
    '; - const actions='
    '+button(mode==='room'?'leave':'enter',mode==='room'?'Leave cottage ↗':'Enter cottage ↗')+button('talk','Talk to '+esc(a.name),'aria-pressed="'+(tab==='talk')+'"')+button('follow',followId===a.id?'Leave bench':'Follow from bench')+handoffAction(a.handoffUrl)+button('postcard','Make a postcard')+'
    '; + const header='
    '+esc(a.town)+' · '+(mode==='room'&&interiorId===a.id?'INSIDE':'COTTAGE')+'

    '+esc(a.name)+'

    '+esc(a.status)+''+s.symbol+' '+s.label+'
    '; + const actions='
    '+button(mode==='room'&&interiorId===a.id?'leave':'enter',mode==='room'&&interiorId===a.id?'Leave cottage ↗':'Enter cottage ↗')+button('talk','Talk to '+esc(a.name),'aria-pressed="'+(tab==='talk')+'"')+button('follow',followId===a.id?'Leave bench':'Follow from bench')+handoffAction(a.handoffUrl)+button('postcard','Make a postcard')+'
    '; const worktree=worktreeActionsHtml(a); - replacePanel(header+actions+''+content+worktree,(mode==='room'?interiorId:id)+'|'+(a.taskId||'')+':'+tab); + replacePanel(header+actions+''+content+worktree,id+'|'+(a.taskId||'')+':'+tab); return true; } function showHistory(next='board'){ + api.activateTown?.(); api.showDetails?.(); if(mode==='room')leave();followId=null;historyView=next==='board'?'since':'all';replaying=false;setMode(next);renderHistory(); } @@ -547,10 +623,10 @@ export function createObservatory(api){ panel.addEventListener('input',e=>{ if(e.target.id==='replay-range'){replaying=false;replayEvents=history.events();replayIndex=Number(e.target.value);showReplay();} if(e.target.id==='agent-message'){ - const a=byId(interiorId||selected);if(!a)return; + const a=evidenceFor(selected);if(!a)return; const conversation=conversationFor(a);conversation.text=e.target.value; - if(conversation.phase!=='sending'&&conversation.text.trim()!==conversation.payload){conversation.phase='idle';conversation.requestId=null;conversation.notice='';} - const send=panel.querySelector('[data-action="send-message"]');if(send)send.disabled=!capabilityFor(a).available||!conversation.text.trim()||['sending','unconfirmed'].includes(conversation.phase)||conversation.text.trim()===conversation.uncertainPayload; + if(!conversation.deliveryLocked&&!['sending','unconfirmed'].includes(conversation.phase)&&conversation.text.trim()!==conversation.payload){conversation.phase='idle';conversation.requestId=null;conversation.notice='';} + const send=panel.querySelector('[data-action="send-message"]');if(send)send.disabled=!capabilityFor(a).available||conversation.deliveryLocked||!conversation.text.trim()||['sending','unconfirmed'].includes(conversation.phase)||conversation.text.trim()===conversation.uncertainPayload; } }); panel.addEventListener('submit',e=>{if(e.target.id==='agent-conversation'){e.preventDefault();sendMessage();}}); @@ -558,23 +634,23 @@ export function createObservatory(api){ const action=e.target.closest('[data-action]')?.dataset.action;if(!action)return; if(action==='enter')enter(selected); else if(action==='leave')leave(); - else if(action==='follow')follow(interiorId||selected); - else if(action==='talk')talk(interiorId||selected); - else if(action==='postcard')extras.openPostcard(interiorId||selected); + else if(action==='follow')follow(selected); + else if(action==='talk')talk(selected); + else if(action==='postcard')extras.openPostcard(selected); else if(action.startsWith('input-choice:')){ - const a=byId(interiorId||selected),request=normalizeInputRequest(a?.inputRequest);if(!request)return; - const conversation=conversationFor(a);if(conversation.phase==='sending')return; + const a=evidenceFor(selected),request=normalizeInputRequest(a?.inputRequest);if(!request)return; + const conversation=conversationFor(a);if(conversation.deliveryLocked||['sending','unconfirmed'].includes(conversation.phase))return; const [,qi,oi]=action.split(':'),question=request.questions[Number(qi)],option=question?.options[Number(oi)];if(!option)return; const chosen=conversation.answers[question.id]||[]; conversation.answers[question.id]=question.multiSelect?(chosen.includes(option.label)?chosen.filter(label=>label!==option.label):[...chosen,option.label]):[option.label]; conversation.text=request.questions.filter(q=>conversation.answers[q.id]?.length).map(q=>(request.questions.length>1?q.prompt+'\n':'')+conversation.answers[q.id].join(', ')).join('\n\n'); conversation.phase='idle';conversation.notice='';conversation.requestId=null;renderPanel(selected);$('agent-message')?.focus({preventScroll:true}); } - else if(action.startsWith('tab:')){tab=action.slice(4);selectedObject=({request:'request',journal:'workbench',review:'review',artifacts:'shelf',overview:'clock'})[tab];renderPanel(selected);if(['journal','todos'].includes(tab))ensureActivity(byId(interiorId||selected));} - else if(action==='older')await ensureActivity(byId(interiorId||selected),{older:true}); - else if(action==='latest'){const a=byId(interiorId||selected);if(a){returnToLatestActivity(cacheFor(a));renderPanel(selected);const log=panel.querySelector('.journal');if(log)log.scrollTop=log.scrollHeight;}} - else if(action==='copy'){try{await navigator.clipboard.writeText(byId(interiorId||selected).worktreePath);e.target.textContent='Copied';}catch{e.target.textContent='Copy unavailable';}} - else if(action==='handoff'){const url=safeHttpsUrl(byId(interiorId||selected)?.handoffUrl);if(url)window.open(url,'_blank','noopener,noreferrer');} + else if(action.startsWith('tab:')){tab=action.slice(4);selectedObject=selected===interiorId?({request:'request',journal:'workbench',review:'review',artifacts:'shelf',overview:'clock'})[tab]:null;renderPanel(selected);if(['journal','todos'].includes(tab))ensureActivity(evidenceFor(selected));} + else if(action==='older')await ensureActivity(evidenceFor(selected),{older:true}); + else if(action==='latest'){const a=evidenceFor(selected);if(a){returnToLatestActivity(cacheFor(a));renderPanel(selected);const log=panel.querySelector('.journal');if(log)log.scrollTop=log.scrollHeight;}} + else if(action==='copy'){try{await navigator.clipboard.writeText(evidenceFor(selected).worktreePath);e.target.textContent='Copied';}catch{e.target.textContent='Copy unavailable';}} + else if(action==='handoff'){const url=safeHttpsUrl(evidenceFor(selected)?.handoffUrl);if(url)window.open(url,'_blank','noopener,noreferrer');} else if(action.startsWith('jump:'))focusCottage(action.slice(5)); else if(action.startsWith('history:')){historyView=action.slice(8);renderHistory();} else if(action==='back'){setMode('town');renderPanel(selected);} @@ -621,12 +697,15 @@ export function createObservatory(api){ const nextKey=api.getSourceKey?.()||api.getEndpoint()||'demo'; const sourceChanged=nextKey!==sourceKey; if(sourceChanged){ - if(mode==='room')leave(); + if(mode==='room')leave({activate:false}); sourceKey=nextKey;for(const c of inflight.values())c.abort();inflight.clear();activity.clear();rooms.clear();activityLines.clear(); history=createHistory({storage,key:sourceKey});historyInitialized=false;seenHandoffs.clear();couriers=[];knownKids.clear();apprentices=[];player=null;returnTo=null;followId=null; setMode('town');selected=null;prFilter=null;stageSignature='';rosterSignature='';replaying=false;replayEvents=[];replayIndex=-1; } const changes=history.observe(agents,{initial:!historyInitialized}); + // Only newly observed changes travel through town; a source switch resets + // decorative traffic without replaying its initial inventory. + api.onMilestones?.(historyInitialized?changes:[],{sourceKey,sourceChanged}); for(const e of changes){if(e.text==='PR · ready')sound.play('ready');if(e.text==='Task done')sound.play('done');if(e.text==='Task blocked'||e.text==='PR · blocked')sound.play('blocked');} for(const a of agents){ if(a.parent&&!knownKids.has(a.id)){ @@ -644,7 +723,7 @@ export function createObservatory(api){ activityLines.set(a.id,line); } if(mode==='room'){ - if(!byId(interiorId))leave(); + if(!byId(interiorId))leave({activate:false}); else{const plot=plotFor(interiorId);if(plot)returnTo=cottageReturnPoint(plot);} } historyInitialized=true;placePlayer();if(sourceChanged)scrollToPlayer(true); @@ -660,7 +739,7 @@ export function createObservatory(api){ board=w.square?{x:w.square.x+2,y:w.square.y-8}:{x:w.roadX+26,y:(w.roadYs?.[0]||w.height-60)+16}; gramophone={x:board.x+40,y:board.y}; if(mode==='room'&&byId(interiorId)){const newRoom=roomFor(byId(interiorId));if(newRoom.seed!==room.seed){room=newRoom;roomPlayer={...room.door};}} - updatePrTally();updateRoster();ensureActivity(byId(interiorId||selected));renderPanel(selected); + updatePrTally();updateRoster();ensureActivity(evidenceFor(selected));renderPanel(selected); } function drawApprenticeArrivals(ctx,time){ if(mode!=='town')return; @@ -728,7 +807,7 @@ export function createObservatory(api){ const near=room.objects.filter(o=>o.interactable&&o.id!=='exit').sort((a,b)=>nearRect(roomPlayer,a)-nearRect(roomPlayer,b))[0]; hint(distance(roomPlayer,roomHost())<30?'E · Talk to '+(byId(interiorId)?.name||'your host')+' · Walk out through the door to leave':near&&nearRect(roomPlayer,near)<30?'E · '+near.label+' · Walk out through the door to leave':rest?'Your host is tucked in · E beside the bed to talk · Walk into the doorway to leave':'Walk around your host’s cottage · E near your host to talk · Walk into the doorway to leave'); }else drawTown(api.ctx,time,dt,moving); - if(replaying){replayTimer+=dt;if(replayTimer>1.8){replayTimer=0;replayIndex++;if(replayIndex>=replayEvents.length){replaying=false;replayIndex=replayEvents.length-1;renderHistory();}showReplay();}} + if(replaying&&!suspended){replayTimer+=dt;if(replayTimer>1.8){replayTimer=0;replayIndex++;if(replayIndex>=replayEvents.length){replaying=false;replayIndex=replayEvents.length-1;renderHistory();}showReplay();}} } function drawDispatch(ctx,p,time){ const stage=prStage(p.agent.pr),s=STAGES[stage],x=p.x-15,y=p.y+55; @@ -749,7 +828,9 @@ export function createObservatory(api){ if(p.agent.pr?.number){ctx.fillStyle=label;ctx.fillText('#'+p.agent.pr.number,x-2,y+26);} else if(stage==='none'){ctx.fillStyle=label;ctx.font='6px "Silkscreen",monospace';ctx.fillText('NO PR',x-4,y+26);} } - return {update,draw,renderPanel,handleClick,enter,leave,follow,focusCottage,drawDispatch,latestLine,visible,talk, + return {update,draw,renderPanel,handleClick,enter,leave,follow,focusCottage,inspectCottage, + setSuspended(value){suspended=Boolean(value);keys.clear();}, + drawDispatch,latestLine,visible,talk, unfollow(){followId=null;replaying=false;keys.clear();if(mode!=='room')setMode(mode);}, drawAtmosphere:(ctx,world,time,beforePalette)=>extras.drawTown(ctx,world,time,reduce,beforePalette), drawApprenticeArrivals, @@ -759,6 +840,6 @@ export function createObservatory(api){ apprenticeArrival:(id,time=performance.now()/1000)=>apprenticeResidentPosition(apprentices,id,api.getPlots(),time,null,{reduce}), isTalking:id=>talkingId===id&&performance.now() - + @@ -281,9 +287,14 @@

    +
    +
    +
    + +

    Arrow keys / WASD to walk · Walk into doors · E to talk · F for fullscreen · Click to inspect

    -
    DETAILS
    +
    EVIDENCE
    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 age
    2h 0m','Recorded execution
    3m 0s', + 'Session age
    10m 0s','Last observed
    5s ago','Last progress
    2m 0s ago', + 'Harness
    codex','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(); +});