"use strict"; import GameRules, { RaisingRule } from "/models/game_rules.js"; import Game from "/models/game.js"; import { Team } from "/models/round.js"; /** A session of Watten. * * A session consists of several games, and can be continued for as long as the * players want to. * * This class keeps track of various games and offers the functionality to * start new ones. It also has a `result` property, that calculates the current * points totals. * * Note that game points are punitive, players want to avoid earning them. * Sessions are also self contained, there is no higher construct they are a * part of. */ export default class Session extends EventTarget { /** The event triggered when something about the session changes. */ static get EVENT_CHANGE() { return "wb:session:change"; } /** The ID of this session. */ #id = null; /** Get the ID of this session. */ get id() { return this.#id; } /** Set the ID of this session. * * Note that an existing ID cannot be overwritten. Setting an ID also doesn't * trigger the `Session.EVENT_CHANGE` event. */ set id(value) { if (this.#id !== null) throw new Error("the ID cannot be changed if it has been set"); this.#id = value; } /** The time when this session was initially created. */ #created = new Date(); /** Get the time when this session was initially created. */ get created() { return this.#created; } /** The time when this session was last updated. */ #updated = new Date(); /** Get the time when this session was last updated. */ get updated() { return this.#updated; } /** Mark this session as changed. * * Triggers the `Session.EVENT_CHANGE` event and sets the update time. */ #changed = () => { this.#updated = new Date(); this.dispatchEvent(new CustomEvent(Session.EVENT_CHANGE)); } /** The rules of the next game. */ #rules = new GameRules(); /** Get the rules of the next game. * * Note that the returned object can be manipulated. */ get rules() { return this.#rules; } /** The name or members of the "we" team. */ #ourTeam = ""; /** Get the name or members of the "we" team. */ get ourTeam() { return this.#ourTeam; } /** Set the name or members of the "we" team. */ set ourTeam(value) { this.#ourTeam = value; this.#changed(); } /** The name or members of the "they" team. */ #theirTeam = ""; /** Get the name or members of the "they" team. */ get theirTeam() { return this.#theirTeam; } /** Set the name or members of the "they" team. */ set theirTeam(value) { this.#theirTeam = value; this.#changed(); } /** 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.rules); this.#currentGame.addEventListener( Game.EVENT_CHANGE, this.#boundHandleGameChange); this.#changed(); } } /** 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 changes to the current game. */ #handleGameChange() { if (this.#currentGame.decided) { this.#currentGame.removeEventListener( Game.EVENT_CHANGE, this.#boundHandleGameChange); this.#games.push(this.#currentGame); this.#currentGame = null; } this.#changed(); } /** #handleGameChange, but bound to this instance. */ #boundHandleGameChange = this.#handleGameChange.bind(this); constructor(value) { super(); if (value === undefined) { this.#rules.addEventListener(GameRules.EVENT_CHANGE, this.#changed); } else if (typeof value === "object") { this.#fromStruct(value); } else { throw new TypeError("unknown form of Session constructor"); } } /** Export the data of this `Session` 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 `Session` 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() { let res = { rules: this.#rules.toStruct(), ourTeam: this.#ourTeam, theirTeam: this.#theirTeam, games: this.#games.map((g) => g.toStruct()), currentGame: this.#currentGame !== null ? this.#currentGame.toStruct() : null, created: this.#created, updated: this.#updated, }; if (this.#id !== null) res.id = this.#id; return res; } /** Read in an object created by `Session.toStruct` */ #fromStruct(value) { if (typeof value !== "object") throw new TypeError("struct must be an object"); if ("id" in value) { if (typeof value.id !== "number") throw new TypeError("if struct contains id, then it must be a number"); if (!Number.isInteger(value.id)) throw new RangeError( "if struct contains id, then it must be an integer"); this.#id = value.id; } if ("goal" in value && "rules" in value) throw new TypeError("struct cannot contain both rules and goal"); else if ("goal" in value) { if (typeof value.goal !== "number") throw new TypeError("if struct contains goal, it must be a number"); if (!Number.isInteger(value.goal) || value.goal < 1) throw new RangeError("if struct contains goal, must be integer >= 1"); this.#rules.goal = value.goal; this.#rules.raising = RaisingRule.UntilEnough; } else if ("rules" in value) { if (typeof value.rules !== "object") throw new TypeError("if struct contains rules, they must be an object"); this.#rules = new GameRules(value.rules); this.#rules.addEventListener(GameRules.EVENT_CHANGE, this.#changed); } else throw new TypeError("struct must contain either rules or goal"); if (typeof value.ourTeam !== "string") throw new TypeError("struct must contain ourTeam as string"); this.#ourTeam = value.ourTeam; if (typeof value.theirTeam !== "string") throw new TypeError("struct must contain theirTeam as string"); this.#theirTeam = value.theirTeam; if (!("games" in value)) throw new TypeError("struct must contain games"); if (!Array.isArray(value.games)) throw new TypeError("struct must contain games as array"); this.#games = value.games.map((g) => new Game(g)); for (let g of this.#games) if (g.result.winner === null) throw new Error("past games must be finished"); if (typeof value.currentGame !== "object") throw new TypeError("struct must contain currentGame as object"); if (value.currentGame !== null) { this.#currentGame = new Game(value.currentGame); this.#currentGame.addEventListener( Game.EVENT_CHANGE, this.#boundHandleGameChange); if (this.#currentGame.result.winner !== null) throw new Error("currentGame in struct must not be finished"); } if ("created" in value) { if (!(value.created instanceof Date)) throw new TypeError( "if struct contains creation time, it must be a date"); this.#created = value.created; } else this.#created = new Date("2026-02-26T22:00:00"); if ("updated" in value) { if (!(value.updated instanceof Date)) throw new TypeError( "if struct contains update time, it must be a date"); this.#updated = value.updated; } else this.#updated = new Date("2026-02-26T22:00:00"); } }