1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269 | ;; ported-from: src/games/room.ts @ v1.3.0
;;
;; The shared shape of a headless game client: joins the room, announces with
;; "hi", tracks the host through "ro" roster frames with the same bystander
;; rules the browser clients apply, and routes the social layer's leaderboard
;; co-sign frames (lbq/lbs/lbr) before game frames — exactly where the games
;; route them.
;;
;; Hosting is opt-in (`host` on a game client's options): a designated bot
;; claims hostship only when it boots into an empty room — the matchmaker mints
;; the room and starts the designated host first, so bots invited into
;; browser-hosted rooms never claim. While hosting, the attached HostDriver runs
;; the ported sim and applies its own frames through injectLocal(), the exact
;; analog of the browsers' hostEmit loopback. Foreign self-claims are settled by
;; the same per-game comparator the browsers use (claimBeats), applied by
;; incumbent and bystanders alike — mismatched rules would partition a mixed
;; bot/browser room.
;;
;; Class shape: `GameRoom` extends node:events' EventEmitter, and `join` is a
;; POLYMORPHIC static — `SeanceClient.join(opts)` must yield a SeanceClient.
;; That works because defclass compiles to a real ES class, so static
;; inheritance puts the subclass constructor in `this` (see the `this-as ctor`
;; below). `score` and `claimBeats` were TS `protected` methods; here they are
;; plain prototype methods that subclasses override.
;;
;; Vendor retirement: GameLb comes from the real ardegazu-social-kit (its CLJS
;; sources, on this build's classpath) instead of src/vendor/social/gamelb.
(ns ardegazu.peer.games.room
(:require ["node:events" :refer (EventEmitter)]
[ardegazu.social.gamelb :as gamelb]
[ardegazu.peer.apps :as apps]
[ardegazu.peer.connect :as connect]
[ardegazu.peer.node.env :as env]
[shadow.cljs.modern :refer (defclass)]))
(declare on-roster wire hi)
(defclass GameRoom
(extends EventEmitter)
(constructor [this peer opts]
(super)
(unchecked-set this "peer" peer)
(unchecked-set this "app" (unchecked-get opts "app"))
(unchecked-set this "nick" (unchecked-get opts "nick"))
(unchecked-set this "hostId" nil)
;; Attached by a game client when its options carry `host` (see subclasses).
(unchecked-set this "hostDriver" nil)
(unchecked-set this "_lb" nil)
(unchecked-set this "_opts" opts)
(unchecked-set this "_keepAlive" nil)
(wire this)))
;; Polymorphic join: `SeanceClient.join(...)` yields a SeanceClient.
(unchecked-set
GameRoom "join"
(fn [opts]
(this-as ctor
(env/install-browser-globals (unchecked-get opts "storageFile"))
(let [def (unchecked-get apps/GAMES (unchecked-get opts "app"))
suite (unchecked-get opts "suite")]
(-> (connect/connect
(js-obj "app" (unchecked-get opts "app")
"roomSecret" (unchecked-get opts "roomSecret")
"identity" (if ^boolean (js* "!!(~{})" suite)
((unchecked-get suite "netIdentity") (unchecked-get def "salt"))
nil)
"origin" (unchecked-get opts "origin")
"webrtc" (unchecked-get opts "webrtc")))
(.then (fn [peer] (new ctor peer opts))))))))
(let [proto (.-prototype GameRoom)
getter (fn [name f]
(js/Object.defineProperty proto name
#js {:get (fn [] (this-as self (f self)))
:configurable true}))]
(getter "myId" (fn [self] (unchecked-get (unchecked-get self "peer") "myId")))
(getter "hosting" (fn [self] (identical? (unchecked-get self "hostId")
(unchecked-get (unchecked-get self "peer") "myId"))))
;; The leaderboard co-signer, when a suite identity was given — a hosting
;; driver calls its hostMatchEnd(), the browsers' main.ts wiring.
(getter "leaderboard" (fn [self] (unchecked-get self "_lb"))))
(defn- hi [self]
(let [f (js-obj "t" "hi" "nick" (unchecked-get self "nick"))
extra-fn (unchecked-get (unchecked-get self "_opts") "hiExtra")
extra (if ^boolean (js* "!!(~{})" extra-fn) (extra-fn) (js-obj "score" 0))]
;; `{ t, nick, ...extra }` — extra's keys after the two fixed ones
(js/Object.assign f extra)
f))
(defn- on-roster
"Host tracking. As a bystander: the games' shared acceptance shape with the
per-game claimBeats comparator. As the incumbent host: foreign self-claims
route to the driver, which concedes or reasserts by the same rule. Returns
false when the frame must not reach subscribers (a foreign roster we
refused)."
[self from m]
(let [raw-host (unchecked-get m "host")
host (if (string? raw-host) raw-host nil)]
(if (nil? host)
false
(let [my-id (unchecked-get (unchecked-get self "peer") "myId")
claims-self (identical? host from)
driver (unchecked-get self "hostDriver")
hosting (identical? (unchecked-get self "hostId") my-id)
proceed
(cond
(and hosting (not (identical? from my-id)))
(cond
(or (not claims-self) (not ^boolean (js* "!!(~{})" driver))) false
;; conceded ⇒ adopt their claim below; reasserted ⇒ stop
(not (.onForeignClaim ^js driver from m)) false
:else true)
(not (identical? from my-id))
(let [known (unchecked-get self "hostId")]
(boolean
(or (identical? from known)
(nil? known)
(and claims-self
(or (let [^js net (unchecked-get (unchecked-get self "peer") "net")]
(not (.has (.roomPeers net) known)))
(.claimBeats ^js self from m))))))
:else true)]
(if-not proceed
false
(do
(when-not (identical? (unchecked-get self "hostId") host)
(unchecked-set self "hostId" host)
(.emit ^js self "host" host))
(let [ps (unchecked-get m "ps")]
(when (js/Array.isArray ps)
;; a host that doesn't know us yet gets our announce
(when (and (not (identical? from my-id))
(not (.some ^js ps (fn [e] (and (js/Array.isArray e)
(identical? (aget e 0) my-id))))))
(.sendToHost ^js self (hi self)))
(.emit ^js self "roster" ps)))
true))))))
(defn- wire [self]
(let [opts (unchecked-get self "_opts")
def (unchecked-get apps/GAMES (unchecked-get opts "app"))
peer (unchecked-get self "peer")
suite (unchecked-get opts "suite")]
(when ^boolean (js* "!!(~{})" suite)
(unchecked-set
self "_lb"
(gamelb/GameLb.
(js-obj "g" (unchecked-get def "salt")
"roomId" (unchecked-get (unchecked-get peer "net") "roomId")
"ns" (.replace (aget (.split (unchecked-get def "salt") "/") 0)
#"[^a-z0-9]+" "-")
"identity" (unchecked-get suite "identity")
"net" (js-obj "myId" (unchecked-get peer "myId")
"broadcast" (fn [f] (.broadcast ^js peer f))
"send" (fn [to f] (.send ^js peer to f))
"identityOf" (fn [id] (.identityOf ^js peer id)))
"myScore" (fn [] (.score ^js self))
"onReceipt" (fn [rec] (.emit ^js self "receipt" rec) js/undefined)))))
(let [ka (let [x (unchecked-get opts "keepAliveMs")]
(if ^boolean (js* "~{} == null" x) 30000 x))]
(when (> ka 0)
(let [t (js/setInterval
(fn []
(let [net (unchecked-get peer "net")]
(when-not (.relayConnected ^js net) (.wake ^js net))))
ka)]
(unchecked-set self "_keepAlive" t)
(when ^boolean (js* "!!(~{})" (unchecked-get t "unref")) (.unref ^js t)))))
(.on ^js peer "roster"
(fn [peers]
;; alone in the room and equipped to host — claim (the browsers' rule)
(let [^js driver (unchecked-get self "hostDriver")]
(when (and (zero? (.-length ^js peers))
^boolean (js* "!!(~{})" driver)
(not (identical? (unchecked-get self "hostId") (unchecked-get peer "myId"))))
(unchecked-set self "hostId" (unchecked-get peer "myId"))
(.emit ^js self "host" (unchecked-get peer "myId"))
(.onClaimed driver)))))
;; announce to every verified member — the host (whoever it is) seats us
(.on ^js peer "peerOpen" (fn [id] (.send ^js peer id (hi self))))
(.on ^js peer "peerIdentity" (fn [id ident] (.emit ^js self "peerIdentity" id ident)))
(.on ^js peer "status" (fn [] (.emit ^js self "status")))
(.on ^js peer "peerGone"
(fn [id]
(when (and (identical? id (unchecked-get self "hostId"))
(not (identical? (unchecked-get self "hostId") (unchecked-get peer "myId"))))
(unchecked-set self "hostId" nil)
(.emit ^js self "host" nil))))
(.on ^js peer "message"
(fn [from payload]
(let [lb (unchecked-get self "_lb")]
(when-not (and ^boolean (js* "!!(~{})" lb) (.onFrame ^js lb from payload))
(let [m payload]
(when (and (some? m)
(not (identical? m js/undefined))
(string? (unchecked-get m "t")))
(if (and (identical? (unchecked-get m "t") "ro")
(not (on-roster self from m)))
nil
(.emit ^js self "frame" from m))))))))
js/undefined))
(let [proto (.-prototype GameRoom)]
;; My score as I believe it — the leaderboard co-sign guard. Null = never sign.
(unchecked-set proto "score" (fn [] nil))
;; Does a foreign self-claim from `from` outrank the host I follow? The
;; per-game split-brain comparator — the base rule is plain lowest-id
;; (séance's rule); neon-grid and valley-blocks override with
;; live-beats-idle. Incumbent and bystanders MUST share this rule.
(unchecked-set proto "claimBeats"
(fn [from _m]
(this-as self
(let [h (unchecked-get self "hostId")]
(and (some? h) (< from h))))))
;; Send a frame to the current host; self-routes to the driver when hosting.
(unchecked-set
proto "sendToHost"
(fn [frame]
(this-as self
(let [h (unchecked-get self "hostId")]
(when ^boolean (js* "!!(~{})" h)
(if (identical? h (unchecked-get (unchecked-get self "peer") "myId"))
(let [d (unchecked-get self "hostDriver")]
(when ^boolean (js* "!!(~{})" d) (.onSelfFrame ^js d frame)))
(.send ^js (unchecked-get self "peer") h frame))))
js/undefined)))
;; Apply one of our own host frames through the same path every other peer
;; takes — the browsers' hostEmit loopback. Never maintain parallel host
;; state: the hosting bot's client view stays correct because it folds the
;; exact frames it broadcast.
(unchecked-set
proto "injectLocal"
(fn [frame]
(this-as self
(let [m frame]
(when (and (some? m)
(not (identical? m js/undefined))
(string? (unchecked-get m "t")))
(let [my-id (unchecked-get (unchecked-get self "peer") "myId")]
(when (identical? (unchecked-get m "t") "ro") (on-roster self my-id m))
(.emit ^js self "frame" my-id m))))
js/undefined)))
;; Manual relay revive (also runs on the keepAlive interval).
(unchecked-set proto "wake"
(fn [] (this-as self
(.wake ^js (unchecked-get (unchecked-get self "peer") "net"))
js/undefined)))
(unchecked-set
proto "close"
(fn []
(this-as self
(when ^boolean (js* "!!(~{})" (unchecked-get self "_keepAlive"))
(js/clearInterval (unchecked-get self "_keepAlive")))
(unchecked-set self "_keepAlive" nil)
(let [d (unchecked-get self "hostDriver")]
(when ^boolean (js* "!!(~{})" d) (.close ^js d)))
(.close ^js (unchecked-get self "peer"))))))
|