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}