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}