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

12.24 Kb · 363 lines
  1package connect4_v3
  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	shown := 0
 76	active.Iterate("", "", func(_ string, v any) bool {
 77		if shown == lobbyRows {
 78			return true
 79		}
 80		shown++
 81		g := v.(*Game)
 82		switch {
 83		case g.Status == Open && now.Before(g.ExpiresAt):
 84			open.WriteString("| " + gameLink(base, g.ID) + " | " + gnot(g.Stake) + " | " + g.Creator.String() + " | " + openTo(g) +
 85				" | " + remaining(g.ExpiresAt.Sub(now)) + " | [Accept](" + acceptURL(g) + ") |\n")
 86		case g.Status == Playing:
 87			turn := g.player(g.Turn).String()
 88			if g.Turn == 0 {
 89				turn = g.Creator.String() + " (reveal)"
 90			}
 91			live.WriteString("| " + gameLink(base, g.ID) + " | " + gnot(2*g.Stake) + " | " + turn + " | " + remaining(g.deadline().Sub(now)) + " |\n")
 92		}
 93		return false
 94	})
 95
 96	var b strings.Builder
 97	b.WriteString("# Connect 4\n\n")
 98	b.WriteString("Both players stake the same amount; the winner takes the pot minus a " + gnot(fee) + " fee. " +
 99		"90s per move: run out and you forfeit. If the first mover never moves, both stakes are refunded.\n\n")
