- Decomposition now classifies each task kind (build/research/review); research/review map to matching work_item types, dispatcher dispatches them alongside task/subtask/defect - Office: walled research room (bookshelves, reading table, own floor) bottom-right; generalized room routing (per-room door waypoints); workers on research-type tasks walk there and read (page-flip anim) instead of sitting at a desk; break area moved to mid-floor Verified live: research directive decomposed into 4 research + 1 build task; worker rendered at the reading table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
241 lines
11 KiB
HTML
241 lines
11 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>FarmHouse Office</title>
|
|
<style>
|
|
body { background:#101418; color:#d8dee6; font:14px/1.5 system-ui, sans-serif;
|
|
margin:0; padding:1rem; text-align:center; }
|
|
h1 { font-size:1.1rem; } a { color:#5c9de0; }
|
|
canvas { image-rendering:pixelated; border:1px solid #2a323c; border-radius:8px;
|
|
max-width:100%; }
|
|
#ticker { max-width:960px; margin:.5rem auto; color:#8a94a0; font-size:.8rem;
|
|
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
#legend { color:#8a94a0; font-size:.75rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>🏠 FarmHouse Office <a href="/">← dashboard</a></h1>
|
|
<canvas id="c" width="480" height="320" style="width:960px"></canvas>
|
|
<div id="legend">at desk = building · research room = researching · conference room = blocked, waiting on your answer · water cooler = idle</div>
|
|
<div id="ticker">…</div>
|
|
<script>
|
|
// Top-down pixel office. Every position/animation derives from real engine
|
|
// state (PRD 12: no fake activity): working -> walk to own desk and type,
|
|
// blocked -> walk to conference room and wait with a "?" bubble,
|
|
// idle -> stand at the break area.
|
|
const ctx = document.getElementById('c').getContext('2d');
|
|
const T = 16, COLS = 30, ROWS = 20;
|
|
|
|
// # wall . carpet , conference wood floor ; research floor + door (passable)
|
|
const MAP = [
|
|
"##############################",
|
|
"#.................#,,,,,,,,,,#",
|
|
"#.................#,,,,,,,,,,#",
|
|
"#.................#,,,,,,,,,,#",
|
|
"#.................+,,,,,,,,,,#",
|
|
"#.................#,,,,,,,,,,#",
|
|
"#.................#,,,,,,,,,,#",
|
|
"#.................#,,,,,,,,,,#",
|
|
"#.................############",
|
|
"#............................#",
|
|
"#............................#",
|
|
"#............................#",
|
|
"#...................####+#####",
|
|
"#...................#;;;;;;;;#",
|
|
"#...................#;;;;;;;;#",
|
|
"#...................#;;;;;;;;#",
|
|
"#...................#;;;;;;;;#",
|
|
"#...................#;;;;;;;;#",
|
|
"#...................#;;;;;;;;#",
|
|
"##############################",
|
|
];
|
|
const DESKS = [[3,10],[8,10],[13,10],[3,14],[8,14],[13,14]]; // desk tile; worker sits above it
|
|
const CONF_SEATS = [[21,2],[23,2],[25,2],[21,6],[23,6],[25,6]]; // around the table
|
|
const CONF_TABLE = {x:20, y:3, w:7, h:3};
|
|
const LAB_SEATS = [[22,15],[24,15],[26,15]]; // reading table, north side
|
|
const BREAK_SPOTS = [[15,16],[17,16],[14,17],[16,17],[18,17],[15,15]];
|
|
// rooms with a single door: test = inside?, W = waypoint outside door, D = inside doorway
|
|
const ROOMS = {
|
|
conf: { test:(x,y)=> x>18*T && y<8*T, W:[17*T+8, 4*T+8], D:[19*T+8, 4*T+8] },
|
|
lab: { test:(x,y)=> x>20*T && y>12*T, W:[24*T+8, 11*T+8], D:[24*T+8, 13*T+8] },
|
|
};
|
|
const ROLE_SHIRT = { manager:'#e0b354', engineer:'#5c9de0', qa:'#b48ce0' };
|
|
const HAIR = ['#3a2a1c','#151412','#c9a24b','#8a3b22','#4a4e57'];
|
|
const px = (x,y,w,h,c) => { ctx.fillStyle=c; ctx.fillRect(x|0,y|0,w,h); };
|
|
const hash = s => [...s].reduce((a,ch)=>(a*31+ch.charCodeAt(0))>>>0, 7);
|
|
const center = ([tx,ty]) => [tx*T+T/2, ty*T+T/2];
|
|
|
|
let tick = 0, actors = {}, companyName = '…';
|
|
|
|
// ---------------------------------------------------------------- tiles
|
|
function drawMap() {
|
|
for (let r=0;r<ROWS;r++) for (let c=0;c<COLS;c++) {
|
|
const t = MAP[r][c], x=c*T, y=r*T;
|
|
if (t==='#') { px(x,y,T,T,'#35465c'); px(x,y,T,4,'#465b76'); px(x,y+T-2,T,2,'#22303f'); }
|
|
else if (t===',') { px(x,y,T,T,(r+c)%2?'#6b4a33':'#64452f'); px(x,y,T,1,'#77543b'); }
|
|
else if (t===';') { px(x,y,T,T,(r+c)%2?'#3d4a63':'#38455e'); px(x,y,T,1,'#46536e'); }
|
|
else if (t==='+') { px(x,y,T,T,'#64452f'); px(x,y,2,T,'#2b2620'); px(x+T-2,y,2,T,'#2b2620'); }
|
|
else { px(x,y,T,T,(r+c)%2?'#3f5d56':'#3a5850'); if((r*7+c*3)%11===0) px(x+7,y+7,2,2,'#456660'); }
|
|
}
|
|
// conference table
|
|
const {x,y,w,h} = CONF_TABLE;
|
|
px(x*T+4, y*T+4, w*T-8, h*T-8, '#7a4f34'); px(x*T+4, y*T+4, w*T-8, 3, '#8f5f40');
|
|
px(x*T+6, y*T+7, w*T-12, h*T-14, '#6b4229');
|
|
// desks: wood top + monitor + chair above
|
|
for (const [dc,dr] of DESKS) {
|
|
const dx=dc*T, dy=dr*T;
|
|
px(dx-4, dy, T+8, 12, '#8a5c3b'); px(dx-4, dy, T+8, 3, '#9c6b46'); px(dx-4, dy+10, T+8, 2, '#5e3d26');
|
|
px(dx+3, dy-1, 10, 7, '#20262e'); // monitor (glow drawn per-actor)
|
|
}
|
|
// break area: rug, water cooler, plant
|
|
px(14*T, 15*T, 5*T, 3*T, '#4c5a68'); px(14*T, 15*T, 5*T, 2, '#5a6a7a');
|
|
px(18*T+6, 14*T+2, 8, 14, '#bcd8e8'); px(18*T+7, 14*T+4, 6, 6, '#8fc4e0'); px(18*T+6, 14*T+14, 8, 4, '#6a7684');
|
|
px(19*T+6, 16*T, 8, 6, '#a25636'); px(19*T+4, 16*T-8, 4, 8, '#3e7d46'); px(19*T+10, 16*T-8, 4, 8, '#356e3e'); px(19*T+7, 16*T-12, 4, 8, '#478a4f');
|
|
// research room: bookshelves along the top wall, reading table
|
|
for (let c2=21;c2<=27;c2++) {
|
|
const bx=c2*T, by=13*T;
|
|
px(bx, by-4, T, 14, '#5e3d26'); px(bx, by-4, T, 2, '#77543b');
|
|
for (let i=0;i<4;i++) { px(bx+1+i*4, by-1, 3, 5, ['#a34d3f','#3e7d46','#4a6da8','#c9a24b'][(c2+i)%4]);
|
|
px(bx+1+i*4, by+5, 3, 4, ['#4a6da8','#c9a24b','#a34d3f','#3e7d46'][(c2*3+i)%4]); }
|
|
px(bx, by+10, T, 2, '#3a2a1c');
|
|
}
|
|
px(21*T+8, 15*T+10, 6*T-16, 22, '#7a4f34'); px(21*T+8, 15*T+10, 6*T-16, 3, '#8f5f40');
|
|
}
|
|
|
|
// ---------------------------------------------------------------- sprite
|
|
// 12x18 character, feet anchored at (x,y). dir: 0 down, 1 up, 2 left, 3 right.
|
|
function drawChar(x, y, a) {
|
|
const L=x-6, Tp=y-18, step = a.moving ? (tick>>3)%2 : 0;
|
|
const bob = a.anim==='type' && (tick>>4)%2 ? 1 : 0;
|
|
const skin='#e8c39e', pants='#2e3440', shoe='#1c2026';
|
|
// legs
|
|
if (a.moving) { px(L+2,Tp+13,3,4-step,pants); px(L+7,Tp+13,3,3+step,pants);
|
|
px(L+2,Tp+16-step,3,2,shoe); px(L+7,Tp+16+ (step?0:-1),3,2,shoe); }
|
|
else { px(L+2,Tp+13,3,4,pants); px(L+7,Tp+13,3,4,pants); px(L+2,Tp+16,3,2,shoe); px(L+7,Tp+16,3,2,shoe); }
|
|
// torso + arms
|
|
px(L+1,Tp+7+bob,10,6,a.shirt);
|
|
if (a.dir===2) px(L,Tp+8+bob,2,5,a.shirt); else if (a.dir===3) px(L+10,Tp+8+bob,2,5,a.shirt);
|
|
else { px(L-1,Tp+8+bob,2,5,a.shirt); px(L+11,Tp+8+bob,2,5,a.shirt); }
|
|
// head
|
|
px(L+2,Tp+1+bob,8,7,skin);
|
|
// hair by facing
|
|
if (a.dir===1) px(L+1,Tp+bob,10,6,a.hair);
|
|
else { px(L+1,Tp+bob,10,3,a.hair); px(L+1,Tp+bob,2,5,a.hair); px(L+9,Tp+bob,2,5,a.hair); }
|
|
// eyes
|
|
if (a.dir===0) { px(L+3,Tp+4+bob,2,2,'#20262e'); px(L+7,Tp+4+bob,2,2,'#20262e'); }
|
|
else if (a.dir===2) px(L+3,Tp+4+bob,2,2,'#20262e');
|
|
else if (a.dir===3) px(L+7,Tp+4+bob,2,2,'#20262e');
|
|
}
|
|
|
|
function drawLabel(x, y, text, color) {
|
|
ctx.font='7px monospace';
|
|
const w = ctx.measureText(text).width;
|
|
px(x-w/2-2, y, w+4, 9, 'rgba(16,20,24,.75)');
|
|
ctx.fillStyle = color; ctx.fillText(text, x-w/2, y+7);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- actors
|
|
function roomOf(x, y) { for (const k in ROOMS) if (ROOMS[k].test(x, y)) return k; return null; }
|
|
|
|
function route(a, target) {
|
|
const from = roomOf(a.x, a.y), to = roomOf(target[0], target[1]);
|
|
if (from === to) { a.path = [target]; return; }
|
|
const path = [];
|
|
if (from) path.push(ROOMS[from].D, ROOMS[from].W); // step to the doorway, then outside
|
|
if (to) path.push(ROOMS[to].W, ROOMS[to].D); // outside the door, then through it
|
|
path.push(target);
|
|
a.path = path;
|
|
}
|
|
|
|
function isResearch(t) { return t === 'research' || t === 'experiment'; }
|
|
|
|
function targetFor(a, i) {
|
|
if (a.status === 'working') {
|
|
if (isResearch(a.taskType)) { const [c]=LAB_SEATS[i%LAB_SEATS.length]; return [c*T+T/2, 15*T+4]; }
|
|
const [c,r]=DESKS[i%DESKS.length]; return [c*T+T/2, r*T-2];
|
|
}
|
|
if (a.status === 'blocked') return center(CONF_SEATS[i%CONF_SEATS.length]);
|
|
return center(BREAK_SPOTS[i%BREAK_SPOTS.length]);
|
|
}
|
|
|
|
function step(a) {
|
|
if (!a.path?.length) { a.moving=false; return; }
|
|
const [tx,ty] = a.path[0], SPD=0.8;
|
|
a.moving = true;
|
|
if (Math.abs(tx-a.x) > SPD) { a.dir = tx>a.x?3:2; a.x += tx>a.x?SPD:-SPD; }
|
|
else if (Math.abs(ty-a.y) > SPD) { a.x=tx; a.dir = ty>a.y?0:1; a.y += ty>a.y?SPD:-SPD; }
|
|
else { a.x=tx; a.y=ty; a.path.shift();
|
|
if (!a.path.length) { a.moving=false; a.dir = 0; } }
|
|
}
|
|
|
|
async function poll() {
|
|
try {
|
|
const companies = (await (await fetch('/companies')).json()).filter(c=>c.lifecycle_state==='active');
|
|
const want = new URLSearchParams(location.search).get('company');
|
|
const co = companies.find(c=>c.slug===want) ?? companies[0];
|
|
if (!co) return;
|
|
companyName = co.name;
|
|
const [detail, items, events] = await Promise.all([
|
|
fetch(`/companies/${co.slug}`).then(r=>r.json()),
|
|
fetch(`/companies/${co.slug}/work-items`).then(r=>r.json()),
|
|
fetch(`/companies/${co.slug}/events?limit=1`).then(r=>r.json())]);
|
|
const working = Object.fromEntries(items.filter(w=>w.status==='in_progress'&&w.owner_name)
|
|
.map(w=>[w.owner_name, w]));
|
|
detail.employees.forEach((e,i) => {
|
|
let a = actors[e.id];
|
|
if (!a) { const [bx,by]=center(BREAK_SPOTS[i%BREAK_SPOTS.length]);
|
|
a = actors[e.id] = { x:bx, y:by, dir:0, path:[], status:'', shirt:ROLE_SHIRT[e.role]??'#8a94a0',
|
|
hair:HAIR[hash(e.name)%HAIR.length], name:e.name, i }; }
|
|
const w = working[e.name];
|
|
a.task = w?.title;
|
|
const taskType = w?.type;
|
|
if (e.status !== a.status || taskType !== a.taskType) {
|
|
a.status = e.status; a.taskType = taskType; route(a, targetFor(a, i));
|
|
}
|
|
});
|
|
const ev = events[0];
|
|
if (ev) document.getElementById('ticker').textContent =
|
|
`${new Date(ev.created_at).toLocaleTimeString()} — ${ev.event_type}: ${JSON.stringify(ev.payload??{}).slice(0,120)}`;
|
|
} catch (e) { document.getElementById('ticker').textContent = 'engine unreachable'; }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- frame
|
|
function draw() {
|
|
tick++;
|
|
drawMap();
|
|
const list = Object.values(actors).sort((a,b)=>a.y-b.y); // painter's order
|
|
for (const a of list) {
|
|
step(a);
|
|
a.anim = !a.moving && a.status==='working' ? 'type' : '';
|
|
if (a.anim==='type') {
|
|
if (isResearch(a.taskType)) { // open book, page flip
|
|
const flip = (tick>>4)%2 ? 1 : 0;
|
|
px(a.x-5, a.y+13, 10, 6, '#f5efdc'); px(a.x-1, a.y+13, 2, 6, '#c9c2ae');
|
|
px(a.x-4+flip, a.y+14, 3, 1, '#8a94a0'); px(a.x+2-flip, a.y+16, 3, 1, '#8a94a0');
|
|
} else { // lit monitor while typing
|
|
const [c,r] = DESKS[a.i%DESKS.length];
|
|
px(c*T+4, r*T, 8, 5, (tick>>4)%2 ? '#9fd0ff' : '#7db8ec');
|
|
}
|
|
}
|
|
drawChar(a.x, a.y, a);
|
|
if (!a.moving && a.status==='blocked') { // waiting on shareholder
|
|
const by = a.y - 26 - ((tick>>4)%2);
|
|
px(a.x+4, by, 9, 9, '#f5efdc'); px(a.x+6, by+9, 2, 2, '#f5efdc');
|
|
ctx.fillStyle='#a3402e'; ctx.font='bold 8px monospace'; ctx.fillText('?', a.x+7, by+8);
|
|
}
|
|
drawLabel(a.x, a.y+2, a.name, '#d8dee6');
|
|
}
|
|
ctx.fillStyle='#d8dee6'; ctx.font='8px monospace';
|
|
ctx.fillText(companyName, 8, 12);
|
|
ctx.fillStyle='#8a94a0'; ctx.fillText('CONFERENCE', 20*T, 1.5*T);
|
|
ctx.fillText('RESEARCH', 21*T+4, 18*T+10);
|
|
requestAnimationFrame(draw);
|
|
}
|
|
|
|
poll(); setInterval(poll, 5000); draw();
|
|
</script>
|
|
</body>
|
|
</html>
|