feature: Initial info gathering

This commit is contained in:
Keith Solomon
2026-02-08 23:25:50 -06:00
parent e1a144480d
commit 58353b249a
9 changed files with 1153 additions and 8 deletions

13
lib/project-config.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
/**
* Project runtime configuration values.
*
* @package Spacetraders
*/
return array(
'api_base_url' => 'https://api.spacetraders.io/v2',
'api_timeout' => 30,
'cache_ttl' => 600,
'db_path' => dirname( __DIR__ ) . '/data/spacetraders.sqlite',
);

View File

@@ -0,0 +1,52 @@
<?php
/**
* Spacetraders API Exception Class
*
* PHP version 7.4
*
* @category Exception
* @package Spacetraders
* @author Keith Solomon <keith@keithsolomon.net>
* @license MIT License
* @link https://github.com/your-repo/spacetraders
*/
/**
* Custom exception class for Spacetraders API related errors.
*
* This exception is thrown when API calls to the Spacetraders service
* encounter errors such as network issues, invalid responses, authentication
* failures, or other API-specific problems.
*
* @extends RuntimeException
*/
class SpacetradersApiException extends RuntimeException {
/**
* Error payload data from the API response.
*
* @var array<string,mixed>
*/
protected array $errorPayload = array();
/**
* Constructor for SpacetradersApiException.
*
* @param string $message The exception message.
* @param int $code The exception code (default: 0).
* @param array<string,mixed> $errorPayload The error payload from API response (default: empty array).
*/
public function __construct( string $message, int $code = 0, array $errorPayload = array() ) {
parent::__construct( $message, $code );
$this->errorPayload = $errorPayload;
}
/**
* Get the error payload from the API response.
*
* @return array<string,mixed>
*/
public function getErrorPayload(): array {
return $this->errorPayload;
}
}

465
lib/spacetraders-api.php Normal file
View File

