Units draw their real arrangement; suggestions learn the owner's plan
Three requests from Eric plus his reviewer's ranking doctrine. The unit renders its PHYSICAL shape: openings group into rows by their label letters and each cell's width is proportional to its interior width — the Kallax reads as two double-wides spanning the top, then four-across cube rows. A diagram of the wall, not a list. The toggle buttons died: "Add a unit" and "Add an opening" are labeled, always-visible forms — the button that merely revealed the form below it was ceremony. Suggestions got their relevance doctrine, three rules deep: (1) Reunification beats geometry — an opening holding a series-mate or base game (kinship = the normalized pre-colon name stem, plus the series field where enrich has one; no taxonomy invented) ranks first, marked ♥. Every placement the owner confirms teaches the suggester their organization by example; expansions chase their base games automatically during the move. (2) "Fits" means the REMAINING opening: the stack budget already spent is subtracted before offering, so the suggester's honesty holds precisely as shelves fill — the direction it used to degrade. (3) Then tightest verified fit, so a small box is offered cubes and never the oversize row. Nothing matching says "no matching openings yet" instead of offering everything the wall has. One composed test pins the interplay: a full shelf isn't offered even to a series-mate (honesty outranks reunification), and freeing the space flips the same suggestion to ♥-first. 382 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
co-authored by
Claude Fable 5
parent
446579e96c
commit
f4f3877651
@@ -124,6 +124,22 @@ def fits_opening(entry: dict, opening: dict) -> bool | None:
|
||||
)
|
||||
|
||||
|
||||
def family_stem(entry: dict) -> str:
|
||||
"""The taxonomy-free kinship signal: the normalized pre-colon stem of
|
||||
the name ("Castle Panic: The Wizard's Tower" -> "castle panic"). Base
|
||||
games and their expansions share it without anyone defining a series."""
|
||||
name = (entry.get("name") or "").split(":")[0]
|
||||
return normalize_title(name)
|
||||
|
||||
|
||||
def related(entry: dict, other: dict) -> bool:
|
||||
"""Same family stem, or overlapping series field where enrich has one."""
|
||||
if family_stem(entry) and family_stem(entry) == family_stem(other):
|
||||
return True
|
||||
mine = set(entry.get("series") or [])
|
||||
return bool(mine and mine & set(other.get("series") or []))
|
||||
|
||||
|
||||
def stack_thickness(entry: dict) -> float | None:
|
||||
"""A box on a shelf stack contributes its thinnest axis."""
|
||||
dims = game_dims(entry)
|
||||
|
||||
@@ -549,13 +549,21 @@ select {
|
||||
.card.stack { display: block; }
|
||||
.unit .unithead { display: flex; align-items: center; gap: .7rem;
|
||||
justify-content: space-between; flex-wrap: wrap; }
|
||||
.openings { display: grid; gap: .5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); }
|
||||
/* rows mirror the unit's PHYSICAL arrangement: one .orow per label
|
||||
row (A, B, ...), cells sized by flex-grow proportional to interior
|
||||
width — a double-wide reads double wide */
|
||||
.openings { display: flex; flex-direction: column; gap: .5rem;
|
||||
margin-bottom: .8rem; }
|
||||
.orow { display: flex; gap: .5rem; }
|
||||
.formlabel { margin: 1.2rem 0 .35rem; }
|
||||
.formlabel.small { font-weight: 700; font-size: .85rem;
|
||||
color: var(--ink-soft); margin: .8rem 0 .3rem; }
|
||||
button.opening {
|
||||
font: inherit; text-align: left; cursor: pointer;
|
||||
background: #fff; border: 2px solid var(--board-edge);
|
||||
border-radius: var(--radius); padding: .5rem .6rem;
|
||||
display: flex; flex-direction: column; gap: .25rem; min-height: 5.2rem;
|
||||
flex-basis: 0; min-width: 0; overflow: hidden;
|
||||
}
|
||||
button.opening:hover, button.opening:focus-visible {
|
||||
outline: 3px solid var(--accent); }
|
||||
@@ -576,6 +584,7 @@ button.suggest { font: inherit; font-size: .8rem; cursor: pointer;
|
||||
border: 2px solid var(--board-edge); border-radius: 1rem;
|
||||
background: var(--board); padding: .15rem .6rem; }
|
||||
button.suggest:hover { border-color: var(--accent); }
|
||||
button.suggest.reunites { border-color: var(--accent); font-weight: 700; }
|
||||
|
||||
/* the opening sheet: modal on desktop, bottom sheet on phones */
|
||||
#opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<h1>Shelves</h1>
|
||||
<div class="pagebar"><span id="shelfcount"></span>
|
||||
<button id="addunit" aria-expanded="false">add a unit</button>
|
||||
</div>
|
||||
<div class="pagebar"><span id="shelfcount"></span></div>
|
||||
|
||||
<form id="unitform" class="editform card" hidden>
|
||||
<h2 class="formlabel">Add a unit</h2>
|
||||
<form id="unitform" class="editform card">
|
||||
<label>Unit name <input name="name" required placeholder="Living room Kallax"></label>
|
||||
<label>Preset
|
||||
<select name="preset">
|
||||
@@ -22,7 +21,6 @@
|
||||
<label>Zone <input name="zone" placeholder="party games"></label>
|
||||
<span class="editactions">
|
||||
<button type="submit" class="primary">create</button>
|
||||
<button type="button" id="unitcancel">cancel</button>
|
||||
</span>
|
||||
<span class="edithint">grid mode labels openings A1, A2… by row; you can edit,
|
||||
add, or remove openings afterward — repeat with another grid to stack
|
||||
@@ -92,7 +90,8 @@ function openingCell(o) {
|
||||
const count = o.games
|
||||
? `${o.games}${o.unmeasured ? ` · ${o.unmeasured} unmeasured` : ""}`
|
||||
: "—";
|
||||
return `<button class="opening" data-id="${esc(o.id)}">
|
||||
return `<button class="opening" data-id="${esc(o.id)}"
|
||||
style="flex-grow:${Math.round((o.width_in || 13.25) * 4)}">
|
||||
<span class="olabel">${esc(o.label)}${warn}</span>
|
||||
${o.zone ? `<span class="ozone">${esc(o.zone)}</span>` : ""}
|
||||
<span class="ocount">${count}</span>
|
||||
@@ -105,11 +104,11 @@ function unitBlock(u) {
|
||||
return `<section class="card stack unit">
|
||||
<div class="unithead"><h2>${esc(u.name)}</h2>
|
||||
<span class="editactions">
|
||||
<button class="addopening" data-unit="${esc(u.name)}"
|
||||
aria-expanded="false">add opening</button>
|
||||
<button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button>
|
||||
</span></div>
|
||||
<form class="editform addopeningform" data-unit="${esc(u.name)}" hidden>
|
||||
${unitRows(u.openings)}
|
||||
<p class="formlabel small">Add an opening</p>
|
||||
<form class="editform addopeningform" data-unit="${esc(u.name)}">
|
||||
<label>Rows <input name="rows" inputmode="numeric" size="3" value="1"></label>
|
||||
<label>Columns <input name="cols" inputmode="numeric" size="3" value="1"></label>
|
||||
<label>Label <input name="label" placeholder="wide top — for a single opening"></label>
|
||||
@@ -122,10 +121,26 @@ function unitBlock(u) {
|
||||
3 × 4 under an existing A row becomes B1…D4; or set 1 × 1 and a label
|
||||
for a single opening. All three sizes, or none for a no-limit spot.</span>
|
||||
</form>
|
||||
<div class="openings">${u.openings.map(openingCell).join("")}</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// the unit draws its ACTUAL arrangement: openings group into rows by
|
||||
// their label letters (A1 A2 | B1..B4), and each cell's width is
|
||||
// proportional to its interior width — a double-wide reads double wide
|
||||
function unitRows(openings) {
|
||||
const rows = [];
|
||||
let key = null;
|
||||
for (const o of openings) {
|
||||
const m = /^([A-Za-z]+)\d+$/.exec(o.label || "");
|
||||
const rowKey = m ? m[1].toUpperCase() : `single-${o.id}`;
|
||||
if (rowKey !== key) { rows.push([]); key = rowKey; }
|
||||
rows[rows.length - 1].push(o);
|
||||
}
|
||||
return `<div class="openings">` + rows.map(row =>
|
||||
`<div class="orow">${row.map(openingCell).join("")}</div>`
|
||||
).join("") + `</div>`;
|
||||
}
|
||||
|
||||
function unshelvedRow(g) {
|
||||
const dims = g.measured ? "" : ` <span class="chip open" title="no trusted size — fit can't be verified">unmeasured</span>`;
|
||||
const gos = g.suggestions.map(id => {
|
||||
@@ -133,12 +148,14 @@ function unshelvedRow(g) {
|
||||
return o ? `<button class="suggest" data-key="${esc(g.key)}" data-id="${esc(id)}"
|
||||
title="fits, with room to spare">→ ${esc(o.unit)} · ${esc(o.label)}</button>` : "";
|
||||
}).join("");
|
||||
const gosOut = gos
|
||||
|| (g.measured ? `<span class="meta">no matching openings yet</span>` : "");
|
||||
return `<div class="unshelved-row">
|
||||
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>${dims}
|
||||
${g.lost_home ? `<span class="chip no"
|
||||
title="its assigned opening no longer exists — the shelves changed">shelf gone</span>` : ""}
|
||||
${g.note ? `<span class="meta">${esc(g.note)}</span>` : ""}
|
||||
<span class="gos">${gos}</span>
|
||||
<span class="gos">${gosOut}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -194,8 +211,6 @@ let SHEET_OPENER = null; // the button to hand focus back to on close
|
||||
function openSheet(id) {
|
||||
OPEN = id;
|
||||
SHEET_OPENER = document.activeElement;
|
||||
document.getElementById("unitform").hidden = true; // one layer at a time
|
||||
document.querySelectorAll(".addopeningform").forEach(f => f.hidden = true);
|
||||
document.getElementById("sheetsearch").value = "";
|
||||
document.getElementById("sheetmatches").innerHTML = "";
|
||||
document.getElementById("opensheet").hidden = false;
|
||||
@@ -245,14 +260,6 @@ function wire() {
|
||||
if (confirm(`Remove "${b.dataset.unit}" and its openings? Its games become unshelved.`))
|
||||
post("/api/furniture/delete-unit", {name: b.dataset.unit});
|
||||
});
|
||||
document.querySelectorAll(".addopening").forEach(b =>
|
||||
b.onclick = () => {
|
||||
const form = document.querySelector(
|
||||
`.addopeningform[data-unit="${CSS.escape(b.dataset.unit)}"]`);
|
||||
form.hidden = !form.hidden;
|
||||
b.setAttribute("aria-expanded", String(!form.hidden));
|
||||
if (!form.hidden) form.elements.label.focus();
|
||||
});
|
||||
document.querySelectorAll(".addopeningform").forEach(form =>
|
||||
form.onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
@@ -358,18 +365,7 @@ const PRESETS = {
|
||||
virtual: {width_in: null, height_in: null, depth_in: null},
|
||||
};
|
||||
const unitForm = document.getElementById("unitform");
|
||||
const addUnitBtn = document.getElementById("addunit");
|
||||
addUnitBtn.addEventListener("click", () => {
|
||||
unitForm.hidden = !unitForm.hidden;
|
||||
addUnitBtn.setAttribute("aria-expanded", String(!unitForm.hidden));
|
||||
syncDims();
|
||||
if (!unitForm.hidden) unitForm.elements.name.focus();
|
||||
});
|
||||
document.getElementById("unitcancel").addEventListener("click", () => {
|
||||
unitForm.hidden = true;
|
||||
unitForm.reset();
|
||||
syncDims();
|
||||
});
|
||||
function syncDims() {
|
||||
const v = unitForm.elements.preset.value;
|
||||
document.getElementById("customdims").hidden = v !== "custom";
|
||||
@@ -411,7 +407,6 @@ unitForm.addEventListener("submit", async e => {
|
||||
or add more openings for its other sections`);
|
||||
unitForm.reset();
|
||||
syncDims();
|
||||
unitForm.hidden = true;
|
||||
} finally {
|
||||
createBtn.disabled = false;
|
||||
}
|
||||
|
||||
@@ -71,8 +71,10 @@ from bggpipe.shelves import (
|
||||
new_opening_id,
|
||||
opening_index,
|
||||
opening_report,
|
||||
related,
|
||||
save_furniture,
|
||||
save_locations,
|
||||
stack_thickness,
|
||||
)
|
||||
|
||||
|
||||
@@ -1125,13 +1127,35 @@ def create_app(
|
||||
if key in located_keys or entry.get("stored_in"):
|
||||
continue
|
||||
note = (locations.get(key) or {}).get("note", "")
|
||||
# relevance gates, in rank order. (1) Reunification beats
|
||||
# geometry: an opening already holding a series-mate or base
|
||||
# game ranks first — every confirmed placement teaches the
|
||||
# suggester the owner's organization by example. (2) "Fits"
|
||||
# means the REMAINING opening, not the empty one: the stack
|
||||
# budget already spent is subtracted before offering, so
|
||||
# honesty holds as shelves fill. Then tightest fit, so a
|
||||
# small box is offered cubes, never the oversize row.
|
||||
thickness = stack_thickness(entry)
|
||||
fitting = []
|
||||
for oid, opening in openings.items():
|
||||
if not opening.get("width_in"):
|
||||
continue
|
||||
if fits_opening(entry, opening) is not True:
|
||||
continue
|
||||
here = residents.get(oid, [])
|
||||
report = opening_report(opening, here)
|
||||
remaining = opening["height_in"] - report["stacked_in"]
|
||||
if thickness is None or thickness > remaining:
|
||||
continue
|
||||
reunites = any(related(entry, e) for _, e in here)
|
||||
volume = (
|
||||
opening["width_in"] * opening["height_in"] * opening["depth_in"]
|
||||
)
|
||||
fitting.append((0 if reunites else 1, volume, oid, reunites))
|
||||
fitting.sort(key=lambda item: item[:2])
|
||||
suggestions = [
|
||||
oid
|
||||
for oid, opening in openings.items()
|
||||
if opening.get("width_in")
|
||||
and fits_opening(entry, opening) is True
|
||||
and not opening_report(opening, residents.get(oid, []))["overfull"]
|
||||
][:3]
|
||||
{"id": oid, "reunites": reunites} for _, _, oid, reunites in fitting[:3]
|
||||
]
|
||||
unshelved.append(
|
||||
{
|
||||
"key": key,
|
||||
|
||||
@@ -617,3 +617,149 @@ def test_move_opening_boundaries_no_op(tmp_path):
|
||||
web.post("/api/furniture/move-opening", json={"id": ids[0], "direction": -1})
|
||||
after = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]]
|
||||
assert after == ids # first can't move earlier: quiet no-op
|
||||
|
||||
|
||||
def test_suggestions_prefer_the_tightest_verified_fit(tmp_path):
|
||||
"""The relevance gate: a small box is offered cubes, never the
|
||||
oversize row; only a box too big for cubes gets the double-wide."""
|
||||
games = {
|
||||
"1": {
|
||||
"bgg_id": 1,
|
||||
"name": "Small",
|
||||
"dims": {
|
||||
"width_in": 10,
|
||||
"length_in": 10,
|
||||
"depth_in": 2,
|
||||
"source": "version",
|
||||
},
|
||||
},
|
||||
"2": {
|
||||
"bgg_id": 2,
|
||||
"name": "Wide Boi",
|
||||
"dims": {
|
||||
"width_in": 11.5,
|
||||
"length_in": 17,
|
||||
"depth_in": 2.25,
|
||||
"source": "version",
|
||||
},
|
||||
},
|
||||
}
|
||||
web, cfg = _web(tmp_path, games)
|
||||
web.post("/api/furniture/add-unit", json={"name": "Wall"})
|
||||
# oversize row FIRST in list order — the gate must not care
|
||||
web.post(
|
||||
"/api/furniture/add-openings",
|
||||
json={
|
||||
"unit": "Wall",
|
||||
"label": "A1",
|
||||
"zone": "oversize",
|
||||
"width_in": 26.5,
|
||||
"height_in": 13.25,
|
||||
"depth_in": 15.4,
|
||||
},
|
||||
)
|
||||
web.post(
|
||||
"/api/furniture/add-openings",
|
||||
json={
|
||||
"unit": "Wall",
|
||||
"rows": 1,
|
||||
"cols": 2,
|
||||
"zone": "cubes",
|
||||
"width_in": 13.25,
|
||||
"height_in": 13.25,
|
||||
"depth_in": 15.4,
|
||||
},
|
||||
)
|
||||
state = web.get("/api/shelves").json()
|
||||
ids = {o["label"]: o["id"] for o in state["units"][0]["openings"]}
|
||||
by_name = {g["name"]: g for g in state["unshelved"]}
|
||||
# the small box: cubes first (tightest fit), the wide never leads
|
||||
assert by_name["Small"]["suggestions"][0]["id"] in (ids["B1"], ids["B2"])
|
||||
# the 17" box fits ONLY the double-wide
|
||||
assert [s["id"] for s in by_name["Wide Boi"]["suggestions"]] == [ids["A1"]]
|
||||
|
||||
|
||||
def test_reunification_outranks_tightest_fit_and_full_shelves_stop_lying(tmp_path):
|
||||
"""The two ranking rules together: an opening holding a series-mate
|
||||
ranks above a geometrically tighter empty cube, and an opening whose
|
||||
stack budget is spent stops being offered at all."""
|
||||
games = {
|
||||
"base": {
|
||||
"bgg_id": 1,
|
||||
"name": "Castle Panic",
|
||||
"dims": {
|
||||
"width_in": 10,
|
||||
"length_in": 10,
|
||||
"depth_in": 3,
|
||||
"source": "version",
|
||||
},
|
||||
},
|
||||
"exp": {
|
||||
"bgg_id": 2,
|
||||
"name": "Castle Panic: The Wizard's Tower",
|
||||
"dims": {
|
||||
"width_in": 10,
|
||||
"length_in": 10,
|
||||
"depth_in": 2,
|
||||
"source": "version",
|
||||
},
|
||||
},
|
||||
"fat": {
|
||||
"bgg_id": 3,
|
||||
"name": "Shelf Hog",
|
||||
"dims": {
|
||||
"width_in": 12,
|
||||
"length_in": 12,
|
||||
"depth_in": 11,
|
||||
"source": "version",
|
||||
},
|
||||
},
|
||||
}
|
||||
web, cfg = _web(tmp_path, games)
|
||||
web.post("/api/furniture/add-unit", json={"name": "Wall"})
|
||||
# C1 (holds the base game), C2 (empty, geometrically identical),
|
||||
# and a BIGGER wide opening — reunification must beat both
|
||||
web.post(
|
||||
"/api/furniture/add-openings",
|
||||
json={
|
||||
"unit": "Wall",
|
||||
"rows": 1,
|
||||
"cols": 2,
|
||||
"zone": "cubes",
|
||||
"width_in": 13.25,
|
||||
"height_in": 13.25,
|
||||
"depth_in": 15.4,
|
||||
},
|
||||
)
|
||||
web.post(
|
||||
"/api/furniture/add-openings",
|
||||
json={
|
||||
"unit": "Wall",
|
||||
"label": "wide",
|
||||
"zone": "oversize",
|
||||
"width_in": 26.5,
|
||||
"height_in": 13.25,
|
||||
"depth_in": 15.4,
|
||||
},
|
||||
)
|
||||
state = web.get("/api/shelves").json()
|
||||
ids = {o["label"]: o["id"] for o in state["units"][0]["openings"]}
|
||||
web.post("/api/locate", json={"key": "base", "opening_id": ids["A1"]})
|
||||
# nearly fill A1: the 11"-thick hog leaves ~0 budget behind the base
|
||||
web.post("/api/locate", json={"key": "fat", "opening_id": ids["A1"]})
|
||||
|
||||
state = web.get("/api/shelves").json()
|
||||
exp = next(g for g in state["unshelved"] if g["name"].endswith("Tower"))
|
||||
# A1 holds the series-mate BUT its remaining height (13.25 - 3 - 11 < 2)
|
||||
# can't take the expansion: honesty beats reunification, so the empty
|
||||
# twin cube leads and A1 is not offered at all
|
||||
offered = [s["id"] for s in exp["suggestions"]]
|
||||
assert ids["A1"] not in offered
|
||||
assert offered[0] == ids["A2"]
|
||||
|
||||
# free the space: reunification now wins over the identical empty cube
|
||||
web.post("/api/locate", json={"key": "fat", "opening_id": ids["wide"]})
|
||||
state = web.get("/api/shelves").json()
|
||||
exp = next(g for g in state["unshelved"] if g["name"].endswith("Tower"))
|
||||
first = exp["suggestions"][0]
|
||||
assert first["id"] == ids["A1"] and first["reunites"] is True
|
||||
|
||||
Reference in New Issue
Block a user