added comodities

This commit is contained in:
2026-11-12 13:04:02 -07:00
parent 120daf8c31
commit b53e3a8a30
10 changed files with 139 additions and 16 deletions

View File

@@ -11,8 +11,8 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Download the full scunpacked dataset (~83 MB) at build time so the image
# is self-contained; DATA_DIR points the API at it.
# Download the full scunpacked dataset at build time so the image is
# self-contained; fetch_data.py reduces items.json to commodities.json.
RUN python scripts/fetch_data.py --output-dir /app/data/full
ENV DATA_DIR=/app/data/full

View File

@@ -48,7 +48,7 @@ app.include_router(auth_router)
app.include_router(inventory_router)
app.include_router(available_router)
VALID_CATEGORIES = {"ships", "ship-items", "fps-items"}
VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"}
def _filter_items(category, search, manufacturer, item_type, size):

View File

@@ -25,6 +25,7 @@ PLACEHOLDER = "<= PLACEHOLDER =>"
CATEGORY_SHIPS = "ships"
CATEGORY_SHIP_ITEMS = "ship-items"
CATEGORY_FPS_ITEMS = "fps-items"
CATEGORY_COMMODITIES = "commodities"
def _load_json(data_dir, filename):
@@ -111,6 +112,8 @@ def _normalize_item(item, category):
"description": std.get("Description") or std.get("DescriptionText") or "",
"tags": tags,
}
if category == CATEGORY_COMMODITIES:
summary["size"] = None
details = {
"ports": std.get("Ports") or [],
"mass": std.get("Mass"),
@@ -133,6 +136,9 @@ class Store:
CATEGORY_SHIPS: sum(1 for i in items if i["category"] == CATEGORY_SHIPS),
CATEGORY_SHIP_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_SHIP_ITEMS),
CATEGORY_FPS_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_FPS_ITEMS),
CATEGORY_COMMODITIES: sum(
1 for i in items if i["category"] == CATEGORY_COMMODITIES
),
}
@@ -157,6 +163,11 @@ def load_store(data_dir=None):
items.append(summary)
details[summary["class_name"]] = detail
for entry in _load_json(data_dir, "commodities.json"):
summary, detail = _normalize_item(entry, CATEGORY_COMMODITIES)
items.append(summary)
details[summary["class_name"]] = detail
# Deterministic order so pagination is stable.
items.sort(key=lambda i: (i["category"], i["name"].lower(), i["class_name"]))

View File

