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}