@homie-rocks/studio 0.45.2 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/guides/game/RULES.md +276 -3
- package/guides/game/SKILL.md +46 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-check-worker.mjs +6 -5
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +2 -2
- package/lib/scaffold.mjs +9 -3
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +3 -1
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +116 -26
- package/rules/guard.ts +1 -1
- package/rules/host.ts +139 -19
- package/rules/interest.mjs +3 -3
- package/rules/live.ts +19 -7
- package/rules/map-index.ts +7 -1
- package/rules/math.ts +20 -8
- package/rules/navigation.ts +69 -0
- package/rules/pack.ts +2 -2
- package/rules/query.ts +103 -0
- package/rules/rules.ts +108 -29
- package/rules/terrain-legacy.ts +93 -0
- package/rules/terrain.ts +96 -11
- package/rules/types.ts +17 -7
- package/rules/view.ts +38 -19
- package/worker/characters.mjs +41 -0
- package/worker/functions-schema.mjs +16 -0
- package/worker/index.mjs +181 -17
- package/worker/players.mjs +3 -1
- package/worker/room-store.mjs +113 -11
- package/worker/room.mjs +12 -3
- package/worker/saves-shell.mjs +5 -1
- package/worker/saves.mjs +19 -1
- package/worker/version.mjs +1 -1
- package/worker/world-journal.mjs +109 -0
package/worker/saves-shell.mjs
CHANGED
|
@@ -98,11 +98,15 @@ export const SAVES_SHELL_JS = `${PASSKEY_JS}
|
|
|
98
98
|
if (late) loaded.then(function () { tell('player', { player: me(), cache: snapshot(), same: true }); });
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
|
-
if (op === 'get') return loaded.then(function () {
|
|
101
|
+
if (op === 'get') return loaded.then(function () {
|
|
102
|
+
var key=String(m.key);if(key.indexOf('server:')===0&&KEY.test(key))return api('GET','/api/player/saves/'+GAME+'/'+encodeURIComponent(key)).then(function(r){if(r.ok){S.cache[key]={v:r.value,ver:r.version,at:r.updatedAt};persist();}reply(r);},function(){reply({ok:false,error:'offline',value:valueOf(key)});});
|
|
103
|
+
reply({ ok: true, value: valueOf(key), version: versionOf(key) });
|
|
104
|
+
});
|
|
102
105
|
if (op === 'list') return loaded.then(function () { var keys = {}; Object.keys(S.cache).forEach(function (k) { keys[k] = 1; }); Object.keys(S.pending).forEach(function (k) { keys[k] = 1; });
|
|
103
106
|
reply({ ok: true, keys: Object.keys(keys).filter(function (k) { return valueOf(k) !== null; }).sort().map(function (k) { var c = S.cache[k]; return { key: k, version: c ? c.ver : 0, updatedAt: c ? c.at : null, pending: !!S.pending[k] }; }) }); });
|
|
104
107
|
if (op === 'set' || op === 'del') {
|
|
105
108
|
var key = String(m.key || '');
|
|
109
|
+
if(key.indexOf('server:')===0)return reply({ok:false,error:'authority',message:'This record is written by the game server'});
|
|
106
110
|
if (!KEY.test(key)) return reply({ ok: false, error: 'key', message: 'a save key is 1 to 64 letters, digits, _ . : or -' });
|
|
107
111
|
if (op === 'set') {
|
|
108
112
|
var text; try { text = JSON.stringify(m.value); } catch (e) { return reply({ ok: false, error: 'value', message: 'a save is JSON' }); }
|
package/worker/saves.mjs
CHANGED
|
@@ -85,6 +85,7 @@ export async function writeSaves(env, player, game, { set = [], del = [] } = {})
|
|
|
85
85
|
const at = Date.now();
|
|
86
86
|
for (const item of items) {
|
|
87
87
|
const key = String(item?.key ?? '');
|
|
88
|
+
if(key.startsWith('server:')){results.push({key,ok:false,error:'authority',message:'This record is written by the game server'});continue;}
|
|
88
89
|
if (!SAVE_KEY.test(key)) { results.push({ key, ok: false, error: 'key', message: 'a save key is 1 to 64 letters, digits, _ . : or -' }); continue; }
|
|
89
90
|
const base = item.base === undefined || item.base === null ? null : Math.floor(Number(item.base));
|
|
90
91
|
if (base !== null && !(base >= 0)) { results.push({ key, ok: false, error: 'base', message: 'base is the version the change was made from (0 when the key is new)' }); continue; }
|
|
@@ -148,7 +149,7 @@ export async function writeSaves(env, player, game, { set = [], del = [] } = {})
|
|
|
148
149
|
export function wipeStatements(env, player, game, keep = []) {
|
|
149
150
|
const kept = (Array.isArray(keep) ? keep : []).filter((k) => SAVE_KEY.test(String(k))).slice(0, SAVE_LIMITS.keys);
|
|
150
151
|
const marks = kept.map((_, i) => `?${i + 3}`).join(', ');
|
|
151
|
-
return env.DB.prepare(`DELETE FROM saves WHERE player = ?1 AND game = ?2${kept.length ? ` AND key NOT IN (${marks})` : ''} RETURNING key, value, blob`).bind(player, game, ...kept);
|
|
152
|
+
return env.DB.prepare(`DELETE FROM saves WHERE player = ?1 AND game = ?2 AND key NOT LIKE 'server:%'${kept.length ? ` AND key NOT IN (${marks})` : ''} RETURNING key, value, blob`).bind(player, game, ...kept);
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
export async function wipeSaves(env, player, game, { keep = [] } = {}) {
|
|
@@ -289,3 +290,20 @@ export async function adoptStatements(env, from, to) {
|
|
|
289
290
|
after: () => dropBlobs(env, clash.map(blobKey)),
|
|
290
291
|
};
|
|
291
292
|
}
|
|
293
|
+
|
|
294
|
+
/** Server-only, idempotent bounded round receipts. Optimistic versions also
|
|
295
|
+
* serialize simultaneous rooms without dropping either room's result. */
|
|
296
|
+
export async function appendServerRecord(env,player,game,key,record){
|
|
297
|
+
if(!key.startsWith('server:')||!SAVE_KEY.test(key)||typeof record.id!=='string')throw new Error('invalid server record');
|
|
298
|
+
for(let attempt=0;attempt<16;attempt++){
|
|
299
|
+
const old=await getSave(env,player,game,key),matches=Array.isArray(old.value?.matches)?old.value.matches:[];
|
|
300
|
+
if(matches.some(r=>r.id===record.id))return;
|
|
301
|
+
const kept=[record,...matches].sort((a,b)=>b.endedAt-a.endedAt||a.id.localeCompare(b.id)).slice(0,100);
|
|
302
|
+
let value=JSON.stringify({v:1,matches:kept}),bytes=bytesOf(value);
|
|
303
|
+
while(bytes>SAVE_LIMITS.valueBytes&&kept.length>1){kept.pop();value=JSON.stringify({v:1,matches:kept});bytes=bytesOf(value);}
|
|
304
|
+
if(bytes>SAVE_LIMITS.valueBytes)throw new Error('server record size');
|
|
305
|
+
const result=old.version?await env.DB.prepare('UPDATE saves SET value=?4,bytes=?5,version=version+1,updated_at=?6 WHERE player=?1 AND game=?2 AND key=?3 AND version=?7 RETURNING version').bind(player,game,key,value,bytes,Date.now(),old.version).first():await env.DB.prepare('INSERT INTO saves(player,game,key,value,bytes,version,updated_at,blob) VALUES(?1,?2,?3,?4,?5,1,?6,0) ON CONFLICT(player,game,key) DO NOTHING RETURNING version').bind(player,game,key,value,bytes,Date.now()).first();
|
|
306
|
+
if(result)return;
|
|
307
|
+
}
|
|
308
|
+
throw new Error('server record conflict; retry the durable outbox');
|
|
309
|
+
}
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.46.0';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Absolute assignments, never increments: replay cannot mint value twice.
|
|
2
|
+
* Entity IDs are keys so removing one entity never rewrites the rest of a world.
|
|
3
|
+
* The snapshot format remains the host's validated format at the read boundary.
|
|
4
|
+
*/
|
|
5
|
+
const collections = [
|
|
6
|
+
[['host','core','ents'], row => row[0]], [['host','core','spawns'], row => row[0]],
|
|
7
|
+
[['host','characters'], row => row.account], [['host','names'], row => row[0]],
|
|
8
|
+
[['host','inputs'], row => row[0]], [['host','queues'], row => row[0]],
|
|
9
|
+
[['host','core','seats'], row => row[0]], [['net','seats'], row => row[0]],
|
|
10
|
+
[['host','core','queue'], row => `${row[3]}:${row[4]}`],
|
|
11
|
+
[['host','core','areas'], row => row[2]], [['host','core','asks'], row => row.n],
|
|
12
|
+
];
|
|
13
|
+
function collectionsOf(copy, encode) {
|
|
14
|
+
for (const [path, identity] of collections) {
|
|
15
|
+
let parent = copy;
|
|
16
|
+
for (const key of path.slice(0,-1)) parent = parent?.[key];
|
|
17
|
+
const key = path.at(-1), rows = parent?.[key];
|
|
18
|
+
if (rows) parent[key] = encode ? Object.fromEntries(rows.map(row => [`k${identity(row)}`,row])) : Object.values(rows);
|
|
19
|
+
}
|
|
20
|
+
return copy;
|
|
21
|
+
}
|
|
22
|
+
/** Keyed collections on a copied spine. The rows themselves are shared with `value`: callers that keep the result clone first. */
|
|
23
|
+
export function normalizeWorld(value) {
|
|
24
|
+
const host = typeof value.host === 'string' ? JSON.parse(value.host) : value.host;
|
|
25
|
+
const copy = { ...value, host: { ...host, ...(host.core ? { core: { ...host.core } } : {}) }, ...(value.net ? { net: { ...value.net } } : {}) };
|
|
26
|
+
return collectionsOf(copy, true);
|
|
27
|
+
}
|
|
28
|
+
export function denormalizeWorld(value) {
|
|
29
|
+
return collectionsOf(structuredClone(value), false);
|
|
30
|
+
}
|
|
31
|
+
const isField = path => path[4] === 17 && path.length >= 6 || path[2] === 'shared' && path.length >= 4;
|
|
32
|
+
/**
|
|
33
|
+
* One walk of both values, no serialising of what did not change: a tick's cost follows the durable state, and a
|
|
34
|
+
* record's size follows what changed in it. Arrays are diffed by cell, packed maps and lists included.
|
|
35
|
+
* `path` is where the two values sit; a patch is `[path, value]`, or `[path]` for a key that is gone.
|
|
36
|
+
*/
|
|
37
|
+
export function changes(before, after, path = [], out = []) {
|
|
38
|
+
walk(before, after, [...path], out);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** `at` is one stack for the whole walk, copied only into a patch: nothing is allocated for what is equal. */
|
|
42
|
+
function walk(before, after, at, out) {
|
|
43
|
+
if (before === after) return;
|
|
44
|
+
if (!before || !after || typeof before !== 'object' || typeof after !== 'object' || Array.isArray(before) !== Array.isArray(after)) { out.push([[...at], after]); return; }
|
|
45
|
+
const start = out.length;
|
|
46
|
+
if (Array.isArray(after)) {
|
|
47
|
+
// By index, as numbers: a cell past the shorter array's end is new, and the length patch drops what is gone.
|
|
48
|
+
for (let i = 0; i < after.length; i += 1) { at.push(i); walk(before[i], after[i], at, out); at.pop(); }
|
|
49
|
+
if (before.length !== after.length) out.push([[...at, 'length'], after.length]);
|
|
50
|
+
} else {
|
|
51
|
+
for (const key in before) if (!Object.hasOwn(after, key)) out.push([[...at, key]]);
|
|
52
|
+
for (const key in after) { at.push(key); walk(before[key], after[key], at, out); at.pop(); }
|
|
53
|
+
}
|
|
54
|
+
// A packed inventory whose every slot changes is smaller as one assignment.
|
|
55
|
+
// Never coalesce a projected entity or room (that would pull movement in).
|
|
56
|
+
if (out.length > start && isField(at) && JSON.stringify([at, after]).length < JSON.stringify(out.slice(start)).length) out.splice(start, out.length - start, [[...at], after]);
|
|
57
|
+
}
|
|
58
|
+
export function applyChanges(value, patches) {
|
|
59
|
+
for (const [path, next] of patches) {
|
|
60
|
+
if (!path.length) { value = structuredClone(next); continue; }
|
|
61
|
+
let target = value;
|
|
62
|
+
for (const key of path.slice(0,-1)) target = target[key];
|
|
63
|
+
const key = path.at(-1);
|
|
64
|
+
if (next === undefined) delete target[key];
|
|
65
|
+
else if (Array.isArray(target) && key === 'length') target.length = next;
|
|
66
|
+
else Object.defineProperty(target, key, {value:structuredClone(next),writable:true,enumerable:true,configurable:true});
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
/** Durable fields plus the causal machinery that can create/consume value.
|
|
71
|
+
* Movement, inputs, pose history and undeclared fields wait for a checkpoint.
|
|
72
|
+
* Takes a normalised world and leaves it untouched. The view it returns shares its leaves with that world: it is for
|
|
73
|
+
* comparing now, and `ownDurable` makes the copy that is kept.
|
|
74
|
+
*/
|
|
75
|
+
const POSE = [3, 7, 8, 9, 10, 15, 16, 18, 19];
|
|
76
|
+
export function durableWorld(value, fields) {
|
|
77
|
+
const { at, ...v } = value, { inputs, queues, guideViews, agents, ...h } = v.host, { tick, rng, nextId, seq, history, collision, guideViews: views, ...c } = h.core;
|
|
78
|
+
if (v.net) { const { savedAt, ckpt, state, round, roster, ...net } = v.net; v.net = net; }
|
|
79
|
+
for (const key of ['ents', 'spawns']) {
|
|
80
|
+
const rows = {};
|
|
81
|
+
for (const [id, row] of Object.entries(c[key] ?? {})) {
|
|
82
|
+
const list = fields.kinds[row[2]] ?? [];
|
|
83
|
+
// Undeclared non-player entities (for example visual projectiles) belong
|
|
84
|
+
// to checkpoints. Seat-bearing bodies remain part of the ownership protocol.
|
|
85
|
+
if (!list.length && row[11] < 0) continue;
|
|
86
|
+
// Preserve membership and ownership; pose/motion rewind to the checkpoint.
|
|
87
|
+
const kept = row.slice();
|
|
88
|
+
for (const i of POSE) if (i < kept.length) kept[i] = null;
|
|
89
|
+
kept[17] = {};
|
|
90
|
+
for (const i of list) if (row[17][i] !== undefined) kept[17][i] = row[17][i];
|
|
91
|
+
rows[id] = kept;
|
|
92
|
+
}
|
|
93
|
+
c[key] = rows;
|
|
94
|
+
}
|
|
95
|
+
const shared = {};
|
|
96
|
+
for (const i of fields.shared) if (c.shared?.[i] !== undefined) shared[i] = c.shared[i];
|
|
97
|
+
c.shared = shared; h.core = c; v.host = h;
|
|
98
|
+
return v;
|
|
99
|
+
}
|
|
100
|
+
/** A durable view that shares nothing with the world it was taken from. */
|
|
101
|
+
export const ownDurable = (value, fields) => JSON.parse(JSON.stringify(durableWorld(value, fields)));
|
|
102
|
+
/** Ignore clocks when deciding if an unchanged room needs a checkpoint. */
|
|
103
|
+
export function checkpointWorld(value) {
|
|
104
|
+
const v = structuredClone(value);
|
|
105
|
+
delete v.at; if(v.net) delete v.net.savedAt;
|
|
106
|
+
if(v.host?.core) { delete v.host.core.tick; delete v.host.core.history; delete v.host.core.collision; }
|
|
107
|
+
for(const [,q] of Object.values(v.host?.inputs ?? {})) delete q.lastFrameTick;
|
|
108
|
+
return v;
|
|
109
|
+
}
|