@zaisma/game-server 1.4.0 → 1.5.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/README.md +2 -0
- package/bench/brand/.assetsignore +4 -0
- package/bench/brand/README.md +51 -0
- package/bench/brand/_headers +13 -0
- package/bench/brand/app-icon-1024.png +0 -0
- package/bench/brand/app-icon-192.png +0 -0
- package/bench/brand/app-icon-512.png +0 -0
- package/bench/brand/apple-touch-icon.png +0 -0
- package/bench/brand/cover-guide/coin-cascade-busy-fine.webp +0 -0
- package/bench/brand/cover-guide/gem-grid-cover.webp +0 -0
- package/bench/brand/cover-guide/gem-grid-screenshot.webp +0 -0
- package/bench/brand/cover-guide/good-arena.webp +0 -0
- package/bench/brand/cover-guide/good-four-in-a-row.webp +0 -0
- package/bench/brand/cover-guide/lava-leap-extra-text.webp +0 -0
- package/bench/brand/cover-guide/lava-leap-fake-reward.webp +0 -0
- package/bench/brand/cover-guide/lava-leap-fine.webp +0 -0
- package/bench/brand/cover-guide/lava-leap-overlay.webp +0 -0
- package/bench/brand/cover-guide/screenshot-fine.webp +0 -0
- package/bench/brand/cover-guide/screenshot-not-fine.webp +0 -0
- package/bench/brand/design/covers/ashen-crown.webp +0 -0
- package/bench/brand/design/covers/frostline.webp +0 -0
- package/bench/brand/design/covers/glasswing.webp +0 -0
- package/bench/brand/design/covers/hollow-tide.webp +0 -0
- package/bench/brand/design/covers/iron-vigil.webp +0 -0
- package/bench/brand/design/covers/kestrel.webp +0 -0
- package/bench/brand/design/covers/neon-drift.webp +0 -0
- package/bench/brand/design/covers/verdant.webp +0 -0
- package/bench/brand/favicon-32.png +0 -0
- package/bench/brand/favicon.ico +0 -0
- package/bench/brand/favicon.svg +1 -0
- package/bench/brand/flags/LICENSE +21 -0
- package/bench/brand/flags/ad.png +0 -0
- package/bench/brand/flags/ae.png +0 -0
- package/bench/brand/flags/af.png +0 -0
- package/bench/brand/flags/ag.png +0 -0
- package/bench/brand/flags/ai.png +0 -0
- package/bench/brand/flags/al.png +0 -0
- package/bench/brand/flags/am.png +0 -0
- package/bench/brand/flags/ao.png +0 -0
- package/bench/brand/flags/aq.png +0 -0
- package/bench/brand/flags/ar.png +0 -0
- package/bench/brand/flags/as.png +0 -0
- package/bench/brand/flags/at.png +0 -0
- package/bench/brand/flags/au.png +0 -0
- package/bench/brand/flags/aw.png +0 -0
- package/bench/brand/flags/ax.png +0 -0
- package/bench/brand/flags/az.png +0 -0
- package/bench/brand/flags/ba.png +0 -0
- package/bench/brand/flags/bb.png +0 -0
- package/bench/brand/flags/bd.png +0 -0
- package/bench/brand/flags/be.png +0 -0
- package/bench/brand/flags/bf.png +0 -0
- package/bench/brand/flags/bg.png +0 -0
- package/bench/brand/flags/bh.png +0 -0
- package/bench/brand/flags/bi.png +0 -0
- package/bench/brand/flags/bj.png +0 -0
- package/bench/brand/flags/bl.png +0 -0
- package/bench/brand/flags/bm.png +0 -0
- package/bench/brand/flags/bn.png +0 -0
- package/bench/brand/flags/bo.png +0 -0
- package/bench/brand/flags/bq.png +0 -0
- package/bench/brand/flags/br.png +0 -0
- package/bench/brand/flags/bs.png +0 -0
- package/bench/brand/flags/bt.png +0 -0
- package/bench/brand/flags/bv.png +0 -0
- package/bench/brand/flags/bw.png +0 -0
- package/bench/brand/flags/by.png +0 -0
- package/bench/brand/flags/bz.png +0 -0
- package/bench/brand/flags/ca.png +0 -0
- package/bench/brand/flags/cc.png +0 -0
- package/bench/brand/flags/cd.png +0 -0
- package/bench/brand/flags/cf.png +0 -0
- package/bench/brand/flags/cg.png +0 -0
- package/bench/brand/flags/ch.png +0 -0
- package/bench/brand/flags/ci.png +0 -0
- package/bench/brand/flags/ck.png +0 -0
- package/bench/brand/flags/cl.png +0 -0
- package/bench/brand/flags/cm.png +0 -0
- package/bench/brand/flags/cn.png +0 -0
- package/bench/brand/flags/co.png +0 -0
- package/bench/brand/flags/cp.png +0 -0
- package/bench/brand/flags/cr.png +0 -0
- package/bench/brand/flags/cu.png +0 -0
- package/bench/brand/flags/cv.png +0 -0
- package/bench/brand/flags/cw.png +0 -0
- package/bench/brand/flags/cx.png +0 -0
- package/bench/brand/flags/cy.png +0 -0
- package/bench/brand/flags/cz.png +0 -0
- package/bench/brand/flags/de.png +0 -0
- package/bench/brand/flags/dg.png +0 -0
- package/bench/brand/flags/dj.png +0 -0
- package/bench/brand/flags/dk.png +0 -0
- package/bench/brand/flags/dm.png +0 -0
- package/bench/brand/flags/do.png +0 -0
- package/bench/brand/flags/dz.png +0 -0
- package/bench/brand/flags/ec.png +0 -0
- package/bench/brand/flags/ee.png +0 -0
- package/bench/brand/flags/eg.png +0 -0
- package/bench/brand/flags/eh.png +0 -0
- package/bench/brand/flags/er.png +0 -0
- package/bench/brand/flags/es.png +0 -0
- package/bench/brand/flags/et.png +0 -0
- package/bench/brand/flags/eu.png +0 -0
- package/bench/brand/flags/fi.png +0 -0
- package/bench/brand/flags/fj.png +0 -0
- package/bench/brand/flags/fk.png +0 -0
- package/bench/brand/flags/fm.png +0 -0
- package/bench/brand/flags/fo.png +0 -0
- package/bench/brand/flags/fr.png +0 -0
- package/bench/brand/flags/ga.png +0 -0
- package/bench/brand/flags/gb.png +0 -0
- package/bench/brand/flags/gd.png +0 -0
- package/bench/brand/flags/ge.png +0 -0
- package/bench/brand/flags/gf.png +0 -0
- package/bench/brand/flags/gg.png +0 -0
- package/bench/brand/flags/gh.png +0 -0
- package/bench/brand/flags/gi.png +0 -0
- package/bench/brand/flags/gl.png +0 -0
- package/bench/brand/flags/gm.png +0 -0
- package/bench/brand/flags/gn.png +0 -0
- package/bench/brand/flags/gp.png +0 -0
- package/bench/brand/flags/gq.png +0 -0
- package/bench/brand/flags/gr.png +0 -0
- package/bench/brand/flags/gs.png +0 -0
- package/bench/brand/flags/gt.png +0 -0
- package/bench/brand/flags/gu.png +0 -0
- package/bench/brand/flags/gw.png +0 -0
- package/bench/brand/flags/gy.png +0 -0
- package/bench/brand/flags/hk.png +0 -0
- package/bench/brand/flags/hm.png +0 -0
- package/bench/brand/flags/hn.png +0 -0
- package/bench/brand/flags/hr.png +0 -0
- package/bench/brand/flags/ht.png +0 -0
- package/bench/brand/flags/hu.png +0 -0
- package/bench/brand/flags/ic.png +0 -0
- package/bench/brand/flags/id.png +0 -0
- package/bench/brand/flags/ie.png +0 -0
- package/bench/brand/flags/il.png +0 -0
- package/bench/brand/flags/im.png +0 -0
- package/bench/brand/flags/in.png +0 -0
- package/bench/brand/flags/io.png +0 -0
- package/bench/brand/flags/iq.png +0 -0
- package/bench/brand/flags/ir.png +0 -0
- package/bench/brand/flags/is.png +0 -0
- package/bench/brand/flags/it.png +0 -0
- package/bench/brand/flags/je.png +0 -0
- package/bench/brand/flags/jm.png +0 -0
- package/bench/brand/flags/jo.png +0 -0
- package/bench/brand/flags/jp.png +0 -0
- package/bench/brand/flags/ke.png +0 -0
- package/bench/brand/flags/kg.png +0 -0
- package/bench/brand/flags/kh.png +0 -0
- package/bench/brand/flags/ki.png +0 -0
- package/bench/brand/flags/km.png +0 -0
- package/bench/brand/flags/kn.png +0 -0
- package/bench/brand/flags/kp.png +0 -0
- package/bench/brand/flags/kr.png +0 -0
- package/bench/brand/flags/kw.png +0 -0
- package/bench/brand/flags/ky.png +0 -0
- package/bench/brand/flags/kz.png +0 -0
- package/bench/brand/flags/la.png +0 -0
- package/bench/brand/flags/lb.png +0 -0
- package/bench/brand/flags/lc.png +0 -0
- package/bench/brand/flags/li.png +0 -0
- package/bench/brand/flags/lk.png +0 -0
- package/bench/brand/flags/lr.png +0 -0
- package/bench/brand/flags/ls.png +0 -0
- package/bench/brand/flags/lt.png +0 -0
- package/bench/brand/flags/lu.png +0 -0
- package/bench/brand/flags/lv.png +0 -0
- package/bench/brand/flags/ly.png +0 -0
- package/bench/brand/flags/ma.png +0 -0
- package/bench/brand/flags/mc.png +0 -0
- package/bench/brand/flags/md.png +0 -0
- package/bench/brand/flags/me.png +0 -0
- package/bench/brand/flags/mf.png +0 -0
- package/bench/brand/flags/mg.png +0 -0
- package/bench/brand/flags/mh.png +0 -0
- package/bench/brand/flags/mk.png +0 -0
- package/bench/brand/flags/ml.png +0 -0
- package/bench/brand/flags/mm.png +0 -0
- package/bench/brand/flags/mn.png +0 -0
- package/bench/brand/flags/mo.png +0 -0
- package/bench/brand/flags/mp.png +0 -0
- package/bench/brand/flags/mq.png +0 -0
- package/bench/brand/flags/mr.png +0 -0
- package/bench/brand/flags/ms.png +0 -0
- package/bench/brand/flags/mt.png +0 -0
- package/bench/brand/flags/mu.png +0 -0
- package/bench/brand/flags/mv.png +0 -0
- package/bench/brand/flags/mw.png +0 -0
- package/bench/brand/flags/mx.png +0 -0
- package/bench/brand/flags/my.png +0 -0
- package/bench/brand/flags/mz.png +0 -0
- package/bench/brand/flags/na.png +0 -0
- package/bench/brand/flags/nc.png +0 -0
- package/bench/brand/flags/ne.png +0 -0
- package/bench/brand/flags/nf.png +0 -0
- package/bench/brand/flags/ng.png +0 -0
- package/bench/brand/flags/ni.png +0 -0
- package/bench/brand/flags/nl.png +0 -0
- package/bench/brand/flags/no.png +0 -0
- package/bench/brand/flags/np.png +0 -0
- package/bench/brand/flags/nr.png +0 -0
- package/bench/brand/flags/nu.png +0 -0
- package/bench/brand/flags/nz.png +0 -0
- package/bench/brand/flags/om.png +0 -0
- package/bench/brand/flags/pa.png +0 -0
- package/bench/brand/flags/pc.png +0 -0
- package/bench/brand/flags/pe.png +0 -0
- package/bench/brand/flags/pf.png +0 -0
- package/bench/brand/flags/pg.png +0 -0
- package/bench/brand/flags/ph.png +0 -0
- package/bench/brand/flags/pk.png +0 -0
- package/bench/brand/flags/pl.png +0 -0
- package/bench/brand/flags/pm.png +0 -0
- package/bench/brand/flags/pn.png +0 -0
- package/bench/brand/flags/pr.png +0 -0
- package/bench/brand/flags/ps.png +0 -0
- package/bench/brand/flags/pt.png +0 -0
- package/bench/brand/flags/pw.png +0 -0
- package/bench/brand/flags/py.png +0 -0
- package/bench/brand/flags/qa.png +0 -0
- package/bench/brand/flags/re.png +0 -0
- package/bench/brand/flags/ro.png +0 -0
- package/bench/brand/flags/rs.png +0 -0
- package/bench/brand/flags/ru.png +0 -0
- package/bench/brand/flags/rw.png +0 -0
- package/bench/brand/flags/sa.png +0 -0
- package/bench/brand/flags/sb.png +0 -0
- package/bench/brand/flags/sc.png +0 -0
- package/bench/brand/flags/sd.png +0 -0
- package/bench/brand/flags/se.png +0 -0
- package/bench/brand/flags/sg.png +0 -0
- package/bench/brand/flags/sh.png +0 -0
- package/bench/brand/flags/si.png +0 -0
- package/bench/brand/flags/sj.png +0 -0
- package/bench/brand/flags/sk.png +0 -0
- package/bench/brand/flags/sl.png +0 -0
- package/bench/brand/flags/sm.png +0 -0
- package/bench/brand/flags/sn.png +0 -0
- package/bench/brand/flags/so.png +0 -0
- package/bench/brand/flags/sr.png +0 -0
- package/bench/brand/flags/ss.png +0 -0
- package/bench/brand/flags/st.png +0 -0
- package/bench/brand/flags/sv.png +0 -0
- package/bench/brand/flags/sx.png +0 -0
- package/bench/brand/flags/sy.png +0 -0
- package/bench/brand/flags/sz.png +0 -0
- package/bench/brand/flags/tc.png +0 -0
- package/bench/brand/flags/td.png +0 -0
- package/bench/brand/flags/tf.png +0 -0
- package/bench/brand/flags/tg.png +0 -0
- package/bench/brand/flags/th.png +0 -0
- package/bench/brand/flags/tj.png +0 -0
- package/bench/brand/flags/tk.png +0 -0
- package/bench/brand/flags/tl.png +0 -0
- package/bench/brand/flags/tm.png +0 -0
- package/bench/brand/flags/tn.png +0 -0
- package/bench/brand/flags/to.png +0 -0
- package/bench/brand/flags/tr.png +0 -0
- package/bench/brand/flags/tt.png +0 -0
- package/bench/brand/flags/tv.png +0 -0
- package/bench/brand/flags/tw.png +0 -0
- package/bench/brand/flags/tz.png +0 -0
- package/bench/brand/flags/ua.png +0 -0
- package/bench/brand/flags/ug.png +0 -0
- package/bench/brand/flags/um.png +0 -0
- package/bench/brand/flags/un.png +0 -0
- package/bench/brand/flags/us.png +0 -0
- package/bench/brand/flags/uy.png +0 -0
- package/bench/brand/flags/uz.png +0 -0
- package/bench/brand/flags/va.png +0 -0
- package/bench/brand/flags/vc.png +0 -0
- package/bench/brand/flags/ve.png +0 -0
- package/bench/brand/flags/vg.png +0 -0
- package/bench/brand/flags/vi.png +0 -0
- package/bench/brand/flags/vn.png +0 -0
- package/bench/brand/flags/vu.png +0 -0
- package/bench/brand/flags/wf.png +0 -0
- package/bench/brand/flags/ws.png +0 -0
- package/bench/brand/flags/xk.png +0 -0
- package/bench/brand/flags/xx.png +0 -0
- package/bench/brand/flags/ye.png +0 -0
- package/bench/brand/flags/yt.png +0 -0
- package/bench/brand/flags/za.png +0 -0
- package/bench/brand/flags/zm.png +0 -0
- package/bench/brand/flags/zw.png +0 -0
- package/bench/brand/fonts/OFL.txt +93 -0
- package/bench/brand/fonts/jost.7726a5cd.woff2 +0 -0
- package/bench/brand/loader/gif/zaisma-loader-black-128.gif +0 -0
- package/bench/brand/loader/gif/zaisma-loader-black-512.gif +0 -0
- package/bench/brand/loader/gif/zaisma-loader-white-128.gif +0 -0
- package/bench/brand/loader/gif/zaisma-loader-white-512.gif +0 -0
- package/bench/brand/loader/png/zaisma-loader-black-128.png +0 -0
- package/bench/brand/loader/png/zaisma-loader-black-512.png +0 -0
- package/bench/brand/loader/png/zaisma-loader-white-128.png +0 -0
- package/bench/brand/loader/png/zaisma-loader-white-512.png +0 -0
- package/bench/brand/loader/webp/zaisma-loader-black-128.webp +0 -0
- package/bench/brand/loader/webp/zaisma-loader-black-512.webp +0 -0
- package/bench/brand/loader/webp/zaisma-loader-white-128.webp +0 -0
- package/bench/brand/loader/webp/zaisma-loader-white-512.webp +0 -0
- package/bench/brand/loader/zaisma-loader-black.svg +1 -0
- package/bench/brand/loader/zaisma-loader-white.svg +1 -0
- package/bench/brand/loader/zaisma-loader.svg +1 -0
- package/bench/brand/sign-in/city-day-phone.webp +0 -0
- package/bench/brand/sign-in/city-day.webp +0 -0
- package/bench/brand/sign-in/city-night-phone.webp +0 -0
- package/bench/brand/sign-in/city-night.webp +0 -0
- package/bench/brand/zaisma-app-icon.svg +1 -0
- package/bench/brand/zaisma-mark-black-2048.png +0 -0
- package/bench/brand/zaisma-mark-black.svg +1 -0
- package/bench/brand/zaisma-mark-metal-2048.png +0 -0
- package/bench/brand/zaisma-mark-metal.svg +1 -0
- package/bench/brand/zaisma-mark-small-black.svg +1 -0
- package/bench/brand/zaisma-mark-small-white.svg +1 -0
- package/bench/brand/zaisma-mark-tricolor.svg +1 -0
- package/bench/brand/zaisma-mark-white-2048.png +0 -0
- package/bench/brand/zaisma-mark-white.svg +1 -0
- package/bench/brand/zaisma-wordmark-black-2400.png +0 -0
- package/bench/brand/zaisma-wordmark-black.svg +1 -0
- package/bench/brand/zaisma-wordmark-metal-2400.png +0 -0
- package/bench/brand/zaisma-wordmark-metal.svg +1 -0
- package/bench/brand/zaisma-wordmark-white-2400.png +0 -0
- package/bench/brand/zaisma-wordmark-white.svg +1 -0
- package/bench/glass.js +46466 -0
- package/bin/local-play-bench.d.ts +8 -0
- package/bin/local-play-bench.js +218 -0
- package/bin/local-play-certificate.js +99 -0
- package/bin/local-play-runner.js +43 -0
- package/bin/local-play.d.ts +41 -0
- package/bin/local-play.js +1567 -0
- package/bin/node.js +66 -1
- package/package.json +1 -1
- package/sdk/picture.js +97 -0
- package/sdk/relay.js +269 -0
- package/sdk/saves.js +884 -0
- package/sdk/zaisma.d.ts +1720 -0
- package/sdk/zaisma.js +2374 -0
- package/src/qr-code.d.ts +20 -0
- package/src/qr-code.js +319 -0
- package/src/room.js +1 -1
package/sdk/zaisma.js
ADDED
|
@@ -0,0 +1,2374 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The Zaisma SDK. Games include it with one tag, from the game's top folder:
|
|
3
|
+
* <script src="_zaisma/v1.js"></script>
|
|
4
|
+
* Plain JavaScript, no dependencies, MIT licensed. Its reference is zaisma.d.ts beside this file (served
|
|
5
|
+
* as _zaisma/v1.d.ts): every call, option, event and error code, explained once, there. Change the two
|
|
6
|
+
* together; test/node/sdk-types.test.ts checks that they match.
|
|
7
|
+
*
|
|
8
|
+
* On Zaisma, the games Worker defines __ZAISMA__ before this file with the rooms address, the game and
|
|
9
|
+
* the site. A game that isn't hosted on Zaisma gets "not_on_zaisma" for online play, serverUrl or not; serverUrl points a
|
|
10
|
+
* game hosted on Zaisma at your own server.
|
|
11
|
+
*/
|
|
12
|
+
(function () {
|
|
13
|
+
"use strict";
|
|
14
|
+
var config = typeof __ZAISMA__ === "object" && __ZAISMA__ ? __ZAISMA__ : {};
|
|
15
|
+
var ROOM_CODE = /^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{6}$/;
|
|
16
|
+
var SERVER_URL = /^(wss?|https?):\/\/[^/]/;
|
|
17
|
+
// A game's own server that friends can be sent to: secure addresses only.
|
|
18
|
+
var OWN_SERVER_URL = /^(wss|https):\/\/[^/]/;
|
|
19
|
+
|
|
20
|
+
// What each call takes. Anything else is refused, so a misspelled or old option never goes quietly unused.
|
|
21
|
+
var JOIN_OPTIONS = ["roomCode", "serverUrl", "isListed", "friendJoinsEnabled", "name", "labels", "joinData", "idleTimeoutSec", "idleCheckEnabled", "isSpectator"];
|
|
22
|
+
// quickJoin finds Zaisma rooms, so it takes no serverUrl.
|
|
23
|
+
var QUICK_JOIN_OPTIONS = ["isListed", "friendJoinsEnabled", "name", "labels", "joinData", "idleTimeoutSec", "idleCheckEnabled", "region"];
|
|
24
|
+
var LIST_OPTIONS = ["region", "labels"];
|
|
25
|
+
// move.join joins the room the move names, on this room's server, as a player: the game server moves only players.
|
|
26
|
+
var MOVE_JOIN_OPTIONS = JOIN_OPTIONS.filter(function (name) { return name !== "roomCode" && name !== "serverUrl" && name !== "isSpectator"; });
|
|
27
|
+
var KICK_OPTIONS = ["keepOut"];
|
|
28
|
+
var SEND_OPTIONS = ["delivery", "latestKey"];
|
|
29
|
+
var SMOOTHING_OPTIONS = ["delayMs"];
|
|
30
|
+
var DELIVERIES = ["reliable", "latest_only"];
|
|
31
|
+
var ROOM_INFO_FIELDS = ["name", "labels"];
|
|
32
|
+
var ROOM_EVENTS = ["message", "bytes", "state", "players", "connectionStatus", "ended", "ending", "kicked", "settings", "move", "moved", "idle", "away", "back", "muted", "error", "versionUpdate"];
|
|
33
|
+
var SAVES_EVENTS = ["conflict", "developerChange"];
|
|
34
|
+
var ITEMS_EVENTS = ["purchase", "refund", "sheetOpen", "sheetClose"];
|
|
35
|
+
var VERSIONS_EVENTS = ["update"];
|
|
36
|
+
var BUY_OPTIONS = ["itemCount", "gameAccountId"];
|
|
37
|
+
/** The room messages this SDK speaks (zaisma.d.ts: the wire protocol), sent in the hello. */
|
|
38
|
+
var PROTOCOL_VERSION_NUMBER = 1;
|
|
39
|
+
/** Reliable sends kept while the connection isn't open or the player is away: a second or two of a busy game. */
|
|
40
|
+
var MAXIMUM_QUEUED_SEND_COUNT = 60;
|
|
41
|
+
/**
|
|
42
|
+
* How the queue goes out once the player is in: 50 at once (the room's burst of 60, less room for what
|
|
43
|
+
* Zaisma says as it reconnects), then 3 every 100 ms (the room's 30 a second), so none comes back too_fast.
|
|
44
|
+
*/
|
|
45
|
+
var FLUSH_NOW_COUNT = 50;
|
|
46
|
+
var FLUSH_STEP_COUNT = 3;
|
|
47
|
+
var FLUSH_INTERVAL_MS = 100;
|
|
48
|
+
/** A send's size at most: the room's own limit for a message (too_big). */
|
|
49
|
+
var MAXIMUM_SEND_SIZE_BYTES = 16 * 1024;
|
|
50
|
+
/** Continent codes, for room lists and quickJoin's region. */
|
|
51
|
+
var REGIONS = ["AF", "AN", "AS", "EU", "NA", "OC", "SA"];
|
|
52
|
+
var MINIMUM_IDLE_TIMEOUT_SEC = 30;
|
|
53
|
+
var MAXIMUM_IDLE_TIMEOUT_SEC = 86400;
|
|
54
|
+
/** How long the game hears its player has to answer "Still there?" (Idle.answerTimeoutSec). */
|
|
55
|
+
var IDLE_ANSWER_TIMEOUT_SEC = 30;
|
|
56
|
+
/** The room closes connections with this as its match switches to a version published "now". */
|
|
57
|
+
var VERSION_UPDATE_CLOSE_CODE = 4004;
|
|
58
|
+
/** A real-time room's ping from this on shows as a weak connection beside the game. */
|
|
59
|
+
var WEAK_PING_MS = 150;
|
|
60
|
+
/** A latest-only send waits while the socket holds this much not yet sent, checked every LATEST_CHECK_MS. */
|
|
61
|
+
var BACKED_UP_SIZE_BYTES = 64 * 1024;
|
|
62
|
+
var LATEST_CHECK_MS = 50;
|
|
63
|
+
/** Latest-only sends waiting, one per latestKey (the room keeps the same many per player), and its latestKey's length. */
|
|
64
|
+
var MAXIMUM_LATEST_ONLY_SLOT_COUNT = 64;
|
|
65
|
+
var MAXIMUM_LATEST_KEY_CHARACTER_COUNT = 40;
|
|
66
|
+
/** room.smoothedState() draws this far behind the room's clock unless the game says, and keeps a second of views to draw from. */
|
|
67
|
+
var DEFAULT_SMOOTHING_DELAY_MS = 100;
|
|
68
|
+
var KEPT_VIEWS_DURATION_MS = 1000;
|
|
69
|
+
|
|
70
|
+
/** An Error with a code, as the SDK's errors all have. */
|
|
71
|
+
function codedError(code, message) {
|
|
72
|
+
var error = new Error(message);
|
|
73
|
+
error.code = code;
|
|
74
|
+
return error;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The error for a call that needs Zaisma, in a game running somewhere else (no __ZAISMA__). */
|
|
78
|
+
function notOnZaisma(message) {
|
|
79
|
+
return codedError("not_on_zaisma", message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The error for options a call doesn't take, naming the ones it does, or null when they're all fine. */
|
|
83
|
+
function unknownOption(given, valid, call) {
|
|
84
|
+
if (given === undefined || given === null) return null;
|
|
85
|
+
if (typeof given !== "object") return codedError("unknown_option", call + " takes an object of options: " + valid.join(", ") + ".");
|
|
86
|
+
var unknown = Object.keys(given).filter(function (name) { return valid.indexOf(name) === -1; });
|
|
87
|
+
if (!unknown.length) return null;
|
|
88
|
+
return codedError("unknown_option", call + " doesn't take " + unknown.join(", ") + ". It takes: " + valid.join(", ") + ".");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The game's page on Zaisma, or the tester link it was opened from (a private game has no public page).
|
|
92
|
+
function gamePage() {
|
|
93
|
+
return config.page || "/g/" + config.game;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Events by name. eventsOwner names whose events they are in a wrong name's error ("Rooms have"). */
|
|
97
|
+
function Emitter(events, eventsOwner) {
|
|
98
|
+
this._on = {};
|
|
99
|
+
this._events = events;
|
|
100
|
+
this._eventsOwner = eventsOwner || "Rooms have";
|
|
101
|
+
}
|
|
102
|
+
Emitter.prototype.on = function (event, listener) {
|
|
103
|
+
if (this._events && this._events.indexOf(event) === -1) throw codedError("unknown_event", 'There\'s no "' + event + '" event. ' + this._eventsOwner + ": " + this._events.join(", ") + ".");
|
|
104
|
+
(this._on[event] = this._on[event] || []).push(listener);
|
|
105
|
+
return this;
|
|
106
|
+
};
|
|
107
|
+
Emitter.prototype.off = function (event, listener) {
|
|
108
|
+
this._on[event] = (this._on[event] || []).filter(function (each) { return each !== listener; });
|
|
109
|
+
return this;
|
|
110
|
+
};
|
|
111
|
+
// Most events hand the game one thing; "bytes" hands two (the bytes, and whose they are).
|
|
112
|
+
Emitter.prototype._emit = function (event, payload, secondPayload) {
|
|
113
|
+
var given = arguments.length > 2 ? [payload, secondPayload] : [payload];
|
|
114
|
+
(this._on[event] || []).slice().forEach(function (listener) {
|
|
115
|
+
try { listener.apply(null, given); } catch (error) { console.error(error); }
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A call to Zaisma rooms. Errors answer { error: { code, message } }, which becomes an Error with that code;
|
|
121
|
+
* no connection, or an answer that isn't one of Zaisma's, is request_failed.
|
|
122
|
+
*/
|
|
123
|
+
function post(path, body) {
|
|
124
|
+
if (!config.rooms) return Promise.reject(notOnZaisma("This call goes to Zaisma, so it works only in games played on Zaisma."));
|
|
125
|
+
// text/plain keeps this a simple request, so the browser needs no preflight.
|
|
126
|
+
return fetch(config.rooms + path, { method: "POST", headers: { "Content-Type": "text/plain" }, body: JSON.stringify(body) }).then(answered, requestFailed);
|
|
127
|
+
}
|
|
128
|
+
function answered(response) {
|
|
129
|
+
return response.json().then(function (answer) {
|
|
130
|
+
if (response.ok) return answer;
|
|
131
|
+
var error = answer && answer.error;
|
|
132
|
+
if (error && error.code) throw codedError(error.code, error.message);
|
|
133
|
+
requestFailed();
|
|
134
|
+
}, requestFailed);
|
|
135
|
+
}
|
|
136
|
+
function requestFailed() {
|
|
137
|
+
throw codedError("request_failed", "Zaisma didn't answer, so check the connection and try again.");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
var passKey = "zaisma:pass:" + config.game;
|
|
141
|
+
var passPromise = null;
|
|
142
|
+
var keyPromise = null;
|
|
143
|
+
var UNCHECKED = "Zaisma couldn't check this browser, so it can't play online. Reload the game to try again.";
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The game page's key to player passes, as { key, playerKind }: whose passes it gives, the signed-in
|
|
147
|
+
* account's or a guest's. zaisma.com gives one when Cloudflare's check on Play says a real browser
|
|
148
|
+
* pressed it, so bots can't fill rooms; a game opened anywhere else gets none.
|
|
149
|
+
*/
|
|
150
|
+
function roomKey() {
|
|
151
|
+
if (!keyPromise) {
|
|
152
|
+
keyPromise = new Promise(function (resolve, reject) {
|
|
153
|
+
function fail(message) { reject(codedError("unchecked", message)); }
|
|
154
|
+
if (!config.site || window.parent === window) return fail("Online play works when the game is played on Zaisma.");
|
|
155
|
+
// The check usually answers in a second or two, or asks the player to tap a box.
|
|
156
|
+
var timer = setTimeout(function () { removeEventListener("message", onMessage); fail(UNCHECKED); }, 30000);
|
|
157
|
+
function onMessage(event) {
|
|
158
|
+
if (event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== "zaisma:room-key") return;
|
|
159
|
+
removeEventListener("message", onMessage);
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
if (typeof event.data.key === "string") resolve({ key: event.data.key, playerKind: event.data.playerKind === "account" ? "account" : "guest" });
|
|
162
|
+
else fail(UNCHECKED);
|
|
163
|
+
}
|
|
164
|
+
addEventListener("message", onMessage);
|
|
165
|
+
window.parent.postMessage({ type: "zaisma:room-key" }, config.site);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return keyPromise;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The pass this tab kept, while it has 10 minutes or more left, else null. */
|
|
172
|
+
function keptPass() {
|
|
173
|
+
try {
|
|
174
|
+
var kept = JSON.parse(sessionStorage.getItem(passKey) || "null");
|
|
175
|
+
return kept && Date.parse(kept.expiresAt) - Date.now() > 10 * 60 * 1000 ? kept : null;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return null; // storage off: fetch a new pass
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* This tab's player: { id, name, kind, pass, expiresAt }. The signed-in account, or a guest. Kept for
|
|
183
|
+
* the tab, so a guest who reloads stays the same guest and gets their seat back.
|
|
184
|
+
*/
|
|
185
|
+
function getPass() {
|
|
186
|
+
if (!config.rooms) return Promise.reject(notOnZaisma("Player passes come from Zaisma, so they work only in games played on Zaisma."));
|
|
187
|
+
if (!passPromise) {
|
|
188
|
+
passPromise = roomKey().then(function (given) {
|
|
189
|
+
// The kept pass, unless the player signed in or out since it was made.
|
|
190
|
+
var kept = keptPass();
|
|
191
|
+
if (kept && kept.kind === given.playerKind) return kept;
|
|
192
|
+
return post("/v1/pass", { gameSlug: config.game, roomKey: given.key }).then(function (answer) {
|
|
193
|
+
var mine = { id: answer.player.id, name: answer.player.name, kind: answer.player.kind, pass: answer.pass, expiresAt: answer.expiresAt };
|
|
194
|
+
try { sessionStorage.setItem(passKey, JSON.stringify(mine)); } catch (error) { /* fine */ }
|
|
195
|
+
return mine;
|
|
196
|
+
});
|
|
197
|
+
}, function (error) {
|
|
198
|
+
// No key this time (the check couldn't answer): the pass this tab kept still works.
|
|
199
|
+
var kept = keptPass();
|
|
200
|
+
if (kept) return kept;
|
|
201
|
+
throw error;
|
|
202
|
+
}).then(function (mine) {
|
|
203
|
+
passPromise = null;
|
|
204
|
+
return mine;
|
|
205
|
+
}, function (error) {
|
|
206
|
+
passPromise = null;
|
|
207
|
+
throw error;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return passPromise;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* An account's public profile by player ID: what anyone sees of it on zaisma.com/@username, in the SDK's
|
|
215
|
+
* camelCase. Guests have none (error.code "no_player").
|
|
216
|
+
*/
|
|
217
|
+
function getProfile(playerId) {
|
|
218
|
+
if (!config.rooms) return Promise.reject(notOnZaisma("Profiles come from Zaisma, so they work only in games played on Zaisma."));
|
|
219
|
+
if (typeof playerId !== "string" || !/^player_[a-z0-9]+$/.test(playerId)) return Promise.reject(codedError("bad_player_id", "playerId isn't a player ID like \"player_8f3k2m\": take it from room.players or Zaisma.player.getPass()."));
|
|
220
|
+
return fetch(config.rooms + "/v1/players/" + playerId).then(answered, requestFailed).then(function (answer) {
|
|
221
|
+
var profile = answer.player;
|
|
222
|
+
var game = function (playedGame) {
|
|
223
|
+
var shown = { slug: playedGame.slug, title: playedGame.title, pageUrl: playedGame.page_url };
|
|
224
|
+
if (playedGame.played_period) shown.playedPeriod = playedGame.played_period;
|
|
225
|
+
return shown;
|
|
226
|
+
};
|
|
227
|
+
return {
|
|
228
|
+
id: profile.id,
|
|
229
|
+
name: profile.name,
|
|
230
|
+
username: profile.username,
|
|
231
|
+
displayName: profile.display_name,
|
|
232
|
+
bio: profile.bio,
|
|
233
|
+
linkUrls: profile.link_urls,
|
|
234
|
+
profileUrl: profile.profile_url,
|
|
235
|
+
isProfilePrivate: profile.is_profile_private,
|
|
236
|
+
publishedGames: profile.published_games.map(game),
|
|
237
|
+
playedGames: profile.played_games.map(game),
|
|
238
|
+
playingNow: profile.playing_now ? game(profile.playing_now) : null,
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* Signing in while the game runs. The Zaisma page asks the player beside the game and signs them in in
|
|
245
|
+
* a new tab, so the game keeps running. However the player signs in (here, or the page's "Keep your
|
|
246
|
+
* progress"), the page says so (zaisma:signed-in): the room key was the guest's, so the next pass
|
|
247
|
+
* comes from the account's key (and the guest's kept pass, of the wrong kind, isn't used).
|
|
248
|
+
*/
|
|
249
|
+
var signInPromise = null;
|
|
250
|
+
var signInWaiters = [];
|
|
251
|
+
|
|
252
|
+
window.addEventListener("message", function (event) {
|
|
253
|
+
if (!config.site || event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== "zaisma:signed-in") return;
|
|
254
|
+
var given = event.data.player;
|
|
255
|
+
if (!given || typeof given.id !== "string" || typeof given.name !== "string") return;
|
|
256
|
+
keyPromise = null;
|
|
257
|
+
friendsAsked = null;
|
|
258
|
+
var signedIn = { id: given.id, name: given.name, kind: "account" };
|
|
259
|
+
signInWaiters.splice(0).forEach(function (waiter) { waiter.resolve(signedIn); });
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/*
|
|
263
|
+
* Ask the page for something while the game runs and wait for its answer (signIn, offerAccount): one
|
|
264
|
+
* promise at a time, resolved with the account once the player is signed in. The page answers at once
|
|
265
|
+
* that it's asking; no answer in 3 seconds means no Zaisma page is around the game. statuses maps the
|
|
266
|
+
* page's other answers to the error each one is.
|
|
267
|
+
*/
|
|
268
|
+
function askPage(messageType, notOnZaismaMessage, statuses) {
|
|
269
|
+
return new Promise(function (resolve, reject) {
|
|
270
|
+
// Signed in (zaisma:signed-in, above): stop listening for the page's answers.
|
|
271
|
+
var waiter = { resolve: function (signedIn) { settle(null); resolve(signedIn); } };
|
|
272
|
+
signInWaiters.push(waiter);
|
|
273
|
+
var isAsking = false;
|
|
274
|
+
var timer = setTimeout(function () { if (!isAsking) settle(notOnZaisma(notOnZaismaMessage)); }, 3000);
|
|
275
|
+
function settle(error) {
|
|
276
|
+
removeEventListener("message", onMessage);
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
signInWaiters = signInWaiters.filter(function (each) { return each !== waiter; });
|
|
279
|
+
if (error) reject(error);
|
|
280
|
+
}
|
|
281
|
+
function onMessage(event) {
|
|
282
|
+
if (event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== messageType) return;
|
|
283
|
+
var status = event.data.status;
|
|
284
|
+
if (status === "asking") isAsking = true;
|
|
285
|
+
else if (Object.prototype.hasOwnProperty.call(statuses, status)) settle(codedError(statuses[status][0], statuses[status][1]));
|
|
286
|
+
}
|
|
287
|
+
addEventListener("message", onMessage);
|
|
288
|
+
window.parent.postMessage({ type: messageType }, config.site);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Ask a guest to sign in, without leaving the game: the account, once they have (zaisma.d.ts: Zaisma.player.signIn). */
|
|
293
|
+
function signIn() {
|
|
294
|
+
if (!config.site || window.parent === window) return Promise.reject(notOnZaisma("Signing in works when the game is played on its zaisma.com page."));
|
|
295
|
+
if (signInPromise) return signInPromise;
|
|
296
|
+
signInPromise = askPage("zaisma:sign-in", "No Zaisma page answered: signing in works when the game is played on its zaisma.com page.", {
|
|
297
|
+
declined: ["sign_in_declined", "The player chose Not now. Ask again when they press something that needs an account, not on a timer."],
|
|
298
|
+
}).then(function (signedIn) {
|
|
299
|
+
signInPromise = null;
|
|
300
|
+
return signedIn;
|
|
301
|
+
}, function (error) {
|
|
302
|
+
signInPromise = null;
|
|
303
|
+
throw error;
|
|
304
|
+
});
|
|
305
|
+
return signInPromise;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Show Zaisma's "Keep your progress" card at the game's moment: the account, once the player has one (zaisma.d.ts: Zaisma.player.offerAccount). */
|
|
309
|
+
var offerPromise = null;
|
|
310
|
+
function offerAccount() {
|
|
311
|
+
if (!config.site || window.parent === window) return Promise.reject(notOnZaisma("The \"Keep your progress\" card shows when the game is played on its zaisma.com page."));
|
|
312
|
+
if (offerPromise) return offerPromise;
|
|
313
|
+
offerPromise = askPage("zaisma:account-offer", "No Zaisma page answered: the \"Keep your progress\" card shows when the game is played on its zaisma.com page.", {
|
|
314
|
+
declined: ["sign_in_declined", "The player chose Not now. Zaisma's card won't show again for 3 days; show your own message if you like."],
|
|
315
|
+
too_soon: ["account_offer_too_soon", "Zaisma's card showed this visit or the player chose Not now in the last 3 days. Show your own message instead."],
|
|
316
|
+
cloud_saves_off: ["cloud_saves_off", "This game has cloud saves off, so there's no progress for Zaisma's card to keep: ask with Zaisma.player.signIn() instead."],
|
|
317
|
+
cloud_saves_not_here: ["cloud_saves_not_here", "Cloud saves don't work here (a tester link, or a Zaisma without accounts), so there's no progress for Zaisma's card to keep. Save in the browser as usual."],
|
|
318
|
+
}).then(function (signedIn) {
|
|
319
|
+
offerPromise = null;
|
|
320
|
+
return signedIn;
|
|
321
|
+
}, function (error) {
|
|
322
|
+
offerPromise = null;
|
|
323
|
+
throw error;
|
|
324
|
+
});
|
|
325
|
+
return offerPromise;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Zaisma.player: this tab's player and their account (zaisma.d.ts: Zaisma.player). */
|
|
329
|
+
var player = { getPass: getPass, getProfile: getProfile, signIn: signIn, offerAccount: offerAccount };
|
|
330
|
+
|
|
331
|
+
function roomCodeFromPage() {
|
|
332
|
+
try {
|
|
333
|
+
var code = new URLSearchParams(location.search).get("room");
|
|
334
|
+
return code && ROOM_CODE.test(code.toUpperCase()) ? code.toUpperCase() : null;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Whether the page's link is a Watch (?watch=1 beside ?room=): the player came to watch that room, not to play. */
|
|
341
|
+
function watchFromPage() {
|
|
342
|
+
try {
|
|
343
|
+
return Boolean(roomCodeFromPage()) && new URLSearchParams(location.search).get("watch") === "1";
|
|
344
|
+
} catch (error) {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The game's own server a friend's Join names beside the room (?server=), or null. */
|
|
350
|
+
function serverUrlFromPage() {
|
|
351
|
+
try {
|
|
352
|
+
var given = new URLSearchParams(location.search).get("server");
|
|
353
|
+
return given && OWN_SERVER_URL.test(given) ? given.replace(/\/+$/, "") : null;
|
|
354
|
+
} catch (error) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function hostOf(address) {
|
|
360
|
+
try { return new URL(address).host; } catch (error) { return null; }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The linked room, for a join on this server (serverUrl, or none for Zaisma rooms): a link that names
|
|
365
|
+
* no server is for whichever the game uses, and one that names a server only for that one.
|
|
366
|
+
*/
|
|
367
|
+
function linkedRoomFor(serverUrl) {
|
|
368
|
+
var code = roomCodeFromPage();
|
|
369
|
+
var linkedServerUrl = serverUrlFromPage();
|
|
370
|
+
if (!code || !linkedServerUrl) return code;
|
|
371
|
+
return serverUrl && hostOf(String(serverUrl)) === hostOf(linkedServerUrl) ? code : null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function randomRoomCode() {
|
|
375
|
+
var alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
376
|
+
return Array.prototype.map.call(crypto.getRandomValues(new Uint8Array(6)), function (byte) { return alphabet[byte & 31]; }).join("");
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The idle check the game asked for in joinRoom's options: its timeout, or none at all. */
|
|
380
|
+
function clientIdle(options) {
|
|
381
|
+
var asked = {};
|
|
382
|
+
if (typeof options.idleTimeoutSec === "number") asked.idleTimeoutSec = options.idleTimeoutSec;
|
|
383
|
+
if (typeof options.idleCheckEnabled === "boolean") asked.idleCheckEnabled = options.idleCheckEnabled;
|
|
384
|
+
return asked;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A player as the room shows them, field by field: { id, name, kind }. */
|
|
388
|
+
function shownPlayerOf(given) {
|
|
389
|
+
given = given || {};
|
|
390
|
+
return { id: given.id, name: given.name, kind: given.kind };
|
|
391
|
+
}
|
|
392
|
+
/** The room's player list, each with why they aren't playing (awayReason), or null while they play. */
|
|
393
|
+
function playersOf(given) {
|
|
394
|
+
return (Array.isArray(given) ? given : []).map(function (each) {
|
|
395
|
+
var shown = shownPlayerOf(each);
|
|
396
|
+
shown.awayReason = typeof each.awayReason === "string" ? each.awayReason : null;
|
|
397
|
+
return shown;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function Room(setup) {
|
|
402
|
+
Emitter.call(this, ROOM_EVENTS);
|
|
403
|
+
this.roomCode = setup.roomCode;
|
|
404
|
+
this.me = null;
|
|
405
|
+
this.players = [];
|
|
406
|
+
this.hostPlayerId = null;
|
|
407
|
+
// The socket only: away is awayReason, not a connection status.
|
|
408
|
+
this.connectionStatus = "connecting";
|
|
409
|
+
this.awayReason = null;
|
|
410
|
+
this.inviteUrl = inviteUrlFor(this.roomCode, setup.serverUrl);
|
|
411
|
+
this.isListed = setup.isListed === true;
|
|
412
|
+
this.isOpen = true;
|
|
413
|
+
this.friendJoinsEnabled = setup.friendJoinsEnabled !== false;
|
|
414
|
+
this.name = null;
|
|
415
|
+
this.labels = {};
|
|
416
|
+
this.endsAt = null;
|
|
417
|
+
// Watching, not playing (joinRoom({ isSpectator: true })): sends nothing, and never counts as away.
|
|
418
|
+
this.isSpectator = setup.isSpectator === true;
|
|
419
|
+
this.spectatorCount = 0;
|
|
420
|
+
this._url = setup.url;
|
|
421
|
+
this._pass = setup.pass;
|
|
422
|
+
this._made = { isListed: this.isListed, friendJoinsEnabled: setup.friendJoinsEnabled === false ? false : undefined, name: setup.name, labels: setup.labels };
|
|
423
|
+
this._joinData = setup.joinData === undefined ? null : setup.joinData;
|
|
424
|
+
this._serverUrl = setup.serverUrl;
|
|
425
|
+
this._tries = 0;
|
|
426
|
+
this._done = false;
|
|
427
|
+
// The room's protocolVersionNumber from its welcome, or null before one (or from a room too old to say).
|
|
428
|
+
this._protocolVersionNumber = null;
|
|
429
|
+
this._isReloadingForOtherVersion = false;
|
|
430
|
+
this._ready = null;
|
|
431
|
+
// Walking away: the idle check the game asked for, when the room counts the player away (from welcome),
|
|
432
|
+
// whether they're out of the match (and their connection gone too), and the last ping, pong and message.
|
|
433
|
+
this._clientIdle = setup.clientIdle;
|
|
434
|
+
this._checks = { idleTimeoutSec: null, hiddenTimeoutSec: null };
|
|
435
|
+
this._away = false;
|
|
436
|
+
this._gone = false;
|
|
437
|
+
this._idleSent = false;
|
|
438
|
+
this._pingAtMs = 0;
|
|
439
|
+
this._pongAtMs = 0;
|
|
440
|
+
this._sentAtMs = 0;
|
|
441
|
+
// Ping and the room's clock (docs/DECISIONS.md, "Rooms under lag"): the room's probes carry this player's
|
|
442
|
+
// ping and the room's time. The last 8 clock offsets are kept across reconnects, since the room's clock doesn't change.
|
|
443
|
+
this.pingDurationMs = null;
|
|
444
|
+
this._clockOffsetsMs = [];
|
|
445
|
+
// How often a ticking room probes (from welcome), or null, and when this connection last heard from the room.
|
|
446
|
+
this._probeIntervalSec = null;
|
|
447
|
+
this._heardAtMs = 0;
|
|
448
|
+
// State sync (the game server's stateSyncEnabled): this player's view of the state, null until the
|
|
449
|
+
// first update, and the views of the last second with the room's time of each, for smoothedState().
|
|
450
|
+
this.state = null;
|
|
451
|
+
this._views = [];
|
|
452
|
+
// Latest-only sends waiting for the socket to drain, by "<kind>:<latestKey>", least recently replaced first.
|
|
453
|
+
this._slots = new Map();
|
|
454
|
+
this._slotTimer = null;
|
|
455
|
+
// Reliable sends waiting for the connection, in order, and whether this outage already said some were dropped.
|
|
456
|
+
this._queue = [];
|
|
457
|
+
this._flushTimer = null;
|
|
458
|
+
this._isQueueFullSaid = false;
|
|
459
|
+
this._isWelcomed = false;
|
|
460
|
+
}
|
|
461
|
+
Room.prototype = Object.create(Emitter.prototype);
|
|
462
|
+
|
|
463
|
+
/** The link that opens this room: on Zaisma, the game's page with ?room=, and &server= for a room on the game's own server. */
|
|
464
|
+
function inviteUrlFor(roomCode, serverUrl) {
|
|
465
|
+
if (!config.site || !config.game) return null;
|
|
466
|
+
return config.site + gamePage() + "?room=" + roomCode + (serverUrl && OWN_SERVER_URL.test(serverUrl) ? "&server=" + encodeURIComponent(serverUrl) : "");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Whether a send goes onto the socket now: open, the room's welcome heard (before it, a send would be taken for the hello), and the player not away. */
|
|
470
|
+
Room.prototype._canSend = function () {
|
|
471
|
+
return Boolean(this._ws && this._ws.readyState === 1 && this._isWelcomed && !this._away);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
/** The room's clock now, in epoch milliseconds, estimated from its probes: Date.now() until the first one. */
|
|
475
|
+
Room.prototype.serverTimeMs = function () {
|
|
476
|
+
if (!this._clockOffsetsMs.length) return Date.now();
|
|
477
|
+
// Queueing only delays a probe, which makes its sample smaller: the largest is the least delayed.
|
|
478
|
+
return Math.round(performance.timeOrigin + performance.now() + Math.max.apply(null, this._clockOffsetsMs));
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/** A probe from the room: answered at once, before anything else, and never a game message. */
|
|
482
|
+
Room.prototype._probe = function (message) {
|
|
483
|
+
// Straight onto the socket: an answer isn't something the game said, so it doesn't count for the "here" check.
|
|
484
|
+
if (this._ws && this._ws.readyState === 1) this._ws.send(JSON.stringify({ type: "probe_answer", probeNumber: message.probeNumber }));
|
|
485
|
+
if (typeof message.pingDurationMs === "number") this.pingDurationMs = message.pingDurationMs;
|
|
486
|
+
// The page beside the game shows a weak connection: told only when it starts or stops being weak.
|
|
487
|
+
var isWeak = this._probeIntervalSec !== null && this.pingDurationMs !== null && this.pingDurationMs >= WEAK_PING_MS;
|
|
488
|
+
if (isWeak !== this._isWeak) {
|
|
489
|
+
this._isWeak = isWeak;
|
|
490
|
+
this._tellConnection();
|
|
491
|
+
}
|
|
492
|
+
if (typeof message.serverTimeMs !== "number") return;
|
|
493
|
+
this._clockOffsetsMs.push(message.serverTimeMs + (this.pingDurationMs || 0) / 2 - (performance.timeOrigin + performance.now()));
|
|
494
|
+
if (this._clockOffsetsMs.length > 8) this._clockOffsetsMs.shift();
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
/** Send a message to the room if the connection is open. */
|
|
498
|
+
Room.prototype._say = function (message) {
|
|
499
|
+
if (!this._ws || this._ws.readyState !== 1) return;
|
|
500
|
+
this._ws.send(JSON.stringify(message));
|
|
501
|
+
this._sentAtMs = Date.now();
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// Codes that mean this room won't take the player, so joining fails instead of trying again.
|
|
505
|
+
var REFUSED = ["room_full", "room_closed", "ended", "bad_pass", "too_big", "game_unavailable", "kept_out", "refused", "hosting_paused", "account_needed", "game_not_open_to_player", "blocked_player_here", "other_version", "spectators_full", "room_not_running", "already_playing", "bad_origin"];
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Whether a Zaisma room on another version (other_version) has the page reload the game at it: on the
|
|
509
|
+
* game's Zaisma page, except through a tester link (it plays only its own version) or in a tab that opened
|
|
510
|
+
* into this very room, where the game domain already had its chance to serve the room's version.
|
|
511
|
+
*/
|
|
512
|
+
Room.prototype._reloadsForOtherVersion = function () {
|
|
513
|
+
return Boolean(config.site && window.parent !== window && !config.tester && roomCodeFromPage() !== this.roomCode);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
Room.prototype._connect = function () {
|
|
517
|
+
var room = this;
|
|
518
|
+
// From a tester link, Zaisma rooms hear which one, so its players leave when it's turned off.
|
|
519
|
+
var ws = new WebSocket(this._url + (config.tester && !this._serverUrl ? "?tester=" + config.tester : ""));
|
|
520
|
+
ws.binaryType = "arraybuffer";
|
|
521
|
+
this._ws = ws;
|
|
522
|
+
this._isWelcomed = false;
|
|
523
|
+
watch();
|
|
524
|
+
ws.onopen = function () {
|
|
525
|
+
room._pingAtMs = room._pongAtMs = room._heardAtMs = Date.now();
|
|
526
|
+
room._probeIntervalSec = null;
|
|
527
|
+
var hello = { type: "hello", protocolVersionNumber: PROTOCOL_VERSION_NUMBER, pass: room._pass, room: room._made, joinData: room._joinData, idleTimeoutSec: room._clientIdle.idleTimeoutSec, idleCheckEnabled: room._clientIdle.idleCheckEnabled };
|
|
528
|
+
// quickJoin's first try at a room: rather another room than one with someone this player blocked.
|
|
529
|
+
if (room._avoidsBlockedPlayers) hello.avoidBlockedPlayers = true;
|
|
530
|
+
// Any quickJoin: Zaisma learns the game joins players by itself, for its Rooms tab.
|
|
531
|
+
if (room._isQuickJoin) hello.isQuickJoin = true;
|
|
532
|
+
// The version this tab runs, so a Zaisma room on another one has the game reload at it first.
|
|
533
|
+
if (config.version && !room._serverUrl) hello.versionId = config.version;
|
|
534
|
+
if (room.isSpectator) hello.isSpectator = true;
|
|
535
|
+
room._say(hello);
|
|
536
|
+
};
|
|
537
|
+
ws.onmessage = function (event) {
|
|
538
|
+
room._heardAtMs = Date.now();
|
|
539
|
+
if (event.data === "pong") { room._pongAtMs = Date.now(); return; }
|
|
540
|
+
if (typeof event.data !== "string") { room._bytes(event.data); return; }
|
|
541
|
+
var message;
|
|
542
|
+
try { message = JSON.parse(event.data); } catch (error) { return; }
|
|
543
|
+
if (message.type === "probe") {
|
|
544
|
+
room._probe(message);
|
|
545
|
+
} else if (message.type === "welcome") {
|
|
546
|
+
var wasAway = room._away;
|
|
547
|
+
var wasComing = room._coming;
|
|
548
|
+
var wasRejoining = room._rejoining === true;
|
|
549
|
+
room._isWelcomed = true;
|
|
550
|
+
room._rejoining = false;
|
|
551
|
+
room._tries = 0;
|
|
552
|
+
room._protocolVersionNumber = typeof message.protocolVersionNumber === "number" ? message.protocolVersionNumber : null;
|
|
553
|
+
room.me = shownPlayerOf(message.me);
|
|
554
|
+
room.isSpectator = message.isSpectator === true;
|
|
555
|
+
room.players = playersOf(message.players);
|
|
556
|
+
room.hostPlayerId = message.hostPlayerId || null;
|
|
557
|
+
room._checks = message.awayChecks || { idleTimeoutSec: null, hiddenTimeoutSec: null };
|
|
558
|
+
room._probeIntervalSec = typeof message.probeIntervalSec === "number" ? message.probeIntervalSec : null;
|
|
559
|
+
room.spectatorCount = typeof message.spectatorCount === "number" ? message.spectatorCount : 0;
|
|
560
|
+
room._away = room._gone = room._idleSent = room._coming = room._offered = room._isReloadingForVersion = false;
|
|
561
|
+
room.awayReason = null;
|
|
562
|
+
room._isQueueFullSaid = false;
|
|
563
|
+
// In: coming back after a lost connection never turns the player away for someone they blocked.
|
|
564
|
+
room._avoidsBlockedPlayers = false;
|
|
565
|
+
// A new connection: the room tells this one its blocks afresh, and hears this tab's.
|
|
566
|
+
room._blocks = new Set();
|
|
567
|
+
room._said = {};
|
|
568
|
+
room._sayBlocks();
|
|
569
|
+
// From here on, the room's row counts this player (room lists leave them out once they've left).
|
|
570
|
+
room._joinedAtMs = Date.now();
|
|
571
|
+
if (message.room) room._settle(message.room);
|
|
572
|
+
room.endsAt = message.endsAt || null;
|
|
573
|
+
room._setConnectionStatus("open");
|
|
574
|
+
// What waited for the connection: reliable sends in order, then the latest-only ones.
|
|
575
|
+
room._sendQueued();
|
|
576
|
+
room._sendWaiting();
|
|
577
|
+
if (room._moved || wasAway) { room._moved = false; room._emit("players", room.players); }
|
|
578
|
+
if (room._ready) { room._ready.resolve(room); room._ready = null; }
|
|
579
|
+
// Back to the seat the room held, answering this tab's back: the game hears it, with nothing to rejoin.
|
|
580
|
+
if (wasAway && wasComing && !wasRejoining) {
|
|
581
|
+
room._tellConnection();
|
|
582
|
+
room._emit("back", { roomCode: room.roomCode, isSeatHeld: true, rejoin: function () { /* already in */ } });
|
|
583
|
+
}
|
|
584
|
+
// In (again, after walking away or a move): the Zaisma page counts on knowing.
|
|
585
|
+
room._sayRoomToPage(true);
|
|
586
|
+
room._tellPage();
|
|
587
|
+
// In a room on the game's own servers: Zaisma hears which, for friends' Join.
|
|
588
|
+
room._sayOwnRoom();
|
|
589
|
+
// Hidden already (a move, a reconnect): the room should know.
|
|
590
|
+
if (document.hidden && room._checks.hiddenTimeoutSec) room._say({ type: "hidden" });
|
|
591
|
+
} else if (message.type === "away") {
|
|
592
|
+
room._away = true;
|
|
593
|
+
room._idleSent = room._coming = false;
|
|
594
|
+
room.awayReason = message.awayReason === "hidden" ? "hidden" : "idle";
|
|
595
|
+
room._tellConnection();
|
|
596
|
+
room._emit("away", { awayReason: room.awayReason, seatHoldDurationSec: typeof message.seatHoldDurationSec === "number" ? message.seatHoldDurationSec : 0 });
|
|
597
|
+
left(room);
|
|
598
|
+
} else if (message.type === "players") {
|
|
599
|
+
room.players = playersOf(message.players);
|
|
600
|
+
room.hostPlayerId = message.hostPlayerId || null;
|
|
601
|
+
room._sayBlocks();
|
|
602
|
+
room._emit("players", room.players);
|
|
603
|
+
room._tellPage();
|
|
604
|
+
} else if (message.type === "blocks") {
|
|
605
|
+
room._blocks = new Set(Array.isArray(message.playerIds) ? message.playerIds : []);
|
|
606
|
+
room._mutedChanged();
|
|
607
|
+
} else if (message.type === "message") {
|
|
608
|
+
room._emit("message", message.data, typeof message.fromPlayerId === "string" ? message.fromPlayerId : null);
|
|
609
|
+
} else if (message.type === "state") {
|
|
610
|
+
room._takeState(message);
|
|
611
|
+
} else if (message.type === "room") {
|
|
612
|
+
var wasTakingFriendJoins = room.friendJoinsEnabled;
|
|
613
|
+
room._settle(message.room);
|
|
614
|
+
if (typeof message.spectatorCount === "number") room.spectatorCount = message.spectatorCount;
|
|
615
|
+
if (room.friendJoinsEnabled !== wasTakingFriendJoins) room._sayOwnRoom();
|
|
616
|
+
room._emit("settings", { isListed: room.isListed, isOpen: room.isOpen, friendJoinsEnabled: room.friendJoinsEnabled, name: room.name, labels: room.labels, spectatorCount: room.spectatorCount });
|
|
617
|
+
room._tellPage();
|
|
618
|
+
} else if (message.type === "move") {
|
|
619
|
+
if ((room._on.move || []).length) room._emit("move", room._move(message));
|
|
620
|
+
else room._follow(message.roomCode);
|
|
621
|
+
} else if (message.type === "kicked") {
|
|
622
|
+
room._done = true;
|
|
623
|
+
room._emit("kicked", { message: message.message, isKeptOut: message.isKeptOut === true });
|
|
624
|
+
left(room);
|
|
625
|
+
} else if (message.type === "ending") {
|
|
626
|
+
room.endsAt = message.endsAt;
|
|
627
|
+
room._emit("ending", { endsAt: message.endsAt });
|
|
628
|
+
} else if (message.type === "version_update") {
|
|
629
|
+
var versionUpdate = { versionId: message.versionId, appliesAt: message.appliesAt, isReloading: message.isReloading === true };
|
|
630
|
+
if (message.step === "now") room._isReloadingForVersion = versionUpdate.isReloading;
|
|
631
|
+
else room._emit("versionUpdate", versionUpdate);
|
|
632
|
+
if (config.site && window.parent !== window) window.parent.postMessage({ type: "zaisma:version-update", code: room.roomCode, step: message.step === "now" ? "now" : "coming", appliesAt: versionUpdate.appliesAt, isReloading: versionUpdate.isReloading }, config.site);
|
|
633
|
+
} else if (message.type === "ended") {
|
|
634
|
+
room._done = true;
|
|
635
|
+
room._queue = [];
|
|
636
|
+
room._emit("ended", { message: typeof message.message === "string" ? message.message : "", endReason: typeof message.endReason === "string" ? message.endReason : "game" });
|
|
637
|
+
left(room);
|
|
638
|
+
} else if (message.type === "error" && message.code === "other_version" && room._ready && room._reloadsForOtherVersion()) {
|
|
639
|
+
// The room runs another version: the page reloads the game at it, into this room, and this join waits for that.
|
|
640
|
+
room._isReloadingForOtherVersion = room._done = true;
|
|
641
|
+
window.parent.postMessage(room._spectating({ type: "zaisma:reload", code: room.roomCode, versionId: message.versionId }), config.site);
|
|
642
|
+
} else if (message.type === "error") {
|
|
643
|
+
room._emit("error", { code: message.code, message: message.message });
|
|
644
|
+
if (room._ready && REFUSED.indexOf(message.code) !== -1) {
|
|
645
|
+
room._done = true;
|
|
646
|
+
room._ready.reject(codedError(message.code, message.message));
|
|
647
|
+
room._ready = null;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
ws.onclose = function (event) {
|
|
652
|
+
if (room._isReloadingForOtherVersion) return;
|
|
653
|
+
// The match switched to a new version: its files changed, so the page reloads the game at it, back into this room.
|
|
654
|
+
if (event.code === VERSION_UPDATE_CLOSE_CODE && room._isReloadingForVersion && config.site && window.parent !== window) {
|
|
655
|
+
room._done = true;
|
|
656
|
+
room._setConnectionStatus("reconnecting");
|
|
657
|
+
window.parent.postMessage(room._spectating({ type: "zaisma:reload", code: room.roomCode }), config.site);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// 4000: the match ended. 4001: refused or replaced by another tab. Neither comes back.
|
|
661
|
+
if (room._done || event.code === 4000 || event.code === 4001) {
|
|
662
|
+
room._done = true;
|
|
663
|
+
room._queue = [];
|
|
664
|
+
room._setConnectionStatus("closed");
|
|
665
|
+
if (room._ready) {
|
|
666
|
+
room._ready.reject(codedError("connection_closed", "The connection closed before the room let the player in" + (event.reason ? ": " + event.reason : "") + "."));
|
|
667
|
+
room._ready = null;
|
|
668
|
+
}
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// 4002: the room stopped holding the seat of a player who walked away. Either way, while they're
|
|
672
|
+
// away, the connection is closed until they come back (back, then rejoin).
|
|
673
|
+
if (event.code === 4002 || room._away) {
|
|
674
|
+
room._ws = null;
|
|
675
|
+
room._away = room._gone = true;
|
|
676
|
+
if (!room.awayReason) room.awayReason = "idle";
|
|
677
|
+
room._setConnectionStatus("closed");
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
// The closed socket goes now, so a move while this waits connects once, not twice.
|
|
681
|
+
room._ws = null;
|
|
682
|
+
room._setConnectionStatus("reconnecting");
|
|
683
|
+
var waitMs = Math.min(8000, 500 * Math.pow(2, room._tries++));
|
|
684
|
+
setTimeout(function () { if (!room._done && !room._ws) room._connect(); }, waitMs);
|
|
685
|
+
};
|
|
686
|
+
};
|
|
687
|
+
|
|
688
|
+
/** The connection stopped answering pings (lost signal, a laptop waking up): drop it and connect again. */
|
|
689
|
+
Room.prototype._lost = function () {
|
|
690
|
+
var ws = this._ws;
|
|
691
|
+
if (ws) {
|
|
692
|
+
ws.onclose = null;
|
|
693
|
+
ws.onmessage = null;
|
|
694
|
+
try { ws.close(); } catch (error) { /* fine */ }
|
|
695
|
+
}
|
|
696
|
+
this._ws = null;
|
|
697
|
+
if (this._away) { this._gone = true; this._setConnectionStatus("closed"); return; }
|
|
698
|
+
this._setConnectionStatus("reconnecting");
|
|
699
|
+
var room = this;
|
|
700
|
+
setTimeout(function () { if (!room._done && !room._ws) room._connect(); }, 500);
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* The player is here (input, or the game shown again). Back from walking away: to their held seat,
|
|
705
|
+
* or once it ran out, their old room if it has space (offered to the game first, when it listens for "back").
|
|
706
|
+
*/
|
|
707
|
+
Room.prototype._return = function () {
|
|
708
|
+
if (this._done) return;
|
|
709
|
+
if (this._idleSent && !this._away) {
|
|
710
|
+
this._idleSent = false;
|
|
711
|
+
this._say({ type: "back" });
|
|
712
|
+
}
|
|
713
|
+
if (!this._away || this._coming) return;
|
|
714
|
+
if (this._ws && this._ws.readyState === 1) {
|
|
715
|
+
this._coming = true;
|
|
716
|
+
this._say({ type: "back", joinData: this._joinData, idleTimeoutSec: this._clientIdle.idleTimeoutSec, idleCheckEnabled: this._clientIdle.idleCheckEnabled });
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (!this._gone || this._offered || this._paused) return;
|
|
720
|
+
this._offered = true;
|
|
721
|
+
var room = this;
|
|
722
|
+
function rejoin() {
|
|
723
|
+
if (room._done || room._ws) return;
|
|
724
|
+
room._tries = 0;
|
|
725
|
+
room._rejoining = true;
|
|
726
|
+
room._setConnectionStatus("connecting");
|
|
727
|
+
room._connect();
|
|
728
|
+
}
|
|
729
|
+
// The seat is gone: a game listening for "back" decides whether to rejoin (its old room, if it has space).
|
|
730
|
+
if ((this._on.back || []).length) this._emit("back", { roomCode: this.roomCode, isSeatHeld: false, rejoin: rejoin });
|
|
731
|
+
else rejoin();
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
/** Seconds without input before this room's player is asked "Still there?", or null: the game's choice, else the room's. */
|
|
735
|
+
Room.prototype._idleTimeoutSec = function () {
|
|
736
|
+
if (this.isSpectator || this._clientIdle.idleCheckEnabled === false) return null;
|
|
737
|
+
return typeof this._clientIdle.idleTimeoutSec === "number" ? this._clientIdle.idleTimeoutSec : this._checks.idleTimeoutSec;
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
Room.prototype._setConnectionStatus = function (connectionStatus) {
|
|
741
|
+
if (this.connectionStatus === connectionStatus) return;
|
|
742
|
+
this.connectionStatus = connectionStatus;
|
|
743
|
+
this._emit("connectionStatus", connectionStatus);
|
|
744
|
+
this._tellConnection();
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
/** The Zaisma page shows the connection beside the game when it isn't fine: weak, reconnecting, lost or away. */
|
|
748
|
+
Room.prototype._tellConnection = function () {
|
|
749
|
+
if (!config.site || window.parent === window) return;
|
|
750
|
+
// The page still hears "away" while the player is away: it shows that, whatever the socket does.
|
|
751
|
+
window.parent.postMessage({ type: "zaisma:connection", code: this.roomCode, connectionStatus: this._away ? "away" : this.connectionStatus, pingDurationMs: this.pingDurationMs, isWeak: this._isWeak === true }, config.site);
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
/** Spectators watch: what a spectator tries to send throws, and nothing goes. */
|
|
755
|
+
function spectatorCannotSend() {
|
|
756
|
+
return codedError("spectator_cannot_send", "Spectators watch; they can't send to the room. Join without isSpectator to play.");
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Send a message to the game server. Plain JSON, up to 16 KB. Send input when it changes, not every frame.
|
|
761
|
+
* While the connection isn't open or the player is away, reliable sends wait, in order (up to 60).
|
|
762
|
+
* { delivery: "latest_only", latestKey } may drop it for a newer one with the same latestKey. False once the room is gone.
|
|
763
|
+
*/
|
|
764
|
+
Room.prototype.send = function (message, sendOptions) {
|
|
765
|
+
if (this.isSpectator) throw spectatorCannotSend();
|
|
766
|
+
var latestKey = readDelivery(sendOptions, "room.send");
|
|
767
|
+
var payload = JSON.stringify({ type: "message", data: message });
|
|
768
|
+
checkSendSize(payload);
|
|
769
|
+
if (this._done) return false;
|
|
770
|
+
if (latestKey !== null) this._sendLatest("message:" + latestKey, payload);
|
|
771
|
+
else this._sendReliable(payload);
|
|
772
|
+
return true;
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Send bytes to the game server (its onBytes), like a compact input: an ArrayBuffer or any typed array,
|
|
777
|
+
* up to 16 KB, counted with messages (30 a second). Takes the same sendOptions as room.send.
|
|
778
|
+
*/
|
|
779
|
+
Room.prototype.sendBytes = function (bytes, sendOptions) {
|
|
780
|
+
if (this.isSpectator) throw spectatorCannotSend();
|
|
781
|
+
if (!(bytes instanceof ArrayBuffer) && !ArrayBuffer.isView(bytes)) throw codedError("bad_bytes", "room.sendBytes takes bytes: a Uint8Array, another typed array or an ArrayBuffer. For JSON, use room.send.");
|
|
782
|
+
var latestKey = readDelivery(sendOptions, "room.sendBytes");
|
|
783
|
+
if (bytes.byteLength > MAXIMUM_SEND_SIZE_BYTES) throw tooBig();
|
|
784
|
+
if (this._done) return false;
|
|
785
|
+
// A copy: the game may reuse its buffer while this one waits.
|
|
786
|
+
var copy = ArrayBuffer.isView(bytes) ? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) : bytes.slice(0);
|
|
787
|
+
if (latestKey !== null) this._sendLatest("bytes:" + latestKey, copy);
|
|
788
|
+
else this._sendReliable(copy);
|
|
789
|
+
return true;
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
/** The error for a send over 16 KB, in the room's own words (too_big). */
|
|
793
|
+
function tooBig() {
|
|
794
|
+
return codedError("too_big", "Message too big.");
|
|
795
|
+
}
|
|
796
|
+
/** Throws too_big for text over 16 KB in UTF-8, measured only when it could be. */
|
|
797
|
+
function checkSendSize(text) {
|
|
798
|
+
if (text.length * 3 <= MAXIMUM_SEND_SIZE_BYTES) return;
|
|
799
|
+
if (new TextEncoder().encode(text).length > MAXIMUM_SEND_SIZE_BYTES) throw tooBig();
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** A reliable send: now, or in order once the connection is open again, dropping the oldest past 60. */
|
|
803
|
+
Room.prototype._sendReliable = function (payload) {
|
|
804
|
+
if (this._canSend() && !this._queue.length) {
|
|
805
|
+
this._ws.send(payload);
|
|
806
|
+
this._sentAtMs = Date.now();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
this._queue.push(payload);
|
|
810
|
+
if (this._queue.length <= MAXIMUM_QUEUED_SEND_COUNT) return;
|
|
811
|
+
this._queue.shift();
|
|
812
|
+
if (this._isQueueFullSaid) return;
|
|
813
|
+
this._isQueueFullSaid = true;
|
|
814
|
+
this._emit("error", { code: "send_queue_full", message: "Up to " + MAXIMUM_QUEUED_SEND_COUNT + " sends wait while reconnecting; older ones were dropped." });
|
|
815
|
+
};
|
|
816
|
+
/** Reliable sends that waited for the connection, in order and at the room's pace; then the latest-only ones. */
|
|
817
|
+
Room.prototype._sendQueued = function () {
|
|
818
|
+
if (!this._queue.length || !this._canSend() || this._flushTimer !== null) return;
|
|
819
|
+
var room = this;
|
|
820
|
+
var ws = this._ws;
|
|
821
|
+
function send(sendCount) {
|
|
822
|
+
room._queue.splice(0, sendCount).forEach(function (payload) { ws.send(payload); });
|
|
823
|
+
room._sentAtMs = Date.now();
|
|
824
|
+
}
|
|
825
|
+
send(FLUSH_NOW_COUNT);
|
|
826
|
+
if (!this._queue.length) return;
|
|
827
|
+
this._flushTimer = setInterval(function () {
|
|
828
|
+
var isGoing = !room._done && room._ws === ws && room._canSend();
|
|
829
|
+
if (isGoing) send(FLUSH_STEP_COUNT);
|
|
830
|
+
if (isGoing && room._queue.length) return;
|
|
831
|
+
clearInterval(room._flushTimer);
|
|
832
|
+
room._flushTimer = null;
|
|
833
|
+
// Done, or the connection changed under it: a new one already welcomed starts again, else its welcome will.
|
|
834
|
+
if (room._queue.length) room._sendQueued();
|
|
835
|
+
else room._sendWaiting();
|
|
836
|
+
}, FLUSH_INTERVAL_MS);
|
|
837
|
+
};
|
|
838
|
+
|
|
839
|
+
/** A send's latestKey when its delivery is "latest_only", or null for a reliable one. Mistakes throw, naming what to give. */
|
|
840
|
+
function readDelivery(sendOptions, call) {
|
|
841
|
+
var refused = unknownOption(sendOptions, SEND_OPTIONS, call);
|
|
842
|
+
if (refused) throw refused;
|
|
843
|
+
var given = sendOptions || {};
|
|
844
|
+
if (given.delivery !== undefined && DELIVERIES.indexOf(given.delivery) === -1) throw codedError("bad_send_option", call + "'s delivery is " + JSON.stringify(given.delivery) + '. Give "reliable" (the default) or "latest_only".');
|
|
845
|
+
if (given.latestKey !== undefined && (typeof given.latestKey !== "string" || given.latestKey.length > MAXIMUM_LATEST_KEY_CHARACTER_COUNT)) throw codedError("bad_send_option", call + "'s latestKey must be text up to " + MAXIMUM_LATEST_KEY_CHARACTER_COUNT + ' characters, like "position".');
|
|
846
|
+
if (given.delivery !== "latest_only") return null;
|
|
847
|
+
if (given.latestKey === undefined) throw codedError("bad_send_option", 'latest_only needs a latestKey: the slot this update replaces, like "position".');
|
|
848
|
+
return given.latestKey;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** A latest-only send: now, after anything still waiting, or into its slot while the socket is backed up. */
|
|
852
|
+
Room.prototype._sendLatest = function (slotKey, payload) {
|
|
853
|
+
if (this._canSend() && !this._queue.length && !(this._ws.bufferedAmount >= BACKED_UP_SIZE_BYTES)) {
|
|
854
|
+
this._sendWaiting();
|
|
855
|
+
this._ws.send(payload);
|
|
856
|
+
this._sentAtMs = Date.now();
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
this._slots.delete(slotKey);
|
|
860
|
+
this._slots.set(slotKey, payload);
|
|
861
|
+
if (this._slots.size > MAXIMUM_LATEST_ONLY_SLOT_COUNT) this._slots.delete(this._slots.keys().next().value);
|
|
862
|
+
if (this._slotTimer === null) {
|
|
863
|
+
var room = this;
|
|
864
|
+
this._slotTimer = setInterval(function () {
|
|
865
|
+
if (room._done) room._slots.clear();
|
|
866
|
+
else if (room._canSend() && !room._queue.length && !(room._ws.bufferedAmount >= BACKED_UP_SIZE_BYTES)) room._sendWaiting();
|
|
867
|
+
if (!room._slots.size) { clearInterval(room._slotTimer); room._slotTimer = null; }
|
|
868
|
+
}, LATEST_CHECK_MS);
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
/** Latest-only sends that waited, least recently replaced first. */
|
|
872
|
+
Room.prototype._sendWaiting = function () {
|
|
873
|
+
if (!this._slots.size || !this._canSend() || this._queue.length) return;
|
|
874
|
+
var ws = this._ws;
|
|
875
|
+
this._slots.forEach(function (payload) { ws.send(payload); });
|
|
876
|
+
this._slots.clear();
|
|
877
|
+
this._sentAtMs = Date.now();
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
/** A state update from the game server (stateSyncEnabled): a full copy of this player's view, or what changed. */
|
|
881
|
+
Room.prototype._takeState = function (message) {
|
|
882
|
+
var changes = null;
|
|
883
|
+
if (Object.prototype.hasOwnProperty.call(message, "full")) this.state = message.full;
|
|
884
|
+
else if (Array.isArray(message.changes) && this._views.length) {
|
|
885
|
+
try { this.state = applyStateChanges(this.state, message.changes); } catch (error) { return; }
|
|
886
|
+
changes = message.changes;
|
|
887
|
+
} else return;
|
|
888
|
+
var serverTimeMs = typeof message.serverTimeMs === "number" ? message.serverTimeMs : this.serverTimeMs();
|
|
889
|
+
this._views.push({ serverTimeMs: serverTimeMs, state: this.state });
|
|
890
|
+
while (this._views.length > 2 && this._views[1].serverTimeMs <= serverTimeMs - KEPT_VIEWS_DURATION_MS) this._views.shift();
|
|
891
|
+
this._emit("state", this.state, changes);
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* The state as it was smoothingOptions.delayMs (100 unless given) behind the room's clock, between the two
|
|
896
|
+
* updates around then: numbers in between (integers too, as fractions), anything else as the older one had
|
|
897
|
+
* it, and what's in only one of them as it is. A copy, so the game can keep it; null until the first update.
|
|
898
|
+
*/
|
|
899
|
+
Room.prototype.smoothedState = function (smoothingOptions) {
|
|
900
|
+
var refused = unknownOption(smoothingOptions, SMOOTHING_OPTIONS, "room.smoothedState");
|
|
901
|
+
if (refused) throw refused;
|
|
902
|
+
var delayMs = smoothingOptions && smoothingOptions.delayMs !== undefined ? smoothingOptions.delayMs : DEFAULT_SMOOTHING_DELAY_MS;
|
|
903
|
+
if (typeof delayMs !== "number" || !(delayMs >= 0 && delayMs <= KEPT_VIEWS_DURATION_MS)) throw codedError("bad_delay", "room.smoothedState's delayMs runs from 0 to " + KEPT_VIEWS_DURATION_MS + " milliseconds, like { delayMs: 100 }.");
|
|
904
|
+
var views = this._views;
|
|
905
|
+
if (!views.length) return null;
|
|
906
|
+
var atMs = this.serverTimeMs() - delayMs;
|
|
907
|
+
// The first view newer than then: the one to blend toward.
|
|
908
|
+
var newerIndex = 0;
|
|
909
|
+
while (newerIndex < views.length && views[newerIndex].serverTimeMs <= atMs) newerIndex++;
|
|
910
|
+
if (newerIndex === 0) return blendStates(views[0].state, views[0].state, 0);
|
|
911
|
+
if (newerIndex === views.length) return blendStates(views[newerIndex - 1].state, views[newerIndex - 1].state, 0);
|
|
912
|
+
var older = views[newerIndex - 1];
|
|
913
|
+
var spanMs = views[newerIndex].serverTimeMs - older.serverTimeMs;
|
|
914
|
+
return blendStates(older.state, views[newerIndex].state, spanMs > 0 ? (atMs - older.serverTimeMs) / spanMs : 1);
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
/** Two views blended: numbers fraction of the way from older to newer, anything else the older one's, and what's in only one as it is, copied. */
|
|
918
|
+
function blendStates(older, newer, fraction) {
|
|
919
|
+
if (typeof older === "number" && typeof newer === "number") return older + (newer - older) * fraction;
|
|
920
|
+
var isList = Array.isArray(older);
|
|
921
|
+
if (!older || typeof older !== "object" || !newer || typeof newer !== "object" || isList !== Array.isArray(newer)) {
|
|
922
|
+
return older && typeof older === "object" ? blendStates(older, older, 0) : older;
|
|
923
|
+
}
|
|
924
|
+
var blended = isList ? [] : {};
|
|
925
|
+
var keys = isList ? Array.from({ length: Math.max(older.length, newer.length) }, function (unused, i) { return i; }) : Object.keys(older).concat(Object.keys(newer).filter(function (key) { return !Object.prototype.hasOwnProperty.call(older, key); }));
|
|
926
|
+
keys.forEach(function (key) {
|
|
927
|
+
var isInOlder = Object.prototype.hasOwnProperty.call(older, key);
|
|
928
|
+
var isInNewer = Object.prototype.hasOwnProperty.call(newer, key);
|
|
929
|
+
var part = isInOlder && isInNewer ? blendStates(older[key], newer[key], fraction) : isInOlder ? blendStates(older[key], older[key], 0) : blendStates(newer[key], newer[key], 0);
|
|
930
|
+
Object.defineProperty(blended, key, { value: part, writable: true, enumerable: true, configurable: true });
|
|
931
|
+
});
|
|
932
|
+
return blended;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* The state with a state update's changes applied, as a new state: what changed is copied along its path
|
|
937
|
+
* and the rest shared, so earlier views stay as they were. The game server's applyStateChanges
|
|
938
|
+
* (packages/game-server/src/state-sync.ts) does the same; test/node/state-sync.test.ts holds them equal.
|
|
939
|
+
*/
|
|
940
|
+
function applyStateChanges(state, changes) {
|
|
941
|
+
var copied = new Set();
|
|
942
|
+
function copyOf(given) {
|
|
943
|
+
if (copied.has(given)) return given;
|
|
944
|
+
var copy = Array.isArray(given) ? given.slice() : {};
|
|
945
|
+
if (!Array.isArray(given)) Object.keys(given).forEach(function (key) { Object.defineProperty(copy, key, { value: given[key], writable: true, enumerable: true, configurable: true }); });
|
|
946
|
+
copied.add(copy);
|
|
947
|
+
return copy;
|
|
948
|
+
}
|
|
949
|
+
var root = state;
|
|
950
|
+
changes.forEach(function (change) {
|
|
951
|
+
var path = change[0];
|
|
952
|
+
if (!path.length) { root = change.length > 1 ? change[1] : null; return; }
|
|
953
|
+
root = copyOf(root);
|
|
954
|
+
var parent = root;
|
|
955
|
+
for (var i = 0; i < path.length - 1; i++) {
|
|
956
|
+
var child = copyOf(parent[path[i]]);
|
|
957
|
+
parent[path[i]] = child;
|
|
958
|
+
parent = child;
|
|
959
|
+
}
|
|
960
|
+
var key = path[path.length - 1];
|
|
961
|
+
// A key named __proto__ is plain data, as JSON.parse makes it, never the object's prototype.
|
|
962
|
+
if (change.length > 1) Object.defineProperty(parent, key, { value: change[1], writable: true, enumerable: true, configurable: true });
|
|
963
|
+
else delete parent[key];
|
|
964
|
+
});
|
|
965
|
+
return root;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** A binary frame from the room: one byte, the sender's player ID length, the ID (none for the game server), then the bytes. */
|
|
969
|
+
Room.prototype._bytes = function (received) {
|
|
970
|
+
var frame = new Uint8Array(received);
|
|
971
|
+
if (!frame.length) return;
|
|
972
|
+
var fromSizeBytes = frame[0];
|
|
973
|
+
var fromPlayerId = fromSizeBytes ? new TextDecoder().decode(frame.subarray(1, 1 + fromSizeBytes)) : null;
|
|
974
|
+
this._emit("bytes", frame.slice(1 + fromSizeBytes), fromPlayerId);
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Show or hide the room in room lists, and open or close it to new players. Works from the room's
|
|
979
|
+
* host (its first player) when the game uses the relay; with a game server, the server decides.
|
|
980
|
+
* The "settings" event says when it changed.
|
|
981
|
+
*/
|
|
982
|
+
Room.prototype.setListed = function (isListed) {
|
|
983
|
+
this._say({ type: "room", room: { isListed: Boolean(isListed) } });
|
|
984
|
+
};
|
|
985
|
+
Room.prototype.setOpen = function (isOpen) {
|
|
986
|
+
this._say({ type: "room", room: { isOpen: Boolean(isOpen) } });
|
|
987
|
+
};
|
|
988
|
+
/**
|
|
989
|
+
* Turn Zaisma's Join on or off for this room ("Private room" when off). Works from the room's host
|
|
990
|
+
* when the game uses the relay; with a game server, the server decides. Not a lock: codes and links still work.
|
|
991
|
+
*/
|
|
992
|
+
Room.prototype.setFriendJoinsEnabled = function (isEnabled) {
|
|
993
|
+
this._say({ type: "room", room: { friendJoinsEnabled: Boolean(isEnabled) } });
|
|
994
|
+
};
|
|
995
|
+
/** In a room on the game's own servers: Zaisma hears which, with its Join switch, for friends' Join. */
|
|
996
|
+
Room.prototype._sayOwnRoom = function () {
|
|
997
|
+
if (this.isSpectator || !this._serverUrl || !config.rooms || !OWN_SERVER_URL.test(this._serverUrl)) return;
|
|
998
|
+
setOwnRoom({ serverUrl: this._serverUrl, roomCode: this.roomCode, friendJoinsEnabled: this.friendJoinsEnabled }).catch(function () { /* not one of the game's own servers, or a guest */ });
|
|
999
|
+
};
|
|
1000
|
+
/**
|
|
1001
|
+
* The host removes a player, when the game server gives the host controls (the relay does).
|
|
1002
|
+
* keepOut: true keeps them out for the rest of the match. They're told the host removed them.
|
|
1003
|
+
*/
|
|
1004
|
+
Room.prototype.kick = function (playerId, kickOptions) {
|
|
1005
|
+
var refused = unknownOption(kickOptions, KICK_OPTIONS, "room.kick");
|
|
1006
|
+
if (refused) throw refused;
|
|
1007
|
+
this._say({ type: "kick", playerId: playerId, keepOut: Boolean(kickOptions && kickOptions.keepOut) });
|
|
1008
|
+
};
|
|
1009
|
+
/** Name and label the room: { name, labels }. Leave a field out to keep it; null clears it. */
|
|
1010
|
+
Room.prototype.setInfo = function (nameAndLabels) {
|
|
1011
|
+
var refused = unknownOption(nameAndLabels, ROOM_INFO_FIELDS, "room.setInfo");
|
|
1012
|
+
if (refused) throw refused;
|
|
1013
|
+
nameAndLabels = nameAndLabels || {};
|
|
1014
|
+
this._say({ type: "room", room: { name: nameAndLabels.name, labels: nameAndLabels.labels } });
|
|
1015
|
+
};
|
|
1016
|
+
/**
|
|
1017
|
+
* The game server sent this player to another room: { roomCode, follow(), join(joinOptions) }.
|
|
1018
|
+
* follow() moves this room object there. join takes joinRoom's options and joins it as a new room
|
|
1019
|
+
* object instead, on the same server as this one (leave this one first).
|
|
1020
|
+
*/
|
|
1021
|
+
Room.prototype._move = function (message) {
|
|
1022
|
+
var serverUrl = this._serverUrl;
|
|
1023
|
+
var room = this;
|
|
1024
|
+
return {
|
|
1025
|
+
roomCode: message.roomCode,
|
|
1026
|
+
follow: function () { room._follow(message.roomCode); },
|
|
1027
|
+
join: function (joinOptions) {
|
|
1028
|
+
var refused = unknownOption(joinOptions, MOVE_JOIN_OPTIONS, "move.join");
|
|
1029
|
+
if (refused) return Promise.reject(refused);
|
|
1030
|
+
return connect(message.roomCode, Object.assign({}, joinOptions || {}, { serverUrl: serverUrl }));
|
|
1031
|
+
},
|
|
1032
|
+
};
|
|
1033
|
+
};
|
|
1034
|
+
/** Move this room object to another room of the game: the old connection closes, the new one opens. */
|
|
1035
|
+
Room.prototype._follow = function (roomCode) {
|
|
1036
|
+
if (this._done || !ROOM_CODE.test(roomCode) || roomCode === this.roomCode) return;
|
|
1037
|
+
var old = this._ws;
|
|
1038
|
+
if (old) {
|
|
1039
|
+
old.onclose = null;
|
|
1040
|
+
old.onmessage = null;
|
|
1041
|
+
try { old.close(1000, "Moved"); } catch (error) { /* fine */ }
|
|
1042
|
+
}
|
|
1043
|
+
this._ws = null;
|
|
1044
|
+
left(this);
|
|
1045
|
+
// The new room takes the player as they come in: away was the old room's (awayMode "lobby" moves an idle player).
|
|
1046
|
+
this._gone = this._coming = this._away = this._idleSent = false;
|
|
1047
|
+
this.awayReason = null;
|
|
1048
|
+
var fromRoomCode = this.roomCode;
|
|
1049
|
+
this.roomCode = roomCode;
|
|
1050
|
+
this._url = this._url.replace(/[A-Z0-9]{6}$/, roomCode);
|
|
1051
|
+
this.inviteUrl = inviteUrlFor(roomCode, this._serverUrl);
|
|
1052
|
+
// A room the game server moves players to is listed only if the game server lists it.
|
|
1053
|
+
this._made = { isListed: false };
|
|
1054
|
+
this._joinData = null;
|
|
1055
|
+
this._tries = 0;
|
|
1056
|
+
this._moved = true;
|
|
1057
|
+
// Another room's state comes with its first update.
|
|
1058
|
+
this.state = null;
|
|
1059
|
+
this._views = [];
|
|
1060
|
+
this._slots.clear();
|
|
1061
|
+
this._queue = [];
|
|
1062
|
+
this._sayRoomToPage();
|
|
1063
|
+
this._setConnectionStatus("connecting");
|
|
1064
|
+
this._emit("moved", { roomCode: roomCode, fromRoomCode: fromRoomCode });
|
|
1065
|
+
this._connect();
|
|
1066
|
+
};
|
|
1067
|
+
Room.prototype._settle = function (settings) {
|
|
1068
|
+
this.isListed = settings.isListed === true;
|
|
1069
|
+
this.isOpen = settings.isOpen !== false;
|
|
1070
|
+
this.friendJoinsEnabled = settings.friendJoinsEnabled !== false;
|
|
1071
|
+
this.name = typeof settings.name === "string" ? settings.name : null;
|
|
1072
|
+
this.labels = settings.labels && typeof settings.labels === "object" ? settings.labels : {};
|
|
1073
|
+
};
|
|
1074
|
+
/** Tell the Zaisma page the room this tab is in, so its Invite button and address bar point there. */
|
|
1075
|
+
Room.prototype._sayRoomToPage = function (isJoined) {
|
|
1076
|
+
if (!config.site || window.parent === window) return;
|
|
1077
|
+
var message = { type: "zaisma:room", code: this.roomCode };
|
|
1078
|
+
if (isJoined) message.joined = true;
|
|
1079
|
+
if (this._serverUrl) message.serverUrl = this._serverUrl;
|
|
1080
|
+
window.parent.postMessage(message, config.site);
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
/*
|
|
1084
|
+
* Mute lasts for this room. A block is a wall both ways (zaisma.d.ts: room.block): between two
|
|
1085
|
+
* accounts it lasts until it's undone on zaisma.com/friends, and rooms find it themselves as players
|
|
1086
|
+
* join; any other block (a guest's, or of a guest) lasts for the session, in every game played in this
|
|
1087
|
+
* tab on Zaisma. Either way the room hears of it at once, and tells both players' games ("blocks"),
|
|
1088
|
+
* so each hides the other's emotes and phrases. Games never get anyone's list of blocks.
|
|
1089
|
+
*/
|
|
1090
|
+
var blockKey = "zaisma:blocked:" + config.game;
|
|
1091
|
+
/** Session blocks this tab made in this game. */
|
|
1092
|
+
function blockedSet() {
|
|
1093
|
+
try { return new Set(JSON.parse(sessionStorage.getItem(blockKey) || "[]")); } catch (error) { return new Set(); }
|
|
1094
|
+
}
|
|
1095
|
+
function rememberBlock(playerId, isBlocked) {
|
|
1096
|
+
var blocked = blockedSet();
|
|
1097
|
+
if (isBlocked) blocked.add(playerId);
|
|
1098
|
+
else blocked.delete(playerId);
|
|
1099
|
+
try { sessionStorage.setItem(blockKey, JSON.stringify(Array.from(blocked))); } catch (error) { /* fine */ }
|
|
1100
|
+
}
|
|
1101
|
+
/** Blocks between accounts this tab made, which last: the room hears of them until its next pass and join find them. */
|
|
1102
|
+
var lastingBlocked = new Set();
|
|
1103
|
+
function hasBlocked(playerId) {
|
|
1104
|
+
return lastingBlocked.has(playerId) || blockedSet().has(playerId);
|
|
1105
|
+
}
|
|
1106
|
+
Room.prototype._mutedChanged = function () {
|
|
1107
|
+
var room = this;
|
|
1108
|
+
var hidden = new Set(Array.from(this._muted || []).concat(Array.from(blockedSet()), Array.from(lastingBlocked), Array.from(this._blocks || [])));
|
|
1109
|
+
this._emit("muted", Array.from(hidden).filter(function (playerId) { return !room.me || playerId !== room.me.id; }));
|
|
1110
|
+
this._tellPage();
|
|
1111
|
+
};
|
|
1112
|
+
/**
|
|
1113
|
+
* Tell the Zaisma page who's in the room, for the Players plate in its bar: the players, and which of
|
|
1114
|
+
* them this player muted or blocked. Blocks someone else made of them never leave the SDK.
|
|
1115
|
+
*/
|
|
1116
|
+
Room.prototype._tellPage = function () {
|
|
1117
|
+
if (!onZaismaPage() || this._done || !this.roomCode) return;
|
|
1118
|
+
var ids = this.players.map(function (each) { return each.id; });
|
|
1119
|
+
var muted = this._muted || new Set();
|
|
1120
|
+
var session = blockedSet();
|
|
1121
|
+
window.parent.postMessage(this._spectating({
|
|
1122
|
+
type: "zaisma:players",
|
|
1123
|
+
code: this.roomCode,
|
|
1124
|
+
// Whether the room takes new players by its code, so the page shows the code or says it's closed.
|
|
1125
|
+
isOpen: this.isOpen !== false,
|
|
1126
|
+
myPlayerId: this.me ? this.me.id : null,
|
|
1127
|
+
players: this.players.map(function (each) { return { id: each.id, name: each.name, kind: each.kind }; }),
|
|
1128
|
+
mutedPlayerIds: ids.filter(function (playerId) { return muted.has(playerId); }),
|
|
1129
|
+
blockedPlayerIds: ids.filter(function (playerId) { return session.has(playerId); }),
|
|
1130
|
+
lastingBlockedPlayerIds: ids.filter(function (playerId) { return lastingBlocked.has(playerId); }),
|
|
1131
|
+
}), config.site);
|
|
1132
|
+
};
|
|
1133
|
+
/**
|
|
1134
|
+
* A message to the Zaisma page with how many watch, and whether this player is one of them, when anyone
|
|
1135
|
+
* is: the Players plate says so under the players, and a reload brings a spectator back to watching.
|
|
1136
|
+
*/
|
|
1137
|
+
Room.prototype._spectating = function (message) {
|
|
1138
|
+
if (this.spectatorCount > 0) message.spectatorCount = this.spectatorCount;
|
|
1139
|
+
if (this.isSpectator) message.isSpectator = true;
|
|
1140
|
+
return message;
|
|
1141
|
+
};
|
|
1142
|
+
Room.prototype.isMuted = function (playerId) {
|
|
1143
|
+
return Boolean((this._muted && this._muted.has(playerId)) || hasBlocked(playerId) || (this._blocks && this._blocks.has(playerId)));
|
|
1144
|
+
};
|
|
1145
|
+
Room.prototype.mute = function (playerId) {
|
|
1146
|
+
(this._muted = this._muted || new Set()).add(playerId);
|
|
1147
|
+
this._mutedChanged();
|
|
1148
|
+
};
|
|
1149
|
+
Room.prototype.unmute = function (playerId) {
|
|
1150
|
+
if (this._muted) this._muted.delete(playerId);
|
|
1151
|
+
this._mutedChanged();
|
|
1152
|
+
};
|
|
1153
|
+
/** Lift this session's block of a player. A block between accounts is undone on zaisma.com/friends, never from a game. */
|
|
1154
|
+
Room.prototype.unblock = function (playerId) {
|
|
1155
|
+
if (blockedSet().has(playerId)) {
|
|
1156
|
+
rememberBlock(playerId, false);
|
|
1157
|
+
if (onZaismaPage()) window.parent.postMessage({ type: "zaisma:unblock", playerId: playerId }, config.site);
|
|
1158
|
+
joined.forEach(function (room) {
|
|
1159
|
+
if (room._said) delete room._said[playerId];
|
|
1160
|
+
if (room.players.some(function (each) { return each.id === playerId; })) room._say({ type: "unblock", playerId: playerId });
|
|
1161
|
+
room._mutedChanged();
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
this._mutedChanged();
|
|
1165
|
+
};
|
|
1166
|
+
/** Block a player: true once they're blocked, false if the player changed their mind (zaisma.d.ts: room.block). */
|
|
1167
|
+
Room.prototype.block = function (playerId) {
|
|
1168
|
+
var room = this;
|
|
1169
|
+
var other = this.players.filter(function (each) { return each.id === playerId; })[0];
|
|
1170
|
+
if (!other || (this.me && playerId === this.me.id)) return Promise.reject(codedError("bad_player_id", "room.block takes the ID of another player in this room, from room.players."));
|
|
1171
|
+
return askToBlock(this, other).then(function (answer) {
|
|
1172
|
+
if (!answer) return false;
|
|
1173
|
+
if (answer.isLasting) {
|
|
1174
|
+
lastingBlocked.add(playerId);
|
|
1175
|
+
// The next pass carries the block, for the game's own servers.
|
|
1176
|
+
try { sessionStorage.removeItem(passKey); } catch (error) { /* fine */ }
|
|
1177
|
+
} else rememberBlock(playerId, true);
|
|
1178
|
+
joined.forEach(function (each) { each._sayBlocks(); each._mutedChanged(); });
|
|
1179
|
+
return true;
|
|
1180
|
+
});
|
|
1181
|
+
};
|
|
1182
|
+
/** Tell the room of this tab's blocks of players in it, each once a connection; on Zaisma, ask the page which others this session blocked. */
|
|
1183
|
+
Room.prototype._sayBlocks = function () {
|
|
1184
|
+
var room = this;
|
|
1185
|
+
var me = this.me ? this.me.id : null;
|
|
1186
|
+
room._said = room._said || {};
|
|
1187
|
+
room._asked = room._asked || {};
|
|
1188
|
+
var unasked = [];
|
|
1189
|
+
this.players.forEach(function (each) {
|
|
1190
|
+
if (each.id === me) return;
|
|
1191
|
+
// Spectators send nothing, blocks included: nothing of theirs reaches a player.
|
|
1192
|
+
if (hasBlocked(each.id) && !room._said[each.id] && !room.isSpectator) {
|
|
1193
|
+
room._said[each.id] = true;
|
|
1194
|
+
room._say({ type: "block", playerId: each.id });
|
|
1195
|
+
}
|
|
1196
|
+
if (!room._asked[each.id]) {
|
|
1197
|
+
room._asked[each.id] = true;
|
|
1198
|
+
unasked.push(each.id);
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
if (unasked.length && onZaismaPage()) window.parent.postMessage({ type: "zaisma:blocks-among", playerIds: unasked }, config.site);
|
|
1202
|
+
};
|
|
1203
|
+
|
|
1204
|
+
function onZaismaPage() {
|
|
1205
|
+
return Boolean(config.site && window.parent !== window);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// The Zaisma page answers which of the players asked about this tab blocked earlier in the session, in other games too.
|
|
1209
|
+
window.addEventListener("message", function (event) {
|
|
1210
|
+
if (!config.site || event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== "zaisma:blocks-among" || !Array.isArray(event.data.playerIds)) return;
|
|
1211
|
+
event.data.playerIds.forEach(function (playerId) { if (typeof playerId === "string") rememberBlock(playerId, true); });
|
|
1212
|
+
joined.forEach(function (room) { room._sayBlocks(); room._mutedChanged(); });
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
// The Players plate in the Zaisma page's bar mutes, unmutes and blocks through the game's own rooms.
|
|
1216
|
+
window.addEventListener("message", function (event) {
|
|
1217
|
+
var asked = event.data;
|
|
1218
|
+
if (!config.site || event.origin !== config.site || event.source !== window.parent || !asked || asked.type !== "zaisma:player-action" || typeof asked.playerId !== "string") return;
|
|
1219
|
+
var roomsWithPlayer = joined.filter(function (room) { return !room._done && room.players.some(function (each) { return each.id === asked.playerId; }); });
|
|
1220
|
+
if (asked.action === "mute") roomsWithPlayer.forEach(function (room) { room.mute(asked.playerId); });
|
|
1221
|
+
// The plate's Unmute lifts a mute and a session block alike.
|
|
1222
|
+
else if (asked.action === "unmute") roomsWithPlayer.forEach(function (room) { room.unmute(asked.playerId); room.unblock(asked.playerId); });
|
|
1223
|
+
else if (asked.action === "block" && roomsWithPlayer.length) roomsWithPlayer[0].block(asked.playerId).catch(function () { /* fine */ });
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
var blockAsk = null;
|
|
1227
|
+
/**
|
|
1228
|
+
* Ask the Zaisma page to block a player: between two accounts it confirms with the player first and
|
|
1229
|
+
* keeps the block with the account ({ isLasting: true }); otherwise it keeps it for the session. The
|
|
1230
|
+
* answer is null when the player changed their mind. Outside Zaisma, the block lasts for this tab.
|
|
1231
|
+
*/
|
|
1232
|
+
function askToBlock(room, other) {
|
|
1233
|
+
if (!onZaismaPage()) return Promise.resolve({ isLasting: false });
|
|
1234
|
+
// One question at a time: another block while the page is asking is a no.
|
|
1235
|
+
if (blockAsk) return Promise.resolve(null);
|
|
1236
|
+
blockAsk = new Promise(function (resolve) {
|
|
1237
|
+
var isAsking = false;
|
|
1238
|
+
var timer = setTimeout(function () { if (!isAsking) settle({ isLasting: false }); }, 1500);
|
|
1239
|
+
function settle(answer) {
|
|
1240
|
+
removeEventListener("message", onMessage);
|
|
1241
|
+
clearTimeout(timer);
|
|
1242
|
+
blockAsk = null;
|
|
1243
|
+
resolve(answer);
|
|
1244
|
+
}
|
|
1245
|
+
function onMessage(event) {
|
|
1246
|
+
if (event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== "zaisma:block" || event.data.playerId !== other.id) return;
|
|
1247
|
+
if (event.data.status === "asking") isAsking = true;
|
|
1248
|
+
else if (event.data.status === "blocked") settle({ isLasting: event.data.isLasting === true });
|
|
1249
|
+
else if (event.data.status === "cancelled") settle(null);
|
|
1250
|
+
}
|
|
1251
|
+
addEventListener("message", onMessage);
|
|
1252
|
+
window.parent.postMessage({ type: "zaisma:block", code: room.roomCode, playerKind: room.me ? room.me.kind : "guest", player: { id: other.id, name: other.name, kind: other.kind } }, config.site);
|
|
1253
|
+
});
|
|
1254
|
+
return blockAsk;
|
|
1255
|
+
}
|
|
1256
|
+
/** Report a player to Zaisma. reason: real_life_threat, personal_details, following_after_block or sexual (Zaisma looks), or harassment, cheating or other (passed to the game's developer). Nothing about you is sent. */
|
|
1257
|
+
Room.prototype.report = function (playerId, reason, details) {
|
|
1258
|
+
var reported = this.players.filter(function (each) { return each.id === playerId; })[0];
|
|
1259
|
+
return post("/v1/report", { gameSlug: config.game, roomCode: this.roomCode, playerId: playerId, playerName: reported ? reported.name : "", reason: reason, details: details || "" });
|
|
1260
|
+
};
|
|
1261
|
+
|
|
1262
|
+
Room.prototype.leave = function () {
|
|
1263
|
+
if (!this._done) left(this);
|
|
1264
|
+
this._done = true;
|
|
1265
|
+
this._queue = [];
|
|
1266
|
+
if (this._ws) this._ws.close(1000, "Left");
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
/** Join a room: the code in joinOptions, else the page's ?room=, else a new one (zaisma.d.ts: Zaisma.joinRoom). */
|
|
1270
|
+
function joinRoom(joinOptions) {
|
|
1271
|
+
var refused = unknownOption(joinOptions, JOIN_OPTIONS, "Zaisma.joinRoom");
|
|
1272
|
+
if (refused) return Promise.reject(refused);
|
|
1273
|
+
joinOptions = joinOptions || {};
|
|
1274
|
+
return connect(joinOptions.roomCode ? String(joinOptions.roomCode).toUpperCase() : linkedRoomFor(joinOptions.serverUrl), joinOptions);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function connect(roomCode, joinOptions, isRetry) {
|
|
1278
|
+
var serverUrl = joinOptions.serverUrl ? String(joinOptions.serverUrl).replace(/\/+$/, "") : null;
|
|
1279
|
+
if (!serverUrl && !config.rooms) return Promise.reject(notOnZaisma("Player passes come from Zaisma, so rooms work only in games hosted on Zaisma, in Zaisma rooms or on your own server (serverUrl)."));
|
|
1280
|
+
var idleTimeoutSec = joinOptions.idleTimeoutSec;
|
|
1281
|
+
if (idleTimeoutSec !== undefined && !(typeof idleTimeoutSec === "number" && idleTimeoutSec >= MINIMUM_IDLE_TIMEOUT_SEC && idleTimeoutSec <= MAXIMUM_IDLE_TIMEOUT_SEC)) return Promise.reject(codedError("bad_idle_timeout", "idleTimeoutSec runs from " + MINIMUM_IDLE_TIMEOUT_SEC + " to " + MAXIMUM_IDLE_TIMEOUT_SEC + "."));
|
|
1282
|
+
if (serverUrl && !SERVER_URL.test(serverUrl)) return Promise.reject(codedError("bad_server_url", "serverUrl takes a full address starting with wss://, ws://, https:// or http://, like \"wss://play.example.com\"."));
|
|
1283
|
+
if (roomCode && !ROOM_CODE.test(roomCode)) return Promise.reject(codedError("bad_room_code", JSON.stringify(roomCode) + " isn't a room code: room codes are six characters like \"K7Q2MX\", from 2 to 9 and A to Z without I or O."));
|
|
1284
|
+
|
|
1285
|
+
var roomCodeReady = roomCode
|
|
1286
|
+
? Promise.resolve(roomCode)
|
|
1287
|
+
: serverUrl
|
|
1288
|
+
? Promise.resolve(randomRoomCode())
|
|
1289
|
+
: post("/v1/rooms", { gameSlug: config.game }).then(function (answer) { return answer.roomCode; });
|
|
1290
|
+
return Promise.all([roomCodeReady, getPass()]).then(function (ready) {
|
|
1291
|
+
var base = (serverUrl || config.rooms).replace(/^http/, "ws");
|
|
1292
|
+
var room = new Room({
|
|
1293
|
+
roomCode: ready[0],
|
|
1294
|
+
pass: ready[1].pass,
|
|
1295
|
+
url: base + "/v1/rooms/" + config.game + "/" + ready[0],
|
|
1296
|
+
isListed: joinOptions.isListed,
|
|
1297
|
+
friendJoinsEnabled: joinOptions.friendJoinsEnabled,
|
|
1298
|
+
name: joinOptions.name,
|
|
1299
|
+
labels: joinOptions.labels,
|
|
1300
|
+
joinData: joinOptions.joinData,
|
|
1301
|
+
clientIdle: clientIdle(joinOptions),
|
|
1302
|
+
serverUrl: serverUrl,
|
|
1303
|
+
isSpectator: joinOptions.isSpectator === true,
|
|
1304
|
+
});
|
|
1305
|
+
room._avoidsBlockedPlayers = joinOptions.avoidBlockedPlayers === true;
|
|
1306
|
+
room._isQuickJoin = joinOptions.isQuickJoin === true;
|
|
1307
|
+
joined.push(room);
|
|
1308
|
+
// Tell the Zaisma page the room code, so its Invite button and address bar point here.
|
|
1309
|
+
room._sayRoomToPage();
|
|
1310
|
+
return new Promise(function (resolve, reject) {
|
|
1311
|
+
room._ready = { resolve: resolve, reject: reject };
|
|
1312
|
+
room._connect();
|
|
1313
|
+
}).catch(function (error) {
|
|
1314
|
+
// A pass this tab kept from before Zaisma changed its keys: get a new one and try once more.
|
|
1315
|
+
if (error.code !== "bad_pass" || isRetry) throw error;
|
|
1316
|
+
try { sessionStorage.removeItem(passKey); } catch (storageError) { /* fine */ }
|
|
1317
|
+
return connect(room.roomCode, joinOptions, true);
|
|
1318
|
+
});
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** The game's listed rooms on Zaisma, for a game's own lobby (zaisma.d.ts: RoomList). */
|
|
1323
|
+
function listRooms(listOptions) {
|
|
1324
|
+
var refused = unknownOption(listOptions, LIST_OPTIONS, "Zaisma.rooms.list");
|
|
1325
|
+
if (refused) return Promise.reject(refused);
|
|
1326
|
+
if (!config.rooms) return Promise.reject(notOnZaisma("Room lists show Zaisma rooms, so they work only in games played on Zaisma."));
|
|
1327
|
+
listOptions = listOptions || {};
|
|
1328
|
+
if (listOptions.region !== undefined && listOptions.region !== null && REGIONS.indexOf(listOptions.region) === -1) return Promise.reject(badRegion());
|
|
1329
|
+
var query = [];
|
|
1330
|
+
if (listOptions.region) query.push("region=" + encodeURIComponent(listOptions.region));
|
|
1331
|
+
if (listOptions.labels) query.push("labels=" + encodeURIComponent(JSON.stringify(listOptions.labels)));
|
|
1332
|
+
// The room this tab just left, so the player doesn't see themselves in it while lists catch up.
|
|
1333
|
+
var leftAgoMs = recent ? Date.now() - recent.leftAtMs : -1;
|
|
1334
|
+
var isBack = recent && joined.some(function (room) { return room.roomCode === recent.roomCode && !room._done && !room._away; });
|
|
1335
|
+
if (leftAgoMs >= 0 && leftAgoMs < 30000 && !isBack) query.push("left=" + recent.roomCode + "." + leftAgoMs + "." + (Date.now() - recent.joinedAtMs));
|
|
1336
|
+
if (config.tester) query.push("tester=" + config.tester);
|
|
1337
|
+
return fetch(config.rooms + "/v1/rooms/" + config.game + (query.length ? "?" + query.join("&") : "")).then(answered, requestFailed).then(readRoomList);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function badRegion() {
|
|
1341
|
+
return codedError("bad_region", "region is a continent code: " + REGIONS.slice(0, -1).join(", ") + " or " + REGIONS[REGIONS.length - 1] + ".");
|
|
1342
|
+
}
|
|
1343
|
+
function regionOf(given) {
|
|
1344
|
+
return REGIONS.indexOf(given) === -1 ? null : given;
|
|
1345
|
+
}
|
|
1346
|
+
function countOf(given) {
|
|
1347
|
+
return typeof given === "number" ? given : 0;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
/** The Worker's room list in the SDK's names (zaisma.d.ts: RoomList), field by field, so nothing unknown reaches the game. */
|
|
1351
|
+
function readRoomList(answer) {
|
|
1352
|
+
var playing = (answer && answer.playing) || {};
|
|
1353
|
+
return {
|
|
1354
|
+
rooms: (Array.isArray(answer.rooms) ? answer.rooms : []).map(function (room) {
|
|
1355
|
+
var location = room.location;
|
|
1356
|
+
return {
|
|
1357
|
+
roomCode: room.roomCode,
|
|
1358
|
+
playerCount: countOf(room.playerCount),
|
|
1359
|
+
heldSeatCount: countOf(room.heldSeatCount),
|
|
1360
|
+
maximumPlayerCount: countOf(room.maximumPlayerCount),
|
|
1361
|
+
spectatorCount: countOf(room.spectatorCount),
|
|
1362
|
+
maximumSpectatorCount: countOf(room.maximumSpectatorCount),
|
|
1363
|
+
isOpen: room.isOpen !== false,
|
|
1364
|
+
location: location ? { countryCode: location.countryCode, countryName: location.countryName, region: regionOf(location.region) } : null,
|
|
1365
|
+
isNear: room.isNear === true,
|
|
1366
|
+
name: typeof room.name === "string" ? room.name : null,
|
|
1367
|
+
labels: room.labels && typeof room.labels === "object" ? room.labels : {},
|
|
1368
|
+
isKept: room.isKept === true,
|
|
1369
|
+
inviteUrl: room.inviteUrl,
|
|
1370
|
+
};
|
|
1371
|
+
}),
|
|
1372
|
+
playingPlayerCount: countOf(playing.playerCount),
|
|
1373
|
+
playingRoomCount: countOf(playing.roomCount),
|
|
1374
|
+
listedClosedRoomCount: countOf(playing.closedRoomCount),
|
|
1375
|
+
listedJoinableRoomCount: countOf(answer.joinableRoomCount),
|
|
1376
|
+
matchingOpenRoomCount: countOf(answer.openRoomCount),
|
|
1377
|
+
moreOpenRoomCount: countOf(answer.moreOpenRoomCount),
|
|
1378
|
+
regions: (Array.isArray(answer.regions) ? answer.regions : []).filter(function (entry) { return regionOf(entry.region); }).map(function (entry) {
|
|
1379
|
+
return { region: entry.region, regionName: entry.name, roomCount: countOf(entry.roomCount), playerCount: countOf(entry.playerCount) };
|
|
1380
|
+
}),
|
|
1381
|
+
region: regionOf(answer.region),
|
|
1382
|
+
filters: Array.isArray(answer.filters) ? answer.filters : [],
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// A room that can't take this player, so another may: hosting_paused is an empty room that can't open again while its game's hosting is paused.
|
|
1387
|
+
var TRY_ANOTHER = ["room_full", "room_closed", "ended", "hosting_paused"];
|
|
1388
|
+
|
|
1389
|
+
/** The linked room if it has space, else a listed room with space (nearby and busy first), else a new room. */
|
|
1390
|
+
function quickJoin(quickJoinOptions) {
|
|
1391
|
+
var refused = unknownOption(quickJoinOptions, QUICK_JOIN_OPTIONS, "Zaisma.rooms.quickJoin");
|
|
1392
|
+
if (refused) return Promise.reject(refused);
|
|
1393
|
+
quickJoinOptions = quickJoinOptions || {};
|
|
1394
|
+
if (quickJoinOptions.region !== undefined && quickJoinOptions.region !== null && REGIONS.indexOf(quickJoinOptions.region) === -1) return Promise.reject(badRegion());
|
|
1395
|
+
var joinOptions = {};
|
|
1396
|
+
JOIN_OPTIONS.forEach(function (name) { if (quickJoinOptions[name] !== undefined) joinOptions[name] = quickJoinOptions[name]; });
|
|
1397
|
+
// Rooms quickJoin makes are for others to find too, unless the game says otherwise.
|
|
1398
|
+
if (joinOptions.isListed === undefined) joinOptions.isListed = true;
|
|
1399
|
+
joinOptions.isQuickJoin = true;
|
|
1400
|
+
var linked = linkedRoomFor(undefined);
|
|
1401
|
+
function fresh() { return connect(null, joinOptions); }
|
|
1402
|
+
function busiest() {
|
|
1403
|
+
return listRooms({ region: quickJoinOptions.region, labels: quickJoinOptions.labels }).then(function (found) {
|
|
1404
|
+
var tries = found.rooms.filter(function (room) { return room.isOpen && room.playerCount + room.heldSeatCount < room.maximumPlayerCount && room.roomCode !== linked; }).slice(0, 3);
|
|
1405
|
+
// Rooms with someone this player blocked come after the others, and before a new room.
|
|
1406
|
+
var later = [];
|
|
1407
|
+
function next() {
|
|
1408
|
+
var room = tries.shift();
|
|
1409
|
+
if (room) {
|
|
1410
|
+
return connect(room.roomCode, Object.assign({}, joinOptions, { avoidBlockedPlayers: true })).catch(function (error) {
|
|
1411
|
+
if (error.code === "blocked_player_here") later.push(room);
|
|
1412
|
+
else if (TRY_ANOTHER.indexOf(error.code) === -1) throw error;
|
|
1413
|
+
return next();
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
room = later.shift();
|
|
1417
|
+
if (!room) return fresh();
|
|
1418
|
+
return connect(room.roomCode, joinOptions).catch(function (error) { if (TRY_ANOTHER.indexOf(error.code) !== -1) return next(); throw error; });
|
|
1419
|
+
}
|
|
1420
|
+
return next();
|
|
1421
|
+
}, fresh);
|
|
1422
|
+
}
|
|
1423
|
+
if (!linked) return busiest();
|
|
1424
|
+
return connect(linked, joinOptions).catch(function (error) { if (TRY_ANOTHER.indexOf(error.code) !== -1) return busiest(); throw error; });
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/*
|
|
1428
|
+
* Friends in games (zaisma.d.ts: Zaisma.friends): the player's friends playing this game, and the
|
|
1429
|
+
* room on the game's own servers the player is in, so friends' Join works there too. The player's
|
|
1430
|
+
* pass says who's asking; games never see friends who aren't playing them.
|
|
1431
|
+
*/
|
|
1432
|
+
var FRIENDS_KEPT_MS = 10000;
|
|
1433
|
+
var OWN_ROOM_CHECK_IN_MS = 2 * 60 * 1000;
|
|
1434
|
+
var OWN_ROOM_OPTIONS = ["serverUrl", "roomCode", "friendJoinsEnabled"];
|
|
1435
|
+
// The last answer and when it was asked, for 10 seconds.
|
|
1436
|
+
var friendsAsked = null;
|
|
1437
|
+
// The room on the game's own servers the player is in ({ serverUrl, roomCode }), said again every 2 minutes.
|
|
1438
|
+
var ownRoom = null;
|
|
1439
|
+
var ownRoomTimer = null;
|
|
1440
|
+
|
|
1441
|
+
/** A call about friends with the player's pass, with a new pass once if Zaisma's keys changed; guests have no friends. */
|
|
1442
|
+
function friendsCall(path, body, isRetry) {
|
|
1443
|
+
return getPass().then(function (mine) {
|
|
1444
|
+
if (mine.kind !== "account") throw codedError("account_needed", "Friends are for players signed in to Zaisma: ask the player to sign in first (Zaisma.player.signIn).");
|
|
1445
|
+
return post(path, Object.assign({ gameSlug: config.game, pass: mine.pass }, body));
|
|
1446
|
+
}).catch(function (error) {
|
|
1447
|
+
if (error.code !== "bad_pass" || isRetry) throw error;
|
|
1448
|
+
try { sessionStorage.removeItem(passKey); } catch (storageError) { /* fine */ }
|
|
1449
|
+
return friendsCall(path, body, true);
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/** The player's friends playing this game, kept for 10 seconds (zaisma.d.ts: Friends.list). */
|
|
1454
|
+
function listFriends() {
|
|
1455
|
+
if (!config.rooms) return Promise.reject(notOnZaisma("Friends come from Zaisma, so they work only in games played on Zaisma."));
|
|
1456
|
+
if (friendsAsked && Date.now() - friendsAsked.atMs < FRIENDS_KEPT_MS) return friendsAsked.answer;
|
|
1457
|
+
var asked = {
|
|
1458
|
+
atMs: Date.now(),
|
|
1459
|
+
answer: friendsCall("/v1/friends", {}, false).then(function (found) {
|
|
1460
|
+
return {
|
|
1461
|
+
friends: found.friends.map(function (friend) {
|
|
1462
|
+
return { id: friend.player_id, name: friend.username, username: friend.username, roomCode: friend.room_code, serverUrl: friend.server_url, joinRefusal: friend.join_refusal };
|
|
1463
|
+
}),
|
|
1464
|
+
};
|
|
1465
|
+
}),
|
|
1466
|
+
};
|
|
1467
|
+
friendsAsked = asked;
|
|
1468
|
+
asked.answer.catch(function () { if (friendsAsked === asked) friendsAsked = null; });
|
|
1469
|
+
return asked.answer;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** Which room on the game's own servers the player is in, or null (zaisma.d.ts: Friends.setRoom). */
|
|
1473
|
+
function setOwnRoom(room) {
|
|
1474
|
+
if (!config.rooms) return Promise.reject(notOnZaisma("Friends' Join comes from Zaisma, so it works only in games played on Zaisma."));
|
|
1475
|
+
if (room !== null) {
|
|
1476
|
+
if (!room || typeof room !== "object") return Promise.reject(codedError("unknown_option", "Zaisma.friends.setRoom takes { serverUrl, roomCode, friendJoinsEnabled? }, or null once the player left the room."));
|
|
1477
|
+
var refused = unknownOption(room, OWN_ROOM_OPTIONS, "Zaisma.friends.setRoom");
|
|
1478
|
+
if (refused) return Promise.reject(refused);
|
|
1479
|
+
var serverUrl = String(room.serverUrl || "").replace(/\/+$/, "");
|
|
1480
|
+
if (!OWN_SERVER_URL.test(serverUrl)) return Promise.reject(codedError("bad_server_url", "serverUrl takes your own server's address starting with wss:// or https://, like \"wss://play.example.com\"."));
|
|
1481
|
+
var roomCode = String(room.roomCode || "").toUpperCase();
|
|
1482
|
+
if (!ROOM_CODE.test(roomCode)) return Promise.reject(codedError("bad_room_code", JSON.stringify(room.roomCode) + " isn't a room code: room codes are six characters like \"K7Q2MX\", from 2 to 9 and A to Z without I or O."));
|
|
1483
|
+
room = { serverUrl: serverUrl, roomCode: roomCode, friendJoinsEnabled: room.friendJoinsEnabled !== false };
|
|
1484
|
+
}
|
|
1485
|
+
ownRoom = room;
|
|
1486
|
+
clearInterval(ownRoomTimer);
|
|
1487
|
+
ownRoomTimer = room ? setInterval(function () { sayOwnRoom().catch(function () { /* said again next time */ }); }, OWN_ROOM_CHECK_IN_MS) : null;
|
|
1488
|
+
return sayOwnRoom();
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function sayOwnRoom() {
|
|
1492
|
+
var said = ownRoom ? { serverUrl: ownRoom.serverUrl, roomCode: ownRoom.roomCode, friendJoinsEnabled: ownRoom.friendJoinsEnabled } : { serverUrl: null, roomCode: null };
|
|
1493
|
+
return friendsCall("/v1/friends/room", said, false).then(function () { /* said */ }, function (error) {
|
|
1494
|
+
// A guest has no friends to tell.
|
|
1495
|
+
if (error.code !== "account_needed") throw error;
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// Closing the page leaves the room for friends at once; one that comes back from the back button says it again.
|
|
1500
|
+
addEventListener("pagehide", function () {
|
|
1501
|
+
var kept = ownRoom && config.rooms ? keptPass() : null;
|
|
1502
|
+
if (!kept || kept.kind !== "account") return;
|
|
1503
|
+
try {
|
|
1504
|
+
fetch(config.rooms + "/v1/friends/room", { method: "POST", keepalive: true, headers: { "Content-Type": "text/plain" }, body: JSON.stringify({ gameSlug: config.game, pass: kept.pass, serverUrl: null, roomCode: null }) }).catch(function () { /* drops off by itself */ });
|
|
1505
|
+
} catch (error) {
|
|
1506
|
+
/* drops off by itself */
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
addEventListener("pageshow", function (event) {
|
|
1510
|
+
if (event.persisted && ownRoom) sayOwnRoom().catch(function () { /* said again next time */ });
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
var REQUEST_OPTIONS = ["serverUrl"];
|
|
1514
|
+
var MAXIMUM_REQUEST_SIZE_BYTES = 16 * 1024;
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* A request to the game's own game server, its onRequest, outside any room: what it answered
|
|
1518
|
+
* (zaisma.d.ts: Zaisma.server.request). The player's pass goes with it, so the server knows who's asking.
|
|
1519
|
+
*/
|
|
1520
|
+
function serverRequest(request, requestOptions, isRetry) {
|
|
1521
|
+
var refused = unknownOption(requestOptions, REQUEST_OPTIONS, "Zaisma.server.request");
|
|
1522
|
+
if (refused) return Promise.reject(refused);
|
|
1523
|
+
var serverUrl = requestOptions && requestOptions.serverUrl ? String(requestOptions.serverUrl).replace(/\/+$/, "") : null;
|
|
1524
|
+
if (!serverUrl && !config.rooms) return Promise.reject(notOnZaisma("Player passes come from Zaisma, so Zaisma.server.request works only in games hosted on Zaisma, to Zaisma's servers or to your own (serverUrl)."));
|
|
1525
|
+
if (serverUrl && !SERVER_URL.test(serverUrl)) return Promise.reject(codedError("bad_server_url", "serverUrl takes a full address starting with https://, http://, wss:// or ws://, like \"https://play.example.com\"."));
|
|
1526
|
+
var requestText;
|
|
1527
|
+
try {
|
|
1528
|
+
requestText = JSON.stringify(request === undefined ? null : request);
|
|
1529
|
+
} catch (error) {
|
|
1530
|
+
requestText = undefined;
|
|
1531
|
+
}
|
|
1532
|
+
if (requestText === undefined) return Promise.reject(codedError("bad_request", "A request is plain JSON: numbers, text, true or false, null, lists and plain objects, like { type: \"buy\", itemId: \"sword\" }."));
|
|
1533
|
+
if (new TextEncoder().encode(requestText).length > MAXIMUM_REQUEST_SIZE_BYTES) return Promise.reject(codedError("too_big", "Requests are up to 16 KB as JSON."));
|
|
1534
|
+
var base = (serverUrl || config.rooms).replace(/^ws/, "http");
|
|
1535
|
+
return getPass().then(function (mine) {
|
|
1536
|
+
return fetch(base + "/v1/requests/" + config.game, { method: "POST", headers: { "Content-Type": "text/plain" }, body: JSON.stringify({ pass: mine.pass, request: JSON.parse(requestText) }) }).then(answered, requestFailed);
|
|
1537
|
+
}).then(function (answer) {
|
|
1538
|
+
return answer.answer;
|
|
1539
|
+
}, function (error) {
|
|
1540
|
+
// A pass this tab kept from before Zaisma changed its keys: get a new one and try once more.
|
|
1541
|
+
if (error.code !== "bad_pass" || isRetry) throw error;
|
|
1542
|
+
try { sessionStorage.removeItem(passKey); } catch (storageError) { /* fine */ }
|
|
1543
|
+
return serverRequest(request, requestOptions, true);
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
var server = {
|
|
1548
|
+
request: function (request, requestOptions) { return serverRequest(request, requestOptions, false); },
|
|
1549
|
+
};
|
|
1550
|
+
|
|
1551
|
+
/** Zaisma's chat check: { isAllowed, reason, message, hasSwearing, text } (zaisma.d.ts: Zaisma.checkChat). */
|
|
1552
|
+
function checkChat(text) {
|
|
1553
|
+
return post("/v1/filter", { gameSlug: config.game, text: String(text == null ? "" : text) });
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
var shareRequestCount = 0;
|
|
1557
|
+
var shareWaiters = {};
|
|
1558
|
+
|
|
1559
|
+
window.addEventListener("message", function (event) {
|
|
1560
|
+
if (!config.site || event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== "zaisma:share-answer") return;
|
|
1561
|
+
var waiter = shareWaiters[event.data.requestNumber];
|
|
1562
|
+
if (!waiter) return;
|
|
1563
|
+
delete shareWaiters[event.data.requestNumber];
|
|
1564
|
+
clearTimeout(waiter.timer);
|
|
1565
|
+
waiter.resolve(event.data.isOpened === true ? "opened" : "failed");
|
|
1566
|
+
});
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* On Zaisma, the page's Share (its QR code, the link and the room's code): "opened", or "failed" when the
|
|
1570
|
+
* page couldn't open it. Elsewhere the share sheet on phones, else copy the link: "shared", "copied",
|
|
1571
|
+
* "cancelled" or "failed". Either way only right after the player's tap.
|
|
1572
|
+
*/
|
|
1573
|
+
function invite(room) {
|
|
1574
|
+
// Only right after the player tapped or pressed a key in the game, on Zaisma or off it.
|
|
1575
|
+
if (navigator.userActivation && !navigator.userActivation.isActive) return Promise.reject(codedError("needs_player_tap", "Invite opens only right after the player taps or presses a key in the game: call Zaisma.invite from that tap's handler."));
|
|
1576
|
+
if (onZaismaPage()) {
|
|
1577
|
+
return new Promise(function (resolve, reject) {
|
|
1578
|
+
var requestNumber = ++shareRequestCount;
|
|
1579
|
+
var waiter = { resolve: resolve };
|
|
1580
|
+
// No answer means no Zaisma page is around the game.
|
|
1581
|
+
waiter.timer = setTimeout(function () {
|
|
1582
|
+
delete shareWaiters[requestNumber];
|
|
1583
|
+
reject(notOnZaisma("No Zaisma page answered: Zaisma's Share opens when the game is played on its zaisma.com page."));
|
|
1584
|
+
}, 3000);
|
|
1585
|
+
shareWaiters[requestNumber] = waiter;
|
|
1586
|
+
window.parent.postMessage({ type: "zaisma:share", requestNumber: requestNumber }, config.site);
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
var link = room && room.inviteUrl ? room.inviteUrl : config.site && config.game ? config.site + gamePage() : location.href;
|
|
1590
|
+
function copy() {
|
|
1591
|
+
if (!navigator.clipboard) return Promise.resolve("failed");
|
|
1592
|
+
return navigator.clipboard.writeText(link).then(function () { return "copied"; }, function () { return "failed"; });
|
|
1593
|
+
}
|
|
1594
|
+
if (navigator.share && matchMedia("(pointer: coarse)").matches) {
|
|
1595
|
+
return navigator.share({ title: document.title, url: link }).then(function () { return "shared"; }, function (error) {
|
|
1596
|
+
return error && error.name === "AbortError" ? "cancelled" : copy();
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
return copy();
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/*
|
|
1603
|
+
* Registered links (zaisma.d.ts: Zaisma.links.open). On Zaisma, the page around the game looks the
|
|
1604
|
+
* link up, shows its "You're leaving Zaisma" card and opens it; on the developer's own site there's no
|
|
1605
|
+
* Zaisma page, so the SDK opens ownSiteUrl itself. Either way only right after the player's tap.
|
|
1606
|
+
*/
|
|
1607
|
+
var LINK_OPTIONS = ["path", "queryParameters", "ownSiteUrl"];
|
|
1608
|
+
var LINK_NAME = /^[a-z][a-z0-9_]{0,31}$/;
|
|
1609
|
+
var LINK_PATH = /^(\/[^/?#\\]+)+\/?$/;
|
|
1610
|
+
// What the page answers when a link can't be looked up; anything else is request_failed.
|
|
1611
|
+
var LINK_ERROR_CODES = ["needs_player_tap", "unknown_link", "bad_link_path", "bad_link_query", "slow_down", "request_failed"];
|
|
1612
|
+
var linkRequestCount = 0;
|
|
1613
|
+
var linkWaiters = {};
|
|
1614
|
+
|
|
1615
|
+
window.addEventListener("message", function (event) {
|
|
1616
|
+
if (!config.site || event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== "zaisma:link") return;
|
|
1617
|
+
var waiter = linkWaiters[event.data.requestNumber];
|
|
1618
|
+
if (!waiter) return;
|
|
1619
|
+
if (event.data.status === "asking") { waiter.isAsked = true; return; }
|
|
1620
|
+
delete linkWaiters[event.data.requestNumber];
|
|
1621
|
+
clearTimeout(waiter.timer);
|
|
1622
|
+
if (event.data.status === "opened" || event.data.status === "stayed") return waiter.resolve(event.data.status);
|
|
1623
|
+
var code = LINK_ERROR_CODES.indexOf(event.data.code) === -1 ? "request_failed" : event.data.code;
|
|
1624
|
+
waiter.reject(codedError(code, typeof event.data.message === "string" ? event.data.message : "Zaisma didn't answer, so check the connection and try again."));
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
/** What's wrong with the path and query a game adds under a link's address, or null. */
|
|
1628
|
+
function linkPartsProblem(linkOptions) {
|
|
1629
|
+
var path = linkOptions.path;
|
|
1630
|
+
var query = linkOptions.queryParameters;
|
|
1631
|
+
var isBadPath = path !== undefined && path !== null && (typeof path !== "string" || path.length > 500 || !LINK_PATH.test(path) || path.split("/").some(function (segment) {
|
|
1632
|
+
var dots = segment.replace(/%2e/gi, ".");
|
|
1633
|
+
return dots === "." || dots === "..";
|
|
1634
|
+
}));
|
|
1635
|
+
if (isBadPath) return codedError("bad_link_path", "path " + JSON.stringify(path) + " isn't a path to add under the link's address: it starts with /, like \"/buy\", with no ?, #, . or .. parts. Give query parameters as queryParameters.");
|
|
1636
|
+
if (query === undefined || query === null) return null;
|
|
1637
|
+
var names = typeof query === "object" && !Array.isArray(query) ? Object.keys(query) : null;
|
|
1638
|
+
var isBadQuery = !names || names.length > 20 || names.some(function (name) { return !name || name.length > 100 || typeof query[name] !== "string" || query[name].length > 500; });
|
|
1639
|
+
return isBadQuery ? codedError("bad_link_query", "queryParameters takes up to 20 names, each with text of up to 500 characters, like { item: \"sword\", count: String(3) }.") : null;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/** Open one of the game's registered links: "opened" or "stayed" (zaisma.d.ts: Zaisma.links.open). */
|
|
1643
|
+
function openLink(linkName, linkOptions) {
|
|
1644
|
+
var refused = unknownOption(linkOptions, LINK_OPTIONS, "Zaisma.links.open");
|
|
1645
|
+
if (refused) return Promise.reject(refused);
|
|
1646
|
+
linkOptions = linkOptions || {};
|
|
1647
|
+
if (typeof linkName !== "string" || !LINK_NAME.test(linkName)) return Promise.reject(codedError("unknown_link", JSON.stringify(linkName) + " isn't a link name: open a link by the link_name registered on the game, like \"store\"."));
|
|
1648
|
+
var problem = linkPartsProblem(linkOptions);
|
|
1649
|
+
if (problem) return Promise.reject(problem);
|
|
1650
|
+
var ownSiteUrl = linkOptions.ownSiteUrl;
|
|
1651
|
+
if (ownSiteUrl !== undefined && ownSiteUrl !== null && (typeof ownSiteUrl !== "string" || !/^https:\/\/[^/]/.test(ownSiteUrl))) return Promise.reject(codedError("bad_own_site_url", "ownSiteUrl takes the link's whole address on your own site, starting with https://, like \"https://kit.itch.io/sky-hopper\"."));
|
|
1652
|
+
// Only right after the player tapped or pressed a key in the game, so nothing ever opens by itself.
|
|
1653
|
+
if (navigator.userActivation && !navigator.userActivation.isActive) return Promise.reject(codedError("needs_player_tap", "Links open only right after the player taps or presses a key in the game: call Zaisma.links.open from that tap's handler."));
|
|
1654
|
+
if (!config.site || window.parent === window) return openOnOwnSite(linkOptions);
|
|
1655
|
+
return new Promise(function (resolve, reject) {
|
|
1656
|
+
var requestNumber = ++linkRequestCount;
|
|
1657
|
+
var waiter = { resolve: resolve, reject: reject, isAsked: false };
|
|
1658
|
+
// The page answers at once that it's asking; no answer means no Zaisma page is around the game.
|
|
1659
|
+
waiter.timer = setTimeout(function () {
|
|
1660
|
+
if (waiter.isAsked) return;
|
|
1661
|
+
delete linkWaiters[requestNumber];
|
|
1662
|
+
reject(notOnZaisma("No Zaisma page answered: links open when the game is played on its zaisma.com page, or give ownSiteUrl for your own site."));
|
|
1663
|
+
}, 3000);
|
|
1664
|
+
linkWaiters[requestNumber] = waiter;
|
|
1665
|
+
window.parent.postMessage({ type: "zaisma:link", requestNumber: requestNumber, linkName: linkName, path: linkOptions.path === undefined ? null : linkOptions.path, queryParameters: linkOptions.queryParameters === undefined ? null : linkOptions.queryParameters }, config.site);
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/** On the developer's own site: the address the game gave, with the path and query added under it. */
|
|
1670
|
+
function openOnOwnSite(linkOptions) {
|
|
1671
|
+
if (!linkOptions.ownSiteUrl) return Promise.reject(notOnZaisma("Outside Zaisma there's no page to look the link up, so give its address too, like Zaisma.links.open(\"store\", { ownSiteUrl: \"https://kit.itch.io/sky-hopper\" })."));
|
|
1672
|
+
var url = new URL(linkOptions.ownSiteUrl);
|
|
1673
|
+
var basePath = url.pathname.replace(/\/+$/, "");
|
|
1674
|
+
if (linkOptions.path) url.pathname = basePath + linkOptions.path;
|
|
1675
|
+
var query = linkOptions.queryParameters || {};
|
|
1676
|
+
Object.keys(query).forEach(function (name) { url.searchParams.append(name, query[name]); });
|
|
1677
|
+
var opened = window.open(url.href, "_blank");
|
|
1678
|
+
if (opened) opened.opener = null;
|
|
1679
|
+
return Promise.resolve(opened ? "opened" : "stayed");
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
var links = { open: openLink };
|
|
1683
|
+
|
|
1684
|
+
/*
|
|
1685
|
+
* Items (zaisma.d.ts: Zaisma.items): what the player owns comes from their pass (owned_item_names), so it
|
|
1686
|
+
* needs no call of its own and matches what the game's server sees; what's for sale comes from Zaisma rooms,
|
|
1687
|
+
* priced in the player's currency by their country.
|
|
1688
|
+
*/
|
|
1689
|
+
function passClaimsOf(pass) {
|
|
1690
|
+
try {
|
|
1691
|
+
var body = pass.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
1692
|
+
var bytes = Uint8Array.from(atob(body), function (character) { return character.charCodeAt(0); });
|
|
1693
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
1694
|
+
} catch (error) {
|
|
1695
|
+
return {};
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
function ownedItems() {
|
|
1699
|
+
return getPass().then(function (mine) {
|
|
1700
|
+
var names = passClaimsOf(mine.pass).owned_item_names;
|
|
1701
|
+
return Array.isArray(names) ? names.slice() : [];
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
function ownsItem(itemName) {
|
|
1705
|
+
if (typeof itemName !== "string" || !itemName) return Promise.reject(codedError("bad_item_name", "itemName isn't an item's name, like \"revive\": list_items names the game's items."));
|
|
1706
|
+
return ownedItems().then(function (names) { return names.indexOf(itemName) !== -1; });
|
|
1707
|
+
}
|
|
1708
|
+
function listItems() {
|
|
1709
|
+
if (!config.rooms) return Promise.reject(notOnZaisma("Items for sale come from Zaisma, so they list only in games played on Zaisma."));
|
|
1710
|
+
var listed = fetch(config.rooms + "/v1/items/" + config.game + (config.tester ? "?tester=" + config.tester : "")).then(answered, requestFailed);
|
|
1711
|
+
// Whether the player owns each comes from their pass; without one (not checked yet), none are owned.
|
|
1712
|
+
var owned = ownedItems().catch(function () { return []; });
|
|
1713
|
+
return Promise.all([listed, owned]).then(function (both) {
|
|
1714
|
+
return both[0].items.map(function (forSale) {
|
|
1715
|
+
return { itemName: forSale.itemName, title: forSale.title, description: forSale.description, itemKind: forSale.itemKind, itemCategory: forSale.itemCategory, priceMinorUnits: forSale.priceMinorUnits, currencyCode: forSale.currencyCode, priceText: forSale.priceText, isOwned: both[1].indexOf(forSale.itemName) !== -1 };
|
|
1716
|
+
});
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/*
|
|
1721
|
+
* Buying through Zaisma's buy sheet: on the game's zaisma.com page the sheet slides up over the game, which keeps
|
|
1722
|
+
* running; on the developer's own site a zaisma.com window opens. Every player outcome resolves (bought,
|
|
1723
|
+
* cancelled, failed); only a developer's mistake rejects. After a buy, the next pass owns the item at once.
|
|
1724
|
+
*/
|
|
1725
|
+
// What a buy rejects with: the developer's mistakes and a page that couldn't be reached.
|
|
1726
|
+
var BUY_ERROR_CODES = ["needs_player_tap", "bad_item_name", "no_item", "no_game", "not_for_sale", "stripe_not_connected", "stripe_cannot_take_payments", "bad_item_count", "bad_game_account_id", "request_failed"];
|
|
1727
|
+
var BUY_WINDOW_WIDTH_PX = 480;
|
|
1728
|
+
var BUY_WINDOW_HEIGHT_PX = 720;
|
|
1729
|
+
var buyRequestCount = 0;
|
|
1730
|
+
var buyWaiters = {};
|
|
1731
|
+
var itemsEvents = new Emitter(ITEMS_EVENTS, "Zaisma.items has");
|
|
1732
|
+
|
|
1733
|
+
window.addEventListener("message", function (event) {
|
|
1734
|
+
if (!event.data || event.data.type !== "zaisma:buy") return;
|
|
1735
|
+
var waiter = buyWaiters[event.data.requestNumber];
|
|
1736
|
+
// From the page around the game, or the buy window this tab opened: never anyone else.
|
|
1737
|
+
if (!waiter || event.origin !== waiter.origin || event.source !== waiter.source) return;
|
|
1738
|
+
if (event.data.status === "open") {
|
|
1739
|
+
waiter.isOpen = true;
|
|
1740
|
+
clearTimeout(waiter.timer);
|
|
1741
|
+
itemsEvents._emit("sheetOpen", { itemName: waiter.itemName });
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
delete buyWaiters[event.data.requestNumber];
|
|
1745
|
+
clearTimeout(waiter.timer);
|
|
1746
|
+
clearInterval(waiter.closedCheck);
|
|
1747
|
+
if (waiter.isOpen) itemsEvents._emit("sheetClose", { itemName: waiter.itemName });
|
|
1748
|
+
if (event.data.status === "error") {
|
|
1749
|
+
var code = BUY_ERROR_CODES.indexOf(event.data.code) === -1 ? "request_failed" : event.data.code;
|
|
1750
|
+
var errorText = typeof event.data.message === "string" ? event.data.message : "Zaisma didn't answer, so check the connection and try again.";
|
|
1751
|
+
return waiter.reject(codedError(code, typeof event.data.fix === "string" && event.data.fix ? errorText + " " + event.data.fix : errorText));
|
|
1752
|
+
}
|
|
1753
|
+
var status = event.data.status === "bought" || event.data.status === "failed" ? event.data.status : "cancelled";
|
|
1754
|
+
var purchase = status !== "cancelled" && event.data.purchase && typeof event.data.purchase === "object" ? purchaseFromSheet(event.data.purchase) : null;
|
|
1755
|
+
if (status !== "bought") return waiter.resolve({ status: status, purchase: purchase });
|
|
1756
|
+
// A fresh pass, so items.owns and the pass the game's servers get say it's owned now.
|
|
1757
|
+
try { sessionStorage.removeItem(passKey); } catch (storageError) { /* fine */ }
|
|
1758
|
+
passPromise = null;
|
|
1759
|
+
if (purchase) purchaseMade(purchase);
|
|
1760
|
+
waiter.resolve({ status: "bought", purchase: purchase });
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
/** The sheet's purchase (the developer API's snake_case) as the camelCase Purchase the purchase event hands over; receipt "" while there's none (a payment that never completed). */
|
|
1764
|
+
function purchaseFromSheet(purchase) {
|
|
1765
|
+
return {
|
|
1766
|
+
id: purchase.id,
|
|
1767
|
+
itemName: purchase.item_name,
|
|
1768
|
+
itemKind: purchase.item_kind,
|
|
1769
|
+
itemCount: purchase.item_count,
|
|
1770
|
+
purchaseSource: purchase.purchase_source,
|
|
1771
|
+
purchaseStatus: purchase.purchase_status,
|
|
1772
|
+
gameAccountId: purchase.game_account_id === undefined ? null : purchase.game_account_id,
|
|
1773
|
+
paidMinorUnits: purchase.paid_minor_units === undefined ? null : purchase.paid_minor_units,
|
|
1774
|
+
currencyCode: purchase.currency_code === undefined ? null : purchase.currency_code,
|
|
1775
|
+
purchasedAt: purchase.purchased_at === undefined ? null : purchase.purchased_at,
|
|
1776
|
+
refundedAt: purchase.refunded_at === undefined ? null : purchase.refunded_at,
|
|
1777
|
+
receipt: typeof purchase.receipt === "string" ? purchase.receipt : ""
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/** Buy an item through Zaisma's buy sheet (zaisma.d.ts: Zaisma.items.buy). */
|
|
1782
|
+
function buyItem(itemName, buyOptions) {
|
|
1783
|
+
var refused = unknownOption(buyOptions, BUY_OPTIONS, "Zaisma.items.buy");
|
|
1784
|
+
if (refused) return Promise.reject(refused);
|
|
1785
|
+
buyOptions = buyOptions || {};
|
|
1786
|
+
if (typeof itemName !== "string" || !itemName) return Promise.reject(codedError("bad_item_name", "itemName isn't an item's name, like \"revive\": list_items names the game's items."));
|
|
1787
|
+
var itemCount = buyOptions.itemCount === undefined ? 1 : buyOptions.itemCount;
|
|
1788
|
+
if (typeof itemCount !== "number" || itemCount % 1 !== 0 || itemCount < 1) return Promise.reject(codedError("bad_item_count", "itemCount is a whole number from 1, and 1 for a permanent item."));
|
|
1789
|
+
var gameAccountId = buyOptions.gameAccountId === undefined ? null : buyOptions.gameAccountId;
|
|
1790
|
+
if (gameAccountId !== null && (typeof gameAccountId !== "string" || gameAccountId.length > 200)) return Promise.reject(codedError("bad_game_account_id", "gameAccountId is your game's own account ID for the player, text of up to 200 characters, or null."));
|
|
1791
|
+
// Only right after the player tapped or pressed a key in the game, so a sheet never opens by itself.
|
|
1792
|
+
if (navigator.userActivation && !navigator.userActivation.isActive) return Promise.reject(codedError("needs_player_tap", "The buy sheet opens only right after the player taps or presses a key in the game: call Zaisma.items.buy from that tap's handler."));
|
|
1793
|
+
if (!config.game) return Promise.reject(notOnZaisma("Buying goes through Zaisma's buy sheet, so the game needs its Zaisma link name."));
|
|
1794
|
+
var requestNumber = ++buyRequestCount;
|
|
1795
|
+
var message = { type: "zaisma:buy", requestNumber: requestNumber, itemName: itemName, itemCount: itemCount, gameAccountId: gameAccountId };
|
|
1796
|
+
return new Promise(function (resolve, reject) {
|
|
1797
|
+
var waiter = { resolve: resolve, reject: reject, isOpen: false, itemName: itemName };
|
|
1798
|
+
if (config.site && window.parent !== window) {
|
|
1799
|
+
waiter.origin = config.site;
|
|
1800
|
+
waiter.source = window.parent;
|
|
1801
|
+
// The page answers "open" at once; no answer means no Zaisma page is around the game.
|
|
1802
|
+
waiter.timer = setTimeout(function () {
|
|
1803
|
+
if (waiter.isOpen) return;
|
|
1804
|
+
delete buyWaiters[requestNumber];
|
|
1805
|
+
reject(notOnZaisma("No Zaisma page answered: the buy sheet opens when the game is played on its zaisma.com page."));
|
|
1806
|
+
}, 3000);
|
|
1807
|
+
buyWaiters[requestNumber] = waiter;
|
|
1808
|
+
window.parent.postMessage(message, config.site);
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
// On the developer's own site: a zaisma.com window, which answers this page if its address is one the developer registered.
|
|
1812
|
+
var site = config.site || "https://zaisma.com";
|
|
1813
|
+
var query = "?item_count=" + itemCount + "&request_number=" + requestNumber + (gameAccountId === null ? "" : "&game_account_id=" + encodeURIComponent(gameAccountId));
|
|
1814
|
+
var opened = window.open(site + "/buy/" + encodeURIComponent(config.game) + "/" + encodeURIComponent(itemName) + query, "zaisma-buy", "popup,width=" + BUY_WINDOW_WIDTH_PX + ",height=" + BUY_WINDOW_HEIGHT_PX);
|
|
1815
|
+
if (!opened) return reject(codedError("needs_player_tap", "The browser didn't open the buy window: call Zaisma.items.buy from the tap's handler itself."));
|
|
1816
|
+
waiter.origin = new URL(site).origin;
|
|
1817
|
+
waiter.source = opened;
|
|
1818
|
+
// A window the player closed is a cancel; the purchase, if it went through, still shows in the next pass.
|
|
1819
|
+
waiter.closedCheck = setInterval(function () {
|
|
1820
|
+
if (!opened.closed) return;
|
|
1821
|
+
clearInterval(waiter.closedCheck);
|
|
1822
|
+
if (!buyWaiters[requestNumber]) return;
|
|
1823
|
+
delete buyWaiters[requestNumber];
|
|
1824
|
+
if (waiter.isOpen) itemsEvents._emit("sheetClose", { itemName: itemName });
|
|
1825
|
+
resolve({ status: "cancelled", purchase: null });
|
|
1826
|
+
}, 500);
|
|
1827
|
+
buyWaiters[requestNumber] = waiter;
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/*
|
|
1832
|
+
* Purchases on their way to the game (purchase_delivery in the game's purchase settings, given in config):
|
|
1833
|
+
* with "game", the game hears "purchase" and "refund" here and marks each handled, so it comes once; with
|
|
1834
|
+
* "game_server", the rooms the game is in get it (zaisma:purchase) and their game server's onPurchase takes
|
|
1835
|
+
* it; with "own_server", the game sends the receipt to its own server its own way.
|
|
1836
|
+
*/
|
|
1837
|
+
var heardPurchaseIds = {}; // "<purchase id> purchase|refund" -> true, so this page hands each over once
|
|
1838
|
+
var isLookingForPurchases = false;
|
|
1839
|
+
|
|
1840
|
+
/** The player's purchases the game hasn't marked handled yet, oldest first, at most 50 at a time. */
|
|
1841
|
+
function unhandledPurchases() {
|
|
1842
|
+
if (!config.rooms) return Promise.reject(notOnZaisma("Purchases come from Zaisma, so they work only in games played on Zaisma."));
|
|
1843
|
+
return getPass().then(function (mine) {
|
|
1844
|
+
return post("/v1/items/unhandled", { gameSlug: config.game, pass: mine.pass });
|
|
1845
|
+
}).then(function (answer) { return answer.purchases; });
|
|
1846
|
+
}
|
|
1847
|
+
function markPurchaseHandled(purchaseId) {
|
|
1848
|
+
if (typeof purchaseId !== "string" || purchaseId.indexOf("purchase_") !== 0 || purchaseId.length > 80) return Promise.reject(codedError("bad_purchase_id", "purchaseId isn't a purchase's ID, like \"purchase_8f2k1x\": it's the id of the purchase \"purchase\" or \"refund\" gave the game."));
|
|
1849
|
+
if (!config.rooms) return Promise.reject(notOnZaisma("Purchases come from Zaisma, so they work only in games played on Zaisma."));
|
|
1850
|
+
return getPass().then(function (mine) {
|
|
1851
|
+
return post("/v1/items/handled", { gameSlug: config.game, pass: mine.pass, purchaseId: purchaseId });
|
|
1852
|
+
}).then(function () { return undefined; });
|
|
1853
|
+
}
|
|
1854
|
+
/** Hand the game what's waiting for it, each purchase to "purchase" and each one taken back to "refund", once a page. */
|
|
1855
|
+
function tellGame(purchases) {
|
|
1856
|
+
purchases.forEach(function (purchase) {
|
|
1857
|
+
var event = purchase.purchaseStatus === "completed" ? "purchase" : "refund";
|
|
1858
|
+
var key = purchase.id + " " + event;
|
|
1859
|
+
if (heardPurchaseIds[key] || !(itemsEvents._on[event] || []).length) return;
|
|
1860
|
+
heardPurchaseIds[key] = true;
|
|
1861
|
+
itemsEvents._emit(event, purchase);
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
/** With "game" delivery, look for waiting purchases once the game listens, and again after each purchase. */
|
|
1865
|
+
function lookForPurchases() {
|
|
1866
|
+
if (config.purchaseDelivery !== "game" || !config.rooms || isLookingForPurchases) return;
|
|
1867
|
+
isLookingForPurchases = true;
|
|
1868
|
+
// After this turn, so a game that listens to both events in a row gets both.
|
|
1869
|
+
Promise.resolve().then(unhandledPurchases).then(tellGame, function (error) {
|
|
1870
|
+
console.error(error);
|
|
1871
|
+
}).then(function () { isLookingForPurchases = false; });
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* A purchase the buy sheet just completed (Zaisma.items.buy answered "bought"): to the game's own events,
|
|
1875
|
+
* or to the game server in each room the game is in, which marks it handled itself.
|
|
1876
|
+
*/
|
|
1877
|
+
function purchaseMade(purchase) {
|
|
1878
|
+
if (config.purchaseDelivery === "game") return lookForPurchases();
|
|
1879
|
+
if (config.purchaseDelivery !== "game_server") return;
|
|
1880
|
+
joined.forEach(function (room) {
|
|
1881
|
+
if (!room._done) room._say({ type: "zaisma:purchase", purchaseId: purchase.id, receipt: purchase.receipt });
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
var items = {
|
|
1886
|
+
owns: ownsItem,
|
|
1887
|
+
owned: ownedItems,
|
|
1888
|
+
list: listItems,
|
|
1889
|
+
buy: buyItem,
|
|
1890
|
+
unhandled: unhandledPurchases,
|
|
1891
|
+
markHandled: markPurchaseHandled,
|
|
1892
|
+
on: function (event, listener) {
|
|
1893
|
+
itemsEvents.on(event, listener);
|
|
1894
|
+
lookForPurchases();
|
|
1895
|
+
return items;
|
|
1896
|
+
},
|
|
1897
|
+
off: function (event, listener) {
|
|
1898
|
+
itemsEvents.off(event, listener);
|
|
1899
|
+
return items;
|
|
1900
|
+
},
|
|
1901
|
+
};
|
|
1902
|
+
|
|
1903
|
+
/*
|
|
1904
|
+
* A version the developer published "now", for a game that isn't in a match (single-player, or a menu):
|
|
1905
|
+
* the page beside it notices, and a game listening switches when it says (update.apply()); otherwise the
|
|
1906
|
+
* page offers Update beside the game. Never forced. Matches hear it from their room ("versionUpdate").
|
|
1907
|
+
*/
|
|
1908
|
+
var versionsEvents = new Emitter(VERSIONS_EVENTS, "Zaisma.versions has");
|
|
1909
|
+
var versions = {
|
|
1910
|
+
on: function (event, listener) {
|
|
1911
|
+
versionsEvents.on(event, listener);
|
|
1912
|
+
return versions;
|
|
1913
|
+
},
|
|
1914
|
+
off: function (event, listener) {
|
|
1915
|
+
versionsEvents.off(event, listener);
|
|
1916
|
+
return versions;
|
|
1917
|
+
},
|
|
1918
|
+
};
|
|
1919
|
+
if (config.site && window.parent !== window) {
|
|
1920
|
+
// Which version this tab runs, so the page knows when a newer one is out.
|
|
1921
|
+
if (config.version) window.parent.postMessage({ type: "zaisma:version", versionId: config.version }, config.site);
|
|
1922
|
+
window.addEventListener("message", function (event) {
|
|
1923
|
+
if (event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== "zaisma:version-ready") return;
|
|
1924
|
+
var isHandled = (versionsEvents._on.update || []).length > 0;
|
|
1925
|
+
if (isHandled) {
|
|
1926
|
+
versionsEvents._emit("update", {
|
|
1927
|
+
versionId: event.data.versionId,
|
|
1928
|
+
apply: function () { window.parent.postMessage({ type: "zaisma:reload", code: null }, config.site); },
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
window.parent.postMessage({ type: "zaisma:version-ready", versionId: event.data.versionId, isHandled: isHandled }, config.site);
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
/*
|
|
1936
|
+
* The player's cloud saves, for games that draw their own save screens. The game page on Zaisma holds
|
|
1937
|
+
* the player's session and answers (apps/site/src/saves-client.ts); the game never talks to a server.
|
|
1938
|
+
*/
|
|
1939
|
+
var SAVE_COPY_ID = /^save_copy_[a-z0-9-]{1,40}$/;
|
|
1940
|
+
var SAVES_ERROR_CODES = ["account_needed", "bad_save_copy_id", "no_save_copy", "cloud_saves_off", "cloud_saves_not_here", "slow_down", "request_failed"];
|
|
1941
|
+
var savesRequestCount = 0;
|
|
1942
|
+
var savesWaiters = {};
|
|
1943
|
+
var savesEvents = new Emitter(SAVES_EVENTS, "Zaisma.saves has");
|
|
1944
|
+
|
|
1945
|
+
/** An earlier version as the game gets it (SaveCopy). */
|
|
1946
|
+
function saveCopyOf(given) {
|
|
1947
|
+
return { id: given.id, savedAt: given.savedAt, copyReason: given.copyReason, sizeBytes: given.sizeBytes, isCurrentSave: given.isCurrentSave, versionId: typeof given.versionId === "string" ? given.versionId : null };
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
window.addEventListener("message", function (event) {
|
|
1951
|
+
if (!config.site || event.origin !== config.site || event.source !== window.parent || !event.data) return;
|
|
1952
|
+
var message = event.data;
|
|
1953
|
+
if (message.type === "zaisma:saves-event") {
|
|
1954
|
+
var given = message.event || {};
|
|
1955
|
+
if (message.eventName === "conflict" && given.keptSaveCopy) savesEvents._emit("conflict", { winnerKind: given.winnerKind, keptSaveCopy: saveCopyOf(given.keptSaveCopy) });
|
|
1956
|
+
if (message.eventName === "developerChange") savesEvents._emit("developerChange", { savedAt: given.savedAt, keptSaveCopy: given.keptSaveCopy ? saveCopyOf(given.keptSaveCopy) : null });
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
if (message.type !== "zaisma:saves-answer") return;
|
|
1960
|
+
var waiter = savesWaiters[message.requestNumber];
|
|
1961
|
+
if (!waiter) return;
|
|
1962
|
+
if (message.status === "asking") { waiter.isAsked = true; return; }
|
|
1963
|
+
delete savesWaiters[message.requestNumber];
|
|
1964
|
+
clearTimeout(waiter.timer);
|
|
1965
|
+
if (!message.error) return waiter.resolve(message.answer);
|
|
1966
|
+
var code = SAVES_ERROR_CODES.indexOf(message.error.code) === -1 ? "request_failed" : message.error.code;
|
|
1967
|
+
waiter.reject(codedError(code, typeof message.error.message === "string" ? message.error.message : "Zaisma didn't answer, so check the connection and try again."));
|
|
1968
|
+
});
|
|
1969
|
+
|
|
1970
|
+
/** Ask the game page about the player's saves: its answer, or an error with a code. */
|
|
1971
|
+
function askSaves(request, saveCopyId) {
|
|
1972
|
+
if (!config.site || window.parent === window) return Promise.reject(notOnZaisma("Cloud saves work when the game is played on its zaisma.com page."));
|
|
1973
|
+
return new Promise(function (resolve, reject) {
|
|
1974
|
+
var requestNumber = ++savesRequestCount;
|
|
1975
|
+
var waiter = { resolve: resolve, reject: reject, isAsked: false };
|
|
1976
|
+
// The page answers at once that it's asking; no answer means no Zaisma page is around the game.
|
|
1977
|
+
waiter.timer = setTimeout(function () {
|
|
1978
|
+
if (waiter.isAsked) return;
|
|
1979
|
+
delete savesWaiters[requestNumber];
|
|
1980
|
+
reject(notOnZaisma("No Zaisma page answered: cloud saves work when the game is played on its zaisma.com page."));
|
|
1981
|
+
}, 3000);
|
|
1982
|
+
savesWaiters[requestNumber] = waiter;
|
|
1983
|
+
window.parent.postMessage({ type: "zaisma:saves-request", requestNumber: requestNumber, request: request, saveCopyId: saveCopyId }, config.site);
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
function checkedSaveCopyId(saveCopyId) {
|
|
1987
|
+
return typeof saveCopyId === "string" && SAVE_COPY_ID.test(saveCopyId) ? null : codedError("bad_save_copy_id", JSON.stringify(saveCopyId) + " isn't a save copy ID: give an id from Zaisma.saves.listCopies(), like \"save_copy_k2v9q4x7m1p3w8r5t6y0\".");
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
var savesListened = {};
|
|
1991
|
+
var saves = {
|
|
1992
|
+
// Listening for an event tells the page, the first time for each, so it sends that event.
|
|
1993
|
+
on: function (event, listener) {
|
|
1994
|
+
savesEvents.on(event, listener);
|
|
1995
|
+
if (!savesListened[event] && config.site && window.parent !== window) {
|
|
1996
|
+
savesListened[event] = true;
|
|
1997
|
+
window.parent.postMessage({ type: "zaisma:saves-request", requestNumber: ++savesRequestCount, request: "listen", eventNames: [event] }, config.site);
|
|
1998
|
+
}
|
|
1999
|
+
return saves;
|
|
2000
|
+
},
|
|
2001
|
+
off: function (event, listener) {
|
|
2002
|
+
savesEvents.off(event, listener);
|
|
2003
|
+
return saves;
|
|
2004
|
+
},
|
|
2005
|
+
};
|
|
2006
|
+
saves.listCopies = function () {
|
|
2007
|
+
return askSaves("list_copies").then(function (copies) { return { saveCopies: copies.map(saveCopyOf) }; });
|
|
2008
|
+
};
|
|
2009
|
+
saves.getCopy = function (saveCopyId) {
|
|
2010
|
+
var problem = checkedSaveCopyId(saveCopyId);
|
|
2011
|
+
if (problem) return Promise.reject(problem);
|
|
2012
|
+
return askSaves("get_copy", saveCopyId).then(function (copy) {
|
|
2013
|
+
var contents = saveCopyOf(copy);
|
|
2014
|
+
contents.localStorageItems = copy.localStorageItems;
|
|
2015
|
+
contents.indexeddbDatabases = copy.indexeddbDatabases;
|
|
2016
|
+
return contents;
|
|
2017
|
+
});
|
|
2018
|
+
};
|
|
2019
|
+
saves.restoreCopy = function (saveCopyId) {
|
|
2020
|
+
var problem = checkedSaveCopyId(saveCopyId);
|
|
2021
|
+
if (problem) return Promise.reject(problem);
|
|
2022
|
+
return askSaves("restore_copy", saveCopyId).then(function () { return undefined; });
|
|
2023
|
+
};
|
|
2024
|
+
|
|
2025
|
+
var joined = [];
|
|
2026
|
+
|
|
2027
|
+
// The room this tab last left and when it joined it, for its own room lists, and for the Zaisma page's (zaisma:left).
|
|
2028
|
+
var recent = null;
|
|
2029
|
+
function left(room) {
|
|
2030
|
+
if (ownRoom && room._serverUrl === ownRoom.serverUrl && room.roomCode === ownRoom.roomCode) setOwnRoom(null).catch(function () { /* fine: it drops off by itself */ });
|
|
2031
|
+
if (room._joinedAtMs) recent = { roomCode: room.roomCode, leftAtMs: Date.now(), joinedAtMs: room._joinedAtMs };
|
|
2032
|
+
room._joinedAtMs = 0;
|
|
2033
|
+
if (config.site && window.parent !== window) window.parent.postMessage({ type: "zaisma:left", code: room.roomCode }, config.site);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
/*
|
|
2037
|
+
* Players who walk away. Honest clients do the fast part (docs/DECISIONS.md): ping so a dead connection
|
|
2038
|
+
* comes back by itself, say "Still there?" after a while without input and tell the room the answer,
|
|
2039
|
+
* say when the game is hidden, and say goodbye as the page goes. The room keeps its own slower checks.
|
|
2040
|
+
*/
|
|
2041
|
+
var PING_MS = 25000;
|
|
2042
|
+
var PONG_MS = 15000;
|
|
2043
|
+
// A ticking room probes every probeIntervalSec, so this long without a word from it means the connection is gone.
|
|
2044
|
+
var SILENCE_MS = 8000;
|
|
2045
|
+
var ASK_MS = IDLE_ANSWER_TIMEOUT_SEC * 1000;
|
|
2046
|
+
var lastInputAtMs = Date.now();
|
|
2047
|
+
var asking = null; // { untilMs, rooms } while "Still there?" is up
|
|
2048
|
+
var watching = null;
|
|
2049
|
+
|
|
2050
|
+
function watch() {
|
|
2051
|
+
if (watching) return;
|
|
2052
|
+
watching = setInterval(check, 1000);
|
|
2053
|
+
["keydown", "pointerdown", "pointermove", "wheel", "touchstart"].forEach(function (type) {
|
|
2054
|
+
addEventListener(type, input, { capture: true, passive: true });
|
|
2055
|
+
});
|
|
2056
|
+
addEventListener("gamepadconnected", pollPads);
|
|
2057
|
+
pollPads();
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
function input() {
|
|
2061
|
+
lastInputAtMs = Date.now();
|
|
2062
|
+
if (asking) stopAsking("answered");
|
|
2063
|
+
joined.forEach(function (room) { room._return(); });
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// Gamepads don't send events for their buttons, so they're checked every second while one is connected.
|
|
2067
|
+
var pads = "";
|
|
2068
|
+
var padTimer = null;
|
|
2069
|
+
function pollPads() {
|
|
2070
|
+
if (padTimer || !navigator.getGamepads) return;
|
|
2071
|
+
padTimer = setInterval(function () {
|
|
2072
|
+
var connected = Array.prototype.filter.call(navigator.getGamepads() || [], Boolean);
|
|
2073
|
+
if (!connected.length) { clearInterval(padTimer); padTimer = null; return; }
|
|
2074
|
+
var seen = connected.map(function (pad) {
|
|
2075
|
+
return pad.buttons.map(function (button) { return button.pressed ? 1 : 0; }).join("") + pad.axes.map(function (axis) { return Math.round(axis * 4); }).join(",");
|
|
2076
|
+
}).join("|");
|
|
2077
|
+
if (pads && seen !== pads) input();
|
|
2078
|
+
pads = seen;
|
|
2079
|
+
}, 1000);
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
function check() {
|
|
2083
|
+
var nowMs = Date.now();
|
|
2084
|
+
var due = [];
|
|
2085
|
+
joined.forEach(function (room) {
|
|
2086
|
+
var ws = room._ws;
|
|
2087
|
+
if (room._done || !ws || ws.readyState !== 1) return;
|
|
2088
|
+
// A ping that went unanswered: the connection is gone, even if the browser hasn't noticed.
|
|
2089
|
+
if (room._pingAtMs > room._pongAtMs && nowMs - room._pingAtMs > PONG_MS) return room._lost();
|
|
2090
|
+
// In a ticking room, silence. Not while the player is away or the game is hidden: the room stops probing players who walked away.
|
|
2091
|
+
if (room._probeIntervalSec && !room._away && !room._idleSent && !document.hidden && nowMs - room._heardAtMs > SILENCE_MS) return room._lost();
|
|
2092
|
+
if (nowMs - room._pingAtMs >= PING_MS) {
|
|
2093
|
+
room._pingAtMs = nowMs;
|
|
2094
|
+
ws.send("ping");
|
|
2095
|
+
}
|
|
2096
|
+
if (room._away || room._idleSent) return;
|
|
2097
|
+
var idleTimeoutSec = room._idleTimeoutSec();
|
|
2098
|
+
if (idleTimeoutSec !== null && nowMs - lastInputAtMs >= idleTimeoutSec * 1000) due.push(room);
|
|
2099
|
+
// The room's own check hears from players who give input but whose game sends nothing for a while.
|
|
2100
|
+
var roomIdleTimeoutSec = room._checks.idleTimeoutSec;
|
|
2101
|
+
if (roomIdleTimeoutSec !== null && lastInputAtMs > room._sentAtMs && nowMs - room._sentAtMs > Math.min(240000, roomIdleTimeoutSec * 500)) room._say({ type: "here" });
|
|
2102
|
+
});
|
|
2103
|
+
if (asking && nowMs >= asking.untilMs) {
|
|
2104
|
+
var asked = asking.rooms;
|
|
2105
|
+
stopAsking("timed_out");
|
|
2106
|
+
asked.forEach(function (room) {
|
|
2107
|
+
if (room._done || room._away || nowMs - lastInputAtMs < ASK_MS) return;
|
|
2108
|
+
room._idleSent = true;
|
|
2109
|
+
room._say({ type: "idle" });
|
|
2110
|
+
});
|
|
2111
|
+
} else if (!asking && due.length) startAsking(due);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function startAsking(rooms) {
|
|
2115
|
+
asking = { untilMs: Date.now() + ASK_MS, rooms: rooms, isPageAsked: false, isShownByPage: false, pageTimer: null };
|
|
2116
|
+
var isOwn = false;
|
|
2117
|
+
rooms.forEach(function (room) {
|
|
2118
|
+
if ((room._on.idle || []).length) isOwn = true;
|
|
2119
|
+
room._emit("idle", { idleStatus: "asking", answerTimeoutSec: IDLE_ANSWER_TIMEOUT_SEC });
|
|
2120
|
+
});
|
|
2121
|
+
if (!isOwn) showStillThere();
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
/** The "Still there?" question is over: answered by input (idleStatus "answered"), or "timed_out" as the room hears the player went idle. */
|
|
2125
|
+
function stopAsking(idleStatus) {
|
|
2126
|
+
var asked = asking;
|
|
2127
|
+
asking = null;
|
|
2128
|
+
if (asked) {
|
|
2129
|
+
clearTimeout(asked.pageTimer);
|
|
2130
|
+
// The Zaisma page hides its card too.
|
|
2131
|
+
if (asked.isPageAsked) window.parent.postMessage({ type: "zaisma:still-there", untilMs: null }, config.site);
|
|
2132
|
+
}
|
|
2133
|
+
var rooms = asked ? asked.rooms : [];
|
|
2134
|
+
hideStillThere();
|
|
2135
|
+
rooms.forEach(function (room) { room._emit("idle", { idleStatus: idleStatus, answerTimeoutSec: IDLE_ANSWER_TIMEOUT_SEC }); });
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
/*
|
|
2139
|
+
* "Still there?" on a Zaisma page: the page shows it beside the game, in its own look, until the SDK says
|
|
2140
|
+
* the asking is over (untilMs: null), and answers "showing" at once and "here" when the player presses
|
|
2141
|
+
* I'm here, which counts as input. No answer within a second (a game on the developer's own site, or
|
|
2142
|
+
* the page's game in full screen): the game draws its own plate.
|
|
2143
|
+
*/
|
|
2144
|
+
var STILL_THERE_PAGE_MS = 1000;
|
|
2145
|
+
function showStillThere() {
|
|
2146
|
+
var asked = asking;
|
|
2147
|
+
if (!asked || !config.site || window.parent === window) return drawStillThere();
|
|
2148
|
+
asked.isPageAsked = true;
|
|
2149
|
+
window.parent.postMessage({ type: "zaisma:still-there", untilMs: asked.untilMs }, config.site);
|
|
2150
|
+
asked.pageTimer = setTimeout(function () {
|
|
2151
|
+
if (asking === asked && !asked.isShownByPage) drawStillThere();
|
|
2152
|
+
}, STILL_THERE_PAGE_MS);
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
addEventListener("message", function (event) {
|
|
2156
|
+
if (!asking || !asking.isPageAsked || !config.site || event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== "zaisma:still-there") return;
|
|
2157
|
+
if (event.data.status === "showing") {
|
|
2158
|
+
asking.isShownByPage = true;
|
|
2159
|
+
hideStillThere();
|
|
2160
|
+
} else if (event.data.status === "here") input();
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2163
|
+
/** Zaisma's small "Still there?" plate, in the Steel look, off Zaisma. It never covers the game; games can restyle #zaisma-still-there. */
|
|
2164
|
+
var stillTherePlate = null;
|
|
2165
|
+
function drawStillThere() {
|
|
2166
|
+
hideStillThere();
|
|
2167
|
+
var plate = document.createElement("div");
|
|
2168
|
+
stillTherePlate = plate;
|
|
2169
|
+
plate.id = "zaisma-still-there";
|
|
2170
|
+
plate.setAttribute("role", "status");
|
|
2171
|
+
plate.style.cssText = "position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:2147483647;display:flex;gap:14px;align-items:center;max-width:calc(100vw - 32px);box-sizing:border-box;padding:12px 14px 14px;font:15px/1.4 system-ui,-apple-system,sans-serif;color:#eceef0;background:linear-gradient(180deg,#262a31,#1b1e23);box-shadow:inset 1px 1px 0 rgba(255,255,255,.09),inset -1px -1px 0 rgba(0,0,0,.7),0 14px 32px rgba(0,0,0,.42);clip-path:polygon(0 0,calc(100% - 14px) 0,100% 14px,100% 100%,0 100%)";
|
|
2172
|
+
var text = document.createElement("div");
|
|
2173
|
+
var title = document.createElement("strong");
|
|
2174
|
+
title.textContent = "Still there?";
|
|
2175
|
+
var note = document.createElement("div");
|
|
2176
|
+
note.textContent = "Tap or press any key to keep playing.";
|
|
2177
|
+
note.style.cssText = "color:#969ba4;font-size:13px";
|
|
2178
|
+
text.append(title, note);
|
|
2179
|
+
var here = document.createElement("button");
|
|
2180
|
+
here.type = "button";
|
|
2181
|
+
here.textContent = "I'm here";
|
|
2182
|
+
here.style.cssText = "font:inherit;font-size:13px;font-weight:600;min-height:36px;padding:0 14px;border:0;background:linear-gradient(180deg,#eef0f2 0%,#b9bcc1 46%,#7d8086 100%);box-shadow:inset 1px 1px 0 #fff,inset -1px -1px 0 #000;clip-path:polygon(0 0,calc(100% - 7px) 0,100% 7px,100% 100%,0 100%);color:#0b0b0c;cursor:pointer;flex:none";
|
|
2183
|
+
here.onclick = input;
|
|
2184
|
+
// How long is left, as a bar that runs down along the bottom.
|
|
2185
|
+
var bar = document.createElement("i");
|
|
2186
|
+
bar.style.cssText = "position:absolute;left:0;bottom:0;height:3px;width:100%;background:#b9bcc1;transform-origin:left;transition:transform " + ASK_MS + "ms linear";
|
|
2187
|
+
plate.append(text, here, bar);
|
|
2188
|
+
document.body.appendChild(plate);
|
|
2189
|
+
requestAnimationFrame(function () { requestAnimationFrame(function () { bar.style.transform = "scaleX(0)"; }); });
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
function hideStillThere() {
|
|
2193
|
+
if (stillTherePlate) stillTherePlate.remove();
|
|
2194
|
+
stillTherePlate = null;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// Hidden and shown again: the room counts a real-time game hidden for 2 minutes as away; coming back counts as input.
|
|
2198
|
+
document.addEventListener("visibilitychange", function () {
|
|
2199
|
+
joined.forEach(function (room) {
|
|
2200
|
+
if (!room._done && room._checks.hiddenTimeoutSec) room._say({ type: document.hidden ? "hidden" : "shown" });
|
|
2201
|
+
});
|
|
2202
|
+
if (!document.hidden) input();
|
|
2203
|
+
});
|
|
2204
|
+
|
|
2205
|
+
// Goodbye as the page goes, so the room lets the player go at once instead of waiting for pings to stop.
|
|
2206
|
+
addEventListener("pagehide", function () {
|
|
2207
|
+
joined.forEach(function (room) {
|
|
2208
|
+
var ws = room._ws;
|
|
2209
|
+
if (room._done || !ws) return;
|
|
2210
|
+
ws.onclose = null;
|
|
2211
|
+
ws.onmessage = null;
|
|
2212
|
+
try { ws.close(1000, "Left"); } catch (error) { /* fine */ }
|
|
2213
|
+
room._ws = null;
|
|
2214
|
+
room._paused = true;
|
|
2215
|
+
});
|
|
2216
|
+
});
|
|
2217
|
+
// Back from the browser's back-forward cache: join again, or wait for the player if they'd walked away.
|
|
2218
|
+
addEventListener("pageshow", function (event) {
|
|
2219
|
+
if (!event.persisted) return;
|
|
2220
|
+
joined.forEach(function (room) {
|
|
2221
|
+
if (!room._paused || room._done) return;
|
|
2222
|
+
room._paused = false;
|
|
2223
|
+
if (room._away) { room._gone = true; return; }
|
|
2224
|
+
room._tries = 0;
|
|
2225
|
+
room._setConnectionStatus("reconnecting");
|
|
2226
|
+
room._connect();
|
|
2227
|
+
});
|
|
2228
|
+
});
|
|
2229
|
+
|
|
2230
|
+
// The Zaisma page says when a player report was sent from its plate: mute them, as the panel's own form does.
|
|
2231
|
+
window.addEventListener("message", function (event) {
|
|
2232
|
+
if (!config.site || event.origin !== config.site || event.source !== window.parent || !event.data || event.data.type !== "zaisma:reported") return;
|
|
2233
|
+
var playerId = event.data.player;
|
|
2234
|
+
joined.forEach(function (room) {
|
|
2235
|
+
if (room.players.some(function (each) { return each.id === playerId; })) room.mute(playerId);
|
|
2236
|
+
});
|
|
2237
|
+
});
|
|
2238
|
+
|
|
2239
|
+
/** Hand a player report to the Zaisma page. If no Zaisma page answers (the game runs somewhere else), use our own form. */
|
|
2240
|
+
function handOff(room, reported, opened, fallback) {
|
|
2241
|
+
if (!config.site || window.parent === window) return fallback();
|
|
2242
|
+
var isAnswered = false;
|
|
2243
|
+
function listen(event) {
|
|
2244
|
+
if (event.origin === config.site && event.source === window.parent && event.data && event.data.type === "zaisma:report-open") {
|
|
2245
|
+
isAnswered = true;
|
|
2246
|
+
window.removeEventListener("message", listen);
|
|
2247
|
+
opened();
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
window.addEventListener("message", listen);
|
|
2251
|
+
window.parent.postMessage({ type: "zaisma:report", code: room.roomCode, player: { id: reported.id, name: reported.name } }, config.site);
|
|
2252
|
+
setTimeout(function () {
|
|
2253
|
+
window.removeEventListener("message", listen);
|
|
2254
|
+
if (!isAnswered) fallback();
|
|
2255
|
+
}, 800);
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// The first four go to Zaisma; the rest to the game's developer.
|
|
2259
|
+
var REASONS = [["real_life_threat", "Threatening them in real life"], ["personal_details", "Sharing someone's personal details"], ["following_after_block", "Following me after I blocked them"], ["sexual", "Sexual behavior"], ["harassment", "Harassing or bullying"], ["cheating", "Cheating"], ["other", "Something else"]];
|
|
2260
|
+
|
|
2261
|
+
/** A ready-made Players panel: mute, block and report. Games open it from their own menu. */
|
|
2262
|
+
function openPlayersPanel(room) {
|
|
2263
|
+
// On Zaisma, the page's own Players (the bar's), so a game's Players button opens the same one.
|
|
2264
|
+
if (onZaismaPage()) {
|
|
2265
|
+
window.parent.postMessage({ type: "zaisma:players-open" }, config.site);
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
var old = document.getElementById("zaisma-safety");
|
|
2269
|
+
if (old) old.remove();
|
|
2270
|
+
var wrap = document.createElement("div");
|
|
2271
|
+
wrap.id = "zaisma-safety";
|
|
2272
|
+
wrap.setAttribute("role", "dialog");
|
|
2273
|
+
wrap.setAttribute("aria-label", "Players");
|
|
2274
|
+
// Zaisma's Steel look: gunmetal plates with the cut corner.
|
|
2275
|
+
wrap.style.cssText = "position:fixed;inset:0;z-index:2147483647;background:rgba(4,5,7,.6);display:grid;place-items:center;font:15px/1.4 system-ui,-apple-system,sans-serif;color:#eceef0";
|
|
2276
|
+
var box = document.createElement("div");
|
|
2277
|
+
box.style.cssText = "background:linear-gradient(180deg,#262a31,#1b1e23);box-shadow:inset 1px 1px 0 rgba(255,255,255,.09),inset -1px -1px 0 rgba(0,0,0,.7),0 14px 32px rgba(0,0,0,.42);clip-path:polygon(0 0,calc(100% - 18px) 0,100% 18px,100% 100%,0 100%);padding:18px;width:min(420px,92vw);max-height:80vh;overflow:auto";
|
|
2278
|
+
wrap.appendChild(box);
|
|
2279
|
+
var buttonStyle = "font:inherit;font-size:13px;font-weight:600;min-height:36px;padding:0 12px;border:0;background:linear-gradient(180deg,#30353d,#22262c);box-shadow:inset 1px 1px 0 rgba(255,255,255,.13),inset -1px -1px 0 #000;clip-path:polygon(0 0,calc(100% - 7px) 0,100% 7px,100% 100%,0 100%);color:#eceef0;cursor:pointer";
|
|
2280
|
+
function close() { wrap.remove(); }
|
|
2281
|
+
wrap.addEventListener("click", function (event) { if (event.target === wrap) close(); });
|
|
2282
|
+
wrap.addEventListener("keydown", function (event) { if (event.key === "Escape") close(); });
|
|
2283
|
+
|
|
2284
|
+
function render(note) {
|
|
2285
|
+
box.textContent = "";
|
|
2286
|
+
var head = document.createElement("div");
|
|
2287
|
+
head.style.cssText = "display:flex;justify-content:space-between;align-items:center;margin-bottom:10px";
|
|
2288
|
+
var heading = document.createElement("strong");
|
|
2289
|
+
heading.textContent = "Players";
|
|
2290
|
+
var closeButton = document.createElement("button");
|
|
2291
|
+
closeButton.textContent = "Close";
|
|
2292
|
+
closeButton.style.cssText = buttonStyle;
|
|
2293
|
+
closeButton.onclick = close;
|
|
2294
|
+
head.append(heading, closeButton);
|
|
2295
|
+
box.appendChild(head);
|
|
2296
|
+
if (note) {
|
|
2297
|
+
var noteText = document.createElement("p");
|
|
2298
|
+
noteText.textContent = note;
|
|
2299
|
+
noteText.style.cssText = "margin:0 0 10px;color:#b9e7a6";
|
|
2300
|
+
box.appendChild(noteText);
|
|
2301
|
+
}
|
|
2302
|
+
var others = room.players.filter(function (each) { return !room.me || each.id !== room.me.id; });
|
|
2303
|
+
if (!others.length) {
|
|
2304
|
+
var none = document.createElement("p");
|
|
2305
|
+
none.textContent = "Nobody else is here.";
|
|
2306
|
+
none.style.color = "#969ba4";
|
|
2307
|
+
box.appendChild(none);
|
|
2308
|
+
}
|
|
2309
|
+
others.forEach(function (other) {
|
|
2310
|
+
var row = document.createElement("div");
|
|
2311
|
+
row.style.cssText = "display:flex;gap:6px;align-items:center;padding:10px 0;box-shadow:inset 0 1px 0 #000,inset 0 2px 0 rgba(255,255,255,.05)";
|
|
2312
|
+
// Only what this player did themselves shows: a block someone made of them never does.
|
|
2313
|
+
var isBlocked = hasBlocked(other.id);
|
|
2314
|
+
var isMuted = Boolean(room._muted && room._muted.has(other.id));
|
|
2315
|
+
var name = document.createElement("span");
|
|
2316
|
+
name.textContent = other.name + (isBlocked ? " (blocked)" : isMuted ? " (muted)" : "");
|
|
2317
|
+
name.style.flex = "1";
|
|
2318
|
+
row.appendChild(name);
|
|
2319
|
+
function button(text, onclick) {
|
|
2320
|
+
var each = document.createElement("button");
|
|
2321
|
+
each.textContent = text;
|
|
2322
|
+
each.style.cssText = buttonStyle;
|
|
2323
|
+
each.onclick = onclick;
|
|
2324
|
+
row.appendChild(each);
|
|
2325
|
+
}
|
|
2326
|
+
// A lasting block is undone on zaisma.com/friends; a session one here.
|
|
2327
|
+
if (!lastingBlocked.has(other.id)) button(isBlocked ? "Unblock" : isMuted ? "Unmute" : "Mute", function () { isBlocked || isMuted ? room.unmute(other.id) : room.mute(other.id); render(); });
|
|
2328
|
+
if (!isBlocked) {
|
|
2329
|
+
button("Block", function () {
|
|
2330
|
+
room.block(other.id).then(function (isDone) {
|
|
2331
|
+
render(!isDone ? undefined : lastingBlocked.has(other.id) ? other.name + " is blocked. Unblock them on your Zaisma friends page." : other.name + " is blocked for this session.");
|
|
2332
|
+
}, function () { render(); });
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
2335
|
+
button("Report", function () { handOff(room, other, close, function () { reportForm(other); }); });
|
|
2336
|
+
box.appendChild(row);
|
|
2337
|
+
});
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function reportForm(reported) {
|
|
2341
|
+
box.textContent = "";
|
|
2342
|
+
var heading = document.createElement("strong");
|
|
2343
|
+
heading.textContent = "Report " + reported.name;
|
|
2344
|
+
var select = document.createElement("select");
|
|
2345
|
+
select.style.cssText = "display:block;width:100%;margin:12px 0;min-height:40px;padding:0 10px;background:#0d0f12;color:#eceef0;border:1px solid #000;box-shadow:inset 0 2px 5px rgba(0,0,0,.65),0 1px 0 rgba(255,255,255,.06);font:inherit";
|
|
2346
|
+
REASONS.forEach(function (reason) { var option = document.createElement("option"); option.value = reason[0]; option.textContent = reason[1]; select.appendChild(option); });
|
|
2347
|
+
var note = document.createElement("p");
|
|
2348
|
+
note.textContent = "Zaisma reviews reports. Nothing about you is sent. They'll also be muted for you.";
|
|
2349
|
+
note.style.cssText = "margin:0 0 12px;color:#969ba4;font-size:13px";
|
|
2350
|
+
var send = document.createElement("button");
|
|
2351
|
+
send.textContent = "Send report";
|
|
2352
|
+
send.style.cssText = buttonStyle + ";background:linear-gradient(180deg,#eef0f2 0%,#b9bcc1 46%,#7d8086 100%);box-shadow:inset 1px 1px 0 #fff,inset -1px -1px 0 #000;color:#0b0b0c";
|
|
2353
|
+
var back = document.createElement("button");
|
|
2354
|
+
back.textContent = "Back";
|
|
2355
|
+
back.style.cssText = buttonStyle + ";margin-left:6px";
|
|
2356
|
+
back.onclick = function () { render(); };
|
|
2357
|
+
send.onclick = function () {
|
|
2358
|
+
send.disabled = true;
|
|
2359
|
+
room.mute(reported.id);
|
|
2360
|
+
room.report(reported.id, select.value).then(function () { render("Thanks. Your report was sent."); }, function (error) { render(error.message); });
|
|
2361
|
+
};
|
|
2362
|
+
box.append(heading, select, note, send, back);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
render();
|
|
2366
|
+
document.body.appendChild(wrap);
|
|
2367
|
+
wrap.tabIndex = -1;
|
|
2368
|
+
wrap.focus();
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
var rooms = { list: listRooms, quickJoin: quickJoin, linkedRoomCode: roomCodeFromPage(), linkedServerUrl: roomCodeFromPage() ? serverUrlFromPage() : null, isLinkedAsSpectator: watchFromPage() };
|
|
2372
|
+
var friends = { list: listFriends, setRoom: setOwnRoom };
|
|
2373
|
+
window.Zaisma = { sdkVersionNumber: 1, gameSlug: config.game || null, player: player, joinRoom: joinRoom, rooms: rooms, server: server, invite: invite, checkChat: checkChat, openPlayersPanel: openPlayersPanel, links: links, items: items, friends: friends, saves: saves, versions: versions };
|
|
2374
|
+
})();
|