@homie-rocks/studio 0.46.2 → 0.46.5
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 +106 -0
- package/bin/homie-studio.mjs +29 -10
- package/guides/game/RULES.md +127 -3
- package/guides/game/SKILL.md +7 -0
- package/lib/check-site.mjs +85 -0
- package/lib/check.mjs +2 -1
- package/lib/rules-check-worker.mjs +1 -1
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +12 -0
- package/netplay/netplay.ts +3 -1
- package/package.json +1 -1
- package/rules/core.ts +29 -4
- package/rules/host.ts +94 -14
- package/rules/pack.ts +1 -0
- package/rules/rules.ts +24 -7
- package/rules/types.ts +5 -0
- package/rules/view.ts +291 -33
- package/saves/SAVES.md +83 -3
- package/saves/saves.ts +2 -0
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +20 -2
- package/worker/pages.mjs +46 -1
- package/worker/players.mjs +13 -15
- package/worker/room.mjs +28 -3
- package/worker/saves-shell.mjs +22 -2
- package/worker/saves.mjs +282 -19
- package/worker/version.mjs +1 -1
package/saves/SAVES.md
CHANGED
|
@@ -64,8 +64,8 @@ landing. Pressing Play never needs an account.
|
|
|
64
64
|
security key. No password, no email. A guest who makes an account keeps everything: it is the same player.
|
|
65
65
|
- **Another device**: "Sign in" with the same passkey. The device's own passkey sync carries it (iCloud Keychain,
|
|
66
66
|
Google Password Manager, a password manager), or the browser offers a phone's QR code. The same saves are there.
|
|
67
|
-
A guest who signs in to an account they already have
|
|
68
|
-
|
|
67
|
+
A guest who signs in to an account they already have loses nothing: what only the guest had (keys, stats,
|
|
68
|
+
memorials) moves over, and a key both have is combined ("When a guest signs in to their account", below).
|
|
69
69
|
- **Recovery**: add another passkey on another device or a security key (`/account/`). A **recovery email** is
|
|
70
70
|
offered only when the studio has a mail sender: a Cloudflare Email Service binding named `PLAYER_MAIL`
|
|
71
71
|
(`"send_email": [{ "name": "PLAYER_MAIL" }]` in wrangler.jsonc; Email Sending needs the Workers paid plan and a
|
|
@@ -130,6 +130,82 @@ an old offline copy of a dead hero is a conflict with the wipe, and the wipe win
|
|
|
130
130
|
|
|
131
131
|
A write is atomic **per key**, not across keys: keep one character in one key.
|
|
132
132
|
|
|
133
|
+
## When a guest signs in to their account
|
|
134
|
+
|
|
135
|
+
Somebody plays as a guest, then signs in to the account they made on another device. Both players have saves.
|
|
136
|
+
Whatever only the guest had moves to the account as it is (keys, stats, memorials), and the guest is gone. For a
|
|
137
|
+
key **both** have, the game says how the two combine, once, when the studio's Worker loads
|
|
138
|
+
(`site/src/worker.mjs`):
|
|
139
|
+
|
|
140
|
+
```js
|
|
141
|
+
import { mergeSaves } from '@homie-rocks/studio/worker';
|
|
142
|
+
|
|
143
|
+
mergeSaves({
|
|
144
|
+
'night-run': {
|
|
145
|
+
saves: {
|
|
146
|
+
'server:career-v1': { union: 'matches', by: 'id', order: 'endedAt', keep: 100 },
|
|
147
|
+
settings: 'newer', 'best-level': 'larger', coins: 'sum', 'slot-*': 'account',
|
|
148
|
+
inventory: (guest, account, ctx) => ({ items: [...new Set([...account.items, ...guest.items])] }),
|
|
149
|
+
},
|
|
150
|
+
stats: { kills: 'sum', level: 'larger', 'best-time': 'smaller' },
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A name is the exact key, or a prefix ending in `*`. The exact key beats a prefix; a longer prefix beats a shorter
|
|
156
|
+
one. A rule that is none of the ones below throws when the Worker loads, naming it.
|
|
157
|
+
|
|
158
|
+
| A save's rule | The account ends with |
|
|
159
|
+
| --- | --- |
|
|
160
|
+
| `'account'`, `'guest'` | that side's value. |
|
|
161
|
+
| `'newer'` | the one written last (a tie: the account's). |
|
|
162
|
+
| `'larger'`, `'smaller'` | the larger or smaller of two numbers. `{ rule: 'larger', by: 'level' }` compares that number in two objects and keeps the whole object. |
|
|
163
|
+
| `'sum'` | two numbers added; or two objects with every number they share added, and for anything else the account's (the guest's where the account has none). |
|
|
164
|
+
| `{ union: 'matches', by: 'id', order: 'endedAt', keep: 100 }` | the account's object with that list joined to the guest's by `by` (on the same id, the account's entry), newest `order` first, cut to `keep`. `order` and `keep` are optional; `'union'` alone is exactly the rule shown. A joined list too large for a save loses entries from its end until it fits. |
|
|
165
|
+
| `(guest, account, ctx) => value` | what the game's own function returns, run on the server. It may be async. `ctx` is `{ game, key, guest: { updatedAt, version }, account: { updatedAt, version } }`. |
|
|
166
|
+
|
|
167
|
+
A stat's rule is `'sum'`, `'larger'`, `'smaller'`, `'guest'` or `'account'`.
|
|
168
|
+
|
|
169
|
+
**With no rule**, and whenever a rule cannot apply (two values that are not numbers to compare, a function that
|
|
170
|
+
throws, returns nothing, or returns more than a save holds), the default applies. It never fails the sign-in:
|
|
171
|
+
|
|
172
|
+
1. Equal values: nothing to do.
|
|
173
|
+
2. Two values of the **record shape** the room server writes (`{ matches: [{ id, … }] }`, every `id` a text):
|
|
174
|
+
joined by id, newest first. For a `server:` key the newest 100 are kept, the record's own bound; only the server
|
|
175
|
+
can write such a key, so nothing else could combine the two. A key the browser wrote that happens to have this
|
|
176
|
+
shape is joined the same way and not cut (it is cut only if it would not fit a save). Declare `'account'` for a
|
|
177
|
+
key of that shape that must not be joined.
|
|
178
|
+
3. Anything else: the account's value stays, and the guest's is **handed back** to the browser that signed in. The
|
|
179
|
+
play page raises it as an ordinary conflict, `mine` the guest's value and `theirs` the account's: a game with
|
|
180
|
+
`onConflict` keeps or merges it, and its answer is written on top; without `onConflict` the account's stays and
|
|
181
|
+
the game hears `conflict` with `kept: 'theirs'`. A stat keeps the account's number.
|
|
182
|
+
|
|
183
|
+
The sign-in answers with what happened, beside `adoptedGuest: true`:
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
adopted: {
|
|
187
|
+
merged: [{ game, key, rule, took }], // took: 'merged', 'guest' or 'account' (a rule chose it)
|
|
188
|
+
moved: 3, // keys only the guest had
|
|
189
|
+
kept: [{ game, key, value }], // case 3; `rule` and `error` when a declared rule could not apply
|
|
190
|
+
stats: { merged: [{ game, name, rule }], kept: [{ game, name, value }] },
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
`kept` carries at most 256 KiB of values; past that a key is named with `truncated: true` and the play page uses
|
|
195
|
+
its own copy of the guest's value. A sign-in on `/account/` has no game to ask, so there the account's simply stays.
|
|
196
|
+
|
|
197
|
+
It happens **exactly once**, in the one database transaction of the sign-in. If a room writes a record to either
|
|
198
|
+
player between the read and that transaction, the transaction fails whole, and it is read and tried again; after
|
|
199
|
+
four tries the account keeps every key both have and the answer says `fallback: true`. A round that ended a moment
|
|
200
|
+
before the sign-in writes its record a moment after, under the guest's id: the guest leaves a forwarding row
|
|
201
|
+
(`saves`, game `''`, key `server:moved`) and the record follows it to the account.
|
|
202
|
+
|
|
203
|
+
**What is new and what is a fix.** `mergeSaves` and its rules are opt-in: a studio that declares nothing has the
|
|
204
|
+
results it always had for every key and stat (the account's stays). Two things changed for everyone, both fixes:
|
|
205
|
+
server records are joined (a guest's verified matches used to vanish), and a value the account did not take is
|
|
206
|
+
handed back instead of dropped. A merged value larger than 64 KiB falls to the default; a merge never counts
|
|
207
|
+
against a game's 256 KiB.
|
|
208
|
+
|
|
133
209
|
## The hall of the fallen: a hardcore recipe
|
|
134
210
|
|
|
135
211
|
```ts
|
|
@@ -182,6 +258,10 @@ Migration `site/migrations/0004_players.sql` (deploy applies it; an older studio
|
|
|
182
258
|
| `player_emails` | a recovery email, only if the player added one | |
|
|
183
259
|
| `saves`, `player_stats`, `memorials` | what the game saved | anything the game did not |
|
|
184
260
|
|
|
261
|
+
A guest who signed in to their account leaves two small rows in `saves` under game `''`: `server:moved` under the
|
|
262
|
+
guest's id (the account's id and when) and `server:from:<guest>` under the account's. They are in no export and no
|
|
263
|
+
count, and they are deleted with the account.
|
|
264
|
+
|
|
185
265
|
- **The player** sees all of it at `/account/`, downloads it as one JSON file (`/api/player/export`), and deletes
|
|
186
266
|
it all (`/api/player/delete`): their passkeys, sessions, saves, stats, memorials and email, and their large
|
|
187
267
|
saves in R2. The page tells them to remove the passkey from their device too, and tells the device itself where
|
|
@@ -227,7 +307,7 @@ Same origin only (a POST must come from this site's own pages, as JSON). For ref
|
|
|
227
307
|
| --- | --- |
|
|
228
308
|
| `GET /api/player/me` | the signed-in player, the features (`accounts`, `email`), the limits |
|
|
229
309
|
| `POST /api/player/signup/options`, `POST /api/player/signup` | make an account, or add a passkey to this browser's guest or account |
|
|
230
|
-
| `POST /api/player/signin/options`, `POST /api/player/signin` | sign in with a passkey (discoverable: no name typed) |
|
|
310
|
+
| `POST /api/player/signin/options`, `POST /api/player/signin` | sign in with a passkey (discoverable: no name typed): `{ ok, player, adoptedGuest, adopted? }` |
|
|
231
311
|
| `POST /api/player/signout`, `POST /api/player/name`, `GET /api/player/passkeys`, `POST /api/player/passkeys/remove` | |
|
|
232
312
|
| `GET /api/player/export`, `POST /api/player/delete` (`{ "confirm": "delete" }`) | everything, and nothing |
|
|
233
313
|
| `GET /api/player/saves/<game>[?values=1]`, `GET /api/player/saves/<game>/<key>` | read |
|
package/saves/saves.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface SavesOptions {
|
|
|
54
54
|
* Another device saved a key since this one's copy (this browser was offline, or two devices played at once).
|
|
55
55
|
* Return the value to keep (merge them, or return `mine`), or `undefined` for the cloud's (the default: a stale
|
|
56
56
|
* offline copy never overwrites newer progress, so a hardcore character cannot come back from the dead).
|
|
57
|
+
* It is also asked when a guest signs in to an account that has the same key with another value and no
|
|
58
|
+
* `mergeSaves` rule combined them on the server: `mine` is the guest's value, `theirs` the account's.
|
|
57
59
|
*/
|
|
58
60
|
onConflict?: (c: Conflict) => unknown | Promise<unknown>;
|
|
59
61
|
/** Tests and custom shells: where messages go (default: the parent frame) and the shell's answer window. */
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.46.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.46.5","sha256":"6e01cf0c45cbe8649134cb3332feed0ba34c9ac39f3431d4c8374e0237345c0b"});
|
package/worker/index.mjs
CHANGED
|
@@ -92,7 +92,7 @@ import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room
|
|
|
92
92
|
import { hostedGame, hostedBuild, compiledFor, startHost } from './hosted.mjs';
|
|
93
93
|
import { roomStore, restoreDelay } from './room-store.mjs';
|
|
94
94
|
import { appCors, isAppOrigin } from './standalone.mjs';
|
|
95
|
-
import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, watchPage } from './pages.mjs';
|
|
95
|
+
import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, shellKey, shellStandIn, watchPage } from './pages.mjs';
|
|
96
96
|
import {
|
|
97
97
|
PUBLIC_SERVER, SERVER_ID, homeOf, memberCounts, memberOf, noteMember, policyOf, pooledRoom, roomCode, roomServer, serverAccess, serverPassOf, serverView, serversOf,
|
|
98
98
|
setMembership,
|
|
@@ -143,6 +143,8 @@ export { usePlayers } from './office.mjs';
|
|
|
143
143
|
export { countVisit } from './stats.mjs';
|
|
144
144
|
/** Players and cloud saves (saves/SAVES.md): `players.list`, `players.get`, `players.isOwner`… for the back office. */
|
|
145
145
|
export { players, cleanName } from './players.mjs';
|
|
146
|
+
/** How a guest's saves combine with the account's when they sign in (saves/SAVES.md): the studio's Worker calls it once, at load. */
|
|
147
|
+
export { mergeSaves } from './saves.mjs';
|
|
146
148
|
|
|
147
149
|
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
148
150
|
const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
|
|
@@ -1224,6 +1226,17 @@ async function route(request, env, ctx) {
|
|
|
1224
1226
|
if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
|
|
1225
1227
|
if (url.searchParams.get('embed') === '1' && (!playerEnabled(cat, meta) || launch !== 'public')) return notFoundPage('Embedding is off for this game.', cat);
|
|
1226
1228
|
const who = await ticketSub(env, game, url.searchParams.get('t'));
|
|
1229
|
+
// A play page left open across a deploy (0.46.5). One that names what it was made for (`sh`) and is behind is
|
|
1230
|
+
// told so, and reloads itself whole, once, to the same room and seat. One from before that handshake is sent
|
|
1231
|
+
// to a fresh page only when this game cannot work inside it: the game keeps verified results for a player, and
|
|
1232
|
+
// the old page has no player ticket to give its frame (a result would have nobody to be written for).
|
|
1233
|
+
if (url.searchParams.get('pf') === '1' && url.searchParams.get('embed') !== '1' && url.searchParams.get('want') !== 'screen' && !url.searchParams.has('watch')) {
|
|
1234
|
+
const said = url.searchParams.get('sh'), now = shellKey(meta);
|
|
1235
|
+
const ticketless = Boolean(meta.room?.records && env.DB) && !holders(who).some((x) => x.startsWith('p-'));
|
|
1236
|
+
if (said !== null ? said !== now : ticketless) {
|
|
1237
|
+
return new Response(shellStandIn(url.searchParams.get('room') ?? '', said !== null ? now : null), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } });
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1227
1240
|
if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('This game is not open to you.\n', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
|
|
1228
1241
|
// An agent's frame (hands `self`): the game plays as that AI.
|
|
1229
1242
|
const agentPart = holders(who).find((x) => x.startsWith('a-'));
|
|
@@ -1776,7 +1789,12 @@ export class Table {
|
|
|
1776
1789
|
sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');
|
|
1777
1790
|
const client=[...this.room.clients.values()].find(c=>c.seat!==null&&'p'+this.room.seats.get(c.seat)?.occ===owner);
|
|
1778
1791
|
const player=client?.player??sql.exec('SELECT player FROM round_players WHERE owner=?',owner).toArray()[0]?.player;
|
|
1779
|
-
if(!player)
|
|
1792
|
+
if(!player){
|
|
1793
|
+
// Never dropped in silence (0.46.5): the log says so, and the person is told on their own screen.
|
|
1794
|
+
try{console.error(JSON.stringify({at:new Date().toISOString(),ev:'record-unsaved',why:'no-player',game:this.game??null,room:this.code??null,key,id:value?.id??null}));}catch{}
|
|
1795
|
+
if(client)this.room.error(client,'unsaved','This round was not saved to your history. Reload the page to keep the next one.');
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1780
1798
|
sql.exec('CREATE TABLE IF NOT EXISTS round_outbox(id TEXT PRIMARY KEY,player TEXT,key TEXT,value TEXT)');
|
|
1781
1799
|
sql.exec('INSERT OR IGNORE INTO round_outbox VALUES(?,?,?,?)',player+':'+key+':'+value.id,player,key,JSON.stringify(value));
|
|
1782
1800
|
this.ctx.waitUntil(this.flushRoundRecords());
|
package/worker/pages.mjs
CHANGED
|
@@ -8,6 +8,36 @@ import { playerAncestors, PLAYER_SANDBOX, PLAYER_SIZE } from './embed.mjs';
|
|
|
8
8
|
import { esc, layout, gameSocialTags } from './site.mjs';
|
|
9
9
|
import { NET_PALETTE } from './room.mjs';
|
|
10
10
|
import { PARAM_VALUE, PREFS_LIMITS, playParams } from './seats.mjs';
|
|
11
|
+
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a play page was made for (0.46.5): the toolkit that wrote it and the parts of the game's manifest it is built
|
|
15
|
+
* around (who may sit, what the room keeps, whether the player has saves and a ticket). The page hands it to the
|
|
16
|
+
* game's frame (`sh`), and the Worker that serves the frame knows what a page made now would say. A page left open
|
|
17
|
+
* across a deploy says something older: it is brought up to date with one full reload, to the same room and seat,
|
|
18
|
+
* instead of hosting a newer game inside a page that lacks what that game needs (a player ticket, for one).
|
|
19
|
+
* It does not change when only the game's code or art does: that is still a reload of the frame alone.
|
|
20
|
+
*/
|
|
21
|
+
export function shellKey(g) {
|
|
22
|
+
// Never what names a build (the rules' build and state hashes, the bundle's version): those change with every
|
|
23
|
+
// edit, and a newer build of the same game inside the same page is exactly what the frame's own reload is for.
|
|
24
|
+
const { build: _build, stateHash: _state, ...room } = g?.room ?? {};
|
|
25
|
+
const { version: _version, ...netplay } = g?.netplay ?? {};
|
|
26
|
+
const text = JSON.stringify([g?.kind ?? null, g?.players ?? null, room, g?.saves ?? null, g?.agents ?? null, g?.screen ?? null, netplay, g?.launch ?? null]);
|
|
27
|
+
let h = 2166136261;
|
|
28
|
+
for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
29
|
+
return `${STUDIO_VERSION_TAG}-${(h >>> 0).toString(36)}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The frame's stand-in when its page is older than the game it would host. It tells the page, and loads no game.
|
|
33
|
+
* `key`: the page understands the handshake and reloads itself once. No key: a page from before the handshake, whose
|
|
34
|
+
* only way to load a whole new page is the one it has for leaving a finished room; it is used only when the game cannot
|
|
35
|
+
* work inside that page at all.
|
|
36
|
+
*/
|
|
37
|
+
export function shellStandIn(room, key) {
|
|
38
|
+
const message = key ? { t: 'homie-net', what: 'shell', key } : { t: 'homie-net', what: 'rematch', room };
|
|
39
|
+
return `<!doctype html><meta charset="utf-8"><title>Updating</title><body style="margin:0;background:#0b0d14;color:#cfd6e6;font:15px system-ui;display:grid;place-items:center;height:100vh"><p>This game was updated. Loading it again…</p><script>try{parent.postMessage(${JSON.stringify(message).replace(/</g, '\\u003c')},'*');}catch(e){}</script>`;
|
|
40
|
+
}
|
|
11
41
|
import { SKILLS } from './agents.mjs';
|
|
12
42
|
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
13
43
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
@@ -180,7 +210,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
|
|
|
180
210
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
181
211
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
182
212
|
// The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
|
|
183
|
-
const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
213
|
+
const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), sh: shellKey(g), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
184
214
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
185
215
|
const joinCorner = corner('join', 'bottom-right');
|
|
186
216
|
const first = places.desk;
|
|
@@ -497,6 +527,8 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
497
527
|
q.set('b', roomKey);
|
|
498
528
|
if (boot.embed) q.set('name', guestName);
|
|
499
529
|
if (boot.t) q.set('t', boot.t);
|
|
530
|
+
// What this page was made for (0.46.5): the Worker answers with a stand-in when a page made now would differ.
|
|
531
|
+
if (boot.sh) q.set('sh', shellSaid || boot.sh);
|
|
500
532
|
if (!boot.embed && params.get('name')) q.set('name', params.get('name'));
|
|
501
533
|
else if (!boot.embed) {
|
|
502
534
|
// A player with an account (or a named guest) on this studio plays under their own name in every room.
|
|
@@ -517,6 +549,18 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
517
549
|
}
|
|
518
550
|
state.params = {};
|
|
519
551
|
(boot.params || []).forEach(function (k) { var v = params.get(k); if (v !== null && new RegExp(boot.paramValue || '^$').test(v)) state.params[k] = v; });
|
|
552
|
+
// The Worker says this page is older than the game (a deploy while it was open): one full reload, which keeps the
|
|
553
|
+
// room (it is in the address) and the seat (its token is in this tab's storage). Once for each newer page: if the
|
|
554
|
+
// reload came back the same, the frame is asked for as the Worker described it and play goes on.
|
|
555
|
+
var shellSaid = '';
|
|
556
|
+
function shellBehind(key) {
|
|
557
|
+
var tried = '';
|
|
558
|
+
try { tried = sessionStorage.getItem('homie-shell') || ''; } catch (e) {}
|
|
559
|
+
if (tried === key) { shellSaid = key; frame.src = frameUrl(); return; }
|
|
560
|
+
try { sessionStorage.setItem('homie-shell', key); } catch (e) { shellSaid = key; frame.src = frameUrl(); return; }
|
|
561
|
+
state.shellReload = key;
|
|
562
|
+
location.reload();
|
|
563
|
+
}
|
|
520
564
|
var updating = buildRetry(function () {
|
|
521
565
|
state.stale = null; state.closed = null; state.reloaded = (state.reloaded || 0) + 1;
|
|
522
566
|
frame.src = frameUrl();
|
|
@@ -532,6 +576,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
532
576
|
var m = ev.data;
|
|
533
577
|
if (!m || typeof m !== 'object' || m.t !== 'homie-net') return;
|
|
534
578
|
A.message(m);
|
|
579
|
+
if (m.what === 'shell' && typeof m.key === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(m.key)) { shellBehind(m.key); return; }
|
|
535
580
|
if (m.what === 'wait') updating.stop();
|
|
536
581
|
if (m.what === 'build' && updating.acknowledge(m.ver) && toast) toast.hidden = true;
|
|
537
582
|
if (m.what === 'attached' || m.what === 'ready') updating.reported();
|
package/worker/players.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { toolIdentity } from './tool-identity.mjs';
|
|
|
28
28
|
import { handleFor } from './room.mjs';
|
|
29
29
|
import { isAiName } from './agents.mjs';
|
|
30
30
|
import { cookieValue } from './stats.mjs';
|
|
31
|
-
import {
|
|
31
|
+
import { adoptGuest, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
|
|
32
32
|
import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
|
|
33
33
|
import { ACCOUNT_JS, accountPage } from './account-page.mjs';
|
|
34
34
|
import { adoptShopStatements, migrated as shopMigrated, forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
|
|
@@ -693,21 +693,19 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
|
|
|
693
693
|
session.statement,
|
|
694
694
|
];
|
|
695
695
|
let adopted = null;
|
|
696
|
-
if (current && current.player.id !== key.player)
|
|
697
|
-
|
|
698
|
-
// A guest signing in to their account: what only the guest had moves over,
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
}
|
|
707
|
-
await env.DB.batch(writes);
|
|
708
|
-
if (adopted) await adopted.after();
|
|
696
|
+
if (current && current.player.id !== key.player) writes.push(env.DB.prepare('DELETE FROM player_sessions WHERE hash = ?1').bind(current.hash));
|
|
697
|
+
if (current && current.player.id !== key.player && current.player.guest) {
|
|
698
|
+
// A guest signing in to their account: what only the guest had moves over, what both have is combined by the
|
|
699
|
+
// game's rules (mergeSaves), then the guest is gone. One batch; read and tried again if a room wrote in between.
|
|
700
|
+
const guest = current.player.id;
|
|
701
|
+
const shop = await adoptShopStatements(env, guest, key.player);
|
|
702
|
+
adopted = await adoptGuest(env, guest, key.player, (statements) => [...writes, ...statements, ...shop,
|
|
703
|
+
env.DB.prepare('DELETE FROM player_sessions WHERE player = ?1').bind(guest),
|
|
704
|
+
env.DB.prepare('DELETE FROM player_challenges WHERE player = ?1').bind(guest),
|
|
705
|
+
env.DB.prepare('DELETE FROM players WHERE id = ?1 AND guest = 1').bind(guest)]);
|
|
706
|
+
} else await env.DB.batch(writes);
|
|
709
707
|
const player = { id: key.player, name: key.name, named: Number(key.named) === 1, guest: false, owner: Number(key.owner) === 1, since: Number(key.created_at) };
|
|
710
|
-
return withCookies(json({ ok: true, player: await meOf(env, player), adoptedGuest: Boolean(adopted) }), [session.cookie]);
|
|
708
|
+
return withCookies(json({ ok: true, player: await meOf(env, player), adoptedGuest: Boolean(adopted), ...(adopted ? { adopted } : {}) }), [session.cookie]);
|
|
711
709
|
}
|
|
712
710
|
|
|
713
711
|
/* ---------------- a recovery email, signed out: the link in the mail is the proof */
|
package/worker/room.mjs
CHANGED
|
@@ -452,7 +452,8 @@ export class NetRoom {
|
|
|
452
452
|
this.stats.evs += 1;
|
|
453
453
|
const out = { t: 'ev', from: Number.isInteger(m.from) ? m.from : null, k: kind, d: m.d ?? null };
|
|
454
454
|
const line = JSON.stringify(out);
|
|
455
|
-
|
|
455
|
+
// `to: -1` (0.46.3): everyone with no seat, a watcher. An effect with an audience reaches them whole.
|
|
456
|
+
if (Number.isInteger(m.to)) { for (const o of this.live()) if (m.to === -1 ? o.seat === null : o.seat === m.to) this.sendText(o.conn, line); return; }
|
|
456
457
|
for (const o of this.live()) if (!this.lite(o) || this.liteHears(kind)) this.sendText(o.conn, line);
|
|
457
458
|
return;
|
|
458
459
|
}
|
|
@@ -460,6 +461,25 @@ export class NetRoom {
|
|
|
460
461
|
}
|
|
461
462
|
}
|
|
462
463
|
|
|
464
|
+
/**
|
|
465
|
+
* One measurement of a connection's round trip (0.46.3), and what the rules are told: a LOW PERCENTILE of the last ten
|
|
466
|
+
* seconds of them. A measurement is never shorter than the real round trip, so the low end of a window is close to
|
|
467
|
+
* it and one slow answer does not raise it; a window, so a connection that really got slower or faster is followed.
|
|
468
|
+
* A client can still pretend to be slower by answering late every time: what that buys is bounded by the game's
|
|
469
|
+
* `room.rewindSeconds`, and with acknowledgements on the input frames it has to send its input late as well.
|
|
470
|
+
*/
|
|
471
|
+
latencySample(c, ms, now) {
|
|
472
|
+
if (!(ms >= 0 && ms <= 10000)) return;
|
|
473
|
+
const w = (c.rttWindow ??= []);
|
|
474
|
+
w.push([now, ms]);
|
|
475
|
+
while (w.length > 256 || (w.length > 1 && w[0][0] < now - 10000)) w.shift();
|
|
476
|
+
const sorted = w.map((x) => x[1]).sort((a, b) => a - b);
|
|
477
|
+
const low = sorted[Math.floor((sorted.length - 1) * 0.1)];
|
|
478
|
+
if (c.measuredRtt !== undefined && Math.abs(low - c.measuredRtt) < 2 && now - (c.rttToldAt ?? 0) < 1000) return;
|
|
479
|
+
c.measuredRtt = low; c.rttToldAt = now;
|
|
480
|
+
if (c.seat !== null) this.toServer({ t: 'latency', seat: c.seat, ms: low });
|
|
481
|
+
}
|
|
482
|
+
|
|
463
483
|
/** A snapshot from the host (a browser's socket `c`, or the server: `c` null), kept for joiners and fanned out. */
|
|
464
484
|
hostSnap(c, m, now, text = null) {
|
|
465
485
|
// The server's stamp is this relay's own clock; a browser's is its estimate of it, and is checked.
|
|
@@ -468,6 +488,7 @@ export class NetRoom {
|
|
|
468
488
|
// Revision 10: a rules game's snapshot names the room's epoch.
|
|
469
489
|
if ((!c || this.rules && c.rules) && Number.isFinite(m.e)) snap.e = m.e;
|
|
470
490
|
this.lastSnap = snap;
|
|
491
|
+
if (!c) { const sentAt = (this.snapAt ??= new Map()); sentAt.set(snap.k, now); if (sentAt.size > 128) sentAt.delete(sentAt.keys().next().value); }
|
|
471
492
|
if (c) c.lastSnapAt = now;
|
|
472
493
|
this.stats.snaps += 1;
|
|
473
494
|
this.snapTimes.push(now);
|
|
@@ -870,6 +891,9 @@ export class NetRoom {
|
|
|
870
891
|
if (this.server) {
|
|
871
892
|
if (!Array.isArray(m.s)) return;
|
|
872
893
|
this.stats.ins += 1;
|
|
894
|
+
// 0.46.3: the frame names the newest snapshot this browser had when it sent it (`g`). From when that snapshot
|
|
895
|
+
// left here to when this input arrived is this connection's round trip, measured on the input's own path.
|
|
896
|
+
if (Number.isInteger(m.g)) { c.inBand = true; const sent = this.snapAt?.get(m.g); if (sent !== undefined) this.latencySample(c, now - sent, now); }
|
|
873
897
|
this.toServer({ t: 'in', from: c.seat, e: Number(m.e) || 0, k: Number(m.k) || 0, s: m.s.slice(0, 64), r: Number(m.r) || 0 });
|
|
874
898
|
return;
|
|
875
899
|
}
|
|
@@ -909,7 +933,7 @@ export class NetRoom {
|
|
|
909
933
|
const recipients = isHost && this.rules && c.rules ? this.live() : this.others(c);
|
|
910
934
|
if (isHost) {
|
|
911
935
|
if (typeof m.to === 'string') { const o = this.clients.get(m.to); if (o && o.helloed && (o !== c || (this.rules && c.rules))) this.send(o, out); }
|
|
912
|
-
else if (Number.isInteger(m.to)) { for (const o of recipients) if (o.seat === m.to) this.send(o, out); }
|
|
936
|
+
else if (Number.isInteger(m.to)) { for (const o of recipients) if (m.to === -1 ? o.seat === null : o.seat === m.to) this.send(o, out); }
|
|
913
937
|
// An agent with no game client hears only what is addressed to its seat (the lite feed), and the party's lines.
|
|
914
938
|
else for (const o of recipients) { if (!this.lite(o) || this.liteHears(kind)) this.send(o, out); }
|
|
915
939
|
} else {
|
|
@@ -958,7 +982,8 @@ export class NetRoom {
|
|
|
958
982
|
case 'probeAck': {
|
|
959
983
|
if (c.probe && m.n === c.probe.n) {
|
|
960
984
|
const ms = now - c.probe.at; c.probe = null;
|
|
961
|
-
|
|
985
|
+
// A browser that acknowledges on its input frames is measured there; this separate answer is for one that does not.
|
|
986
|
+
if(ms>=0 && ms<=10000 && !c.inBand) this.latencySample(c, ms, now);
|
|
962
987
|
}
|
|
963
988
|
return;
|
|
964
989
|
}
|
package/worker/saves-shell.mjs
CHANGED
|
@@ -321,6 +321,25 @@ export const SAVES_SHELL_JS = `${PASSKEY_JS}
|
|
|
321
321
|
});
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/**
|
|
325
|
+
* This browser's guest signed in to an account that had some of the same keys with other values, and no rule of
|
|
326
|
+
* the game's combined them on the server (mergeSaves): the account's stayed, and the sign-in answered with the
|
|
327
|
+
* guest's. Each is an ordinary conflict, the guest's value (mine) against the account's (theirs), so a game with
|
|
328
|
+
* onConflict keeps or merges it. Never a server: key: a browser cannot write one.
|
|
329
|
+
*/
|
|
330
|
+
function keptByAccount(adopted, guestCopy) {
|
|
331
|
+
var asked = [];
|
|
332
|
+
((adopted && adopted.kept) || []).forEach(function (k) {
|
|
333
|
+
var key = String(k.key);
|
|
334
|
+
if (k.game !== GAME || key.indexOf('server:') === 0 || !KEY.test(key)) return;
|
|
335
|
+
var mine = k.value !== undefined ? k.value : guestCopy[key];
|
|
336
|
+
if (mine === undefined || mine === null) return;
|
|
337
|
+
var c = S.cache[key];
|
|
338
|
+
asked.push(resolve(key, { op: 'set', v: mine }, { value: c ? c.v : null, version: c ? c.ver : 0 }, false));
|
|
339
|
+
});
|
|
340
|
+
if (asked.length) Promise.all(asked).then(function () { flush(); });
|
|
341
|
+
}
|
|
342
|
+
|
|
324
343
|
addEventListener('online', function () { online(true); S.backoff = 0; flush(); });
|
|
325
344
|
addEventListener('offline', function () { online(false); });
|
|
326
345
|
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flush(true); });
|
|
@@ -385,9 +404,10 @@ export const SAVES_SHELL_JS = `${PASSKEY_JS}
|
|
|
385
404
|
};
|
|
386
405
|
inn.onclick = function () {
|
|
387
406
|
busy(true); said.className = 'said'; said.textContent = 'Follow your device…';
|
|
388
|
-
|
|
407
|
+
var guestCopy = {};
|
|
408
|
+
flush().then(function () { guestCopy = snapshot(); return P.signIn(); }).then(function (r) {
|
|
389
409
|
setPlayer({ id: r.player.id, name: r.player.name, guest: false, owner: r.player.owner }, false);
|
|
390
|
-
return refresh().then(function () { tell('player', { player: me(), cache: snapshot() }); close(); });
|
|
410
|
+
return refresh().then(function () { tell('player', { player: me(), cache: snapshot() }); close(); keptByAccount(r.adopted, guestCopy); });
|
|
391
411
|
}).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'); });
|
|
392
412
|
};
|
|
393
413
|
}
|