summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--golang/cmd/spacetraders/main.go78
-rw-r--r--golang/go.mod5
-rw-r--r--golang/go.sum2
-rw-r--r--golang/pkg/api/agents.go14
-rw-r--r--golang/pkg/api/api.go87
-rw-r--r--golang/pkg/api/client.go75
-rw-r--r--golang/pkg/api/errors.go23
-rw-r--r--golang/pkg/api/priority_queue.go44
-rw-r--r--golang/pkg/api/register.go20
-rw-r--r--golang/pkg/database/migrations.go78
-rw-r--r--golang/pkg/database/sql/000_init.sql7
-rw-r--r--golang/pkg/database/tokens.go14
12 files changed, 447 insertions, 0 deletions
diff --git a/golang/cmd/spacetraders/main.go b/golang/cmd/spacetraders/main.go
new file mode 100644
index 0000000..937144c
--- /dev/null
+++ b/golang/cmd/spacetraders/main.go
@@ -0,0 +1,78 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/signal"
+
+ "git.adyxax.org/adyxax/spacetraders/v2/pkg/api"
+ "git.adyxax.org/adyxax/spacetraders/v2/pkg/database"
+)
+
+func main() {
+ opts := &slog.HandlerOptions{
+ // //AddSource: true,
+ Level: slog.LevelDebug,
+ }
+ //logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
+ logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
+ slog.SetDefault(logger)
+
+ ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
+ defer cancel()
+ db, err := database.DBInit(ctx, "./spacetraders.db")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "DbInit error %+v\n", err)
+ os.Exit(1)
+ }
+ client := api.NewClient(ctx)
+ defer client.Close()
+ err = run( //ctx,
+ db,
+ client,
+ //os.Args,
+ //os.Getenv,
+ //os.Getwd,
+ //os.Stdin,
+ //os.Stdout,
+ //os.Stderr,
+ )
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "%s\n", err)
+ if err = db.Close(); err != nil {
+ fmt.Fprintf(os.Stderr, "%s\n", err)
+ }
+ os.Exit(2)
+ }
+}
+
+func run( // ctx context.Context,
+ db *database.DB,
+ client *api.Client,
+ //args []string,
+ //getenv func(string) string,
+ //getwd func() (string, error),
+ //stdin io.Reader,
+ //stdout, stderr io.Writer,
+) (err error) {
+ // ----- Get token or register ---------------------------------------------
+ token, err := db.GetToken()
+ if err != nil || token == "" {
+ var r api.APIMessage[api.RegisterMessage, any]
+ if r, err = client.Register("COSMIC", "ADYXAX-GO"); err != nil {
+ // TODO handle server reset
+ fmt.Printf("%+v, %+v\n", r, err)
+ return err
+ }
+ if err = db.AddToken(r.Data.Token); err != nil {
+ return err
+ }
+ }
+ client.SetToken(token)
+ // ----- Update agent ------------------------------------------------------
+ agent, err := client.MyAgent()
+ slog.Info("agent", "agent", agent, "err", err)
+ return err
+}
diff --git a/golang/go.mod b/golang/go.mod
new file mode 100644
index 0000000..25e76bd
--- /dev/null
+++ b/golang/go.mod
@@ -0,0 +1,5 @@
+module git.adyxax.org/adyxax/spacetraders/v2
+
+go 1.22.2
+
+require github.com/mattn/go-sqlite3 v1.14.22
diff --git a/golang/go.sum b/golang/go.sum
new file mode 100644
index 0000000..e8d092a
--- /dev/null
+++ b/golang/go.sum
@@ -0,0 +1,2 @@
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
diff --git a/golang/pkg/api/agents.go b/golang/pkg/api/agents.go
new file mode 100644
index 0000000..aa8107a
--- /dev/null
+++ b/golang/pkg/api/agents.go
@@ -0,0 +1,14 @@
+package api
+
+type AgentMessage struct {
+ AccountID string `json:"accountId"`
+ Credits int `json:"credits"`
+ Headquarters string `json:"headquarters"`
+ ShipCount int `json:"shipCount"`
+ StartingFaction string `json:"startingFaction"`
+ Symbol string `json:"symbol"`
+}
+
+func (c *Client) MyAgent() (APIMessage[AgentMessage, any], error) {
+ return Send[AgentMessage](c, "GET", "/my/agent", nil)
+}
diff --git a/golang/pkg/api/api.go b/golang/pkg/api/api.go
new file mode 100644
index 0000000..662bb87
--- /dev/null
+++ b/golang/pkg/api/api.go
@@ -0,0 +1,87 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "time"
+)
+
+type Error[T any] struct {
+ Code int `json:"code"`
+ Data T `json:"data"`
+ Message string `json:"message"`
+}
+
+type APIMessage[T any, E any] struct {
+ Data T `json:"data"`
+ Error Error[E] `json:"error"`
+ //meta
+}
+
+type Response struct {
+ Response []byte
+ Err error
+}
+
+func Send[T any](c *Client, method, path string, payload any) (message APIMessage[T, any], err error) {
+ resp := make(chan *Response)
+ c.channel <- &Request{
+ method: method,
+ path: path,
+ payload: payload,
+ priority: 10,
+ resp: resp,
+ }
+ res := <-resp
+ if res.Err != nil {
+ return message, res.Err
+ }
+ err = json.Unmarshal(res.Response, &message)
+ return message, err
+}
+
+func (c *Client) sendOne(method, path string, payload any) (body []byte, err error) {
+ slog.Debug("Request", "method", method, "path", path, "payload", payload)
+ var req *http.Request
+ if payload != nil {
+ body, err = json.Marshal(payload)
+ if err == nil {
+ req, err = http.NewRequest(method, c.baseURL+path, bytes.NewBuffer(body))
+ } else {
+ return nil, err
+ }
+ } else {
+ req, err = http.NewRequest(method, c.baseURL+path, nil)
+ }
+ if err != nil {
+ return nil, err
+ }
+ req.Header = *c.headers
+ req = req.WithContext(c.ctx)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ slog.Error("sendOne Do", "method", method, "path", path, "error", err)
+ return nil, err
+ }
+ defer func() {
+ if e := resp.Body.Close(); err == nil {
+ err = e
+ }
+ }()
+ if body, err = io.ReadAll(resp.Body); err != nil {
+ slog.Error("sendOne ReadAll", "method", method, "path", path, "error", err)
+ return nil, err
+ }
+ slog.Debug("Response", "body", string(body))
+ switch resp.StatusCode {
+ case 429:
+ e := decode429(body)
+ time.Sleep(time.Duration(e.Error.Data.RetryAfter * float64(time.Second)))
+ return c.sendOne(method, path, payload)
+ }
+ return body, nil
+}
diff --git a/golang/pkg/api/client.go b/golang/pkg/api/client.go
new file mode 100644
index 0000000..70f3e68
--- /dev/null
+++ b/golang/pkg/api/client.go
@@ -0,0 +1,75 @@
+package api
+
+import (
+ "container/heap"
+ "context"
+ "net/http"
+ "time"
+)
+
+type Client struct {
+ baseURL string
+ channel chan *Request
+ ctx context.Context
+ headers *http.Header
+ httpClient *http.Client
+ pq *PriorityQueue
+}
+
+func NewClient(ctx context.Context) *Client {
+ pq := make(PriorityQueue, 0)
+ heap.Init(&pq)
+ client := &Client{
+ baseURL: "https://api.spacetraders.io/v2",
+ channel: make(chan *Request),
+ ctx: ctx,
+ headers: &http.Header{
+ "Content-Type": {"application/json"},
+ },
+ httpClient: &http.Client{
+ Timeout: time.Minute,
+ },
+ pq: &pq,
+ }
+ go queueProcessor(client)
+ return client
+}
+
+func (c *Client) Close() {
+ close(c.channel)
+}
+
+func (c *Client) SetToken(token string) {
+ c.headers.Set("Authorization", "Bearer "+token)
+}
+
+func queueProcessor(client *Client) {
+ var ok bool
+ for {
+ // The queue is empty so we do this blocking call
+ req := <-client.channel
+ heap.Push(client.pq, req)
+ // we enqueue all values read from the channel and process the queue's
+ // contents until empty. We keep reading the channel as long as this
+ // emptying goes on
+ for {
+ select {
+ case req = <-client.channel:
+ heap.Push(client.pq, req)
+ default:
+ if client.pq.Len() == 0 {
+ break
+ }
+ // we process one
+ if req, ok = heap.Pop(client.pq).(*Request); !ok {
+ panic("queueProcessor got something other than a Request on its channel")
+ }
+ response, err := client.sendOne(req.method, req.path, req.payload)
+ req.resp <- &Response{
+ Response: response,
+ Err: err,
+ }
+ }
+ }
+ }
+}
diff --git a/golang/pkg/api/errors.go b/golang/pkg/api/errors.go
new file mode 100644
index 0000000..d39a205
--- /dev/null
+++ b/golang/pkg/api/errors.go
@@ -0,0 +1,23 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+type RateLimitError struct {
+ LimitType string `json:"type"`
+ RetryAfter float64 `json:"retryAfter"`
+ LimitBurst int `json:"limitBurst"`
+ LimitPerSecond int `json:"limitPerSecond"`
+ Remaining int `json:"remaining"`
+ Reset time.Time `json:"reset"`
+}
+
+func decode429(msg []byte) (e APIMessage[any, RateLimitError]) {
+ if err := json.Unmarshal(msg, &e); err != nil {
+ panic(fmt.Sprintf("Failed to decode419: %+v", err))
+ }
+ return e
+}
diff --git a/golang/pkg/api/priority_queue.go b/golang/pkg/api/priority_queue.go
new file mode 100644
index 0000000..077c8f7
--- /dev/null
+++ b/golang/pkg/api/priority_queue.go
@@ -0,0 +1,44 @@
+package api
+
+type Request struct {
+ index int
+ priority int
+
+ method string
+ path string
+ payload any
+ resp chan *Response
+}
+
+type PriorityQueue []*Request
+
+func (pq PriorityQueue) Len() int {
+ return len(pq)
+}
+
+func (pq PriorityQueue) Less(i, j int) bool {
+ return pq[i].priority < pq[j].priority
+}
+
+func (pq PriorityQueue) Swap(i, j int) {
+ pq[i], pq[j] = pq[j], pq[i]
+ pq[i].index = i
+ pq[j].index = j
+}
+
+func (pq *PriorityQueue) Push(x any) {
+ n := len(*pq)
+ item := x.(*Request)
+ item.index = n
+ *pq = append(*pq, item)
+}
+
+func (pq *PriorityQueue) Pop() any {
+ old := *pq
+ n := len(old)
+ item := old[n-1]
+ old[n-1] = nil // avoid memory leak
+ item.index = -1 // for safety
+ *pq = old[0 : n-1]
+ return item
+}
diff --git a/golang/pkg/api/register.go b/golang/pkg/api/register.go
new file mode 100644
index 0000000..4f95e2f
--- /dev/null
+++ b/golang/pkg/api/register.go
@@ -0,0 +1,20 @@
+package api
+
+type RegisterMessage struct {
+ //agent
+ //contract
+ //faction
+ //ship
+ Token string `json:"token"`
+}
+
+func (c *Client) Register(faction, symbol string) (APIMessage[RegisterMessage, any], error) {
+ type RegisterRequest struct {
+ Faction string `json:"faction"`
+ Symbol string `json:"symbol"`
+ }
+ return Send[RegisterMessage](c, "POST", "/register", RegisterRequest{
+ Faction: faction,
+ Symbol: symbol,
+ })
+}
diff --git a/golang/pkg/database/migrations.go b/golang/pkg/database/migrations.go
new file mode 100644
index 0000000..94207a5
--- /dev/null
+++ b/golang/pkg/database/migrations.go
@@ -0,0 +1,78 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "embed"
+ "io/fs"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+type DB struct {
+ ctx context.Context
+ db *sql.DB
+}
+
+//go:embed sql/*.sql
+var schemaFiles embed.FS
+
+func DBInit(ctx context.Context, url string) (myDB *DB, err error) {
+ var db *sql.DB
+ if db, err = sql.Open("sqlite3", url); err != nil {
+ return nil, err
+ }
+ defer func() {
+ if err != nil {
+ _ = db.Close()
+ }
+ }()
+
+ if _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
+ return nil, err
+ }
+ if _, err = db.ExecContext(ctx, "PRAGMA journal_mode = WAL"); err != nil {
+ return nil, err
+ }
+
+ var version int
+ if err = db.QueryRowContext(ctx, `SELECT version FROM schema_version;`).Scan(&version); err != nil {
+ if err.Error() == "no such table: schema_version" {
+ version = 0
+ } else {
+ return nil, err
+ }
+ }
+
+ statements := make([]string, 0)
+ err = fs.WalkDir(schemaFiles, ".", func(path string, d fs.DirEntry, err error) error {
+ if d.IsDir() || err != nil {
+ return err
+ }
+ var stmts []byte
+ if stmts, err = schemaFiles.ReadFile(path); err != nil {
+ return err
+ } else {
+ statements = append(statements, string(stmts))
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ for version < len(statements) {
+ if _, err = db.ExecContext(ctx, statements[version]); err != nil {
+ return nil, err
+ }
+ version++
+ }
+ if _, err = db.ExecContext(ctx, `DELETE FROM schema_version; INSERT INTO schema_version (version) VALUES (?);`, version); err != nil {
+ return nil, err
+ }
+ return &DB{ctx: ctx, db: db}, nil
+}
+
+func (db *DB) Close() error {
+ return db.db.Close()
+}
diff --git a/golang/pkg/database/sql/000_init.sql b/golang/pkg/database/sql/000_init.sql
new file mode 100644
index 0000000..c06d2d3
--- /dev/null
+++ b/golang/pkg/database/sql/000_init.sql
@@ -0,0 +1,7 @@
+CREATE TABLE schema_version (
+ version INTEGER NOT NULL
+);
+CREATE TABLE tokens (
+ id INTEGER PRIMARY KEY,
+ data TEXT NOT NULL
+);
diff --git a/golang/pkg/database/tokens.go b/golang/pkg/database/tokens.go
new file mode 100644
index 0000000..16bda2b
--- /dev/null
+++ b/golang/pkg/database/tokens.go
@@ -0,0 +1,14 @@
+package database
+
+func (db DB) AddToken(token string) error {
+ _, err := db.db.ExecContext(db.ctx, `INSERT INTO tokens(data) VALUES (?);`, token)
+ return err
+}
+
+func (db DB) GetToken() (string, error) {
+ var token string
+ if err := db.db.QueryRowContext(db.ctx, `SELECT data FROM tokens;`).Scan(&token); err != nil {
+ return "", err
+ }
+ return token, nil
+}