100	b.WriteString("**[Post an offer](" + base + ":new)** · open a game below to play it\n\n")
101	b.WriteString("## Open offers\n\n")
102	if open.Len() == 0 {
103		b.WriteString("No open offers.\n\n")
104	} else {
105		b.WriteString("| Game | Stake | Creator | For | Expires in | |\n|---|---|---|---|---|---|\n" + open.String() + "\n")
106	}
107	b.WriteString("## Live games\n\n")
108	if live.Len() == 0 {
109		b.WriteString("No games in progress.\n\n")
110	} else {
111		b.WriteString("| Game | Pot | Turn | Time left |\n|---|---|---|---|\n" + live.String() + "\n")
112	}
113	if n := active.Size(); n > lobbyRows {
114		b.WriteString("Showing the first " + strconv.Itoa(lobbyRows) + " of " + strconv.Itoa(n) + " active games.\n\n")
115	}
116	b.WriteString("## Most games won\n\n")
117	b.WriteString(leaderboard(topWins, func(v int64) string { return strconv.FormatInt(v, 10) }))
118	b.WriteString("## Most gnot won\n\n")
119	b.WriteString(leaderboard(topGnot, gnot))
120	return b.String()
121}
122
123// renderNew shows the offer form and, once submitted, a single Offer link
124// with the stake and every argument prefilled (gnoweb cannot edit .send).
125func renderNew(query string) string {
126	var b strings.Builder
127	b.WriteString("# Post an offer\n\n[← Lobby](" + pageURL("") + ")\n\n")
128	b.WriteString("Generate a fresh random passphrase for every offer and keep it: " +
129		"once someone accepts you have 90s to reveal it, or you forfeit. " +
130		"A guessable one lets an opponent work out who moves first. " +
131		"Paste only its sha256 below, never the passphrase itself:\n\n" +
132		"    pass=$(openssl rand -hex 32); echo \"$pass\"\n" +
133		"    printf %s \"$pass\" | shasum -a 256\n\n")
134	b.WriteString(`<gno-form path="new">
135<gno-input name="stake" type="text" placeholder="Stake in GNOT, e.g. 10 (min 1)" />
136<gno-input name="validFor" type="number" placeholder="Minutes the offer stays open, 1-60 (default 15)" />
137<gno-input name="opponent" type="text" placeholder="Opponent address (optional, leave empty for anyone)" />
138<gno-input name="commitment" type="text" placeholder="sha256 of your passphrase (64 hex chars)" />
139</gno-form>
140
141`)
142	if query == "" {
143		return b.String()
144	}
145
146	q, err := url.ParseQuery(query)
147	if err != nil {
148		return b.String() + "Could not read the form.\n"
149	}
150	// Parse first; the offer rules themselves live in validateOffer so this
151	// page and Offer cannot drift apart.
152	var errs []string
153	stake, ok := parseGNOT(q.Get("stake"))
154	if !ok {
155		errs = append(errs, "stake must be a GNOT amount with at most 6 decimals, e.g. 10 or 2.5")
156	}
157	validFor := 15
158	if v := q.Get("validFor"); v != "" {
159		n, err := strconv.Atoi(v)
160		if err != nil {
161			errs = append(errs, "validFor must be a whole number of minutes")
162		}
163		validFor = n
164	}
165	opponent := address(q.Get("opponent"))
166	commitment := q.Get("commitment")
167	if len(errs) == 0 {
168		errs = validateOffer(stake, validFor, opponent, commitment)
169	}
170	if len(errs) > 0 {
171		b.WriteString("**Fix the following:**\n\n")
172		for _, e := range errs {
173			b.WriteString("- " + e + "\n")
174		}
175		return b.String()
176	}
177
178	b.WriteString("## Ready\n\n")
179	who := "anyone"
180	if opponent != "" {
181		who = opponent.String()
182	}
183	b.WriteString("Stake " + gnot(stake) + ", open to " + who + " for " + strconv.Itoa(validFor) + " min.\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		SetSend(strconv.FormatInt(stake, 10)+denom).URL() + ")**\n")
187	return b.String()
188}
189
190func renderGame(g *Game) string {
191	now := time.Now()
192	var b strings.Builder
193	id := strconv.Itoa(g.ID)
194	b.WriteString("# Game #" + id + " — " + g.Status.String() + "\n\n[← Lobby](" + pageURL("") + ")\n\n")
195	b.WriteString("🔴 " + g.Creator.String() + "  \n🟡 " + orNone(g.Acceptor) + "  \n")
196	b.WriteString("Stake: " + gnot(g.Stake) + " each · fee " + gnot(g.Fee) + "\n\n")
197	b.WriteString(boardView(g, now) + "\n\n")
198
199	switch g.Status {
200	case Open:
201		cancel := "[Cancel offer](" + txlink.NewLink("Cancel").AddArgs("id", id).URL() + ")"
202		if now.Before(g.ExpiresAt) {
203			b.WriteString("Open offer for " + openTo(g) + ", expires in " + remaining(g.ExpiresAt.Sub(now)) + ".\n\n")
204			b.WriteString("**[Accept for " + gnot(g.Stake) + "](" + acceptURL(g) + ")**\n\n")
205			b.WriteString(cancel + " (creator)\n")
206		} else {
207			b.WriteString("This offer expired. Anyone can " + cancel + " to refund the creator.\n")
208		}
209	case Playing:
210		left := g.deadline().Sub(now)
211		claim := "Clock ran out: **[Claim timeout](" + txlink.NewLink("ClaimTimeout").AddArgs("id", id).URL() + ")**\n\n"
212		resign := "[Resign](" + txlink.NewLink("Resign").AddArgs("id", id).URL() + ")\n"
213		if g.Turn == 0 {
214			b.WriteString("Waiting for the creator to reveal the passphrase.\n\n")
215			if left > 0 {
216				b.WriteString("Time left: " + remaining(left) + "\n\n")
217				b.WriteString("**[Reveal](" + txlink.NewLink("Reveal").AddArgs("id", id, "passphrase", "").URL() + ")** (creator)\n\n")
218			} else {
219				b.WriteString(claim)
220			}
221			b.WriteString(resign)
222			break
223		}
224		b.WriteString("Turn: " + [...]string{"", "🔴", "🟡"}[g.Turn] + " " + g.player(g.Turn).String() + "\n\n")
225		if left > 0 {
226			b.WriteString("Time left: " + remaining(left) + " · click a column to drop your piece\n\n")
227		} else {
228			b.WriteString(claim)
229		}
230		b.WriteString(resign)
231	case Won:
232		b.WriteString("Winner: " + g.Winner.String() + ", paid " + gnot(2*g.Stake-g.Fee) + ".\n")
233	case Draw:
234		b.WriteString("Draw: both stakes refunded.\n")
235	case Void:
236		b.WriteString("Void: the first mover never moved, both stakes refunded.\n")
237	case Cancelled:
238		b.WriteString("Cancelled: stake refunded to the creator.\n")
239	}
240	return b.String()
241}
242
243// boardView draws the board. While a move can be played it is 7 column
244// images, each wrapped in its Play link (gnoweb lays linked images out side by
245// side). Otherwise it is one image of all 7 columns, since gnoweb stacks bare
246// images vertically. The last move gets a dark ring, a winning line a green one.
247func boardView(g *Game, now time.Time) string {
248	playable := g.Status == Playing && g.Turn != 0 && !now.After(g.deadline())
249	var win [cols][rows]bool
250	if g.Status == Won && g.Moves > 0 {
251		for _, cell := range g.Board.winLine(g.LastCol, g.LastRow) {
252			win[cell[0]][cell[1]] = true
253		}
254	}
255	const height = headerPx + rows*cellPx
256	if !playable {
257		canvas := svg.NewCanvas(cols*cellPx, height).WithViewBox(0, 0, cols*cellPx, height)
258		alt := "board:"
259		for c := 0; c < cols; c++ {
260			alt += " " + drawColumn(canvas, g, &win, c, c*cellPx) + ";"
261		}
262		return canvas.Render(alt)
263	}
264	id := strconv.Itoa(g.ID)
265	var b strings.Builder
266	for c := 0; c < cols; c++ {
267		canvas := svg.NewCanvas(cellPx, height).WithViewBox(0, 0, cellPx, height)
268		alt := drawColumn(canvas, g, &win, c, 0)
269		b.WriteString("[" + canvas.Render(alt) + "](" +
270			txlink.NewLink("Play").AddArgs("id", id, "column", strconv.Itoa(c+1), "move", strconv.Itoa(g.Moves)).URL() + ")")
271	}
272	return b.String()
273}
274
275// drawColumn draws column c at horizontal offset x and returns its alt text.
276func drawColumn(canvas *svg.Canvas, g *Game, win *[cols][rows]bool, c, x int) string {
277	bg := svg.NewRectangle(x, 0, cellPx, headerPx+rows*cellPx, boardBlue)
278	bg.Attr.Class = "col"
279	canvas.Append(bg, svg.NewText(x+cellPx/2-4, 15, strconv.Itoa(c+1), hole))
280	alt := "column " + strconv.Itoa(c+1) + ":"
281	for r := 0; r < rows; r++ {
282		disc := svg.NewCircle(x+cellPx/2, headerPx+(rows-1-r)*cellPx+cellPx/2, cellPx/2-5, pieceColor[g.Board[c][r]])
283		switch {
284		case win[c][r]:
285			disc.Attr.Stroke, disc.Attr.StrokeWidth = winStroke, "5"
286		case g.Moves > 0 && c == g.LastCol && r == g.LastRow:
287			disc.Attr.Stroke, disc.Attr.StrokeWidth = lastMoveStroke, "3"
288		}
289		canvas.Append(disc)
290		alt += " " + pieceName[g.Board[c][r]]
291	}
292	return alt
293}
294
295func acceptURL(g *Game) string {
296	return txlink.NewLink("Accept").AddArgs("id", strconv.Itoa(g.ID)).
297		SetSend(strconv.FormatInt(g.Stake, 10) + denom).URL()
298}
299
300func digits(s string) bool {
301	for _, c := range s {
302		if c < '0' || c > '9' {
303			return false
304		}
305	}
306	return true
307}
308
309func openTo(g *Game) string {
310	if g.Opponent != "" {
311		return g.Opponent.String()
312	}
313	return "anyone"
314}
315
316// parseGNOT parses a decimal GNOT amount ("10", "2.5") into ugnot.
317func parseGNOT(s string) (int64, bool) {
318	whole, frac, _ := strings.Cut(s, ".")
319	if whole == "" || len(whole) > 12 || len(frac) > 6 {
320		return 0, false
321	}
322	if !digits(whole) || !digits(frac) {
323		return 0, false
324	}
325	w, _ := strconv.ParseInt(whole, 10, 64)
326	f, _ := strconv.ParseInt(frac+strings.Repeat("0", 6-len(frac)), 10, 64)
327	return w*1_000_000 + f, true
328}
329
330// leaderboard renders a board kept at settlement (topWins, topGnot).
331func leaderboard(board []leader, format func(int64) string) string {
332	if len(board) == 0 {
333		return "No finished games yet.\n\n"
334	}
335	var b strings.Builder
336	b.WriteString("| # | Player | |\n|---|---|---|\n")
337	for i, r := range board {
338		b.WriteString("| " + strconv.Itoa(i+1) + " | " + r.Addr.String() + " | " + format(r.Score) + " |\n")
339	}
340	b.WriteString("\n")
341	return b.String()
342}
343
344// gnot formats ugnot as GNOT with up to 6 decimals.
345func gnot(ugnot int64) string {
346	whole := strconv.FormatInt(ugnot/1_000_000, 10)
347	frac := ugnot % 1_000_000
348	if frac == 0 {
349		return whole + " GNOT"
350	}
351	f := strconv.FormatInt(frac, 10)
352	f = strings.TrimRight(strings.Repeat("0", 6-len(f))+f, "0")
353	return whole + "." + f + " GNOT"
354}
355
356func remaining(d time.Duration) string { return d.Truncate(time.Second).String() }
357
358func orNone(a address) string {
359	if a == "" {
360		return "waiting for opponent"
361	}
362	return a.String()
363}