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:
|
def stack_thickness(entry: dict) -> float | None:
|
||||||
"""A box on a shelf stack contributes its thinnest axis."""
|
"""A box on a shelf stack contributes its thinnest axis."""
|
||||||
dims = game_dims(entry)
|
dims = game_dims(entry)
|
||||||
|
|||||||
@@ -549,13 +549,21 @@ select {
|
|||||||
.card.stack { display: block; }
|
.card.stack { display: block; }
|
||||||
.unit .unithead { display: flex; align-items: center; gap: .7rem;
|
.unit .unithead { display: flex; align-items: center; gap: .7rem;
|
||||||
justify-content: space-between; flex-wrap: wrap; }
|
justify-content: space-between; flex-wrap: wrap; }
|
||||||
.openings { display: grid; gap: .5rem;
|
/* rows mirror the unit's PHYSICAL arrangement: one .orow per label
|
||||||
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr)); }
|
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 {
|
button.opening {
|
||||||
font: inherit; text-align: left; cursor: pointer;
|
font: inherit; text-align: left; cursor: pointer;
|
||||||
background: #fff; border: 2px solid var(--board-edge);
|
background: #fff; border: 2px solid var(--board-edge);
|
||||||
border-radius: var(--radius); padding: .5rem .6rem;
|
border-radius: var(--radius); padding: .5rem .6rem;
|
||||||
display: flex; flex-direction: column; gap: .25rem; min-height: 5.2rem;
|
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 {
|
button.opening:hover, button.opening:focus-visible {
|
||||||
outline: 3px solid var(--accent); }
|
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;
|
border: 2px solid var(--board-edge); border-radius: 1rem;
|
||||||
background: var(--board); padding: .15rem .6rem; }
|
background: var(--board); padding: .15rem .6rem; }
|
||||||
button.suggest:hover { border-color: var(--accent); }
|
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 */
|
/* the opening sheet: modal on desktop, bottom sheet on phones */
|
||||||
#opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45);
|
#opensheet { position: fixed; inset: 0; background: rgba(42, 36, 56, .45);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
<h1>Shelves</h1>
|
<h1>Shelves</h1>
|
||||||
<div class="pagebar"><span id="shelfcount"></span>
|
<div class="pagebar"><span id="shelfcount"></span></div>
|
||||||
<button id="addunit" aria-expanded="false">add a unit</button>
|
|
||||||
</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>Unit name <input name="name" required placeholder="Living room Kallax"></label>
|
||||||
<label>Preset
|
<label>Preset
|
||||||
<select name="preset">
|
<select name="preset">
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
<label>Zone <input name="zone" placeholder="party games"></label>
|
<label>Zone <input name="zone" placeholder="party games"></label>
|
||||||
<span class="editactions">
|
<span class="editactions">
|
||||||
<button type="submit" class="primary">create</button>
|
<button type="submit" class="primary">create</button>
|
||||||
<button type="button" id="unitcancel">cancel</button>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="edithint">grid mode labels openings A1, A2… by row; you can edit,
|
<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
|
add, or remove openings afterward — repeat with another grid to stack
|
||||||
@@ -92,7 +90,8 @@ function openingCell(o) {
|
|||||||
const count = o.games
|
const count = o.games
|
||||||
? `${o.games}${o.unmeasured ? ` · ${o.unmeasured} unmeasured` : ""}`
|
? `${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>
|
<span class="olabel">${esc(o.label)}${warn}</span>
|
||||||
${o.zone ? `<span class="ozone">${esc(o.zone)}</span>` : ""}
|
${o.zone ? `<span class="ozone">${esc(o.zone)}</span>` : ""}
|
||||||
<span class="ocount">${count}</span>
|
<span class="ocount">${count}</span>
|
||||||
@@ -105,11 +104,11 @@ function unitBlock(u) {
|
|||||||
return `<section class="card stack unit">
|
return `<section class="card stack unit">
|
||||||
<div class="unithead"><h2>${esc(u.name)}</h2>
|
<div class="unithead"><h2>${esc(u.name)}</h2>
|
||||||
<span class="editactions">
|
<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>
|
<button class="delunit danger" data-unit="${esc(u.name)}">remove unit</button>
|
||||||
</span></div>
|
</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>Rows <input name="rows" inputmode="numeric" size="3" value="1"></label>
|
||||||
<label>Columns <input name="cols" 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>
|
<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
|
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>
|
for a single opening. All three sizes, or none for a no-limit spot.</span>
|
||||||
</form>
|
</form>
|
||||||
<div class="openings">${u.openings.map(openingCell).join("")}</div>
|
|
||||||
</section>`;
|
</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) {
|
function unshelvedRow(g) {
|
||||||
const dims = g.measured ? "" : ` <span class="chip open" title="no trusted size — fit can't be verified">unmeasured</span>`;
|
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 => {
|
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)}"
|
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>` : "";
|
title="fits, with room to spare">→ ${esc(o.unit)} · ${esc(o.label)}</button>` : "";
|
||||||
}).join("");
|
}).join("");
|
||||||
|
const gosOut = gos
|
||||||
|
|| (g.measured ? `<span class="meta">no matching openings yet</span>` : "");
|
||||||
return `<div class="unshelved-row">
|
return `<div class="unshelved-row">
|
||||||
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>${dims}
|
<a href="/library/game/${encodeURIComponent(g.key)}">${esc(g.name)}</a>${dims}
|
||||||
${g.lost_home ? `<span class="chip no"
|
${g.lost_home ? `<span class="chip no"
|
||||||
title="its assigned opening no longer exists — the shelves changed">shelf gone</span>` : ""}
|
title="its assigned opening no longer exists — the shelves changed">shelf gone</span>` : ""}
|
||||||
${g.note ? `<span class="meta">${esc(g.note)}</span>` : ""}
|
${g.note ? `<span class="meta">${esc(g.note)}</span>` : ""}
|
||||||
<span class="gos">${gos}</span>
|
<span class="gos">${gosOut}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +211,6 @@ let SHEET_OPENER = null; // the button to hand focus back to on close
|
|||||||
function openSheet(id) {
|
function openSheet(id) {
|
||||||
OPEN = id;
|
OPEN = id;
|
||||||
SHEET_OPENER = document.activeElement;
|
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("sheetsearch").value = "";
|
||||||
document.getElementById("sheetmatches").innerHTML = "";
|
document.getElementById("sheetmatches").innerHTML = "";
|
||||||
document.getElementById("opensheet").hidden = false;
|
document.getElementById("opensheet").hidden = false;
|
||||||
@@ -245,14 +260,6 @@ function wire() {
|
|||||||
if (confirm(`Remove "${b.dataset.unit}" and its openings? Its games become unshelved.`))
|
if (confirm(`Remove "${b.dataset.unit}" and its openings? Its games become unshelved.`))
|
||||||
post("/api/furniture/delete-unit", {name: b.dataset.unit});
|
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 =>
|
document.querySelectorAll(".addopeningform").forEach(form =>
|
||||||
form.onsubmit = async e => {
|
form.onsubmit = async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -358,18 +365,7 @@ const PRESETS = {
|
|||||||
virtual: {width_in: null, height_in: null, depth_in: null},
|
virtual: {width_in: null, height_in: null, depth_in: null},
|
||||||
};
|
};
|
||||||
const unitForm = document.getElementById("unitform");
|
const unitForm = document.getElementById("unitform");
|
||||||
const addUnitBtn = document.getElementById("addunit");
|
syncDims();
|
||||||
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() {
|
function syncDims() {
|
||||||
const v = unitForm.elements.preset.value;
|
const v = unitForm.elements.preset.value;
|
||||||
document.getElementById("customdims").hidden = v !== "custom";
|
document.getElementById("customdims").hidden = v !== "custom";
|
||||||
@@ -411,7 +407,6 @@ unitForm.addEventListener("submit", async e => {
|
|||||||
or add more openings for its other sections`);
|
or add more openings for its other sections`);
|
||||||
unitForm.reset();
|
unitForm.reset();
|
||||||
syncDims();
|
syncDims();
|
||||||
unitForm.hidden = true;
|
|
||||||
} finally {
|
} finally {
|
||||||
createBtn.disabled = false;
|
createBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,8 +71,10 @@ from bggpipe.shelves import (
|
|||||||
new_opening_id,
|
new_opening_id,
|
||||||
opening_index,
|
opening_index,
|
||||||
opening_report,
|
opening_report,
|
||||||
|
related,
|
||||||
save_furniture,
|
save_furniture,
|
||||||
save_locations,
|
save_locations,
|
||||||
|
stack_thickness,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1125,13 +1127,35 @@ def create_app(
|
|||||||
if key in located_keys or entry.get("stored_in"):
|
if key in located_keys or entry.get("stored_in"):
|
||||||
continue
|
continue
|
||||||
note = (locations.get(key) or {}).get("note", "")
|
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 = [
|
suggestions = [
|
||||||
oid
|
{"id": oid, "reunites": reunites} for _, _, oid, reunites in fitting[:3]
|
||||||
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]
|
|
||||||
unshelved.append(
|
unshelved.append(
|
||||||
{
|
{
|
||||||
"key": key,
|
"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})
|
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"]]
|
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
|
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