package tomestone import ( "database/sql" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "regexp" "strings" "ffxiv-bot/db" ) const base = "https://tomestone.gg" type searchResult struct { Characters []struct { Item struct { Name string `json:"name"` ID int64 `json:"id"` ServerName string `json:"serverName"` } `json:"item"` } `json:"characters"` } type activityResponse struct { Activities struct { Activities struct { Activities struct { Paginator struct { Data []struct { Activity struct { Encounter struct { ID int `json:"id"` } `json:"encounter"` BestPercent string `json:"bestPercent"` } `json:"activity"` } `json:"data"` LastPage int `json:"last_page"` NextPageURL string `json:"next_page_url"` } `json:"paginator"` } `json:"activities"` } `json:"activities"` } `json:"activities"` } func SearchCharacterID(firstName, lastName, serverName string) (int64, error) { term := url.QueryEscape(firstName + " " + lastName) req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/search/autocomplete?term=%s", base, term), nil) if err != nil { return 0, err } req.AddCookie(&http.Cookie{Name: "tomestone_human_verified", Value: "1"}) client := &http.Client{} resp, err := client.Do(req) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("search returned status %d", resp.StatusCode) } var result searchResult if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return 0, err } for _, char := range result.Characters { if strings.EqualFold(char.Item.ServerName, serverName) { log.Printf("[Tomestone] Found character %s on %s with ID %d", char.Item.Name, char.Item.ServerName, char.Item.ID) return char.Item.ID, nil } } return 0, fmt.Errorf("character %s %s not found on server %s", firstName, lastName, serverName) } var bestPercentRe = regexp.MustCompile(`^(\d+(?:\.\d+)?)%\s*(?:P(\d+))?$`) func ParseBestPercent(s string) float64 { s = strings.TrimSpace(s) matches := bestPercentRe.FindStringSubmatch(s) if matches == nil { return 0 } var percent float64 fmt.Sscanf(matches[1], "%f", &percent) if percent == 0 { return 100.0 } if matches[2] == "" { return 1.0 + percent/100.0 } var phase int fmt.Sscanf(matches[2], "%d", &phase) return float64(phase) + percent/100.0 } func FormatProgression(value float64) string { if value == 100.0 { return "Cleared" } phase := int(value) percent := (value - float64(phase)) * 100.0 return fmt.Sprintf("P%d %.2f%%", phase, percent) } func IsBetter(newVal, existing float64) bool { if existing == 0 { return newVal > 0 } if newVal == 100.0 { return true } if existing == 100.0 { return false } newPhase := int(newVal) existingPhase := int(existing) if newPhase != existingPhase { return newPhase > existingPhase } newPercent := newVal - float64(newPhase) existingPercent := existing - float64(existingPhase) return newPercent < existingPercent } func FetchActivityEntries(tomestoneID int64, firstName, lastName string, fetchAll bool) []activityEntry { var allEntries []activityEntry slug := strings.ToLower(firstName) + "-" + strings.ToLower(lastName) url := fmt.Sprintf("%s/character-contents/%d/%s/activity", base, tomestoneID, slug) for url != "" { req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { log.Printf("[Tomestone] Error fetching activity for %s: %v", slug, err) break } req.AddCookie(&http.Cookie{Name: "tomestone_human_verified", Value: "1"}) client := &http.Client{} resp, err := client.Do(req) if err != nil { log.Printf("[Tomestone] Error fetching activity for %s: %v", slug, err) break } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { log.Printf("[Tomestone] Error reading activity body for %s: %v", slug, err) break } var data activityResponse if err := json.Unmarshal(body, &data); err != nil { log.Printf("[Tomestone] Error parsing activity for %s: %v", slug, err) break } for _, d := range data.Activities.Activities.Activities.Paginator.Data { allEntries = append(allEntries, activityEntry{ EncounterID: d.Activity.Encounter.ID, BestPercent: d.Activity.BestPercent, }) } if !fetchAll { break } nextURL := data.Activities.Activities.Activities.Paginator.NextPageURL if nextURL != "" && !strings.HasPrefix(nextURL, "http") { nextURL = base + nextURL } url = nextURL } return allEntries } type activityEntry struct { EncounterID int BestPercent string } func UpdateProgressionForCharacter(dbConn *sql.DB, tomestoneID int64, firstName, lastName string, fetchAll bool) { entries := FetchActivityEntries(tomestoneID, firstName, lastName, fetchAll) if len(entries) == 0 { log.Printf("[Tomestone] No activity entries for tomestone ID %d (%s %s)", tomestoneID, firstName, lastName) return } encounterBest := make(map[int]float64) for _, entry := range entries { parsed := ParseBestPercent(entry.BestPercent) if parsed == 0 { continue } current, ok := encounterBest[entry.EncounterID] if !ok || IsBetter(parsed, current) { encounterBest[entry.EncounterID] = parsed } } for encounterID, bestValue := range encounterBest { existing, err := db.GetProgression(dbConn, tomestoneID, encounterID) if err != nil || IsBetter(bestValue, existing) { if err := db.UpdateProgression(dbConn, tomestoneID, encounterID, bestValue); err != nil { log.Printf("[Tomestone] Error updating progression for %d, encounter %d: %v", tomestoneID, encounterID, err) } } } log.Printf("[Tomestone] Updated progression for tomestone ID %d: %d encounters", tomestoneID, len(encounterBest)) } func LookupAndStoreCharacterID(dbConn *sql.DB, firstName, lastName, serverName string) { tomestoneID, err := SearchCharacterID(firstName, lastName, serverName) if err != nil { log.Printf("[Tomestone] Could not find tomestone ID for %s %s on %s: %v", firstName, lastName, serverName, err) return } if err := db.UpdateCharacterTomestoneID(dbConn, firstName, lastName, serverName, tomestoneID); err != nil { log.Printf("[Tomestone] Error storing tomestone ID for %s %s on %s: %v", firstName, lastName, serverName, err) return } UpdateProgressionForCharacter(dbConn, tomestoneID, firstName, lastName, true) } func FetchAllProgression(dbConn *sql.DB, fetchAll bool) { chars, err := db.GetAllCharactersWithTomestoneID(dbConn) if err != nil { log.Printf("[Tomestone] Error fetching characters with tomestone IDs: %v", err) return } log.Printf("[Tomestone] Fetching progression for %d characters with tomestone IDs (fetchAll=%t)", len(chars), fetchAll) for _, c := range chars { if !c.TomestoneID.Valid { continue } UpdateProgressionForCharacter(dbConn, c.TomestoneID.Int64, c.FirstName, c.LastName, fetchAll) } } func FetchProgressionForUser(dbConn *sql.DB, discordID string, fetchAll bool) { chars, err := db.GetCharactersByDiscordID(dbConn, discordID) if err != nil { log.Printf("[Tomestone] Error fetching characters for user %s: %v", discordID, err) return } var count int for _, c := range chars { if !c.TomestoneID.Valid { continue } count++ UpdateProgressionForCharacter(dbConn, c.TomestoneID.Int64, c.FirstName, c.LastName, fetchAll) } log.Printf("[Tomestone] Finished fetching progression for user %s: %d character(s)", discordID, count) }