package api
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/maintc/rustmaps-cli/pkg/common"
"github.com/maintc/rustmaps-cli/pkg/types"
"go.uber.org/zap"
)
type RustMapsGenerateResponseMeta struct {
Status string `json:"status"`
StatusCode int `json:"statusCode"`
Errors []string `json:"errors"`
}
type RustMapsGenerateResponseData struct {
MapID string `json:"mapId"`
QueuePosition int `json:"queuePosition"`
State string `json:"state"`
CurrentStep string `json:"currentStep"`
LastGeneratorPingUtc time.Time `json:"lastGeneratorPingUtc"`
}
type RustMapsGenerateResponse struct {
Meta RustMapsGenerateResponseMeta `json:"meta"`
Data RustMapsGenerateResponseData `json:"data"`
}
type RustMapsGenerateProceduralRequest struct {
Size int `json:"size"`
Seed string `json:"seed"`
Staging bool `json:"staging"`
}
type RustMapsGenerateCustomRequest struct {
MapParameters RustMapsGenerateProceduralRequest `json:"mapParameters"`
ConfigName string `json:"configName"`
}
func (c *RustMapsClient) GenerateCustom(log *zap.Logger, m *types.Map) (*RustMapsGenerateResponse, error) {
c.rateLimiter.Wait()
// Create a client with custom timeouts
client := &http.Client{
Timeout: 10 * time.Second,
}
data := RustMapsGenerateCustomRequest{
MapParameters: RustMapsGenerateProceduralRequest{
Size: m.Size,
Seed: m.Seed,
Staging: m.Staging,
},
ConfigName: m.SavedConfig,
}
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
// Create request
log.Debug("Generating custom map", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
req, err := http.NewRequest("POST", fmt.Sprintf("%s/maps/custom/saved-config", c.ApiUrl), bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
// Add headers
req.Header.Set("X-API-Key", c.apiKey)
req.Header.Set("Content-Type", "application/json")
// Make request
resp, err := client.Do(req)
if err != nil {
log.Error("Error making request", zap.Error(err))
return nil, err
}
defer resp.Body.Close()
log.Debug("Response status", zap.Int("status", resp.StatusCode), zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error("Error reading response", zap.Error(err))
return nil, err
}
log.Debug("Response body", zap.String("body", string(body)), zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
switch resp.StatusCode {
case http.StatusUnauthorized:
log.Error("Unauthorized request", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusUnauthorized)
return nil, fmt.Errorf(common.StatusUnauthorized)
case http.StatusForbidden:
log.Error("Forbidden request", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusForbidden)
return nil, fmt.Errorf(common.StatusForbidden)
case http.StatusConflict:
log.Debug("Map already generating", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusGenerating)
return nil, nil
}
var generateResponse RustMapsGenerateResponse
if err := json.Unmarshal(body, &generateResponse); err != nil {
return nil, err
}
m.MapID = generateResponse.Data.MapID
switch resp.StatusCode {
case http.StatusOK:
log.Debug("Map generated", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusComplete)
return &generateResponse, nil
case 201:
log.Debug("Map generating", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusGenerating)
return &generateResponse, nil
case http.StatusBadRequest:
log.Error("Bad request", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
if len(generateResponse.Meta.Errors) > 0 && generateResponse.Meta.Errors[0] == "Staging is not enabled" {
m.ReportStatus(common.StatusStagingNotEnabled)
} else {
m.ReportStatus(common.StatusBadRequest)
}
return nil, fmt.Errorf(common.StatusBadRequest)
}
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
func (c *RustMapsClient) GenerateProcedural(log *zap.Logger, m *types.Map) (*RustMapsGenerateResponse, error) {
c.rateLimiter.Wait()
// Create a client with custom timeouts
client := &http.Client{
Timeout: 10 * time.Second,
}
data := RustMapsGenerateProceduralRequest{
Size: m.Size,
Seed: m.Seed,
Staging: m.Staging,
}
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
// Create request
log.Debug("Generating procedural map", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
req, err := http.NewRequest("POST", fmt.Sprintf("%s/maps", c.ApiUrl), bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
// Add headers
req.Header.Set("X-API-Key", c.apiKey)
req.Header.Set("Content-Type", "application/json")
// Make request
resp, err := client.Do(req)
if err != nil {
log.Error("Error making request", zap.Error(err))
return nil, err
}
defer resp.Body.Close()
log.Debug("Response status", zap.Int("status", resp.StatusCode), zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error("Error reading response", zap.Error(err))
return nil, err
}
log.Debug("Response body", zap.String("body", string(body)), zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
switch resp.StatusCode {
case http.StatusBadRequest:
log.Error("Bad request", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusBadRequest)
return nil, fmt.Errorf(common.StatusBadRequest)
case http.StatusUnauthorized:
log.Error("Unauthorized request", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusUnauthorized)
return nil, fmt.Errorf(common.StatusUnauthorized)
case http.StatusForbidden:
log.Error("Forbidden request", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusForbidden)
return nil, fmt.Errorf(common.StatusForbidden)
case http.StatusConflict:
log.Debug("Map already generating", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusGenerating)
return nil, nil
}
var generateResponse RustMapsGenerateResponse
if err := json.Unmarshal(body, &generateResponse); err != nil {
return nil, err
}
m.MapID = generateResponse.Data.MapID
switch resp.StatusCode {
case http.StatusOK:
log.Debug("Map generated", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusComplete)
return &generateResponse, nil
case 201:
log.Debug("Map generating", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
m.ReportStatus(common.StatusGenerating)
return &generateResponse, nil
}
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"go.uber.org/zap"
)
type RustMapsLimitsResponseMeta struct {
Status string `json:"status"`
StatusCode int `json:"statusCode"`
Errors []string `json:"errors"`
}
type RustMapsLimitsResponseDataConcurrent struct {
Current int `json:"current"`
Allowed int `json:"allowed"`
}
type RustMapsLimitsResponseDataMonthly struct {
Current int `json:"current"`
Allowed int `json:"allowed"`
}
type RustMapsLimitsResponseData struct {
Concurrent RustMapsLimitsResponseDataConcurrent `json:"concurrent"`
Monthly RustMapsLimitsResponseDataMonthly `json:"monthly"`
}
type RustMapsLimitsResponse struct {
Meta RustMapsLimitsResponseMeta `json:"meta"`
Data RustMapsLimitsResponseData `json:"data"`
}
func (c *RustMapsClient) GetLimits(log *zap.Logger) (*RustMapsLimitsResponse, error) {
c.rateLimiter.Wait()
// Create a client with custom timeouts
client := &http.Client{
Timeout: 10 * time.Second,
}
// Create request
log.Debug("GET /maps/limits - Getting API limits")
req, err := http.NewRequest("GET", fmt.Sprintf("%s/maps/limits", c.ApiUrl), nil)
if err != nil {
log.Error("Error creating request", zap.Error(err))
return nil, err
}
// Add headers
req.Header.Set("X-API-Key", c.apiKey)
// Make request
resp, err := client.Do(req)
if err != nil {
log.Error("Error making request", zap.Error(err))
return nil, err
}
defer resp.Body.Close()
log.Debug("Response status", zap.Int("status", resp.StatusCode))
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error("Error reading response", zap.Error(err))
return nil, err
}
log.Debug("Response body", zap.String("body", string(body)))
switch resp.StatusCode {
case http.StatusUnauthorized:
log.Error("Unauthorized request")
return nil, fmt.Errorf("unauthorized")
}
limits := &RustMapsLimitsResponse{}
if err := json.Unmarshal(body, limits); err != nil {
log.Error("Error unmarshalling response", zap.Error(err))
return nil, err
}
return limits, nil
}
package api
import (
"sync"
"time"
"github.com/maintc/rustmaps-cli/pkg/types"
"go.uber.org/zap"
)
type RustMapsClientBase interface {
GetStatus(log *zap.Logger, m *types.Map) (*RustMapsStatusResponse, error)
SetApiKey(apiKey string)
GetLimits(log *zap.Logger) (*RustMapsLimitsResponse, error)
GenerateCustom(log *zap.Logger, m *types.Map) (*RustMapsGenerateResponse, error)
GenerateProcedural(log *zap.Logger, m *types.Map) (*RustMapsGenerateResponse, error)
}
type RustMapsClient struct {
RustMapsClientBase
ApiUrl string
apiKey string
rateLimiter *RateLimiter
}
func NewRustMapsClient(apiKey string) RustMapsClientBase {
return &RustMapsClient{
ApiUrl: "https://api.rustmaps.com/v4",
apiKey: apiKey,
rateLimiter: NewRateLimiter(60),
}
}
func (r *RustMapsClient) SetApiKey(apiKey string) {
r.apiKey = apiKey
}
// RateLimiter manages API request timing
type RateLimiter struct {
callsPerMinute int
interval time.Duration
lastCall time.Time
mu sync.Mutex
}
// NewRateLimiter creates a new rate limiter
func NewRateLimiter(callsPerMinute int) *RateLimiter {
return &RateLimiter{
callsPerMinute: callsPerMinute,
interval: time.Minute / time.Duration(callsPerMinute),
}
}
// Wait ensures enough time has passed since the last call
func (r *RateLimiter) Wait() {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
if !r.lastCall.IsZero() {
timePassed := now.Sub(r.lastCall)
if timePassed < r.interval {
time.Sleep(r.interval - timePassed)
}
}
r.lastCall = time.Now()
}
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/maintc/rustmaps-cli/pkg/common"
"github.com/maintc/rustmaps-cli/pkg/types"
"go.uber.org/zap"
)
type RustMapsStatusResponseMeta struct {
Status string `json:"status"`
StatusCode int `json:"statusCode"`
Errors []string `json:"errors"`
}
type RustMapsStatusResponseDataCoordinates struct {
X int `json:"x"`
Y int `json:"y"`
}
type RustMapsStatusResponseDataMonuments struct {
Type string `json:"type"`
Coordinates RustMapsStatusResponseDataCoordinates `json:"coordinates"`
NameOverride string `json:"nameOverride"`
}
type RustMapsStatusResponseDataBiomePercentages struct {
S float64 `json:"s"`
D float64 `json:"d"`
F float64 `json:"f"`
T float64 `json:"t"`
}
type RustMapsStatusResponseData struct {
ID string `json:"id"`
Type string `json:"type"`
Seed int `json:"seed"`
Size int `json:"size"`
SaveVersion int `json:"saveVersion"`
URL string `json:"url"`
RawImageURL string `json:"rawImageUrl"`
ImageURL string `json:"imageUrl"`
ImageIconURL string `json:"imageIconUrl"`
ThumbnailURL string `json:"thumbnailUrl"`
IsStaging bool `json:"isStaging"`
IsCustomMap bool `json:"isCustomMap"`
CanDownload bool `json:"canDownload"`
DownloadURL string `json:"downloadUrl"`
TotalMonuments int `json:"totalMonuments"`
Monuments []RustMapsStatusResponseDataMonuments `json:"monuments"`
LandPercentageOfMap int `json:"landPercentageOfMap"`
BiomePercentages RustMapsStatusResponseDataBiomePercentages `json:"biomePercentages"`
Islands int `json:"islands"`
Mountains int `json:"mountains"`
IceLakes int `json:"iceLakes"`
Rivers int `json:"rivers"`
Lakes int `json:"lakes"`
Canyons int `json:"canyons"`
Oases int `json:"oases"`
BuildableRocks int `json:"buildableRocks"`
}
type RustMapsStatusResponse struct {
Meta RustMapsStatusResponseMeta `json:"meta"`
Data RustMapsStatusResponseData `json:"data"`
}
func (c *RustMapsClient) GetStatus(log *zap.Logger, m *types.Map) (*RustMapsStatusResponse, error) {
c.rateLimiter.Wait()
// Create a client with custom timeouts
client := &http.Client{
Timeout: 10 * time.Second,
}
var endpoint = m.MapID
if endpoint == "" {
endpoint = fmt.Sprintf("%d/%s", m.Size, m.Seed)
}
// Create request
log.Debug("Getting map status", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
req, err := http.NewRequest("GET", fmt.Sprintf("%s/maps/%s", c.ApiUrl, endpoint), nil)
if err != nil {
return nil, err
}
// Add headers
req.Header.Set("X-API-Key", c.apiKey)
// Make request
resp, err := client.Do(req)
if err != nil {
log.Error("Error making request", zap.Error(err))
return nil, err
}
defer resp.Body.Close()
log.Debug("Response status", zap.Int("status", resp.StatusCode), zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Error("Error reading response", zap.Error(err))
return nil, err
}
log.Debug("Response body", zap.String("body", string(body)), zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.String("config", m.SavedConfig), zap.Bool("staging", m.Staging))
status := &RustMapsStatusResponse{}
switch resp.StatusCode {
case http.StatusOK:
if err := json.Unmarshal(body, status); err != nil {
return nil, err
}
status.Meta.Status = common.StatusComplete
return status, nil
case http.StatusUnauthorized:
log.Error("Unauthorized request", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
status.Meta.Status = common.StatusUnauthorized
status.Meta.StatusCode = http.StatusUnauthorized
case http.StatusForbidden:
log.Error("Forbidden request", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
status.Meta.Status = common.StatusForbidden
status.Meta.StatusCode = http.StatusForbidden
case http.StatusNotFound:
log.Error("Map not found", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
status.Meta.Status = common.StatusNotFound
status.Meta.StatusCode = http.StatusNotFound
case http.StatusConflict:
log.Debug("Map generating", zap.String("seed", m.Seed), zap.Int("size", m.Size), zap.Bool("staging", m.Staging))
status.Meta.Status = common.StatusGenerating
status.Meta.StatusCode = http.StatusConflict
default:
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return status, nil
}
package rustmaps
import (
"fmt"
"go.uber.org/zap"
)
func (g *Generator) ValidateAuthentication(log *zap.Logger) error {
if g.config.APIKey == "" {
log.Error("API key not set")
return fmt.Errorf("API key not set")
}
if g.config.Tier == "" {
log.Error("Tier not set")
return fmt.Errorf("tier not set")
}
return nil
}
func (g *Generator) DetermineTier(log *zap.Logger) (string, bool) {
limits, err := g.rmcli.GetLimits(log)
if err != nil {
log.Error("Error getting limits", zap.Error(err))
return "", false
}
if tier, exists := tierLimits[limits.Data.Monthly.Allowed]; exists {
return tier, true
}
log.Error("Invalid tier", zap.Int("allowed", limits.Data.Monthly.Allowed))
return "", false
}
package rustmaps
import (
"encoding/json"
"os"
"github.com/maintc/rustmaps-cli/pkg/api"
)
// LoadConfig loads the configuration from disk
func (g *Generator) LoadConfig() error {
data, err := os.ReadFile(g.configPath)
if err != nil {
if os.IsNotExist(err) {
// Create default config if it doesn't exist
return g.SaveConfig()
}
return err
}
if err := json.Unmarshal(data, &g.config); err != nil {
return err
}
g.rmcli = api.NewRustMapsClient(g.config.APIKey)
return nil
}
// SaveConfig saves the current configuration to disk
func (g *Generator) SaveConfig() error {
data, err := json.MarshalIndent(g.config, "", " ")
if err != nil {
return err
}
return os.WriteFile(g.configPath, data, 0644)
}
package rustmaps
import (
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"time"
"github.com/maintc/rustmaps-cli/pkg/common"
"go.uber.org/zap"
)
type DownloadLinks struct {
MapURL string `json:"map_url"`
ImageURL string `json:"image_url"`
ImageIconURL string `json:"image_icon_url"`
ThumbnailURL string `json:"thumbnail_url"`
}
func (g *Generator) OverrideDownloadsDir(log *zap.Logger, dir string) {
g.downloadsDir = dir
if err := os.MkdirAll(dir, 0755); err != nil {
log.Error("Error creating downloads directory", zap.Error(err))
}
}
// DownloadFile downloads a file using net/http
func (g *Generator) DownloadFile(log *zap.Logger, url, target string) error {
maxRetries := 3
backoff := 5 * time.Second
client := &http.Client{
Timeout: 10 * time.Second,
}
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
sleepDuration := backoff * time.Duration(math.Pow(2, float64(attempt-1)))
log.Info("Retrying download",
zap.Int("attempt", attempt),
zap.Duration("backoff", sleepDuration))
time.Sleep(sleepDuration)
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
lastErr = err
log.Error("Error creating request", zap.Error(err))
continue
}
resp, err := client.Do(req)
if err != nil {
lastErr = err
log.Error("Error downloading file",
zap.Error(err),
zap.Int("attempt", attempt))
continue
}
// Always close response body, but keep error for checking
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
lastErr = fmt.Errorf("error downloading file: %s", resp.Status)
log.Error("Error downloading file",
zap.String("status", resp.Status),
zap.Int("attempt", attempt))
continue
}
file, err := os.Create(target)
if err != nil {
lastErr = err
log.Error("Error creating file", zap.Error(err))
return err // Don't retry file creation errors
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
lastErr = err
log.Error("Error writing file",
zap.Error(err),
zap.Int("attempt", attempt))
continue
}
// If we get here, the download was successful
log.Info("File downloaded successfully",
zap.String("url", url),
zap.String("target", target),
zap.Int("attempts", attempt+1))
return nil
}
return fmt.Errorf("failed after %d attempts, last error: %v", maxRetries, lastErr)
}
func (g *Generator) Download(log *zap.Logger, version string) error {
if len(g.maps) == 0 {
log.Warn("No maps loaded")
return fmt.Errorf("no maps loaded")
}
for _, m := range g.maps {
if m.Status != common.StatusComplete {
continue
}
if status, err := g.rmcli.GetStatus(log, m); err != nil {
log.Error("Error downloading map", zap.String("seed", m.Seed), zap.Error(err))
return err
} else {
if !status.Data.CanDownload {
log.Warn("Cannot download map", zap.String("seed", m.Seed), zap.Int("size", m.Size))
fmt.Println()
stagingFlag := ""
if m.Staging {
stagingFlag = " -b"
}
fmt.Printf("But you can open it in the browser: `rustmaps open -s '%s' -z %d -S '%s'%s`\n", m.Seed, m.Size, m.SavedConfig, stagingFlag)
fmt.Println()
continue
}
downloadsDir := filepath.Join(g.downloadsDir, version)
if err := os.MkdirAll(downloadsDir, 0755); err != nil {
log.Error("Error creating downloads directory", zap.Error(err))
return err
}
savedConfig := m.SavedConfig
if savedConfig == "" {
savedConfig = "procedural"
}
prefix := fmt.Sprintf("%s_%d_%s_%t_%s", m.Seed, m.Size, savedConfig, m.Staging, m.MapID)
mapTarget := filepath.Join(downloadsDir, fmt.Sprintf("%s.map", prefix))
imageTarget := filepath.Join(downloadsDir, fmt.Sprintf("%s.png", prefix))
imageWithIconsTarget := filepath.Join(downloadsDir, fmt.Sprintf("%s_icons.png", prefix))
thumbnailTarget := filepath.Join(downloadsDir, fmt.Sprintf("%s_thumbnail.png", prefix))
downloadLinksTarget := filepath.Join(downloadsDir, fmt.Sprintf("%s_download_links.json", prefix))
mapSpecsTarget := filepath.Join(downloadsDir, fmt.Sprintf("%s_specs.json", prefix))
// create a json file next to the rest that contains the download urls
log.Info("Downloading assets", zap.String("seed", m.Seed), zap.String("map_id", m.MapID))
links := DownloadLinks{
MapURL: status.Data.DownloadURL,
ImageURL: status.Data.ImageURL,
ImageIconURL: status.Data.ImageIconURL,
ThumbnailURL: status.Data.ThumbnailURL,
}
downloadLinksData, err := json.MarshalIndent(links, "", " ")
if err != nil {
log.Error("Error marshalling JSON", zap.Error(err))
return err
}
log.Info("Writing download links", zap.String("target", downloadLinksTarget))
if err := os.WriteFile(downloadLinksTarget, downloadLinksData, 0644); err != nil {
log.Error("Error writing JSON file", zap.Error(err))
return err
}
mapSpecsData, err := json.MarshalIndent(status, "", " ")
if err != nil {
log.Error("Error marshalling JSON", zap.Error(err))
return err
}
log.Info("Writing map specs", zap.String("target", mapSpecsTarget))
if err := os.WriteFile(mapSpecsTarget, mapSpecsData, 0644); err != nil {
log.Error("Error writing JSON file", zap.Error(err))
return err
}
if err := g.DownloadFile(log, status.Data.DownloadURL, mapTarget); err != nil {
log.Error("Error downloading map", zap.String("seed", m.Seed), zap.Error(err))
return err
}
if err := g.DownloadFile(log, status.Data.ImageURL, imageTarget); err != nil {
log.Error("Error downloading image", zap.String("seed", m.Seed), zap.Error(err))
return err
}
if err := g.DownloadFile(log, status.Data.ImageIconURL, imageWithIconsTarget); err != nil {
log.Error("Error downloading image with icons", zap.String("seed", m.Seed), zap.Error(err))
return err
}
if err := g.DownloadFile(log, status.Data.ThumbnailURL, thumbnailTarget); err != nil {
log.Error("Error downloading thumbnail", zap.String("seed", m.Seed), zap.Error(err))
return err
}
}
}
return nil
}
package rustmaps
import (
"time"
"github.com/maintc/rustmaps-cli/pkg/common"
"go.uber.org/zap"
)
func (g *Generator) Generate(log *zap.Logger) bool {
if err := g.ValidateAuthentication(log); err != nil {
log.Error("Error validating authentication", zap.Error(err))
return false
}
if !(g.Pending() || g.Generating()) {
log.Info("All maps are complete")
return false
}
for _, m := range g.maps {
if m.Status == common.StatusComplete && m.ShouldSync() {
if err := g.SyncStatus(log, m); err != nil {
log.Error("Error syncing status", zap.String("seed", m.Seed))
}
if m.Status == common.StatusNotFound {
m.Status = common.StatusPending
}
}
if m.Status == common.StatusGenerating {
if err := g.SyncStatus(log, m); err != nil {
log.Error("Error syncing status", zap.String("seed", m.Seed))
continue
}
}
}
if !(g.Pending() && g.CanGenerate(log)) {
time.Sleep(g.backoffTime)
return true
}
for _, m := range g.maps {
if m.Status == common.StatusPending {
if m.SavedConfig == "" {
g.rmcli.GenerateProcedural(log, m)
} else {
g.rmcli.GenerateCustom(log, m)
}
if err := m.SaveJSON(g.importsDir); err != nil {
log.Error("Error saving map file", zap.Error(err))
}
break
}
}
time.Sleep(2 * time.Second)
return true
}
package rustmaps
import "github.com/maintc/rustmaps-cli/pkg/types"
func (g *Generator) GetDownloadsDir() string {
return g.downloadsDir
}
func (g *Generator) GetImportDir() string {
return g.importsDir
}
func (g *Generator) GetLogPath() string {
return g.logPath
}
func (g *Generator) GetConfigPath() string {
return g.configPath
}
func (g *Generator) GetMaps() []*types.Map {
return g.maps
}
package rustmaps
import (
"fmt"
"math/rand"
"strings"
"time"
"github.com/maintc/rustmaps-cli/pkg/common"
)
func (g *Generator) IsApiKeySet() bool {
return g.config.APIKey != ""
}
func (g *Generator) Pending() bool {
for _, m := range g.maps {
if m.Status == common.StatusPending {
return true
}
}
return false
}
func (g *Generator) Generating() bool {
for _, m := range g.maps {
if m.Status == common.StatusGenerating {
return true
}
}
return false
}
func (g *Generator) ContainCustomMaps() bool {
for _, m := range g.maps {
if m.SavedConfig != "" {
return true
}
}
return false
}
func (g *Generator) GetRandomSeed() string {
// Seed the random number generator using the current time
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Define the maximum seed value
const maxSeed = 2147483647
// Generate a random integer between 0 and maxSeed (inclusive)
seed := rng.Intn(maxSeed + 1)
// Convert the integer to a string
return fmt.Sprintf("%d", seed)
}
func parseInt(s string) int {
var i int
fmt.Sscanf(s, "%d", &i)
return i
}
func parseBool(s string) bool {
return strings.ToLower(s) == "true"
}
package rustmaps
import (
"encoding/csv"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/maintc/rustmaps-cli/pkg/common"
"github.com/maintc/rustmaps-cli/pkg/types"
"go.uber.org/zap"
)
// LoadCSV reads the currently selected map file
func (g *Generator) LoadCSV(log *zap.Logger, mapsPath string) error {
if err := g.ValidateAuthentication(log); err != nil {
log.Error("Error validating authentication", zap.Error(err))
return err
}
if err := g.ValidateCSV(log, mapsPath); err != nil {
log.Error("Error validating map file", zap.Error(err))
return err
}
g.maps = nil
g.target = mapsPath
file, err := os.Open(g.target)
if err != nil {
return err
}
defer file.Close()
reader := csv.NewReader(file)
// Allow variable number of fields per record
reader.FieldsPerRecord = -1
// Skip header
_, err = reader.Read()
if err != nil {
return err
}
records, err := reader.ReadAll()
if err != nil {
return err
}
g.maps = make([]*types.Map, 0, len(records))
for _, record := range records {
m := types.Map{
Status: common.StatusPending, // Set default status
}
// Safely assign fields based on what's available
if len(record) > 0 {
m.Seed = record[0]
}
if len(record) > 1 {
m.Size = parseInt(record[1])
}
if len(record) > 2 {
m.SavedConfig = record[2]
}
if len(record) > 3 {
m.Staging = parseBool(record[3])
}
if len(record) > 4 {
m.MapID = record[4]
}
if len(record) > 5 {
m.Status = record[5]
}
m.SetFilename()
g.maps = append(g.maps, &m)
}
if len(g.maps) == 0 {
log.Warn("No maps loaded")
return fmt.Errorf("no maps loaded")
}
if (g.config.Tier == "Free" || g.config.Tier == "Supporter") && g.ContainCustomMaps() {
log.Warn("Cannot generate custom maps with Free or Supporter tier")
return fmt.Errorf("cannot generate custom maps with Free or Supporter tier")
}
return nil
}
func (g *Generator) ValidateCSV(log *zap.Logger, mapsPath string) error {
if _, err := os.Stat(mapsPath); err != nil {
log.Error("Error reading file", zap.Error(err))
return err
}
// get first line in the file
file, err := os.Open(mapsPath)
if err != nil {
log.Error("Error opening file", zap.Error(err))
return err
}
defer file.Close()
reader := csv.NewReader(file)
headers, err := reader.Read()
if err != nil {
log.Error("Error reading CSV headers", zap.Error(err))
return err
}
requiredColumns := []string{"seed", "size"}
for _, req := range requiredColumns {
found := false
for _, col := range headers {
if strings.TrimSpace(col) == req {
found = true
break
}
}
if !found {
log.Error("Missing required column", zap.String("column", req))
return fmt.Errorf("missing required column: %s", req)
}
}
return nil
}
// Import imports a CSV file containing map definitions
func (g *Generator) Import(log *zap.Logger, force bool) error {
// if err := g.ValidateCSV(log, mapsPath); err != nil {
// log.Error("Error validating map file", zap.Error(err))
// return err
// }
for _, m := range g.maps {
if m.Filename == "" {
m.SetFilename()
}
path := filepath.Join(g.importsDir, m.Filename)
if !force {
if _, err := os.Stat(path); err == nil {
log.Debug("Map file already exists, loading existing file", zap.String("path", path))
// Read and deserialize the existing JSON file
file, err := os.Open(path)
if err != nil {
log.Error("Error opening existing file", zap.Error(err), zap.String("path", path))
return err
}
defer file.Close()
var existingMap types.Map // Replace with the actual type of your map structure
if err := json.NewDecoder(file).Decode(&existingMap); err != nil {
log.Error("Error decoding existing map file", zap.Error(err), zap.String("path", path))
return err
}
// Merge the fields from existingMap into m
m.MergeFrom(existingMap)
continue
}
}
// export json to file
file, err := os.Create(path)
if err != nil {
log.Error("Error creating file", zap.Error(err), zap.String("path", path))
return err
}
defer file.Close()
enc := json.NewEncoder(file)
enc.SetIndent("", " ")
if err := enc.Encode(m); err != nil {
log.Error("Error encoding map", zap.Error(err), zap.String("map", m.String()), zap.String("path", path))
return err
}
log.Debug("Exported map", zap.String("map", m.String()), zap.String("path", path))
}
return nil
}
// Package rustmaps provides functionality for generating and managing Rust game maps
package rustmaps
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/maintc/rustmaps-cli/pkg/api"
"github.com/maintc/rustmaps-cli/pkg/types"
"go.uber.org/zap"
)
var (
tierLimits = map[int]string{
250: "Free",
500: "Supporter",
800: "Premium",
1000: "Organization 1",
1750: "Organization 2",
}
)
// Generator handles map generation and management
type Generator struct {
config types.Config
maps []*types.Map
target string
rmcli api.RustMapsClientBase
configPath string
importsDir string
downloadsDir string
logPath string
baseDir string
backoffTime time.Duration
}
// NewGenerator creates a new Generator instance
func NewGenerator(baseDir *string) (*Generator, error) {
g := &Generator{
config: types.Config{Tier: "Free"},
backoffTime: 30 * time.Second,
}
if baseDir == nil {
var err error
baseDir = new(string)
*baseDir, err = os.UserHomeDir()
if err != nil {
return nil, err
}
}
g.baseDir = filepath.Join(*baseDir, ".rustmaps")
return g, nil
}
// InitDirs initializes required directories
func (g *Generator) InitDirs() error {
g.configPath = filepath.Join(g.baseDir, "config.json")
g.importsDir = filepath.Join(g.baseDir, "imports")
g.downloadsDir = filepath.Join(g.baseDir, "downloads")
g.logPath = filepath.Join(g.baseDir, "generator.log")
dirs := []string{g.baseDir, g.importsDir, g.downloadsDir}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
return nil
}
func (g *Generator) CanGenerate(log *zap.Logger) bool {
limits, err := g.rmcli.GetLimits(log)
if err != nil {
fmt.Printf("Error getting limits: %v\n", err)
return false
}
var canGenerateConcurrent = limits.Data.Concurrent.Current < limits.Data.Concurrent.Allowed
var canGenerateMonthly = limits.Data.Monthly.Current < limits.Data.Monthly.Allowed
if !canGenerateConcurrent {
fmt.Println("Cannot generate map: concurrent limit reached")
}
if !canGenerateMonthly {
fmt.Println("Cannot generate map: monthly limit reached")
}
return canGenerateConcurrent && canGenerateMonthly
}
func (g *Generator) GetStatus(log *zap.Logger, m *types.Map) (*api.RustMapsStatusResponse, error) {
status, err := g.rmcli.GetStatus(log, m)
if err != nil {
fmt.Printf("Error getting status: %v\n", err)
return nil, err
}
return status, nil
}
func (g *Generator) SyncStatus(log *zap.Logger, m *types.Map) error {
status, err := g.rmcli.GetStatus(log, m)
if err != nil {
fmt.Printf("Error getting status: %v\n", err)
return err
}
m.ReportStatus(status.Meta.Status)
m.SaveJSON(g.importsDir)
return nil
}
func (g *Generator) AddMap(m *types.Map) {
m.SetFilename()
g.maps = append(g.maps, m)
}
package rustmaps
func (g *Generator) SetApiKey(apiKey string) {
g.config.APIKey = apiKey
g.rmcli.SetApiKey(apiKey)
}
func (g *Generator) SetTier(tier string) {
g.config.Tier = tier
}
package types
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/maintc/rustmaps-cli/pkg/common"
)
// Config represents the application configuration
type Config struct {
APIKey string `json:"api_key"`
Tier string `json:"tier"`
}
// Map represents a single map configuration
type Map struct {
Seed string `json:"seed"`
Size int `json:"size"`
SavedConfig string `json:"saved_config,omitempty"`
Staging bool `json:"staging"`
MapID string `json:"map_id,omitempty"`
Status string `json:"status"`
LastSync string `json:"last_sync,omitempty"`
Filename string `json:"filename,omitempty"`
}
func NewMap(seed string, size int, savedConfig string, staging bool) *Map {
m := &Map{
Seed: seed,
Size: size,
SavedConfig: savedConfig,
Staging: staging,
}
m.Status = common.StatusPending
return m
}
func (m *Map) SetFilename() {
// export map to g.importsDir/m.seed_m.size_m.saved_config_staging
filename := fmt.Sprintf("%s_%d", m.Seed, m.Size)
if m.SavedConfig != "" {
filename = fmt.Sprintf("%s_%s", filename, m.SavedConfig)
}
if m.Staging {
filename = fmt.Sprintf("%s_staging", filename)
}
filename = fmt.Sprintf("%s.json", filename)
m.Filename = filename
}
func (m *Map) ReportStatus(status string) {
m.Status = status
fmt.Println(m.String())
m.MarkSynced()
}
func (m *Map) String() string {
return fmt.Sprintf("Seed: %s | Size: %d | Config: '%s' | Status: '%s'", m.Seed, m.Size, m.SavedConfig, m.Status)
}
func (m *Map) ShouldSync() bool {
// Parse the last sync time
lastSyncTime, err := time.Parse(time.RFC3339, m.LastSync)
if err != nil {
// If parsing fails, we assume the map should sync
return true
}
// Check if the current time is more than 5 minutes after the last sync time
return time.Now().After(lastSyncTime.Add(5 * time.Minute))
}
func (m *Map) MarkSynced() {
// Update the LastSync field with the current time in RFC3339 format
m.LastSync = time.Now().Format(time.RFC3339)
}
func (m *Map) MergeFrom(other Map) {
m.Seed = other.Seed
m.Size = other.Size
m.SavedConfig = other.SavedConfig
m.Staging = other.Staging
m.MapID = other.MapID
m.Status = other.Status
m.LastSync = other.LastSync
}
func (m *Map) SaveJSON(outputDir string) error {
// Check if the Filename field is set
if m.Filename == "" {
return fmt.Errorf("map does not have its filename set")
}
outputPath := filepath.Join(outputDir, m.Filename)
// Open the file for writing (create it if it doesn't exist)
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer file.Close()
// Create a JSON encoder and write the struct to the file
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ") // Optional: Pretty-print the JSON
if err := encoder.Encode(m); err != nil {
return fmt.Errorf("failed to encode map to JSON: %w", err)
}
return nil
}
// CSVInfo represents a map and its entry count
type CSVInfo struct {
Name string
Count int
}
package main
import (
cmd "github.com/maintc/rustmaps-cli/cmd/rustmaps"
)
func main() {
cmd.Execute()
}