chess.gno

12.68 Kb · 538 lines
  1// Realm chess implements a Gno chess server.
  2package chess
  3
  4import (
  5	"errors"
  6	"std"
  7	"time"
  8
  9	"gno.land/p/demo/avl"
 10	"gno.land/p/demo/seqid"
 11	"gno.land/p/morgan/chess"
 12)
 13
 14// realm state
 15var (
 16	// (not "games" because that's too useful a variable name)
 17	gameStore     avl.Tree // string (game ID) -> *Game
 18	gameIDCounter seqid.ID
 19
 20	// Value must be sorted by game ID, descending
 21	user2Games avl.Tree // std.Address -> []*Game
 22)
 23
 24// Game represents a chess game.
 25type Game struct {
 26	ID string `json:"id"`
 27
 28	White    std.Address    `json:"white"`
 29	Black    std.Address    `json:"black"`
 30	Position chess.Position `json:"position"`
 31	State    GameState      `json:"state"`
 32	Winner   Winner         `json:"winner"`
 33
 34	Creator     std.Address  `json:"creator"`
 35	CreatedAt   time.Time    `json:"created_at"`
 36	DrawOfferer *std.Address `json:"draw_offerer"` // set on draw offers
 37	Concluder   *std.Address `json:"concluder"`    // set on non-auto draws, and aborts
 38
 39	Time *TimeControl `json:"time"`
 40}
 41
 42func (g Game) json() string {
 43	s, err := g.MarshalJSON()
 44	checkErr(err)
 45	return string(s)
 46}
 47
 48// Winner represents the "direct" outcome of a game
 49// (white, black or draw?)
 50type Winner byte
 51
 52const (
 53	WinnerNone Winner = iota
 54	WinnerWhite
 55	WinnerBlack
 56	WinnerDraw
 57)
 58
 59var winnerString = [...]string{
 60	WinnerNone:  "none",
 61	WinnerWhite: "white",
 62	WinnerBlack: "black",
 63	WinnerDraw:  "draw",
 64}
 65
 66// GameState represents the current game state.
 67type GameState byte
 68
 69const (
 70	GameStateInvalid = iota
 71
 72	GameStateOpen
 73
 74	// "automatic" endgames following moves
 75	GameStateCheckmated
 76	GameStateStalemate
 77	GameStateDrawn75Move
 78	GameStateDrawn5Fold
 79
 80	// single-party draws
 81	GameStateDrawn50Move
 82	GameStateDrawn3Fold
 83	GameStateDrawnInsufficient
 84
 85	// timeout by either player
 86	GameStateTimeout
 87	// aborted within first two moves
 88	GameStateAborted
 89	// resignation by either player
 90	GameStateResigned
 91	// draw by agreement
 92	GameStateDrawnByAgreement
 93)
 94
 95var gameStatesSnake = [...]string{
 96	GameStateInvalid:           "invalid",
 97	GameStateOpen:              "open",
 98	GameStateCheckmated:        "checkmated",
 99	GameStateStalemate:         "stalemate",
100	GameStateDrawn75Move:       "drawn_75_move",
101	GameStateDrawn5Fold:        "drawn_5_fold",
102	GameStateDrawn50Move:       "drawn_50_move",
103	GameStateDrawn3Fold:        "drawn_3_fold",
104	GameStateDrawnInsufficient: "drawn_insufficient",
105	GameStateTimeout:           "timeout",
106	GameStateAborted:           "aborted",
107	GameStateResigned:          "resigned",
108	GameStateDrawnByAgreement:  "drawn_by_agreement",
109}
110
111// IsFinished returns whether the game is in a finished state.
112func (g GameState) IsFinished() bool {
113	return g != GameStateOpen
114}
115
116// NewGame initialized a new game with the given opponent.
117// opponent may be a bech32 address or "@user" (r/demo/users).
118//
119// seconds and increment specifies the time control for the given game.
120// seconds is the amount of time given to play to each player; increment
121// is by how many seconds the player's time should be increased when they make a move.
122// seconds <= 0 means no time control (correspondence).
123//
124// XXX: Disabled for GnoChess production temporarily. (prefixed with x for unexported)
125// Ideally, we'd need this to work either by not forcing users not to have
126// parallel games OR by introducing a "request" system, so that a game is not
127// immediately considered "open" when calling NewGame.
128func xNewGame(cur realm, opponentRaw string, seconds, increment int) string {
129	assertOriginCall()
130	if seconds >= 0 && increment < 0 {
131		panic("negative increment invalid")
132	}
133
134	opponent := parsePlayer(opponentRaw)
135	caller := std.PreviousRealm().Address()
136	assertUserNotInLobby(caller)
137
138	return newGame(caller, opponent, seconds, increment).json()
139}
140
141func getUserGames(user std.Address) []*Game {
142	val, exist := user2Games.Get(user.String())
143	if !exist {
144		return nil
145	}
146	return val.([]*Game)
147}
148
149func assertGamesFinished(games []*Game) {
150	for _, g := range games {
151		if g.State.IsFinished() {
152			continue
153		}
154		err := g.claimTimeout()
155		if err != nil {
156			panic("can't start new game: game " + g.ID + " is not yet finished")
157		}
158	}
159}
160
161func newGame(caller, opponent std.Address, seconds, increment int) *Game {
162	games := getUserGames(caller)
163	// Ensure player has no ongoing games.
164	assertGamesFinished(games)
165	assertGamesFinished(getUserGames(opponent))
166
167	if caller == opponent {
168		panic("can't create a game with yourself")
169	}
170
171	isBlack := determineColor(games, caller, opponent)
172
173	// id is zero-padded to work well with avl's alphabetic order.
174	id := gameIDCounter.Next().String()
175	g := &Game{
176		ID:        id,
177		White:     caller,
178		Black:     opponent,
179		Position:  chess.NewPosition(),
180		State:     GameStateOpen,
181		Creator:   caller,
182		CreatedAt: time.Now(),
183		Time:      NewTimeControl(seconds, increment),
184	}
185	if isBlack {
186		g.White, g.Black = g.Black, g.White
187	}
188
189	gameStore.Set(g.ID, g)
190	addToUser2Games(caller, g)
191	addToUser2Games(opponent, g)
192
193	return g
194}
195
196func addToUser2Games(addr std.Address, game *Game) {
197	var games []*Game
198	v, ok := user2Games.Get(string(addr))
199	if ok {
200		games = v.([]*Game)
201	}
202	// game must be at top, because it is the latest ID
203	games = append([]*Game{game}, games...)
204	user2Games.Set(string(addr), games)
205}
206
207func determineColor(games []*Game, caller, opponent std.Address) (isBlack bool) {
208	// fast path for no games
209	if len(games) == 0 {
210		return false
211	}
212
213	// Determine color of player. If the player has already played with
214	// opponent, invert from last game played among them.
215	// Otherwise invert from last game played by the player.
216	isBlack = games[0].White == caller
217
218	// "try" to save gas if the user has really a lot of past games
219	if len(games) > 256 {
220		games = games[:256]
221	}
222	for _, game := range games {
223		if game.White == opponent || game.Black == opponent {
224			return game.White == caller
225		}
226	}
227	return
228}
229
230// GetGame returns a game, knowing its ID.
231func GetGame(id string) string {
232	return getGame(id, false).json()
233}
234
235func getGame(id string, wantOpen bool) *Game {
236	graw, ok := gameStore.Get(id)
237	if !ok {
238		panic("game not found")
239	}
240	g := graw.(*Game)
241	if wantOpen && g.State.IsFinished() {
242		panic("game is already finished")
243	}
244	return g
245}
246
247// MakeMove specifies a move to be done on the given game, specifying in
248// algebraic notation the square where to move the piece.
249// If the piece is a pawn which is moving to the last row, a promotion piece
250// must be specified.
251// Castling is specified by indicating the king's movement.
252func MakeMove(cur realm, gameID, from, to string, promote chess.Piece) string {
253	assertOriginCall()
254	g := getGame(gameID, true)
255
256	// determine if this is a black move
257	isBlack := len(g.Position.Moves)%2 == 1
258
259	caller := std.PreviousRealm().Address()
260	if (isBlack && g.Black != caller) ||
261		(!isBlack && g.White != caller) {
262		// either not a player involved; or not the caller's turn.
263		panic("you are not allowed to make a move at this time")
264	}
265
266	// game is time controlled? add move to time control
267	if g.Time != nil {
268		valid := g.Time.AddMove()
269		if !valid && len(g.Position.Moves) < 2 {
270			g.State = GameStateAborted
271			g.Concluder = &caller
272			g.Winner = WinnerNone
273			return g.json()
274		}
275		if !valid {
276			g.State = GameStateTimeout
277			if caller == g.White {
278				g.Winner = WinnerBlack
279			} else {
280				g.Winner = WinnerWhite
281			}
282			g.saveResult()
283			return g.json()
284		}
285	}
286
287	// validate move
288	m := chess.Move{
289		From: chess.SquareFromString(from),
290		To:   chess.SquareFromString(to),
291	}
292	if m.From == chess.SquareInvalid || m.To == chess.SquareInvalid {
293		panic("invalid from/to square")
294	}
295	if promote > 0 && promote <= chess.PieceKing {
296		m.Promotion = promote
297	}
298	newp, ok := g.Position.ValidateMove(m)
299	if !ok {
300		panic("illegal move")
301	}
302
303	// add move and record new board
304	g.Position = newp
305
306	o := newp.IsFinished()
307	if o == chess.NotFinished {
308		// opponent of draw offerer has made a move. take as implicit rejection of draw.
309		if g.DrawOfferer != nil && *g.DrawOfferer != caller {
310			g.DrawOfferer = nil
311		}
312
313		return g.json()
314	}
315
316	switch {
317	case o == chess.Checkmate && isBlack:
318		g.State = GameStateCheckmated
319		g.Winner = WinnerBlack
320	case o == chess.Checkmate && !isBlack:
321		g.State = GameStateCheckmated
322		g.Winner = WinnerWhite
323	case o == chess.Stalemate:
324		g.State = GameStateStalemate
325		g.Winner = WinnerDraw
326
327	case o == chess.Drawn75Move:
328		g.State = GameStateDrawn75Move
329		g.Winner = WinnerDraw
330	case o == chess.Drawn5Fold:
331		g.State = GameStateDrawn5Fold
332		g.Winner = WinnerDraw
333	}
334	g.DrawOfferer = nil
335	g.saveResult()
336
337	return g.json()
338}
339
340func (g *Game) claimTimeout() error {
341	// no assert origin call or caller check: anyone can claim a game to have
342	// finished in timeout.
343
344	if g.Time == nil {
345		return errors.New("game is not time controlled")
346	}
347
348	// game is time controlled? add move to time control
349	to := g.Time.TimedOut()
350	if !to {
351		return errors.New("game is not timed out")
352	}
353
354	if nmov := len(g.Position.Moves); nmov < 2 {
355		g.State = GameStateAborted
356		if nmov == 1 {
357			g.Concluder = &g.Black
358		} else {
359			g.Concluder = &g.White
360		}
361		g.Winner = WinnerNone
362		return nil
363	}
364
365	g.State = GameStateTimeout
366	if len(g.Position.Moves)&1 == 0 {
367		g.Winner = WinnerBlack
368	} else {
369		g.Winner = WinnerWhite
370	}
371	g.DrawOfferer = nil
372	g.saveResult()
373
374	return nil
375}
376
377// ClaimTimeout should be called when the caller believes the game has resulted
378// in a timeout.
379func ClaimTimeout(cur realm, gameID string) string {
380	g := getGame(gameID, true)
381	err := g.claimTimeout()
382	checkErr(err)
383
384	return g.json()
385}
386
387func Abort(cur realm, gameID string) string {
388	assertOriginCall()
389	g := getGame(gameID, true)
390	err := abort(g)
391	if err != nil {
392		panic(err.Error())
393	}
394	return g.json()
395}
396
397func abort(g *Game) error {
398	if len(g.Position.Moves) >= 2 {
399		return errors.New("game can no longer be aborted; if you wish to quit, resign")
400	}
401
402	caller := std.PreviousRealm().Address()
403	if caller != g.White && caller != g.Black {
404		return errors.New("you are not involved in this game")
405	}
406	g.State = GameStateAborted
407	g.Concluder = &caller
408	g.DrawOfferer = nil
409	g.Winner = WinnerNone
410
411	return nil
412}
413
414func Resign(cur realm, gameID string) string {
415	assertOriginCall()
416	g := getGame(gameID, true)
417	err := resign(g)
418	if err != nil {
419		panic(err.Error())
420	}
421	return g.json()
422}
423
424func resign(g *Game) error {
425	if len(g.Position.Moves) < 2 {
426		return abort(g)
427	}
428	caller := std.PreviousRealm().Address()
429	switch caller {
430	case g.Black:
431		g.State = GameStateResigned
432		g.Winner = WinnerWhite
433	case g.White:
434		g.State = GameStateResigned
435		g.Winner = WinnerBlack
436	default:
437		return errors.New("you are not involved in this game")
438	}
439	g.DrawOfferer = nil
440	g.saveResult()
441
442	return nil
443}
444
445// DrawOffer creates a draw offer in the current game, if one doesn't already
446// exist.
447func DrawOffer(cur realm, gameID string) string {
448	assertOriginCall()
449	g := getGame(gameID, true)
450	caller := std.PreviousRealm().Address()
451
452	switch {
453	case caller != g.Black && caller != g.White:
454		panic("you are not involved in this game")
455	case g.DrawOfferer != nil:
456		panic("a draw offer in this game already exists")
457	}
458
459	g.DrawOfferer = &caller
460	return g.json()
461}
462
463// DrawRefuse refuse a draw offer in the given game.
464func DrawRefuse(cur realm, gameID string) string {
465	assertOriginCall()
466	g := getGame(gameID, true)
467	caller := std.PreviousRealm().Address()
468
469	switch {
470	case caller != g.Black && caller != g.White:
471		panic("you are not involved in this game")
472	case g.DrawOfferer == nil:
473		panic("no draw offer present")
474	case *g.DrawOfferer == caller:
475		panic("can't refuse an offer you sent yourself")
476	}
477
478	g.DrawOfferer = nil
479	return g.json()
480}
481
482// Draw implements draw by agreement, as well as "single-party" draw:
483// - Threefold repetition (§9.2)
484// - Fifty-move rule (§9.3)
485// - Insufficient material (§9.4)
486// Note: stalemate happens as a consequence of a Move, and thus is handled in that function.
487func Draw(cur realm, gameID string) string {
488	assertOriginCall()
489	g := getGame(gameID, true)
490
491	caller := std.PreviousRealm().Address()
492	if caller != g.Black && caller != g.White {
493		panic("you are not involved in this game")
494	}
495
496	// accepted draw offer (do early to avoid gas for g.Position.IsFinished())
497	if g.DrawOfferer != nil && *g.DrawOfferer != caller {
498		g.State = GameStateDrawnByAgreement
499		g.Winner = WinnerDraw
500		g.Concluder = &caller
501
502		g.saveResult()
503
504		return g.json()
505	}
506
507	o := g.Position.IsFinished()
508	switch {
509	case o&chess.Can50Move != 0:
510		g.State = GameStateDrawn50Move
511	case o&chess.Can3Fold != 0:
512		g.State = GameStateDrawn3Fold
513	case o&chess.CanInsufficient != 0:
514		g.State = GameStateDrawnInsufficient
515	default:
516		panic("this game can't be automatically drawn")
517	}
518	g.Concluder = &caller
519	g.Winner = WinnerDraw
520	g.DrawOfferer = nil
521
522	g.saveResult()
523
524	return g.json()
525}
526
527func checkErr(err error) {
528	if err != nil {
529		panic(err.Error())
530	}
531}
532
533// Replacement for OriginCall using std.PreviousRealm().
534func assertOriginCall() {
535	if !std.PreviousRealm().IsUser() {
536		panic("invalid non-origin call")
537	}
538}