diff --git a/models/session.js b/models/session.js
new file mode 100644
index 0000000..04814ae
--- /dev/null
+++ b/models/session.js
@@ -0,0 +1,138 @@
+"use strict";
+
+import Game from "./game.js";
+import { Team } from "./round.js";
+
+export default class Session {
+ /** The amout of points at which individual games are won.
+ *
+ * Only applies to new games.
+ */
+ goal = 11;
+
+ /** The name or members of the "we" team. */
+ ourTeam = "";
+
+ /** The name or members of the "they" team. */
+ theirTeam = "";
+
+ /** The finished games.
+ * @type {Game[]}
+ */
+ #games = [];
+
+ /** Get the finished games.
+ *
+ * DO NOT write to the returned object.
+ */
+ get games() {
+ return this.#games;
+ }
+
+ /** The currently played game.
+ * @type {?Game}
+ */
+ #currentGame = null;
+
+ /** Get the currently played game. */
+ get currentGame() {
+ return this.#currentGame;
+ }
+
+ /** Add another round if there is no current one. */
+ anotherGame() {
+ if (this.#currentGame === null) {
+ this.#currentGame = new Game(this.goal);
+ this.#currentGame.addEventListener(
+ Game.finishedEvent, this.#boundGameFinishedHandler);
+ }
+ }
+
+ /** Get the current amouts of points.
+ *
+ * Note that on this level points are a punishment.
+ */
+ get result() {
+ let ourPoints = 0;
+ let theirPoints = 0;
+
+ for (let g of this.#games) {
+ let r = g.result;
+ if (r.winner === Team.We) {
+ theirPoints += r.points;
+ } else if (r.winner === Team.They) {
+ ourPoints += r.points;
+ }
+ }
+
+ return { ourPoints, theirPoints };
+ }
+
+ /** Handle it when the current game is finished. */
+ #gameFinishedHandler() {
+ this.#currentGame.removeEventListener(
+ Game.finishedEvent, this.#boundGameFinishedHandler);
+ this.#games.push(this.#currentGame);
+ this.#currentGame = null;
+ }
+
+ #boundGameFinishedHandler = this.#gameFinishedHandler.bind(this);
+
+ constructor(value) {
+ if (value === undefined) {
+ this.anotherGame();
+ } 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 (!("ourTeam" in value))
+ throw new TypeError("missing ourTeam in deserialization object");
+ if (typeof value.ourTeam !== "string")
+ throw new TypeError(
+ "ourTeam in deserialization object must be string");
+ this.ourTeam = value.ourTeam;
+
+ if (!("theirTeam" in value))
+ throw new TypeError("missing theirTeam in deserialization object");
+ if (typeof value.theirTeam !== "string")
+ throw new TypeError(
+ "theirTeam in deserialization object must be string");
+ this.theirTeam = value.theirTeam;
+
+ if (!("games" in value))
+ throw new TypeError("missing games in deserialization object");
+ if (!Array.isArray(value.games))
+ throw new TypeError("games in deserialization object must be array");
+ for (let g of value.games) {
+ let game = new Game (g);
+ if (game.result.winner === null)
+ throw new TypeError("past game cannot be unfinished");
+ this.#games.push(game);
+ }
+
+ if (!("currentGame" in value))
+ throw new TypeError("missing currentGame in deserialization object");
+ if (value.currentGame !== null) {
+ this.#currentGame = new Game(value.currentGame);
+ if (this.#currentGame.result.winner !== null)
+ throw new Error("currentGame cannot be finished");
+ }
+ } else {
+ throw new TypeError("unknown form of Session constructor");
+ }
+ }
+
+ /** Export needed data for JSON serialization. */
+ toJSON() {
+ return {
+ goal: this.goal,
+ ourTeam: this.ourTeam,
+ theirTeam: this.theirTeam,
+ games: this.#games,
+ currentGame: this.#currentGame,
+ }
+ }
+}
diff --git a/models/session.test.js b/models/session.test.js
new file mode 100644
index 0000000..5d7c45c
--- /dev/null
+++ b/models/session.test.js
@@ -0,0 +1,242 @@
+"use strict";
+
+import { Round, Team } from "./round.js";
+import Game from "./game.js";
+import Session from "./session.js";
+
+QUnit.module("models", function() {
+ QUnit.module("session", function() {
+ QUnit.test("initial state", function(assert) {
+ let session = new Session();
+ assert.strictEqual(session.goal, 11, "initial goal");
+ assert.strictEqual(session.games.length, 0, "no finished games");
+ assert.notStrictEqual(session.currentGame, null, "game in progress");
+ assert.deepEqual(
+ session.result,
+ { ourPoints: 0, theirPoints: 0 },
+ "initially no points");
+ assert.strictEqual(session.ourTeam, "", "our team name");
+ assert.strictEqual(session.theirTeam, "", "their team name");
+ });
+
+ QUnit.test("single game finished", function(assert) {
+ let session = new Session();
+ session.currentGame.currentRound.winner = Team.We;
+ for (let i = 0; i < session.goal; i += 2)
+ session.currentGame.currentRound.winner = Team.They;
+
+ assert.strictEqual(session.games.length, 1, "single game");
+ assert.deepEqual(
+ session.games[0].result,
+ {
+ winner: Team.They,
+ points: 1,
+ ourPoints: 2,
+ theirPoints: 12,
+ });
+ assert.strictEqual(session.currentGame, null, "no game in progress");
+ assert.deepEqual(
+ session.result,
+ { ourPoints: 1, theirPoints: 0 },
+ "one point for losing team");
+ });
+
+ QUnit.test("two games finished", function(assert) {
+ let session = new Session();
+ session.currentGame.currentRound.winner = Team.We;
+ for (let i = 0; i < session.goal; i += 2)
+ session.currentGame.currentRound.winner = Team.They;
+ session.anotherGame();
+ for (let i = 0; i < session.goal; i += 2)
+ session.currentGame.currentRound.winner = Team.We;
+
+ assert.strictEqual(session.games.length, 2, "two games")
+ assert.deepEqual(
+ session.games[1].result,
+ {
+ winner: Team.We,
+ points: 2,
+ ourPoints: 12,
+ theirPoints: 0,
+ });
+ assert.strictEqual(session.currentGame, null, "no game in progress");
+ assert.deepEqual(
+ session.result,
+ { ourPoints: 1, theirPoints: 2 },
+ "one point for losing team");
+ });
+
+ QUnit.test("new game doesn't overwrite existing", function(assert) {
+ let session = new Session();
+ session.currentGame.currentRound.winner = Team.We;
+ assert.notStrictEqual(session.currentGame, null, "ongoing game");
+
+ session.anotherGame();
+ assert.deepEqual(
+ session.currentGame.result,
+ {
+ winner: null,
+ points: 0,
+ ourPoints: 2,
+ theirPoints: 0,
+ },
+ "initial game still current");
+ });
+
+ QUnit.test("serialization - new session", function(assert) {
+ let session = new Session();
+ let json = session.toJSON();
+ json.currentGame = session.currentGame.toJSON();
+
+ assert.deepEqual(
+ json,
+ {
+ goal: 11,
+ ourTeam: "",
+ theirTeam: "",
+ games: [],
+ currentGame: session.currentGame.toJSON()
+ },
+ "correct serialization");
+ });
+
+ QUnit.test("serialization - finished & unfinished game", function(assert) {
+ let session = new Session();
+ session.currentGame.currentRound.winner = Team.We;
+ for (
+ let i = 0;
+ session.currentGame !== null && i < session.currentGame.goal;
+ i += 2
+ )
+ session.currentGame.currentRound.winner = Team.They;
+
+ session.goal = 15;
+ session.anotherGame();
+ session.currentGame.currentRound.winner = Team.They;
+ for (
+ let i = 0;
+ session.currentGame !== null && i < session.currentGame.goal - 2;
+ i += 2
+ )
+ session.currentGame.currentRound.winner = Team.We;
+
+ session.goal = 5;
+ session.ourTeam = "This is us!";
+ session.theirTeam = "This is them!";
+
+ let json = session.toJSON();
+ json.games = [];
+ for (let i = 0; i < session.games.length; i++)
+ json.games.push(session.games[i].toJSON());
+ json.currentGame = session.currentGame.toJSON();
+
+ assert.deepEqual(
+ json,
+ {
+ goal: 5,
+ ourTeam: "This is us!",
+ theirTeam: "This is them!",
+ games: [
+ session.games[0].toJSON(),
+ ],
+ currentGame: session.currentGame.toJSON(),
+ },
+ "correct serialization");
+ assert.strictEqual(json.games[0].goal, 11, "first goal");
+ assert.strictEqual(json.currentGame.goal, 15, "second goal");
+ });
+
+ QUnit.test("deserialization - new session", function(assert) {
+ let game = new Game();
+ let json = {
+ goal: 11,
+ ourTeam: "",
+ theirTeam: "",
+ games: [],
+ currentGame: game.toJSON(),
+ };
+ json.currentGame.currentRound = game.currentRound.toJSON();
+
+ let session = new Session(json);
+ assert.strictEqual(session.goal, 11, "goal");
+ assert.strictEqual(session.ourTeam, "", "our team name");
+ assert.strictEqual(session.theirTeam, "", "their team name");
+ assert.strictEqual(session.games.length, 0, "no past games");
+ assert.deepEqual(session.currentGame.toJSON(), game.toJSON());
+ });
+
+ QUnit.test("deserialization - un- and finished games", function(assert) {
+ let finished = new Game(2);
+ finished.currentRound.winner = Team.We;
+
+ let unfinished = new Game(3);
+ unfinished.currentRound.winner = Team.They;
+
+ let json = {
+ goal: 4,
+ ourTeam: "This is us!",
+ theirTeam: "This is them!",
+ games: [finished],
+ currentGame: unfinished,
+ };
+ let deso = JSON.parse(JSON.stringify(json));
+ let session = new Session(deso);
+
+ assert.strictEqual(session.goal, 4, "goal");
+ assert.strictEqual(session.ourTeam, "This is us!", "our team name");
+ assert.strictEqual(session.theirTeam, "This is them!", "their team");
+ assert.strictEqual(session.games.length, 1, "one past game");
+ assert.deepEqual(
+ session.games[0].toJSON(), finished.toJSON(), "finished game");
+ assert.notStrictEqual(session.currentGame, null, "unfinished game here");
+ assert.deepEqual(
+ session.currentGame.toJSON(), unfinished.toJSON(), "unfinished game");
+ });
+
+ QUnit.test("deserialization - invalid", function(assert) {
+ let deso = {};
+ assert.throws(function() { new Session(deso); }, "no goal");
+
+ deso.goal = "11";
+ assert.throws(function() { new Session(deso); }, "string goal");
+
+ deso.goal = 11;
+ assert.throws(function() { new Session(deso); }, "no ourTeam");
+
+ deso.ourTeam = 11;
+ assert.throws(function() { new Session(deso); }, "number ourTeam");
+
+ deso.ourTeam = "";
+ assert.throws(function() { new Session(deso); }, "no theirTeam");
+
+ deso.theirTeam = 11;
+ assert.throws(function() { new Session(deso); }, "number theirTeam");
+
+ deso.theirTeam = "";
+ assert.throws(function() { new Session(deso); }, "no games");
+
+ deso.games = null;
+ assert.throws(function() { new Session(deso); }, "null games");
+
+ deso.games = [];
+ assert.throws(function() { new Session(deso); }, "no currentGame");
+
+ deso.currentGame = {
+ goal: 3,
+ rounds: [{ winner: Team.They, points: 3 }],
+ currentRound: null,
+ };
+ assert.throws(function() { new Session(deso); }, "finished currentGame");
+
+ deso.currentGame = null;
+ new Session(deso);
+
+ deso.games = [{
+ goal: 3,
+ rounds: [{ winner: Team.They, points: 2}],
+ currentRound: (new Round(3, 2)).toJSON(),
+ }];
+ assert.throws(function() { new Session(deso); }, "unfinished past");
+ });
+ });
+});
diff --git a/test.html b/test.html
index e7811a3..e0ca443 100644
--- a/test.html
+++ b/test.html
@@ -14,6 +14,7 @@
+