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

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 "![Board for game " + strconv.Itoa(g.ID) + "](data:image/svg+xml;base64," +
306		base64.StdEncoding.EncodeToString([]byte(sb.String())) + ")"
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}