init test commit

This commit is contained in:
2026-11-10 20:36:01 -07:00
commit 8693b3bd8d
42 changed files with 311150 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Star Citizen Inventory backend package (scunpacked data)."""

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -0,0 +1,98 @@
"""FastAPI routes for the Star Citizen inventory (scunpacked data)."""
import math
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from app import loader
app = FastAPI(
title="Star Citizen Inventory API",
description="Read-only item inventory built from scunpacked game data.",
version="1.0.0",
)
# Read-only public GET API; permissive CORS keeps local development easy.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
)
store = loader.load_store()
VALID_CATEGORIES = {"ships", "ship-items", "fps-items"}
def _filter_items(category, search, manufacturer, item_type, size):
result = store.items
if category is not None:
if category not in VALID_CATEGORIES:
raise HTTPException(
status_code=422,
detail=f"Invalid category '{category}'. Valid: {sorted(VALID_CATEGORIES)}",
)
result = [i for i in result if i["category"] == category]
if search is not None and search.strip():
needle = search.strip().lower()
result = [
i
for i in result
if needle in i["name"].lower() or needle in i["class_name"].lower()
]
if manufacturer is not None and manufacturer.strip():
code = manufacturer.strip().lower()
result = [i for i in result if i["manufacturer"]["code"].lower() == code]
if item_type is not None and item_type.strip():
wanted = item_type.strip().lower()
result = [i for i in result if (i["type"] or "").lower() == wanted]
if size is not None:
result = [i for i in result if i["size"] == size]
return result
@app.get("/healthz")
def healthz():
return {"status": "ok", "counts": store.counts}
@app.get("/api/v1/stats")
def stats():
return {"categories": store.counts, "manufacturers": len(store.manufacturers)}
@app.get("/api/v1/manufacturers")
def manufacturers():
return store.manufacturers
@app.get("/api/v1/items")
def list_items(
category: str | None = None,
search: str | None = None,
manufacturer: str | None = None,
item_type: str | None = Query(default=None, alias="type"),
size: int | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
):
page_size = min(page_size, 100) # cap, don't reject
filtered = _filter_items(category, search, manufacturer, item_type, size)
total = len(filtered)
total_pages = max(1, math.ceil(total / page_size))
start = (page - 1) * page_size
return {
"total": total,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
"items": filtered[start : start + page_size],
}
@app.get("/api/v1/items/{class_name}")
def item_detail(class_name: str):
summary = store.by_class_name.get(class_name)
if summary is None:
raise HTTPException(status_code=404, detail=f"Item '{class_name}' not found")
return {**summary, "details": store.details.get(class_name, {})}

View File

@@ -0,0 +1,12 @@
"""Configuration for the inventory backend.
DATA_DIR selects which dataset the API serves:
- local dev / tests: small real-schema samples (default) or a fixture dir
- container image: the full scunpacked dataset downloaded at build time
"""
import os
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
_BACKEND_DIR = os.path.dirname(_APP_DIR)
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(_BACKEND_DIR, "data", "samples"))

View File

@@ -0,0 +1,175 @@
"""Load and normalize scunpacked JSON data into the inventory store.
Source data: https://github.com/richardthombs/scunpacked
(mirrored at https://github.com/StarCitizenWiki/scunpacked-data)
Real schemas this loader consumes:
- ships.json: {"UUID","ClassName","Name","Description","Career","Role",
"Manufacturer":{"UUID","Code","Name"},"Size","Length","Width",
"Height","Crew","Mass","IsVehicle","IsGravlev","IsSpaceship",...}
- ship-items.json / fps-items.json:
{"className","reference","itemName","type","subType","size",
"grade","name","tags","classification",
"stdItem":{"UUID","ClassName","Size","Grade","Width","Height",
"Length","Mass","Type","Name","Description","DescriptionText",
"Manufacturer":{"Name","Code","UUID"},"Tags":[...],"Ports":[...]}}
- manufacturers.json:{"Code","Name","Reference","Description"}
"""
import json
import os
from app import config
PLACEHOLDER = "<= PLACEHOLDER =>"
CATEGORY_SHIPS = "ships"
CATEGORY_SHIP_ITEMS = "ship-items"
CATEGORY_FPS_ITEMS = "fps-items"
def _load_json(data_dir, filename):
path = os.path.join(data_dir, filename)
if not os.path.exists(path):
return []
with open(path, encoding="utf-8") as f:
return json.load(f)
def _resolve_name(*candidates, class_name=None):
"""Pick the first real display name; fall back to a prettified ClassName.
Many scunpacked entries have names literally set to "<= PLACEHOLDER =>"
(untranslated labels), so the fallback rule is mandatory.
"""
for candidate in candidates:
if candidate and str(candidate).strip() and str(candidate).strip() != PLACEHOLDER:
return str(candidate).strip()
if class_name:
return str(class_name).replace("_", " ")
return "Unknown"
def _manufacturer(raw):
if not raw:
return {"code": "UNKN", "name": "Unknown Manufacturer"}
code = raw.get("Code") or raw.get("code") or "UNKN"
name = _resolve_name(raw.get("Name"), raw.get("name"), class_name=code)
return {"code": code, "name": name}
def _normalize_ship(ship):
class_name = ship.get("ClassName") or ""
manufacturer = ship.get("Manufacturer") or {}
flags = {
"is_spaceship": bool(ship.get("IsSpaceship")),
"is_vehicle": bool(ship.get("IsVehicle")),
"is_gravlev": bool(ship.get("IsGravlev")),
}
tags = [flag for flag, value in flags.items() if value]
return {
"uuid": ship.get("UUID") or "",
"class_name": class_name,
"name": _resolve_name(ship.get("Name"), class_name=class_name),
"category": CATEGORY_SHIPS,
"type": ship.get("Career") or "Ship",
"sub_type": ship.get("Role") or "",
"size": ship.get("Size") or 0,
"grade": None,
"manufacturer": _manufacturer(manufacturer),
"classification": "Ship",
"description": ship.get("Description") or ship.get("DescriptionText") or "",
"tags": tags,
}, {
"career": ship.get("Career") or "",
"role": ship.get("Role") or "",
"length": ship.get("Length"),
"width": ship.get("Width"),
"height": ship.get("Height"),
"crew": ship.get("Crew"),
"mass": ship.get("Mass"),
**flags,
}
def _normalize_item(item, category):
class_name = item.get("className") or ""
std = item.get("stdItem") or {}
manufacturer = std.get("Manufacturer") or {}
raw_tags = std.get("Tags")
tags = list(raw_tags) if isinstance(raw_tags, list) else []
summary = {
"uuid": std.get("UUID") or item.get("reference") or "",
"class_name": class_name,
"name": _resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
"category": category,
"type": item.get("type") or std.get("Type") or "",
"sub_type": item.get("subType") or "",
"size": item.get("size") if item.get("size") is not None else std.get("Size"),
"grade": item.get("grade") if item.get("grade") is not None else std.get("Grade"),
"manufacturer": _manufacturer(manufacturer),
"classification": item.get("classification") or "",
"description": std.get("Description") or std.get("DescriptionText") or "",
"tags": tags,
}
details = {
"ports": std.get("Ports") or [],
"mass": std.get("Mass"),
"width": std.get("Width"),
"height": std.get("Height"),
"length": std.get("Length"),
}
return summary, details
class Store:
"""In-memory, read-only inventory store built from the scunpacked JSON."""
def __init__(self, items, details, manufacturers):
self.items = items
self.details = details
self.manufacturers = manufacturers
self.by_class_name = {item["class_name"]: item for item in items}
self.counts = {
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),
}
def load_store(data_dir=None):
data_dir = data_dir or config.DATA_DIR
items = []
details = {}
for ship in _load_json(data_dir, "ships.json"):
summary, detail = _normalize_ship(ship)
items.append(summary)
details[summary["class_name"]] = detail
for entry in _load_json(data_dir, "ship-items.json"):
summary, detail = _normalize_item(entry, CATEGORY_SHIP_ITEMS)
items.append(summary)
details[summary["class_name"]] = detail
for entry in _load_json(data_dir, "fps-items.json"):
summary, detail = _normalize_item(entry, CATEGORY_FPS_ITEMS)
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"]))
manufacturers = sorted(
(
{
"code": m.get("Code") or "",
"name": _resolve_name(m.get("Name"), class_name=m.get("Code") or ""),
"description": m.get("Description") or "",
}
for m in _load_json(data_dir, "manufacturers.json")
),
key=lambda m: m["name"].lower(),
)
return Store(items, details, manufacturers)