diff --git a/.gitignore b/.gitignore index f733bca..091ad0b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ state # Executables *.exe *.exe~ +wwfc # Editor files .vscode +.github diff --git a/api/mkw_rr.go b/api/mkw_rr.go new file mode 100644 index 0000000..a1db84e --- /dev/null +++ b/api/mkw_rr.go @@ -0,0 +1,60 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "wwfc/qr2" +) + +type RaceResultInfo struct { + Players map[uint32]qr2.PlayerInfo `json:"players"` + Results map[int][]qr2.RaceResult `json:"results"` +} + +func HandleMKWRR(w http.ResponseWriter, r *http.Request) { + u, err := url.Parse(r.URL.String()) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + query, err := url.ParseQuery(u.RawQuery) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + groupNames := query["id"] + if len(groupNames) != 1 { + w.WriteHeader(http.StatusBadRequest) + return + } + + groupName := query["id"][0] + players := qr2.GetRacePlayersForGroup(groupName) + results := qr2.GetRaceResultsForGroup(groupName) + if results == nil { + w.WriteHeader(http.StatusNotFound) + return + } + if players == nil { + players = map[uint32]qr2.PlayerInfo{} + } + + var jsonData []byte + jsonData, err = json.Marshal(RaceResultInfo{ + Players: players, + Results: results, + }) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Content-Length", strconv.Itoa(len(jsonData))) + w.Write(jsonData) +} diff --git a/api/pinfo.go b/api/pinfo.go new file mode 100644 index 0000000..9d41712 --- /dev/null +++ b/api/pinfo.go @@ -0,0 +1,106 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "strconv" + "wwfc/database" +) + +type PinfoRequestSpec struct { + Secret string `json:"secret"` + ProfileID uint32 `json:"pid"` +} + +type PinfoResponse struct { + User database.User `json:"User"` + Success bool `json:"Success"` + Error string `json:"Error"` +} + +func HandlePinfo(w http.ResponseWriter, r *http.Request) { + var response PinfoResponse + var statusCode int + + if r.Method == http.MethodPost { + response, statusCode = handlePinfoImpl(r) + } else if r.Method == http.MethodOptions { + statusCode = http.StatusNoContent + w.Header().Set("Access-Control-Allow-Methods", "POST") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + } else { + statusCode = http.StatusMethodNotAllowed + w.Header().Set("Allow", "POST") + response = PinfoResponse{ + Success: false, + Error: "Incorrect request. POST only.", + } + } + + w.Header().Set("Access-Control-Allow-Origin", "*") + + var jsonData []byte + if statusCode != http.StatusNoContent { + w.Header().Set("Content-Type", "application/json") + jsonData, _ = json.Marshal(response) + } + + w.Header().Set("Content-Length", strconv.Itoa(len(jsonData))) + w.WriteHeader(statusCode) + w.Write(jsonData) +} + +func handlePinfoImpl(r *http.Request) (PinfoResponse, int) { + body, err := io.ReadAll(r.Body) + if err != nil { + return PinfoResponse{ + Success: false, + Error: "Unable to read request body", + }, http.StatusBadRequest + } + + var req PinfoRequestSpec + err = json.Unmarshal(body, &req) + if err != nil { + return PinfoResponse{ + Success: false, + Error: err.Error(), + }, http.StatusBadRequest + } + + if req.ProfileID == 0 { + return PinfoResponse{ + Success: false, + Error: "Profile ID missing or 0 in request", + }, http.StatusBadRequest + } + + realUser, ok := database.GetProfile(pool, ctx, req.ProfileID) + if !ok { + return PinfoResponse{ + User: database.User{}, + Success: false, + Error: "Failed to find user in the database", + }, http.StatusInternalServerError + } + + user := realUser + if apiSecret == "" || req.Secret != apiSecret { + // Invalid or missing secret: return only the public-safe subset. + user = database.User{ + ProfileId: realUser.ProfileId, + Restricted: realUser.Restricted, + BanReason: realUser.BanReason, + OpenHost: realUser.OpenHost, + LastInGameSn: realUser.LastInGameSn, + DiscordID: realUser.DiscordID, + } + } + + return PinfoResponse{ + User: user, + Success: true, + Error: "", + }, http.StatusOK +} diff --git a/database/login.go b/database/login.go index d7a6885..6be291d 100644 --- a/database/login.go +++ b/database/login.go @@ -76,8 +76,9 @@ func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsb } else { var firstName *string var lastName *string + var discordID *string - err := pool.QueryRow(ctx, GetUserProfileID, userId, gsbrcd).Scan(&user.ProfileId, &user.NgDeviceId, &user.Email, &user.UniqueNick, &firstName, &lastName, &user.OpenHost, &lastIPAddress) + err := pool.QueryRow(ctx, GetUserProfileID, userId, gsbrcd).Scan(&user.ProfileId, &user.NgDeviceId, &user.Email, &user.UniqueNick, &firstName, &lastName, &user.OpenHost, &discordID, &lastIPAddress) if err != nil { return User{}, err } @@ -90,6 +91,11 @@ func LoginUserToGPCM(pool *pgxpool.Pool, ctx context.Context, userId uint64, gsb user.LastName = *lastName } + if discordID != nil { + user.DiscordID = *discordID + user.LinkStage = LS_FINISHED + } + validDeviceId := false deviceIdList := "" for index, id := range user.NgDeviceId { @@ -226,7 +232,8 @@ func LoginUserToGameStats(pool *pgxpool.Pool, ctx context.Context, userId uint64 var firstName *string var lastName *string var lastIPAddress *string - err := pool.QueryRow(ctx, GetUserProfileID, userId, gsbrcd).Scan(&user.ProfileId, &user.NgDeviceId, &user.Email, &user.UniqueNick, &firstName, &lastName, &user.OpenHost, &lastIPAddress) + var discordID *string + err := pool.QueryRow(ctx, GetUserProfileID, userId, gsbrcd).Scan(&user.ProfileId, &user.NgDeviceId, &user.Email, &user.UniqueNick, &firstName, &lastName, &user.OpenHost, &discordID, &lastIPAddress) if err != nil { return User{}, err } @@ -239,5 +246,10 @@ func LoginUserToGameStats(pool *pgxpool.Pool, ctx context.Context, userId uint64 user.LastName = *lastName } + if discordID != nil { + user.DiscordID = *discordID + user.LinkStage = LS_FINISHED + } + return user, nil } diff --git a/database/user.go b/database/user.go index 0e79d89..fe1c77f 100644 --- a/database/user.go +++ b/database/user.go @@ -6,7 +6,10 @@ import ( "math/rand" "time" + "wwfc/logging" + "github.com/jackc/pgx/v4/pgxpool" + "github.com/logrusorgru/aurora/v3" ) const ( @@ -15,13 +18,14 @@ const ( UpdateUserTable = `UPDATE users SET firstname = CASE WHEN $3 THEN $2 ELSE firstname END, lastname = CASE WHEN $5 THEN $4 ELSE lastname END, open_host = CASE WHEN $7 THEN $6 ELSE open_host END WHERE profile_id = $1` UpdateUserProfileID = `UPDATE users SET profile_id = $3 WHERE user_id = $1 AND gsbrcd = $2` UpdateUserNGDeviceID = `UPDATE users SET ng_device_id = $2 WHERE profile_id = $1` - GetUser = `SELECT user_id, gsbrcd, email, unique_nick, firstname, lastname, open_host, last_ip_address, last_ingamesn FROM users WHERE profile_id = $1` + GetUser = `SELECT user_id, gsbrcd, ng_device_id, email, unique_nick, firstname, lastname, has_ban, ban_reason, open_host, last_ingamesn, last_ip_address, discord_id, ban_moderator, ban_reason_hidden, ban_issued, ban_expires FROM users WHERE profile_id = $1` ClearProfileQuery = `DELETE FROM users WHERE profile_id = $1 RETURNING user_id, gsbrcd, email, unique_nick, firstname, lastname, open_host, last_ip_address, last_ingamesn` DoesUserExist = `SELECT EXISTS(SELECT 1 FROM users WHERE user_id = $1 AND gsbrcd = $2)` IsProfileIDInUse = `SELECT EXISTS(SELECT 1 FROM users WHERE profile_id = $1)` DeleteUserSession = `DELETE FROM sessions WHERE profile_id = $1` - GetUserProfileID = `SELECT profile_id, ng_device_id, email, unique_nick, firstname, lastname, open_host, last_ip_address FROM users WHERE user_id = $1 AND gsbrcd = $2` + GetUserProfileID = `SELECT profile_id, ng_device_id, email, unique_nick, firstname, lastname, open_host, discord_id, last_ip_address FROM users WHERE user_id = $1 AND gsbrcd = $2` UpdateUserLastIPAddress = `UPDATE users SET last_ip_address = $2, last_ingamesn = $3 WHERE profile_id = $1` + UpdateDiscordID = `UPDATE users SET discord_id = $2 WHERE profile_id = $1` UpdateUserBan = `UPDATE users SET has_ban = true, ban_issued = $2, ban_expires = $3, ban_reason = $4, ban_reason_hidden = $5, ban_moderator = $6, ban_tos = $7 WHERE profile_id = $1` DisableUserBan = `UPDATE users SET has_ban = false WHERE profile_id = $1` @@ -29,6 +33,15 @@ const ( UpdateMKWFriendInfoQuery = `UPDATE users SET mariokartwii_friend_info = $2 WHERE profile_id = $1` ) +type LinkStage byte + +const ( + LS_NONE LinkStage = iota + LS_STARTED + LS_FRIENDED + LS_FINISHED +) + type User struct { ProfileId uint32 UserId uint64 @@ -44,6 +57,12 @@ type User struct { OpenHost bool LastInGameSn string LastIPAddress string + DiscordID string + LinkStage LinkStage + BanModerator string + BanReasonHidden string + BanIssued *time.Time + BanExpires *time.Time } var ( @@ -56,9 +75,7 @@ func (user *User) CreateUser(pool *pgxpool.Pool, ctx context.Context) error { return pool.QueryRow(ctx, InsertUser, user.UserId, user.GsbrCode, "", user.NgDeviceId, user.Email, user.UniqueNick).Scan(&user.ProfileId) } - if user.ProfileId >= 1000000000 { - return ErrReservedProfileIDRange - } + // Reserved profile ID check removed; all profile IDs allowed var exists bool err := pool.QueryRow(ctx, IsProfileIDInUse, user.ProfileId).Scan(&exists) @@ -75,9 +92,7 @@ func (user *User) CreateUser(pool *pgxpool.Pool, ctx context.Context) error { } func (user *User) UpdateProfileID(pool *pgxpool.Pool, ctx context.Context, newProfileId uint32) error { - if newProfileId >= 1000000000 { - return ErrReservedProfileIDRange - } + // Reserved profile ID check removed; all profile IDs allowed var exists bool err := pool.QueryRow(ctx, IsProfileIDInUse, newProfileId).Scan(&exists) @@ -97,6 +112,17 @@ func (user *User) UpdateProfileID(pool *pgxpool.Pool, ctx context.Context, newPr return err } +func (user *User) UpdateDiscordID(pool *pgxpool.Pool, ctx context.Context, discordID string) error { + _, err := pool.Exec(ctx, UpdateDiscordID, user.ProfileId, discordID) + if err == nil { + user.DiscordID = discordID + } else { + logging.Error("DB", "Failed to persist DiscordID", aurora.Cyan(discordID), "for profile", aurora.Cyan(user.ProfileId), "error:", aurora.Cyan(err)) + } + + return err +} + func GetUniqueUserID() uint64 { // Not guaranteed unique but doesn't matter in practice if multiple people have the same user ID. return uint64(rand.Int63n(0x80000000000)) @@ -132,12 +158,74 @@ func (user *User) UpdateProfile(pool *pgxpool.Pool, ctx context.Context, data ma func GetProfile(pool *pgxpool.Pool, ctx context.Context, profileId uint32) (User, bool) { user := User{} row := pool.QueryRow(ctx, GetUser, profileId) - err := row.Scan(&user.UserId, &user.GsbrCode, &user.Email, &user.UniqueNick, &user.FirstName, &user.LastName, &user.OpenHost, &user.LastIPAddress, &user.LastInGameSn) + + var firstName *string + var lastName *string + var banReason *string + var lastInGameSn *string + var lastIPAddress *string + var discordID *string + var banModerator *string + var banReasonHidden *string + + err := row.Scan( + &user.UserId, + &user.GsbrCode, + &user.NgDeviceId, + &user.Email, + &user.UniqueNick, + &firstName, + &lastName, + &user.Restricted, + &banReason, + &user.OpenHost, + &lastInGameSn, + &lastIPAddress, + &discordID, + &banModerator, + &banReasonHidden, + &user.BanIssued, + &user.BanExpires, + ) if err != nil { return User{}, false } user.ProfileId = profileId + + if firstName != nil { + user.FirstName = *firstName + } + + if lastName != nil { + user.LastName = *lastName + } + + if banReason != nil { + user.BanReason = *banReason + } + + if lastInGameSn != nil { + user.LastInGameSn = *lastInGameSn + } + + if lastIPAddress != nil { + user.LastIPAddress = *lastIPAddress + } + + if discordID != nil { + user.DiscordID = *discordID + user.LinkStage = LS_FINISHED + } + + if banModerator != nil { + user.BanModerator = *banModerator + } + + if banReasonHidden != nil { + user.BanReasonHidden = *banReasonHidden + } + return user, true } diff --git a/gpcm/message.go b/gpcm/message.go index 61c31b4..39512cd 100644 --- a/gpcm/message.go +++ b/gpcm/message.go @@ -38,11 +38,11 @@ func (g *GameSpySession) bestieMessage(command common.GameSpyCommand) { return } - if !g.isFriendAuthorized(uint32(toProfileId)) { - logging.Error(g.ModuleName, "Destination", aurora.Cyan(toProfileId), "is not even on sender's friend list") - g.replyError(ErrMessageNotFriends) - return - } + // if !g.isFriendAuthorized(uint32(toProfileId)) { + // logging.Error(g.ModuleName, "Destination", aurora.Cyan(toProfileId), "is not even on sender's friend list") + // g.replyError(ErrMessageNotFriends) + // return + // } msg, ok := command.OtherValues["msg"] if !ok || msg == "" { diff --git a/gpcm/report.go b/gpcm/report.go index 97dfb38..80c83bf 100644 --- a/gpcm/report.go +++ b/gpcm/report.go @@ -1,7 +1,9 @@ package gpcm import ( + "encoding/json" "strconv" + "time" "wwfc/common" "wwfc/logging" "wwfc/qr2" @@ -9,6 +11,19 @@ import ( "github.com/logrusorgru/aurora/v3" ) +type RaceResultPlayer struct { + Pid int `json:"pid"` + FinishTimeMs int `json:"finish_time_ms"` + CharacterId int `json:"character_id"` + KartId int `json:"kart_id"` + PlayerCount int `json:"player_count"` +} + +type RaceResult struct { + ClientReportVersion string `json:"client_report_version"` + Player *RaceResultPlayer `json:"player"` +} + func (g *GameSpySession) handleWWFCReport(command common.GameSpyCommand) { for key, value := range command.OtherValues { logging.Info(g.ModuleName, "WiiLink Report:", aurora.Yellow(key)) @@ -63,6 +78,69 @@ func (g *GameSpySession) handleWWFCReport(command common.GameSpyCommand) { } qr2.ProcessMKWSelectRecord(g.User.ProfileId, key, value) + + case "wl:mkw_race_result": + if g.GameName != "mariokartwii" { + logging.Warn(g.ModuleName, "Ignoring", keyColored, "from wrong game") + continue + } + + logging.Info(g.ModuleName, "Received race result from profile", aurora.BrightCyan(strconv.FormatUint(uint64(g.User.ProfileId), 10))) + + var raceResult RaceResult + err := json.Unmarshal([]byte(value), &raceResult) + if err != nil { + logging.Error(g.ModuleName, "Error parsing race result JSON:", err.Error()) + logging.Info(g.ModuleName, "Raw payload:", aurora.BrightMagenta(value)) + continue + } + + logging.Info(g.ModuleName, "Race result version:", aurora.Yellow(raceResult.ClientReportVersion)) + + player := raceResult.Player + + logging.Info(g.ModuleName, + "Player", + "- PID:", aurora.Cyan(strconv.Itoa(player.Pid)), + "Time:", aurora.Cyan(strconv.Itoa(player.FinishTimeMs)), "ms", + "Char:", aurora.Cyan(strconv.Itoa(player.CharacterId)), + "Kart:", aurora.Cyan(strconv.Itoa(player.KartId)), + "Count:", aurora.Cyan(strconv.Itoa(player.PlayerCount))) + + // Hand off to qr2 for processing + qr2.ProcessMKWRaceResult(g.User.ProfileId, player.Pid, player.FinishTimeMs, player.CharacterId, player.KartId, player.PlayerCount) + + case "wl:mkw_race_start_time": + serverTime := time.Now().UnixMilli() + logging.Info(g.ModuleName, + "Race start time:", aurora.Yellow(value), + "Server time:", aurora.Yellow(strconv.FormatInt(serverTime, 10))) + + // Parse client timestamp + clientTime, err := strconv.ParseInt(value, 10, 64) + if err != nil { + logging.Error(g.ModuleName, "Failed to parse client timestamp:", err.Error()) + return + } + + // Store timing data in qr2 module + qr2.StoreRaceStartTime(g.User.ProfileId, clientTime, serverTime) + + case "wl:mkw_race_finish_time": + serverTime := time.Now().UnixMilli() + logging.Info(g.ModuleName, + "Race finish time:", aurora.Yellow(value), + "Server time:", aurora.Yellow(strconv.FormatInt(serverTime, 10))) + + // Parse client timestamp + clientTime, err := strconv.ParseInt(value, 10, 64) + if err != nil { + logging.Error(g.ModuleName, "Failed to parse client finish timestamp:", err.Error()) + return + } + + // Store timing data in qr2 module + qr2.StoreRaceFinishTime(g.User.ProfileId, clientTime, serverTime) } } } diff --git a/nas/main.go b/nas/main.go index 22630d2..366ef54 100644 --- a/nas/main.go +++ b/nas/main.go @@ -153,6 +153,12 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { return } + // Check for /api/pinfo + if r.URL.Path == "/api/pinfo" { + api.HandlePinfo(w, r) + return + } + // Check for /api/stats if r.URL.Path == "/api/stats" { api.HandleStats(w, r) @@ -177,6 +183,12 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { return } + // Check for /api/mkw_rr + if r.URL.Path == "/api/mkw_rr" { + api.HandleMKWRR(w, r) + return + } + logging.Info("NAS", aurora.Yellow(r.Method), aurora.Cyan(r.URL), "via", aurora.Cyan(r.Host), "from", aurora.BrightCyan(r.RemoteAddr)) replyHTTPError(w, 404, "404 Not Found") } diff --git a/qr2/group.go b/qr2/group.go index 6fb901a..4f6b6e8 100644 --- a/qr2/group.go +++ b/qr2/group.go @@ -31,6 +31,39 @@ type Group struct { MKWEngineClassID int } +type RaceResultPlayer struct { + Pid int `json:"pid"` + FinishTimeMs int `json:"finish_time_ms"` + CharacterId int `json:"character_id"` + KartId int `json:"kart_id"` +} + +type RaceResult struct { + ProfileID uint32 + PlayerID int + FinishTime uint32 + CharacterID uint32 + VehicleID uint32 + PlayerCount uint32 + CourseID int + EngineClassID int + Delta int +} + +var raceResults = map[string]map[int][]RaceResult{} // GroupName -> RaceNumber -> []RaceResult +var racePlayers = map[string]map[uint32]PlayerInfo{} // GroupName -> ProfileID -> historical PlayerInfo + +// Timing storage for delta calculation using start/finish times +var raceStartTimings = map[uint32]struct { + ClientTime int64 + ServerTime int64 +}{} + +var raceFinishTimings = map[uint32]struct { + ClientTime int64 + ServerTime int64 +}{} + var groups = map[string]*Group{} func processResvOK(moduleName string, matchVersion int, reservation common.MatchCommandDataReservation, resvOK common.MatchCommandDataResvOK, sender, destination *Session) bool { @@ -310,6 +343,22 @@ func CheckGPReservationAllowed(senderIP uint64, senderPid uint32, destPid uint32 return checkReservationAllowed(moduleName, from, to, joinType) } +// StoreRaceStartTime stores the start timing data for delta calculation +func StoreRaceStartTime(profileId uint32, clientTime, serverTime int64) { + raceStartTimings[profileId] = struct { + ClientTime int64 + ServerTime int64 + }{ClientTime: clientTime, ServerTime: serverTime} +} + +// StoreRaceFinishTime stores the finish timing data for delta calculation +func StoreRaceFinishTime(profileId uint32, clientTime, serverTime int64) { + raceFinishTimings[profileId] = struct { + ClientTime int64 + ServerTime int64 + }{ClientTime: clientTime, ServerTime: serverTime} +} + func ProcessNATNEGReport(result byte, ip1 string, ip2 string) { moduleName := "QR2:NATNEGReport" @@ -525,6 +574,189 @@ func ProcessMKWSelectRecord(profileId uint32, key string, value string) { } +func ProcessMKWRaceResult(profileId uint32, playerPid int, finishTimeMs int, characterId int, kartId int, playerCount int) { + moduleName := "QR2:MKWRaceResult:" + strconv.FormatUint(uint64(profileId), 10) + + mutex.Lock() + login := logins[profileId] + if login == nil { + mutex.Unlock() + logging.Warn(moduleName, "Received race result from non-existent profile ID", aurora.Cyan(profileId)) + return + } + + session := login.session + if session == nil { + mutex.Unlock() + logging.Warn(moduleName, "Received race result from profile ID", aurora.Cyan(profileId), "but no session exists") + return + } + mutex.Unlock() + + group := session.groupPointer + if group == nil { + return + } + + if group.MKWRaceNumber == 0 { + logging.Error(moduleName, "Received race result but no races have been started") + return + } + + // Calculate delta using start/finish times + var delta int + if startTiming, startExists := raceStartTimings[profileId]; startExists { + if finishTiming, finishExists := raceFinishTimings[profileId]; finishExists { + clientElapsedTime := finishTiming.ClientTime - startTiming.ClientTime + serverElapsedTime := finishTiming.ServerTime - startTiming.ServerTime + delta = int(serverElapsedTime - clientElapsedTime) + + logging.Info(moduleName, "Delta calculated:", aurora.Cyan(strconv.Itoa(delta)), + "Client elapsed:", aurora.Cyan(strconv.FormatInt(clientElapsedTime, 10)), + "Server elapsed:", aurora.Cyan(strconv.FormatInt(serverElapsedTime, 10))) + } else { + logging.Warn(moduleName, "Missing finish timing data for profile", aurora.Cyan(strconv.FormatUint(uint64(profileId), 10))) + } + } else { + logging.Warn(moduleName, "Missing start timing data for profile", aurora.Cyan(strconv.FormatUint(uint64(profileId), 10))) + } + + // Convert race result data to internal format + raceResultData := RaceResult{ + ProfileID: profileId, + PlayerID: playerPid, + FinishTime: uint32(finishTimeMs), + CharacterID: uint32(characterId), + VehicleID: uint32(kartId), + PlayerCount: uint32(playerCount), + CourseID: group.MKWCourseID, + EngineClassID: group.MKWEngineClassID, + Delta: delta, + } + + mutex.Lock() + defer mutex.Unlock() + + if raceResults[group.GroupName] == nil { + raceResults[group.GroupName] = map[int][]RaceResult{} + } + if racePlayers[group.GroupName] == nil { + racePlayers[group.GroupName] = map[uint32]PlayerInfo{} + } + + sortedJoinIndex := make([]string, 0, len(group.players)) + rawPlayers := make(map[string]map[string]string, len(group.players)) + for playerSession := range group.players { + mapData := map[string]string{} + for k, v := range playerSession.Data { + mapData[k] = v + } + + if login := playerSession.login; login != nil { + mapData["+ingamesn"] = login.InGameName + } else { + mapData["+ingamesn"] = "" + } + + joinIndex := mapData["+joinindex"] + rawPlayers[joinIndex] = mapData + + myJoinIndex, _ := strconv.Atoi(joinIndex) + added := false + for i, existingJoinIndex := range sortedJoinIndex { + intJoinIndex, _ := strconv.Atoi(existingJoinIndex) + if intJoinIndex > myJoinIndex { + sortedJoinIndex = append(sortedJoinIndex, "") + copy(sortedJoinIndex[i+1:], sortedJoinIndex[i:]) + sortedJoinIndex[i] = joinIndex + added = true + break + } + } + if !added { + sortedJoinIndex = append(sortedJoinIndex, joinIndex) + } + } + + for joinIndex, rawPlayer := range rawPlayers { + profileID, err := strconv.ParseUint(rawPlayer["dwc_pid"], 10, 32) + if err != nil { + continue + } + racePlayers[group.GroupName][uint32(profileID)] = buildPlayerInfo(rawPlayer, sortedJoinIndex, joinIndex) + } + + raceNumber := group.MKWRaceNumber + for _, existingResult := range raceResults[group.GroupName][raceNumber] { + if existingResult.ProfileID == profileId { + logging.Info(moduleName, "Ignored duplicate race result for profile", aurora.BrightCyan(strconv.FormatUint(uint64(profileId), 10)), + "Race #:", aurora.Cyan(strconv.Itoa(raceNumber))) + return + } + } + + raceResults[group.GroupName][raceNumber] = append(raceResults[group.GroupName][raceNumber], raceResultData) + + logging.Info(moduleName, "Stored race result for profile", aurora.BrightCyan(strconv.FormatUint(uint64(profileId), 10)), + "Race #:", aurora.Cyan(strconv.Itoa(raceNumber)), + "Course:", aurora.Cyan(strconv.Itoa(group.MKWCourseID)), + "Delta:", aurora.Cyan(strconv.Itoa(delta))) +} + +func GetRaceResultsForGroup(groupName string) map[int][]RaceResult { + mutex.Lock() + defer mutex.Unlock() + + groupResults, ok := raceResults[groupName] + if !ok { + return nil + } + + // Return a copy to prevent external modification + copiedRaceResults := make(map[int][]RaceResult) + for raceNumber, results := range groupResults { + copiedRaceResults[raceNumber] = make([]RaceResult, len(results)) + copy(copiedRaceResults[raceNumber], results) + } + + return copiedRaceResults +} + +func StoreRacePlayersForGroup(groupName string, players map[uint32]PlayerInfo) { + mutex.Lock() + defer mutex.Unlock() + + if racePlayers[groupName] == nil { + racePlayers[groupName] = map[uint32]PlayerInfo{} + } + + for profileID, player := range players { + racePlayers[groupName][profileID] = player + } +} + +func GetRacePlayersForGroup(groupName string) map[uint32]PlayerInfo { + mutex.Lock() + defer mutex.Unlock() + + groupPlayers, ok := racePlayers[groupName] + if !ok { + return nil + } + + copiedPlayers := make(map[uint32]PlayerInfo, len(groupPlayers)) + for profileID, player := range groupPlayers { + playerCopy := player + if player.Mii != nil { + playerCopy.Mii = make([]MiiInfo, len(player.Mii)) + copy(playerCopy.Mii, player.Mii) + } + copiedPlayers[profileID] = playerCopy + } + + return copiedPlayers +} + // saveGroups saves the current groups state to disk. // Expects the mutex to be locked. func saveGroups() error { diff --git a/qr2/group_info.go b/qr2/group_info.go index 954c97f..5968616 100644 --- a/qr2/group_info.go +++ b/qr2/group_info.go @@ -49,6 +49,59 @@ type RaceInfo struct { EngineClassID int `json:"cc"` } +func buildPlayerInfo(rawPlayer map[string]string, sortedJoinIndex []string, joinIndex string) PlayerInfo { + playerInfo := PlayerInfo{ + Count: rawPlayer["+localplayers"], + ProfileID: rawPlayer["dwc_pid"], + InGameName: rawPlayer["+ingamesn"], + } + + pid, err := strconv.ParseUint(rawPlayer["dwc_pid"], 10, 32) + if err == nil { + if fcGame := rawPlayer["+fcgameid"]; len(fcGame) == 4 { + playerInfo.FriendCode = common.CalcFriendCodeString(uint32(pid), fcGame) + } + } + + if rawPlayer["gamename"] == "mariokartwii" { + playerInfo.VersusELO = rawPlayer["ev"] + playerInfo.BattleELO = rawPlayer["eb"] + } + + for i := 0; i < 32; i++ { + miiData := rawPlayer["+mii"+strconv.Itoa(i)] + if miiData == "" { + continue + } + + playerInfo.Mii = append(playerInfo.Mii, MiiInfo{ + MiiData: miiData, + MiiName: rawPlayer["+mii_name"+strconv.Itoa(i)], + }) + } + + for _, newIndex := range sortedJoinIndex { + if newIndex == joinIndex { + continue + } + + if rawPlayer["+conn_"+newIndex] == "" { + playerInfo.ConnMap += "0" + continue + } + + playerInfo.ConnMap += rawPlayer["+conn_"+newIndex] + } + + playerInfo.ConnFail = rawPlayer["+conn_fail"] + if playerInfo.ConnFail == "" { + playerInfo.ConnFail = "0" + } + + playerInfo.Suspend = rawPlayer["dwc_suspend"] + return playerInfo +} + func getGroupsRaw(gameNames []string, groupNames []string) []GroupInfo { var groupsCopy []GroupInfo @@ -156,57 +209,7 @@ func GetGroups(gameNames []string, groupNames []string, sorted bool) []GroupInfo for i, group := range groupsCopy { for joinIndex, rawPlayer := range group.PlayersRaw { - playerInfo := PlayerInfo{ - Count: rawPlayer["+localplayers"], - ProfileID: rawPlayer["dwc_pid"], - InGameName: rawPlayer["+ingamesn"], - } - - pid, err := strconv.ParseUint(rawPlayer["dwc_pid"], 10, 32) - if err == nil { - if fcGame := rawPlayer["+fcgameid"]; len(fcGame) == 4 { - playerInfo.FriendCode = common.CalcFriendCodeString(uint32(pid), fcGame) - } - } - - if rawPlayer["gamename"] == "mariokartwii" { - playerInfo.VersusELO = rawPlayer["ev"] - playerInfo.BattleELO = rawPlayer["eb"] - } - - for i := 0; i < 32; i++ { - miiData := rawPlayer["+mii"+strconv.Itoa(i)] - if miiData == "" { - continue - } - - playerInfo.Mii = append(playerInfo.Mii, MiiInfo{ - MiiData: miiData, - MiiName: rawPlayer["+mii_name"+strconv.Itoa(i)], - }) - } - - for _, newIndex := range group.SortedJoinIndex { - if newIndex == joinIndex { - continue - } - - if rawPlayer["+conn_"+newIndex] == "" { - playerInfo.ConnMap += "0" - continue - } - - playerInfo.ConnMap += rawPlayer["+conn_"+newIndex] - } - - playerInfo.ConnFail = rawPlayer["+conn_fail"] - if playerInfo.ConnFail == "" { - playerInfo.ConnFail = "0" - } - - playerInfo.Suspend = rawPlayer["dwc_suspend"] - - groupsCopy[i].Players[joinIndex] = playerInfo + groupsCopy[i].Players[joinIndex] = buildPlayerInfo(rawPlayer, group.SortedJoinIndex, joinIndex) } } diff --git a/qr2/session.go b/qr2/session.go index 037618e..a02a4f9 100644 --- a/qr2/session.go +++ b/qr2/session.go @@ -82,6 +82,19 @@ func (session *Session) removeFromGroup() { if len(session.groupPointer.players) == 0 { logging.Notice("QR2", "Deleting group", aurora.Cyan(session.groupPointer.GroupName)) + + groupName := session.groupPointer.GroupName + _, ok := raceResults[groupName] + if ok { + go func() { + time.AfterFunc(3*time.Hour, func() { + delete(raceResults, groupName) + delete(racePlayers, groupName) + logging.Notice("QR2", "Deleted race results and players for group", aurora.Cyan(groupName)) + }) + }() + } + delete(groups, session.groupPointer.GroupName) } else if session.groupPointer.server == session { logging.Notice("QR2", "Server down in group", aurora.Cyan(session.groupPointer.GroupName))