mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
feat(multiplayer): expose active ranked season
This commit is contained in:
@@ -996,7 +996,11 @@ func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.logEvent(observability.Event{Event: "ranked_profile_get", Stage: "ok", OccurredAt: s.now()})
|
||||
writeJSON(w, http.StatusOK, rankedProfileResponse{Rating: profile.Value, RD: profile.RD, Volatility: profile.Volatility, RankedGames: profile.RankedGames, Tier: string(tier), Provisional: domain.RankedIsProvisional(profile), SeasonID: profile.LastSeasonID})
|
||||
seasonID := profile.CurrentSeasonID
|
||||
if seasonID == "" {
|
||||
seasonID = profile.LastSeasonID
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rankedProfileResponse{Rating: profile.Value, RD: profile.RD, Volatility: profile.Volatility, RankedGames: profile.RankedGames, Tier: string(tier), Provisional: domain.RankedIsProvisional(profile), SeasonID: seasonID})
|
||||
}
|
||||
|
||||
type probeRequest struct {
|
||||
|
||||
@@ -1079,7 +1079,7 @@ func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(t *testing.T) {
|
||||
}
|
||||
service := &Service{
|
||||
Sessions: sessions,
|
||||
RankedProfiles: map[string]domain.RankedProfile{"player-a": {Rating: domain.Rating{Value: 1600, RD: 200, Volatility: 0.06}, RankedGames: 10, LastSeasonID: "season-1"}},
|
||||
RankedProfiles: map[string]domain.RankedProfile{"player-a": {Rating: domain.Rating{Value: 1600, RD: 200, Volatility: 0.06}, RankedGames: 10, CurrentSeasonID: "season-current", LastSeasonID: "season-1"}},
|
||||
TierPolicy: policy,
|
||||
Now: func() time.Time { return now },
|
||||
}
|
||||
@@ -1099,7 +1099,7 @@ func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(t *testing.T) {
|
||||
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Tier != string(domain.RankTierGold) || body.Provisional || body.RankedGames != 10 || body.SeasonID != "season-1" {
|
||||
if body.Tier != string(domain.RankTierGold) || body.Provisional || body.RankedGames != 10 || body.SeasonID != "season-current" {
|
||||
t.Fatalf("ranked profile response = %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,10 @@ func ScoreForPlayer(outcome MatchOutcome, playerID string, team int) (float64, e
|
||||
|
||||
type RankedProfile struct {
|
||||
Rating
|
||||
RankedGames int
|
||||
LastSeasonID string
|
||||
SeasonHistory []string
|
||||
RankedGames int
|
||||
CurrentSeasonID string
|
||||
LastSeasonID string
|
||||
SeasonHistory []string
|
||||
}
|
||||
|
||||
type RankTier string
|
||||
|
||||
@@ -1275,6 +1275,26 @@ func TestPostgreSQLEmptyRankedSeasonIsMarkedRolledOver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgreSQLRankedProfileProjectsActiveSeason(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
ctx := context.Background()
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO identities (player_id, steam_id) VALUES ('profile-season-player', 'profile-season-steam')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO ratings (player_id, rating, deviation, volatility, ranked_games) VALUES ('profile-season-player', 1600, 200, 0.06, 10)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO seasons (season_id, playlist, starts_at, ends_at) VALUES ('profile-season-current', 'ranked', $1, $2)`, now.Add(-time.Hour), now.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile, found, err := (PostgresRankedProfiles{DB: db}).Get(ctx, "profile-season-player")
|
||||
if err != nil || !found || profile.CurrentSeasonID != "profile-season-current" {
|
||||
t.Fatalf("profile=%+v found=%t err=%v", profile, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgreSQLMigrationsAreForwardExecutable(t *testing.T) {
|
||||
db := openIntegrationPostgres(t)
|
||||
applyIntegrationMigrations(t, db)
|
||||
|
||||
@@ -8,7 +8,10 @@ import (
|
||||
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
||||
)
|
||||
|
||||
const RankedProfileSelectSQL = `SELECT rating, deviation, volatility, ranked_games, updated_at
|
||||
const RankedProfileSelectSQL = `SELECT rating, deviation, volatility, ranked_games, updated_at,
|
||||
COALESCE((SELECT season_id FROM seasons
|
||||
WHERE playlist = 'ranked' AND starts_at <= CURRENT_TIMESTAMP AND ends_at > CURRENT_TIMESTAMP
|
||||
ORDER BY starts_at DESC, season_id DESC LIMIT 1), '')
|
||||
FROM ratings
|
||||
WHERE player_id = $1`
|
||||
|
||||
@@ -19,11 +22,10 @@ WHERE player_id = $1`
|
||||
// the same way the in-memory RankedProfiles map api.Service still falls
|
||||
// back to already did: (zero value, false, nil).
|
||||
//
|
||||
// LastSeasonID and SeasonHistory are deliberately left at their zero values.
|
||||
// The ratings table has no "current season" column, and reconstructing
|
||||
// season history means a second query against ranked_season_rollovers with
|
||||
// its own display semantics to settle -- a real, separate piece of work,
|
||||
// not bundled into this read path speculatively.
|
||||
// LastSeasonID and SeasonHistory remain zero-valued because they describe
|
||||
// rollover history, while CurrentSeasonID is derived from the active ranked
|
||||
// season row. Keeping those concepts separate prevents the profile endpoint
|
||||
// from making a current season look already rolled over to maintenance.
|
||||
type PostgresRankedProfiles struct{ DB *sql.DB }
|
||||
|
||||
func (p PostgresRankedProfiles) Get(ctx context.Context, playerID string) (domain.RankedProfile, bool, error) {
|
||||
@@ -32,7 +34,7 @@ func (p PostgresRankedProfiles) Get(ctx context.Context, playerID string) (domai
|
||||
}
|
||||
var profile domain.RankedProfile
|
||||
err := p.DB.QueryRowContext(ctx, RankedProfileSelectSQL, playerID).
|
||||
Scan(&profile.Value, &profile.RD, &profile.Volatility, &profile.RankedGames, &profile.LastRatedAt)
|
||||
Scan(&profile.Value, &profile.RD, &profile.Volatility, &profile.RankedGames, &profile.LastRatedAt, &profile.CurrentSeasonID)
|
||||
if err == sql.ErrNoRows {
|
||||
return domain.RankedProfile{}, false, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRankedProfileQueryProjectsOnlyTheActiveRankedSeason(t *testing.T) {
|
||||
for _, fragment := range []string{"playlist = 'ranked'", "starts_at <= CURRENT_TIMESTAMP", "ends_at > CURRENT_TIMESTAMP", "ORDER BY starts_at DESC", "LIMIT 1"} {
|
||||
if !contains(RankedProfileSelectSQL, fragment) {
|
||||
t.Fatalf("ranked profile query missing %q", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user