Scroll To Bottom Mystery Master Model a Logic Puzzle in JavaScript Member

Table of Contents

Detective

Introduction

This article explains how to model a logic grid puzzle in the JavaScript programming language. Logic grid puzzles, also known as logic puzzles or logic problems can be found in magazines by Dell Magazines and Penny Press. The Mystery Master website at http://www.mysterymaster.com is devoted to writing software that can solve logic puzzles. Think "Skynet", but without the intent of eliminating mankind. This article will focus on the logic puzzle "Five Houses". Earlier versions of this two-star puzzle have been called "Einstein's Riddle" or the "Zebra Puzzle". This version of the puzzle appeared in a column by the very cerebral Marilyn vos Savant. Please look at this logic puzzle before continuing. You should have a basic knowledge of JavaScript, know some of the newer constructs, and understand how classes work in JavaScript.

JavaScript

JavaScript is the client-side language of the web. I don't think it is the best language, but there have been improvements over the years. I wish it was strongly-typed, had better scoping rules (public, private, etc.), but I guess I could use TypeScript. It can't do some of the nice things I can do in C# like initialize an object when I instantiate it. It doesn't have the null-coalescing operator ??. It is also very fragile - your IDE and/or browser may not pick up your mistakes. But enough complaining... Here are some of the things I do like.

  1. The "use strict"; option.
  2. Define a constant with the const keyword.
  3. Avoid the hoisting of variables with the let keyword.
  4. The addEventListener method so the method the event calls can be local.
  5. The ability to return closures - functions that refer to local variables. See the sections on Smart Links and Smart Rules for more on closures.
  6. The ability to store/retrieve information in local storage.

What is a Logic Puzzle

A logic puzzle is a story of mystery. But you are not only its reader, you are it's most important character - the detective! And as with any detective worth their spyglass, you must solve the mystery. Most logic puzzles have properties such as its title, author, and number of stars. One star means the puzzle is easy, and five stars means it is very difficult. Each puzzle also has a humorous image. While these properties are important, they won't help you solve a logic puzzle. What does help are the clues of the puzzle. The clues are usually given in a numbered list, but sometimes they can also be in the introduction. The properties, introduction, and list of clues are the text of the logic puzzle.

Note: If you wanted to solve a logic puzzle on your own, you have the following tools at your disposal: the Chart and the Grids. Both of these forms will be discussed in a future article.

To model a logic puzzle, the text of the logic puzzle must be parsed into specific types of data. Everything that is essential to solving a logic puzzle must be captured by this data. There is one important thing to remember as you go forward:

All of the relationships between the nouns in a logic puzzle must be expressed as either facts or rules.

Please read the text of the puzzle with the following questions in mind.

We need data structures to store each type of data, and when we talk data structures in a world of OOP, we're talking classes. And what should we name a class that stores all of the data for a logic puzzle? Why Puzzle of course!

Puzzle

The Puzzle class is instantiated and populated by every puzzle module. It has methods to load data into an array for each type of object. It has a method to validate the data. And it needs to make that data available to the classes that view and solve the puzzle.

The Puzzle Class

/* global Helper, Verb, NounType, Link, Fact, Rule, SmartLink */
/* jshint unused:true, eqnull:true, esversion:6 */
/* exported puzzle */
"use strict";

/**
 * Puzzle class for Mystery Master Logic Puzzle Solver.  
 * Note: Puzzle object is returned by puzzle module; It is NOT the parent class!  
 * Copyright mysterymaster.com. All rights reserved.
 * @class Puzzle
 * @author Michael Benson
 * @version 2019-10-10
 * @param {string} myName Name of puzzle.
 * @param {string} myTitle Title of puzzle.
 */
function Puzzle(myName, myTitle) {
	/**
	 * Optional answer to puzzle.
	 * @type {number[][]|null}
	 */
	let answer = null;

	/**
	 * Validation flag. Set in validate.
	 * @type {boolean}
	 */
	let valid = false;

	/**
	 * Number of nouns per noun type. Set in validate.
	 * @type {number}
	 */
	let numNouns = 0;

	/**
	 * Array of noun types.
	 * @type {NounType[]}
	 */
	let nounTypes = [];

	/**
	 * Array of verbs.
	 * @type {Verb[]}
	 */
	let verbs = Puzzle.verbs;

	/**
	 * Array of links.
	 * @type {Link[]}
	 */
	let links = [];

	/**
	 * Array of facts.
	 * @type {Fact[]}
	 */
	let facts = [];

	/**
	 * Array of rules.
	 * @type {Rule[]}
	 */
	let rules = [];

	/**
	 * Returns puzzle as string.
	 * @returns {string} String representation of puzzle.
	 */
	const toString = () => myName;

	/**
	 * Returns puzzle as string for debugging.
	 * @returns {string} String representation of puzzle.
	 */
	const asString = toString;

	/**
	 * Resets the puzzle. Called by solver.reset.
	 */
	const reset = () => {
		//console.log(`exec puzzle.reset puzzle="${myName}"`);
		for (let nounType of nounTypes) { nounType.reset(); }
		for (let fact of facts) { fact.reset(); }
		for (let rule of rules) { rule.reset(); }
		//console.log("done puzzle.reset");
	};

	/**
	 * Creates and appends new Noun Type object to array of noun types.  
	 * Note: When first noun type is created, the "with" link is created.
	 * @param {string} name Name of noun type.
	 * @returns {NounType} Noun Type object.
	 */
	const addNounType = (name) => {
		let nounType = Object.seal(new NounType(nounTypes.length + 1, name));
		nounTypes.push(nounType);
		if (nounType.num === 1) {
			Link.With = addLink("with", nounType, SmartLink.getIsWith());
		}
		return nounType;
	};

	/**
	 * Creates and appends new Link object to array of link.
	 * @param {string} name Name of link.
	 * @param {NounType} nounType Noun type of link.
	 * @param {Function} getVerb Function that returns verb based on relationship between two nouns.
	 * @returns {Link} Link object.
	 */
	const addLink = (name, nounType, getVerb = null) => {
		let link = Object.seal(new Link(links.length, name, nounType, getVerb));
		links.push(link);
		return link;		
	};

	/**
	 * Creates and appends new Fact to array of facts.
	 * @param {string} clueNum Clue number(s).
	 * @param {Noun} nounA Noun A of fact.
	 * @param {Verb} verb Verb of fact.
	 * @param {Link} link Link of fact.
	 * @param {Noun|null} nounB Noun B of fact, or null.
	 * @param {string|null} name Name of fact, or null.
	 * @param {boolean} initEnabled Initially enable/disable fact.
	 * @returns {Fact} Fact object.
	 */
	const addFact = (clueNum, nounA, verb, link, nounB = null, name = null, initEnabled = true) => {
		if (nounB === null)
			addFacts1(clueNum, nounA, verb, link, name, initEnabled);
		else
			addFacts2(clueNum, nounA, verb, link, nounB, name, initEnabled);
	};

	/**
	 * Returns "proper English" for fact. Puzzle module should override this method.
	 * @param {Noun} noun1 Noun 1 of fact.
	 * @param {Verb} verb Verb of fact.
	 * @param {Link} link Link of fact.
	 * @param {Noun} noun2 Noun 2 of fact.
	 * @returns {string} Name of fact.
	 */
	let sayFact = (noun1, verb, link, noun2) => noun1.name + " " + verb.name + " " + link.name + " " + noun2.name + ".";

	/**
	 * Returns clue number(s) in parenthesis from clueNum or name.  
	 * Called by addOneFact, addRule.
	 * @private
	 * @param {string} clueNum Clue number(s).
	 * @param {string} name Name of clue.
	 * @returns {string} Clue number(s) as string.
	 */
	const getClueNumAsString = (clueNum, name) => {
		//console.log(`puzzle.getClueNumAsString clueNum="${clueNum}" name="${name}"`);
		if (clueNum === null || clueNum.length < 1) return name;

		let i = name.length - 1;
		if (i < 0) return name;
		let eos = name[i];

		let txt = "";
		if (clueNum[0] === 'A') {
			let tmp = clueNum.length > 1 ? " " + clueNum.substring(1) : "";
			txt = "analysis" + tmp;
		}
		else if (clueNum[0] === '0') {
			txt = "intro";
		}
		else {
			let tmp = clueNum.indexOf(",") > -1 ? "s" : "";
			txt = "clue" + tmp + " " + clueNum;
		}

		let msg = name.substring(0, i) + " (" + txt + ")" + eos;
		return msg;
	};

	/**
	 * Creates and appends new fact to array of facts.
	 * @private
	 * @param {string} clueNum Clue number(s).
	 * @param {Noun} noun1 Noun 1 of fact.
	 * @param {Verb} verb Verb of fact.
	 * @param {Link} link Link of fact.
	 * @param {Noun} noun2 Noun 2 of fact.
	 * @param {string} name Name of fact.
	 * @param {boolean} initEnabled Initally enable/disable fact.
	 * @returns {Fact} Fact object.
	 */
	const addOneFact = (clueNum, noun1, verb, link, noun2, name = null, initEnabled = true) => {
		let txt = name;
		if (name === null || name.length < 1) {
			txt = sayFact(noun1, verb, link, noun2);
		}

		// Don't enter duplicate facts.
		let ok = true;
		for (let oldFact of facts) {
			if (oldFact.verb !== verb) continue;
			if (oldFact.noun1 === noun1 && oldFact.link === link && oldFact.noun2 === noun2)
				ok = false;
			else if (oldFact.noun1 === noun2 && oldFact.link === link && oldFact.link.num === 0 && oldFact.noun2 === noun1)
				ok = false;
			if (!ok) {
				console.log(`puzzle.addOneFact Warning! This fact already exists: num=${oldFact.num} name="${oldFact.name}"`);
				return null;
			}
		}

		let msg = getClueNumAsString(clueNum, txt);
		let fact = Object.seal(new Fact(facts.length + 1, msg, noun1, verb, link, noun2, initEnabled));
		facts.push(fact);
		return fact;
	};

	/**
	 * Creates and appends new fact(s) to array of facts.
	 * @private
	 * @param {string} clueNum Clue number(s).
	 * @param {Noun[]} nouns Array of nouns to create fact(s).
	 * @param {Verb} verb Verb of fact(s).
	 * @param {Link} link Link of fact(s).
	 * @param {string|null} name Name of fact(s), or null.
	 * @param {boolean} initEnabled Initially enable/disable fact(s).
	 */
	const addFacts1 = (clueNum, nouns, verb, link, name = null, initEnabled = true) => {
		for (let i = 0; i < nouns.length - 1; i++) {
			let noun1 = nouns[i];
			for (let j = i + 1; j < nouns.length; j++) {
				let noun2 = nouns[j];
				if (noun1 === noun2 || (link === Link.With && noun1.type === noun2.type)) continue;
				addOneFact(clueNum, noun1, verb, link, noun2, name, initEnabled);
			}
		}
	};

	/**
	 * Creates and appends new fact(s) to array of facts. Overload: nounA and nounB can each be one
	 * noun or a list of nouns.
	 * @private
	 * @param {string} clueNum Clue number(s).
	 * @param {Noun|Noun[]} nounA Noun A or array of nouns to create fact(s).
	 * @param {Verb} verb Verb of fact(s).
	 * @param {Link} link Link of fact(s).
	 * @param {Noun|Noun[]} nounB Noun B or array of nouns to create fact(s).
	 * @param {string|null} name Name of fact(s), or null.
	 * @param {boolean} initEnabled Initially enable/disable fact(s).
	 */
	const addFacts2 = (clueNum, nounA, verb, link, nounB, name = null, initEnabled = true) => {
		let nouns1 = Array.isArray(nounA) ? nounA : [nounA];
		let nouns2 = Array.isArray(nounB) ? nounB : [nounB];

		for (let noun1 of nouns1) {
			for (let noun2 of nouns2) {
				if (noun1 === noun2 || (link === Link.With && noun1.type === noun2.type)) continue;
				addOneFact(clueNum, noun1, verb, link, noun2, name, initEnabled);
			}
		}
	};

	/**
	 * Creates and appends new fact(s) to array of facts.
	 * @param {string} clueNum Clue number(s).
	 * @param {Noun[]} nouns Array of nouns to create fact(s).
	 * @param {Verb} verb Verb of fact(s).
	 * @param {Link} link Link of fact(s).
	 * @param {string|null} name Name of fact(s), or null.
	 * @param {boolean} initEnabled Initially enable/disable fact(s).
	 */
	const addFactsInSequence = (clueNum, nouns, verb, link, name = null, initEnabled = true) => {
		for (let i = 0; i < nouns.length - 1; i++) {
			addOneFact(clueNum, nouns[i], verb, link, nouns[i + 1], name, initEnabled);
		}
	};

	/**
	 * Creates and appends new fact(s) to array of facts.
	// Overload: nounA and nounB can each be a noun type or list of nouns.
	 * @param {string} clueNum Clue number(s).
	 * @param {NounType|Noun[]} nounA Noun type or array of nouns to create fact(s).
	 * @param {Verb} verb Verb of fact(s).
	 * @param {Link} link Link of fact(s).
	 * @param {NounType|Noun[]} nounB Noun type or array of nouns to create fact(s).
	 * @param {string|null} name Name of fact(s), or null.
	 * @param {boolean} initEnabled Initially enable/disable fact(s).
	 */
	const addFactsOneToOne = (clueNum, nounA, verb, link, nounB, name = null, initEnabled = true) => {
		let nouns1 = Array.isArray(nounA) ? nounA : nounA.nouns;
		let nouns2 = Array.isArray(nounB) ? nounB : nounB.nouns;

		let n = nouns1.length;
		if (n !== nouns2.length) return;

		for (let i = 0; i < n; i++) {
			addOneFact(clueNum, nouns1[i], verb, link, nouns2[i], name, initEnabled);
		}
	};

	/**
	 * Creates and appends new fact(s) to array of facts.
	 * @param {string} clueNum Clue number(s).
	 * @param {Noun} noun1 Noun 1 of fact(s).
	 * @param {NounType} nounType2 Noun type 2 of fact(s).
	 * @param {boolean} flag Flag if first character of noun 2's name [not] match character. 
	 * @param {String} ch Character to test.
	 * @param {string} name Name of fact(s), or null.
	 * @param {boolean} initEnabled Initially enable/disable fact(s).
	 */
	const addFactsStartsWith = (clueNum, noun1, nounType2, flag, ch, name = null, initEnabled = true) => {
		for (let noun2 of nounType2.nouns) {
			if ((noun2.name[0] === ch) === flag) {
				addOneFact(clueNum, noun1, Verb.IsNot, Link.With, noun2, name, initEnabled);
			}
		}
	};

	/**
	 * Creates and appends new fact(s) to array of facts.
	 * @param {string} clueNum Clue number(s).
	 * @param {NounType} nounType1 Noun type 1.
	 * @param {NounType} nounType2 Noun type 2.
	 * @param {boolean} flag Flag if first character of noun 2's name [not] match character. 
	 * @param {string} name Name of fact(s), or null.
	 * @param {boolean} initEnabled Initially enable/disable fact(s).
	 */
	const addFactsIsNotFirstChar = (clueNum, nounType1, nounType2, flag, name = null, initEnabled = true) => {
		for (let noun1 of nounType1.nouns) {
			for (let noun2 of nounType2.nouns) {
				if ((noun1.name[0] === noun2.name[0]) === flag) {
					addOneFact(clueNum, noun1, Verb.IsNot, Link.With, noun2, name, initEnabled);
				}
			}
		}
	};

	/**
	 * Creates and appends new fact(s) to array of facts.
	 * @param {string} clueNum Clue number(s).
	 * @param {Noun[]} nouns Array of nouns to create fact(s).
	 * @param {Link} link Link of fact(s).
	 * @param {string} name Name of fact(s), or null.
	 * @param {boolean} initEnabled Initially enable/disable fact(s).
	 */
	const addFactsNotConsecutive = (clueNum, nouns, link, name = null, initEnabled = true) => {
		let n = nouns.length;
		let type = link.nounType;
		let max = type.nouns.length;

		if (2 * n - 1 === max) {
			for (let noun of nouns) {
				for (let i = 1; i < max; i += 2) {
					let slot = type.nouns[i];
					addOneFact(clueNum, noun, Verb.IsNot, Link.With, slot, name, initEnabled);
				}
			}
		}
		else {
			for (let i1 = 0; i1 < n - 1; i1++) {
				let noun1 = nouns[i1];
				for (let i2 = i1 + 1; i2 < n; i2++) {
					let noun2 = nouns[i2];
					addOneFact(clueNum, noun1, Verb.IsNot, link, noun2, name, initEnabled);
					addOneFact(clueNum, noun2, Verb.IsNot, link, noun1, name, initEnabled);
				}
			}
		}
	};

	/**
	 * Creates and appends new rule to array of rules.
	 * @param {string} clueNum Clue number(s).
	 * @param {string} name Name of rule.
	 * @param {Noun[]} nouns Nouns referenced by rule.
	 * @param {boolean} initEnabled Initially enable/disable rule.
	 * @returns {rule}
	 */
	const addRule = (clueNum, name, nouns = [], initEnabled = true) => {
		let msg = getClueNumAsString(clueNum, name);
		let rule = Object.seal(new Rule(rules.length + 1, msg, nouns, initEnabled));
		rules.push(rule);
		return rule;
	};

	/**
	 * Returns Noun Type given its one-based number.
	 * @param {number} num One-based number of noun type.
	 * @returns {NounType} NounType object.
	 */
	const getNounType = (num) => nounTypes[num - 1];

	/**
	 * Returns noun given its one-based number of noun type, and itself.
	 * @param {number} typeNum One-based number of noun type.
	 * @param {number} num One-based number of noun.
	 * @returns {Noun} Noun object.
	 */
	const getNoun = (typeNum, num) => nounTypes[typeNum - 1].nouns[num - 1];

	/**
	 * Return verb given its zero-based number.
	 * @param {number} num Zero-based number.
	 * @returns {Verb} Verb Object.
	 */
	const getVerb = (num) => verbs[num];

	/**
	 * Validates puzzle and sets valid flag accordingly.
	 * @returns {number} Return status -1=fail, 0=okay.
	 */
	const validate = () => {
		let rs = -1;
		valid = false;
	
		// Properties.
		if (myName === null || myName.length === 0) return rs;
		if (myTitle === null || myTitle.length === 0) return rs;

		// Verbs.
		console.log(`puzzle.validate verbs: "${verbs[0]}", "${verbs[1]}", "${verbs[2]}"`);
		console.log(`puzzle.validate verbs.length=${verbs.length}`);
		if (verbs.length !== Verb.MaxVerbs) return rs;

		// Nouns.
		console.log(`puzzle.validate nounTypes.length=${nounTypes.length}`);
		if (nounTypes.length < 2) return rs;
		numNouns = nounTypes[0].nouns.length;
		console.log(`puzzle.validate numNouns=${numNouns}`);

		// Allocate and initialize pairs array for each noun.
		for (let nounType of nounTypes) {
			if (nounType.nouns.length !== numNouns) return rs;
			for (let noun of nounType.nouns) {
				noun.pairs = [];
				for (let k = 0; k < nounTypes.length; k++) noun.pairs[k] = null;
			}
			console.log(`puzzle.validate ${nounType}: ${Helper.getArrayAsString(nounType.nouns)}`);
		}

		// Links.
		console.log(`puzzle.validate links.length=${links.length}`);
		if (links.length < 1) return rs;
		for (let link of links) {
			link.setOneToOne();
			console.log(`puzzle.validate link=${link.asString()}`);
			if (link.getVerb === null) return rs;
		}

		// Facts.
		console.log(`puzzle.validate facts.length=${facts.length}`);
		for (let fact of facts) console.log(`puzzle.validate fact=${fact.asString()}`);

		// Rules.
		console.log(`puzzle.validate rules.length=${rules.length}`);
		for (let rule of rules) {
			console.log(`puzzle.validate rule=${rule.asString()}`);
			if (rule.f === null) return rs;
		}

		// There must be at least one fact or one rule.
		if (facts.length + rules.length < 1) return rs;

		valid = true;
		reset();
		console.log(`puzzle.validate valid=${valid}`);
		return 0;
	};

	/**
	 * Determines if solution is correct. Called by addMark.
	 * @returns {boolean} True if solution is correct (or answer is null), otherwise false.
	 */
	const isAnswer = () => {
		getEncodedAnswer();
		if (answer === null) return true;
		let nounType1 = nounTypes[0];
		for (let noun1 of nounType1.nouns) {
			for (let nounType2 of nounTypes) {
				if (nounType2.num === 1) continue;
				//console.log((noun1.getPairNounNum(nounType2) - 1) + ' ' + puzzle.answer[nounType2.num - 2][noun1.num - 1]);
				if ((noun1.getPairNounNum(nounType2) - 1) !== answer[nounType2.num - 2][noun1.num - 1]) return false;
			}
		}
		return true;
	};

	/**
	 * Displays the encoded answer for development only. Called by isAnswer.
	 */
	const getEncodedAnswer = () => {
		let m = nounTypes.length - 1;
		let n = numNouns;
		let answer = Helper.getArray2D(m, n, 0);
		let nounType1 = nounTypes[0];
		for (let noun1 of nounType1.nouns) {
			for (let nounType2 of nounTypes) {
				if (nounType2.num === 1) continue;
				let n2 = noun1.getPairNounNum(nounType2) - 1;
				answer[nounType2.num - 2][noun1.num - 1] = n2;
			}
		}
		let msg = Helper.getArray2DAsString(answer);
		console.log(msg);
	};

	return {
		get myName()    { return myName; },
		get myTitle()   { return myTitle; },
		get answer()    { return answer; }, set answer(value) { answer = value; },
		get valid()     { return valid; },
		get numNouns()  { return numNouns; },
		get nounTypes() { return nounTypes; },
		get verbs()     { return verbs; },
		get links()     { return links; },
		get facts()     { return facts; },
		get rules()     { return rules; },
		get sayFact()   { return sayFact;  }, set sayFact(value) { sayFact = value; },
		toString, asString, reset, addNounType, addLink, addFact,
		addFactsInSequence, addFactsOneToOne, addFactsStartsWith, addFactsIsNotFirstChar, addFactsNotConsecutive,
		addRule, getNounType, getNoun, getVerb, validate, isAnswer
	};
}

