mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): expose ranked season countdown
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user