diff --git a/src/fullscreen.mjs b/src/fullscreen.mjs new file mode 100644 index 0000000..e6a6469 --- /dev/null +++ b/src/fullscreen.mjs @@ -0,0 +1,31 @@ +/** + * Townmap fullscreen helpers. Prefer the Fullscreen API on the stage; + * fall back to a CSS body class when the API is missing or denied. + */ + +export function isMapFullscreen({ + fullscreenElement = null, + cssFallback = false, +} = {}) { + return Boolean(fullscreenElement) || Boolean(cssFallback); +} + +export function fullscreenButtonCopy(active) { + return { + label: active ? "Exit fullscreen" : "Fullscreen", + ariaLabel: active + ? "Exit fullscreen townmap" + : "Enter fullscreen townmap", + }; +} + +/** Pure decision for the next toggle action. */ +export function nextFullscreenAction({ + fullscreenElement = null, + cssFallback = false, + canRequest = true, +} = {}) { + if (fullscreenElement) return "exit-api"; + if (cssFallback) return "exit-css"; + return canRequest ? "enter-api" : "enter-css"; +} diff --git a/src/interaction.mjs b/src/interaction.mjs index 2e19fe5..32e5a9b 100644 --- a/src/interaction.mjs +++ b/src/interaction.mjs @@ -9,13 +9,27 @@ export function cottageDoors(plots, visible = () => true) { /** A resident is interactable only while their own cottage still has a * rendered plot. Actors survive scene refreshes for animation continuity, so - * the plot list is the authoritative visibility boundary. */ + * the plot list is the authoritative visibility boundary. Roommates stay + * talkable beside the stoop even when the host is indoors. */ export function residentTargets(actors, plots) { const rendered = new Set((plots || []) .filter(plot => plot?.agent && plot.hidden !== true && plot.rendered !== false && plot.visible !== false) .map(plot => plot.agent.id)); - return [...(actors || [])].flatMap(([id, actor]) => + const byId = new Map(actors || []); + const hosts = [...byId].flatMap(([id, actor]) => rendered.has(id) && !actor?.indoors ? [{id, x:actor.x + 5, y:actor.y + 14}] : []); + const mates = (plots || []).flatMap(plot => { + if (!rendered.has(plot.agent?.id)) return []; + const actor = byId.get(plot.agent.id); + const baseX = actor ? actor.x : plot.x + 22; + const baseY = actor ? actor.y : plot.y + 52; + return (plot.agent.roommates || []) + .filter((mate) => mate.status !== "offline") + .map((mate, i) => ({ + id: mate.id, x: baseX + 12 + i * 6, y: baseY + 14, + })); + }); + return hosts.concat(mates); } export function crossedDoor(from, to, doors, direction = 'in') { diff --git a/src/observatory.mjs b/src/observatory.mjs index b774a97..c575d15 100644 --- a/src/observatory.mjs +++ b/src/observatory.mjs @@ -152,7 +152,15 @@ export function createObservatory(api){ matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change',e=>{reduce=e.matches;if(reduce)transition=1;}); let storage=null;try{storage=localStorage;}catch{} const byId=id=>latestAgents.find(a=>a.id===id); - const plotFor=id=>{for(const p of api.getPlots()){if(p.agent.id===id)return p;const k=p.kids?.find(k=>k.agent.id===id);if(k)return {...k,agent:k.agent,parentPlot:p};}return null;}; + const plotFor=id=>{ + for(const p of api.getPlots()){ + if(p.agent.id===id)return p; + if((p.agent.roommates||[]).some(mate=>mate.id===id))return p; + const k=p.kids?.find(k=>k.agent.id===id); + if(k)return {...k,agent:k.agent,parentPlot:p}; + } + return null; + }; const roomFor=a=>{const key=a.id+'|'+(a.taskId||'');if(!rooms.has(key))rooms.set(key,createInterior(a));return rooms.get(key);}; const extras=createVillageExtras({clearKeys:()=>keys.clear(),getPostcard(id){ const agent=byId(id);if(!agent)return null; @@ -169,7 +177,12 @@ export function createObservatory(api){ return conversations.get(key); }; 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 visible=a=>!prFilter||prStage(a.pr)===prFilter; + const visible=a=>{ + if(!prFilter) return true; + if(prStage(a.pr)===prFilter) return true; + // Shared cottages stay visible/enterable when any roommate matches the filter. + return (a.roommates||[]).some(mate=>prStage((byId(mate.id)||mate).pr)===prFilter); + }; const hint=text=>{if(text!==lastHint){$('scene-status').textContent=text;lastHint=text;}}; function placePlayer(){ if(player)return; @@ -287,7 +300,13 @@ export function createObservatory(api){ keys.add(key);e.preventDefault(); } if((key==='e'||key==='Enter')&&!e.repeat){e.preventDefault();interact();} - if(key==='Escape'){e.preventDefault();if(mode==='room')leave();else{followId=null;replaying=false;setMode('town');renderPanel(selected);}} + if((key==='f')&&!e.repeat&&!e.metaKey&&!e.ctrlKey&&!e.altKey){e.preventDefault();void api.toggleMapFullscreen?.();} + if(key==='Escape'){ + e.preventDefault(); + if(mode==='room')leave(); + else if(api.isMapFullscreen?.())void api.exitMapFullscreen(); + else{followId=null;replaying=false;setMode('town');renderPanel(selected);} + } } canvas.addEventListener('keydown',onKey);roomCanvas.addEventListener('keydown',onKey); window.addEventListener('keyup',e=>keys.delete(e.key.length===1?e.key.toLowerCase():e.key)); diff --git a/src/roommates.mjs b/src/roommates.mjs new file mode 100644 index 0000000..ebd9e8d --- /dev/null +++ b/src/roommates.mjs @@ -0,0 +1,105 @@ +/** + * Agents that share an absolute worktreePath share one cottage plot. + * Decorative roommate sprites only — each agent remains selectable. + */ + +import { isAbsolutePath } from "./occupancy.mjs"; + +export function roommateKey(agent) { + const path = typeof agent?.worktreePath === "string" ? agent.worktreePath.trim() : ""; + return isAbsolutePath(path) ? path : ""; +} + +/** Stable plot anchor: earliest id. Status and children must not move the cottage. */ +function preferHost(a, b) { + const left = String(a?.id || ""); + const right = String(b?.id || ""); + return left <= right ? a : b; +} + +/** + * Cottage-plot agents only (not shed kids). Shared checkouts collapse to one + * host with a `roommates` array of the other agents on that path. + */ +export function plotAgentsForLayout(agents = []) { + const list = Array.isArray(agents) ? agents.filter(Boolean) : []; + const ids = new Set(list.map((a) => a.id)); + const candidates = list.filter((a) => !a.parent || !ids.has(a.parent)); + const groups = new Map(); + const result = []; + + for (const agent of candidates) { + const key = roommateKey(agent); + if (!key) { + result.push({ ...agent, roommates: [], plotKey: agent.id }); + continue; + } + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(agent); + } + + for (const [key, members] of groups.entries()) { + if (members.length === 1) { + result.push({ ...members[0], roommates: [], plotKey: "wt:" + key }); + continue; + } + const host = members.reduce(preferHost); + const roommates = members + .filter((m) => m.id !== host.id) + .sort((a, b) => String(a.id).localeCompare(String(b.id))); + // Stable slot identity survives host membership churn. + result.push({ ...host, roommates, plotKey: "wt:" + key }); + } + + return result; +} + +/** Lodger ids that must not receive their own plot. */ +export function roommateLodgerIds(agents = []) { + const ids = new Set(); + for (const host of plotAgentsForLayout(agents)) { + for (const mate of host.roommates || []) ids.add(mate.id); + } + return ids; +} + +/** Plot host id for an agent that may be a roommate. */ +export function plotHostId(agents, agentId) { + if (!agentId) return ""; + for (const host of plotAgentsForLayout(agents)) { + if (host.id === agentId) return host.id; + if ((host.roommates || []).some((mate) => mate.id === agentId)) return host.id; + } + return agentId; +} + +/** Household ids on a shared plot (host + roommates). */ +export function plotHouseholdIds(host) { + return new Set([ + host?.id, + ...((host?.roommates || []).map((mate) => mate.id)), + ].filter(Boolean)); +} + +/** True when the host or any roommate is actively working. */ +export function householdWorking(agent) { + if (!agent) return false; + if (agent.status === "working") return true; + return (agent.roommates || []).some((mate) => mate.status === "working"); +} + +/** + * Prefer a household member whose PR matches `matchPr`, else any outstanding PR. + * `matchPr` receives an agent and returns true when it should win. + */ +export function householdDispatchAgent(host, matchPr = null) { + const members = [host, ...(host?.roommates || [])].filter(Boolean); + if (typeof matchPr === "function") { + const match = members.find((mate) => matchPr(mate)); + if (match) return match; + } + const open = members.find( + (mate) => mate.pr && mate.pr.state && mate.pr.state !== "none" && mate.pr.state !== "unknown", + ); + return open || host; +} diff --git a/src/town.html b/src/town.html index 8bcdcd9..e8b4ea1 100644 --- a/src/town.html +++ b/src/town.html @@ -114,6 +114,18 @@ .map-viewport:has(#room-canvas:not([hidden])){overflow:hidden} .map-viewport:has(#room-canvas:not([hidden])) #town{display:none} #room-canvas:not([hidden]){margin-top:0} + .stage:fullscreen,.stage:-webkit-full-screen{box-sizing:border-box;width:100%;height:100%;display:flex;flex-direction:column;padding:12px;border:none;border-radius:0;background:#171d23} + .stage:fullscreen .map-viewport,.stage:-webkit-full-screen .map-viewport{flex:1 1 auto;height:auto;min-height:0} + .stage:fullscreen .stage-foot,.stage:fullscreen .feed,.stage:fullscreen #folklore-hint, + .stage:-webkit-full-screen .stage-foot,.stage:-webkit-full-screen .feed,.stage:-webkit-full-screen #folklore-hint{display:none!important} + body.is-map-fullscreen{overflow:hidden} + body.is-map-fullscreen .app{max-width:none;width:100%;height:100vh;height:100dvh;padding:0;gap:0} + body.is-map-fullscreen header,body.is-map-fullscreen #panel,body.is-map-fullscreen .cottage-directory, + body.is-map-fullscreen .stage-foot,body.is-map-fullscreen .feed,body.is-map-fullscreen #folklore-hint, + body.is-map-fullscreen .pr-tally,body.is-map-fullscreen .village-settings,body.is-map-fullscreen #sound-settings{display:none!important} + body.is-map-fullscreen .split{grid-template-columns:minmax(0,1fr);height:100%;gap:0} + body.is-map-fullscreen .stage{height:100%;display:flex;flex-direction:column;border:none;border-radius:0;padding:10px 12px;background:#171d23} + body.is-map-fullscreen .map-viewport{flex:1 1 auto;height:auto;min-height:0} .scene-status{font-size:11px;color:#c9c9a9;margin:9px 0 1px;min-height:18px} #sound-settings{font-size:12px;color:#bdd2c4;margin-bottom:8px} .village-settings{margin:0 0 10px;color:#c9d4c7;font-size:12px} @@ -237,6 +249,7 @@

Scrapbook + @@ -257,7 +270,7 @@

-

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

+

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

+
roof flag: opus diff --git a/src/town.mjs b/src/town.mjs index 7447105..a48d5b9 100644 --- a/src/town.mjs +++ b/src/town.mjs @@ -15,6 +15,12 @@ import { paintLetterCrows, paintCheckStorm, } from "./folklore.mjs"; +import { plotAgentsForLayout, roommateLodgerIds, plotHostId, plotHouseholdIds, householdWorking, householdDispatchAgent } from "./roommates.mjs"; +import { + isMapFullscreen, + fullscreenButtonCopy, + nextFullscreenAction, +} from "./fullscreen.mjs"; import { PUBLIC_DEMO } from "./runtime.mjs"; import { createBedtimeRoutine, paintCoop, routineForAgent, statusBubbleAnchor, villageLifeLabel, visibleBedtimeKids } from "./bedtime.mjs"; import { residentTargets } from "./interaction.mjs"; @@ -256,7 +262,14 @@ const SIM = (() => { a.branch = mum ? mum.branch : "main"; } }); - const FOLKLORE_PINNED = new Set(["Moss", "Sable", "Vera", "Kip", "Gus"]); + const FOLKLORE_PINNED = new Set([ + "Moss", "Sable", "Reed", "Vera", "Cass", "Wren", "Ruth", "Kip", "Gus", "Marge", "Otto", + ]); + const openParcel = (agent, stamp, { number, title, openedAt, reviewState = "open" }) => ({ + number, repo: "demo/" + agent.town, url: "https://github.com/demo/" + agent.town + "/pull/" + number, + title, state: "open", reviewState, headSha: "demo-" + agent.id, + source: "demo", checkedAt: stamp, openedAt, + }); const stampFolkloreDemo = (stamp = Date.now()) => { const moss = agents.find(a => a.name === "Moss"); if (moss) { @@ -288,17 +301,61 @@ const SIM = (() => { sable.lastLine = pick(LINES.offline); sable.activity = pick(ACTIVITY.offline); } + // Settled but not offline → cobweb only (contrast with ivy/ruins). + const reed = agents.find(a => a.name === "Reed"); + if (reed) { + reed.status = "done"; + reed.endedAt = stamp - 45 * 60 * 1000; + reed.updatedAt = reed.endedAt; + reed.occupancy = "settled"; + reed.branch = "main"; + reed.defaultBranch = "main"; + reed.pr = { state: "none", source: "demo", checkedAt: stamp }; + reed.inputRequest = null; + reed.attention = ""; + reed.task = "Session wrapped; cottage still warm."; + reed.lastLine = pick(LINES.done); + reed.activity = pick(ACTIVITY.done); + } const vera = agents.find(a => a.name === "Vera"); if (vera) { vera.status = "done"; vera.occupancy = "live"; vera.endedAt = stamp - 14 * 60 * 60 * 1000; vera.updatedAt = stamp; - vera.pr = { - number: 501, repo: "demo/HubTown", url: "https://github.com/demo/HubTown/pull/501", - title: "stale parcel", state: "open", reviewState: "open", headSha: "demo-vera", - source: "demo", checkedAt: stamp, openedAt: stamp - 14 * 60 * 60 * 1000, - }; + vera.pr = openParcel(vera, stamp, { + number: 501, title: "nest parcel", openedAt: stamp - 14 * 60 * 60 * 1000, + }); + } + const cass = agents.find(a => a.name === "Cass"); + if (cass) { + cass.status = "done"; + cass.occupancy = "live"; + cass.endedAt = stamp - 6 * 60 * 60 * 1000; + cass.updatedAt = stamp; + cass.pr = openParcel(cass, stamp, { + number: 503, title: "moss parcel", openedAt: stamp - 6 * 60 * 60 * 1000, + }); + } + const wren = agents.find(a => a.name === "Wren"); + if (wren) { + wren.status = "done"; + wren.occupancy = "live"; + wren.endedAt = stamp - 50 * 60 * 60 * 1000; + wren.updatedAt = stamp; + wren.pr = openParcel(wren, stamp, { + number: 504, title: "shrine parcel", openedAt: stamp - 50 * 60 * 60 * 1000, + }); + } + const ruth = agents.find(a => a.name === "Ruth"); + if (ruth) { + ruth.status = "done"; + ruth.occupancy = "live"; + ruth.endedAt = stamp - 20 * 60 * 1000; + ruth.updatedAt = stamp; + ruth.pr = openParcel(ruth, stamp, { + number: 505, title: "fresh parcel", openedAt: stamp - 20 * 60 * 1000, + }); } const kip = agents.find(a => a.name === "Kip"); if (kip) { @@ -326,6 +383,20 @@ const SIM = (() => { checks: [{ name: "ci", status: "COMPLETED", conclusion: "FAILURE" }], }; } + // Shared checkout → one cramped cottage (roommates). + const marge = agents.find(a => a.name === "Marge"); + const otto = agents.find(a => a.name === "Otto"); + if (marge && otto) { + const shared = "/demo/hubtown/shared-checkout"; + marge.worktreePath = shared; + otto.worktreePath = shared; + marge.worktree = "shared-checkout"; + otto.worktree = "shared-checkout"; + marge.occupancy = "live"; + otto.occupancy = "live"; + if (!["working", "idle", "blocked"].includes(marge.status)) marge.status = "working"; + if (!["working", "idle", "blocked"].includes(otto.status)) otto.status = "idle"; + } }; stampFolkloreDemo(now); let live = true; @@ -1026,6 +1097,10 @@ function cottageName(raw){ while(name.length>1&&ctx.measureText(name).width>40)name=name.slice(0,-1); return name; } +function nameplateLabel(ag){ + const mates = Array.isArray(ag.roommates) ? ag.roommates.length : 0; + return mates ? cottageName(ag.name) + "+" + mates : cottageName(ag.name); +} function drawHouse(x, y, ag){ const d = townStyle(ag); @@ -1080,12 +1155,12 @@ function drawHouse(x, y, ag){ px(nb.x+1, nb.y+1, nb.w-2, 1, dead ? "#7d8288" : C.woodLt); ctx.font = '8px "Silkscreen", monospace'; ctx.textBaseline = "top"; - const nm = cottageName(ag.name); + const nm = nameplateLabel(ag); ctx.fillStyle = dead ? "#2d3238" : "#2b1d10"; ctx.fillText(nm, nb.x + Math.round((nb.w - ctx.measureText(nm).width)/2), nb.y+3); // windows + door - const lit = ag.status==="working"; + const lit = householdWorking(ag); const winC = dead ? "#3d454b" : (lit ? C.winOn : C.winOff); [x+12, x+34].forEach(wx=>{ px(wx-1, bodyY+4, 10, 10, O); @@ -1550,8 +1625,21 @@ function drawParcel(x, y, state){ } function layout(){ - const vis=visibleAgents(); - const world=stableLayout.update(vis); + // Group from the full feed so a settled host cannot lose the shared slot + // when roommates remain live and visible. + const rooted=plotAgentsForLayout(agents); + const lodgers=roommateLodgerIds(agents); + const hostById=new Map(rooted.map(h=>[h.id,h])); + const forLayout=agents.filter(a=>!lodgers.has(a.id)).map(a=>{ + const host=hostById.get(a.id); + if(host) return host; + if(a.parent && lodgers.has(a.parent)){ + const hostId=plotHostId(agents, a.parent); + return hostId ? {...a, parent:hostId} : a; + } + return a; + }); + const world=stableLayout.update(forLayout); const styles=districtsFrom(agents); TOWNS=world.blocks.map(b=>({...styles.find(d=>d.key===b.key)||FALLBACK_TOWN,key:b.key,label:b.key.toUpperCase()+(b.page?" ANNEX":"")})); DR=world.blocks;sceneDistricts=DR; @@ -1559,10 +1647,19 @@ function layout(){ COLS=world.columns;D_W=world.districtWidth;VERT_ROAD=world.roadX;HORZ_ROADS=world.roadYs; ROAD_Y=HORZ_ROADS[0];W=world.width;H=world.height+112; if(cv.width!==W||cv.height!==H){cv.width=W;cv.height=H;bg.width=W;bg.height=H;ctx.imageSmoothingEnabled=false;} - plots=world.plots.map(p=>({...p,district:townStyle(p.agent), - kids:vis.filter(a=>a.parent===p.agent.id&&world.shedSlots[a.id]<3&&!world.plots.some(q=>q.agent.id===a.id)).map(a=>({agent:a,x:p.x+1+world.shedSlots[a.id]*18,y:p.y+HOUSE_H+SHED_Y}))})); + const vis=visibleAgents(); + const visIds=new Set(vis.map(a=>a.id)); + plots=world.plots.map(p=>{ + const host=hostById.get(p.agent.id)||p.agent; + const household=plotHouseholdIds(host); + return {...p,agent:host,district:townStyle(host), + kids:vis.filter(a=>household.has(a.parent)&&world.shedSlots[a.id]<3&&!world.plots.some(q=>q.agent.id===a.id)).map(a=>({agent:a,x:p.x+1+world.shedSlots[a.id]*18,y:p.y+HOUSE_H+SHED_Y}))}; + }).filter(p=>{ + const household=plotHouseholdIds(p.agent); + return [...household].some(id=>visIds.has(id)); + }); gardens=[]; - const signature=JSON.stringify(plots.map(p=>[p.agent.id,p.x,p.y,p.kids.map(k=>k.agent.id)]))+W+":"+H; + const signature=JSON.stringify(plots.map(p=>[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;} if(!fauna.length)stockTown(); placeJack(); @@ -1612,7 +1709,8 @@ function draw(){ drawHouse(p.x, p.y, ag); drawBranchPost(p.x, p.y+HOUSE_H+SIGN_Y, ag.branch || ag.worktree, ag.status==="offline"); paintBranchWeeds(px, p.x, p.y, HOUSE_W, HOUSE_H, branchLane(ag)); - if(ag.status === "working"){ + const householdWorkingNow = householdWorking(ag); + if(householdWorkingNow){ const phase = reduce ? 0.4 : (t*0.35 + p.x*0.07) % 1; drawSmoke(p.x+40, p.y+2, phase); } @@ -1664,6 +1762,15 @@ function draw(){ else drawPerson(Math.round(a.x),Math.round(a.y)+bob,townStyle(ag).roof,step); if(atBench && !reduce) drawSparks(p.x+35, p.y+HOUSE_H+3, p.x); } + // Roommates keep their own outdoor presence even when the host is indoors. + const stoopX = a.indoors ? door.x : a.x; + const stoopY = a.indoors ? door.y : a.y; + (ag.roommates||[]).forEach((mate,i)=>{ + if(mate.status === "offline") return; + const ox = 7 + i * 6, oy = i % 2; + if(observatory)renderResident(ctx,Math.round(stoopX)+5+ox,Math.round(stoopY)+oy+14,observatory.resident(mate),{time:t*1000,walking:false,scale:.78,talking:observatory.isTalking(mate.id),reduce}); + else drawPerson(Math.round(stoopX)+ox,Math.round(stoopY)+oy,townStyle(mate).roof,0); + }); } (p.kids||[]).forEach(k=>{ drawShed(k.x, k.y, k.agent); @@ -1690,7 +1797,8 @@ function draw(){ } ctx.globalAlpha = 1; - const on = ag.id===selectedId, over = ag.id===hoverId; + const on = ag.id===selectedId || (ag.roommates||[]).some(m=>m.id===selectedId); + const over = ag.id===hoverId || (ag.roommates||[]).some(m=>m.id===hoverId); if(on || over){ ctx.strokeStyle = on ? "#ffd166" : "#ffffff"; ctx.lineWidth = 1; @@ -1699,7 +1807,11 @@ function draw(){ }); 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=>({...p,opacity:(filter&&filter!==townKey(p.agent))||!observatory.visible(p.agent)?0.3:1}))},t,paletteCtx=>observatory.drawApprenticeArrivals(paletteCtx,t)); + observatory?.drawAtmosphere(ctx,{width:W,height:H,ponds:scenePonds,trees:sceneSolids.filter(r=>r.w===6&&r.h===8),plots:plots.map(p=>{ + const working=householdWorking(p.agent); + const agent=working&&p.agent.status!=="working"?{...p.agent,status:"working"}:p.agent; + return {...p,agent,opacity:(filter&&filter!==townKey(p.agent))||!observatory.visible(p.agent)?0.3:1}; + })},t,paletteCtx=>observatory.drawApprenticeArrivals(paletteCtx,t)); const nightInk=Math.min(1,(observatory?.lightAt(t).darkness||0)/.4); if(nightInk>0){ ctx.textBaseline='top';ctx.fillStyle='#bacbe2'; @@ -1712,7 +1824,7 @@ function draw(){ }); for(const p of plots){ ctx.globalAlpha=nightInk*(((filter&&filter!==townKey(p.agent))||!observatory.visible(p.agent)) ? .3 : 1); - const name=cottageName(p.agent.name); + const name=nameplateLabel(p.agent); ctx.fillText(name,p.x+4+Math.round((46-ctx.measureText(name).width)/2),p.y+26); } ctx.globalAlpha=1; @@ -1720,8 +1832,9 @@ function draw(){ // 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; - observatory?.drawDispatch(ctx,p,t); - const bubble=statusBubbleAnchor({agent:p.agent,plot:p,routine:bedtimeFrames.get(p.agent.id),actor:actors.get(p.agent.id),time:t,reduce}); + observatory?.drawDispatch(ctx,{...p,agent:householdDispatchAgent(p.agent, a=>prStage(a.pr)===observatory.state.prFilter)},t); + const bubbleAgent=(p.agent.status==="blocked")?p.agent:((p.agent.roommates||[]).find(m=>m.status==="blocked")?{...p.agent,status:"blocked"}:p.agent); + const bubble=statusBubbleAnchor({agent:bubbleAgent,plot:p,routine:bedtimeFrames.get(p.agent.id),actor:actors.get(p.agent.id),time:t,reduce}); if(bubble)drawBubble(bubble.x,bubble.y,bubble.status); for(const kid of p.kids||[])if(['blocked','done'].includes(kid.agent.status)){ px(kid.x+6,kid.y-3,5,5,C.outline);px(kid.x+7,kid.y-2,3,3,kid.agent.status==='blocked'?C.alert:C.ok); @@ -1940,6 +2053,8 @@ function renderTally(){ sb.textContent = `settled (${settledN})`; sb.setAttribute("aria-pressed", showSettled ? "true":"false"); } + const folkloreHint = document.getElementById("folklore-hint"); + if(folkloreHint) folkloreHint.hidden = !(builtInDemo || PUBLIC_DEMO); } const noteEl = document.getElementById("note"); function feedNote(msg, color){ noteEl.textContent = msg; noteEl.style.color = color || "#93a1b0"; } @@ -1981,6 +2096,65 @@ document.getElementById("pause").onclick = (e)=>{ e.target.textContent = paused ? "resume feed" : "pause feed"; }; +const stageEl = document.querySelector(".stage"); +const fullscreenBtn = document.getElementById("fullscreen-toggle"); +function mapFullscreenActive(){ + return isMapFullscreen({ + fullscreenElement: document.fullscreenElement === stageEl ? stageEl : null, + cssFallback: document.body.classList.contains("is-map-fullscreen"), + }); +} +function syncFullscreenButton(){ + if(!fullscreenBtn) return; + const active = mapFullscreenActive(); + const copy = fullscreenButtonCopy(active); + fullscreenBtn.setAttribute("aria-pressed", String(active)); + fullscreenBtn.setAttribute("aria-label", copy.ariaLabel); + fullscreenBtn.textContent = copy.label; +} +async function exitMapFullscreen(){ + if(document.fullscreenElement){ + try{ await document.exitFullscreen(); }catch{ /* CSS fallback below */ } + } + document.body.classList.remove("is-map-fullscreen"); + syncFullscreenButton(); + return true; +} +async function toggleMapFullscreen(){ + const action = nextFullscreenAction({ + fullscreenElement: document.fullscreenElement === stageEl ? stageEl : null, + cssFallback: document.body.classList.contains("is-map-fullscreen"), + canRequest: Boolean(stageEl?.requestFullscreen), + }); + if(action === "exit-api" || action === "exit-css"){ + await exitMapFullscreen(); + return mapFullscreenActive(); + } + if(action === "enter-api"){ + try{ + await stageEl.requestFullscreen(); + document.body.classList.remove("is-map-fullscreen"); + syncFullscreenButton(); + return true; + }catch{ /* fall through to CSS */ } + } + document.body.classList.add("is-map-fullscreen"); + syncFullscreenButton(); + return true; +} +fullscreenBtn?.addEventListener("click", ()=>{ void toggleMapFullscreen(); }); +document.addEventListener("fullscreenchange", syncFullscreenButton); +// CSS fallback keeps focus on the button; Escape must still exit without canvas focus. +window.addEventListener("keydown", (e)=>{ + if(e.key !== "Escape") return; + if(e.defaultPrevented) return; // canvas already left the cottage / cleared follow + if(document.fullscreenElement) return; // browser owns native fullscreen Escape + if(!document.body.classList.contains("is-map-fullscreen")) return; + e.preventDefault(); + void exitMapFullscreen(); +}); +syncFullscreenButton(); + const refresh=createLatestRefresh(async ()=>{ const incoming=await fetchAgents(); if(incoming===null)return true; @@ -2002,18 +2176,25 @@ observatory=createObservatory({ })), getWorld:()=>({width:W,height:H,solids:sceneSolids,ponds:scenePonds,districts:sceneDistricts,roadX:VERT_ROAD,roadYs:HORZ_ROADS,jack:jackPlot,showSettled}), select(id){selectedId=id;renderPanel();},drawJack, - answerDemo:(id,text,requestId)=>SIM.answer(id,text,requestId),refresh + answerDemo:(id,text,requestId)=>SIM.answer(id,text,requestId),refresh, + isMapFullscreen:mapFullscreenActive,toggleMapFullscreen,exitMapFullscreen }); window.cottageObservatory=observatory; -window.cottageState=()=>({agents,plots:plots.map(p=>({id:p.agent.id,x:p.x,y:p.y,kids:p.kids})),solids:sceneSolids,ponds:scenePonds,fauna,bedtime:[...bedtimeFrames].map(([id,routine])=>({id,...routine})),live:LIVE,stale:feedStale,width:W,height:H,scene:observatory.state}); +window.cottageState=()=>({agents,plots:plots.map(p=>({id:p.agent.id,x:p.x,y:p.y,roommates:(p.agent.roommates||[]).map(m=>m.id),kids:p.kids})),solids:sceneSolids,ponds:scenePonds,fauna,bedtime:[...bedtimeFrames].map(([id,routine])=>({id,...routine})),live:LIVE,stale:feedStale,width:W,height:H,scene:observatory.state}); (function boot(){ + const previewNight=()=>{ + try{if(sessionStorage.getItem("cottage-demo-night"))return;sessionStorage.setItem("cottage-demo-night","1");}catch{/* still preview once */} + const sel=document.getElementById("village-time"); + if(sel&&sel.tagName==="SELECT"&&sel.value!=="night"){sel.value="night";sel.dispatchEvent(new Event("change"));} + }; const box = document.getElementById("endpoint"); if(PUBLIC_DEMO){ ENDPOINT=null;box.value="";box.disabled=true; document.getElementById("connect").disabled=true; box.closest(".feed").hidden=true; feedNote("Sample village · fictional agents and activity."); + previewNight(); return; } const params = new URLSearchParams(location.search); @@ -2022,6 +2203,7 @@ window.cottageState=()=>({agents,plots:plots.map(p=>({id:p.agent.id,x:p.x,y:p.y, box.value = ""; ENDPOINT = null; feedNote("demo townmap. connect a cottage JSON feed when you have one."); + previewNight(); return; } const ep = params.get("endpoint") || params.get("feed"); @@ -2038,6 +2220,8 @@ window.cottageState=()=>({agents,plots:plots.map(p=>({id:p.agent.id,x:p.x,y:p.y, ENDPOINT = same; builtInDemo = false; feedNote("connecting to local feed..."); + } else { + previewNight(); } })(); refresh(); diff --git a/src/world.mjs b/src/world.mjs index ca87e67..9a0b02e 100644 --- a/src/world.mjs +++ b/src/world.mjs @@ -5,7 +5,8 @@ export function createTownLayout({columns=4, rows=2, houseWidth=54, houseHeight= const height = signHeight + rows * (houseHeight+gapY) + pad*2; const capacity=columns*rows; function allocate(agent) { - if(slots.has(agent.id)) return slots.get(agent.id); + const key = agent.plotKey || agent.id; + if(slots.has(key)) return slots.get(key); const town=agent.town || agent.role || 'WildTown'; const pages=byTown.get(town)||[]; let block=pages.find(b=>b.useda.id)); + const slotKeys=new Set([...slots.keys()]); for(const a of agents){ if(a.parent&&!childSlots.has(a.id)){const n=parentCounts.get(a.parent)||0;childSlots.set(a.id,n);parentCounts.set(a.parent,n+1);} } - const roots=agents.filter(a=>!a.parent||!ids.has(a.parent)||childSlots.get(a.id)>=3||slots.has(a.id)); + const roots=agents.filter(a=>!a.parent||!ids.has(a.parent)||childSlots.get(a.id)>=3||slotKeys.has(a.plotKey||a.id)||slots.has(a.id)); const plots=roots.map(a=>({...allocate(a),agent:a})); const n=Math.max(1,blocks.length); return {plots,shedSlots:Object.fromEntries(childSlots),blocks:blocks.map(b=>({...b})),width:margin*2+2*width+road, diff --git a/test/fullscreen.test.mjs b/test/fullscreen.test.mjs new file mode 100644 index 0000000..f1c8f01 --- /dev/null +++ b/test/fullscreen.test.mjs @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isMapFullscreen, + fullscreenButtonCopy, + nextFullscreenAction, +} from "../src/fullscreen.mjs"; + +test("isMapFullscreen is true for API or CSS fallback", () => { + assert.equal(isMapFullscreen({}), false); + assert.equal(isMapFullscreen({ fullscreenElement: {} }), true); + assert.equal(isMapFullscreen({ cssFallback: true }), true); +}); + +test("fullscreen button copy flips with active state", () => { + assert.deepEqual(fullscreenButtonCopy(false), { + label: "Fullscreen", + ariaLabel: "Enter fullscreen townmap", + }); + assert.deepEqual(fullscreenButtonCopy(true), { + label: "Exit fullscreen", + ariaLabel: "Exit fullscreen townmap", + }); +}); + +test("nextFullscreenAction prefers exiting the active mode", () => { + assert.equal( + nextFullscreenAction({ fullscreenElement: {} }), + "exit-api", + ); + assert.equal(nextFullscreenAction({ cssFallback: true }), "exit-css"); + assert.equal(nextFullscreenAction({ canRequest: true }), "enter-api"); + assert.equal(nextFullscreenAction({ canRequest: false }), "enter-css"); +}); diff --git a/test/interaction.test.mjs b/test/interaction.test.mjs index cdb4e87..e995db2 100644 --- a/test/interaction.test.mjs +++ b/test/interaction.test.mjs @@ -43,3 +43,21 @@ test('only actors with a currently rendered cottage remain conversation targets' ]; assert.deepEqual(residentTargets(actors,plots),[{id:'shown',x:15,y:34}]); }); + +test('roommates on a shared plot stay talkable beside the host', () => { + const actors=new Map([['host',{x:10,y:20,indoors:false}]]); + const plots=[{agent:{id:'host',roommates:[{id:'guest',status:'working'}]}}]; + assert.deepEqual(residentTargets(actors,plots),[ + {id:'host',x:15,y:34}, + {id:'guest',x:22,y:34}, + ]); +}); + +test('roommates stay talkable when the host is indoors', () => { + const actors=new Map([['host',{x:10,y:20,indoors:true}]]); + const plots=[{agent:{id:'host',roommates:[{id:'guest',status:'working'}]},x:40,y:50}]; + assert.deepEqual(residentTargets(actors,plots),[ + {id:'guest',x:22,y:34}, + ]); +}); + diff --git a/test/roommates.test.mjs b/test/roommates.test.mjs new file mode 100644 index 0000000..60ed26d --- /dev/null +++ b/test/roommates.test.mjs @@ -0,0 +1,69 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + roommateKey, + plotAgentsForLayout, + plotHostId, + householdWorking, +} from "../src/roommates.mjs"; + +test("roommateKey only groups absolute worktree paths", () => { + assert.equal(roommateKey({ worktreePath: "/tmp/wt-a" }), "/tmp/wt-a"); + assert.equal(roommateKey({ worktreePath: "relative" }), ""); + assert.equal(roommateKey({ worktreePath: "" }), ""); + assert.equal(roommateKey({}), ""); +}); + +test("plotAgentsForLayout keeps unique paths as their own cottages", () => { + const agents = [ + { id: "a", worktreePath: "/tmp/one", status: "working", occupancy: "live" }, + { id: "b", worktreePath: "/tmp/two", status: "idle", occupancy: "live" }, + ]; + const plots = plotAgentsForLayout(agents); + assert.equal(plots.length, 2); + assert.deepEqual(plots.map((p) => p.id).sort(), ["a", "b"]); + assert.equal(plots[0].roommates.length, 0); +}); + +test("shared worktreePath collapses to a stable host regardless of status or children", () => { + const agents = [ + { id: "aaa", worktreePath: "/tmp/shared", status: "idle", occupancy: "live", startedAt: 100 }, + { id: "zzz", worktreePath: "/tmp/shared", status: "working", occupancy: "live", startedAt: 200 }, + { id: "kid", parent: "zzz", worktreePath: "/tmp/shared", status: "working", occupancy: "live" }, + ]; + const plots = plotAgentsForLayout(agents); + assert.equal(plots.length, 1); + assert.equal(plots[0].id, "aaa"); + assert.equal(plots[0].plotKey, "wt:/tmp/shared"); + assert.deepEqual(plots[0].roommates.map((r) => r.id), ["zzz"]); + assert.ok(householdWorking(plots[0])); + + // Status flip or parenting must not move the cottage to another agent id. + const flipped = plotAgentsForLayout([ + { ...agents[0], status: "working" }, + { ...agents[1], status: "idle" }, + { ...agents[2], parent: "aaa" }, + ]); + assert.equal(flipped[0].id, "aaa"); + assert.equal(flipped[0].plotKey, "wt:/tmp/shared"); + assert.equal(plotHostId(agents, "zzz"), "aaa"); +}); + +test("plotKey stays on the worktree after the earliest host leaves", () => { + const before = plotAgentsForLayout([ + { id: "aaa", worktreePath: "/tmp/shared", status: "idle" }, + { id: "zzz", worktreePath: "/tmp/shared", status: "working" }, + ]); + const after = plotAgentsForLayout([ + { id: "zzz", worktreePath: "/tmp/shared", status: "working" }, + ]); + assert.equal(before[0].plotKey, after[0].plotKey); +}); + +test("missing paths never roommate together", () => { + const agents = [ + { id: "a", worktreePath: "", status: "working" }, + { id: "b", worktreePath: "", status: "working" }, + ]; + assert.equal(plotAgentsForLayout(agents).length, 2); +});