From 14aad4a73dac89d84aff2a21ae326807fcf54525 Mon Sep 17 00:00:00 2001 From: Adrian Wannenmacher Date: Sun, 1 Mar 2026 22:42:28 +0100 Subject: [PATCH] implement game rules model --- models/game_rules.js | 128 +++++++++++++++++++++++++++ models/game_rules.test.js | 182 ++++++++++++++++++++++++++++++++++++++ test.js | 2 + 3 files changed, 312 insertions(+) create mode 100644 models/game_rules.js create mode 100644 models/game_rules.test.js diff --git a/models/game_rules.js b/models/game_rules.js new file mode 100644 index 0000000..7b81b4b --- /dev/null +++ b/models/game_rules.js @@ -0,0 +1,128 @@ +"use strict"; + +/** Rules for how long teams can raise. */ +export const RaisingRule = Object.freeze({ + /** Teams can raƬse unless they are stricken. + * + * This means a team can win until it needs two points or less to win the + * game. This corresponds to the starting amount of points. + */ + UnlessStricken: 1, + /** Teams can raise until they would win the game if they won the round. */ + UntilEnough: 2, + + /** Check if the passed value is a raising rule. + * + * @param {RaisingRule} rule The rule to check. + * @returns {boolean} Whether the value is a raising rule. + */ + isRaisingRule(rule) { + return (rule === RaisingRule.UnlessStricken) + || (rule === RaisingRule.UntilEnough); + } +}); + +/** The rules of a specific game. */ +export default class GameRules extends EventTarget { + /** The event triggered when something about the game rules changes. */ + static get EVENT_CHANGE() { return "wb:game_rules:change"}; + + /** The points target needed to win a round. */ + #goal = 11; + + /** Get the points target needed to win a round. */ + get goal() { + return this.#goal; + } + + /** Set the points target needed to win a round. + * + * Must be at least 1. + */ + set goal(value) { + if (!Number.isInteger(value)) + throw new TypeError("goal must be an integer value"); + if (value < 1) + throw new RangeError("goal must be at least one"); + this.#goal = value; + this.dispatchEvent(new CustomEvent(GameRules.EVENT_CHANGE)); + } + + /** The rules about how long teams can raise. */ + #raising = RaisingRule.UnlessStricken; + + /** Get the rules about how long teams can raise. */ + get raising() { + return this.#raising; + } + + /** Set the rules about how long teams can raise. */ + set raising(value) { + if (!RaisingRule.isRaisingRule(value)) + throw new TypeError("raising rule must be actual raising rule"); + this.#raising = value; + this.dispatchEvent(new CustomEvent(GameRules.EVENT_CHANGE)); + } + + constructor(value) { + super(); + if (value === undefined){ + } else if (value instanceof GameRules) { + this.goal = value.goal; + this.raising = value.raising; + } else if (typeof value === "object") { + this.#fromStruct(value); + } else { + throw new TypeError("unknown form of GameRules constructor"); + } + } + + /** Calculate to what number a team can raise the points. + * + * @param {number} currentPoints The teams current points. + * @returns {number} The target to which the team can raise. + */ + raisingLimit(currentPoints) { + if (this.#raising === RaisingRule.UnlessStricken) + return (currentPoints >= (this.#goal - 2)) ? 2 : Number.MAX_SAFE_INTEGER; + if (this.#raising === RaisingRule.UntilEnough) + return Math.max(this.#goal - currentPoints, 2); + throw new TypeError("unknown raising rule"); + } + + /** Export the data of this `GameRules` 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 `GameRules` 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, + raising: this.#raising, + }; + } + + /** Read in an object created by `GameRules.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 (!("raising" in value) || !RaisingRule.isRaisingRule(value.raising)) + throw new TypeError("struct must contain valid raising rule"); + this.#raising = value.raising; + } +} diff --git a/models/game_rules.test.js b/models/game_rules.test.js new file mode 100644 index 0000000..e8a19da --- /dev/null +++ b/models/game_rules.test.js @@ -0,0 +1,182 @@ +"use strict"; + +import GameRules, { RaisingRule } from "/models/game_rules.js"; + +export default function() { + QUnit.module("game_rules", function() { + QUnit.test("default constructor", function(assert) { + let rules = new GameRules(); + assert.strictEqual(rules.goal, 11, "initial goal"); + assert.strictEqual( + rules.raising, RaisingRule.UnlessStricken, "initial raising rule"); + }); + + QUnit.test("copy constructor", function(assert) { + let first = new GameRules(); + first.goal = 5; + first.raising = RaisingRule.UntilEnough; + + let second = new GameRules(first); + assert.strictEqual(second.goal, first.goal, "copied goal"); + assert.strictEqual(second.raising, first.raising, "copied raising rule"); + }); + + QUnit.test("invalid constructor", function(assert) { + assert.throws( + function() { new GameRules("nope"); }, + new TypeError("unknown form of GameRules constructor")); + }); + + QUnit.test("setting goal", function(assert) { + let rules = new GameRules(); + rules.addEventListener( + GameRules.EVENT_CHANGE, () => assert.step("change")); + + rules.goal = 15; + assert.strictEqual(rules.goal, 15, "correct goal"); + assert.verifySteps(["change"], "change triggered"); + + assert.throws( + () => rules.goal = true, + new TypeError("goal must be an integer value"), + "bool goal"); + + assert.throws( + () => rules.goal = 0, + new RangeError("goal must be at least one"), + "zero goal"); + + assert.throws( + () => rules.goal = -15, + new RangeError("goal must be at least one"), + "negative goal"); + + rules.goal = 1; + assert.strictEqual(rules.goal, 1, "one goal"); + assert.verifySteps(["change"], "change triggered"); + }); + + QUnit.test("setting raising rule", function(assert) { + let rules = new GameRules(); + rules.addEventListener( + GameRules.EVENT_CHANGE, () => assert.step("change")); + + rules.raising = RaisingRule.UntilEnough; + assert.strictEqual( + rules.raising, RaisingRule.UntilEnough, "until enough"); + assert.verifySteps(["change"], "change triggered"); + + rules.raising = RaisingRule.UnlessStricken; + assert.strictEqual( + rules.raising, RaisingRule.UnlessStricken, "unless sticken"); + assert.verifySteps(["change"], "change triggered"); + + assert.throws( + () => rules.raising = 5, + new TypeError("raising rule must be actual raising rule"), + "integer raising rule"); + + assert.throws( + () => rules.raising = true, + new TypeError("raising rule must be actual raising rule"), + "boolean raising rule"); + }); + + QUnit.test.each( + "raisingLimit - unless stricken", + [ + { rule: RaisingRule.UnlessStricken, goal: 2, points: 0, limit: 2 }, + { rule: RaisingRule.UnlessStricken, goal: 3, points: 0 }, + { rule: RaisingRule.UnlessStricken, goal: 11, points: 0 }, + { rule: RaisingRule.UnlessStricken, goal: 11, points: 2 }, + { rule: RaisingRule.UnlessStricken, goal: 11, points: 9, limit: 2 }, + { rule: RaisingRule.UnlessStricken, goal: 11, points: 11, limit: 2 }, + { rule: RaisingRule.UnlessStricken, goal: 11, points: 13, limit: 2 }, + { rule: RaisingRule.UntilEnough, goal: 2, points: 0, limit: 2 }, + { rule: RaisingRule.UntilEnough, goal: 11, points: 0, limit: 11 }, + { rule: RaisingRule.UntilEnough, goal: 11, points: 2, limit: 9 }, + { rule: RaisingRule.UntilEnough, goal: 11, points: 9, limit: 2 }, + { rule: RaisingRule.UntilEnough, goal: 11, points: 11, limit: 2 }, + { rule: RaisingRule.UntilEnough, goal: 11, points: 13, limit: 2 }, + ], + function(assert, input) { + let rules = new GameRules(); + rules.goal = input.goal; + rules.raising = input.rule; + assert.strictEqual( + rules.raisingLimit(input.points), + input.limit ?? Number.MAX_SAFE_INTEGER, + "correct limit"); + } + ); + + QUnit.test("toStruct", function(assert) { + let rules = new GameRules(); + let struct = rules.toStruct(); + + let expected = { + goal: 11, + raising: RaisingRule.UnlessStricken, + }; + + assert.deepEqual(struct, expected, "successfull structurizing"); + }); + + QUnit.test.each( + "fromStruct", + { + "v1": { goal: 15, raising: RaisingRule.UntilEnough }, + }, + function(assert, input) { + let rules = new GameRules(input); + let expected = { + goal: 15, + raising: RaisingRule.UntilEnough, + }; + assert.deepEqual(rules.toStruct(), expected, "reexport matches"); + } + ); + + QUnit.test.each( + "invalid fromStruct", + { + "no goal": { + struct: { }, + error: new TypeError("struct must contain goal as number"), + }, + "boolean goal": { + struct: { goal: true }, + error: new TypeError("struct must contain goal as number"), + }, + "non-integer goal": { + struct: { goal: 1.5 }, + error: new RangeError("struct must contain goal >= 1 as integer"), + }, + "zero goal": { + struct: { goal: 0 }, + error: new RangeError("struct must contain goal >= 1 as integer"), + }, + "negative goal": { + struct: { goal: -15 }, + error: new RangeError("struct must contain goal >= 1 as integer"), + }, + "no raising rule": { + struct: { goal: 2 }, + error: new TypeError("struct must contain valid raising rule"), + }, + "boolean raising rule": { + struct: { goal: 2, raising: true }, + error: new TypeError("struct must contain valid raising rule"), + }, + "integer raising rule": { + struct: { goal: 2, raising: 5 }, + error: new TypeError("struct must contain valid raising rule"), + }, + }, + function(assert, input) { + assert.throws( + () => new GameRules(input.struct), input.error, "correct error"); + } + ) + }); +} diff --git a/test.js b/test.js index 55d702a..711fd63 100644 --- a/test.js +++ b/test.js @@ -2,6 +2,7 @@ import round from "/models/round.test.js"; import roundResult from "/models/round_result.test.js"; +import gameRules from "/models/game_rules.test.js"; import game from "/models/game.test.js"; import session from "/models/session.test.js"; @@ -11,6 +12,7 @@ import session_repo from "/data/session_repo.test.js"; QUnit.module("models", function() { round(); roundResult(); + gameRules(); game(); session(); });