feat: add smdb intergration

This commit is contained in:
2026-05-07 21:20:43 -04:00
parent 767e929bf9
commit 71638fcaed
8 changed files with 646 additions and 1 deletions
+1
View File
@@ -2,6 +2,7 @@ OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_MODEL=qwen3.5:9b OLLAMA_MODEL=qwen3.5:9b
OLLAMA_NUM_CTX=64512 OLLAMA_NUM_CTX=64512
UEX_BASE_URL=https://api.uexcorp.space/2.0 UEX_BASE_URL=https://api.uexcorp.space/2.0
SCMDB_BASE_URL=https://scmdb.net
UEX_SECRET_KEY= UEX_SECRET_KEY=
UEX_BEARER_TOKEN= UEX_BEARER_TOKEN=
TRADERAI_USER_NAME= TRADERAI_USER_NAME=
+3
View File
@@ -5,6 +5,7 @@ Local Ollama-powered chat for UEX marketplace workflows.
## What It Does ## What It Does
- Searches active/current UEX marketplace listings through `GET /marketplace_listings/`. - 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. - 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. - Drafts negotiation messages and marketplace listings as pending actions.
- Requires browser approval before sending authenticated write requests to UEX. - 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. 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: 4. Install and run:
```powershell ```powershell
@@ -71,6 +73,7 @@ UEX notifications are checked every `UEX_NOTIFICATION_POLL_SECONDS` seconds by d
## Sources Used ## Sources Used
- UEX SwaggerHub OpenAPI v2.1: https://app.swaggerhub.com/apis-docs/dolejska-daniel/UEX-API/v2.1 - 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 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 - 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 - Ollama tool calling docs: https://docs.ollama.com/capabilities/tool-calling
+132
View File
@@ -121,6 +121,98 @@ class FakeUEX:
return {"status": "ok", "posted": self.posts[-1]} 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 @pytest.mark.asyncio
async def test_search_marketplace_listings_filters_locally(): async def test_search_marketplace_listings_filters_locally():
registry = ToolRegistry(FakeUEX()) 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 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 @pytest.mark.asyncio
async def test_search_uex_api_index_finds_history_tools(): async def test_search_uex_api_index_finds_history_tools():
registry = ToolRegistry(FakeUEX()) registry = ToolRegistry(FakeUEX())
+4
View File
@@ -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. 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. 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. 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. 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. 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. 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_commodity_price_history": "Summarizing commodity price history",
"summarize_uex_marketplace_price_history": "Summarizing marketplace price history", "summarize_uex_marketplace_price_history": "Summarizing marketplace price history",
"summarize_uex_currency_index_history": "Summarizing currency index 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_api_catalog": "Checking UEX API catalog",
"uex_get": "Fetching UEX data", "uex_get": "Fetching UEX data",
"uex_draft_post": "Drafting UEX write for approval", "uex_draft_post": "Drafting UEX write for approval",
+2
View File
@@ -15,6 +15,7 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False}, "ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False}, "ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "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_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "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}, "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_model: str = "qwen3.5:9b"
ollama_num_ctx: int = 64512 ollama_num_ctx: int = 64512
uex_base_url: str = "https://api.uexcorp.space/2.0" 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_secret_key: str | None = Field(default=None)
uex_bearer_token: str | None = Field(default=None) uex_bearer_token: str | None = Field(default=None)
traderai_user_name: str | None = Field(default=None) traderai_user_name: str | None = Field(default=None)
+73
View File
@@ -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
+3 -1
View File
@@ -23,6 +23,7 @@ from traderai.config import save_settings, settings_payload
from traderai.config import get_settings from traderai.config import get_settings
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
from traderai.scheduler import WakeScheduler from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient
from traderai.tools import ToolRegistry from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient from traderai.uex_client import UEXClient
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__ from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
@@ -75,7 +76,8 @@ def create_app() -> FastAPI:
memory = MemoryStore(settings.traderai_memory_path) memory = MemoryStore(settings.traderai_memory_path)
scheduler = WakeScheduler(memory) scheduler = WakeScheduler(memory)
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token) 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( agent = OllamaAgent(
settings.ollama_base_url, settings.ollama_base_url,
settings.ollama_model, settings.ollama_model,
+428
View File
@@ -6,6 +6,7 @@ from typing import Any, Awaitable, Callable
from traderai.memory import MemoryStore from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler from traderai.scheduler import WakeScheduler
from traderai.scmdb_client import SCMDBClient
from traderai.uex_client import UEXClient from traderai.uex_client import UEXClient
@@ -155,8 +156,10 @@ class ToolRegistry:
require_write_approval: bool = True, require_write_approval: bool = True,
memory: MemoryStore | None = None, memory: MemoryStore | None = None,
scheduler: WakeScheduler | None = None, scheduler: WakeScheduler | None = None,
scmdb: SCMDBClient | None = None,
) -> None: ) -> None:
self.uex = uex self.uex = uex
self.scmdb = scmdb or SCMDBClient()
self.require_write_approval = require_write_approval self.require_write_approval = require_write_approval
self.memory = memory self.memory = memory
self.scheduler = scheduler self.scheduler = scheduler
@@ -173,6 +176,9 @@ class ToolRegistry:
"schedule_wake_job": self.schedule_wake_job, "schedule_wake_job": self.schedule_wake_job,
"list_wake_jobs": self.list_wake_jobs, "list_wake_jobs": self.list_wake_jobs,
"check_uex_notifications": self.check_uex_notifications, "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_api_catalog"] = self.uex_api_catalog
self.handlers["uex_get"] = self.uex_get self.handlers["uex_get"] = self.uex_get
@@ -197,6 +203,7 @@ class ToolRegistry:
*self._history_summary_schemas(), *self._history_summary_schemas(),
*self._uex_post_schemas(), *self._uex_post_schemas(),
*self._uex_delete_schemas(), *self._uex_delete_schemas(),
*self._scmdb_schemas(),
{ {
"type": "function", "type": "function",
"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 @classmethod
def _uex_post_schemas(cls) -> list[dict[str, Any]]: def _uex_post_schemas(cls) -> list[dict[str, Any]]:
return [ return [
@@ -980,6 +1047,125 @@ class ToolRegistry:
pending = [item for item in notifications if not item.get("date_read")] pending = [item for item in notifications if not item.get("date_read")]
return {"count": len(pending), "notifications": pending} 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]: def _pending(self, label: str, endpoint: str, payload: dict[str, Any], method: str = "POST") -> dict[str, Any]:
action_id = str(uuid.uuid4()) action_id = str(uuid.uuid4())
payload = {key: value for key, value in payload.items() if value is not None} 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: def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, 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 @staticmethod
def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]: def _summarize_listing(listing: dict[str, Any]) -> dict[str, Any]:
return { return {