@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/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
- prisms.push({vertices,faces:valid,planes,edges});
27
- }
28
- cache.set(t,prisms);
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] of prism.faces){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));}
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:Vec3[]=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
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 axis of axes){charge(16);const len=Math.sqrt(dot(axis,axis));if(len<1e-10)continue;const n={x:axis.x/len,y:axis.y/len,z:axis.z/len};let lo=Infinity,hi=-Infinity;
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 {let t=0;for(let i=0;i<32;i++){charge(24);const v=distance(prism,add(a.min,d,t),add(a.max,d,t)),len=Math.sqrt(dot(v,v)),gap=len-a.r;if(i===0&&gap< -1e-7)break;if(len===0)break;const n={x:v.x/len,y:v.y/len,z:v.z/len},closing=-dot(n,d);if(closing<=1e-9)break;if(gap<=1e-7){hit={t,nx:n.x,ny:n.y,nz:n.z};break;}t+=gap/closing;if(t>1)break;if(i===31)hit={t,nx:n.x,ny:n.y,nz:n.z};}}
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=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
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
- ray(from: Point, direction: Point, max: number): (Hit & { readonly dist: number }) | undefined;
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 Set<string>();
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 Set();
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 && pressed.has(name); input[name] = on; values.push(on ? 1 : 0); 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); }
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 Set();
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 Set(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
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 Set<string>();
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.add(name);
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 Set(t === f.k + 1 ? late : []);
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.add(name);
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.has(name);
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.has(name) : coerce(fd, sample[name] ?? fd.init, dims);
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] === true || s[name] === 1) && !pressed.has(name)) changed = true; }
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] === true || s[name] === 1)) pressed.add(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');