/**
 * Array of verbs for the Puzzle static class.
 * @type {Verb[]}
 */
Puzzle.verbs = [Verb.IsNot, Verb.Is, Verb.Maybe];

Puzzle Module

The puzzle module stores all of the data and functions necessary to solve it. Below is the puzzle module for the logic puzzle "Five Houses". Please note that since all of the relationships in this puzzle can be represented by facts, there are no rules.

Note: In most languages, the puzzle module class would inherit from the puzzle class. But to avoid using the "this" keyword, I decided to instantiate, populate, and return the puzzle object in each puzzle module.

The FiveHouses Class

/* global Verb, Link, Puzzle, Helper, SmartLink */
/* jshint unused:true, eqnull:true, esversion:6 */

/**
 * Puzzle module for "Five Houses".
 * @copyright mysterymaster.com. All rights reserved.
 * @author Michael Benson
 * @version 2019-10-10
 * @param {Solver} solver Solver object.
 */
function FiveHouses(solver) {
	"use strict";
	let puzzle = new Puzzle("FiveHouses", "Five Houses");
	puzzle.answer  = [ [ 3, 4, 0, 2, 1 ], [ 3, 2, 0, 1, 4 ], [ 1, 2, 0, 3, 4 ], [ 2, 3, 1, 0, 4 ], [ 4, 1, 2, 3, 0 ] ];

	const houses = puzzle.addNounType("House");
	const house1 = houses.addNoun("1st");
	const house2 = houses.addNoun("2nd");
	const house3 = houses.addNoun("3rd");
	const house4 = houses.addNoun("4th");
	const house5 = houses.addNoun("5th");

	const colors = puzzle.addNounType("Color");
	const red    = colors.addNoun("red");
	const green  = colors.addNoun("green");
	const white  = colors.addNoun("white");
	const yellow = colors.addNoun("yellow");
	const blue   = colors.addNoun("blue");

	const nationalities = puzzle.addNounType("Nationality");
	const englishman = nationalities.addNoun("Englishman");
	const spaniard   = nationalities.addNoun("Spaniard");
	const ukrainian  = nationalities.addNoun("Ukrainian");
	const norwegian  = nationalities.addNoun("Norwegian");
	const japanese   = nationalities.addNoun("Japanese man", "Japanese");

	const hobbies = puzzle.addNounType("Hobby");
	const stamps   = hobbies.addNoun("stamps");
	const antiques = hobbies.addNoun("antiques");
	const sings    = hobbies.addNoun("singing");
	const gardens  = hobbies.addNoun("gardening");
	const cooking  = hobbies.addNoun("cooking");

	const pets = puzzle.addNounType("Pet");
	const dogs   = pets.addNoun("dogs");
	const snails = pets.addNoun("snails");
	const fox    = pets.addNoun("fox");
	const horse  = pets.addNoun("horse");
	const zebra  = pets.addNoun("zebra");

	const drinks = puzzle.addNounType("Drink");
	const coffee = drinks.addNoun("coffee");
	const tea    = drinks.addNoun("tea");
	const milk   = drinks.addNoun("milk");
	const juice  = drinks.addNoun("juice");
	const water  = drinks.addNoun("water");

	const directlyRightOf = puzzle.addLink("directly to the right of", houses, SmartLink.getIsMoreBy(1));
	const nextTo = puzzle.addLink("next to", houses, SmartLink.getIsNextTo());

	puzzle.sayFact = (noun1, verb, link, noun2) => {
		let msg = noun1.name + " " + verb.name + " " + link.name + " " + noun2.name;

		// Types: 1=House, 2=Color, 3=Nationality, 4=Hobby, 5=Pet, 6=Drink
		switch (noun1.type.num) {
			case 1:
				switch (noun2.type.num) {
					case 1: break;
					case 2: break;
					case 3: break;
					case 4: break;
					case 5: break;
					case 6:
						if (link === Link.With)
							msg = "The man in the " + noun1.name + " house " + (verb === Verb.Is ? "drinks" : "does not drink") + " " + noun2.name;
						break;
				}
				break;
			case 2:
				switch (noun2.type.num) {
					case 1: break;
					case 2:
						msg = "The " + noun1.name + " house " + verb.name + " " + link.name + " the " + noun2.name + " house";
						break;
					case 3: break;
					case 4: break;
					case 5: break;
					case 6: break;
				}
				break;
			case 3:
				msg = "The " + noun1.name;
				switch (noun2.type.num) {
					case 1:
						if (link === Link.With)
							msg += " " + verb.name + " in the " + noun2.name + " house";
						break;
					case 2:
						if (link === Link.With)
							msg += " " + verb.name + " in the " + noun2.name + " house";
						else
							msg += " " + verb.name + " " + link.name + " the " + noun2.name + " house";
						break;
					case 3: break;
					case 4:
						if (link === Link.With)
							msg += "'s hobby " + verb.name + " " + noun2.name;
						break;
					case 5:
						if (link === Link.With)
							msg += "'s pet " + verb.name + " " + noun2.name;
						break;
					case 6:
						if (link === Link.With)
							msg += "'s favorite drink" + " " + verb.name + " " + noun2.name;
						break;
				}
				break;
			case 4:
				msg = "The man who's hobby is " + noun1.name + " ";
				switch (noun2.type.num) {
					case 1: break;
					case 2:
						if (link === Link.With)
							msg += verb.name + " in the " + noun2.name + " house";
						break;
					case 3: break;
					case 4: break;
					case 5:
						if (link === Link.With)
							msg += (verb === Verb.Is ? "owns" : "does not own") + " " + noun2.name;
						else
							msg += verb.name + " " + link.name + " the man with the " + noun2.name;
						break;
					case 6:
						msg += (verb === Verb.Is ? "drinks" : "does not drink") + " " + noun2.name;
						break;
				}
				break;
			case 5:
				switch (noun2.type.num) {
					case 1: break;
					case 2: break;
					case 3: break;
					case 4: break;
					case 5: break;
					case 6: break;
				}
				break;
			case 6:
				switch (noun2.type.num) {
					case 1: break;
					case 2:
						if (link === Link.With)
							msg = Helper.getFirstCap(noun1.name) + " " + verb.name + " drunk in the " + noun2.name + " house";
						break;
					case 3: break;
					case 4: break;
					case 5: break;
					case 6: break;
				}
				break;
		}

		return msg + ".";
	};

	puzzle.addFact("1", englishman, Verb.Is, Link.With, red);
	puzzle.addFact("2", spaniard, Verb.Is, Link.With, dogs);
	puzzle.addFact("3", coffee, Verb.Is, Link.With, green);
	puzzle.addFact("4", ukrainian, Verb.Is, Link.With, tea);
	puzzle.addFact("5", green, Verb.Is, directlyRightOf, white);
	puzzle.addFact("6", stamps, Verb.Is, Link.With, snails);
	puzzle.addFact("7", antiques, Verb.Is, Link.With, yellow);
	puzzle.addFact("8", house3, Verb.Is, Link.With, milk);
	puzzle.addFact("9", norwegian, Verb.Is, Link.With, house1);
	puzzle.addFact("10", sings, Verb.Is, nextTo, fox);
	puzzle.addFact("11", gardens, Verb.Is, Link.With, juice);
	puzzle.addFact("12", antiques, Verb.Is, nextTo, horse);
	puzzle.addFact("13", japanese, Verb.Is, Link.With, cooking);
	puzzle.addFact("14", norwegian, Verb.Is, nextTo, blue);

	return puzzle;
}
console.log("loaded puzzle module " + FiveHouses.name);

