Skip to content
31 changes: 31 additions & 0 deletions src/fullscreen.mjs
Original file line number Diff line number Diff line change
@@ -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";
}
18 changes: 16 additions & 2 deletions src/interaction.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
25 changes: 22 additions & 3 deletions src/observatory.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);}
Comment thread
jack-arturo marked this conversation as resolved.
}
}
canvas.addEventListener('keydown',onKey);roomCanvas.addEventListener('keydown',onKey);
window.addEventListener('keyup',e=>keys.delete(e.key.length===1?e.key.toLowerCase():e.key));
Expand Down
105 changes: 105 additions & 0 deletions src/roommates.mjs
Original file line number Diff line number Diff line change
@@ -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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep plot keys stable when path metadata changes

When a feed first omits worktreePath and supplies it on a later poll, this changes the same agent's key from its ID to wt:<path>; createTownLayout has no slot under the new key and allocates a different position. The reverse happens if the path temporarily disappears, and newly recognized roommate groups likewise move to a third slot instead of reusing a member's existing one. Preserve or alias the prior slot when the grouping key changes so ordinary metadata enrichment cannot move cottages.

AGENTS.md reference: AGENTS.md:L67-L68

Useful? React with 👍 / 👎.

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;
}
16 changes: 15 additions & 1 deletion src/town.html
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -237,6 +249,7 @@ <h1><a class="brand-link" href="https://github.com/verygoodplugins/cottagecode"
<button id="scrapbook" type="button">Scrapbook</button>
<button id="sound-toggle" type="button" aria-pressed="false">Sound off</button>
<button id="music-toggle" type="button" aria-pressed="false" aria-label="Play village music">♫ Music off</button>
<button id="fullscreen-toggle" type="button" aria-pressed="false" aria-label="Enter fullscreen townmap">Fullscreen</button>
<button id="leave-cottage" type="button" hidden>Leave cottage ↗</button>
</div>
<div id="sound-settings" hidden><label><input id="ambient-sound" type="checkbox" checked> Ambience</label> <label><input id="alert-sound" type="checkbox" checked> Alerts</label></div>
Expand All @@ -257,7 +270,7 @@ <h1><a class="brand-link" href="https://github.com/verygoodplugins/cottagecode"
<div id="map-viewport" class="map-viewport">
<canvas id="town" tabindex="0" role="img" aria-label="Townmap: arrow keys or WASD to walk, walk into doors to enter, E near an agent to talk, click cottages to inspect"></canvas><canvas id="room-canvas" tabindex="0" role="img" aria-label="Cottage interior. Arrow keys to walk, walk through the doorway to leave, E near your host to talk or inspect objects, Escape to leave." hidden></canvas>
</div>
<p class="scene-status" id="scene-status">Arrow keys / WASD to walk · Walk into doors · E to talk · Click to inspect</p>
<p class="scene-status" id="scene-status">Arrow keys / WASD to walk · Walk into doors · E to talk · F for fullscreen · Click to inspect</p>
<div class="stage-foot">
<span class="key"><i style="background:#e8eef2;border-radius:50%"></i>smoke = working</span>
<span class="key"><i style="background:#93a1b0"></i>idle</span>
Expand All @@ -271,6 +284,7 @@ <h1><a class="brand-link" href="https://github.com/verygoodplugins/cottagecode"
<button id="settled" aria-pressed="false">settled (0)</button>
<button id="pause" aria-pressed="false">pause feed</button>
</div>
<p id="folklore-hint" class="hint" hidden>Toggle settled for haunted cottages. Time → Night shows pale ghosts. Shared worktrees crowd one stoop.</p>
<div class="stage-foot" style="margin-top:4px">
<span class="key">roof flag:</span>
<span class="key"><i style="background:#b07bd4"></i>opus</span>
Expand Down
Loading
Loading