Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}