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.

gnoslinger.gno

12.75 Kb · 395 lines
  1// Package gnoslinger is the on-chain lobby and referee of Gunslinger
  2// showdowns (the 1982 Avalon Hill gunfight game) of two to four players.
  3// Players create and join games and play their turn plans hidden: each
  4// commits the hash of its plan, then reveals it once every plan is
  5// committed (DESIGN_HIDDEN_PLANS.md); the realm resolves each turn with the
  6// pure engine package and keeps the result for the web client to animate.
  7package gnoslinger
  8
  9import (
 10	"crypto/sha256"
 11	enchex "encoding/hex"
 12	"strconv"
 13	"time"
 14
 15	"gno.land/p/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/engine/v0"
 16	"gno.land/p/nt/avl/v0"
 17	"gno.land/p/nt/seqid/v0"
 18	profiles "gno.land/r/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/profiles/v0"
 19)
 20
 21const (
 22	realmPath = "gno.land/r/g1th0masp67zfs9lg6d34qhghldhg6kjcqjuv7c2/gnoslinger/v0"
 23
 24	// DefaultTimeout is how long a turn may sit without progress before
 25	// anyone can call ClaimTimeout on it, when CreateGame gets 0.
 26	DefaultTimeout = 48 * time.Hour
 27	// MinTimeout and MaxTimeout bound the timeout a creator may pick.
 28	MinTimeout = 5 * time.Minute
 29	MaxTimeout = 30 * 24 * time.Hour
 30	// MinMaxTurns and MaxMaxTurns bound the game length.
 31	MinMaxTurns = 10
 32	MaxMaxTurns = 30
 33	// MaxMissed is the turns in a row a player may let time out: each is
 34	// passed for it, the last puts its character out of the fight.
 35	MaxMissed = 3
 36	// RevealGrace is how long after the commit deadline a claim waits
 37	// before it may pass the turn of a seat whose plan is not revealed: the
 38	// timelocked plans open LockDelay after the deadline, and their opener
 39	// needs time for its transaction.
 40	RevealGrace = 5 * time.Minute
 41	// LockDelay puts the drand round the plans are timelocked to after the
 42	// deadline, past any drift between the block time and drand's clock.
 43	LockDelay = 2 * time.Minute
 44	// MaxLocked bounds the size of a timelocked plan.
 45	MaxLocked = 1024
 46	// MaxSalt bounds the size of a salt (hex).
 47	MaxSalt = 64
 48	// The drand quicknet chain the plans are timelocked to: its genesis
 49	// (unix seconds) and the seconds between two rounds.
 50	DrandGenesis = 1692803367
 51	DrandPeriod  = 3
 52)
 53
 54// Phase is where a game stands.
 55type Phase int
 56
 57const (
 58	// PhaseWaiting: the seats are filling up.
 59	PhaseWaiting Phase = iota
 60	// PhasePlanning: every seat taken; collecting the commits of the
 61	// current turn.
 62	PhasePlanning
 63	// PhaseFinished: over, see Game.EndReason.
 64	PhaseFinished
 65	// PhaseRevealing: every seat in the fight committed; collecting the
 66	// reveals.
 67	PhaseRevealing
 68)
 69
 70// String returns the phase name used in JSON views.
 71func (p Phase) String() string {
 72	switch p {
 73	case PhaseWaiting:
 74		return "waiting"
 75	case PhasePlanning:
 76		return "planning"
 77	case PhaseFinished:
 78		return "finished"
 79	case PhaseRevealing:
 80		return "revealing"
 81	}
 82	return "?"
 83}
 84
 85// Player is a seat: the address that controls it (empty while nobody took
 86// it) and its character token.
 87type Player struct {
 88	Addr address
 89	Char string
 90}
 91
 92// TurnResult is the outcome of one resolved turn, kept so the client can
 93// replay it: the state the turn started from, the plans (one per seat),
 94// and what happened. Seed lets anyone re-run the resolution; Cards is the
 95// trace of the result cards drawn from the deck it shuffles, in order. The
 96// start snapshot, the weapons on the ground at the start, the cards and the
 97// events are kept in the engine's compact text encodings: as structs they
 98// cost a hundred times more storage (see engine/encode.gno). With the
 99// ground, a turn is shown from its own record, without the turns before it.
