@homie-rocks/studio 0.57.0 → 0.58.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +56 -4
  2. package/docs/toolkit-place-patterns.md +16 -0
  3. package/guides/game/RULES.md +15 -1
  4. package/lib/agent-seat.mjs +10 -3
  5. package/lib/template-history.json +84 -0
  6. package/netplay/NETPLAY.md +10 -0
  7. package/netplay/netplay.ts +30 -9
  8. package/package.json +1 -1
  9. package/parts/README.md +28 -0
  10. package/parts/catalogue.json +236 -0
  11. package/parts/owner-action-checklist/LICENSE +202 -0
  12. package/parts/owner-action-checklist/NOTICE +3 -0
  13. package/parts/owner-action-checklist/README.md +68 -0
  14. package/parts/owner-action-checklist/part.json +95 -0
  15. package/parts/owner-action-checklist/preview/index.html +16 -0
  16. package/parts/owner-action-checklist/src/handler.mjs +44 -0
  17. package/parts/owner-action-checklist/src/index.mjs +96 -0
  18. package/parts/owner-action-checklist/src/style.css +3 -0
  19. package/parts/owner-action-checklist/tuning.json +3 -0
  20. package/parts/terrain-route-map/LICENSE +202 -0
  21. package/parts/terrain-route-map/NOTICE +3 -0
  22. package/parts/terrain-route-map/README.md +67 -0
  23. package/parts/terrain-route-map/THIRD-PARTY-LICENSE +21 -0
  24. package/parts/terrain-route-map/part.json +133 -0
  25. package/parts/terrain-route-map/preview/demo.js +4127 -0
  26. package/parts/terrain-route-map/preview/heightmap.png +0 -0
  27. package/parts/terrain-route-map/preview/index.html +1 -0
  28. package/parts/terrain-route-map/preview/main.mjs +4 -0
  29. package/parts/terrain-route-map/preview/route.json +1 -0
  30. package/parts/terrain-route-map/src/data.mjs +81 -0
  31. package/parts/terrain-route-map/src/index.mjs +131 -0
  32. package/parts/terrain-route-map/src/style.css +3 -0
  33. package/parts/terrain-route-map/tuning.json +5 -0
  34. package/rules/browser.ts +2 -0
  35. package/rules/core.ts +3 -0
  36. package/rules/host.ts +170 -16
  37. package/rules/rules.ts +16 -3
  38. package/rules/view.ts +39 -7
  39. package/starters/coin-dash/game.json +2 -1
  40. package/starters/ember-vale/game.json +2 -1
  41. package/starters/gem-rush/game.json +2 -1
  42. package/starters/gem-rush-3d/game.json +2 -1
  43. package/starters/hero-rush-3d/game.json +2 -1
  44. package/tools/tools.ts +1 -1
  45. package/worker/agents.mjs +20 -4
  46. package/worker/booking-calendar.mjs +15 -2
  47. package/worker/build-id.mjs +1 -1
  48. package/worker/limits.mjs +3 -1
  49. package/worker/room-sessions.mjs +1 -1
  50. package/worker/room.mjs +64 -9
  51. package/worker/table.mjs +51 -18
  52. package/worker/version.mjs +1 -1
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Terrain route map</title><link rel="stylesheet" href="../src/style.css"><style>body{margin:0;background:#091e28;color:#f0eedf;font-family:system-ui;padding:20px}header{display:flex;justify-content:space-between;align-items:end;gap:20px;max-width:1800px;margin:0 auto 20px}small{color:#f4d47b;letter-spacing:.14em}h1{font-size:clamp(2rem,5vw,3.5rem);letter-spacing:-.05em;margin:8px 0}p{color:#a7c0c7;margin:0;max-width:28rem}#map{max-width:1800px;margin:auto;height:70vh;min-height:440px}@media(max-width:600px){header{display:block}#map{height:65vh}}</style><header><div><small>FIELD ATLAS / 01</small><h1>The coastal path.</h1></div><p>From sheltered harbour to high ridge. Drag to explore, pinch to zoom, or use the camera buttons.</p></header><main id="map" aria-label="Coastal path terrain"></main><script type="module" src="./demo.js"></script></html>
@@ -0,0 +1,4 @@
1
+ import {mountTerrainMap} from '../src/index.mjs';
2
+ const data=await (await fetch(new URL('./route.json',import.meta.url))).json();
3
+ data.heightmap=new URL('./heightmap.png',import.meta.url).href;
4
+ try { window.map=await mountTerrainMap(document.querySelector('main'),data); } catch(error) { document.querySelector('main').textContent=`Map could not load: ${error.message}`; throw error; }
@@ -0,0 +1 @@
1
+ {"bounds":[0,0,100,100],"height":42,"seaLevel":6,"heightmap":"heightmap.png","route":{"type":"LineString","coordinates":[[20,72],[30,66],[40,58],[48,51],[58,45],[67,36],[75,26]]},"markers":[{"coordinates":[20,72],"label":"Harbour"},{"coordinates":[48,51],"label":"Lookout"},{"coordinates":[75,26],"label":"High ridge"}]}
@@ -0,0 +1,81 @@
1
+ export function validateMap(data, {baseURL = globalThis.location?.href, allowedOrigins = []} = {}) {
2
+ validateHeightmapURL(data.heightmap, {baseURL, allowedOrigins});
3
+ const {bounds, route, markers = [], height = 28, seaLevel = 6} = data;
4
+ if (!Array.isArray(bounds) || bounds.length !== 4 || !bounds.every(Number.isFinite) || bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) throw new Error('bounds must be [minX,minZ,maxX,maxZ]');
5
+ const point = p => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite) && p[0]>=bounds[0] && p[0]<=bounds[2] && p[1]>=bounds[1] && p[1]<=bounds[3];
6
+ if (route?.type !== 'LineString' || !Array.isArray(route.coordinates) || route.coordinates.length < 2 || route.coordinates.length > 2048 || !route.coordinates.every(point)) throw new Error('route must be a bounded LineString of 2–2048 points');
7
+ if (route.coordinates.some((p,i,a)=> i && p[0]===a[i-1][0] && p[1]===a[i-1][1])) throw new Error('Consecutive route points must differ');
8
+ if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(seaLevel) || seaLevel < 0 || seaLevel >= height) throw new Error('Invalid height or sea level');
9
+ if (!Array.isArray(markers) || markers.length > 32 || markers.some(m=>!point(m.coordinates)||typeof m.label!=='string'||!m.label.trim()||m.label.length>80)) throw new Error('Invalid markers (maximum 32)');
10
+ return {...data, markers, height, seaLevel};
11
+ }
12
+ /** Red channel, row zero = minZ. Bilinear interpolation; pixel endpoints span bounds. */
13
+ export function heightSampler({width, height, data}, bounds, amplitude) {
14
+ if (!Number.isInteger(width)||!Number.isInteger(height)||width<2||height<2||data.length!==width*height*4) throw new Error('Invalid RGBA heightmap');
15
+ return (x,z) => {
16
+ const u=Math.max(0,Math.min(1,(x-bounds[0])/(bounds[2]-bounds[0])))*(width-1);
17
+ const v=Math.max(0,Math.min(1,(z-bounds[1])/(bounds[3]-bounds[1])))*(height-1);
18
+ const a=Math.floor(u), b=Math.floor(v), fx=u-a, fz=v-b;
19
+ const sample=(i,j)=>data[(Math.min(j,height-1)*width+Math.min(i,width-1))*4]/255*amplitude;
20
+ return (sample(a,b)*(1-fx)+sample(a+1,b)*fx)*(1-fz)+(sample(a,b+1)*(1-fx)+sample(a+1,b+1)*fx)*fz;
21
+ };
22
+ }
23
+
24
+ /** Stable screen-space packing. Hidden overflow remains in the accessible marker list. */
25
+ export function placeLabels(points, width, height) {
26
+ const placed=[];
27
+ return points.map(({x,y,w,h})=>{
28
+ const left=Math.max(8,Math.min(width-w-8,x-w/2));
29
+ for(let step=0;step<height;step+=h+8){
30
+ for(const sign of [-1,1]){
31
+ const top=Math.max(8,Math.min(height-h-112,y-72+step*sign));
32
+ if(top<8 || placed.some(b=>left<b.x+b.w+8&&left+w+8>b.x&&top<b.y+b.h+8&&top+h+8>b.y))continue;
33
+ const box={x:left,y:top,w,h};placed.push(box);return box;
34
+ }
35
+ }
36
+ return null;
37
+ });
38
+ }
39
+ /** Ignore startup/hidden gaps; sustained slow frames reduce the GPU budget once. */
40
+ export function qualityMonitor({threshold=28,samples=90}={}) {
41
+ let count=0,slow=0;
42
+ return dt=>{if(dt<=0||dt>250)return false;count++;if(dt>threshold)slow++;return count>=samples&&slow/count>.25;};
43
+ }
44
+
45
+ /** PNG only: verify signature and IHDR dimensions before invoking an image decoder. */
46
+ export function heightmapDimensions(bytes) {
47
+ const signature = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82];
48
+ if (bytes.length < 24 || signature.some((value,i) => bytes[i] !== value)) throw new Error('Expected a PNG heightmap');
49
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
50
+ const width = view.getUint32(16), height = view.getUint32(20);
51
+ if (width < 2 || height < 2 || width > 2048 || height > 2048) throw new Error('Heightmap dimensions must be 2–2048 pixels');
52
+ return {width, height};
53
+ }
54
+ export function validateHeightmapURL(value, {baseURL, allowedOrigins = []} = {}) {
55
+ if (typeof value !== 'string' || !value.trim()) throw new Error('Invalid heightmap URL');
56
+ // Without a browser origin, validation permits relative paths only by default.
57
+ const base = new URL(baseURL || 'https://relative.invalid/');
58
+ const url = new URL(value, base);
59
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || !/\.png$/i.test(url.pathname) ||
60
+ (url.origin !== base.origin && !allowedOrigins.includes(url.origin)) ||
61
+ (!baseURL && /^(?:[a-z][a-z0-9+.-]*:|[\/\\]{2})/i.test(value) && !allowedOrigins.includes(url.origin))) throw new Error('Heightmap must be a same-origin or explicitly allowed PNG URL');
62
+ return url.href;
63
+ }
64
+ export async function loadHeightmap(value, options = {}) {
65
+ const url = validateHeightmapURL(value, options);
66
+ const response = await fetch(url, {redirect:'error', credentials:'same-origin'});
67
+ if (!response.ok || response.headers.get('content-type')?.split(';')[0].trim() !== 'image/png') throw new Error('Expected a PNG response');
68
+ const reader = response.body.getReader(), chunks = []; let size = 0;
69
+ try {
70
+ for (;;) {
71
+ const {done, value:chunk} = await reader.read(); if (done) break;
72
+ size += chunk.byteLength;
73
+ if (size > 16 * 1024 * 1024) throw new Error('Heightmap exceeds download budget');
74
+ chunks.push(chunk);
75
+ }
76
+ } catch (error) {await reader.cancel(); throw error;}
77
+ const bytes = new Uint8Array(size); let offset = 0;
78
+ for (const chunk of chunks) {bytes.set(chunk,offset); offset += chunk.length;}
79
+ const {width,height} = heightmapDimensions(bytes);
80
+ return createImageBitmap(new Blob([bytes], {type:'image/png'}), {resizeWidth:width, resizeHeight:height});
81
+ }
@@ -0,0 +1,131 @@
1
+ import * as THREE from 'three';
2
+ import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
3
+ import {validateMap, loadHeightmap, heightSampler, placeLabels, qualityMonitor} from './data.mjs';
4
+ /** Owns only its canvas, controls and GPU resources. Call dispose when leaving the view. */
5
+ export async function mountTerrainMap(root, input, {segments=192, pixelRatio=1.5, quality='auto', allowedOrigins=[]}={}) {
6
+ if(!['auto','full','lite'].includes(quality))throw new Error('Invalid quality');
7
+ const imageOptions={baseURL:location.href, allowedOrigins};
8
+ const data=validateMap(input,imageOptions);
9
+ if (!Number.isInteger(segments)||segments<8||segments>256||!Number.isFinite(pixelRatio)||pixelRatio<.5||pixelRatio>2) throw new Error('Invalid rendering budget');
10
+ const spanBudget=Math.max(data.bounds[2]-data.bounds[0],data.bounds[3]-data.bounds[1]);
11
+ const estimatedPoints=data.route.coordinates.slice(1).reduce((sum,b,i)=>{const a=data.route.coordinates[i];return sum+Math.max(1,Math.ceil(Math.hypot(b[0]-a[0],b[1]-a[1])/(spanBudget/segments/2)));},1);
12
+ if(estimatedPoints>8192) throw new Error('Route exceeds rendering budget');
13
+ const image = await loadHeightmap(data.heightmap,imageOptions);
14
+ const sampling=document.createElement('canvas'); sampling.width=image.width; sampling.height=image.height;
15
+ const context=sampling.getContext('2d',{willReadFrequently:true}); context.drawImage(image,0,0);
16
+ image.close();
17
+ const rawSample=heightSampler(context.getImageData(0,0,image.width,image.height),data.bounds,data.height);
18
+ const sample=(x,z)=>(rawSample(x,z)*4+rawSample(x-.5,z)+rawSample(x+.5,z)+rawSample(x,z-.5)+rawSample(x,z+.5))/8;
19
+ const [x0,z0,x1,z1]=data.bounds, width=x1-x0, depth=z1-z0, span=Math.max(width,depth);
20
+ const scene=new THREE.Scene(); scene.background=new THREE.Color('#9ebbc5'); scene.fog=new THREE.FogExp2('#9ebbc5',.0009);
21
+ const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
22
+ let lite=quality==='lite', intro=!reduced; const monitor=qualityMonitor();
23
+ const camera=new THREE.PerspectiveCamera(40,1,span/1000,span*12);
24
+ const renderer=new THREE.WebGLRenderer({antialias:true,alpha:false}); renderer.setPixelRatio(Math.min(devicePixelRatio,pixelRatio));
25
+ renderer.outputColorSpace=THREE.SRGBColorSpace; renderer.toneMapping=THREE.ACESFilmicToneMapping; renderer.toneMappingExposure=1.15;
26
+ const canvas=renderer.domElement; canvas.tabIndex=0; canvas.setAttribute('role','img'); canvas.setAttribute('aria-label','Terrain map. Drag to orbit, pinch to zoom. Arrow keys rotate, plus and minus zoom, Home resets.');
27
+ root.classList.add('terrain-map'); root.append(canvas);
28
+ const controls=new OrbitControls(camera,canvas); controls.enableDamping=false; controls.enablePan=false; controls.minDistance=span*.3; controls.maxDistance=span*3; controls.maxPolarAngle=Math.PI*.46;
29
+ controls.target.set((x0+x1)/2,data.seaLevel,(z0+z1)/2);
30
+ function reset(){const fit=Math.max(1,1/Math.max(.35,camera.aspect));camera.position.set((x0+x1)/2+span*.48*fit,span*.65*fit,(z0+z1)/2+span*.78*fit); controls.update();}
31
+ reset();
32
+ scene.add(new THREE.HemisphereLight('#bce2ff','#293d32',1.7));
33
+ const sun=new THREE.DirectionalLight('#ffe2ad',3.1); sun.position.set(-span,span*.9,span); scene.add(sun);
34
+ const sky=new THREE.Mesh(new THREE.SphereGeometry(span*5,16,12),new THREE.ShaderMaterial({side:THREE.BackSide,depthWrite:false,uniforms:{},vertexShader:`varying vec3 direction;void main(){direction=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,fragmentShader:`varying vec3 direction;void main(){float h=smoothstep(-.1,.7,normalize(direction).y);gl_FragColor=vec4(mix(vec3(.62,.73,.77),vec3(.12,.32,.49),h),1.);}`}));sky.position.copy(controls.target);scene.add(sky);
35
+ const geometry=new THREE.PlaneGeometry(width,depth,segments,segments); geometry.rotateX(-Math.PI/2); geometry.translate((x0+x1)/2,0,(z0+z1)/2);
36
+ const positions=geometry.attributes.position; const colors=[];
37
+ for(let i=0;i<positions.count;i++) {
38
+ const y=sample(positions.getX(i),positions.getZ(i)); positions.setY(i,y);
39
+ const x=positions.getX(i),z=positions.getZ(i);
40
+ const slope=Math.hypot(sample(x+1,z)-sample(x-1,z),sample(x,z+1)-sample(x,z-1));
41
+ const color=new THREE.Color('#3e6951').lerp(new THREE.Color('#a2a493'),Math.min(1,Math.max(slope*.35,(y/data.height-.5)*2)));
42
+ color.lerp(new THREE.Color('#d7c99d'),1-THREE.MathUtils.smoothstep(y,data.seaLevel+.2,data.seaLevel+2.5));
43
+ const cavity=Math.max(0,(sample(x+3,z)+sample(x-3,z)+sample(x,z+3)+sample(x,z-3))/4-y);
44
+ color.multiplyScalar((.96+.04*Math.sin(x*.7+Math.sin(z*.3))*Math.cos(z*.8))*(1-Math.min(.3,cavity*.16))); colors.push(color.r,color.g,color.b);
45
+ }
46
+ geometry.setAttribute('color',new THREE.Float32BufferAttribute(colors,3)); geometry.computeVertexNormals();
47
+ function useLite(){lite=true;renderer.setPixelRatio(Math.min(1,pixelRatio));renderer.setSize(root.clientWidth,root.clientHeight,false);
48
+ // Reuse the vertex buffer, drawing every second grid cell. No second mesh allocation.
49
+ if(segments%2===0){const indices=[];for(let z=0;z<segments;z+=2)for(let x=0;x<segments;x+=2){const a=z*(segments+1)+x,b=a+2,c=a+2*(segments+1),d=c+2;indices.push(a,c,b,b,c,d);}geometry.setIndex(indices);}
50
+ }
51
+ if(lite)useLite();
52
+ scene.add(new THREE.Mesh(geometry,new THREE.MeshStandardMaterial({vertexColors:true,roughness:1,flatShading:false})));
53
+ const heightTexture=new THREE.CanvasTexture(sampling); heightTexture.flipY=false;
54
+ const waterMaterial=new THREE.ShaderMaterial({uniforms:{time:{value:0},heights:{value:heightTexture},bounds:{value:new THREE.Vector4(x0,z0,width,depth)},amplitude:{value:data.height},sea:{value:data.seaLevel},lite:{value:lite?1:0}},
55
+ vertexShader:`varying vec3 world; void main(){world=(modelMatrix*vec4(position,1.)).xyz;gl_Position=projectionMatrix*viewMatrix*vec4(world,1.);}`,
56
+ fragmentShader:`precision highp float; varying vec3 world; uniform sampler2D heights; uniform vec4 bounds; uniform float amplitude,sea,time,lite;
57
+ void main(){vec2 uv=(world.xz-bounds.xy)/bounds.zw;float inside=smoothstep(0.,.08,uv.x)*smoothstep(0.,.08,uv.y)*(1.-smoothstep(.92,1.,uv.x))*(1.-smoothstep(.92,1.,uv.y));
58
+ float depth=max(0.,sea-texture2D(heights,clamp(uv,0.,1.)).r*amplitude*inside);
59
+ vec3 col=mix(vec3(.14,.58,.56),vec3(.025,.18,.27),smoothstep(0.,3.5,depth));
60
+ float wave=sin(world.x*.7+world.z*.55+sin(world.z*.13)*2.-time*1.1)+sin(world.z*1.3-world.x*.4+sin(world.x*.17)*3.+time*.8);
61
+ float foam=(1.-smoothstep(.12,1.1,depth))*(.55+.45*sin(depth*15.-time*2.+wave*.5));
62
+ col=mix(col,vec3(.84,.93,.83),foam*.8);col+=pow(max(0.,wave*.5),12.)*.035*(1.-lite*.7);
63
+ float haze=1.-exp(-distance(cameraPosition,world)*.0009);col=mix(col,vec3(.35,.52,.60),haze);
64
+ gl_FragColor=vec4(col,1.); #include <tonemapping_fragment>
65
+ #include <colorspace_fragment>
66
+ }`.replace(' #include','\n#include')});
67
+ const water=new THREE.Mesh(new THREE.PlaneGeometry(width*12,depth*12),waterMaterial); water.rotation.x=-Math.PI/2; water.position.set((x0+x1)/2,data.seaLevel,(z0+z1)/2); scene.add(water);
68
+ // Densify straight segments before draping: avoids spline overshoot and tubes cutting hills.
69
+ const points=[];
70
+ for(let i=1;i<data.route.coordinates.length;i++) {
71
+ const a=data.route.coordinates[i-1],b=data.route.coordinates[i];
72
+ const steps=Math.max(1,Math.ceil(Math.hypot(b[0]-a[0],b[1]-a[1])/(span/segments/2)));
73
+ for(let j=0;j<steps;j++){const t=j/steps,x=a[0]+(b[0]-a[0])*t,z=a[1]+(b[1]-a[1])*t;points.push(new THREE.Vector3(x,Math.max(sample(x,z),data.seaLevel)+span*.008,z));}
74
+ }
75
+ const last=data.route.coordinates.at(-1); points.push(new THREE.Vector3(last[0],Math.max(sample(...last),data.seaLevel)+span*.008,last[1]));
76
+ const path=new THREE.CurvePath(); for(let i=1;i<points.length;i++)path.add(new THREE.LineCurve3(points[i-1],points[i]));
77
+ scene.add(new THREE.Mesh(new THREE.TubeGeometry(path,Math.min(4096,points.length*2),span*.003,5,false),new THREE.MeshBasicMaterial({color:'#ffcd61'})));
78
+ scene.add(new THREE.Mesh(new THREE.TubeGeometry(path,Math.min(4096,points.length*2),span*.006,5,false),new THREE.MeshBasicMaterial({color:'#ffcc65',transparent:true,opacity:.12,depthWrite:false})));
79
+ const flowGeometry=new THREE.SphereGeometry(span*.004,6,4);
80
+ const flow=new THREE.InstancedMesh(flowGeometry,new THREE.MeshBasicMaterial({color:'#ffffff'}),12);scene.add(flow);
81
+ const flowMatrix=new THREE.Matrix4();
82
+ const labels=document.createElement('div');labels.className='terrain-labels';root.append(labels);
83
+ const labelPoints=data.markers.map((marker,index)=>{
84
+ const [x,z]=marker.coordinates,y=Math.max(sample(x,z),data.seaLevel);
85
+ const mesh=new THREE.Mesh(new THREE.CylinderGeometry(span*.003,span*.003,span*.035,8),new THREE.MeshBasicMaterial({color:'#fff2c4'}));mesh.position.set(x,y+span*.0175,z);scene.add(mesh);
86
+ const line=document.createElement('i');line.setAttribute('aria-hidden','true');labels.append(line);
87
+ const label=document.createElement('span');label.textContent=`${String(index+1).padStart(2,'0')} ${marker.label}`;labels.append(label);
88
+ return {label,line,position:new THREE.Vector3(x,y+span*.04,z)};
89
+ });
90
+ const accessible=document.createElement('ul');accessible.className='terrain-accessible';
91
+ for(const m of data.markers){const li=document.createElement('li');li.textContent=m.label;accessible.append(li);}root.append(accessible);
92
+ labels.setAttribute('aria-hidden','true');
93
+ let scheduled=0,disposed=false,visible=true,previous=0,start=0;
94
+ const projected=new THREE.Vector3();
95
+ function render(now){scheduled=0;if(disposed||!visible||document.hidden){previous=0;return;}
96
+ if(!start)start=now; const elapsed=(now-start)/1000;
97
+ if(quality==='auto'&&!lite&&previous&&elapsed>2&&monitor(now-previous)){useLite();waterMaterial.uniforms.lite.value=1;}
98
+ previous=now;waterMaterial.uniforms.time.value=reduced?0:elapsed;
99
+ if(intro){const t=Math.min(1,elapsed/2.2);reset();const offset=camera.position.clone().sub(controls.target);offset.applyAxisAngle(new THREE.Vector3(0,1,0),.15*(1-t)*(1-t));camera.position.copy(controls.target).add(offset);controls.update();if(t===1)intro=false;}
100
+ for(let i=0;i<12;i++){const p=path.getPoint(((reduced?0:elapsed*.035)+i/12)%1);flowMatrix.makeTranslation(p.x,p.y,p.z);flow.setMatrixAt(i,flowMatrix);}flow.instanceMatrix.needsUpdate=true;
101
+ renderer.render(scene,camera);
102
+ const projectedLabels=labelPoints.map(({label,position})=>{projected.copy(position).project(camera);return {x:(projected.x*.5+.5)*canvas.clientWidth,y:(-projected.y*.5+.5)*canvas.clientHeight,w:label.offsetWidth,h:label.offsetHeight,visible:projected.z<=1&&projected.z>=-1};});
103
+ const boxes=placeLabels(projectedLabels,canvas.clientWidth,canvas.clientHeight);
104
+ labelPoints.forEach(({label,line},i)=>{const box=boxes[i],p=projectedLabels[i];label.style.visibility=line.style.visibility=box&&p.visible?'visible':'hidden';if(!box)return;
105
+ label.style.transform=`translate(${box.x}px,${box.y}px)`;
106
+ const dx=box.x+box.w/2-p.x,dy=box.y+box.h-p.y;line.style.width=`${Math.hypot(dx,dy)}px`;line.style.transform=`translate(${p.x}px,${p.y}px) rotate(${Math.atan2(dy,dx)}rad)`;
107
+ });
108
+ if(!reduced)invalidate();
109
+ }
110
+ function invalidate(){if(!scheduled&&!disposed)scheduled=requestAnimationFrame(render);}
111
+ controls.addEventListener('start',()=>{intro=false;});
112
+ controls.addEventListener('change',invalidate);
113
+ let initialSize=true;
114
+ const resize=new ResizeObserver(()=>{const w=root.clientWidth,h=root.clientHeight;if(!w||!h)return;renderer.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix();if(initialSize){initialSize=false;reset();}invalidate();});resize.observe(root);
115
+ const intersection=new IntersectionObserver(entries=>{visible=entries[0].isIntersecting;if(visible)invalidate();});intersection.observe(root);
116
+ document.addEventListener('visibilitychange',invalidate);
117
+ function command(action){
118
+ intro=false;
119
+ const offset=camera.position.clone().sub(controls.target),s=new THREE.Spherical().setFromVector3(offset);
120
+ if(action==='left')s.theta-=.15; if(action==='right')s.theta+=.15;
121
+ if(action==='up')s.phi=Math.max(.15,s.phi-.1);if(action==='down')s.phi=Math.min(controls.maxPolarAngle,s.phi+.1);
122
+ if(action==='in')s.radius=Math.max(controls.minDistance,s.radius*.85);if(action==='out')s.radius=Math.min(controls.maxDistance,s.radius/ .85);
123
+ if(action==='reset')reset();else {camera.position.copy(controls.target).add(new THREE.Vector3().setFromSpherical(s));controls.update();}invalidate();
124
+ }
125
+ const keyActions={ArrowLeft:'left',ArrowRight:'right',ArrowUp:'up',ArrowDown:'down','+':'in','=':'in','-':'out',Home:'reset'};
126
+ function keydown(e){if(keyActions[e.key]){e.preventDefault();command(keyActions[e.key]);}}canvas.addEventListener('keydown',keydown);
127
+ const toolbar=document.createElement('div');toolbar.className='terrain-controls';toolbar.setAttribute('aria-label','Map camera');
128
+ for(const [action,label] of [['left','Rotate left'],['right','Rotate right'],['in','Zoom in'],['out','Zoom out'],['reset','Reset view']]){const button=document.createElement('button');button.type='button';button.textContent=label;button.onclick=()=>command(action);toolbar.append(button);}root.append(toolbar);
129
+ invalidate();
130
+ return {camera,renderer,command,get quality(){return lite?'lite':'full';},render:invalidate,dispose(){disposed=true;cancelAnimationFrame(scheduled);resize.disconnect();intersection.disconnect();document.removeEventListener('visibilitychange',invalidate);canvas.removeEventListener('keydown',keydown);controls.dispose();scene.traverse(o=>{o.geometry?.dispose();if(o.material)o.material.dispose();});heightTexture.dispose();renderer.dispose();canvas.remove();labels.remove();accessible.remove();toolbar.remove();}};
131
+ }
@@ -0,0 +1,3 @@
1
+ .terrain-map{position:relative;min-height:360px;height:100%;overflow:hidden;background:#102d39;border-radius:24px}.terrain-map canvas{width:100%;height:100%;display:block;touch-action:none}.terrain-map canvas:focus-visible{outline:3px solid #ffe9a6;outline-offset:-4px}.terrain-labels{position:absolute;inset:0;pointer-events:none;overflow:hidden}.terrain-labels span{position:absolute;top:0;left:0;background:#102329e8;border:1px solid #91b1ac;border-radius:8px;padding:5px 9px;color:#f5f2de;font:600 13px system-ui;white-space:nowrap}.terrain-labels [hidden]{display:none}.terrain-controls{position:absolute;bottom:12px;left:12px;right:12px;display:flex;justify-content:center;flex-wrap:wrap;gap:6px}.terrain-controls button{min-height:44px;padding:10px 12px;border:1px solid #91b1ac;background:#102329ed;color:#fff4cf;border-radius:10px;font:600 13px system-ui;cursor:pointer}.terrain-controls button:focus-visible{outline:3px solid #ffe9a6;outline-offset:2px}
2
+ .terrain-labels span{border:1px solid #e6d7a070;border-radius:5px;padding:9px 12px;background:#142f39ed;box-shadow:0 4px 18px #03192345;letter-spacing:.03em;font-size:12px}.terrain-labels i{position:absolute;left:0;top:0;height:1px;background:#ffe7a9b0;transform-origin:0 0}.terrain-accessible{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}@media(min-width:1400px){.terrain-labels span{font-size:18px;padding:12px 18px}.terrain-controls button{font-size:16px}}
3
+ .terrain-labels span{max-width:min(240px,calc(100% - 40px));white-space:normal;overflow-wrap:anywhere;box-sizing:border-box}
@@ -0,0 +1,5 @@
1
+ {
2
+ "segments": 192,
3
+ "pixelRatio": 1.5,
4
+ "quality": "auto"
5
+ }
package/rules/browser.ts CHANGED
@@ -71,6 +71,8 @@ export function browserHost(def: RulesDef, game: BrowserGame): RulesHostFactory
71
71
  stage: lab.stage ?? undefined, startPaused: false, restoreEpoch: (Math.floor(Math.random() * 4294967296) >>> 0) || 1,
72
72
  clock: { now: o.now, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>) },
73
73
  onEnd: o.onEnd,
74
+ // Online, the page rests its room as the server does; offline nobody is charged for it, and it never rests.
75
+ ...(o.onRest ? { onRest: o.onRest } : {}),
74
76
  });
75
77
  if (game.vocab) host.frame({ t: 'vocabulary', vocab: game.vocab });
76
78
  const present = new Set<number>();
package/rules/core.ts CHANGED
@@ -175,6 +175,8 @@ export interface Core {
175
175
  round(): Extract<CoreOut, { t: 'round' }>;
176
176
  /** End a settled wall-clock break without simulating its idle ticks. */
177
177
  resumeRound(): boolean;
178
+ /** What the world has waiting on its own clock: events and timers on the queue, bodies yet to spawn, asks unanswered, and a running round's deadline. */
179
+ waiting(): number;
178
180
  bodies(): Body[];
179
181
  /** Every person's body with where it stands: who an effect with an audience reaches (0.46.3). */
180
182
  listeners(): { seat: number; id: string; pos: Vec3 }[];
@@ -1539,6 +1541,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1539
1541
  }
1540
1542
  return {
1541
1543
  round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
1544
+ waiting: () => queue.length + spawns.length + pendingAsks.length + (round.phase === 'live' ? (round.endsAt > 0 ? 1 : 0) : (round.endsAt > tick ? 1 : 0)) + (restartAt > tick ? 1 : 0),
1542
1545
  resumeRound() {
1543
1546
  if (round.phase !== 'over' || overAt) return false;
1544
1547
  // Preserve queued events and their simulation-time deadlines. Nothing
package/rules/host.ts CHANGED
@@ -31,6 +31,19 @@ import { stageTimings } from './timing.mjs';
31
31
  * timer would keep the `Table` in memory and billed), the clock stops, and a person who returns finds the tick it
32
32
  * paused on. Watchers and AI seats never keep the world ticking.
33
33
  *
34
+ * A ROOM NOBODY IS PLAYING RESTS. When every person here has stopped playing (nothing held, pressed or sent for the
35
+ * game's `room.restSeconds`, 60 by default, or every page in the background for ten seconds) and, unless the game
36
+ * asked for an away-from-keyboard pause, no AI has sent a command for as long, the clock stops as it does for an
37
+ * empty room, with everybody still connected: no timer is pending, so the `Table` hibernates. It is not charged for
38
+ * duration while it sleeps; a liveness alarm still wakes it about once a minute (one request, and the CPU of that
39
+ * short wake). A deliberate rest is restored under the same epoch and does not rewrite its save. With `room.rest:
40
+ * 'quiet'` (the default) the world must also have nothing of its own to do (no round clock, nothing waiting on the
41
+ * queue, nothing moving). `'idle'` rests regardless and pauses round clocks, timers, bots and AI with it.
42
+ * `'never'` never rests. The first input that holds or presses something, a command, an AI's command, a page coming
43
+ * to the front or a new arrival starts it again at the tick it rested on.
44
+ * Only browsers that said they understand resting (`rest` in their hello) let a room rest, and only when the caller
45
+ * asks for it (`onRest`: the server's Table, or an online browser host). A lasting world never rests.
46
+ *
34
47
  * THE CHAIN CANNOT BREAK. One rule holds whenever this module hands control back to its caller: A ROOM THAT IS RUNNING
35
48
  * HAS EXACTLY ONE TIMER PENDING, AND A ROOM THAT IS PAUSED OR ENDED HAS NONE. A running room with no timer would stand
36
49
  * still for good with its players connected and its object billed. So every way in (`wake`, `frame`, `start`,
@@ -89,6 +102,8 @@ export interface HostOptions {
89
102
  onTick?(): void;
90
103
  /** Suspend settled round breaks; the owner persists and wakes this deadline. */
91
104
  onRoundIdle?(until: number): void;
105
+ /** Rest a room nobody is playing (`true`), and start it again (`false`). Without it a room never rests. */
106
+ onRest?(resting: boolean): void;
92
107
  /** Only the shared server isolate can attribute timer lateness to another tick. */
93
108
  enforceOverrun?: boolean;
94
109
  log?(line: Record<string, unknown>): void;
@@ -115,6 +130,8 @@ export interface Host {
115
130
  readonly paused: boolean;
116
131
  readonly roundIdleUntil: number | null;
117
132
  wakeRound(): void;
133
+ /** Nobody is playing: the clock is stopped with people still here (see A ROOM NOBODY IS PLAYING RESTS). */
134
+ readonly resting: boolean;
118
135
  readonly people: number;
119
136
  readonly viewRadiusM: number | null;
120
137
  readonly privateDelivery: boolean;
@@ -157,6 +174,10 @@ interface SeatIn {
157
174
 
158
175
  /** Every tick failing (cut short by the budget, or faulted) for this long ends the room. */
159
176
  export const FAIL_MS = 2000;
177
+ /** How long a room plays on with nobody playing before it rests, unless the game says otherwise (`room.restSeconds`). */
178
+ export const REST_SECONDS = 60;
179
+ /** How long a room plays on with every page in the background before it rests (never longer than `restSeconds`). */
180
+ export const REST_HIDDEN_MS = 10_000;
160
181
  /** Every tick blamed for running longer than a period for this long ends the room. */
161
182
  export const OVERRUN_MS = 5000;
162
183
 
@@ -203,12 +224,13 @@ export function createHost(o: HostOptions): Host {
203
224
  // A log that throws takes nothing down with it.
204
225
  const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
205
226
  const random = o.random ?? Math.random;
206
- const saved = o.restore ? fromBytes(o.restore) as { v: number; roundIdleUntil?: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
227
+ const saved = o.restore ? fromBytes(o.restore) as { v: number; roundIdleUntil?: number; rest?: { on: boolean; at: [number, number][] }; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
207
228
  if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
208
229
  if (saved) {
209
230
  const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
210
231
  const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
211
- check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters', 'roundIdleUntil'].includes(k)));
232
+ check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters', 'roundIdleUntil', 'rest'].includes(k)));
233
+ if (saved.rest !== undefined) check(saved.rest && typeof saved.rest.on === 'boolean' && Array.isArray(saved.rest.at) && saved.rest.at.length <= c.seats && saved.rest.at.every(row => Array.isArray(row) && row.length === 2 && uint(row[0]) && row[0] < c.seats && Number.isFinite(row[1])));
212
234
  check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
213
235
  if (saved.roundIdleUntil !== undefined) check(Number.isFinite(saved.roundIdleUntil) && saved.roundIdleUntil >= 0 && saved.core.round[1] === 0 && !saved.core.overAt);
214
236
  if (saved.characters !== undefined) {
@@ -402,8 +424,23 @@ export function createHost(o: HostOptions): Host {
402
424
  }
403
425
  }
404
426
  let roundIdleUntil: number | null = o.onRoundIdle && Number.isFinite(saved?.roundIdleUntil) ? saved!.roundIdleUntil! : null;
427
+ /*
428
+ * Resting (A ROOM NOBODY IS PLAYING RESTS): how long a room plays on with nobody playing (0: never rests), whether it
429
+ * rests now, when each person last played, since when each page has been in the background, which browsers can rest,
430
+ * and when an AI with its own hands last played. A restored room that was resting rests on.
431
+ */
432
+ const restMode = c.settings.rest ?? 'quiet';
433
+ const restMs = o.onRest && !persistent && restMode !== 'never' ? Math.max(0, c.settings.restSeconds ?? REST_SECONDS) * 1000 : 0;
434
+ /** The world as the last rest check saw it (a quiet room must also be a still one). */
435
+ let stillAs = '';
436
+ let resting = restMs > 0 && saved?.rest?.on === true && roundIdleUntil === null;
437
+ const playedAt = new Map<number, number>(restMs ? saved?.rest?.at ?? [] : []);
438
+ const hiddenSince = new Map<number, number>();
439
+ const restful = new Set<number>();
440
+ let handsAt = -Infinity;
441
+ let restCheckAt = 0;
405
442
  let running = false;
406
- let paused = Boolean(saved && (o.startPaused ?? o.restoreEpoch !== undefined));
443
+ let paused = !resting && Boolean(saved && (o.startPaused ?? o.restoreEpoch !== undefined));
407
444
  let ended = false;
408
445
  /** The pending timer, and whether there is one. `armed` is the truth: a clock may hand back any value as its handle. */
409
446
  let timer: unknown = null;
@@ -471,12 +508,96 @@ export function createHost(o: HostOptions): Host {
471
508
  }
472
509
  const people = (): number => { let n = 0; for (const person of present.values()) if (person) n += 1; return n; };
473
510
 
511
+ /* ---------------------------------------------------------------- resting */
512
+
513
+ /** Each kind's inputs at rest, one text a field: nothing held, nothing pressed. */
514
+ const restingInput = new Map<KindTable, string[]>();
515
+ /** Whether an input frame holds or presses anything (its newest entry's values, and a press in any entry). */
516
+ function engaged(kind: KindTable, rows: unknown[]): boolean {
517
+ let idle = restingInput.get(kind);
518
+ if (!idle) { const v = neutral(kind); idle = kind.input.map(([name]) => JSON.stringify(v[name])); restingInput.set(kind, idle); }
519
+ const last = rows.length - 1;
520
+ for (let n = 0; n < rows.length && n < 64; n += 1) {
521
+ const raw = rows[n];
522
+ if (!Array.isArray(raw)) return false;
523
+ for (let i = 0; i < kind.input.length; i += 1) {
524
+ const fd = kind.input[i][1];
525
+ if (fd.t === 'press') { if (raw[i + 1] === 1) return true; }
526
+ else if (n === last && fd.t !== 'pulse' && JSON.stringify(coerce(fd, raw[i + 1], c.dims)) !== idle[i]) return true;
527
+ }
528
+ }
529
+ return false;
530
+ }
531
+ /** Somebody played: a person (their seat) or an AI with its own hands. A resting room wakes. */
532
+ function played(seat: number, driver: Driver, now: number): void {
533
+ if (driver === 'person') playedAt.set(seat, now); else handsAt = now;
534
+ if (resting) wakeUp('played');
535
+ }
536
+ /**
537
+ * Nobody is playing: every person here has a resting browser and has not played for `restMs` (or hid their page),
538
+ * and, unless this is an away-from-keyboard pause, no AI has played for as long. A `quiet` room (the default)
539
+ * must also have nothing of its own to do: no running round clock, no event, timer, spawn or ask waiting, and a
540
+ * world that stood still since the last check (bots, physics and anything a `tick` handler moves keep it running).
541
+ * An `idle` room rests regardless, pausing all of that, guides included, until somebody plays.
542
+ */
543
+ function quiet(now: number): boolean {
544
+ // A command already sent keeps a quiet room awake. An idle room pauses AI with the people, so that command does
545
+ // not keep the pause from starting. A command that arrives while the room rests still wakes it (`played`).
546
+ if (restMode !== 'idle' && now - handsAt < restMs) return false;
547
+ let anyone = false;
548
+ for (const [seat, person] of present) {
549
+ if (!person) continue;
550
+ anyone = true;
551
+ if (!restful.has(seat)) return false;
552
+ const hid = hiddenSince.get(seat);
553
+ if (hid !== undefined && now - hid >= Math.min(restMs, REST_HIDDEN_MS)) continue;
554
+ if (now - (playedAt.get(seat) ?? now) < restMs) return false;
555
+ }
556
+ if (!anyone || restMode === 'idle') { stillAs = ''; return anyone; }
557
+ if (core.waiting() > 0) { stillAs = ''; return false; }
558
+ const was = stillAs;
559
+ stillAs = JSON.stringify([core.snapshot(), core.shared()]);
560
+ return was === stillAs;
561
+ }
562
+ /** The clock stops with everybody still here. The room is saved first, so the object may be put away at once. */
563
+ function rest(): void {
564
+ resting = true; running = false;
565
+ disarm();
566
+ log({ ev: 'host-rest', game: o.game, tick: core.tick, people: people() });
567
+ try { o.send({ t: 'rest', on: true }); checkpoint(true); o.onRest?.(true); } catch (error) { fault('rest', error); }
568
+ }
569
+ /** Somebody plays again: the room carries on from the tick it rested on, on a clock started now. */
570
+ function wakeUp(why: string): void {
571
+ if (!resting || ended) return;
572
+ resting = false; running = true; paused = false;
573
+ try {
574
+ const now = o.clock.now();
575
+ base = { ms: now, tick: core.tick };
576
+ overSince = null; blamed = false; failingSince = null; lastStart = now;
577
+ for (const q of queues.values()) q.lastFrameTick = core.tick;
578
+ o.send({ t: 'rest', on: false });
579
+ roundRebaseAt = -Infinity; publishRebase(now);
580
+ // The save on disk says it rests, and a rested save comes back under its own epoch. Once the room moves on it
581
+ // must not: a restart now restores this save, under a new epoch, as any crash does.
582
+ checkpoint(true);
583
+ log({ ev: 'host-wake', game: o.game, tick: core.tick, why });
584
+ o.onRest?.(false);
585
+ } catch (error) { fault('wake', error); } finally { keepTime(); }
586
+ }
587
+
474
588
  /* ---------------------------------------------------------------- frames in */
475
589
 
476
590
  function onIn(m: Record<string, unknown>): void {
477
591
  const seat = m.from;
478
- if (!Number.isInteger(seat) || m.e !== epoch || !Array.isArray(m.s)) { stats.dropped += 1; return; }
592
+ if (!Number.isInteger(seat) || !Array.isArray(m.s)) { stats.dropped += 1; return; }
479
593
  const body = core.bodyOf(seat as number);
594
+ // Somebody playing is seen before the epoch is: the first touch after a rest (which a restore may have given a new
595
+ // epoch) wakes the room even when the frame itself is from before it. Read at most once a second a seat.
596
+ if (restMs && body && hasHands(seat as number, body.driver)) {
597
+ const now = o.clock.now();
598
+ if ((resting || now - (playedAt.get(seat as number) ?? -Infinity) >= 1000) && engaged(body.kind, m.s as unknown[])) played(seat as number, body.driver, now);
599
+ }
600
+ if (m.e !== epoch) { stats.dropped += 1; return; }
480
601
  if (!body || !hasHands(seat as number, body.driver)) return;
481
602
  const kind = body.kind;
482
603
  const q = queueOf(seat as number, kind);
@@ -504,7 +625,9 @@ export function createHost(o: HostOptions): Host {
504
625
  if (seq && r !== body.r && !action) continue;
505
626
  if (seq ? q.received?.includes(seq) : j <= q.newest) continue;
506
627
  // Too early: more than a second ahead. This entry and the rest of the frame are dropped.
507
- if (j > K + tickHz) { stats.dropped += 1; break; }
628
+ // An entry more than a second ahead is still reported, as the furthest lead there is: a browser whose clock ran on
629
+ // (a rest, a stalled host clock) sets its clock again instead of being dropped without a word.
630
+ if (j > K + tickHz) { stats.dropped += 1; q.lead = Math.min(q.lead, 126); break; }
508
631
  if(j >= q.newest && lead < q.lead)q.lead=lead;
509
632
  q.newest = Math.max(q.newest,j);
510
633
  if(seq){q.received??=[];q.received.push(seq);if(q.received.length>256)q.received.shift();}
@@ -585,7 +708,13 @@ export function createHost(o: HostOptions): Host {
585
708
  core.seatAway(seat, false);
586
709
  if (!previous || previous.driver !== driver || previous.owner !== owner) queues.delete(seat);
587
710
  rosterText = '';
588
- if (people() > 0) { if (!running && !paused) start(); else if (paused) resume(); }
711
+ // A socket the room restored after hibernating is the same person, still resting; anybody else has just come.
712
+ const now = o.clock.now();
713
+ // The relay says whether the browser understands resting: on the server's join, or on the peer a browser host hears.
714
+ if (m.rest === true || (p as { rest?: unknown }).rest === true) restful.add(seat); else restful.delete(seat);
715
+ if (m.hidden === true) { if (!hiddenSince.has(seat)) hiddenSince.set(seat, now); } else hiddenSince.delete(seat);
716
+ if (m.restored !== true || !playedAt.has(seat)) playedAt.set(seat, now);
717
+ if (people() > 0) { if (resting) { if (m.restored !== true) wakeUp('join'); } else if (!running && !paused) start(); else if (paused) resume(); }
589
718
  return;
590
719
  }
591
720
  case 'leave': {
@@ -598,9 +727,10 @@ export function createHost(o: HostOptions): Host {
598
727
  held.character = core.character(held.seat) ?? held.character;
599
728
  held.returning = true;
600
729
  core.seatLeave(held.seat);
601
- tick(o.clock.now());
730
+ if (!resting) tick(o.clock.now());
602
731
  } else core.seatAway(m.seat as number, true);
603
- if ((running || roundIdleUntil !== null) && people() === 0) pause();
732
+ playedAt.delete(m.seat as number); hiddenSince.delete(m.seat as number); restful.delete(m.seat as number);
733
+ if ((running || resting || roundIdleUntil !== null) && people() === 0) pause();
604
734
  return;
605
735
  }
606
736
  case 'free': {
@@ -609,16 +739,30 @@ export function createHost(o: HostOptions): Host {
609
739
  for (const [id, p] of peers) if (p.seat === m.seat) peers.delete(id);
610
740
  present.delete(m.seat as number);
611
741
  queues.delete(m.seat as number);
742
+ playedAt.delete(m.seat as number); hiddenSince.delete(m.seat as number); restful.delete(m.seat as number);
612
743
  const held = [...characters.values()].find(c => c.seat === m.seat && !c.returning);
613
744
  if (held) { held.character = core.character(held.seat) ?? held.character; held.returning = true; }
614
745
  core.seatLeave(m.seat as number);
615
- if (held) tick(o.clock.now());
746
+ if (held && !resting) tick(o.clock.now());
616
747
  rosterText = '';
748
+ if (resting && people() === 0) pause();
749
+ return;
750
+ }
751
+ case 'hidden': {
752
+ // A page went to the background, or came back (the relay hears it on the page's clock pings). Coming back wakes a
753
+ // resting room: somebody is looking at it again.
754
+ if (!Number.isInteger(m.seat) || !present.get(m.seat as number)) return;
755
+ const now = o.clock.now();
756
+ if (m.on === true) { if (!hiddenSince.has(m.seat as number)) hiddenSince.set(m.seat as number, now); }
757
+ else if (hiddenSince.delete(m.seat as number)) played(m.seat as number, 'person', now);
617
758
  return;
618
759
  }
619
760
  case 'ev': {
620
761
  // What a player sends is read as what it is: a kind or a command's name that is not a text is no kind and no command.
621
762
  const kind = typeof m.k === 'string' ? m.k : '';
763
+ // Anything a seated person sends (a command, a pulse, a line of chat) is somebody playing, and so is an AI's
764
+ // command, whichever hands it plays with: a resting room wakes for a guide as for a person.
765
+ if (restMs && Number.isInteger(m.from)) { const b = core.bodyOf(m.from as number); if (b && (present.get(m.from as number) || hasHands(m.from as number, b.driver) || kind === 'agent:do' || /^ask:/.test(kind))) played(m.from as number, present.get(m.from as number) ? 'person' : b.driver, o.clock.now()); }
622
766
  if (!Number.isInteger(m.from)) {
623
767
  if (m.from === null && SPEECH.test(kind)) o.send({ t: 'ev', from: null, k: kind, d: cleanSpeech(m.d) });
624
768
  return;
@@ -846,6 +990,9 @@ export function createHost(o: HostOptions): Host {
846
990
  running = false; disarm();
847
991
  try { o.store?.save(save()); o.onRoundIdle(roundIdleUntil); }
848
992
  catch (error) { fault('round-idle', error); stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); }
993
+ } else if (restMs && running && now >= restCheckAt) {
994
+ restCheckAt = now + 1000;
995
+ if (quiet(now)) rest();
849
996
  }
850
997
  return;
851
998
  }
@@ -929,7 +1076,7 @@ export function createHost(o: HostOptions): Host {
929
1076
  const self = { blame: (): void => { blamed = true; } };
930
1077
 
931
1078
  function start(): void {
932
- if (running || ended || roundIdleUntil !== null) return;
1079
+ if (running || resting || ended || roundIdleUntil !== null) return;
933
1080
  running = true; paused = false;
934
1081
  try {
935
1082
  base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
@@ -943,6 +1090,10 @@ export function createHost(o: HostOptions): Host {
943
1090
  }
944
1091
  function pause(): void {
945
1092
  if (!running) {
1093
+ // Everybody left a resting room: it is an empty room now, paused like one. People still here (a tab
1094
+ // went to the background) stay resting. Ending it would start every clock while the next host restores
1095
+ // the rest save and stays asleep, so the input that should wake the room is dropped.
1096
+ if (resting) { if (people() > 0) return; resting = false; paused = true; log({ ev: 'host-pause', game: o.game, tick: core.tick }); try { o.send({ t: 'rest', on: false }); checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); } return; }
946
1097
  if (roundIdleUntil !== null && !paused) { paused = true; checkpoint(true); o.onPause?.(); }
947
1098
  return;
948
1099
  }
@@ -953,7 +1104,7 @@ export function createHost(o: HostOptions): Host {
953
1104
  try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
954
1105
  }
955
1106
  function resume(announceRound = true): void {
956
- if (running || ended || !paused || roundIdleUntil !== null) return;
1107
+ if (running || resting || ended || !paused || roundIdleUntil !== null) return;
957
1108
  running = true; paused = false;
958
1109
  try {
959
1110
  // The room resumes at the tick it paused on: no tick is skipped and no timer fires for the gap.
@@ -986,7 +1137,7 @@ export function createHost(o: HostOptions): Host {
986
1137
  } catch (error) { fault('announce', error); }
987
1138
  }
988
1139
  function stop(): void {
989
- running = false; paused = false; ended = true;
1140
+ running = false; paused = false; resting = false; ended = true;
990
1141
  core.dispose();
991
1142
  disarm();
992
1143
  agents?.stop();
@@ -1002,7 +1153,7 @@ export function createHost(o: HostOptions): Host {
1002
1153
 
1003
1154
  function save(): Uint8Array {
1004
1155
  const reserved = new Set(core.bodies().filter(b => b.owner === 'reserved').map(b => b.seat));
1005
- return toBytes({ v: 1, ...(roundIdleUntil !== null ? { roundIdleUntil } : {}), ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
1156
+ return toBytes({ v: 1, ...(roundIdleUntil !== null ? { roundIdleUntil } : {}), ...(restMs ? { rest: { on: resting, at: [...playedAt] } } : {}), ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
1006
1157
  }
1007
1158
  function checkpoint(force = false): void {
1008
1159
  if (persistent) {
@@ -1048,8 +1199,10 @@ export function createHost(o: HostOptions): Host {
1048
1199
  if (!held || held.returning || ended) return;
1049
1200
  held.character = core.character(held.seat) ?? held.character; held.returning = true;
1050
1201
  present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
1051
- if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
1052
- if ((running || roundIdleUntil !== null) && people() === 0) pause();
1202
+ playedAt.delete(held.seat); hiddenSince.delete(held.seat); restful.delete(held.seat);
1203
+ if (held.seat >= 0) core.seatLeave(held.seat);
1204
+ if (!resting) tick(o.clock.now());
1205
+ if ((running || resting || roundIdleUntil !== null) && people() === 0) pause();
1053
1206
  },
1054
1207
  characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
1055
1208
  get viewSettings() { return c.settings.view; },
@@ -1061,6 +1214,7 @@ export function createHost(o: HostOptions): Host {
1061
1214
  get paused() { return paused; },
1062
1215
  get roundIdleUntil() { return roundIdleUntil; },
1063
1216
  wakeRound,
1217
+ get resting() { return resting; },
1064
1218
  get people() { return people(); },
1065
1219
  frame, start, pause, resume, announce,
1066
1220
  // An orderly stop of a lasting world folds what moved since the last checkpoint. One that follows a failed write, or arrives during one, writes nothing.
@@ -1071,7 +1225,7 @@ export function createHost(o: HostOptions): Host {
1071
1225
  try { now = o.clock.now(); tick(now); } catch (error) { fault('tickNow', error); } finally { keepTime(); }
1072
1226
  },
1073
1227
  save,
1074
- facts: () => ({ timing: timing.facts(), tick: core.tick, epoch, running, paused, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
1228
+ facts: () => ({ timing: timing.facts(), tick: core.tick, epoch, running, paused, resting, ended, armed, people: people(), tickHz, ...stats, core: { ...core.stats } }),
1075
1229
  core,
1076
1230
  };
1077
1231
  }