semanticchess.js

const namespaces = require('./namespaces');
let Chess = require('chess.js');

// why? see https://github.com/jhlywa/chess.js/issues/196
if (Chess.Chess) {
  Chess = Chess.Chess;
}

/**
 * This is class represents a chess game using semantic annotations.
 */
class SemanticChess {

  /**
   * @param {Object} options: options to initialize the chess game
   * @param {null|string} options.chess: Chess game from chess.js
   * @param {null|string} options.startPosition: start position of the game, using FEN
   * @param {string} options.url: url that represents the game
   * @param {string} options.userWebId: WebId of the user
   * @param {string} options.opponentWebId: WebId of the opponent
   * @param {string|function(): string} options.moveBaseUrl: base url used to create urls for new moves
   * @param {null|string} options.name: name of the game
   * @param {null|Object} options.lastMove: latest move made in the game
   * @param {null|Object} options.lastUserMove: last move made by the use in the game
   * @param {null|string} options.colorOfUser: color of the user ('w' or 'b', default is 'w')
   * @param {null|function()} options.uniqid: function that will return a unique id for the moves
   * @param {null|string} options.givenUpBy: WebId of the player that gave up
   */
  constructor(options) {
    if (options.chess) {
      this.chess = options.chess;
      this.startPosition = options.startPosition; // FEN
    } else {
      if (options.startPosition) {
        // if there is start position we se the chess game to that
        this.chess = new Chess(options.startPosition);
      } else {
        // else use default start position
        this.chess = new Chess();
      }

      this.startPosition = this.chess.fen();
    }

    this.url = options.url;
    this.userWebId = options.userWebId;
    this.opponentWebId = options.opponentWebId;
    this.name = options.name;
    this.lastMove = options.lastMove;
    this.lastUserMove = options.lastUserMove;
    this.moveBaseUrl = options.moveBaseUrl;

    this.realTime = !!options.realTime;
    // if move base url is a string create function that returns this string
    // else a function so we leave it
    if (typeof this.moveBaseUrl === 'string') {
      const t = this.moveBaseUrl;

      this.moveBaseUrl = function() {
        return t;
      }
    }

    // the default color of the user is white
    if (!options.colorOfUser) {
      this.colorOfUser = 'w';
    } else {
      this.colorOfUser = options.colorOfUser;
    }

    // set the color of the opponent opposite of the user
    if (this.colorOfUser === 'w') {
      this.colorOfOpponent = 'b';
    } else {
      this.colorOfOpponent = 'w';
    }

    // an empty string as name does not make much sense
    if (this.name === '') {
      this.name = null;
    }

    // if we don't have a last move, the game just started so we can check who which color started the game
    if (!this.lastMove) {
      this.colorOfFirstTurn = this.chess.turn();
    }

    // set the default uniqid function to the function of the package 'uniqid'
    if (!options.uniqid) {
      this.uniqid = require('uniqid');
    } else {
      this.uniqid = options.uniqid;
    }

    this.givenUpWebId = options.givenUpBy ? options.givenUpBy : null;
  }

  /**
   * The method returns true if the next half move has to be made by the opponent.
   * @returns {boolean}: true if the next half move has to be made by the opponent
   */
  isOpponentsTurn() {
    return this.chess.turn() === this.colorOfOpponent;
  }

  /**
   * This method returns true if the game is over because someone gave up.
   * @returns {boolean}: true if someone give up on the game
   */
  isGivenUp() {
    return this.givenUpWebId !== null;
  }

  /**
   * This method returns the WebId of the player that gave up.
   * @returns {string|null}: WebId of the player that gave up or null if nobody gave up
   */
  givenUpBy() {
    return this.givenUpWebId;
  }

  /**
   * This method is called to give up to game by a player.
   * @param webId: the WebId of the player that gives up on the game
   * @returns {{sparqlUpdate: string, notification: string}}: corresponding SPARQL update and (inbox) notification
   * that is generated by giving up
   */
  giveUpBy(webId) {
    if ((webId === this.userWebId || webId === this.opponentWebId) && this.givenUpWebId === null) {
      this.givenUpWebId = webId;
      const giveUpActionUrl = this.moveBaseUrl() + `#` + this.uniqid();

      //todo use existing class

      const sparqlUpdate = `<${giveUpActionUrl}> a <${namespaces.schema}GiveUpAction>;
                        <${namespaces.schema}agent> <${webId}>;
                        <${namespaces.schema}object> <${this.url}>.
                        
        <${this.url}> <${namespaces.chess}givenUpBy> <${webId}>.`;
      const notification = `<${giveUpActionUrl}> a <${namespaces.schema}GiveUpAction>.`;

      return {sparqlUpdate, notification};
    } else {
      return null;
    }
  }

