Off-BGG games become local library citizens

An unmatched title that's a REAL game BGG doesn't have dead-ended:
manual id or reject. The RPG local-citizen pattern generalizes to a
human decision — review (web + TUI, key l) gains "not on BGG — keep
locally": match_status "local" clears any BGG identity, diff routes it
to local_only (never queued), and enrich synthesizes a library entry
from the game's own photo reads (name, year, publisher cue — no API
call, so even a blocked run lands them; pruning keeps local keys).
Library and Titles show a "local — not on BGG" chip; Help's legend,
review description, and shortcuts cover the new verb, distinguishing
it from reject (bad read / not a game).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 18:59:56 -04:00
co-authored by Claude Fable 5
parent 59f4b8c43c
commit e187ed4f3f
15 changed files with 184 additions and 24 deletions
+8
View File
@@ -407,6 +407,14 @@ def test_rpgitem_rows_are_local_only_never_queued():
assert result.recognized == 1 # RPGs aren't counted against the queue
def test_local_rows_are_library_citizens_never_queued():
local = {**_match("Obscure Homebrew", ""), "match_status": "local"}
result = compute_diff([local, _match("Catan", "13")], [])
assert result.local_only == ["Obscure Homebrew"]
assert [r["bgg_id"] for r in result.to_add] == ["13"]
assert result.pending == [] # decided, not waiting
def test_split_copies_become_extra_collection_entries():
# three vetoed per-photo copies, one owned: one claims the copy, the
# other two are queued as new entries
+52
View File
@@ -226,3 +226,55 @@ def test_enrich_degrades_without_token(tmp_path, capsys):
assert games == {}
assert "waiting on the BGG API" in capsys.readouterr().out
assert json.loads((cfg.data_dir / "games.json").read_text()) == {}
def test_local_rows_enrich_from_their_own_reads(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
write_matches(
cfg.matches_path,
[
{
"title_raw": "Obscure Homebrew",
"bgg_id": "",
"bgg_name": "",
"year": "",
"type": "",
"match_status": "local",
"version_id": "",
"version_name": "",
"version_status": "",
"candidates_json": "[]",
"version_candidates_json": "[]",
"source_photos": "shelf.jpg",
}
],
)
cfg.titles_path.write_text(
json.dumps(
[
{
"title_raw": "Obscure Homebrew",
"publisher_hint": "Basement Press",
"year_hint": 1998,
"source_photos": ["shelf.jpg"],
}
]
)
)
from bggpipe.enrich import run_enrich
client = BGGClient(
cache_dir=cfg.data_dir / "cache",
transport=httpx.MockTransport(_no_network),
)
games = run_enrich(cfg, client=client)
(key,) = [k for k in games if k.startswith("local:")]
entry = games[key]
assert entry["name"] == "Obscure Homebrew"
assert entry["year"] == 1998
assert entry["publishers"] == ["Basement Press"]
assert entry["type"] == "localgame"
# idempotent: a second run keeps the entry (no prune, no dupe)
games2 = run_enrich(cfg, client=client)
assert key in games2
+12
View File
@@ -593,3 +593,15 @@ def test_open_version_ballot_puts_every_edition_up_for_review(tmp_path):
)
with pytest.raises(ValueError, match="no BGG match"):
session2.open_version_ballot(session2.rows[0])
def test_local_decision_keeps_a_real_game_off_bgg(tmp_path):
cfg = _setup(
tmp_path, [_row(title_raw="Obscure Homebrew", match_status="unmatched")]
)
run_review(
cfg, console=quiet_console(), input_fn=scripted("l"), client=fixture_client()
)
(row,) = read_matches(cfg.matches_path)
assert row["match_status"] == "local"
assert row["bgg_id"] == "" # a local citizen carries no BGG identity
+11
View File
@@ -1128,3 +1128,14 @@ def test_open_versions_endpoint_routes_row_into_review(tmp_path):
json={"title_raw": "Dungeon!", "source_photos": "shelf.jpg"},
)
assert res.status_code == 502
def test_local_decision_via_web(tmp_path):
web, cfg = make_client(tmp_path)
state = web.post(
"/api/decision",
json={"title_raw": "Mystery", "source_photos": "shelf.jpg", "action": "local"},
).json()
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert saved["Mystery"]["match_status"] == "local"
assert state["summary"]["local"] == 1