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.

minichess.gno

12.53 Kb · 499 lines
  1// Package minichess is an on-chain chess game (standard 8x8 board).
  2//
  3// Two ways to play:
  4//
  5//   - Solo: NewSoloGame starts a game against the built-in bot. The bot
  6//     replies inside the same transaction as your move.
  7//   - Two players: Challenge creates a game (open to anyone, or for one
  8//     invited address); AcceptChallenge takes the empty seat.
  9//
 10// Moves use coordinate notation: "e2e4", "g1f3", castling as the king's
 11// move ("e1g1"), en passant as the pawn's move to the target square, and
 12// promotion as "e7e8" (queen) or "e7e8n" (under-promotion). Visit the realm
 13// in gnoweb to see the board and click legal moves.
 14package minichess
 15
 16import (
 17	"chain"
 18	"chain/runtime"
 19	"chain/runtime/unsafe"
 20	"strconv"
 21	"strings"
 22
 23	mc "gno.land/p/g1um2pwwnx3gn8p3ryfdert8mhpu4u4up49sc9ru/minichess"
 24	"gno.land/p/nt/avl/v0"
 25)
 26
 27// Mode is how a game is played.
 28type Mode int
 29
 30const (
 31	ModeSolo      Mode = iota // human vs bot
 32	ModeTwoPlayer             // human vs human
 33)
 34
 35func (m Mode) String() string {
 36	if m == ModeSolo {
 37		return "vs bot"
 38	}
 39	return "two-player"
 40}
 41
 42// State is a game's lifecycle stage.
 43type State int
 44
 45const (
 46	StateWaiting State = iota // challenge waiting for an opponent
 47	StateActive               // being played
 48	StateWhiteWon
 49	StateBlackWon
 50	StateDraw
 51	StateCancelled // challenge withdrawn before anyone joined
 52)
 53
 54func (s State) String() string {
 55	switch s {
 56	case StateWaiting:
 57		return "waiting for opponent"
 58	case StateActive:
 59		return "in progress"
 60	case StateWhiteWon:
 61		return "white won"
 62	case StateBlackWon:
 63		return "black won"
 64	case StateDraw:
 65		return "draw"
 66	case StateCancelled:
 67		return "cancelled"
 68	}
 69	return "unknown"
 70}
 71
 72// Finished reports whether the game is over.
 73func (s State) Finished() bool { return s >= StateWhiteWon }
 74
 75// Game is one game of minichess.
 76type Game struct {
 77	ID        int
 78	Mode      Mode
 79	White     address // empty: the bot (solo) or an open seat (waiting)
 80	Black     address
 81	Creator   address
 82	Invitee   address // two-player challenge target; empty = open to all
 83	BotLevel  int     // solo only
 84	Pos       mc.Position
 85	Moves     []string // coordinate notation, in order
 86	History   []string // board keys since the last capture or pawn move
 87	Hashes    []uint64 // position hashes since the last capture or pawn move (for the bot)
 88	State     State
 89	Reason    string  // how the game ended
 90	DrawOffer address // player with a pending draw offer
 91	CreatedAt int64   // block height
 92	UpdatedAt int64
 93}
 94
 95var (
 96	games       = avl.NewTree() // padded id -> *Game
 97	openGames   = avl.NewTree() // padded id -> *Game, challenges awaiting an opponent
 98	playerGames = avl.NewTree() // address -> []int
 99	nextID      = 1
100	realmPath   string // e.g. "/r/g1um2pwwnx3gn8p3ryfdert8mhpu4u4up49sc9ru/minichess", for links
101)
102
103func init() {
104	p := unsafe.CurrentRealm().PkgPath()
105	realmPath = strings.TrimPrefix(p, "gno.land")
106}
107
108// ---------------------------------------------------------------------------
109// Public, state-changing functions (crossing).
110
111// NewSoloGame starts a game against the bot.
112// color: "white", "black" or "random". level: 1 (easy) to 3 (hard).
113// If you play black, the bot makes the first move immediately.
114func NewSoloGame(cur realm, color string, level int) int {
115	caller := cur.Previous().Address()
116	if level < mc.MinLevel || level > mc.MaxLevel {
117		panic("level must be between " + strconv.Itoa(mc.MinLevel) + " and " + strconv.Itoa(mc.MaxLevel))
118	}
119	side := parseColor(color)
120
121	g := newGame(ModeSolo, caller)
122	g.BotLevel = level
123	g.State = StateActive
124	if side == mc.White {
125		g.White = caller
126	} else {
127		g.Black = caller
128	}
129	saveGame(g)
130	addPlayerGame(caller, g.ID)
131	chain.Emit("GameCreated", "id", strconv.Itoa(g.ID), "mode", "solo", "player", caller.String())
132
133	if side == mc.Black {
134		botMove(g)
135	}
136	return g.ID
137}
138
139// Challenge creates a two-player game. opponent may be empty (anyone can
140// accept) or a g1... address (only they can accept). color is the
141// challenger's side: "white", "black" or "random".
142func Challenge(cur realm, opponent string, color string) int {
143	caller := cur.Previous().Address()
144	side := parseColor(color)
145
146	var invitee address
147	opponent = strings.TrimSpace(opponent)
148	if opponent != "" {
149		invitee = address(opponent)
150		if !invitee.IsValid() {
151			panic("invalid opponent address")
152		}
153		if invitee == caller {
154			panic("you cannot challenge yourself")
155		}
156	}
157
158	g := newGame(ModeTwoPlayer, caller)
159	g.Invitee = invitee
160	if side == mc.White {
161		g.White = caller
162	} else {
163		g.Black = caller
164	}
165	saveGame(g)
166	openGames.Set(gameKey(g.ID), g)
167	addPlayerGame(caller, g.ID)
168	if invitee != "" {
169		addPlayerGame(invitee, g.ID)
170	}
171	chain.Emit("ChallengeCreated", "id", strconv.Itoa(g.ID), "creator", caller.String(), "invitee", invitee.String())
172	return g.ID
173}
174
175// AcceptChallenge takes the open seat in a waiting two-player game.
176func AcceptChallenge(cur realm, gameID int) {
177	caller := cur.Previous().Address()
178	g := mustGetGame(gameID)
179	if g.State != StateWaiting {
180		panic("game is not waiting for an opponent")
181	}
182	if caller == g.Creator {
183		panic("you cannot accept your own challenge")
184	}
185	if g.Invitee != "" && caller != g.Invitee {
186		panic("this challenge is for another player")
187	}
188	if g.White == "" {
189		g.White = caller
190	} else {
191		g.Black = caller
192	}
193	g.State = StateActive
194	openGames.Remove(gameKey(g.ID))
195	touch(g)
196	if g.Invitee == "" {
197		addPlayerGame(caller, g.ID)
198	}
199	chain.Emit("ChallengeAccepted", "id", strconv.Itoa(g.ID), "player", caller.String())
200}
201
202// CancelChallenge withdraws a challenge nobody has accepted yet.
203func CancelChallenge(cur realm, gameID int) {
204	caller := cur.Previous().Address()
205	g := mustGetGame(gameID)
206	if caller != g.Creator {
207		panic("only the creator can cancel this challenge")
208	}
209	if g.State != StateWaiting {
210		panic("only waiting challenges can be cancelled")
211	}
212	g.State = StateCancelled
213	g.Reason = "challenge withdrawn"
214	openGames.Remove(gameKey(g.ID))
215	touch(g)
216	chain.Emit("ChallengeCancelled", "id", strconv.Itoa(g.ID))
217}
218
219// Move plays a move, e.g. "e2e4", "e1g1" (castling) or "e7e8q". In a solo game the bot
220// answers in the same transaction. Returns a short summary.
221func Move(cur realm, gameID int, move string) string {
222	caller := cur.Previous().Address()
223	g := mustGetGame(gameID)
224	if g.State != StateActive {
225		panic("game is not in progress (" + g.State.String() + ")")
226	}
227	if g.playerToMove() != caller {
228		if !g.IsPlayer(caller) {
229			panic("you are not a player in this game")
230		}
231		panic("it is not your turn")
232	}
233
234	m, err := mc.ParseMove(move)
235	if err != nil {
236		panic(err.Error())
237	}
238	lm, ok := g.Pos.FindLegal(m)
239	if !ok {
240		panic("illegal move: " + m.String())
241	}
242	g.apply(lm)
243	if g.DrawOffer != "" && g.DrawOffer != caller {
244		g.DrawOffer = "" // moving instead of accepting declines the offer
245	}
246	summary := "you played " + lm.String()
247
248	if g.Mode == ModeSolo && g.State == StateActive {
249		if bm, ok := botMove(g); ok {
250			summary += ", bot played " + bm
251		}
252	}
253	touch(g)
254	if g.State.Finished() {
255		summary += ". Game over: " + g.State.String() + " (" + g.Reason + ")"
256	}
257	return summary
258}
259
260// Resign ends the game as a loss for the caller.
261func Resign(cur realm, gameID int) {
262	caller := cur.Previous().Address()
263	g := mustGetGame(gameID)
264	if g.State != StateActive {
265		panic("game is not in progress")
266	}
267	switch caller {
268	case g.White:
269		g.finish(StateBlackWon, "white resigned")
270	case g.Black:
271		g.finish(StateWhiteWon, "black resigned")
272	default:
273		panic("you are not a player in this game")
274	}
275	touch(g)
276}
277
278// OfferDraw offers a draw in a two-player game. If your opponent has
279// already offered one, this accepts it.
280func OfferDraw(cur realm, gameID int) {
281	caller := cur.Previous().Address()
282	g := mustGetGame(gameID)
283	g.assertActiveTwoPlayer(caller)
284	if g.DrawOffer != "" && g.DrawOffer != caller {
285		g.finish(StateDraw, "draw agreed")
286	} else {
287		g.DrawOffer = caller
288		chain.Emit("DrawOffered", "id", strconv.Itoa(g.ID), "by", caller.String())
289	}
290	touch(g)
291}
292
293// AcceptDraw accepts your opponent's pending draw offer.
294func AcceptDraw(cur realm, gameID int) {
295	caller := cur.Previous().Address()
296	g := mustGetGame(gameID)
297	g.assertActiveTwoPlayer(caller)
298	if g.DrawOffer == "" || g.DrawOffer == caller {
299		panic("there is no draw offer from your opponent")
300	}
301	g.finish(StateDraw, "draw agreed")
302	touch(g)
303}
304
305// ---------------------------------------------------------------------------
306// Read-only helpers (callable with vm/qeval).
307
308// GameFEN returns the current position of a game in FEN notation.
309func GameFEN(gameID int) string {
310	return mustGetGame(gameID).Pos.FEN()
311}
312
313// GameStatus returns a one-line status for a game.
314func GameStatus(gameID int) string {
315	g := mustGetGame(gameID)
316	s := g.State.String()
317	if g.State == StateActive {
318		s += ", " + g.Pos.Turn.String() + " to move"
319	}
320	if g.Reason != "" {
321		s += " (" + g.Reason + ")"
322	}
323	return s
324}
325
326// LegalMoves returns the legal moves of a game as a space-separated list.
327func LegalMoves(gameID int) string {
328	g := mustGetGame(gameID)
329	if g.State != StateActive {
330		return ""
331	}
332	out := []string{}
333	for _, m := range g.Pos.LegalMoves() {
334		out = append(out, m.String())
335	}
336	return strings.Join(out, " ")
337}
338
339// ---------------------------------------------------------------------------
340// Internals.
341
342func newGame(mode Mode, creator address) *Game {
343	h := runtime.ChainHeight()
344	g := &Game{
345		ID:        nextID,
346		Mode:      mode,
347		Creator:   creator,
348		Pos:       mc.NewGame(),
349		State:     StateWaiting,
350		CreatedAt: h,
351		UpdatedAt: h,
352	}
353	g.History = []string{g.Pos.BoardKey()}
354	g.Hashes = []uint64{g.Pos.Hash()}
355	nextID++
356	return g
357}
358
359func parseColor(color string) mc.Color {
360	switch strings.ToLower(strings.TrimSpace(color)) {
361	case "white", "w", "":
362		return mc.White
363	case "black", "b":
364		return mc.Black
365	case "random", "r":
366		// Deterministic but unpredictable enough for picking a side.
367		if (runtime.ChainHeight()+int64(nextID))%2 == 0 {
368			return mc.White
369		}
370		return mc.Black
371	}
372	panic("color must be white, black or random")
373}
374
375func gameKey(id int) string {
376	s := strconv.Itoa(id)
377	for len(s) < 10 {
378		s = "0" + s
379	}
380	return s
381}
382
383func saveGame(g *Game) { games.Set(gameKey(g.ID), g) }
384
385func getGame(id int) (*Game, bool) {
386	v := games.Get(gameKey(id))
387	if v == nil {
388		return nil, false
389	}
390	return v.(*Game), true
391}
392
393func mustGetGame(id int) *Game {
394	g, ok := getGame(id)
395	if !ok {
396		panic("game " + strconv.Itoa(id) + " not found")
397	}
398	return g
399}
400
401func addPlayerGame(a address, id int) {
402	var ids []int
403	if v := playerGames.Get(a.String()); v != nil {
404		ids = v.([]int)
405	}
406	playerGames.Set(a.String(), append(ids, id))
407}
408
409func touch(g *Game) { g.UpdatedAt = runtime.ChainHeight() }
410
411// IsPlayer reports whether a is one of the game's human players.
412func (g *Game) IsPlayer(a address) bool {
413	return a != "" && (a == g.White || a == g.Black)
414}
415
416// playerToMove is the address whose turn it is ("" = the bot).
417func (g *Game) playerToMove() address {
418	if g.Pos.Turn == mc.White {
419		return g.White
420	}
421	return g.Black
422}
423
424func (g *Game) assertActiveTwoPlayer(caller address) {
425	if g.Mode != ModeTwoPlayer {
426		panic("draw offers are only for two-player games; resign to end a bot game")
427	}
428	if g.State != StateActive {
429		panic("game is not in progress")
430	}
431	if !g.IsPlayer(caller) {
432		panic("you are not a player in this game")
433	}
434}
435
436// apply plays a legal move and updates the game result.
437func (g *Game) apply(m mc.Move) {
438	irreversible := g.Pos.IsIrreversible(m)
439	g.Pos = g.Pos.Play(m)
440	g.Moves = append(g.Moves, m.String())
441	key := g.Pos.BoardKey()
442	h := g.Pos.Hash()
443	if irreversible {
444		g.History = []string{key}
445		g.Hashes = []uint64{h}
446	} else {
447		g.History = append(g.History, key)
448		g.Hashes = append(g.Hashes, h)
449	}
450
451	switch g.Pos.Status() {
452	case mc.Checkmate:
453		if g.Pos.Turn == mc.White {
454			g.finish(StateBlackWon, "checkmate")
455		} else {
456			g.finish(StateWhiteWon, "checkmate")
457		}
458		return
459	case mc.Stalemate:
460		g.finish(StateDraw, "stalemate")
461		return
462	case mc.InsufficientMaterial:
463		g.finish(StateDraw, "insufficient material")
464		return
465	case mc.FiftyMoveDraw:
466		g.finish(StateDraw, "50-move rule")
467		return
468	}
469	reps := 0
470	for _, k := range g.History {
471		if k == key {
472			reps++
473		}
474	}
475	if reps >= 3 {
476		g.finish(StateDraw, "threefold repetition")
477	}
478}
479
480func (g *Game) finish(s State, reason string) {
481	g.State = s
482	g.Reason = reason
483	g.DrawOffer = ""
484	chain.Emit("GameOver", "id", strconv.Itoa(g.ID), "result", s.String(), "reason", reason)
485}
486
487// botMove lets the bot play if it is its turn in an active solo game.
488func botMove(g *Game) (string, bool) {
489	if g.Mode != ModeSolo || g.State != StateActive || g.playerToMove() != "" {
490		return "", false
491	}
492	seed := g.ID*1009 + len(g.Moves)
493	m, ok := mc.BestMoveWithHistory(g.Pos, g.BotLevel, seed, g.Hashes)
494	if !ok {
495		return "", false
496	}
497	g.apply(m)
498	return m.String(), true
499}