Skip to content

Commit

Permalink
feat(examples): add stateless r/demo/games/tictactoe 1P-VS-CPU
Browse files Browse the repository at this point in the history
- add p/demo/tictactoe (basically @moul's model gnolang#613)
- add p/demo/tictactoe1p (human VS cpu logic, extending the above)
- add p/demo/ternary (to cope w/ not having C `a ? b : c` ternary operator)
- add r/demo/games (start addressing gnolang#611)
- add r/demo/games/tictactoe

This last realm is a playable demo
against a parrot which, somehow learned
how to play Tic-tac-toe.

This is a stateless realm which uses gnoweb
as a webserver and uses css to offer a game-like
experience without javascript.

this depends on gnolang#2553 (improved ufmt)
  • Loading branch information
grepsuzette committed Jul 9, 2024
1 parent d28253d commit 6b42940
Show file tree
Hide file tree
Showing 19 changed files with 1,071 additions and 0 deletions.
217 changes: 217 additions & 0 deletions examples/gno.land/p/demo/tictactoe/game.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package tictactoe

import (
"errors"
"std"

"gno.land/p/demo/ufmt"
)

// this file is @moul's work in #613
// a few changes and bugfixes have been made

type Game struct {
player1, player2 std.Address
board [9]rune // 0=empty, 1=player1, 2=player2
turnCtr int
winnerIdx int
}

func NewGame(player1, player2 std.Address) (*Game, error) {
if player1 == player2 {
return nil, errors.New("cannot fight against self")
}

g := Game{
player1: player1,
player2: player2,
winnerIdx: -1,
turnCtr: -1,
}
return &g, nil
}

// Partially recover a game
// The game is guaranteed to be legit in terms of number of tiles 1 and 2
// No winning detection is implemented here however
func RecoverGame(player1, player2 std.Address, board string) (*Game, error) {
g, e := NewGame(player1, player2)
if e != nil {
return nil, e
}
if len(board) != 9 {
return nil, ufmt.Errorf("invalid board length: %d", len(board))
}
num1, num2 := 0, 0
runes := [9]rune{}
for i, c := range board {
switch c {
case rune(0), '_', '-':
runes[i] = rune(0)
case rune(1), 'O', 'o':
num1 += 1
runes[i] = rune(1)
case rune(2), 'X', 'x':
num2 += 1
runes[i] = rune(2)
default:
return nil, errors.New("invalid rune")
}
}
if num1 != num2 && num1 != num2+1 {
return nil, errors.New("invalid number of x and o")
}
g.board = runes
g.turnCtr = num1 + num2
g.winnerIdx = -1
return g, nil
}

// start sets turnCtr to 0.
func (g *Game) start() {
if g.turnCtr != -1 {
panic("game already started")
}
g.turnCtr = 0
}

func (g *Game) Play(player std.Address, posX, posY int) error {
if !g.Started() {
return errors.New("game not started")
}

if g.Turn() != player {
return errors.New("invalid turn")
}

if g.IsOver() {
return errors.New("game over")
}

// are posX and posY valid
if posX < 0 || posY < 0 || posX > 2 || posY > 2 {
return errors.New("posX and posY should be 0, 1 or 2")
}

// is slot already used?
idx := xyToIdx(posX, posY)
if g.board[idx] != 0 {
return ufmt.Errorf("slot already used (%d, %d)", posX, posY)
}

// play
playerVal := rune(g.turnCtr%2) + 1 // player1=1, player2=2
g.board[idx] = playerVal

// check if win
if g.checkLastMoveWon(posX, posY) {
g.winnerIdx = g.turnCtr
}

// change turn
g.turnCtr++
return nil
}

func (g Game) WouldWin(side rune, x, y int) bool {
idx := xyToIdx(x, y)
if g.board[idx] != rune(0) {
panic("tile should be empty")
}
// place rune temporarily
g.board[idx] = side
b := g.checkLastMoveWon(x, y)
g.board[idx] = rune(0)
return b
}

func (g Game) checkLastMoveWon(posX, posY int) bool {
// assumes the game wasn't won yet, and that the move was already applied.

// check vertical line
{
a := g.At(posX, 0)
b := g.At(posX, 1)
c := g.At(posX, 2)
if a == b && b == c {
return true
}
}

// check horizontal line
{
a := g.At(0, posY)
b := g.At(1, posY)
c := g.At(2, posY)
if a == b && b == c {
return true
}
}

// diagonals
{
tl := g.At(0, 0)
tr := g.At(0, 2)
bl := g.At(2, 0)
br := g.At(2, 2)
c := g.At(1, 1)
if posX == posY && tl == c && c == br {
return true
}
if posX+posY == 2 && tr == c && c == bl {
return true
}
}
return false
}

func (g Game) At(posX, posY int) rune { return g.board[xyToIdx(posX, posY)] }
func (g Game) Winner() std.Address { return g.PlayerByIndex(g.winnerIdx) }
func (g Game) Turn() std.Address { return g.PlayerByIndex(g.turnCtr) }
func (g Game) TurnNumber() int { return g.turnCtr }
func (g Game) IsDraw() bool { return g.turnCtr > 8 && g.winnerIdx == -1 }
func (g Game) Started() bool { return g.turnCtr >= 0 }

func (g Game) IsOver() bool {
// draw
if g.turnCtr > 8 {
return true
}

// winner
return g.Winner() != std.Address("")
}

func (g Game) Output() string {
output := ""

for y := 2; y >= 0; y-- {
for x := 0; x < 3; x++ {
val := g.At(x, y)
switch val {
case 0:
output += "-"
case 1:
output += "O"
case 2:
output += "X"
}
}
output += "\n"
}

return output
}

func (g Game) PlayerByIndex(idx int) std.Address {
switch idx % 2 {
case 0:
return g.player1
case 1:
return g.player2
default:
return std.Address("")
}
}

func xyToIdx(x, y int) int { return y*3 + x }
81 changes: 81 additions & 0 deletions examples/gno.land/p/demo/tictactoe/game_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package tictactoe

import (
"strings"
"testing"

"gno.land/p/demo/testutils"
"gno.land/p/demo/uassert"
)

var (
addr1 = testutils.TestAddress("addr1")
addr2 = testutils.TestAddress("addr2")
addr3 = testutils.TestAddress("addr3")
)

func TestGame(t *testing.T) {
game, err := NewGame(addr1, addr1)
uassert.Error(t, err)

game, err = NewGame(addr2, addr3)
uassert.NoError(t, err)

uassert.False(t, game.IsOver())
uassert.False(t, game.IsDraw())
game.start()
uassert.Error(t, game.Play(addr3, 0, 0)) // addr2's turn
uassert.Error(t, game.Play(addr2, -1, 0)) // invalid location
uassert.Error(t, game.Play(addr2, 3, 0)) // invalid location
uassert.Error(t, game.Play(addr2, 0, -1)) // invalid location
uassert.Error(t, game.Play(addr2, 0, 3)) // invalid location
uassert.NoError(t, game.Play(addr2, 1, 1)) // first move
uassert.Error(t, game.Play(addr2, 2, 2)) // addr3's turn
uassert.Error(t, game.Play(addr3, 1, 1)) // slot already used
uassert.NoError(t, game.Play(addr3, 0, 0)) // second move
uassert.NoError(t, game.Play(addr2, 1, 2)) // third move
uassert.NoError(t, game.Play(addr3, 0, 1)) // fourth move
uassert.False(t, game.IsOver())
uassert.NoError(t, game.Play(addr2, 1, 0)) // fifth move (win)
uassert.True(t, game.IsOver())
uassert.False(t, game.IsDraw())

expected := `-O-
XO-
XO-
`
got := game.Output()
uassert.Equal(t, expected, got)
}

func TestRecoverGame(t *testing.T) {
for _, o := range []struct {
repr, err string
}{
{"", "error"},
{"--", "error"},
{"---", "error"},
{"-----", "error"},
{"--------", "error"},
{"---------", ""},
{"XX-------", "error"},
{"OO-------", "error"},
{"XO-X-----", "error"}, // O is first
{"XO-O-----", ""}, // valid from there on
{"XOXO-----", ""},
{"XOXOO----", ""},
{"XOXOO-X--", ""},
{"XOXOOOX--", ""}, // circles won but the function doesn't care
{"XOXOOOX-X", ""},
{"XOXOOOXOX", ""}, // circles won a second time
{"XOXOOOXOXX", "error"}, // too long (10 squares)
} {
g, e := RecoverGame(addr1, addr2, o.repr)
if o.err == "error" {
uassert.Error(t, e, "repr=", o.repr)
} else {
uassert.NoError(t, e, "repr=", o.repr)
uassert.True(t, g != nil, "repr=", o.repr)
}
}
}
6 changes: 6 additions & 0 deletions examples/gno.land/p/demo/tictactoe/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module gno.land/p/demo/tictactoe

require (
gno.land/p/demo/uassert v0.0.0-latest
gno.land/p/demo/ufmt v0.0.0-latest
)
Loading

0 comments on commit 6b42940

Please sign in to comment.