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 | ;; canon: bot.git src/bot/rooms.cljs (ported from src/rooms.ts)
;;
;; Room lifecycle: joins any suite app headless and keeps a handle for
;; leaving. One client (and, underneath, one libp2p node) per room — the
;; relay allows 20 connections per IP, so the reaper in __NAME__.main and the
;; matchmaker's caps keep the count honest.
;;
;; Learned games plug in through the policy registry (__NAME__.policies.*): a
;; joined game room gets its game's decision hook spread into the join options
;; and its recorder attached, if a provider registered — otherwise the
;; client's stock brain plays. Self-play rooms are joined as designated host
;; (:host option) so a bot-only room actually runs.
;;
;; STACK A ONLY: this namespace value-requires ardegazu-peer-kit and rides in
;; dist/main.js. Chat/board rooms are forked into dist/worker.js (stack B,
;; rooms-kit) — the two kits' WebRTC transports load two different builds of
;; libdatachannel, and two copies of that native library in one process abort
;; it. Isolation also turns a native crash into a one-room restart instead of
;; a bot restart.
(ns __NAME__.rooms
(:require ["node:path" :as path]
["node:crypto" :as crypto]
["node:child_process" :as cp]
["ardegazu-peer-kit" :refer (GAMES SeanceClient NeonGridClient TesseraClient
LampionClient OdeonClient ValleyBlocksClient)]
[__NAME__.behaviors :as behaviors]
[__NAME__.policies.registry :as registry]))
(defn room-key [app secret]
(str app "|" (.slice (.digest (.update (crypto/createHash "sha256") secret) "base64url") 0 16)))
(defn backoff-delay
"Worker respawn backoff: 2000·2^min(restarts,5), capped at 60 s."
[restarts]
(js/Math.min 60000 (* 2000 (js/Math.pow 2 (js/Math.min restarts 5)))))
(def ^:private GAME-CLIENTS
#js {"seance" SeanceClient
"neon-grid" NeonGridClient
"tessera" TesseraClient
"lampion" LampionClient
"odeon" OdeonClient
"valley-blocks" ValleyBlocksClient})
(defn join-worker-room
"chat/board run in their own worker process (see the namespace note): fork
dist/worker.js (sibling of dist/main.js) with the JSON job argv, respawn on
exit with exponential backoff, SIGTERM on close."
[^js cfg entry-app secret behavior nick key provenance log]
(let [job #js {:app entry-app
:secret secret
:behavior behavior
:nick nick
:dataDir (path/join (.-dataDir cfg) "rooms" (.replace key "|" "-"))
:seedFile (path/join (.-dataDir cfg) "seed")
:origin (.-origin cfg)}
;; dist/worker.js sits next to dist/main.js — the running entry script
;; (process.argv[1] is dist/main.js in every deployment shape;
;; import.meta is not available through the Closure pipeline)
worker-path (path/join (path/dirname (path/resolve (aget js/process.argv 1))) "worker.js")
child (atom nil)
stopped (atom false)
restarts (atom 0)
respawn-timer (atom nil)
;; a close() can race a pending respawn timer (worker crashed, room
;; closed inside the backoff window) — the spawn re-checks `stopped`
;; and close() clears the timer, so no orphan worker is ever forked
spawn (fn spawn []
(when-not @stopped
(reset! respawn-timer nil)
(let [^js c (cp/fork worker-path #js [(js/JSON.stringify job)] #js {:stdio "inherit"})]
(reset! child c)
(.on c "exit"
(fn [code]
(when-not @stopped
(swap! restarts inc)
(let [delay (backoff-delay @restarts)]
;; `${code}` in the TS template renders null as "null"
(log (str entry-app " worker exited (" (if (some? code) code "null")
") — restarting in "
(js/Math.round (/ delay 1000)) "s"))
(reset! respawn-timer (js/setTimeout spawn delay)))))))))]
(spawn)
(log (str "joined " entry-app " room via worker (" key ")"))
(js/Promise.resolve
#js {:key key
:app entry-app
:provenance provenance
:idleMs (fn [] 0) ; workers manage their own lives
:matchEnded false
:close (fn []
(reset! stopped true)
(some-> @respawn-timer js/clearTimeout)
(reset! respawn-timer nil)
(some-> ^js @child (.kill "SIGTERM"))
(js/Promise.resolve js/undefined))})))
(defn join-room
"Join one room; resolves to a room handle
#js {:key :app :provenance :url? :room? :idleMs :matchEnded :close}."
[^js cfg ^js suite ^js entry ^js extras log]
(let [app (.-app entry)
secret (.-secret entry)
key (room-key app secret)
nick (let [n (.-nick entry)] (if (some? n) n (.-name cfg)))
provenance (.-provenance extras)]
(if (or (identical? app "chat") (identical? app "board"))
(join-worker-room cfg app secret (.-behavior entry) nick key provenance log)
(let [Client (unchecked-get GAME-CLIENTS app)]
(when-not (some? Client)
(throw (js/Error. (str "unknown app: " app))))
(let [epsilon (if (or (identical? provenance "selfplay") (identical? provenance "sibling"))
(.. cfg -learning -epsilonSelfPlay)
(.. cfg -learning -epsilonHuman))
;; learned games contribute their decision hook + recorder; the
;; rest play the client's stock brain
^js pol (registry/policy-for-room app epsilon (.-dataDir cfg))
opts (js/Object.assign
#js {:app app
:roomSecret secret
:nick nick
:suite suite
:origin (.-origin cfg)
:storageFile (path/join (.-dataDir cfg) "kv.json")
:host (.-host extras)}
(some-> pol .-joinOptions))]
(-> (.join ^js Client opts)
(.then
(fn [^js room]
(let [last-frame (atom (js/Date.now))
handle #js {:key key
:app app
:provenance provenance
:url (str "https://" (.-host (unchecked-get GAMES app)) "/#" secret)
:room room
:idleMs (fn [] (if (some? @last-frame)
(- (js/Date.now) @last-frame)
js/Infinity))
:matchEnded false
:close (fn [] (.close room))}]
(.on room "frame" (fn [] (reset! last-frame (js/Date.now))))
(.on room "matchEnd" (fn [] (set! (.-matchEnded handle) true)))
(when (some? pol) ((.-attach pol) room))
(behaviors/attach-behavior (let [b (.-behavior entry)] (if (some? b) b "default"))
#js {:kind "game" :room room :log log :nick nick})
(log (str "joined " app " room (" key ", " provenance
(if (some? (.-host extras)) ", hosting" "") ")"))
handle)))))))))
(defn app-of-host
"Map an invite's app host (\"game4.ardegazu.ro\") to a joinable app kind."
[host]
(cond
(identical? host "chat.ardegazu.ro") "chat"
(identical? host "board.ardegazu.ro") "board"
:else (let [names (js/Object.keys GAMES)]
(loop [i 0]
(if (< i (.-length names))
(let [n (aget names i)]
(if (identical? (.-host ^js (unchecked-get GAMES n)) host)
n
(recur (inc i))))
nil)))))
|