@@ -0,0 +1,465 @@
<?php
/**
* Spacetraders API Client Library
*
* This library provides a simple interface to interact with the Spacetraders API,
* allowing you to manage your agents, ships, and other resources in the game.
* It includes methods for authentication, making API requests, and handling responses,
* making it easier to integrate Spacetraders into your applications or scripts.
*
* @package SpacetradersAPI
* @author Keith Solomon <keith@keithsolomon.net>
* @license MIT License
* @version GIT: <git_id>
* @link https://spacetraders.io
*/
require_once __DIR__ . '/spacetraders-api-exception.php';
require_once __DIR__ . '/spacetraders-storage.php';
/**
* Spacetraders API Client
*
* Main client class for interacting with the Spacetraders API. Provides methods
* for authentication, managing agents, ships, contracts, systems, and performing
* various game actions like navigation, trading, and resource extraction.
*
* @package SpacetradersAPI
*/
class SpacetradersApi {
/**
* The base URL for the Spacetraders API.
*
* @var string
*/
private string $baseUrl;
/**
* The authentication token for API requests.
*
* @var string|null
*/
private ?string $token;
/**
* The timeout for HTTP requests in seconds.
*
* @var int
*/
private int $timeout;
/**
* Optional SQLite storage for settings and cache.
*
* @var SpacetradersStorage|null
*/
private ?SpacetradersStorage $storage;
/**
* Default cache lifetime in seconds.
*
* @var int
*/
private int $cacheTtl;
/**
* Constructor for SpacetradersApi.
*
* @param string|null $token The authentication token for API requests ( default: null ).
* @param string $baseUrl The base URL for the Spacetraders API ( default: 'https://api.spacetraders.io/v2' ).
* @param int $timeout The timeout for HTTP requests in seconds ( default: 30 ).
* @param SpacetradersStorage|null $storage Optional SQLite storage object for settings and caching.
* @param int $cacheTtl API cache lifetime in seconds (default: 600).
*/
public function __construct(
?string $token = null,
string $baseUrl = 'https://api.spacetraders.io/v2',
int $timeout = 30,
?SpacetradersStorage $storage = null,
int $cacheTtl = 600
) {
$this->baseUrl = rtrim( $baseUrl, '/' );
$this->token = $token;
$this->timeout = $timeout;
$this->storage = $storage;
$this->cacheTtl = max( 1, $cacheTtl );
}
/**
* Set the authentication token for API requests.
*
* @param string $token The authentication token to set.
*
* @return void
*/
public function setToken( string $token ): void {
$this->token = trim( $token );
}
/**
* Get the current authentication token.
*
* @return string|null The authentication token or null if not set.
*/
public function getToken(): ?string {
return $this->token;
}
/**
* Attach storage for API caching and persisted settings.
*
* @param SpacetradersStorage|null $storage Storage handler.
*
* @return void
*/
public function setStorage( ?SpacetradersStorage $storage ): void {
$this->storage = $storage;
}
/**
* Set cache lifetime for cached API calls in seconds.
*
* @param int $cacheTtl Cache lifetime in seconds.
*
* @return void
*/
public function setCacheTtl( int $cacheTtl ): void {
$this->cacheTtl = max( 1, $cacheTtl );
}
/**
* Make an HTTP request to the Spacetraders API.
*
* @param string $method The HTTP method (GET, POST, etc.).
* @param string $endpoint The API endpoint to call.
* @param array<string,mixed> $payload The request payload data.
* @param array<string,mixed> $query The query parameters.
*
* @return array<string,mixed>
* @throws SpacetradersApiException When cURL fails to initialize, JSON encoding fails, network errors occur, or API returns an error response.
*/
public function request(
string $method,
string $endpoint,
array $payload = array(),
array $query = array()
): array {
$url = $this->_buildUrl( $endpoint, $query );
$method = strtoupper( $method );
$cacheKey = $this->_buildCacheKey( $method, $url, $payload );
if ($method === 'GET' && $this->storage !== null ) {
$cachedResponse = $this->storage->getCache( $cacheKey );
if (is_array( $cachedResponse ) ) {
return $cachedResponse;
}
}
$ch = curl_init( $url );
if ($ch === false ) {
throw new SpacetradersApiException( 'Unable to initialize cURL.' );
}
$headers = array( 'Accept: application/json' );
$jsonPayload = null;
if (! empty( $this->token ) ) {
$headers[] = 'Authorization: Bearer ' . $this->token;
}
if (! empty( $payload ) ) {
$jsonPayload = json_encode( $payload );
if ($jsonPayload === false ) {
throw new SpacetradersApiException( 'Unable to encode request payload as JSON.' );
}
$headers[] = 'Content-Type: application/json';
}
curl_setopt_array(
$ch,
array(
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => $this->timeout,
)
);
if ($jsonPayload !== null ) {
curl_setopt( $ch, CURLOPT_POSTFIELDS, $jsonPayload );
}
$rawResponse = curl_exec( $ch );
if ($rawResponse === false ) {
$error = curl_error( $ch );
curl_close( $ch );
throw new SpacetradersApiException( 'Network error: ' . $error );
}
$statusCode = (int) curl_getinfo( $ch, CURLINFO_HTTP_CODE );
curl_close( $ch );
$decoded = json_decode( $rawResponse, true );
if (! is_array( $decoded ) ) {
throw new SpacetradersApiException(
'API returned an invalid JSON response.',
$statusCode
);
}
if ($statusCode >= 400 ) {
$message = $decoded['error']['message'] ?? 'Unknown API error.';
$errorCode = (int) ( $decoded['error']['code'] ?? $statusCode );
throw new SpacetradersApiException( $message, $errorCode, $decoded );
}
if ($method === 'GET' && $this->storage !== null ) {
$this->storage->setCache( $cacheKey, $decoded, $this->cacheTtl );
$this->storage->purgeExpiredCache();
}
return $decoded;
}
/**
* Register a new agent and returns the API response.
*
* @param array<string,mixed> $options Optional registration options: `symbol`, `faction`, and `email`.
*
* @return array<string,mixed>
*/
public function registerAgent( array $options ): array {
return $this->request( 'POST', '/register', $options );
}
/**
* Get the current agent's information.
*
* @return array<string,mixed>
*/
public function getMyAgent(): array {
return $this->request( 'GET', '/my/agent' );
}
/**
* Get a list of all ships owned by the current agent.
*
* @param array<string,mixed> $query
*
* @return array<string,mixed>
*/
public function listMyShips( array $query = array() ): array {
return $this->request( 'GET', '/my/ships', array(), $query );
}
/**
* Get information about a specific ship owned by the current agent.
*
* @param string $shipSymbol The symbol of the ship to retrieve.
*
* @return array<string,mixed>
*/
public function getShip( string $shipSymbol ): array {
return $this->request( 'GET', '/my/ships/' . rawurlencode( $shipSymbol ) );
}
/**
* Get a list of all contracts for the current agent.
*
* @param array<string,mixed> $query
*
* @return array<string,mixed>
*/
public function listMyContracts( array $query = array() ): array {
return $this->request( 'GET', '/my/contracts', array(), $query );
}
/**
* Accept a contract for the current agent.
*
* @param string $contractId The ID of the contract to accept.
*
* @return array<string,mixed>
*/
public function acceptContract( string $contractId ): array {
return $this->request(
'POST',
'/my/contracts/' . rawurlencode( $contractId ) . '/accept'
);
}
/**
* Get a list of all systems in the universe.
*
* @param array<string,mixed> $query
*
* @return array<string,mixed>
*/
public function listSystems( array $query = array() ): array {
return $this->request( 'GET', '/systems', array(), $query );
}
/**
* Get information about a specific system.
*
* @param string $systemSymbol The symbol of the system to retrieve.
*
* @return array<string,mixed>
*/
public function getSystem( string $systemSymbol ): array {
return $this->request( 'GET', '/systems/' . rawurlencode( $systemSymbol ) );
}
/**
* Get a list of waypoints in a specific system.
*
* @param string $systemSymbol The symbol of the system to retrieve waypoints from.
* @param array<string,mixed> $query
*
* @return array<string,mixed>
*/
public function listWaypoints( string $systemSymbol, array $query = array() ): array {
return $this->request(
'GET',
'/systems/' . rawurlencode( $systemSymbol ) . '/waypoints',
array(),
$query
);
}
/**
* Navigate a ship to a specific waypoint.
*
* @param string $shipSymbol The symbol of the ship to navigate.
* @param string $waypointSymbol The symbol of the waypoint to navigate to.
*
* @return array<string,mixed>
*/
public function navigateShip( string $shipSymbol, string $waypointSymbol ): array {
return $this->request(
'POST',
'/my/ships/' . rawurlencode( $shipSymbol ) . '/navigate',
array( 'waypointSymbol' => $waypointSymbol )
);
}
/**
* Put a ship into orbit around its current waypoint.
*
* @param string $shipSymbol The symbol of the ship to put into orbit.
*
* @return array<string,mixed>
*/
public function orbitShip( string $shipSymbol ): array {
return $this->request(
'POST',
'/my/ships/' . rawurlencode( $shipSymbol ) . '/orbit'
);
}
/**
* Dock a ship at its current waypoint.
*
* @param string $shipSymbol The symbol of the ship to dock.
*
* @return array<string,mixed>
*/
public function dockShip( string $shipSymbol ): array {
return $this->request(
'POST',
'/my/ships/' . rawurlencode( $shipSymbol ) . '/dock'
);
}
/**
* Extract resources from the current waypoint using the specified ship.
*
* @param string $shipSymbol The symbol of the ship to use for resource extraction.
*
* @return array<string,mixed>
*/
public function extractResources( string $shipSymbol ): array {
return $this->request(
'POST',
'/my/ships/' . rawurlencode( $shipSymbol ) . '/extract'
);
}
/**
* Purchase cargo for a specific ship at its current waypoint.
*
* @param string $shipSymbol The symbol of the ship to purchase cargo for.
* @param string $tradeSymbol The symbol of the trade good to purchase.
* @param int $units The number of units to purchase.
*
* @return array<string,mixed>
*/
public function purchaseCargo( string $shipSymbol, string $tradeSymbol, int $units ): array {
return $this->request(
'POST',
'/my/ships/' . rawurlencode( $shipSymbol ) . '/purchase',
array(
'symbol' => $tradeSymbol,
'units' => $units,
)
);
}
/**
* Sell cargo from a specific ship at its current waypoint.
*
* @param string $shipSymbol The symbol of the ship to sell cargo from.
* @param string $tradeSymbol The symbol of the trade good to sell.
* @param int $units The number of units to sell.
*
* @return array<string,mixed>
*/
public function sellCargo( string $shipSymbol, string $tradeSymbol, int $units ): array {
return $this->request(
'POST',
'/my/ships/' . rawurlencode( $shipSymbol ) . '/sell',
array(
'symbol' => $tradeSymbol,
'units' => $units,
)
);
}
/**
* Build the full URL for an API endpoint with optional query parameters.
*
* @param string $endpoint The API endpoint path.
* @param array<string,mixed> $query The query parameters to append.
*
* @return string The complete URL.
*/
private function _buildUrl( string $endpoint, array $query = array() ): string {
$url = $this->baseUrl . '/' . ltrim( $endpoint, '/' );
if (! empty( $query ) ) {
$url .= '?' . http_build_query( $query );
}
return $url;
}
/**
* Build a deterministic cache key for an API request.
*
* @param string $method HTTP method.
* @param string $url Full URL.
* @param array<string,mixed> $payload Request payload.
*
* @return string
*/
private function _buildCacheKey( string $method, string $url, array $payload ): string {
$payloadJson = json_encode( $payload );
if ($payloadJson === false ) {
$payloadJson = '';
}
return hash( 'sha256', $method . '|' . $url . '|' . $payloadJson );
}
}

