updated quality for ore

This commit is contained in:
2026-11-12 13:57:23 -07:00
parent 4fbad900a2
commit c8c5e658e2
12 changed files with 376 additions and 25 deletions

View File

@@ -55,3 +55,4 @@ LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900"))
# --- Input limits ---------------------------------------------------------
MAX_NOTES_LENGTH = 1000
MAX_QUANTITY = 1_000_000
MAX_QUALITY = 1000

View File

@@ -83,6 +83,29 @@ def _upgrade_schema(engine) -> None:
"""
)
)
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"quality INTEGER"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_quality_range'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_quality_range
CHECK (quality IS NULL OR (quality >= 1 AND quality <= 1000));
END IF;
END $$
"""
)
)
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:

View File

@@ -46,6 +46,8 @@ def enrich_entry(entry: InventoryEntry) -> dict:
"manufacturer": None,
"quantity": entry.quantity,
"notes": entry.notes,
"quality": entry.quality,
"quality_applicable": False,
"visibility": entry.visibility,
"available": False,
"created_at": entry.created_at,
@@ -61,6 +63,8 @@ def enrich_entry(entry: InventoryEntry) -> dict:
"manufacturer": item["manufacturer"],
"quantity": entry.quantity,
"notes": entry.notes,
"quality": entry.quality,
"quality_applicable": item.get("quality_applicable", False),
"visibility": entry.visibility,
"available": True,
"created_at": entry.created_at,
@@ -68,6 +72,14 @@ def enrich_entry(entry: InventoryEntry) -> dict:
}
def _validate_quality(item: dict, quality: int | None) -> None:
if quality is not None and not item.get("quality_applicable", False):
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{item['name']}'",
)
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
return db.execute(
select(InventoryEntry).where(
@@ -178,10 +190,12 @@ def add_to_inventory(
db: DbSession = Depends(get_db),
):
class_name = payload.class_name.strip()
if class_name not in catalog.store.by_class_name:
item = catalog.store.by_class_name.get(class_name)
if item is None:
raise HTTPException(
status_code=404, detail=f"Catalog item '{class_name}' not found"
)
_validate_quality(item, payload.quality)
entry = _find(db, user, class_name)
created = entry is None
@@ -192,12 +206,15 @@ def add_to_inventory(
class_name=class_name,
quantity=payload.quantity,
notes=payload.notes,
quality=payload.quality,
)
db.add(entry)
else:
entry.quantity += payload.quantity
if payload.notes:
entry.notes = payload.notes
if payload.quality is not None:
entry.quality = payload.quality
try:
db.commit()
@@ -210,6 +227,8 @@ def add_to_inventory(
entry.quantity += payload.quantity
if payload.notes:
entry.notes = payload.notes
if payload.quality is not None:
entry.quality = payload.quality
db.commit()
created = False
@@ -235,6 +254,17 @@ def update_inventory_entry(
entry.notes = payload.notes
if payload.visibility is not None:
entry.visibility = payload.visibility
if "quality" in payload.model_fields_set:
# Explicit null clears the value; omitted keeps the existing one.
if payload.quality is not None:
item = catalog.store.by_class_name.get(entry.class_name)
if item is None:
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{entry.class_name}'",
)
_validate_quality(item, payload.quality)
entry.quality = payload.quality
db.commit()
db.refresh(entry)

View File

@@ -17,6 +17,7 @@ Real schemas this loader consumes:
"""
import json
import os
import re
from app import config
@@ -27,6 +28,68 @@ CATEGORY_SHIP_ITEMS = "ship-items"
CATEGORY_FPS_ITEMS = "fps-items"
CATEGORY_COMMODITIES = "commodities"
# Commodities that can carry a mining/refining quality value (1-1000). These are
# the ores, minerals and gems that players actually mine (and their refined
# counterparts); crafted alloys, gases, organics, processed goods and junk
# by-products are deliberately excluded. Keep in sync with the Ore quality table
# at https://starcitizen.tools/Ore_quality.
QUALITY_MATERIALS = frozenset(
name.lower()
for name in (
"Agricium",
"Aluminum",
"Aphorite",
"Aslarite",
"Beradom",
"Beryl",
"Bexalite",
"Borase",
"Carinite",
"Copper",
"Corundum",
"Diamond",
"Dolivine",
"Feynmaline",
"Glacosite",
"Gold",
"Hadanite",
"Hephaestanite",
"Iron",
"Jaclium",
"Janalite",
"Laranite",
"Lindinium",
"Ouratite",
"Pressurized Ice",
"Quantainium",
"Quartz",
"Riccite",
"Sadaryx",
"Saldynium",
"Savrilium",
"Silicon",
"Stileron",
"Taranite",
"Tin",
"Titanium",
"Torite",
"Tungsten",
)
)
# Variant suffixes are presentation labels, not a different material: "Iron
# (Ore)" and "Iron" share a quality value, as do "(Raw)", "(Pure)" and "(R)".
_QUALITY_VARIANT = re.compile(r"\s*\((?:Ore|Raw|Pure|R|\d+\s*SCU)\)\s*$", re.IGNORECASE)
_RAW_PREFIX = re.compile(r"^Raw\s+", re.IGNORECASE)
def _quality_applicable(name, category):
if category != CATEGORY_COMMODITIES:
return False
base_name = _QUALITY_VARIANT.sub("", name or "").strip()
base_name = _RAW_PREFIX.sub("", base_name).strip().lower()
return base_name in QUALITY_MATERIALS
def _load_json(data_dir, filename):
path = os.path.join(data_dir, filename)
@@ -80,6 +143,7 @@ def _normalize_ship(ship):
"classification": "Ship",
"description": ship.get("Description") or ship.get("DescriptionText") or "",
"tags": tags,
"quality_applicable": False,
}, {
"career": ship.get("Career") or "",
"role": ship.get("Role") or "",
@@ -111,6 +175,10 @@ def _normalize_item(item, category):
"classification": item.get("classification") or "",
"description": std.get("Description") or std.get("DescriptionText") or "",
"tags": tags,
"quality_applicable": _quality_applicable(
_resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
category,
),
}
if category == CATEGORY_COMMODITIES:
summary["size"] = None

View File

@@ -78,6 +78,10 @@ class InventoryEntry(Base):
"visibility IN ('private', 'available')",
name="ck_inventory_visibility",
),
CheckConstraint(
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
name="ck_inventory_quality_range",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@@ -87,6 +91,10 @@ class InventoryEntry(Base):
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
# Mining/refining quality (1-1000) for commodities where it applies. NULL
# means "not specified"; non-quality commodities must keep this NULL. The
# applicability itself is catalog data, not stored here.
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
# "private" (default): only the owner sees the row. "available": the owner
# is offering the item. Unrelated to the response flag `available`, which
# only reports whether the catalog still contains the referenced item.

View File

@@ -33,12 +33,15 @@ class InventoryAddRequest(BaseModel):
class_name: str = Field(min_length=1, max_length=255)
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class InventoryPatchRequest(BaseModel):
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
visibility: Literal["private", "available"] | None = None
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class InventoryEntryOut(BaseModel):
@@ -51,6 +54,10 @@ class InventoryEntryOut(BaseModel):
manufacturer: dict | None
quantity: int
notes: str
# Mining/refining quality (1-1000); only meaningful when
# `quality_applicable` is true. None means the owner has not set it.
quality: int | None = None
quality_applicable: bool = False
# Owner-chosen classification. Unrelated to `available` below.
visibility: Literal["private", "available"]
# False when the catalog no longer contains the referenced item (e.g. after
@@ -84,6 +91,8 @@ class AvailableItemOut(BaseModel):
grade: Any | None = None
manufacturer: dict | None
quantity: int
quality: int | None = None
quality_applicable: bool = False
# Public listing: only the owner's display name, never notes.
username: str
updated_at: datetime

View File

@@ -7,6 +7,7 @@ PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
def authed_client(username: str) -> TestClient:
@@ -18,11 +19,17 @@ def authed_client(username: str) -> TestClient:
return client
def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""):
resp = client.post(
"/api/v1/me/inventory",
json={"class_name": class_name, "quantity": quantity, "notes": notes},
)
def add(
client: TestClient,
class_name: str,
quantity: int = 1,
notes: str = "",
quality: int | None = None,
):
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
if quality is not None:
payload["quality"] = quality
resp = client.post("/api/v1/me/inventory", json=payload)
assert resp.status_code in (200, 201), resp.text
return resp
@@ -128,3 +135,22 @@ def test_commodity_offers_are_listed_with_owner():
assert item["category"] == "commodities"
assert item["quantity"] == 12
assert item["username"] == "alice"
def test_commodity_quality_is_listed_publicly():
alice = authed_client("alice")
offer(alice, IRON_ORE, quantity=12)
add(alice, IRON_ORE, quality=875)
item = public_items()["items"][0]
assert item["quality"] == 875
assert item["quality_applicable"] is True
def test_non_quality_commodity_is_listed_without_quality():
alice = authed_client("alice")
offer(alice, CONSTRUCTION)
item = public_items()["items"][0]
assert item["quality"] is None
assert item["quality_applicable"] is False

View File

@@ -7,6 +7,8 @@ PASSWORD = "correct horse battery staple"
CARROCK = "ANVL_Carrack"
RIFLE = "BEHR_Rifle_Ballistic_01"
IRON_ORE = "Carryable_TBO_FL_1SCU_Commodity_Metal_Ore_Iron"
IRON = "Carryable_TBO_FL_1SCU_Commodity_Metal_Iron"
CONSTRUCTION = "Carryable_TBO_FL_1SCU_Commodity_ProcessedGoods_ConstructionMaterials"
def authed_client(username: str = "alice") -> TestClient:
@@ -18,11 +20,17 @@ def authed_client(username: str = "alice") -> TestClient:
return client
def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""):
return client.post(
"/api/v1/me/inventory",
json={"class_name": class_name, "quantity": quantity, "notes": notes},
)
def add(
client: TestClient,
class_name: str,
quantity: int = 1,
notes: str = "",
quality: int | None = None,
):
payload = {"class_name": class_name, "quantity": quantity, "notes": notes}
if quality is not None:
payload["quality"] = quality
return client.post("/api/v1/me/inventory", json=payload)
def test_new_account_starts_with_empty_inventory():
@@ -264,3 +272,85 @@ def test_add_commodity_uses_catalog_name_and_quantity():
assert entry["quantity"] == 12
assert entry["available"] is True
assert entry["visibility"] == "private"
def test_catalog_marks_only_mineable_commodities_as_quality_applicable():
client = TestClient(app)
items = client.get(
"/api/v1/items", params={"category": "commodities", "page_size": 100}
).json()["items"]
flags = {item["class_name"]: item["quality_applicable"] for item in items}
assert flags[IRON] is True
assert flags[IRON_ORE] is True
assert flags[CONSTRUCTION] is False
assert flags["Carryable_TBO_FL_1SCU_Commodity_Metal_RMC"] is False
ships = client.get("/api/v1/items", params={"category": "ships"}).json()["items"]
assert all(item["quality_applicable"] is False for item in ships)
def test_quality_can_be_set_and_cleared_on_a_quality_commodity():
client = authed_client()
assert add(client, IRON_ORE).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": 750})
assert resp.status_code == 200
body = resp.json()
assert body["quality"] == 750
assert body["quality_applicable"] is True
cleared = client.patch(
f"/api/v1/me/inventory/{IRON_ORE}", json={"quality": None}
)
assert cleared.status_code == 200
assert cleared.json()["quality"] is None
def test_add_persists_quality_and_keeps_it_when_quantity_is_incremented():
client = authed_client()
first = add(client, IRON, quality=900)
assert first.status_code == 201
assert first.json()["entry"]["quality"] == 900
second = add(client, IRON, quantity=2)
assert second.status_code == 200
entry = second.json()["entry"]
assert entry["quality"] == 900
assert entry["quantity"] == 3
replaced = add(client, IRON, quality=400)
assert replaced.json()["entry"]["quality"] == 400
def test_quality_is_rejected_for_non_mineable_commodities():
client = authed_client()
assert add(client, CONSTRUCTION).status_code == 201
resp = client.patch(
f"/api/v1/me/inventory/{CONSTRUCTION}", json={"quality": 500}
)
assert resp.status_code == 422
assert "does not apply" in resp.json()["detail"]
add_resp = add(client, CONSTRUCTION, quality=500)
assert add_resp.status_code == 422
def test_quality_is_rejected_for_ships():
client = authed_client()
assert add(client, CARROCK).status_code == 201
resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quality": 100})
assert resp.status_code == 422
def test_quality_range_is_enforced():
client = authed_client()
assert add(client, IRON_ORE, quality=1).status_code == 201
assert add(client, IRON, quality=1000).status_code == 201
assert add(client, IRON_ORE, quality=0).status_code == 422
assert add(client, IRON_ORE, quality=1001).status_code == 422
assert add(client, IRON, quantity=1).status_code == 200
assert client.patch(
f"/api/v1/me/inventory/{IRON}", json={"quality": 1001}
).status_code == 422

View File

@@ -361,6 +361,10 @@ async function openModal(class_name) {
<div class="add-row">
<label for="add-qty">Qty</label>
<input type="number" id="add-qty" min="1" max="1000000" value="1">
${item.quality_applicable ? `
<label for="add-quality">Quality</label>
<input type="number" id="add-quality" min="1" max="1000" placeholder="—">
` : ""}
<button type="button" id="add-to-inventory" class="btn-primary">
${state.user ? "Add to my inventory" : "Log in to add"}
</button>
@@ -368,7 +372,16 @@ async function openModal(class_name) {
`;
document.getElementById("add-to-inventory").addEventListener("click", () => {
const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1);
addToInventory(item.class_name, qty);
let quality = null;
const qualityInput = document.getElementById("add-quality");
if (qualityInput && qualityInput.value.trim() !== "") {
quality = Number(qualityInput.value);
if (!Number.isInteger(quality) || quality < 1 || quality > 1000) {
showToast("Quality must be between 1 and 1000", true);
return;
}
}
addToInventory(item.class_name, qty, quality);
});
} catch (err) {
els["modal-body"].innerHTML = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
@@ -503,6 +516,48 @@ function itemNameCell(entry) {
return cell;
}
function qualityCell(entry, editable) {
const cell = document.createElement("td");
if (!entry.quality_applicable) {
// Keep a recorded quality visible even if the catalog item disappeared.
cell.innerHTML =
entry.quality == null
? '<span class="inv-quality-na">—</span>'
: escapeHtml(String(entry.quality));
return cell;
}
if (!editable) {
cell.textContent = entry.quality == null ? "—" : String(entry.quality);
return cell;
}
const input = document.createElement("input");
input.type = "number";
input.className = "inv-quality";
input.min = "1";
input.max = "1000";
input.placeholder = "—";
input.value = entry.quality == null ? "" : String(entry.quality);
input.setAttribute("aria-label", `Quality of ${entry.name}`);
input.addEventListener("change", () => {
const raw = input.value.trim();
if (raw === "") {
entry.quality = null;
patchEntry(entry.class_name, { quality: null });
return;
}
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > 1000) {
input.value = entry.quality == null ? "" : String(entry.quality);
showToast("Quality must be between 1 and 1000", true);
return;
}
entry.quality = value;
patchEntry(entry.class_name, { quality: value });
});
cell.appendChild(input);
return cell;
}
function renderInventory(listing) {
const tbody = els["inv-tbody"];
tbody.innerHTML = "";
@@ -567,7 +622,15 @@ function renderInventory(listing) {
visibilityCell.appendChild(visibility);
applyVisibility(visibility, entry.visibility);
row.append(nameCell, categoryCell, qtyCell, notesCell, visibilityCell, actionCell);
row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, true),
notesCell,
visibilityCell,
actionCell
);
tbody.appendChild(row);
}
}
@@ -633,15 +696,17 @@ async function deleteEntry(class_name, name) {
}
}
async function addToInventory(class_name, quantity = 1) {
async function addToInventory(class_name, quantity = 1, quality = null) {
if (!state.user) {
openAuth("login", "Log in to add items to your inventory.");
return;
}
const body = { class_name, quantity };
if (quality !== null) body.quality = quality;
try {
const data = await apiFetch("/api/v1/me/inventory", {
method: "POST",
body: { class_name, quantity },
body,
});
showToast(
data.created
@@ -718,7 +783,14 @@ function renderAvailable(listing) {
updatedCell.className = "avail-updated";
updatedCell.textContent = formatDate(entry.updated_at);
row.append(nameCell, categoryCell, qtyCell, userCell, updatedCell);
row.append(
nameCell,
categoryCell,
qtyCell,
qualityCell(entry, false),
userCell,
updatedCell
);
tbody.appendChild(row);
}
}

View File

@@ -90,6 +90,7 @@
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Quality</th>
<th>Notes</th>
<th>Visibility</th>
<th></th>
@@ -129,6 +130,7 @@
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Quality</th>
<th>Offered by</th>
<th>Updated</th>
</tr>

View File

@@ -429,6 +429,16 @@ main {
border-radius: 6px;
padding: 0.35rem 0.5rem;
}
.inv-quality {
width: 5rem;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.5rem;
}
.inv-quality-na { color: var(--text-dim); }
.inv-quality:focus { outline: none; border-color: var(--accent); }
.inv-notes {
width: 100%;
min-width: 12rem;