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
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286 | ;; canon: bot.git src/bot/main.cljs (ported from src/main.ts)
;;
;; The bot process: one suite identity, a social agent (friend link, presence,
;; invites), every configured room, and — when fleet siblings are configured —
;; the befriending bootstrap plus (on the arranger) the self-play matchmaker.
;; Friends can summon it — an invite from a friend joins the invited room on
;; the spot; sibling-minted rooms are ephemeral and reaped after their match.
;;
;; THE BOOT ORDER IS LAW (NEW-BOT-PROMPT §2): installOrigin →
;; installBrowserGlobals → initPolicies → loadIdentity, then everything else.
;; The kits read the origin and browser globals at import-time-adjacent
;; moments; identity loads last so everything it touches is already in place.
;;
;; THE TWO-STACK LAW: this module graph (the :main shadow build →
;; dist/main.js) value-requires ONLY ardegazu-peer-kit — chat/board rooms run
;; in forked dist/worker.js processes (see __NAME__.rooms / __NAME__.worker).
;;
;; node dist/main.js /etc/ardegazu-__NAME__/bot.json
(ns __NAME__.main
(:require ["node:path" :as path]
["ardegazu-peer-kit" :refer (loadIdentity Social installOrigin installBrowserGlobals)]
[__NAME__.config :as config]
[__NAME__.rooms :as rooms]
[__NAME__.policies.registry :as registry]
[__NAME__.policies.index] ; side effect: learned-game providers register
[__NAME__.fleet.bootstrap :as bootstrap]
[__NAME__.fleet.matchmaker :as matchmaker]))
(def ^:private SECRET-RE #"#(?:g=)?([A-Za-z0-9_-]{22,64})")
(def ^:private REAP-TICK-MS 60000)
(def ^:private SIBLING-IDLE-MS (* 10 60000))
(def ^:private SIBLING-LINGER-MS 15000) ; post-match, so receipts finish co-signing
(def ^:private HUMAN-IDLE-MS (* 30 60000))
(defn- log [line]
(js/console.log (str "[" (.toISOString (js/Date.)) "] " line)))
(defn- or-str
"`a || b` for optional display strings."
[a b]
(if (and (some? a) (not (identical? a ""))) a b))
(defn should-reap
"The reaper's decision (golden-vectored): sibling rooms die after their one
match (or idle), human rooms after a long idle, config rooms never, selfplay
rooms belong to the matchmaker."
[provenance match-ended idle-ms]
(or (and (identical? provenance "sibling")
(or (and match-ended (> idle-ms SIBLING-LINGER-MS))
(> idle-ms SIBLING-IDLE-MS)))
(and (identical? provenance "human") (> idle-ms HUMAN-IDLE-MS))))
(defn- start-social
"Social side: friend link, presence, invites, the fleet. Resolves to
#js {:social :siblings :stopBootstrap :stopMatchmaker :presence} (or nil
fields when social is disabled)."
[^js cfg ^js suite rooms* enter leave]
(let [fleet-join-link
;; The freshest open fleet room — advertised in presence so any friend
;; can hop in ("join them" in the hub). Human-provenance rooms are
;; never shared: their links are the humans' capabilities, not ours to
;; hand out.
(fn []
(let [best (atom nil)]
(.forEach ^js rooms*
(fn [^js h _k]
(when (and (or (identical? (.-provenance h) "selfplay")
(identical? (.-provenance h) "sibling"))
(some? (.-url h))
(not (.-matchEnded h)))
(reset! best h))))
(if-some [^js b @best] (.-url b) nil)))]
(-> (.start Social #js {:suite suite
:ns (.-name cfg)
:app "ardegazu.ro"
:origin (.-origin cfg)
:storageFile (path/join (.-dataDir cfg) "social.json")
:autoAcceptFriends (.. cfg -social -autoAcceptFriends)
:joinLink fleet-join-link})
(.then (fn [^js social]
(-> (.friendLink social)
(.then (fn [link] (log (str "friend link: " link)) social)))))
(.then
(fn [^js social]
(-> (bootstrap/parse-siblings (.. cfg -fleet -siblings)
(.. suite -identity -publicKeyB64) log)
(.then
(fn [^js siblings]
(let [sibling-pubs (js/Set. (.map siblings (fn [^js s] (.-pub s))))
presence (js/Map.)] ; pub → display name, while online
(.on social "request"
(fn [^js f]
(log (str "friend request from "
(or-str (.-name f) (.slice ^js (.-pub f) 0 8))))))
(.on social "accepted"
(fn [^js f]
(log (str "now friends with "
(or-str (.-name f) (.slice ^js (.-pub f) 0 8))))))
(.on social "presence"
(fn [pub ^js info]
(if (some? info)
(.set presence pub (or-str (.-name info) (.slice ^js pub 0 8)))
(.delete presence pub))
(log (str "presence " (.slice ^js pub 0 8) " "
(if (some? info)
(str (.-name info) " @ " (.-app info))
"offline")))))
(.on social "invite"
(fn [^js inv]
(let [provenance (if (.has sibling-pubs (.. inv -from -pub))
"sibling" "human")]
(log (str "invite from "
(or-str (.. inv -from -name)
(.slice ^js (.. inv -from -pub) 0 8))
" → " (.-app inv) " (" provenance ")"))
(when (.. cfg -social -autoJoinInvites)
(let [app (rooms/app-of-host (.-app inv))
m (.match ^js (.-url inv) SECRET-RE)
secret (when (some? m) (aget m 1))]
(if (and (some? app) (some? secret))
(do (enter app secret provenance nil) js/undefined)
(log (str "can't join invite (app=" (.-app inv) ")"))))))))
(let [stop-bootstrap (when (pos? (.-length siblings))
(bootstrap/start-fleet-bootstrap social siblings log))
stop-matchmaker
(when (and (.. cfg -fleet -matchmaker -enabled)
(pos? (.-length siblings)))
(matchmaker/start-matchmaker
#js {:cfg (.. cfg -fleet -matchmaker)
:social social
:myPub (.. suite -identity -publicKeyB64)
:siblings siblings
:hostRoom
(fn [app secret expected-players]
(-> (enter app secret "selfplay"
#js {:host #js {:match #js {:minPlayers 2
:expectedPlayers expected-players
:startTimeoutMs 25000
:maxMatches 1}}})
(.then
(fn [^js handle]
(when-not (and (some? handle) (some? (.-room handle)))
(throw (js/Error. "join failed")))
#js {:key (.-key handle)
:room (.-room handle)
:close (fn [] (leave (.-key handle)))}))))
:roomStats
(fn []
(let [human (atom 0) selfplay (atom 0)]
(.forEach ^js rooms*
(fn [^js h _k]
(when (identical? (.-provenance h) "human")
(swap! human inc))
(when (identical? (.-provenance h) "selfplay")
(swap! selfplay inc))))
#js {:total (.-size ^js rooms*)
:human @human
:selfplay @selfplay}))
:onlineHumanFriends
(fn []
(let [out #js []]
(.forEach presence
(fn [name pub]
(when (and (not (.has sibling-pubs pub))
(let [^js f (.get (.-store social) pub)]
(and (some? f)
(identical? (.-state f) "friend"))))
(.push out #js {:pub pub :name name}))))
out))
:log log}))]
#js {:social social
:siblings siblings
:stopBootstrap stop-bootstrap
:stopMatchmaker stop-matchmaker}))))))))))
(defn- start [^js cfg ^js suite]
(log (str "suite identity " (.. suite -identity -publicKeyB64) " "
(.. suite -identity -fingerprint -emoji)))
(let [rooms* (js/Map.) ; key → room handle
joining (js/Set.)
leave (fn [key]
(if-some [^js handle (.get rooms* key)]
(do (.delete rooms* key)
(-> ((.-close handle)) (.catch (fn [_] nil))))
(js/Promise.resolve js/undefined)))
enter (fn enter [app secret provenance ^js extras]
(let [key (rooms/room-key app secret)]
(if (.has joining key)
(js/Promise.resolve nil)
(let [^js existing (.get rooms* key)]
(-> (if (some? existing)
(if (or (identical? provenance "config")
(identical? provenance "selfplay"))
(js/Promise.resolve existing)
;; a repeat invite means "come here NOW" — the old
;; node may have gone stale (browser peers churn);
;; rejoin fresh rather than sit deaf
(do (log (str "re-invited to " app " (" key ") — rejoining fresh"))
(-> (leave key) (.then (fn [_] nil)))))
(js/Promise.resolve nil))
(.then
(fn [short]
(if (some? short)
short
(do (.add joining key)
(-> (rooms/join-room cfg suite
#js {:app app
:secret secret
:behavior (some-> extras .-behavior)}
#js {:provenance provenance
:host (some-> extras .-host)}
log)
(.then (fn [^js handle]
(.set rooms* key handle)
handle))
(.catch (fn [^js err]
(log (str "failed to join " app ": " (.-message err)))
nil))
(.finally (fn [] (.delete joining key)))))))))))))]
(-> (if (.. cfg -social -enabled)
(start-social cfg suite rooms* enter leave)
(js/Promise.resolve #js {:social nil :siblings #js []
:stopBootstrap nil :stopMatchmaker nil}))
(.then
(fn [^js soc]
(.forEach (.-rooms cfg)
(fn [^js entry]
(enter (.-app entry) (.-secret entry) "config"
#js {:behavior (.-behavior entry)})
js/undefined))
;; the reaper keeps ephemeral rooms from eating the per-IP
;; connection budget
(let [reaper (js/setInterval
(fn []
(let [hs #js []]
(.forEach rooms* (fn [h _k] (.push hs h)))
(.forEach hs
(fn [^js h]
(when (should-reap (.-provenance h) (.-matchEnded h)
((.-idleMs h)))
(log (str "reaping " (.-app h) " room (" (.-key h) ", "
(.-provenance h)
(if (.-matchEnded h) ", match done" ", idle") ")"))
(leave (.-key h))
js/undefined)))))
REAP-TICK-MS)]
(when (some? (.-unref ^js reaper)) (.unref ^js reaper))
(let [shutdown
(fn []
(log "leaving…")
(when-some [f (.-stopMatchmaker soc)] (f))
(when-some [f (.-stopBootstrap soc)] (f))
(js/clearInterval reaper)
(let [closes #js []]
(.forEach rooms* (fn [^js h _k] (.push closes ((.-close h)))))
(-> (js/Promise.allSettled closes)
(.then (fn [_] (some-> ^js (.-social soc) (.stop))))
(.then (fn [_] (js/process.exit 0)))))
(let [t (js/setTimeout (fn [] (js/process.exit 0)) 5000)]
(when (some? (.-unref ^js t)) (.unref ^js t)))
js/undefined)]
(.on js/process "SIGINT" shutdown)
(.on js/process "SIGTERM" shutdown)
(log (str "up — " (.-length (.-rooms cfg)) " configured room(s), social "
(if (.. cfg -social -enabled) "on" "off")
", fleet " (.-length ^js (.-siblings soc)) " sibling(s)")))))))))
(defn- boot []
(let [cfg-path (let [a (aget js/process.argv 2)]
(if (some? a) a "/etc/ardegazu-__NAME__/bot.json"))
^js cfg (config/load-config cfg-path)]
;; THE BOOT ORDER (see the namespace note) — nothing goes between these:
(installOrigin (.-origin cfg))
(installBrowserGlobals (path/join (.-dataDir cfg) "kv.json"))
(registry/init-policies (.-learning cfg) log)
(-> (loadIdentity #js {:seedFile (path/join (.-dataDir cfg) "seed")
:name (.-name cfg)})
(.then (fn [^js suite] (start cfg suite))))))
(defn init
"Run only as the process entry script (`node dist/main.js …`) — an import of
dist/main.js (the black-box test suite uses its :exports) must stay
side-effect free."
[]
(when (identical? (some-> (aget js/process.argv 1) (path/basename)) "main.js")
(boot)))
|