mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 00:14:00 +00:00
feat(multiplayer): rotate ranked arenas deterministically
This commit is contained in:
+3
-1
@@ -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.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
+18
-1
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user