mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
test(domain): guard the ranked arena list against Godot registry drift
Task 8.20. `arena_registry.gd` is the documented single source of truth for arenas, but `domain/ranked.go` keeps a hand-maintained mirror of its floor-goal entries and nothing checked the two against each other -- ranked_test.go asserts the same three paths the production code hardcodes, so both could drift together silently. Drift is not hypothetical in either direction. The registry's own comment anticipates flipping an elevated variant to random:true once a checkpoint trained on that geometry is promoted, which ranked would then keep excluding indefinitely. A rename or removal is worse: the allocator would hand out a scene path that no longer exists, and the ranked server fails to load its arena at match start -- after allocation, so it burns a real match and a real server. Keeping the two copies is deliberate rather than a wart: ranked arena selection is server-authoritative and happens before any Godot process exists. So this guards the relationship instead of removing it, the same way the golden join-authorisation token guards the signing format. It parses the registry and fails if the sets disagree either way, if rotation order diverges from declaration order, or if a ranked path has no scene behind it. The parser asserts it found both eligible and ineligible entries, so a format change cannot make everything pass vacuously. Verified against four drift scenarios. The allocation-wiring half of 8.20 turned out to be already complete end to end, with coverage at each hop; recorded in the task row rather than rebuilt. Add a Server Unit Tests workflow, because none of this would otherwise run: the only Go tests CI executed were multiplayer-load's two load tests, so ~24k lines of control plane gated nothing. Docker-free so it can gate every push, and it vets the integration-tagged files too, since those are excluded from the default build and could otherwise rot uncompiled. CLAUDE.md's CI section claimed two workflows and no unit-test job; there were seven and now eight.
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// arenaRegistryPath is the Godot-side single source of truth for the arena
|
||||
// list (CLAUDE.md says so explicitly). rankedArenas in ranked.go is a
|
||||
// hand-maintained mirror of its floor-goal entries, and nothing has ever
|
||||
// checked the two against each other -- ranked_test.go asserts the same three
|
||||
// paths the production code hardcodes, so both could drift together silently.
|
||||
//
|
||||
// Drift is not hypothetical in either direction:
|
||||
//
|
||||
// - The registry's own comment anticipates flipping an elevated variant's
|
||||
// `random` flag to true once a checkpoint trained on that geometry is
|
||||
// promoted. Ranked would keep excluding it indefinitely.
|
||||
// - Adding an arena leaves ranked never selecting it.
|
||||
// - Renaming or removing one leaves the allocator handing out a scene path
|
||||
// that no longer exists, and an allocated ranked server fails to load its
|
||||
// arena at match start -- after allocation, so it burns a real match.
|
||||
const arenaRegistryPath = "../../Game/scripts/arena_registry.gd"
|
||||
|
||||
// gameScenesDir resolves a res:// path to the checked-out scene file.
|
||||
const gameScenesDir = "../../Game"
|
||||
|
||||
var arenaEntryPattern = regexp.MustCompile(`\{"name":\s*"([^"]*)",\s*"path":\s*"([^"]*)",\s*"random":\s*(true|false)\}`)
|
||||
|
||||
type registryArena struct {
|
||||
Name string
|
||||
Path string
|
||||
Random bool
|
||||
}
|
||||
|
||||
func parseArenaRegistry(t *testing.T) []registryArena {
|
||||
t.Helper()
|
||||
source, err := os.ReadFile(arenaRegistryPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read the Godot arena registry: %v", err)
|
||||
}
|
||||
matches := arenaEntryPattern.FindAllStringSubmatch(string(source), -1)
|
||||
arenas := make([]registryArena, 0, len(matches))
|
||||
for _, match := range matches {
|
||||
arenas = append(arenas, registryArena{Name: match[1], Path: match[2], Random: match[3] == "true"})
|
||||
}
|
||||
|
||||
// Guard the guard. If the literal format changes and the pattern stops
|
||||
// matching, every assertion below would pass vacuously against an empty
|
||||
// list -- which is the exact failure mode this test exists to prevent.
|
||||
if len(arenas) < 2 {
|
||||
t.Fatalf("parsed %d arenas from %s; the entry format probably changed and this parser needs updating", len(arenas), arenaRegistryPath)
|
||||
}
|
||||
var eligible, ineligible int
|
||||
for _, arena := range arenas {
|
||||
if arena.Random {
|
||||
eligible++
|
||||
} else {
|
||||
ineligible++
|
||||
}
|
||||
}
|
||||
if eligible == 0 || ineligible == 0 {
|
||||
t.Fatalf("parsed %d eligible and %d ineligible arenas; expected both kinds, so the `random` flag is probably not being read correctly", eligible, ineligible)
|
||||
}
|
||||
return arenas
|
||||
}
|
||||
|
||||
// TestRankedArenasMatchTheGodotRegistry is the cross-language contract. It is
|
||||
// the arena equivalent of the golden join-authorisation token in
|
||||
// Game/tests/cases/test_match_net.gd: one side owns the truth, and this fails
|
||||
// loudly when the other stops agreeing.
|
||||
func TestRankedArenasMatchTheGodotRegistry(t *testing.T) {
|
||||
registry := parseArenaRegistry(t)
|
||||
|
||||
expected := map[string]string{}
|
||||
var expectedOrder []string
|
||||
for _, arena := range registry {
|
||||
if !arena.Random {
|
||||
continue
|
||||
}
|
||||
expected[arena.Path] = arena.Name
|
||||
expectedOrder = append(expectedOrder, arena.Path)
|
||||
}
|
||||
|
||||
actual := map[string]string{}
|
||||
for id, arena := range rankedArenas {
|
||||
actual[arena.Path] = id
|
||||
}
|
||||
|
||||
for path, name := range expected {
|
||||
if _, present := actual[path]; !present {
|
||||
t.Errorf("registry arena %q (%s) is ranked-eligible in Godot but missing from rankedArenas.\n"+
|
||||
"If a checkpoint trained on this geometry was promoted, add it to rankedArenas and rankedArenaOrder in ranked.go.", path, name)
|
||||
}
|
||||
}
|
||||
for path, id := range actual {
|
||||
if _, present := expected[path]; !present {
|
||||
t.Errorf("rankedArenas contains %q (id %q), which is not a random:true entry in %s.\n"+
|
||||
"Ranked would allocate a scene the Godot registry no longer offers.", path, id, arenaRegistryPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation order must follow the registry's declaration order, since
|
||||
// RankedArenaForProposal indexes rankedArenaOrder and callers reason about
|
||||
// "the arenas, in order" across both languages.
|
||||
if len(rankedArenaOrder) != len(expectedOrder) {
|
||||
t.Fatalf("rankedArenaOrder has %d entries, registry has %d eligible", len(rankedArenaOrder), len(expectedOrder))
|
||||
}
|
||||
for index, id := range rankedArenaOrder {
|
||||
arena, known := rankedArenas[id]
|
||||
if !known {
|
||||
t.Fatalf("rankedArenaOrder[%d] = %q, which is not a key of rankedArenas", index, id)
|
||||
}
|
||||
if arena.Path != expectedOrder[index] {
|
||||
t.Errorf("rotation position %d is %q, registry declares %q there", index, arena.Path, expectedOrder[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A ranked arena path is handed to an allocated server after allocation, so a
|
||||
// path with no scene behind it fails at match start rather than at selection --
|
||||
// burning a real match and a real server. Cheap to catch here instead.
|
||||
func TestRankedArenaPathsResolveToRealScenes(t *testing.T) {
|
||||
for id, arena := range rankedArenas {
|
||||
relative, ok := scenePathFromRes(arena.Path)
|
||||
if !ok {
|
||||
t.Errorf("ranked arena %q has path %q, which is not a res:// path", id, arena.Path)
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(gameScenesDir, relative)); err != nil {
|
||||
t.Errorf("ranked arena %q points at %q, which does not exist: %v", id, arena.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Elevated-goal variants stay ranked-ineligible until a policy trained on that
|
||||
// geometry is promoted; the current bots cannot score on one. Assert this
|
||||
// against the registry's own flag rather than a second hardcoded list, so the
|
||||
// exclusion tracks the registry instead of drifting alongside it.
|
||||
func TestIneligibleRegistryArenasAreRejectedForRanked(t *testing.T) {
|
||||
registry := parseArenaRegistry(t)
|
||||
checked := 0
|
||||
for _, arena := range registry {
|
||||
if arena.Random {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
if IsRankedArenaPath(arena.Path) {
|
||||
t.Errorf("%q (%s) is random:false in the Godot registry but accepted for ranked", arena.Path, arena.Name)
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no ineligible arenas were checked")
|
||||
}
|
||||
}
|
||||
|
||||
func scenePathFromRes(path string) (string, bool) {
|
||||
const prefix = "res://"
|
||||
if len(path) <= len(prefix) || path[:len(prefix)] != prefix {
|
||||
return "", false
|
||||
}
|
||||
return path[len(prefix):], true
|
||||
}
|
||||
Reference in New Issue
Block a user