From f10c3181231ae29f08d13448b7437ef48f02cab1 Mon Sep 17 00:00:00 2001 From: "autohub-codex[bot]" <334634853+autohub-codex[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:25:35 +0100 Subject: [PATCH 1/2] feat(town): add grounded village life and family paths --- src/heartbeat.mjs | 66 ++++++++++++++++ src/parent-paths.mjs | 65 ++++++++++++++++ src/town-layout.mjs | 4 +- src/town-trades.mjs | 76 +++++++++++++++++++ src/trade-art.mjs | 149 +++++++++++++++++++++++++++++++++++++ src/trade-panel.mjs | 45 +++++++++++ test/heartbeat.test.mjs | 63 ++++++++++++++++ test/parent-paths.test.mjs | 90 ++++++++++++++++++++++ test/town-layout.test.mjs | 12 ++- test/town-trades.test.mjs | 101 +++++++++++++++++++++++++ test/trade-art.test.mjs | 119 +++++++++++++++++++++++++++++ test/trade-panel.test.mjs | 87 ++++++++++++++++++++++ 12 files changed, 873 insertions(+), 4 deletions(-) create mode 100644 src/heartbeat.mjs create mode 100644 src/parent-paths.mjs create mode 100644 src/town-trades.mjs create mode 100644 src/trade-art.mjs create mode 100644 src/trade-panel.mjs create mode 100644 test/heartbeat.test.mjs create mode 100644 test/parent-paths.test.mjs create mode 100644 test/town-trades.test.mjs create mode 100644 test/trade-art.test.mjs create mode 100644 test/trade-panel.test.mjs diff --git a/src/heartbeat.mjs b/src/heartbeat.mjs new file mode 100644 index 0000000..bb881e1 --- /dev/null +++ b/src/heartbeat.mjs @@ -0,0 +1,66 @@ +/** Decorative street life. Only explicit, newly observed PR outcomes produce carts. */ +import { parsePrUrl } from './pr.mjs'; +const TTL = 20, MAX_SEEN = 256, MAX_CARTS = 16; +const town = t => t?.raw?.town || t?.town || ''; +const active = t => t?.status === 'working' && !t.quiet && !t.sourceStale; +const clock = () => globalThis.performance.now() / 1000; +function identity(event) { + const parsed = parsePrUrl(event.url); + const key = parsed ? `${parsed.host}/${parsed.repo.toLowerCase()}#${parsed.number}` : String(event.prKey || '').toLowerCase(); + const match = key.match(/^([\w.-]+)\/([\w.-]+)\/([\w.-]+)#([1-9]\d*)$/); + return match ? { key, number: Number(match[4]) } : null; +} +function rect(ctx, color, x, y, w, h) { ctx.fillStyle = color; ctx.fillRect(Math.round(x), Math.round(y), w, h); } +export function createHeartbeat() { + let sourceKey, shipments = []; + const seen = new Map(); + function reset() { seen.clear(); shipments = []; sourceKey = undefined; } + function prune(now) { shipments = shipments.filter(s => now - s.started < TTL); } + function observe(events = [], options = {}) { + const now = Number.isFinite(options.now) ? options.now : clock(); + if (options.sourceChanged || (sourceKey !== undefined && sourceKey !== options.sourceKey)) { reset(); } + sourceKey = options.sourceKey; prune(now); + for (const event of events) { + if (event?.kind !== 'pr') continue; + const outcome = /^PR · (merged|closed)$/.exec(event.text || '')?.[1], pr = identity(event); + if (!outcome || !pr || seen.has(pr.key)) continue; + seen.set(pr.key, now); + while (seen.size > MAX_SEEN) seen.delete(seen.keys().next().value); + shipments.push({ ...pr, outcome, town: event.town || '', agentIds: [...new Set([event.agentId, ...(event.agentIds || [])].filter(Boolean))], started: now }); + if (shipments.length > MAX_CARTS) shipments.shift(); + } + } + function drawGround(ctx, { plots = [], tasks = [], time = 0, reduce = false, stale = false, townFilter = '' } = {}) { + ctx.save(); + for (const p of plots) { + const residents = [p.agent, ...(p.agent?.roommates || []), ...(p.kids || []).map(k=>k.agent)].filter(Boolean); + const members = tasks.filter(t=>residents.some(a=>a.id===t.id)); + if (townFilter && town(p.agent) !== townFilter) continue; + const y = p.y + 67; + if (members.some(t=>t.attention)) { + rect(ctx,'#584c44',p.x+5,y-9,2,10); rect(ctx,'#8d7351',p.x+3,y-10,6,5); rect(ctx,'#ffd98b',p.x+4,y-9,4,3); + } + if (!members.some(active)) continue; + const phase = reduce || stale ? 0 : Math.floor(time * 5) % 12; + for (let i=0;i<3;i++) rect(ctx, stale?'#7b8270':i===1?'#d8bb76':'#8da76c', p.x+11+i*12+phase,y,4,1); + } + ctx.restore(); + } + function drawLife(ctx, { districts = [], plots = [], tasks = [], time = 0, reduce = false, stale = false, townFilter = '' } = {}) { + prune(time); ctx.save(); + for (const s of shipments) { + const p = plots.find(p=>[p.agent,...(p.agent?.roommates||[]),...(p.kids||[]).map(k=>k.agent)].some(a=>a&&(s.agentIds.includes(a.id)||(a.prKey&&a.prKey===s.key)))); + if (!p || (townFilter && town(p.agent)!==townFilter)) continue; + // The whole route is on this cottage's existing front lane. + const progress = reduce || stale ? .5 : Math.max(0,Math.min(1,(time-s.started)/TTL)); + const routeProgress = s.outcome === 'closed' ? 1-progress : progress; + const x = p.x+8+routeProgress*30, y=p.y+67; + rect(ctx,'#4b4546',x+2,y+4,3,3); rect(ctx,'#4b4546',x+12,y+4,3,3); + rect(ctx,'#a28a63',x,y,17,4); rect(ctx,'#6d5a49',x+17,y+1,5,1); + if(s.outcome==='merged') { rect(ctx,'#c6a77a',x+4,y-7,9,7); rect(ctx,'#f0d29a',x+8,y-7,1,7); } + if (ctx.fillText) { ctx.fillStyle='#f1dfb0'; ctx.font='7px monospace'; ctx.textAlign='center'; ctx.fillText(`#${s.number}`,Math.round(x+8),y-10); } + } + ctx.restore(); + } + return { observe, reset, drawGround, drawLife, get state() { return { sourceKey, seenCount:seen.size, shipments:shipments.map(s=>({...s,agentIds:[...s.agentIds]})) }; } }; +} diff --git a/src/parent-paths.mjs b/src/parent-paths.mjs new file mode 100644 index 0000000..6ef5be7 --- /dev/null +++ b/src/parent-paths.mjs @@ -0,0 +1,65 @@ +import {districtRoute} from './town-layout.mjs'; + +const compact = points => points.filter((p,i) => !i || p.x !== points[i-1].x || p.y !== points[i-1].y) + .filter((p,i,all) => !i || i === all.length-1 || + !(all[i-1].x === p.x && p.x === all[i+1].x || all[i-1].y === p.y && p.y === all[i+1].y)); + +/** Paths describe recorded family links, never proximity or matching branches. + * Yard turns go around the sign/workbench; longer links reuse the actual streets. + */ +export function parentPaths(plots, world, {houseHeight = 66, laneOffset = 5} = {}) { + const endpoints = new Map(), blocks = new Map(world.blocks.map(b => [b.id,b])); + const streetRoutes = new Map(); + for (const plot of plots) { + for (const agent of [plot.agent,...(plot.agent.roommates || [])]) { + endpoints.set(agent.id,{agent,plot,door:{x:plot.x+27,y:plot.y+houseHeight-2}}); + } + for (const kid of plot.kids || []) { + endpoints.set(kid.agent.id,{agent:kid.agent,plot,shed:true,door:{x:kid.x+11,y:kid.y+15}}); + } + } + const laneY = endpoint => endpoint.plot.y+houseHeight+laneOffset; + // A shed opens south. Its apron joins the lane around the yard's west edge. + const toLane = endpoint => endpoint.shed + ? [endpoint.door,{x:endpoint.door.x,y:endpoint.door.y+2}, + {x:endpoint.plot.x-4,y:endpoint.door.y+2}, + {x:endpoint.plot.x-4,y:laneY(endpoint)}] + : [endpoint.door,{x:endpoint.door.x,y:laneY(endpoint)}]; + const toStreet = (endpoint,block) => [...toLane(endpoint), + {x:block.x+7,y:laneY(endpoint)}, + {x:block.x+7,y:block.y+block.h+(world.road || 48)/2}, + {x:block.x+block.w/2,y:block.y+block.h+(world.road || 48)/2}]; + const paths = []; + for (const child of endpoints.values()) { + const parent = endpoints.get(child.agent.parent); + if (!parent || parent.agent.id === child.agent.id) continue; + // Roommates use one doorway; a line inside their shared house says nothing. + if (parent.plot === child.plot && !parent.shed && !child.shed) continue; + let points; + if (parent.plot === child.plot && parent.shed && child.shed && parent.door.y === child.door.y) { + points = [parent.door,{x:parent.door.x,y:parent.door.y+2}, + {x:child.door.x,y:child.door.y+2},child.door]; + } else if (parent.plot.blockId === child.plot.blockId) { + const from = toLane(parent), to = toLane(child); + const a = from.at(-1), b = to.at(-1); + if (a.y === b.y) points = [...from,...to.reverse()]; + else { + const block = blocks.get(parent.plot.blockId); + if (!block) continue; + points = [...from,{x:block.x+7,y:a.y},{x:block.x+7,y:b.y},...to.reverse()]; + } + } else { + const from = blocks.get(parent.plot.blockId), to = blocks.get(child.plot.blockId); + if (!from || !to) continue; + const key = from.id+'\0'+to.id; + if (!streetRoutes.has(key)) streetRoutes.set(key,districtRoute(world,from,to)); + const street = streetRoutes.get(key); + if (!street.length) continue; + points = [...toStreet(parent,from),...street,...toStreet(child,to).reverse()]; + } + paths.push({parent:parent.agent,child:child.agent,parentHost:parent.plot.agent.id, + childHost:child.plot.agent.id,kind:child.shed && parent.plot === child.plot ? 'yard' : 'street', + points:compact(points)}); + } + return paths; +} diff --git a/src/town-layout.mjs b/src/town-layout.mjs index 9f31968..4df359d 100644 --- a/src/town-layout.mjs +++ b/src/town-layout.mjs @@ -1,10 +1,10 @@ /** The old two-district width remains the reference for 100% cottage scale. */ export const MAP_REFERENCE_WIDTH = 768; -/** Choose a packing budget once, including space for fullscreen Details. */ +/** Choose a packing budget once. Floating Details must not permanently narrow the world. */ export function townWidthBudget({windowWidth, standardWidth}) { const scale = Math.max(1, standardWidth) / MAP_REFERENCE_WIDTH; - const clearWidth = windowWidth - (windowWidth > 600 ? 364 : 24); + const clearWidth = windowWidth - 24; return Math.round(Math.max(MAP_REFERENCE_WIDTH, Math.min(1920, clearWidth / scale))); } diff --git a/src/town-trades.mjs b/src/town-trades.mjs new file mode 100644 index 0000000..a2a1b93 --- /dev/null +++ b/src/town-trades.mjs @@ -0,0 +1,76 @@ +/** World cues derived from bounded, observed facts. Never a task status authority. */ +import {validTime} from './feed-client.mjs'; +import {normalizeTown} from './world.mjs'; +export const TOOL_WINDOW_MS=120000, ROAD_WINDOW_MS=180000, GROWTH_WINDOW_MS=600000; +const LIMIT=4096, EVENTS=512; +const reads=new Set(['read','read_file','read_files','readfile','glob','grep','rg','search','search_query','search_code','code_search','web_search','websearch','webfetch','read_resource','read_mcp_resource','recall_memory','fetch']); +const toolLeaf=name=>typeof name==='string'?name.split(/__|[./:]/).at(-1).toLowerCase():''; +const active=t=>t.status==='working'&&!t.quiet&&!t.sourceStale; +const resident=t=>t.raw?.inventoryScope==='dashboard'||(t.raw?.inventoryScope!=='history'&&t.raw?.occupancy!=='settled'); +const recent=(at,now,window)=>Number.isFinite(at)&&at<=now&&now-atLIMIT)map.delete(map.keys().next().value);} +function linksOf(tasks,relationships=[],handoffs=[]){ + const aliases=new Map(),towns=new Set(tasks.map(t=>t.town)),links=new Map(); + for(const t of tasks)for(const id of [t.id,t.raw?.taskId].filter(Boolean)){ + const key=String(id);aliases.set(key,aliases.has(key)&&aliases.get(key)?.id!==t.id?null:t); + } + const resolve=id=>typeof id==='string'?aliases.get(id.replace(/^task:/,'')):null; + const endpoint=id=>{const task=resolve(id),town=typeof id==='string'&&!id.startsWith('task:')?normalizeTown(id):null;return task?{id:task.id,town:task.town}:towns.has(town)?{id:null,town}:null;}; + function add(id,kind,from,to,stamp){ + const a=endpoint(from),b=endpoint(to);if(!a||!b||(a.id===b.id&&a.town===b.town))return; + links.set(id,{id,kind,fromId:a.id,toId:b.id,fromTown:a.town,toTown:b.town,recordedAt:validTime(stamp)}); + } + for(const t of tasks)if(t.parent&&resolve(t.parent))add(`parent:${t.parent}:${t.id}`,'parent',t.parent,t.id); + for(const r of Array.isArray(relationships)?relationships:[]){ + if(typeof r?.from!=='string'||typeof r?.to!=='string')continue; + const kind=typeof r.kind==='string'?r.kind:'relationship'; + add(`relationship:${r.id||`${r.from}:${r.to}:${kind}`}`,kind,r.from,r.to); + } + for(const h of Array.isArray(handoffs)?handoffs:[]){ + if(typeof h?.id!=='string'||typeof h?.from!=='string'||typeof h?.to!=='string')continue; + add(`handoff:${h.id}`,'handoff',h.from,h.to,h.timestamp??h.at); + } + return links; +} +export function createTownTrades(){ + let sourceKey,initialized=false,arrivals=[],roadEvents=[]; + const seenResidents=new Map(),seenLinks=new Map(); + function reset(){sourceKey=undefined;initialized=false;arrivals=[];roadEvents=[];seenResidents.clear();seenLinks.clear();} + function prune(now){arrivals=arrivals.filter(e=>recent(e.at,now,GROWTH_WINDOW_MS));roadEvents=roadEvents.filter(e=>recent(e.at,now,ROAD_WINDOW_MS));} + function observeSnapshot({tasks=[],relationships=[],handoffs=[]}={},options={}){ + const {now=Date.now(),stale=false}=options; + if(sourceKey!==undefined&&sourceKey!==options.sourceKey)reset(); + sourceKey=options.sourceKey;prune(now);if(stale)return; + for(const t of tasks){ + if(!seenResidents.has(t.id)&&initialized&&resident(t)&&!t.sourceStale)arrivals.push({taskId:t.id,town:t.town,at:now}); + remember(seenResidents,t.id,now); + } + for(const [id,link] of linksOf(tasks,relationships,handoffs)){ + if(!seenLinks.has(id)&&initialized&&(link.kind!=='handoff'||recent(link.recordedAt,now,ROAD_WINDOW_MS)))roadEvents.push({...link,at:link.kind==='handoff'?link.recordedAt:now}); + remember(seenLinks,id,now); + } + arrivals=arrivals.slice(-EVENTS);roadEvents=roadEvents.slice(-EVENTS);initialized=true; + } + function snapshot({tasks=[],now=Date.now(),stale=false}={}){ + prune(now);const visible=tasks.filter(resident),ids=new Set(visible.map(t=>t.id)),towns=new Set(visible.map(t=>t.town)); + const miners=[],workshops=[],stories=[];let toolTasks=0,missingToolTasks=0; + for(const t of visible){ + const tool=t.raw?.toolActivity,at=validTime(tool?.at),hasTool=tool&&typeof tool.name==='string'&&tool.name.trim().length>0&&['started','succeeded','failed','cancelled'].includes(tool.state)&&at!==null&&at<=now; + if(hasTool)toolTasks++;else missingToolTasks++; + if(stale||!active(t)||!hasTool||tool.stale||!recent(at,now,TOOL_WINDOW_MS)||!['started','succeeded'].includes(tool.state))continue; + const gathering=reads.has(toolLeaf(tool.name)),role=gathering?'miner':'workshop'; + (gathering?miners:workshops).push({taskId:t.id,town:t.town,toolName:tool.name,state:'working',at}); + stories.push({id:`${role}:${t.id}`,role,taskId:t.id,town:t.town,title:`${gathering?'Gathering material':'Workshop activity'} · ${t.raw?.name||t.title}`,reason:`${tool.name} ${tool.state==='started'?'started':'succeeded'}. ${gathering?'A read or search tool':'Tool use'} was observed within the last two minutes; the task is recorded as working. ${gathering?'':'The tool name alone does not establish what work it performed. '}This does not assert that the tool is still running.`,at}); + } + const roads=roadEvents.filter(r=>towns.has(r.toTown)||towns.has(r.fromTown)).filter(r=>(!r.toId||ids.has(r.toId))&&(!r.fromId||ids.has(r.fromId))); + for(const r of roads)stories.push({id:r.id,role:'road',taskId:r.toId||r.fromId||null,town:r.toTown,fromTown:r.fromTown,toTown:r.toTown,fromId:r.fromId,toId:r.toId,title:`Laying a connection · ${r.toTown}`,reason:`${r.kind==='handoff'?'Recorded handoff':'Newly observed '+r.kind+' link'} from ${r.fromTown}${r.fromId?' / '+r.fromId:''} to ${r.toTown}${r.toId?' / '+r.toId:''}. Road work lasts three minutes on existing streets.`,at:r.at}); + const gardens=[...towns].map(town=>{ + const local=visible.filter(t=>t.town===town),newcomers=arrivals.filter(a=>a.town===town),count=newcomers.length; + const g={town,arrivals:count,activeCount:stale?0:local.filter(active).length,growth:count>=3?'spurt':count?'growing':'settled',taskIds:newcomers.map(a=>a.taskId).filter(id=>ids.has(id))}; + stories.push({id:`garden:${town}`,role:'gardener',taskId:g.taskIds[0]||null,town,title:`${count>=3?'Growth spurt':count?'New seedlings':'A settled garden'} · ${town}`,reason:count?`${count} residents first observed here during the last ten minutes. Each seedling marks an arrival observed by this browser, not an inferred task creation. ${count>=3?'The gardener pauses beside a full bench.':'The gardener carries new pots.'}`:'No new residents observed here during the last ten minutes. The first snapshot is a baseline; a calm garden does not mean the tasks are idle.',at:newcomers.at(-1)?.at??null}); + return g; + }); + return {miners,workshops,roads,gardens,stories,coverage:{toolTasks,missingToolTasks},stale}; + } + return {observeSnapshot,snapshot,reset}; +} diff --git a/src/trade-art.mjs b/src/trade-art.mjs new file mode 100644 index 0000000..2951561 --- /dev/null +++ b/src/trade-art.mjs @@ -0,0 +1,149 @@ +/** Evidence-backed trades, painted only on the supplied visible town geometry. */ +const townOf = a => a?.town || a?.raw?.town || ''; +const finiteRect = r => r && ['x','y','w','h'].every(k => Number.isFinite(r[k])) && r.w > 0 && r.h > 0; +const contains = (a,b) => b.x >= a.x && b.y >= a.y && b.x+b.w <= a.x+a.w && b.y+b.h <= a.y+a.h; +const overlaps = (a,b) => a.x < b.x+b.w && a.x+a.w > b.x && a.y < b.y+b.h && a.y+a.h > b.y; +const clamp = (v,a,b) => Math.max(a,Math.min(b,v)); +function household(plots,id,town) { + if (!id) return; + return plots.find(p => (!town || townOf(p.agent) === town) && + [p.agent,...(p.agent?.roommates || []),...(p.kids || []).map(k=>k.agent)].some(a=>a?.id===id)); +} +function districtFor(districts,p,town) { + return districts.find(d=>d.key===town && contains(d,{x:p.x,y:p.y,w:p.w||54,h:p.h||66})); +} +/** Shared geometry for painting and evidence hit-testing. No input is mutated. */ +export function tradeSites({trades={},plots=[],districts=[],roads=[],townFilter=''}={}) { + const sites=[], yardPlots=new Set(), allowed = town => !townFilter || town===townFilter; + const visibleDistricts=districts.filter(finiteRect); + for (const [role,work] of [['miner',trades.miners],['workshop',trades.workshops]]) for (const m of work || []) { + if (!allowed(m.town)) continue; + const p=household(plots,m.taskId,m.town); + if (!p || yardPlots.has(p)) continue; + const d=districtFor(visibleDistricts,p,m.town); + if (!d) continue; + // Try either side of this exact household. The last column has no right + // margin, but its left yard can still fit without covering the front lane. + const candidates=[p.x+(p.w||54)+2,p.x-25].map(x=>({x,y:p.y+(p.h||66)-25,w:23,h:24})); + const box=candidates.find(b=>contains(d,b) && !sites.some(s=>overlaps(s,b)) && + !plots.some(q=>overlaps(b,{x:q.x,y:q.y,w:q.w||54,h:(q.h||66)+24}) || + (q.kids||[]).some(k=>overlaps(b,{x:k.x,y:k.y,w:16,h:18})))); + if (!box) continue; + yardPlots.add(p); + sites.push({...box,role,taskId:m.taskId,town:m.town,evidence:m,clip:d}); + } + for (const work of trades.roads || []) { + // Prefer the recorded destination. Never substitute an unrelated cottage. + const endpoints=[[work.toId,work.toTown],[work.fromId,work.fromTown]]; + let p,town,taskId=null,d; + for (const [id,t] of endpoints) { + if (!t || !allowed(t)) continue; + if (id) { + p=household(plots,id,t); + d=p && districtFor(visibleDistricts,p,t); + } else { + p=null; + d=visibleDistricts.find(candidate=>candidate.key===t && plots.some(plot=> + townOf(plot.agent)===t && contains(candidate,{x:plot.x,y:plot.y,w:plot.w||54,h:plot.h||66}))); + } + if (d) { town=t;taskId=id || null;break; } + } + if (!d) continue; + const cx=p?p.x+(p.w||54)/2:d.x+d.w/2, cy=p?p.y+(p.h||66)+5:d.y+d.h/2; + const candidates=roads.filter(r=>finiteRect(r) && r.w>=28 && r.h>=22 && + // Only a street in or immediately beside this recorded district belongs. + overlaps(r,{x:d.x-16,y:d.y-16,w:d.w+32,h:d.h+32})).map(r=>{ + const x=clamp(cx-14,r.x,r.x+r.w-28), y=clamp(cy-11,r.y,r.y+r.h-22); + return {x,y,w:28,h:22,clip:r,distance:(x+14-cx)**2+(y+11-cy)**2}; + }).sort((a,b)=>a.distance-b.distance); + const box=candidates.find(candidate=>!sites.some(s=>s.role==='road' && overlaps(s,candidate))); + if (!box) continue; + sites.push({...box,role:'road',taskId,town,evidence:work}); + } + for (const garden of trades.gardens || []) { + if (!allowed(garden.town)) continue; + // Do not decorate a filtered-away district on the strength of its bounds alone. + const local=plots.filter(p=>townOf(p.agent)===garden.town); + if (!local.length) continue; + const districtsForTown=visibleDistricts.filter(d=>d.key===garden.town && local.some(p=>contains(d,{x:p.x,y:p.y,w:54,h:66}))); + for (const d of districtsForTown) { + const candidates=[{x:d.x+8,y:d.y+d.h-25,w:66,h:23},{x:d.x+d.w-74,y:d.y+d.h-25,w:66,h:23}]; + const box=candidates.find(b=>contains(d,b) && !local.some(p=>overlaps(b,{x:p.x-2,y:p.y-2,w:(p.w||54)+4,h:(p.h||66)+24})) && !sites.some(s=>overlaps(s,b))); + if (box) sites.push({...box,role:'gardener',taskId:(garden.taskIds||[]).find(id=>household(local,id,garden.town)),town:garden.town,evidence:garden,clip:d}); + } + } + return sites; +} +function rect(ctx,c,x,y,w,h) {ctx.fillStyle=c;ctx.fillRect(Math.round(x),Math.round(y),w,h);} +function worker(ctx,x,y,coat,step=0) { + rect(ctx,'#493d3c',x+2,y,4,2);rect(ctx,'#e2b98e',x+2,y+2,4,4); + rect(ctx,coat,x+1,y+6,6,6);rect(ctx,'#4c535b',x+1+step,y+12,2,3);rect(ctx,'#4c535b',x+5-step,y+12,2,3); +} +function miner(ctx,s,phase,moving) { + const {x,y}=s; + rect(ctx,'#82745e',x+1,y+12,11,9);rect(ctx,'#b2a087',x+3,y+10,7,3); + rect(ctx,'#393e43',x+4,y+14,6,7);rect(ctx,'#af936c',x+2,y+13,2,8); + rect(ctx,'#89b6b1',x+6,y+16,2,2);rect(ctx,'#d1b879',x+1,y+22,4,2); + worker(ctx,x+14,y+6,'#7f9ca2'); + rect(ctx,'#d4ad53',x+15,y+6,6,3);rect(ctx,'#f7df9a',x+14,y+8,2,2); + const lift=moving && phase%4<2?3:0; + rect(ctx,'#99764f',x+11,y+13-lift,2,9);rect(ctx,'#b4c1bb',x+8,y+12-lift,7,2); + if (moving && phase%4===3) {rect(ctx,'#f7df9a',x+6,y+11,1,2);rect(ctx,'#d5bd7a',x+9,y+8,2,1);} +} +function workshop(ctx,s,phase,moving) { + const {x,y}=s; + rect(ctx,'#9a7953',x+1,y+15,12,3);rect(ctx,'#594f40',x+2,y+18,2,6);rect(ctx,'#594f40',x+10,y+18,2,6); + rect(ctx,'#b6bab0',x+4,y+11,5,4);rect(ctx,'#7a8785',x+3,y+12,7,2);rect(ctx,'#d2caa7',x+5,y+10,3,6);rect(ctx,'#586d70',x+6,y+12,1,2); + rect(ctx,'#766b4d',x+1,y+2,1,9);rect(ctx,'#b8a471',x,y+2,6,1);rect(ctx,'#e9cb78',x+3,y+3,3,3); + if(moving&&phase%4<2)rect(ctx,'#f8e3a4',x+4,y+4,1,1); + worker(ctx,x+14,y+6,'#a48769'); + const lift=moving&&phase%4<2?3:0; + rect(ctx,'#876c49',x+12,y+12-lift,1,7);rect(ctx,'#768d90',x+10,y+11-lift,5,3); +} +function roadmender(ctx,s,phase,moving) { + const {x,y}=s; + for(let i=0;i<4;i++) {rect(ctx,'#747b76',x+i*6,y+17,5,3);rect(ctx,'#b4baa3',x+i*6,y+17,5,1);} + rect(ctx,'#85704e',x+1,y+2,2,7);rect(ctx,'#85704e',x+11,y+2,2,7);rect(ctx,'#c9a56b',x,y+3,14,2); + rect(ctx,'#dfce9a',x+4,y+3,3,2);rect(ctx,'#dfce9a',x+10,y+3,3,2); + rect(ctx,'#919b97',x+2,y+12,7,3);rect(ctx,'#b8c0ac',x+3,y+10,5,2); + worker(ctx,x+18,y+2,'#b79568');rect(ctx,'#b8b092',x+19,y+2,6,2); + const lift=moving && phase%4<2?4:0; + rect(ctx,'#8f7455',x+15,y+11-lift,1,7);rect(ctx,'#596b70',x+12,y+10-lift,6,3); +} +function gardener(ctx,s,phase,moving) { + const {x,y,evidence:g}=s; + rect(ctx,'#80654d',x+1,y+10,18,3);rect(ctx,'#554f40',x+2,y+13,2,7);rect(ctx,'#554f40',x+16,y+13,2,7); + const count=Math.min(6,Math.max(1,Number(g.arrivals)||0)); + for(let i=0;i!town||story.town===town||(story.role==='road'&&(story.fromTown===town||story.toTown===town)); +const roles={miner:'Miner',workshop:'Workshop',road:'Roadmender',gardener:'Gardener'}; +const recordedTime=at=>{const d=new Date(at);return Number.isFinite(d.getTime())?d.toLocaleString():String(at);}; +export function scopedTrades(trades={},townFilter='') { + const matches=s=>!townFilter||s.town===townFilter; + return {miners:(trades.miners||[]).filter(matches),workshops:(trades.workshops||[]).filter(matches),roads:(trades.roads||[]).filter(s=>!townFilter||s.toTown===townFilter||s.fromTown===townFilter),gardens:(trades.gardens||[]).filter(matches),stories:(trades.stories||[]).filter(s=>inTown(s,townFilter))}; +} +export function tradeStoryForSite(site,trades={},townFilter='') { + if(!site||!roles[site.role]||(townFilter&&site.town!==townFilter))return null; + const stories=scopedTrades(trades,townFilter).stories; + const role=story=>story.role===site.role && inTown(story,site.town); + const source=site.evidence; + // Prefer exact recorded event identity; never manufacture a story from a prop. + return stories.find(s=>role(s)&&(source?.id?s.id===source.id||s.id===`${site.role}:${source.id}`:false)) || + stories.find(s=>role(s)&&(site.role==='gardener'||(site.taskId?[s.taskId,...(site.role==='road'?[s.fromId,s.toId]:[])].includes(site.taskId):!s.taskId))) || null; +} +export function toolEvidence(tasks,{townFilter='',now=Date.now(),stale=false}={}) { + const result={recent:0,older:0,unreported:0,total:0,working:[]}; + for(const task of tasks.filter(task=>!townFilter||townOf(task)===townFilter)){ + const tool=task.raw?.toolActivity,at=validTime(tool?.at); + const valid=tool&&typeof tool.name==='string'&&tool.name.trim()&&['started','succeeded','failed','cancelled'].includes(tool.state)&&at!==null&&at<=now; + const ageMs=valid?now-at:null; + const freshness=!valid?'unreported':stale||task.sourceStale||tool.stale||ageMs>=120000?'older':'recent'; + result[freshness]++;result.total++; + if(task.status==='working')result.working.push({taskId:task.id,name:task.raw?.name||task.title||task.id, + toolName:valid?tool.name:null,state:valid?tool.state:null,ageMs,freshness}); + } + return result; +} +function toolRowsHTML(coverage){ + if(!coverage.working.length)return ''; + return `

${coverage.working.length} recorded working · latest tool observations

${coverage.working.map(row=>``).join('')}
`; +} +export function tradePanelHTML({trades={},tasks=[],townFilter='',stale=false,illustrative=false,selectedId=null,canRehearseGrowth=true,now=Date.now()}={}) { + const scope=scopedTrades(trades,townFilter),selected=scope.stories.find(s=>s.id===selectedId); + const exactIds=selected?[selected.taskId,...(selected.role==='road'?[selected.toId,selected.fromId]:[])].filter(Boolean):[]; + const exactTask=exactIds.map(id=>tasks.find(t=>t.id===id&&(!townFilter||townOf(t)===townFilter))).find(Boolean); + const arrivals=scope.gardens.reduce((sum,g)=>sum+(Number(g.arrivals)||0),0); + const coverage=toolEvidence(tasks,{townFilter,now,stale}); + return `Village activity ${scope.miners.length} miners${scope.workshops.length?` · ${scope.workshops.length} workshops`:''} · ${scope.roads.length} road crews · ${arrivals} arrivals

${e(townFilter||'All towns')} · ${illustrative?'Illustrative town · local demo':stale?'Source stale · last recorded evidence':'Recorded feed'}${illustrative&&stale?' · paused evidence':''}

Tool evidence · ${coverage.recent} recent (<2m) · ${coverage.older} older/stale · ${coverage.unreported} unreported across ${coverage.total} cottages. Unreported does not mean idle.

${toolRowsHTML(coverage)}
${scope.stories.map(s=>``).join('')||'No new recorded trade activity in this view.'}
${selected?`
${e(selected.title)}

${e(selected.reason)}

${selected.at?`

Recorded ${e(recordedTime(selected.at))}

`:''}${exactTask?``:''}
`:''}${illustrative?``:''}
`; +} diff --git a/test/heartbeat.test.mjs b/test/heartbeat.test.mjs new file mode 100644 index 0000000..781b874 --- /dev/null +++ b/test/heartbeat.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHeartbeat } from '../src/heartbeat.mjs'; +const event = (extra={}) => ({id:'e1',kind:'pr',text:'PR · merged',prKey:'github.com/acme/app#42',agentId:'a',town:'AppTown',...extra}); +function context() { const calls=[]; return {calls,save(){},restore(){},fillRect(...v){calls.push([this.fillStyle,...v]);},fillText(...v){calls.push(['label',...v]);}}; } +const scene={districts:[{key:'AppTown',x:0,y:0,w:200,h:160}],plots:[{x:40,y:40,agent:{id:'a',town:'AppTown'}}],tasks:[{id:'a',status:'working',raw:{town:'AppTown'}}]}; +test('only canonical explicit outcomes launch a shipment, shared observations count once',()=>{ + const h=createHeartbeat(); h.observe([event(),event({id:'e2',agentId:'b'}),event({text:'PR · ready',prKey:'github.com/acme/app#43'})],{now:0,sourceKey:'a'}); + assert.equal(h.state.shipments.length,1); assert.equal(h.state.shipments[0].outcome,'merged'); + h.observe([event({text:'PR · closed'})],{now:1,sourceKey:'a'}); assert.equal(h.state.shipments.length,1); +}); +test('closed cart is empty; merged parcel and short PR label are drawn on front lane',()=>{ + const merged=createHeartbeat(),closed=createHeartbeat(); merged.observe([event()],{now:0});closed.observe([event({text:'PR · closed'})],{now:0}); + const a=context(),b=context();merged.drawLife(a,{...scene,time:1});closed.drawLife(b,{...scene,time:1}); + assert.ok(a.calls.some(c=>c[0]==='label'&&c[1]==='#42'));assert.equal(a.calls.length,b.calls.length+2); +}); +test('missing PR identity never invents a delivery, URL identity deduplicates canonical key',()=>{ + const h=createHeartbeat();h.observe([event({prKey:'',id:'unknown'}),event({url:'https://github.com/acme/app/pull/42',prKey:''}),event()],{now:1});assert.equal(h.state.shipments.length,1); +}); +test('source reset clears shipments and deduplication',()=>{ + const h=createHeartbeat();h.observe([event()],{now:0,sourceKey:'one'});h.observe([],{now:1,sourceKey:'two'});assert.equal(h.state.seenCount,0);assert.equal(h.state.shipments.length,0); + h.observe([event()],{now:2,sourceKey:'two'});assert.equal(h.state.shipments.length,1);h.observe([],{now:3,sourceKey:'two',sourceChanged:true});assert.equal(h.state.seenCount,0); +}); +test('bounded lifetime and memory',()=>{ + const h=createHeartbeat();h.observe(Array.from({length:400},(_,i)=>event({prKey:`github.com/acme/app#${i+1}`})),{now:0});assert.equal(h.state.seenCount,256);assert.equal(h.state.shipments.length,16); + h.drawLife(context(),{time:21});assert.equal(h.state.shipments.length,0); +}); +test('stale and reduced motion are static; idle town rests; scope excludes other towns',()=>{ + for(const flag of ['stale','reduce']) {const h=createHeartbeat();h.observe([event()],{now:0});const a=context(),b=context();h.drawGround(a,{...scene,time:1,[flag]:true});h.drawLife(a,{...scene,time:1,[flag]:true});h.drawGround(b,{...scene,time:9,[flag]:true});h.drawLife(b,{...scene,time:9,[flag]:true});assert.deepEqual(a.calls,b.calls);} + const h=createHeartbeat(),a=context(),b=context();const idle={...scene,tasks:[{...scene.tasks[0],status:'idle'}]};h.drawLife(a,{...idle,time:1});h.drawLife(b,{...idle,time:4});assert.deepEqual(a.calls,b.calls); + const scoped=context();h.drawLife(scoped,{...scene,townFilter:'OtherTown'});h.drawGround(scoped,{...scene,townFilter:'OtherTown'});assert.deepEqual(scoped.calls,[]); +}); +test('closed carts return along the lane opposite merged deliveries',()=>{ + for(const outcome of ['merged','closed']) { + const h=createHeartbeat();h.observe([event({text:`PR · ${outcome}`})],{now:0}); + const a=context(),b=context();h.drawLife(a,{...scene,time:1});h.drawLife(b,{...scene,time:10}); + const x1=a.calls.find(c=>c[0]==='label')[2],x2=b.calls.find(c=>c[0]==='label')[2]; + assert.ok(outcome==='merged'?x2>x1:x2{ + const h=createHeartbeat(),stale={...scene,tasks:[{...scene.tasks[0],sourceStale:true}]}; + const a=context(),b=context();h.drawGround(a,{...stale,time:1});assert.deepEqual(a.calls,[]); + h.drawLife(a,{...stale,time:1});h.drawLife(b,{...stale,time:10});assert.deepEqual(a.calls,b.calls); +}); +test('a disappeared or filtered household never reassigns its cart to a town neighbour',()=>{ + const h=createHeartbeat();h.observe([event()],{now:0}); + const unrelated={...scene.plots[0],agent:{id:'other',town:'AppTown'}}; + for(const plots of [[unrelated],[]]) { + const ctx=context();h.drawLife(ctx,{...scene,plots,time:1}); + assert.ok(!ctx.calls.some(c=>c[0]==='label'),'no PR label at unrelated household'); + assert.ok(!ctx.calls.some(c=>c[0]==='#c6a77a'),'no parcel at unrelated household'); + } + assert.equal(h.state.shipments.length,1,'cart remains bounded in memory in case actual plot returns'); +}); +test('explicit reset immediately clears same-source shipments and accepts subsequent IDs',()=>{ + const h=createHeartbeat();h.observe([event()],{sourceKey:'same',now:0});h.reset(); + assert.equal(h.state.shipments.length,0);assert.equal(h.state.seenCount,0); + // A synchronous empty observatory callback may still name the previous source. + h.observe([],{sourceKey:'same',now:1}); + const ctx=context();h.drawLife(ctx,{...scene,time:1});assert.ok(!ctx.calls.some(c=>c[0]==='label')); + h.observe([event()],{sourceKey:'same',now:2});assert.equal(h.state.shipments.length,1); +}); diff --git a/test/parent-paths.test.mjs b/test/parent-paths.test.mjs new file mode 100644 index 0000000..39fcbb6 --- /dev/null +++ b/test/parent-paths.test.mjs @@ -0,0 +1,90 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {parentPaths} from '../src/parent-paths.mjs'; +import {createTownLayout} from '../src/town-layout.mjs'; + +const agent = (id,extra={}) => ({id,town:'HubTown',...extra}); +const samples = points => points.flatMap((b,i) => { + if(!i)return [b]; + const a=points[i-1],length=Math.abs(b.x-a.x)+Math.abs(b.y-a.y); + assert.ok(a.x===b.x || a.y===b.y,'Every footpath turn must be orthogonal'); + return Array.from({length},(_,n)=>({x:a.x+Math.sign(b.x-a.x)*(n+1),y:a.y+Math.sign(b.y-a.y)*(n+1)})); +}); +const inside = (p,r) => p.x>r.x && p.xr.y && p.y world.plots.map(p=>({...p,kids:[]})); + +test('shed footpaths reach both doors and skirt the branch sign and workbench',()=>{ + const parent=agent('parent'),child=agent('child',{parent:'parent'}); + const world=createTownLayout().update([parent,child]),plots=rendered(world),plot=plots[0]; + plot.kids=[{agent:child,x:plot.x+1,y:plot.y+115}]; + const [path]=parentPaths(plots,world),points=samples(path.points); + assert.equal(path.kind,'yard'); + assert.deepEqual(path.points[0],{x:plot.x+27,y:plot.y+64}); + assert.deepEqual(path.points.at(-1),{x:plot.x+12,y:plot.y+130}); + const furniture=[{x:plot.x+4,y:plot.y+79,w:46,h:10},{x:plot.x+30,y:plot.y+95,w:24,h:12}]; + for(const point of points)assert.equal(furniture.some(box=>inside(point,box)),false,'Family path crosses yard furniture'); + assert.ok(points.some(p=>p.x{ + const world=createTownLayout().update([agent('a',{branch:'same'}),agent('b',{branch:'same'})]); + const plots=rendered(world); + assert.deepEqual(parentPaths(plots,world),[]); + plots[1].agent.parent='unknown'; + assert.deepEqual(parentPaths(plots,world),[]); + plots[1].agent.parent='a'; + assert.equal(parentPaths(plots,world).length,1); + assert.deepEqual(parentPaths(plots.slice(1),world),[]); + plots[1].agent.parent='b'; + assert.deepEqual(parentPaths(plots,world),[]); +}); + +test('overflow children follow row lanes and district streets without crossing cottages',()=>{ + const layout=createTownLayout({columns:2,rows:1,maxWidth:1000}); + const residents=[agent('parent'),...Array.from({length:7},(_,i)=>agent('child-'+i,{parent:'parent'}))]; + const world=layout.update(residents),plots=rendered(world),paths=parentPaths(plots,world); + assert.equal(paths.length,4,'Only the four rendered overflow cottages have endpoints'); + assert.ok(paths.some(p=>p.points.length>4),'At least one child uses a district street'); + for(const path of paths){ + const from=plots.find(p=>p.agent.id===path.parent.id),to=plots.find(p=>p.agent.id===path.child.id); + assert.deepEqual(path.points[0],{x:from.x+27,y:from.y+64}); + assert.deepEqual(path.points.at(-1),{x:to.x+27,y:to.y+64}); + for(const point of samples(path.points)){ + assert.equal(plots.some(p=>inside(point,{x:p.x,y:p.y,w:54,h:63})),false,'A family route crosses a cottage'); + if(!world.blocks.some(b=>inside(point,b)))assert.ok(world.roads.some(r=>point.x>=r.x && point.x<=r.x+r.w && point.y>=r.y && point.y<=r.y+r.h),'A route outside a district must follow a real street'); + } + } +}); + +test('a roommate can parent a shed without inventing a link to the host',()=>{ + const host=agent('host'),mate=agent('mate'),child=agent('child',{parent:'mate'}); + const world=createTownLayout().update([host]); + const plots=rendered(world),plot=plots[0]; + plot.agent={...host,roommates:[mate]};plot.kids=[{agent:child,x:plot.x+19,y:plot.y+115}]; + const [path]=parentPaths(plots,world); + assert.equal(path.parent.id,'mate');assert.equal(path.parentHost,'host'); + assert.equal(path.child.id,'child');assert.equal(path.kind,'yard'); +}); + +test('shed-to-shed paths stay orthogonal when their door heights differ',()=>{ + const world=createTownLayout().update([agent('host')]),plots=rendered(world),plot=plots[0]; + plot.kids=[{agent:agent('parent'),x:plot.x+1,y:plot.y+115}, + {agent:agent('child',{parent:'parent'}),x:plot.x+19,y:plot.y+105}]; + const [path]=parentPaths(plots,world); + samples(path.points); + assert.deepEqual(path.points[0],{x:plot.x+12,y:plot.y+130}); + assert.deepEqual(path.points.at(-1),{x:plot.x+30,y:plot.y+120}); +}); + +test('late arrivals do not move existing family routes and missing streets fail closed',()=>{ + const layout=createTownLayout({columns:2,rows:1,maxWidth:1000}); + const residents=[agent('parent'),...Array.from({length:6},(_,i)=>agent('child-'+i,{parent:'parent'}))]; + const first=layout.update(residents),before=parentPaths(rendered(first),first); + const next=layout.update([...residents,agent('new',{town:'NewTown'})]); + assert.deepEqual(parentPaths(rendered(next),next),before); + const disconnected=parentPaths(rendered(first),{...first,roads:[]}); + for(const path of disconnected){ + const from=first.plots.find(p=>p.agent.id===path.parent.id),to=first.plots.find(p=>p.agent.id===path.child.id); + assert.equal(from.blockId,to.blockId,'Disconnected districts must not gain a straight-line shortcut'); + } +}); diff --git a/test/town-layout.test.mjs b/test/town-layout.test.mjs index 2cd03d8..6fe330b 100644 --- a/test/town-layout.test.mjs +++ b/test/town-layout.test.mjs @@ -51,12 +51,20 @@ test('the noticeboard square remains reachable when the original busy town empti assert.deepEqual(positions(after)['AppTown-0'],positions(first)['AppTown-0']); }); -test('width budget uses the normal cottage scale and reserves the desktop panel', () => { - assert.equal(townWidthBudget({windowWidth:2077,standardWidth:1084}),1214); +test('width budget uses the normal cottage scale and full scene width once', () => { + assert.equal(townWidthBudget({windowWidth:2077,standardWidth:1084}),1455); assert.equal(townWidthBudget({windowWidth:390,standardWidth:340}),827); assert.equal(townWidthBudget({windowWidth:8000,standardWidth:800}),1920); }); +test('a desktop fleet can place large districts abreast without shrinking cottages',()=>{ + const agents=[16,13,3,3,3,2,2,1].flatMap((n,i)=>Array.from({length:n},(_,j)=>({id:`${i}-${j}`,town:i===0?'HubTown':`Repo${i}Town`,status:'idle'}))); + const budget=townWidthBudget({windowWidth:1422,standardWidth:1007}); + const compact=createTownLayout({maxWidth:budget}).update(agents),narrow=createTownLayout({maxWidth:807}).update(agents); + assert.ok(compact.heightb.y===30&&b.w>300).length>=2); +}); + test('town activity counts people once, including roommates and children', () => { const mate={id:'mate',town:'HubTown',status:'working'}; const list=[{id:'host',town:'HubTown',status:'idle',roommates:[mate]},mate, diff --git a/test/town-trades.test.mjs b/test/town-trades.test.mjs new file mode 100644 index 0000000..af49661 --- /dev/null +++ b/test/town-trades.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createTownTrades,TOOL_WINDOW_MS,ROAD_WINDOW_MS,GROWTH_WINDOW_MS} from '../src/town-trades.mjs'; +const now=Date.parse('2026-11-05T12:00:00Z'); +const task=(id,extra={})=>({id,town:'MemTown',status:'working',raw:{name:id,taskId:`task-${id}`},...extra}); +const read=(id,extra={})=>task(id,{raw:{name:id,toolActivity:{name:'mcp__memory__recall_memory',state:'succeeded',at:now,...extra}}}); +test('miners require explicit fresh allowlisted tools and canonical working state',()=>{ + const model=createTownTrades(),tasks=[read('a'),read('b',{name:'Bash'}),read('c',{at:now-TOOL_WINDOW_MS}),read('d',{at:now+1}),read('e',{state:'failed'}),read('f',{stale:true}),{...read('g'),quiet:true},{...read('h'),sourceStale:true},{...read('i'),status:'done'},task('j',{activity:'Read all the source files'})]; + const output=model.snapshot({tasks,now});assert.deepEqual(output.miners.map(m=>m.taskId),['a']);assert.equal(output.coverage.missingToolTasks,2); + assert.equal(model.snapshot({tasks,now,stale:true}).miners.length,0); +}); +test('tool evidence expires independently of inventory refresh',()=>{ + const model=createTownTrades();assert.equal(model.snapshot({tasks:[read('a')],now}).miners.length,1); + assert.equal(model.snapshot({tasks:[read('a')],now:now+TOOL_WINDOW_MS}).miners.length,0); +}); +test('canonical WebSearch and WebFetch tool names mine without classifying shell calls',()=>{ + const model=createTownTrades(),tasks=['WebSearch','WebFetch','mcp__browser__WebSearch','Bash','shell','exec_command'].map((name,i)=>read(String(i),{name})); + assert.deepEqual(model.snapshot({tasks,now}).miners.map(m=>m.toolName),['WebSearch','WebFetch','mcp__browser__WebSearch']); +}); +test('recent non-reading tools create truthful workshops while read tools stay miners',()=>{ + const model=createTownTrades(),tasks=[read('read',{name:'Read'}),read('bash',{name:'Bash'}),read('shell',{name:'shell',state:'started'}),read('tool',{name:'mcp__agent__wrap_up'})]; + tasks[1].raw.toolActivity.arguments={command:'private command'}; + tasks[1].activity='Running tests and writing source files'; + const output=model.snapshot({tasks,now}); + assert.deepEqual(output.miners.map(m=>m.taskId),['read']); + assert.deepEqual(output.workshops.map(w=>w.taskId),['bash','shell','tool']); + const story=output.stories.find(s=>s.id==='workshop:bash'); + assert.equal(story.role,'workshop');assert.equal(story.taskId,'bash');assert.equal(story.at,now); + assert.match(story.reason,/Bash succeeded/);assert.match(story.reason,/Tool use was observed/); + assert.match(story.reason,/does not assert that the tool is still running/); + assert.doesNotMatch(JSON.stringify(output),/private command|Running tests|writing source files|arguments/); +}); +test('workshops require fresh successful tool facts and working nonquiet tasks',()=>{ + const model=createTownTrades(); + const tasks=[read('okay',{name:'Bash'}),read('expired',{name:'Bash',at:now-TOOL_WINDOW_MS}),read('future',{name:'shell',at:now+1}),read('failed',{name:'Bash',state:'failed'}),read('cancelled',{name:'shell',state:'cancelled'}),read('stale',{name:'Bash',stale:true}),read('missing',{name:' '}),{...read('quiet',{name:'Bash'}),quiet:true},{...read('source',{name:'shell'}),sourceStale:true},{...read('done',{name:'Bash'}),status:'done'},task('prose',{activity:'Bash running'})]; + assert.deepEqual(model.snapshot({tasks,now}).workshops.map(w=>w.taskId),['okay']); + assert.deepEqual(model.snapshot({tasks,now,stale:true}).workshops,[]); + assert.deepEqual(model.snapshot({tasks:[tasks[0]],now:now+TOOL_WINDOW_MS}).workshops,[]); +}); +test('baseline, view filtering, repeated snapshots and reappearances do not invent growth',()=>{ + const model=createTownTrades(),a=task('a'),b=task('b'); + model.observeSnapshot({tasks:[a]},{sourceKey:'one',now});assert.equal(model.snapshot({tasks:[a],now}).gardens[0].arrivals,0); + model.snapshot({tasks:[],now});model.observeSnapshot({tasks:[a,b]},{sourceKey:'one',now:now+1}); + model.observeSnapshot({tasks:[a]},{sourceKey:'one',now:now+2});model.observeSnapshot({tasks:[a,b]},{sourceKey:'one',now:now+3}); + assert.equal(model.snapshot({tasks:[a,b],now:now+3}).gardens[0].arrivals,1); + assert.equal(model.snapshot({tasks:[a,b],now:now+GROWTH_WINDOW_MS+1}).gardens[0].arrivals,0); +}); +test('three new residents make a growth spurt, historical membership does not',()=>{ + const model=createTownTrades(),tasks=[task('a')];model.observeSnapshot({tasks},{sourceKey:'one',now}); + tasks.push(task('b'),task('c'),task('d'),task('historic',{raw:{inventoryScope:'history'}})); + model.observeSnapshot({tasks},{sourceKey:'one',now:now+1}); + const garden=model.snapshot({tasks,now:now+1}).gardens[0];assert.equal(garden.growth,'spurt');assert.equal(garden.arrivals,3);assert.deepEqual(garden.taskIds,['b','c','d']); +}); +test('dashboard membership keeps trades and growth aligned with canonical inventory',()=>{ + const model=createTownTrades(),base=task('baseline'); + model.observeSnapshot({tasks:[base]},{sourceKey:'one',now}); + const dashboard=task('dashboard',{status:'offline',raw:{inventoryScope:'dashboard',occupancy:'settled'}}); + const history=task('history',{raw:{inventoryScope:'history',occupancy:'live'}}); + const standalone=task('standalone',{raw:{occupancy:'settled'}}); + const tasks=[base,dashboard,history,standalone]; + model.observeSnapshot({tasks},{sourceKey:'one',now:now+1}); + const snapshot=model.snapshot({tasks,now:now+1}); + assert.equal(snapshot.gardens[0].arrivals,1); + assert.deepEqual(snapshot.gardens[0].taskIds,['dashboard']); + assert.equal(snapshot.gardens[0].activeCount,1,'finished dashboard residents do not act working'); + assert.equal(snapshot.coverage.missingToolTasks,2,'dashboard and standalone visible baseline count; history and settled standalone do not'); +}); +test('only newly observed explicit links create roads; handoffs need recent clocks',()=>{ + const model=createTownTrades(),a=task('a'),b=task('b',{parent:'task-a'}),c=task('c'); + model.observeSnapshot({tasks:[a,b,c]},{sourceKey:'one',now}); + assert.equal(model.snapshot({tasks:[a,b,c],now}).roads.length,0); + const relationships=[{id:'r',from:'task:a',to:'task:c',kind:'depends-on'},{id:'bad',from:'missing',to:'c'}]; + const handoffs=[{id:'old',from:'a',to:'b',at:now-ROAD_WINDOW_MS},{id:'future',from:'a',to:'b',at:now+999},{id:'new',from:'a',to:'b',at:now}]; + model.observeSnapshot({tasks:[a,b,c],relationships,handoffs},{sourceKey:'one',now:now+1}); + assert.deepEqual(model.snapshot({tasks:[a,b,c],now:now+1}).roads.map(r=>r.id),['relationship:r','handoff:new']); + assert.equal(model.snapshot({tasks:[a,b,c],now:now+ROAD_WINDOW_MS+1}).roads.length,0); +}); +test('stale snapshots cannot create arrivals and source change resets immediately',()=>{ + const model=createTownTrades(),a=task('a'),b=task('b');model.observeSnapshot({tasks:[a]},{sourceKey:'one',now}); + model.observeSnapshot({tasks:[a,b]},{sourceKey:'one',now:now+1,stale:true});assert.equal(model.snapshot({tasks:[a,b],now:now+1}).gardens[0].arrivals,0); + model.observeSnapshot({tasks:[a,b]},{sourceKey:'one',now:now+2});assert.equal(model.snapshot({tasks:[a,b],now:now+2}).gardens[0].arrivals,1); + model.observeSnapshot({tasks:[a,b]},{sourceKey:'two',now:now+3,stale:true});assert.equal(model.snapshot({tasks:[a,b],now:now+3}).gardens[0].arrivals,0); + model.observeSnapshot({tasks:[a,b]},{sourceKey:'two',now:now+4});assert.equal(model.snapshot({tasks:[a,b],now:now+4}).gardens[0].arrivals,0); + model.reset();assert.deepEqual(model.snapshot({tasks:[],now:now+5}).stories,[]); +}); + +test('documented town-stem aliases preserve explicit links without assigning a cottage',()=>{ + const model=createTownTrades(),tasks=[task('a'),task('b',{town:'HubTown'})]; + model.observeSnapshot({tasks},{sourceKey:'one',now}); + model.observeSnapshot({tasks,relationships:[{id:'known',from:'Mem',to:'Hub'},{id:'unknown',from:'Missing',to:'Hub'}]},{sourceKey:'one',now:now+1}); + const roads=model.snapshot({tasks,now:now+1}).roads; + assert.equal(roads.length,1);assert.equal(roads[0].fromTown,'MemTown');assert.equal(roads[0].toTown,'HubTown');assert.equal(roads[0].toId,null); +}); + +test('an unresolved parent or task-prefixed endpoint never becomes a town',()=>{ + const model=createTownTrades(),tasks=[task('a'),task('b',{town:'TaskMemTown'})]; + model.observeSnapshot({tasks},{sourceKey:'one',now}); + tasks.push(task('child',{parent:'Mem'})); + model.observeSnapshot({tasks,relationships:[{from:'task:Mem',to:'a',kind:'depends-on'}]},{sourceKey:'one',now:now+1}); + assert.deepEqual(model.snapshot({tasks,now:now+1}).roads,[]); +}); diff --git a/test/trade-art.test.mjs b/test/trade-art.test.mjs new file mode 100644 index 0000000..8cce4e3 --- /dev/null +++ b/test/trade-art.test.mjs @@ -0,0 +1,119 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {drawTradeLife,tradeSites} from '../src/trade-art.mjs'; +const fixtures=()=>({ + plots:[{agent:{id:'a',town:'OakTown',roommates:[{id:'mate',town:'OakTown'}]},x:40,y:60,kids:[{agent:{id:'kid',town:'OakTown'},x:100,y:150}]}, + {agent:{id:'b',town:'PineTown'},x:340,y:60,kids:[]}], + districts:[{key:'OakTown',x:0,y:0,w:240,h:240},{key:'PineTown',x:300,y:0,w:240,h:240}], + roads:[{x:0,y:140,w:240,h:24},{x:240,y:0,w:48,h:300},{x:0,y:250,w:600,h:40}], + trades:{miners:[{taskId:'a',town:'OakTown'},{taskId:'b',town:'PineTown'}], + roads:[{id:'link',fromId:'a',toId:'b',fromTown:'OakTown',toTown:'PineTown'}], + gardens:[{town:'OakTown',arrivals:4,growth:'spurt',taskIds:['a']},{town:'PineTown',arrivals:1,growth:'growing',taskIds:['b']}]} +}); +function canvas(fail=false) { + const calls=[], stack=[]; + const ctx={calls,fillStyle:'original',save(){stack.push(this.fillStyle);calls.push(['save']);},restore(){this.fillStyle=stack.pop();calls.push(['restore']);}, + beginPath(){calls.push(['begin']);},rect(...v){calls.push(['rect',...v]);},clip(){calls.push(['clip']);}, + fillRect(...v){if(fail)throw Error('paint failed');calls.push(['fill',this.fillStyle,...v]);}}; + Object.defineProperty(ctx,'depth',{get:()=>stack.length});return ctx; +} +test('every miner belongs to its exact visible household, including roommates and children',()=>{ + const f=fixtures();f.trades.miners=[{taskId:'missing',town:'OakTown'},{taskId:'b',town:'OakTown'},{taskId:'mate',town:'OakTown'},{taskId:'kid',town:'OakTown'}]; + const sites=tradeSites(f).filter(s=>s.role==='miner'); + assert.equal(sites.length,1,'one compact work yard per household');assert.equal(sites[0].taskId,'mate'); + assert.equal(sites[0].x,96);assert.equal(sites[0].y,101); + f.trades.miners=[{taskId:'kid',town:'OakTown'}];assert.equal(tradeSites(f).find(s=>s.role==='miner').taskId,'kid'); + f.plots=[];assert.deepEqual(tradeSites(f),[]); +}); +test('workshops use exact household yards and do not overlap a miner in the same household',()=>{ + const f=fixtures();f.trades.miners=[{taskId:'a',town:'OakTown'}]; + f.trades.workshops=[{taskId:'mate',town:'OakTown'},{taskId:'missing',town:'PineTown'},{taskId:'b',town:'PineTown'}]; + let sites=tradeSites(f).filter(s=>s.role==='workshop'); + assert.equal(sites.length,1);assert.equal(sites[0].taskId,'b');assert.equal(sites[0].town,'PineTown'); + assert.equal(sites[0].x,396);assert.equal(sites[0].y,101); + f.townFilter='OakTown';assert.equal(tradeSites(f).filter(s=>s.role==='workshop').length,0); + f.trades.miners=[];sites=tradeSites(f).filter(s=>s.role==='workshop');assert.equal(sites[0].taskId,'mate'); + f.plots=[];assert.deepEqual(tradeSites(f),[]); +}); +test('last-column roommate activity uses its own left yard inside the district',()=>{ + const f={districts:[{key:'HubTown',id:'HubTown:0',x:114,y:30,w:330,h:356}], + plots:[132,212,292,372].flatMap(x=>[96,232].map(y=>({x,y,agent:{id:`${x}-${y}`,town:'HubTown',roommates:x===372&&y===232?[{id:'live-roommate',town:'HubTown'}]:[]}})))}; + for(const [key,role] of [['miners','miner'],['workshops','workshop']]){ + const sites=tradeSites({...f,trades:{[key]:[{taskId:'live-roommate',town:'HubTown',toolName:role==='miner'?'Grep':'Bash'}]}}); + assert.equal(sites.length,1);const site=sites[0]; + assert.equal(site.role,role);assert.equal(site.taskId,'live-roommate'); + assert.deepEqual({x:site.x,y:site.y,w:site.w,h:site.h},{x:347,y:273,w:23,h:24}); + assert.ok(site.x>292+54&&site.x+site.w<372,'yard stays between adjacent cottage bodies'); + assert.ok(site.y+site.h<232+66,'yard remains above the doorway, front lane and branch sign'); + assert.ok(site.x>=114&&site.x+site.w<=444,'yard remains inside its recorded district'); + } + const busy=tradeSites({...f,trades:{miners:[{taskId:'292-232',town:'HubTown'}],workshops:[{taskId:'live-roommate',town:'HubTown'}]}}); + assert.equal(busy.length,1,'do not overlap the neighbouring miner when both side yards are unavailable'); + assert.equal(busy[0].taskId,'292-232'); + assert.deepEqual(tradeSites({...f,trades:{workshops:[{taskId:'missing-roommate',town:'HubTown'}]}}),[]); +}); +test('workshop paint moves only with fresh motion enabled and preserves canvas state',()=>{ + const f=fixtures();f.trades={workshops:[{taskId:'a',town:'OakTown',toolName:'Bash'}]}; + for(const flag of ['reduce','stale']){ + const a=canvas(),b=canvas();drawTradeLife(a,{...f,[flag]:true,time:0});drawTradeLife(b,{...f,[flag]:true,time:1}); + assert.deepEqual(a.calls,b.calls);assert.equal(a.depth,0);assert.equal(a.fillStyle,'original'); + } + const a=canvas(),b=canvas();drawTradeLife(a,{...f,time:0});drawTradeLife(b,{...f,time:1});assert.notDeepEqual(a.calls,b.calls); + const site=tradeSites(f)[0];assert.equal(site.role,'workshop');assert.equal(site.evidence.toolName,'Bash'); +}); +test('roads require recorded endpoints and stay inside an actual street',()=>{ + const f=fixtures();const site=tradeSites(f).find(s=>s.role==='road'); + assert.equal(site.taskId,'b');assert.equal(site.town,'PineTown'); + assert.ok(f.roads.some(r=>site.x>=r.x&&site.y>=r.y&&site.x+site.w<=r.x+r.w&&site.y+site.h<=r.y+r.h)); + f.trades.roads=[{fromId:'missing',toId:'also-missing',fromTown:'OakTown',toTown:'OakTown'}]; + assert.equal(tradeSites(f).filter(s=>s.role==='road').length,0); + f.trades.roads=[{fromId:'a',fromTown:'OakTown'}];f.roads=[]; + assert.equal(tradeSites(f).filter(s=>s.role==='road').length,0); +}); +test('town filtering applies to every trade and can use the visible recorded endpoint',()=>{ + const f={...fixtures(),townFilter:'OakTown'}; + const sites=tradeSites(f);assert.ok(sites.length>=3);assert.ok(sites.every(s=>s.town==='OakTown')); + assert.equal(sites.find(s=>s.role==='road').taskId,'a'); + f.plots=f.plots.filter(p=>p.agent.id==='b');assert.deepEqual(tradeSites(f),[]); +}); +test('gardens fit district margins and avoid houses and their signs',()=>{ + const f=fixtures();const sites=tradeSites(f).filter(s=>s.role==='gardener');assert.equal(sites.length,2); + for(const s of sites) {assert.ok(s.x>=s.clip.x&&s.y>=s.clip.y&&s.x+s.w<=s.clip.x+s.clip.w&&s.y+s.h<=s.clip.y+s.clip.h);} + f.districts=[{key:'OakTown',x:40,y:60,w:60,h:70}];assert.equal(tradeSites(f).filter(s=>s.role==='gardener').length,0); +}); +test('reduced motion and stale scenes are completely still across clocks',()=>{ + for(const flag of ['reduce','stale']) { + const a=canvas(),b=canvas();drawTradeLife(a,{...fixtures(),[flag]:true,time:0});drawTradeLife(b,{...fixtures(),[flag]:true,time:123.9}); + assert.deepEqual(a.calls,b.calls); + } + const a=canvas(),b=canvas();drawTradeLife(a,{...fixtures(),time:0});drawTradeLife(b,{...fixtures(),time:1});assert.notDeepEqual(a.calls,b.calls); +}); +test('every site is clipped; canvas state and inputs survive drawing and paint failures',()=>{ + const f=fixtures(),before=structuredClone(f),ctx=canvas();drawTradeLife(ctx,f); + assert.equal(ctx.depth,0);assert.equal(ctx.fillStyle,'original');assert.deepEqual(f,before); + assert.equal(ctx.calls.filter(c=>c[0]==='clip').length,tradeSites(f).length*2); + const broken=canvas(true);assert.throws(()=>drawTradeLife(broken,f),/paint failed/);assert.equal(broken.depth,0);assert.equal(broken.fillStyle,'original'); +}); +test('growth seedlings are bounded independently of reported arrivals',()=>{ + const f=fixtures();f.trades={gardens:[{town:'OakTown',arrivals:6,growth:'spurt'}]}; + const a=canvas();drawTradeLife(a,f);f.trades.gardens[0].arrivals=1000000; + const b=canvas();drawTradeLife(b,f);assert.deepEqual(a.calls,b.calls);assert.equal(f.trades.gardens[0].arrivals,1000000); +}); + +test('town links use the exact visible recorded district and its adjacent streets',()=>{ + const f=fixtures();f.trades={roads:[{fromId:null,toId:null,fromTown:'OakTown',toTown:'PineTown'}]}; + let site=tradeSites(f)[0];assert.equal(site.town,'PineTown');assert.equal(site.taskId,null); + f.townFilter='OakTown';site=tradeSites(f)[0];assert.equal(site.town,'OakTown');assert.equal(site.taskId,null); + f.townFilter='';f.trades.roads=[{toTown:'MissingTown'}];assert.deepEqual(tradeSites(f),[]); + f.trades.roads=[{toTown:'PineTown'}];f.plots=f.plots.filter(p=>p.agent.town==='OakTown');assert.deepEqual(tradeSites(f),[]); + f.plots=fixtures().plots;f.roads=[{x:1000,y:1000,w:100,h:40}];assert.deepEqual(tradeSites(f),[]); + f.trades.roads=[{toId:'b',toTown:'PineTown'}];assert.deepEqual(tradeSites(f),[]); +}); + +test('a cottage in the middle of a wide district still uses that district’s street',()=>{ + const site=tradeSites({plots:[{agent:{id:'middle',town:'OakTown'},x:212,y:96}], + districts:[{key:'OakTown',x:114,y:50,w:330,h:336}], + roads:[{x:66,y:0,w:48,h:434},{x:444,y:0,w:48,h:434}], + trades:{roads:[{id:'parent',toId:'middle',toTown:'OakTown'}]}}).find(s=>s.role==='road'); + assert.ok(site);assert.equal(site.taskId,'middle');assert.ok(site.x>=66&&site.x+site.w<=114); +}); diff --git a/test/trade-panel.test.mjs b/test/trade-panel.test.mjs new file mode 100644 index 0000000..6746c6c --- /dev/null +++ b/test/trade-panel.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {scopedTrades,tradePanelHTML,tradeStoryForSite,toolEvidence} from '../src/trade-panel.mjs'; +const trades={miners:[{taskId:'a',town:'OakTown'},{taskId:'b',town:'PineTown'}],roads:[{fromId:'a',toId:'b',fromTown:'OakTown',toTown:'PineTown'}],gardens:[{town:'OakTown',arrivals:9},{town:'PineTown',arrivals:2}],stories:[{id:'m1',role:'miner',taskId:'a',town:'OakTown',title:'Read source',reason:'Recorded read tool',at:'2026-11-05T00:00:00Z'},{id:'g1',role:'gardener',town:'PineTown',title:'Two arrivals',reason:'First observed residents'}],coverage:{missingToolTasks:3}}; +const tasks=[{id:'a',raw:{town:'OakTown'}},{id:'b',raw:{town:'PineTown'}}]; +test('counts and stories obey town scope while cross-town explicit roads have both endpoints',()=>{ + const scoped=scopedTrades(trades,'OakTown');assert.equal(scoped.miners.length,1);assert.equal(scoped.roads.length,1);assert.equal(scoped.stories.length,1); + const html=tradePanelHTML({trades,tasks,townFilter:'OakTown'}); + assert.match(html,/1 miners · 1 road crews · 9 arrivals/);assert.doesNotMatch(html,/Two arrivals|PineTown/);assert.match(html,/0 recent .*0 older\/stale · 1 unreported across 1 cottages/); +}); +test('all source strings and attributes are escaped, supported exact task gets inspect',()=>{ + const evil=''; + const html=tradePanelHTML({trades:{stories:[{id:evil,role:'miner',taskId:'a',town:evil,title:evil,reason:evil,at:evil}]},tasks,selectedId:evil}); + assert.doesNotMatch(html,/{ + assert.doesNotMatch(tradePanelHTML({trades,tasks,selectedId:'expired'}),/cc-trade-story/); + assert.doesNotMatch(tradePanelHTML({trades,tasks,townFilter:'PineTown',selectedId:'m1'}),/Recorded read tool/); +}); +test('demo rehearsal is exclusive to illustrative views; stale and unknown remain explicit',()=>{ + const html=tradePanelHTML({stale:true});assert.match(html,/Source stale/);assert.match(html,/Unreported does not mean idle/);assert.doesNotMatch(html,/data-cc-rehearse-growth/); + const demo=tradePanelHTML({illustrative:true});assert.match(demo,/Illustrative town/);assert.match(demo,/data-cc-rehearse-growth/); +}); +test('sprite inspection resolves only current matching role, town and recorded identity',()=>{ + const site={role:'miner',taskId:'a',town:'OakTown'};assert.equal(tradeStoryForSite(site,trades).id,'m1'); + assert.equal(tradeStoryForSite({...site,taskId:'missing'},trades),null); + assert.equal(tradeStoryForSite(site,trades,'PineTown'),null); + assert.equal(tradeStoryForSite(site,{stories:[]}),null); + assert.equal(tradeStoryForSite({...site,role:'unknown'},trades),null); +}); +test('named tool activity objects count as available only with known state and valid recorded time',()=>{ + const tools=[{name:'read_file',state:'succeeded',at:'2026-01-01T00:00:00Z'},{name:'read',state:'failed',at:Date.now()-1000},{name:'read',state:'unknown',at:Date.now()-1000},{name:'read',state:'started',at:'invalid'},{name:'read',state:'started',at:Date.now()+60000}]; + const local=tools.map((tool,i)=>({id:String(i),raw:{town:'OakTown',toolActivity:tool}})); + assert.match(tradePanelHTML({tasks:local,townFilter:'OakTown'}),/1 recent .*1 older\/stale · 3 unreported across 5 cottages/); +}); +test('source-side roads resolve current explicit story and inspect only a supported in-town endpoint',()=>{ + const story={id:'road:link',role:'road',town:'PineTown',fromTown:'OakTown',toTown:'PineTown',fromId:'a',toId:'b',taskId:'b',title:'New explicit link',reason:'Recorded parent'}; + const t={...trades,stories:[story]}; + assert.equal(scopedTrades(t,'OakTown').stories[0],story); + assert.equal(tradeStoryForSite({role:'road',town:'OakTown',taskId:'a',evidence:{id:'link'}},t,'OakTown'),story); + const html=tradePanelHTML({trades:t,tasks,townFilter:'OakTown',selectedId:story.id});assert.match(html,/Recorded parent/);assert.match(html,/data-cc-inspect="a"/);assert.doesNotMatch(html,/data-cc-inspect="b"/); + assert.doesNotMatch(tradePanelHTML({trades:t,tasks:tasks.slice(1),townFilter:'OakTown',selectedId:story.id}),/data-cc-inspect/); +}); +test('garden story follows exact town when its first arrival has departed',()=>{ + const story={id:'garden:OakTown',role:'gardener',town:'OakTown',taskId:'departed',title:'A growing garden'}; + const t={stories:[story]};assert.equal(tradeStoryForSite({role:'gardener',town:'OakTown',taskId:'a'},t,'OakTown'),story); + assert.equal(tradeStoryForSite({role:'gardener',town:'PineTown',taskId:'a'},t),null); + assert.doesNotMatch(tradePanelHTML({trades:t,tasks,selectedId:story.id}),/data-cc-inspect/); +}); + +test('tool coverage distinguishes recent, historical, stale and unreported facts within scope',()=>{ + const now=Date.parse('2026-11-06T12:00:00Z'); + const task=(id,tool,extra={})=>({id,status:'working',raw:{town:'OakTown',toolActivity:tool},...extra}); + const call={name:'Bash',state:'succeeded',at:now-1000}; + const local=[task('recent',call),task('old',{...call,at:now-120000}),task('stale',{...call,stale:true}), + task('source',call,{sourceStale:true}),task('missing',null),task('future',{...call,at:now+1}),task('invalid',{...call,state:'unknown'}), + task('other',null,{raw:{town:'PineTown'}})]; + const coverage=toolEvidence(local,{townFilter:'OakTown',now}); + assert.deepEqual({recent:coverage.recent,older:coverage.older,unreported:coverage.unreported,total:coverage.total},{recent:1,older:3,unreported:3,total:7}); + assert.equal(coverage.working.length,7);assert.equal(toolEvidence(local,{townFilter:'OakTown',now,stale:true}).recent,0); +}); + +test('working tool rows expose exact safe facts and inspect IDs, never arguments or outputs',()=>{ + const now=Date.parse('2026-11-06T12:00:00Z'),tool={name:'Bash',state:'succeeded',at:now-420000,arguments:'secret arguments',output:'secret output'}; + const local=[{id:'active',status:'working',raw:{town:'OakTown',name:'agent',toolActivity:tool}}, + {id:'missing',status:'working',raw:{town:'OakTown'}}, + {id:'done',status:'done',raw:{town:'OakTown',toolActivity:tool}}]; + const html=tradePanelHTML({tasks:local,now}); + assert.match(html,/Bash · succeeded · 7m 0s ago · older\/stale/);assert.match(html,/tool unreported/); + assert.match(html,/data-cc-inspect="active"/);assert.match(html,/data-cc-inspect="missing"/);assert.doesNotMatch(html,/data-cc-inspect="done"/); + assert.match(html,/<em>agent<\/em>/);assert.doesNotMatch(html,/|secret arguments|secret output/); +}); + +test('workshops scope their counts and exact evidence to the recorded cottage',()=>{ + const story={id:'workshop:a',role:'workshop',taskId:'a',town:'OakTown',title:'Recent tool use',reason:'Bash succeeded; command contents are not inferred.'}; + const value={workshops:[{taskId:'a',town:'OakTown'},{taskId:'b',town:'PineTown'}],stories:[story]}; + assert.equal(scopedTrades(value,'OakTown').workshops.length,1); + assert.equal(tradeStoryForSite({role:'workshop',taskId:'a',town:'OakTown'},value,'OakTown'),story); + assert.equal(tradeStoryForSite({role:'workshop',taskId:'missing',town:'OakTown'},value),null); + assert.equal(tradeStoryForSite({role:'workshop',taskId:'a',town:'PineTown'},value),null); + const html=tradePanelHTML({trades:value,tasks,townFilter:'OakTown',selectedId:story.id}); + assert.match(html,/0 miners · 1 workshops · 0 road crews/);assert.match(html,/Workshop · Recent tool use/); + assert.match(html,/command contents are not inferred/);assert.match(html,/data-cc-inspect="a"/);assert.doesNotMatch(html,/PineTown|data-cc-inspect="b"/); + assert.doesNotMatch(tradePanelHTML({trades}),/0 workshops/); +}); From 15fbd2b401500676812802e90b25cc00373fd5cf Mon Sep 17 00:00:00 2001 From: "autohub-codex[bot]" <334634853+autohub-codex[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:33:24 +0100 Subject: [PATCH 2/2] fix(trades): require an exact recorded evidence match --- src/trade-panel.mjs | 4 ++-- test/trade-panel.test.mjs | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/trade-panel.mjs b/src/trade-panel.mjs index ad67de6..c640bba 100644 --- a/src/trade-panel.mjs +++ b/src/trade-panel.mjs @@ -15,8 +15,8 @@ export function tradeStoryForSite(site,trades={},townFilter='') { const role=story=>story.role===site.role && inTown(story,site.town); const source=site.evidence; // Prefer exact recorded event identity; never manufacture a story from a prop. - return stories.find(s=>role(s)&&(source?.id?s.id===source.id||s.id===`${site.role}:${source.id}`:false)) || - stories.find(s=>role(s)&&(site.role==='gardener'||(site.taskId?[s.taskId,...(site.role==='road'?[s.fromId,s.toId]:[])].includes(site.taskId):!s.taskId))) || null; + if(source?.id)return stories.find(s=>role(s)&&(s.id===source.id||s.id===`${site.role}:${source.id}`))||null; + return stories.find(s=>role(s)&&(site.role==='gardener'||(site.taskId?[s.taskId,...(site.role==='road'?[s.fromId,s.toId]:[])].includes(site.taskId):!s.taskId)))||null; } export function toolEvidence(tasks,{townFilter='',now=Date.now(),stale=false}={}) { const result={recent:0,older:0,unreported:0,total:0,working:[]}; diff --git a/test/trade-panel.test.mjs b/test/trade-panel.test.mjs index 6746c6c..6eb1ead 100644 --- a/test/trade-panel.test.mjs +++ b/test/trade-panel.test.mjs @@ -43,6 +43,14 @@ test('source-side roads resolve current explicit story and inspect only a suppor const html=tradePanelHTML({trades:t,tasks,townFilter:'OakTown',selectedId:story.id});assert.match(html,/Recorded parent/);assert.match(html,/data-cc-inspect="a"/);assert.doesNotMatch(html,/data-cc-inspect="b"/); assert.doesNotMatch(tradePanelHTML({trades:t,tasks:tasks.slice(1),townFilter:'OakTown',selectedId:story.id}),/data-cc-inspect/); }); +test('explicit expired evidence never falls back to another story for the same task',()=>{ + const current={id:'road:new',role:'road',town:'OakTown',fromTown:'OakTown',toTown:'OakTown',taskId:'a',fromId:'a',toId:'b',title:'New road',reason:'Recorded now'}; + const value={stories:[current]}; + const site={role:'road',town:'OakTown',taskId:'a',evidence:{id:'old'}}; + assert.equal(tradeStoryForSite(site,value,'OakTown'),null); + assert.equal(tradeStoryForSite({...site,evidence:{id:'new'}},value,'OakTown'),current); + assert.equal(tradeStoryForSite({...site,evidence:undefined},value,'OakTown'),current); +}); test('garden story follows exact town when its first arrival has departed',()=>{ const story={id:'garden:OakTown',role:'gardener',town:'OakTown',taskId:'departed',title:'A growing garden'}; const t={stories:[story]};assert.equal(tradeStoryForSite({role:'gardener',town:'OakTown',taskId:'a'},t,'OakTown'),story);