diff --git a/docs/MATCHMAKING.md b/docs/MATCHMAKING.md index 97573679..ee616801 100644 --- a/docs/MATCHMAKING.md +++ b/docs/MATCHMAKING.md @@ -237,7 +237,9 @@ rating loss because no rated match began. - Solo queue only at launch. - 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 + until the trained-policy restriction is lifted. It chooses from that list + deterministically from the proposal ID, so retrying a proposal cannot change + its arena. The selected scene is persisted with the proposal/match plan, included in the allocation identity, and passed through the Agones GameServer annotation into the allocated server's validated `--arena-path` flag. diff --git a/multiplayer-next.md b/multiplayer-next.md index 22119c5d..bcd69f50 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1448,7 +1448,7 @@ The same allocation path now carries the matcher-selected playlist, preventing a 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. -The arena hand-off is now durable: migration 0008 stores the matcher-selected path on proposals and matches, allocation claims and idempotency digests retain it, Agones applies it as a match-scoped annotation, and the supervisor overlays the allocated child’s `--arena-path`. Godot accepts only the same floor-goal `ArenaRegistry` paths and requires one for allocated ranked matches, so a stale Fleet default, an elevated variant, or an altered retry cannot substitute a ranked arena. +The arena hand-off is now durable: the matcher deterministically selects an eligible floor-goal arena from the proposal ID, migration 0008 stores that path on proposals and matches, allocation claims and idempotency digests retain it, Agones applies it as a match-scoped annotation, and the supervisor overlays the allocated child’s `--arena-path`. Godot accepts only the same floor-goal `ArenaRegistry` paths and requires one for allocated ranked matches, so a stale Fleet default, an elevated variant, or an altered retry cannot substitute a ranked arena. 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. diff --git a/server/cmd/matcher/main.go b/server/cmd/matcher/main.go index 2ac7bbea..d8034dc3 100644 --- a/server/cmd/matcher/main.go +++ b/server/cmd/matcher/main.go @@ -107,7 +107,7 @@ func main() { if err != nil { return domain.PreparedProposal{}, err } - return domain.PrepareProposal(id, playlist, formation, participants, domain.DefaultRankedArena(), at) + return domain.PrepareProposal(id, playlist, formation, participants, domain.RankedArenaForProposal(id), at) } return domain.PrepareProposal(id, playlist, formation, nil, domain.RankedArena{}, at) }, diff --git a/server/domain/ranked.go b/server/domain/ranked.go index 45fbd1e7..9b078b52 100644 --- a/server/domain/ranked.go +++ b/server/domain/ranked.go @@ -1,6 +1,9 @@ package domain -import "fmt" +import ( + "crypto/sha256" + "fmt" +) type RankedParticipant struct { PlayerID string @@ -25,6 +28,8 @@ var rankedArenas = map[string]RankedArena{ "arena_03": {ID: "arena_03", Path: "res://scenes/arena_03.tscn"}, } +var rankedArenaOrder = []string{"arena_01", "arena_02", "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. @@ -32,6 +37,18 @@ func DefaultRankedArena() RankedArena { return rankedArenas["arena_01"] } +// RankedArenaForProposal chooses a floor-goal arena deterministically from the +// proposal identity. The same durable proposal retry therefore cannot change +// arena, while independent proposals rotate across the registry without +// mutable worker-local counters. +func RankedArenaForProposal(proposalID string) RankedArena { + if proposalID == "" { + return DefaultRankedArena() + } + digest := sha256.Sum256([]byte(proposalID)) + return rankedArenas[rankedArenaOrder[int(digest[0])%len(rankedArenaOrder)]] +} + func ValidateRankedAdmission(participants []RankedParticipant, arena RankedArena) error { if len(participants) != 6 || !validRankedArena(arena) { return fmt.Errorf("ranked admission requirements not met") diff --git a/server/domain/ranked_test.go b/server/domain/ranked_test.go index 6403a790..8b26790f 100644 --- a/server/domain/ranked_test.go +++ b/server/domain/ranked_test.go @@ -1,6 +1,9 @@ package domain -import "testing" +import ( + "fmt" + "testing" +) func rankedParticipants() []RankedParticipant { result := make([]RankedParticipant, 6) @@ -44,3 +47,21 @@ func TestRankedArenaRegistryExcludesElevatedVariants(t *testing.T) { } } } + +func TestRankedArenaForProposalIsStableAndRotatesEligibleRegistry(t *testing.T) { + first := RankedArenaForProposal("proposal-stable") + if first != RankedArenaForProposal("proposal-stable") { + t.Fatal("same proposal selected different arenas") + } + seen := map[string]bool{} + for index := 0; index < 128; index++ { + arena := RankedArenaForProposal(fmt.Sprintf("proposal-%d", index)) + if !validRankedArena(arena) { + t.Fatalf("proposal selected ineligible arena %+v", arena) + } + seen[arena.ID] = true + } + if len(seen) != len(rankedArenaOrder) { + t.Fatalf("selected arenas = %v, want every eligible arena", seen) + } +}