hudly/app/app.go
2024-10-16 18:37:43 -06:00

763 lines
19 KiB
Go
Raw Blame History

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, app *App)
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, app *App) {
cachedPlayer, isAlive := app.UUIDCache.Get(player)
if isAlive {
fetchedPlayer, err := app.API.FetchPlayer(cachedPlayer.Uuid)
if err != nil {
fmt.Println("Error fetching player from Hypixel API:", err)
return
}
b.AddPlayer(fetchedPlayer.Player)
} else {
playerData, err := FetchMCPlayer(player)
if err != nil {
fmt.Println("Error fetching player data:", err)
return
}
app.UUIDCache.Add(&CachedUuid{
CleanUuid: strings.ReplaceAll(playerData.UUID, "-", ""),
Uuid: playerData.UUID,
PlayerName: playerData.Username,
TimeFetched: time.Now(),
})
fetchedPlayer, err := app.API.FetchPlayer(playerData.UUID)
if err != nil {
fmt.Println("Error fetching player from Hypixel API:", err)
return
}
b.AddPlayer(fetchedPlayer.Player)
}
}
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 == '<27>' {
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, a)
//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, a)
// 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()
}