package tw import ( "cmp" "context" "encoding/csv" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" "slices" "strconv" "time" "github.com/elliotchance/phpserialize" ) const ( 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" endpointEnnoblements = "/map/conquer_extended.txt" endpointInterface = "/interface.php" endpointGetServers = "/backend/get_servers.php" endpointGame = "/game.php" ) const ( 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" ) 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, } } var ErrBaseURLCantBeNil = errors.New("baseURL can't be nil") var ( ErrInvalidServerKey = errors.New("invalid server key") ErrInvalidServerURL = errors.New("invalid server URL") ) func (c *Client) GetOpenServers(ctx context.Context, baseURL *url.URL) ([]Server, error) { if baseURL == nil { return nil, ErrBaseURLCantBeNil } resp, err := c.get(ctx, buildURL(baseURL, endpointGetServers)) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() return serverParser{r: resp.Body}.parse() } func (c *Client) GetServerConfig(ctx context.Context, baseURL *url.URL) (ServerConfig, error) { if baseURL == nil { return ServerConfig{}, ErrBaseURLCantBeNil } 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, baseURL *url.URL) (BuildingInfo, error) { if baseURL == nil { return BuildingInfo{}, ErrBaseURLCantBeNil } 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, baseURL *url.URL) (UnitInfo, error) { if baseURL == nil { return UnitInfo{}, ErrBaseURLCantBeNil } 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, baseURL *url.URL) ([]Tribe, error) { if baseURL == nil { return nil, ErrBaseURLCantBeNil } 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, baseURL *url.URL) ([]Player, error) { if baseURL == nil { return nil, ErrBaseURLCantBeNil } 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 *url.URL, tribe bool) (map[int]OpponentsDefeated, error) { m := make(map[int]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, u *url.URL) ([]odRecord, error) { resp, err := c.get(ctx, u) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() return odCSVParser{ r: resp.Body, }.parse() } func (c *Client) GetVillages(ctx context.Context, baseURL *url.URL) ([]Village, error) { if baseURL == nil { return nil, ErrBaseURLCantBeNil } 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, baseURL *url.URL, since time.Time) ([]Ennoblement, error) { if baseURL == nil { return nil, ErrBaseURLCantBeNil } u := buildURL(baseURL, endpointEnnoblements) if c.ennoblementsUseInterfaceFunc(since) { u = buildURLWithQuery(baseURL, endpointInterface, fmt.Sprintf(queryInterfaceEnnoblements, since.Unix())) } resp, err := c.get(ctx, u) 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, u *url.URL, v any) error { resp, err := c.get(ctx, u) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if err = xml.NewDecoder(resp.Body).Decode(v); err != nil { return fmt.Errorf("%s: %w", u, err) } return nil } func (c *Client) get(ctx context.Context, u *url.URL) (*http.Response, error) { urlStr := u.String() req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) if err != nil { return nil, err } // headers req.Header.Set("User-Agent", c.userAgent) resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("%s: %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 serverParser struct { r io.Reader } func (p serverParser) parse() ([]Server, error) { b, err := io.ReadAll(p.r) if err != nil { return nil, fmt.Errorf("couldn't read data: %w", err) } m, err := phpserialize.UnmarshalAssociativeArray(b) if err != nil { return nil, fmt.Errorf("couldn't unmarshal response body: %w", err) } servers := make([]Server, 0, len(m)) for key, val := range m { keyStr, ok := key.(string) if !ok || keyStr == "" { return nil, fmt.Errorf("parsing '%v': %w", key, ErrInvalidServerKey) } urlStr, ok := val.(string) if !ok || urlStr == "" { return nil, fmt.Errorf("parsing '%v': %w", val, ErrInvalidServerURL) } serverURL, parseErr := url.ParseRequestURI(urlStr) if parseErr != nil { return nil, fmt.Errorf("parsing '%v': %w", val, parseErr) } servers = append(servers, Server{ Key: keyStr, URL: serverURL, }) } return servers, nil } type tribeCSVParser struct { r io.Reader baseURL *url.URL od map[int]OpponentsDefeated } const fieldsPerRecordTribe = 8 func (p tribeCSVParser) parse() ([]Tribe, error) { csvR := newCSVReader(p.r, fieldsPerRecordTribe) var tribes []Tribe for { rec, err := csvR.Read() if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } tribe, err := p.parseRecord(rec) if err != nil { return nil, err } tribes = append(tribes, tribe) } slices.SortFunc(tribes, func(a, b Tribe) int { return cmp.Compare(a.ID, b.ID) }) return tribes, nil } func (p tribeCSVParser) parseRecord(record []string) (Tribe, error) { var err error var tribe Tribe // $id, $name, $tag, $members, $villages, $points, $all_points, $rank tribe.ID, err = strconv.Atoi(record[0]) if err != nil { return Tribe{}, ParseError{Err: err, Str: record[0], Field: "Tribe.ID"} } tribe.Name, err = url.QueryUnescape(record[1]) if err != nil { return Tribe{}, ParseError{Err: err, Str: record[1], Field: "Tribe.Name"} } tribe.Tag, err = url.QueryUnescape(record[2]) if err != nil { return Tribe{}, ParseError{Err: err, Str: record[2], Field: "Tribe.Tag"} } tribe.NumMembers, err = strconv.Atoi(record[3]) if err != nil { return Tribe{}, ParseError{Err: err, Str: record[3], Field: "Tribe.NumMembers"} } tribe.NumVillages, err = strconv.Atoi(record[4]) if err != nil { return Tribe{}, ParseError{Err: err, Str: record[4], Field: "Tribe.NumVillages"} } tribe.Points, err = strconv.Atoi(record[5]) if err != nil { return Tribe{}, ParseError{Err: err, Str: record[5], Field: "Tribe.Points"} } tribe.AllPoints, err = strconv.Atoi(record[6]) if err != nil { return Tribe{}, ParseError{Err: err, Str: record[6], Field: "Tribe.AllPoints"} } tribe.Rank, err = strconv.Atoi(record[7]) if err != nil { return Tribe{}, ParseError{Err: err, Str: record[7], Field: "Tribe.Rank"} } tribe.OpponentsDefeated = p.od[tribe.ID] tribe.ProfileURL = buildURLWithQuery(p.baseURL, endpointGame, fmt.Sprintf(queryTribeProfile, tribe.ID)) return tribe, nil } type playerCSVParser struct { r io.Reader od map[int]OpponentsDefeated baseURL *url.URL } const fieldsPerRecordPlayer = 6 func (p playerCSVParser) parse() ([]Player, error) { csvR := newCSVReader(p.r, fieldsPerRecordPlayer) 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) } slices.SortFunc(players, func(a, b Player) int { return cmp.Compare(a.ID, b.ID) }) return players, nil } func (p playerCSVParser) parseRecord(record []string) (Player, error) { var err error var player Player // $id, $name, $ally, $villages, $points, $rank player.ID, err = strconv.Atoi(record[0]) if err != nil { return Player{}, ParseError{Err: err, Str: record[0], Field: "Player.ID"} } player.Name, err = url.QueryUnescape(record[1]) if err != nil { return Player{}, ParseError{Err: err, Str: record[1], Field: "Player.Name"} } player.TribeID, err = strconv.Atoi(record[2]) if err != nil { return Player{}, ParseError{Err: err, Str: record[2], Field: "Player.TribeID"} } player.NumVillages, err = strconv.Atoi(record[3]) if err != nil { return Player{}, ParseError{Err: err, Str: record[3], Field: "Player.NumVillages"} } player.Points, err = strconv.Atoi(record[4]) if err != nil { return Player{}, ParseError{Err: err, Str: record[4], Field: "Player.Points"} } player.Rank, err = strconv.Atoi(record[5]) 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)) return player, nil } type villageCSVParser struct { r io.Reader baseURL *url.URL } const fieldsPerRecordVillage = 7 func (p villageCSVParser) parse() ([]Village, error) { csvR := newCSVReader(p.r, fieldsPerRecordVillage) var villages []Village for { rec, err := csvR.Read() if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } village, err := p.parseRecord(rec) if err != nil { return nil, err } villages = append(villages, village) } slices.SortFunc(villages, func(a, b Village) int { return cmp.Compare(a.ID, b.ID) }) return villages, nil } func (p villageCSVParser) parseRecord(record []string) (Village, error) { var err error var village Village // $id, $name, $x, $y, $player, $points, $bonus_id village.ID, err = strconv.Atoi(record[0]) if err != nil { return Village{}, ParseError{Err: err, Str: record[0], Field: "Village.ID"} } village.Name, err = url.QueryUnescape(record[1]) if err != nil { return Village{}, ParseError{Err: err, Str: record[1], Field: "Village.Name"} } village.X, err = strconv.Atoi(record[2]) if err != nil { return Village{}, ParseError{Err: err, Str: record[2], Field: "Village.X"} } village.Y, err = strconv.Atoi(record[3]) if err != nil { return Village{}, ParseError{Err: err, Str: record[3], Field: "Village.Y"} } village.PlayerID, err = strconv.Atoi(record[4]) if err != nil { return Village{}, ParseError{Err: err, Str: record[4], Field: "Village.PlayerID"} } village.Points, err = strconv.Atoi(record[5]) if err != nil { return Village{}, ParseError{Err: err, Str: record[5], Field: "Village.Points"} } village.Bonus, err = strconv.Atoi(record[6]) if err != nil { return Village{}, ParseError{Err: err, Str: record[6], Field: "Village.Bonus"} } village.Continent = p.buildContinent(record, village.X, village.Y) village.ProfileURL = buildURLWithQuery(p.baseURL, endpointGame, fmt.Sprintf(queryVillageProfile, village.ID)) return village, nil } func (p villageCSVParser) buildContinent(record []string, x, y int) string { continent := "K" switch { case x < 100 && y < 100: continent += "0" case x > 100 && y < 100: continent += string(record[2][0]) case x < 100 && y > 100: continent += string(record[3][0]) + "0" default: continent += string(record[3][0]) + string(record[2][0]) } return continent } type odCSVParser struct { r io.Reader } type odRecord struct { ID int Rank int Score int } const fieldsPerRecordOD = 3 func (p odCSVParser) parse() ([]odRecord, error) { csvR := newCSVReader(p.r, fieldsPerRecordOD) var odRecords []odRecord for { rec, err := csvR.Read() if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } od, err := p.parseRecord(rec) if err != nil { return nil, err } odRecords = append(odRecords, od) } return odRecords, nil } func (p odCSVParser) parseRecord(record []string) (odRecord, error) { var err error var rec odRecord // $rank, $id, $score rec.Rank, err = strconv.Atoi(record[0]) if err != nil { return odRecord{}, ParseError{Err: err, Str: record[0], Field: "odRecord.Rank"} } rec.ID, err = strconv.Atoi(record[1]) if err != nil { return odRecord{}, ParseError{Err: err, Str: record[1], Field: "odRecord.ID"} } rec.Score, err = strconv.Atoi(record[2]) 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 } const fieldsPerRecordEnnoblement = 7 func (p ennoblementCSVParser) parse() ([]Ennoblement, error) { csvR := newCSVReader(p.r, fieldsPerRecordEnnoblement) var ennoblements []Ennoblement for { rec, err := csvR.Read() if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } ennoblement, err := p.parseRecord(rec) if err != nil { return nil, err } if ennoblement.CreatedAt.Before(p.since) { continue } ennoblements = append(ennoblements, ennoblement) } slices.SortFunc(ennoblements, func(a, b Ennoblement) int { return a.CreatedAt.Compare(b.CreatedAt) }) return ennoblements, nil } func (p ennoblementCSVParser) parseRecord(record []string) (Ennoblement, error) { var err error var ennoblement Ennoblement // $village_id, $unix_timestamp, $new_owner, $old_owner, $old_tribe_id, $new_tribe_id, $points ennoblement.VillageID, err = strconv.Atoi(record[0]) if err != nil { return Ennoblement{}, ParseError{Err: err, Str: record[0], Field: "Ennoblement.VillageID"} } ennoblement.CreatedAt, err = p.parseTimestamp(record[1]) if err != nil { return Ennoblement{}, ParseError{Err: err, Str: record[1], Field: "Ennoblement.CreatedAt"} } ennoblement.NewOwnerID, err = strconv.Atoi(record[2]) if err != nil { return Ennoblement{}, ParseError{Err: err, Str: record[2], Field: "Ennoblement.NewOwnerID"} } ennoblement.OldOwnerID, err = strconv.Atoi(record[3]) if err != nil { return Ennoblement{}, ParseError{Err: err, Str: record[3], Field: "Ennoblement.OldOwnerID"} } ennoblement.OldTribeID, err = strconv.Atoi(record[4]) if err != nil { return Ennoblement{}, ParseError{Err: err, Str: record[4], Field: "Ennoblement.OldTribeID"} } ennoblement.NewTribeID, err = strconv.Atoi(record[5]) if err != nil { return Ennoblement{}, ParseError{Err: err, Str: record[5], Field: "Ennoblement.NewTribeID"} } ennoblement.Points, err = strconv.Atoi(record[6]) if err != nil { return Ennoblement{}, ParseError{Err: err, Str: record[6], Field: "Ennoblement.Points"} } return ennoblement, nil } func (p 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 } //nolint:revive func buildODURLs(base *url.URL, tribe bool) [4]*url.URL { // there is no endpoint to get opponents defeated as supporter for tribes :( if tribe { return [4]*url.URL{ buildURL(base, endpointTribesOD), buildURL(base, endpointTribesODA), buildURL(base, endpointTribesODD), nil, } } return [4]*url.URL{ buildURL(base, endpointPlayersOD), buildURL(base, endpointPlayersODA), buildURL(base, endpointPlayersODD), buildURL(base, endpointPlayersODS), } } func buildURL(base *url.URL, path string) *url.URL { return buildURLWithQuery(base, path, "") } func buildURLWithQuery(base *url.URL, path, query string) *url.URL { newURL := *base newURL.Path = path newURL.RawQuery = query return &newURL }