play.gno
9.21 Kb · 358 lines
1package arena
2
3import (
4 "crypto/sha256"
5 "encoding/hex"
6 "strconv"
7 "strings"
8 "time"
9
10 "gno.land/p/g188vafzutzvlfw6wt7u3s62xtuvm0epmu8ufmfz/gnomepit/engine"
11)
12
13// Accept answers open challenge id as clan, with deck and the round-1 plan in plain
14// text. When the challenge names an opponent, only that address may accept.
15func Accept(cur realm, id, clan, deck, plan string) {
16 rules := engine.ProofOfConcept()
17 n, m := find(rules, id)
18 now := time.Now().Unix()
19 if m.phase != phaseOpen {
20 panic("wrong phase")
21 }
22 if m.expired(now) {
23 panic("challenge expired")
24 }
25 caller := cur.Previous().Address()
26 if m.opponent != "" && caller != m.opponent {
27 panic("not the named opponent")
28 }
29 c := clanNumber(rules, clan)
30 if c == 0 {
31 panic("unknown clan")
32 }
33 bits := deckBits(rules, rules.Clans[c-1], deck)
34 b := planOf(rules, plan)
35 if !legal(rules, stateOf(rules, m), engine.SideB, bits, b) {
36 panic("illegal plan")
37 }
38
39 m.acceptor = caller
40 m.opponent = ""
41 join(cur.Previous().IsUser(), caller, n)
42 m.clans[1] = c
43 m.decks[1] = bits
44 m.plans[1] = withPlan(m.plans[1], 1, b)
45 m.phase = phaseReveal
46 m.deadline = now + moveWindow
47 saveMatch(rules, n, m)
48 openRemove(n)
49 tellBoard(n, "Challenge "+strconv.Itoa(n)+" was taken.")
50 tell("Turn", m.challenger, caller, n, clan+" accepted your challenge. Your move within 24 hours.")
51}
52
53// Reveal opens the challenger's sealed plan for the round being played, and on the
54// first reveal the sealed deck, then resolves the round. A match that goes on needs
55// nextSeal and nextBox for the challenger's next plan; one that ends takes neither.
56func Reveal(cur realm, id, deck, deckSalt, plan, planSalt, nextSeal, nextBox string) {
57 rules := engine.ProofOfConcept()
58 n, m := find(rules, id)
59 now := time.Now().Unix()
60 caller := cur.Previous().Address()
61 guard(cur.Previous().IsUser(), caller, m, n)
62 m.checkMove(caller, phaseReveal, m.challenger, now)
63
64 s := stateOf(rules, m)
65 first := s.Round == 1
66 bits := m.decks[0]
67 if first {
68 if deck == "" {
69 panic("deck required")
70 }
71 checkSeal(m.deckSeal, deckSalt, deck)
72 bits = deckBits(rules, rules.Clans[m.clans[0]-1], deck)
73 } else if deck != "" || deckSalt != "" {
74 panic("deck already revealed")
75 }
76 checkSeal(m.seal, planSalt, plan)
77 a := planOf(rules, plan)
78 if !legal(rules, s, engine.SideA, bits, a) {
79 panic("illegal plan")
80 }
81 o := rules.Resolve(s, a, planAt(m.plans[1], s.Round))
82 var box string
83 if o.Result == engine.Ongoing {
84 if nextSeal == "" {
85 panic("next seal required")
86 }
87 if !validSeal(nextSeal) {
88 panic("invalid seal")
89 }
90 box = boxBytes(nextBox)
91 } else if nextSeal != "" || nextBox != "" {
92 panic("unexpected next seal")
93 }
94
95 if first {
96 m.decks[0] = bits
97 m.deckSeal = ""
98 }
99 m.plans[0] = withPlan(m.plans[0], s.Round, a)
100 for side, g := range o.State.Gnomes {
101 m.hp[s.Round-1][side] = g.HP
102 m.carried[side] = engine.Vitals{Vulnerable: g.Vulnerable, Strength: g.Strength}
103 }
104 m.seal, m.box = nextSeal, box
105 if o.Result != engine.Ongoing {
106 m.end(resultName(o.Result), reasonOf(o.State))
107 saveMatch(rules, n, m)
108 rate(rules, m)
109 tellEnded(m, n, caller)
110 return
111 }
112 m.phase = phasePlay
113 m.deadline = now + moveWindow
114 saveMatch(rules, n, m)
115 hp := m.hp[s.Round-1]
116 tell("Turn", m.acceptor, caller, n, "Round "+strconv.Itoa(s.Round)+" is over: you "+strconv.Itoa(hp[1])+
117 " HP, them "+strconv.Itoa(hp[0])+". Your move within 24 hours.")
118}
119
120// Play records the acceptor's plan for the round being played.
121func Play(cur realm, id, plan string) {
122 rules := engine.ProofOfConcept()
123 n, m := find(rules, id)
124 now := time.Now().Unix()
125 caller := cur.Previous().Address()
126 guard(cur.Previous().IsUser(), caller, m, n)
127 m.checkMove(caller, phasePlay, m.acceptor, now)
128 s := stateOf(rules, m)
129 b := planOf(rules, plan)
130 if !legal(rules, s, engine.SideB, m.decks[1], b) {
131 panic("illegal plan")
132 }
133 m.plans[1] = withPlan(m.plans[1], s.Round, b)
134 m.phase = phaseReveal
135 m.deadline = now + moveWindow
136 saveMatch(rules, n, m)
137 tell("Turn", m.challenger, caller, n, clanName(rules, m.clans[1])+" played round "+strconv.Itoa(s.Round)+". Your move within 24 hours.")
138}
139
140// Resign withdraws the caller's open challenge, expired or not, deleting it, or concedes
141// a running match.
142func Resign(cur realm, id string) {
143 rules := engine.ProofOfConcept()
144 n, m := find(rules, id)
145 caller := cur.Previous().Address()
146 guard(cur.Previous().IsUser(), caller, m, n)
147 switch m.phase {
148 case phaseOpen:
149 if caller != m.challenger {
150 panic("not your challenge")
151 }
152 openRemove(n)
153 forget(n, m.challenger)
154 tellBoard(n, "Challenge "+strconv.Itoa(n)+" was withdrawn.")
155 case phaseReveal, phasePlay:
156 switch caller {
157 case m.challenger:
158 m.end("b", "resign")
159 case m.acceptor:
160 m.end("a", "resign")
161 default:
162 panic("not a player")
163 }
164 saveMatch(rules, n, m)
165 rate(rules, m)
166 tellEnded(m, n, caller)
167 default:
168 panic("wrong phase")
169 }
170}
171
172// ClaimForfeit ends a running match whose deadline has passed: the player who had to
173// move loses. Any wallet may claim it; a session may claim only a match its wallet plays.
174func ClaimForfeit(cur realm, id string) {
175 rules := engine.ProofOfConcept()
176 n, m := find(rules, id)
177 caller := cur.Previous().Address()
178 guard(cur.Previous().IsUser(), caller, m, n)
179 var result string
180 switch m.phase {
181 case phaseReveal:
182 result = "b"
183 case phasePlay:
184 result = "a"
185 default:
186 panic("wrong phase")
187 }
188 if time.Now().Unix() <= m.deadline {
189 panic("deadline not passed")
190 }
191 m.end(result, "forfeit")
192 saveMatch(rules, n, m)
193 rate(rules, m)
194 tellEnded(m, n, caller)
195}
196
197func find(rules *engine.Ruleset, id string) (int, record) {
198 n, m, ok := lookup(rules, id)
199 if !ok {
200 panic("unknown match")
201 }
202 return n, m
203}
204
205// lookup returns the match id names, false when there is none. Only an id's own decimal
206// text names it, never "01" or "+1".
207func lookup(rules *engine.Ruleset, id string) (int, record, bool) {
208 if len(id) == 0 || len(id) > idDigits {
209 return 0, record{}, false
210 }
211 n, err := strconv.Atoi(id)
212 if err != nil || strconv.Itoa(n) != id {
213 return 0, record{}, false
214 }
215 m, ok := loadMatch(rules, n)
216 return n, m, ok
217}
218
219func (m record) checkMove(caller address, phase string, mover address, now int64) {
220 if m.phase != phase {
221 panic("wrong phase")
222 }
223 if caller != mover {
224 panic("not your move")
225 }
226 if now > m.deadline {
227 panic("deadline passed")
228 }
229}
230
231func (m *record) end(result, reason string) {
232 m.phase = phaseEnded
233 m.result = result
234 m.reason = reason
235 m.deckSeal, m.seal, m.box = "", "", ""
236}
237
238// stateOf rebuilds the engine state the next round of m starts from. A round is resolved
239// once the challenger's plan for it is stored; the specials of resolved rounds are spent.
240func stateOf(rules *engine.Ruleset, m record) engine.State {
241 resolved := plansMade(m.plans[0])
242 s := rules.Start()
243 s.Round = resolved + 1
244 for side := range s.Gnomes {
245 g := &s.Gnomes[side]
246 if resolved > 0 {
247 g.HP = m.hp[resolved-1][side]
248 }
249 g.Vulnerable = m.carried[side].Vulnerable
250 g.Strength = m.carried[side].Strength
251 for round := 1; round <= resolved; round++ {
252 for _, id := range planAt(m.plans[side], round) {
253 if rules.Cards[id].Special {
254 g.Spent |= uint64(1) << uint(id)
255 }
256 }
257 }
258 }
259 return s
260}
261
262// checkSeal panics unless seal is the sha256 of salt's bytes followed by text.
263func checkSeal(seal, salt, text string) {
264 if !validSeal(salt) {
265 panic("invalid salt")
266 }
267 b, _ := hex.DecodeString(salt)
268 sum := sha256.Sum256(append(b, text...))
269 if hex.EncodeToString(sum[:]) != seal {
270 panic("seal mismatch")
271 }
272}
273
274// clanNumber is clan's index in rules plus one, as a record stores it, or 0 when no clan
275// has that name.
276func clanNumber(rules *engine.Ruleset, clan string) int {
277 for i, c := range rules.Clans {
278 if c.Name == clan {
279 return i + 1
280 }
281 }
282 return 0
283}
284
285// clanName is the name of the clan a record stores as n, "" for none.
286func clanName(rules *engine.Ruleset, n int) string {
287 if n == 0 {
288 return ""
289 }
290 return rules.Clans[n-1].Name
291}
292
293// planOf maps comma-separated card names to the engine's ids.
294func planOf(rules *engine.Ruleset, text string) engine.Plan {
295 p, ok := rules.PlanOf(strings.Split(text, ",")...)
296 if !ok {
297 panic("unknown card")
298 }
299 return p
300}
301
302// deckBits returns deck as the engine's bit set, panicking unless it is legal for c. A
303// card named twice is illegal, so a legal deck text holds exactly the deck's names.
304func deckBits(rules *engine.Ruleset, c engine.Clan, deck string) uint64 {
305 var bits uint64
306 for _, id := range planOf(rules, deck) {
307 bit := uint64(1) << uint(id)
308 if bits&bit != 0 {
309 panic("illegal deck")
310 }
311 bits |= bit
312 }
313 if !rules.LegalDeck(c, bits) {
314 panic("illegal deck")
315 }
316 return bits
317}
318
319func legal(rules *engine.Ruleset, s engine.State, side engine.Side, deck uint64, p engine.Plan) bool {
320 for _, q := range rules.LegalPlans(s, side, deck) {
321 if samePlan(p, q) {
322 return true
323 }
324 }
325 return false
326}
327
328func samePlan(p, q engine.Plan) bool {
329 if len(p) != len(q) {
330 return false
331 }
332 for i := range p {
333 if p[i] != q[i] {
334 return false
335 }
336 }
337 return true
338}
339
340func resultName(r engine.Result) string {
341 switch r {
342 case engine.AWins:
343 return "a"
344 case engine.BWins:
345 return "b"
346 case engine.Draw:
347 return "draw"
348 }
349 return ""
350}
351
352// reasonOf says why a finished match ended: a gnome at 0 HP or less is a knockout.
353func reasonOf(s engine.State) string {
354 if s.Gnomes[0].HP <= 0 || s.Gnomes[1].HP <= 0 {
355 return "knockout"
356 }
357 return "hp"
358}