From 71638fcaed4ff5d6ebc35125488e7ae2fcab0d59 Mon Sep 17 00:00:00 2001 From: HRiggs Date: Thu, 7 May 2026 21:20:43 -0400 Subject: [PATCH] feat: add smdb intergration --- .env.example | 1 + README.md | 3 + tests/test_tools.py | 132 ++++++++++++ traderai/agent.py | 4 + traderai/config.py | 2 + traderai/scmdb_client.py | 73 +++++++ traderai/server.py | 4 +- traderai/tools.py | 428 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 traderai/scmdb_client.py diff --git a/.env.example b/.env.example index 67e3a2c..6c9c175 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_MODEL=qwen3.5:9b OLLAMA_NUM_CTX=64512 UEX_BASE_URL=https://api.uexcorp.space/2.0 +SCMDB_BASE_URL=https://scmdb.net UEX_SECRET_KEY= UEX_BEARER_TOKEN= TRADERAI_USER_NAME= diff --git a/README.md b/README.md index 6e198cf..a2a88b3 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Local Ollama-powered chat for UEX marketplace workflows. ## What It Does - Searches active/current UEX marketplace listings through `GET /marketplace_listings/`. +- Searches SCMDB mission data so the assistant can answer what Star Citizen missions pay or reward, including UEC, reputation, item rewards, blueprint rewards, partial payouts, and hauling cargo. - Reads authenticated marketplace negotiations and negotiation messages when `UEX_SECRET_KEY` or `UEX_BEARER_TOKEN` is set. - Drafts negotiation messages and marketplace listings as pending actions. - Requires browser approval before sending authenticated write requests to UEX. @@ -23,6 +24,7 @@ Local Ollama-powered chat for UEX marketplace workflows. ``` 3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions. + `SCMDB_BASE_URL` defaults to `https://scmdb.net`. 4. Install and run: ```powershell @@ -71,6 +73,7 @@ UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by d ## Sources Used - UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1 +- SCMDB mission data: https://scmdb.net/ - UEX marketplace listings docs: https://uexcorp.space/api/documentation/id/get_marketplace_listings/?is_kiosk=1 - UEX negotiation message docs: https://uexcorp.space/api/documentation/id/post_marketplace_negotiations_messages/?is_kiosk=1 - Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling diff --git a/tests/test_tools.py b/tests/test_tools.py index 0b89a81..f2fa503 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -121,6 +121,98 @@ class FakeUEX: return {"status": "ok", "posted": self.posts[-1]} +class FakeSCMDB: + base_url = "https://scmdb.test" + + async def list_versions(self): + return [ + {"version": "4.8.0-ptu.1", "file": "merged-4.8.0-ptu.1.json"}, + {"version": "4.7.2-live.1", "file": "merged-4.7.2-live.1.json"}, + ] + + async def get_data(self, version=None, channel="live"): + return { + "version": version or "4.7.2-live.1", + "factions": { + "fac-haul": {"name": "Covalex"}, + "fac-bounty": {"name": "Bounty Hunters Guild"}, + }, + "scopes": { + "scope-rep": {"scopeName": "FactionReputation"}, + }, + "factionRewardsPools": [ + [{"factionGuid": "fac-haul", "scopeGuid": "scope-rep", "amount": 125}], + [{"factionGuid": "fac-bounty", "scopeGuid": "scope-rep", "amount": 250}], + ], + "partialRewardPayoutPools": [ + [], + [{"minPercentage": 50, "maxPercentage": 99, "currencyRewardMultiplier": 0.75, "reputationMultipliers": None}], + ], + "resourcePools": { + "res-tungsten": {"name": "Tungsten"}, + }, + "blueprintPools": { + "bp-pool": { + "name": "Ship Salvage Rewards", + "blueprints": [{"name": "Abrade Scraper Module"}], + }, + }, + "locationPools": { + "loc-a18": {"name": "Area18"}, + "loc-baijini": {"name": "Baijini Point"}, + }, + "contracts": [ + { + "id": "mission-haul", + "debugName": "Haul_Tungsten_Test", + "title": "Move Tungsten", + "description": "Move Tungsten to Baijini Point.", + "missionType": "Hauling", + "category": "career", + "factionGuid": "fac-haul", + "rewardUEC": 50250, + "factionRewardsIndex": 0, + "partialRewardPayoutIndex": 1, + "haulingOrders": [{"resource": "res-tungsten", "minSCU": 6, "maxSCU": 6, "maxContainerSize": 1}], + "locations": ["loc-a18"], + "destinations": ["loc-baijini"], + "systems": ["Stanton"], + "illegal": False, + "canBeShared": False, + }, + { + "id": "mission-bounty", + "debugName": "Bounty_Blueprint_Test", + "title": "Ambush Op", + "description": "Clean out targets.", + "missionType": "Bounty Hunter", + "factionGuid": "fac-bounty", + "rewardUEC": 120000, + "factionRewardsIndex": 1, + "partialRewardPayoutIndex": 0, + "itemRewards": [{"name": "Council Scrip", "amount": 5}], + "blueprintRewards": [{"blueprintPool": "bp-pool", "chance": 1, "trigger": "complete"}], + "systems": ["Pyro"], + "illegal": True, + "canBeShared": True, + }, + ], + "legacyContracts": [ + { + "id": "legacy-delivery", + "debugName": "Legacy_Delivery_Test", + "title": "Old Box Run", + "missionType": "Delivery", + "factionGuid": "fac-haul", + "rewardUEC": 1000, + "factionRewardsIndex": 0, + "partialRewardPayoutIndex": 0, + "systems": ["Stanton"], + } + ], + } + + @pytest.mark.asyncio async def test_search_marketplace_listings_filters_locally(): registry = ToolRegistry(FakeUEX()) @@ -236,6 +328,46 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool(): assert "uex_draft_post" not in names +def test_schemas_expose_scmdb_mission_tools(): + registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB()) + + names = {schema["function"]["name"] for schema in registry.schemas} + + assert "list_scmdb_versions" in names + assert "search_scmdb_missions" in names + assert "get_scmdb_mission_rewards" in names + + +@pytest.mark.asyncio +async def test_search_scmdb_missions_returns_reward_summary(): + registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB()) + + result = await registry.search_scmdb_missions(query="tungsten", mission_type="hauling") + + assert result["version"] == "4.7.2-live.1" + assert result["matched"] == 1 + mission = result["missions"][0] + assert mission["title"] == "Move Tungsten" + assert mission["rewards"]["uec"] == 50250 + assert mission["rewards"]["reputation"] == [{"faction": "Covalex", "scope": "FactionReputation", "amount": 125}] + assert mission["rewards"]["hauling"] == [ + {"resource": "Tungsten", "min_scu": 6, "max_scu": 6, "max_container_size_scu": 1} + ] + + +@pytest.mark.asyncio +async def test_get_scmdb_mission_rewards_enriches_items_blueprints_and_locations(): + registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB()) + + result = await registry.get_scmdb_mission_rewards(debug_name="Bounty_Blueprint_Test") + + mission = result["mission"] + assert mission["title"] == "Ambush Op" + assert mission["faction"] == "Bounty Hunters Guild" + assert mission["rewards"]["items"] == [{"name": "Council Scrip", "amount": 5}] + assert mission["rewards"]["blueprints"][0]["blueprints"] == ["Abrade Scraper Module"] + + @pytest.mark.asyncio async def test_search_uex_api_index_finds_history_tools(): registry = ToolRegistry(FakeUEX()) diff --git a/traderai/agent.py b/traderai/agent.py index 2d9b207..b886428 100644 --- a/traderai/agent.py +++ b/traderai/agent.py @@ -17,6 +17,7 @@ Use tools when the user asks about UEX data, open/current listings, active negot UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error. Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact. When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints. +Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version. Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales. Treat UEX marketplace prices as in-game aUEC/UEC credits, never real-world dollars, unless the user explicitly says otherwise. For marketplace writes, draft the exact pending action and tell the user what will be sent; never claim it was sent until approval succeeds. @@ -514,6 +515,9 @@ class OllamaAgent: "summarize_uex_commodity_price_history": "Summarizing commodity price history", "summarize_uex_marketplace_price_history": "Summarizing marketplace price history", "summarize_uex_currency_index_history": "Summarizing currency index history", + "list_scmdb_versions": "Checking SCMDB versions", + "search_scmdb_missions": "Searching SCMDB missions", + "get_scmdb_mission_rewards": "Fetching SCMDB mission rewards", "uex_api_catalog": "Checking UEX API catalog", "uex_get": "Fetching UEX data", "uex_draft_post": "Drafting UEX write for approval", diff --git a/traderai/config.py b/traderai/config.py index 61067b0..9b0e259 100644 --- a/traderai/config.py +++ b/traderai/config.py @@ -15,6 +15,7 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = { "ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False}, "ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False}, "uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False}, + "scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False}, "uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True}, "uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True}, "traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False}, @@ -64,6 +65,7 @@ class Settings(BaseSettings): ollama_model: str = "qwen3.5:9b" ollama_num_ctx: int = 64512 uex_base_url: str = "https://api.uexcorp.space/2.0" + scmdb_base_url: str = "https://scmdb.net" uex_secret_key: str | None = Field(default=None) uex_bearer_token: str | None = Field(default=None) traderai_user_name: str | None = Field(default=None) diff --git a/traderai/scmdb_client.py b/traderai/scmdb_client.py new file mode 100644 index 0000000..1a844f4 --- /dev/null +++ b/traderai/scmdb_client.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Any + +import httpx + + +class SCMDBError(RuntimeError): + pass + + +class SCMDBClient: + def __init__(self, base_url: str = "https://scmdb.net") -> None: + self.base_url = base_url.rstrip("/") + self._versions: list[dict[str, Any]] | None = None + self._data_cache: dict[str, dict[str, Any]] = {} + + async def list_versions(self) -> list[dict[str, Any]]: + if self._versions is not None: + return self._versions + body = await self._get_json("data/versions.json") + if not isinstance(body, list): + raise SCMDBError("SCMDB versions response was not a list.") + self._versions = [ + item + for item in body + if isinstance(item, dict) and item.get("version") and item.get("file") + ] + return self._versions + + async def get_data(self, version: str | None = None, channel: str = "live") -> dict[str, Any]: + selected = await self.resolve_version(version=version, channel=channel) + cache_key = str(selected["version"]) + if cache_key not in self._data_cache: + body = await self._get_json(f"data/{selected['file']}") + if not isinstance(body, dict): + raise SCMDBError(f"SCMDB data for {cache_key} was not an object.") + self._data_cache[cache_key] = body + return self._data_cache[cache_key] + + async def resolve_version(self, version: str | None = None, channel: str = "live") -> dict[str, Any]: + versions = await self.list_versions() + if not versions: + raise SCMDBError("SCMDB did not return any data versions.") + + if version: + needle = version.casefold().strip() + for item in versions: + item_version = str(item["version"]) + if item_version.casefold() == needle or needle in item_version.casefold(): + return item + raise SCMDBError(f"SCMDB version not found: {version}") + + channel = (channel or "live").casefold().strip() + if channel in {"latest", "any", "all"}: + return versions[0] + if channel not in {"live", "ptu"}: + raise SCMDBError("SCMDB channel must be live, ptu, or latest.") + for item in versions: + if f"-{channel}." in str(item["version"]).casefold(): + return item + return versions[0] + + async def _get_json(self, path: str) -> Any: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + response = await client.get(f"{self.base_url}/{path.lstrip('/')}", headers={"Accept": "application/json"}) + try: + body = response.json() + except ValueError as exc: + raise SCMDBError(f"SCMDB returned non-JSON response: HTTP {response.status_code}") from exc + if response.status_code >= 400: + raise SCMDBError(f"SCMDB HTTP {response.status_code}: {body}") + return body diff --git a/traderai/server.py b/traderai/server.py index 27f3d6c..86e72fa 100644 --- a/traderai/server.py +++ b/traderai/server.py @@ -23,6 +23,7 @@ from traderai.config import save_settings, settings_payload from traderai.config import get_settings from traderai.memory import DEFAULT_THREAD_ID, MemoryStore from traderai.scheduler import WakeScheduler +from traderai.scmdb_client import SCMDBClient from traderai.tools import ToolRegistry from traderai.uex_client import UEXClient from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__ @@ -75,7 +76,8 @@ def create_app() -> FastAPI: memory = MemoryStore(settings.traderai_memory_path) scheduler = WakeScheduler(memory) uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token) - tools = ToolRegistry(uex, settings.require_write_approval, memory=memory, scheduler=scheduler) + scmdb = SCMDBClient(settings.scmdb_base_url) + tools = ToolRegistry(uex, settings.require_write_approval, memory=memory, scheduler=scheduler, scmdb=scmdb) agent = OllamaAgent( settings.ollama_base_url, settings.ollama_model, diff --git a/traderai/tools.py b/traderai/tools.py index c08aba0..380fb2c 100644 --- a/traderai/tools.py +++ b/traderai/tools.py @@ -6,6 +6,7 @@ from typing import Any, Awaitable, Callable from traderai.memory import MemoryStore from traderai.scheduler import WakeScheduler +from traderai.scmdb_client import SCMDBClient from traderai.uex_client import UEXClient @@ -155,8 +156,10 @@ class ToolRegistry: require_write_approval: bool = True, memory: MemoryStore | None = None, scheduler: WakeScheduler | None = None, + scmdb: SCMDBClient | None = None, ) -> None: self.uex = uex + self.scmdb = scmdb or SCMDBClient() self.require_write_approval = require_write_approval self.memory = memory self.scheduler = scheduler @@ -173,6 +176,9 @@ class ToolRegistry: "schedule_wake_job": self.schedule_wake_job, "list_wake_jobs": self.list_wake_jobs, "check_uex_notifications": self.check_uex_notifications, + "list_scmdb_versions": self.list_scmdb_versions, + "search_scmdb_missions": self.search_scmdb_missions, + "get_scmdb_mission_rewards": self.get_scmdb_mission_rewards, } self.handlers["uex_api_catalog"] = self.uex_api_catalog self.handlers["uex_get"] = self.uex_get @@ -197,6 +203,7 @@ class ToolRegistry: *self._history_summary_schemas(), *self._uex_post_schemas(), *self._uex_delete_schemas(), + *self._scmdb_schemas(), { "type": "function", "function": { @@ -767,6 +774,66 @@ class ToolRegistry: }, ] + @classmethod + def _scmdb_schemas(cls) -> list[dict[str, Any]]: + version_controls = { + "version": {"type": "string", "description": "SCMDB game-data version, such as 4.7.2-live.11715810."}, + "channel": {"type": "string", "enum": ["live", "ptu", "latest"], "default": "live"}, + } + return [ + { + "type": "function", + "function": { + "name": "list_scmdb_versions", + "description": "List SCMDB mission-data versions. Use this when the user asks which Star Citizen game versions are available.", + "parameters": { + "type": "object", + "properties": { + "channel": {"type": "string", "enum": ["live", "ptu", "latest"]}, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "search_scmdb_missions", + "description": "Search SCMDB Star Citizen missions/contracts and return compact reward summaries: UEC, reputation, item, blueprint, and hauling rewards.", + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "Text to search in title, debug name, description, faction, mission type, or reward names."}, + "mission_type": {"type": "string", "description": "Mission type such as Hauling, Delivery, Bounty Hunter, Mercenary, Racing, Salvage, or Mining."}, + "category": {"type": "string"}, + "faction": {"type": "string"}, + "system": {"type": "string", "description": "Star system such as Stanton or Pyro."}, + "illegal": {"type": "boolean"}, + "include_legacy": {"type": "boolean", "default": True}, + "limit": {"type": "integer", "minimum": 1, "maximum": 25, "default": 10}, + **version_controls, + }, + }, + }, + }, + { + "type": "function", + "function": { + "name": "get_scmdb_mission_rewards", + "description": "Fetch detailed SCMDB rewards and requirements for one Star Citizen mission/contract by id, debug name, or title.", + "parameters": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "debug_name": {"type": "string"}, + "title": {"type": "string"}, + "include_legacy": {"type": "boolean", "default": True}, + **version_controls, + }, + }, + }, + }, + ] + @classmethod def _uex_post_schemas(cls) -> list[dict[str, Any]]: return [ @@ -980,6 +1047,125 @@ class ToolRegistry: pending = [item for item in notifications if not item.get("date_read")] return {"count": len(pending), "notifications": pending} + async def list_scmdb_versions(self, channel: str | None = None) -> dict[str, Any]: + versions = await self.scmdb.list_versions() + channel_filter = (channel or "").casefold().strip() + if channel_filter in {"live", "ptu"}: + versions = [ + item + for item in versions + if f"-{channel_filter}." in str(item.get("version", "")).casefold() + ] + elif channel_filter not in {"", "latest"}: + return {"error": "SCMDB channel must be live, ptu, or latest."} + return { + "source": self.scmdb.base_url, + "count": len(versions), + "versions": versions, + "default_channel": "live", + } + + async def search_scmdb_missions( + self, + query: str = "", + mission_type: str | None = None, + category: str | None = None, + faction: str | None = None, + system: str | None = None, + illegal: bool | None = None, + include_legacy: bool = True, + limit: int = 10, + version: str | None = None, + channel: str = "live", + ) -> dict[str, Any]: + data = await self.scmdb.get_data(version=version, channel=channel) + q = (query or "").casefold().strip() + mission_type_filter = (mission_type or "").casefold().strip() + category_filter = (category or "").casefold().strip() + faction_filter = (faction or "").casefold().strip() + system_filter = (system or "").casefold().strip() + matched = [] + + for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy): + summary = self._summarize_scmdb_mission(data, mission, source=source) + if mission_type_filter and mission_type_filter not in str(summary.get("mission_type") or "").casefold(): + continue + if category_filter and category_filter not in str(summary.get("category") or "").casefold(): + continue + if faction_filter and faction_filter not in str(summary.get("faction") or "").casefold(): + continue + if system_filter and system_filter not in " ".join(summary.get("systems") or []).casefold(): + continue + if illegal is not None and bool(summary.get("illegal")) != illegal: + continue + if q and q not in self._scmdb_search_text(data, mission, summary): + continue + matched.append(summary) + + limit = max(1, min(limit, 25)) + return { + "source": self.scmdb.base_url, + "version": data.get("version"), + "matched": len(matched), + "returned": min(len(matched), limit), + "truncated": len(matched) > limit, + "missions": matched[:limit], + } + + async def get_scmdb_mission_rewards( + self, + id: str | None = None, + debug_name: str | None = None, + title: str | None = None, + include_legacy: bool = True, + version: str | None = None, + channel: str = "live", + ) -> dict[str, Any]: + if not any([id, debug_name, title]): + return {"error": "Provide id, debug_name, or title."} + + data = await self.scmdb.get_data(version=version, channel=channel) + exact = [] + fuzzy = [] + id_filter = (id or "").casefold().strip() + debug_filter = (debug_name or "").casefold().strip() + title_filter = (title or "").casefold().strip() + + for source, mission in self._scmdb_contracts(data, include_legacy=include_legacy): + mission_id = str(mission.get("id") or "").casefold() + mission_debug = str(mission.get("debugName") or "").casefold() + mission_title = str(mission.get("title") or "").casefold() + if id_filter and mission_id == id_filter: + exact.append((source, mission)) + elif debug_filter and mission_debug == debug_filter: + exact.append((source, mission)) + elif title_filter and mission_title == title_filter: + exact.append((source, mission)) + elif title_filter and title_filter in mission_title: + fuzzy.append((source, mission)) + elif debug_filter and debug_filter in mission_debug: + fuzzy.append((source, mission)) + + candidates = exact or fuzzy + if len(candidates) != 1: + return { + "source": self.scmdb.base_url, + "version": data.get("version"), + "matched": len(candidates), + "error": "No SCMDB mission matched." if not candidates else "Multiple SCMDB missions matched; refine by id or debug_name.", + "matches": [ + self._summarize_scmdb_mission(data, mission, source=source) + for source, mission in candidates[:10] + ], + } + + source, mission = candidates[0] + return { + "source": self.scmdb.base_url, + "version": data.get("version"), + "mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True), + } + def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]: action_id = str(uuid.uuid4()) payload = {key: value for key, value in payload.items() if value is not None} @@ -1171,6 +1357,248 @@ class ToolRegistry: def _is_number(value: Any) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) + @staticmethod + def _scmdb_contracts(data: dict[str, Any], include_legacy: bool = True) -> list[tuple[str, dict[str, Any]]]: + contracts = [ + ("contracts", mission) + for mission in data.get("contracts") or [] + if isinstance(mission, dict) + ] + if include_legacy: + contracts.extend( + ("legacyContracts", mission) + for mission in data.get("legacyContracts") or [] + if isinstance(mission, dict) + ) + return contracts + + @classmethod + def _summarize_scmdb_mission( + cls, + data: dict[str, Any], + mission: dict[str, Any], + source: str, + detailed: bool = False, + ) -> dict[str, Any]: + summary: dict[str, Any] = { + "id": mission.get("id"), + "debug_name": mission.get("debugName"), + "source": source, + "title": mission.get("title"), + "mission_type": mission.get("missionType"), + "category": mission.get("category"), + "faction": cls._scmdb_faction_name(data, mission.get("factionGuid")), + "systems": mission.get("systems") or [], + "illegal": bool(mission.get("illegal")), + "can_be_shared": bool(mission.get("canBeShared")), + "once_only": bool(mission.get("onceOnly")), + "time_to_complete_minutes": mission.get("timeToComplete"), + "max_players": mission.get("maxPlayersPerInstance"), + "cooldown_minutes": mission.get("personalCooldownTime"), + "min_standing": cls._scmdb_standing(mission.get("minStanding")), + "max_standing": cls._scmdb_standing(mission.get("maxStanding")), + "rewards": cls._scmdb_rewards(data, mission, detailed=detailed), + } + if detailed: + summary.update( + { + "description": cls._compact_scalar(mission.get("description")), + "locations": cls._scmdb_pool_names(data, "locationPools", mission.get("locations"), limit=20), + "destinations": cls._scmdb_pool_names(data, "locationPools", mission.get("destinations"), limit=20), + "prerequisites": cls._compact_scalar(mission.get("prerequisites")), + "available_in_prison": bool(mission.get("availableInPrison")), + "reaccept_after_abandoning": bool(mission.get("canReacceptAfterAbandoning")), + "reaccept_after_failing": bool(mission.get("canReacceptAfterFailing")), + "hide_in_mobiglas": bool(mission.get("hideInMobiGlas")), + } + ) + return {key: value for key, value in summary.items() if value not in (None, "", [], {})} + + @classmethod + def _scmdb_rewards(cls, data: dict[str, Any], mission: dict[str, Any], detailed: bool = False) -> dict[str, Any]: + rewards: dict[str, Any] = { + "uec": mission.get("rewardUEC"), + "buy_in": mission.get("buyIn"), + "dynamic_uec": mission.get("rewardIsDynamic"), + "reputation": cls._scmdb_indexed_reputation(data, mission.get("factionRewardsIndex")), + "failure_reputation": cls._scmdb_reputation_rewards(data, mission.get("factionRewards_fail")), + "items": cls._scmdb_item_rewards(mission.get("itemRewards")), + "blueprints": cls._scmdb_blueprint_rewards(data, mission.get("blueprintRewards"), detailed=detailed), + "hauling": cls._scmdb_hauling_orders(data, mission.get("haulingOrders")), + "partial_payouts": cls._scmdb_partial_payouts(data, mission.get("partialRewardPayoutIndex")), + } + return {key: value for key, value in rewards.items() if value not in (None, "", [], {})} + + @classmethod + def _scmdb_indexed_reputation(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]: + if not isinstance(index, int): + return [] + pools = data.get("factionRewardsPools") or [] + if index < 0 or index >= len(pools): + return [] + return cls._scmdb_reputation_rewards(data, pools[index]) + + @classmethod + def _scmdb_reputation_rewards(cls, data: dict[str, Any], rewards: Any) -> list[dict[str, Any]]: + if not isinstance(rewards, list): + return [] + result = [] + for reward in rewards: + if not isinstance(reward, dict): + continue + result.append( + { + "faction": cls._scmdb_faction_name(data, reward.get("factionGuid")), + "scope": cls._scmdb_scope_name(data, reward.get("scopeGuid")), + "amount": reward.get("amount"), + } + ) + return [item for item in result if item.get("amount") not in (None, "")] + + @classmethod + def _scmdb_item_rewards(cls, rewards: Any) -> list[dict[str, Any]]: + if not isinstance(rewards, list): + return [] + return [ + { + "name": reward.get("name"), + "amount": reward.get("amount"), + } + for reward in rewards + if isinstance(reward, dict) + ] + + @classmethod + def _scmdb_blueprint_rewards(cls, data: dict[str, Any], rewards: Any, detailed: bool = False) -> list[dict[str, Any]]: + if not isinstance(rewards, list): + return [] + result = [] + for reward in rewards: + if not isinstance(reward, dict): + continue + pool_id = reward.get("blueprintPool") + pool = (data.get("blueprintPools") or {}).get(pool_id) or {} + blueprints = pool.get("blueprints") or [] + result.append( + { + "pool": pool.get("name") or reward.get("poolName"), + "chance": reward.get("chance"), + "trigger": reward.get("trigger"), + "blueprints": [ + cls._compact_scalar(item.get("name")) + for item in blueprints[: 20 if detailed else 5] + if isinstance(item, dict) and item.get("name") + ], + } + ) + return result + + @classmethod + def _scmdb_hauling_orders(cls, data: dict[str, Any], orders: Any) -> list[dict[str, Any]]: + if not isinstance(orders, list): + return [] + result = [] + resources = data.get("resourcePools") or {} + for order in orders: + if not isinstance(order, dict): + continue + resource = resources.get(order.get("resource")) or {} + result.append( + { + "resource": resource.get("name") or order.get("resource"), + "min_scu": order.get("minSCU"), + "max_scu": order.get("maxSCU"), + "max_container_size_scu": order.get("maxContainerSize"), + } + ) + return result + + @classmethod + def _scmdb_partial_payouts(cls, data: dict[str, Any], index: Any) -> list[dict[str, Any]]: + if not isinstance(index, int): + return [] + pools = data.get("partialRewardPayoutPools") or [] + if index < 0 or index >= len(pools): + return [] + payouts = pools[index] + if not isinstance(payouts, list): + return [] + return [ + { + "min_percent": payout.get("minPercentage"), + "max_percent": payout.get("maxPercentage"), + "currency_multiplier": payout.get("currencyRewardMultiplier"), + "reputation_multipliers": payout.get("reputationMultipliers"), + } + for payout in payouts + if isinstance(payout, dict) + ] + + @staticmethod + def _scmdb_faction_name(data: dict[str, Any], guid: Any) -> str | None: + if not guid: + return None + faction = (data.get("factions") or {}).get(guid) + if isinstance(faction, dict): + return faction.get("name") or guid + return str(guid) + + @staticmethod + def _scmdb_scope_name(data: dict[str, Any], guid: Any) -> str | None: + if not guid: + return None + scope = (data.get("scopes") or {}).get(guid) + if isinstance(scope, dict): + return scope.get("scopeName") or guid + return str(guid) + + @staticmethod + def _scmdb_standing(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + return None + return { + key: value.get(key) + for key in ("name", "minReputation", "scopeName") + if value.get(key) not in (None, "") + } + + @staticmethod + def _scmdb_pool_names(data: dict[str, Any], pool_key: str, keys: Any, limit: int = 10) -> list[str]: + if not isinstance(keys, list): + return [] + pool = data.get(pool_key) or {} + names = [] + for key in keys[:limit]: + item = pool.get(key) + if isinstance(item, dict): + names.append(str(item.get("name") or key)) + else: + names.append(str(key)) + return names + + @classmethod + def _scmdb_search_text(cls, data: dict[str, Any], mission: dict[str, Any], summary: dict[str, Any]) -> str: + pieces = [ + summary.get("title"), + summary.get("debug_name"), + summary.get("mission_type"), + summary.get("category"), + summary.get("faction"), + mission.get("description"), + " ".join(summary.get("systems") or []), + ] + rewards = summary.get("rewards") or {} + pieces.append(str(rewards.get("uec") or "")) + for item in rewards.get("reputation") or []: + pieces.extend([item.get("faction"), item.get("scope"), item.get("amount")]) + for item in rewards.get("items") or []: + pieces.extend([item.get("name"), item.get("amount")]) + for item in rewards.get("blueprints") or []: + pieces.extend([item.get("pool"), " ".join(item.get("blueprints") or [])]) + for item in rewards.get("hauling") or []: + pieces.extend([item.get("resource"), item.get("min_scu"), item.get("max_scu")]) + return " ".join(str(piece) for piece in pieces if piece not in (None, "")).casefold() + @staticmethod def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]: return {