517 lines
13 KiB
Go
517 lines
13 KiB
Go
|
package tw
|
||
|
|
||
|
import (
|
||
|
"context"
|
||
|
"encoding/csv"
|
||
|
"encoding/xml"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"io"
|
||
|
"net/http"
|
||
|
"net/url"
|
||
|
"strconv"
|
||
|
"strings"
|
||
|
"time"
|
||
|
|
||
|
"github.com/elliotchance/phpserialize"
|
||
|
|
||
|
"gitea.dwysokinski.me/twhelp/core/internal/domain"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
defaultUserAgent = "tribalwarshelp/development"
|
||
|
defaultTimeout = 10 * time.Second
|
||
|
fieldsPerRecordPlayer = 6
|
||
|
fieldsPerRecordTribe = 8
|
||
|
fieldsPerRecordVillage = 7
|
||
|
fieldsPerRecordOD = 3
|
||
|
|
||
|
endpointPlayers = "/map/player.txt"
|
||
|
endpointTribes = "/map/ally.txt"
|
||
|
endpointVillages = "/map/village.txt"
|
||
|
endpointPlayersODA = "/map/kill_att.txt"
|
||
|
endpointPlayersODD = "/map/kill_def.txt"
|
||
|
endpointPlayersODS = "/map/kill_sup.txt"
|
||
|
endpointPlayersOD = "/map/kill_all.txt"
|
||
|
endpointTribesODA = "/map/kill_att_tribe.txt"
|
||
|
endpointTribesODD = "/map/kill_def_tribe.txt"
|
||
|
endpointTribesOD = "/map/kill_all_tribe.txt"
|
||
|
endpointInterface = "/interface.php"
|
||
|
endpointConfig = endpointInterface + "?func=get_config"
|
||
|
endpointUnitConfig = endpointInterface + "?func=get_unit_info"
|
||
|
endpointBuildingConfig = endpointInterface + "?func=get_building_info"
|
||
|
endpointGetServers = "/backend/get_servers.php"
|
||
|
)
|
||
|
|
||
|
var (
|
||
|
ErrInvalidServerKey = errors.New("invalid server key")
|
||
|
ErrInvalidServerURL = errors.New("invalid server URL")
|
||
|
)
|
||
|
|
||
|
type ClientOption func(c *Client)
|
||
|
|
||
|
func WithUserAgent(ua string) ClientOption {
|
||
|
return func(c *Client) {
|
||
|
c.userAgent = ua
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func WithHTTPClient(hc *http.Client) ClientOption {
|
||
|
return func(c *Client) {
|
||
|
c.client = hc
|
||
|
}
|
||
|
}
|
||
|
|
||
|
type Client struct {
|
||
|
client *http.Client
|
||
|
userAgent string
|
||
|
}
|
||
|
|
||
|
func NewClient(opts ...ClientOption) *Client {
|
||
|
c := &Client{
|
||
|
client: &http.Client{
|
||
|
Timeout: defaultTimeout,
|
||
|
},
|
||
|
userAgent: defaultUserAgent,
|
||
|
}
|
||
|
|
||
|
for _, opt := range opts {
|
||
|
opt(c)
|
||
|
}
|
||
|
|
||
|
return c
|
||
|
}
|
||
|
|
||
|
func (c *Client) GetOpenServers(ctx context.Context, baseURL string) ([]domain.OpenServer, error) {
|
||
|
resp, err := c.get(ctx, buildURL(baseURL, endpointGetServers))
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.get: %w", err)
|
||
|
}
|
||
|
defer func() {
|
||
|
_ = resp.Body.Close()
|
||
|
}()
|
||
|
if resp.StatusCode != http.StatusOK {
|
||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||
|
return nil, fmt.Errorf("Non-OK HTTP status: %d", resp.StatusCode)
|
||
|
}
|
||
|
|
||
|
bytes, err := io.ReadAll(resp.Body)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("couldn't read response body: %w", err)
|
||
|
}
|
||
|
|
||
|
m, err := phpserialize.UnmarshalAssociativeArray(bytes)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("phpserialize.UnmarshalAssociativeArray: %w", err)
|
||
|
}
|
||
|
|
||
|
servers := make([]domain.OpenServer, 0, len(m))
|
||
|
for key, val := range m {
|
||
|
keyStr, ok := key.(string)
|
||
|
if !ok {
|
||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidServerKey, key)
|
||
|
}
|
||
|
|
||
|
urlStr, ok := val.(string)
|
||
|
if !ok {
|
||
|
return nil, fmt.Errorf("%w: %v", ErrInvalidServerURL, val)
|
||
|
}
|
||
|
|
||
|
servers = append(servers, domain.OpenServer{
|
||
|
Key: keyStr,
|
||
|
URL: urlStr,
|
||
|
})
|
||
|
}
|
||
|
|
||
|
return servers, nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) GetServerConfig(ctx context.Context, baseURL string) (domain.ServerConfig, error) {
|
||
|
var cfg serverConfig
|
||
|
|
||
|
if err := c.getXML(ctx, buildURL(baseURL, endpointConfig), &cfg); err != nil {
|
||
|
return domain.ServerConfig{}, fmt.Errorf("c.getXML: %w", err)
|
||
|
}
|
||
|
|
||
|
return cfg.toDomain(), nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) GetBuildingConfig(ctx context.Context, baseURL string) (domain.BuildingConfig, error) {
|
||
|
var cfg buildingConfig
|
||
|
|
||
|
if err := c.getXML(ctx, buildURL(baseURL, endpointBuildingConfig), &cfg); err != nil {
|
||
|
return domain.BuildingConfig{}, fmt.Errorf("c.getXML: %w", err)
|
||
|
}
|
||
|
|
||
|
return cfg.toDomain(), nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) GetUnitConfig(ctx context.Context, baseURL string) (domain.UnitConfig, error) {
|
||
|
var cfg unitConfig
|
||
|
|
||
|
if err := c.getXML(ctx, buildURL(baseURL, endpointUnitConfig), &cfg); err != nil {
|
||
|
return domain.UnitConfig{}, fmt.Errorf("c.getXML: %w", err)
|
||
|
}
|
||
|
|
||
|
return cfg.toDomain(), nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) GetTribes(ctx context.Context, baseURL string) ([]domain.BaseTribe, error) {
|
||
|
od, err := c.getOD(ctx, baseURL, true)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.getOD: %w", err)
|
||
|
}
|
||
|
|
||
|
records, err := c.getCSV(ctx, buildURL(baseURL, endpointTribes), fieldsPerRecordTribe)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.getCSV: %w", err)
|
||
|
}
|
||
|
|
||
|
tribes := make([]domain.BaseTribe, len(records))
|
||
|
for i, rec := range records {
|
||
|
tribe, err := c.parseTribeRecord(rec, od)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.parseTribeRecord: %w", err)
|
||
|
}
|
||
|
|
||
|
tribes[i] = tribe
|
||
|
}
|
||
|
|
||
|
return tribes, nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) parseTribeRecord(record []string, od map[int64]domain.OpponentsDefeated) (domain.BaseTribe, error) {
|
||
|
var err error
|
||
|
var tribe domain.BaseTribe
|
||
|
|
||
|
tribe.ID, err = strconv.ParseInt(record[0], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseTribe{}, NewParseError(err, record, "tribe.ID")
|
||
|
}
|
||
|
|
||
|
tribe.Name, err = url.QueryUnescape(record[1])
|
||
|
if err != nil {
|
||
|
return domain.BaseTribe{}, NewParseError(err, record, "tribe.Name")
|
||
|
}
|
||
|
|
||
|
tribe.Tag, err = url.QueryUnescape(record[2])
|
||
|
if err != nil {
|
||
|
return domain.BaseTribe{}, NewParseError(err, record, "tribe.Tag")
|
||
|
}
|
||
|
|
||
|
tribe.NumMembers, err = strconv.ParseInt(record[3], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseTribe{}, NewParseError(err, record, "tribe.NumMembers")
|
||
|
}
|
||
|
|
||
|
tribe.NumVillages, err = strconv.ParseInt(record[4], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseTribe{}, NewParseError(err, record, "tribe.NumVillages")
|
||
|
}
|
||
|
|
||
|
tribe.Points, err = strconv.ParseInt(record[5], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseTribe{}, NewParseError(err, record, "tribe.Points")
|
||
|
}
|
||
|
|
||
|
tribe.AllPoints, err = strconv.ParseInt(record[6], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseTribe{}, NewParseError(err, record, "tribe.AllPoints")
|
||
|
}
|
||
|
|
||
|
tribe.Rank, err = strconv.ParseInt(record[7], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseTribe{}, NewParseError(err, record, "tribe.Rank")
|
||
|
}
|
||
|
|
||
|
tribe.OpponentsDefeated = od[tribe.ID]
|
||
|
|
||
|
return tribe, nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) GetPlayers(ctx context.Context, baseURL string) ([]domain.BasePlayer, error) {
|
||
|
od, err := c.getOD(ctx, baseURL, false)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.getOD: %w", err)
|
||
|
}
|
||
|
|
||
|
records, err := c.getCSV(ctx, buildURL(baseURL, endpointPlayers), fieldsPerRecordPlayer)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.getCSV: %w", err)
|
||
|
}
|
||
|
|
||
|
players := make([]domain.BasePlayer, len(records))
|
||
|
for i, rec := range records {
|
||
|
player, err := c.parsePlayerRecord(rec, od)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.parsePlayerRecord: %w", err)
|
||
|
}
|
||
|
|
||
|
players[i] = player
|
||
|
}
|
||
|
|
||
|
return players, nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) parsePlayerRecord(record []string, od map[int64]domain.OpponentsDefeated) (domain.BasePlayer, error) {
|
||
|
var err error
|
||
|
var player domain.BasePlayer
|
||
|
|
||
|
player.ID, err = strconv.ParseInt(record[0], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BasePlayer{}, NewParseError(err, record, "player.ID")
|
||
|
}
|
||
|
|
||
|
player.Name, err = url.QueryUnescape(record[1])
|
||
|
if err != nil {
|
||
|
return domain.BasePlayer{}, NewParseError(err, record, "player.Name")
|
||
|
}
|
||
|
|
||
|
player.TribeID, err = strconv.ParseInt(record[2], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BasePlayer{}, NewParseError(err, record, "player.TribeID")
|
||
|
}
|
||
|
|
||
|
player.NumVillages, err = strconv.ParseInt(record[3], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BasePlayer{}, NewParseError(err, record, "player.NumVillages")
|
||
|
}
|
||
|
|
||
|
player.Points, err = strconv.ParseInt(record[4], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BasePlayer{}, NewParseError(err, record, "player.Points")
|
||
|
}
|
||
|
|
||
|
player.Rank, err = strconv.ParseInt(record[5], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BasePlayer{}, NewParseError(err, record, "player.Rank")
|
||
|
}
|
||
|
|
||
|
player.OpponentsDefeated = od[player.ID]
|
||
|
|
||
|
return player, nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) getOD(ctx context.Context, baseURL string, tribe bool) (map[int64]domain.OpponentsDefeated, error) {
|
||
|
m := make(map[int64]domain.OpponentsDefeated)
|
||
|
urls := buildODURLs(baseURL, tribe)
|
||
|
|
||
|
for _, u := range urls {
|
||
|
if u == "" {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
records, err := c.getCSV(ctx, u, fieldsPerRecordOD)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.getCSV: %w", err)
|
||
|
}
|
||
|
|
||
|
for _, rec := range records {
|
||
|
parsed, err := c.parseODRecord(rec)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.parseODRecord: %w", err)
|
||
|
}
|
||
|
|
||
|
od := m[parsed.ID]
|
||
|
|
||
|
switch u {
|
||
|
case urls[0]:
|
||
|
od.RankTotal = parsed.Rank
|
||
|
od.ScoreTotal = parsed.Score
|
||
|
case urls[1]:
|
||
|
od.RankAtt = parsed.Rank
|
||
|
od.ScoreAtt = parsed.Score
|
||
|
case urls[2]:
|
||
|
od.RankDef = parsed.Rank
|
||
|
od.ScoreDef = parsed.Score
|
||
|
case urls[3]:
|
||
|
od.RankSup = parsed.Rank
|
||
|
od.ScoreSup = parsed.Score
|
||
|
}
|
||
|
|
||
|
m[parsed.ID] = od
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return m, nil
|
||
|
}
|
||
|
|
||
|
func buildODURLs(base string, tribe bool) [4]string {
|
||
|
if tribe {
|
||
|
return [4]string{
|
||
|
buildURL(base, endpointTribesOD),
|
||
|
buildURL(base, endpointTribesODA),
|
||
|
buildURL(base, endpointTribesODD),
|
||
|
"",
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return [4]string{
|
||
|
buildURL(base, endpointPlayersOD),
|
||
|
buildURL(base, endpointPlayersODA),
|
||
|
buildURL(base, endpointPlayersODD),
|
||
|
buildURL(base, endpointPlayersODS),
|
||
|
}
|
||
|
}
|
||
|
|
||
|
type odRecord struct {
|
||
|
ID int64
|
||
|
Rank int64
|
||
|
Score int64
|
||
|
}
|
||
|
|
||
|
func (c *Client) parseODRecord(record []string) (odRecord, error) {
|
||
|
var err error
|
||
|
var p odRecord
|
||
|
|
||
|
p.Rank, err = strconv.ParseInt(record[0], 10, 0)
|
||
|
if err != nil {
|
||
|
return odRecord{}, NewParseError(err, record, "odRecord.Rank")
|
||
|
}
|
||
|
|
||
|
p.ID, err = strconv.ParseInt(record[1], 10, 0)
|
||
|
if err != nil {
|
||
|
return odRecord{}, NewParseError(err, record, "odRecord.ID")
|
||
|
}
|
||
|
|
||
|
p.Score, err = strconv.ParseInt(record[2], 10, 0)
|
||
|
if err != nil {
|
||
|
return odRecord{}, NewParseError(err, record, "odRecord.Score")
|
||
|
}
|
||
|
|
||
|
return p, nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) GetVillages(ctx context.Context, baseURL string) ([]domain.BaseVillage, error) {
|
||
|
records, err := c.getCSV(ctx, buildURL(baseURL, endpointVillages), fieldsPerRecordVillage)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.getCSV: %w", err)
|
||
|
}
|
||
|
|
||
|
villages := make([]domain.BaseVillage, len(records))
|
||
|
for i, rec := range records {
|
||
|
village, err := c.parseVillageRecord(rec)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.parseVillageRecord: %w", err)
|
||
|
}
|
||
|
|
||
|
villages[i] = village
|
||
|
}
|
||
|
|
||
|
return villages, nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) parseVillageRecord(record []string) (domain.BaseVillage, error) {
|
||
|
var err error
|
||
|
var village domain.BaseVillage
|
||
|
|
||
|
village.ID, err = strconv.ParseInt(record[0], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseVillage{}, NewParseError(err, record, "village.ID")
|
||
|
}
|
||
|
|
||
|
village.Name, err = url.QueryUnescape(record[1])
|
||
|
if err != nil {
|
||
|
return domain.BaseVillage{}, NewParseError(err, record, "village.Name")
|
||
|
}
|
||
|
|
||
|
village.X, err = strconv.ParseInt(record[2], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseVillage{}, NewParseError(err, record, "village.X")
|
||
|
}
|
||
|
|
||
|
village.Y, err = strconv.ParseInt(record[3], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseVillage{}, NewParseError(err, record, "village.Y")
|
||
|
}
|
||
|
|
||
|
village.PlayerID, err = strconv.ParseInt(record[4], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseVillage{}, NewParseError(err, record, "village.PlayerID")
|
||
|
}
|
||
|
|
||
|
village.Points, err = strconv.ParseInt(record[5], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseVillage{}, NewParseError(err, record, "village.Points")
|
||
|
}
|
||
|
|
||
|
village.Bonus, err = strconv.ParseInt(record[6], 10, 0)
|
||
|
if err != nil {
|
||
|
return domain.BaseVillage{}, NewParseError(err, record, "village.Bonus")
|
||
|
}
|
||
|
|
||
|
village.Continent = "K" + string(record[3][0]) + string(record[2][0])
|
||
|
|
||
|
return village, nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) getXML(ctx context.Context, url string, v any) error {
|
||
|
resp, err := c.get(ctx, url)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("c.get: %w", err)
|
||
|
}
|
||
|
defer func() {
|
||
|
_ = resp.Body.Close()
|
||
|
}()
|
||
|
if resp.StatusCode != http.StatusOK {
|
||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||
|
return fmt.Errorf("got non-ok HTTP status: %d", resp.StatusCode)
|
||
|
}
|
||
|
|
||
|
if err := xml.NewDecoder(resp.Body).Decode(v); err != nil {
|
||
|
return fmt.Errorf("xml.Decode: %w", err)
|
||
|
}
|
||
|
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) getCSV(ctx context.Context, url string, fieldsPerRecord int) ([][]string, error) {
|
||
|
resp, err := c.get(ctx, url)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("c.get: %w", err)
|
||
|
}
|
||
|
defer func() {
|
||
|
_ = resp.Body.Close()
|
||
|
}()
|
||
|
if resp.StatusCode != http.StatusOK {
|
||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||
|
return nil, fmt.Errorf("got non-ok HTTP status: %d", resp.StatusCode)
|
||
|
}
|
||
|
|
||
|
r := csv.NewReader(resp.Body)
|
||
|
r.Comma = ','
|
||
|
r.FieldsPerRecord = fieldsPerRecord
|
||
|
|
||
|
records, err := r.ReadAll()
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("CSVReader.ReadAll: %w", err)
|
||
|
}
|
||
|
|
||
|
return records, nil
|
||
|
}
|
||
|
|
||
|
func (c *Client) get(ctx context.Context, url string) (*http.Response, error) {
|
||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("http.NewRequestWithContext: %w", err)
|
||
|
}
|
||
|
|
||
|
// headers
|
||
|
req.Header.Set("User-Agent", c.userAgent)
|
||
|
|
||
|
resp, err := c.client.Do(req)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("client.Do: %w", err)
|
||
|
}
|
||
|
|
||
|
return resp, nil
|
||
|
}
|
||
|
|
||
|
func buildURL(base, endpoint string) string {
|
||
|
if !strings.HasPrefix(base, "http") {
|
||
|
return "https://" + base + endpoint
|
||
|
}
|
||
|
|
||
|
return base + endpoint
|
||
|
}
|