play.gno
16.06 Kb · 449 lines
1package gnoslinger
2
3import (
4 "chain/runtime"
5 enchex "encoding/hex"
6 "strconv"
7 "strings"
8 "time"
9
10 "gno.land/p/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/cards/v0"
11 "gno.land/p/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/engine/v0"
12 "gno.land/p/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/hex/v0"
13)
14
15// Commit seals the caller's plan for the given turn, the one in progress,
16// and returns the game revision (a commit meant for another turn, say one
17// still on its way when time was called, is refused: its hash, which covers
18// the turn, could never be revealed in this one, and it would take the
19// seat's commit). hash is PlanHash of a salt and the plan, which a Reveal shows
20// once every character still in the fight committed; locked is the salt
21// and plan timelocked to the turn's LockRound (at most MaxLocked bytes,
22// required: encrypting needs no drand node), which anyone can open and
23// reveal from then if the player does not. When this commit is the last one the turn waits for, the game
24// moves on to the reveals.
25func Commit(cur realm, id string, turn int, hash string, locked string) int {
26 assertNoSend()
27 caller := cur.Previous().Address()
28 g := getGame(id)
29 if turn != g.Turn {
30 panic("gnoslinger: turn " + strconv.Itoa(turn) + " is not the turn in progress")
31 }
32 if g.Phase != PhasePlanning {
33 panic("gnoslinger: game is not accepting plans")
34 }
35 g.assertInTime()
36 p := g.mustSeat(caller)
37 if g.State.Chars[p].Status != engine.Alive {
38 panic("gnoslinger: your character is out of the fight")
39 }
40 if g.committed(p) {
41 panic("gnoslinger: plan already submitted for this turn")
42 }
43 if !isHex(hash, 64, 64) {
44 panic("gnoslinger: the hash must be 64 hex characters")
45 }
46 if locked == "" {
47 panic("gnoslinger: a commit needs its timelocked plan")
48 }
49 if len(locked) > MaxLocked {
50 panic("gnoslinger: the timelocked plan is over " + strconv.Itoa(MaxLocked) + " bytes")
51 }
52 g.Commits[p] = Sealed{Hash: hash, Locked: locked}
53 emit("PlanCommitted", "id", id, "turn", strconv.Itoa(g.Turn), "player", caller.String(), "seat", strconv.Itoa(p))
54 progress(cur, g)
55 save(g)
56 return g.Rev
57}
58
59// Reveal puts a seat's salt and plan for the given turn on chain and
60// returns the game revision. Anyone may send it while the game collects the
61// reveals: the salt and plan must match the seat's commit (PlanHash). The
62// last seat to commit may also reveal straight away, without committing:
63// the others' plans are already fixed by their hashes, so its plan in clear
64// helps nobody; that is only accepted from the seat itself, before the
65// deadline, once every other character still in the fight committed (a
66// refused transaction still shows its plan). The plan is a comma-separated
67// list of "<card><f|b>[:<choice>]" entries, e.g. "1f:ahead_left,2f:ahead,3f";
68// an empty string passes. The reveal that completes the turn resolves it in
69// this same transaction and the next turn starts.
70func Reveal(cur realm, id string, turn int, seat int, salt string, plan string) int {
71 assertNoSend()
72 caller := cur.Previous().Address()
73 g := getGame(id)
74 if turn != g.Turn {
75 panic("gnoslinger: turn " + strconv.Itoa(turn) + " is not the turn in progress")
76 }
77 if seat < 0 || seat >= g.seats() {
78 panic("gnoslinger: there is no seat " + strconv.Itoa(seat) + " in this game")
79 }
80 switch g.Phase {
81 case PhaseRevealing:
82 if g.Revealed[seat] {
83 panic("gnoslinger: that plan is already revealed")
84 }
85 revealSeat(g, seat, salt, plan, caller)
86 case PhasePlanning:
87 // The last commit, in clear.
88 g.assertInTime()
89 if g.mustSeat(caller) != seat {
90 panic("gnoslinger: only its player may reveal a plan before every plan is committed")
91 }
92 if g.State.Chars[seat].Status != engine.Alive {
93 panic("gnoslinger: your character is out of the fight")
94 }
95 if g.committed(seat) {
96 panic("gnoslinger: plan already submitted for this turn")
97 }
98 for p := range g.Players {
99 if p != seat && g.State.Chars[p].Status == engine.Alive && !g.committed(p) {
100 panic("gnoslinger: only the last plan to commit may be revealed at once")
101 }
102 }
103 storePlan(g, seat, salt, plan, caller)
104 default:
105 panic("gnoslinger: game is not accepting plans")
106 }
107 progress(cur, g)
108 save(g)
109 return g.Rev
110}
111
112// RevealMany reveals several seats of the given turn in one transaction,
113// as Reveal does each, in the order given: what an opener of the
114// timelocked plans sends. seats, salts and plans are lists separated by
115// ";", of the same length (a plan string never holds a ";", an empty plan
116// is an empty field). A seat already revealed is skipped (its player's own
117// reveal landed first), and so is one whose salt and plan do not match its
118// commit or whose plan is not valid (event PlanUnopened): the opener
119// cannot check that before opening, and one bad timelocked plan must not
120// keep the others back; that seat stays unrevealed, for the claim.
121func RevealMany(cur realm, id string, turn int, seats string, salts string, plans string) int {
122 assertNoSend()
123 caller := cur.Previous().Address()
124 g := getGame(id)
125 if turn != g.Turn {
126 panic("gnoslinger: turn " + strconv.Itoa(turn) + " is not the turn in progress")
127 }
128 if g.Phase != PhaseRevealing {
129 panic("gnoslinger: game is not collecting the reveals")
130 }
131 ss, sa, pl := strings.Split(seats, ";"), strings.Split(salts, ";"), strings.Split(plans, ";")
132 if len(sa) != len(ss) || len(pl) != len(ss) {
133 panic("gnoslinger: seats, salts and plans must list as many entries")
134 }
135 for i, s := range ss {
136 seat, err := strconv.Atoi(s)
137 if err != nil || seat < 0 || seat >= g.seats() {
138 panic("gnoslinger: there is no seat " + s + " in this game")
139 }
140 if g.Revealed[seat] {
141 continue
142 }
143 if why := checkReveal(g, seat, sa[i], pl[i]); why != "" {
144 emit("PlanUnopened", "id", g.ID, "turn", strconv.Itoa(g.Turn), "seat", s, "reason", why)
145 continue
146 }
147 revealSeat(g, seat, sa[i], pl[i], caller)
148 }
149 progress(cur, g)
150 save(g)
151 return g.Rev
152}
153
154// revealSeat checks a committed seat's salt and plan against its hash and
155// stores them, clearing the commit.
156func revealSeat(g *Game, seat int, salt, plan string, by address) {
157 if why := checkReveal(g, seat, salt, plan); why != "" {
158 panic("gnoslinger: " + why)
159 }
160 storePlan(g, seat, salt, plan, by)
161}
162
163// checkReveal tells why a committed seat's salt and plan cannot be
164// revealed, "" if they can.
165func checkReveal(g *Game, seat int, salt, plan string) string {
166 if g.Commits[seat].Hash == "" {
167 return "seat " + strconv.Itoa(seat) + " has no plan committed"
168 }
169 if PlanHash(g.ID, g.Turn, seat, salt, plan) != g.Commits[seat].Hash {
170 return "the salt and plan do not match seat " + strconv.Itoa(seat) + "'s commit"
171 }
172 if _, why := checkPlan(g, seat, salt, plan); why != "" {
173 return why
174 }
175 return ""
176}
177
178// checkPlan parses and validates a seat's salt and plan; why is "" when
179// they are fine.
180func checkPlan(g *Game, seat int, salt, plan string) (pl engine.Plan, why string) {
181 if !isHex(salt, 1, MaxSalt) {
182 return nil, "the salt must be 1 to " + strconv.Itoa(MaxSalt) + " hex characters"
183 }
184 pl, err := engine.ParsePlan(plan)
185 if err != nil {
186 return nil, err.Error()
187 }
188 if err := pl.Validate(g.State.Chars[seat]); err != nil {
189 return nil, err.Error()
190 }
191 for _, a := range pl {
192 if a.Ground > 0 && !groundExists(g.State, a.Ground) {
193 return nil, "there is no such weapon on the ground"
194 }
195 if a.Target >= g.seats() {
196 return nil, "there is no character @" + strconv.Itoa(a.Target) + " in this game"
197 }
198 if a.Target == seat {
199 return nil, "you cannot " + a.Opt + " at your own character"
200 }
201 }
202 return pl, ""
203}
204
205// storePlan validates a seat's plan and records it as revealed, clearing
206// its commit (whose storage deposit goes back to the caller).
207func storePlan(g *Game, seat int, salt, plan string, by address) {
208 pl, why := checkPlan(g, seat, salt, plan)
209 if why != "" {
210 panic("gnoslinger: " + why)
211 }
212 // Target-status flags for this turn, set whether or not the actions
213 // end up executing ([H12]).
214 g.State.Chars[seat].PlayedFoot = pl.PlayedFoot()
215 g.State.Chars[seat].PlayedRun = pl.Has(cards.Run)
216 g.Plans[seat] = pl.String()
217 g.Salts[seat] = salt
218 g.Revealed[seat] = true
219 g.Missed[seat] = 0
220 g.Commits[seat] = Sealed{}
221 emit("PlanRevealed", "id", g.ID, "turn", strconv.Itoa(g.Turn), "seat", strconv.Itoa(seat), "by", by.String())
222}
223
224// isHex reports whether s is min to max lowercase hex characters.
225func isHex(s string, min, max int) bool {
226 if len(s) < min || len(s) > max {
227 return false
228 }
229 for i := 0; i < len(s); i++ {
230 c := s[i]
231 if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') {
232 return false
233 }
234 }
235 return true
236}
237
238// progress moves the turn on as far as it can: it passes the players who
239// cannot play anything (a carried delay of 5 or more leaves no time
240// points, rule 7.3: there is nothing to wait for from them), goes on to
241// the reveals once every character in the fight committed, and resolves
242// the turn once every plan is in; when nobody is left to wait for in the
243// next turn either, that turn resolves at once, and so on.
244func progress(cur realm, g *Game) {
245 for {
246 if g.Phase == PhasePlanning {
247 for p := range g.Players {
248 c := &g.State.Chars[p]
249 if c.Status != engine.Alive || g.committed(p) || g.State.Budget(p) > 0 {
250 continue
251 }
252 c.PlayedFoot = false
253 c.PlayedRun = false
254 g.Plans[p] = ""
255 g.Revealed[p] = true
256 emit("PlanPassed", "id", g.ID, "turn", strconv.Itoa(g.Turn), "seat", strconv.Itoa(p))
257 }
258 if !g.allCommitted() {
259 return
260 }
261 g.Phase = PhaseRevealing
262 emit("AllCommitted", "id", g.ID, "turn", strconv.Itoa(g.Turn))
263 g.notifyRevealDue()
264 }
265 if g.Phase != PhaseRevealing || !g.allRevealed() {
266 return
267 }
268 turn := g.Turn
269 seed := resolveTurn(cur, g)
270 emit("TurnResolved", "id", g.ID, "turn", strconv.Itoa(turn), "rev", strconv.Itoa(g.Rev+1), "seed", seed)
271 g.notifyPlanDue()
272 }
273}
274
275// resolveTurn plays the seats' plans with the result deck shuffled from
276// this turn's seed, stores the result and advances the game. It returns the
277// seed in hex.
278func resolveTurn(cur realm, g *Game) string {
279 n := g.seats()
280 // The salts make the seed unknown to every player when it commits, the
281 // block keeps the outcome unknown to the last one to reveal.
282 parts := append([]string{realmPath, g.ID, strconv.Itoa(g.Turn)}, g.Salts...)
283 parts = append(parts, g.Plans...)
284 parts = append(parts, strconv.FormatInt(runtime.ChainHeight(), 10), strconv.FormatInt(time.Now().UnixNano(), 10))
285 seed := engine.Seed(parts...)
286 plans := make([]engine.Plan, n)
287 for i, s := range g.Plans {
288 pl, err := engine.ParsePlan(s)
289 if err != nil {
290 panic("gnoslinger: stored plan is invalid: " + err.Error())
291 }
292 plans[i] = pl
293 }
294 // From a copy: the live state may share the characters with what the
295 // engine returns (see engine.CloneChars).
296 start := engine.CloneChars(g.State.Chars)
297 ground := engine.EncodeGround(g.State.Ground)
298 deck := engine.NewDeck(seed)
299 st, evs := engine.Resolve(hex.A, g.State, plans, deck)
300 st, endEvs := engine.EndTurn(st)
301 evs = append(evs, endEvs...)
302 if g.Turn >= g.MaxTurns && engine.AliveCount(st) > 1 { // the end-of-showdown SERIOUS wound check (14.5)
303 st, endEvs = engine.EndShowdown(st, deck)
304 evs = append(evs, endEvs...)
305 }
306 // Victory points close the turn: what it did, then, when the showdown
307 // ends with it, the end of the showdown (rule 15).
308 over := engine.AliveCount(st) <= 1 || g.Turn >= g.MaxTurns
309 awards := engine.TurnVictoryPoints(engine.State{Chars: start}, st)
310 if over {
311 awards = append(awards, engine.EndVictoryPoints(st)...)
312 }
313 evs = append(evs, engine.VPEvents(awards)...)
314 seedHex := enchex.EncodeToString(seed[:])
315 g.Turns = append(g.Turns, newTurnResult(g.Turn, start, ground, g.Plans, seedHex, deck.Drawn(), evs))
316 g.State = st
317 g.award(cur, awards)
318 g.clearTurn()
319 switch {
320 case engine.AliveCount(g.State) == 0:
321 g.finish(cur, "all_down")
322 case engine.AliveCount(g.State) == 1:
323 g.finish(cur, "last_standing")
324 case g.Turn >= g.MaxTurns:
325 g.finish(cur, "max_turns")
326 default:
327 g.Turn++
328 g.Phase = PhasePlanning
329 g.startTurn()
330 }
331 return seedHex
332}
333
334// groundExists reports whether a gun with that ground id lies somewhere on
335// the board; whether it is in the character's hex is checked when the
336// action executes, since the character may walk to it first.
337func groundExists(st engine.State, id int) bool {
338 for _, gg := range st.Ground {
339 if gg.ID == id {
340 return true
341 }
342 }
343 return false
344}
345
346// Surrender gives up the showdown (rule 15.4: at the start of a turn, that
347// is while the plans are committed; once they are revealed, surrendering
348// after reading the others' would be the abort the hidden plans remove).
349// The character is Out: nobody can shoot it and it plays no more. With one
350// character left in the fight the game ends and is scored, the surrendered
351// losing the "Surrender" points and, an enemy holding the field, being
352// captured; with more, the showdown goes on without it (its commit is
353// dropped), and goes on to the reveals if the others' plans are committed.
354func Surrender(cur realm, id string) {
355 assertNoSend()
356 caller := cur.Previous().Address()
357 g := getGame(id)
358 switch g.Phase {
359 case PhasePlanning:
360 case PhaseRevealing:
361 panic("gnoslinger: the plans are being revealed: surrender at the start of a turn")
362 default:
363 panic("gnoslinger: game is not in progress")
364 }
365 p := g.mustSeat(caller)
366 if g.State.Chars[p].Status != engine.Alive {
367 panic("gnoslinger: your character is out of the fight")
368 }
369 g.State.Chars[p].Status = engine.Out
370 emit("PlayerSurrendered", "id", id, "turn", strconv.Itoa(g.Turn), "player", caller.String(), "seat", strconv.Itoa(p))
371 if engine.AliveCount(g.State) <= 1 {
372 g.award(cur, engine.EndVictoryPoints(g.State))
373 g.finish(cur, "surrender")
374 } else {
375 g.Commits[p] = Sealed{}
376 g.Plans[p] = ""
377 g.Salts[p] = ""
378 g.Revealed[p] = false
379 progress(cur, g)
380 }
381 save(g)
382}
383
384// ClaimTimeout ends the wait of a turn past its deadline. Anyone may call
385// it. A game waiting for players has no timeout (its creator may cancel
386// it). While the plans are committed, past the deadline: each player who
387// did not commit misses the turn: it is passed for them, unless that makes
388// MaxMissed in a row, which puts the character out of the fight (out of
389// time counts as surrendering); the game then goes on to the reveals, and
390// with nobody's plan in, the turn resolves empty. A showdown nobody plays
391// any more so ends after MaxMissed claims, everyone out of the fight (its
392// stakes given back, stakes.gno). While they are
393// revealed, RevealGrace past the deadline (the timelocked plans had time
394// to be opened, which is how a vanished player's plan normally comes in):
395// every character whose plan is still not in misses the turn the same way.
396// The realm cannot tell a copy nobody could open from one nobody tried to
397// open, so it does not punish more than a missed turn. With one character
398// left the game ends and is scored, with more the turn resolves with the
399// plans in and the showdown goes on.
400func ClaimTimeout(cur realm, id string) {
401 assertNoSend()
402 g := getGame(id)
403 switch g.Phase {
404 case PhaseFinished:
405 panic("gnoslinger: game is finished")
406 case PhaseWaiting:
407 panic("gnoslinger: the game is waiting for players: it has no timeout")
408 }
409 if time.Now().Before(g.timeoutAt()) {
410 if g.Phase == PhaseRevealing {
411 panic("gnoslinger: the plans can still be revealed")
412 }
413 panic("gnoslinger: the game has not timed out yet")
414 }
415 for p := range g.Players {
416 late := !g.committed(p) // planning: no commit
417 if g.Phase == PhaseRevealing {
418 late = !g.Revealed[p] // revealing: no plan in
419 }
420 if g.State.Chars[p].Status == engine.Alive && late {
421 missTurn(g, p)
422 }
423 }
424 if engine.AliveCount(g.State) <= 1 {
425 g.award(cur, engine.EndVictoryPoints(g.State))
426 g.finish(cur, "timeout")
427 } else {
428 progress(cur, g)
429 }
430 save(g)
431}
432
433// missTurn passes the turn for a seat whose plan is not in at the claim
434// (its commit, if any, dropped), or puts the character out of the fight
435// at its MaxMissed-th miss in a row; only a revealed plan resets the count.
436func missTurn(g *Game, p int) {
437 c := &g.State.Chars[p]
438 g.Missed[p]++
439 g.Commits[p] = Sealed{}
440 emit("PlanMissed", "id", g.ID, "turn", strconv.Itoa(g.Turn), "seat", strconv.Itoa(p), "missed", strconv.Itoa(g.Missed[p]))
441 if g.Missed[p] >= MaxMissed {
442 c.Status = engine.Out
443 return
444 }
445 c.PlayedFoot = false
446 c.PlayedRun = false
447 g.Plans[p] = ""
448 g.Revealed[p] = true
449}