  /**
   * This methods loads the giving up of a player.
   * @param webId: the WebId of the player that gives up on the game
   */
  loadGiveUpBy(webId) {
    if ((webId === this.userWebId || webId === this.opponentWebId) && this.givenUpWebId === null) {
      this.givenUpWebId = webId;
    }
  }

  /**
   * This method does the next move, which specified via the san and the options.
   * It returns the corresponding SPARQL update and inbox notification if the move is valid.
   * It returns null if the move was invalid. For example, when the san is invalid or
   * when it's the opponents turn.
   * Note that opponents turns can only be added via the method loadMove.
   * @param {string|Object} move: new move either via SAN (string) or object (inherited from chess.js)
   * @param {Object} options: the options of the move, inherited from Chess from chess.js
   * @returns {null|{sparqlUpdate: string, notification: string}}: corresponding SPARQL update and inbox notification
   * that is generated by doing the move
   */
  doMove(move, options) {
    // check if it's the user's turn
    if (!this.isOpponentsTurn()) {
      // save the current turn for later
      const currentTurn = this.chess.turn();
      const createdMove = this.chess.move(move, options);

      // check if the move is valid
      if (createdMove) {
        // if the color of the first turn has not been set that means that this move is the first move
        if (!this.colorOfFirstTurn) {
          this.colorOfFirstTurn = currentTurn;
        }

        return this._addUserMove(createdMove.san);
      } else {
        // invalid mode
        return null;
      }
    } else {
      // not the user's turn
      return null;
    }
  }

  /**
   * This method load a user's or opponent's move.
   * This method returns {url, san}, where `url` is the url of the newly loaded move and
   * `san` is the SAN of the newly loaded move.
   * This method returns null if the SAN of the move is invalid.
   * When multiple moves need to be loaded, they have to be loaded in the order that they are played.
   * @param {string} san: SAN of the new move
   * @param {Object} options: is inherited from Chess.move() from chess.js extended with the key url that represents the move
   * @returns {null|{url: string, san:string}}: null if the move is invalid or the url and san of the move if valid
   */
  loadMove(san, options) {
    const currentTurn = this.chess.turn();
    const createdMove = this.chess.move(san, options);

    // check if the move is valid
    if (createdMove) {
      if (!this.colorOfFirstTurn) {
        this.colorOfFirstTurn = currentTurn;
      }

      // check if the current turn is made by the user
      if (currentTurn === this.colorOfUser) {
        this.lastUserMove = {url: options.url, san};
      }

      this.lastMove = {url: options.url, san};

      return this.lastMove;
    } else {
      // invalid move
      return null;
    }
  }

  /**
   * This method returns the RDF (Turtle) representation of the game, without any moves.
   * @returns {string}: RDF representation of the game
   */
  getMinimumRDF() {
    if (!this.minimumRDF) {
      const userAgentRole = this.moveBaseUrl() + `#` + this.uniqid();
      const opponentAgentRole = this.moveBaseUrl() + `#` + this.uniqid();

      let whiteWebId;
      let blackWebId;

      // determine the WebIds per color
      if (this.colorOfUser === 'w') {
        whiteWebId = this.userWebId;
        blackWebId = this.opponentWebId;
      } else {
        whiteWebId = this.opponentWebId;
        blackWebId = this.userWebId;
      }

      this.minimumRDF = `<${this.url}> <${namespaces.rdf}type> <${namespaces.chess}ChessGame>;\n` +
        `<${namespaces.chess}providesAgentRole> <${userAgentRole}>, <${opponentAgentRole}>.\n\n` +

        `<${userAgentRole}> <${namespaces.rdf}type> <${namespaces.chess}WhitePlayerRole>;\n` +
        `<${namespaces.chess}performedBy> <${whiteWebId}>.\n\n` +

        `<${opponentAgentRole}> <${namespaces.rdf}type> <${namespaces.chess}BlackPlayerRole>;\n` +
        `<${namespaces.chess}performedBy> <${blackWebId}>.\n\n` +

        `<${this.url}> <${namespaces.chess}isRealTime> ${this.realTime}.\n\n`;

      if (this.name) {
        this.minimumRDF += `<${this.url}> <http://schema.org/name> "${this.name}".\n`;
      }

      if (this.startPosition) {
        this.minimumRDF += `<${this.url}> <${namespaces.chess}startPosition> "${this.startPosition}".\n`;
      }

      if (this.colorOfFirstTurn === 'w') {
        this.minimumRDF += `<${this.url}> <${namespaces.chess}starts> <${userAgentRole}>.\n`;
      } else if (this.colorOfFirstTurn === 'b') {
        this.minimumRDF += `<${this.url}> <${namespaces.chess}starts> <${opponentAgentRole}>.\n`;
      }
    }

    return this.minimumRDF;
  }