@@ -6,6 +6,7 @@ Usage:
Default output: backend/data/full (used by the Docker image build).
"""
import argparse
import json
import os
import sys
import urllib.request
@@ -16,7 +17,38 @@ FILES = [
"ship-items.json",
"fps-items.json",
"manufacturers.json",
"items.json",
]
COMMODITY_KEYS = ("className", "name", "type", "subType", "reference")
PLACEHOLDER = "<= PLACEHOLDER =>"
def _commodity_rank(entry):
class_name = entry.get("className") or ""
return (0 if "_1SCU_" in class_name else 1, entry.get("size") or 0, class_name)
def build_commodities(items_path, out_path):
with open(items_path, encoding="utf-8") as f:
items = json.load(f)
grouped = {}
for entry in items:
class_name = (entry.get("className") or "").lower()
name = (entry.get("name") or "").strip()
if entry.get("type") != "Cargo" or "commodity" not in class_name:
continue
if not name or name == PLACEHOLDER:
continue
grouped.setdefault(name, []).append(entry)
commodities = [
{key: best.get(key) for key in COMMODITY_KEYS}
for best in (min(grouped[name], key=_commodity_rank) for name in sorted(grouped))
]
with open(out_path, "w", encoding="utf-8") as f:
json.dump(commodities, f, ensure_ascii=False, separators=(",", ":"))
return len(commodities)
def main():
@@ -35,6 +67,13 @@ def main():
urllib.request.urlretrieve(url, dest)
size = os.path.getsize(dest)
print(f" done ({size / 1_000_000:.1f} MB)", flush=True)
items_path = os.path.join(args.output_dir, "items.json")
count = build_commodities(
items_path, os.path.join(args.output_dir, "commodities.json")
)
os.remove(items_path)
print(f"Wrote {count} commodities (items.json discarded)", flush=True)
return 0

View File

@@ -11,14 +11,24 @@ def test_healthz():
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
assert body["counts"] == {"ships": 2, "ship-items": 2, "fps-items": 1}
assert body["counts"] == {
"ships": 2,
"ship-items": 2,
"fps-items": 1,
"commodities": 4,
}
def test_stats():
resp = client.get("/api/v1/stats")
assert resp.status_code == 200
body = resp.json()
assert body["categories"] == {"ships": 2, "ship-items": 2, "fps-items": 1}
assert body["categories"] == {
"ships": 2,
"ship-items": 2,
"fps-items": 1,
"commodities": 4,
}
assert body["manufacturers"] == 4
@@ -33,11 +43,11 @@ def test_items_default_pagination():
resp = client.get("/api/v1/items")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 5
assert body["total"] == 9
assert body["page"] == 1
assert body["page_size"] == 25
assert body["total_pages"] == 1
assert len(body["items"]) == 5
assert len(body["items"]) == 9
def test_items_page_size_capped():
@@ -155,3 +165,29 @@ def test_deterministic_sort_order():
body = client.get("/api/v1/items", params={"page_size": 100}).json()
keys = [(i["category"], i["name"].lower()) for i in body["items"]]
assert keys == sorted(keys)
def test_items_category_filter_commodities():
resp = client.get("/api/v1/items", params={"category": "commodities"})
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 4
assert all(i["category"] == "commodities" for i in body["items"])
def test_items_search_matches_refined_and_ore_iron():
resp = client.get("/api/v1/items", params={"search": "iron"})
body = resp.json()
assert body["total"] == 2
assert {i["name"] for i in body["items"]} == {"Iron", "Iron (Ore)"}
def test_item_detail_commodity():
resp = client.get("/api/v1/items/Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron")
assert resp.status_code == 200
body = resp.json()
assert body["name"] == "Iron (Ore)"
assert body["category"] == "commodities"
assert body["type"] == "Cargo"
assert body["size"] is None
assert isinstance(body["details"], dict)

View File

@@ -6,6 +6,7 @@ from app.api import app
PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
def authed_client(username: str = "alice") -> TestClient:
@@ -251,3 +252,15 @@ def test_sort_defaults_to_name_and_rejects_unknown_values():
listing = client.get("/api/v1/me/inventory").json()
assert [i["class_name"] for i in listing["items"]] == [CARROCK]
assert client.get("/api/v1/me/inventory?sort=bogus").status_code == 422
def test_add_commodity_uses_catalog_name_and_quantity():
client = authed_client("refiner")
resp = add(client, IRON_ORE, quantity=12, notes="from Aaron Halo")
assert resp.status_code == 201
entry = client.get("/api/v1/me/inventory").json()["items"][0]
assert entry["name"] == "Iron (Ore)"
assert entry["category"] == "commodities"
assert entry["quantity"] == 12
assert entry["available"] is True
assert entry["visibility"] == "private"

View File

@@ -107,7 +107,7 @@ async function initHeader() {
const c = health.counts || {};
els["stats-line"].textContent =
`${c.ships ?? 0} ships · ${c["ship-items"] ?? 0} ship items · ` +
`${c["fps-items"] ?? 0} FPS items`;
`${c["fps-items"] ?? 0} FPS items · ${c.commodities ?? 0} commodities`;
} catch {
els["api-status"].textContent = "API OFFLINE";
els["api-status"].className = "status-pill offline";
@@ -239,7 +239,12 @@ function buildQuery() {
}
function categoryBadge(category) {
const labels = { ships: "Ship", "ship-items": "Ship Item", "fps-items": "FPS Item" };
const labels = {
ships: "Ship",
"ship-items": "Ship Item",
"fps-items": "FPS Item",
commodities: "Commodity",
};
return `<span class="badge cat-${category}">${labels[category] || category}</span>`;
}
@@ -457,7 +462,12 @@ async function refreshSummary() {
}
function renderInventorySummary(summary) {
const labels = { ships: "ships", "ship-items": "ship items", "fps-items": "FPS items" };
const labels = {
ships: "ships",
"ship-items": "ship items",
"fps-items": "FPS items",
commodities: "commodities",
};
const parts = Object.entries(summary.categories || {})
.map(([key, qty]) => `${qty} ${labels[key] || key}`)
.join(" · ");

View File

@@ -28,6 +28,7 @@
<button class="tab" data-view="catalog" data-category="ships">Ships</button>
<button class="tab" data-view="catalog" data-category="ship-items">Ship Equipment</button>
<button class="tab" data-view="catalog" data-category="fps-items">FPS Items</button>
<button class="tab" data-view="catalog" data-category="commodities">Commodities</button>
<button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
<button class="tab" id="tab-available" data-view="available">Available</button>
</nav>
@@ -68,6 +69,7 @@
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
<option value="commodities">Commodities</option>
</select>
<select id="inv-sort" aria-label="Sort inventory">
<option value="name">Sort: Name</option>
@@ -106,6 +108,7 @@
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
<option value="commodities">Commodities</option>
</select>
<select id="avail-sort" aria-label="Sort available items">
<option value="updated">Sort: Recently updated</option>

View File

@@ -14,6 +14,7 @@
--cat-ships: #38bdf8;
--cat-ship-items: #a78bfa;
--cat-fps-items: #f59e0b;
--cat-commodities: #4ade80;
}
* { box-sizing: border-box; }
@@ -215,6 +216,7 @@ main {
.badge.cat-ships { color: var(--cat-ships); border-color: var(--cat-ships); }
.badge.cat-ship-items { color: var(--cat-ship-items); border-color: var(--cat-ship-items); }
.badge.cat-fps-items { color: var(--cat-fps-items); border-color: var(--cat-fps-items); }
.badge.cat-commodities { color: var(--cat-commodities); border-color: var(--cat-commodities); }
.badge.size { color: var(--text-dim); border-color: var(--border); }
.item-classification { color: var(--text-dim); font-size: 0.78rem; font-family: ui-monospace, monospace; }