@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.
@@ -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 () { reply({ ok: true, value: valueOf(String(m.key)), version: versionOf(String(m.key)) }); });
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
+ }
@@ -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.45.2';
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
+ }