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.

render.gno

13.46 Kb · 393 lines
  1package connect4_v4
  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}