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

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