feat(multiplayer): expose ranked season countdown

This commit is contained in:
Josh Creek
2026-09-01 21:49:48 +01:00
parent 9240cd4b27
commit 51f6e19339
9 changed files with 47 additions and 20 deletions
+11 -2
View File
@@ -13,6 +13,7 @@ var ranked_games := 0
var tier := "" var tier := ""
var provisional := false var provisional := false
var season_id := "" var season_id := ""
var season_ends_at_unix := 0
var error_message := "" var error_message := ""
@@ -37,6 +38,9 @@ func apply(payload: Dictionary) -> bool:
tier = next_tier tier = next_tier
provisional = bool(payload["provisional"]) provisional = bool(payload["provisional"])
season_id = String(payload.get("season_id", "")) season_id = String(payload.get("season_id", ""))
season_ends_at_unix = 0
if payload.has("season_ends_at") and payload["season_ends_at"] is String and not String(payload["season_ends_at"]).is_empty():
season_ends_at_unix = maxi(0, int(Time.get_unix_time_from_datetime_string(String(payload["season_ends_at"]))))
available = true available = true
error_message = "" error_message = ""
return true return true
@@ -47,11 +51,16 @@ func set_error(reason: String) -> void:
error_message = reason error_message = reason
func display_text() -> String: func display_text(now_unix: int = -1) -> String:
if not available: if not available:
return error_message if not error_message.is_empty() else "Ranked profile unavailable" return error_message if not error_message.is_empty() else "Ranked profile unavailable"
var status := "Provisional" if provisional else tier var status := "Provisional" if provisional else tier
return "%s · %d ranked game%s" % [status, ranked_games, "" if ranked_games == 1 else "s"] var text := "%s · %d ranked game%s" % [status, ranked_games, "" if ranked_games == 1 else "s"]
if season_ends_at_unix > 0:
var current_unix := int(Time.get_unix_time_from_system()) if now_unix < 0 else now_unix
var remaining_days := maxi(0, int(ceil(float(season_ends_at_unix - current_unix) / 86400.0)))
text += " · Season ends in %dd" % remaining_days
return text
func _reject(reason: String) -> bool: func _reject(reason: String) -> bool:
@@ -80,3 +80,10 @@ func test_ranked_profile_is_backend_display_data_and_rejects_unsafe_values() ->
assert_true(not profile.available, "unsafe response is not displayed") assert_true(not profile.available, "unsafe response is not displayed")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "", "provisional": false}), "empty tier is rejected") assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "", "provisional": false}), "empty tier is rejected")
assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": "false"}), "string boolean is rejected") assert_true(not profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 3, "tier": "GOLD", "provisional": "false"}), "string boolean is rejected")
func test_ranked_profile_projects_and_bounds_season_countdown() -> void:
var profile := RankedProfileState.new()
assert_true(profile.apply({"rating": 1500.0, "rd": 200.0, "volatility": 0.06, "ranked_games": 10, "tier": "GOLD", "provisional": false, "season_id": "s1", "season_ends_at": "1970-01-03T00:00:00Z"}), "season end applies")
assert_true(profile.display_text(1000).contains("Season ends in 2d"), "countdown rounds up remaining season time")
assert_true(profile.display_text(300000).contains("Season ends in 0d"), "expired season countdown is clamped")
+2
View File
@@ -1528,3 +1528,5 @@ the live PostgreSQL chaos/restart gate remains part of 8.50.
The ENet gate now auto-detects `/Applications/Godot.app/Contents/MacOS/Godot` when no PATH executable or `GODOT_BIN` override exists, while retaining explicit override precedence. The same gate passes without an environment override on this macOS host. The ENet gate now auto-detects `/Applications/Godot.app/Contents/MacOS/Godot` when no PATH executable or `GODOT_BIN` override exists, while retaining explicit override precedence. The same gate passes without an environment override on this macOS host.
The client matchmaking projection now preserves the server's `enqueued_at` timestamp through normalization, snapshots, and recovery, and uses it for the displayed queue wait when available. This prevents a client restart or delayed response from resetting the user's perceived wait to local process uptime; a local timer remains the fallback when older responses omit the timestamp. Godot state and normalization tests cover the projection and restore path. The client matchmaking projection now preserves the server's `enqueued_at` timestamp through normalization, snapshots, and recovery, and uses it for the displayed queue wait when available. This prevents a client restart or delayed response from resetting the user's perceived wait to local process uptime; a local timer remains the fallback when older responses omit the timestamp. Godot state and normalization tests cover the projection and restore path.
The ranked profile projection now also carries the active season's authoritative end timestamp from PostgreSQL through the API and Godot client. Ranked matchmaking displays a bounded days-remaining countdown, while providers without an active season remain compatible and omit the countdown.
+13 -8
View File
@@ -931,13 +931,14 @@ func assignmentChangedEvent(view AssignmentView, now time.Time) ControlPlaneEven
} }
type rankedProfileResponse struct { type rankedProfileResponse struct {
Rating float64 `json:"rating"` Rating float64 `json:"rating"`
RD float64 `json:"rd"` RD float64 `json:"rd"`
Volatility float64 `json:"volatility"` Volatility float64 `json:"volatility"`
RankedGames int `json:"ranked_games"` RankedGames int `json:"ranked_games"`
Tier string `json:"tier"` Tier string `json:"tier"`
Provisional bool `json:"provisional"` Provisional bool `json:"provisional"`
SeasonID string `json:"season_id,omitempty"` SeasonID string `json:"season_id,omitempty"`
SeasonEndsAt string `json:"season_ends_at,omitempty"`
} }
func (s *Service) profile(w http.ResponseWriter, r *http.Request) { func (s *Service) profile(w http.ResponseWriter, r *http.Request) {
@@ -1000,7 +1001,11 @@ func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) {
if seasonID == "" { if seasonID == "" {
seasonID = profile.LastSeasonID 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}) seasonEndsAt := ""
if profile.CurrentSeasonID != "" && !profile.CurrentSeasonEndsAt.IsZero() {
seasonEndsAt = profile.CurrentSeasonEndsAt.UTC().Format(time.RFC3339)
}
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, SeasonEndsAt: seasonEndsAt})
} }
type probeRequest struct { type probeRequest struct {
+2 -2
View File
@@ -1079,7 +1079,7 @@ func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(t *testing.T) {
} }
service := &Service{ service := &Service{
Sessions: sessions, Sessions: sessions,
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"}}, RankedProfiles: map[string]domain.RankedProfile{"player-a": {Rating: domain.Rating{Value: 1600, RD: 200, Volatility: 0.06}, RankedGames: 10, CurrentSeasonID: "season-current", CurrentSeasonEndsAt: now.Add(48 * time.Hour), LastSeasonID: "season-1"}},
TierPolicy: policy, TierPolicy: policy,
Now: func() time.Time { return now }, 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 { if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if body.Tier != string(domain.RankTierGold) || body.Provisional || body.RankedGames != 10 || body.SeasonID != "season-current" { if body.Tier != string(domain.RankTierGold) || body.Provisional || body.RankedGames != 10 || body.SeasonID != "season-current" || body.SeasonEndsAt != "1970-01-03T00:16:40Z" {
t.Fatalf("ranked profile response = %+v", body) t.Fatalf("ranked profile response = %+v", body)
} }
} }
+5 -4
View File
@@ -60,10 +60,11 @@ func ScoreForPlayer(outcome MatchOutcome, playerID string, team int) (float64, e
type RankedProfile struct { type RankedProfile struct {
Rating Rating
RankedGames int RankedGames int
CurrentSeasonID string CurrentSeasonID string
LastSeasonID string CurrentSeasonEndsAt time.Time
SeasonHistory []string LastSeasonID string
SeasonHistory []string
} }
type RankTier string type RankTier string
+1 -1
View File
@@ -1290,7 +1290,7 @@ func TestPostgreSQLRankedProfileProjectsActiveSeason(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
profile, found, err := (PostgresRankedProfiles{DB: db}).Get(ctx, "profile-season-player") profile, found, err := (PostgresRankedProfiles{DB: db}).Get(ctx, "profile-season-player")
if err != nil || !found || profile.CurrentSeasonID != "profile-season-current" { if err != nil || !found || profile.CurrentSeasonID != "profile-season-current" || !profile.CurrentSeasonEndsAt.Equal(now.Add(time.Hour)) {
t.Fatalf("profile=%+v found=%t err=%v", profile, found, err) t.Fatalf("profile=%+v found=%t err=%v", profile, found, err)
} }
} }
+5 -2
View File
@@ -11,7 +11,10 @@ import (
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 COALESCE((SELECT season_id FROM seasons
WHERE playlist = 'ranked' AND starts_at <= CURRENT_TIMESTAMP AND ends_at > CURRENT_TIMESTAMP WHERE playlist = 'ranked' AND starts_at <= CURRENT_TIMESTAMP AND ends_at > CURRENT_TIMESTAMP
ORDER BY starts_at DESC, season_id DESC LIMIT 1), '') ORDER BY starts_at DESC, season_id DESC LIMIT 1), ''),
COALESCE((SELECT ends_at 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), TIMESTAMP 'epoch')
FROM ratings FROM ratings
WHERE player_id = $1` WHERE player_id = $1`
@@ -34,7 +37,7 @@ func (p PostgresRankedProfiles) Get(ctx context.Context, playerID string) (domai
} }
var profile domain.RankedProfile var profile domain.RankedProfile
err := p.DB.QueryRowContext(ctx, RankedProfileSelectSQL, playerID). err := p.DB.QueryRowContext(ctx, RankedProfileSelectSQL, playerID).
Scan(&profile.Value, &profile.RD, &profile.Volatility, &profile.RankedGames, &profile.LastRatedAt, &profile.CurrentSeasonID) Scan(&profile.Value, &profile.RD, &profile.Volatility, &profile.RankedGames, &profile.LastRatedAt, &profile.CurrentSeasonID, &profile.CurrentSeasonEndsAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return domain.RankedProfile{}, false, nil return domain.RankedProfile{}, false, nil
} }
+1 -1
View File
@@ -3,7 +3,7 @@ package store
import "testing" import "testing"
func TestRankedProfileQueryProjectsOnlyTheActiveRankedSeason(t *testing.T) { 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"} { for _, fragment := range []string{"playlist = 'ranked'", "starts_at <= CURRENT_TIMESTAMP", "ends_at > CURRENT_TIMESTAMP", "ORDER BY starts_at DESC", "LIMIT 1", "TIMESTAMP 'epoch'"} {
if !contains(RankedProfileSelectSQL, fragment) { if !contains(RankedProfileSelectSQL, fragment) {
t.Fatalf("ranked profile query missing %q", fragment) t.Fatalf("ranked profile query missing %q", fragment)
} }