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