@homie-rocks/studio 0.38.0 → 0.40.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 +57 -1
- package/bin/homie-studio.mjs +13 -1
- package/lib/build.mjs +4 -0
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev.mjs +12 -1
- package/lib/mcp-tools.mjs +10 -0
- package/lib/port-check.mjs +33 -7
- package/lib/rules-build.mjs +8 -1
- package/lib/rules-check-worker.mjs +3 -3
- package/lib/rules-check.mjs +3 -1
- package/lib/scaffold.mjs +19 -6
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +82 -0
- package/lib/tools-build.mjs +55 -0
- package/lib/tools-cli.mjs +55 -0
- package/lib/worker-build.mjs +35 -0
- package/netplay/NETPLAY.md +74 -2
- package/package.json +17 -6
- package/rules/collision.ts +115 -0
- package/rules/core.ts +65 -25
- package/rules/host.ts +5 -1
- package/rules/math.ts +15 -5
- package/rules/rules.ts +33 -10
- package/rules/types.ts +2 -2
- package/rules/view.ts +126 -32
- package/starters/ember-vale/game.json +16 -4
- package/starters/ember-vale/map/main.json +66 -0
- package/starters/ember-vale/src/move.ts +31 -0
- package/starters/ember-vale/src/rules.ts +244 -0
- package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
- package/starters/ember-vale/tunables.json +100 -11
- package/starters/gem-rush/src/move.ts +9 -1
- package/starters/gem-rush/src/view.ts +2 -1
- package/starters/gem-rush-3d/game.json +23 -5
- package/starters/gem-rush-3d/map/main.json +153 -0
- package/starters/gem-rush-3d/src/move.ts +37 -0
- package/starters/gem-rush-3d/src/rules.ts +133 -0
- package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
- package/starters/gem-rush-3d/tunables.json +98 -11
- package/starters/hero-rush-3d/game.json +23 -5
- package/starters/hero-rush-3d/map/main.json +153 -0
- package/starters/hero-rush-3d/src/move.ts +49 -0
- package/starters/hero-rush-3d/src/rules.ts +200 -0
- package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
- package/starters/hero-rush-3d/tunables.json +260 -30
- package/tools/TOOLS.md +113 -0
- package/tools/tools.ts +28 -0
- package/worker/app-records.mjs +8 -0
- package/worker/index.mjs +26 -0
- package/worker/mcp-room.mjs +61 -0
- package/worker/mcp-store.mjs +45 -0
- package/worker/mcp-tools.mjs +105 -0
- package/worker/mcp.mjs +159 -0
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +2 -1
- package/worker/purchase-mcp.mjs +3 -3
- package/worker/tool-events.mjs +14 -0
- package/worker/tool-identity.mjs +4 -0
- package/worker/tool-webhook.mjs +28 -0
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {existsSync,mkdirSync,writeFileSync,readFileSync,chmodSync} from 'node:fs';
|
|
2
|
+
import {join,dirname} from 'node:path';
|
|
3
|
+
import {createServer} from 'node:http';
|
|
4
|
+
import {randomUUID} from 'node:crypto';
|
|
5
|
+
import {spawn} from 'node:child_process';
|
|
6
|
+
export function newTool(root,name,{app=null}={}) {
|
|
7
|
+
if(!/^[a-z][a-z0-9-]{0,39}$/.test(name??'') || app!==null&&!/^[a-z][a-z0-9-]{0,39}$/.test(app))throw new Error('name uses lowercase letters, numbers and hyphens');
|
|
8
|
+
if(app&&!existsSync(join(root,'apps',app,'app.json')))throw new Error('unknown app');
|
|
9
|
+
const file=join(root,...(app?['apps',app,'tools']:['tools']),name+'.ts');if(existsSync(file))throw new Error('tool already exists');
|
|
10
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,`import { defineTool } from '@homie-rocks/studio/tools';\n\nexport default defineTool<{ message: string }>({\n name: '${name.replaceAll('-','_')}',\n description: 'Describe what this tool does and when to use it.',\n audience: ${app?JSON.stringify({app,role:'staff'}):"'owner'"},\n inputSchema: { type: 'object', properties: { message: { type: 'string', maxLength: 200 } }, required: ['message'], additionalProperties: false },\n async handler({ message }, context) {\n return { message, by: context.caller.id };\n },\n});\n`);
|
|
11
|
+
return {ok:true,command:'tool new',file,next:'Edit the definition, then homie-studio dev. Call it with homie-studio tool call '+name.replaceAll('-','_')+' --input \'{"message":"Hello"}\' --url http://localhost:8787'};
|
|
12
|
+
}
|
|
13
|
+
/** The official SDK owns discovery, registration, PKCE, refresh and issuer checks. */
|
|
14
|
+
export async function connectToolClient(root,site,{publicOnly=false,open=defaultOpen,log=()=>{}}={}) {
|
|
15
|
+
const {Client,StreamableHTTPClientTransport,auth}=await import('mcp-studio-client');
|
|
16
|
+
const url=new URL('/mcp',site);if(url.protocol!=='https:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Use HTTPS, or localhost for local development');
|
|
17
|
+
const client=new Client({name:'Homie studio tool CLI',version:'1.0.0'});
|
|
18
|
+
if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url));return client;}
|
|
19
|
+
const file=join(root,'.studio','mcp',Buffer.from(url.origin).toString('base64url')+'.json');
|
|
20
|
+
let state={};try{state=JSON.parse(readFileSync(file,'utf8'));}catch{}
|
|
21
|
+
// Credentials without an issuer predate the SDK's OAuth binding fix; reconnect safely.
|
|
22
|
+
if((state.tokens&&!state.tokens.issuer)||(state.client&&!state.client.issuer))state={};
|
|
23
|
+
const save=()=>{mkdirSync(dirname(file),{recursive:true});writeFileSync(file,JSON.stringify(state),{mode:0o600});chmodSync(file,0o600);};
|
|
24
|
+
let accept;const returned=new Promise(resolve=>{accept=resolve;});
|
|
25
|
+
const csrf=randomUUID();
|
|
26
|
+
const server=createServer((req,res)=>{const back=new URL(req.url,'http://localhost');if(back.pathname!=='/callback'||back.searchParams.get('state')!==csrf){res.writeHead(400);res.end('Invalid callback');return;}res.end('Your AI is connected. You can close this tab.');accept(back);});
|
|
27
|
+
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
|
28
|
+
const redirect=`http://127.0.0.1:${server.address().port}/callback`;
|
|
29
|
+
const provider={redirectUrl:redirect,clientMetadata:{client_name:'Homie studio tool CLI',redirect_uris:[redirect],grant_types:['authorization_code','refresh_token'],response_types:['code'],token_endpoint_auth_method:'none'},state:()=>csrf,
|
|
30
|
+
clientInformation:()=>state.client,saveClientInformation:v=>{state.client=v;save();},tokens:()=>state.tokens,saveTokens:v=>{state.tokens=v;save();},
|
|
31
|
+
codeVerifier:()=>state.verifier,saveCodeVerifier:v=>{state.verifier=v;save();},
|
|
32
|
+
discoveryState:()=>state.discovery,saveDiscoveryState:v=>{state.discovery=v;save();},
|
|
33
|
+
invalidateCredentials:scope=>{if(scope==='all')state={};else {delete state[scope==='tokens'?'tokens':scope];if(scope==='tokens')delete state.client;}save();},
|
|
34
|
+
redirectToAuthorization:async u=>{log('Approve this connection in your browser.');await open(u.href);},
|
|
35
|
+
};
|
|
36
|
+
try{
|
|
37
|
+
// A new loopback port needs a matching registered redirect; refresh still uses the prior client.
|
|
38
|
+
if(!state.tokens){delete state.client;delete state.discovery;}
|
|
39
|
+
const result=await auth(provider,{serverUrl:url,scope:'studio offline_access'});
|
|
40
|
+
if(result==='REDIRECT'){
|
|
41
|
+
let timer;const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Browser approval timed out')),300000);});
|
|
42
|
+
const back=await Promise.race([returned,timeout]).finally(()=>clearTimeout(timer));
|
|
43
|
+
if(back.searchParams.has('error'))throw new Error('Connection declined');
|
|
44
|
+
await auth(provider,{serverUrl:url,authorizationCode:back.searchParams.get('code'),iss:back.searchParams.get('iss')??undefined});
|
|
45
|
+
}
|
|
46
|
+
await client.connect(new StreamableHTTPClientTransport(url,{authProvider:provider}));return client;
|
|
47
|
+
}finally{server.close();}
|
|
48
|
+
}
|
|
49
|
+
function defaultOpen(url){const cmd=process.platform==='darwin'?'open':process.platform==='win32'?'explorer':'xdg-open';const child=spawn(cmd,[url],{stdio:'ignore',detached:true});child.unref();}
|
|
50
|
+
export async function toolCommand(root,sub,positional,flags,{log=()=>{}}={}) {
|
|
51
|
+
if(sub==='new')return newTool(root,positional[2],{app:flags.get('app')??null});
|
|
52
|
+
if(!['call','list'].includes(sub))throw new Error('Use tool new <name>, tool list, or tool call <name> --input <JSON>');
|
|
53
|
+
const client=await connectToolClient(root,flags.get('url')??'http://localhost:8787',{publicOnly:flags.has('public'),log});
|
|
54
|
+
try{const result=sub==='list'?await client.listTools():await client.callTool({name:positional[2],arguments:JSON.parse(flags.get('input')??'{}')});return {ok:result.isError!==true,command:'tool '+sub,result,...(result.isError?{why:'The tool refused or failed; see its result.'}:{})};}finally{await client.close();}
|
|
55
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {existsSync,mkdirSync,readFileSync,writeFileSync,rmSync} from 'node:fs';
|
|
2
|
+
import {join,relative,dirname} from 'node:path';
|
|
3
|
+
import {readConfig} from './routes.mjs';
|
|
4
|
+
import {paidReleases} from './parts-upload.mjs';
|
|
5
|
+
import {configPath} from './studio.mjs';
|
|
6
|
+
/** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
|
|
7
|
+
export async function buildWorker(root,esbuild) {
|
|
8
|
+
const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
|
|
9
|
+
const out=join(root,'site/src/runtime');
|
|
10
|
+
const alias={...(readConfig(root)?.alias??{})};
|
|
11
|
+
if(paidReleases(root).length)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
|
|
12
|
+
const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
|
|
13
|
+
rmSync(out,{recursive:true,force:true});
|
|
14
|
+
mkdirSync(out,{recursive:true});
|
|
15
|
+
for(const file of result.outputFiles)writeFileSync(file.path,file.contents);
|
|
16
|
+
const config=configPath(root);
|
|
17
|
+
if(existsSync(config)){
|
|
18
|
+
const original=readFileSync(config,'utf8');
|
|
19
|
+
const json=JSON.parse(original.replace(/^\s*\/\/.*$/gm,''));
|
|
20
|
+
const before=JSON.stringify(json);
|
|
21
|
+
json.main=relative(dirname(config),join(out,'worker.js')).split('\\').join('/');
|
|
22
|
+
json.no_bundle=true;json.find_additional_modules=true;
|
|
23
|
+
json.rules=[...(json.rules??[]).filter(r=>!r.homieMcp),{type:'ESModule',globs:['**/*.js'],fallthrough:true}];
|
|
24
|
+
// No duplicate rule on rebuild.
|
|
25
|
+
json.rules=json.rules.filter((r,i,all)=>all.findIndex(x=>JSON.stringify(x)===JSON.stringify(r))===i);
|
|
26
|
+
json.compatibility_flags=[...new Set([...(json.compatibility_flags??[]),'nodejs_compat'])];
|
|
27
|
+
if(JSON.stringify(json)!==before) {
|
|
28
|
+
const comments=original.match(/^(?:\s*\/\/[^\n]*\n)*/)?.[0]??'';
|
|
29
|
+
writeFileSync(config,comments+JSON.stringify(json,null,2)+'\n');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,path),bytes:value.bytes,imports:value.imports}));
|
|
33
|
+
writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
|
|
34
|
+
return sizes;
|
|
35
|
+
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -2163,8 +2163,13 @@ Small errors fade as a visual offset with a nominal 100 ms blend. Overlapping sm
|
|
|
2163
2163
|
queue their individual fades, so their accumulated offset is not erased all at once. A disagreement
|
|
2164
2164
|
beyond three ticks of travel plays the corrected path from the snapshot at 1.25 times speed,
|
|
2165
2165
|
showing the hold and slide of a knock. Offset fades are limited by progress along the drawn
|
|
2166
|
-
path and
|
|
2167
|
-
|
|
2166
|
+
path and by normal movement speed, so a late-input correction cannot erase more travel than
|
|
2167
|
+
the player makes or amplify a fast knockback. When movement stops after catch-up, the remaining
|
|
2168
|
+
fade uses the configured blend time with the same speed limit; large offsets can take longer.
|
|
2169
|
+
Catch-up preserves the drawn pose when a snapshot replaces its history and uses the next
|
|
2170
|
+
movement preview when its next whole-tick history entry is not yet available.
|
|
2171
|
+
The fade's speed limit has a 1 m/s floor so even a body with zero declared running speed can
|
|
2172
|
+
finish a vertical correction. An effect on the body waits for its drawn tick. Placements, epoch changes and clock rebases
|
|
2168
2173
|
jump deliberately. Other bodies keep the helper's adaptive interpolation, with one send period
|
|
2169
2174
|
as the default minimum delay. The `room.predict` settings customize these defaults.
|
|
2170
2175
|
|
|
@@ -2390,3 +2395,70 @@ A missing offline module leaves the connection helper alive and says the game ne
|
|
|
2390
2395
|
connection. A failed offline runtime stops locally while reconnection continues. An online
|
|
2391
2396
|
runtime failure ends the room. Snapshots never grow above 16,384 bytes, including above
|
|
2392
2397
|
sixteen seats.
|
|
2398
|
+
|
|
2399
|
+
### 29.8 Three dimensions, level data and animation
|
|
2400
|
+
|
|
2401
|
+
All five shipped starters are rules games with `room.host: 'server'`. Existing games
|
|
2402
|
+
made with `port` keep their version 1 callbacks and browser hosting. Ember Vale keeps
|
|
2403
|
+
its cloud character saves; the room save holds the shared fight and companion state.
|
|
2404
|
+
|
|
2405
|
+
A rules declaration with `space: { dims: 3 }` uses metres and seconds, with **z up**.
|
|
2406
|
+
`pos` is the body's feet, `vel` has three components, `heading` is a unit direction,
|
|
2407
|
+
and `grounded` says a supporting collision has an upward normal greater than 0.5.
|
|
2408
|
+
The guarded `move` gets these values and declared `motion`, input, public tuning,
|
|
2409
|
+
`ctx.dt`, deterministic maths and `ctx.map.sweep`. The host and prediction run the
|
|
2410
|
+
same function. Gravity, jump impulse, acceleration and facing are game decisions,
|
|
2411
|
+
not a second physics loop in the view. Camera-relative controls are converted to
|
|
2412
|
+
world-space input in the view before calling `room.input`.
|
|
2413
|
+
|
|
2414
|
+
Bodies may be upright capsules (`radius`, full `height`), spheres (`radius`), or
|
|
2415
|
+
axis-aligned boxes (`radius` is horizontal half-width, `height` is full height).
|
|
2416
|
+
Sweeps are continuous: a fast body cannot skip a thin solid. `ctx.map.sweep` moves
|
|
2417
|
+
against the static map and returns its contact normal; a second sweep along that
|
|
2418
|
+
normal's tangent slides. `world.sweep` also considers entity bodies and accepts
|
|
2419
|
+
`ignore` ids. `world.ray`, `near`, `inBox` and areas consider height in a 3D game.
|
|
2420
|
+
A placement is a discontinuity, with a new spawn revision, rather than a sweep.
|
|
2421
|
+
|
|
2422
|
+
The game's declared map directory contains `main.json`. Static data is compiled
|
|
2423
|
+
into both the host and view and included in the state compatibility hash. It is
|
|
2424
|
+
not copied into each room save. Changing geometry makes old room state incompatible,
|
|
2425
|
+
just as changing a field does. Named `spots` provide spawn points and other fixed
|
|
2426
|
+
positions. A map has finite, increasing `bounds.min`/`max` vectors and at most 4,096
|
|
2427
|
+
solid shapes across these lists:
|
|
2428
|
+
|
|
2429
|
+
| List | Declaration | Meaning in 3D |
|
|
2430
|
+
|---|---|---|
|
|
2431
|
+
| `boxes` | `{ min: [x,y,z], max: [x,y,z] }` | Axis-aligned solid |
|
|
2432
|
+
| `circles` | `{ at: [x,y,z], r }` | Vertical column through the map, for arena obstacles |
|
|
2433
|
+
| `spheres` | `{ at: [x,y,z], r }` | Centre and radius |
|
|
2434
|
+
| `capsules` | `{ at: [x,y,z], r, height }` | Feet, radius, full upright height (at least twice the radius) |
|
|
2435
|
+
| `heightTiles` | `{ at: [x,y,z], size: [width,depth], heights: [h00,h10,h01,h11] }` | Relative corner heights, split on the 00–11 diagonal |
|
|
2436
|
+
|
|
2437
|
+
Height tiles support the foot point, with open edges and no underside. They are
|
|
2438
|
+
terrain, not a triangle-mesh collider or a solid platform; use boxes for platforms
|
|
2439
|
+
and ceilings. Every tile, solid and bounded convex-cast iteration is charged to
|
|
2440
|
+
the handler budget. The build check uses the same map and body shape as the host
|
|
2441
|
+
and predictor, measures full-room steps, and restores the complete declared state.
|
|
2442
|
+
The two meadow starters' playable ground is flat; their rolling scenery is visual.
|
|
2443
|
+
|
|
2444
|
+
The view interpolates remote three-axis position and velocity, and takes the shortest
|
|
2445
|
+
arc between unit headings, including half turns. Discrete animation fields stay at
|
|
2446
|
+
the earlier snapshot until their tick. Takeoff becomes airborne as the interpolated
|
|
2447
|
+
pose rises; landing waits for contact. Own-body reconciliation eases all three axes
|
|
2448
|
+
and heading. A renderer reads `room.me`/`room.each`, maps rules `(x,y,z)` to its
|
|
2449
|
+
renderer axes, and feeds those poses and grounded/vertical-speed values to animation.
|
|
2450
|
+
Effects such as strikes and hits still come from the authoritative room.
|
|
2451
|
+
|
|
2452
|
+
Rules can read `world.kids` and `world.levelSet` (an explicitly set party dial).
|
|
2453
|
+
`world.level` is the party's skill dial; `world.guideLevel` is the server's own level
|
|
2454
|
+
for guides. `world.guideSeats` is the frozen list of guide seats, assigned by the
|
|
2455
|
+
host from admitted agents and reserved seats; party companions use `world.level`.
|
|
2456
|
+
These policy values survive saves without changing older saves' defaults.
|
|
2457
|
+
|
|
2458
|
+
A view may opt into `openRoom({ fallback: 'solo' })` when its room allows offline
|
|
2459
|
+
play. After four active seconds without a body in a connected room, it plays a
|
|
2460
|
+
private rules round while the original connection keeps waiting for a seat. The
|
|
2461
|
+
private round imports no room save, match score or simulation state into the online
|
|
2462
|
+
room. When a body is assigned, the private round closes and the view joins the
|
|
2463
|
+
online match. Ember Vale retains its separate cloud hero: experience earned in
|
|
2464
|
+
solo play still belongs to that hero, whose level is sent through the usual command.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -84,7 +84,11 @@
|
|
|
84
84
|
"types": "./apps/apps.ts",
|
|
85
85
|
"default": "./apps/apps.ts"
|
|
86
86
|
},
|
|
87
|
-
"./worker/selling": "./worker/selling/index.mjs"
|
|
87
|
+
"./worker/selling": "./worker/selling/index.mjs",
|
|
88
|
+
"./tools": {
|
|
89
|
+
"types": "./tools/tools.ts",
|
|
90
|
+
"default": "./tools/tools.ts"
|
|
91
|
+
}
|
|
88
92
|
},
|
|
89
93
|
"files": [
|
|
90
94
|
"bin/",
|
|
@@ -111,7 +115,8 @@
|
|
|
111
115
|
"NOTICE",
|
|
112
116
|
"links/",
|
|
113
117
|
"apps/",
|
|
114
|
-
"app-starters/"
|
|
118
|
+
"app-starters/",
|
|
119
|
+
"tools/"
|
|
115
120
|
],
|
|
116
121
|
"engines": {
|
|
117
122
|
"node": ">=22"
|
|
@@ -122,17 +127,22 @@
|
|
|
122
127
|
"@babel/parser": "8.0.7",
|
|
123
128
|
"@babel/traverse": "8.0.7",
|
|
124
129
|
"@babel/types": "8.0.6",
|
|
130
|
+
"@cloudflare/workers-oauth-provider": "1.2.3",
|
|
125
131
|
"@coinbase/x402": "2.1.0",
|
|
126
132
|
"@gltf-transform/core": "4.5.1",
|
|
127
133
|
"@gltf-transform/extensions": "4.5.1",
|
|
128
134
|
"@gltf-transform/functions": "4.5.1",
|
|
129
135
|
"@jridgewell/trace-mapping": "0.3.31",
|
|
130
|
-
"@modelcontextprotocol/sdk": "1.
|
|
136
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
137
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
131
138
|
"@x402/core": "2.28.0",
|
|
132
139
|
"@x402/evm": "2.28.0",
|
|
140
|
+
"agents": "0.28.0",
|
|
133
141
|
"esbuild": "0.28.1",
|
|
134
142
|
"gltf-validator": "2.0.0-dev.3.10",
|
|
135
143
|
"jose": "6.2.12",
|
|
144
|
+
"mcp-purchase-sdk": "npm:@modelcontextprotocol/sdk@1.32.1",
|
|
145
|
+
"mcp-studio-client": "npm:@modelcontextprotocol/client@2.3.1",
|
|
136
146
|
"meshoptimizer": "1.3.0",
|
|
137
147
|
"mppx": "0.13.2",
|
|
138
148
|
"puppeteer-core": "25.8.0",
|
|
@@ -140,10 +150,11 @@
|
|
|
140
150
|
"spdx-expression-parse": "5.0.0",
|
|
141
151
|
"stripe": "23.0.0",
|
|
142
152
|
"three": "0.185.1",
|
|
153
|
+
"typescript": "5.9.3",
|
|
143
154
|
"viem": "2.57.4",
|
|
144
|
-
"
|
|
155
|
+
"zod": "4.3.6"
|
|
145
156
|
},
|
|
146
157
|
"scripts": {
|
|
147
|
-
"test": "node --test test/*.test.mjs"
|
|
158
|
+
"test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs"
|
|
148
159
|
}
|
|
149
160
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
|
|
2
|
+
* Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
|
|
3
|
+
*/
|
|
4
|
+
import { charge } from './guard.ts';
|
|
5
|
+
import type { Vec3, MapBox, MapCircle, MapHeightTile } from './rules.ts';
|
|
6
|
+
|
|
7
|
+
export interface BodyShape { shape: string; radius: number; height: number }
|
|
8
|
+
export interface Solid { min: Vec3; max: Vec3; r: number }
|
|
9
|
+
export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
|
|
10
|
+
export interface Map3 {
|
|
11
|
+
bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
|
|
12
|
+
heightTiles?: readonly MapHeightTile[];
|
|
13
|
+
spheres?: readonly MapCircle[];
|
|
14
|
+
capsules?: readonly (MapCircle & { height: number })[];
|
|
15
|
+
}
|
|
16
|
+
const EPS = 1e-7;
|
|
17
|
+
const point = (x: number, y: number, z: number): Vec3 => ({ x, y, z });
|
|
18
|
+
export function solidAt(at: Vec3, body: BodyShape): Solid {
|
|
19
|
+
const r = body.radius, height = body.height || 2 * r;
|
|
20
|
+
if (body.shape === 'box') return { min: point(at.x - r, at.y - r, at.z), max: point(at.x + r, at.y + r, at.z + height), r: 0 };
|
|
21
|
+
return { min: point(at.x, at.y, at.z + r), max: point(at.x, at.y, at.z + (body.shape === 'sphere' ? r : height - r)), r };
|
|
22
|
+
}
|
|
23
|
+
/** Closest vector between the cores of two rounded boxes. At most one core has
|
|
24
|
+
* horizontal extent, except box/box, so this represents all supported pairs exactly.
|
|
25
|
+
*/
|
|
26
|
+
function separation(a: Solid, b: Solid, d: Vec3, t: number): Vec3 {
|
|
27
|
+
const gap = (lo: number, hi: number, min: number, max: number): number => lo > max ? lo - max : hi < min ? hi - min : 0;
|
|
28
|
+
return point(gap(a.min.x + d.x * t, a.max.x + d.x * t, b.min.x, b.max.x), gap(a.min.y + d.y * t, a.max.y + d.y * t, b.min.y, b.max.y), gap(a.min.z + d.z * t, a.max.z + d.z * t, b.min.z, b.max.z));
|
|
29
|
+
}
|
|
30
|
+
/** Continuous convex cast: each separating plane gives a lower bound on impact.
|
|
31
|
+
* Every iteration is charged, and a fixed cap bounds grazing contacts as well.
|
|
32
|
+
*/
|
|
33
|
+
export function castSolid(a: Solid, d: Vec3, b: Solid): Hit3 | null {
|
|
34
|
+
charge(30);
|
|
35
|
+
let t = 0;
|
|
36
|
+
const r = a.r + b.r;
|
|
37
|
+
let normal = point(0, 0, 0);
|
|
38
|
+
// Sharp boxes need the slab test: their closest vector vanishes at a face.
|
|
39
|
+
if (r === 0) {
|
|
40
|
+
let enter = 0, leave = 1;
|
|
41
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
42
|
+
const lo = b.min[axis] - a.max[axis], hi = b.max[axis] - a.min[axis], v = d[axis];
|
|
43
|
+
if (v === 0) { if (lo >= 0 || hi <= 0) return null; continue; }
|
|
44
|
+
const near = (v > 0 ? lo : hi) / v, far = (v > 0 ? hi : lo) / v;
|
|
45
|
+
if (near >= enter) { enter = near; normal = point(0, 0, 0); normal[axis] = v > 0 ? -1 : 1; }
|
|
46
|
+
leave = Math.min(leave, far);
|
|
47
|
+
if (enter > leave) return null;
|
|
48
|
+
}
|
|
49
|
+
return enter >= 0 && enter <= 1 && normal.x + normal.y + normal.z !== 0 ? { t: enter, nx: normal.x, ny: normal.y, nz: normal.z } : null;
|
|
50
|
+
}
|
|
51
|
+
for (let i = 0; i < 32; i++) {
|
|
52
|
+
charge(24);
|
|
53
|
+
const s = separation(a, b, d, t), length = Math.sqrt(s.x * s.x + s.y * s.y + s.z * s.z);
|
|
54
|
+
const distance = length - r;
|
|
55
|
+
// Let an initially overlapping body escape. Placements are not collision moves.
|
|
56
|
+
if (i === 0 && distance < -EPS) return null;
|
|
57
|
+
if (length > 0) normal = point(s.x / length, s.y / length, s.z / length);
|
|
58
|
+
const approach = -(normal.x * d.x + normal.y * d.y + normal.z * d.z);
|
|
59
|
+
if (approach <= EPS) return null;
|
|
60
|
+
if (distance <= EPS) return { t, nx: normal.x, ny: normal.y, nz: normal.z };
|
|
61
|
+
t += distance / approach;
|
|
62
|
+
if (t > 1) return null;
|
|
63
|
+
}
|
|
64
|
+
// At the iteration cap, stop conservatively on the separating plane.
|
|
65
|
+
return { t, nx: normal.x, ny: normal.y, nz: normal.z };
|
|
66
|
+
}
|
|
67
|
+
/** Height tiles support a body's foot point. Each tile is split along its
|
|
68
|
+
* 00–11 diagonal; unlike bilinear sampling each half has a constant slope and
|
|
69
|
+
* the sweep has an exact intersection. Borders are open, so a body can walk off.
|
|
70
|
+
*/
|
|
71
|
+
function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
|
|
72
|
+
charge(80);
|
|
73
|
+
const [h00, h10, h01, h11] = tile.heights, sx = tile.size.x, sy = tile.size.y;
|
|
74
|
+
const x = p.x - tile.at.x, y = p.y - tile.at.y;
|
|
75
|
+
let best: Hit3 | null = null;
|
|
76
|
+
for (const half of [0, 1]) {
|
|
77
|
+
const gx = half === 0 ? (h10 - h00) / sx : (h11 - h01) / sx;
|
|
78
|
+
const gy = half === 0 ? (h11 - h10) / sy : (h01 - h00) / sy;
|
|
79
|
+
const gap = p.z - tile.at.z - h00 - gx * x - gy * y;
|
|
80
|
+
const closing = gx * d.x + gy * d.y - d.z;
|
|
81
|
+
if (gap < -EPS || closing <= EPS) continue;
|
|
82
|
+
const t = Math.max(0, gap / closing);
|
|
83
|
+
if (t > 1 || best && t >= best.t) continue;
|
|
84
|
+
const u = (x + d.x * t) / sx, v = (y + d.y * t) / sy;
|
|
85
|
+
if (u < -EPS || u > 1 + EPS || v < -EPS || v > 1 + EPS || (half === 0 ? v > u + EPS : u > v + EPS)) continue;
|
|
86
|
+
const length = Math.sqrt(gx * gx + gy * gy + 1);
|
|
87
|
+
best = { t, nx: -gx / length, ny: -gy / length, nz: 1 / length };
|
|
88
|
+
}
|
|
89
|
+
return best;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | null {
|
|
93
|
+
charge(24);
|
|
94
|
+
const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
|
|
95
|
+
let best: Hit3 | null = null;
|
|
96
|
+
const take = (h: Hit3 | null): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = h; };
|
|
97
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
98
|
+
const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
|
|
99
|
+
const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
|
|
100
|
+
if (d[axis] === 0) continue;
|
|
101
|
+
const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
|
|
102
|
+
take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
|
|
103
|
+
}
|
|
104
|
+
for (const tile of map.heightTiles ?? []) take(castHeightTile(tile, p, d));
|
|
105
|
+
for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }));
|
|
106
|
+
// Legacy map circles are vertical columns in a 3D map.
|
|
107
|
+
for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }));
|
|
108
|
+
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }));
|
|
109
|
+
for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })));
|
|
110
|
+
return best;
|
|
111
|
+
}
|
|
112
|
+
export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {
|
|
113
|
+
const h = castMap3(map, p, point(0, 0, -0.002), body);
|
|
114
|
+
return Boolean(h && h.nz > 0.5);
|
|
115
|
+
}
|
package/rules/core.ts
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* that is held to its shape, and paid for, before the handler's budget is closed.
|
|
39
39
|
* =============================================================================
|
|
40
40
|
*/
|
|
41
|
+
import { castMap3, castSolid, restsOnMap, solidAt, type BodyShape, type Hit3 } from './collision.ts';
|
|
41
42
|
import { argsWhy, type Vocabulary } from '../agents/agents.ts';
|
|
42
43
|
import { BudgetError, G, brand, charge, deepFreeze, plainData } from './guard.ts';
|
|
43
44
|
import { SKIN, castMap, exact, math, rayCircle, sweepMap } from './math.ts';
|
|
@@ -88,7 +89,7 @@ export type CoreOut =
|
|
|
88
89
|
| { t: 'shared' }
|
|
89
90
|
| { t: 'epoch'; epoch: number }
|
|
90
91
|
| { t: 'fail'; why: 'budget'; kind: string; handler: string };
|
|
91
|
-
export interface CorePolicy { reserved?: number; bots: 'fill' | 'off'; level: number; levelMax: number }
|
|
92
|
+
export interface CorePolicy { kids?: boolean; levelSet?: boolean; guideLevel?: number; guideSeats?: readonly number[]; reserved?: number; bots: 'fill' | 'off'; level: number; levelMax: number }
|
|
92
93
|
export interface Body { seat: number; id: string; kind: string; driver: Driver; owner: string; away: boolean; score: number; r: number }
|
|
93
94
|
|
|
94
95
|
interface Ent {
|
|
@@ -178,7 +179,6 @@ export interface Core {
|
|
|
178
179
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
179
180
|
*/
|
|
180
181
|
export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
181
|
-
if (c.dims !== 2) throw new Error('this release runs rules with space.dims: 2; bodies with height (dims: 3) arrive in a later one');
|
|
182
182
|
const dims = c.dims;
|
|
183
183
|
const tickHz = c.settings.tickHz;
|
|
184
184
|
const dt = 1 / tickHz;
|
|
@@ -212,11 +212,13 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
212
212
|
let vocabulary: Vocabulary | null = null;
|
|
213
213
|
const restoredGoals = new Map<number, unknown>();
|
|
214
214
|
const guideViews = new Map<number, Record<string, unknown>>();
|
|
215
|
+
const EMPTY_GUIDE_SEATS: readonly number[] = Object.freeze([]);
|
|
215
216
|
let policy: CorePolicy = { reserved: 0, bots: 'fill', level: 3, levelMax: 5 };
|
|
216
217
|
function normalizeCorePolicy(p: Partial<CorePolicy>): CorePolicy {
|
|
217
218
|
const levelMax = Math.max(1, Math.min(5, Math.floor(num(own(p, 'levelMax') ?? policy.levelMax)) || 5));
|
|
219
|
+
const guideSeats = Object.freeze([...new Set(p.guideSeats ?? policy.guideSeats ?? [])].filter(seat => Number.isInteger(seat) && seat >= 0 && seat < c.seats).sort((a, b) => a - b));
|
|
218
220
|
// The keys in the order the room starts with them: a save is compared byte for byte with the save of the room rebuilt from it.
|
|
219
|
-
return { reserved: Math.max(0, Math.min(c.seats - 1, Math.floor(num(own(p, 'reserved') ?? policy.reserved)) || 0)), bots: p.bots === 'off' ? 'off' : p.bots === 'fill' ? 'fill' : policy.bots, level: Math.max(1, Math.min(levelMax, Math.floor(num(own(p, 'level') ?? policy.level)) || 3)), levelMax };
|
|
221
|
+
return { reserved: Math.max(0, Math.min(c.seats - 1, Math.floor(num(own(p, 'reserved') ?? policy.reserved)) || 0)), bots: p.bots === 'off' ? 'off' : p.bots === 'fill' ? 'fill' : policy.bots, level: Math.max(1, Math.min(levelMax, Math.floor(num(own(p, 'level') ?? policy.level)) || 3)), levelMax, ...((p.kids ?? policy.kids) ? { kids: true } : {}), ...((p.levelSet ?? policy.levelSet) ? { levelSet: true } : {}), ...((p.guideLevel ?? policy.guideLevel) !== undefined ? { guideLevel: Math.max(1, Math.min(levelMax, Math.floor(num(p.guideLevel ?? policy.guideLevel)) || 3)) } : {}), ...(guideSeats.length ? { guideSeats } : {}) };
|
|
220
222
|
}
|
|
221
223
|
let shared: Record<string, unknown> = initFields(c.shared, dims);
|
|
222
224
|
let sharedRO: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
@@ -342,7 +344,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
342
344
|
naming(kind.name, 'fields'); const f = coerceFields(kind.fields, init.fields, dims);
|
|
343
345
|
naming(kind.name, 'motion'); const m = coerceFields(kind.motion, init.motion, dims); naming();
|
|
344
346
|
const e: Ent = {
|
|
345
|
-
id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded:
|
|
347
|
+
id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: dims === 2 || restsOnMap(c.map, clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), kind.body ?? { shape: 'sphere', radius: 0, height: 0 }),
|
|
346
348
|
r: 0, born: tick, arrived: false, why, dead: false,
|
|
347
349
|
f, m,
|
|
348
350
|
seat: player ? player.seat : -1, owner: player ? player.owner : '', driver: player ? player.driver : 'bot', away: false, goal: null,
|
|
@@ -351,10 +353,11 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
351
353
|
makeSelf(e);
|
|
352
354
|
return e;
|
|
353
355
|
}
|
|
354
|
-
function clampIn(p: Vec3, r: number): Vec3 {
|
|
356
|
+
function clampIn(p: Vec3, r: number, height = 0): Vec3 {
|
|
355
357
|
const b = c.map.bounds;
|
|
356
358
|
const x = Math.max(b.min.x + r, Math.min(b.max.x - r, p.x)); const y = Math.max(b.min.y + r, Math.min(b.max.y - r, p.y));
|
|
357
|
-
|
|
359
|
+
const z = dims === 3 ? Math.max(b.min.z, Math.min(b.max.z - height, p.z)) : 0;
|
|
360
|
+
return x === p.x && y === p.y && z === p.z ? p : V({ x, y, z });
|
|
358
361
|
}
|
|
359
362
|
|
|
360
363
|
/* ---------------------------------------------------------------- running a handler under the budget */
|
|
@@ -475,9 +478,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
475
478
|
return sharedRO;
|
|
476
479
|
};
|
|
477
480
|
|
|
481
|
+
function cast3(p: Vec3, d: Vec3, shape: BodyShape, ignored: Set<string>): Hit3 | null {
|
|
482
|
+
let best = castMap3(c.map, p, d, shape);
|
|
483
|
+
const solid = solidAt(p, shape);
|
|
484
|
+
for (const other of ents.values()) {
|
|
485
|
+
if (!other.kind.body || ignored.has(other.id)) continue;
|
|
486
|
+
const hit = castSolid(solid, d, solidAt(other.pos, other.kind.body));
|
|
487
|
+
if (hit && (!best || hit.t < best.t)) best = { ...hit, id: other.id };
|
|
488
|
+
}
|
|
489
|
+
return best;
|
|
490
|
+
}
|
|
478
491
|
const world = brand({} as Record<string, unknown>);
|
|
479
492
|
const getters: Record<string, () => unknown> = {
|
|
480
|
-
tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax,
|
|
493
|
+
tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax, guideLevel: () => policy.guideLevel ?? policy.level, guideSeats: () => policy.guideSeats ?? EMPTY_GUIDE_SEATS, kids: () => Boolean(policy.kids), levelSet: () => Boolean(policy.levelSet),
|
|
481
494
|
stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
|
|
482
495
|
};
|
|
483
496
|
for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
|
|
@@ -558,9 +571,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
558
571
|
charge(10 + 12 * COPY);
|
|
559
572
|
const e = mine(self, 'world.place');
|
|
560
573
|
const vel = own(o, 'vel'); const heading = own(o, 'heading');
|
|
561
|
-
e.pos = clampIn(V(at), e.kind.body?.radius ?? 0);
|
|
574
|
+
e.pos = clampIn(V(at), e.kind.body?.radius ?? 0, e.kind.body ? e.kind.body.height || 2 * e.kind.body.radius : 0);
|
|
562
575
|
e.vel = vel !== undefined ? V(vel) : ZERO;
|
|
563
576
|
if (heading !== undefined) e.heading = dir(heading, dims);
|
|
577
|
+
e.grounded = dims === 2 || restsOnMap(c.map, e.pos, e.kind.body ?? { shape: 'sphere', radius: 0, height: 0 });
|
|
564
578
|
e.r = (e.r + 1) & 0xffff;
|
|
565
579
|
e.allow = 0;
|
|
566
580
|
},
|
|
@@ -572,7 +586,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
572
586
|
for (const e of ents.values()) {
|
|
573
587
|
if (k && e.kind !== k) continue;
|
|
574
588
|
const dx = e.pos.x - p.x; const dy = e.pos.y - p.y;
|
|
575
|
-
const
|
|
589
|
+
const dz = dims === 3 ? e.pos.z - p.z : 0;
|
|
590
|
+
const d2 = dx * dx + dy * dy + dz * dz;
|
|
576
591
|
if (d2 <= R * R) { found.push({ d: d2, e }); cost += viewCost.get(e.kind) as number; }
|
|
577
592
|
}
|
|
578
593
|
// Sorting them and handing each out is charged before either is done.
|
|
@@ -583,10 +598,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
583
598
|
inBox: (box: unknown, kind?: unknown): readonly unknown[] => {
|
|
584
599
|
charge(20 + 2 * ents.size);
|
|
585
600
|
const min = V(own(box, 'min')); const max = V(own(box, 'max')); const k = kindFilter(kind);
|
|
586
|
-
if (max.x - min.x > 2 * REACH_M || max.y - min.y > 2 * REACH_M) throw new Error(`world.inBox reaches ${REACH_M} m from its centre at most`);
|
|
601
|
+
if (max.x - min.x > 2 * REACH_M || max.y - min.y > 2 * REACH_M || dims === 3 && max.z - min.z > 2 * REACH_M) throw new Error(`world.inBox reaches ${REACH_M} m from its centre at most`);
|
|
587
602
|
const found: Ent[] = [];
|
|
588
603
|
let cost = 0;
|
|
589
|
-
for (const e of ents.values()) if ((!k || e.kind === k) && e.pos.x >= min.x && e.pos.x <= max.x && e.pos.y >= min.y && e.pos.y <= max.y) { found.push(e); cost += viewCost.get(e.kind) as number; }
|
|
604
|
+
for (const e of ents.values()) if ((!k || e.kind === k) && e.pos.x >= min.x && e.pos.x <= max.x && e.pos.y >= min.y && e.pos.y <= max.y && (dims === 2 || e.pos.z >= min.z && e.pos.z <= max.z)) { found.push(e); cost += viewCost.get(e.kind) as number; }
|
|
590
605
|
charge(cost);
|
|
591
606
|
return Object.freeze(found.map(viewOf));
|
|
592
607
|
},
|
|
@@ -594,6 +609,11 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
594
609
|
// Charged before the cast, for every shape it may test: the map's, and every entity in the room.
|
|
595
610
|
charge(20 + 4 * (mapShapes + ents.size));
|
|
596
611
|
const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
|
|
612
|
+
if (dims === 3) {
|
|
613
|
+
const delta = { x: d.x * far, y: d.y * far, z: d.z * far };
|
|
614
|
+
const hit = cast3(p, delta, { shape: 'sphere', radius: 0, height: 0 }, new Set(cx.ent ? [cx.ent.id] : []));
|
|
615
|
+
return hit ? Object.freeze({ ...(hit.id ? { entity: hit.id } : {}), at: V({ x: p.x + delta.x * hit.t, y: p.y + delta.y * hit.t, z: p.z + delta.z * hit.t }), normal: V({ x: hit.nx, y: hit.ny, z: hit.nz }), dist: far * hit.t }) : undefined;
|
|
616
|
+
}
|
|
597
617
|
const cast = castMap(c.map, p.x, p.y, d.x * far, d.y * far, 0);
|
|
598
618
|
let best: Hit | null = cast.hit;
|
|
599
619
|
for (const e of ents.values()) {
|
|
@@ -609,12 +629,23 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
609
629
|
charge(20 + 4 * (mapShapes + ents.size));
|
|
610
630
|
const d = V(delta);
|
|
611
631
|
const radius = e.kind.body?.radius ?? 0;
|
|
612
|
-
const cast = castMap(c.map, e.pos.x, e.pos.y, d.x, d.y, radius);
|
|
613
|
-
let best: Hit | null = cast.hit;
|
|
614
632
|
// The ids to pass through: the first few of a list, read as plain texts. A longer list is not searched once for every entity.
|
|
615
633
|
const list = own(o, 'ignore');
|
|
616
634
|
const ignore = new Set<string>();
|
|
617
635
|
if (Array.isArray(list)) for (let i = 0; i < list.length && i < IGNORE_MAX; i += 1) { const id = own(list, i); if (typeof id === 'string') ignore.add(id); }
|
|
636
|
+
if (dims === 3) {
|
|
637
|
+
ignore.add(e.id);
|
|
638
|
+
const shape = e.kind.body ?? { shape: 'sphere', radius: 0, height: 0 };
|
|
639
|
+
const hit = cast3(e.pos, d, shape, ignore);
|
|
640
|
+
const length = Math.sqrt(d.x * d.x + d.y * d.y + d.z * d.z);
|
|
641
|
+
const t = hit ? Math.max(0, hit.t - (length > 0 ? SKIN / length : 0)) : 1;
|
|
642
|
+
e.pos = V({ x: e.pos.x + d.x * t, y: e.pos.y + d.y * t, z: e.pos.z + d.z * t });
|
|
643
|
+
const support = cast3(e.pos, { x: 0, y: 0, z: -2 * SKIN }, shape, ignore);
|
|
644
|
+
e.grounded = Boolean(support && support.nz > 0.5);
|
|
645
|
+
return hit ? Object.freeze({ ...(hit.id ? { entity: hit.id } : {}), at: e.pos, normal: V({ x: hit.nx, y: hit.ny, z: hit.nz }) }) : undefined;
|
|
646
|
+
}
|
|
647
|
+
const cast = castMap(c.map, e.pos.x, e.pos.y, d.x, d.y, radius);
|
|
648
|
+
let best: Hit | null = cast.hit;
|
|
618
649
|
for (const other of ents.values()) {
|
|
619
650
|
if (other === e || !other.kind.body || ignore.has(other.id)) continue;
|
|
620
651
|
const h = rayCircle(e.pos.x, e.pos.y, d.x, d.y, other.pos.x, other.pos.y, other.kind.body.radius + radius);
|
|
@@ -647,8 +678,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
647
678
|
});
|
|
648
679
|
Object.freeze(world);
|
|
649
680
|
|
|
650
|
-
const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, sweep: (body: unknown, delta: unknown): unknown => sweepMap(c.map, body, delta, moveRadius, dims) }));
|
|
681
|
+
const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, sweep: (body: unknown, delta: unknown): unknown => sweepMap(c.map, body, delta, moveRadius, dims, moveShape) }));
|
|
651
682
|
let moveRadius = 0;
|
|
683
|
+
let moveShape = { shape: 'sphere', radius: 0, height: 0 };
|
|
652
684
|
const moveCtx = brand({} as Record<string, unknown>);
|
|
653
685
|
for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
|
|
654
686
|
Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
|
|
@@ -887,14 +919,15 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
887
919
|
/* ---------------------------------------------------------------- one tick */
|
|
888
920
|
|
|
889
921
|
function inArea(a: any, p: Vec3): boolean {
|
|
890
|
-
if (a.k === 'b') return p.x >= a.min.x && p.x <= a.max.x && p.y >= a.min.y && p.y <= a.max.y;
|
|
922
|
+
if (a.k === 'b') return p.x >= a.min.x && p.x <= a.max.x && p.y >= a.min.y && p.y <= a.max.y && (dims === 2 || p.z >= a.min.z && p.z <= a.max.z);
|
|
891
923
|
const dx = p.x - a.at.x; const dy = p.y - a.at.y;
|
|
892
|
-
const
|
|
924
|
+
const dz = dims === 3 ? p.z - a.at.z : 0;
|
|
925
|
+
const d2 = dx * dx + dy * dy + dz * dz;
|
|
893
926
|
if (d2 > a.r * a.r) return false;
|
|
894
927
|
if (a.k === 's' || d2 === 0) return true;
|
|
895
928
|
const l = Math.sqrt(d2);
|
|
896
929
|
// Inside the cone: the angle to its axis is at most half its opening, compared by cosine.
|
|
897
|
-
return (dx * a.dir.x + dy * a.dir.y) / l >= exact.cos(a.half);
|
|
930
|
+
return (dx * a.dir.x + dy * a.dir.y + dz * a.dir.z) / l >= exact.cos(a.half);
|
|
898
931
|
}
|
|
899
932
|
function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
|
|
900
933
|
tick = (tick + 1) >>> 0;
|
|
@@ -992,10 +1025,14 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
992
1025
|
// The browser ran the game's own `move` and came further than `maxSpeed` allows: the body is held back.
|
|
993
1026
|
if (go < dist) stats.held += 1;
|
|
994
1027
|
e.allow -= go;
|
|
995
|
-
|
|
1028
|
+
e.pos = clampIn(V({ x: e.pos.x + (dist > 0 ? (dx / dist) * go : 0), y: e.pos.y + (dist > 0 ? (dy / dist) * go : 0), z: dims === 3 ? claim.pos.z : 0 }), body.radius, body.height || 2 * body.radius);
|
|
996
1029
|
const sp = Math.sqrt(claim.vel.x * claim.vel.x + claim.vel.y * claim.vel.y);
|
|
997
|
-
e.vel = sp > body.maxSpeed && sp > 0 ? V({ x: (claim.vel.x / sp) * body.maxSpeed, y: (claim.vel.y / sp) * body.maxSpeed, z:
|
|
1030
|
+
e.vel = sp > body.maxSpeed && sp > 0 ? V({ x: (claim.vel.x / sp) * body.maxSpeed, y: (claim.vel.y / sp) * body.maxSpeed, z: claim.vel.z }) : V(claim.vel);
|
|
998
1031
|
e.heading = dir(claim.heading, dims);
|
|
1032
|
+
e.grounded = dims === 2;
|
|
1033
|
+
// The support cast is map work even when a browser supplies the pose.
|
|
1034
|
+
// Keep it under the same movement quota as a server-driven body's sweep.
|
|
1035
|
+
if (dims === 3) run(k.name, 'move', e, 'move', () => { e.grounded = restsOnMap(c.map, e.pos, body); }, 'body');
|
|
999
1036
|
}
|
|
1000
1037
|
continue;
|
|
1001
1038
|
}
|
|
@@ -1003,13 +1040,13 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1003
1040
|
if (!k.move) continue;
|
|
1004
1041
|
const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
|
|
1005
1042
|
const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
|
|
1006
|
-
moveRadius = body.radius;
|
|
1043
|
+
moveRadius = body.radius; moveShape = body;
|
|
1007
1044
|
run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
|
|
1008
1045
|
// The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
|
|
1009
1046
|
// `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
|
|
1010
1047
|
// (A listener hears of a position that was not a number here too, as `move`'s.)
|
|
1011
1048
|
if (noted) G.note = (what, at, written) => noted(k.name, 'move', what, at, written);
|
|
1012
|
-
naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
|
|
1049
|
+
naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius, body.height || 2 * body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
|
|
1013
1050
|
G.note = null;
|
|
1014
1051
|
e.grounded = own(b, 'grounded') === true;
|
|
1015
1052
|
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } });
|
|
@@ -1128,7 +1165,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1128
1165
|
const duration = r.round[1] === 1 ? ticks(c.rounds?.seconds ?? 0) : Math.max(3, ticks(c.rounds?.breakSeconds ?? 0));
|
|
1129
1166
|
check(r.round[3] <= r.tick && r.round[2] <= r.tick + duration && !(r.round[1] === 1 && !duration && r.round[2] !== 0));
|
|
1130
1167
|
check(r.policy && ['fill', 'off'].includes(r.policy.bots) && Number.isInteger(r.policy.level) && Number.isInteger(r.policy.levelMax) && r.policy.level >= 1 && r.policy.level <= r.policy.levelMax && r.policy.levelMax <= 5);
|
|
1131
|
-
check(
|
|
1168
|
+
check(r.policy.guideLevel === undefined || Number.isInteger(r.policy.guideLevel) && r.policy.guideLevel >= 1 && r.policy.guideLevel <= r.policy.levelMax);
|
|
1169
|
+
check(r.policy.guideSeats === undefined || Array.isArray(r.policy.guideSeats) && r.policy.guideSeats.length > 0 && r.policy.guideSeats.length <= c.seats && r.policy.guideSeats.every((seat, i, seats) => uint(seat) && seat < c.seats && (i === 0 || seat > seats[i - 1])));
|
|
1170
|
+
check((r.policy.kids === undefined || r.policy.kids === true) && (r.policy.levelSet === undefined || r.policy.levelSet === true));
|
|
1171
|
+
check(Object.keys(r.policy).every((key) => ['bots', 'level', 'levelMax', 'reserved', 'kids', 'levelSet', 'guideLevel', 'guideSeats'].includes(key)));
|
|
1132
1172
|
check(r.policy.reserved === undefined || uint(r.policy.reserved) && r.policy.reserved < c.seats);
|
|
1133
1173
|
fields(c.shared, r.shared);
|
|
1134
1174
|
check(Array.isArray(r.ents) && Array.isArray(r.spawns) && r.ents.length + r.spawns.length <= ENTITY_MAX);
|
|
@@ -1147,7 +1187,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1147
1187
|
const goal = w[15] as any;
|
|
1148
1188
|
if (goal !== null) check(goal && typeof goal === 'object' && !Array.isArray(goal) && Object.keys(goal).every(k => ['goal', 'args', 'from', 'at', 'asked', 'state'].includes(k)) && typeof goal.goal === 'string' && goal.args && typeof goal.args === 'object' && !Array.isArray(goal.args) && ['brain', 'floor'].includes(goal.from) && uint(goal.at) && goal.at <= r.tick && typeof goal.asked === 'boolean' && goal.state === 'active');
|
|
1149
1189
|
const pos = unpackVec(w[7] as number[], 3), bounds = c.map.bounds;
|
|
1150
|
-
check(pos.x >= bounds.min.x && pos.x <= bounds.max.x && pos.y >= bounds.min.y && pos.y <= bounds.max.y && pos.z === 0);
|
|
1190
|
+
check(pos.x >= bounds.min.x && pos.x <= bounds.max.x && pos.y >= bounds.min.y && pos.y <= bounds.max.y && (dims === 3 ? pos.z >= bounds.min.z && pos.z <= bounds.max.z : pos.z === 0));
|
|
1151
1191
|
check(Number.isInteger(w[11]) && (w[11] as number) >= -1 && (w[11] as number) < c.seats && ['person', 'bot', 'ai'].includes(w[12] as string) && typeof w[13] === 'string' && w[13].length <= 128);
|
|
1152
1192
|
if (k.player) { check(!bodySeats.has(w[11])); bodySeats.add(w[11]); }
|
|
1153
1193
|
fields(k.fields, w[17]); fields(k.motion, w[18]); fields(k.input, w[19]);
|
|
@@ -1327,8 +1367,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
|
|
|
1327
1367
|
snapshot: () => {
|
|
1328
1368
|
packed.n = 0;
|
|
1329
1369
|
const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
|
|
1330
|
-
//
|
|
1331
|
-
snapCells = packed.n + 10 * list.length;
|
|
1370
|
+
// Include all three components of position, velocity and heading in a 3D snapshot.
|
|
1371
|
+
snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
|
|
1332
1372
|
return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list];
|
|
1333
1373
|
},
|
|
1334
1374
|
shared: () => packFields(c.shared, shared, dims),
|