Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP feat: add tic-tac-toe experiment #613

Draft
wants to merge 11 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions examples/gno.land/r/demo/games/tictactoe/game.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package tictactoe

import (
"errors"
"std"
)

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
}

// 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 errors.New("slot already used")
}

// 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) checkLastMoveWon(posX, posY int) bool {
// assumes the game wasn't won yet, and that's 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 tl == c && c == br {
return true
}
if 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) IsDraw() bool { return g.turnCtr >= 8 && g.winnerIdx == 0 }
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 := 0; y < 3; 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 x*3 + y }
87 changes: 87 additions & 0 deletions examples/gno.land/r/demo/games/tictactoe/game_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package tictactoe

import (
"strings"
"testing"

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

func TestGame(t *testing.T) {
var (
addr1 = testutils.TestAddress("addr1")
addr2 = testutils.TestAddress("addr2")
addr3 = testutils.TestAddress("addr3")
)

game, err := NewGame(addr1, addr1)
assertErr(t, err)

game, err = NewGame(addr2, addr3)
assertNoErr(t, err)
assertFalse(t, game.IsOver())
assertFalse(t, game.IsDraw())
assertErr(t, game.Play(addr3, 0, 0)) // addr2's turn
assertErr(t, game.Play(addr2, -1, 0)) // invalid location
assertErr(t, game.Play(addr2, 3, 0)) // invalid location
assertErr(t, game.Play(addr2, 0, -1)) // invalid location
uassert.ErrorContains(t, game.Play(addr2, 0, 3), "game not started") // invalid location
// assertNoErr(t,
game.start()
uassert.NoError(t, game.Play(addr2, 1, 1)) // first move
assertErr(t, game.Play(addr2, 2, 2)) // addr3's turn
assertErr(t, game.Play(addr3, 1, 1)) // slot already used
assertNoErr(t, game.Play(addr3, 0, 0)) // second move
assertNoErr(t, game.Play(addr2, 1, 2)) // third move
assertNoErr(t, game.Play(addr3, 0, 1)) // fourth move
assertFalse(t, game.IsOver())
assertNoErr(t, game.Play(addr2, 1, 0)) // fifth move (win)
assertTrue(t, game.IsOver())
assertFalse(t, game.IsDraw())

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

func assertEqualString(t *testing.T, expected, got string) {
t.Helper()
expected = strings.TrimSpace(expected)
got = strings.TrimSpace(got)
if expected != got {
t.Errorf("expected %q, got %q", expected, got)
}
}

func assertNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Errorf("got err: %v", err)
}
}

func assertErr(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Errorf("expected an error, got nil")
}
}

func assertTrue(t *testing.T, val bool) {
t.Helper()
if !val {
t.Errorf("expected true, got false")
}
}

func assertFalse(t *testing.T, val bool) {
t.Helper()
if val {
t.Errorf("expected false, got true")
}
}
6 changes: 6 additions & 0 deletions examples/gno.land/r/demo/games/tictactoe/gno.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module gno.land/r/demo/games/tictactoe

require (
gno.land/p/demo/avl v0.0.0-latest
gno.land/p/demo/testutils v0.0.0-latest
)
85 changes: 85 additions & 0 deletions examples/gno.land/r/demo/games/tictactoe/tictactoe.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package tictactoe

import (
"errors"
"std"
"strconv"
"strings"

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

var (
gameCtr int // game counter
games avl.Tree // int(gameID) -> Game
)

// Challenge creates a new game, and return a gameID.
//
// If ugnot are sent, they will be stored for bet and require the opponent to send the same amount.
func Challenge(opponent std.Address) string {
caller := std.GetOrigCaller()
g, err := NewGame(caller, opponent)
if err != nil {
panic(err)
}
// TODO: handle "sent gnots" for bets.

gameCtr++
gameID := strconv.Itoa(gameCtr)

games.Set(gameID, g)
return gameID
}

// Join joins a previously created game.
//
// Caller should have be the opponent of gameID.
func Join(gameID string) {
caller := std.GetOrigCaller()

g, err := getGameByID(gameID)
if err != nil {
panic("no such game: " + err.Error())
}
// TODO: handle "sent".
// TODO: determines starting player randomly.
// TODO: check for already accepted.

if g.player2 != caller {
panic("only invited opponent can join the game.")
}

g.start()
games.Set(gameID, g)
}

func getGameByID(id string) (*Game, error) {
obj, found := games.Get(id)
if !found {
return nil, errors.New("game not found.")
}
return obj.(*Game), nil
}

func Render(path string) string {
path = strings.TrimSpace(path)
parts := strings.Split(path, "/")
partN := len(parts)

switch {
case partN == 0:
// TODO: leaderboard
// TODO: "new game" link
// TODO: "join challenge" link
// TODO: vanity metrics
// TODO: last N active games
return "home"
case partN == 2 && parts[0] == "game":
gameID := parts[1]
_ = gameID
// FIXME: continue implementation
// games.Get()
}
return "404"
}
7 changes: 7 additions & 0 deletions examples/gno.land/r/demo/games/tictactoe/tictactoe_test.gno
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package tictactoe

import "testing"

func TestRender(t *testing.T) {

}
Loading