diff --git a/models/game.js b/models/game.js
new file mode 100644
index 0000000..34be225
--- /dev/null
+++ b/models/game.js
@@ -0,0 +1,147 @@
+"use strict";
+
+import { Round, Team } from "./round.js";
+import RoundResult from "./round_result.js";
+
+export default class Game {
+ /** 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) {
+ if (value === undefined || typeof value === "number") {
+ if (typeof value === "number")
+ this.#goal = value;
+
+ this.#currentRound = new Round(this.#goal, this.#goal);
+ this.#currentRound.addEventListener(
+ Round.winEvent, this.#boundRoundFinishedHandler);
+ } else if (typeof value === "object") {
+ if (!("goal" in value))
+ throw new TypeError("missing goal in deserialization object");
+ if (typeof value.goal !== "number")
+ throw new TypeError("goal in deserialization object must be number");
+ this.#goal = value.goal;
+
+ if (!("rounds" in value))
+ throw new TypeError("missing rounds in deserialization object");
+ if (!Array.isArray(value.rounds))
+ throw new TypeError("rounds in deserialization object must be array");
+ for (let r of value.rounds)
+ this.#rounds.push(new RoundResult(r));
+
+ if (!("currentRound" in value))
+ throw new TypeError("missing currentRound in deserialization object");
+ if (this.result.winner === null)
+ this.#currentRound = new Round(value.currentRound);
+ else if (value.currentRound !== null)
+ throw new TypeError("currentRound in finished game must be null");
+ } 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);
+ }
+ }
+
+ #boundRoundFinishedHandler = this.#handleRoundFinished.bind(this);
+
+ /** Export needed data for JSON serialization. */
+ toJSON() {
+ return {
+ goal: this.#goal,
+ rounds: this.#rounds,
+ currentRound: this.#currentRound,
+ };
+ }
+}
diff --git a/models/game.test.js b/models/game.test.js
new file mode 100644
index 0000000..f1a29e6
--- /dev/null
+++ b/models/game.test.js
@@ -0,0 +1,346 @@
+"use strict";
+
+import { Round, Team } from "./round.js";
+import RoundResult from "./round_result.js";
+import Game from "./game.js";
+
+QUnit.module("models", function() {
+ QUnit.module("game", function() {
+ QUnit.test("default construction", function(assert) {
+ let game = new Game();
+ assert.strictEqual(game.rounds.length, 0, "no past rounds");
+ assert.equal(game.goal, 11, "default goal");
+ assert.notStrictEqual(game.currentRound, null, "current round there");
+ assert.deepEqual(
+ game.result,
+ {
+ winner: null,
+ points: 0,
+ ourPoints: 0,
+ theirPoints: 0
+ },
+ "initial results",
+ );
+ });
+
+ QUnit.test("higher goal", function(assert) {
+ let game = new Game(15);
+ assert.strictEqual(game.rounds.length, 0, "no past rounds");
+ assert.equal(game.goal, 15, "higher goal");
+ assert.notStrictEqual(game.currentRound, null, "current round there");
+ assert.deepEqual(
+ game.result,
+ {
+ winner: null,
+ points: 0,
+ ourPoints: 0,
+ theirPoints: 0
+ },
+ "initial results",
+ );
+ });
+
+ QUnit.test("single round played", function(assert) {
+ let game = new Game();
+ game.currentRound.winner = Team.We;
+
+ assert.equal(game.rounds.length, 1, "one round played");
+ assert.deepEqual(
+ game.rounds[0].toJSON(),
+ { points: 2, winner: Team.We},
+ "first round correct");
+ assert.notStrictEqual(game.currentRound, null, "current round there");
+ assert.false(game.currentRound.decided, "current round is not decided");
+ assert.deepEqual(
+ game.result,
+ {
+ winner: null,
+ points: 0,
+ ourPoints: 2,
+ theirPoints: 0
+ },
+ "intermediate results",
+ );
+ });
+
+ QUnit.test("two rounds played", function(assert) {
+ let game = new Game();
+ game.currentRound.winner = Team.We;
+ game.currentRound.raise(Team.We);
+ game.currentRound.winner = Team.They;
+
+ assert.equal(game.rounds.length, 2, "two round played");
+ assert.deepEqual(
+ game.rounds[1].toJSON(),
+ { points: 3, winner: Team.They},
+ "second round correct");
+ assert.notStrictEqual(game.currentRound, null, "current round there");
+ assert.false(game.currentRound.decided, "current round is not decided");
+ assert.deepEqual(
+ game.result,
+ {
+ winner: null,
+ points: 0,
+ ourPoints: 2,
+ theirPoints: 3
+ },
+ "intermediate results",
+ );
+ });
+
+ QUnit.test("regular victory", function(assert) {
+ let game = new Game();
+ game.currentRound.winner = Team.We; // 2
+ game.currentRound.winner = Team.They; // 2
+ game.currentRound.winner = Team.We; // 4
+ game.currentRound.winner = Team.We; // 6
+ game.currentRound.winner = Team.We; // 8
+ game.currentRound.winner = Team.We; // 10
+ game.currentRound.winner = Team.We; // 12
+
+ assert.equal(game.rounds.length, 7, "seven rounds played");
+ assert.strictEqual(game.currentRound, null, "no further rounds");
+ assert.deepEqual(
+ game.result,
+ {
+ winner: Team.We,
+ points: 1,
+ ourPoints: 12,
+ theirPoints: 2,
+ },
+ "final results",
+ );
+ });
+
+ QUnit.test("tailor victory", function(assert) {
+ let game = new Game();
+ game.currentRound.winner = Team.They; // 2
+ game.currentRound.winner = Team.They; // 4
+ game.currentRound.winner = Team.They; // 6
+ game.currentRound.winner = Team.They; // 8
+ game.currentRound.winner = Team.They; // 10
+ game.currentRound.winner = Team.They; // 12
+
+ assert.equal(game.rounds.length, 6, "seven rounds played");
+ assert.strictEqual(game.currentRound, null, "no further rounds");
+ assert.deepEqual(
+ game.result,
+ {
+ winner: Team.They,
+ points: 2,
+ ourPoints: 0,
+ theirPoints: 12,
+ },
+ "final results",
+ );
+ });
+
+ QUnit.test("reverse tailor victory", function(assert) {
+ let game = new Game();
+ game.currentRound.winner = Team.We; // 2
+ game.currentRound.winner = Team.We; // 4
+ game.currentRound.winner = Team.We; // 6
+ game.currentRound.winner = Team.We; // 8
+ game.currentRound.winner = Team.We; // 10
+ game.currentRound.winner = Team.They; // 2
+ game.currentRound.winner = Team.They; // 4
+ game.currentRound.winner = Team.They; // 6
+ game.currentRound.winner = Team.They; // 8
+ game.currentRound.winner = Team.They; // 10
+ game.currentRound.winner = Team.They; // 12
+
+ assert.equal(game.rounds.length, 11, "eleven rounds played");
+ assert.strictEqual(game.currentRound, null, "no further rounds");
+ assert.deepEqual(
+ game.result,
+ {
+ winner: Team.They,
+ points: 4,
+ ourPoints: 10,
+ theirPoints: 12,
+ },
+ "final results",
+ );
+ });
+
+ QUnit.test("reverse tailor victory with low goal", function(assert) {
+ let game = new Game(3);
+ game.currentRound.winner = Team.They; // 2
+ game.currentRound.winner = Team.We; // 2
+ game.currentRound.winner = Team.We; // 4
+
+ assert.equal(game.rounds.length, 3, "three rounds played");
+ assert.strictEqual(game.currentRound, null, "no further rounds");
+ assert.deepEqual(
+ game.result,
+ {
+ winner: Team.We,
+ points: 4,
+ ourPoints: 4,
+ theirPoints: 2,
+ },
+ "final results",
+ );
+ });
+
+ QUnit.test("serialization - unfinished", function(assert) {
+ let game = new Game();
+ game.currentRound.winner = Team.We;
+ game.currentRound.raise(Team.They);
+ game.currentRound.winner = Team.They;
+ game.currentRound.raise(Team.We);
+
+ let json = game.toJSON();
+ json.currentRound = json.currentRound.toJSON();
+ for (let i = 0; i < json.rounds.length; i++)
+ json.rounds[i] = json.rounds[i].toJSON();
+
+ assert.deepEqual(
+ json,
+ {
+ goal: 11,
+ rounds: [
+ { points: 2, winner: Team.We },
+ { points: 3, winner: Team.They },
+ ],
+ currentRound: {
+ points: 3,
+ raisedLast: Team.We,
+ winner: null,
+ weLimit: 9,
+ theyLimit: 8,
+ },
+ },
+ "serialized data"
+ );
+ });
+
+ QUnit.test("serialization - finished", function(assert) {
+ let game = new Game(3);
+ game.currentRound.winner = Team.We;
+ game.currentRound.raise(Team.They);
+ game.currentRound.winner = Team.They;
+
+ let json = game.toJSON();
+ for (let i = 0; i < json.rounds.length; i++)
+ json.rounds[i] = json.rounds[i].toJSON();
+
+ assert.deepEqual(
+ json,
+ {
+ goal: 3,
+ rounds: [
+ { points: 2, winner: Team.We },
+ { points: 3, winner: Team.They },
+ ],
+ currentRound: null,
+ },
+ "serialized data"
+ );
+ });
+
+ QUnit.test("deserialize - unfinished", function(assert) {
+ let game = new Game({
+ goal: 3,
+ rounds: [{ winner: Team.We, points: 2 }],
+ currentRound: {
+ points: 3,
+ raisedLast: Team.They,
+ winner: null,
+ weLimit: 2,
+ theyLimit: 3,
+ },
+ });
+
+ assert.strictEqual(game.goal, 3, "goal");
+ assert.strictEqual(game.rounds.length, 1, "one round played");
+ assert.deepEqual(
+ game.rounds[0].toJSON(),
+ { winner: Team.We, points: 2 },
+ "correct past round");
+ assert.deepEqual(
+ game.currentRound.toJSON(),
+ {
+ points: 3,
+ raisedLast: Team.They,
+ winner: null,
+ weLimit: 2,
+ theyLimit: 3,
+ },
+ "correct current round");
+ assert.deepEqual(
+ game.result,
+ {
+ winner: null,
+ points: 0,
+ ourPoints: 2,
+ theirPoints: 0,
+ },
+ "intermediate results");
+ });
+
+ QUnit.test("deserialize - finished", function(assert) {
+ let game = new Game({
+ goal: 3,
+ rounds: [{ winner: Team.They, points: 3 }],
+ currentRound: null,
+ });
+
+ assert.strictEqual(game.goal, 3, "goal");
+ assert.strictEqual(game.rounds.length, 1, "one round played");
+ assert.deepEqual(
+ game.rounds[0].toJSON(),
+ { winner: Team.They, points: 3 },
+ "correct past round");
+ assert.strictEqual(game.currentRound, null, "no current round");
+ assert.deepEqual(
+ game.result,
+ {
+ winner: Team.They,
+ points: 2,
+ ourPoints: 0,
+ theirPoints: 3,
+ },
+ "final results");
+ });
+
+ QUnit.test("deserialize - invalid", function(assert) {
+ let deso = {};
+ assert.throws(function() { new Game(deso); }, "no goal");
+
+ deso.goal = "5";
+ assert.throws(function() { new Game(deso); }, "string goal");
+
+ deso.goal = 5;
+ assert.throws(function() { new Game(deso); }, "no rounds");
+
+ deso.rounds = ["nonono"];
+ assert.throws(function() { new Game(deso); }, "string rounds");
+
+ deso.rounds = [];
+ assert.throws(function() { new Game(deso); }, "no currentRound");
+
+ deso.currentRound = null;
+ assert.throws(function() { new Game(deso); }, "missing currentRound");
+
+ deso.currentRound = "nonono";
+ assert.throws(function() { new Game(deso); }, "broken currentRound");
+
+ deso.rounds = [{ winner: Team.We, points: 5 }];
+ deso.currentRound = {
+ points: 2,
+ raisedLast: Team.They,
+ winner: null,
+ weLimit: 2,
+ theyLimit: 5};
+ assert.throws(function() { new Game(deso); }, "unneeded currentRound");
+
+ deso.goal = 11;
+ new Game(deso);
+
+ deso.goal = 5;
+ deso.currentRound = null;
+ new Game(deso);
+ });
+ });
+});
diff --git a/test.html b/test.html
index ace4f27..e7811a3 100644
--- a/test.html
+++ b/test.html
@@ -13,6 +13,7 @@
+