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 | ;; canon: bot.git src/bot/fleet/matchmaker.cljs (ported from src/fleet/matchmaker.ts)
;;
;; The fleet's self-play arranger. Exactly one bot runs it — the member with
;; the lexicographically lowest suite pub among {self} ∪ siblings, derived
;; from config alone (no election, no runtime coordination; arranger down =
;; no self-play until it returns, which is fine).
;;
;; A match slot: mint a fresh secret → join it as designated host → invite
;; partners → the host driver auto-starts when they arrive → watch for the
;; end frame → close the room. One match per room, secrets never reused,
;; humans always come first (self-play never blocks joining a human invite —
;; the gates below only ever *decline to start* new self-play).
(ns __NAME__.fleet.matchmaker
(:require ["ardegazu-peer-kit" :refer (GAMES newRoomSecret)]))
(def ^:private TICK-MS 30000)
(defn- js-truthy-str? [s]
(and (some? s) (not (identical? s ""))))
(defn- post-inc!
"`cursor++` — returns the pre-increment value."
[a]
(let [v @a] (swap! a inc) v))
(defn- run-match [^js deps online active next-allowed-at game-cursor partner-cursor
last-human-invite ^js here]
(let [^js cfg (.-cfg deps)
^js social (.-social deps)
log (.-log deps)]
(swap! active inc)
(reset! next-allowed-at (+ (js/Date.now) (.-cooldownMs cfg) (* (js/Math.random) (.-jitterMs cfg))))
(let [games (.-games cfg)
game (aget games (js-mod (post-inc! game-cursor) (.-length games)))
partners #js []
_ (dotimes [_ (js/Math.min (.-partners cfg) (.-length here))]
(.push partners (aget here (js-mod (post-inc! partner-cursor) (.-length here)))))
host (.-host ^js (unchecked-get GAMES game))
secret (newRoomSecret)
started-at (js/Date.now)
sp (atom nil)]
(-> (js/Promise.resolve nil)
(.then (fn [_] ((.-hostRoom deps) game secret (inc (.-length partners)))))
(.then (fn [^js sp*]
(reset! sp sp*)
;; the empty-room claim lands on the solo-roster grace (~4s
;; after join); invite only once we actually host, or a fast
;; partner could arrive first and leave the room hostless
(js/Promise.
(fn [resolve reject]
(let [^js room (.-room sp*)]
(if (.-hosting room)
(resolve nil)
(let [t (js/setTimeout (fn [] (reject (js/Error. "never-claimed"))) 15000)]
(.on room "host"
(fn [h]
(when (identical? h (.-myId room))
(js/clearTimeout t)
(resolve nil)))))))))))
(.then (fn [_]
(.sendInvite (.-agent social)
(.map partners (fn [^js p] (.-pub p)))
host (str "https://" host "/#" secret) "self-play")))
(.then (fn [n]
(log (str "selfplay " game ": invited "
(.join (.map partners (fn [^js p] (.-name p))) ", ")
" (" n " delivered)"))
;; pull in the people too: a few online human friends,
;; gently rationed, get a "come play" for the same room
(when (> (.-inviteHumans cfg) 0)
(let [now (js/Date.now)
eligible (.filter ^js ((.-onlineHumanFriends deps))
(fn [^js f]
(> (- now (let [v (.get last-human-invite (.-pub f))]
(if (some? v) v 0)))
(.-humanInviteCooldownMs cfg))))]
(loop [i (dec (.-length eligible))]
(when (> i 0)
(let [j (js/Math.floor (* (js/Math.random) (inc i)))
tmp (aget eligible i)]
(aset eligible i (aget eligible j))
(aset eligible j tmp))
(recur (dec i))))
(let [picked (.slice eligible 0 (.-inviteHumans cfg))]
(when (pos? (.-length picked))
(.forEach picked (fn [^js f] (.set last-human-invite (.-pub f) now)))
(-> (.sendInvite (.-agent social)
(.map picked (fn [^js f] (.-pub f)))
host (str "https://" host "/#" secret)
"the bots are starting a game — come play")
(.then (fn [hn]
(log (str "selfplay " game ": also invited "
(.join (.map picked (fn [^js f] (.-name f))) ", ")
" (" hn " delivered)")))))))))))
(.then (fn [_]
(js/Promise.
(fn [resolve reject]
(let [^js room (.-room ^js @sp)
invite-timer (js/setTimeout
(fn []
(when (zero? (.-length (.openPeers (.-net (.-peer room)))))
(reject (js/Error. "invite-timeout"))))
(.-inviteTimeoutMs cfg))
match-timer (js/setTimeout
(fn [] (reject (js/Error. "match-timeout")))
(.-matchTimeoutMs cfg))]
(.on room "matchEnd"
(fn [w]
(js/clearTimeout invite-timer)
(js/clearTimeout match-timer)
(resolve w))))))))
;; linger for the receipt co-sign window before tearing the room down
(.then (fn [winner]
(js/Promise. (fn [resolve _] (js/setTimeout (fn [] (resolve winner)) 8000)))))
(.then (fn [winner]
(let [^js room (.-room ^js @sp)
who (if (js-truthy-str? winner)
(if (identical? winner (.-myId room))
"me"
(let [nm (some-> ^js (.identityOf (.-peer room) winner) .-name)]
(if (some? nm) nm (.slice ^js winner -8))))
"draw")]
(log (str "selfplay " game " done winner=" who
" dur=" (js/Math.round (/ (- (js/Date.now) started-at) 1000)) "s")))))
(.catch (fn [^js err] (log (str "selfplay " game " abort: " (.-message err)))))
(.then (fn [_]
(-> (if-some [^js sp* @sp]
(-> (.close sp*) (.catch (fn [_] nil)))
(js/Promise.resolve nil))
(.then (fn [_] (swap! active dec))))))))))
(defn start-matchmaker
"deps: #js {:cfg :social :myPub :siblings :hostRoom :roomStats
:onlineHumanFriends :log}. Returns a stop function."
[^js deps]
(let [^js cfg (.-cfg deps)
^js social (.-social deps)
^js siblings (.-siblings deps)
log (.-log deps)
arranger (aget (.sort (.concat #js [(.-myPub deps)]
(.map siblings (fn [^js s] (.-pub s)))))
0)]
(if-not (identical? arranger (.-myPub deps))
(let [^js sib (.find siblings (fn [^js s] (identical? (.-pub s) arranger)))
who (let [nm (some-> sib .-name)]
(if (some? nm) nm (.slice ^js arranger 0 8)))]
(log (str "matchmaker: " who " arranges — standing by"))
(fn [] nil))
(do
(log "matchmaker: arranging for the fleet")
(let [online (js/Map.)
active (atom 0)
next-allowed-at (atom 0) ; the fleet should always have a game running
game-cursor (atom 0)
partner-cursor (atom 0)
stopped (atom false)
last-human-invite (js/Map.)]
(.on social "presence"
(fn [pub info]
(when (.some siblings (fn [^js s] (identical? (.-pub s) pub)))
(.set online pub (some? info)))))
(let [tick (fn []
(when-not (or @stopped (not (.-enabled cfg)))
(when (< @active (.-maxConcurrentSelfPlay cfg))
;; "at least one game" beats the cooldown: pace only
;; extra concurrency
(when-not (and (> @active 0) (< (js/Date.now) @next-allowed-at))
(let [^js stats ((.-roomStats deps))]
(when (< (.-human stats) (.-maxHumanRoomsForSelfPlay cfg))
(let [here (.filter siblings (fn [^js s] (.get online (.-pub s))))]
(when (pos? (.-length here))
(run-match deps online active next-allowed-at game-cursor
partner-cursor last-human-invite here)
js/undefined))))))))
timer (js/setInterval tick TICK-MS)]
(when (some? (.-unref ^js timer)) (.unref ^js timer))
(fn []
(reset! stopped true)
(js/clearInterval timer))))))))
|