// Scorecard.js
(function () {
/* global adventurejs A */
/**
* @class adventurejs.Scorecard
* @ajsinternal
* @param {Game} game A reference to the game instance.
* @ajsnavheading FrameworkClasses
* @summary Manages score for a {@link adventurejs.Game|Game} instance.
* @classdesc
* <p>
* <strong>Scorecard</strong> is a repository for game score
* options. Scorecard is created automatically
* by {@link adventurejs.Game|Game}. This is an internal class
* that authors should not need to construct. However,
* authors can set scoring options from their game file as
* shown below, and call score updates from custom scripts.
* </p>
* <h3 class="examples">Example:</h3>
* <pre class="display"><code class="language-javascript">var MyGame = new adventurejs.Game( "MyGame", "GameDisplay" );
* MyGame.scorecard.set({
* points: {
* "unlock door": { value: 1, awarded: false, bonus: false, award_text: '', recorded: false },
* "unlock chest": { value: 1, awarded: false, bonus: false, award_text: '', recorded: false },
* "drink potion": { value: 1, awarded: false, bonus: false, award_text: '', recorded: false },
* }
* });
* </code></pre>
*/
class Scorecard {
constructor(game) {
this.game = game;
this.game.world._scorecard = {};
/**
* Used to keep track of player's current score.
* @var {Boolean} adventurejs.Scorecard#score
* @default 0
*/
this.score = 0;
/**
* Used to compare old score vs new score during
* score updates.
* @var {Boolean} adventurejs.Scorecard#newscore
* @default 0
*/
this.newscore = 0;
/**
* Used to show the difference between old score and
* new score during score updates.
* @var {Boolean} adventurejs.Scorecard#diff
* @default 0
*/
this.diff = 0;
/**
* Can be set to print when player is awarded any point.
* @var {Boolean} adventurejs.Scorecard#award_text
* @default ""
*/
this.award_text = "";
/**
* Can be used to set customized score printouts in
* the game display. <code>score_format</code> is
* subject to <code>getStringArrayFunction()</code>
* which means that it can be set to a string or an
* array or a function.
* For example, this returns a string:
* <pre class="display"><code class="language-javascript">award_text: `{Our} score went up! `,</code></pre>
* This returns a custom function:
* <pre class="display"><code class="language-javascript">award_text: function(){
* return `Dude, you totally just got ${this.diff} points!`
* },</code></pre>
* Or maybe you just want to tweak the score display.
* By default score appears in 0/0 format, but let's
* say you'd like it to say "Score: 0 out of 0".
* <pre class="display"><code class="language-javascript">score_format: function()
* {
* return `Score: ${this.score} out of ${this.total}`;
* }</code></pre>
*
* @var {Boolean} adventurejs.Scorecard#score_format
* @default {}
*/
this.score_format = {};
/**
* Used to store the game's points.
* @var {Boolean} adventurejs.Scorecard#points
* @default {}
*/
this.points = {};
/**
* If user has no points and types <i>points</i> print this.
* @var {Boolean} adventurejs.Scorecard#noscore
* @default {}
*/
this.noscore = null;
/**
* If user types <i>points</i> print this as a leading overview.
* @var {Boolean} adventurejs.Scorecard#overview
* @default {}
*/
this.overview = null;
/**
* summarize_updates determines how score updates
* are printed. With summarize_updates set to true,
* if multiple points occur in a turn, only
* one score update will be printed, with the
* cumulative score change. If summarize_updates are
* false, each score update will be printed, and will
* use unique award_text that may be provided.
* @var {Boolean} adventurejs.Scorecard#summarize_updates
* @default {}
*/
this.summarize_updates = false;
// set scorecard to listen for end of turn,
// then check to see if score has changed,
// and if it has, print score updates
this.game.reactor.addEventListener("inputParseComplete", function (e) {
this.game.scorecard.updateScore();
});
}
/**
* Print score updates in the aggregate.
* @method adventurejs.Scorecard#summarizeUpdates
*/
summarizeUpdates() {
let msg = "";
if (this.newscore !== this.score) {
let direction = this.newscore > this.score ? "up" : "down";
let diff = this.newscore - this.score;
let s = Math.abs(diff) > 1 ? "s" : "";
this.diff = diff;
// the aggregate uses a single award_text setting
msg = this.award_text
? A.getSAF.call(this.game, this.award_text, this)
: `[ ** {Our} score went ${direction} by ${diff} point${s} ** ]`;
msg = `<span class="ajs-p ajs-score-msg ${direction}">${msg}</span>`;
}
return msg;
}
/**
* Mark the selected point as complete and update the score.
* @method adventurejs.Scorecard#awardPoint
* @param {String|Object} point An id string or object reference.
* @param {Boolean} recurse Whether to award nested points.
* @returns {Boolean}
*/
awardPoint(key, recurse = true) {
const point = this.getPoint(key);
if (!point) return false;
const input = this.game.getInput();
point.awarded = true;
this.game.world._scorecard[key] = true;
if (!input.points.includes(key)) input.points.push(key);
// is point a group?
// if so, complete all sub-points
if (point.points && Object.keys(point.points).length && recurse) {
for (let nested_key in point.points) {
this.awardPoint(point.points[nested_key]);
}
}
if (null !== point.action) {
let action = A.getSAF.call(this.game, point.action, this);
}
// is point a member of a group?
if (point.parent?.points) {
let count = 0;
for (let childkey in point.parent.points) {
let childpoint = point.parent.points[childkey];
if (childpoint.awarded) count++;
}
// if so, and all siblings are complete, and parent incomplete, complete parent
if (
count === Object.keys(point.parent.points).length &&
!point.parent.awarded
) {
this.awardPoint(point.parent, false);
}
}
return true;
}
/**
* Mark the selected point as incomplete and update the score.
* @method adventurejs.Scorecard#clearPoint
* @param {String} point A string matching an point key.
* @returns {Boolean}
*/
clearPoint(key) {
const point = this.getPoint(key);
if (!point) return false;
point.awarded = false;
point.recorded = false;
if ("undefined" !== typeof this.game.world._scorecard[key]) {
this.game.world._scorecard[key] = false;
}
return true;
}
/**
* Recursively count awarded points.
* @method adventurejs.Scorecard#countAwardedPoints
*/
countAwardedPoints(points = this.points, score = 0, total = 0) {
for (const key in points) {
const point = points[key];
if (!point.bonus) {
total += point.value;
}
if (point.awarded) {
score += point.value;
}
if (point.points && Object.keys(point.points).length) {
[score, total] = this.countAwardedPoints(point.points, score, total);
}
}
return [score, total];
}
/**
* Create a new point object with default values.
* @method adventurejs.Scorecard#createPoint
* @returns {Object}
*/
createPoint() {
let point = {
value: 0,
awarded: false,
bonus: false,
recorded: false,
award_text: "",
summary: "",
action: null,
parent: null,
points: {},
key: "",
name: "",
complete_text: "",
};
return point;
}
/**
* Find point by key. Looks recursively.
* @method adventurejs.Scorecard#findPointByKey
* @param {String} key An object key to search for.
* @param {Object} root The root object level to begin search.
* @returns {Object|null}
*/
findPointByKey(key, root = this) {
if (!root || typeof root !== "object") return null;
if (
root.points &&
Object.prototype.hasOwnProperty.call(root.points, key)
) {
return root.points[key];
}
if (root.points) {
for (const k of Object.keys(root.points)) {
const found = this.findPointByKey(key, root.points[k]);
if (found) return found;
}
}
return null;
}
/**
* Recursively find point by its text.
* @method adventurejs.Scorecard#findPointByText
* @param {String} key An object key to search for.
* @param {Object} root The root object level to begin search.
* @returns {Object|null}
*/
findPointByText(text, points = this.points) {
const comparator = this.game.settings.obfuscate_points
? A.obfuscate(text)
: text;
for (let key in points) {
let point = points[key];
if (point.name === comparator) {
return point;
}
if (point.points) {
let nested_points = this.findPointByText(text, point.points);
if (nested_points) return nested_points;
}
}
return null;
}
/**
* Format the score/total before printing it to display.
* @method adventurejs.Scorecard#formatScore
*/
formatScore(score, total) {
if (Object.keys(this.score_format).length) {
let results = A.getSAF.call(this.game, this.score_format, this);
if (results) return results;
}
return `${score}/${total}`;
}
/**
* Get a point by numerical key or by its text or if a point object is received pass it on.
* @method adventurejs.Scorecard#getPoint
* @param {String|Object} point An id string or object reference.
* @returns {Boolean}
*/
getPoint(key) {
let point;
if ("object" === typeof key) {
point = key;
// } else if (!isNaN(key)) {
} else if (key.startsWith("$")) {
point = this.findPointByKey(key);
} else if ("string" === typeof key) {
point = this.findPointByText(key);
}
if (!point || "undefined" === typeof point.key) return false;
return point;
}
/**
* Get an itemized list of completed points.
* @method adventurejs.Scorecard#itemizePoints
*/
itemizePoints(points = this.points, count = 0, list = "") {
let msg = "";
let overview = "";
// check points
[count, list] = this.listPoints();
if (count === 0) {
overview = `{We've} earned nothing. Nothing! `;
if (this.noscore) {
overview = A.getSAF.call(this.game, this.noscore, this);
}
} else {
list = `<ul class="ajs-score-item">${list}</ul>`;
overview = `<span class="ajs-p">{We've} earned ${this.score} out of ${this.total} points.</span>${msg}`;
if (this.overview) {
overview = A.getSAF.call(this.game, this.overview, this);
}
}
msg = overview + list;
msg = msg.replace("{score}", this.score);
msg = msg.replace("{total}", this.total);
return msg;
}
/**
* Get an itemized list of completed points.
* @method adventurejs.Scorecard#listPoints
*/
listPoints(points = this.points, count = 0, list = "") {
// check points
for (let key in points) {
const item = points[key];
if (Object.keys(item.points).length) {
[count, list] = this.listPoints(item.points, count, list);
}
if (item.awarded) {
count++;
let itemize = item.text;
// does score event have complete_text?
if (item.complete_text) {
itemize = A.getSAF.call(this.game, item.complete_text, this);
}
// wrap it
list += `<li class="ajs-li">${itemize}</li>`;
}
}
return [count, list];
}
/**
* Process score data in longhand format.
* @method adventurejs.Scorecard#processLonghand
*/
processLonghand(points, group = this.points, parent = "") {
if (!points) return;
let groupcount = 0;
let keys = Object.keys(points);
for (let i = 0; i < keys.length; i++) {
let name = keys[i];
groupcount++;
const value = points[name];
// create a new empty point
let key = parent ? `${parent}|${groupcount}` : `$${groupcount}`;
group[key] = this.createPoint();
const point = group[key];
point.key = key;
point.name = this.game.settings.obfuscate_points
? A.obfuscate(name)
: name;
point.text = this.game.settings.obfuscate_points
? A.obfuscate(name)
: name;
// we accept a number in the form of
// "open window": 1,
if ("number" === typeof value) {
point.value = value;
continue;
}
// or we accept an object with any subset of fields
// "take andy": { value: 1 },
for (var nested_key in value) {
if (nested_key !== "points") {
point[nested_key] = value[nested_key];
}
}
// if it's already completed or recorded, ensure that's stored
if (point.awarded) {
this.awardPoint(key);
}
if (point.recorded) {
this.recordPoint(key);
}
if (
value.points &&
"object" === typeof value.points &&
Object.keys(value.points).length
) {
this.processLonghand(value.points, group.points, key);
}
}
}
/**
* Process score data in shorthand format.
* @method adventurejs.Scorecard#processShorthand
*/
processShorthand(points) {
// break shortcut format into lines
const lines = String(points)
.trim()
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
// ancestors[level] is the most recent group node for that header level.
// ancestors[0] is the root container.
const ancestors = [];
ancestors[0] = this;
let count = 0;
for (const line of lines) {
count++;
// separate # indicators from text
const line_content = line.match(/^(#{1,6})?\s*(.*)$/);
if (!line_content) continue;
// get level // 1 => "#", 2 => "##", 3 => "###", ...
// if none set, default to 1
const level = line_content[1] ? line_content[1].length : 1;
// get body of line
let line_body = line_content[2].trim();
// remove leading dash
const is_sub = line_body.startsWith("-");
if (is_sub) line_body = line_body.replace(/^-\s*/, "").trim();
// find nearest group at level-1 or above
// (should exist at ancestors[level-1])
const node = ancestors[level - 1] || ancestors[0];
node.points = node.points || {};
const [name, raw_value] = line_body.split(/\s*[:|]\s*/);
if (!name) continue;
const value = Number(raw_value) || 0;
if (isNaN(value)) {
const msg = `The value of scorecard item "${name}" is not a number.`;
this.game.log("L1593", "error", 0, msg, "Game");
}
let target, parent, id;
if (is_sub) {
// Attach item under the node at this exact header level if it exists;
// otherwise attach to the node (level-1 (ancestors[level]
// will exist if a node line with this level appeared)
const groupAtLevel = ancestors[level];
const attachTo =
groupAtLevel && groupAtLevel.points ? groupAtLevel : node;
parent = attachTo;
attachTo.points = attachTo.points || {};
// attachTo.points[count] = this.createPoint();
// target = attachTo.points[count];
// use the count of the hint as its key
let nodecount = Object.keys(attachTo.points).length + 1;
id = `${attachTo.key ? attachTo.key + "|" : "$"}${nodecount}`;
attachTo.points[id] = this.createPoint();
attachTo.points[id].key = id;
target = attachTo.points[id];
} else {
// create or reuse group node at this level
let nodecount = Object.keys(node.points).length + 1;
let id = `${node.key ? node.key + "|" : "$"}${nodecount}`;
// node.points[count] = this.createPoint();
// target = node.points[count];
node.points[id] = this.createPoint();
node.points[id].key = id;
target = node.points[id];
ancestors[level] = target;
// clear any deeper-level ancestors
// (so later siblings won't incorrectly attach)
for (let k = level + 1; k < ancestors.length; k++)
ancestors[k] = undefined;
}
target.value = value;
target.name = this.game.settings.obfuscate_points
? A.obfuscate(name)
: name;
target.text = this.game.settings.obfuscate_points
? A.obfuscate(name)
: name;
// target.key = count;
// target.key = id;
if (parent) target.parent = parent;
// count++;
}
}
/**
* Mark the selected point as recorded after updating the score.
* @method adventurejs.Scorecard#recordPoint
* @param {String} point A string matching an point key.
* @returns {Boolean}
*/
recordPoint(key) {
const point = this.getPoint(key);
if (!point) return false;
point.recorded = true;
this.game.world._scorecard[key] = true;
return true;
}
/**
* This is a utility method that awards and also records a point.
* Meant for debugging so author can award a point in the console.
* @method adventurejs.Scorecard#completePoint
* @param {String} point A string matching an point key.
* @returns {Boolean}
*/
completePoint(key) {
let a = this.awardPoint(key);
let r = this.recordPoint(key);
return a && r;
}
/**
* Restore the player's score from a saved file or undo.
* @method adventurejs.Scorecard#restoreScore
*/
restoreScore(points = this.points) {
for (let key in points) {
// cycle through scorecard
if ("undefined" === typeof this.game.world._scorecard[key]) {
// not found in saved game so make sure it's unset
points[key].awarded = false;
points[key].recorded = false;
} else {
points[key].awarded = this.game.world._scorecard[key];
points[key].recorded = this.game.world._scorecard[key];
}
if (points.points && Object.keys(points.points).length) {
this.restoreScore(points.points);
}
}
}
/**
* Print score updates in a stack.
* @method adventurejs.Scorecard#stackUpdates
*/
stackUpdates(points = this.points) {
let msg = "";
for (let key in points) {
const item = points[key];
if (Object.keys(item.points).length) {
msg += this.stackUpdates(item.points);
}
let eventmsg = "";
let diff = item.value;
if (item.awarded && !item.recorded && diff !== 0) {
let direction = diff > 0 ? "up" : "down";
let s = Math.abs(diff) > 1 ? "s" : "";
this.diff = diff;
this.recordPoint(key);
if (item.award_text) {
eventmsg = A.getSAF.call(this.game, item.award_text, this);
} else {
eventmsg = this.award_text
? A.getSAF.call(this.game, this.award_text, this)
: `<!-- ${A.deobfuscate(item.text)} -->[ ** {Our} score went ${direction} by ${diff} point${s} ** ]`;
}
msg += `<span class="ajs-p ajs-score-msg ${direction}">${eventmsg}</span>`;
}
}
return msg;
}
/**
* Provides a chainable shortcut method for setting a number of properties on the instance.
* @method adventurejs.Scorecard#set
* @param {Object} props A generic object containing properties to copy to the instance.
* @returns {adventurejs.Scorecard} Returns the instance the method is called on (useful for chaining calls.)
* @chainable
*/
set(scorecard) {
if (scorecard.points) {
// handle shortcut format
if ("string" === typeof scorecard.points) {
this.processShorthand(scorecard.points);
}
// handle full format
else {
this.processLonghand(scorecard.points);
}
delete scorecard.points;
}
// pass on any other properties - expected properties are
// summarize_updates, award_text, score_format
for (let prop in scorecard) {
this[prop] = scorecard[prop];
}
return this;
}
/**
* Update the player's score.
* @method adventurejs.Scorecard#updateScore
*/
updateScore() {
const input = this.game.getInput();
let msg = "";
let [score, total] = this.countAwardedPoints();
this.total = total;
this.newscore = score;
// @TODO if only 1 point then stack regardless of setting
msg +=
input.points.length > 1 && this.summarize_updates
? this.summarizeUpdates()
: this.stackUpdates();
this.score = score;
this.game.display.updateScore(this.formatScore(score, total));
if (msg) this.game.print(msg);
return true;
}
}
adventurejs.Scorecard = Scorecard;
})();