@homie-rocks/studio 0.45.2 → 0.46.0
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 +87 -0
- package/guides/game/RULES.md +276 -3
- package/guides/game/SKILL.md +46 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-check-worker.mjs +6 -5
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +2 -2
- package/lib/scaffold.mjs +9 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +116 -26
- package/rules/guard.ts +1 -1
- package/rules/host.ts +139 -19
- package/rules/interest.mjs +3 -3
- package/rules/live.ts +19 -7
- package/rules/map-index.ts +7 -1
- package/rules/math.ts +20 -8
- package/rules/navigation.ts +69 -0
- package/rules/pack.ts +2 -2
- package/rules/query.ts +103 -0
- package/rules/rules.ts +108 -29
- package/rules/terrain-legacy.ts +93 -0
- package/rules/terrain.ts +96 -11
- package/rules/types.ts +17 -7
- package/rules/view.ts +38 -19
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/index.mjs +181 -17
- package/worker/players.mjs +3 -1
- package/worker/room-store.mjs +113 -11
- package/worker/room.mjs +12 -3
- package/worker/saves-shell.mjs +5 -1
- package/worker/saves.mjs +19 -1
- package/worker/version.mjs +1 -1
- package/worker/world-journal.mjs +109 -0
package/rules/terrain.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { charge } from './guard.ts';
|
|
|
4
4
|
import type { Vec3, MapHeightTile } from './rules.ts';
|
|
5
5
|
import type { Solid, Hit3 } from './collision.ts';
|
|
6
6
|
type Plane = { n: Vec3; c: number };
|
|
7
|
-
type Prism = { vertices: Vec3[]; faces: Vec3[][]; planes: Plane[]; edges: Vec3[] };
|
|
7
|
+
type Prism = { vertices: Vec3[]; faces: Vec3[][]; bounds: {min:Vec3;max:Vec3}[]; planes: Plane[]; edges: Vec3[]; segments: [Vec3,Vec3][]; segmentIds: [number,number][]; boxAxes:Vec3[] };
|
|
8
8
|
const cache = new WeakMap<MapHeightTile, Prism[]>();
|
|
9
9
|
const sub=(a:Vec3,b:Vec3):Vec3=>({x:a.x-b.x,y:a.y-b.y,z:a.z-b.z});
|
|
10
10
|
const add=(a:Vec3,b:Vec3,t=1):Vec3=>({x:a.x+b.x*t,y:a.y+b.y*t,z:a.z+b.z*t});
|
|
@@ -19,13 +19,25 @@ export function prepareTile(t:MapHeightTile):void {
|
|
|
19
19
|
for(const tri of tris){
|
|
20
20
|
const upper=tri.map(i=>top[i]),lower=upper.map(v=>({...v,z:t.at.z+t.base!}));
|
|
21
21
|
if(upper.every(v=>v.z===lower[0].z))continue;
|
|
22
|
+
prisms.push(makePrism(upper,lower));
|
|
23
|
+
}
|
|
24
|
+
cache.set(t,prisms);
|
|
25
|
+
}
|
|
26
|
+
function makePrism(upper:Vec3[],lower:Vec3[]):Prism {
|
|
22
27
|
const vertices=[...upper,...lower],faces=[upper,[...lower].reverse()];
|
|
23
28
|
for(let i=0;i<3;i++){const j=(i+1)%3;faces.push([upper[i],lower[i],lower[j]],[upper[i],lower[j],upper[j]]);}
|
|
24
29
|
const center=vertices.reduce((s,v)=>add(s,v,1/6),{x:0,y:0,z:0}),planes:Plane[]=[],valid:Vec3[][]=[],edges:Vec3[]=[];
|
|
25
30
|
for(const face of faces){let n=cross(sub(face[1],face[0]),sub(face[2],face[0]));const len=Math.sqrt(dot(n,n));if(len<1e-12)continue;n={x:n.x/len,y:n.y/len,z:n.z/len};let c=dot(n,face[0]);if(dot(n,center)>c){n={x:-n.x,y:-n.y,z:-n.z};c=-c;}planes.push({n,c});valid.push(face);for(let i=0;i<3;i++)edges.push(sub(face[(i+1)%3],face[i]));}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
const segments:[Vec3,Vec3][]=[],segmentIds:[number,number][]=[];
|
|
32
|
+
for(let i=0;i<vertices.length;i++)for(let j=i+1;j<vertices.length;j++){
|
|
33
|
+
const common=planes.filter(p=>Math.abs(dot(p.n,vertices[i])-p.c)<1e-9&&Math.abs(dot(p.n,vertices[j])-p.c)<1e-9);
|
|
34
|
+
if(common.some((p,k)=>common.slice(k+1).some(q=>Math.abs(dot(p.n,q.n))<1-1e-9))){segments.push([vertices[i],vertices[j]]);segmentIds.push([i,j]);}
|
|
35
|
+
}
|
|
36
|
+
const unique=planes.filter((p,i)=>!planes.slice(0,i).some(q=>dot(p.n,q.n)>1-1e-10&&Math.abs(p.c-q.c)<1e-9));
|
|
37
|
+
const boxAxes:Vec3[]=[];
|
|
38
|
+
const axis=(v:Vec3)=>{const len=Math.sqrt(dot(v,v));if(len<1e-10)return;const n={x:v.x/len,y:v.y/len,z:v.z/len};if(!boxAxes.some(a=>Math.abs(dot(a,n))>1-1e-10))boxAxes.push(n);};
|
|
39
|
+
const bases=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1}];for(const b of bases)axis(b);for(const p of unique)axis(p.n);for(const e of edges)for(const b of bases)axis(cross(e,b));
|
|
40
|
+
return {vertices,segments,segmentIds,boxAxes,faces:valid,bounds:valid.map(face=>({min:{x:Math.min(...face.map(v=>v.x)),y:Math.min(...face.map(v=>v.y)),z:Math.min(...face.map(v=>v.z))},max:{x:Math.max(...face.map(v=>v.x)),y:Math.max(...face.map(v=>v.y)),z:Math.max(...face.map(v=>v.z))}})),planes:unique,edges};
|
|
29
41
|
}
|
|
30
42
|
// Closest point on a triangle, including vertex and edge Voronoi regions.
|
|
31
43
|
function triangle(p:Vec3,a:Vec3,b:Vec3,c:Vec3):Vec3 {
|
|
@@ -52,16 +64,20 @@ function clip(prism:Prism,p:Vec3,d:Vec3):Hit3|null {
|
|
|
52
64
|
}
|
|
53
65
|
function distance(prism:Prism,a:Vec3,b:Vec3):Vec3 {
|
|
54
66
|
if(clip(prism,a,sub(b,a)))return {x:0,y:0,z:0};
|
|
67
|
+
// A separating plane is a distance lower bound. If its orthogonal projection
|
|
68
|
+
// lies on the prism, that bound is attained: no edge search is necessary.
|
|
69
|
+
let gap=0,normal:Vec3|null=null,endpoint=a;
|
|
70
|
+
for(const plane of prism.planes){charge(8);const ga=dot(plane.n,a)-plane.c,gb=dot(plane.n,b)-plane.c,g=Math.min(ga,gb);if(g>gap){gap=g;normal=plane.n;endpoint=ga<=gb?a:b;}}
|
|
71
|
+
if(normal){const projected=add(endpoint,normal,-gap);let inside=true;for(const plane of prism.planes){charge(8);if(dot(plane.n,projected)>plane.c+1e-10){inside=false;break;}}if(inside)return {x:normal.x*gap,y:normal.y*gap,z:normal.z*gap};}
|
|
55
72
|
let best={x:Infinity,y:Infinity,z:Infinity},sq=Infinity;
|
|
56
73
|
const take=(v:Vec3)=>{const n=dot(v,v);if(n<sq){sq=n;best=v;}};
|
|
57
|
-
for(const [x,y,z]
|
|
74
|
+
for(let i=0;i<prism.faces.length;i++){charge(12);const bounds=prism.bounds[i];let lower=0;for(const k of ['x','y','z'] as const){const gap=Math.max(0,bounds.min[k]-Math.max(a[k],b[k]),Math.min(a[k],b[k])-bounds.max[k]);lower+=gap*gap;}if(lower>sq)continue;const [x,y,z]=prism.faces[i];charge(36);take(sub(a,triangle(a,x,y,z)));take(sub(b,triangle(b,x,y,z)));take(segmentPair(a,b,x,y));take(segmentPair(a,b,y,z));take(segmentPair(a,b,z,x));}
|
|
58
75
|
return best;
|
|
59
76
|
}
|
|
60
77
|
function boxCast(prism:Prism,a:Solid,d:Vec3):Hit3|null {
|
|
61
|
-
const axes
|
|
62
|
-
for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
|
|
78
|
+
const axes=prism.boxAxes;
|
|
63
79
|
let near=0,far=1,normal={x:0,y:0,z:0};
|
|
64
|
-
for(const
|
|
80
|
+
for(const n of axes){charge(16);let lo=Infinity,hi=-Infinity;
|
|
65
81
|
for(const v of prism.vertices){const p=dot(n,v);lo=Math.min(lo,p);hi=Math.max(hi,p);}
|
|
66
82
|
let amin=0,amax=0;for(const k of ['x','y','z'] as const){amin+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);amax+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}
|
|
67
83
|
const speed=dot(n,d);if(Math.abs(speed)<1e-12){if(amax<=lo+1e-9||amin>=hi-1e-9)return null;continue;}
|
|
@@ -70,13 +86,83 @@ function boxCast(prism:Prism,a:Solid,d:Vec3):Hit3|null {
|
|
|
70
86
|
}
|
|
71
87
|
return near<=1&&dot(normal,d)<0?{t:near,nx:normal.x,ny:normal.y,nz:normal.z}:null;
|
|
72
88
|
}
|
|
89
|
+
const extended = new WeakMap<Prism, Map<number, Prism>>();
|
|
90
|
+
function capsulePrism(prism:Prism,length:number):Prism {
|
|
91
|
+
if(!length)return prism;
|
|
92
|
+
let variants=extended.get(prism);if(!variants){variants=new Map();extended.set(prism,variants);}
|
|
93
|
+
let result=variants.get(length);if(!result){const vertices=prism.vertices.map((p,i)=>i<3?p:{...p,z:p.z-length});
|
|
94
|
+
const translate=(p:Vec3)=>vertices[prism.vertices.indexOf(p)];
|
|
95
|
+
result={vertices,faces:prism.faces.map(face=>face.map(translate)),bounds:prism.bounds.map(b=>({min:{...b.min,z:b.min.z-length},max:b.max})),planes:prism.planes.map(p=>({n:p.n,c:p.c+Math.max(0,-p.n.z*length)})),edges:prism.edges,segments:prism.segmentIds.map(([i,j])=>[vertices[i],vertices[j]]),segmentIds:prism.segmentIds,boxAxes:prism.boxAxes};if(variants.size>=32)variants.clear();variants.set(length,result);}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
/** Downward vertical support only meets the upper triangle or its rounded rim.
|
|
99
|
+
* The capsule's upper centre trails its lower centre, so no extrusion is needed. */
|
|
100
|
+
function downCast(prism:Prism,a:Solid,d:Vec3):Hit3|null {
|
|
101
|
+
charge(24);const p=a.min,r=a.r,top=prism.vertices.slice(0,3);
|
|
102
|
+
// Reject the rounded top bounds before doing any face/edge root solving.
|
|
103
|
+
if(p.x+r<Math.min(...top.map(v=>v.x))||p.x-r>Math.max(...top.map(v=>v.x))||p.y+r<Math.min(...top.map(v=>v.y))||p.y-r>Math.max(...top.map(v=>v.y))||p.z+d.z-r>Math.max(...top.map(v=>v.z)))return null;
|
|
104
|
+
const plane=prism.planes.find(q=>q.n.z>0);
|
|
105
|
+
if(!plane)return null;
|
|
106
|
+
let best:Hit3|null=null;
|
|
107
|
+
const take=(t:number,n:Vec3)=>{if(t<0||t>1||best&&t>=best.t||n.z<=1e-12)return;const len=Math.sqrt(dot(n,n));best={t,nx:n.x/len,ny:n.y/len,nz:n.z/len};};
|
|
108
|
+
const t=(plane.c+r-dot(plane.n,p))/(plane.n.z*d.z);
|
|
109
|
+
if(t>=0&&t<=1){const q=add(add(p,d,t),plane.n,-r),near=triangle(q,top[0],top[1],top[2]),delta=sub(q,near);if(dot(delta,delta)<1e-14)return {t,nx:plane.n.x,ny:plane.n.y,nz:plane.n.z};}
|
|
110
|
+
const speed2=d.z*d.z;
|
|
111
|
+
for(let i=0;i<3;i++){
|
|
112
|
+
const from=top[i],to=top[(i+1)%3],ba=sub(to,from),oa=sub(p,from);
|
|
113
|
+
charge(8);const xy=ba.x*ba.x+ba.y*ba.y,u=xy?clamp((oa.x*ba.x+oa.y*ba.y)/xy):0;
|
|
114
|
+
if((oa.x-ba.x*u)**2+(oa.y-ba.y*u)**2>r*r+1e-12)continue;
|
|
115
|
+
charge(40);const ba2=dot(ba,ba),bard=ba.z*d.z,baoa=dot(ba,oa);
|
|
116
|
+
const k2=ba2*speed2-bard*bard,k1=ba2*oa.z*d.z-baoa*bard,k0=ba2*dot(oa,oa)-baoa*baoa-r*r*ba2,h=k1*k1-k2*k0;
|
|
117
|
+
if(k2>1e-16&&h>=0){const t=(-k1-Math.sqrt(h))/k2,y=baoa+t*bard;if(y>=0&&y<=ba2)take(t,sub(add(p,d,t),add(from,ba,y/ba2)));}
|
|
118
|
+
const horizontal=oa.x*oa.x+oa.y*oa.y;if(horizontal<=r*r){const height=Math.sqrt(r*r-horizontal),t=(from.z+height-p.z)/d.z;take(t,{x:oa.x,y:oa.y,z:height});}
|
|
119
|
+
}
|
|
120
|
+
if(best&&dot(plane.n,p)-plane.c<r){const initial=distance(prism,a.min,a.max);if(dot(initial,initial)<r*r-1e-9)return null;}
|
|
121
|
+
return best;
|
|
122
|
+
}
|
|
123
|
+
/** Exact swept sphere against a convex prism's faces and rounded edges. A
|
|
124
|
+
* vertical capsule is a sphere against the prism extended down its centreline. */
|
|
125
|
+
function roundedCast(source:Prism,a:Solid,d:Vec3):Hit3|null {
|
|
126
|
+
charge(24);
|
|
127
|
+
const lo=source.vertices[3],hiZ=Math.max(source.vertices[0].z,source.vertices[1].z,source.vertices[2].z);
|
|
128
|
+
if(Math.min(a.min.z,a.min.z+d.z)-a.r>hiZ||Math.max(a.max.z,a.max.z+d.z)+a.r<lo.z)return null;
|
|
129
|
+
let near=0,far=1;
|
|
130
|
+
for(const k of ['x','y'] as const){const low=Math.min(source.vertices[0][k],source.vertices[1][k],source.vertices[2][k])-a.r,high=Math.max(source.vertices[0][k],source.vertices[1][k],source.vertices[2][k])+a.r;if(!d[k]){if(a.min[k]<low||a.min[k]>high)return null;}else{let t=(low-a.min[k])/d[k],u=(high-a.min[k])/d[k];if(t>u)[t,u]=[u,t];near=Math.max(near,t);far=Math.min(far,u);if(near>far)return null;}}
|
|
131
|
+
charge(64);const prism=capsulePrism(source,a.max.z-a.min.z),p=a.min,r=a.r;
|
|
132
|
+
let entry=0,exit=1,enterPlane:Plane|null=null;const approaching:{plane:Plane;t:number}[]=[];
|
|
133
|
+
for(const plane of prism.planes){charge(8);const gap=plane.c+r-dot(plane.n,p),speed=dot(plane.n,d);if(Math.abs(speed)<1e-12){if(gap< -1e-10)return null;}else if(speed>0)exit=Math.min(exit,gap/speed);else {const t=gap/speed;if(t>=-1e-9&&t<=1)approaching.push({plane,t});if(t>=entry){entry=t;enterPlane=plane;}}if(entry>exit+1e-10)return null;}
|
|
134
|
+
if(entry>1||exit<0)return null;
|
|
135
|
+
if(enterPlane){const projected=add(add(p,d,entry),enterPlane.n,-r);let inside=true;for(const plane of prism.planes){charge(8);if(dot(plane.n,projected)>plane.c+1e-10){inside=false;break;}}if(inside)return{t:Math.max(0,entry),nx:enterPlane.n.x,ny:enterPlane.n.y,nz:enterPlane.n.z};}
|
|
136
|
+
|
|
137
|
+
const speed2=dot(d,d);if(speed2<1e-20)return null;
|
|
138
|
+
let best:Hit3|null=null;
|
|
139
|
+
const take=(t:number,normal:Vec3)=>{if(t< -1e-9||t>1||best&&t>=best.t)return;const len=Math.sqrt(dot(normal,normal));if(len<1e-12||dot(normal,d)>=-1e-12)return;best={t:Math.max(0,t),nx:normal.x/len,ny:normal.y/len,nz:normal.z/len};};
|
|
140
|
+
const sphere=(v:Vec3)=>{const o=sub(p,v),b=dot(o,d),c=dot(o,o)-r*r,h=b*b-speed2*c;if(h<0)return;const t=(-b-Math.sqrt(h))/speed2;take(t,sub(add(p,d,t),v));};
|
|
141
|
+
// Reuse the plane times from clipping. Parallel and receding faces cannot
|
|
142
|
+
// be entry contacts; do not project or retest their already-known times.
|
|
143
|
+
for(const {plane,t} of approaching){
|
|
144
|
+
charge(8);if(best&&t>=best.t)continue;charge(16);const hit=add(add(p,d,t),plane.n,-r);let inside=true;
|
|
145
|
+
for(const q of prism.planes){charge(4);if(dot(q.n,hit)>q.c+1e-10){inside=false;break;}}
|
|
146
|
+
if(inside)take(t,plane.n);
|
|
147
|
+
}
|
|
148
|
+
const sweptMin={x:Math.min(p.x,p.x+d.x)-r,y:Math.min(p.y,p.y+d.y)-r,z:Math.min(p.z,p.z+d.z)-r},sweptMax={x:Math.max(p.x,p.x+d.x)+r,y:Math.max(p.y,p.y+d.y)+r,z:Math.max(p.z,p.z+d.z)+r};
|
|
149
|
+
for(const [from,to] of prism.segments){
|
|
150
|
+
charge(8);if(Math.max(from.x,to.x)<sweptMin.x||Math.min(from.x,to.x)>sweptMax.x||Math.max(from.y,to.y)<sweptMin.y||Math.min(from.y,to.y)>sweptMax.y||Math.max(from.z,to.z)<sweptMin.z||Math.min(from.z,to.z)>sweptMax.z)continue;
|
|
151
|
+
charge(48);const ba=sub(to,from),oa=sub(p,from),ba2=dot(ba,ba),bard=dot(ba,d),baoa=dot(ba,oa);
|
|
152
|
+
const k2=ba2*speed2-bard*bard,k1=ba2*dot(oa,d)-baoa*bard,k0=ba2*dot(oa,oa)-baoa*baoa-r*r*ba2,h=k1*k1-k2*k0;
|
|
153
|
+
if(k2>1e-16&&h>=0){const t=(-k1-Math.sqrt(h))/k2,y=baoa+t*bard;if(y>=0&&y<=ba2)take(t,sub(add(p,d,t),add(from,ba,y/ba2)));}
|
|
154
|
+
}
|
|
155
|
+
for(const v of prism.vertices){charge(4);if(v.x<sweptMin.x||v.x>sweptMax.x||v.y<sweptMin.y||v.y>sweptMax.y||v.z<sweptMin.z||v.z>sweptMax.z)continue;charge(24);sphere(v);}
|
|
156
|
+
if(best&&entry===0){const initial=distance(prism,p,p);if(dot(initial,initial)<r*r-1e-9)return null;}
|
|
157
|
+
return best;
|
|
158
|
+
}
|
|
73
159
|
export function castTerrain(tile:MapHeightTile,a:Solid,d:Vec3):Hit3|null {
|
|
74
160
|
charge(12);
|
|
75
161
|
let best:Hit3|null=null;
|
|
76
162
|
for(const prism of cache.get(tile)??[]){
|
|
77
163
|
let hit:Hit3|null=null;
|
|
78
164
|
if(a.r===0)hit=a.min.x===a.max.x&&a.min.y===a.max.y&&a.min.z===a.max.z?clip(prism,a.min,d):boxCast(prism,a,d);
|
|
79
|
-
else
|
|
165
|
+
else hit=d.x===0&&d.y===0&&d.z<0?downCast(prism,a,d):roundedCast(prism,a,d);
|
|
80
166
|
if(hit&&(!best||hit.t<best.t))best=hit;
|
|
81
167
|
}return best;
|
|
82
168
|
}
|
|
@@ -85,8 +171,7 @@ export function overlapsTerrain(tile:MapHeightTile,a:Solid):boolean {
|
|
|
85
171
|
if(a.r>0){const v=distance(prism,a.min,a.max);if(dot(v,v)<a.r*a.r-1e-9)return true;}
|
|
86
172
|
else {
|
|
87
173
|
// SAT overlap uses the same face, edge and box axes as continuous casts.
|
|
88
|
-
const axes=
|
|
89
|
-
for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
|
|
174
|
+
const axes=prism.boxAxes;
|
|
90
175
|
let separated=false;for(const n of axes){charge(16);if(dot(n,n)<1e-18)continue;const ps=prism.vertices.map(v=>dot(n,v));let lo=0,hi=0;for(const k of ['x','y','z'] as const){lo+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);hi+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}if(hi<=Math.min(...ps)+1e-9||lo>=Math.max(...ps)-1e-9){separated=true;break;}}if(!separated)return true;
|
|
91
176
|
}
|
|
92
177
|
}return false;
|
package/rules/types.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/** The build specialises these faces from a game's declarations; the runtime still checks every write. */
|
|
2
|
-
import type { Vec3, BodyDef, Fields, ColliderDef } from './rules.ts';
|
|
2
|
+
import type { Vec3, BodyDef, Fields, ColliderDef, QueryDef } from './rules.ts';
|
|
3
3
|
import type { math } from './math.ts';
|
|
4
4
|
|
|
5
5
|
export type Point = { readonly x: number; readonly y: number; readonly z?: number };
|
|
6
6
|
export type ReadonlyState<T> = T extends object ? { readonly [K in keyof T]: ReadonlyState<T[K]> } : T;
|
|
7
7
|
export type WriteState<T> = T extends Vec3 ? keyof T extends keyof Vec3 ? Point : { [K in keyof T]: WriteState<T[K]> } : T extends object ? { [K in keyof T]: WriteState<T[K]> } : T;
|
|
8
8
|
type Payload<T> = {} extends T ? [data?: WriteState<T>] : [data: WriteState<T>];
|
|
9
|
-
type Query<T> = ReadonlyState<Omit<T, 'input'
|
|
9
|
+
type Query<T> = T extends object ? ReadonlyState<Omit<T, 'input'>> : never;
|
|
10
10
|
export interface Goal { readonly goal: string; readonly args: Readonly<Record<string, unknown>>; readonly from: 'brain' | 'floor'; readonly at: number; readonly asked: boolean; readonly state: 'active' | 'done' | 'failed' }
|
|
11
11
|
export interface GuideDecision { goal?: string | null; args?: Record<string, unknown>; say?: string | null; sayArgs?: Record<string, unknown> }
|
|
12
12
|
export interface GuideRequest { readonly k: string; readonly args: Readonly<Record<string, unknown>>; readonly from: number; readonly at: number }
|
|
@@ -16,7 +16,12 @@ export type Self<F, M, I, K extends string, P extends boolean, G = Goal> = F & {
|
|
|
16
16
|
readonly id: string; readonly kind: K; readonly pos: Vec3; get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); readonly grounded: boolean;
|
|
17
17
|
readonly motion: M; readonly input: ReadonlyState<I>;
|
|
18
18
|
} & (P extends true ? Player<G> : {});
|
|
19
|
-
export interface Hit { readonly entity?: string; readonly at: Point; readonly normal: Vec3; readonly dist?: number }
|
|
19
|
+
export interface Hit { readonly entity?: string; readonly part?: string; readonly at: Point; readonly normal: Vec3; readonly dist?: number }
|
|
20
|
+
export interface RayOptions {
|
|
21
|
+
terrain?: 'exact'; atTick?: number; profile?: string; radius?: number; shape?: 'sphere' | 'box' | 'capsule'; height?:number; geometryOnly?: boolean; entitiesOnly?: boolean; ignoreSelf?: boolean; ignore?: readonly string[]; kind?: string; tag?: string; layer?: string; where?: Readonly<Record<string, number | boolean | string | {gt?: number; gte?: number; lt?: number; lte?: number; eq?: number | boolean | string}>> }
|
|
22
|
+
export type RayHit = Hit & { readonly dist: number };
|
|
23
|
+
export interface RaySnapshot {sweep(body:{pos:Point;grounded?:boolean},delta:Point,options?:{ground?:boolean}):Hit|undefined;support(body:{pos:Point},distance?:number):(Hit&{dist:number})|undefined;ray(from:Point,direction:Point,max:number):RayHit|undefined;rayAll(from:Point,direction:Point,max:number):readonly RayHit[]}
|
|
24
|
+
export interface RayQueries { rays(options?:RayOptions):RaySnapshot; ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined; rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[] }
|
|
20
25
|
export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
|
|
21
26
|
export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
|
|
22
27
|
export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
|
|
@@ -24,9 +29,11 @@ export interface Round { readonly n: number; readonly phase: 'live' | 'over'; re
|
|
|
24
29
|
export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
|
|
25
30
|
readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
|
|
26
31
|
readonly shared: Room extends true ? S : ReadonlyState<S>;
|
|
27
|
-
readonly round: Round & (Room extends true ? { end(): void } : {});
|
|
32
|
+
readonly round: Round & (Room extends true ? { end(): void; restart(): void } : {});
|
|
28
33
|
random(): number;
|
|
34
|
+
label(player:string):string;
|
|
29
35
|
send<K extends keyof E>(target: string, event: K, ...data: Payload<E[K]>): void;
|
|
36
|
+
dispatch<K extends keyof E>(target: string, event: K, ...data: Payload<E[K]>): boolean;
|
|
30
37
|
sendRoom<K extends keyof E>(event: K, ...data: Payload<E[K]>): void;
|
|
31
38
|
sendArea<K extends keyof E>(area: Area, event: K, ...data: Payload<E[K]>): void;
|
|
32
39
|
after<K extends keyof E>(ticks: number, event: K, ...data: Payload<E[K]>): void;
|
|
@@ -36,7 +43,10 @@ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
|
|
|
36
43
|
near(at: Point, radius: number): readonly Query<V[keyof V]>[];
|
|
37
44
|
inBox<K extends keyof V>(box: { min: Point; max: Point }, kind: K): readonly Query<V[K]>[];
|
|
38
45
|
inBox(box: { min: Point; max: Point }): readonly Query<V[keyof V]>[];
|
|
39
|
-
|
|
46
|
+
route(graph: string | ReadonlyArray<readonly [number,number,number,readonly number[]]>, from: number, to: number, options?: {radius?:number;height?:number}): readonly number[];
|
|
47
|
+
ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined;
|
|
48
|
+
rays(options?:RayOptions):RaySnapshot;
|
|
49
|
+
rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[];
|
|
40
50
|
ask<K extends keyof A>(name: K, state: WriteState<A[K]>): boolean;
|
|
41
51
|
} & (Room extends true ? { announce<K extends keyof E>(event: K, ...data: Payload<E[K]>): void; finish(): void } : {
|
|
42
52
|
goalDone(ok?: boolean): void;
|
|
@@ -51,7 +61,7 @@ export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined:
|
|
|
51
61
|
export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
|
|
52
62
|
type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'guideLevel' | 'guideSeats' | 'kids' | 'levelSet' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
|
|
53
63
|
export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
|
|
54
|
-
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef;
|
|
64
|
+
player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; takeover?: string }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef; query?: QueryDef;
|
|
55
65
|
tick?: (world: W, self: S) => void;
|
|
56
66
|
think?: (world: W, self: S) => Partial<I>;
|
|
57
67
|
on?: { [K in keyof (Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })]?: (world: W, self: S, event: EventData<(Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })[K]>) => void };
|
|
@@ -60,5 +70,5 @@ export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A =
|
|
|
60
70
|
guide?: { view: (world: GuideWorld<W>, self: ReadonlyState<Omit<S, 'input'>>) => V; floor?: (world: W, self: S, view: GuideView<V, G, A>) => D };
|
|
61
71
|
};
|
|
62
72
|
export interface MoveBody<M> { get pos(): Vec3; set pos(value: Point); get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); grounded: boolean; readonly motion: M }
|
|
63
|
-
export interface MoveCollision<M> { sweep(body: MoveBody<M>, delta: Point): Hit | undefined; support(body: MoveBody<M>, distance?: number): (Hit & {dist: number}) | undefined; overlaps(body: MoveBody<M>): boolean }
|
|
73
|
+
export interface MoveCollision<M> extends RayQueries { sweep(body: MoveBody<M>, delta: Point, options?: {ground?: boolean}): Hit | undefined; support(body: MoveBody<M>, distance?: number): (Hit & {dist: number}) | undefined; overlaps(body: MoveBody<M>): boolean }
|
|
64
74
|
export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & MoveCollision<M>; readonly world: MoveCollision<M> };
|
package/rules/view.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
|
|
2
|
+
import { solidAt } from './collision.ts';
|
|
3
|
+
import type { RayQueries, RayOptions } from './types.ts';
|
|
1
4
|
import { snapshotDecoder } from './interest.mjs';
|
|
2
|
-
import { collisionMap, revisionAt, type CollisionRevision } from './live.ts';
|
|
5
|
+
import { collisionMap, collisionTargets, revisionAt, type CollisionRevision } from './live.ts';
|
|
3
6
|
import { rulesOutput } from '../netplay/rules-output.mjs';
|
|
4
7
|
import { rulesCaps, rulesRates } from '../worker/limits.mjs';
|
|
5
8
|
/*
|
|
@@ -98,7 +101,8 @@ export interface OpenRoomOptions {
|
|
|
98
101
|
now?: () => number;
|
|
99
102
|
timers?: boolean;
|
|
100
103
|
}
|
|
101
|
-
export interface Room<R = unknown> {
|
|
104
|
+
export interface Room<R = unknown> extends RayQueries {
|
|
105
|
+
readonly viewTick: number;
|
|
102
106
|
readonly status: RoomStatus;
|
|
103
107
|
readonly seat: number | null;
|
|
104
108
|
readonly me: Entity | null;
|
|
@@ -193,7 +197,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
193
197
|
const hostAges: number[] = [];
|
|
194
198
|
/** What the player holds, the presses since the last step, what the server was last told, and the entries of this send period. */
|
|
195
199
|
let sample: Record<string, unknown> = {};
|
|
196
|
-
let pressed = new
|
|
200
|
+
let pressed = new Map<string, number | boolean>();
|
|
197
201
|
let lastSent = '';
|
|
198
202
|
let lastSendAt = 0;
|
|
199
203
|
let entries: { t: number; row: number[] }[] = [];
|
|
@@ -233,7 +237,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
233
237
|
// A knock may travel faster than normal movement, and catch-up already accelerates it.
|
|
234
238
|
// Fading the offset must not add another fraction of that accelerated speed.
|
|
235
239
|
const allowance = length ? (myKind()?.maxSpeed ?? 0) * elapsed / 1000 / length : 1;
|
|
236
|
-
const release = settled && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
|
|
240
|
+
const release = settled && !predict.idleHold && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
|
|
237
241
|
const progress = length ? Math.min(allowance, distance(path, o.path) * 0.8 / length) : 1;
|
|
238
242
|
const nominal = Math.max(0, Math.min(1, 1 - (now - o.at) / predict.blendMs));
|
|
239
243
|
o.fade = Math.max(nominal, o.fade - Math.max(release, progress));
|
|
@@ -258,7 +262,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
258
262
|
if (revision !== cachedCollision) { geometry = collisionMap(map, revision?.[2] ?? []); cachedCollision = revision; }
|
|
259
263
|
return geometry;
|
|
260
264
|
};
|
|
261
|
-
const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
|
|
265
|
+
const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, self: () => mine?.id, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ terrain: myKind()?.terrain, shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
|
|
262
266
|
function authorityRtt(): number {
|
|
263
267
|
// The helper's ping ends at the relay. A browser host adds another network leg in both directions.
|
|
264
268
|
// Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
|
|
@@ -290,7 +294,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
290
294
|
leadAfter = clock() + 2 * rtt + period;
|
|
291
295
|
base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
|
|
292
296
|
stepped = Math.floor(base.tick) - 1;
|
|
293
|
-
entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new
|
|
297
|
+
entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map();
|
|
294
298
|
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
|
|
295
299
|
const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
|
|
296
300
|
if (mine) {
|
|
@@ -306,7 +310,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
306
310
|
/** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
|
|
307
311
|
function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body {
|
|
308
312
|
const fn = moves[kindName];
|
|
309
|
-
if (!fn) return body;
|
|
313
|
+
if (!fn || (kindOf.get(kindName)?.control && !body.motion[kindOf.get(kindName)!.control!])) return body;
|
|
310
314
|
moveTick = t;
|
|
311
315
|
// Like snapshot unpacking, prepare the bounded geometry outside the handler
|
|
312
316
|
// quota. Authority debits projection once per tick, not once per mover.
|
|
@@ -327,9 +331,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
327
331
|
const input: Record<string, unknown> = {};
|
|
328
332
|
let press = false;
|
|
329
333
|
for (const [name, fd] of kind.input) {
|
|
330
|
-
if (fd.t === 'press') { const on = fresh
|
|
334
|
+
if (fd.t === 'press' || fd.t === 'pulse') { const on = fresh ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : (fd.t === 'press' ? false : 0); input[name] = on; values.push(fd.t === 'pulse' ? 0 : Number(on)); if (on) press = true; } else { const v = coerce(fd, (fresh ? sample[name] : held[name]) ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
|
|
331
335
|
}
|
|
332
|
-
if (fresh) pressed = new
|
|
336
|
+
if (fresh) pressed = new Map();
|
|
333
337
|
held = Object.freeze({ ...input });
|
|
334
338
|
{
|
|
335
339
|
const body = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: mine.motion }, held, t);
|
|
@@ -359,7 +363,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
359
363
|
function keepalive(t: number): void {
|
|
360
364
|
const kind = myKind();
|
|
361
365
|
if (!mine || !kind) return;
|
|
362
|
-
const values = kind.input.map(([name, fd]) => (fd.t === 'press' ? 0 : Number(coerce(fd, sample[name] ?? fd.init, dims))));
|
|
366
|
+
const values = kind.input.map(([name, fd]) => ((fd.t === 'press' || fd.t === 'pulse') ? 0 : Number(coerce(fd, sample[name] ?? fd.init, dims))));
|
|
363
367
|
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
364
368
|
entries = [{ t, row: [...values, ...claim] }]; pending.push({ t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
|
|
365
369
|
}
|
|
@@ -375,7 +379,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
375
379
|
if (hidden) {
|
|
376
380
|
// A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
|
|
377
381
|
// one neutral entry at once (every field at its init, no press), then no more steps until the tab shows again.
|
|
378
|
-
if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new
|
|
382
|
+
if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new Map(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
|
|
379
383
|
base = null;
|
|
380
384
|
return;
|
|
381
385
|
}
|
|
@@ -430,21 +434,21 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
430
434
|
adopt(me);
|
|
431
435
|
history.set(f.k, copyMine(mine!));
|
|
432
436
|
const ack = row?.[2] ?? 0;
|
|
433
|
-
const late = new
|
|
437
|
+
const late = new Map<string, unknown>();
|
|
434
438
|
for (const entry of pending) if (entry.t <= f.k) {
|
|
435
439
|
replayHeld = entry.input;
|
|
436
440
|
if (entry.t > ack && f.k + 1 - entry.t <= Math.ceil(tickHz / 4))
|
|
437
|
-
for (const [name, fd] of myKind()!.input) if (fd.t === 'press' && entry.input[name]) late.
|
|
441
|
+
for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name]) late.set(name, entry.input[name]);
|
|
438
442
|
}
|
|
439
443
|
let input = replayHeld;
|
|
440
444
|
for (let t = f.k + 1; t <= stepped; t++) {
|
|
441
|
-
const presses = new
|
|
445
|
+
const presses = new Map(t === f.k + 1 ? late : []);
|
|
442
446
|
for (const entry of pending) if (entry.t === t) {
|
|
443
447
|
input = entry.input;
|
|
444
|
-
for (const [name, fd] of myKind()!.input) if (fd.t === 'press' && entry.input[name]) presses.
|
|
448
|
+
for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name]) presses.set(name, entry.input[name]);
|
|
445
449
|
}
|
|
446
450
|
const stepInput = { ...input };
|
|
447
|
-
for (const [name, fd] of myKind()!.input) if (fd.t === 'press') stepInput[name] = presses.
|
|
451
|
+
for (const [name, fd] of myKind()!.input) if (fd.t === 'press' || fd.t === 'pulse') stepInput[name] = presses.get(name) ?? (fd.t === 'press' ? false : 0);
|
|
448
452
|
Object.assign(mine!, runMove(me.kind, mine!, Object.freeze(stepInput), t));
|
|
449
453
|
history.set(t, copyMine(mine!));
|
|
450
454
|
}
|
|
@@ -581,6 +585,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
581
585
|
const server = latest.ents.get(mine.id);
|
|
582
586
|
if (!server) return null;
|
|
583
587
|
const kind = kindOf.get(mine.kind);
|
|
588
|
+
if (kind?.control && !server.motion[kind.control]) return entityOf(server, true);
|
|
584
589
|
let pos = mine.pos;
|
|
585
590
|
let heading = mine.heading;
|
|
586
591
|
let velocity = mine.vel, grounded = mine.grounded, motion = mine.motion;
|
|
@@ -589,7 +594,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
589
594
|
if (kind && base) {
|
|
590
595
|
const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
|
|
591
596
|
const now: Record<string, unknown> = {};
|
|
592
|
-
for (const [name, fd] of kind.input) now[name] = fd.t === 'press' ? pressed.
|
|
597
|
+
for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
593
598
|
const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
|
|
594
599
|
pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, a) }, dims);
|
|
595
600
|
heading = blendHeading(mine.heading, next.heading, a, dims);
|
|
@@ -710,11 +715,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
710
715
|
const onShow = (): void => { if (typeof document !== 'undefined' && !document.hidden) { base = null; if (latest) rebase(latest.k); } };
|
|
711
716
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
|
|
712
717
|
|
|
718
|
+
const viewTargets = () => {
|
|
719
|
+
pump();
|
|
720
|
+
const targets: QueryTarget[] = collisionTargets(collisionMap(map, collisionHistory.at(-1)?.[2] ?? []));
|
|
721
|
+
for(const k of schema.kinds) {
|
|
722
|
+
if(k.collider || !k.radius)continue;
|
|
723
|
+
each(k.name,e=>targets.push({id:e.id,kind:k.name,query:k.query,fields:e as Record<string,unknown>,geometry:false,solid:solidAt(e.pos,{shape:k.shape??'circle',radius:k.radius,height:k.height??0}),at:e.pos}));
|
|
724
|
+
}
|
|
725
|
+
return targets;
|
|
726
|
+
};
|
|
727
|
+
const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(),dims,from,direction,max,options,mine?.id,all);
|
|
713
728
|
return {
|
|
729
|
+
get viewTick() { if(solo)return solo.viewTick;const s=net.sample();const a=s?frameOf(s.a):latest,b=s?frameOf(s.b):latest;return a&&b?a.k+(b.k-a.k)*(s?.alpha??0):latest?.k??0; },
|
|
714
730
|
get status() { return solo ? 'offline' : status; },
|
|
715
731
|
get seat() { return solo ? solo.seat : net.seat; },
|
|
716
732
|
get me() { pump(); return meNow(); },
|
|
717
733
|
each, get,
|
|
734
|
+
ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
|
|
735
|
+
rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(),dims,options,mine?.id),
|
|
736
|
+
rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
|
|
718
737
|
on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
|
|
719
738
|
get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
|
|
720
739
|
input(s) {
|
|
@@ -723,7 +742,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
723
742
|
const next: Record<string, unknown> = {};
|
|
724
743
|
let changed = false;
|
|
725
744
|
for (const [name, fd] of kind?.input ?? []) {
|
|
726
|
-
if (fd.t === 'press') { if ((s[name]
|
|
745
|
+
if (fd.t === 'press' || fd.t === 'pulse') { if (coerce(fd,s[name],dims) && !pressed.has(name)) changed = true; }
|
|
727
746
|
else { if (s[name] !== undefined) next[name] = s[name]; if ((next[name] ?? fd.init) !== (sample[name] ?? fd.init)) changed = true; }
|
|
728
747
|
}
|
|
729
748
|
// A fresh control can replace a preview partway through a tick (especially
|
|
@@ -731,7 +750,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
731
750
|
// may advance the new path, rather than drawing elapsed time with new input.
|
|
732
751
|
if (changed) pump();
|
|
733
752
|
const shown = changed ? meNow()?.pos : null;
|
|
734
|
-
for (const [name, fd] of kind?.input ?? []) if (fd.t === 'press' && (s[name]
|
|
753
|
+
for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'&&!pressed.has(name))net.send('pulse',[epoch,name,value]); pressed.set(name,value); }
|
|
735
754
|
sample = next;
|
|
736
755
|
if (shown) {
|
|
737
756
|
const now = clock(), nextShown = meNow()?.pos;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Characters belong to existing player account IDs. Only trusted room code calls this store.
|
|
2
|
+
* A lease never expires: a delayed room cannot spend a second copy after a timeout.
|
|
3
|
+
* The existing Table namespace supplies per-account objects; no new database or hand-entered ID.
|
|
4
|
+
*/
|
|
5
|
+
export function characterStore(storage) {
|
|
6
|
+
const sql = storage.sql;
|
|
7
|
+
sql.exec('CREATE TABLE IF NOT EXISTS characters (game TEXT PRIMARY KEY, data TEXT NOT NULL)');
|
|
8
|
+
const read = game => JSON.parse(sql.exec('SELECT data FROM characters WHERE game = ?', game).toArray()[0]?.data ?? 'null');
|
|
9
|
+
const write = (game, value) => sql.exec('INSERT INTO characters(game,data) VALUES (?,?) ON CONFLICT(game) DO UPDATE SET data=excluded.data', game, JSON.stringify(value));
|
|
10
|
+
return {
|
|
11
|
+
read,
|
|
12
|
+
acquire(game, holder, schema) {
|
|
13
|
+
return storage.transactionSync(() => {
|
|
14
|
+
const row = read(game) ?? { lease: 0, holder: null, character: null, schema };
|
|
15
|
+
if (row.schema !== schema) return { ok: false, error: 'character-schema-changed' };
|
|
16
|
+
if (row.holder && row.holder !== holder) return { ok: false, error: 'character-held', holder: row.holder };
|
|
17
|
+
if (!row.holder) { row.holder = holder; row.lease += 1; write(game, row); }
|
|
18
|
+
return { ok: true, ...row };
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
return(game, holder, lease, character) {
|
|
22
|
+
return storage.transactionSync(() => {
|
|
23
|
+
const row = read(game);
|
|
24
|
+
if (!row || lease > row.lease) throw new Error('Unknown character lease');
|
|
25
|
+
// Lost acknowledgement or an older room: success means retire that outbox,
|
|
26
|
+
// never apply an old return to a newer lease.
|
|
27
|
+
if (lease < row.lease || !row.holder) return { ok: true };
|
|
28
|
+
if (row.holder !== holder) throw new Error('Wrong character holder');
|
|
29
|
+
write(game, { ...row, holder: null, character });
|
|
30
|
+
return { ok: true };
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function characterRequest(env, account, body) {
|
|
37
|
+
const object = env.TABLE.get(env.TABLE.idFromName(`__player/${account}`));
|
|
38
|
+
const response = await object.fetch('https://player/__character', { method: 'POST', body: JSON.stringify(body) });
|
|
39
|
+
if (!response.ok) throw new Error('Character storage unavailable');
|
|
40
|
+
return response.json();
|
|
41
|
+
}
|
|
@@ -53,3 +53,19 @@ ALTER TABLE customer_offers ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0;
|
|
|
53
53
|
CREATE INDEX customer_offers_expiry ON customer_offers(expires_at);
|
|
54
54
|
${FUNCTIONS_MIGRATION.slice(FUNCTIONS_MIGRATION.indexOf('CREATE TRIGGER')).replace(/CREATE TRIGGER (\w+)/g, 'DROP TRIGGER IF EXISTS $1; CREATE TRIGGER $1').replace(/ON (shop_orders|purchase_orders|meta|mcp_audit)\n?WHEN /g, 'ON $1 WHEN ').replace(/WHEN OLD.paid_at/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid') AND OLD.paid_at").replace(/WHEN OLD.status/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.refunded') AND OLD.status").replace(/ON app_records BEGIN/g, "ON app_records WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='record.changed') BEGIN").replace(/WHEN NEW.key/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='payment.refunded') AND NEW.key").replace(/WHEN NEW.outcome/g, "WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='tool.called') AND NEW.outcome").replace(/INTO studio_events(?= VALUES|\n SELECT)/g,'INTO studio_events(id,type,data,at)')}
|
|
55
55
|
`;
|
|
56
|
+
|
|
57
|
+
// SQLite may evaluate AND operands in either order. Guard parsing itself: meta also
|
|
58
|
+
// holds plain strings, including the key used to sign player room tickets.
|
|
59
|
+
export const FUNCTIONS_META_MIGRATION_FILE = '0020_function_meta.sql';
|
|
60
|
+
export const FUNCTIONS_META_MIGRATION = ['INSERT', 'UPDATE'].map(op => `
|
|
61
|
+
DROP TRIGGER IF EXISTS money_function_refund_${op.toLowerCase()};
|
|
62
|
+
CREATE TRIGGER money_function_refund_${op.toLowerCase()} AFTER ${op} ON meta
|
|
63
|
+
WHEN CASE WHEN NEW.key LIKE 'shop-money:%:refund:%' AND json_valid(NEW.value)
|
|
64
|
+
THEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='payment.refunded')
|
|
65
|
+
AND json_extract(NEW.value,'$.status')='succeeded'
|
|
66
|
+
AND NOT EXISTS (SELECT 1 FROM studio_events WHERE id='refund:' || json_extract(NEW.value,'$.id'))
|
|
67
|
+
ELSE 0 END BEGIN
|
|
68
|
+
INSERT OR IGNORE INTO studio_events(id,type,data,at)
|
|
69
|
+
SELECT 'refund:' || json_extract(NEW.value,'$.id'),'payment.refunded',
|
|
70
|
+
json_object('refund',json_extract(NEW.value,'$.id'),'payment',json_extract(NEW.value,'$.payment_intent'),'amount',json_extract(NEW.value,'$.amount'),'currency',json_extract(NEW.value,'$.currency')),json_extract(NEW.value,'$.at')*1000;
|
|
71
|
+
END;`).join('\n');
|