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}