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 | ;; ported-from: src/games/host/seance-sim.ts @ v1.3.0
;; (itself: game4/client/src/game/game.ts host block L289–410 + config.ts
;; CANDLES/REVEAL_MS/walkMs + state.ts H @ 12d701e)
;; divergences from the browser: DOM/ui/audio calls removed; Math.random →
;; injected rng; Date.now → injected now(); the setTimeout chain → `next`
;; step descriptors the driver (or gym) executes; roster reads / win credit →
;; injected callbacks.
;;
;; RNG DISCIPLINE — the trainer replays episodes from a seed, so the rng is
;; consumed in EXACTLY this order and count per bell, and no more:
;; 1. pick-haunt (one draw, weighted by last bell's occupancy)
;; 2. hint-not (one draw, which of the two safe rooms the medium hears)
;; 3. medium seat (one draw over the alive seats)
;; Pinned by test/vectors/seance-sim.json.
(ns ardegazu.peer.games.host.seance-sim
(:require [ardegazu.peer.games.host.sim :as sim]
[shadow.cljs.modern :refer (defclass)]))
(def CANDLES 3)
(def REVEAL-MS 6500)
(defn walk-ms
"Walk-phase duration: 25s on the first bell, shrinking to a 12s floor."
[bell]
(js/Math.max 12000 (- 25000 (* (- bell 1) 1500))))
;; st → first bell delay, and ov → lo delay (browser constants).
(def START-BELL-MS 2600)
(def OVER-LOBBY-MS 6500)
;; Grace beyond the client countdown so last-second taps still land.
(def PICK-GRACE-MS 500)
(defclass SeanceSim
(constructor [this opts]
(let [rng (unchecked-get opts "rng")
now (unchecked-get opts "now")]
;; `opts.rng ?? Math.random`, `opts.now ?? Date.now`
(unchecked-set this "_rng" (if ^boolean (js* "~{} == null" rng) js/Math.random rng))
(unchecked-set this "_now" (if ^boolean (js* "~{} == null" now) js/Date.now now)))
(unchecked-set this "_roster" (unchecked-get opts "roster"))
(unchecked-set this "_addWin" (unchecked-get opts "addWin"))
(unchecked-set this "_seance" 0)
(unchecked-set this "_order" #js [])
(unchecked-set this "_candles" #js [])
;; Per-seat room a guest is assumed to stand in if they don't tap.
(unchecked-set this "_prevRooms" #js [])
(unchecked-set this "_picks" (js/Map.))
;; Alive-occupancy of each room after the last resolved bell (ghost bait).
(unchecked-set this "_lastOcc" #js [0 0 0])
(unchecked-set this "_bell" 0)
(unchecked-set this "_haunt" -1)
(unchecked-set this "_hintNot" -1)
(unchecked-set this "_mediumSeat" -1)
(unchecked-set this "_inWalk" false)
(unchecked-set this "_walkEndAt" 0)
(unchecked-set this "_running" false)))
;; string-named getters: the public read surface, rename-safe
(let [proto (.-prototype SeanceSim)
getter (fn [name slot]
(js/Object.defineProperty
proto name
#js {:get (fn [] (this-as self (unchecked-get self slot)))
:configurable true}))]
(getter "running" "_running")
(getter "bell" "_bell")
(getter "order" "_order")
(getter "candles" "_candles")
;; The medium's seat this bell and the room the ghost shuns (host-secret).
(getter "mediumSeat" "_mediumSeat")
(getter "hintNot" "_hintNot")
(getter "haunt" "_haunt")
(getter "inWalk" "_inWalk"))
(defn- alive-seats [self]
(let [candles (unchecked-get self "_candles")]
(.filter (.map candles (fn [c s] (if (> c 0) s -1))) (fn [s] (>= s 0)))))
(defn- pick-haunt
"The ghost is drawn to where the crowd stood last bell (plus caprice)."
[self]
(let [occ (unchecked-get self "_lastOcc")
w #js [(+ 1 (* 2 (aget occ 0))) (+ 1 (* 2 (aget occ 1))) (+ 1 (* 2 (aget occ 2)))]
total (+ (aget w 0) (aget w 1) (aget w 2))]
(loop [r 0 x (* ((unchecked-get self "_rng")) total)]
(if (< r 3)
(let [x' (- x (aget w r))]
(if (< x' 0) r (recur (inc r) x')))
2))))
(defn start
"Start a séance with a fixed seat order (ports hostStartSeance)."
[self seance-no order]
(if (zero? (.-length ^js order))
sim/NO-EMIT
(do
(unchecked-set self "_seance" seance-no)
(unchecked-set self "_order" (.slice ^js order))
(unchecked-set self "_candles" (.map ^js order (fn [_] CANDLES)))
(unchecked-set self "_prevRooms" (.map ^js order (fn [_ i] (mod i 3))))
(unchecked-set self "_lastOcc" #js [0 0 0])
(unchecked-set self "_bell" 0)
(.clear ^js (unchecked-get self "_picks"))
(unchecked-set self "_running" true)
(js-obj "frames"
#js [(sim/frame (js-obj "t" "st" "seance" seance-no
"order" (.slice ^js order) "candles" CANDLES))]
"next" (sim/next-step "bell" START-BELL-MS)))))
(defn- start-bell [self]
(if-not (unchecked-get self "_running")
sim/NO-EMIT
(do
(unchecked-set self "_bell" (inc (unchecked-get self "_bell")))
(.clear ^js (unchecked-get self "_picks"))
(unchecked-set self "_haunt" (pick-haunt self))
(let [haunt (unchecked-get self "_haunt")
others (.filter #js [0 1 2] (fn [r] (not (identical? r haunt))))]
(unchecked-set self "_hintNot"
(aget others (js/Math.floor (* ((unchecked-get self "_rng")) 2))))
(let [alive (alive-seats self)
picked (aget alive (js/Math.floor (* ((unchecked-get self "_rng"))
(.-length alive))))]
;; `alive[…] ?? -1` — an empty table yields undefined
(unchecked-set self "_mediumSeat" (if ^boolean (js* "~{} == null" picked) -1 picked)))
(let [dur (walk-ms (unchecked-get self "_bell"))
medium (unchecked-get self "_mediumSeat")]
(unchecked-set self "_inWalk" true)
(unchecked-set self "_walkEndAt" (+ ((unchecked-get self "_now")) dur))
(let [fs #js [(sim/frame (js-obj "t" "be" "bell" (unchecked-get self "_bell")
"durMs" dur "medium" medium))]]
(when (>= medium 0)
(.push fs (sim/frame (aget (unchecked-get self "_order") medium)
(js-obj "t" "hm" "not" (unchecked-get self "_hintNot")))))
(js-obj "frames" fs
"next" (sim/next-step "resolve" (+ dur PICK-GRACE-MS)))))))))
(defn- resolve-bell [self]
(if-not (unchecked-get self "_running")
sim/NO-EMIT
(do
(unchecked-set self "_inWalk" false)
(let [order (unchecked-get self "_order")
candles (unchecked-get self "_candles")
picks (unchecked-get self "_picks")
prev (unchecked-get self "_prevRooms")
haunt (unchecked-get self "_haunt")
;; no tap = you stayed where you were (seat%3 on the first bell)
rooms (.map ^js order
(fn [_ s]
(if (> (aget candles s) 0)
(let [p (.get ^js picks s)]
(if (identical? p js/undefined) (aget prev s) p))
-1)))
occ #js [0 0 0]]
(.forEach rooms (fn [r] (when (>= r 0) (aset occ r (inc (aget occ r))))))
(dotimes [s (.-length rooms)]
(when (and (identical? (aget rooms s) haunt) (> (aget candles s) 0))
(aset candles s (dec (aget candles s)))))
(unchecked-set self "_lastOcc" occ)
(unchecked-set self "_prevRooms"
(.map rooms (fn [r s] (if (>= r 0) r (aget prev s)))))
(let [fs #js [(sim/frame (js-obj "t" "rv" "bell" (unchecked-get self "_bell")
"haunt" haunt "rooms" rooms
"candles" (.slice candles)))]
alive (.-length (alive-seats self))
over (if (> (.-length ^js order) 1) (<= alive 1) (identical? alive 0))]
(js-obj "frames" fs
"next" (sim/next-step (if over "end" "bell") REVEAL-MS)))))))
(defn- end-seance [self]
(if-not (unchecked-get self "_running")
sim/NO-EMIT
(let [alive (alive-seats self)
order (unchecked-get self "_order")
winner (if (and (identical? 1 (.-length alive)) (> (.-length ^js order) 1))
(aget order (aget alive 0))
nil)
bells (unchecked-get self "_bell")]
(when (some? winner) ((unchecked-get self "_addWin") winner))
(unchecked-set self "_running" false)
(unchecked-set self "_inWalk" false)
(let [emit (js-obj "frames"
#js [(sim/frame (js-obj "t" "ov" "w" winner "bells" bells
"ps" ((unchecked-get self "_roster"))))]
"next" (sim/next-step "lobby" OVER-LOBBY-MS))]
(when (some? winner)
(unchecked-set emit "matchEnd"
(.map ^js ((unchecked-get self "_roster"))
(fn [e] #js [(aget e 0) (aget e 1) (aget e 3)]))))
emit))))
(defn step
"Execute a scheduled step (\"bell\" | \"resolve\" | \"end\" | \"lobby\")."
[self s]
(case s
"bell" (start-bell self)
"resolve" (resolve-bell self)
"end" (end-seance self)
"lobby" (js-obj "frames" #js [(sim/frame (js-obj "t" "lo"))] "next" nil)
sim/NO-EMIT))
(defn pick
"A guest picked a room this bell (ports hostPick)."
[self id r]
(if-not (and (unchecked-get self "_running") (unchecked-get self "_inWalk"))
sim/NO-EMIT
(if (or (< r 0) (> r 2))
sim/NO-EMIT
(let [s (.indexOf ^js (unchecked-get self "_order") id)]
(if (or (< s 0) (<= (aget (unchecked-get self "_candles") s) 0))
sim/NO-EMIT
(let [picks (unchecked-get self "_picks")]
(.set ^js picks s (bit-or r 0))
(js-obj "frames" #js [(sim/frame (js-obj "t" "pc" "n" (.-size ^js picks)))])))))))
(defn peer-gone
"A guest is gone for good: their candle goes out with them; mid-walk
departures can leave ≤1 candle standing — end without waiting (ports the
onPeerGone candle-out + hostCheckAbandon path)."
[self id]
(if-not (unchecked-get self "_running")
sim/NO-EMIT
(let [s (.indexOf ^js (unchecked-get self "_order") id)
candles (unchecked-get self "_candles")]
(if (or (< s 0) (<= (aget candles s) 0))
sim/NO-EMIT
(do
(aset candles s 0)
(if (and (> (.-length ^js (unchecked-get self "_order")) 1)
(<= (.-length (alive-seats self)) 1))
(end-seance self)
sim/NO-EMIT))))))
(defn snapshot
"Late-joiner snapshot (ports sendSnap; remainMs uses the injected clock).
Nine keys in the `sn` frame — past the eight-pair js-obj limit, so built
with sequential sets (dev/docs/CLJS.md: at nine pairs js-obj emits in HASH
order). Pinned byte-exact by test/vectors/seance-sim.json."
[self to]
(let [in-walk (unchecked-get self "_inWalk")
f (js-obj)]
(unchecked-set f "t" "sn")
(unchecked-set f "seance" (unchecked-get self "_seance"))
(unchecked-set f "bell" (unchecked-get self "_bell"))
(unchecked-set f "walk" (if in-walk 1 0))
(unchecked-set f "remainMs" (if in-walk
(js/Math.max 0 (- (unchecked-get self "_walkEndAt")
((unchecked-get self "_now"))))
0))
(unchecked-set f "medium" (unchecked-get self "_mediumSeat"))
(unchecked-set f "order" (.slice ^js (unchecked-get self "_order")))
(unchecked-set f "candles" (.slice ^js (unchecked-get self "_candles")))
(unchecked-set f "rooms" (.slice ^js (unchecked-get self "_prevRooms")))
(unchecked-set f "haunt" (if in-walk -1 (unchecked-get self "_haunt")))
(js-obj "frames" #js [(sim/frame to f)])))
(defn abort
"Void the séance (host conceding or closing — ports clearHostTimers)."
[self]
(unchecked-set self "_running" false)
(unchecked-set self "_inWalk" false)
js/undefined)
(let [proto (.-prototype SeanceSim)]
(unchecked-set proto "start" (fn [n order] (this-as self (start self n order))))
(unchecked-set proto "step" (fn [s] (this-as self (step self s))))
(unchecked-set proto "pick" (fn [id r] (this-as self (pick self id r))))
(unchecked-set proto "peerGone" (fn [id] (this-as self (peer-gone self id))))
(unchecked-set proto "snapshot" (fn [to] (this-as self (snapshot self to))))
(unchecked-set proto "abort" (fn [] (this-as self (abort self)))))
|