@homie-rocks/studio 0.11.0 → 0.12.1
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/README.md +51 -6
- package/bin/homie-studio.mjs +25 -4
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +19 -7
- package/lib/codex.mjs +1 -0
- package/lib/directory.mjs +5 -4
- package/lib/doctor.mjs +11 -5
- package/lib/net.mjs +137 -0
- package/lib/players.mjs +38 -0
- package/lib/progress.mjs +39 -43
- package/lib/repo.mjs +51 -0
- package/lib/scaffold.mjs +31 -1
- package/lib/setup.mjs +15 -23
- package/lib/site.mjs +2 -2
- package/lib/stats.mjs +3 -3
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +48 -0
- package/netplay/NETPLAY.md +5 -0
- package/package.json +6 -1
- package/port/index.ts +2 -0
- package/port/keys.ts +3 -0
- package/saves/SAVES.md +236 -0
- package/saves/saves.ts +326 -0
- package/site/SITE.md +9 -0
- package/starters/ember-vale/game.json +20 -0
- package/starters/ember-vale/index.html +46 -0
- package/starters/ember-vale/src/main.ts +429 -0
- package/worker/account-page.mjs +350 -0
- package/worker/index.mjs +36 -8
- package/worker/pages.mjs +10 -3
- package/worker/players.mjs +817 -0
- package/worker/saves-shell.mjs +388 -0
- package/worker/saves.mjs +287 -0
- package/worker/site.mjs +1 -1
- package/worker/stats-page.mjs +1 -0
- package/worker/stats.mjs +16 -1
- package/worker/version.mjs +1 -1
- package/worker/webauthn.mjs +259 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The play shell's half of cloud saves (saves/SAVES.md), in the page around a game whose game.json says
|
|
3
|
+
* `"saves": true`. The game runs in a sandboxed frame with an opaque origin: it can neither read this site's
|
|
4
|
+
* cookies nor keep anything of its own. So the shell, on the site's own origin, does it for the game:
|
|
5
|
+
*
|
|
6
|
+
* - it answers the game's `homie-save` messages (createSaves in @homie-rocks/studio/saves), only from its own
|
|
7
|
+
* frame, and only for THIS game (the game id is the page's, never the message's);
|
|
8
|
+
* - it keeps a local copy of the player's saves in this browser (localStorage, per player and game) and an
|
|
9
|
+
* outbox of changes, so a save works offline and syncs when the connection comes back, in order: memorials
|
|
10
|
+
* (a hardcore death and its wipe) first, then saves, then stats;
|
|
11
|
+
* - every change names the version it was made from, so a stale copy from another device never overwrites newer
|
|
12
|
+
* progress: on a conflict the newer cloud copy wins, unless the game resolves it (onConflict);
|
|
13
|
+
* - it shows the sign-in sheet (a passkey) when the game asks, or from the room button, and tells the game when
|
|
14
|
+
* the player changes.
|
|
15
|
+
*/
|
|
16
|
+
import { PASSKEY_JS } from './account-page.mjs';
|
|
17
|
+
|
|
18
|
+
export const SAVES_SHELL_CSS = `
|
|
19
|
+
.who-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 10px 2px 0; padding-top: 10px; border-top: 1px solid rgba(255,255,255,.12); font-weight: 500; color: #c9d1e3; }
|
|
20
|
+
.who-row b { color: #fff; font-weight: 700; }
|
|
21
|
+
.who-row button { background: none; border: 0; color: var(--hot); font: inherit; font-weight: 700; cursor: pointer; padding: 6px 2px; }
|
|
22
|
+
.signin { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center; padding: 16px; background: rgba(2,4,10,.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); touch-action: manipulation; -webkit-user-select: text; user-select: text; }
|
|
23
|
+
.signin .box { box-sizing: border-box; width: min(380px, 100%); padding: 20px; border-radius: 18px; background: rgba(10,14,24,.97); border: 1px solid rgba(255,255,255,.16); color: #eef1f8; font: 15px/1.45 ui-sans-serif, system-ui, -apple-system, sans-serif; box-shadow: 0 24px 70px rgba(0,0,0,.6); }
|
|
24
|
+
.signin h2 { margin: 0 0 6px; font-size: 20px; }
|
|
25
|
+
.signin p { margin: 0 0 12px; color: #b9c2d6; }
|
|
26
|
+
.signin input { box-sizing: border-box; width: 100%; min-height: 44px; margin: 0 0 12px; padding: 0 12px; border-radius: 11px; border: 1px solid rgba(255,255,255,.18); background: #05070d; color: #fff; font: 16px ui-sans-serif, system-ui, sans-serif; }
|
|
27
|
+
.signin .acts { display: grid; gap: 8px; }
|
|
28
|
+
.signin button { min-height: 46px; border-radius: 12px; border: 1px solid rgba(255,255,255,.18); background: transparent; color: inherit; font: 700 15px ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
|
|
29
|
+
.signin button.primary { background: var(--hot); color: #0b0b10; border-color: transparent; }
|
|
30
|
+
.signin .note { margin: 12px 0 0; font-size: 12.5px; color: #8f99b0; }
|
|
31
|
+
.signin .note a { color: #c9d1e3; }
|
|
32
|
+
.signin .said { min-height: 1.3em; margin: 10px 0 0; font-weight: 600; }
|
|
33
|
+
.signin .said.bad { color: #ff8a80; }
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
export const SAVES_SHELL_JS = `${PASSKEY_JS}
|
|
37
|
+
(function () {
|
|
38
|
+
'use strict';
|
|
39
|
+
var boot = window.__HOMIE_PLAY;
|
|
40
|
+
var frame = document.querySelector('iframe.game');
|
|
41
|
+
var P = homiePasskey;
|
|
42
|
+
var GAME = boot.game;
|
|
43
|
+
var KEY = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
44
|
+
var hint = null;
|
|
45
|
+
try { hint = JSON.parse(localStorage.getItem('homie.player') || 'null'); } catch (e) { hint = null; }
|
|
46
|
+
var S = { player: hint && hint.id ? { id: hint.id, name: hint.name, guest: !!hint.guest, owner: !!hint.owner } : null, cache: {}, pending: {}, falls: [], stats: { add: {}, max: {}, min: {} },
|
|
47
|
+
online: navigator.onLine !== false, lastSyncAt: null, lastError: null, resolver: false, flushing: null, again: false, backoff: 0, timer: null, loaded: false, seq: 0 };
|
|
48
|
+
var state = window.__shell || (window.__shell = {});
|
|
49
|
+
state.saves = { get player() { return S.player; }, status: status, cache: function () { return S.cache; }, pending: function () { return S.pending; } };
|
|
50
|
+
|
|
51
|
+
/* -------------------------------------------------- this browser's copy */
|
|
52
|
+
function ns() { return 'homie-saves.' + GAME + '.' + (S.player ? S.player.id : 'anon'); }
|
|
53
|
+
function persist() { try { localStorage.setItem(ns(), JSON.stringify({ cache: S.cache, pending: S.pending, falls: S.falls, stats: S.stats })); } catch (e) {} }
|
|
54
|
+
function restore() {
|
|
55
|
+
S.cache = {}; S.pending = {}; S.falls = []; S.stats = { add: {}, max: {}, min: {} };
|
|
56
|
+
try { var j = JSON.parse(localStorage.getItem(ns()) || 'null'); if (j) { S.cache = j.cache || {}; S.pending = j.pending || {}; S.falls = j.falls || []; S.stats = j.stats || S.stats; } } catch (e) {}
|
|
57
|
+
}
|
|
58
|
+
function valueOf(key) { var p = S.pending[key]; if (p) return p.op === 'del' ? null : p.v; var c = S.cache[key]; return c ? c.v : null; }
|
|
59
|
+
function versionOf(key) { var c = S.cache[key]; return c ? c.ver : 0; }
|
|
60
|
+
function snapshot() { var out = {}; Object.keys(S.cache).forEach(function (k) { out[k] = valueOf(k); }); Object.keys(S.pending).forEach(function (k) { out[k] = valueOf(k); }); Object.keys(out).forEach(function (k) { if (out[k] === null) delete out[k]; }); return out; }
|
|
61
|
+
function pendingCount() { var n = Object.keys(S.pending).length + S.falls.length; ['add', 'max', 'min'].forEach(function (k) { n += Object.keys(S.stats[k]).length ? 1 : 0; }); return n; }
|
|
62
|
+
function status() { return { mode: 'cloud', online: S.online, pending: pendingCount(), lastSyncAt: S.lastSyncAt, lastError: S.lastError }; }
|
|
63
|
+
|
|
64
|
+
/* -------------------------------------------------- to and from the game */
|
|
65
|
+
function post(m) { try { frame.contentWindow.postMessage(Object.assign({ t: 'homie-save' }, m), '*'); } catch (e) {} }
|
|
66
|
+
function tell(ev, extra) { post(Object.assign({ ev: ev }, extra || {})); }
|
|
67
|
+
function me() { return S.player ? { id: S.player.id, name: S.player.name, guest: !!S.player.guest, owner: !!S.player.owner, signedIn: !S.player.guest, local: false } : { id: null, name: 'Guest', guest: true, owner: false, signedIn: false, local: false }; }
|
|
68
|
+
var asks = {};
|
|
69
|
+
var askSeq = 0;
|
|
70
|
+
|
|
71
|
+
function setPlayer(p, fromServer) {
|
|
72
|
+
var before = S.player ? S.player.id : null;
|
|
73
|
+
var after = p ? p.id : null;
|
|
74
|
+
if (before !== after) {
|
|
75
|
+
// The first save made a guest: this browser's anonymous copy is that guest's now.
|
|
76
|
+
if (!before && after) { var anon = null; try { anon = localStorage.getItem(ns()); localStorage.removeItem(ns()); } catch (e) {} S.player = p; if (anon) { try { localStorage.setItem(ns(), anon); } catch (e) {} } }
|
|
77
|
+
else { S.player = p; restore(); }
|
|
78
|
+
} else S.player = p;
|
|
79
|
+
P.remember(S.player);
|
|
80
|
+
paintWho();
|
|
81
|
+
if (fromServer && before !== after) { tell('player', { player: me(), cache: snapshot() }); }
|
|
82
|
+
else if (fromServer) tell('player', { player: me(), cache: snapshot(), same: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
window.addEventListener('message', function (ev) {
|
|
86
|
+
if (ev.source !== frame.contentWindow) return;
|
|
87
|
+
var m = ev.data;
|
|
88
|
+
if (!m || typeof m !== 'object' || m.t !== 'homie-save') return;
|
|
89
|
+
var reply = function (body) { post(Object.assign({ q: m.q }, body)); };
|
|
90
|
+
var op = m.op;
|
|
91
|
+
try {
|
|
92
|
+
if (op === 'hello') {
|
|
93
|
+
S.resolver = !!m.resolver;
|
|
94
|
+
// At most 3 s: a slow network gets this browser's copy now, and the cloud's when it lands (a 'player' event).
|
|
95
|
+
var late = false;
|
|
96
|
+
return Promise.race([loaded, new Promise(function (r) { setTimeout(function () { late = true; r(); }, 3000); })]).then(function () {
|
|
97
|
+
reply({ ok: true, player: me(), cache: snapshot(), status: status(), fresh: !late });
|
|
98
|
+
if (late) loaded.then(function () { tell('player', { player: me(), cache: snapshot(), same: true }); });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (op === 'get') return loaded.then(function () { reply({ ok: true, value: valueOf(String(m.key)), version: versionOf(String(m.key)) }); });
|
|
102
|
+
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
|
+
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
|
+
if (op === 'set' || op === 'del') {
|
|
105
|
+
var key = String(m.key || '');
|
|
106
|
+
if (!KEY.test(key)) return reply({ ok: false, error: 'key', message: 'a save key is 1 to 64 letters, digits, _ . : or -' });
|
|
107
|
+
if (op === 'set') {
|
|
108
|
+
var text; try { text = JSON.stringify(m.value); } catch (e) { return reply({ ok: false, error: 'value', message: 'a save is JSON' }); }
|
|
109
|
+
if (text === undefined) return reply({ ok: false, error: 'value', message: 'a save needs a value' });
|
|
110
|
+
if (text.length > 1048576) return reply({ ok: false, error: 'too-large', message: 'a save is at most 1 MiB' });
|
|
111
|
+
}
|
|
112
|
+
var prev = S.pending[key];
|
|
113
|
+
S.pending[key] = { op: op, v: op === 'set' ? m.value : null, base: prev ? prev.base : versionOf(key), n: ++S.seq };
|
|
114
|
+
persist();
|
|
115
|
+
return flushSoon(250).then(function () {
|
|
116
|
+
var p = S.pending[key];
|
|
117
|
+
if (p && p.err) { var err = p.err; delete S.pending[key]; persist(); return reply({ ok: false, error: err.error, message: err.message }); }
|
|
118
|
+
var c = S.cache[key];
|
|
119
|
+
reply({ ok: true, synced: !S.pending[key], version: c ? c.ver : 0 });
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (op === 'wipe') {
|
|
123
|
+
var keep = Array.isArray(m.keep) ? m.keep.map(String) : [];
|
|
124
|
+
Object.keys(snapshot()).forEach(function (k) { if (keep.indexOf(k) < 0) S.pending[k] = { op: 'del', v: null, base: S.pending[k] ? S.pending[k].base : versionOf(k), n: ++S.seq }; });
|
|
125
|
+
persist();
|
|
126
|
+
return flushSoon(0).then(function () { reply({ ok: true, synced: pendingCount() === 0 }); });
|
|
127
|
+
}
|
|
128
|
+
if (op === 'stats') {
|
|
129
|
+
if (m.kind === 'get') return api('GET', '/api/player/stats/' + GAME).then(function (r) { reply({ ok: true, stats: merged(r.ok ? r.stats : {}) }); }, function () { reply({ ok: true, stats: merged({}), offline: true }); });
|
|
130
|
+
var kind = m.kind === 'max' || m.kind === 'min' ? m.kind : 'add';
|
|
131
|
+
var bad = null;
|
|
132
|
+
Object.keys(m.values || {}).forEach(function (name) {
|
|
133
|
+
var n = Number(m.values[name]);
|
|
134
|
+
if (!/^[A-Za-z0-9_.:-]{1,32}$/.test(name) || !isFinite(n)) { bad = name; return; }
|
|
135
|
+
var cur = S.stats[kind][name];
|
|
136
|
+
S.stats[kind][name] = cur === undefined ? n : kind === 'add' ? cur + n : kind === 'max' ? Math.max(cur, n) : Math.min(cur, n);
|
|
137
|
+
});
|
|
138
|
+
if (bad !== null) return reply({ ok: false, error: 'stat', message: 'a stat name is 1 to 32 letters, digits, _ . : or -, and its value a number' });
|
|
139
|
+
persist();
|
|
140
|
+
return flushSoon(1500).then(function () { reply({ ok: true, synced: !Object.keys(S.stats[kind]).length }); });
|
|
141
|
+
}
|
|
142
|
+
if (op === 'fall') {
|
|
143
|
+
var f = { character: String(m.character || '').slice(0, 64), summary: m.summary && typeof m.summary === 'object' ? m.summary : {}, wipe: m.wipe === true, keep: Array.isArray(m.keep) ? m.keep.map(String) : [], n: ++S.seq };
|
|
144
|
+
S.falls.push(f);
|
|
145
|
+
// The wipe happens here at once (a death is final even offline); the server does it with the memorial.
|
|
146
|
+
if (f.wipe) { Object.keys(snapshot()).forEach(function (k) { if (f.keep.indexOf(k) < 0) { delete S.pending[k]; delete S.cache[k]; } }); }
|
|
147
|
+
persist();
|
|
148
|
+
return flushSoon(0).then(function () { reply({ ok: true, synced: S.falls.indexOf(f) < 0, memorial: f.memorial || null }); });
|
|
149
|
+
}
|
|
150
|
+
if (op === 'fallen') return api('GET', '/api/player/fallen/' + GAME + '?limit=' + (Number(m.limit) || 20) + (m.mine ? '&mine=1' : '')).then(function (r) { reply({ ok: !!r.ok, fallen: r.fallen || [], message: r.message }); }, function () { reply({ ok: false, fallen: [], error: 'offline', message: 'the hall of the fallen needs a connection' }); });
|
|
151
|
+
if (op === 'rename') return api('POST', '/api/player/name', { name: m.name }).then(function (r) { if (r.ok) setPlayer(Object.assign({}, S.player, { name: r.player.name }), true); reply({ ok: !!r.ok, name: r.ok ? r.player.name : undefined, error: r.error, message: r.message }); }, function () { reply({ ok: false, error: 'offline', message: 'renaming needs a connection' }); });
|
|
152
|
+
if (op === 'signin') { openSheet(m.reason ? String(m.reason).slice(0, 140) : ''); return reply({ ok: true }); }
|
|
153
|
+
if (op === 'flush') return flushSoon(0).then(function () { reply({ ok: true, status: status() }); });
|
|
154
|
+
if (op === 'resolver') { S.resolver = !!m.on; return reply({ ok: true }); }
|
|
155
|
+
if (op === 'resolve' && asks[m.ask]) { var a = asks[m.ask]; delete asks[m.ask]; a(m); return; }
|
|
156
|
+
} catch (e) { reply({ ok: false, error: 'shell', message: String(e && e.message || e) }); }
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
function merged(server) {
|
|
160
|
+
var out = {}; Object.keys(server || {}).forEach(function (k) { out[k] = server[k]; });
|
|
161
|
+
Object.keys(S.stats.add).forEach(function (k) { out[k] = (out[k] || 0) + S.stats.add[k]; });
|
|
162
|
+
Object.keys(S.stats.max).forEach(function (k) { out[k] = out[k] === undefined ? S.stats.max[k] : Math.max(out[k], S.stats.max[k]); });
|
|
163
|
+
Object.keys(S.stats.min).forEach(function (k) { out[k] = out[k] === undefined ? S.stats.min[k] : Math.min(out[k], S.stats.min[k]); });
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* -------------------------------------------------- the network */
|
|
168
|
+
function api(method, path, body, keepalive) {
|
|
169
|
+
var init = { method: method, credentials: 'same-origin', cache: 'no-store', headers: {}, keepalive: !!keepalive && (body === undefined || JSON.stringify(body).length < 60000) };
|
|
170
|
+
if (body !== undefined) { init.headers['content-type'] = 'application/json'; init.body = JSON.stringify(body); }
|
|
171
|
+
// A request that hangs (a train tunnel) counts as offline after 12 s; the change stays queued.
|
|
172
|
+
if (typeof AbortController === 'function' && !init.keepalive) { var ac = new AbortController(); setTimeout(function () { ac.abort(); }, 12000); init.signal = ac.signal; }
|
|
173
|
+
return fetch(path, init).then(function (r) {
|
|
174
|
+
return r.json().catch(function () { return { ok: false, error: 'http' }; }).then(function (j) { j.status = r.status; if (r.status >= 500 && !j.error) j.error = 'server'; return j; });
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function online(on) { if (S.online !== on) { S.online = on; tell('status', { status: status() }); } }
|
|
178
|
+
|
|
179
|
+
/** What the cloud has for this game: the copy for every key with no change waiting. */
|
|
180
|
+
function refresh() {
|
|
181
|
+
return api('GET', '/api/player/saves/' + GAME + '?values=1').then(function (r) {
|
|
182
|
+
online(true);
|
|
183
|
+
if (!r.ok) { S.lastError = r.message || r.error; return; }
|
|
184
|
+
if (r.player && (!S.player || S.player.id !== r.player.id || S.player.guest !== r.player.guest || S.player.name !== r.player.name)) setPlayer(r.player, true);
|
|
185
|
+
if (!r.player && S.player) setPlayer(null, true);
|
|
186
|
+
if (!r.player) return;
|
|
187
|
+
var seen = {};
|
|
188
|
+
(r.keys || []).forEach(function (k) { seen[k.key] = 1; if (!S.pending[k.key]) S.cache[k.key] = { v: k.value, ver: k.version, at: k.updatedAt }; });
|
|
189
|
+
Object.keys(S.cache).forEach(function (k) { if (!seen[k] && !S.pending[k]) delete S.cache[k]; });
|
|
190
|
+
S.lastSyncAt = Date.now(); persist();
|
|
191
|
+
}, function () { online(false); });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function flushSoon(ms) {
|
|
195
|
+
clearTimeout(S.timer);
|
|
196
|
+
return new Promise(function (resolve) {
|
|
197
|
+
var waiters = flushSoon.waiters || (flushSoon.waiters = []);
|
|
198
|
+
waiters.push(resolve);
|
|
199
|
+
// A promise for a game never waits more than 4 s: the change is safe in this browser either way.
|
|
200
|
+
setTimeout(function () { var i = waiters.indexOf(resolve); if (i >= 0) { waiters.splice(i, 1); resolve(); } }, 4000);
|
|
201
|
+
S.timer = setTimeout(function () { flush().then(function () { var w = flushSoon.waiters.splice(0); w.forEach(function (f) { f(); }); }); }, ms);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function flush(keepalive) {
|
|
206
|
+
if (S.flushing) { S.again = true; return S.flushing; }
|
|
207
|
+
S.flushing = run(keepalive).then(function () {
|
|
208
|
+
S.flushing = null;
|
|
209
|
+
if (S.again) { S.again = false; return flush(keepalive); }
|
|
210
|
+
}, function () { S.flushing = null; });
|
|
211
|
+
return S.flushing;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function retryLater() {
|
|
215
|
+
S.backoff = Math.min(30000, S.backoff ? S.backoff * 2 : 2000);
|
|
216
|
+
clearTimeout(S.retry);
|
|
217
|
+
S.retry = setTimeout(function () { flush(); }, S.backoff);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function run(keepalive) {
|
|
221
|
+
if (!pendingCount()) return Promise.resolve();
|
|
222
|
+
// 1. Memorials (and their wipes), in order.
|
|
223
|
+
var fallStep = S.falls.length ? api('POST', '/api/player/fallen/' + GAME, (function (f) { return { character: f.character, summary: f.summary, wipe: f.wipe, keep: f.keep }; })(S.falls[0]), keepalive).then(function (r) {
|
|
224
|
+
var f = S.falls[0];
|
|
225
|
+
if (r.player) adopt(r.player);
|
|
226
|
+
if (r.ok || (r.status >= 400 && r.status < 500 && r.status !== 429)) {
|
|
227
|
+
S.falls.shift(); f.memorial = r.memorial || null;
|
|
228
|
+
if (!r.ok) S.lastError = r.message || r.error;
|
|
229
|
+
if (r.ok && f.wipe) (r.wiped || []).forEach(function (k) { if (!S.pending[k]) delete S.cache[k]; else S.pending[k].base = 0; });
|
|
230
|
+
if (r.ok && f.wipe) Object.keys(S.pending).forEach(function (k) { if (S.pending[k].n > f.n && f.keep.indexOf(k) < 0) S.pending[k].base = 0; });
|
|
231
|
+
persist();
|
|
232
|
+
return run(keepalive);
|
|
233
|
+
}
|
|
234
|
+
throw new Error(r.status === 429 ? 'rate' : 'server');
|
|
235
|
+
}) : null;
|
|
236
|
+
if (fallStep) return fallStep.then(function () { S.backoff = 0; online(true); }, function () { online(false); retryLater(); });
|
|
237
|
+
// 2. Saves, up to 32 keys a request.
|
|
238
|
+
var keys = Object.keys(S.pending).slice(0, 32);
|
|
239
|
+
var sent = {};
|
|
240
|
+
var body = { set: [], del: [] };
|
|
241
|
+
keys.forEach(function (k) { var p = S.pending[k]; sent[k] = p.n; if (p.op === 'del') body.del.push({ key: k, base: p.base }); else body.set.push({ key: k, value: p.v, base: p.base }); });
|
|
242
|
+
var saveStep = keys.length ? api('POST', '/api/player/saves/' + GAME, body, keepalive).then(function (r) {
|
|
243
|
+
if (r.player) adopt(r.player);
|
|
244
|
+
if (!r.ok && !r.results) { if (r.status >= 400 && r.status < 500 && r.status !== 429) { keys.forEach(function (k) { if (S.pending[k] && S.pending[k].n === sent[k]) S.pending[k].err = { error: r.error, message: r.message }; }); S.lastError = r.message || r.error; return; } throw new Error(r.status === 429 ? 'rate' : 'server'); }
|
|
245
|
+
var asked = [];
|
|
246
|
+
(r.results || []).forEach(function (x) {
|
|
247
|
+
var p = S.pending[x.key];
|
|
248
|
+
var same = p && p.n === sent[x.key];
|
|
249
|
+
if (x.ok) {
|
|
250
|
+
if (p && p.op === 'del' && same) delete S.cache[x.key];
|
|
251
|
+
else if (p && p.op === 'set') S.cache[x.key] = { v: p.v, ver: x.version, at: Date.now() };
|
|
252
|
+
if (same) delete S.pending[x.key]; else if (p) p.base = x.version;
|
|
253
|
+
} else if (x.error === 'conflict') {
|
|
254
|
+
var theirs = x.current || { value: null, version: 0 };
|
|
255
|
+
asked.push(resolve(x.key, p, theirs, same));
|
|
256
|
+
} else if (same) { p.err = { error: x.error, message: x.message }; S.lastError = x.message || x.error; tell('error', { key: x.key, error: x.error, message: x.message }); }
|
|
257
|
+
});
|
|
258
|
+
persist();
|
|
259
|
+
return Promise.all(asked);
|
|
260
|
+
}) : Promise.resolve();
|
|
261
|
+
return saveStep.then(function () {
|
|
262
|
+
// 3. Stats: the sums and highs gathered since the last sync.
|
|
263
|
+
var st = S.stats;
|
|
264
|
+
if (!Object.keys(st.add).length && !Object.keys(st.max).length && !Object.keys(st.min).length) return;
|
|
265
|
+
S.stats = { add: {}, max: {}, min: {} }; persist();
|
|
266
|
+
return api('POST', '/api/player/stats/' + GAME, st, keepalive).then(function (r) {
|
|
267
|
+
if (r.player) adopt(r.player);
|
|
268
|
+
if (!r.ok && (r.status === 429 || r.status >= 500 || !r.status)) { restoreStats(st); throw new Error('server'); }
|
|
269
|
+
if (!r.ok) S.lastError = r.message || r.error;
|
|
270
|
+
}, function (e) { restoreStats(st); throw e; });
|
|
271
|
+
}).then(function () {
|
|
272
|
+
S.backoff = 0; S.lastSyncAt = Date.now(); online(true); persist();
|
|
273
|
+
tell('synced', { status: status() });
|
|
274
|
+
var left = Object.keys(S.pending).filter(function (k) { return !S.pending[k].err; }).length;
|
|
275
|
+
if (left) return run(keepalive);
|
|
276
|
+
}, function () { online(false); retryLater(); tell('status', { status: status() }); });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function restoreStats(st) {
|
|
280
|
+
['add', 'max', 'min'].forEach(function (kind) { Object.keys(st[kind]).forEach(function (k) {
|
|
281
|
+
var cur = S.stats[kind][k];
|
|
282
|
+
S.stats[kind][k] = cur === undefined ? st[kind][k] : kind === 'add' ? cur + st[kind][k] : kind === 'max' ? Math.max(cur, st[kind][k]) : Math.min(cur, st[kind][k]);
|
|
283
|
+
}); });
|
|
284
|
+
persist();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The server made this browser a guest (its first save), or the player changed name. */
|
|
288
|
+
function adopt(p) { if (!S.player || S.player.id !== p.id || S.player.guest !== p.guest || S.player.name !== p.name) setPlayer({ id: p.id, name: p.name, guest: p.guest, owner: p.owner }, true); }
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Another device saved this key since this browser's copy. The newer cloud copy wins, unless the game said it
|
|
292
|
+
* resolves conflicts: then it is asked (mine, theirs) and its answer is written on top of the cloud's version.
|
|
293
|
+
*/
|
|
294
|
+
function resolve(key, p, theirs, same) {
|
|
295
|
+
var mine = p ? (p.op === 'del' ? null : p.v) : null;
|
|
296
|
+
var takeTheirs = function () {
|
|
297
|
+
if (theirs.version) S.cache[key] = { v: theirs.value, ver: theirs.version, at: Date.now() }; else delete S.cache[key];
|
|
298
|
+
if (same) delete S.pending[key];
|
|
299
|
+
persist();
|
|
300
|
+
tell('conflict', { key: key, mine: mine, theirs: theirs.value, theirsVersion: theirs.version, kept: 'theirs' });
|
|
301
|
+
};
|
|
302
|
+
if (!S.resolver) { takeTheirs(); return Promise.resolve(); }
|
|
303
|
+
var id = ++askSeq;
|
|
304
|
+
return new Promise(function (done) {
|
|
305
|
+
var timer = setTimeout(function () { delete asks[id]; takeTheirs(); done(); }, 5000);
|
|
306
|
+
asks[id] = function (m) {
|
|
307
|
+
clearTimeout(timer);
|
|
308
|
+
if (m.keep === 'theirs' || m.value === undefined) { takeTheirs(); return done(); }
|
|
309
|
+
S.cache[key] = { v: theirs.value, ver: theirs.version, at: Date.now() };
|
|
310
|
+
S.pending[key] = { op: m.value === null ? 'del' : 'set', v: m.value, base: theirs.version, n: ++S.seq };
|
|
311
|
+
persist();
|
|
312
|
+
tell('conflict', { key: key, mine: mine, theirs: theirs.value, theirsVersion: theirs.version, kept: 'game' });
|
|
313
|
+
S.again = true;
|
|
314
|
+
done();
|
|
315
|
+
};
|
|
316
|
+
tell('conflict', { key: key, mine: mine, theirs: theirs.value, theirsVersion: theirs.version, ask: id });
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
addEventListener('online', function () { online(true); S.backoff = 0; flush(); });
|
|
321
|
+
addEventListener('offline', function () { online(false); });
|
|
322
|
+
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flush(true); });
|
|
323
|
+
addEventListener('pagehide', function () { flush(true); });
|
|
324
|
+
|
|
325
|
+
/* -------------------------------------------------- who is playing, and signing in */
|
|
326
|
+
var sheet = null;
|
|
327
|
+
function paintWho() {
|
|
328
|
+
var row = document.querySelector('[data-who]');
|
|
329
|
+
if (!row) return;
|
|
330
|
+
var name = row.querySelector('[data-who-name]');
|
|
331
|
+
var btn = row.querySelector('[data-who-act]');
|
|
332
|
+
if (S.player && !S.player.guest) { name.textContent = S.player.name; btn.textContent = 'Account'; btn.onclick = function () { location.href = '/account/?next=' + encodeURIComponent(location.pathname + location.search); }; }
|
|
333
|
+
else { name.textContent = S.player ? S.player.name + ' (guest)' : 'Guest'; btn.textContent = 'Save my progress'; btn.onclick = function () { openSheet(''); }; }
|
|
334
|
+
row.hidden = false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function openSheet(reason) {
|
|
338
|
+
if (sheet) return;
|
|
339
|
+
var signedIn = S.player && !S.player.guest;
|
|
340
|
+
if (signedIn) { tell('player', { player: me(), cache: snapshot(), same: true }); return; }
|
|
341
|
+
sheet = document.createElement('div');
|
|
342
|
+
sheet.className = 'signin';
|
|
343
|
+
sheet.setAttribute('role', 'dialog');
|
|
344
|
+
sheet.setAttribute('aria-label', 'Keep your progress');
|
|
345
|
+
var box = document.createElement('div'); box.className = 'box';
|
|
346
|
+
var h = document.createElement('h2'); h.textContent = 'Keep your progress';
|
|
347
|
+
var p = document.createElement('p'); p.textContent = reason || 'Make an account with a passkey (Face ID, a fingerprint or your PIN) and your progress follows you to every device. No password, no email.';
|
|
348
|
+
var input = document.createElement('input'); input.placeholder = 'Your name (optional)'; input.maxLength = 24; input.autocomplete = 'nickname';
|
|
349
|
+
if (S.player && S.player.name) input.placeholder = S.player.name;
|
|
350
|
+
var acts = document.createElement('div'); acts.className = 'acts';
|
|
351
|
+
var make = document.createElement('button'); make.className = 'primary'; make.type = 'button'; make.textContent = 'Make an account';
|
|
352
|
+
var inn = document.createElement('button'); inn.type = 'button'; inn.textContent = 'I have one: sign in';
|
|
353
|
+
var later = document.createElement('button'); later.type = 'button'; later.textContent = 'Not now';
|
|
354
|
+
var said = document.createElement('p'); said.className = 'said'; said.setAttribute('role', 'status');
|
|
355
|
+
var note = document.createElement('p'); note.className = 'note';
|
|
356
|
+
note.append('On this studio only. ');
|
|
357
|
+
var more = document.createElement('a'); more.href = '/account/'; more.target = '_blank'; more.rel = 'noopener'; more.textContent = 'What is kept';
|
|
358
|
+
note.append(more);
|
|
359
|
+
acts.append(make, inn, later);
|
|
360
|
+
box.append(h, p, input, acts, said, note);
|
|
361
|
+
sheet.append(box);
|
|
362
|
+
document.body.append(sheet);
|
|
363
|
+
// Fetched now, so the tap reaches the passkey prompt at once (Safari wants the tap).
|
|
364
|
+
P.prime('up', '/api/player/signup/options'); P.prime('in', '/api/player/signin/options');
|
|
365
|
+
function close() { if (sheet) { sheet.remove(); sheet = null; try { frame.focus(); } catch (e) {} } }
|
|
366
|
+
function busy(on) { make.disabled = inn.disabled = on; }
|
|
367
|
+
later.onclick = close;
|
|
368
|
+
sheet.addEventListener('pointerdown', function (e) { if (e.target === sheet) close(); });
|
|
369
|
+
make.onclick = function () {
|
|
370
|
+
busy(true); said.className = 'said'; said.textContent = 'Follow your device…';
|
|
371
|
+
flush().then(function () { return P.signUp(input.value.trim()); }).then(function (r) {
|
|
372
|
+
setPlayer({ id: r.player.id, name: r.player.name, guest: false, owner: r.player.owner }, true);
|
|
373
|
+
return refresh().then(function () { tell('player', { player: me(), cache: snapshot() }); close(); });
|
|
374
|
+
}).catch(function (e) { said.className = 'said bad'; said.textContent = e && e.name ? P.said(e) : (e && e.message) || 'That did not work.'; busy(false); P.prime('up', '/api/player/signup/options'); });
|
|
375
|
+
};
|
|
376
|
+
inn.onclick = function () {
|
|
377
|
+
busy(true); said.className = 'said'; said.textContent = 'Follow your device…';
|
|
378
|
+
flush().then(function () { return P.signIn(); }).then(function (r) {
|
|
379
|
+
setPlayer({ id: r.player.id, name: r.player.name, guest: false, owner: r.player.owner }, false);
|
|
380
|
+
return refresh().then(function () { tell('player', { player: me(), cache: snapshot() }); close(); });
|
|
381
|
+
}).catch(function (e) { said.className = 'said bad'; said.textContent = e && e.name ? P.said(e) : (e && e.message) || 'That did not work.'; busy(false); P.prime('in', '/api/player/signin/options'); });
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* -------------------------------------------------- start */
|
|
386
|
+
restore();
|
|
387
|
+
var loaded = refresh().then(function () { paintWho(); return flush(); }, function () { paintWho(); });
|
|
388
|
+
}());`;
|