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 provisional := false
var season_id := ""
var season_ends_at_unix := 0
var error_message := ""
@@ -37,6 +38,9 @@ func apply(payload: Dictionary) -> bool:
tier = next_tier
provisional = bool(payload["provisional"])
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
error_message = ""
return true
@@ -47,11 +51,16 @@ func set_error(reason: String) -> void:
error_message = reason
func display_text() -> String:
func display_text(now_unix: int = -1) -> String:
if not available:
return error_message if not error_message.is_empty() else "Ranked profile unavailable"
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:
@@ -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.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")
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 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 {
Rating float64 `json:"rating"`
RD float64 `json:"rd"`
Volatility float64 `json:"volatility"`
RankedGames int `json:"ranked_games"`
Tier string `json:"tier"`
Provisional bool `json:"provisional"`
SeasonID string `json:"season_id,omitempty"`
Rating float64 `json:"rating"`
RD float64 `json:"rd"`
Volatility float64 `json:"volatility"`
RankedGames int `json:"ranked_games"`
Tier string `json:"tier"`
Provisional bool `json:"provisional"`
SeasonID string `json:"season_id,omitempty"`
SeasonEndsAt string `json:"season_ends_at,omitempty"`
}
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 == "" {
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 {
+2 -2
View File
@@ -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, 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,
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-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)
}
}
+5 -4
View File
@@ -60,10 +60,11 @@ func ScoreForPlayer(outcome MatchOutcome, playerID string, team int) (float64, e
type RankedProfile struct {
Rating
RankedGames int
CurrentSeasonID string
LastSeasonID string
SeasonHistory []string
RankedGames int
CurrentSeasonID string
CurrentSeasonEndsAt time.Time
LastSeasonID string
SeasonHistory []string
}
type RankTier string
+1 -1
View File
@@ -1290,7 +1290,7 @@ func TestPostgreSQLRankedProfileProjectsActiveSeason(t *testing.T) {
t.Fatal(err)
}
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)
}
}
+5 -2
View File
@@ -11,7 +11,10 @@ import (
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), '')
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
WHERE player_id = $1`
@@ -34,7 +37,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, &profile.CurrentSeasonID)
Scan(&profile.Value, &profile.RD, &profile.Volatility, &profile.RankedGames, &profile.LastRatedAt, &profile.CurrentSeasonID, &profile.CurrentSeasonEndsAt)
if err == sql.ErrNoRows {
return domain.RankedProfile{}, false, nil
}
+1 -1
View File
@@ -3,7 +3,7 @@ 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"} {
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) {
t.Fatalf("ranked profile query missing %q", fragment)
}