View File

@@ -0,0 +1,245 @@
<?php
/**
* Spacetraders SQLite storage for configuration and API cache.
*
* @category Storage
* @package SpacetradersAPI
* @author Keith Solomon <keith@keithsolomon.net>
* @license MIT License
* @link https://git.keithsolomon.net/keith/Spacetraders
*/
/**
* Spacetraders SQLite storage for configuration and API cache.
*
* @package SpacetradersAPI
*/
class SpacetradersStorage {
/**
* Database handle.
*
* @var PDO
*/
private PDO $db;
/**
* Create storage and initialize schema.
*
* @param string $dbPath Absolute or project-relative SQLite database path.
*/
public function __construct( string $dbPath ) {
$directory = dirname( $dbPath );
if (! is_dir( $directory ) ) {
mkdir( $directory, 0777, true );
}
$this->db = new PDO( 'sqlite:' . $dbPath );
$this->db->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION );
$this->db->setAttribute( PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC );
$this->_initializeSchema();
}
/**
* Create required tables if they do not exist.
*
* @return void
*/
private function _initializeSchema(): void {
$this->db->exec(
'CREATE TABLE IF NOT EXISTS settings (
setting_key TEXT PRIMARY KEY,
setting_value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)'
);
$this->db->exec(
'CREATE TABLE IF NOT EXISTS api_cache (
cache_key TEXT PRIMARY KEY,
response_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)'
);
$this->db->exec(
'CREATE INDEX IF NOT EXISTS idx_api_cache_expires
ON api_cache (expires_at)'
);
}
/**
* Save a generic setting.
*
* @param string $key Setting key.
* @param string $value Setting value.
*
* @return void
*/
public function setSetting( string $key, string $value ): void {
$stmt = $this->db->prepare(
'INSERT INTO settings (setting_key, setting_value, updated_at)
VALUES (:key, :value, :updated_at)
ON CONFLICT(setting_key) DO UPDATE SET
setting_value = excluded.setting_value,
updated_at = excluded.updated_at'
);
$stmt->execute(
array(
':key' => $key,
':value' => $value,
':updated_at' => time(),
)
);
}
/**
* Load a generic setting.
*
* @param string $key Setting key.
*
* @return string|null
*/
public function getSetting( string $key ): ?string {
$stmt = $this->db->prepare(
'SELECT setting_value FROM settings WHERE setting_key = :key LIMIT 1'
);
$stmt->execute( array( ':key' => $key ) );
$row = $stmt->fetch();
if (! is_array( $row ) || ! isset( $row['setting_value'] ) ) {
return null;
}
return (string) $row['setting_value'];
}
/**
* Save the agent token in settings.
*
* @param string $token Agent token.
*
* @return void
*/
public function setAgentToken( string $token ): void {
$this->setSetting( 'agent_token', $token );
}
/**
* Get the stored agent token, if available.
*
* @return string|null
*/
public function getAgentToken(): ?string {
return $this->getSetting( 'agent_token' );
}
/**
* Fetch a valid cached response by cache key.
*
* @param string $cacheKey Cache key.
*
* @return array<string,mixed>|null
*/
public function getCache( string $cacheKey ): ?array {
$stmt = $this->db->prepare(
'SELECT response_json, expires_at
FROM api_cache
WHERE cache_key = :cache_key
LIMIT 1'
);
$stmt->execute( array( ':cache_key' => $cacheKey ) );
$row = $stmt->fetch();
if (! is_array( $row ) ) {
return null;
}
if ((int) $row['expires_at'] <= time() ) {
$this->deleteCache( $cacheKey );
return null;
}
$decoded = json_decode( (string) $row['response_json'], true );
if (! is_array( $decoded ) ) {
$this->deleteCache( $cacheKey );
return null;
}
return $decoded;
}
/**
* Store API response in cache.
*
* @param string $cacheKey Cache key.
* @param array<string,mixed> $response API response payload.
* @param int $ttl Time to live in seconds.
*
* @return void
*/
public function setCache( string $cacheKey, array $response, int $ttl = 600 ): void {
$json = json_encode( $response );
if ($json === false ) {
return;
}
$createdAt = time();
$expiresAt = $createdAt + max( 1, $ttl );
$stmt = $this->db->prepare(
'INSERT INTO api_cache (cache_key, response_json, created_at, expires_at)
VALUES (:cache_key, :response_json, :created_at, :expires_at)
ON CONFLICT(cache_key) DO UPDATE SET
response_json = excluded.response_json,
created_at = excluded.created_at,
expires_at = excluded.expires_at'
);
$stmt->execute(
array(
':cache_key' => $cacheKey,
':response_json' => $json,
':created_at' => $createdAt,
':expires_at' => $expiresAt,
)
);
}
/**
* Remove one cache row.
*
* @param string $cacheKey Cache key.
*
* @return void
*/
public function deleteCache( string $cacheKey ): void {
$stmt = $this->db->prepare(
'DELETE FROM api_cache WHERE cache_key = :cache_key'
);
$stmt->execute( array( ':cache_key' => $cacheKey ) );
}
/**
* Remove all expired cache rows.
*
* @return void
*/
public function purgeExpiredCache(): void {
$stmt = $this->db->prepare(
'DELETE FROM api_cache WHERE expires_at <= :now'
);
$stmt->execute( array( ':now' => time() ) );
}
/**
* Remove all cached API rows.
*
* @return void
*/
public function clearAllCache(): void {
$this->db->exec( 'DELETE FROM api_cache' );
}
}