@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.
- package/CHANGELOG.md +56 -4
- package/docs/toolkit-place-patterns.md +16 -0
- package/guides/game/RULES.md +15 -1
- package/lib/agent-seat.mjs +10 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +10 -0
- package/netplay/netplay.ts +30 -9
- package/package.json +1 -1
- package/parts/README.md +28 -0
- package/parts/catalogue.json +236 -0
- package/parts/owner-action-checklist/LICENSE +202 -0
- package/parts/owner-action-checklist/NOTICE +3 -0
- package/parts/owner-action-checklist/README.md +68 -0
- package/parts/owner-action-checklist/part.json +95 -0
- package/parts/owner-action-checklist/preview/index.html +16 -0
- package/parts/owner-action-checklist/src/handler.mjs +44 -0
- package/parts/owner-action-checklist/src/index.mjs +96 -0
- package/parts/owner-action-checklist/src/style.css +3 -0
- package/parts/owner-action-checklist/tuning.json +3 -0
- package/parts/terrain-route-map/LICENSE +202 -0
- package/parts/terrain-route-map/NOTICE +3 -0
- package/parts/terrain-route-map/README.md +67 -0
- package/parts/terrain-route-map/THIRD-PARTY-LICENSE +21 -0
- package/parts/terrain-route-map/part.json +133 -0
- package/parts/terrain-route-map/preview/demo.js +4127 -0
- package/parts/terrain-route-map/preview/heightmap.png +0 -0
- package/parts/terrain-route-map/preview/index.html +1 -0
- package/parts/terrain-route-map/preview/main.mjs +4 -0
- package/parts/terrain-route-map/preview/route.json +1 -0
- package/parts/terrain-route-map/src/data.mjs +81 -0
- package/parts/terrain-route-map/src/index.mjs +131 -0
- package/parts/terrain-route-map/src/style.css +3 -0
- package/parts/terrain-route-map/tuning.json +5 -0
- package/rules/browser.ts +2 -0
- package/rules/core.ts +3 -0
- package/rules/host.ts +170 -16
- package/rules/rules.ts +16 -3
- package/rules/view.ts +39 -7
- package/starters/coin-dash/game.json +2 -1
- package/starters/ember-vale/game.json +2 -1
- package/starters/gem-rush/game.json +2 -1
- package/starters/gem-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/game.json +2 -1
- package/tools/tools.ts +1 -1
- package/worker/agents.mjs +20 -4
- package/worker/booking-calendar.mjs +15 -2
- package/worker/build-id.mjs +1 -1
- package/worker/limits.mjs +3 -1
- package/worker/room-sessions.mjs +1 -1
- package/worker/room.mjs +64 -9
- package/worker/table.mjs +51 -18
- package/worker/version.mjs +1 -1
|
Binary file
|
|
@@ -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}
|
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) ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1052
|
-
if (
|
|
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
|
}
|