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}