feat(multiplayer): harden ranked arena admission

This commit is contained in:
Josh Creek
2026-09-01 19:39:11 +01:00
parent 52e3d73678
commit 5630c5c8dc
6 changed files with 46 additions and 14 deletions
+5 -2
View File
@@ -235,8 +235,11 @@ rating loss because no rated match began.
- Exactly six verified humans; never bots and never backfill.
- Solo queue only at launch.
- Only `ArenaRegistry` entries with `"random": true` are eligible. Elevated
goals remain excluded until the trained-policy restriction is lifted.
- The matcher validates ranked admission against its server-owned allowlist of
the three floor-goal `ArenaRegistry` entries. Elevated goals remain excluded
until the trained-policy restriction is lifted. The selected scene is not
yet passed through the allocation-to-Godot launch contract, so match-scoped
arena selection remains a rollout gate rather than a client-controlled flag.
- A reconnecting player has 60 seconds to return using the existing
assignment. After that, that player receives a loss regardless of the final
team result and a rolling seven-day cooldown: 5 minutes, 15 minutes, 1
+2
View File
@@ -1446,6 +1446,8 @@ Allocator-selected region, build, protocol, and transport now travel with the al
The same allocation path now carries the matcher-selected playlist, preventing a ranked match from inheriting the Fleets casual default. Durable allocation claims return the playlist, the worker includes it in Fleet selection metadata, Agones copies it to the allocated GameServer, and the supervisor overrides `--playlist` before launch; the existing compatibility tests remain green.
Ranked proposal admission no longer trusts the matchers `--ranked-random-arena` boolean. The Go domain now owns a named allowlist for the three floor-goal `ArenaRegistry` entries, and rejects unknown and elevated IDs before any proposal is created. This is an admission boundary only: persisting a selected arena and passing it through allocation annotations to the Godot launch command is still required before the server can claim match-scoped arena selection.
The Godot control-plane client now retains the exact last idempotent mutation and exposes `retry_last_mutation()` for transport, timeout, rate-limit, and 5xx failures. Retries reuse the original idempotency key and expected revision, while 401 and 409 responses remain non-retryable; the harness covers the policy boundary. This closes the local duplicate-action recovery mechanism for heartbeat/cancel/proposal calls, with broader live UI retry verification still remaining.
The matchmaking UI now exposes that retained replay through its existing action button as `Retry Request` while a heartbeat, cancellation, or proposal action has a retryable failure. Terminal, authentication, and revision-conflict paths remain ineligible, so the button cannot issue a stale blind command.
+1 -2
View File
@@ -25,7 +25,6 @@ func main() {
playlist := flag.String("playlist", string(domain.Casual), "playlist to match")
size := flag.Int("size", 4, "players per match")
interval := flag.Duration("interval", time.Second, "poll interval")
rankedRandomArena := flag.Bool("ranked-random-arena", false, "enable ranked matching only when the selected arena is random and non-elevated")
redisAddr := flag.String("redis-addr", os.Getenv("COSMIC_CLASH_REDIS_ADDR"), "optional Redis candidate projection address")
redisPrefix := flag.String("redis-prefix", envOrDefault("COSMIC_CLASH_REDIS_PREFIX", "cosmic-clash"), "Redis key prefix")
redisTTL := flag.Duration("redis-ttl", 60*time.Second, "TTL for transient candidate projection entries")
@@ -108,7 +107,7 @@ func main() {
if err != nil {
return domain.PreparedProposal{}, err
}
return domain.PrepareProposal(id, playlist, formation, participants, domain.RankedArena{RandomEnabled: *rankedRandomArena}, at)
return domain.PrepareProposal(id, playlist, formation, participants, domain.DefaultRankedArena(), at)
}
return domain.PrepareProposal(id, playlist, formation, nil, domain.RankedArena{}, at)
},
+3 -3
View File
@@ -53,16 +53,16 @@ func TestPrepareProposalRejectsInvalidRankedMetadataAndAcceptsVerifiedSix(t *tes
for i, player := range formation.Selection.Players {
participants[i] = RankedParticipant{PlayerID: player.PlayerID, SteamID: "steam-" + player.PlayerID}
}
if _, err := PrepareProposal("proposal-ranked-123456", Ranked, formation, participants, RankedArena{RandomEnabled: true}, time.Unix(1000, 0)); err != nil {
if _, err := PrepareProposal("proposal-ranked-123456", Ranked, formation, participants, DefaultRankedArena(), time.Unix(1000, 0)); err != nil {
t.Fatal(err)
}
participants[0].IsBot = true
if _, err := PrepareProposal("proposal-ranked-654321", Ranked, formation, participants, RankedArena{RandomEnabled: true}, time.Unix(1000, 0)); err == nil {
if _, err := PrepareProposal("proposal-ranked-654321", Ranked, formation, participants, DefaultRankedArena(), time.Unix(1000, 0)); err == nil {
t.Fatal("ranked bot metadata accepted")
}
participants[0].IsBot = false
participants[0].PlayerID = "unknown"
if _, err := PrepareProposal("proposal-ranked-000000", Ranked, formation, participants, RankedArena{RandomEnabled: true}, time.Unix(1000, 0)); err == nil {
if _, err := PrepareProposal("proposal-ranked-000000", Ranked, formation, participants, DefaultRankedArena(), time.Unix(1000, 0)); err == nil {
t.Fatal("ranked unknown player metadata accepted")
}
}
+24 -3
View File
@@ -11,12 +11,28 @@ type RankedParticipant struct {
}
type RankedArena struct {
RandomEnabled bool
ElevatedGoals bool
ID string
}
// rankedArenas is the server-owned eligibility registry for launch ranked
// matches. It mirrors the floor-goal ArenaRegistry entries in the Godot
// project, but deliberately excludes every elevated-goal variant until a
// policy trained for that geometry is promoted.
var rankedArenas = map[string]RankedArena{
"arena_01": {ID: "arena_01"},
"arena_02": {ID: "arena_02"},
"arena_03": {ID: "arena_03"},
}
// DefaultRankedArena supplies a safe server-owned eligibility decision while
// the allocator-to-Godot match configuration channel is being completed. A
// ranked proposal is never admitted based on a mutable command-line boolean.
func DefaultRankedArena() RankedArena {
return rankedArenas["arena_01"]
}
func ValidateRankedAdmission(participants []RankedParticipant, arena RankedArena) error {
if len(participants) != 6 || !arena.RandomEnabled || arena.ElevatedGoals {
if len(participants) != 6 || !validRankedArena(arena) {
return fmt.Errorf("ranked admission requirements not met")
}
seenPlayers := make(map[string]bool, len(participants))
@@ -30,3 +46,8 @@ func ValidateRankedAdmission(participants []RankedParticipant, arena RankedArena
}
return nil
}
func validRankedArena(arena RankedArena) bool {
registered, ok := rankedArenas[arena.ID]
return ok && registered == arena
}
+11 -4
View File
@@ -11,7 +11,7 @@ func rankedParticipants() []RankedParticipant {
}
func TestRankedAdmissionRequiresSixUniqueVerifiedSoloHumansAndEligibleArena(t *testing.T) {
if err := ValidateRankedAdmission(rankedParticipants(), RankedArena{RandomEnabled: true}); err != nil {
if err := ValidateRankedAdmission(rankedParticipants(), DefaultRankedArena()); err != nil {
t.Fatal(err)
}
cases := []struct {
@@ -23,13 +23,12 @@ func TestRankedAdmissionRequiresSixUniqueVerifiedSoloHumansAndEligibleArena(t *t
{"bot", func(p []RankedParticipant, _ *RankedArena) { p[0].IsBot = true }},
{"backfill", func(p []RankedParticipant, _ *RankedArena) { p[0].IsBackfill = true }},
{"duplicate identity", func(p []RankedParticipant, _ *RankedArena) { p[1].SteamID = p[0].SteamID }},
{"random disabled", func(_ []RankedParticipant, a *RankedArena) { a.RandomEnabled = false }},
{"elevated arena", func(_ []RankedParticipant, a *RankedArena) { a.ElevatedGoals = true }},
{"unknown arena", func(_ []RankedParticipant, a *RankedArena) { a.ID = "arena_unknown" }},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
participants := rankedParticipants()
arena := RankedArena{RandomEnabled: true}
arena := DefaultRankedArena()
test.edit(participants, &arena)
if err := ValidateRankedAdmission(participants, arena); err == nil {
t.Fatal("invalid ranked admission accepted")
@@ -37,3 +36,11 @@ func TestRankedAdmissionRequiresSixUniqueVerifiedSoloHumansAndEligibleArena(t *t
})
}
}
func TestRankedArenaRegistryExcludesElevatedVariants(t *testing.T) {
for _, id := range []string{"arena_01_elevated", "arena_02_elevated", "arena_03_elevated"} {
if err := ValidateRankedAdmission(rankedParticipants(), RankedArena{ID: id}); err == nil {
t.Fatalf("elevated arena %q accepted for ranked", id)
}
}
}