peer-kit / src / ardegazu / peer / games / odeon.cljs
  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
;; ported-from: src/games/odeon.ts @ v1.3.0
;;
;; odeon (game6) — the p2p concert hall. No rounds: the host owns the roster and
;; the transport clock ("tr" carries bpm + the host-time of bar 0), every peer
;; keeps an offset to the host's performance.now() via "pg"/"po" pings, and
;; notes ride "nt"/"nf" (instrument, midi note, velocity).
;;
;; The bot is a session musician: it locks to the transport and walks a bass
;; line on the beat. Seniority host election means it never fights an
;; established band's clock — and it never claims one (no hostship).
;;
;; Host election: odeon is the one game with no live-phase flag. Its "ro" ALWAYS
;; carries `age`, the rounded seconds of the sending host's incumbency
;; (frames.cljs mk-ro), and colliding claims are settled by SENIORITY — the
;; older host wins outside a ±AGE-SLACK-S near-tie band, inside which lowest id
;; decides (sim.cljs incumbent-wins?). So claimBeats below overrides the base
;; lowest-id rule, which only ever agreed with game6 inside that band, and the
;; two fields it needs (`_hostAge` / `_hostAgeAt`) track the incumbency of the
;; host we FOLLOW — game6's host-claim-age/host-claim-at pair. The bot and the
;; browsers must apply the SAME comparator or a mixed room partitions (see
;; room.cljs's header); a cold-booted browser whose solo backstop fires before
;; discovery completes is exactly the usurper this rule exists to reject.
(ns ardegazu.peer.games.odeon
  (:require [ardegazu.peer.games.room :as room]
            [shadow.cljs.modern :refer (defclass)]))

(def ^:private BEAT-PATTERN #js [0 0 7 0 3 0 7 5]) ; semitone offsets, 8 beats
(def ^:private ROOT 36) ; C2

;; game6 sim.cljs's AGE-SLACK-S — the near-tie band, in seconds.
(def ^:private AGE-SLACK-S 10)

(declare wire start-metronome play-beat)

(defn- incumbent-wins?
  "game6 sim.cljs's incumbent-wins?, verbatim: true = the incumbent I follow
   outranks the challenger's claim. The OLDER host wins; only near-ties (both
   young or both old, within AGE-SLACK-S) fall back to lowest peer id."
  [my-age their-age my-id their-id]
  (cond
    (> (- my-age their-age) AGE-SLACK-S) true
    (> (- their-age my-age) AGE-SLACK-S) false
    :else (< my-id their-id)))

(defn- note-host-claim!
  "game6's note-host-claim!: stamp the incumbency `age-seconds` we just saw on
   the wire, against the local clock reading that observed it."
  [self age-seconds]
  (unchecked-set self "_hostAge" age-seconds)
  (unchecked-set self "_hostAgeAt" (js/performance.now))
  js/undefined)

(defn- current-host-age
  "game6's current-host-age: the followed host's incumbency in seconds, aged
   forward by wall time since we observed it. No host ⇒ 0."
  [self]
  (if (nil? (unchecked-get self "hostId"))
    0
    (+ (unchecked-get self "_hostAge")
       (/ (- (js/performance.now) (unchecked-get self "_hostAgeAt")) 1000))))

(defn- wire-age
  "The `age` a \"ro\" carries, number-guarded exactly as game6's on-roster-frame
   guards it (`(if (number? raw-age) raw-age 0)`)."
  [m]
  (let [raw (unchecked-get m "age")]
    (if (number? raw) raw 0)))

(defclass OdeonClient
  (extends room/GameRoom)
  (constructor [this peer opts]
    (super peer (let [o (js/Object.assign (js-obj) opts)]
                  (unchecked-set o "hiExtra" (fn [] (js-obj "inst" (unchecked-get this "_inst"))))
                  o))
    (unchecked-set this "_inst" (let [i (unchecked-get opts "instrument")]
                                  (if ^boolean (js* "~{} == null" i) 3 i)))
    (unchecked-set this "_perform" (not (identical? false (unchecked-get opts "perform"))))
    (unchecked-set this "_bpm" 100)
    (unchecked-set this "_org" 0)     ; host-time of bar 0
    (unchecked-set this "_offset" 0)  ; hostNow ≈ now + offset
    (unchecked-set this "_bestRtt" js/Infinity)
    (unchecked-set this "_beat" -1)
    (unchecked-set this "_timers" #js [])
    (unchecked-set this "_pingTimer" nil)
    (unchecked-set this "_beatTimer" nil)
    ;; seniority bookkeeping for the host we follow — game6's two volatiles,
    ;; same initial values (unreadable until the first claim: current-host-age
    ;; is 0 while hostId is nil, and every host adoption notes an age)
    (unchecked-set this "_hostAge" 0)   ; seconds, as of _hostAgeAt
    (unchecked-set this "_hostAgeAt" 0) ; performance.now() when observed
    (wire this)))

(unchecked-set OdeonClient "joinOdeon"
               (fn [opts]
                 (let [o (js/Object.assign (js-obj) opts)]
                   (unchecked-set o "app" "odeon")
                   ((unchecked-get OdeonClient "join") o))))

(defn- host-now [self]
  (+ (js/performance.now) (unchecked-get self "_offset")))

(defn- play-beat [self beat]
  (let [step (aget BEAT-PATTERN (mod beat (.-length BEAT-PATTERN)))]
    (when-not (identical? step js/undefined)
      (let [note (+ ROOT step)
            peer (unchecked-get self "peer")
            inst (unchecked-get self "_inst")]
        (.broadcast ^js peer (js-obj "t" "nt" "i" inst "n" note "v" 0.7))
        (let [off (volatile! nil)]
          (vreset! off (js/setTimeout
                        (fn []
                          (.broadcast ^js peer (js-obj "t" "nf" "i" inst "n" note))
                          (unchecked-set self "_timers"
                                         (.filter ^js (unchecked-get self "_timers")
                                                  (fn [t] (not (identical? t @off))))))
                        (js/Math.min 300 (* (/ 60000 (unchecked-get self "_bpm")) 0.6))))
          (.push ^js (unchecked-get self "_timers") @off)))))
  js/undefined)

(defn- start-metronome [self]
  (when ^boolean (js* "!!(~{})" (unchecked-get self "_beatTimer"))
    (js/clearInterval (unchecked-get self "_beatTimer")))
  (when (unchecked-get self "_perform")
    ;; poll fast, fire on beat boundaries in HOST time — jitter under ~15 ms
    (unchecked-set
     self "_beatTimer"
     (js/setInterval
      (fn []
        (let [beat-ms (/ 60000 (unchecked-get self "_bpm"))
              pos (/ (- (host-now self) (unchecked-get self "_org")) beat-ms)
              beat (js/Math.floor pos)]
          (when (and (not (identical? beat (unchecked-get self "_beat"))) (>= beat 0))
            (unchecked-set self "_beat" beat)
            (play-beat self beat))))
      15)))
  js/undefined)

(defn- wire [self]
  (.on ^js self "host"
       (fn [h]
         (when ^boolean (js* "!!(~{})" (unchecked-get self "_pingTimer"))
           (js/clearInterval (unchecked-get self "_pingTimer")))
         (unchecked-set self "_pingTimer" nil)
         (unchecked-set self "_bestRtt" js/Infinity)
         ;; new host (or none — this fires with nil on peerGone): the incumbency
         ;; we were tracking is void. game6's become-host and its host-gone
         ;; election both do exactly this `note-host-claim! 0`; the accepted "ro"
         ;; that carries the new host's real age reaches the frame handler
         ;; BELOW after this listener returns (room.cljs emits "host" from
         ;; on-roster, "frame" only once on-roster has accepted), so an adoption
         ;; ends on the wire age, never on this zero.
         (note-host-claim! self 0)
         (when (and ^boolean (js* "!!(~{})" h)
                    (not (identical? h (unchecked-get (unchecked-get self "peer") "myId"))))
           (let [peer (unchecked-get self "peer")
                 ping (fn [] (.send ^js peer h (js-obj "t" "pg" "c" (js/performance.now))))]
             (ping)
             (unchecked-set self "_pingTimer" (js/setInterval ping 4000))))
         js/undefined))
  (.on ^js self "frame"
       (fn [from m]
         (when (identical? from (unchecked-get self "hostId"))
           (let [t (unchecked-get m "t")
                 nn (fn [v d] (if ^boolean (js* "~{} == null" v) d v))]
             (cond
               ;; The roster of the host we follow: note how long it says it has
               ;; been conducting. room.cljs re-emits an ACCEPTED "ro" as this
               ;; event, and the `from` = hostId filter above is game6's
               ;; note-host-claim! guard exactly — game6 notes only when `from`
               ;; is the just-applied host and is not itself, and the bot never
               ;; hosts odeon (no host driver exists), so both halves hold.
               (identical? t "ro")
               (note-host-claim! self (wire-age m))

               (identical? t "tr")
               (do (unchecked-set self "_bpm" (nn (unchecked-get m "bpm")
                                                  (unchecked-get self "_bpm")))
                   (unchecked-set self "_org" (nn (unchecked-get m "org")
                                                  (unchecked-get self "_org")))
                   (start-metronome self))

               (identical? t "po")
               (let [now (js/performance.now)
                     rtt (- now (nn (unchecked-get m "c") now))]
                 (when (< rtt (unchecked-get self "_bestRtt"))
                   (unchecked-set self "_bestRtt" rtt)
                   (unchecked-set self "_offset"
                                  (- (+ (nn (unchecked-get m "h") 0) (/ rtt 2)) now))))

               :else nil)))
         js/undefined))
  js/undefined)

(let [proto (.-prototype OdeonClient)]
  ;; odeon keeps no scores — a cooperative jam is never on the leaderboard
  (unchecked-set proto "score" (fn [] nil))

  ;; Seniority, ties to lowest id — game6's bystander rule (sim.cljs ro-action's
  ;; third branch, `(not (incumbent-wins? my-age their-age host-id from))`).
  ;; Unlike the siblings' live-beats-idle comparators this one ADVANCES WITH
  ;; WALL TIME: the incumbent's age grows between rosters, so the same pair of
  ;; claims can flip once the followed host crosses the slack band. room.cljs
  ;; supplies ro-action's `host-in-room?` escape ahead of this call, so the only
  ;; question left here is the comparator. The `my-age` argument ranks the
  ;; INCUMBENT I follow, not me — hence hostId, not myId, as its id.
  (unchecked-set
   proto "claimBeats"
   (fn [from m]
     (this-as self
       (let [h (unchecked-get self "hostId")]
         (if (nil? h)
           true
           (not (incumbent-wins? (current-host-age self) (wire-age m) h from)))))))

  (unchecked-set
   proto "close"
   (fn []
     (this-as self
       (.forEach ^js (unchecked-get self "_timers") (fn [t] (js/clearTimeout t)))
       (when ^boolean (js* "!!(~{})" (unchecked-get self "_pingTimer"))
         (js/clearInterval (unchecked-get self "_pingTimer")))
       (when ^boolean (js* "!!(~{})" (unchecked-get self "_beatTimer"))
         (js/clearInterval (unchecked-get self "_beatTimer")))
       ;; super.close()
       (.call (unchecked-get (.-prototype room/GameRoom) "close") self)))))

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/peer-kit.git