implement game rules model
This commit is contained in:
parent
542af83a5a
commit
14aad4a73d
128
models/game_rules.js
Normal file
128
models/game_rules.js
Normal 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
182
models/game_rules.test.js
Normal 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");
|
||||||
|
}
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
2
test.js
2
test.js
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user