@zaisma/game-server 1.4.0 → 1.6.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 +46486 -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 +1573 -0
- package/bin/node.js +99 -5
- 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 +1810 -0
- package/sdk/zaisma.js +2459 -0
- package/src/chat.d.ts +23 -8
- package/src/chat.js +21 -11
- package/src/index.d.ts +2 -2
- package/src/index.js +1 -1
- package/src/pass.d.ts +9 -8
- package/src/pass.js +10 -8
- package/src/qr-code.d.ts +20 -0
- package/src/qr-code.js +319 -0
- package/src/room.d.ts +148 -3
- package/src/room.js +204 -3
package/src/room.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* running one copy of the game server. Names follow docs/NAMING.md.
|
|
8
8
|
*/
|
|
9
9
|
import { type GameDatabase } from "./database.ts";
|
|
10
|
-
import type { PlayerKind, ReceiptClaims } from "./pass.ts";
|
|
10
|
+
import type { PassClaims, PlayerKind, ReceiptClaims } from "./pass.ts";
|
|
11
11
|
import type { Server } from "./requests.ts";
|
|
12
12
|
import { type Schedule, type ScheduledRun } from "./schedules.ts";
|
|
13
13
|
import { type StateChange } from "./state-sync.ts";
|
|
@@ -409,6 +409,13 @@ export interface GameServer<S = any> {
|
|
|
409
409
|
* Off by default. The state goes as JSON, so keep it plain; cut what each player sees with stateView.
|
|
410
410
|
*/
|
|
411
411
|
stateSyncEnabled?: boolean;
|
|
412
|
+
/**
|
|
413
|
+
* Check chat between adults (room.chat) with Zaisma's filter too: contact details, moving apps or
|
|
414
|
+
* meeting up, personal details and sexual content are stopped, as they always are for under-18s. Off by
|
|
415
|
+
* default, when chat between adults is only flagged (hasSwearing, hasSlur) and your game decides. Each
|
|
416
|
+
* message the filter's AI looks at counts on your hosting meter (adult_chat_checks).
|
|
417
|
+
*/
|
|
418
|
+
adultChatFilterEnabled?: boolean;
|
|
412
419
|
/**
|
|
413
420
|
* How often a real-time room saves its state, in seconds: 1 to 60, default 5. A restarted room comes
|
|
414
421
|
* back with the last saved state (onRestored). Turn-based rooms save after every change and ignore it.
|
|
@@ -473,6 +480,16 @@ export interface GameServer<S = any> {
|
|
|
473
480
|
* joinInfo.fromRoomCode, and not again when they arrive.
|
|
474
481
|
*/
|
|
475
482
|
canJoin?(state: S, player: Player, room: Room, joinData: unknown, joinInfo: JoinInfo): JoinAnswer | Promise<JoinAnswer>;
|
|
483
|
+
/**
|
|
484
|
+
* A player sent a chat message (room.chat in their game), before Zaisma's filter checks it: answer true
|
|
485
|
+
* to send it, false to stop it (the sender's room.chat fails with chat_stopped, reason stopped_by_game),
|
|
486
|
+
* or { toPlayerIds } to send it to only some of chatInfo.toPlayerIds, like a team chat or a player your
|
|
487
|
+
* moderators muted. chatInfo.toPlayerIds are the players the sender's game sent it to; whatever you
|
|
488
|
+
* answer, it reaches only those of them who may chat with the sender, never anyone else. Only decide
|
|
489
|
+
* here, as in canJoin: room can be read, not changed. Without onChat every message goes on to the
|
|
490
|
+
* filter. If it throws or answers anything else, the message is stopped and the mistake is logged.
|
|
491
|
+
*/
|
|
492
|
+
onChat?(state: S, player: Player, room: Room, chatInfo: ChatInfo): ChatAnswer | Promise<ChatAnswer>;
|
|
476
493
|
/** After room.moveTo: who the other room took, and who it turned away and why. */
|
|
477
494
|
onMoved?(state: S, moveResult: MoveResult, room: Room): HandlerResult<S>;
|
|
478
495
|
/**
|
|
@@ -581,6 +598,29 @@ export declare function restoreInfoOf(savedAtMs: number, nowMs?: number, savedVe
|
|
|
581
598
|
export type JoinAnswer = boolean | {
|
|
582
599
|
refusalMessage: string;
|
|
583
600
|
};
|
|
601
|
+
/** A chat message as onChat sees it: what the player typed, and the players their game sent it to. */
|
|
602
|
+
export interface ChatInfo {
|
|
603
|
+
text: string;
|
|
604
|
+
toPlayerIds: string[];
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* What onChat answers: true sends the message on to Zaisma's filter; false stops it; { toPlayerIds }
|
|
608
|
+
* sends it to only those of chatInfo.toPlayerIds (it can narrow who reads it, never widen it).
|
|
609
|
+
*/
|
|
610
|
+
export type ChatAnswer = boolean | {
|
|
611
|
+
toPlayerIds: string[];
|
|
612
|
+
};
|
|
613
|
+
/** What the sender hears when the game server's onChat stopped their message. */
|
|
614
|
+
export declare const STOPPED_BY_GAME_MESSAGE = "Your message wasn't sent.";
|
|
615
|
+
/** onChat's answer as the room acts on it: send (to toPlayerIds, or all it was sent to when null), or stop. Anything else is a mistake, and stops it. */
|
|
616
|
+
export declare function readChatAnswer(answer: unknown): {
|
|
617
|
+
isAllowed: true;
|
|
618
|
+
toPlayerIds: string[] | null;
|
|
619
|
+
} | {
|
|
620
|
+
isAllowed: false;
|
|
621
|
+
} | {
|
|
622
|
+
mistake: GameServerError;
|
|
623
|
+
};
|
|
584
624
|
/**
|
|
585
625
|
* canJoin's answer as the room acts on it: let in, or turn away with a refusalMessage (null for the
|
|
586
626
|
* usual one). Answering nothing or anything else is a mistake (bad_handler_answer), described for the game server's errors.
|
|
@@ -594,7 +634,7 @@ export declare function readJoinAnswer(answer: unknown): {
|
|
|
594
634
|
mistake: GameServerError;
|
|
595
635
|
};
|
|
596
636
|
/** Everything a game server object may have, in the order the docs give them. Anything else is refused. */
|
|
597
|
-
export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "reconnectTimeoutSec", "maximumRoomCountPerPlayer", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
|
|
637
|
+
export declare const GAME_SERVER_OPTIONS: readonly ["maximumPlayerCount", "maximumSpectatorCount", "ticksPerSec", "hostControlsEnabled", "idleCheck", "idleTimeoutSec", "awayMode", "seatHoldDurationSec", "stateSyncEnabled", "adultChatFilterEnabled", "reconnectTimeoutSec", "maximumRoomCountPerPlayer", "stateSaveIntervalSec", "setup", "onJoin", "canJoin", "onChat", "onMoved", "onLeave", "onSpectatorJoin", "onSpectatorLeave", "onAway", "onBack", "onMessage", "onBytes", "onPurchase", "onRefund", "onTick", "onEnding", "onRestored", "stateView", "onVersionUpdate", "onRequest", "schedules", "onSchedule", "onWebhook"];
|
|
598
638
|
export declare const ROOM_LIMITS: {
|
|
599
639
|
/** Players in one room: what one room carries at 60 ticks a second (test/load/room-load.test.ts). */
|
|
600
640
|
readonly maximumPlayerCount: 100;
|
|
@@ -774,6 +814,13 @@ export interface RoomSettings {
|
|
|
774
814
|
* isSpectator: true, and they get the players, the state and every message sent to everyone, but they send
|
|
775
815
|
* nothing: only probe answers, and anything else gets spectator_cannot_send. welcome and every "room"
|
|
776
816
|
* message carry spectatorCount, and a "room" message goes out whenever it changes.
|
|
817
|
+
*
|
|
818
|
+
* Chat (room.chat): a client sends {type: "chat", chatNumber, text, toPlayerIds?}, which the room's host
|
|
819
|
+
* handles before the game's messages (readChatMessage, then startChat, Zaisma's filter, and deliverChat
|
|
820
|
+
* or stopChat). Each player it reaches gets {type: "chat", fromPlayerId, text, hasSwearing, hasSlur,
|
|
821
|
+
* sentAt}; the sender gets {type: "chat_sent", chatNumber, sentAt}, {type: "chat_stopped", chatNumber,
|
|
822
|
+
* reason, message}, or an error with its chatNumber (chat_not_allowed, chat_empty, chat_too_long,
|
|
823
|
+
* chat_too_fast). chat_sent never says who read it. welcome says canChat: whether this player can chat at all.
|
|
777
824
|
*/
|
|
778
825
|
export type ServerMessage = {
|
|
779
826
|
type: "welcome";
|
|
@@ -787,6 +834,7 @@ export type ServerMessage = {
|
|
|
787
834
|
endsAt: string | null;
|
|
788
835
|
probeIntervalSec: number | null;
|
|
789
836
|
protocolVersionNumber: number;
|
|
837
|
+
canChat: boolean;
|
|
790
838
|
} | {
|
|
791
839
|
type: "probe";
|
|
792
840
|
probeNumber: number;
|
|
@@ -835,11 +883,28 @@ export type ServerMessage = {
|
|
|
835
883
|
} | {
|
|
836
884
|
type: "blocks";
|
|
837
885
|
playerIds: string[];
|
|
886
|
+
} | {
|
|
887
|
+
type: "chat";
|
|
888
|
+
fromPlayerId: string;
|
|
889
|
+
text: string;
|
|
890
|
+
hasSwearing: boolean;
|
|
891
|
+
hasSlur: boolean;
|
|
892
|
+
sentAt: string;
|
|
893
|
+
} | {
|
|
894
|
+
type: "chat_sent";
|
|
895
|
+
chatNumber: number;
|
|
896
|
+
sentAt: string;
|
|
897
|
+
} | {
|
|
898
|
+
type: "chat_stopped";
|
|
899
|
+
chatNumber: number;
|
|
900
|
+
reason: string;
|
|
901
|
+
message: string;
|
|
838
902
|
} | {
|
|
839
903
|
type: "error";
|
|
840
904
|
code: RoomErrorCode;
|
|
841
905
|
message: string;
|
|
842
906
|
versionId?: string;
|
|
907
|
+
chatNumber?: number;
|
|
843
908
|
} | {
|
|
844
909
|
type: "ended";
|
|
845
910
|
message: string;
|
|
@@ -863,6 +928,11 @@ export type ClientMessage = {
|
|
|
863
928
|
} | {
|
|
864
929
|
type: "message";
|
|
865
930
|
data: unknown;
|
|
931
|
+
} | {
|
|
932
|
+
type: "chat";
|
|
933
|
+
chatNumber: number;
|
|
934
|
+
text: string;
|
|
935
|
+
toPlayerIds?: string[];
|
|
866
936
|
} | {
|
|
867
937
|
type: "zaisma:purchase";
|
|
868
938
|
purchaseId: string;
|
|
@@ -902,7 +972,7 @@ export type ClientMessage = {
|
|
|
902
972
|
* the game became unavailable, its kept room was deleted, or no players were left for its spectators.
|
|
903
973
|
*/
|
|
904
974
|
export type EndReason = "game" | "hosting_paused" | "game_server_stopped" | "game_unavailable" | "kept_room_deleted" | "no_players";
|
|
905
|
-
export type RoomErrorCode = "room_full" | "room_closed" | "ended" | "too_big" | "too_fast" | "bad_message" | "bad_pass" | "no_hello" | "not_allowed" | "game_unavailable" | "busy" | "kept_out" | "refused" | "room_not_running" | "hosting_paused" | "account_needed" | "game_not_open_to_player" | "blocked_player_here" | "other_version" | "spectators_full" | "spectator_cannot_send" | "already_playing" | "bad_origin" | "send_queue_full";
|
|
975
|
+
export type RoomErrorCode = "room_full" | "room_closed" | "ended" | "too_big" | "too_fast" | "bad_message" | "bad_pass" | "no_hello" | "not_allowed" | "game_unavailable" | "busy" | "kept_out" | "refused" | "room_not_running" | "hosting_paused" | "account_needed" | "game_not_open_to_player" | "blocked_player_here" | "other_version" | "spectators_full" | "spectator_cannot_send" | "already_playing" | "bad_origin" | "send_queue_full" | "chat_not_here" | "chat_not_allowed" | "chat_empty" | "chat_too_long" | "chat_too_fast";
|
|
906
976
|
export declare const ERROR_TEXT: Record<RoomErrorCode, string>;
|
|
907
977
|
/** What players are told when Zaisma ends a match at room.endsAt. */
|
|
908
978
|
export declare const HOSTING_PAUSED_END_MESSAGE = "This game's hosting is paused, so this match has ended. New rooms open again when the month resets or its developer adds credit.";
|
|
@@ -928,6 +998,8 @@ export interface SavedRoom extends RoomSettings {
|
|
|
928
998
|
sessionBlocks?: [string, string][];
|
|
929
999
|
/** Friendships between the room's players, as pairs of player IDs. */
|
|
930
1000
|
friendships?: [string, string][];
|
|
1001
|
+
/** Who each account player the room remembers may chat with, by player ID. */
|
|
1002
|
+
chatSettings?: Record<string, PlayerChatSettings>;
|
|
931
1003
|
/** When Zaisma will end the match (room.endsAt), once it's set. */
|
|
932
1004
|
endsAt?: string;
|
|
933
1005
|
/** Kept (room.setKept): it sleeps with its state when everyone leaves, instead of going a day later. */
|
|
@@ -1011,7 +1083,33 @@ export declare function checkRoomData(given: unknown, maximumSizeBytes: number):
|
|
|
1011
1083
|
*/
|
|
1012
1084
|
export declare function errorMessage(code: RoomErrorCode, message?: string, details?: {
|
|
1013
1085
|
versionId: string;
|
|
1086
|
+
} | {
|
|
1087
|
+
chatNumber: number;
|
|
1014
1088
|
}): string;
|
|
1089
|
+
/**
|
|
1090
|
+
* Who an account player may chat with, from their pass (who_can_chat and chat_group_id): kept by the
|
|
1091
|
+
* room's host, never shown to the game. A guest has none and chats with nobody.
|
|
1092
|
+
*/
|
|
1093
|
+
export interface PlayerChatSettings {
|
|
1094
|
+
whoCanChat: "nobody" | "friends" | "anyone";
|
|
1095
|
+
chatGroupId: string | null;
|
|
1096
|
+
}
|
|
1097
|
+
/** A checked pass's chat settings, for RoomCore.setChatSettings: null for a guest. A setting this version doesn't know chats with nobody. */
|
|
1098
|
+
export declare function chatSettingsOf(claims: PassClaims): PlayerChatSettings | null;
|
|
1099
|
+
/** A chat message from a client as readChatMessage found it, for startChat to check. */
|
|
1100
|
+
export interface ChatClientMessage {
|
|
1101
|
+
chatNumber: number;
|
|
1102
|
+
text: unknown;
|
|
1103
|
+
toPlayerIds: unknown;
|
|
1104
|
+
sizeBytes: number;
|
|
1105
|
+
}
|
|
1106
|
+
/** Room traffic of chat, in the room's message types. */
|
|
1107
|
+
export declare const CHAT_MESSAGE_TYPE = "(chat)";
|
|
1108
|
+
/**
|
|
1109
|
+
* A client's chat message ({type: "chat"}), or null for any other text, which goes to receive. The
|
|
1110
|
+
* room's host handles chat before receive, since Zaisma's filter runs between the room and the players.
|
|
1111
|
+
*/
|
|
1112
|
+
export declare function readChatMessage(text: string): ChatClientMessage | null;
|
|
1015
1113
|
/** Whether a player may come in, and if not, why. */
|
|
1016
1114
|
export type Admission = {
|
|
1017
1115
|
isAllowed: true;
|
|
@@ -1163,6 +1261,12 @@ export declare class RoomCore<S = any> {
|
|
|
1163
1261
|
private readonly sessionBlocks;
|
|
1164
1262
|
/** Friendships between players from their accounts (Zaisma's own record, or passes on your own server): pairKey(first, second). */
|
|
1165
1263
|
private readonly friendships;
|
|
1264
|
+
/** Who each account player may chat with, from their pass: never in Player, so games never see it. */
|
|
1265
|
+
private readonly chatSettings;
|
|
1266
|
+
/** When each player sent their chat messages in the last CHAT_ROOM_LIMITS.chatWindowDurationMs. */
|
|
1267
|
+
private readonly chatSentAtMs;
|
|
1268
|
+
/** Each player's last chat messages, for Zaisma's filter to read pieces together: in memory only. */
|
|
1269
|
+
private readonly recentChats;
|
|
1166
1270
|
/** The blocks each player was last told of, so they're told again only when theirs change. */
|
|
1167
1271
|
private readonly toldBlocks;
|
|
1168
1272
|
/** When each player in the match was last heard from, when their game was hidden, and the idle check their game asked for. */
|
|
@@ -1190,6 +1294,8 @@ export declare class RoomCore<S = any> {
|
|
|
1190
1294
|
private readonly api;
|
|
1191
1295
|
/** The room as canJoin and stateView see it: reading works, and any call that changes or sends throws room_call_unavailable. */
|
|
1192
1296
|
private readonly canJoinRoom;
|
|
1297
|
+
/** The room as onChat sees it: reading only, like canJoin. */
|
|
1298
|
+
private readonly chatRoom;
|
|
1193
1299
|
private readonly stateViewRoom;
|
|
1194
1300
|
readonly server: GameServer<S>;
|
|
1195
1301
|
private readonly transport;
|
|
@@ -1259,6 +1365,45 @@ export declare class RoomCore<S = any> {
|
|
|
1259
1365
|
* room knew of that player's, so a friend removed since is gone. Call before join.
|
|
1260
1366
|
*/
|
|
1261
1367
|
setFriends(playerId: string, friendPlayerIds: readonly string[]): void;
|
|
1368
|
+
/** A player's chat settings from their pass, before join: null for a guest, who chats with nobody. */
|
|
1369
|
+
setChatSettings(playerId: string, settings: PlayerChatSettings | null): void;
|
|
1370
|
+
/** Whether a player can chat at all: an account whose setting isn't nobody, with a chat group. */
|
|
1371
|
+
canChat(playerId: string): boolean;
|
|
1372
|
+
/**
|
|
1373
|
+
* Whether one player's chat may reach another, canChat's rule on passes (pass.ts): two accounts in the
|
|
1374
|
+
* same chat group with no block either way, friends unless both chat with anyone.
|
|
1375
|
+
*/
|
|
1376
|
+
private mayChat;
|
|
1377
|
+
/** Who a player's chat may reach: the players in the match (or those of toPlayerIds in it), never spectators, kept only where they may chat. */
|
|
1378
|
+
chatReceivers(senderPlayerId: string, toPlayerIds?: readonly string[]): string[];
|
|
1379
|
+
/**
|
|
1380
|
+
* A chat message from a player (readChatMessage), checked by the room: the sender, the text, their pace,
|
|
1381
|
+
* then the game server's onChat with the players their game sent it to. When it goes no further the room
|
|
1382
|
+
* answers the sender itself (an error, or chat_stopped with stopped_by_game) and this is null. Otherwise
|
|
1383
|
+
* it's the text, who may read it (chatReceivers, which may be nobody) and the sender's last messages, for
|
|
1384
|
+
* the host to run Zaisma's filter and then deliverChat or stopChat.
|
|
1385
|
+
*/
|
|
1386
|
+
startChat(playerId: string, chat: ChatClientMessage): Promise<{
|
|
1387
|
+
text: string;
|
|
1388
|
+
receiverPlayerIds: string[];
|
|
1389
|
+
recentTexts: string[];
|
|
1390
|
+
} | null>;
|
|
1391
|
+
/**
|
|
1392
|
+
* Send a chat message Zaisma's filter let through to its receivers, and tell the sender it went
|
|
1393
|
+
* (chat_sent), even when it reached nobody: the sender never learns who can't read them.
|
|
1394
|
+
*/
|
|
1395
|
+
deliverChat(senderPlayerId: string, chatNumber: number, receiverPlayerIds: readonly string[], chat: {
|
|
1396
|
+
text: string;
|
|
1397
|
+
hasSwearing: boolean;
|
|
1398
|
+
hasSlur: boolean;
|
|
1399
|
+
}): void;
|
|
1400
|
+
/** Tell the sender their chat message was stopped: reason is the filter's, or stopped_by_game; message is what they see. */
|
|
1401
|
+
stopChat(senderPlayerId: string, chatNumber: number, reason: string, message: string): void;
|
|
1402
|
+
/** A player's chat messages from the last CHAT_CHECK_LIMITS.recentTextDurationMs, oldest first, up to recentTextCount. */
|
|
1403
|
+
recentChatTexts(playerId: string): string[];
|
|
1404
|
+
private rememberChat;
|
|
1405
|
+
/** Whether a player may send another chat message: CHAT_ROOM_LIMITS.maximumChatMessageCount in any chatWindowDurationMs. */
|
|
1406
|
+
private takeChat;
|
|
1262
1407
|
/** Whether two players have a block between them, either way: from their accounts, or one a player's game said it made. */
|
|
1263
1408
|
hasBlockBetween(firstPlayerId: string, secondPlayerId: string): boolean;
|
|
1264
1409
|
/**
|
package/src/room.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* developer's code that holds the official game state. A *room* is one live match,
|
|
7
7
|
* running one copy of the game server. Names follow docs/NAMING.md.
|
|
8
8
|
*/
|
|
9
|
+
import { CHAT_CHECK_LIMITS, CHAT_ROOM_LIMITS } from "./chat.js";
|
|
9
10
|
import { DatabaseError } from "./database.js";
|
|
10
11
|
import { isRoomCode, roomCode } from "./names.js";
|
|
11
12
|
import { readSchedules } from "./schedules.js";
|
|
@@ -22,6 +23,20 @@ function shownPlayer(player) {
|
|
|
22
23
|
export function restoreInfoOf(savedAtMs, nowMs = Date.now(), savedVersionId = null, isSavedEmpty = false) {
|
|
23
24
|
return { savedAt: new Date(savedAtMs).toISOString(), lostDurationSec: isSavedEmpty ? 0 : Math.max(0, Math.round((nowMs - savedAtMs) / 1000)), savedVersionId };
|
|
24
25
|
}
|
|
26
|
+
/** What the sender hears when the game server's onChat stopped their message. */
|
|
27
|
+
export const STOPPED_BY_GAME_MESSAGE = "Your message wasn't sent.";
|
|
28
|
+
/** onChat's answer as the room acts on it: send (to toPlayerIds, or all it was sent to when null), or stop. Anything else is a mistake, and stops it. */
|
|
29
|
+
export function readChatAnswer(answer) {
|
|
30
|
+
if (answer === true)
|
|
31
|
+
return { isAllowed: true, toPlayerIds: null };
|
|
32
|
+
if (answer === false)
|
|
33
|
+
return { isAllowed: false };
|
|
34
|
+
const toPlayerIds = typeof answer === "object" && answer !== null && !Array.isArray(answer) ? answer.toPlayerIds : undefined;
|
|
35
|
+
if (Array.isArray(toPlayerIds) && toPlayerIds.every((id) => typeof id === "string") && Object.keys(answer).length === 1)
|
|
36
|
+
return { isAllowed: true, toPlayerIds };
|
|
37
|
+
const given = answer === undefined || answer === null ? "nothing" : typeof answer === "string" ? `the text ${JSON.stringify(answer.slice(0, 60))}` : typeof answer === "object" ? describeObject(answer) : String(answer);
|
|
38
|
+
return { mistake: new GameServerError("bad_handler_answer", "onChat", `onChat answered ${given}, so the message was stopped.`, 'Answer true to send it, false to stop it, or { toPlayerIds: ["player_..."] } to send it to only those players.') };
|
|
39
|
+
}
|
|
25
40
|
function describeObject(given) {
|
|
26
41
|
try {
|
|
27
42
|
return (JSON.stringify(given) ?? "an object").slice(0, 80);
|
|
@@ -59,12 +74,14 @@ export const GAME_SERVER_OPTIONS = [
|
|
|
59
74
|
"awayMode",
|
|
60
75
|
"seatHoldDurationSec",
|
|
61
76
|
"stateSyncEnabled",
|
|
77
|
+
"adultChatFilterEnabled",
|
|
62
78
|
"reconnectTimeoutSec",
|
|
63
79
|
"maximumRoomCountPerPlayer",
|
|
64
80
|
"stateSaveIntervalSec",
|
|
65
81
|
"setup",
|
|
66
82
|
"onJoin",
|
|
67
83
|
"canJoin",
|
|
84
|
+
"onChat",
|
|
68
85
|
"onMoved",
|
|
69
86
|
"onLeave",
|
|
70
87
|
"onSpectatorJoin",
|
|
@@ -240,6 +257,8 @@ export function defineGameServer(server) {
|
|
|
240
257
|
throw bad("hostControlsEnabled", "hostControlsEnabled isn't true or false.", "Give true or false.");
|
|
241
258
|
if (server.stateSyncEnabled !== undefined && typeof server.stateSyncEnabled !== "boolean")
|
|
242
259
|
throw bad("stateSyncEnabled", "stateSyncEnabled isn't true or false.", "Give true to send each player the state and then only what changes, or leave it out.");
|
|
260
|
+
if (server.adultChatFilterEnabled !== undefined && typeof server.adultChatFilterEnabled !== "boolean")
|
|
261
|
+
throw bad("adultChatFilterEnabled", "adultChatFilterEnabled isn't true or false.", "Give true to check chat between adults with Zaisma's filter too, or leave it out.");
|
|
243
262
|
if (server.stateView !== undefined && !server.stateSyncEnabled)
|
|
244
263
|
throw bad("stateView", "stateView only works with stateSyncEnabled.", "Add stateSyncEnabled: true, or leave stateView out.");
|
|
245
264
|
if (idleCheck !== undefined && idleCheck !== "server" && idleCheck !== "client" && idleCheck !== "off") {
|
|
@@ -290,6 +309,11 @@ export const ERROR_TEXT = {
|
|
|
290
309
|
bad_origin: "Rooms take players from the game's own page.",
|
|
291
310
|
// The SDK's own: sends waiting while it reconnects ran past its queue.
|
|
292
311
|
send_queue_full: "Up to 60 sends wait while reconnecting; older ones were dropped.",
|
|
312
|
+
chat_not_here: "This room's server doesn't take chat.",
|
|
313
|
+
chat_not_allowed: "You can't chat here.",
|
|
314
|
+
chat_empty: "Type a message first.",
|
|
315
|
+
chat_too_long: "That message is too long to send.",
|
|
316
|
+
chat_too_fast: "Too many messages at once. Wait a moment.",
|
|
293
317
|
};
|
|
294
318
|
/** What players are told when Zaisma ends a match at room.endsAt. */
|
|
295
319
|
export const HOSTING_PAUSED_END_MESSAGE = "This game's hosting is paused, so this match has ended. New rooms open again when the month resets or its developer adds credit.";
|
|
@@ -323,6 +347,35 @@ export function checkRoomData(given, maximumSizeBytes) {
|
|
|
323
347
|
export function errorMessage(code, message, details) {
|
|
324
348
|
return JSON.stringify({ type: "error", code, message: message || ERROR_TEXT[code], ...details });
|
|
325
349
|
}
|
|
350
|
+
/** A checked pass's chat settings, for RoomCore.setChatSettings: null for a guest. A setting this version doesn't know chats with nobody. */
|
|
351
|
+
export function chatSettingsOf(claims) {
|
|
352
|
+
if (claims.kind !== "account")
|
|
353
|
+
return null;
|
|
354
|
+
const whoCanChat = claims.who_can_chat === "friends" ? "friends" : claims.who_can_chat === "anyone" ? "anyone" : "nobody";
|
|
355
|
+
return { whoCanChat, chatGroupId: typeof claims.chat_group_id === "string" ? claims.chat_group_id : null };
|
|
356
|
+
}
|
|
357
|
+
/** Room traffic of chat, in the room's message types. */
|
|
358
|
+
export const CHAT_MESSAGE_TYPE = "(chat)";
|
|
359
|
+
/**
|
|
360
|
+
* A client's chat message ({type: "chat"}), or null for any other text, which goes to receive. The
|
|
361
|
+
* room's host handles chat before receive, since Zaisma's filter runs between the room and the players.
|
|
362
|
+
*/
|
|
363
|
+
export function readChatMessage(text) {
|
|
364
|
+
// Most messages aren't chat: only text that could be is parsed here, and one too big is receive's to refuse.
|
|
365
|
+
if (!text.includes('"chat"') || text.length > ROOM_LIMITS.maximumGameMessageSizeBytes)
|
|
366
|
+
return null;
|
|
367
|
+
let parsed;
|
|
368
|
+
try {
|
|
369
|
+
parsed = JSON.parse(text);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
if (!parsed || typeof parsed !== "object" || parsed.type !== "chat")
|
|
375
|
+
return null;
|
|
376
|
+
const chat = parsed;
|
|
377
|
+
return { chatNumber: Number.isSafeInteger(chat.chatNumber) ? chat.chatNumber : 0, text: chat.text, toPlayerIds: chat.toPlayerIds, sizeBytes: utf8ByteCount(text) };
|
|
378
|
+
}
|
|
326
379
|
/** WebSocket close code for a match that ended normally. */
|
|
327
380
|
export const ENDED_CLOSE_CODE = 4000;
|
|
328
381
|
/** Close code for a player refused or removed by the room: the SDK doesn't reconnect. */
|
|
@@ -482,6 +535,12 @@ export class RoomCore {
|
|
|
482
535
|
sessionBlocks = new Set();
|
|
483
536
|
/** Friendships between players from their accounts (Zaisma's own record, or passes on your own server): pairKey(first, second). */
|
|
484
537
|
friendships = new Set();
|
|
538
|
+
/** Who each account player may chat with, from their pass: never in Player, so games never see it. */
|
|
539
|
+
chatSettings = new Map();
|
|
540
|
+
/** When each player sent their chat messages in the last CHAT_ROOM_LIMITS.chatWindowDurationMs. */
|
|
541
|
+
chatSentAtMs = new Map();
|
|
542
|
+
/** Each player's last chat messages, for Zaisma's filter to read pieces together: in memory only. */
|
|
543
|
+
recentChats = new Map();
|
|
485
544
|
/** The blocks each player was last told of, so they're told again only when theirs change. */
|
|
486
545
|
toldBlocks = new Map();
|
|
487
546
|
/** When each player in the match was last heard from, when their game was hidden, and the idle check their game asked for. */
|
|
@@ -509,6 +568,8 @@ export class RoomCore {
|
|
|
509
568
|
api;
|
|
510
569
|
/** The room as canJoin and stateView see it: reading works, and any call that changes or sends throws room_call_unavailable. */
|
|
511
570
|
canJoinRoom;
|
|
571
|
+
/** The room as onChat sees it: reading only, like canJoin. */
|
|
572
|
+
chatRoom;
|
|
512
573
|
stateViewRoom;
|
|
513
574
|
server;
|
|
514
575
|
transport;
|
|
@@ -604,6 +665,8 @@ export class RoomCore {
|
|
|
604
665
|
this.sessionBlocks.add(blockKey(blocker, blocked));
|
|
605
666
|
for (const [first, second] of saved.friendships ?? [])
|
|
606
667
|
this.friendships.add(pairKey(first, second));
|
|
668
|
+
for (const [id, settings] of Object.entries(saved.chatSettings ?? {}))
|
|
669
|
+
this.chatSettings.set(id, settings);
|
|
607
670
|
}
|
|
608
671
|
else if (startOptions.start) {
|
|
609
672
|
const start = startOptions.start;
|
|
@@ -727,6 +790,7 @@ export class RoomCore {
|
|
|
727
790
|
Object.defineProperty(this.api, PAIR_CHANGE_COUNT, { get: () => this.pairChangeCount });
|
|
728
791
|
Object.defineProperty(this.api, STATE_SYNC, { value: { viewers: () => this.stateViewers(), send: (playerIds, frame) => this.sendStateFrom(playerIds, frame) } });
|
|
729
792
|
this.canJoinRoom = readOnlyRoom(this.api, "canJoin");
|
|
793
|
+
this.chatRoom = readOnlyRoom(this.api, "onChat");
|
|
730
794
|
this.stateViewRoom = readOnlyRoom(this.api, "stateView");
|
|
731
795
|
if (restoreInfo)
|
|
732
796
|
this.restored(restoreInfo, lostPlayers);
|
|
@@ -772,6 +836,7 @@ export class RoomCore {
|
|
|
772
836
|
...(this.endsAt ? { endsAt: this.endsAt } : {}),
|
|
773
837
|
...(this.isKept ? { isKept: true } : {}),
|
|
774
838
|
...this.savedBlocks(),
|
|
839
|
+
...(this.chatSettings.size ? { chatSettings: Object.fromEntries([...this.chatSettings].filter(([id]) => this.known.has(id))) } : {}),
|
|
775
840
|
};
|
|
776
841
|
}
|
|
777
842
|
/** The blocks worth keeping while the room sleeps: those between players it still remembers. */
|
|
@@ -810,6 +875,137 @@ export class RoomCore {
|
|
|
810
875
|
this.dirty = true;
|
|
811
876
|
this.pairChangeCount++;
|
|
812
877
|
}
|
|
878
|
+
/** A player's chat settings from their pass, before join: null for a guest, who chats with nobody. */
|
|
879
|
+
setChatSettings(playerId, settings) {
|
|
880
|
+
if (settings)
|
|
881
|
+
this.chatSettings.set(playerId, { whoCanChat: settings.whoCanChat, chatGroupId: settings.chatGroupId });
|
|
882
|
+
else if (!this.chatSettings.delete(playerId))
|
|
883
|
+
return;
|
|
884
|
+
this.dirty = true;
|
|
885
|
+
}
|
|
886
|
+
/** Whether a player can chat at all: an account whose setting isn't nobody, with a chat group. */
|
|
887
|
+
canChat(playerId) {
|
|
888
|
+
const settings = this.chatSettings.get(playerId);
|
|
889
|
+
return settings !== undefined && settings.whoCanChat !== "nobody" && settings.chatGroupId !== null;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Whether one player's chat may reach another, canChat's rule on passes (pass.ts): two accounts in the
|
|
893
|
+
* same chat group with no block either way, friends unless both chat with anyone.
|
|
894
|
+
*/
|
|
895
|
+
mayChat(senderPlayerId, receiverPlayerId) {
|
|
896
|
+
if (senderPlayerId === receiverPlayerId || !this.canChat(senderPlayerId) || !this.canChat(receiverPlayerId) || this.hasBlockBetween(senderPlayerId, receiverPlayerId))
|
|
897
|
+
return false;
|
|
898
|
+
const sender = this.chatSettings.get(senderPlayerId);
|
|
899
|
+
const receiver = this.chatSettings.get(receiverPlayerId);
|
|
900
|
+
if (sender.chatGroupId !== receiver.chatGroupId)
|
|
901
|
+
return false;
|
|
902
|
+
return (sender.whoCanChat === "anyone" && receiver.whoCanChat === "anyone") || this.areFriends(senderPlayerId, receiverPlayerId);
|
|
903
|
+
}
|
|
904
|
+
/** Who a player's chat may reach: the players in the match (or those of toPlayerIds in it), never spectators, kept only where they may chat. */
|
|
905
|
+
chatReceivers(senderPlayerId, toPlayerIds) {
|
|
906
|
+
const asked = toPlayerIds ? [...new Set(toPlayerIds)].filter((id) => this.players.has(id)) : [...this.players.keys()];
|
|
907
|
+
return asked.filter((id) => this.mayChat(senderPlayerId, id));
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* A chat message from a player (readChatMessage), checked by the room: the sender, the text, their pace,
|
|
911
|
+
* then the game server's onChat with the players their game sent it to. When it goes no further the room
|
|
912
|
+
* answers the sender itself (an error, or chat_stopped with stopped_by_game) and this is null. Otherwise
|
|
913
|
+
* it's the text, who may read it (chatReceivers, which may be nobody) and the sender's last messages, for
|
|
914
|
+
* the host to run Zaisma's filter and then deliverChat or stopChat.
|
|
915
|
+
*/
|
|
916
|
+
async startChat(playerId, chat) {
|
|
917
|
+
const refuse = (code) => {
|
|
918
|
+
this.sendText(playerId, errorMessage(code, undefined, { chatNumber: chat.chatNumber }));
|
|
919
|
+
return null;
|
|
920
|
+
};
|
|
921
|
+
// Spectators send nothing, chat included; limited like anyone's messages, so refusing them costs little.
|
|
922
|
+
if (this.spectators.has(playerId))
|
|
923
|
+
return refuse(this.ended ? "ended" : this.take(playerId) ? "spectator_cannot_send" : "too_fast");
|
|
924
|
+
const player = this.players.get(playerId);
|
|
925
|
+
if (!player || this.ended)
|
|
926
|
+
return refuse("ended");
|
|
927
|
+
this.messageCount++;
|
|
928
|
+
this.countTraffic("inbound", CHAT_MESSAGE_TYPE, 1, chat.sizeBytes);
|
|
929
|
+
// Counted against the room's 30 a second like any message, then chat's own pace below.
|
|
930
|
+
if (!this.take(playerId))
|
|
931
|
+
return refuse("too_fast");
|
|
932
|
+
const isPlayerIdList = chat.toPlayerIds === undefined || (Array.isArray(chat.toPlayerIds) && chat.toPlayerIds.length <= this.maximumPlayerCount && chat.toPlayerIds.every((id) => typeof id === "string"));
|
|
933
|
+
if (typeof chat.text !== "string" || !isPlayerIdList)
|
|
934
|
+
return refuse("bad_message");
|
|
935
|
+
if (!this.canChat(playerId))
|
|
936
|
+
return refuse("chat_not_allowed");
|
|
937
|
+
if (!chat.text.trim())
|
|
938
|
+
return refuse("chat_empty");
|
|
939
|
+
if (chat.text.length > CHAT_CHECK_LIMITS.maximumMessageCharacterCount)
|
|
940
|
+
return refuse("chat_too_long");
|
|
941
|
+
if (!this.takeChat(playerId))
|
|
942
|
+
return refuse("chat_too_fast");
|
|
943
|
+
const presence = this.presence.get(playerId);
|
|
944
|
+
if (presence)
|
|
945
|
+
presence.seenAtMs = this.now();
|
|
946
|
+
const toPlayerIds = (chat.toPlayerIds === undefined ? [...this.players.keys()] : [...new Set(chat.toPlayerIds)]).filter((id) => id !== playerId && this.players.has(id));
|
|
947
|
+
let askedPlayerIds = toPlayerIds;
|
|
948
|
+
if (this.server.onChat) {
|
|
949
|
+
await this.settled();
|
|
950
|
+
let answer;
|
|
951
|
+
try {
|
|
952
|
+
answer = await this.server.onChat(this.state, player, this.chatRoom, { text: chat.text, toPlayerIds: [...toPlayerIds] });
|
|
953
|
+
}
|
|
954
|
+
catch (error) {
|
|
955
|
+
console.error("Game server error:", error);
|
|
956
|
+
answer = false;
|
|
957
|
+
}
|
|
958
|
+
const read = readChatAnswer(answer);
|
|
959
|
+
if ("mistake" in read)
|
|
960
|
+
console.error("Game server error:", read.mistake);
|
|
961
|
+
if ("mistake" in read || !read.isAllowed) {
|
|
962
|
+
this.stopChat(playerId, chat.chatNumber, "stopped_by_game", STOPPED_BY_GAME_MESSAGE);
|
|
963
|
+
return null;
|
|
964
|
+
}
|
|
965
|
+
if (read.toPlayerIds)
|
|
966
|
+
askedPlayerIds = toPlayerIds.filter((id) => read.toPlayerIds.includes(id));
|
|
967
|
+
}
|
|
968
|
+
const recentTexts = this.recentChatTexts(playerId);
|
|
969
|
+
this.rememberChat(playerId, chat.text);
|
|
970
|
+
return { text: chat.text, receiverPlayerIds: this.chatReceivers(playerId, askedPlayerIds), recentTexts };
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* Send a chat message Zaisma's filter let through to its receivers, and tell the sender it went
|
|
974
|
+
* (chat_sent), even when it reached nobody: the sender never learns who can't read them.
|
|
975
|
+
*/
|
|
976
|
+
deliverChat(senderPlayerId, chatNumber, receiverPlayerIds, chat) {
|
|
977
|
+
const sentAt = new Date(this.now()).toISOString();
|
|
978
|
+
// Players who left, or blocked the sender, while the filter looked get nothing.
|
|
979
|
+
const receivers = receiverPlayerIds.filter((id) => this.players.has(id) && this.mayChat(senderPlayerId, id));
|
|
980
|
+
if (receivers.length)
|
|
981
|
+
this.sendEach(receivers, JSON.stringify({ type: "chat", fromPlayerId: senderPlayerId, text: chat.text, hasSwearing: chat.hasSwearing, hasSlur: chat.hasSlur, sentAt }), CHAT_MESSAGE_TYPE);
|
|
982
|
+
this.sendText(senderPlayerId, JSON.stringify({ type: "chat_sent", chatNumber, sentAt }));
|
|
983
|
+
}
|
|
984
|
+
/** Tell the sender their chat message was stopped: reason is the filter's, or stopped_by_game; message is what they see. */
|
|
985
|
+
stopChat(senderPlayerId, chatNumber, reason, message) {
|
|
986
|
+
this.sendText(senderPlayerId, JSON.stringify({ type: "chat_stopped", chatNumber, reason, message }));
|
|
987
|
+
}
|
|
988
|
+
/** A player's chat messages from the last CHAT_CHECK_LIMITS.recentTextDurationMs, oldest first, up to recentTextCount. */
|
|
989
|
+
recentChatTexts(playerId) {
|
|
990
|
+
const nowMs = this.now();
|
|
991
|
+
return (this.recentChats.get(playerId) ?? []).filter((sent) => nowMs - sent.sentAtMs < CHAT_CHECK_LIMITS.recentTextDurationMs).map((sent) => sent.text);
|
|
992
|
+
}
|
|
993
|
+
rememberChat(playerId, text) {
|
|
994
|
+
const nowMs = this.now();
|
|
995
|
+
const recent = (this.recentChats.get(playerId) ?? []).filter((sent) => nowMs - sent.sentAtMs < CHAT_CHECK_LIMITS.recentTextDurationMs);
|
|
996
|
+
recent.push({ text, sentAtMs: nowMs });
|
|
997
|
+
this.recentChats.set(playerId, recent.slice(-CHAT_CHECK_LIMITS.recentTextCount));
|
|
998
|
+
}
|
|
999
|
+
/** Whether a player may send another chat message: CHAT_ROOM_LIMITS.maximumChatMessageCount in any chatWindowDurationMs. */
|
|
1000
|
+
takeChat(playerId) {
|
|
1001
|
+
const nowMs = this.now();
|
|
1002
|
+
const sentAtMs = (this.chatSentAtMs.get(playerId) ?? []).filter((atMs) => nowMs - atMs < CHAT_ROOM_LIMITS.chatWindowDurationMs);
|
|
1003
|
+
const isTaken = sentAtMs.length < CHAT_ROOM_LIMITS.maximumChatMessageCount;
|
|
1004
|
+
if (isTaken)
|
|
1005
|
+
sentAtMs.push(nowMs);
|
|
1006
|
+
this.chatSentAtMs.set(playerId, sentAtMs);
|
|
1007
|
+
return isTaken;
|
|
1008
|
+
}
|
|
813
1009
|
/** Whether two players have a block between them, either way: from their accounts, or one a player's game said it made. */
|
|
814
1010
|
hasBlockBetween(firstPlayerId, secondPlayerId) {
|
|
815
1011
|
if (firstPlayerId === secondPlayerId)
|
|
@@ -1133,7 +1329,7 @@ export class RoomCore {
|
|
|
1133
1329
|
this.remember(player.id);
|
|
1134
1330
|
this.presence.set(player.id, newPresence(this.now(), joinOptions.clientIdle === undefined ? (this.presence.get(player.id)?.clientIdle ?? {}) : clientIdle(joinOptions.clientIdle)));
|
|
1135
1331
|
const probeIntervalSec = this.ticking ? ROOM_TUNING.probeIntervalSec : null;
|
|
1136
|
-
this.sendText(player.id, JSON.stringify({ type: "welcome", me: shownPlayer(player), isSpectator: false, players: this.list(), room: this.settings(), spectatorCount: this.spectators.size, hostPlayerId: this.hostPlayerId, awayChecks: this.checks(player.id), endsAt: this.endsAt, probeIntervalSec, protocolVersionNumber: PROTOCOL_VERSION_NUMBER }));
|
|
1332
|
+
this.sendText(player.id, JSON.stringify({ type: "welcome", me: shownPlayer(player), isSpectator: false, players: this.list(), room: this.settings(), spectatorCount: this.spectators.size, hostPlayerId: this.hostPlayerId, awayChecks: this.checks(player.id), endsAt: this.endsAt, probeIntervalSec, protocolVersionNumber: PROTOCOL_VERSION_NUMBER, canChat: this.canChat(player.id) }));
|
|
1137
1333
|
// Every room gets a first ping and clock sample right away, ticking or not.
|
|
1138
1334
|
this.probe(player.id);
|
|
1139
1335
|
// A new connection starts with nothing of the state: a full copy comes with the next round.
|
|
@@ -1172,7 +1368,7 @@ export class RoomCore {
|
|
|
1172
1368
|
this.spectators.set(spectator.id, spectator);
|
|
1173
1369
|
this.presence.set(spectator.id, newPresence(this.now(), {}));
|
|
1174
1370
|
const probeIntervalSec = this.ticking ? ROOM_TUNING.probeIntervalSec : null;
|
|
1175
|
-
this.sendText(spectator.id, JSON.stringify({ type: "welcome", me: shownPlayer(spectator), isSpectator: true, players: this.list(), room: this.settings(), spectatorCount: this.spectators.size, hostPlayerId: this.hostPlayerId, awayChecks: { idleTimeoutSec: null, hiddenTimeoutSec: null }, endsAt: this.endsAt, probeIntervalSec, protocolVersionNumber: PROTOCOL_VERSION_NUMBER }));
|
|
1371
|
+
this.sendText(spectator.id, JSON.stringify({ type: "welcome", me: shownPlayer(spectator), isSpectator: true, players: this.list(), room: this.settings(), spectatorCount: this.spectators.size, hostPlayerId: this.hostPlayerId, awayChecks: { idleTimeoutSec: null, hiddenTimeoutSec: null }, endsAt: this.endsAt, probeIntervalSec, protocolVersionNumber: PROTOCOL_VERSION_NUMBER, canChat: false }));
|
|
1176
1372
|
this.probe(spectator.id);
|
|
1177
1373
|
// A new connection starts with nothing of the state: a full copy comes with the next round.
|
|
1178
1374
|
if (this.server.stateSyncEnabled) {
|
|
@@ -1254,6 +1450,8 @@ export class RoomCore {
|
|
|
1254
1450
|
/** Take a player out of the match, forgetting what the room knew about them while they were in. */
|
|
1255
1451
|
out(playerId) {
|
|
1256
1452
|
this.players.delete(playerId);
|
|
1453
|
+
this.chatSentAtMs.delete(playerId);
|
|
1454
|
+
this.recentChats.delete(playerId);
|
|
1257
1455
|
this.latestSlots.delete(playerId);
|
|
1258
1456
|
this.stateSkippedPlayerIds.delete(playerId);
|
|
1259
1457
|
this.fullCopyPlayerIds.delete(playerId);
|
|
@@ -1482,6 +1680,9 @@ export class RoomCore {
|
|
|
1482
1680
|
case "message":
|
|
1483
1681
|
this.input(() => this.server.onMessage?.(this.state, player, this.api, message.data), "onMessage");
|
|
1484
1682
|
return null;
|
|
1683
|
+
case "chat":
|
|
1684
|
+
// The room's host takes chat before it gets here (readChatMessage); a host that doesn't, doesn't take chat.
|
|
1685
|
+
return "chat_not_here";
|
|
1485
1686
|
case "zaisma:purchase":
|
|
1486
1687
|
this.purchaseTold(playerId, message);
|
|
1487
1688
|
return null;
|
|
@@ -1898,7 +2099,7 @@ export class RoomCore {
|
|
|
1898
2099
|
const viewerIds = this.stateViewerIds();
|
|
1899
2100
|
const stateView = this.server.stateView;
|
|
1900
2101
|
const state = this.state;
|
|
1901
|
-
const round = tracker.round(state, viewerIds, stateView ? (viewerId) => stateView(state, (this.players.get(viewerId) ?? this.spectators.get(viewerId)), this.stateViewRoom) : null, this.backedUpViewers(viewerIds));
|
|
2102
|
+
const round = tracker.round(state, viewerIds, stateView ? (viewerId) => stateView(state, { ...(this.players.get(viewerId) ?? this.spectators.get(viewerId)) }, this.stateViewRoom) : null, this.backedUpViewers(viewerIds));
|
|
1902
2103
|
for (const problem of round.problems)
|
|
1903
2104
|
console.error(`Game server error: ${problem}`);
|
|
1904
2105
|
for (const { playerIds, frame } of round.frames)
|