This repository has been archived on 2024-04-06. You can view files and clone it, but cannot push or open issues or pull requests.
core-old/internal/tw/client.go
Dawid Wysokiński 56dc421bd0
All checks were successful
continuous-integration/drone/push Build is passing
refactor(tw): improve test quality & refactor ParseError
2023-02-26 08:42:02 +01:00

775 lines
18 KiB
Go

package tw
import (
"context"
"encoding/csv"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
urlpkg "net/url"
"sort"
"strconv"
"time"
"github.com/elliotchance/phpserialize"
)
const (
fieldsPerRecordPlayer = 6
fieldsPerRecordTribe = 8
fieldsPerRecordVillage = 7
fieldsPerRecordOD = 3
fieldsPerRecordEnnoblement = 7
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"
endpointMapEnnoblements = "/map/conquer_extended.txt"
endpointInterface = "/interface.php"
endpointGetServers = "/backend/get_servers.php"
endpointGame = "/game.php"
queryConfig = "func=get_config"
queryUnitInfo = "func=get_unit_info"
queryBuildingInfo = "func=get_building_info"
queryInterfaceEnnoblements = "func=get_conquer_extended&since=%d"
queryPlayerProfile = "screen=info_player&id=%d"
queryTribeProfile = "screen=info_ally&id=%d"
queryVillageProfile = "screen=info_village&id=%d"
)
var (
ErrInvalidServerKey = errors.New("invalid server key")
ErrInvalidServerURL = errors.New("invalid server URL")
)
type Client struct {
client *http.Client
userAgent string
ennoblementsUseInterfaceFunc func(since time.Time) bool
}
func NewClient(opts ...ClientOption) *Client {
cfg := newClientConfig(opts...)
return &Client{
client: cfg.client,
userAgent: cfg.userAgent,
ennoblementsUseInterfaceFunc: cfg.ennoblementsUseInterfaceFunc,
}
}
type Server struct {
Key string
URL string
}
func (c *Client) GetOpenServers(ctx context.Context, baseURLRaw string) ([]Server, error) {
baseURL, err := urlpkg.Parse(baseURLRaw)
if err != nil {
return nil, err
}
url := buildURL(baseURL, endpointGetServers)
resp, err := c.get(ctx, url)
if err != nil {
return nil, fmt.Errorf("c.get: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("couldn't read response body: %w", err)
}
m, err := phpserialize.UnmarshalAssociativeArray(b)
if err != nil {
return nil, fmt.Errorf("%s: phpserialize.UnmarshalAssociativeArray: %w", url, err)
}
servers := make([]Server, 0, len(m))
for key, val := range m {
keyStr, ok := key.(string)
if !ok || keyStr == "" {
return nil, fmt.Errorf("%s: %w: %v", url, ErrInvalidServerKey, key)
}
urlStr, ok := val.(string)
if !ok || urlStr == "" {
return nil, fmt.Errorf("%s: %w: %v", url, ErrInvalidServerURL, val)
}
servers = append(servers, Server{
Key: keyStr,
URL: urlStr,
})
}
return servers, nil
}
func (c *Client) GetServerConfig(ctx context.Context, baseURLRaw string) (ServerConfig, error) {
baseURL, err := urlpkg.Parse(baseURLRaw)
if err != nil {
return ServerConfig{}, err
}
var cfg ServerConfig
if err = c.getXML(ctx, buildURLWithQuery(baseURL, endpointInterface, queryConfig), &cfg); err != nil {
return ServerConfig{}, err
}
return cfg, nil
}
func (c *Client) GetBuildingInfo(ctx context.Context, baseURLRaw string) (BuildingInfo, error) {
baseURL, err := urlpkg.Parse(baseURLRaw)
if err != nil {
return BuildingInfo{}, err
}
var info BuildingInfo
if err = c.getXML(ctx, buildURLWithQuery(baseURL, endpointInterface, queryBuildingInfo), &info); err != nil {
return BuildingInfo{}, err
}
return info, nil
}
func (c *Client) GetUnitInfo(ctx context.Context, baseURLRaw string) (UnitInfo, error) {
baseURL, err := urlpkg.Parse(baseURLRaw)
if err != nil {
return UnitInfo{}, err
}
var info UnitInfo
if err = c.getXML(ctx, buildURLWithQuery(baseURL, endpointInterface, queryUnitInfo), &info); err != nil {
return UnitInfo{}, err
}
return info, nil
}
func (c *Client) GetTribes(ctx context.Context, baseURLRaw string) ([]Tribe, error) {
baseURL, err := urlpkg.Parse(baseURLRaw)
if err != nil {
return nil, err
}
od, err := c.getOD(ctx, baseURL, true)
if err != nil {
return nil, err
}
resp, err := c.get(ctx, buildURL(baseURL, endpointTribes))
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
return tribeCSVParser{
r: resp.Body,
baseURL: baseURL,
od: od,
}.parse()
}
func (c *Client) GetPlayers(ctx context.Context, baseURLRaw string) ([]Player, error) {
baseURL, err := urlpkg.Parse(baseURLRaw)
if err != nil {
return nil, err
}
od, err := c.getOD(ctx, baseURL, false)
if err != nil {
return nil, err
}
resp, err := c.get(ctx, buildURL(baseURL, endpointPlayers))
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
return playerCSVParser{
r: resp.Body,
baseURL: baseURL,
od: od,
}.parse()
}
func (c *Client) getOD(ctx context.Context, baseURL *urlpkg.URL, tribe bool) (map[int64]OpponentsDefeated, error) {
m := make(map[int64]OpponentsDefeated)
urls := buildODURLs(baseURL, tribe)
for _, u := range urls {
if u == nil {
continue
}
records, err := c.getSingleODFile(ctx, u)
if err != nil {
return nil, err
}
for _, rec := range records {
od := m[rec.ID]
switch u {
case urls[0]:
od.RankTotal = rec.Rank
od.ScoreTotal = rec.Score
case urls[1]:
od.RankAtt = rec.Rank
od.ScoreAtt = rec.Score
case urls[2]:
od.RankDef = rec.Rank
od.ScoreDef = rec.Score
case urls[3]:
od.RankSup = rec.Rank
od.ScoreSup = rec.Score
}
m[rec.ID] = od
}
}
return m, nil
}
func (c *Client) getSingleODFile(ctx context.Context, url *urlpkg.URL) ([]odRecord, error) {
resp, err := c.get(ctx, url)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
return odCSVParser{
r: resp.Body,
}.parse()
}
func (c *Client) GetVillages(ctx context.Context, baseURLRaw string) ([]Village, error) {
baseURL, err := urlpkg.Parse(baseURLRaw)
if err != nil {
return nil, err
}
resp, err := c.get(ctx, buildURL(baseURL, endpointVillages))
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
return villageCSVParser{
r: resp.Body,
baseURL: baseURL,
}.parse()
}
func (c *Client) GetEnnoblements(ctx context.Context, baseURLRaw string, since time.Time) ([]Ennoblement, error) {
baseURL, err := urlpkg.Parse(baseURLRaw)
if err != nil {
return nil, err
}
url := buildURL(baseURL, endpointMapEnnoblements)
if c.ennoblementsUseInterfaceFunc(since) {
url = buildURLWithQuery(baseURL, endpointInterface, fmt.Sprintf(queryInterfaceEnnoblements, since.Unix()))
}
resp, err := c.get(ctx, url)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
return ennoblementCSVParser{
r: resp.Body,
since: since,
}.parse()
}
func (c *Client) getXML(ctx context.Context, url *urlpkg.URL, v any) error {
resp, err := c.get(ctx, url)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if err := xml.NewDecoder(resp.Body).Decode(v); err != nil {
return fmt.Errorf("xml.Decode: %w", err)
}
return nil
}
func (c *Client) get(ctx context.Context, url *urlpkg.URL) (*http.Response, error) {
urlStr := url.String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, fmt.Errorf("%s: http.NewRequestWithContext: %w", urlStr, err)
}
// headers
req.Header.Set("User-Agent", c.userAgent)
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("%s: client.Do: %w", urlStr, err)
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("%s: got non-ok HTTP status: %d", urlStr, resp.StatusCode)
}
return resp, nil
}
type tribeCSVParser struct {
r io.Reader
baseURL *urlpkg.URL
od map[int64]OpponentsDefeated
}
func (t tribeCSVParser) parse() ([]Tribe, error) {
csvR := newCSVReader(t.r, fieldsPerRecordTribe)
//nolint:prealloc
var tribes []Tribe
for {
rec, err := csvR.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
tribe, err := t.parseRecord(rec)
if err != nil {
return nil, err
}
tribes = append(tribes, tribe)
}
sort.SliceStable(tribes, func(i, j int) bool {
return tribes[i].ID < tribes[j].ID
})
return tribes, nil
}
func (t tribeCSVParser) parseRecord(record []string) (Tribe, error) {
var err error
var tribe Tribe
tribe.ID, err = strconv.ParseInt(record[0], 10, 64)
if err != nil {
return Tribe{}, ParseError{Err: err, Str: record[0], Field: "Tribe.ID"}
}
tribe.Name, err = urlpkg.QueryUnescape(record[1])
if err != nil {
return Tribe{}, ParseError{Err: err, Str: record[1], Field: "Tribe.Name"}
}
tribe.Tag, err = urlpkg.QueryUnescape(record[2])
if err != nil {
return Tribe{}, ParseError{Err: err, Str: record[2], Field: "Tribe.Tag"}
}
tribe.NumMembers, err = strconv.ParseInt(record[3], 10, 64)
if err != nil {
return Tribe{}, ParseError{Err: err, Str: record[3], Field: "Tribe.NumMembers"}
}
tribe.NumVillages, err = strconv.ParseInt(record[4], 10, 64)
if err != nil {
return Tribe{}, ParseError{Err: err, Str: record[4], Field: "Tribe.NumVillages"}
}
tribe.Points, err = strconv.ParseInt(record[5], 10, 64)
if err != nil {
return Tribe{}, ParseError{Err: err, Str: record[5], Field: "Tribe.Points"}
}
tribe.AllPoints, err = strconv.ParseInt(record[6], 10, 64)
if err != nil {
return Tribe{}, ParseError{Err: err, Str: record[6], Field: "Tribe.AllPoints"}
}
tribe.Rank, err = strconv.ParseInt(record[7], 10, 64)
if err != nil {
return Tribe{}, ParseError{Err: err, Str: record[7], Field: "Tribe.Rank"}
}
tribe.OpponentsDefeated = t.od[tribe.ID]
tribe.ProfileURL = buildURLWithQuery(t.baseURL, endpointGame, fmt.Sprintf(queryTribeProfile, tribe.ID)).String()
return tribe, nil
}
type playerCSVParser struct {
r io.Reader
od map[int64]OpponentsDefeated
baseURL *urlpkg.URL
}
func (p playerCSVParser) parse() ([]Player, error) {
csvR := newCSVReader(p.r, fieldsPerRecordPlayer)
//nolint:prealloc
var players []Player
for {
rec, err := csvR.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
player, err := p.parseRecord(rec)
if err != nil {
return nil, err
}
players = append(players, player)
}
sort.SliceStable(players, func(i, j int) bool {
return players[i].ID < players[j].ID
})
return players, nil
}
func (p playerCSVParser) parseRecord(record []string) (Player, error) {
var err error
var player Player
player.ID, err = strconv.ParseInt(record[0], 10, 64)
if err != nil {
return Player{}, ParseError{Err: err, Str: record[0], Field: "Player.ID"}
}
player.Name, err = urlpkg.QueryUnescape(record[1])
if err != nil {
return Player{}, ParseError{Err: err, Str: record[1], Field: "Player.Name"}
}
player.TribeID, err = strconv.ParseInt(record[2], 10, 64)
if err != nil {
return Player{}, ParseError{Err: err, Str: record[2], Field: "Player.TribeID"}
}
player.NumVillages, err = strconv.ParseInt(record[3], 10, 64)
if err != nil {
return Player{}, ParseError{Err: err, Str: record[3], Field: "Player.NumVillages"}
}
player.Points, err = strconv.ParseInt(record[4], 10, 64)
if err != nil {
return Player{}, ParseError{Err: err, Str: record[4], Field: "Player.Points"}
}
player.Rank, err = strconv.ParseInt(record[5], 10, 64)
if err != nil {
return Player{}, ParseError{Err: err, Str: record[5], Field: "Player.Rank"}
}
player.OpponentsDefeated = p.od[player.ID]
player.ProfileURL = buildURLWithQuery(p.baseURL, endpointGame, fmt.Sprintf(queryPlayerProfile, player.ID)).String()
return player, nil
}
type villageCSVParser struct {
r io.Reader
baseURL *urlpkg.URL
}
func (v villageCSVParser) parse() ([]Village, error) {
csvR := newCSVReader(v.r, fieldsPerRecordVillage)
//nolint:prealloc
var villages []Village
for {
rec, err := csvR.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
village, err := v.parseRecord(rec)
if err != nil {
return nil, err
}
villages = append(villages, village)
}
sort.SliceStable(villages, func(i, j int) bool {
return villages[i].ID < villages[j].ID
})
return villages, nil
}
func (v villageCSVParser) parseRecord(record []string) (Village, error) {
var err error
var village Village
village.ID, err = strconv.ParseInt(record[0], 10, 64)
if err != nil {
return Village{}, ParseError{Err: err, Str: record[0], Field: "Village.ID"}
}
village.Name, err = urlpkg.QueryUnescape(record[1])
if err != nil {
return Village{}, ParseError{Err: err, Str: record[1], Field: "Village.Name"}
}
village.X, err = strconv.ParseInt(record[2], 10, 64)
if err != nil {
return Village{}, ParseError{Err: err, Str: record[2], Field: "Village.X"}
}
village.Y, err = strconv.ParseInt(record[3], 10, 64)
if err != nil {
return Village{}, ParseError{Err: err, Str: record[3], Field: "Village.Y"}
}
village.PlayerID, err = strconv.ParseInt(record[4], 10, 64)
if err != nil {
return Village{}, ParseError{Err: err, Str: record[4], Field: "Village.PlayerID"}
}
village.Points, err = strconv.ParseInt(record[5], 10, 64)
if err != nil {
return Village{}, ParseError{Err: err, Str: record[5], Field: "Village.Points"}
}
village.Bonus, err = strconv.ParseInt(record[6], 10, 64)
if err != nil {
return Village{}, ParseError{Err: err, Str: record[6], Field: "Village.Bonus"}
}
village.Continent = "K" + string(record[3][0]) + string(record[2][0])
village.ProfileURL = buildURLWithQuery(v.baseURL, endpointGame, fmt.Sprintf(queryVillageProfile, village.ID)).String()
return village, nil
}
type odCSVParser struct {
r io.Reader
}
type odRecord struct {
ID int64
Rank int64
Score int64
}
func (o odCSVParser) parse() ([]odRecord, error) {
csvR := newCSVReader(o.r, fieldsPerRecordOD)
//nolint:prealloc
var odRecords []odRecord
for {
rec, err := csvR.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
od, err := o.parseRecord(rec)
if err != nil {
return nil, err
}
odRecords = append(odRecords, od)
}
return odRecords, nil
}
func (o odCSVParser) parseRecord(record []string) (odRecord, error) {
var err error
var rec odRecord
rec.Rank, err = strconv.ParseInt(record[0], 10, 64)
if err != nil {
return odRecord{}, ParseError{Err: err, Str: record[0], Field: "odRecord.Rank"}
}
rec.ID, err = strconv.ParseInt(record[1], 10, 64)
if err != nil {
return odRecord{}, ParseError{Err: err, Str: record[1], Field: "odRecord.ID"}
}
rec.Score, err = strconv.ParseInt(record[2], 10, 64)
if err != nil {
return odRecord{}, ParseError{Err: err, Str: record[2], Field: "odRecord.Score"}
}
return rec, nil
}
type ennoblementCSVParser struct {
r io.Reader
since time.Time
}
func (e ennoblementCSVParser) parse() ([]Ennoblement, error) {
csvR := newCSVReader(e.r, fieldsPerRecordEnnoblement)
//nolint:prealloc
var ennoblements []Ennoblement
for {
rec, err := csvR.Read()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
ennoblement, err := e.parseRecord(rec)
if err != nil {
return nil, err
}
if ennoblement.CreatedAt.Before(e.since) {
continue
}
ennoblements = append(ennoblements, ennoblement)
}
sort.SliceStable(ennoblements, func(i, j int) bool {
return ennoblements[i].CreatedAt.Before(ennoblements[j].CreatedAt)
})
return ennoblements, nil
}
func (e ennoblementCSVParser) parseRecord(record []string) (Ennoblement, error) {
var err error
var ennoblement Ennoblement
ennoblement.VillageID, err = strconv.ParseInt(record[0], 10, 64)
if err != nil {
return Ennoblement{}, ParseError{Err: err, Str: record[0], Field: "Ennoblement.VillageID"}
}
ennoblement.CreatedAt, err = e.parseTimestamp(record[1])
if err != nil {
return Ennoblement{}, ParseError{Err: err, Str: record[1], Field: "Ennoblement.CreatedAt"}
}
ennoblement.NewOwnerID, err = strconv.ParseInt(record[2], 10, 64)
if err != nil {
return Ennoblement{}, ParseError{Err: err, Str: record[2], Field: "Ennoblement.NewOwnerID"}
}
ennoblement.OldOwnerID, err = strconv.ParseInt(record[3], 10, 64)
if err != nil {
return Ennoblement{}, ParseError{Err: err, Str: record[3], Field: "Ennoblement.OldOwnerID"}
}
ennoblement.OldTribeID, err = strconv.ParseInt(record[4], 10, 64)
if err != nil {
return Ennoblement{}, ParseError{Err: err, Str: record[4], Field: "Ennoblement.OldTribeID"}
}
ennoblement.NewTribeID, err = strconv.ParseInt(record[5], 10, 64)
if err != nil {
return Ennoblement{}, ParseError{Err: err, Str: record[5], Field: "Ennoblement.NewTribeID"}
}
ennoblement.Points, err = strconv.ParseInt(record[6], 10, 64)
if err != nil {
return Ennoblement{}, ParseError{Err: err, Str: record[6], Field: "Ennoblement.Points"}
}
return ennoblement, nil
}
func (e ennoblementCSVParser) parseTimestamp(s string) (time.Time, error) {
timestamp, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return time.Time{}, err
}
return time.Unix(timestamp, 0), nil
}
func newCSVReader(r io.Reader, fieldsPerRecord int) *csv.Reader {
csvR := csv.NewReader(r)
csvR.Comma = ','
csvR.FieldsPerRecord = fieldsPerRecord
csvR.ReuseRecord = true
return csvR
}
func buildODURLs(base *urlpkg.URL, tribe bool) [4]*urlpkg.URL {
if tribe {
return [4]*urlpkg.URL{
buildURL(base, endpointTribesOD),
buildURL(base, endpointTribesODA),
buildURL(base, endpointTribesODD),
nil,
}
}
return [4]*urlpkg.URL{
buildURL(base, endpointPlayersOD),
buildURL(base, endpointPlayersODA),
buildURL(base, endpointPlayersODD),
buildURL(base, endpointPlayersODS),
}
}
func buildURL(base *urlpkg.URL, path string) *urlpkg.URL {
return buildURLWithQuery(base, path, "")
}
func buildURLWithQuery(base *urlpkg.URL, path, query string) *urlpkg.URL {
return &urlpkg.URL{
Scheme: base.Scheme,
Host: base.Host,
Path: path,
RawQuery: query,
}
}