render.gno
12.24 Kb · 373 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// 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}