Board and party games
Millbury Mansion
Solve the death of Mr. Millbury in his haunted house (who, with what, where) by asking round and keeping a detective’s notebook.
On this page
Millbury Mansion is a murder-mystery deduction board game for three to six players, with bots to fill any seat. One suspect, one heirloom and one room are sealed in a case file; the rest of the cards are dealt round. Players walk a haunted mansion’s floor plan, suggest who did it in the room they’re in, and see one card from the first player round to their left who can show one. The first to accuse rightly wins.
It plays the classic US rules of the family detective game, with David’s calls: two dice, leftover cards face up, a wrong accuser out but still showing, the called suspect pulled into the room, and a host option for Quick moves. The cast, heirlooms, rooms, floor plan and art are the site’s own. docs/millbury-mansion.md has the rules as played, their sources and the open questions; ADR 0057 says why the notebook keeps facts and leaves the reasoning to you.
It’s a table game on the table service, with a room-kit table in the browser (the front end) and ratings for kept games (Ratings).
At a glance#
| Part | Where |
|---|---|
| Cards, floor plan, doors, passages, moving | tables/millbury_mansion/board.py |
| Rules: the deal, the cast, a turn, suggestions, showing, accusations | tables/millbury_mansion/rules.py |
| Bots (easy, medium, hard) and their notebooks | tables/millbury_mansion/bots.py |
| The table | games/static/games/millbury-mansion/table.js |
| The floor plan | games/static/games/millbury-mansion/board.js, games/static/games/millbury-mansion/data.js |
| The notebook and its reasoning | games/static/games/millbury-mansion/notebook.js, games/static/games/millbury-mansion/deduce.js |
| The cast’s cameos | games/static/games/millbury-mansion/cast.js |
| Room kit entry, How to play | games/static/games/millbury-mansion/app.js, games/static/games/millbury-mansion/rules.js |
| Look | games/static/games/millbury-mansion/millbury-mansion.css, games/static/games/millbury-mansion/table.css |
| Pages | games/templates/games/millbury-mansion.html, games/templates/millbury-mansion/page.html |
| Tests | tests/test_millbury_mansion.py, tools/millbury-mansion/deduce.test.mjs, tools/millbury-mansion/play.mjs |
| Design notes | docs/millbury-mansion.md |
The floor plan#
The plan is a square grid, 24 squares a side. Nine rooms sit round the walls and the marble hall in the middle; every other square is corridor. A room is a rectangle of squares, and a door is the corridor square just outside a gap in its wall:
tables/millbury_mansion/board.py DOORS · lines 42–53# Doors: the room, and the corridor square just outside its gap in the wall.
DOORS = (
("conservatory", (6, 3)),
("ballroom", (7, 4)), ("ballroom", (10, 6)), ("ballroom", (13, 6)),
("kitchen", (20, 6)),
("seance", (6, 9)),
("library", (6, 15)), ("library", (3, 12)),
("dining", (16, 10)), ("dining", (16, 13)), ("dining", (20, 7)),
("study", (4, 18)),
("gallery", (11, 17)), ("gallery", (16, 20)),
("crypt", (17, 20)),
)
Moving is a breadth-first walk from the pawn’s square (or from each of its room’s doors, a step out), never through a square another pawn stands on, up to the roll. A room is entered from the square outside its door, which ends the move, and the room you started in can’t be entered again:
tables/millbury_mansion/board.py reach · lines 116–156def reach(start, steps: int, blocked) -> dict:
"""Where a pawn can go with a roll of `steps`, moving up to that many
squares: {key: path}. `start` is ("room", name) or ("cell", (x, y)).
`blocked` holds the corridor squares other pawns stand on, which no one
lands on or passes through. A room is entered through a door, and ends
the move; the room a pawn starts in can't be entered again this turn.
A key is "room:<name>" or "x,y"; a path lists the squares walked, and
the room last if it ends in one."""
out = {}
dist = {}
prev = {}
todo = deque()
if start[0] == "room":
for cell in DOORS_OF[start[1]]:
if cell not in blocked and cell not in dist:
dist[cell] = 1
prev[cell] = None
todo.append(cell)
home = start[1]
else:
dist[start[1]] = 0
prev[start[1]] = None
todo.append(start[1])
home = None
while todo:
c = todo.popleft()
d = dist[c]
if d < steps:
for n in neighbors(c):
if n not in dist and n not in blocked:
dist[n] = d + 1
prev[n] = c
todo.append(n)
for r in ROOM_AT_DOOR.get(c, ()):
key = f"room:{r}"
if r != home and key not in out:
out[key] = _path(prev, c) + [r]
for c, d in dist.items():
if d >= 1:
out[f"{c[0]},{c[1]}"] = _path(prev, c)
return out
Each destination comes with its path, so the page walks the pawn square by square. The page draws the same plan from games/static/games/millbury-mansion/data.js, which has to be kept in step with board.py.
Its words on a phone#
The plan is one SVG drawn 624 units a side and scaled to its room, so a word set at 13 units is 13px on a computer but about 7px on a phone. The board’s words (the room plates, the passages’ tabs, the pawns’ and start squares’ initials) are sized from how big the plan is drawn instead: a ResizeObserver on the plan, and its faces loading, call fit(), which sets each at the bigger of its own size and 12.75px on screen, and no bigger than its room on the plan allows:
games/static/games/millbury-mansion/board.js wordSizes · lines 260–297/** How every word on the plan is set, for a plan drawn `k` px to the unit. */
export function wordSizes(k) {
const least = FLOOR / k;
// (a computer's plan, drawn at about 1px to the unit or more, keeps its pawns and marks as drawn)
const grown = 12 * k < 12.1;
// (room to breathe: 4px between a word and its box's edge, in units)
const pad = Math.max(6, 4.25 / k);
// The plates: one size, as large as the floor or the tightest room allows.
const plates = (face, ls) => {
let size = least;
for (const r of [...ROOMS, 'hall']) {
const { l, r: right } = roomBox(r);
size = Math.min(size, (right - l - 2 - 2 * pad) / ems(r === 'hall' ? 'The case file' : NAMES[r], face));
}
return { face, size, ls };
};
let plate = { face: SC, size: 13, ls: 0.5 };
if (least > 13) {
plate = plates(SC, 0);
if (plate.size * k < LEAST) plate = plates(ITALIC, 0);
}
// The passages' tabs: from the corner mark along the room's outer wall,
// as far as the room goes. A name too long for that (the Crypt's
// "Conservatory", on a small plan) takes the wall's whole width, and its
// mark stands just inside it.
const mark = grown ? { at: 20, r: 11 } : { at: 24, r: 12 };
const fits = Object.fromEntries(Object.keys(PASSAGE_SPOTS).map(room => {
const { l, r } = roomBox(room), em = ems(NAMES[PASSAGES[room]], ITALIC);
return [room, { beside: (r - l - 1 - mark.at - mark.r - 2 * pad) / em, stacked: (r - l - 2 - 2 * pad) / em }];
}));
const want = Math.max(12, least);
const tab = Math.max(12, Math.min(...Object.values(fits).map(f => Math.min(want, f.beside >= want ? want : Math.max(f.beside, f.stacked)))));
const stacked = Object.keys(fits).filter(room => fits[room].beside < tab - 0.01);
// A pawn grows round its initial (but no wider than a square and a bit, so neighbors don't cover each other).
const pawnR = grown ? Math.max(12, Math.min(14, least * 0.5)) : 12;
const pawn = grown ? Math.min(least, (pawnR - 0.5) / 0.47) : 12;
return { k, pad, plate, tab, stacked, mark, pawn, pawnR, start: grown ? Math.min(least, 28) : 12, faceup: Math.max(12, least) };
}
- The plates take one size together, so alike things stay alike, grown from their own middles toward the room’s and as wide as the room at most. Where the small caps can’t reach the floor in the tightest room (the Conservatory, on a 320px phone or at 375 wide), every plate is set in IM Fell’s italic, which is narrower. A face-up room’s tag sits on the plate’s room side.
- A passage’s tab names the room it leads to beside the corner’s mark (a stair going down, in a brass ring), in ghost mint on a tab with a dashed mint edge, so it never reads as the room’s own name on its brass-edged plate. A name too long to fit beside it (the Crypt’s “Conservatory” on a small plan) takes the outer wall’s whole width, with the mark just inside it. Tabs keep off the outer wall, which the brass plate overhangs at the plan’s foot.
- A pawn grows round its initial, up to a little more than a square, on a deeper face of its color so the initial reads at 4.5:1. A start square’s initial isn’t drawn while a pawn stands on it.
The floor has a little to spare because Chrome measures IM Fell’s lines about 3% short of their size. With it, every word on the plan is at least 12px at 390, 375 and 320 wide and on a phone on its side; on a computer the plan is drawn as it always was.
The rules in code#
The state#
tables/millbury_mansion/rules.py new · lines 54–79def new(players: int, seed: str, now: float, settings: dict | None = None) -> dict:
if not min_players <= players <= max_players:
raise Illegal("Millbury Mansion is for three to six players.")
rules = options(settings)
rng = random.Random(f"{seed}:case")
case = [rng.choice(SUSPECTS), rng.choice(WEAPONS), rng.choice(ROOMS)]
rest = deck.shuffled([c for c in CARDS if c not in case], f"{seed}:deal")
each = len(rest) // players
hands = [sorted(rest[s * each:(s + 1) * each], key=CARDS.index) for s in range(players)]
faceup = sorted(rest[each * players:], key=CARDS.index)
homes = deck.shuffled(list(ROOMS), f"{seed}:heirlooms")
state = {
"game": slug, "n": players, "seed": seed, "rules": {"quick": rules["quick"]},
"_pace": moves.BOT_PACE[rules["bot_pace"]],
"seq": 0, "logged": 0, "events": [],
"case": case, "hands": hands, "faceup": faceup,
"cast": [None] * players,
"at": {s: {"cell": list(STARTS[s])} for s in SUSPECTS},
"heirlooms": {w: homes[i] for i, w in enumerate(WEAPONS)},
"phase": "cast", "turn": None, "step": None, "turns": 0, "rolls": 0, "dice": None, "reach": None,
"entered": False, "summoned": [False] * players, "from_room": None,
"ledger": [], "showing": None, "out": [False] * players, "accusations": [],
"winner": None, "scores": [], "totals": [0] * players, "ready": [False] * players,
}
log.event(state, "deal", each=each, faceup=faceup)
return state
case is the sealed three; hands each seat’s cards; faceup the cards that didn’t split evenly. at is where every suspect’s pawn is (a square, or a room), heirlooms which room each token is in. ledger keeps every suggestion and each player’s answer in turn: the card shown is kept here, and only the two who saw it are ever told which it was.
A turn#
The game starts in the cast phase: everyone picks a suspect (the room kit’s waiting room keeps no choice per player, so this is the game’s first moment). Then turn runs in steps: start (roll, take a passage, with Quick moves go to a room, or suggest if you were pulled into this room), walk (choose where the roll reaches), suggest (in a room you’ve just come into) and after (accuse or end). A suggestion pulls in the suspect and heirloom and then asks round to the left:
tables/millbury_mansion/rules.py _ask_round · lines 263–282def _ask_round(state, entry, seat):
"""Round to the asker's left: each says they have none, until the first
who holds any of the three shows one (themselves choosing, with two or
three)."""
asker = entry["asker"]
while seat != asker:
held = _holds(state, seat, entry)
if not held:
entry["answers"].append({"seat": seat, "card": None})
log.event(state, "pass", seat=seat, asker=asker, n=entry["n"])
seat = (seat + 1) % state["n"]
continue
if len(held) == 1:
return _show(state, entry, seat, held[0])
state["phase"] = "show"
state["showing"] = {"seat": seat, "asker": asker, "n": entry["n"]}
return
entry["done"] = True
log.event(state, "unanswered", seat=asker, n=entry["n"])
state["step"] = "after"
With two or three of the cards, the game waits in the show phase for that player to choose; with one, it’s shown at once. A player who’s away doesn’t hold the table up: away() shows for them (a card they’ve shown that asker before, if there is one), and picks them a suspect if they hadn’t. An accusation can be made at any step of your turn. Wrong, you’re out (your turns are skipped but you’re still asked) and a line to you alone holds the case file. The last one in the running wins.
The end#
tables/millbury_mansion/rules.py _finish · lines 347–359def _finish(state, winner, by_default=False):
n = state["n"]
state["phase"] = "over"
state["winner"] = winner
state["step"] = None
state["showing"] = None
state["reach"] = None
log.event(state, "over", winner=winner, case=list(state["case"]), by_default=by_default)
# The solver first; everyone still in the running next; those who
# accused wrong last.
points = [2 if s == winner else 0 if state["out"][s] else 1 for s in range(n)]
rounds.score(state, points)
rounds.end(state, last=True)
Places come from points: the solver first, those still in the running next, wrong accusers last. A game that reaches the turn ceiling ends unsolved.
What each seat sees#
tables/millbury_mansion/rules.py _entry · lines 451–456def _entry(state, e, seat, over_):
out = {k: e[k] for k in ("n", "asker", "suspect", "weapon", "room", "done")}
out["answers"] = [{"seat": a["seat"], "had": a["card"] is not None,
**({"card": a["card"]} if a["card"] is not None and (over_ or seat in (a["seat"], e["asker"])) else {})}
for a in e["answers"]]
return out
A seat’s view has its own hand, the face-up cards, every pawn and heirloom, how many cards each player holds, and the ledger with each answer’s card only for the asker and the one who showed it. The case file is in the view of a seat that’s accused wrong, and everyone’s once the game’s over (with every hand). A spectator sees the public ledger. The contract test scrambles every other hand, the case file and every card a seat didn’t see, and checks the view and a bot’s move don’t change.
The bots#
Every bot keeps a notebook from its own view: for each card, who could hold it (a seat or the case file).
tables/millbury_mansion/bots.py Notebook.def propagate(self):
counting = self.level == "hard"
changed = True
while changed:
changed = False
# one owner a card
for c, p in self.poss.items():
if len(p) == 1:
(o,) = p
if o == ENVELOPE:
for d in KINDS[KIND[c]]:
if d != c and d in self.poss and ENVELOPE in self.poss[d]:
self.poss[d].discard(ENVELOPE)
changed = True
# someone showed one of three: if only one is still possible, it's that
for seat, trio in self.hasone:
maybe = [c for c in trio if c in self.poss and seat in self.poss[c]]
if len(maybe) == 1 and self.poss[maybe[0]] != {seat}:
self.poss[maybe[0]] = {seat}
changed = True
# the case file holds one of each kind
for kind, cards in KINDS.items():
could = [c for c in cards if c in self.poss and ENVELOPE in self.poss[c]]
if len(could) == 1 and self.poss[could[0]] != {ENVELOPE}:
self.poss[could[0]] = {ENVELOPE}
changed = True
if counting:
for seat in range(self.n):
known = [c for c, p in self.poss.items() if p == {seat}]
maybe = [c for c, p in self.poss.items() if seat in p]
if len(known) >= self.counts[seat]:
for c in maybe:
if self.poss[c] != {seat}:
self.poss[c].discard(seat)
changed = True
elif len(maybe) == self.counts[seat]:
for c in maybe:
if self.poss[c] != {seat}:
self.poss[c] = {seat}
changed = True
Easy writes down only its hand, the face-up cards and the cards shown to it. Medium also crosses off passes, gives a shown trio’s last possible card to whoever showed it, and keeps one of each kind for the file. Hard adds card counts and then searches for deals that fit everything it knows: a card is crossed off a player only when no such deal gives it to them (the search gives up as “could be” after 3000 steps). Every bot accuses only when one suspect, one heirloom and one room are left, so a bot is never wrong.
Moving, a bot heads for the nearest room still in the running (or, once the room’s known, that room or one it holds), suggests the open candidates, and when asked to show, shows a card it’s shown that asker before if it can. Think times are in bot_think; the bots’ fixed ratings are in Rating (Ratings).
The interface#
games/static/games/millbury-mansion/table.js draws the plan beside the side column (seat chips round from your left, you last; the notebook; the Your move card at the foot), or above it on a tall window. On a phone the notebook is a sheet from the bar’s Notebook, and Done puts it away. Others’ moves play one at a time through the room kit’s Cues: the dice, the pawn’s walk, the suspect and heirloom pulled into a room, who answered. A card shown to you lifts over the plan with the ledger’s line writing itself under it, then the notebook inks in and glows. Your own suspect pulled into a room is the strong announcement; a wrong accusation is announced to everyone. A spectator gets the same table (spectate): the plan, the seats and a notebook of the public facts, with no column of their own.
Moving: the squares and rooms your roll reaches light in the candle gold; a room is a button, and Where to? lists the rooms in reach and, for each one out of reach, the square nearest it. Suggest and Accuse open sheets prefilled from the notebook’s open candidates, with the ruled-out struck through.
The notebook#
games/static/games/millbury-mansion/deduce.js deduce · lines 22–117/** Who could hold each card, and why each was ruled in or out. `name(seat)`
* says a seat the notebook's way ("you", "Ann"). */
export function deduce(v, mode = 'facts', name = s => `Seat ${s + 1}`) {
const me = v.seat;
const n = v.n;
const seats = Array.from({ length: n }, (_, s) => s);
const cards = CARDS.filter(c => !v.faceup.includes(c));
const poss = Object.fromEntries(cards.map(c => [c, new Set([...seats, ENV])]));
const why = {}; // `${card}|${owner}` -> words (for an owner ruled out, or the one owner)
const tags = []; // { n, seat, asker, trio }: a show this seat didn't see
const pass = []; // { n, seat, trio }
const say = (c, o, words) => { const k = `${c}|${o}`; if (!why[k]) why[k] = words; };
const cut = (c, o, words) => { if (poss[c]?.has(o) && poss[c].size > 1) { poss[c].delete(o); say(c, o, words); return true; } return false; };
const fix = (c, o, words) => {
if (!poss[c] || (poss[c].size === 1 && poss[c].has(o))) return false;
for (const x of [...poss[c]]) if (x !== o) { poss[c].delete(x); say(c, x, x === ENV ? `${name(o, true)} ${o === me ? 'have' : 'has'} it, so it isn’t in the case file.` : `${name(o, true)} ${o === me ? 'have' : 'has'} it, and a card has one owner.`); }
poss[c] = new Set([o]);
say(c, o, words);
return true;
};
if (mode === 'blank') return result();
if (me !== null && me !== undefined) {
for (const c of cards) {
if (v.hand.includes(c)) fix(c, me, 'In your hand.');
else cut(c, me, 'Not in your hand.');
}
}
for (const e of v.ledger) {
const trio = [e.suspect, e.weapon, e.room];
for (const a of e.answers) {
if (a.card) {
if (e.asker === me) fix(a.card, a.seat, `${name(a.seat, true)} showed you this on turn ${e.n}.`);
else if (a.seat === me) fix(a.card, a.seat, `In your hand (you showed it to ${name(e.asker)} on turn ${e.n}).`);
} else if (a.had) {
tags.push({ n: e.n, seat: a.seat, asker: e.asker, trio });
} else {
pass.push({ n: e.n, seat: a.seat, trio });
for (const c of trio) cut(c, a.seat, `${name(a.seat, true)} had none of the three on turn ${e.n}.`);
}
}
}
if (mode === 'full') reason();
return result();
function reason() {
const counts = v.counts;
for (let loop = 0; loop < 50; loop++) {
let changed = false;
for (const t of tags) {
const left = t.trio.filter(c => poss[c]?.has(t.seat));
if (left.length === 1 && poss[left[0]].size > 1) {
const others = t.trio.filter(c => c !== left[0]);
changed = fix(left[0], t.seat, `${name(t.seat, true)} showed ${name(t.asker)} a card on turn ${t.n} and has neither ${the(others[0])} nor ${the(others[1])}: so it was ${the(left[0])}.`) || changed;
}
}
for (const kind of Object.keys(KINDS)) {
const could = KINDS[kind].filter(c => poss[c]?.has(ENV));
if (could.length === 1 && poss[could[0]].size > 1) {
const c = could[0];
for (const s of seats) changed = cut(c, s, `It’s the only ${kind === 'suspect' ? 'suspect' : kind === 'weapon' ? 'heirloom' : 'room'} that can be in the case file, so no one holds it.`) || changed;
}
const sealed = KINDS[kind].find(c => poss[c]?.size === 1 && poss[c].has(ENV));
if (sealed) for (const c of KINDS[kind]) if (c !== sealed) changed = cut(c, ENV, `${the(sealed)} is in the case file: one of each kind.`) || changed;
}
for (const s of seats) {
const known = cards.filter(c => poss[c].size === 1 && poss[c].has(s));
const maybe = cards.filter(c => poss[c].has(s));
if (known.length >= counts[s]) {
for (const c of maybe) if (poss[c].size > 1) changed = cut(c, s, `${name(s, true)} ${s === me ? 'hold' : 'holds'} ${counts[s]} cards and all ${counts[s]} are known.`) || changed;
} else if (maybe.length === counts[s]) {
for (const c of maybe) if (poss[c].size > 1) changed = fix(c, s, `${name(s, true)} ${s === me ? 'hold' : 'holds'} ${counts[s]} cards, and only ${counts[s]} are still possible.`) || changed;
}
}
// (a card no player can hold is in the case file)
for (const c of cards) {
if (poss[c].size > 1 && poss[c].has(ENV) && seats.every(s => !poss[c].has(s))) changed = fix(c, ENV, 'No one can hold it.') || changed;
}
if (!changed) break;
}
}
function result() {
const mark = (c, s) => {
if (v.faceup.includes(c)) return 'up';
const p = poss[c];
if (mode === 'blank') return null;
if (p.size === 1 && p.has(s)) return 'has';
if (!p.has(s)) return 'not';
return null;
};
const owner = c => (poss[c] && poss[c].size === 1 ? [...poss[c]][0] : null);
const out = c => v.faceup.includes(c) || (mode !== 'blank' && owner(c) !== null && owner(c) !== ENV) || (mode === 'full' && poss[c] && !poss[c].has(ENV));
const sealed = c => mode === 'full' && owner(c) === ENV;
return { poss, why, tags, pass, mark, owner, out, sealed, mode };
}
}
The notebook is computed in the browser from the seat’s view (ADR 0057). Facts only (the default) inks your hand, the cards shown to you, passes and one owner a card; a show you didn’t see is a numbered tag on that player’s three. Full assist adds the Hard bot’s reasoning without its search. Blank inks nothing. Hint lights one cell Full assist knows and your notebook doesn’t, with why. Pencil marks (Has it, Doesn’t, Maybe) are kept in the browser by table, game and seat. Pressing any cell opens the strip at the notebook’s foot: what’s known there and why, the story of each tag (with its three lit) and the pencil. The Ledger lists every suggestion newest first, with filter chips; a turn’s number jumps back to its clues.
Its look#
Millbury Mansion’s personality, in the design system’s personality sheet.
| Family | Board and party: the room kit in a look of its own. Its foyer is the card room’s felt (Board and party games). |
| Palette | Ink #f1eadb on the midnight-violet damask #15111f; the one fill and yours, candle gold #e9c46a; ghost mint #a8e6cf for the uncanny; the notebook’s green leather #1b3229 and parchment #f4ebd3. The six suspects wear the box’s six colors, never color alone: each pawn carries its initial and each card its name. |
| Faces | IM Fell English and its small caps (titles, the room plates, the notebook’s hand); Alegreya Sans for everything else; Kalam for your pencil |
| Corners and edges | Panels 12px, buttons 10px; the plan in a walnut rim with a brass bead and its plate |
| Board | An illustrated top-down floor plan: corridors of one worn stone throughout (no runners, so no hall looks special) with the start squares marked, a stair mark in each passage’s corner, thick paneled walls with brass-jambed doors, every room furnished (fountain and palms, parquet star and piano, range and copper pots, séance table and crystal ball, bookcases and globe, a table set for ten, desk and fireplace, portraits whose eyes glow, sarcophagus and stone angel), quieter than the pawns, doors and lit room. The hero is the sealed case file between two candles. |
| Emblem and art | Its box on the shelf: the notebook open mid-game, the card just shown, a pawn. The result’s emblem: the case file, opened. |
| Voice | The household, heirlooms, the case file; “Suggest”, “Accuse”, “Ann showed you the Study”, “Mom: no card” |
| Payoff | The case file’s seal breaks and its flap lifts; then the room’s celebration for a win |
| The glance | Now: the Your move card lit when it’s yours, the turn ring on a seat chip. The countdown: the turn in the bar. Yours: your pawn’s gold ring, your notebook column first. |
| Exceptions | On a small plan the room plates may be set in italic rather than small caps, where small caps can’t reach 12px in their rooms (Its words on a phone); the notebook’s rows are 18px on a computer and a finger’s size on a touch screen, where the grid scrolls sideways with its names held. |
Rating#
A kept game is rated by place like every table game (Ratings). Its bots sit at fixed ratings measured with tools/ratings/anchors.py (150 deals, each played both ways round): medium beats easy nearly every time, and hard beats medium only a little, so hard sits just above it:
1160 · 1500 · 1510 (easy, medium, hard).
Testing#
tests/test_millbury_mansion.py holds the rules as sentences (the deal, the cast, moving and doors, passages, Quick moves, suggestions and the showing, accusations, what each seat sees, the bots’ notebooks) and the table contract. tools/millbury-mansion/deduce.test.mjs (npm run check:millbury-mansion) tests the notebook’s reasoning in each way of keeping it, and the hint. tools/millbury-mansion/play.mjs (npm run check:millbury-mansion:play) plays a whole game in headless Chrome against three hard bots with Quick moves: the cast, rooms lit on the plan, the suggest sheet prefilled, the card shown to you inking in, a tag’s story, a pencil mark, showing a card, and the result sheet.
Changing it#
- The plan lives twice:
board.py(the rules) anddata.js(the drawing). Change both, then run the tests: every room must have a door and be reachable from every start. - A rule:
rules.py,docs/millbury-mansion.mdand How to play (games/static/games/millbury-mansion/rules.js) together. - The notebook’s reasoning:
deduce.jsand its test; keep Full assist no stronger than what’s certain, since Hint is built from it.
Known issues#
- Hard plays only a little better than medium at three players (its search finds what card counts imply, but its suggestions are medium’s).