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

render.gno

12.24 Kb · 373 lines
  1package connect4_v2
  2
  3import (
  4	"chain/runtime"
  5	"chain/runtime/unsafe"
  6	"net/url"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"gno.land/p/moul/txlink/v0"
 12	"gno.land/p/nt/svg/v0"
 13)
 14
 15// Board colours. gnoweb only embeds SVG as data-URI images, so each column is
 16// its own image, wrapped in its Play link while the game is being played.
 17const (
 18	red            = "#e53935"
 19	yellow         = "#fdd835"
 20	hole           = "#ffffff"
 21	boardBlue      = "#1565c0"
 22	lastMoveStroke = "#212121"
 23	winStroke      = "#00e676"
 24	cellPx         = 48
 25	headerPx       = 20
 26)
 27
 28var pieceColor = [...]string{hole, red, yellow}
 29var pieceName = [...]string{"empty", "red", "yellow"}
 30
 31// Render serves "" (lobby and leaderboards), "new" (offer form) and
 32// "game/<id>" (one game with every action its state allows).
 33// Everything written comes from realm state or from query values that have
 34// been validated and re-formatted; raw path input is never echoed.
 35func Render(path string) string {
 36	p, query, _ := strings.Cut(path, "?")
 37	switch {
 38	case p == "":
 39		return renderLobby()
 40	case p == "new":
 41		return renderNew(query)
 42	}
 43	if s, ok := strings.CutPrefix(p, "game/"); ok {
 44		if id, err := strconv.Atoi(s); err == nil {
 45			if g := get(id); g != nil {
 46				return renderGame(g)
 47			}
 48		}
 49	}
 50	return "# Not found\n"
 51}
 52
 53// pageURL links to one of this realm's Render pages. The realm path is read
 54// at runtime, like txlink does, so links work wherever the realm is deployed.
 55func pageURL(page string) string {
 56	base := strings.TrimPrefix(unsafe.CurrentRealm().PkgPath(), runtime.ChainDomain())
 57	if page == "" {
 58		return base
 59	}
 60	return base + ":" + page
 61}
 62
 63func gameLink(base string, id int) string {
 64	return "[#" + strconv.Itoa(id) + "](" + base + ":game/" + strconv.Itoa(id) + ")"
 65}
 66
 67func renderLobby() string {
 68	now := time.Now()
 69	base := pageURL("")
 70	var open, live strings.Builder
 71	active.Iterate("", "", func(_ string, v any) bool {
 72		g := v.(*Game)
 73		switch {
 74		case g.Status == Open && now.Before(g.ExpiresAt):
 75			open.WriteString("| " + gameLink(base, g.ID) + " | " + gnot(g.Stake) + " | " + g.Creator.String() + " | " + openTo(g) +
 76				" | " + remaining(g.ExpiresAt.Sub(now)) + " | [Accept](" + acceptURL(g) + ") |\n")
 77		case g.Status == Playing:
 78			turn := g.player(g.Turn).String()
 79			if g.Turn == 0 {
 80				turn = g.Creator.String() + " (reveal)"
 81			}
 82			live.WriteString("| " + gameLink(base, g.ID) + " | " + gnot(2*g.Stake) + " | " + turn + " | " + remaining(g.deadline().Sub(now)) + " |\n")
 83		}
 84		return false
 85	})
 86
 87	var b strings.Builder
 88	b.WriteString("# Connect 4\n\n")
 89	b.WriteString("Both players stake the same amount; the winner takes the pot minus a " + gnot(fee) + " fee. " +
 90		"90s per move: run out and you forfeit. If the first mover never moves, both stakes are refunded.\n\n")
 91	b.WriteString("**[Post an offer](" + base + ":new)** · open a game below to play it\n\n")
 92	b.WriteString("## Open offers\n\n")
 93	if open.Len() == 0 {
 94		b.WriteString("No open offers.\n\n")
 95	} else {
 96		b.WriteString("| Game | Stake | Creator | For | Expires in | |\n|---|---|---|---|---|---|\n" + open.String() + "\n")
 97	}
 98	b.WriteString("## Live games\n\n")
 99	if live.Len() == 0 {
100		b.WriteString("No games in progress.\n\n")
101	} else {
102		b.WriteString("| Game | Pot | Turn | Time left |\n|---|---|---|---|\n" + live.String() + "\n")
103	}
104	b.WriteString("## Most games won\n\n")
105	b.WriteString(leaderboard(func(s *Stats) int64 { return int64(s.Wins) }, func(v int64) string { return strconv.FormatInt(v, 10) }))
106	b.WriteString("## Most gnot won\n\n")
107	b.WriteString(leaderboard(func(s *Stats) int64 { return s.GnotWon }, gnot))
108	return b.String()
109}
110
111// renderNew shows the offer form and, once submitted, a single Offer link
112// with the stake and every argument prefilled (gnoweb cannot edit .send).
113func renderNew(query string) string {
114	var b strings.Builder
115	b.WriteString("# Post an offer\n\n[← Lobby](" + pageURL("") + ")\n\n")
116	b.WriteString("Pick a passphrase of 4+ random words, fresh for every offer, and keep it: " +
117		"once someone accepts you have 90s to reveal it, or you forfeit. " +
118		"Paste only its sha256 below, never the passphrase itself:\n\n" +
119		"    printf %s 'your pass phrase' | shasum -a 256\n\n")
120	b.WriteString(`<gno-form path="new">
121<gno-input name="stake" type="text" placeholder="Stake in GNOT, e.g. 10 (min 1)" />
122<gno-input name="validFor" type="number" placeholder="Minutes the offer stays open, 1-60 (default 15)" />
123<gno-input name="opponent" type="text" placeholder="Opponent address (optional, leave empty for anyone)" />
124<gno-input name="commitment" type="text" placeholder="sha256 of your passphrase (64 hex chars)" />
125</gno-form>
126
127`)
128	if query == "" {
129		return b.String()
130	}
131
132	q, err := url.ParseQuery(query)
133	if err != nil {
134		return b.String() + "Could not read the form.\n"
135	}
136	// Parse first; the offer rules themselves live in validateOffer so this
137	// page and Offer cannot drift apart.
138	var errs []string
139	stake, ok := parseGNOT(q.Get("stake"))
140	if !ok {
141		errs = append(errs, "stake must be a GNOT amount with at most 6 decimals, e.g. 10 or 2.5")
142	}
143	validFor := 15
144	if v := q.Get("validFor"); v != "" {
145		n, err := strconv.Atoi(v)
146		if err != nil {
147			errs = append(errs, "validFor must be a whole number of minutes")
148		}
149		validFor = n
150	}
151	opponent := address(q.Get("opponent"))
152	commitment := q.Get("commitment")
153	if len(errs) == 0 {
154		errs = validateOffer(stake, validFor, opponent, commitment)
155	}
156	if len(errs) > 0 {
157		b.WriteString("**Fix the following:**\n\n")
158		for _, e := range errs {
159			b.WriteString("- " + e + "\n")
160		}
161		return b.String()
162	}
163
164	b.WriteString("## Ready\n\n")
165	who := "anyone"
166	if opponent != "" {
167		who = opponent.String()
168	}
169	b.WriteString("Stake " + gnot(stake) + ", open to " + who + " for " + strconv.Itoa(validFor) + " min.\n\n")
170	b.WriteString("**[Post offer for " + gnot(stake) + "](" + txlink.NewLink("Offer").
171		AddArgs("opponent", opponent.String(), "validFor", strconv.Itoa(validFor), "commitment", commitment).
172		SetSend(strconv.FormatInt(stake, 10)+denom).URL() + ")**\n")
173	return b.String()
174}
175
176func renderGame(g *Game) string {
177	now := time.Now()
178	var b strings.Builder
179	id := strconv.Itoa(g.ID)
180	b.WriteString("# Game #" + id + " — " + g.Status.String() + "\n\n[← Lobby](" + pageURL("") + ")\n\n")
181	b.WriteString("🔴 " + g.Creator.String() + "  \n🟡 " + orNone(g.Acceptor) + "  \n")
182	b.WriteString("Stake: " + gnot(g.Stake) + " each · fee " + gnot(g.Fee) + "\n\n")
183	b.WriteString(boardView(g, now) + "\n\n")
184
185	switch g.Status {
186	case Open:
187		cancel := "[Cancel offer](" + txlink.NewLink("Cancel").AddArgs("id", id).URL() + ")"
188		if now.Before(g.ExpiresAt) {
189			b.WriteString("Open offer for " + openTo(g) + ", expires in " + remaining(g.ExpiresAt.Sub(now)) + ".\n\n")
190			b.WriteString("**[Accept for " + gnot(g.Stake) + "](" + acceptURL(g) + ")**\n\n")
191			b.WriteString(cancel + " (creator)\n")
192		} else {
193			b.WriteString("This offer expired. Anyone can " + cancel + " to refund the creator.\n")
194		}
195	case Playing:
196		left := g.deadline().Sub(now)
197		claim := "Clock ran out: **[Claim timeout](" + txlink.NewLink("ClaimTimeout").AddArgs("id", id).URL() + ")**\n\n"
198		resign := "[Resign](" + txlink.NewLink("Resign").AddArgs("id", id).URL() + ")\n"
199		if g.Turn == 0 {
200			b.WriteString("Waiting for the creator to reveal the passphrase.\n\n")
201			if left > 0 {
202				b.WriteString("Time left: " + remaining(left) + "\n\n")
203				b.WriteString("**[Reveal](" + txlink.NewLink("Reveal").AddArgs("id", id, "passphrase", "").URL() + ")** (creator)\n\n")
204			} else {
205				b.WriteString(claim)
206			}
207			b.WriteString(resign)
208			break
209		}
210		b.WriteString("Turn: " + [...]string{"", "🔴", "🟡"}[g.Turn] + " " + g.player(g.Turn).String() + "\n\n")
211		if left > 0 {
212			b.WriteString("Time left: " + remaining(left) + " · click a column to drop your piece\n\n")
213		} else {
214			b.WriteString(claim)
215		}
216		b.WriteString(resign)
217	case Won:
218		b.WriteString("Winner: " + g.Winner.String() + ", paid " + gnot(2*g.Stake-g.Fee) + ".\n")
219	case Draw:
220		b.WriteString("Draw: both stakes refunded.\n")
221	case Void:
222		b.WriteString("Void: the first mover never moved, both stakes refunded.\n")
223	case Cancelled:
224		b.WriteString("Cancelled: stake refunded to the creator.\n")
225	}
226	return b.String()
227}
228
229// boardView draws the board. While a move can be played it is 7 column
230// images, each wrapped in its Play link (gnoweb lays linked images out side by
231// side). Otherwise it is one image of all 7 columns, since gnoweb stacks bare
232// images vertically. The last move gets a dark ring, a winning line a green one.
233func boardView(g *Game, now time.Time) string {
234	playable := g.Status == Playing && g.Turn != 0 && !now.After(g.deadline())
235	var win [cols][rows]bool
236	if g.Status == Won && g.Moves > 0 {
237		for _, cell := range g.Board.winLine(g.LastCol, g.LastRow) {
238			win[cell[0]][cell[1]] = true
239		}
240	}
241	const height = headerPx + rows*cellPx
242	if !playable {
243		canvas := svg.NewCanvas(cols*cellPx, height).WithViewBox(0, 0, cols*cellPx, height)
244		alt := "board:"
245		for c := 0; c < cols; c++ {
246			alt += " " + drawColumn(canvas, g, &win, c, c*cellPx) + ";"
247		}
248		return canvas.Render(alt)
249	}
250	id := strconv.Itoa(g.ID)
251	var b strings.Builder
252	for c := 0; c < cols; c++ {
253		canvas := svg.NewCanvas(cellPx, height).WithViewBox(0, 0, cellPx, height)
254		alt := drawColumn(canvas, g, &win, c, 0)
255		b.WriteString("[" + canvas.Render(alt) + "](" +
256			txlink.NewLink("Play").AddArgs("id", id, "column", strconv.Itoa(c+1)).URL() + ")")
257	}
258	return b.String()
259}
260
261// drawColumn draws column c at horizontal offset x and returns its alt text.
262func drawColumn(canvas *svg.Canvas, g *Game, win *[cols][rows]bool, c, x int) string {
263	bg := svg.NewRectangle(x, 0, cellPx, headerPx+rows*cellPx, boardBlue)
264	bg.Attr.Class = "col"
265	canvas.Append(bg, svg.NewText(x+cellPx/2-4, 15, strconv.Itoa(c+1), hole))
266	alt := "column " + strconv.Itoa(c+1) + ":"
267	for r := 0; r < rows; r++ {
268		disc := svg.NewCircle(x+cellPx/2, headerPx+(rows-1-r)*cellPx+cellPx/2, cellPx/2-5, pieceColor[g.Board[c][r]])
269		switch {
270		case win[c][r]:
271			disc.Attr.Stroke, disc.Attr.StrokeWidth = winStroke, "5"
272		case g.Moves > 0 && c == g.LastCol && r == g.LastRow:
273			disc.Attr.Stroke, disc.Attr.StrokeWidth = lastMoveStroke, "3"
274		}
275		canvas.Append(disc)
276		alt += " " + pieceName[g.Board[c][r]]
277	}
278	return alt
279}
280
281func acceptURL(g *Game) string {
282	return txlink.NewLink("Accept").AddArgs("id", strconv.Itoa(g.ID)).
283		SetSend(strconv.FormatInt(g.Stake, 10) + denom).URL()
284}
285
286func digits(s string) bool {
287	for _, c := range s {
288		if c < '0' || c > '9' {
289			return false
290		}
291	}
292	return true
293}
294
295func openTo(g *Game) string {
296	if g.Opponent != "" {
297		return g.Opponent.String()
298	}
299	return "anyone"
300}
301
302// parseGNOT parses a decimal GNOT amount ("10", "2.5") into ugnot.
303func parseGNOT(s string) (int64, bool) {
304	whole, frac, _ := strings.Cut(s, ".")
305	if whole == "" || len(whole) > 12 || len(frac) > 6 {
306		return 0, false
307	}
308	if !digits(whole) || !digits(frac) {
309		return 0, false
310	}
311	w, _ := strconv.ParseInt(whole, 10, 64)
312	f, _ := strconv.ParseInt(frac+strings.Repeat("0", 6-len(frac)), 10, 64)
313	return w*1_000_000 + f, true
314}
315
316// leaderboard renders the top 10 players by score, skipping zero scores.
317func leaderboard(score func(*Stats) int64, format func(int64) string) string {
318	type row struct {
319		addr  string
320		score int64
321	}
322	var top []row // kept sorted, highest first, at most 10
323	stats.Iterate("", "", func(addr string, v any) bool {
324		sc := score(v.(*Stats))
325		if sc == 0 {
326			return false
327		}
328		i := len(top)
329		for i > 0 && top[i-1].score < sc {
330			i--
331		}
332		if i < 10 {
333			top = append(top, row{})
334			copy(top[i+1:], top[i:])
335			top[i] = row{addr, sc}
336			if len(top) > 10 {
337				top = top[:10]
338			}
339		}
340		return false
341	})
342	if len(top) == 0 {
343		return "No finished games yet.\n\n"
344	}
345	var b strings.Builder
346	b.WriteString("| # | Player | |\n|---|---|---|\n")
347	for i, r := range top {
348		b.WriteString("| " + strconv.Itoa(i+1) + " | " + r.addr + " | " + format(r.score) + " |\n")
349	}
350	b.WriteString("\n")
351	return b.String()
352}
353
354// gnot formats ugnot as GNOT with up to 6 decimals.
355func gnot(ugnot int64) string {
356	whole := strconv.FormatInt(ugnot/1_000_000, 10)
357	frac := ugnot % 1_000_000
358	if frac == 0 {
359		return whole + " GNOT"
360	}
361	f := strconv.FormatInt(frac, 10)
362	f = strings.TrimRight(strings.Repeat("0", 6-len(f))+f, "0")
363	return whole + "." + f + " GNOT"
364}
365
366func remaining(d time.Duration) string { return d.Truncate(time.Second).String() }
367
368func orNone(a address) string {
369	if a == "" {
370		return "waiting for opponent"
371	}
372	return a.String()
373}