  /**
   * This method returns {san, url} of the last move made, where `san` is the SAN of the move and
   * `url` is the url of the move.
   * @returns {null|{url: string, san: string}}: url that represents the move and SAN that describes the move
   */
  getLastMove() {
    return this.lastMove;
  }

  /**
   * This method returns {san, url} of the last move made by the user, where `san` is the SAN of the move and
   * `url` is the url of the move.
   * @returns {null|{url: string, san: string}}: url that represents the move and SAN that describes the move
   */
  getLastUserMove() {
    return this.lastUserMove;
  }

  /**
   * This method returns the color of the user, where 'w' is white and 'b' is black.
   * @returns {string}: 'w' (white) or 'b' (black)
   */
  getUserColor() {
    return this.colorOfUser;
  }

  /**
   * This method returns the color of the opponent, where 'w' is white and 'b' is black.
   * @returns {string}: 'w' (white) or 'b' (black)
   */
  getOpponentColor() {
    return this.colorOfOpponent;
  }

  /**
   * This method returns chess.js game that is used.
   * @returns {Chess}: Chess from chess.js
   */
  getChess() {
    return this.chess;
  }

  /**
   * This method return the WebId of the opponent.
   * @returns {string}: WebId of the opponent
   */
  getOpponentWebId() {
    return this.opponentWebId;
  }

  /**
   * This method returns the URL of the game.
   * @returns {string}: URL of the game
   */
  getUrl() {
    return this.url;
  }

  /**
   * This method returns the function that generates the base url for a new move.
   * @returns {function(): string}: function that generates the base url for a new move
   */
  geMoveBaseUrl() {
    return this.moveBaseUrl;
  }

  /**
   * This method returns the name of the game.
   * @returns {string|null}: name of the game
   */
  getName() {
    return this.name;
  }

  /**
   * This method returns the start position (using FEN) of the game.
   * @returns {string|null}: starting position of the game
   */
  getStartPosition() {
    return this.startPosition;
  }

  /**
   * This method returns true if the game is played real time.
   * @returns {boolean}: true if the game is played real time, else false
   */
  isRealTime() {
    return this.realTime;
  }

  /**
   * This method adds a new move for the user and generates the corresponding SPARQL update and notification.
   * @param {string} san: SAN of the new move
   * @returns {{sparqlUpdate: string, notification: string}}: sparqlUpdate is the SPARQL update representing this move
   * and the corresponding inbox notification that should be send to the opponent
   * @private
   */
  _addUserMove(san) {
    // generate URL for move
    const moveURL = this.moveBaseUrl() + `#` + this.uniqid();

    let sparqlUpdate = 'INSERT DATA {\n';
    let notification = null;

    sparqlUpdate +=
    `<${this.url}> <${namespaces.chess}hasHalfMove> <${moveURL}>.

    <${moveURL}> <${namespaces.rdf}type> <${namespaces.chess}HalfMove>;
      <${namespaces.schema}subEvent> <${this.url}>;
      <${namespaces.chess}hasSANRecord> "${san}"^^<${namespaces.xsd}string>;
      <${namespaces.chess}resultingPosition> "${this.chess.fen()}".\n`;

    if (this.lastMove) {
      sparqlUpdate += `<${this.lastMove.url}> <${namespaces.chess}nextHalfMove> <${moveURL}>.\n`;
      notification = `<${this.lastMove.url}> <${namespaces.chess}nextHalfMove> <${moveURL}>.`;
    } else {
      sparqlUpdate += `<${this.url}> <${namespaces.chess}hasFirstHalfMove> <${moveURL}>.\n`;
      notification = `<${this.url}> <${namespaces.chess}hasFirstHalfMove> <${moveURL}>.`;
    }

    // if we have a checkmate we also say that this move is the last move
    if (this.chess.in_checkmate()) {
      sparqlUpdate += `<${this.url}> <${namespaces.chess}hasLastHalfMove> <${moveURL}>.\n`;
      notification += `<${this.url}> <${namespaces.chess}hasLastHalfMove> <${moveURL}>.`;
    }

    sparqlUpdate += `}`;

    this.lastMove = {
      url: moveURL,
      san
    };

    // because this method is only called when a move is done by the user, so we can set the lastUserMove
    this.lastUserMove = this.lastMove;

    return {
      sparqlUpdate,
      notification
    }
  }
}

module.exports = SemanticChess;