Properties

Properties are the metadata of a logic puzzle. The only properties we care about are the puzzle's name and title. This information is set when the puzzle is instantiated. I use the names myName and myTitle instead of the more generic name and title to avoid naming collisions.

	let puzzle = new Puzzle("FiveHouses", "Five Houses");
	

Nouns

The nouns in a logic puzzle must be organized into categories. These categories are called noun types. A puzzle must have at least two noun types. The noun types for our example puzzle are: House, Color, Nationality, Hobby, Pet, and Drink. Note that the names of the noun types are singular, not plural. For this puzzle, there are five nouns for each type. Below is a table of the nouns, where the column header is the noun type. Please keep in mind that each noun type must have the same number of nouns, and there must be at least two nouns per noun type.

Nouns
#HouseColorNationalityHobbyPetDrink
11stRedEnglishmanStampsDogsCoffee
22ndGreenSpaniardAntiquesSnailsTea
33rdWhiteUkrainianSingingFoxMilk
44thYellowNorwegianGardeningHorseJuice
55thBlueJapaneseCookingZebraWater

The first column (#) in this table shows the one-based number of the noun. Usually, the order of the nouns within a noun type is not important, but there is one major exception:

If a link references a noun type, then the nouns for that type must be in a logical order.

You'll understand why when you read the section on Links.

Placeholders

While most logic puzzles will give you all of the nouns in the puzzle, some very difficult puzzles may not give you all of the values of the nouns. This means the values must be calculated by one or more rules. Nouns where the initial value is unknown are called placeholders. Usually these values are numeric, such as the number of people who attended a talk in "Astrophysics Conference", or the age of a salesperson in "Dandy Salespeople".

The NounType Class

/* global Noun */
/* jshint unused:true, eqnull:true, esversion:6 */
/* exported NounType */
"use strict";

/**
 * NounType class for Mystery Master Logic Puzzle Solver.  
 * Copyright mysterymaster.com. All rights reserved.
 * @class NounType
 * @author Michael Benson
 * @version 2019-10-07
 * @param {number} num One-based number of noun type.
 * @param {string} name Name of noun type.
 */
function NounType(num, name) {
	/**
	 * Nouns for noun type.
	 * @type {Noun[]}
	 */
	let nouns = [];

	/**
	 * Returns noun type as string.
	 * @returns {string} String representation of noun type.
	 */
	const toString = () => name;

	/**
	 * Returns noun type as string for debugging.
	 * @returns {string} String representation of noun type.
	 */
	const asString = () => `{num:${num} name:"${name}" nouns:${nouns}}`;

	/**
	 * Resets nouns for noun type. Called by puzzle.reset.
	 */
	const reset = () => { for (let noun of nouns) noun.reset(); };

	/**
	 * Creates and returns noun for noun type.
	 * Note: CANNOT convert to lambda because "this" will not work!
	 * @param {string} name Name of noun.
	 * @param {string} title Title of noun.
	 * @returns {Noun} Noun object.
	 */
	function addNoun(name, title) {
		let noun = Object.seal(new Noun(nouns.length + 1, this, name, title));
		nouns.push(noun);
		return noun;
	}

	/**
	 * Returns noun given by its one-based number.
	 * @param {number} num One-based number of noun.
	 */
	const getNoun = (num) => nouns[num - 1];

	return {
		get num()   { return num;   },
		get name()  { return name;  },
		get nouns() { return nouns; },
		toString, asString, reset, addNoun, getNoun
	};
}

The Noun Class

/* global Helper */
/* jshint unused:true, eqnull:true, esversion:6 */
/* exported Noun */
"use strict";

/**
 * Noun class for Mystery Master Logic Puzzle Solver.  
 * Copyright mysterymaster.com. All rights reserved.
 * @class Noun
 * @author Michael Benson
 * @version 2019-10-07
 * @param {number} num One-based number of noun.
 * @param {NounType} type Type of noun.
 * @param {string} name Name of noun.
 * @param {string|null} title Optional title of noun.
 */
function Noun(num, type, name, title = null) {
	if (title === null) title = Helper.toTitleCase(name);

	/**
	 * Original name of noun.
	 * @type {string}
	 */
	let name0 = name;

	/**
	 * Original title of noun.
	 * @type {string}
	 */
	let title0 = title;

	/**
	 * Nouns of each noun type paired with this noun, where null represents no pairing. Initialized in puzzle.validate.
	 * @type {Noun[]}
	 */
	let pairs = [];

	/**
	 * Facts that reference this noun. May be empty, but NEVER null. Set in puzzle.validate.
	 * @type {Fact[]}
	 */
	let facts = [];

	/**
	 * Returns noun as string.
	 * @returns {string} String representation of noun.
	 */
	const toString = () => name;

	/**
	 * Returns noun as string for debugging.
	 * @returns {string} String representation of noun.
	 */
	const asString = () => `num:${num} type:"${type.name}" name:"${name}" title:"${title}" name0:"${name0}" title0:"${title0}" pairs.length:${pairs.length} facts.length:${facts.length}`;

	/**
	 * Resets noun. Called by its noun type.
	 */
	const reset = () => {
		let n = pairs.length;
		//console.log(`exec noun.reset name="${name}" pairs.length=${n}`);
		resetPlacer();
		// Note: For/of loop only resets last item in array! for (let pair of pairs) pair = null;
		for (let i = 0; i < n; i++) pairs[i] = null;
		//console.log("done noun.reset pairs=" + pairs);
	};

	/**
	 * Appends new noun to list of nouns for current noun type.
	 * This method allows nounType.addNoun method to be chained.
	 * @param {string} name Name of noun.
	 * @param {string} title Title of noun.
	 * @returns {Noun} Noun object.
	 */
	const addNoun = (name, title) => type.addNoun(name, title);
	
	/**
	 * Returns noun of given noun type that is with this noun, or null.
	 * @param {NounType} nounType Noun type.
	 * @returns {Noun|null} Noun of given noun type that is with this noun, or null.
	 */
	const getPairNoun = (nounType) => pairs[nounType.num - 1];
	
	/**
	 * Returns one-based number of noun with given noun type that is with this noun, or zero.
	 * @param {NounType} nounType Noun type.
	 * @returns {number} One-based number of noun with this noun, or zero.
	 */
	const getPairNounNum = (nounType) => { let noun = pairs[nounType.num - 1]; return noun === null ? 0 : noun.num; };

	/**
	 * Returns true if given noun is with this noun, otherwise false.
	 * @param {Noun} noun Noun.
	 * @returns {boolean} True if given noun is with this noun, otherwise false.
	 */
	const isPair = (noun) => getPairNoun(noun.type) === noun ? true : false;

	/**
	 * Resets noun if it is a placer. Called by mark.clearPlacers, noun.reset.
	 */
	const resetPlacer = () => { name = name0; title = title0; };

	/**
	 * Updates noun if it is a placer. Called by mark.updatePlacer.
	// Coerce name and title to be strings.
	 * @param {string} name1 Updated name of noun.
	 * @param {string} title1 updated title of noun.
	 */
	const updatePlacer = (name1, title1) => { name = "" + name1; title = "" + title1; };

	return {
		get num()    { return num;    },
		get type()   { return type;   },
		get name()   { return name;   },
		get title()  { return title;  },
		get name0()  { return name0;  },
		get title0() { return title0; },
		get pairs()  { return pairs;  }, set pairs(value) { pairs = value; },
		get facts()  { return facts;  }, set facts(value) { facts = value; },
		toString, asString, reset, addNoun, getPairNoun, getPairNounNum, isPair,
		resetPlacer, updatePlacer
	};
}

Adding Nouns

A noun type is created using the Puzzle method addNounType. The nouns for each noun type are created using the NounType method addNoun. Here is the code for the first noun type House.

	// Nouns.
	let houses = puzzle.addNounType("House");
	let house1 = houses.addNoun("1st");
	let house2 = houses.addNoun("2nd");
	let house3 = houses.addNoun("3rd");
	let house4 = houses.addNoun("4th");
	let house5 = houses.addNoun("5th");
	

Verbs

There are always three verbs in a logic puzzle. The three verbs are: the negative (false) verb, the possible (unknown) verb, and the positive (true) verb. Each verb has a brief text phrase for its name, and a character for its code. Our example logic puzzle has the following verbs.

Verbs
#TypeNameCode
-1Negativeis notX
0Possiblemay be
1PositiveisO

Here is a brief description of each verb.

The tense for the names of the verbs can be present, past, or future, and only affects the negative and positive verbs. As a general rule, the names of the negative and positive verbs should come from the clues. Here is an example.

The characters you see in the Grids form are given by the character for each verb. Before you begin solving a logic puzzle, each cell in the grids contains the possible verb, usually represented by a blank character. To solve a logic puzzle, each cell that contains the possible verb must be replaced by either the negative verb ('X'), or the positive verb ('O'). When all of the cells have been properly filled in, then you have the solution to the logic puzzle.

The Verb Class

/* jshint unused:true, eqnull:true, esversion:6 */
/* exported Verb */
"use strict";

/**
 * Verb class for Mystery Master Logic Puzzle Solver.  
 * This class is included in all pages.  
 * Copyright mysterymaster.com. All rights reserved.
 * @class Verb
 * @author Michael Benson
 * @version 2019-10-10
 * @param {number} num Zero-based number of verb.
 * @param {string} name Name of verb.
 * @param {string} code Character code of verb.
 * @param {string} style CSS style for code.
 */
function Verb(num, name, code, style = null) {
	let type = Verb.Types[num];

	if (style === null) {
		switch (num) {
			case 0: style = "color:red;";   break;
			case 1: style = "color:black;"; break;
			case 2: style = "color:gray;";  break;
		}
	}

	/**
	 * Returns verb as string.
	 * @returns {string} String representation of verb.
	 */
	const toString = () => name;

	/**
	 * Returns verb as string for debugging.
	 * @returns {string} String representation of verb.
	 */
	const asString = () => `{num:${num} name:"${name}" type:"${type}" code:"${code}" style:"${style}"}`;

	/**
	 * Returns character code of verb as HTML string.
	 * @returns {string} HTML string.
	 */
	const getCodeAsHtml = () => "<span style=\"" + style + "\">" + code + "</span>";

	return {
		get num()  { return num;  },
		get type() { return type; },
		get name() { return name; }, set name(value) { name = value; },
		get code() { return code; },
		toString, asString, getCodeAsHtml
	};
}

/**
 * Names for each verb where: 0=IsNot, 1=Is, 2=Maybe.
 * @type {string[]}
 */
Verb.Types = ["Negative", "Positive", "Possible"];

/**
 * Maximum number of verbs. This number is always three.
 * @type {number}
 */
Verb.MaxVerbs = Verb.Types.length;

/**
 * Negative verb. Represents two nouns are not together.
 * @type {Verb}
 */
Verb.IsNot = new Verb(0, "is not", "X");

/**
 * Positive verb. Represents two nouns are together.
 * @type {Verb}
 */
Verb.Is = new Verb(1, "is", "O");

/**
 * Possible verb. Represents two nouns may be together.
 * @type {Verb}
 */
Verb.Maybe = new Verb(2, "maybe", " ");

console.log(`Verb.IsNot=${Verb.IsNot.asString()}`);
console.log(`Verb.Is   =${Verb.Is.asString()}`);
console.log(`Verb.Maybe=${Verb.Maybe.asString()}`);

Adding Verbs

The three nouns are always defined for each puzzle module. These three variables are "static" variables of the Verb class: Verb.IsNot, Verb.Is, and Verb.Maybe. Since the predefined attributes for these verbs are acceptable, this section is not needed for our puzzle module.

All of the relationships between two nouns in a puzzle must be expressed as verbs and links. When you examine the clues in a puzzle and the relationship is not obvious, this means the link is "with". For example, the first clue in our example puzzle states: "The Englishman lives in the red house." This clue can be expressed as a fact, where the first noun is "The Englishman", the verb is positive, and the link is "with". Anytime a clue states that one noun is or is not with another noun, just use the default link "with". In other words, the phrase "lives in" really means "with". The data for this fact could be interpreted as: fact(englishman, is, with, red).

For our example puzzle, there are three links: "with", "directly to the right of", and "next to". All of the links use the noun type House. To understand these links, draw a picture of the five houses in this puzzle. You want a picture of five numbered houses in a row, where the 1st house is on the far left, and the 5th house is on the far right. Assume each house is initially colorless.

1st House 2nd House 3rd House 4th House 5th House
1st 2nd 3rd 4th 5th

With

The first clue in our logic puzzle states: "The Englishman lives in the red house." If a clue states that one noun is or is not with another noun, then the default link "with" is used. In less-than-stellar English, the first clue tells us "The Englishman is with the red house." The "with" link is a one-to-one relationship because it means that a noun is with itself. This link is automatically defined for you, and it defaults to the first noun type of the puzzle. For our puzzle, here are the only statements that are true:

I highly recommend that if a noun type is referenced by the links, then it should be the first noun type you define. With that in mind, I should point out that a logic puzzle may have some links that reference one noun type, and other links that reference another noun type. For example "Small Town Motels" has seven links that reference three different noun types. Now that's a hard logic puzzle! In cases like this, have the most logical noun type be first.

Directly To The Right Of

The second link given by the clues is "directly to the right of". This link is in clue 5 "The green house is directly to the right of the white one." Going back to our picture, this means only the following statements are true.

This type of link has a "one-to-one" relationship because exactly one house is directly to the right of another house.

Next To

The third link given by the clues is "next to". This link is in clues 10, 12, and 14. While some houses are only next to one house, other houses are between two houses. Therefore, this link is not "one-to-one". Can you determine all of the statements that are true for this link?

Comparisons

The "directly to the right of" link is a "more than" comparison because we want to see if noun A is higher than noun B. This comparison is done using the one-based number of the noun. To reduce the number of links in a logic puzzle, use either "more than" or "less than" comparisons, but not both. For example, if a fact stated "A is less than B", you can also say "B is more than A", and vice versa. Below are the links for our example puzzle. The first table displays the links. Here is a brief description for each column.

  1. # is the zero-based number of the link.
  2. Noun Type is the noun type referenced by the link.
  3. Name is the name of the link.
  4. 1:1 tells use if the link is one-to-one (checked) or not (unchecked).

The subsequent tables display the link grid for each link. This type of grid visually informs us the relationship between noun A (left-most column of row headers) and noun B (top-most row of column headers). If the intersecting cell has a 'O', then the relationship between noun A and noun B is true. If the intersecting cell has a 'X', then the relationship between noun A and noun B is false.


with
House1st2nd3rd4th5th
1stOXXXX
2ndXOXXX
3rdXXOXX
4thXXXOX
5thXXXXO

directly to the right of
House1st2nd3rd4th5th
1stXXXXX
2ndOXXXX
3rdXOXXX
4thXXOXX
5thXXXOX

next to
House1st2nd3rd4th5th
1stXOXXX
2ndOXOXX
3rdXOXOX
4thXXOXO
5thXXXOX

The Link Class

/* global Verb */
/* jshint unused:true, eqnull:true, esversion:6 */
/* exported Link, */
"use strict";

/**
 * Link class for Mystery Master Logic Puzzle Solver.  
 * Copyright mysterymaster.com. All rights reserved.
 * @class Link
 * @author Michael Benson
 * @version 2019-10-10
 * @param {number} num Zero-based number of link.
 * @param {string} name Name of link.
 * @param {NounType} nounType Noun type of link.
 * @param {Function} getVerb Returns verb given two nouns of link's noun type.
 */
function Link(num, name, nounType, getVerb = null) {
	/**
	 * True if link is one-to-one, otherwise false.
	 * @type {boolean}
	 */
	let oneToOne = false;

	/**
	 * Returns link as string.
	 * @returns {string} String representation of link.
	 */
	const toString = () => name;

	/**
	 * Returns link as string for debugging.
	 * @returns {string} String representation of link.
	 */
	const asString = () => `{num:${num} name:"${name}" nounType:"${nounType}" oneToOne:${oneToOne}`;

	/**
	 * Sets the 1:1 field. Called by puzzle.validate.
	 */
	const setOneToOne = () => {
		oneToOne = false;
		for (let noun1 of nounType.nouns) {
			let cnt = 0;
			for (let noun2 of nounType.nouns) {
				let verb = getVerb(noun1, noun2);
				if (verb === Verb.Is && ++cnt > 1) return;
			}
		}
		oneToOne = true;
	};

	return {
		get num()      { return num;      },
		get name()     { return name;     },
		get nounType() { return nounType; },
		get getVerb()  { return getVerb;  }, set getVerb(value) { getVerb = value; },
		get oneToOne() { return oneToOne; },
		toString, asString, setOneToOne
	};
}

/**
 * Default link. Set when first noun type is created in puzzle.addNounType.
 * @type {Link}
 */
Link.With = null;

Adding Links

The first link "with" is defined as a "static" variable of the Link class, so it is always available for each puzzle module. Each link is created via the Puzzle method addLink. Here is the code for the other links in our puzzle.

	let directlyRightOf = puzzle.addLink("directly to the right of", houses);
	directlyRightOf.getVerb = SmartLink.getIsMoreBy(1);

	let nextTo = puzzle.addLink("next to", houses);
	nextTo.getVerb = SmartLink.getIsNextTo();
	

Note: The function assigned to each link via the getVerb member is given by a method in the SmartLink static class. See the section on Smart Links for more information. If you cannot find what you want in the SmartLink class, you will need to "roll your own".

Facts

The facts are the static relationships between two nouns. An example is "A is next to B." A fact has the following form.

"Noun 1 Verb Link Noun 2.", where (1) the verb is positive or negative, and (2) the verb and link is the relationship between the two nouns.

The first clue in our example puzzle can be expressed directly as a fact: "The Englishman lives in the red house." In fact (pun intended), what makes this puzzle unique is that each clue is a fact. Here are the facts for this puzzle.

Facts
#XHitsName
10
20
30
40
50
60
70
80
90
100
110
120
130
140

Here is a brief description for each column.

  1. # is the one-based number of the fact.
  2. X tells you if the fact is enabled (checked) or disabled (unchecked).
  3. Hits is the number of times a fact is referenced.
  4. Name is the text of the fact.

Types of Facts

The types of facts are given below. While the first type of fact yields only one mark, the other types of facts usually produce more than one mark.

Type 1

A type 1 fact has the default link "with". Only a type 1 fact has "with" as the link. It is the easiest to process, and the easiest to notice when a mark contradicts it. Most of the facts in our example puzzle "Five Houses" are type 1 facts.

I must point out that both nouns in a type 1 fact cannot have the same noun type. Why? Because in any logic grid puzzle, two nouns of the same noun type can never be together! If you had the fact with two first names such as "Abe is with Bob", this would be a violation. And if you had the fact "Abe is not with Bob", this would simply be redundant.

Type 2

A type 2 fact has exactly one noun where its noun type matches the noun type of the link. It is slightly harder to process, and is harder to catch when a mark contradicts it. This puzzle, along with most puzzles, does not have this type of fact.

A logic puzzle that does have facts of type 2 is "Lucky Streets". In this puzzle, fact 15 states "She found the quarter earlier than Friday." This means the quarter was not found on Friday or Saturday. The quarter has the noun type "Coin", while the link "earlier than" and Friday both have the noun type "Day". In this puzzle, facts 16 and 17 are type 2 facts as well.

Again, I must point out that both nouns in a type 2 fact cannot have the same noun type. Why? Because this is like saying "Thursday is earlier than Friday." While this may be factually true, this is already defined within the link "earlier than".

Type 3

A type 3 fact has the same noun type for both nouns, but this noun type is different from the noun type of the link. A fact of type 3 from our example puzzle is fact 5: "The green house is directly to the right of the white one." The green house and the white house have the noun type "Color", and the link "directly to the right of" has the noun type "House".

Type 4

A type 4 fact is where the noun types for both nouns and the link are all different. From our example puzzle, facts 10, 12, and 14 are facts of type 4. Let's examine fact 10: "The man who sings lives next to the man with the fox." The man who sings has the noun type "Hobby". The link "next to" has the noun type "House". And the man with the fox has the noun type "Pet".

An important issue concerning logic puzzles has to do with whether two objects given in a clue are distinct, and therefore cannot be paired. For type 1 facts, the answer is explicitly given. For the other types of facts, usually the link will let you know whether this is true or not. If a fact states: "Abe is in a room next to the room the cat is in", then you know that Abe can never be in the same room as the cat.

But if the fact states: "Abe is in a room that is not next to the room the cat is in", it may be possible that Abe is in the same room as the cat. My suggestion is that if the clue does not say otherwise, assume that the two nouns given in the clue are distinct.

The Fact Class

/* jshint unused:true, eqnull:true, esversion:6 */
/* exported Fact */
"use strict";

/**
 * Fact class for Mystery Master Logic Puzzle Solver.  
 * Copyright mysterymaster.com. All rights reserved.
 * @class Fact
 * @author Michael Benson
 * @version 2019-10-07
 * @param {number} num One-based number of fact.
 * @param {string} name Name of fact.
 * @param {Noun} noun1 Noun 1 of fact.
 * @param {Verb} verb Verb of fact.
 * @param {Link} link Link of fact.
 * @param {Noun} noun2 Noun 2 of fact.
 * @param {boolean} initEnabled Initially enable/disable fact.
 */
function Fact(num, name, noun1, verb, link, noun2, initEnabled = true) {
	/**
	 * Type of fact, where type is either 1, 2, 3, or 4.
	 * @type {number}
	 */
	let type = 0;

	/**
	 * Enabled flag of fact.
	 * @type {boolean}
	 */
	let enabled = initEnabled;
	
	/**
	 * Number of times fact was referenced.
	 * @type {number}
	 */
	let hits = 0;

	if (link.num === 0)
		type = 1;
	else if (noun1.type === link.nounType || noun2.type === link.nounType)
		type = 2;
	else if (noun1.type === noun2.type)
		type = 3;
	else if (noun1.type !== noun2.type)
		type = 4;

	/**
	 * Returns fact as string.
	 * @returns {string} String representation of fact.
	 */
	const toString = () => name;

	/**
	 * Returns fact as string for debugging.
	 * @returns {string} String representation of fact.
	 */
	const asString = () => `{num:${num} name:"${name}" type:"${type}" noun1:"${noun1}" verb:"${verb}" link:"${link}" noun2:"${noun2}" enabled:${enabled} hits:${hits} initEnabled:${initEnabled}}`;

	/**
	 * Resets fact. Called by puzzle.reset.
	 */
	const reset = () => { enabled = initEnabled; hits = 0; };

	/**
	 * Returns string when mark is based on this fact.
	 * @returns {string}
	 */
	const msgBasedOn  = () => "fact " + num;

	/**
	 * Returns string when fact is disabled. Called by solver.addMark.
	 * @returns {string}
	 */
	const msgDisabled = () => " Fact " + num + " is disabled.";

	return {
		get num()         { return num;     },
		get name()        { return name;    },
		get type()        { return type;    },
		get noun1()       { return noun1;   },
		get verb()        { return verb;    },
		get link()        { return link;    },
		get noun2()       { return noun2;   },
		get enabled()     { return enabled; }, set enabled(value)     { enabled = value; },
		get hits()        { return hits;    }, set hits(value)        { hits = value;    },
		get initEnabled() { return enabled; }, set initEnabled(value) { initEnabled = value; },
		toString, asString, reset, msgBasedOn, msgDisabled
	};
}

Adding Facts

This logic puzzle is unique in that each clue corresponds to exactly one fact. This is usually not the case! Each fact is created via the Puzzle method addFact. Here is the first fact.

	puzzle.addFact("1", englishman, Verb.Is, Link.With, red, "The Englishman lives in the red house.");
	

This method appears very straightforward. But I must tell you this method has overloads where you can enter more than one fact at a time. So let's discuss what those overloads are in another article.

Validating Facts

Before the Mystery Master solves a logic puzzle, it first validates the logic puzzle by looking for various logic errors. The Puzzle method that validates the puzzle is appropriately named validate. Here are some reasons a fact is invalid.

  1. The fact's verb is the possible verb ("maybe"). It must be either the positive verb ("is") or the negative verb ("is not").
  2. Both nouns in the fact are the same. The nouns in a fact must be different.
  3. The fact's link is "with", but both nouns have the same type. This is like saying "Bob is not Abe", or "Abe is Bob". For logic grid puzzles, two nouns of the same type are never together anyway.
  4. The fact's link is not "with", but both nouns have the same type as the link. For example "The 2nd person in line is ahead of the 4th person in line.", may make sense, but this is a relationship, not a fact! This statement is exactly what the "ahead of" link is suppose to define.

Rules

A rule is a conditional relationship between two or more nouns such as "If A is next to B, then C is not next to D." Rules are needed when facts cannot represent the clues in a logic puzzle. Since our example puzzle does not have rules, below is the puzzle module for the puzzle "All Tired Out".

/* global Verb, Link, Puzzle, SmartLink, SmartRule*/
/* jshint unused:true, eqnull:true, esversion:6 */

/**
 * Puzzle module for "All Tired Out".
 * @copyright mysterymaster.com. All rights reserved.
 * @author Michael Benson
 * @version 2019-10-10
 * @param {Solver} solver Solver object.
 */
function AllTiredOut(solver) {
	"use strict";
	let puzzle = new Puzzle("AllTiredOut", "All Tired Out");
	puzzle.answer  = [ [ 4, 1, 2, 3, 0 ], [ 1, 4, 2, 3, 0 ] ];

	const slots = puzzle.addNounType("Order");
	const slot1 = slots.addNoun("1st");
	const slot2 = slots.addNoun("2nd");
	const slot3 = slots.addNoun("3rd");
	const slot4 = slots.addNoun("4th");
	const slot5 = slots.addNoun("5th");

	const names = puzzle.addNounType("Customer");
	const ethan = names.addNoun("Ethan");
	const grace = names.addNoun("Grace");
	const jeff  = names.addNoun("Jeff");
	const lisa  = names.addNoun("Lisa");
	const marge = names.addNoun("Marge");

	const wants = puzzle.addNounType("Wanted");
	const alignment = wants.addNoun("alignment");
	const chains    = wants.addNoun("chains");
	const jack      = wants.addNoun("jack");
	const shocks    = wants.addNoun("shock absorbers", "Shocks");
	const tires     = wants.addNoun("tires");

	Verb.IsNot.name = "was not";
	Verb.Is.name    = "was";

	const justAhead  = puzzle.addLink("just ahead of"        , slots, SmartLink.getIsLessBy(1));
	const threeAhead = puzzle.addLink("three places ahead of", slots, SmartLink.getIsLessBy(3));
	const nextTo     = puzzle.addLink("next to"              , slots, SmartLink.getIsNextTo());

	puzzle.sayFact = (noun1, verb, link, noun2) => {
		let msg = noun1.name + " " + verb.name + " " + link.name + " " + noun2.name;
		let lname = link === Link.With ? " " : " " + link.name + " ";

		// Types: 1=Order, 2=Customer, 3=Wanted.
		switch (noun1.type.num) {
			case 1:
				msg = "The " + noun1.name + " person in line ";
				switch (noun2.type.num) {
					case 1: break;
					case 2:
						msg += verb.name + lname + noun2.name;
						break;
					case 3:
						msg += (verb === Verb.Is ? "did" : "did not") + " buy the " + noun2.name;
						break;
				}
				break;
			case 2:
				msg = noun1.name + " ";
				switch (noun2.type.num) {
					case 1:
						msg += verb.name + lname + "the " + noun2.name + " person in line";
						break;
					case 2: break;
					case 3:
						if (link === Link.With)
							msg += (verb === Verb.Is ? "did" : "did not") + " buy the " + noun2.name;
						else
							msg += verb.name + " " + link.name + " the person who bought the " + noun2.name;
						break;
				}
				break;
			case 3:
				msg = "The person who bought the " + noun1.name + " " + verb.name + lname;
				switch (noun2.type.num) {
					case 1: break;
					case 2:
						msg += noun2.name;
						break;
					case 3:
						msg += "the one who bought the " + noun2.name;
						break;
				}
				break;
		}
		return msg + ".";
	};

	puzzle.addFact("1", [ethan, slot3, chains], Verb.IsNot, Link.With);
	puzzle.addFact("2", jack, Verb.Is, justAhead, lisa);
	puzzle.addFact("3", slot2, Verb.IsNot, Link.With, [ ethan, jeff ]);
	puzzle.addFact("4", tires, Verb.Is, threeAhead, alignment);
	puzzle.addFact("6", jeff, Verb.Is, justAhead, shocks);

	const rule1 = puzzle.addRule("5", "Marge wasn't the second of the three women in line.");
	rule1.f = SmartRule.getIsNotBetween(solver, rule1, slots, marge, grace, lisa);

	const rule2 = puzzle.addRule("7", "Grace stood next to at least one man in line.");
	rule2.f = SmartRule.getIsRelated(solver, rule2, grace, nextTo, [ethan, jeff]);

	return puzzle;
}
console.log("loaded puzzle module " + AllTiredOut.name);

In this puzzle module, you can see that I set the names for the verbs to be past tense.

	// Verbs.
	Verb.IsNot.name = "was not";
	Verb.Is.name = "was";
	

For this logic puzzle, clues 5 and 7 need to be expressed as rules. Here are the rules for this puzzle.

Rules
#XHitsName
10
20

Here is a brief description for each column.

  1. # is the one-based number of the rule.
  2. X tells you if the rule is enabled (checked) or disabled (unchecked).
  3. Hits is the number of times a rule is referenced.
  4. Name is the text of the rule.

The Rule Class

/* jshint unused:true, eqnull:true, esversion:6 */
/* exported Rule */
"use strict";

/**
 * Rule class for Mystery Master Logic Puzzle Solver.  
 * Copyright mysterymaster.com. All rights reserved.
 * @class Rule
 * @author Michael Benson
 * @version 2019-10-07
 * @param {number} num One-based number of rule.
 * @param {string} name Name of rule.
 * @param {Noun[]} nouns Array of nouns referenced by rule. Used for sorting loners.
 * @param {boolean} initEnabled Initially enable/disable rule.
 */
function Rule(num, name, nouns = [], initEnabled = true) {
	/**
	 * User-defined function of rule.
	 * Note: MUST assign after rule is created since rule is referenced in the function!
	 * @type {Function}
	 */
	let f = null;

	/**
	 * Enabled flag of rule.
	 * @type {boolean}
	 */
	let enabled = initEnabled;

	/**
	 * Number of times rule was referenced.
	 * @type {number}
	 */
	let hits = 0;

	/**
	 * Returns rule as string.
	 * @returns {string} String representation of rule.
	 */
	const toString = () => name;

	/**
	 * Returns rule as string for debugging.
	 * @returns {string} String representation of rule.
	 */
	const asString = () => `{num:${num} name:"${name}" nouns:"${nouns}" enabled:${enabled} hits:${hits} initEnabled:${initEnabled}}`;

	/**
	 * Resets rule. Called by puzzle.reset.
	 */
	const reset = () => { enabled = initEnabled; hits = 0; };

	return {
		get num()         { return num;     },
		get name()        { return name;    },
		get nouns()       { return nouns;   },
		get f()           { return f;       }, set f(value)           { f = value;       },
		get enabled()     { return enabled; }, set enabled(value)     { enabled = value; },
		get hits()        { return hits;    }, set hits(value)        { hits = value;    },
		get initEnabled() { return enabled; }, set initEnabled(value) { initEnabled = value; },
		toString, asString, reset
	};
}

Adding Rules

Here are the rules that will satisfy clues 5 and 7.

	// Rules.
	let rule1 = puzzle.addRule("5", "Marge wasn't the second of the three women in line.");
	rule1.f = SmartRule.getIsNotBetween(this, rule1, slots, marge, grace, lisa);

	let rule2 = puzzle.addRule("7", "Grace stood next to at least one man in line.");
	rule2.f = SmartRule.getIsRelated(this, rule2, grace, nextTo, [ethan, jeff]);
	

Rules, along with links, require programming. The function f for each rule is provided by a method in the SmartRule static class. See the section on Smart Rules for more information. A rule usually does something based on the marks, and returns a status code. The status code is either negative for a violation, or zero for success. A rule may perform one or more of the following tasks.

Enforce Violations

A violation occurs when a mark contradicts the clues of a puzzle. A mark that violates the clues is called a contradiction. This should only happen when assumptions are made. A violation means the program should undo the last assumption, and make another one. If an assumption was not made, this is a fatal logic error, and the program will stop solving the puzzle. In the puzzle "All Tired Out", if a mark created the situation where Grace was first and a woman was second, this mark would contradict the clues. When a rule encounters this, it must inform the program of the violation.

Trigger Marks

A rule that examines the current mark to enter additional marks is called a trigger. The status of a trigger is the status of the submitted mark. If there are no problems with the mark, the status is zero. If there is a problem with the mark, the status is negative, and is returned immediately. If the mark entered by a trigger is rejected, this is the same as a rule violation. If the trigger was successful, the current rule can continue, and additional rules can be invoked.

For the logic puzzle "All Tired Out", a rule must look for a situation such as "If no man can be 2nd in line, then Grace cannot be 1st in line." When this situation is found, the rule enters 'X' for 1st and Grace. In general, rule violations should be handled first, followed by triggers.

Updating Placeholders

Some logic puzzles may not give you all of the values of the nouns. This means the values must be calculated by a rule. Nouns where the initial value is unknown are called placeholders. Usually these values are numeric, such as the number of people who attended a talk in "Astrophysics Conference", or the age of a salesperson in "Dandy Salespeople". Rules that update placeholders can be quite complex.

To summarize, rules are laws that are specific to a logic puzzle. The laws for solving a logic puzzle will be discussed in a future article.

Solution

This is the encoded solution to the logic puzzle, but is optional. If you know the solution, you can encode it here. See if you can "decode" the following.

	puzzle.answer = [ [ 3, 4, 0, 2, 1 ], [ 3, 2, 0, 1, 4 ], [ 1, 2, 0, 3, 4 ], [ 2, 3, 1, 0, 4 ], [ 4, 1, 2, 3, 0 ] ];
	

Helper

The Helper static class defines helpful methods. This class is referenced by several of my classes.

The Helper Class

/* jshint unused:true, eqnull:true, esversion:6 */
"use strict";

/**
 * Helper object for Mystery Master Logic Puzzle Solver.  
 * This is included in all pages.  
 * Copyright mysterymaster.com. All rights reserved.
 * @namespace Helper
 * @author Michael Benson
 * @version 2019-10-07
 */
let Helper = {
	toString() { return "Helper"; },

	// #region Object Methods.

	/**
	 * Returns name of object for debugging purposes.
	 * @param {Object} obj Object that has name property.
	 * @returns {string} Name of object.
	 */
	getObjName(obj) { return obj ? obj.name : "(null)"; },

	// #endregion

	// #region Boolean Methods.

	/**
	 * Returns true if (a) val is boolean and true, (b) val is numeric and not zero, or
	 * (c) val is string "true" (case insensitive), otherwise returns false.
	 * @param {boolean|number|string} val Value.
	 * @returns {boolean} True or false.
	 */
	getBoolean(val) {
		if (typeof val === "boolean") return val;
		if (Helper.isNumber(val)) return val !== 0;
		if (typeof val !== "string") return false;
		val = val.toLowerCase();
		return val === "true";
	},
	
	// #endregion

	// #region Numeric Methods.
	
	/**
	 * Returns true if given value is a number, otherwise false.
	 * @param {boolean|number|string} val Value.
	 * @returns {boolean} True if given value is a number, otherwise false.
	 */
	isNumber(val) { return typeof val === "number" && !isNaN(val); },

	/**
	 * Returns integer value of given string.
	 * @param {string} str String value.
	 * @returns {number} Integer value of string.
	 */
	toInt(str) { return str === null ? 0 : parseInt(str); },

	/**
	 * Return true if value of given string is an integer, otherwise false.
	 * @param {string} str String value.
	 * @returns {boolean} True if value of given string is an integer, otherwise false.
	 */
	isInt(str) {
		if (str === null) return false;

		let val = typeof str === 'string' ? parseInt(str) : str;

		// Check if val is NaN.
		if (val !== val) return false;

		return parseFloat(val) === parseInt(val);
	},

	/**
	 * Returns true if integer value of noun's name is not divisible by given number, otherwise false.
	 * @param {Noun} noun Noun.
	 * @param {number} num Number.
	 * @returns {boolean} True if integer value of noun's name is not divisible by given number, otherwise false.
	 */
	isNotDivisibleBy(noun, num) {
		if (noun === null || !Helper.isInt(noun.name)) return false;
		let val = Helper.toInt(noun.name);
		return val % num !== 0;
	},

	// #endregion

	// #region Date/Time Methods.
	
	/**
	 * Returns current time as formatted string.
	 * @returns {string} Current time as formatted string.
	 */
	getTimestamp() {
		let date = new Date();
		return new Date(date.getTime()).toLocaleString() + " " + date.getMilliseconds();
	},

	/**
	 * Returns given date as string with format "YYYY-MM-DD hh:mm:ss.ms".
	 * @param {Date} date Date.
	 * @returns {string} Formatted string of date.
	 */
	formatDT(date) {
		let yy = date.getFullYear();
		let mm = date.getMonth() + 1;
		let dd = date.getDate();
		let hh = date.getHours();
		let mi = date.getMinutes();
		let ss = date.getSeconds();
		let ms = date.getMilliseconds();
		let msg = "" + yy + "-" + (mm <= 9 ? "0" + mm : mm) + "-" + (dd <= 9 ? "0" + dd : dd) +
			" " + (hh <= 9 ? "0" + hh : hh) + ":" + (mi <= 9 ? "0" + mi : mi) +
			":" + (ss <= 9 ? "0" + ss : ss) + "." + (ms <= 9 ? "00" + ms : (ms <= 99 ? "0" + ms : ms));
		return msg;
	},

	// #endregion

	// #region String Methods.
	
	/**
	 * Returns given name with first letter capitalized.
	 * @param {string} name Name.
	 * @returns {string} Name with first letter capitalized.
	 */
	getFirstCap(name) { return name.charAt(0).toUpperCase() + name.slice(1); },

	/**
	 * Returns array of two strings where argument is "key=val" string.
	 * @param {string} str String.
	 * @returns {string[]} Array of two strings for key and value.
	 */
	getTupleFromString(str) {
		let key = "";
		let val = "";
		if (typeof str === 'string' || str instanceof String) {
			let tokens = str.split("=", 2);
			key = tokens[0];
			val = tokens.length > 1 ? tokens[1] : "";
		}
		return [ key, val ];
	},

	/**
	 * Returns multi-line string as one line with new lines replaced by given separator.
	 * @param {string} str String.
	 * @param {string} sep Separator.
	 */
	getMsgAsOneLine(str, sep = " ") { return str.replace("\n", sep); },

	/**
	 * Returns given string converted to title case.
	 * See: https://stackoverflow.com/questions/196972/convert-string-to-title-case-with-javascript
	 * @param {string} str String.
	 * @returns {string} String converted to title case.
	 */
	toTitleCase(str) {
		return str.replace(/\w\S*/g, function(txt) { return txt.charAt(0).toUpperCase() + txt.substr(1); });
	},

	toTitleCaseOld(str) {
		if (str === null || str === undefined) return str;
		let n = str.length;
		if (n === 0) return str;

		let seps = " \t-";
		let flag = true;
		let chars = Array.from(str);
		for (let i = 0; i < n; i++) {
			if (seps.indexOf(chars[i]) > -1)
				flag = true;
			else if (flag === true) {
				chars[i] = chars[i].toUpperCase();
				flag = false;
			}
		}
		let res = chars.join("");
		return res;
	},

	/**
	 * Returns array of objects as comma-delimited string.
	 * @param {Object[]} objs Array of objects.
	 * @param {string} sep0 Separator.
	 * @returns {string} Comma-delimited string.
	 */
	getArrayAsString(objs, sep0 = ",") {
		let msg = "[", sep = "";
		for (let obj of objs) {
			//console.log(`Helper.getArrayAsString obj="${obj}"`);
			msg += sep + obj.toString();
			sep = sep0;
		}
		return msg + "]";
	},

	// #endregion

	// #region Array/List Methods.
	
	/**
	 * Returns array of items in list1 that are not in list2.
	 * @param {Object[]} list1 List 1.
	 * @param {Object[]} list2 List 2.
	 * @returns {Object[]} Array of items in list1 that are not in list2.
	 */
	getListExcept(list1, list2) {
		let list = [];
		for (let item1 of list1) {
			let found = false;
			for (let item2 of list2) {
				if (item1 === item2) {
					found = true;
					break;
				}
			}
			if (!found) list.push(item1);
		}
		return list;
	},

	// #endregion

	// #region Matrix Methods.

	/**
	 * Returns the 2D array as a string.
	 * @param {number[][]} a 2D array.
	 * @returns {string} String representation of 2D array.
	 */
	getArray2DAsString(a) {
		let str = "";
		if (a === null) return str;
		str = "[";
		let n1 = a.length;
		for (let i1 = 0; i1 < n1; i1++) {
			let line = "[", sep = "";
			let n2 = a[i1].length;
			for (let i2 = 0; i2 < n2; i2++) {
				line += sep + a[i1][i2];
				sep = ",";
			}
			sep = (i1 < n1 - 1) ? "," : "";
			str += line + "]" + sep;
		}
		str += "]";
		return str;
	},

	/**
	 * Prints the 2D array for debugging.
	 * @param {Object[][]} a 2D array.
	 */
	sayArray2D(a) {
		let msg = "";
		if (a === null) return msg;
		for (let i1 = 0; i1 < a.length; i1++) {
			let line = "", sep = "";
			for (let i2 = 0; i2 < a[i1].length; i2++) {
				line += sep + a[i1][i2];
				sep = ",";
			}
			msg += `${line}\n`;
		}
		console.log(msg);
	},

	/**
	 * Returns 2D array with each element initialized to given value.
	 * @param {number} d1 Dimension 1.
	 * @param {number} d2 Dimension 2.
	 * @param {Object} v Initial value for each element in 2D array.
	 * @returns {Object[][]} 2D array.
	 */
	getArray2D(d1, d2, v) {
		if (v === undefined) v = 0;
		let a = [];
		for (let i1 = 0; i1 < d1; i1++) {
			a[i1] = [];
			for (let i2 = 0; i2 < d2; i2++) {
				a[i1][i2] = v;
			}
		}
		return a;
	},

	/**
	 * Solves system of n linear equations with n variables x[0], x[1], ..., x[n-1] using Gaussian Elimination and Backward Substitution.
	 * Note: This is an augmented matrix, so there is an additional column; hence, n is one less than the number of columns.
	 * @param {number[][]} a 2D array.
	 * @returns {number[]} 1D array of solutions.
	 */
	solveEquations(a) {
		let n = a[0].length - 1;
		//console.log("Helper.solveEquations n=" + n);
		let x = [];
		for (let i = 0; i < n; i++) x[i] = 0;

		for (let i = 0; i < n - 1; i++) {
			// Search for row p where A[p, i] is not zero.
			let p = 0; // swap row index
			for (p = i; p < n; p++) {
				if (a[p][i] !== 0) break;
			}
			// No unique solution exists
			if (p === n) return x;

			if (p !== i) {
				// Swap rows.
				for (let c = 0; c < n + 1; c++) {
					let m = a[p][c];
					a[p][c] = a[i][c];
					a[i][c] = m;
				}
			}

			// Gaussian Elimination.
			for (let j = i + 1; j < n; j++) {
				let m = a[j][i] / a[i][i];
				for (let c = 0; c < n + 1; c++) {
					a[j][c] = a[j][c] - m * a[i][c];
				}
			}
		}

		// No unique solution exists
		if (a[n - 1][n - 1] === 0) return x;

		// Backward Substitution.
		x[n - 1] = a[n - 1][n] / a[n - 1][n - 1];
		for (let i = n - 2; i >= 0; i--) {
			let s = 0.0;
			for (let j = i + 1; j < n; j++) {
				s += a[i][j] * x[j];
			}
			x[i] = (a[i][n] - s) / a[i][i];
		}

		return x;
	},

	// #endregion

	// #region Text Methods (HTML methods are in Puzzler class).

	/**
	 * Returns chart as text table. Called by solver.saySolution.
	 * @param {Puzzle} puzzle Puzzle object.
	 * @param {number} chartCol1 Zero-based index of first noun type to display in chart.
	 * @param {boolean} isSolution True if chart has solution, otherwise false.
	 * @returns {String} Text table.
	 */
	getChartAsText(puzzle, chartCol1, isSolution) {
		let txt = "";
		if (puzzle === null) return txt;
		txt = isSolution ? "Solution\n" : "Chart\n";

		const t = chartCol1;
		const nounTypes = puzzle.nounTypes;
		const nounType1 = nounTypes[t];

		const m = nounTypes.length;
		const n = nounType1.nouns.length;

		const w = 20;
		const pad = " ".repeat(w);
		let tmp;

		let i = 0, j = 0, k = 0;
		for (j = 0; j < m; j++) {
			if (k === t)++k;
			let nounType = (j === 0 ? nounType1 : nounTypes[k++]);
			tmp = nounType.name + pad;
			txt += tmp.substring(0, w);
		}
		txt += "\n";
		for (i = 0; i < n; i++) {
			k = 0;
			for (j = 0; j < m; j++) {
				if (k === t) ++k;
				let noun1 = nounType1.nouns[i];
				tmp = " ";
				if (j === 0)
					tmp =noun1.title;
				else {
					let noun2 = noun1.getPairNoun(nounTypes[k]);
					if (noun2 !== null) tmp = noun2.title;
					++k;
				}
				tmp += pad;
				txt += tmp.substring(0, w);
			}
			txt += "\n";
		}
		return txt;
	},

	// #endregion
};

The SmartLink static class defines methods that return a function for the links in a puzzle module.

The SmartLink Class

/* global Verb */
/* jshint unused:true, eqnull:true, esversion:6 */
/* exported SmartLink */
"use strict";

/**
 * SmartLink object for Mystery Master Logic Puzzle Solver.  
 * Methods that return predefined functions for puzzle's Link objects.  
 * Copyright mysterymaster.com. All rights reserved.
 * @namespace SmartLink
 * @author Michael Benson
 * @version 2019-10-10
 */
let SmartLink = {
	toString() { return "SmartLink"; },

	/**
	 * Returns positive verb if both nouns are equal (i.e., are the same noun), otherwise negative verb.
	 * @returns {Function} Function isWith.
	 */
	getIsWith() {
		return (noun1, noun2) => noun1.num === noun2.num ? Verb.Is : Verb.IsNot;
	},

	/**
	 * Returns positive verb if the number for noun1 is less than the number for noun2 minus n,
	 * otherwise negative verb. For n = 1, this means "before, but not just before."
	 * @param {number} n Number.
	 * @returns {Function} Function isLessThan.
	 */
	getIsLessThan(n = 0) {
		return (noun1, noun2) => noun1.num < noun2.num - n ? Verb.Is : Verb.IsNot;
	},

	/**
	 * Returns positive verb if the number for noun1 is exactly n less than the number for noun2,
	 * otherwise negative verb.
	 * @param {number} n Number.
	 * @returns {Function} Function isLessBy.
	 */
	getIsLessBy(n) {
		return (noun1, noun2) => noun1.num === noun2.num - n ? Verb.Is : Verb.IsNot;
	},

	/**
	 * Returns positive verb if the number for noun1 is more than the number for noun2 plus n,
	 * otherwise negative verb.
	 * For n = 1, this means "after, but not just after."
	 * @param {number} n Number.
	 * @returns {Function} Function isMoreThan.
	 */
	getIsMoreThan(n = 0) {
		return (noun1, noun2) => noun1.num > noun2.num + n ? Verb.Is : Verb.IsNot;
	},

	/**
	 * Returns positive verb if the number for noun1 is exactly n more than the number for noun2,
	 * otherwise negative verb.
	 * @param {number} n Number.
	 * @returns {Function} Function isMoreBy.
	 */
	getIsMoreBy(n) {
		return (noun1, noun2) => noun1.num === noun2.num + n ? Verb.Is : Verb.IsNot;
	},

	/**
	 * Returns positive verb if number for noun1 is exactly 1 less or 1 more than number for noun2,
	 * otherwise negative verb.
	 * @returns {Function} Function isNextTo.
	 */
	getIsNextTo() {
		return (noun1, noun2) => (noun1.num === noun2.num - 1) || (noun1.num === noun2.num + 1) ? Verb.Is : Verb.IsNot;
	},

	/**
	 * Returns positive verb if number for noun1 is exactly n less or n more than number for noun2,
	 * otherwise negative verb.
	 * Equivalent to isNextTo when n is one.
	 * @param {number} n Number.
	 * @returns {Function} Function isOffsetBy.
	 */
	getIsOffsetBy(n) {
		return (noun1, noun2) => (noun1.num === noun2.num - n) || (noun1.num === noun2.num + n) ? Verb.Is : Verb.IsNot;
	},

	/**
	 * Returns positive verb if number for noun1 is either n less or n more than number for noun2,
	 * otherwise negative verb.
	 * @param {number} n Number.
	 * @returns {Function} Function isOutsideOf.
	 */
	getIsOutsideOf(n) {
		return (noun1, noun2) => (noun1.num < noun2.num - n) || (noun1.num > noun2.num + n) ? Verb.Is : Verb.IsNot;
	},

	/**
	 * Returns positive verb if number for noun1 times n1 equals number for noun2 times n2,
	 * otherwise negative verb.
	 * @param {number} n1 Number on left hand side.
	 * @param {number} n2 Number on right hand side.
	 * @returns {Function} Function hasRatio.
	 */
	getHasRatio(n1, n2) {
		return (noun1, noun2) => (n1 * noun1.num === n2 * noun2.num) ? Verb.Is : Verb.IsNot;
	},
};

Smart Rules

The SmartRule static class defines methods that return a function for the rules in a puzzle module.

The SmartRule Class

/* global Helper, Verb */
/* jshint unused:true, eqnull:true, esversion:6 */
/* exported SmartRule */
"use strict";

/**
 * SmartRule object for Mystery Master Logic Puzzle Solver.  
 * Methods that return predefined functions for puzzle's Rule objects.  
 * Copyright mysterymaster.com. All rights reserved.
 * @namespace SmartRule
 * @author Michael Benson
 * @version 2019-10-10
 */
let SmartRule = {
	toString() { return "SmartRule"; },

	// #region 1. matchExactlyOne (NOT IMPLEMENTED).

	// #endregion

	// #region 2. matchAtLeastOne.

	/**
	 * Enforces rule where noun1 is with at least one noun in nouns2.
	 * See: "At The Alter Altar", "Dog Duty", "Modern Novels", "Psychic Phone Friends".
 	 * @param {Solver} solver Solver object.
	 * @param {Rule} rule Rule.
	 * @param {Noun} noun1 Noun 1.
	 * @param {Noun[]} nouns2 Array of Noun 2.
	 * @returns {Function} Function matchAtLeastOne.
	 */
	getMatchAtLeastOne(solver, rule, noun1, nouns2) {
		// Returns true if noun1 can be with at least one noun in list2.
		function canBeWith2(noun1, nouns2) {
			for (let noun2 of nouns2) {
				if (noun1.type === noun2.type) continue;
				if (noun1.getPairNounNum(noun2.type) === noun2.num) return true;
				if (solver.canBeWith(noun1, noun2)) return true;
			}
			return false;
		}

		// Returns noun from list2 if it is the only noun that can be with noun1, otherwise null.
		function isOnlyNoun(noun1, nouns2) {
			let noun = null;
			for (let noun2 of nouns2) {
				if (noun1.type === noun2.type) continue;
				if (noun1.getPairNoun(noun2.type) === noun2) return null;
				if (!solver.canBeWith(noun1, noun2)) continue;
				if (noun !== null) return null;
				noun = noun2;
			}
			return noun;
		}

		// Note: Puzzle object is referenced via solver.puzzle.
		function matchAtLeastOne(mark) {
			let rs = 0;

			// Violation if noun1 cannot be with any noun in nouns2.
			if (!canBeWith2(noun1, nouns2)) return -1;

			// Trigger if noun1 can only be with one noun in nouns2.
			let noun2 = isOnlyNoun(noun1, nouns2);
			if (noun2 !== null) {
				let msg = noun1.name + " must be with " + noun2.name + ".";
				rs = solver.addMarkByRule(mark, rule, ' ', noun1, Verb.Is, noun2, msg);
				if (rs !== 0) return rs;
			}

			// Example: For "Dog Duty", Whiley belongs to a woman.
			// If Whiley can be with nounX, but no woman can be with nounX, then Whiley is not with nounX.
			// TODO Do this for other SmartRules? PsychicPhoneFriends benefits.
			let puzzle = solver.puzzle;
			for (let nounType of puzzle.nounTypes) {
				if (noun1.type === nounType) continue;
				for (let nounX of nounType.nouns) {
					if (solver.getGridVerb(noun1, nounX) === Verb.IsNot) continue;
					let ok = false;
					for (let noun of nouns2) {
						if (noun.type === nounType || solver.getGridVerb(noun, nounX) !== Verb.IsNot) {
							ok = true;
							break;
						}
					}
					if (!ok) {
						let msg = "SmartRule.matchAtLeastOne: No item in list can be with " + nounX.name + ".";
						//console.log(msg);
						rs = solver.addMarkByRule(mark, rule, ' ', noun1, Verb.IsNot, nounX, msg);
						if (rs !== 0) return rs;
					}
				}
			}
			return rs;
		}
		return matchAtLeastOne;
	},

	// #endregion

	// #region 3. matchOneToExactlyOne.

	/**
	 * Enforces rule where exactly one noun in nouns1 is with exactly one noun in nouns2.
	 * See: "Modern Novels".
 	 * @param {Solver} solver Solver object.
	 * @param {Rule} rule Rule.
	 * @param {Noun[]} nouns1 Array of nouns.
	 * @param {Noun[]} nouns2 Array of nouns.
	 * @returns {Function} Function matchOneToExactlyOne.
	 */
	getMatchOneToExactlyOne(solver, rule, nouns1, nouns2) {

		// Returns the number of matches (zero or more) between the nouns in both lists.
		function getNumMatches(nouns1, nouns2) {
			let cnt = 0;
			for (let noun1 of nouns1) {
				for (let noun2 of nouns2) {
					if (noun2.type === noun1.type) continue;
					if (noun1.isPair(noun2)) ++cnt;
				}
			}
			return cnt;
		}

		function matchOneToExactlyOne(mark) {
			let rs = 0;

			// Example: ModernNovels has exactly one of the two men (Oscar, Peter) chose a Faulkner novel ("Light in August", "Absalom! Absalom!").
			// If  only noun1 in list1 can be with noun2 in list2,
			// and only noun2 in list2 can be with noun1, then noun1 must be with noun2.
			// Also, there is a rule violation if all the counts are zero.

			// Get number of nouns in list1, list2.
			const n1 = nouns1.length;
			const n2 = nouns2.length;
			let counts = new Array(n1);

			let scanFlag = true;
			let i1 = -1; // index of noun1 with count of one, and all others zero.
			let i2 = -1; // index of noun2 that can be with noun1.

			// Examine each noun in list1.
			for (let i = 0; i < n1; i++) {
				let noun1 = nouns1[i];
				counts[i] = 0;

				// Examine each noun in list2.
				for (let j = 0; j < n2; j++) {
					let noun2 = nouns2[j];
					// Ignore noun2 if it has the same type as noun1.
					if (noun2.type === noun1.type) continue;

					// Abort if noun1 is already with noun2.
					if (noun1.isPair(noun2)) {
						scanFlag = false;
						break;
					}

					// Remember index of noun2 if noun1 can be with noun2.
					if (solver.canBeWith(noun1, noun2)) {
						// Abort if count is more than one.
						if (++counts[i] > 1) {
							scanFlag = false;
							break;
						}
						i2 = j;
					}
				}

				if (!scanFlag) break;
				// Remember index of noun1 if count is one.
				if (counts[i] === 1) {
					// Abort if more than one noun1 has a count of one.
					if (i1 !== -1) {
						scanFlag = false;
						break;
					}
					i1 = i;
				}
			}

			if (scanFlag) {
				if (i1 !== -1 && i2 !== -1) {
					// There is only one noun1 that can be with noun2.
					let noun1 = nouns1[i1];
					let noun2 = nouns2[i2];
					let msg = noun1.name + " must be with " + noun2.name + ".";
					rs = solver.addMarkByRule(mark, rule, ' ', noun1, Verb.Is, noun2, msg);
					if (rs !== 0) return rs;
				}
				else {
					// If all the counts are zero, then this is a rule violation.
					for (let i = 0; i < n1; i++) {
						if (counts[i] !== 0) {
							scanFlag = false;
							break;
						}
					}
					if (scanFlag) return -1;
				}
			}

			// Rule violation if the number of matches between nouns in list1 and list2 is more than one.
			if (getNumMatches(nouns1, nouns2) > 1) return -1;

			return rs;
		}
		return matchOneToExactlyOne;
	},

	// #endregion

	// #region 4. matchOneToOne.

	/**
	 * Enforces rule where each noun in nouns1 is uniquely matched with one noun in nouns2.
	 * See: "Modern Novels", "Small Town Motels".
 	 * @param {Solver} solver Solver object.
	 * @param {Rule} rule Rule.
	 * @param {Noun[]} nouns1 Array of nouns.
	 * @param {Noun[]} nouns2 Array of nouns.
	 * @returns {Function} Function matchOneToOne.
	 */
	getMatchOneToOne(solver, rule, nouns1, nouns2) {
		const numNouns = nouns1.length;
		let grid = Helper.getArray2D(numNouns, numNouns, null);
		//console.log("SmartRule.getMatchOneToOne rule.num=" + rule.num + " nouns1=" + nouns1 + " nouns2=" + nouns2 + " numNouns=" + numNouns + " grid=" + grid);

		function matchOneToOne(mark) {
			let rs = 0;

			// Populate the grid.
			let str = "";
			for (let irow = 0; irow < numNouns; irow++) {
				let noun1 = nouns1[irow];
				for (let icol = 0; icol < numNouns; icol++) {
					let noun2 = nouns2[icol];
					let verb = solver.getGridVerb(noun1, noun2);
					str += verb.code;
					grid[irow][icol] = verb;
				}
				str += "\n";
			}
			//console.log(`SmartRule.matchOneToOne mark.num=${mark.num} rule.num=${rule.num} grid=\n${str}`);

			// Must perform rule violations before triggers.
			// Rule violation if there is more than one 'O' per row, or every column has 'X'.
			for (let irow = 0; irow < numNouns; irow++) {
				let cntO = 0;
				let cntX = 0;
				for (let icol = 0; icol < numNouns; icol++) {
					let verb = grid[irow][icol];
					if (verb === Verb.Is) ++cntO; else if (verb === Verb.IsNot) ++cntX;
				}
				if (cntO > 1 || cntX === numNouns) return -1;
			}

			// Rule violation if there is more than one 'O' per column, or every row has 'X'.
			for (let icol = 0; icol < numNouns; icol++) {
				let cntO = 0;
				let cntX = 0;
				for (let irow = 0; irow < numNouns; irow++) {
					let verb = grid[irow][icol];
					if (verb === Verb.Is) ++cntO; else if (verb === Verb.IsNot) ++cntX;
				}
				if (cntO > 1 || cntX === numNouns) return -1;
			}

			// a) Trigger: If a row has one 'O', enter 'X' for the other columns in that row.
			for (let irow = 0; irow < numNouns; irow++) {
				let noun1 = nouns1[irow];
				let cntO = 0;
				for (let icol = 0; icol < numNouns; icol++) {
					if (grid[irow][icol] === Verb.Is) ++cntO;
				}
				if (cntO === 1) {
					for (let icol = 0; icol < numNouns; icol++) {
						let noun2 = nouns2[icol];
						if (grid[irow][icol] !== Verb.Maybe) continue;
						grid[irow][icol] = Verb.IsNot;
						let msg = "Only one of each noun in list2 can be with one of each noun in list1.";
						//console.log(msg);
						rs = solver.addMarkByRule(mark, rule, 'a', noun1, Verb.IsNot, noun2, msg);
						if (rs !== 0) return rs;
					}
				}
			}

			// b) Trigger: If a column has one 'O', enter 'X' for the other rows in that column.
			for (let icol = 0; icol < numNouns; icol++) {
				let noun2 = nouns2[icol];
				let cntO = 0;
				for (let irow = 0; irow < numNouns; irow++) {
					if (grid[irow][icol] === Verb.Is) ++cntO;
				}
				if (cntO === 1) {
					for (let irow = 0; irow < numNouns; irow++) {
						let noun1 = nouns1[irow];
						if (grid[irow][icol] !== Verb.Maybe) continue;
						grid[irow][icol] = Verb.IsNot;
						let msg = "Only one of each noun in list1 can be with one of each noun in list2.";
						//console.log(msg);
						rs = solver.addMarkByRule(mark, rule, 'b', noun1, Verb.IsNot, noun2, msg);
						if (rs !== 0) return rs;
					}
				}
			}

			// The following counts ('c' and 'd') are for each verb where 0=IsNot, 1=Is, 2=Maybe.
			// To enter 'O', the counts must indicate all but one 'X', no 'O', and one '?'

			// c) Trigger: If a row has all 'X' except one '?', enter 'O' for the '?'.
			for (let irow = 0; irow < numNouns; irow++) {
				let noun1 = nouns1[irow];
				let i = -1;
				let cnts = [0, 0, 0];
				for (let icol = 0; icol < numNouns; icol++) {
					let verb = grid[irow][icol];
					cnts[verb.num] += 1;
					if (verb.num === 2) i = icol;
				}
				if (cnts[0] === numNouns - 1 && cnts[1] === 0 && cnts[2] === 1) {
					grid[irow][i] = Verb.Is;
					let noun2 = nouns2[i];
					let msg = "Only one noun in list2 is available for noun1.";
					//console.log(msg);
					rs = solver.addMarkByRule(mark, rule, 'c', noun1, Verb.Is, noun2, msg);
					if (rs !== 0) return rs;
				}
			}

			// d) Trigger: if a column has all 'X' except one '?', enter 'O' for the '?'.
			for (let icol = 0; icol < numNouns; icol++) {
				let noun2 = nouns2[icol];
				let i = -1;
				let cnts = [0, 0, 0];
				for (let irow = 0; irow < numNouns; irow++) {
					let verb = grid[irow][icol];
					cnts[verb.num] += 1;
					if (verb.num === 2) i = irow;
				}
				if (cnts[0] === numNouns - 1 && cnts[1] === 0 && cnts[2] === 1) {
					grid[i][icol] = Verb.Is;
					let noun1 = nouns1[i];
					let msg = "Only one noun in list1 is available for noun2.";
					//console.log(msg);
					rs = solver.addMarkByRule(mark, rule, 'd', noun1, Verb.Is, noun2, msg);
					if (rs !== 0) return rs;
				}
			}

			//printGrid();
			return rs;
		}
		return matchOneToOne;
	},

	// #endregion

	// #region 5. matchOneList.

	/**
	 * Enforces rule where nouns in nouns1 must be with one list of nouns in array2.
	 * See: "Overdue", "Playing Cards".
 	 * @param {Solver} solver Solver object.
	 * @param {Rule} rule Rule.
	 * @param {Noun[]} nouns1 Array of nouns.
	 * @param {Noun[][]} array2 2D array of nouns.
	 * @returns {Function} Function matchOneList.
	 */
	getMatchOneList(solver, rule, nouns1, array2) {

		// Returns the zero-based index of the list that the given noun is in, otherwise -1.
		function getListIndex(nounX, lists) {
			let rs = -1;
			let idx = rs;
			for (let list of lists) {
				++idx;
				for (let noun of list) {
					if (noun === nounX) return idx;
				}
			}
			return rs;
		}

		// Returns true if there is coverage (or nothing to do), otherwise false for no coverage.
		function hasCoverage(nouns1, nouns2) {
			let rs = true;
			const n = nouns1.length;

			// Find unique nouns in nouns2 that can be with the nouns in nouns1.
			let nouns = [];
			let nbad = 0;
			for (let noun1 of nouns1) {
				let cnt = 0;
				for (let noun2 of nouns2) {
					let verb = solver.getGridVerb(noun1, noun2);
					if (verb === Verb.Is) return rs;
					if (verb === Verb.IsNot) continue;
					++cnt;
					if (!nouns.includes(noun2)) nouns.push(noun2);
				}
				if (cnt === 0) ++nbad;
			}

			rs = (nouns.length === 0 || nbad === n) || (nouns.length >= n && nbad === 0);
			return rs;
		}

		function matchOneList(mark) {
			let rs = 0;

			// Trigger if a noun1 is with a noun2 in one of the lists of array2, then the other nouns in nouns1 are not with any nouns in the other lists.
			// Example: If Wicks is with a Wednesday, then Jones is not with a Thursday.
			if (mark.verb === Verb.Is) {
				let nounX1 = null;
				let nounX2 = null;
				let idx2 = -1;
				for (let noun of nouns1) {
					if (mark.noun1 === noun) {
						nounX1 = mark.noun1;
						nounX2 = mark.noun2;
						idx2 = getListIndex(nounX2, array2);
					}
					else if (mark.noun2 === noun) {
						nounX1 = mark.noun2;
						nounX2 = mark.noun1;
						idx2 = getListIndex(nounX2, array2);
					}
					if (idx2 > -1) break;
				}

				// The other nouns in nouns1 are not with any nouns in the other lists.
				if (idx2 > -1) {
					//console.log("matchOneList: noun1 " + nounX1 + " is in list[" + idx2 + "].");
					let idx = -1;
					for (let list2 of array2) {
						if (++idx === idx2) continue;
						for (let noun2 of list2) {
							if (noun2 === nounX2) continue;
							for (let noun1 of nouns1) {
								if (noun1 === nounX1) continue;
								let msg = noun1.name + " is not with " + noun2.name + ".";
								rs = solver.addMarkByRule(mark, rule, 'a', noun1, Verb.IsNot, noun2, msg);
								if (rs !== 0) return rs;
							}
						}
					}
				}
			}

			// Trigger for each nouns2 in array2, if there are not enough nouns in nouns2 to cover nouns1, then the nouns in nouns2 are not with the nouns in nouns1.
			for (let nouns2 of array2) {
				if (hasCoverage(nouns1, nouns2)) continue;
				for (let noun1 of nouns1) {
					for (let noun2 of nouns2) {
						let msg = noun1.name + " is not with " + noun2.name + ".";
						rs = solver.addMarkByRule(mark, rule, 'a', noun1, Verb.IsNot, noun2, msg);
						if (rs !== 0) return rs;
					}
				}
			}
			return rs;
		}
		return matchOneList;
	},

	// #endregion

	// #region 6. isNotBetween.

	/**
	 * Enforces rule where noun1 is not between noun2 and noun3, where any two nouns may be slots.
	 * Assumes slots are ordered by number (either low to high or high to low). See: "All Tired Out".
  	 * @param {Solver} solver Solver object.
	 * @param {Rule} rule Rule.
	 * @param {NounType} nounType Noun type.
	 * @param {Noun} noun1 Noun 1.
	 * @param {Noun} noun2 Noun 2.
	 * @param {Noun} noun3 Noun 3.
	 * @returns {Function} Function isNotBetween.
	 */
	getIsNotBetween(solver, rule, nounType, noun1, noun2, noun3) {
		function isNotBetween(mark) {
			console.log(`isNotBetween mark=${mark.num} nounType.num=${nounType.num} noun1="${noun1}" noun2="${noun2}" noun3="${noun3}"`);
			let rs = 0;

			// Use one-based numbers for each slot.
			const slotA = (noun1.type === nounType) ? noun1.num : noun1.getPairNounNum(nounType);
			const slotB = (noun2.type === nounType) ? noun2.num : noun2.getPairNounNum(nounType);
			const slotC = (noun3.type === nounType) ? noun3.num : noun3.getPairNounNum(nounType);

			// Violation if nounA is between nounB and nounC.
			if (slotA > 0 && slotB > 0 && slotC > 0) {
				if (slotB < slotA && slotA < slotC) return -1;
				if (slotC < slotA && slotA < slotB) return -1;
				return rs;
			}

			// Invoke trigger if two of the slots are known.
			const n = nounType.nouns.length;

			let ch = ' ';
			let noun = null;
			let i1 = 0, i2 = 0;

			// a) A < B so C is not less than A.
			if (slotA > 0 && slotB > slotA) {
				ch = 'a'; noun = noun3; i1 = 0; i2 = slotA - 1;
			}
			// b) B < A so C is not more than A.
			if (slotB > 0 && slotA > slotB) {
				ch = 'b'; noun = noun3; i1 = slotA; i2 = n;
			}
				// c) A < C so B is not less than A.
			else if (slotA > 0 && slotC > slotA) {
				ch = 'c'; noun = noun2; i1 = 0; i2 = slotA - 1;
			}
				// d) C < A so B is not more than A.
			else if (slotC > 0 && slotA > slotC) {
				ch = 'd'; noun = noun2; i1 = slotA; i2 = n;
			}
				// e) B < C so A is not between B and C.
			else if (slotB > 0 && slotC > slotB) {
				ch = 'e'; noun = noun1; i1 = slotB; i2 = slotC - 1;
			}
				// f) C < B so A is not between C and B.
			else if (slotC > 0 && slotB > slotC) {
				ch = 'f'; noun = noun1; i1 = slotC; i2 = slotB - 1;
			}

			let msg = noun1.name + " is not between " + noun2.name + " and " + noun3.name + ".";
			for (let i = i1; i < i2; i++) {
				let slot = nounType.nouns[i];
				if (solver.getGridVerb(noun, slot) === Verb.IsNot) continue;
				//console.log(noun.name + " is not with " + slot.name);
				rs = solver.addMarkByRule(mark, rule, ch, noun, Verb.IsNot, slot, msg);
				if (rs !== 0) return rs;
			}

			return rs;
		}
		return isNotBetween;
	},

	// #endregion

	// #region 7. isRelated.

	/**
	 * Enforces rule where noun1 is related to at least one noun in nouns2. See: "All Tired Out".
 	 * @param {Solver} solver Solver object.
	 * @param {Rule} rule Rule.
	 * @param {Noun} noun1 Noun 1.
	 * @param {Link} link Link.
	 * @param {Noun[]} nouns2 Array of nouns.
	 * @returns {Function} Function isRelated.
	 */
	getIsRelated(solver, rule, noun1, link, nouns2) {
		function isRelated(mark) {
			console.log(`isRelated rule.num=${rule.num} noun1="${noun1}" link="${link}" nouns2="${nouns2}"`);
			let rs = 0;
			const slots = link.nounType;
			const slot1 = (noun1.type === slots) ? noun1 : noun1.getPairNoun(slots);

			if (slot1 !== null) {
				let nounB, slotB;
				let ok;

				// Violation if all nouns are slotted and noun1 is not related to any noun in nouns2.
				ok = false;
				for (let noun2 of nouns2) {
					let slot = (noun2.type === slots) ? noun2 : noun2.getPairNoun(slots);
					if (slot === null || link.getVerb(slot1, slot) === Verb.Is) { ok = true; break; }
				}
				if (!ok) return -1;

				// Violation if all slots related to noun1 are full, and no slot contains a noun in the list.
				// In "All Tired Out", rule 2 says "Grace stood next to at least one man in line (clue 7)."
				// If Grace is 1st and a woman is 2nd, then this is a violation.
				ok = false;
				for (let slot of slots.nouns) {
					if (link.getVerb(slot1, slot) !== Verb.Is) continue;
					for (let noun of nouns2) {
						nounB = slot.getPairNoun(noun.type);
						if (nounB === null || nounB === noun) { ok = true; break; }
					}
					if (ok) break;
				}
				if (!ok) return -1;

				// Violation if all slots related to noun1 cannot have any noun in nouns2.
				ok = false;
				for (let slot of slots.nouns) {
					if (link.getVerb(slot1, slot) !== Verb.Is) continue;
					for (let noun of nouns2) {
						if (solver.getGridVerb(slot, noun) !== Verb.IsNot) { ok = true; break; }
					}
					if (ok) break;
				}
				if (!ok) return -1;

				// Trigger if only one noun in list can be related to noun1, then place it.
				// Example: If I manually place Grace first and Ethan fifth, then Jeff must be second!
				nounB = null; slotB = null;
				let cnt = 0;
				for (let slot of slots.nouns) {
					if (link.getVerb(slot1, slot) !== Verb.Is) continue;
					for (let noun of nouns2) {
						let slotX = noun.getPairNoun(slots);
						if (slotX === slot) {
							//console.log(noun.name + " is already in " + slot.name);
							cnt = 2;
							break;
						}
						if (slotX !== null) continue;

						if (solver.getGridVerb(noun, slot) === Verb.Maybe) {
							//console.log(noun.name + " may be in " + slot.name);
							if (++cnt > 1) break;
							nounB = noun; slotB = slot;
						}
					}
					if (cnt > 1) break;
				}
				//console.log("cnt=" + cnt);
				if (cnt === 1) {
					let msg = nounB.name + " must be with " + slotB.name + ".";
					//console.log("Rule " + rule.num + " " + msg);
					rs = solver.addMarkByRule(mark, rule, 'a', nounB, Verb.Is, slotB, msg);
					if (rs !== 0) return rs;
				}
			}

			// Trigger if noun1 can be in slotX, but no noun in list can be related to slotX, then noun1 cannot be in slotX.
			if (slot1 === null) {
				for (let slotX of slots.nouns) {
					if (solver.getGridVerb(noun1, slotX) !== Verb.Maybe) continue;
					let ok = false;
					let msg = noun1.name + " is not with " + slotX.name + ".";
					for (let slot2 of slots.nouns) {
						if (link.getVerb(slotX, slot2) !== Verb.Is) continue;
						for (let noun2 of nouns2) {
							if (solver.getGridVerb(noun2, slot2) !== Verb.IsNot) {
								ok = true;
								break;
							}
						}
						if (ok) break;
					}
					if (!ok) {
						//console.log("SmartRule.isRelated Rule " + rule.num + " on mark " + mark.num + ". " + msg);
						rs = solver.addMarkByRule(mark, rule, 'b', noun1, Verb.IsNot, slotX, msg);
						if (rs !== 0) return rs;
					}
				}
			}

			return rs;
		}
		return isRelated;
	},

	// #endregion

	// #region 8. inOppositeGroup.

	/**
	 * Enforces rule where noun1 and noun2 are not in same group. See: "Big 5 Game Rangers".
  	 * @param {Solver} solver Solver object.
	 * @param {Rule} rule Rule.
	 * @param {Noun} noun1 Noun 1.
	 * @param {Noun} noun2 Noun 2.
	 * @param {NounType} nounType Noun type.
	 * @param {number[]} map Array of numbers.
	 * @param {string} groupName Group name.
	 * @param {string[]} groupNames Array of group names.
	 * @returns {Function} Function inOppositeGroup.
	 */
	getInOppositeGroup(solver, rule, noun1, noun2, nounType, map, groupName, groupNames) {
		function inOppositeGroup(mark) {
			let rs = 0;

			const nounA = (noun1.type === nounType) ? noun1 : noun1.getPairNoun(nounType);
			const nounB = (noun2.type === nounType) ? noun2 : noun2.getPairNoun(nounType);
			if (nounA === null && nounB === null) return rs;

			const g1 = (nounA === null) ? -1 : map[nounA.num - 1];
			const g2 = (nounB === null) ? -1 : map[nounB.num - 1];

			// Violation if both nouns are in the same group, otherwise success if both are in opposite groups.
			if (nounA !== null && nounB !== null) {
				return (g1 === g2) ? -1 : 0;
			}

			// Triggers.
			const msg = noun1.name + " and " + noun2.name + " have the opposite " + groupName + ".";
			for (let noun of nounType.nouns) {
				// If noun1's group is known, then noun2 is not with a noun of that group.
				if (nounA !== null && map[noun.num - 1] === g1) {
					//console.log(msg);
					rs = solver.addMarkByRule(mark, rule, 'a', noun2, Verb.IsNot, noun, msg);
					if (rs !== 0) return rs;
				}
				// If noun2's group is known, then noun1 is not with a noun of that group.
				if (nounB !== null && map[noun.num - 1] === g2) {
					//console.log(msg);
					rs = solver.addMarkByRule(mark, rule, 'b', noun1, Verb.IsNot, noun, msg);
					if (rs !== 0) return rs;
				}
			}

			return rs;
		}
		return inOppositeGroup;
	},

	// #endregion

	// #region 9. inSameGroup.

	/**
	 * Enforces rule where noun1 and noun2 are in same group. See: "Big 5 Game Rangers".
 	 * @param {Solver} solver Solver object.
	 * @param {Rule} rule Rule.
	 * @param {Noun} noun1 Noun 1.
	 * @param {Noun} noun2 Noun 2.
	 * @param {NounType} nounType Noun Type.
	 * @param {number[]} map Array of numbers.
	 * @param {string} groupName Group name.
	 * @param {string[]} groupNames Array of group names.
	 * @returns {Function} Function inSameGroup.
	 */
	getInSameGroup(solver, rule, noun1, noun2, nounType, map, groupName, groupNames) {

		// If there are not at least two nouns in each list, then
		// (a) The nouns in list1 are not with noun1, and
		// (b) The nouns in list2 are not with noun2.
		function doListEliminator2(mark, rule, noun1, noun2, list1, list2, msg) {
			let rs = 0;
			for (let noun of list1) {
				rs = solver.addMarkByRule(mark, rule, 'a', noun1, Verb.IsNot, noun, msg);
				if (rs !== 0) return rs;
			}
			for (let noun of list2) {
				rs = solver.addMarkByRule(mark, rule, 'b', noun2, Verb.IsNot, noun, msg);
				if (rs !== 0) return rs;
			}
			return rs;
		}

		function inSameGroup(mark) {
			let rs = 0;

			const nounA = (noun1.type === nounType) ? noun1 : noun1.getPairNoun(nounType);
			const nounB = (noun2.type === nounType) ? noun2 : noun2.getPairNoun(nounType);

			const g1 = (nounA === null) ? -1 : map[nounA.num - 1];
			const g2 = (nounB === null) ? -1 : map[nounB.num - 1];

			// Violation if both nouns are in opposite groups, otherwise success if both in same group.
			if (nounA !== null && nounB !== null) {
				return (g1 !== g2) ? -1 : 0;
			}

			// Triggers.
			let msg = noun1.name + " and " + noun2.name + " have the same " + groupName + ".";
			// If noun1's group is known, then noun2 is not with a noun of another group.
			if (nounA !== null && nounB === null) {
				for (let noun of nounType.nouns) {
					if (map[noun.num - 1] === g1) continue;
					//console.log(msg);
					rs = solver.addMarkByRule(mark, rule, 'a', noun2, Verb.IsNot, noun, msg);
					if (rs !== 0) return rs;
				}
			}

			// If noun2's group is known, then noun1 is not with a noun of another group.
			if (nounA === null && nounB !== null) {
				for (let noun of nounType.nouns) {
					if (map[noun.num - 1] === g2) continue;
					//console.log(msg);
					rs = solver.addMarkByRule(mark, rule, 'b', noun1, Verb.IsNot, noun, msg);
					if (rs !== 0) return rs;
				}
			}

			// Examine counts if there are only two groups.
			if (nounA !== null || nounB !== null) return rs;

			// Example is from Big5GameRangers.
			// * Elephant camp can be run by Ethan or Julia.
			// * Buffalo camp can be run by Delia, Ethan, or Julia.
			// If there are less than two nouns in a group, then those nouns are not possible candidates.

			let group1 = []; let group1Noun1 = []; let group1Noun2 = [];
			let group2 = []; let group2Noun1 = []; let group2Noun2 = [];

			// Populate the lists.
			for (let noun of nounType.nouns) {
				let i = noun.num - 1;
				let verb1 = solver.getGridVerb(noun, noun1);
				if (verb1 === Verb.Maybe) {
					if (map[i] === 0) group1Noun1.push(noun); else group2Noun1.push(noun);
				}
				let verb2 = solver.getGridVerb(noun, noun2);
				if (verb2 === Verb.Maybe) {
					if (map[i] === 0) group1Noun2.push(noun); else group2Noun2.push(noun);
				}
				if (verb1 === Verb.Maybe || verb2 === Verb.Maybe) {
					if (map[i] === 0) group1.push(noun); else group2.push(noun);
				}
			}

			//console.log(mark.num + " Group 1: " + group1.length + ","  + group1Noun1.length + "," + group1Noun2.length + " Group 2: " + group2.length + "," + group2Noun1.length + "," + group2Noun2.length);

			if ((group1.length < 2 || group1Noun1.length < 1 || group1Noun2.length < 1) && group1.length > 0) {
				msg = "There are not enough " + groupNames[0] + " for " + noun1.name + " and " + noun2.name + ".";
				rs = doListEliminator2(mark, rule, noun1, noun2, group1Noun1, group1Noun2, msg);
				if (rs !== 0) return rs;
			}

			if ((group2.length < 2 || group2Noun1.length < 1 || group2Noun2.length < 1) && group2.length > 0) {
				msg = "There are not enough " + groupNames[1] + " for " + noun1.name + " and " + noun2.name + ".";
				rs = doListEliminator2(mark, rule, noun1, noun2, group2Noun1, group2Noun2, msg);
				if (rs !== 0) return rs;
			}
			return rs;
		}
		return inSameGroup;
	}

	// #endregion
};

Conclusion

I hope you enjoyed reading this article. My motivation for writing this article is that you will try to model a logic puzzle on your own. Then together we can find better ways to model and/or solve logic puzzles. Thank you.

About the Author

Michael Benson Michael Benson

My current position is zookeeper of the Mystery Master website at http://www.mysterymaster.com. My favorite languages are C#, Java, and JavaScript. When I'm not at my computer, I am travelling far more than I like to places far more exciting than I can handle.