100type TurnResult struct {
101	Turn      int
102	Plans     []string
103	Seed      string
104	StartEnc  string // engine.EncodeChars
105	GroundEnc string // engine.EncodeGround
106	CardsEnc  string // engine.EncodeInts
107	EventsEnc string // engine.EncodeEvents
108}
109
110func newTurnResult(turn int, start []engine.Character, ground string, plans []string, seed string, cards []int, evs []engine.Event) *TurnResult {
111	return &TurnResult{
112		Turn: turn, Plans: plans, Seed: seed, StartEnc: engine.EncodeChars(start), GroundEnc: ground,
113		CardsEnc: engine.EncodeInts(cards), EventsEnc: engine.EncodeEvents(evs),
114	}
115}
116
117// Start is the state of every seat when the turn began.
118func (r *TurnResult) Start() []engine.Character { return engine.DecodeChars(r.StartEnc) }
119
120// Ground is the weapons lying on the board when the turn began.
121func (r *TurnResult) Ground() []engine.GroundGun { return engine.DecodeGround(r.GroundEnc) }
122
123// Cards is the result cards drawn during the turn, in order.
124func (r *TurnResult) Cards() []int { return engine.DecodeInts(r.CardsEnc) }
125
126// Events is what happened during the turn, in order.
127func (r *TurnResult) Events() []engine.Event { return engine.DecodeEvents(r.EventsEnc) }
128
129// Sealed is a seat's sealed plan for the turn in progress, cleared by its
130// reveal.
131type Sealed struct {
132	Hash   string // PlanHash of the seat's salt and plan; "" until committed
133	Locked string // the salt and plan timelocked to LockRound
134}
135
136// Game is one showdown. Players, Commits, Plans, Salts, Revealed, Missed and
137// VP have one entry per seat, the seats filling up in the order the players
138// join.
139type Game struct {
140	ID        string
141	Players   []Player
142	State     engine.State
143	Turn      int // 1-based
144	MaxTurns  int
145	Timeout   time.Duration // the time a turn gives, from TurnStart, after which ClaimTimeout succeeds
146	Phase     Phase
147	Commits   []Sealed      // the turn in progress, each cleared by its seat's reveal
148	Plans     []string      // the turn in progress, revealed, canonical form
149	Salts     []string      // the turn in progress, revealed; part of the deck's seed
150	Revealed  []bool        // the turn in progress: the seat's plan is in (revealed, or passed)
151	Missed    []int         // turns in a row each seat let time out, reset by a revealed plan
152	TurnStart time.Time     // when the turn in progress started: its clock, left alone by the saves within the turn
153	Turns     []*TurnResult // resolved turns, in order (at most MaxTurns)
154	Winner    int           // seat index, -1 for none: the most victory points, -1 on a tie
155	EndReason string
156	// Awards are the victory points earned and lost so far (rule 15), at
157	// the end of each turn and of the showdown; VP adds them up per seat.
158	Awards []engine.Award
159	VP     []int
160	// Stake is what each player puts in, in ugnot (0: a free showdown);
161	// Pot what the realm holds for the showdown, the stakes paid so far.
162	// Payouts is what each seat got once the showdown is over (nil before,
163	// and for a free one), HouseCut what the house kept (stakes.gno).
164	Stake     int64
165	Pot       int64
166	Payouts   []int64
167	HouseCut  int64
168	Rev       int // bumped on every change; clients poll it
169	CreatedAt time.Time
170	UpdatedAt time.Time
171}
172
173var (
174	games     = avl.NewTree() // id -> *Game
175	gameSeq   seqid.ID
176	byPlayer  = avl.NewTree() // address -> []string game ids, newest first
177	openGames = avl.NewTree() // id -> *Game while a seat is free
178)
179
180func getGame(id string) *Game {
181	if _, err := seqid.FromString(id); err != nil {
182		panic("gnoslinger: invalid game id")
183	}
184	v := games.Get(id)
185	if v == nil {
186		panic("gnoslinger: game not found")
187	}
188	return v.(*Game)
189}
190
191// seats is the number of seats of the game, taken or not.
192func (g *Game) seats() int { return len(g.Players) }
193
194// seat returns the seat of addr in g, or -1.
195func (g *Game) seat(addr address) int {
196	for i, p := range g.Players {
197		if p.Addr == addr && p.Addr != "" {
198			return i
199		}
200	}
201	return -1
202}
203
204func (g *Game) mustSeat(addr address) int {
205	p := g.seat(addr)
206	if p < 0 {
207		panic("gnoslinger: you are not a player of this game")
208	}
209	return p
210}
211
212// freeSeat is the first seat nobody took, -1 once every seat is taken.
213func (g *Game) freeSeat() int {
214	for i, p := range g.Players {
215		if p.Addr == "" {
216			return i
217		}
218	}
219	return -1
220}
221
222// seated counts the seats taken.
223func (g *Game) seated() int {
224	n := 0
225	for _, p := range g.Players {
226		if p.Addr != "" {
227			n++
228		}
229	}
230	return n
231}
232
233// committed reports whether seat p committed its plan this turn (revealed
234// or not).
235func (g *Game) committed(p int) bool { return g.Commits[p].Hash != "" || g.Revealed[p] }
236
237// allCommitted reports whether every character still in the fight
238// committed its plan.
239func (g *Game) allCommitted() bool {
240	for p := range g.Players {
241		if g.State.Chars[p].Status == engine.Alive && !g.committed(p) {
242			return false
243		}
244	}
245	return true
246}
247
248// allRevealed reports whether the turn has every plan it waits for: one
249// from each character still in the fight.
250func (g *Game) allRevealed() bool {
251	for p := range g.Players {
252		if g.State.Chars[p].Status == engine.Alive && !g.Revealed[p] {
253			return false
254		}
255	}
256	return true
257}
258
259// clearTurn forgets the commits and plans of the turn.
260func (g *Game) clearTurn() {
261	n := g.seats()
262	g.Commits = make([]Sealed, n)
263	g.Plans = make([]string, n)
264	g.Salts = make([]string, n)
265	g.Revealed = make([]bool, n)
266}
267
268// PlanHash is what a seat commits: the sha256, in hex, of its salt and plan
269// for that game, turn and seat ("gnoslinger|id|turn|seat|salt|plan").
270func PlanHash(id string, turn, seat int, salt, plan string) string {
271	h := sha256.Sum256([]byte("gnoslinger|" + id + "|" + strconv.Itoa(turn) + "|" + strconv.Itoa(seat) + "|" + salt + "|" + plan))
272	return enchex.EncodeToString(h[:])
273}
274
275// lastTurn is the most recently resolved turn, or nil.
276func (g *Game) lastTurn() *TurnResult {
277	if len(g.Turns) == 0 {
278		return nil
279	}
280	return g.Turns[len(g.Turns)-1]
281}
282
283// commitUntil is the turn's deadline: commits are refused from then, and
284// the turn can be claimed if commits are missing. Timeout after the turn's
285// start whatever happened since (a commit does not restart it); a game
286// waiting for players has none.
287func (g *Game) commitUntil() time.Time { return g.TurnStart.Add(g.Timeout) }
288
289// revealUntil is when a claim may put the seats that did not reveal out of
290// the fight: RevealGrace after the deadline.
291func (g *Game) revealUntil() time.Time { return g.commitUntil().Add(RevealGrace) }
292
293// timeoutAt is when the turn in progress becomes claimable through
294// ClaimTimeout: the deadline while planning, revealUntil while revealing.
295func (g *Game) timeoutAt() time.Time {
296	if g.Phase == PhaseRevealing {
297		return g.revealUntil()
298	}
299	return g.commitUntil()
300}
301
302// LockRound is the drand quicknet round the turn's plans are timelocked
303// to: the first round after the deadline plus LockDelay. The realm never
304// checks anything about drand: the round only tells every client the same.
305func (g *Game) lockRound() int64 {
306	return (g.commitUntil().Add(LockDelay).Unix()-DrandGenesis)/DrandPeriod + 2
307}
308
309// startTurn starts the turn's clock.
310func (g *Game) startTurn() { g.TurnStart = time.Now() }
311
312// assertInTime refuses a commit once the turn's deadline passed: from
313// then it can only be claimed (ClaimTimeout), so playing late cannot turn
314// a lost game around, nor win it by claiming right after.
315func (g *Game) assertInTime() {
316	if !time.Now().Before(g.commitUntil()) {
317		panic("gnoslinger: out of time: the game can only be claimed now")
318	}
319}
320
321// award records victory points (rule 15): each award is a VictoryPoints
322// event, adds to the seat's total and to the player's profile (in the
323// profiles realm, which cur, the crossing call's, gets to write).
324func (g *Game) award(cur realm, awards []engine.Award) {
325	for _, a := range awards {
326		g.Awards = append(g.Awards, a)
327		g.VP[a.Player] += a.Points
328		emit("VictoryPoints", "id", g.ID, "seat", strconv.Itoa(a.Player), "player", g.Players[a.Player].Addr.String(),
329			"reason", a.Reason, "points", strconv.Itoa(a.Points))
330		profiles.AddVictoryPoints(cross(cur), g.Players[a.Player].Addr, a.Points)
331	}
332}
333
334// finish ends the game: the most victory points win, a tie for the most (a
335// game nobody played included) being a draw; the pot is paid out (cur,
336// the crossing call's, pays from the realm).
337func (g *Game) finish(cur realm, reason string) {
338	g.Phase = PhaseFinished
339	g.EndReason = reason
340	g.Winner = -1
341	best, tie := 0, false
342	for p, v := range g.VP {
343		switch {
344		case g.Winner < 0 || v > best:
345			g.Winner, best, tie = p, v, false
346		case v == best:
347			tie = true
348		}
349	}
350	if tie {
351		g.Winner = -1
352	}
353	w := ""
354	if g.Winner >= 0 {
355		w = g.Players[g.Winner].Addr.String()
356	}
357	emit("GameEnded", "id", g.ID, "reason", reason, "winner", w)
358	g.notifyOver()
359	g.payPot(cur)
360}
361
362// save records a change: bumps the revision and the activity clock.
363func save(g *Game) {
364	g.Rev++
365	g.UpdatedAt = time.Now()
366	games.Set(g.ID, g)
367}
368
369func index(addr address, id string) {
370	var ids []string
371	if v := byPlayer.Get(addr.String()); v != nil {
372		ids = v.([]string)
373	}
374	byPlayer.Set(addr.String(), append([]string{id}, ids...))
375}
376
377// unindex removes id from addr's games, and addr from byPlayer once it has
378// none left.
379func unindex(addr address, id string) {
380	v := byPlayer.Get(addr.String())
381	if v == nil {
382		return
383	}
384	var ids []string
385	for _, x := range v.([]string) {
386		if x != id {
387			ids = append(ids, x)
388		}
389	}
390	if len(ids) == 0 {
391		byPlayer.Remove(addr.String())
392		return
393	}
394	byPlayer.Set(addr.String(), ids)
395}