connect4_test.gno
14.78 Kb · 434 lines
1package connect4_v2
2
3import (
4 "chain"
5 "chain/banker"
6 "strconv"
7 "testing"
8
9 "gno.land/p/nt/avl/v0"
10 "gno.land/p/nt/testutils/v0"
11 "gno.land/p/nt/uassert/v0"
12)
13
14const stake = int64(10_000_000)
15
16var (
17 alice = testutils.TestAddress("alice")
18 bob = testutils.TestAddress("bob")
19 carol = testutils.TestAddress("carol")
20 admin = address("g1cvr48r7l7lkmvp77cr6zg2zhu26jgfwr0y8pew")
21 pkgAddr = chain.PackageAddress("gno.land/r/samcrew/connect4")
22)
23
24func reset() {
25 games, active, stats = avl.Tree{}, avl.Tree{}, avl.Tree{}
26 nextID, fee, feesCollected = 0, 100_000, 0
27}
28
29// pay sets the ugnot sent with the next call and credits the realm with it,
30// mirroring what the chain does. testing.SetRealm must be called in the same
31// frame that makes the cross call, so tests call it inline rather than here.
32func pay(amount int64) {
33 if amount == 0 {
34 testing.SetOriginSend(nil)
35 return
36 }
37 coins := chain.Coins{{"ugnot", amount}}
38 testing.SetOriginSend(coins)
39 testing.IssueCoins(pkgAddr, coins)
40}
41
42func balance(addr address) int64 {
43 return banker.NewReadonlyBanker().GetCoin(addr, "ugnot")
44}
45
46// phrase returns a passphrase unique to the next game id.
47func phrase() string { return "four random words " + strconv.Itoa(nextID+1) }
48
49// commit returns the commitment for phrase().
50func commit() string { return sha256Hex(phrase()) }
51
52// accepted returns a game between alice (creator) and bob awaiting Reveal,
53// and its passphrase.
54func accepted(cur realm) (*Game, string) {
55 p := phrase()
56 testing.SetRealm(testing.NewUserRealm(alice))
57 pay(stake)
58 id := Offer(cross(cur), "", 10, sha256Hex(p))
59 testing.SetRealm(testing.NewUserRealm(bob))
60 pay(stake)
61 Accept(cross(cur), id)
62 return mustGet(id), p
63}
64
65// started returns a revealed Playing game between alice (creator) and bob.
66func started(cur realm) *Game {
67 g, p := accepted(cur)
68 testing.SetRealm(testing.NewUserRealm(alice))
69 pay(0)
70 Reveal(cross(cur), g.ID, p)
71 return g
72}
73
74// move plays column for whoever is on turn.
75func move(cur realm, g *Game, column int) {
76 testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
77 pay(0)
78 Play(cross(cur), g.ID, column)
79}
80
81// expireClock advances block time past the 90s move clock (SkipHeights is 5s/block).
82func expireClock() { testing.SkipHeights(19) }
83
84func TestWinPaysPotMinusFee(cur realm, t *testing.T) {
85 reset()
86 g := started(cur)
87 first := g.player(g.Turn)
88 before := balance(first)
89 for _, c := range []int{1, 2, 1, 2, 1, 2, 1} { // first mover stacks column 1
90 move(cur, g, c)
91 }
92 uassert.Equal(t, "won", g.Status.String())
93 uassert.Equal(t, first, g.Winner)
94 uassert.Equal(t, 2*stake-100_000, balance(first)-before)
95 uassert.Equal(t, int64(100_000), feesCollected)
96 uassert.Equal(t, 1, statsOf(first).Wins)
97 uassert.Equal(t, 2*stake-100_000, statsOf(first).GnotWon)
98 uassert.Equal(t, 1, statsOf(g.player(3-g.Turn)).Games)
99 uassert.Equal(t, 0, active.Size())
100}
101
102func TestOfferRejects(cur realm, t *testing.T) {
103 reset()
104 testing.SetRealm(testing.NewUserRealm(alice))
105 pay(minStake - 1)
106 uassert.AbortsContains(t, cur, "stake below minimum", func() { Offer(cross(cur), "", 10, commit()) })
107 testing.SetRealm(testing.NewUserRealm(alice))
108 pay(stake)
109 uassert.AbortsContains(t, cur, "validFor", func() { Offer(cross(cur), "", 0, commit()) })
110 testing.SetRealm(testing.NewUserRealm(alice))
111 pay(stake)
112 uassert.AbortsContains(t, cur, "validFor", func() { Offer(cross(cur), "", 61, commit()) })
113 testing.SetRealm(testing.NewUserRealm(alice))
114 pay(stake)
115 uassert.AbortsContains(t, cur, "yourself", func() { Offer(cross(cur), alice, 10, commit()) })
116 testing.SetRealm(testing.NewUserRealm(alice))
117 pay(0)
118 uassert.AbortsContains(t, cur, "send exactly one", func() { Offer(cross(cur), "", 10, commit()) })
119 testing.SetRealm(testing.NewUserRealm(alice))
120 testing.SetOriginSend(chain.Coins{{"ugnot", stake}, {"foo", 1}})
121 uassert.AbortsContains(t, cur, "send exactly one", func() { Offer(cross(cur), "", 10, commit()) })
122}
123
124func TestAcceptRejects(cur realm, t *testing.T) {
125 reset()
126 testing.SetRealm(testing.NewUserRealm(alice))
127 pay(stake)
128 id := Offer(cross(cur), bob, 1, commit())
129 testing.SetRealm(testing.NewUserRealm(alice))
130 pay(stake)
131 uassert.AbortsContains(t, cur, "own offer", func() { Accept(cross(cur), id) })
132 testing.SetRealm(testing.NewUserRealm(carol))
133 pay(stake)
134 uassert.AbortsContains(t, cur, "reserved", func() { Accept(cross(cur), id) })
135 testing.SetRealm(testing.NewUserRealm(bob))
136 pay(stake - 1)
137 uassert.AbortsContains(t, cur, "must send exactly", func() { Accept(cross(cur), id) })
138 testing.SetRealm(testing.NewUserRealm(bob))
139 pay(stake + 1)
140 uassert.AbortsContains(t, cur, "must send exactly", func() { Accept(cross(cur), id) })
141 testing.SkipHeights(13) // 65s > 1 minute
142 testing.SetRealm(testing.NewUserRealm(bob))
143 pay(stake)
144 uassert.AbortsContains(t, cur, "expired", func() { Accept(cross(cur), id) })
145 testing.SetRealm(testing.NewUserRealm(bob))
146 pay(stake)
147 uassert.AbortsContains(t, cur, "not found", func() { Accept(cross(cur), 99) })
148}
149
150func TestPlayRejects(cur realm, t *testing.T) {
151 reset()
152 g := started(cur)
153 testing.SetRealm(testing.NewUserRealm(g.player(3 - g.Turn)))
154 pay(0)
155 uassert.AbortsContains(t, cur, "not your turn", func() { Play(cross(cur), g.ID, 1) })
156 testing.SetRealm(testing.NewUserRealm(carol))
157 pay(0)
158 uassert.AbortsContains(t, cur, "not your turn", func() { Play(cross(cur), g.ID, 1) })
159 testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
160 pay(0)
161 uassert.AbortsContains(t, cur, "column must be", func() { Play(cross(cur), g.ID, 0) })
162 testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
163 pay(0)
164 uassert.AbortsContains(t, cur, "column must be", func() { Play(cross(cur), g.ID, 8) })
165 expireClock()
166 testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
167 pay(0)
168 uassert.AbortsContains(t, cur, "clock ran out", func() { Play(cross(cur), g.ID, 1) })
169}
170
171func TestFullColumnRejected(cur realm, t *testing.T) {
172 reset()
173 g := started(cur)
174 for i := 0; i < rows; i++ {
175 move(cur, g, 4) // alternating players never make 4 in a column
176 }
177 testing.SetRealm(testing.NewUserRealm(g.player(g.Turn)))
178 pay(0)
179 uassert.AbortsContains(t, cur, "column is full", func() { Play(cross(cur), g.ID, 4) })
180}
181
182// The creator revealed, then the first mover never moved.
183func TestTimeoutBeforeFirstMoveIsVoid(cur realm, t *testing.T) {
184 reset()
185 g := started(cur)
186 testing.SetRealm(testing.NewUserRealm(carol))
187 pay(0)
188 uassert.AbortsContains(t, cur, "has not run out", func() { ClaimTimeout(cross(cur), g.ID) })
189 a, b := balance(alice), balance(bob)
190 expireClock()
191 testing.SetRealm(testing.NewUserRealm(carol))
192 pay(0) // anyone may settle
193 ClaimTimeout(cross(cur), g.ID)
194 uassert.Equal(t, "void", g.Status.String())
195 uassert.Equal(t, stake, balance(alice)-a)
196 uassert.Equal(t, stake, balance(bob)-b)
197 uassert.Equal(t, int64(0), feesCollected)
198 uassert.Equal(t, 0, stats.Size())
199}
200
201func TestTimeoutAfterMoveForfeits(cur realm, t *testing.T) {
202 reset()
203 g := started(cur)
204 mover := g.player(g.Turn)
205 loser := g.player(3 - g.Turn)
206 move(cur, g, 1)
207 expireClock()
208 before := balance(mover)
209 testing.SetRealm(testing.NewUserRealm(mover))
210 pay(0)
211 ClaimTimeout(cross(cur), g.ID)
212 uassert.Equal(t, "won", g.Status.String())
213 uassert.Equal(t, mover, g.Winner)
214 uassert.Equal(t, 2*stake-100_000, balance(mover)-before)
215 uassert.Equal(t, int64(100_000), feesCollected)
216 uassert.Equal(t, 1, statsOf(loser).Games)
217}
218
219func TestResign(cur realm, t *testing.T) {
220 reset()
221 g := started(cur)
222 testing.SetRealm(testing.NewUserRealm(carol))
223 pay(0)
224 uassert.AbortsContains(t, cur, "not a player", func() { Resign(cross(cur), g.ID) })
225 before := balance(bob)
226 testing.SetRealm(testing.NewUserRealm(alice))
227 pay(0)
228 Resign(cross(cur), g.ID)
229 uassert.Equal(t, "won", g.Status.String())
230 uassert.Equal(t, bob, g.Winner)
231 uassert.Equal(t, 2*stake-100_000, balance(bob)-before)
232 uassert.Equal(t, int64(100_000), feesCollected)
233 uassert.Equal(t, 1, statsOf(alice).Games)
234}
235
236func TestCancel(cur realm, t *testing.T) {
237 reset()
238 testing.SetRealm(testing.NewUserRealm(alice))
239 pay(stake)
240 id := Offer(cross(cur), "", 1, commit())
241 testing.SetRealm(testing.NewUserRealm(bob))
242 pay(0)
243 uassert.AbortsContains(t, cur, "only the creator", func() { Cancel(cross(cur), id) })
244 before := balance(alice)
245 testing.SetRealm(testing.NewUserRealm(alice))
246 pay(0)
247 Cancel(cross(cur), id) // creator, before expiry
248 uassert.Equal(t, "cancelled", mustGet(id).Status.String())
249 uassert.Equal(t, stake, balance(alice)-before)
250
251 // Once expired, anyone may cancel; the refund still goes to the creator.
252 testing.SetRealm(testing.NewUserRealm(alice))
253 pay(stake)
254 id = Offer(cross(cur), "", 1, commit())
255 testing.SkipHeights(13) // 65s > 1 minute
256 before = balance(alice)
257 testing.SetRealm(testing.NewUserRealm(carol))
258 pay(0)
259 Cancel(cross(cur), id)
260 uassert.Equal(t, "cancelled", mustGet(id).Status.String())
261 uassert.Equal(t, stake, balance(alice)-before)
262
263 g := started(cur)
264 testing.SetRealm(testing.NewUserRealm(alice))
265 pay(0)
266 uassert.AbortsContains(t, cur, "not open", func() { Cancel(cross(cur), g.ID) })
267}
268
269func TestDrawRefundsBoth(cur realm, t *testing.T) {
270 reset()
271 g := started(cur)
272 // Fill every cell but the top of column 7 with a pattern that has no
273 // four in a row; player 1 owns the last cell.
274 for c := 0; c < cols; c++ {
275 for r := 0; r < rows; r++ {
276 g.Board[c][r] = byte(1 + (c/2+r)%2)
277 }
278 }
279 g.Board[6][5] = 0
280 g.Moves, g.Turn = cols*rows-1, 1
281 a, b := balance(alice), balance(bob)
282 move(cur, g, 7)
283 uassert.Equal(t, "draw", g.Status.String())
284 uassert.Equal(t, stake, balance(alice)-a)
285 uassert.Equal(t, stake, balance(bob)-b)
286 uassert.Equal(t, 1, statsOf(alice).Draws)
287}
288
289func TestFeeSnapshotAndWithdraw(cur realm, t *testing.T) {
290 reset()
291 testing.SetRealm(testing.NewUserRealm(carol))
292 pay(0)
293 uassert.AbortsContains(t, cur, "not owner", func() { SetFee(cross(cur), 0) })
294 testing.SetRealm(testing.NewUserRealm(carol))
295 pay(0)
296 uassert.AbortsContains(t, cur, "not owner", func() { WithdrawFees(cross(cur), carol) })
297 g := started(cur) // offered at the default 0.1 GNOT fee
298 testing.SetRealm(testing.NewUserRealm(admin))
299 pay(0)
300 uassert.AbortsContains(t, cur, "negative", func() { SetFee(cross(cur), -1) })
301 SetFee(cross(cur), 5_000_000)
302 testing.SetRealm(testing.NewUserRealm(alice))
303 pay(0)
304 Resign(cross(cur), g.ID)
305 uassert.Equal(t, int64(100_000), feesCollected)
306
307 before := balance(carol)
308 testing.SetRealm(testing.NewUserRealm(admin))
309 pay(0)
310 WithdrawFees(cross(cur), carol)
311 uassert.Equal(t, int64(100_000), balance(carol)-before)
312 uassert.Equal(t, int64(0), feesCollected)
313
314 // A raised fee applies to new offers, which must stake more than it.
315 testing.SetRealm(testing.NewUserRealm(alice))
316 pay(5_000_000)
317 uassert.AbortsContains(t, cur, "exceed the house fee", func() { Offer(cross(cur), "", 10, commit()) })
318}
319
320func TestFinishedGameRejects(cur realm, t *testing.T) {
321 reset()
322 g := started(cur)
323 testing.SetRealm(testing.NewUserRealm(alice))
324 pay(0)
325 Resign(cross(cur), g.ID)
326 testing.SetRealm(testing.NewUserRealm(bob))
327 pay(0)
328 uassert.AbortsContains(t, cur, "not in progress", func() { Play(cross(cur), g.ID, 1) })
329 uassert.AbortsContains(t, cur, "not in progress", func() { Resign(cross(cur), g.ID) })
330 expireClock()
331 testing.SetRealm(testing.NewUserRealm(bob))
332 pay(0)
333 uassert.AbortsContains(t, cur, "not in progress", func() { ClaimTimeout(cross(cur), g.ID) })
334 testing.SetRealm(testing.NewUserRealm(carol))
335 pay(stake)
336 uassert.AbortsContains(t, cur, "not open", func() { Accept(cross(cur), g.ID) })
337 testing.SetRealm(testing.NewUserRealm(carol))
338 pay(stake)
339 uassert.AbortsContains(t, cur, "invalid opponent", func() { Offer(cross(cur), "notanaddress", 10, commit()) })
340}
341
342func TestRevealPhase(cur realm, t *testing.T) {
343 reset()
344 g, p := accepted(cur)
345 uassert.Equal(t, byte(0), g.Turn)
346 for _, who := range []address{alice, bob} {
347 testing.SetRealm(testing.NewUserRealm(who))
348 pay(0)
349 uassert.AbortsContains(t, cur, "waiting for the creator", func() { Play(cross(cur), g.ID, 1) })
350 }
351 testing.SetRealm(testing.NewUserRealm(bob))
352 pay(0)
353 uassert.AbortsContains(t, cur, "only the creator", func() { Reveal(cross(cur), g.ID, p) })
354 testing.SetRealm(testing.NewUserRealm(alice))
355 pay(0)
356 uassert.AbortsContains(t, cur, "does not match", func() { Reveal(cross(cur), g.ID, p+"x") })
357 testing.SetRealm(testing.NewUserRealm(alice))
358 pay(0)
359 Reveal(cross(cur), g.ID, p)
360 uassert.True(t, g.Turn == 1 || g.Turn == 2)
361 testing.SetRealm(testing.NewUserRealm(alice))
362 pay(0)
363 uassert.AbortsContains(t, cur, "not awaiting a reveal", func() { Reveal(cross(cur), g.ID, p) })
364 move(cur, g, 1) // the picked first mover can play
365 uassert.Equal(t, 1, g.Moves)
366}
367
368func TestLateRevealForfeits(cur realm, t *testing.T) {
369 reset()
370 g, p := accepted(cur)
371 expireClock()
372 testing.SetRealm(testing.NewUserRealm(alice))
373 pay(0)
374 uassert.AbortsContains(t, cur, "reveal clock ran out", func() { Reveal(cross(cur), g.ID, p) })
375 before := balance(bob)
376 testing.SetRealm(testing.NewUserRealm(carol))
377 pay(0)
378 ClaimTimeout(cross(cur), g.ID)
379 uassert.Equal(t, "won", g.Status.String())
380 uassert.Equal(t, bob, g.Winner)
381 uassert.Equal(t, 2*stake-100_000, balance(bob)-before)
382 uassert.Equal(t, int64(100_000), feesCollected)
383 uassert.Equal(t, 1, statsOf(bob).Wins)
384 uassert.Equal(t, 1, statsOf(alice).Games)
385}
386
387func TestCommitmentRejects(cur realm, t *testing.T) {
388 reset()
389 good := commit()
390 for _, bad := range []string{"", good[:63], good + "0", "G" + good[1:], "A" + good[1:]} {
391 testing.SetRealm(testing.NewUserRealm(alice))
392 pay(stake)
393 uassert.AbortsContains(t, cur, "64 lowercase hex", func() { Offer(cross(cur), "", 10, bad) })
394 }
395 testing.SetRealm(testing.NewUserRealm(alice))
396 pay(stake)
397 Offer(cross(cur), "", 10, good)
398 testing.SetRealm(testing.NewUserRealm(carol))
399 pay(stake)
400 Offer(cross(cur), "", 10, good) // the same commitment may be offered again
401}
402
403func TestPaymentsRequireUserCall(cur realm, t *testing.T) {
404 reset()
405 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/somerealm"))
406 pay(stake)
407 uassert.AbortsContains(t, cur, "only user-call", func() { Offer(cross(cur), "", 10, commit()) })
408 testing.SetRealm(testing.NewUserRealm(alice))
409 pay(stake)
410 id := Offer(cross(cur), "", 10, commit())
411 testing.SetRealm(testing.NewCodeRealm("gno.land/r/demo/somerealm"))
412 pay(stake)
413 uassert.AbortsContains(t, cur, "only user-call", func() { Accept(cross(cur), id) })
414}
415
416func TestNonPayableRejectsCoins(cur realm, t *testing.T) {
417 reset()
418 g, p := accepted(cur)
419 calls := map[string]func(){
420 "Reveal": func() { Reveal(cross(cur), g.ID, p) },
421 "Play": func() { Play(cross(cur), g.ID, 1) },
422 "ClaimTimeout": func() { ClaimTimeout(cross(cur), g.ID) },
423 "Resign": func() { Resign(cross(cur), g.ID) },
424 "Cancel": func() { Cancel(cross(cur), g.ID) },
425 "SetFee": func() { SetFee(cross(cur), 0) },
426 "WithdrawFees": func() { WithdrawFees(cross(cur), admin) },
427 "TransferOwnership": func() { TransferOwnership(cross(cur), carol) },
428 }
429 for name, call := range calls {
430 testing.SetRealm(testing.NewUserRealm(admin))
431 pay(1)
432 uassert.AbortsContains(t, cur, "does not accept coins", call, name)
433 }
434}