mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): harden ranked arena admission
This commit is contained in:
+5
-2
@@ -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
|
||||
|
||||
@@ -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 Fleet’s 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 matcher’s `--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.
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user