diff --git a/build/index.html b/build/index.html index 00eb541..c71b1e4 100644 --- a/build/index.html +++ b/build/index.html @@ -3,8 +3,80 @@ Tic-Tac-Toe + + + +
+ +
+

Tic-Tac-Toe: Ultimate Tournament

+
+ +
+ +
+

Who's playing?

+ + + +

+
+ +
+

Top scorers

+
+
    +
+
+
+ +
+ + + +
+ +
+
+

Previous games

+
+
    +
+
+
+
+ + diff --git a/package.json b/package.json index 9d6057e..ce69e4e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "babel-register": "^6.18.0", "backbone": "^1.3.3", "jasmine": "^2.5.2", + "jasmine-ajax": "^3.3.1", "jasmine-expect": "^3.0.1", "jquery": "^3.1.1", "webpack": "^1.13.3", diff --git a/spec/game.spec.js b/spec/game.spec.js new file mode 100644 index 0000000..c2e1a0b --- /dev/null +++ b/spec/game.spec.js @@ -0,0 +1,184 @@ +import Game from 'app/models/game'; + +describe("Game", function() { + + var game; + beforeEach(function() { + game = new Game(); + }); + + describe('initialize', function(){ + + it('a new game not given parameters should have the appropriate default values', function(){ + expect(game.get("players")).toEqual(["Player1", "Player2"]); + expect(game.get("board")).toEqual([" ", " ", " ", " ", " ", " ", " ", " ", " "]); + expect(game.get("outcome")).toEqual("in progress"); + }); + + it('a new game given player names should contain the correct names', function(){ + game = new Game({"players": ["Satine", "Lottie"]}); + expect(game.get("players")).toEqual(["Satine", "Lottie"]); + }); + + }); + + describe('pickStartingPlayer', function(){ + + it('pickStartingPlayer sets players to an array of length 2', function(){ + game.pickStartingPlayer(); + expect(game.get("players")).toBeArray(); + expect(game.get("players").length).toEqual(2); + }); + + it('both players are still present after pickStartingPlayer is called', function(){ + game = new Game({"players": ["Satine", "Lottie"]}); + game.pickStartingPlayer(); + expect(game.get("players")).toContain("Satine"); + expect(game.get("players")).toContain("Lottie"); + }); + + }); + + describe('currentPlayer', function(){ + + it('currentPlayer returns player 1 (first in players array) for a brand-new game', function(){ + game.pickStartingPlayer(); + expect(game.currentPlayer()).toEqual(game.get("players")[0]); + }); + + it('currentPlayer alternates as marks are placed on the board', function(){ + game.pickStartingPlayer(); + expect(game.currentPlayer()).toEqual(game.get("players")[0]); + game.setSquare(0,0); + expect(game.currentPlayer()).toEqual(game.get("players")[1]); + game.setSquare(0,1); + expect(game.currentPlayer()).toEqual(game.get("players")[0]); + game.setSquare(0,2); + expect(game.currentPlayer()).toEqual(game.get("players")[1]); + }); + + }); + + describe('setSquare', function(){ + + it('sets a square and returns true if the square is empty and valid', function(){ + var result = game.setSquare(0,0); + expect(result).toEqual(true); + expect(game.get("board")).toEqual(["X", " ", " ", " ", " ", " ", " ", " ", " "]); + result = game.setSquare(2,2); + expect(result).toEqual(true); + expect(game.get("board")).toEqual(["X", " ", " ", " ", " ", " ", " ", " ", "O"]); + }); + + it('returns false if attempting to set a square that is already filled', function(){ + game.setSquare(0,0); + var result = game.setSquare(0,0); + expect(result).toEqual(false); + }); + + it('returns false if attempting to set a square that is not on the board', function(){ + var result = game.setSquare(-1,-1); + expect(result).toEqual(false); + var result = game.setSquare(0,4); + expect(result).toEqual(false); + }); + + }); + + describe('hasBeenWon', function(){ + + // Will not call wins before all marks have been placed, even if a win is inevitable (e.g., only one space is left on the + // board and X will win by filling that space) + + it('a blank game has not been won yet', function(){ + expect(game.hasBeenWon()).toEqual(false); + expect(game.get("outcome")).toEqual("in progress"); + }); + + it('a game with six plays in a non-winning configuration has not been won yet', function(){ + game.set("board", ["X", "O", " ", "O", "X", " ", "X", "O", " "]); + expect(game.hasBeenWon()).toEqual(false); + expect(game.get("outcome")).toEqual("in progress"); + }); + + it('a game with three identical marks in a column has been won (by the mark type that is three-in-a-column)', function(){ + game.set("board", ["X", "O", " ", "X", "O", " ", "X", " ", " "]); + expect(game.hasBeenWon()).toEqual("X"); + expect(game.get("outcome")).toEqual("X"); + game.set("board", ["X", "O", "X", " ", "O", " ", "X", "O", " "]); + expect(game.hasBeenWon()).toEqual("O"); + expect(game.get("outcome")).toEqual("O"); + game.set("board", [" ", "O", "X", " ", "O", "X", " ", " ", "X"]); + expect(game.hasBeenWon()).toEqual("X"); + expect(game.get("outcome")).toEqual("X"); + }); + + it('a game with three identical marks in a row has been won (by the mark type that is three-in-a-row)', function(){ + game.set("board", ["X", "X", "X", "O", "O", " ", " ", " ", " "]); + expect(game.hasBeenWon()).toEqual("X"); + expect(game.get("outcome")).toEqual("X"); + game.set("board", [" ", "X", "X", "O", "O", "O", "X", " ", " "]); + expect(game.hasBeenWon()).toEqual("O"); + expect(game.get("outcome")).toEqual("O"); + game.set("board", [" ", " ", " ", "O", "O", " ", "X", "X", "X"]); + expect(game.hasBeenWon()).toEqual("X"); + expect(game.get("outcome")).toEqual("X"); + }); + + it('a game with three identical marks on the diagonal has been won (by the mark type that occupies the diagonal)', function(){ + game.set("board", ["X", "O", " ", "O", "X", " ", " ", " ", "X"]); + expect(game.hasBeenWon()).toEqual("X"); + expect(game.get("outcome")).toEqual("X"); + game.set("board", ["X", " ", "O", " ", "O", "X", "O", "X", " "]); + expect(game.hasBeenWon()).toEqual("O"); + expect(game.get("outcome")).toEqual("O"); + }); + + it('a game with a full board in a non-winning configuration has not been won', function(){ + game.set("board", ["O", "O", "X", "X", "X", "O", "O", "X", "X"]); + expect(game.hasBeenWon()).toEqual(false); + expect(game.get("outcome")).toEqual("in progress"); + }); + + it('a game with a full board in a winning configuration has been won', function(){ + game.set("board", ["X", "X", "X", "O", "X", "O", "X", "O", "O"]); + expect(game.hasBeenWon()).toEqual("X"); + expect(game.get("outcome")).toEqual("X"); + }) + + }); + + describe('isADraw', function(){ + + // Will not call a draw until the board is completely full, even if there is no way for either + // player to win give the remaining turns and spaces + + it('a blank game is not a draw', function(){ + expect(game.isADraw()).toEqual(false); + expect(game.get("outcome")).toEqual("in progress"); + }); + + it('a game with eight plays in a non-winning configuration is not a draw', function(){ + game.set("board", ["X", "O", "X ", + "O", "X", "X", + " ", "O", "O"]); + expect(game.hasBeenWon()).toEqual(false); + expect(game.get("outcome")).toEqual("in progress"); + }); + + it('a game with nine plays in a non-winning configuration is a draw', function(){ + game.set("board", ["X", "O", "X", "X", "O", "O", "O", "X", "X"]); + expect(game.isADraw()).toEqual(true); + expect(game.get("outcome")).toEqual("draw"); + }); + + it('a game that has been won is not a draw, even if the board is full)', function(){ + game.set("board", ["X", "X", "X", "O", "X", "O", "X", "O", "O"]); + expect(game.hasBeenWon()).toEqual("X"); + expect(game.isADraw()).toEqual(false); + expect(game.get("outcome")).toEqual("X"); + }); + + }); + +}); diff --git a/spec/gameslist.spec.js b/spec/gameslist.spec.js new file mode 100644 index 0000000..a973122 --- /dev/null +++ b/spec/gameslist.spec.js @@ -0,0 +1,37 @@ +// *** NOT WORKING - approach would be to use Sinon to mock responses, but haven't +// figured out details. Making direct calls to the API does not work from here in +// the testing section. This seems slightly out of scope since we did not cover API +// testing with Backbone in class. Instructions to get Sinon working at: +// https://tinnedfruit.com/2011/03/03/testing-backbone-apps-with-jasmine-sinon.html + +// import Game from 'app/models/game'; +// import GamesList from 'app/collections/games'; +// import $ from 'jquery'; +// +// describe("GamesList", function() { +// +// var gamesList = new GamesList(); +// gamesList.fetch(); +// +// describe('initialize/defaults', function(){ +// +// it('the gamesList contains Game objects', function(){ +// gamesList.each(function(model){ +// expect(typeOf(model)).toEqual("Game"); +// }); +// }); +// +// it('the gamesList does not contain incomplete games', function(){ +// gamesList.each(function(model){ +// expect(model.outcome == "in progress").toBeFalsy(); +// }); +// }); +// +// // it('...', function(){ +// // +// // }); +// +// }); + + +}); diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..9b05666 --- /dev/null +++ b/src/app.js @@ -0,0 +1,5 @@ +import ApplicationView from 'app/views/application_view'; + +var appView = new ApplicationView({ + el: 'body' +}); diff --git a/src/app/collections/games.js b/src/app/collections/games.js new file mode 100644 index 0000000..3522a86 --- /dev/null +++ b/src/app/collections/games.js @@ -0,0 +1,81 @@ +import Backbone from 'backbone'; +import Game from 'app/models/game' + +const GamesList = Backbone.Collection.extend({ + model: Game, + url: 'http://localhost:3000/api/v1/games', + upToTopN: function(n){ + var playersAndTheirWins = {} + + // If one player wins, they get +1 point, and the losing player gets -1 point. + // If two players tie, they both get 0 points (nothing happens). + // This section of code creates a big hashtable where each player's name is a key and + // their score (computed from all their games) is the corresponding value. + + this.each(function(model){ + if (model.get("outcome") == "X"){ + if (playersAndTheirWins[model.get("players")[0]] == undefined){ + playersAndTheirWins[model.get("players")[0]] = 1; + } else { + playersAndTheirWins[model.get("players")[0]] += 1; + } + if (playersAndTheirWins[model.get("players")[1]] == undefined){ + playersAndTheirWins[model.get("players")[1]] = -1; + } else { + playersAndTheirWins[model.get("players")[1]] -= 1; + } + } else if (model.get("outcome") == "O") { + if (playersAndTheirWins[model.get("players")[1]] == undefined){ + playersAndTheirWins[model.get("players")[1]] = 1; + } else { + playersAndTheirWins[model.get("players")[1]] += 1; + } + if (playersAndTheirWins[model.get("players")[0]] == undefined){ + playersAndTheirWins[model.get("players")[0]] = -1; + } else { + playersAndTheirWins[model.get("players")[0]] -= 1; + } + } else if (model.get("outcome") == "draw") { + if (playersAndTheirWins[model.get("players")[1]] == undefined){ + playersAndTheirWins[model.get("players")[1]] = 0; + } + if (playersAndTheirWins[model.get("players")[0]] == undefined){ + playersAndTheirWins[model.get("players")[0]] = 0; + } + } + }); + + // Next, we take the hashtable and convert it into an array of small objects, each of which + // stores a name and a score. This conversion facilitates sorting of the players by score. + + var arrayOfScoreObjects = []; + + // Documentation of getOwnPropertyNames: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyNames + + for (var i = 0; i < Object.getOwnPropertyNames(playersAndTheirWins).length; i++){ + var miniObject = {}; + console.log(Object.getOwnPropertyNames(playersAndTheirWins)[i]); + miniObject["name"] = Object.getOwnPropertyNames(playersAndTheirWins)[i]; + miniObject["score"] = playersAndTheirWins[Object.getOwnPropertyNames(playersAndTheirWins)[i]]; + arrayOfScoreObjects.push(miniObject); + } + + // Sort the players by score in descending order + // Sort based on: http://stackoverflow.com/questions/979256/sorting-an-array-of-javascript-objects + + var sorted = arrayOfScoreObjects.sort(function(a, b) { + return parseFloat(b.score) - parseFloat(a.score); + }) + + var max = Math.min(n, sorted.length); + + console.log("Sorted, sliced array of objects:"); + console.log(sorted.slice(0, max)); + + return sorted.slice(0, max); + + } +}); + +export default GamesList; diff --git a/src/app/models/game.js b/src/app/models/game.js new file mode 100644 index 0000000..53bbb88 --- /dev/null +++ b/src/app/models/game.js @@ -0,0 +1,118 @@ +import Backbone from 'backbone'; + +const Game = Backbone.Model.extend({ + // Why this needs to be a function (defaults is shared among all instances in a not-useful way and attributes such as board will + // not be reset otherwise): http://stackoverflow.com/questions/19441176/backbone-new-view-reflects-old-model-data + defaults: function() { + return {players: ["Player1", "Player2"], + board: [" ", " ", " ", " ", " ", " ", " ", " ", " "], + outcome: "in progress"}; + }, + initialize: function(options) { + + }, + pickStartingPlayer: function() { + // Randomly choose which player will go first (X player) and which will go second (O player) + var randomNumber = Math.floor(Math.random()*2); + var myPlayers = this.get("players"); + + if (randomNumber == 0){ + this.set("players", [myPlayers[1], myPlayers[0]]); // Reverse order of players + } + }, + currentPlayer: function() { + var numMarks = 0; + var myBoard = this.get("board"); + + for (var row = 0; row < 3; row++){ + for (var col = 0; col < 3; col++){ + if (myBoard[row * 3 + col] != " "){ + numMarks++; + } + } + } + + if (numMarks % 2 == 0){ + return this.get("players")[0]; + } else { + return this.get("players")[1]; + } + }, + setSquare: function(row, col){ + if (this.get("board")[row * 3 + col] != " " || row > 2 || col > 2 || row < 0 || col < 0){ + return false; // Something is already in the selected spot, or the spot is not on the board + } else { + if (this.currentPlayer() == this.get("players")[0]){ + var myBoard = this.get("board"); + myBoard[row * 3 + col] = "X"; + this.set("board", myBoard); + } else { + var myBoard = this.get("board"); + myBoard[row * 3 + col] = "O"; + this.set("board", myBoard); + } + + return true; + } + }, + hasBeenWon: function(){ + + var myBoard = this.get("board"); + + for (var row = 0; row < 3; row++){ + for (var col = 0; col < 3; col++){ + // Check for column wins + if (row == 1){ + if (myBoard[row * 3 + col] != " " && myBoard[row * 3 + col] == myBoard[(row-1) * 3 + col] && myBoard[row * 3 + col] == myBoard[(row+1) * 3 + col]){ + this.set("outcome", myBoard[row * 3 + col]); + return myBoard[row * 3 + col]; + } + } + // Check for row wins + if (col == 1){ + if(myBoard[row * 3 + col] != " " && myBoard[row * 3 + col] == myBoard[row * 3 + col-1] && myBoard[row * 3 + col] == myBoard[row * 3 + col+1]){ + this.set("outcome", myBoard[row * 3 + col]); + return myBoard[row * 3 + col]; + } + } + // Check for diagonal wins + if (row == 1 && col == 1){ + if((myBoard[row * 3 + col] != " " && myBoard[row * 3 + col] == myBoard[(row-1) * 3 + col-1] && myBoard[row * 3 + col] == myBoard[(row+1) * 3 + col+1]) || + (myBoard[row * 3 + col] != " " && myBoard[row * 3 + col] == myBoard[(row+1) * 3 + col-1] && myBoard[row * 3 + col] == myBoard[(row-1) + col+1])){ + this.set("outcome", myBoard[row * 3 + col]); + return myBoard[row * 3 + col]; + } + } + } + } + + //If we did not return, no wins were found + return false; + }, + isADraw: function(){ + + if (this.get("outcome") == "X" || this.get("outcome") == "O"){ + return false; + } + + var numPlaysRemaining = 9; + var myBoard = this.get("board"); + + for (var row = 0; row < 3; row++){ + for (var col = 0; col < 3; col++){ + if (myBoard[row * 3 + col] != " "){ + numPlaysRemaining--; + } + } + } + + if (numPlaysRemaining == 0){ + this.set("outcome", "draw"); + } + + return numPlaysRemaining == 0; // For now, consider it a draw only when all spaces are full + } + +}); + +export default Game; diff --git a/src/app/views/application_view.js b/src/app/views/application_view.js new file mode 100644 index 0000000..e024aef --- /dev/null +++ b/src/app/views/application_view.js @@ -0,0 +1,94 @@ +import Backbone from 'backbone'; +import Game from 'app/models/game'; +import GameView from 'app/views/game_view'; +import GamesList from 'app/collections/games' + +const ApplicationView = Backbone.View.extend({ + initialize: function(options) { + this.listOfGames = new GamesList(); + this.listOfGames.fetch(); + this.listenTo(this.listOfGames, 'update', this.render); + this.render(); + }, + + events: { + 'click .start-game-button': 'startGame', + 'click .tiny': 'deleteGame' + }, + + // Effects of deleting via collection: http://stackoverflow.com/questions/6280553/destroying-a-backbone-model-in-a-collection-in-one-step + + deleteGame: function(e){ + this.listOfGames.get(e.target.id).destroy(); + }, + + startGame: function(e){ + var player1 = this.$('.new-game-form input[name="player1"]').val(); + var player2 = this.$('.new-game-form input[name="player2"]').val(); + + if (player1 == "" || player2 == ""){ + this.$('#error').html("Please enter a name for both players!") + } else { + this.game = new Game({"players": [player1, player2]}); + this.game.pickStartingPlayer(); + this.$('#error').empty(); + } + this.render(); + }, + + // Hashtable info: http://www.mojavelinux.com/articles/javascript_hashes.html + getTopScorers: function(){ + return this.listOfGames.upToTopN(10); + }, + + render: function() { + if (this.game != undefined){ + this.gameView = new GameView({model: this.game, el: this.$("#board")}); + this.listenTo(this.gameView, 'game-over', this.addGameToList); + this.$("#board").show(); + this.gameView.render(); + } + // Fill the top scorers scoreboard + this.$("#top-scorers").empty(); + var topScorers = this.getTopScorers(); + for (var i = 0; i < topScorers.length; i++){ + this.$("#top-scorers").append("
  • " + topScorers[i]["name"] + "
  • "); + } + // Fill the past games board + this.$("#past-games").empty(); + this.listOfGames.each(function(model){ + this.$("#past-games").prepend("
  • " + (model.get("outcome") == "X" ? "":"") + model.get('players')[0] + (model.get("outcome") == "X" ? "":"") + " vs. " + (model.get("outcome") == "O" ? "":"") + model.get('players')[1] + (model.get("outcome") == "O" ? "":"") + "
    Delete
  • "); + }, this); + }, + + // This is hitting the failure condition when the response is actually 201. I have no idea why! + // Because of that, it was not displaying the game as being in the list even after it had been + // added to the collection. (Well...it initially did display the game as being in the list beforeEach + // I had the wait: true as an option in the create call. But then the game was added to the list + // before it was added to the remote collection, with the result that it didn't have an id yet. Since + // the id is what I use to identify the model for deletion, this resulted in an un-deletable model. + // The solution was to add the wait: true, but then the item was not added to the displayed list.) + // The workaround I've found is below - I have a failure callback that triggers a .fetch() that pulls + // all the data for listOfGames freshly from the server. Since the create is working fine + // on the server end, this results in the newly added item being shown. + // Documentation (save is basically the same as create): http://backbonejs.org/#Model-save + // Helpful StackOverflow: http://stackoverflow.com/questions/11890517/backbone-handle-server-response-on-create + + addGameToList: function(game) { + var that = this; + var addedGame = this.listOfGames.create(game, { wait: true, + success: function(model, response, options){ + // console.log("SUCCESS"); + }, + error: function(model, response, options){ + // console.log("FAILURE"); + // console.log(response); + that.listOfGames.fetch(); + that.render(); + } + }); + } + +}); + +export default ApplicationView; diff --git a/src/app/views/game_view.js b/src/app/views/game_view.js new file mode 100644 index 0000000..eb546b7 --- /dev/null +++ b/src/app/views/game_view.js @@ -0,0 +1,54 @@ +import Backbone from 'backbone'; +import Game from 'app/models/game'; + +const GameView = Backbone.View.extend({ + + initialize: function(options) { + + }, + + events: { + 'click td': 'clickTile' + }, + + clickTile: function(e){ + if (this.model.get("outcome") == "in progress"){ + var square = e.currentTarget.id; + this.model.setSquare(Math.floor(square/3),square%3); + if (this.model.get("outcome") == "in progress"){ + this.model.isADraw(); + this.model.hasBeenWon(); + if (this.model.get("outcome") != "in progress"){ + this.render(); + this.trigger('game-over', this.model); + } + } + this.render(); + } + }, + render: function(){ + if (this.model.get("outcome") == "in progress") { + if (this.model.currentPlayer() == this.model.get("players")[0]) { + var symbol = "X"; + } else { + var symbol = "O"; + } + this.$("#player-prompt").html(this.model.currentPlayer() + ", make your move! (" + symbol + ")"); + } else if (this.model.get("outcome") == "X" || this.model.get("outcome") == "O" ) { + if (this.model.get("outcome") == 'X') { + this.$("#player-prompt").html(this.model.get("players")[0] + " has won! Great game!"); + } else { + this.$("#player-prompt").html(this.model.get("players")[1] + " has won! Great game!"); + } + } else if (this.model.get("outcome")) { + this.$("#player-prompt").html("It's a draw! Great game, both of you!"); + } + + for(var square = 0; square < 9; square++){ + this.$("#" + square.toString() + " > h3").html(this.model.get("board")[square]); + } + } + +}); + +export default GameView;