package main import ( "bufio" "encoding/json" "errors" "fmt" rl "github.com/gen2brain/raylib-go/raylib" "io" "log" "net/http" "os" "path/filepath" "regexp" "runtime" "strconv" "strings" "sync" "time" ) func errorContext(skip int) string { pc, file, line, ok := runtime.Caller(skip) if !ok { return "unknown" } fn := runtime.FuncForPC(pc).Name() return fmt.Sprintf("%s:%d %s", file, line, fn) } func APITraceError(format string, args ...interface{}) error { traceInfo := errorContext(2) return fmt.Errorf("[%s] "+format, append([]interface{}{traceInfo}, args...)...) } type HypixelPlayerAchievements struct { BedwarsStar int `json:"bedwars_level"` } type HypixelPlayerBedwarsStats struct { Experience int `json:"Experience"` Winstreak int `json:"winstreak"` Wins int `json:"wins_bedwars"` Losses int `json:"losses_bedwars"` GamesPlayed int `json:"games_played_bedwars"` WinLossRatio float32 // wins / gamesplayed Kills int `json:"kills_bedwars"` Deaths int `json:"deaths_bedwars"` KillDeathRatio float32 // kills / deaths FinalKills int `json:"final_kills_bedwars"` FinalDeaths int `json:"final_deaths_bedwars"` FinalKillDeathRatio float32 // final kills / final deaths BedsBroken int `json:"beds_broken_bedwars"` BedsLost int `json:"beds_lost_bedwars"` BedsBrokenLostRatio float32 // beds broken / beds lost } func (stats *HypixelPlayerBedwarsStats) CalculateRatios() { // WLR if stats.GamesPlayed > 0 { stats.WinLossRatio = float32(stats.Wins) / float32(stats.GamesPlayed) } else { stats.WinLossRatio = float32(stats.Wins) } // KDR if stats.Deaths > 0 { stats.KillDeathRatio = float32(stats.Kills) / float32(stats.Deaths) } else { stats.KillDeathRatio = float32(stats.Kills) } // FKDR if stats.FinalDeaths > 0 { stats.FinalKillDeathRatio = float32(stats.FinalKills) / float32(stats.FinalDeaths) } else { stats.FinalKillDeathRatio = float32(stats.FinalKills) } // BBLR if stats.BedsLost > 0 { stats.BedsBrokenLostRatio = float32(stats.BedsBroken) / float32(stats.BedsLost) } else { stats.BedsBrokenLostRatio = float32(stats.BedsBroken) } } func (stats *HypixelPlayerBedwarsStats) IsWinstreakDisabled() bool { return stats.Winstreak == -1 } type HypixelPlayerStats struct { Bedwars HypixelPlayerBedwarsStats `json:"Bedwars"` } type HypixelPlayer struct { DisplayName string `json:"displayname"` Achievements HypixelPlayerAchievements `json:"achievements"` Stats HypixelPlayerStats `json:"stats"` MonthlyRankColor string `json:"monthlyRankColor"` RankPlusColor string `json:"rankPlusColor"` NetworkExp json.Number `json:"networkExp"` } type HypixelPlayerResponse struct { Success bool `json:"success"` Player HypixelPlayer `json:"player"` } type HypixelAPIKey struct { Value string Uses int RateLimit time.Duration LimitUsesLeft int } func NewHypixelAPIKey(key string) *HypixelAPIKey { return &HypixelAPIKey{ Value: key, Uses: 0, RateLimit: time.Millisecond * 1, LimitUsesLeft: -1, } } type HypixelAPI struct { BaseURL string APIKey HypixelAPIKey } func NewHypixelAPI(apiKey string) *HypixelAPI { // TODO: Ensure valid key before creating key := NewHypixelAPIKey(apiKey) return &HypixelAPI{ BaseURL: "https://api.hypixel.net/v2/", APIKey: *key, } } func (h *HypixelAPI) FetchPlayer(PlayerUUID string) (HypixelPlayerResponse, error) { // TODO: Handle nicked players if len(PlayerUUID) != 32 && len(PlayerUUID) != 36 { return HypixelPlayerResponse{}, fmt.Errorf("invalid Player UUID! please pass in a valid UUID (Not a name!)") } RequestUrl := h.BaseURL + "player?uuid=" + PlayerUUID client := &http.Client{ Timeout: 10 * time.Second, } req, err := http.NewRequest("GET", RequestUrl, nil) if err != nil { return HypixelPlayerResponse{}, APITraceError("failed to create HTTP request: %v", err) } req.Header.Set("API-Key", h.APIKey.Value) resp, err := client.Do(req) if err != nil { return HypixelPlayerResponse{}, APITraceError("failed to send HTTP request: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return HypixelPlayerResponse{}, APITraceError("received non-200 response: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return HypixelPlayerResponse{}, APITraceError("failed to read response body: %v", err) } var playerResponse HypixelPlayerResponse err = json.Unmarshal(body, &playerResponse) if err != nil { return HypixelPlayerResponse{}, APITraceError("failed to unmarshal JSON: %v", err) } if !playerResponse.Success { return HypixelPlayerResponse{}, APITraceError("API returned unsuccessful response") } playerResponse.Player.Stats.Bedwars.CalculateRatios() return playerResponse, nil } func (h *HypixelAPI) FetchPlayerAsync(PlayerUUID string, resultChan chan<- HypixelPlayerResponse, errorChan chan<- error) { go func() { result, err := h.FetchPlayer(PlayerUUID) if err != nil { errorChan <- err return } resultChan <- result }() } func (h *HypixelAPI) FetchPlayersAsync(PlayerUUIDs []string, resultChan chan<- HypixelPlayerResponse, errorChan chan<- error) { var wg sync.WaitGroup for _, uuid := range PlayerUUIDs { wg.Add(1) go func(uuid string) { defer wg.Done() result, err := h.FetchPlayer(uuid) if err != nil { errorChan <- err return } resultChan <- result }(uuid) } go func() { wg.Wait() close(resultChan) close(errorChan) }() } type CachedUuid struct { CleanUuid string Uuid string PlayerName string TimeFetched time.Time } type UUIDCache struct { mu sync.RWMutex lifetimeLimit time.Duration uuidMap map[string]*CachedUuid cleanUuidMap map[string]*CachedUuid playerNameMap map[string]*CachedUuid } func NewUUIDCache(lifetime time.Duration) *UUIDCache { return &UUIDCache{ lifetimeLimit: lifetime, uuidMap: make(map[string]*CachedUuid), cleanUuidMap: make(map[string]*CachedUuid), playerNameMap: make(map[string]*CachedUuid), } } func (c *UUIDCache) Add(cachedUuid *CachedUuid) { c.mu.Lock() defer c.mu.Unlock() c.uuidMap[cachedUuid.Uuid] = cachedUuid c.cleanUuidMap[cachedUuid.CleanUuid] = cachedUuid c.playerNameMap[cachedUuid.PlayerName] = cachedUuid } func (c *UUIDCache) GetByUuid(uuid string) (*CachedUuid, bool) { c.mu.RLock() defer c.mu.RUnlock() val, ok := c.uuidMap[uuid] return val, ok } func (c *UUIDCache) GetByCleanUuid(cleanUuid string) (*CachedUuid, bool) { c.mu.RLock() defer c.mu.RUnlock() val, ok := c.cleanUuidMap[cleanUuid] return val, ok } func (c *UUIDCache) GetByPlayerName(playerName string) (*CachedUuid, bool) { c.mu.RLock() defer c.mu.RUnlock() val, ok := c.playerNameMap[playerName] return val, ok } func (c *UUIDCache) Get(id string) (*CachedUuid, bool) { val, ok := c.GetByPlayerName(id) if ok { return val, ok } val, ok = c.GetByUuid(id) if ok { return val, ok } val, ok = c.GetByCleanUuid(id) return val, ok } func (c *UUIDCache) Delete(uuid string) { c.mu.Lock() defer c.mu.Unlock() if cachedUuid, ok := c.uuidMap[uuid]; ok { delete(c.uuidMap, cachedUuid.Uuid) delete(c.cleanUuidMap, cachedUuid.CleanUuid) delete(c.playerNameMap, cachedUuid.PlayerName) } } func (c *UUIDCache) Clean() { c.mu.Lock() defer c.mu.Unlock() now := time.Now() for uuid, cachedUuid := range c.uuidMap { if now.Sub(cachedUuid.TimeFetched) > c.lifetimeLimit { delete(c.uuidMap, uuid) delete(c.cleanUuidMap, cachedUuid.CleanUuid) delete(c.playerNameMap, cachedUuid.PlayerName) } } } type Property struct { Name string `json:"name"` Value string `json:"value"` Signature string `json:"signature"` } type MCPlayer struct { Username string UUID string AvatarURL string SkinTexture string Properties []Property } func isValidUUID(id string) bool { r := regexp.MustCompile(`^[a-fA-F0-9]{32}$|^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`) return r.MatchString(id) } func isValidUsername(name string) bool { r := regexp.MustCompile(`^[a-zA-Z0-9_]{3,16}$`) return r.MatchString(name) } func fetchFromPlayerDB(id string) (*MCPlayer, error) { url := fmt.Sprintf("https://playerdb.co/api/player/minecraft/%s", id) resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var result map[string]interface{} err = json.Unmarshal(body, &result) if err != nil { return nil, err } if result["success"].(bool) == false { return nil, fmt.Errorf("player not found (playerdb): %s", id) } data := result["data"].(map[string]interface{}) player := data["player"].(map[string]interface{}) properties := player["properties"].([]interface{}) mcPlayer := &MCPlayer{ Username: player["username"].(string), UUID: player["id"].(string), AvatarURL: player["avatar"].(string), SkinTexture: player["skin_texture"].(string), } for _, p := range properties { prop := p.(map[string]interface{}) mcPlayer.Properties = append(mcPlayer.Properties, Property{ Name: prop["name"].(string), Value: prop["value"].(string), Signature: prop["signature"].(string), }) } return mcPlayer, nil } func FetchMCPlayer(id string) (*MCPlayer, error) { // TODO: Implement mojang API as the fallback // TODO: Catch Nicks (propagate nick catcher through to HypixelAPI) if isValidUUID(id) { player, err := fetchFromPlayerDB(id) if err != nil { return nil, err } return player, nil } else if isValidUsername(id) { player, err := fetchFromPlayerDB(id) if err != nil { return nil, err } return player, nil } else { return nil, errors.New("invalid input: not a valid UUID or username") } } type DisplayTable interface { AddPlayer(player HypixelPlayer) AddPlayerN(player string) Draw() GetPlayers() []HypixelPlayer } type BedwarsDisplayTable struct { players []HypixelPlayer } func (b *BedwarsDisplayTable) GetPlayers() []HypixelPlayer { return b.players } func (b *BedwarsDisplayTable) AddPlayer(player HypixelPlayer) { b.players = append(b.players, player) } func (b *BedwarsDisplayTable) AddPlayerN(player string) { cachedPlayer, isAlive := app.UUIDCache.Get(UUID) if isAlive { fmt.Printf("Cached player: %+v\n", cachedPlayer) } else { playerdat, err := FetchMCPlayer(UUID) if err != nil { fmt.Println("Error fetching player:", err) } else { app.UUIDCache.Add(&CachedUuid{ CleanUuid: strings.ReplaceAll(playerdat.UUID, "-", ""), Uuid: playerdat.UUID, PlayerName: playerdat.Username, TimeFetched: time.Now(), }) fmt.Printf("Fetched and cached player: %+v\n", playerdat) } } } func (b *BedwarsDisplayTable) Draw() { fmt.Println("=== Bedwars Player Table ===") for _, player := range b.players { fmt.Printf("Player: %s, Score: %d\n", player.DisplayName, player.Achievements.BedwarsStar) } } type App struct { LogPath string API *HypixelAPI UUIDCache *UUIDCache CurrentDisplayTable DisplayTable } func NewApp(apiKey string) *App { path := os.Getenv("USERPROFILE") logPath := filepath.Join(path, ".lunarclient", "offline", "multiver", "logs", "latest.log") return &App{ LogPath: logPath, API: NewHypixelAPI(apiKey), UUIDCache: NewUUIDCache(120 * time.Minute), CurrentDisplayTable: &BedwarsDisplayTable{}, } } type LogBuffer struct { strings []string size int } func NewLogBuffer(size int) *LogBuffer { return &LogBuffer{ strings: make([]string, 0, size), size: size, } } func (l *LogBuffer) Add(s string) { if len(l.strings) == l.size { l.strings = l.strings[1:] } l.strings = append(l.strings, s) } func (l *LogBuffer) Get() []string { return l.strings } func (l *LogBuffer) GetLast() (string, error) { if len(l.strings) == 0 { return "", fmt.Errorf("log buffer is empty") } return l.strings[len(l.strings)-1], nil } func (l *LogBuffer) GetSecondToLast() (string, error) { if len(l.strings) < 2 { return "", fmt.Errorf("log buffer does not have enough lines") } return l.strings[len(l.strings)-2], nil } func (l *LogBuffer) GetLineStepsBack(x int) (string, error) { if x < 0 || x >= len(l.strings) { return "", fmt.Errorf("log buffer does not have enough lines to step back %d times", x) } return l.strings[len(l.strings)-1-x], nil } var LogBuf = NewLogBuffer(10) func replaceCorruptedRune(msg string) string { runes := []rune(msg) for i, r := range runes { if r == '�' { runes[i] = '§' } } return string(runes) } func (a *App) onFileEmit(line string) { msg := strings.TrimSpace(line) if len(msg) < 34 { return } submsg := msg[33:] if len(submsg) != 0 { LogBuf.Add(submsg) } OnlinePrefix := "[CHAT] ONLINE: " PartyListSeparatorLinePrefix := "[CHAT] -----------------------------------------------------" PartyMemberCountPrefix := "[CHAT] Party Members (" PartyLeaderPrefix := "[CHAT] Party Leader: " PartyListMembersPrefix := "[CHAT] Party Members: " if strings.HasPrefix(submsg, OnlinePrefix) { // Online Message newsubmsg := strings.TrimPrefix(submsg, OnlinePrefix) players := strings.Split(newsubmsg, ",") for _, player := range players { playerName := strings.TrimSpace(player) playerUUID, err := FetchMCPlayer(playerName) if err != nil { log.Fatalf("Error fetching UUID: %v", err) return } fmt.Printf("UUID of player %s: %s\n", playerName, playerUUID) cachedPlayer := CachedUuid{playerUUID.UUID, playerName, playerName, time.Now()} a.UUIDCache.Add(&cachedPlayer) //names, err := GetNameFromUUID(playerUUID) //if err != nil { // log.Fatalf("Error fetching names from UUID: %v", err) //} //fmt.Printf("Name history for UUID %s: %v\n", playerUUID, names) } } else if strings.HasPrefix(submsg, PartyListSeparatorLinePrefix) { // Party List last, _ := LogBuf.GetSecondToLast() // TODO: Check if moderators if !strings.HasPrefix(last, PartyListMembersPrefix) { return } PartyMembersMsg := strings.TrimPrefix(last, PartyListMembersPrefix) for _, player := range strings.Split(PartyMembersMsg, ",") { playerName := strings.TrimSpace(strings.TrimSuffix(player, " ?")) if strings.HasPrefix(playerName, "[") { playerName = strings.Split(playerName, " ")[1] } print("Found Player: '") print(playerName) print("'\n") a.CurrentDisplayTable.AddPlayerN(playerName) //playerName := strings.TrimSpace(player) //playerUUID, err := GetUUIDFromName(playerName) //if err != nil { // log.Fatalf("Error fetching UUID: %v", err) // return //} //fmt.Printf("UUID of player %s: %s\n", playerName, playerUUID) //cachedPlayer := CachedUuid{playerUUID, playerName, playerName, time.Now()} //UuidCache.Add(&cachedPlayer) } // Parse Party Leader leaders_msg, err := LogBuf.GetLineStepsBack(2) if err != nil { println("Unable to find party leader message") return } PartyLeaderMsg := strings.TrimPrefix(leaders_msg, PartyLeaderPrefix) playerName := strings.TrimSpace(strings.TrimSuffix(PartyLeaderMsg, " ?")) if strings.HasPrefix(playerName, "[") { playerName = strings.Split(playerName, " ")[1] } print("Found Leader: '") print(playerName) print("'\n") a.CurrentDisplayTable.AddPlayerN(playerName) // Parse Party Count party_count_msg, err := LogBuf.GetLineStepsBack(4) if err != nil { println("Unable to find party count message") return } PartyCountMsg := strings.TrimPrefix(party_count_msg, PartyMemberCountPrefix) count_str := strings.TrimSuffix(PartyCountMsg, ")") count, err := strconv.Atoi(count_str) if err != nil { println("Unable to parse party count message - Invalid number used") return } print("Expected ") print(count) print(" party members\n") } println(submsg) } func (a *App) Start() { rl.SetConfigFlags(rl.FlagWindowTransparent | rl.FlagWindowUndecorated) screenWidth := 800 screenHeight := 600 rl.InitWindow(int32(screenWidth), int32(screenHeight), "Raylib in Go") defer rl.CloseWindow() windowPosition := rl.NewVector2(500, 200) rl.SetWindowPosition(int(int32(windowPosition.X)), int(int32(windowPosition.Y))) var panOffset rl.Vector2 var dragWindow bool = false const LowFps = 8 rl.SetTargetFPS(LowFps) lineCh := make(chan string) go tailFile(a.LogPath, lineCh) ch := make(chan HypixelPlayerResponse) var wg sync.WaitGroup go func() { wg.Wait() close(ch) }() sx := 10 sy := 10 for !rl.WindowShouldClose() { mousePosition := rl.GetMousePosition() windowPosition := rl.GetWindowPosition() screenMousePosition := rl.Vector2{ X: windowPosition.X + mousePosition.X, Y: windowPosition.Y + mousePosition.Y, } if rl.IsMouseButtonPressed(rl.MouseLeftButton) && !dragWindow { rl.SetTargetFPS(int32(rl.GetMonitorRefreshRate(0))) dragWindow = true panOffset.X = screenMousePosition.X - float32(windowPosition.X) panOffset.Y = screenMousePosition.Y - float32(windowPosition.Y) } if dragWindow { newWindowPositionX := int(screenMousePosition.X - panOffset.X) newWindowPositionY := int(screenMousePosition.Y - panOffset.Y) rl.SetWindowPosition(newWindowPositionX, newWindowPositionY) if rl.IsMouseButtonReleased(rl.MouseLeftButton) { dragWindow = false rl.SetTargetFPS(LowFps) } } select { case line := <-lineCh: a.onFileEmit(replaceCorruptedRune(line)) default: } rl.BeginDrawing() rl.ClearBackground(rl.Color{0, 0, 0, 100}) yOffset := sy for _, player := range a.CurrentDisplayTable.GetPlayers() { playerName := player.DisplayName bedwarsLevel := player.Achievements.BedwarsStar bedwarsWins := player.Stats.Bedwars.Wins rl.DrawText(fmt.Sprintf("Player: %s", playerName), int32(sx), int32(yOffset), 20, rl.Black) yOffset += 25 rl.DrawText(fmt.Sprintf("Bedwars Level: %d", bedwarsLevel), int32(sx), int32(yOffset), 18, rl.DarkGray) yOffset += 25 rl.DrawText(fmt.Sprintf("Bedwars Wins: %d", bedwarsWins), int32(sx), int32(yOffset), 18, rl.DarkGray) yOffset += 40 } rl.EndDrawing() } } func tailFile(path string, lineCh chan<- string) { file, err := os.Open(path) if err != nil { log.Fatalf("Failed to open file: %v", err) } defer file.Close() file.Seek(0, 2) reader := bufio.NewReader(file) for { line, err := reader.ReadString('\n') if err != nil { time.Sleep(100 * time.Millisecond) continue } lineCh <- line } } const KEY = "5039a811-1b27-4db6-a7f0-c8dd28eeebcd" const UUID = "5328930e-d411-49cb-90ad-4e5c7b27dd86" func main() { app := NewApp(KEY) app.Start() res, err := app.API.FetchPlayer(UUID) if err != nil { fmt.Println(err) return } fmt.Printf("%+v\n", res.Player) app.CurrentDisplayTable.AddPlayer(res.Player) app.CurrentDisplayTable.Draw() }