There are two reasons for this: 1. I've reconsidered my original plan to store the past games in localStorage, because that would make it difficult to display them in historical order, and would necessitate more complex logic for updating and removing sessions. 2. I've been unhappy with how I did the testing of the serialization and deserialization logic. So I redid it, and now I'm satisfied with it. I've noticed that the testing methodology for the invalid fromStruct method tests is not fully sound. If a check is accidentally removed that test would not detect that, as long as it is not the very last. That is because then the next error triggers. Therefore that will need to be revisited.
191 lines
5.6 KiB
JavaScript
191 lines
5.6 KiB
JavaScript
"use strict";
|
|
|
|
import { Round, Team } from "./round.js";
|
|
import RoundResult from "./round_result.js";
|
|
|
|
/** A single game of watten.
|
|
*
|
|
* A game consists of several rounds, and continues until either team reaches
|
|
* a points goal.
|
|
*
|
|
* This class keeps track of individual rounds and their results, and sets up
|
|
* new ones until the game is finished. It also has a `results` property, that
|
|
* calculates who won and how many points they earned.
|
|
*/
|
|
export default class Game extends EventTarget {
|
|
/** The event triggered when the game is finished. */
|
|
static finishedEvent = "gameFinished";
|
|
|
|
/** The finished rounds.
|
|
* @type {RoundResult[]}
|
|
*/
|
|
#rounds = [];
|
|
|
|
/** Get the finished rounds.
|
|
*
|
|
* DO NOT write to the returned object.
|
|
*/
|
|
get rounds() {
|
|
return this.#rounds;
|
|
}
|
|
|
|
/** How many points a team needs to win. */
|
|
#goal = 11;
|
|
|
|
/** Get how many points are needed to win. */
|
|
get goal() {
|
|
return this.#goal;
|
|
}
|
|
|
|
/** The current round.
|
|
* @type {?Round}
|
|
*/
|
|
#currentRound = null;
|
|
|
|
/** Get the current round of the game. */
|
|
get currentRound() {
|
|
return this.#currentRound;
|
|
}
|
|
|
|
constructor(value) {
|
|
super();
|
|
if (value === undefined || typeof value === "number") {
|
|
if (typeof value === "number")
|
|
this.#goal = value;
|
|
|
|
if (this.#goal < 1)
|
|
throw new RangeError("goal must be at least 1");
|
|
|
|
this.#currentRound = new Round(this.#goal, this.#goal);
|
|
this.#currentRound.addEventListener(
|
|
Round.winEvent, this.#boundRoundFinishedHandler);
|
|
} else if (typeof value === "object") {
|
|
this.#fromStruct(value);
|
|
} else {
|
|
throw new TypeError("unknown form of Game constructor");
|
|
}
|
|
}
|
|
|
|
/** Get the results of the game. */
|
|
get result() {
|
|
let ourPoints = 0;
|
|
let theirPoints = 0;
|
|
let tailor = null;
|
|
const tailorGoal = this.#goal - 2;
|
|
|
|
for (let r of this.#rounds) {
|
|
if (r.winner === Team.We)
|
|
ourPoints += r.points;
|
|
else if (r.winner === Team.They)
|
|
theirPoints += r.points;
|
|
|
|
if (tailor === null && (
|
|
(ourPoints >= tailorGoal && theirPoints === 0)
|
|
|| (theirPoints >= tailorGoal && ourPoints === 0)))
|
|
{
|
|
tailor = r.winner;
|
|
}
|
|
}
|
|
|
|
let weWon = ourPoints >= this.goal;
|
|
let theyWon = theirPoints >= this.goal;
|
|
let winner;
|
|
|
|
if (!weWon && !theyWon) {
|
|
return {winner: null, points: 0, ourPoints, theirPoints};
|
|
} else if (weWon && theyWon) {
|
|
throw new Error("game with multiple winners");
|
|
} else if (weWon) {
|
|
winner = Team.We;
|
|
} else {
|
|
winner = Team.They;
|
|
}
|
|
|
|
let points;
|
|
if (tailor !== null && winner !== tailor) {
|
|
points = 4;
|
|
} else if (tailor !== null && winner === tailor) {
|
|
points = 2;
|
|
} else {
|
|
points = 1;
|
|
}
|
|
|
|
return {winner, points, ourPoints, theirPoints};
|
|
}
|
|
|
|
/** Handle it when the current round is finished. */
|
|
#handleRoundFinished() {
|
|
this.#currentRound.removeEventListener(
|
|
Round.winEvent, this.#boundRoundFinishedHandler);
|
|
this.#rounds.push(
|
|
new RoundResult(this.#currentRound.points, this.#currentRound.winner));
|
|
this.#currentRound = null;
|
|
|
|
let result = this.result;
|
|
|
|
if (result.winner === null) {
|
|
this.#currentRound = new Round(
|
|
Math.max(this.#goal - result.ourPoints, 2),
|
|
Math.max(this.#goal - result.theirPoints, 2));
|
|
this.#currentRound.addEventListener(
|
|
Round.winEvent, this.#boundRoundFinishedHandler);
|
|
} else {
|
|
this.dispatchEvent(new CustomEvent(Game.finishedEvent));
|
|
}
|
|
}
|
|
|
|
#boundRoundFinishedHandler = this.#handleRoundFinished.bind(this);
|
|
|
|
/** Export the data of this `Game` as a plain JS object with fields.
|
|
*
|
|
* The internals of the returned object are not stabilized, even if they are
|
|
* visible. It should be treated as opaque.
|
|
*
|
|
* There are only two stabile uses of the object:
|
|
* 1. It can be passed to the `Game` constructor as a single argument. The
|
|
* constructor will then create a behaviourally identical instance to the
|
|
* one from which the object was created. This is guaranteed to be
|
|
* backwards compatible, i.e. a revised version of this class can still
|
|
* use the objects created by an older version.
|
|
* 2. It can be stored using IndexedDB.
|
|
*/
|
|
toStruct() {
|
|
return {
|
|
goal: this.#goal,
|
|
rounds: this.#rounds.map((r) => r.toStruct()),
|
|
currentRound:
|
|
this.#currentRound !== null ? this.#currentRound.toStruct() : null,
|
|
};
|
|
}
|
|
|
|
/** Read in an object created by `Game.toStruct` */
|
|
#fromStruct(value) {
|
|
if (typeof value !== "object")
|
|
throw new TypeError("struct must be an object");
|
|
|
|
if (typeof value.goal !== "number")
|
|
throw new TypeError("struct must contain goal as number");
|
|
if (!Number.isInteger(value.goal) || value.goal < 1)
|
|
throw new RangeError("struct must contain goal >= 1 as integer");
|
|
this.#goal = value.goal;
|
|
|
|
if (!("rounds" in value))
|
|
throw new TypeError("struct must contain rounds");
|
|
if (!Array.isArray(value.rounds))
|
|
throw new TypeError("struct must contain rounds as array");
|
|
this.#rounds = value.rounds.map((r) => new RoundResult(r));
|
|
|
|
if (typeof value.currentRound !== "object")
|
|
throw new TypeError("struct must contain currentRound as object");
|
|
if (this.result.winner === null) {
|
|
if (value.currentRound === null)
|
|
throw new TypeError(
|
|
"struct of ongoing game must contain current round");
|
|
else
|
|
this.#currentRound = new Round(value.currentRound);
|
|
} else if (value.currentRound !== null)
|
|
throw new TypeError(
|
|
"struct of finished game must not contain current round");
|
|
}
|
|
}
|