1
0

implement game rules model

This commit is contained in:
Adrian Wannenmacher 2026-03-01 22:42:28 +01:00
parent 542af83a5a
commit 14aad4a73d
Signed by: tfld
GPG Key ID: 19D986ECB1E492D5
3 changed files with 312 additions and 0 deletions

128
models/game_rules.js Normal file
View File

@ -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;
}
}

182
models/game_rules.test.js Normal file
View File

@ -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");
}
)
});
}

View File

@ -2,6 +2,7 @@
import round from "/models/round.test.js"; import round from "/models/round.test.js";
import roundResult from "/models/round_result.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 game from "/models/game.test.js";
import session from "/models/session.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() { QUnit.module("models", function() {
round(); round();
roundResult(); roundResult();
gameRules();
game(); game();
session(); session();
}); });