arena.gno
4.55 Kb · 160 lines
1// Package arena holds Gnomepit's online matches. A challenger posts a sealed deck and
2// round-1 plan; an acceptor answers face up; the challenger reveals each round with the
3// seal of the next, and the shared duel engine checks and resolves every move. A session
4// key is bound to the match of the first call it signs: it then acts in no other match and
5// posts or accepts nothing more. Calls the wallet signs are never bound.
6package arena
7
8import (
9 "encoding/base64"
10 "strconv"
11 "strings"
12 "time"
13
14 "gno.land/p/g188vafzutzvlfw6wt7u3s62xtuvm0epmu8ufmfz/gnomepit/engine"
15)
16
17const (
18 moveWindow = 24 * 60 * 60 // seconds a player has for each move
19 openWindow = 7 * 24 * 60 * 60 // seconds an open challenge stays on the board
20 maxBox = 104 // bytes
21 maxPage = 50
22 maxCleared = 8 // expired challenges one Challenge deletes at most
23 idDigits = 10
24)
25
26const (
27 phaseOpen = "open"
28 phaseReveal = "reveal"
29 phasePlay = "play"
30 phaseEnded = "ended"
31 phaseExpired = "expired" // reported for an open match past its expiry, never stored
32)
33
34var lastID int
35
36// OpenChallenge is an open challenge as the board lists it.
37type OpenChallenge struct {
38 ID, Challenger, Clan, Opponent string
39 Posted, Expires int64
40}
41
42// Challenge posts a challenge for clan with the seals of the challenger's deck and
43// round-1 plan, the box holding both, and an opponent who alone may accept, or none.
44// It returns the new match's id. It first deletes up to 8 expired challenges, whoever
45// posted them.
46func Challenge(cur realm, clan, deckSeal, planSeal, box string, opponent address) string {
47 rules := engine.ProofOfConcept()
48 c := clanNumber(rules, clan)
49 if c == 0 {
50 panic("unknown clan")
51 }
52 if !validSeal(deckSeal) || !validSeal(planSeal) {
53 panic("invalid seal")
54 }
55 raw := boxBytes(box)
56 if opponent != "" && !canonical(opponent) {
57 panic("invalid opponent")
58 }
59 now := time.Now().Unix()
60 for _, id := range openClear(now, maxCleared) {
61 if m, ok := loadMatch(rules, id); ok {
62 forget(id, m.challenger)
63 }
64 }
65
66 lastID++
67 m := record{
68 phase: phaseOpen,
69 deadline: now + openWindow,
70 challenger: cur.Previous().Address(),
71 opponent: opponent,
72 deckSeal: deckSeal,
73 seal: planSeal,
74 box: raw,
75 }
76 m.clans[0] = c
77 join(cur.Previous().IsUser(), m.challenger, lastID)
78 saveMatch(rules, lastID, m)
79 openAdd(lastID, m.deadline)
80 tellBoard(lastID, clan+" posted a challenge.")
81 return strconv.Itoa(lastID)
82}
83
84// Open lists open, unexpired challenges, newest first: limit of them after skipping
85// offset. limit is 1 to 50.
86func Open(offset, limit int) []OpenChallenge {
87 checkPage(offset, limit)
88 rules := engine.ProofOfConcept()
89 list := []OpenChallenge{}
90 for _, id := range openNewest(time.Now().Unix(), offset, limit) {
91 m, ok := loadMatch(rules, id)
92 if !ok {
93 continue
94 }
95 list = append(list, OpenChallenge{
96 ID: strconv.Itoa(id),
97 Challenger: m.challenger.String(),
98 Clan: clanName(rules, m.clans[0]),
99 Opponent: m.opponent.String(),
100 Posted: m.deadline - openWindow,
101 Expires: m.deadline,
102 })
103 }
104 return list
105}
106
107// forget deletes open challenge id and its place among its challenger's matches. Its
108// entry on the open list is the caller's to remove.
109func forget(id int, challenger address) {
110 dropMatch(id)
111 p := loadPlayer(challenger)
112 p.remove(id)
113 savePlayer(challenger, p)
114}
115
116func (m record) expired(now int64) bool {
117 return m.phase == phaseOpen && now > m.deadline
118}
119
120func checkPage(offset, limit int) {
121 if offset < 0 {
122 panic("offset must not be negative")
123 }
124 if limit < 1 || limit > maxPage {
125 panic("limit must be 1 to 50")
126 }
127}
128
129// boxBytes decodes a box argument: the standard base64 of 1 to maxBox bytes, in the one
130// form that encodes them, so Match returns every box exactly as it was sent.
131func boxBytes(text string) string {
132 if len(text) == 0 || len(text) > base64.StdEncoding.EncodedLen(maxBox) {
133 panic("invalid box")
134 }
135 raw, err := base64.StdEncoding.DecodeString(text)
136 if err != nil || len(raw) > maxBox || base64.StdEncoding.EncodeToString(raw) != text {
137 panic("invalid box")
138 }
139 return string(raw)
140}
141
142// canonical accepts a valid address in the lowercase form callers carry. bech32 also
143// allows uppercase, which would never equal a caller's address.
144func canonical(a address) bool {
145 return a.IsValid() && string(a) == strings.ToLower(string(a))
146}
147
148// validSeal accepts a sha256 sum in lowercase hex.
149func validSeal(s string) bool {
150 if len(s) != 64 {
151 return false
152 }
153 for i := 0; i < len(s); i++ {
154 c := s[i]
155 if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
156 return false
157 }
158 }
159 return true
160}