From cd137e4b1a1cb4856250cabe23f08900cf921f44 Mon Sep 17 00:00:00 2001
From: Nils Stinnesbeck <nils.stinnesbeck@plusserver.com>
Date: Wed, 31 Jul 2024 23:09:11 +0200
Subject: [PATCH] initial commit

---
 .gitignore               |   2 +
 accounts/accounts.go     |  72 ++++++++++++++++
 api/api.go               | 175 +++++++++++++++++++++++++++++++++++++++
 characters/characters.go |  61 ++++++++++++++
 characters/move.go       |  34 ++++++++
 characters/move_test.go  |  13 +++
 characters/types.go      | 153 ++++++++++++++++++++++++++++++++++
 go.mod                   |   3 +
 go.sum                   |   0
 maps/maps.go             |  14 ++++
 maps/types.go            |  16 ++++
 status/status.go         |  29 +++++++
 12 files changed, 572 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 accounts/accounts.go
 create mode 100644 api/api.go
 create mode 100644 characters/characters.go
 create mode 100644 characters/move.go
 create mode 100644 characters/move_test.go
 create mode 100644 characters/types.go
 create mode 100644 go.mod
 create mode 100644 go.sum
 create mode 100644 maps/maps.go
 create mode 100644 maps/types.go
 create mode 100644 status/status.go

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b54764c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+# environment variables are saved in here
+setenv.sh
diff --git a/accounts/accounts.go b/accounts/accounts.go
new file mode 100644
index 0000000..7537a6e
--- /dev/null
+++ b/accounts/accounts.go
@@ -0,0 +1,72 @@
+// Package accounts holds functions that interact with the accounts
+package accounts
+
+import (
+	"errors"
+	"regexp"
+
+	"git.stinnesbeck.com/nils/artifacts/api"
+)
+
+// Account is a struct that holds info on an account
+type Account struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+	Email    string `json:"email"`
+}
+
+// custom Error codes
+var (
+	ErrUsernameTooShort = errors.New("username is too short, it needs to be at least 6 chars long")
+	ErrUsernameTooLong  = errors.New("username is too long, it needs to be at max 32 chars long")
+	ErrUsernameRegex    = errors.New(`username must pass this regex: ^[a-zA-Z0-9_-]+$`)
+	ErrPasswordTooShort = errors.New("password is too short, it needs to be at least 5 chars long")
+	ErrPasswordTooLong  = errors.New("password is too long, it needs to be at max 50 chars long")
+	ErrPasswordRegex    = errors.New(`password must pass this regex: ^[^\s]+$`)
+)
+
+// Create can be used to create an account
+func Create(username, password, email string) error {
+	// >= 6 characters<= 32 characters
+	userRegex := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
+	passRegex := regexp.MustCompile(`^[^\s]+$`)
+
+	// username checks
+	switch {
+	case len(username) < 6:
+		return ErrUsernameTooShort
+	case len(username) > 32:
+		return ErrUsernameTooLong
+	case !userRegex.MatchString(username):
+		return ErrUsernameRegex
+	}
+
+	// password checks
+	switch {
+	case len(password) < 5:
+		return ErrPasswordTooShort
+	case len(password) > 50:
+		return ErrPasswordTooLong
+	case !passRegex.MatchString(password):
+		return ErrPasswordRegex
+	}
+
+	// these are the possible response codes, other than 200 OK
+	responseCodes := map[int]string{
+		456: "Username already used.",
+		457: "Email already used.",
+	}
+
+	newAccount := Account{
+		Username: username,
+		Password: password,
+		Email:    email,
+	}
+
+	_, err := api.Post[string]("/accounts/create", newAccount, responseCodes)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/api/api.go b/api/api.go
new file mode 100644
index 0000000..c73753e
--- /dev/null
+++ b/api/api.go
@@ -0,0 +1,175 @@
+// Package api holds functions to interact with the artifacts API
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
+	"slices"
+)
+
+// custom error codes
+var (
+	ErrTokenNotProvided = errors.New("make sure to provide a token via the api.SetToken() function, or set it via env variable ARTIFACTS_TOKEN")
+)
+
+const (
+	baseURL = "https://api.artifactsmmo.com"
+)
+
+var token string // the token must be provided for the client to work
+
+type apiResponse struct {
+	Data  json.RawMessage `json:"data"`
+	Total int             `json:"total"`
+	Page  int             `json:"page"`
+	Size  int             `json:"size"`
+	Pages int             `json:"pages"`
+}
+
+// SetToken can be used to set the token, which must be provided before calling any other function.
+// It can be provided using this function or via environment variable 'ARTIFACTS_TOKEN'.
+func SetToken(t string) {
+	token = t
+}
+
+func meta[T any](endpoint string, method string, body any, responseCodes map[int]string) (*T, error) {
+	// make it possible to set a token via env variable
+	if token == "" && os.Getenv("ARTIFACTS_TOKEN") != "" {
+		SetToken(os.Getenv("ARTIFACTS_TOKEN"))
+	}
+
+	// check for token
+	if token == "" {
+		return nil, ErrTokenNotProvided
+	}
+
+	bodyBytes, err := json.Marshal(body)
+	if err != nil {
+		return nil, err
+	}
+
+	// create a new request to retrieve characters
+	request, err := http.NewRequest(method, baseURL+endpoint, bytes.NewReader(bodyBytes))
+	if err != nil {
+		return nil, err
+	}
+
+	// add auth header to the request
+	request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+	request.Header.Set("Content-Type", "application/json")
+
+	// execute this request
+	response, err := http.DefaultClient.Do(request)
+	if err != nil {
+		return nil, err
+	}
+
+	// check all additional status codes first
+	for code, message := range responseCodes {
+		if response.StatusCode != code {
+			continue
+		}
+
+		// exit the function if a statuscode was found and return it's message
+		return nil, fmt.Errorf("%d: %s", code, message)
+	}
+
+	// basic error handling
+	if response.StatusCode != 200 {
+		return nil, fmt.Errorf("%s", response.Status)
+	}
+
+	// read in the body of the response
+	b, err := io.ReadAll(response.Body)
+	if err != nil {
+		return nil, err
+	}
+
+	// close the body when we are finished
+	defer response.Body.Close()
+
+	// convert it to a native go type
+	var ar apiResponse
+	if err := json.Unmarshal(b, &ar); err != nil {
+		return nil, err
+	}
+
+	if ar.Page < ar.Pages {
+		// parse endpoint
+		u, err := url.Parse(endpoint)
+		if err != nil {
+			return nil, err
+		}
+
+		urlValues := u.Query()
+		urlValues.Set("page", fmt.Sprintf("%d", ar.Page+1))
+		endpoint = u.Path
+
+		nextPage, err := meta[T](fmt.Sprintf("%s?%s", endpoint, urlValues.Encode()), method, body, responseCodes)
+		if err != nil {
+			return nil, err
+		}
+
+		b1, err := ar.Data.MarshalJSON()
+		if err != nil {
+			return nil, err
+		}
+
+		b2, err := json.Marshal(nextPage)
+		if err != nil {
+			return nil, err
+		}
+
+		bCombined := slices.Concat(b1[0:len(b1)-1], []byte(","), b2[1:])
+
+		// copy combined dataset into ar.Data
+		ar.Data.UnmarshalJSON(bCombined)
+
+		// combine both things
+	}
+
+	d, err := ar.Data.MarshalJSON()
+	if err != nil {
+		return nil, err
+	}
+
+	// no error occurred
+	var r T
+	if err := json.Unmarshal(d, &r); err != nil {
+		return nil, err
+	}
+
+	return &r, nil
+}
+
+// Post is used to call an endpoint using a POST request, responseCodes contains all possible
+// error codes that the API can respond with other than 200 OK
+func Post[T any](endpoint string, body any, responseCodes map[int]string) (output T, err error) {
+	// use meta function to get a response
+	response, err := meta[T](endpoint, http.MethodPost, body, responseCodes)
+	if err != nil {
+		return output, err
+	}
+
+	// return the data alongside any error that might have occurred
+	return *response, err
+}
+
+// Get is used to call an endpoint using a GET request, responseCodes contains all possible
+// error codes that the API can respond with other than 200 OK
+func Get[T any](endpoint string, responseCodes map[int]string) (output T, err error) {
+	// use meta function to get a response
+	response, err := meta[T](endpoint, http.MethodGet, nil, responseCodes)
+	if err != nil {
+		return output, err
+	}
+
+	// return the data alongside any error that might have occurred
+	return *response, err
+}
diff --git a/characters/characters.go b/characters/characters.go
new file mode 100644
index 0000000..bfde0db
--- /dev/null
+++ b/characters/characters.go
@@ -0,0 +1,61 @@
+// Package characters holds functions that interact with the characters
+package characters
+
+import (
+	"fmt"
+
+	"git.stinnesbeck.com/nils/artifacts/api"
+)
+
+// GetMy retrieves all characters from your account
+func GetMy() ([]Character, error) {
+	// these are the possible response codes, other than 200 OK
+	responseCodes := map[int]string{
+		404: "Characters not found",
+	}
+
+	// use Get function to retrieve characters
+	return api.Get[[]Character]("/my/characters/", responseCodes)
+}
+
+// GetAll tries to retrieve all characters on the server
+func GetAll() ([]Character, error) {
+	// these are the possible response codes, other than 200 OK
+	responseCodes := map[int]string{
+		404: "Characters not found",
+	}
+
+	// use Get function to retrieve characters
+	return api.Get[[]Character]("/characters/", responseCodes)
+}
+
+// Get tries to retrieve a character by name
+func Get(name string) (Character, error) {
+	// these are the possible response codes, other than 200 OK
+	responseCodes := map[int]string{
+		404: "Character not found",
+	}
+
+	// use Get function to retrieve character
+	return api.Get[Character](fmt.Sprintf("/characters/%s", name), responseCodes)
+}
+
+// Create will try to create a new character
+func Create(name string, skin Skin) (Character, error) {
+	if len(name) > 12 {
+		return Character{}, fmt.Errorf("make sure to provide a name that is >=3 and <=12 characters long")
+	}
+
+	// these are the possible response codes, other than 200 OK
+	responseCodes := map[int]string{
+		494: "Name already used",
+		495: "Maximum characters reached on your account",
+	}
+
+	newChar := Character{
+		Name: name,
+		Skin: skin,
+	}
+
+	return api.Post[Character]("/characters/create", newChar, responseCodes)
+}
diff --git a/characters/move.go b/characters/move.go
new file mode 100644
index 0000000..a83a867
--- /dev/null
+++ b/characters/move.go
@@ -0,0 +1,34 @@
+package characters
+
+import (
+	"fmt"
+
+	"git.stinnesbeck.com/nils/artifacts/api"
+)
+
+// Move will try to move a character to a map tile located at x,y
+func Move(name string, x, y int) (CharacterMovement, error) {
+	// these are the possible response codes, other than 200 OK
+	responseCodes := map[int]string{
+		404: "Map not found.",
+		486: "Character is locked. Action is already in progress.",
+		490: "Character already at destination.",
+		498: "Character not found.",
+		499: "Character in cooldown.",
+	}
+
+	var movement = struct {
+		X int `json:"x"`
+		Y int `json:"y"`
+	}{
+		X: x,
+		Y: y,
+	}
+
+	return api.Post[CharacterMovement](fmt.Sprintf("/my/%s/action/move", name), movement, responseCodes)
+}
+
+// Move will try to move a character to a map tile located at x,y
+func (c *Character) Move(x, y int) (CharacterMovement, error) {
+	return Move(c.Name, x, y)
+}
diff --git a/characters/move_test.go b/characters/move_test.go
new file mode 100644
index 0000000..6bc0807
--- /dev/null
+++ b/characters/move_test.go
@@ -0,0 +1,13 @@
+package characters_test
+
+import (
+	"testing"
+
+	"git.stinnesbeck.com/nils/artifacts/characters"
+)
+
+func TestMove(t *testing.T) {
+	if _, err := characters.Move("Nils", 0, 1); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/characters/types.go b/characters/types.go
new file mode 100644
index 0000000..e192e4f
--- /dev/null
+++ b/characters/types.go
@@ -0,0 +1,153 @@
+package characters
+
+import "time"
+
+// Character is a struct that holds info on a character
+type Character struct {
+	Name                    string    `json:"name"`
+	Skin                    Skin      `json:"skin"`
+	Level                   int       `json:"level"`
+	Xp                      int       `json:"xp"`
+	MaxXp                   int       `json:"max_xp"`
+	TotalXp                 int       `json:"total_xp"`
+	Gold                    int       `json:"gold"`
+	Speed                   int       `json:"speed"`
+	MiningLevel             int       `json:"mining_level"`
+	MiningXp                int       `json:"mining_xp"`
+	MiningMaxXp             int       `json:"mining_max_xp"`
+	WoodcuttingLevel        int       `json:"woodcutting_level"`
+	WoodcuttingXp           int       `json:"woodcutting_xp"`
+	WoodcuttingMaxXp        int       `json:"woodcutting_max_xp"`
+	FishingLevel            int       `json:"fishing_level"`
+	FishingXp               int       `json:"fishing_xp"`
+	FishingMaxXp            int       `json:"fishing_max_xp"`
+	WeaponcraftingLevel     int       `json:"weaponcrafting_level"`
+	WeaponcraftingXp        int       `json:"weaponcrafting_xp"`
+	WeaponcraftingMaxXp     int       `json:"weaponcrafting_max_xp"`
+	GearcraftingLevel       int       `json:"gearcrafting_level"`
+	GearcraftingXp          int       `json:"gearcrafting_xp"`
+	GearcraftingMaxXp       int       `json:"gearcrafting_max_xp"`
+	JewelrycraftingLevel    int       `json:"jewelrycrafting_level"`
+	JewelrycraftingXp       int       `json:"jewelrycrafting_xp"`
+	JewelrycraftingMaxXp    int       `json:"jewelrycrafting_max_xp"`
+	CookingLevel            int       `json:"cooking_level"`
+	CookingXp               int       `json:"cooking_xp"`
+	CookingMaxXp            int       `json:"cooking_max_xp"`
+	Hp                      int       `json:"hp"`
+	Haste                   int       `json:"haste"`
+	CriticalStrike          int       `json:"critical_strike"`
+	Stamina                 int       `json:"stamina"`
+	AttackFire              int       `json:"attack_fire"`
+	AttackEarth             int       `json:"attack_earth"`
+	AttackWater             int       `json:"attack_water"`
+	AttackAir               int       `json:"attack_air"`
+	DmgFire                 int       `json:"dmg_fire"`
+	DmgEarth                int       `json:"dmg_earth"`
+	DmgWater                int       `json:"dmg_water"`
+	DmgAir                  int       `json:"dmg_air"`
+	ResFire                 int       `json:"res_fire"`
+	ResEarth                int       `json:"res_earth"`
+	ResWater                int       `json:"res_water"`
+	ResAir                  int       `json:"res_air"`
+	X                       int       `json:"x"`
+	Y                       int       `json:"y"`
+	Cooldown                int       `json:"cooldown"`
+	CooldownExpiration      time.Time `json:"cooldown_expiration"`
+	WeaponSlot              string    `json:"weapon_slot"`
+	ShieldSlot              string    `json:"shield_slot"`
+	HelmetSlot              string    `json:"helmet_slot"`
+	BodyArmorSlot           string    `json:"body_armor_slot"`
+	LegArmorSlot            string    `json:"leg_armor_slot"`
+	BootsSlot               string    `json:"boots_slot"`
+	Ring1Slot               string    `json:"ring1_slot"`
+	Ring2Slot               string    `json:"ring2_slot"`
+	AmuletSlot              string    `json:"amulet_slot"`
+	Artifact1Slot           string    `json:"artifact1_slot"`
+	Artifact2Slot           string    `json:"artifact2_slot"`
+	Artifact3Slot           string    `json:"artifact3_slot"`
+	Consumable1Slot         string    `json:"consumable1_slot"`
+	Consumable1SlotQuantity int       `json:"consumable1_slot_quantity"`
+	Consumable2Slot         string    `json:"consumable2_slot"`
+	Consumable2SlotQuantity int       `json:"consumable2_slot_quantity"`
+	InventorySlot1          string    `json:"inventory_slot1"`
+	InventorySlot1Quantity  int       `json:"inventory_slot1_quantity"`
+	InventorySlot2          string    `json:"inventory_slot2"`
+	InventorySlot2Quantity  int       `json:"inventory_slot2_quantity"`
+	InventorySlot3          string    `json:"inventory_slot3"`
+	InventorySlot3Quantity  int       `json:"inventory_slot3_quantity"`
+	InventorySlot4          string    `json:"inventory_slot4"`
+	InventorySlot4Quantity  int       `json:"inventory_slot4_quantity"`
+	InventorySlot5          string    `json:"inventory_slot5"`
+	InventorySlot5Quantity  int       `json:"inventory_slot5_quantity"`
+	InventorySlot6          string    `json:"inventory_slot6"`
+	InventorySlot6Quantity  int       `json:"inventory_slot6_quantity"`
+	InventorySlot7          string    `json:"inventory_slot7"`
+	InventorySlot7Quantity  int       `json:"inventory_slot7_quantity"`
+	InventorySlot8          string    `json:"inventory_slot8"`
+	InventorySlot8Quantity  int       `json:"inventory_slot8_quantity"`
+	InventorySlot9          string    `json:"inventory_slot9"`
+	InventorySlot9Quantity  int       `json:"inventory_slot9_quantity"`
+	InventorySlot10         string    `json:"inventory_slot10"`
+	InventorySlot10Quantity int       `json:"inventory_slot10_quantity"`
+	InventorySlot11         string    `json:"inventory_slot11"`
+	InventorySlot11Quantity int       `json:"inventory_slot11_quantity"`
+	InventorySlot12         string    `json:"inventory_slot12"`
+	InventorySlot12Quantity int       `json:"inventory_slot12_quantity"`
+	InventorySlot13         string    `json:"inventory_slot13"`
+	InventorySlot13Quantity int       `json:"inventory_slot13_quantity"`
+	InventorySlot14         string    `json:"inventory_slot14"`
+	InventorySlot14Quantity int       `json:"inventory_slot14_quantity"`
+	InventorySlot15         string    `json:"inventory_slot15"`
+	InventorySlot15Quantity int       `json:"inventory_slot15_quantity"`
+	InventorySlot16         string    `json:"inventory_slot16"`
+	InventorySlot16Quantity int       `json:"inventory_slot16_quantity"`
+	InventorySlot17         string    `json:"inventory_slot17"`
+	InventorySlot17Quantity int       `json:"inventory_slot17_quantity"`
+	InventorySlot18         string    `json:"inventory_slot18"`
+	InventorySlot18Quantity int       `json:"inventory_slot18_quantity"`
+	InventorySlot19         string    `json:"inventory_slot19"`
+	InventorySlot19Quantity int       `json:"inventory_slot19_quantity"`
+	InventorySlot20         string    `json:"inventory_slot20"`
+	InventorySlot20Quantity int       `json:"inventory_slot20_quantity"`
+	InventoryMaxItems       int       `json:"inventory_max_items"`
+	Task                    string    `json:"task"`
+	TaskType                string    `json:"task_type"`
+	TaskProgress            int       `json:"task_progress"`
+	TaskTotal               int       `json:"task_total"`
+}
+
+// Skin is a custom type used for the allowed skins
+type Skin string
+
+// allowed skins
+const (
+	Man1   Skin = "men1"
+	Man2   Skin = "men2"
+	Man3   Skin = "men3"
+	Woman1 Skin = "women1"
+	Woman2 Skin = "women2"
+	Woman3 Skin = "women3"
+)
+
+// CharacterMovement is a struct that holds info on the movement of a character
+type CharacterMovement struct {
+	Cooldown    Cooldown
+	Destination Destination
+	Character   Character
+}
+
+// Cooldown is a struct that holds info on the remaining time for a given action
+type Cooldown struct {
+	TotalSeconds     int       `json:"totalSeconds"`
+	RemainingSeconds int       `json:"remainingSeconds"`
+	Expiration       time.Time `json:"expiration"`
+	Reason           string    `json:"reason"`
+}
+
+// Destination is a struct that holds info on a map tile
+type Destination struct {
+	Name    string `json:"name"`
+	X       int    `json:"x"`
+	Y       int    `json:"y"`
+	Content any    `json:"content"`
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..96698b1
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module git.stinnesbeck.com/nils/artifacts
+
+go 1.22.1
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..e69de29
diff --git a/maps/maps.go b/maps/maps.go
new file mode 100644
index 0000000..65a7564
--- /dev/null
+++ b/maps/maps.go
@@ -0,0 +1,14 @@
+// Package maps holds functions that interact with the maps
+package maps
+
+import "git.stinnesbeck.com/nils/artifacts/api"
+
+// GetAll will try to retrieve all map tiles
+func GetAll() ([]Map, error) {
+	// these are the possible response codes, other than 200 OK
+	responseCodes := map[int]string{
+		404: "Maps not found.",
+	}
+
+	return api.Get[[]Map]("/maps/", responseCodes)
+}
diff --git a/maps/types.go b/maps/types.go
new file mode 100644
index 0000000..a92b1e0
--- /dev/null
+++ b/maps/types.go
@@ -0,0 +1,16 @@
+package maps
+
+// Content is a struct that holds info on the type of a map tile
+type Content struct {
+	Type string `json:"type"`
+	Code string `json:"code"`
+}
+
+// Map is a struct that holds info on a map tile
+type Map struct {
+	Name    string  `json:"name"`
+	Skin    string  `json:"skin"`
+	X       int     `json:"x"`
+	Y       int     `json:"y"`
+	Content Content `json:"content"`
+}
diff --git a/status/status.go b/status/status.go
new file mode 100644
index 0000000..5d1569a
--- /dev/null
+++ b/status/status.go
@@ -0,0 +1,29 @@
+// Package status holds functions that interact with the status of the server
+package status
+
+import (
+	"time"
+
+	"git.stinnesbeck.com/nils/artifacts/api"
+)
+
+// Announcements is used to display messages and when they were created
+type Announcements struct {
+	Message   string    `json:"message"`
+	CreatedAt time.Time `json:"created_at"`
+}
+
+// ServerStatus is a struct that holds info on a server
+type ServerStatus struct {
+	Status           string          `json:"status"`
+	Version          string          `json:"version"`
+	CharactersOnline int             `json:"characters_online"`
+	Announcements    []Announcements `json:"announcements"`
+	LastWipe         string          `json:"last_wipe"`
+	NextWipe         string          `json:"next_wipe"`
+}
+
+// Get tries to retrieve the status of the server
+func Get() (ServerStatus, error) {
+	return api.Get[ServerStatus]("/", nil)
+}