render.gno
11.67 Kb · 376 lines
1package minichess
2
3import (
4 "encoding/base64"
5 "strconv"
6 "strings"
7
8 mc "gno.land/p/g1um2pwwnx3gn8p3ryfdert8mhpu4u4up49sc9ru/minichess"
9)
10
11const listLimit = 10
12
13// Render serves the gnoweb pages:
14//
15// "" home: start a game, open challenges, recent games
16// "game/<id>" a board with clickable legal moves
17// "player/<addr>" a player's games
18// "rules" how to enter moves and the draw rules
19func Render(path string) string {
20 path = strings.Trim(path, "/")
21 switch {
22 case path == "":
23 return renderHome()
24 case path == "rules":
25 return renderRules()
26 case strings.HasPrefix(path, "game/"):
27 id, err := strconv.Atoi(strings.TrimPrefix(path, "game/"))
28 if err != nil {
29 return "# Not found\n\nBad game id."
30 }
31 g, ok := getGame(id)
32 if !ok {
33 return "# Not found\n\nGame " + strconv.Itoa(id) + " does not exist."
34 }
35 return renderGame(g)
36 case strings.HasPrefix(path, "player/"):
37 return renderPlayer(address(strings.TrimPrefix(path, "player/")))
38 }
39 return "# Not found\n\n[Back to Mini Chess](" + realmPath + ")"
40}
41
42// actionLink builds a gnoweb link that opens a prefilled call form.
43func actionLink(label, fn string, args ...string) string {
44 url := realmPath + "$help&func=" + fn
45 for i := 0; i+1 < len(args); i += 2 {
46 url += "&" + args[i] + "=" + args[i+1]
47 }
48 return "[" + label + "](" + url + ")"
49}
50
51func gameLink(id int) string {
52 return "[#" + strconv.Itoa(id) + "](" + realmPath + ":game/" + strconv.Itoa(id) + ")"
53}
54
55func short(a address) string {
56 s := a.String()
57 if len(s) > 12 {
58 return s[:7] + "…" + s[len(s)-4:]
59 }
60 return s
61}
62
63func playerName(g *Game, a address) string {
64 if a == "" {
65 if g.Mode == ModeSolo {
66 return "🤖 bot (" + mc.Level(g.BotLevel).Name + ")"
67 }
68 return "_open seat_"
69 }
70 return "[" + short(a) + "](" + realmPath + ":player/" + a.String() + ")"
71}
72
73func renderHome() string {
74 var sb strings.Builder
75 sb.WriteString("# ♟ Mini Chess\n\n")
76 sb.WriteString("Classic 8×8 chess, fully on-chain — play the bot or challenge a friend. ")
77 sb.WriteString("[Rules](" + realmPath + ":rules)\n\n")
78
79 sb.WriteString("## Play the bot\n\n")
80 sb.WriteString("| Level | Play white | Play black |\n|---|---|---|\n")
81 for lvl := mc.MinLevel; lvl <= mc.MaxLevel; lvl++ {
82 l := strconv.Itoa(lvl)
83 sb.WriteString("| " + mc.Level(lvl).Name + " | " +
84 actionLink("Start as white", "NewSoloGame", "color", "white", "level", l) + " | " +
85 actionLink("Start as black", "NewSoloGame", "color", "black", "level", l) + " |\n")
86 }
87
88 sb.WriteString("\n## Play a friend\n\n")
89 sb.WriteString("- " + actionLink("Create an open challenge", "Challenge", "opponent", "", "color", "random") + " — anyone can join\n")
90 sb.WriteString("- " + actionLink("Challenge a specific player", "Challenge", "opponent", "g1...", "color", "white") + " — paste their address\n\n")
91
92 sb.WriteString("## Open challenges\n\n")
93 open := 0
94 openGames.ReverseIterate("", "", func(_ string, v any) bool {
95 g := v.(*Game)
96 side := "white"
97 if g.White == "" {
98 side = "black"
99 }
100 who := "anyone"
101 if g.Invitee != "" {
102 who = playerName(g, g.Invitee)
103 }
104 sb.WriteString("- " + gameLink(g.ID) + " by " + playerName(g, g.Creator) +
105 " — you play " + side + ", for " + who + " · " +
106 actionLink("Accept", "AcceptChallenge", "gameID", strconv.Itoa(g.ID)) + "\n")
107 open++
108 return open >= listLimit
109 })
110 if open == 0 {
111 sb.WriteString("_None right now._\n")
112 }
113
114 sb.WriteString("\n## Recent games\n\n")
115 sb.WriteString("| Game | White | Black | Mode | Status |\n|---|---|---|---|---|\n")
116 n := 0
117 games.ReverseIterate("", "", func(_ string, v any) bool {
118 g := v.(*Game)
119 if g.State == StateWaiting || g.State == StateCancelled {
120 return false
121 }
122 sb.WriteString("| " + gameLink(g.ID) + " | " + playerName(g, g.White) + " | " +
123 playerName(g, g.Black) + " | " + g.Mode.String() + " | " + statusText(g) + " |\n")
124 n++
125 return n >= listLimit
126 })
127 if n == 0 {
128 sb.WriteString("| — | | | | no games yet |\n")
129 }
130 return sb.String()
131}
132
133func statusText(g *Game) string {
134 if g.State == StateActive {
135 return g.Pos.Turn.String() + " to move"
136 }
137 return g.State.String()
138}
139
140func renderGame(g *Game) string {
141 var sb strings.Builder
142 id := strconv.Itoa(g.ID)
143 sb.WriteString("# Game #" + id + "\n\n")
144 sb.WriteString("⚪ White: " + playerName(g, g.White) + " · ⚫ Black: " + playerName(g, g.Black) + " · " + g.Mode.String() + "\n\n")
145
146 // Status line.
147 switch {
148 case g.State == StateActive:
149 turn := g.Pos.Turn.String()
150 line := "**" + strings.ToUpper(turn[:1]) + turn[1:] + " to move**"
151 if g.Pos.InCheck() {
152 line += " — check!"
153 }
154 if g.DrawOffer != "" {
155 line += " · draw offered by " + playerName(g, g.DrawOffer)
156 }
157 sb.WriteString(line + "\n\n")
158 case g.State == StateWaiting:
159 sb.WriteString("**Waiting for an opponent.** " +
160 actionLink("Accept challenge", "AcceptChallenge", "gameID", id) + " · " +
161 actionLink("Cancel (creator)", "CancelChallenge", "gameID", id) + "\n\n")
162 default:
163 sb.WriteString("**Game over: " + g.State.String() + "** (" + g.Reason + ")\n\n")
164 }
165
166 sb.WriteString(renderBoardSVG(g) + "\n\n")
167
168 if g.State == StateActive {
169 sb.WriteString("### Your move\n\n")
170 if g.Mode == ModeSolo && g.playerToMove() == "" {
171 sb.WriteString("_The bot is thinking…_\n\n")
172 } else {
173 sb.WriteString(renderMoveLinks(g))
174 sb.WriteString("\n" + actionLink("Type a move", "Move", "gameID", id, "move", "") +
175 " · " + actionLink("Resign", "Resign", "gameID", id))
176 if g.Mode == ModeTwoPlayer {
177 sb.WriteString(" · " + actionLink("Offer draw", "OfferDraw", "gameID", id))
178 if g.DrawOffer != "" {
179 sb.WriteString(" · " + actionLink("Accept draw", "AcceptDraw", "gameID", id))
180 }
181 }
182 sb.WriteString("\n\n")
183 }
184 }
185
186 sb.WriteString("### Moves\n\n")
187 if len(g.Moves) == 0 {
188 sb.WriteString("_No moves yet._\n")
189 } else {
190 for i := 0; i < len(g.Moves); i += 2 {
191 sb.WriteString(strconv.Itoa(i/2+1) + ". " + g.Moves[i])
192 if i+1 < len(g.Moves) {
193 sb.WriteString(" " + g.Moves[i+1])
194 }
195 sb.WriteString(" \n")
196 }
197 }
198 sb.WriteString("\n`" + g.Pos.FEN() + "`\n\n[← All games](" + realmPath + ")\n")
199 return sb.String()
200}
201
202var glyphs = [7]string{"", "♟", "♞", "♝", "♜", "♛", "♚"}
203
204// renderMoveLinks lists legal moves grouped by the piece that moves.
205func renderMoveLinks(g *Game) string {
206 moves := g.Pos.LegalMoves()
207 if len(moves) == 0 {
208 return ""
209 }
210 var sb strings.Builder
211 id := strconv.Itoa(g.ID)
212 // Group by origin square in board order.
213 for from := 0; from < mc.NumSquares; from++ {
214 var links []string
215 for _, m := range moves {
216 if m.From == from {
217 links = append(links, actionLink(m.String()[2:], "Move", "gameID", id, "move", m.String()))
218 }
219 }
220 if len(links) == 0 {
221 continue
222 }
223 pc := g.Pos.Board[from]
224 sb.WriteString("- " + glyphs[pc.Type()] + " **" + mc.SquareName(from) + "** → " + strings.Join(links, " ") + "\n")
225 }
226 return sb.String()
227}
228
229const (
230 sqPx = 56
231 margin = 22
232)
233
234// renderBoardSVG draws the board as an inline SVG image (white at bottom).
235func renderBoardSVG(g *Game) string {
236 size := mc.Size*sqPx + margin
237 var sb strings.Builder
238 sb.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="` + strconv.Itoa(size) +
239 `" height="` + strconv.Itoa(size) + `" viewBox="0 0 ` + strconv.Itoa(size) + " " + strconv.Itoa(size) +
240 `" font-family="DejaVu Sans,Segoe UI Symbol,Noto Sans Symbols2,sans-serif">`)
241 sb.WriteString(`<rect width="100%" height="100%" fill="#2b2b2b"/>`)
242
243 from, to := -1, -1
244 if n := len(g.Moves); n > 0 {
245 if m, err := mc.ParseMove(g.Moves[n-1]); err == nil {
246 from, to = m.From, m.To
247 }
248 }
249 checkSq := -1
250 if g.State == StateActive || g.Reason == "checkmate" {
251 if g.Pos.InCheck() {
252 k := mc.King
253 if g.Pos.Turn == mc.Black {
254 k = -mc.King
255 }
256 for i := 0; i < mc.NumSquares; i++ {
257 if g.Pos.Board[i] == k {
258 checkSq = i
259 }
260 }
261 }
262 }
263
264 for r := 0; r < mc.Size; r++ {
265 for f := 0; f < mc.Size; f++ {
266 sq := r*mc.Size + f
267 x := margin + f*sqPx
268 y := (mc.Size - 1 - r) * sqPx
269 fill := "#eeeed2"
270 if (r+f)%2 == 0 {
271 fill = "#769656"
272 }
273 if sq == from || sq == to {
274 fill = "#f6f669"
275 if (r+f)%2 == 0 {
276 fill = "#baca2b"
277 }
278 }
279 if sq == checkSq {
280 fill = "#e05555"
281 }
282 sb.WriteString(`<rect x="` + strconv.Itoa(x) + `" y="` + strconv.Itoa(y) + `" width="` +
283 strconv.Itoa(sqPx) + `" height="` + strconv.Itoa(sqPx) + `" fill="` + fill + `"/>`)
284 pc := g.Pos.Board[sq]
285 if pc == mc.Empty {
286 continue
287 }
288 color, stroke := "#111", "#111"
289 if pc.Color() == mc.White {
290 color, stroke = "#fff", "#222"
291 }
292 sb.WriteString(`<text x="` + strconv.Itoa(x+sqPx/2) + `" y="` + strconv.Itoa(y+sqPx/2+2) +
293 `" font-size="44" text-anchor="middle" dominant-baseline="middle" fill="` + color +
294 `" stroke="` + stroke + `" stroke-width="1.2">` + glyphs[pc.Type()] + `</text>`)
295 }
296 }
297 // Coordinates.
298 for i := 0; i < mc.Size; i++ {
299 sb.WriteString(`<text x="` + strconv.Itoa(margin/2) + `" y="` + strconv.Itoa(i*sqPx+sqPx/2+5) +
300 `" font-size="14" text-anchor="middle" fill="#ddd">` + strconv.Itoa(mc.Size-i) + `</text>`)
301 sb.WriteString(`<text x="` + strconv.Itoa(margin+i*sqPx+sqPx/2) + `" y="` + strconv.Itoa(mc.Size*sqPx+16) +
302 `" font-size="14" text-anchor="middle" fill="#ddd">` + string(rune('a'+i)) + `</text>`)
303 }
304 sb.WriteString(`</svg>`)
305 return ")) + ")"
307}
308
309func renderPlayer(a address) string {
310 var sb strings.Builder
311 sb.WriteString("# Player " + short(a) + "\n\n")
312 v := playerGames.Get(a.String())
313 if v == nil {
314 sb.WriteString("_No games yet._\n\n[← All games](" + realmPath + ")\n")
315 return sb.String()
316 }
317 ids := v.([]int)
318 wins, losses, draws := 0, 0, 0
319 sb.WriteString("| Game | White | Black | Mode | Status |\n|---|---|---|---|---|\n")
320 for i := len(ids) - 1; i >= 0; i-- {
321 g, ok := getGame(ids[i])
322 if !ok {
323 continue
324 }
325 switch {
326 case g.State == StateDraw:
327 draws++
328 case (g.State == StateWhiteWon && g.White == a) || (g.State == StateBlackWon && g.Black == a):
329 wins++
330 case g.State == StateWhiteWon || g.State == StateBlackWon:
331 losses++
332 }
333 sb.WriteString("| " + gameLink(g.ID) + " | " + playerName(g, g.White) + " | " +
334 playerName(g, g.Black) + " | " + g.Mode.String() + " | " + statusText(g) + " |\n")
335 }
336 sb.WriteString("\n**Record:** " + strconv.Itoa(wins) + " W · " + strconv.Itoa(losses) + " L · " +
337 strconv.Itoa(draws) + " D\n\n[← All games](" + realmPath + ")\n")
338 return sb.String()
339}
340
341func renderRules() string {
342 return `# Mini Chess rules
343
344Standard chess on the normal 8×8 board, with all the usual rules:
345castling, en passant, the pawn's two-square first move and promotion.
346
347## Entering moves
348
349Moves are written as the from-square followed by the to-square:
350
351| Move | Example |
352|---|---|
353| Normal move or capture | ` + "`e2e4`, `g1f3`, `f3e5`" + ` |
354| Castling (move the king two squares) | ` + "`e1g1`" + ` (king side), ` + "`e1c1`" + ` (queen side) |
355| En passant (pawn to the empty target square) | ` + "`e5d6`" + ` |
356| Promotion (queen by default) | ` + "`e7e8`" + ` or ` + "`e7e8q`" + `; ` + "`e7e8n`" + ` for a knight, ` + "`b`" + ` bishop, ` + "`r`" + ` rook |
357
358On a game page every legal move is a link, so you rarely need to type one.
359
360## How games end
361
362- **Checkmate** wins.
363- **Stalemate**, **threefold repetition**, the **50-move rule** and **insufficient material** (king vs king, or king and one minor piece vs king) are draws and are applied automatically.
364- Two-player games can also end by **resignation** or an agreed **draw**. Bot games can be resigned.
365
366## Bot levels
367
368| Level | Searches | Typical gas per reply |
369|---|---|---|
370| easy | 1 move ahead + captures | ~10–50M |
371| medium | 2 moves ahead + captures | ~40–220M |
372| hard | 3 moves ahead + captures | ~170–370M |
373
374[← Back](` + realmPath + `)
375`
376}