1
0
watterblock/models/game.js
Adrian Wannenmacher bd79aa568a
switch session and game models to game rules
This is a more flexible system than the previous goal variable. It makes
it possible to conform to the association rules, without sacrificing
backwards compatibility. Also, it makes it easier to add other
changeable rules later on.
2026-03-02 02:52:19 +01:00

222 lines
6.9 KiB
JavaScript

"use strict";
import GameRules, { RaisingRule } from "/models/game_rules.js";
import { Round, Team } from "/models/round.js";
import RoundResult from "/models/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 `result` property, that
* calculates who won and how many points they earned.
*
* Note that game points are punitive, players want to avoid earning them.
*/
export default class Game extends EventTarget {
/** The event triggered when something about the game changes. */
static get EVENT_CHANGE() { return "wb:game:change"; }
/** The finished rounds.
* @type {RoundResult[]}
*/
#rounds = [];
/** Get the finished rounds.
*
* DO NOT write to the returned object.
*/
get rounds() {
return this.#rounds;
}
/** The rules of this game. */
#rules = new GameRules();
/** Get the rules of this game.
*
* Note that this actually returns a copy of the game rules. They cannot be
* changed, as changing the rules during a game would a) be unfair and b)
* rather difficult to correctly implement.
*/
get rules() {
return new GameRules(this.#rules);
}
/** 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 || value instanceof GameRules) {
if (value instanceof GameRules)
this.#rules = value;
this.#currentRound = new Round(
this.#rules.raisingLimit(0), this.#rules.raisingLimit(0));
this.#currentRound.addEventListener(
Round.EVENT_CHANGE, this.#boundHandleRoundChange);
} else if (typeof value === "object") {
this.#fromStruct(value);
} else {
throw new TypeError("unknown form of Game constructor");
}
}
/** Check whether the game is finished. */
get decided() {
return this.#currentRound === null;
}
/** Get the results of the game. */
get result() {
let ourPoints = 0;
let theirPoints = 0;
let tailor = null;
const tailorGoal = this.#rules.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.#rules.goal;
let theyWon = theirPoints >= this.#rules.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
&& (ourPoints === 0 || theirPoints === 0)
) {
points = 2;
} else {
points = 1;
}
return {winner, points, ourPoints, theirPoints};
}
/** Handle changes to the current round. */
#handleRoundChange() {
if (this.#currentRound.decided) {
this.#currentRound.removeEventListener(
Round.EVENT_CHANGE, this.#boundHandleRoundChange);
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(
this.#rules.raisingLimit(result.ourPoints),
this.#rules.raisingLimit(result.theirPoints));
this.#currentRound.addEventListener(
Round.EVENT_CHANGE, this.#boundHandleRoundChange);
}
}
this.dispatchEvent(new CustomEvent(Game.EVENT_CHANGE));
}
/** #handleRoundChange, but bound to this instance. */
#boundHandleRoundChange = this.#handleRoundChange.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 {
rules: this.#rules.toStruct(),
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 ("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);
} else
throw new TypeError("struct must contain either rules or 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 Error(
"struct of ongoing game must contain current round");
else {
this.#currentRound = new Round(value.currentRound);
this.#currentRound.addEventListener(
Round.EVENT_CHANGE, this.#boundHandleRoundChange);
}
} else if (value.currentRound !== null)
throw new Error(
"struct of finished game must not contain current round");
}
}