scripts/engine.js
"use strict"
/***
* Classes and logic for the Wheel of Jeopardy (WOJ) game.
*/
// let engine = (function(){
/**
* @typedef {Object} GameStats
*/
/**
* @typedef {Object} RoundStats
* @property {number} spinsUsed number of spins used.
* @property {number} cluesAnswered number of clues answered.
* @property {PlayerStats} currentPlayer stats for current player.
*/
/**
* @typedef {Object} PlayerStats
* @property {number} id current player's id.
* @property {string} name current player's name.
* @property {number} roundScore current player's round score.
* @property {number} totalScore current player's total score.
* @property {number} tokens current player's token count.
* @property {boolean} hasToken true if current player has token.
*/
/**
* Returns a random integer between min and max.
* @param {number} max upper bound (exclusive).
* @param {number} min lower bound (inclusive).
* @returns {number} random integer.
*/
function randomInt(max, min=0){
return Math.floor(Math.random() * (max - min) + min);
}
/**
* Naive shuffle an array, swaps random slots 3 * array.length times.
* @param {Object[]} arr an array to shuffle.
* @returns {Object[]} shuffled array.
*/
function shuffleArray(arr){
for (let j=0; j < arr.length * 3; j++){
let i1 = randomInt(arr.length);
let i2 = randomInt(arr.length);
let val1 = arr[i1];
let val2 = arr[i2];
arr[i2] = val1;
arr[i1] = val2;
}
return arr;
}
/**
* Initialize the default game setup.
* @returns {Game} a game intialized with default rounds and no players.
*/
function initDefaultGame(){
let game = new Game();
let data = [data1, data2];
let basePoints = [200, 400]
for(let i=0; i<data.length; i++){
let round = game.addRound(6, 5, basePoints[i]);
round.board.import(JSON.stringify(data[i]));
round.wheel.assignSectors();
round.wheel.randomizeSectors();
}
return game;
}
/**
* A single clue, zero indexed from the top left of the board.
*/
class Clue {
/**
* @param {number} column horizontal index on board.
* @param {number} row vertical index on board.
* @param {string} question the question text.
* @param {string} answer the answer text.
*/
constructor(column, row, question, answer) {
this.column = column;
this.row = row;
this.question = question;
this.answer = answer;
/** @type {number} value of the clue. */
this.points = 0;
}
}
/**
* A category of clues on the board.
*/
class Category{
/**
* @param {string} name name of the category.
*/
constructor(name){
this.name = name;
/** @type {Object[]} a list of {@link Clue} objects. */
this.clues = [];
/**
* @type {number} number of clues in the category that have been
* answered.
*/
this.cluesAnswered = 0;
}
/** @type {boolean} true if all clues in the category have been answered. */
get complete(){
return this.cluesAnswered >= this.clues.length;
}
/**
* Reset clues answered count to 0.
*/
reset(){
this.cluesAnswered = 0;
}
/**
* Retrieve next unanswered clue in the category, increment cluesAnswered.
* If all clues are answered, this will return undefined.
* @return {Clue} the next unanswered clue.
* @throws {Error} throw error when category is complete.
*/
nextClue(){
if(this.complete){
throw new Error("All clues already answered for: " + this.name);
} else {
let clue = this.clues[this.cluesAnswered];
this.cluesAnswered++;
return clue;
}
}
}
/**
* A WOJ board with column * row clues arranged in a grid. The top left
* corner is indexed at 0. Each column is a {@link Category} and each row in a
* category is a {@link Clue}. All categories must have the same number of
* clues. Each clue in the first row has {@link Board#basePoints}. Each clue in
* the next row equals the sum of the clue value in the prior row plus
* basePoints.
*/
class Board {
/**
* @param {number} [columns=6] number of columns on the board.
* @param {number} [rows=5] number of rows on the board.
* @param {number} [basePoints=200] point value of each clues in the first
* row.
*/
constructor(columns=6, rows=5, basePoints=200) {
this.columns;
this.rows;
this.basePoints = basePoints;
/**
* @type {Category[]} an array of Categories indexed by column
* position.
*/
this.categories;
this.init_(columns, rows);
}
/** @type {boolean} true if all clues on the board have been answered. */
get complete(){
return this.cluesAnswered >= this.columns * this.rows;
}
/**
* @type {number} number of clues on the board that have been answered.
*/
get cluesAnswered(){
let cluesAnswered = 0;
for(let i=0; i<this.categories.length; i++){
cluesAnswered += this.categories[i].cluesAnswered;
}
return cluesAnswered;
}
/**
* @type {string[]} an array of category names indexed by column position.
*/
get categoryNames(){
let names = [];
for(let i=0; i<this.categories.length; i++){
names.push(this.categories[i].name);
}
return names;
}
/**
* Initializes an empty board of size columns * rows with {@link Clue}'s.
* @private
* @param {number} columns number of columns on the board.
* @param {number} rows number of rows on the board.
*/
init_(columns, rows){
this.categories = [];
this.columns = columns;
this.rows = rows;
for(let i=0; i<this.columns; i++){
let category = new Category();
this.categories.push(category);
for(let j=0; j<this.rows; j++){
let clue = new Clue();
clue.column = i;
clue.row = j;
clue.points = (j+1) * this.basePoints;
category.clues.push(clue);
}
}
}
/**
* Resets each category in the board and their cluesAnswered count to 0.
*/
reset(){
for(let i=0; i<this.categories.length; i++){
this.categories[i].reset();
}
}
/**
* Returns a category at the specified column.
* @param {number} column column index of the category.
* @return {Category} category at the specified column.
*/
getCategory(column){
return this.categories[column];
}
/**
* Returns a clue at the specified column and row.
* @param {number} column column index of the clue.
* @param {number} row row index of the clue.
* @return {Clue} clue at the specified column and row.
*/
getClue(column, row){
return this.categories[column].clues[row];
}
/**
* Edit the name of a category at column index.
* @param {number} column column index of the category.
* @param {string} name new category name.
*/
editCategory(column, name){
let category = this.getCategory(column);
category.name = name;
}
/**
* Edit the question and answer of a clue at column and row index.
* @param {number} column column index of the clue.
* @param {number} row row index of the clue.
* @param {string} question new question text.
* @param {string} answer new answer text.
*/
editClue(column, row, question, answer){
let clue = this.getClue(column, row);
clue.question = question;
clue.answer = answer;
}
/**
* Retrieve next unanswered clue in the category at column and increment
* cluesAnswered for that category. If all clues are answered, this will
* return undefined.
* @param {number} column column index of the category.
* @return {Clue} the next unanswered clue.
* @throws {Error} throw error when board is complete.
*/
nextClue(column) {
if(this.complete){
throw new Error("All clues already answered on the board");
} else {
let category = this.getCategory(column);
let clue = category.nextClue();
return clue;
}
}
/**
* Import JSON board, initializing board and setting categories and clues.
* Data should be a string. Use JSON.stringify if data is an object literal.
*
* @param {string} data board JSON.
* @param {number} data.columns number of columns.
* @param {numnber} data.rows number of rows.
* @param {string[]} data.categories array of category names.
* @param {Object[]} data.clues array of clues, zero indexed from top left
* of board
* @param {number} data.clues.column column index of the clue.
* @param {number} data.clues.row row index of the clue.
* @param {string} data.clues.question new question text.
* @param {string} data.clues.answer new answer text.
*
* @see /scripts/data.js
*/
import(data){
data = JSON.parse(data);
// reinitialize board if columns/rows different
if(this.columns != data.columns || this.rows != data.rows){
this.init_(data.columns, data.rows);
}
// import category names
for(let i=0; i<data.categories.length; i++){
this.editCategory(i, data.categories[i]);
}
// import clues
for(let i=0; i<data.clues.length; i++){
let clue = data.clues[i];
this.editClue(clue.column, clue.row, clue.question, clue.answer);
}
}
/**
* Export board content to JSON board string. See {@link Board.import} for
* details on JSON board structure.
* @returns {string} serialized board.
*/
export(){
let data = {
"columns": this.columns,
"rows": this.rows,
"categories": this.categoryNames,
"clues": []
};
for(let i=0; i<this.columns; i++){
for(let j=0; j<this.rows; j++){
let clue = this.getClue(i, j);
data.clues.push({
"column": i,
"row": j,
"question": clue.question,
"answer": clue.answer
})
}
}
data = JSON.stringify(data);
return data;
}
}
/** Result of a wheel spin. */
class Spin {
/**
* @param {number} slot wheel slot number where spin landed.
* @param {number} sectorNumber corresponding sector number located at slot.
* @param {string} sectorName corresponding sector name located at slot.
* @param {boolean} isCategory true if sector is category.
* @param {Clue} clue if isCategory is true and spinAgain is false, next
* clue in the category.
* @param {boolean} spinAgain true if category is complete.
*/
constructor(slot, sectorNumber, sectorName, isCategory, clue, spinAgain=false){
this.slot = slot;
this.sectorNumber = sectorNumber;
this.sectorName = sectorName;
this.isCategory = isCategory;
this.clue = clue;
this.spinAgain = spinAgain;
}
}
/**
* A WOJ wheel for a specific board and maximum spins. The number of slots on
* the wheel will be the number of categories + the number of special sectors
* (6). Slots are fixed on the wheel. Each special and category is a sector.
* Each sector can be randomly assigned to a slot on the wheel. Categories are
* always populated in the sector list first, followed by special sectors, i.e.
* category 0 on the board is always sector 0 on the wheel.
*/
class Wheel {
/**
* @param {Board} board an initialized WOJ board.
* @param {number} [maxSpins=50] the max spins allowed.
*/
constructor(board, maxSpins=50) {
this.board = board;
this.maxSpins = maxSpins;
/**
* @type {string[]} a list of six special sectors.
*/
this.specialSectors = [
"Lose Turn",
"Free Turn",
"Bankrupt",
"Player's Choice",
"Opponent's Choice",
"Double Score"
]
/**
* @type {number} number of spins used.
*/
this.usedSpins = 0;
/**
* @type {number} sector numbers in each slot.
*/
this.slots = [];
this.assignSectors();
}
/** @type {boolean} true if all spins have been used. */
get complete(){
return this.usedSpins >= this.maxSpins;
}
/**
* @type {string[]} array of ordered sector names: categories + special
* sectors.
*/
get sectors(){
return this.board.categoryNames.concat(this.specialSectors);
}
/**
* Resets used spins to 0.
*/
reset(){
this.usedSpins = 0;
}
/**
* Assigns sectors numbers to each slot ordered by categories then special.
*/
assignSectors(){
this.slots = [];
for(let i=0; i<this.sectors.length; i++){
this.slots.push(i);
}
}
/**
* Randomizes sectors in the slots.
*/
randomizeSectors(){
this.slots = shuffleArray(this.slots);
}
/**
* Get sector number at a specific slot number.
* @param {number} slot slot number in the wheel.
* @return {number} sector number at the specified slot.
*/
getSectorNumber(slot){
return this.slots[slot];
}
/**
* Get sector name for the specific sector number.
* @param {number} sectorNumber sector number.
* @return {number} sector name for the sector number.
*/
getSectorName(sectorNumber){
return this.sectors[sectorNumber];
}
/**
* Check if the sector is a category (vs a special sector).
* @param {number} sectorNumber number of the sector.
* @return {boolean} true if the sector is a category.
*/
sectorIsCategory(sectorNumber){
if(sectorNumber < this.board.categoryNames.length){
return true
} else {
return false
}
}
/**
* Return a random slot number from the wheel.
* @returns {number} a random slot number.
*/
getRandomSlot(){
return randomInt(this.slots.length);
}
/**
* Spins the wheel and returns a random sector. Populates the {@link Spin}
* object with the spin results. If the result is a category, a clue will
* be retrieved. If all clues are used, the spin again field will be
* true.
* @returns {Spin} object summarizing result of the spin.
*/
spin(){
let spin = new Spin();
spin.slot = this.getRandomSlot();
spin.sectorNumber = this.getSectorNumber(spin.slot);
spin.sectorName = this.getSectorName(spin.sectorNumber);
spin.isCategory = this.sectorIsCategory(spin.sectorNumber);
if(spin.isCategory){
let categoryColumn = spin.sectorNumber;
let category = this.board.getCategory(categoryColumn);
if(category.complete){
spin.spinAgain = true;
} else {
let clue = this.board.nextClue(categoryColumn);
spin.clue = clue;
}
}
this.usedSpins++;
return spin;
}
}
/**
* A score keeper for a single player and round.
*/
class Score {
constructor(){
/**
* @type {number} current points for the round.
*/
this.points = 0;
}
/** Reset points to 0. */
reset(){
this.points = 0;
}
/**
* Increase points by a number (sign ignored).
* @param {number} points number of points to increase.
*/
increase(points){
this.points = this.points + Math.abs(points);
}
/**
* Decrease points by a number (sign ignored).
* @param {number} points number of points to decrease.
*/
decrease(points){
this.points = this.points - Math.abs(points);
}
/** If points are positive, set points to 0. */
bankrupt(){
if(this.points > 0){ this.points = 0; }
}
/** Double points, even if negative. */
double(){
this.points = this.points + this.points;
}
}
/**
* A player in the WOJ game.
*/
class Player {
/**
* @param {number} id A unique numeric ID for the player.
* @param {string} [name] the players name.
*/
constructor(id, name) {
this.id = id;
this.name = name;
/**
* @type {number} number of tokens (free turns).
*/
this.tokens = 0;
/**
* @type {Score[]} array of scores, indexed by round number, i.e. 0 is
* the first round.
*/
this.scores = [];
}
/** @type {numbner} sum of points for all rounds. */
get totalScore(){
let points = 0;
for(let i=0; i<this.scores.length; i++){
points += this.scores[i].points;
}
return points;
}
/** Sets the player's tokens to 0. */
resetTokens(){
this.tokens = 0;
}
/**
* Reset score for round to 0.
* @param {number} round number of the round, 0 is round 1.
*/
resetScore(round){
this.scores[round] = new Score();
}
/**
* Return the score for a given round number.
* @param {number} round number of the round, 0 is round 1.
* @returns {number} score for the given round number.
*/
getScore(round){
return this.scores[round];
}
/**
* Adds 1 token to the player's tokens.
*/
addToken(){
this.tokens += 1;
}
/**
* Checks if the player has any tokens.
* @returns true if the player has at least one token.
*/
hasToken(){
return this.tokens > 0;
}
/**
* Deduct a token, if the player has one.
* @returns {boolean} true if the token was deducted.
* @throws {Error} throws error if the player has no tokens.
*/
useToken(){
if(this.hasToken()){
this.tokens--;
return true;
} else {
throw new Error('Player does not have any tokens.')
}
}
}
/**
* A round in the WOJ game. The standard game is played with two rounds.
*/
class Round {
/**
* @param {number} id A unique numeric ID for the round.
* @param {Wheel} wheel a wheel for the round.
* @param {Player[]} players an array of players in the game.
* @param {number} [maxTime=20] maximum time to answer a question in
* seconds.
*/
constructor(id, wheel, players, maxTime=20){
this.id = id;
this.wheel = wheel;
this.players = players;
this.maxTime = maxTime;
/**
* @type {Board} a board for the round.
*/
this.board = wheel.board;
/**
* @type {number} player id of the player currently spinning.
*/
this.currentPlayerID = 0
/**
* @type {Spin} latest Spin resulting from spin.
*/
this.currentSpin;
/**
* @type {Clue} latest Clue resulting form spin or pickCategory.
*/
this.currentClue;
/**
* @type {boolean} true if the round is ready to begin.
* @private
*/
this.roundReady_ = false;
/**
* @type {boolean} true if the current turn is complete.
* @private
*/
this.turnComplete_ = true;
}
/** @type {boolean} true if the round is complete. */
get complete(){
return this.wheel.complete || this.board.complete
}
/**
* Stats for the current round, including spins used, clues answered, and
* attributes for the player who has the current turn, e.g. score, tokens.
* @returns {RoundStats} current stats for the round.
*/
get stats(){
let stats = {
spinsUsed: this.wheel.usedSpins,
cluesAnswered: this.board.cluesAnswered,
currentPlayer: {
id: this.currentPlayer.id,
name: this.currentPlayer.name,
roundScore: this.currentPlayerScore_.points,
totalScore: this.currentPlayer.totalScore,
tokens: this.currentPlayer.tokens,
hasToken: this.currentPlayer.hasToken()
}
}
return stats;
}
/**
* Current player's player object.
* @return {Player} current player's player object.
*/
get currentPlayer(){
return this.players[this.currentPlayerID];
}
/**
* Current player's score object.
* @returns {Score} current players score object.
* @private
*/
get currentPlayerScore_(){
return this.currentPlayer.scores[this.id]
}
/**
* Start the WOJ round.
*/
start(){
this.reset();
}
/**
* Resets the current round by reseting the wheel, board, and player scores.
*/
reset(){
this.wheel.reset();
this.board.reset();
for(let i=0; i<this.players.length; i++){
this.players[i].resetScore(this.id);
}
this.currentPlayerID = 0;
this.roundReady_ = true;
this.turnComplete_ = true;
}
/**
* Spins the wheel and returns the result in a Spin object. If Free Turn,
* Bankrupt, or Double Score are landed, the players tokens or score is
* adjusted. If Player's Choice or Opponent's Choice are landed, use
* pickCategory to get the next clue.
* @returns {Spin} result of the spin.
*/
spin(){
if(this.complete){
// check if the round is complete
let msg = 'Round complete, must start new round or end the game.';
throw new Error(msg);
} else if(!this.roundReady_){
// did not start round
throw new Error('Must call start before round can begin.');
} else if(!this.turnComplete_) {
// check if turn is complete
let msg = `
Must complete current turn before spinning again by calling
validAnswer, endTurn, or useToken.
`;
throw new Error(msg)
}else{
// spin wheel
this.turnComplete_ = false;
let spin = this.wheel.spin();
this.currentSpin = spin;
if(spin.isCategory){
// category sector
if(!spin.spinAgain){
// valid clue
this.currentClue = spin.clue;
}
} else {
// special sector
switch(spin.sectorName){
case "Free Turn":
this.currentPlayer.addToken();
break;
case "Lose Turn":
break;
case "Bankrupt":
this.currentPlayerScore_.bankrupt();
break;
case "Player's Choice":
break;
case "Opponent's Choice":
break;
case "Double Score":
this.currentPlayerScore_.double();
break;
default:
// do nothing
}
}
return spin;
}
}
/**
* When spin is Player's Choice or Opponent's Choice, use pickCategory,
* to get the next clue from the specificed category. This will set the
* currentClue.
* @param {number} column column index of the category.
* @returns {Clue} next unanswered clue in the category.
* @throws {Error} throws error if current spin sector does not permit
* picking a category, i.e. it is not Player's Choice or Opponents Choice.
*/
pickCategory(column){
let validOpt = ["Player's Choice", "Opponent's Choice"];
let specialSector = !this.currentSpin.isCategory
if(specialSector && validOpt.includes(this.currentSpin.sectorName)){
let category = this.board.getCategory(column);
let clue = category.nextClue();
this.currentClue = clue;
return clue;
} else {
let msg = 'pickCategory is not allowed for current spin sector: ' +
this.currentSpin.sectorName;
throw new Error(msg);
}
}
/**
* When Spin resulted in a clue or pickCategory was used to set a clue, call
* this method if the player answered correctly, to adjust their score.
* @throws {Error} throws error if no clue is set to validate.
*/
validAnswer(){
if(this.currentClue === undefined){
throw new Error('Check spin result, no clue was set.');
} else {
this.currentPlayerScore_.increase(this.currentClue.points);
this.resetTurn_();
}
}
/**
* When Spin resulted in a clue or pickCategory was used to set a clue, call
* this method if the player answered incorrectly, to adjust their score.
* @throws {Error} throws error if no clue is set to validate.
*/
invalidAnswer(){
if(this.currentClue === undefined){
throw new Error('Check spin result, no clue was set.');
} else {
this.currentPlayerScore_.decrease(this.currentClue.points);
}
}
/**
* Private method to reset the turn: currentSpin and currentClue are set
* to undefined.
* @private
*/
resetTurn_(){
this.currentSpin = undefined;
this.currentClue = undefined;
this.turnComplete_ = true;
}
/**
* Use a free turn token for the current player. A token may be used if the
* player loses his turn or answers a question incorrectly. A token cannot
* be used if the player spins free turn.
* @returns {boolean} true if succesfully used token, false otherwise.
* @throws {Error} throws error if player does not have any tokens.
* @throws {Error} throws error if the current spin is Free Turn.
*/
useToken(){
let specialSector = !this.currentSpin.isCategory;
if(specialSector && this.currentSpin.sectorName == 'Free Turn'){
throw new Error('Free turn not allowed when sector is Free Turn')
} else {
try {
let token = this.currentPlayer.useToken();
this.resetTurn_();
return token;
} catch (e) {
throw e;
}
}
}
/**
* End the current players turn, should be called after a spin and
* validation (if applicable) is complete. This method will switch to the
* next player and reset the currentSpin and currentClue.
*/
endTurn(){
this.resetTurn_();
this.currentPlayerID++;
if(this.currentPlayerID == this.players.length){
// restart from first player
this.currentPlayerID = 0;
}
}
}
/**
* The WOJ game. Supports creation of board, wheel, and players which are
* used to track game play. This class should be used to create games and add
* rounds to ensure proper functionality.
*
* @example
* let game = new Game();
*
* // create default boards
* let data = [data1, data2];
*
* for(let i=0; i<data.length; i++){
* let round = game.addRound();
* round.board.import(JSON.stringify(data[i]));
* round.wheel.assignSectors();
* round.wheel.randomizeSectors();
* }
*
* // edit board content if needed
* round = game.getRound(0);
* let clue = round.board.getCategory(1).clues[2];
* clue.question = "What is the oldest soft drink in America?";
* clue.answer = "Dr. Pepper.";
*
* // start game
* game.addPlayer("John");
* game.addPlayer("Jane");
*
* // start first round
* round = game.getRound(0);
* round.start();
*
* // player spins
* console.log(round.currentPlayer);
* let spin = round.wheel.spin();
* console.log(spin);
* console.log(round.complete);
*/
class Game {
constructor() {
/** @type {Player[]} array of players. */
this.players = [];
/** @type {Rounds[]} array of rounds. */
this.rounds = [];
/** @type {number} current round number. */
this.currentRound = 0;
}
/**
* Stats for the game.
* @returns {GameStats} current stats for the game.
*/
get stats(){
let stats = {
// add game wide stats
}
return stats;
}
/**
* Add a player to the game. Players are assigned an ID starting with 0 and
* incrementing by 1.
* @param {string} name the players name.
* @returns {number} auto assigned player ID.
*/
addPlayer(name){
let id = this.players.length;
let player = new Player(id, name);
this.players.push(player);
return player;
}
/**
* Retrieve a player.
* @param {number} id the players id number.
* @returns {Player} the player at index number.
*/
getPlayer(id){
return this.players[id];
}
/**
* Retrieve and edit a player.
* @param {number} id the players id number, player one is id: 0.
* @param {string} name the players name.
*/
editPlayer(id, name){
let player = this.getPlayer(id);
player.name = name;
}
/**
* Add and initialize a game round with an empty wheel and board. Rounds are
* assigned an ID starting with 0 and incrementing by 1.
* @param {number} [columns=6] number of columns on the board.
* @param {number} [rows=5] number of rows on the board.
* @param {number} [basePoints=200] point value of each clues in the first
* row.
* @returns {id} auto assigned round ID.
*/
addRound(columns = 6, rows = 5, basePoints = 200){
let id = this.rounds.length;
let board = new Board(columns, rows, basePoints);
let wheel = new Wheel(board)
let round = new Round(id, wheel, this.players)
this.rounds.push(round);
return round;
}
/**
* Retrieve a game round by id.
* @param {Round} id the rounds id number, round one is id: 0.
*/
getRound(id){
return this.rounds[id];
}
/**
* Retrieve array of players ordered by top score.
* @returns {Player[]} array of players ordered by total score.
*/
getLeaderBoard(){
let leaders = this.players.slice();
leaders.sort(function(obj1, obj2){
return obj2.totalScore - obj1.totalScore;
});
return leaders;
}
}
// node exports
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
Game: Game,
Round: Round,
Player: Player,
Score: Score
}
}
// return {
// Game:Game,
// initDefaultGame:initDefaultGame
// }
// })();