feat(multiplayer): persist ranked arena allocations

This commit is contained in:
Josh Creek
2026-09-01 21:04:55 +01:00
parent 5630c5c8dc
commit 84372204fd
22 changed files with 100 additions and 33 deletions
+1
View File
@@ -142,6 +142,7 @@ func _install_match_loop() -> void:
loop.allocated_mode = bool(config.get_value("allocated-mode"))
loop.allocated_playlist = String(config.get_value("playlist"))
loop.allocated_roster_size = MatchNet.assigned_player_slots().size() if loop.allocated_mode else 0
loop.allocated_arena_path = String(config.get_value("arena-path"))
get_tree().root.add_child.call_deferred(loop)
+6
View File
@@ -60,6 +60,7 @@ static func specs() -> Array[Spec]:
out.append(Spec.new("min-players", Kind.INT, 1, "match", "Players required before a match starts"))
out.append(Spec.new("start-countdown", Kind.FLOAT, 5.0, "match", "Seconds to wait after min-players is met before starting"))
out.append(Spec.new("arena-rotation", Kind.STRING, "sequential", "match", "How the next arena is picked: sequential or random"))
out.append(Spec.new("arena-path", Kind.STRING, "", "match", "Allocated arena scene path; empty uses rotation"))
out.append(Spec.new("smoke-force-goal-after", Kind.FLOAT, -1.0, "match", "LOCAL TEST ONLY: force one server-authoritative goal this many seconds after play starts; -1 disables"))
out.append(Spec.new("fill-bots", Kind.BOOL, false, "match", "Give a disconnected player's ship to a bot instead of leaving it inert"))
out.append(Spec.new("slot-reservation-seconds", Kind.FLOAT, 30.0, "match", "How long a departed player's slot is held for their return"))
@@ -268,6 +269,9 @@ func _validate() -> void:
var rotation := String(values["arena-rotation"])
if not rotation in ["sequential", "random"]:
errors.append("--arena-rotation must be sequential or random, got '%s'" % rotation)
var arena_path := String(values["arena-path"])
if not arena_path.is_empty() and not arena_path in ArenaRegistry.rotation_paths():
errors.append("--arena-path must be a ranked-eligible ArenaRegistry path, got '%s'" % arena_path)
if bool(values["allocated-mode"]):
for key in ["match-id", "server-id", "playlist-version", "playlist", "client-build", "assignment-expiry-unix", "server-image-digest", "transport", "region"]:
if str(values[key]).is_empty():
@@ -290,6 +294,8 @@ func _validate() -> void:
var playlist := String(values["playlist"])
if not playlist in ["casual", "ranked"]:
errors.append("--playlist must be casual or ranked, got '%s'" % playlist)
if playlist == "ranked" and arena_path.is_empty():
errors.append("--allocated-mode ranked matches require --arena-path")
static func _is_sha256_digest(value: String) -> bool:
+2 -1
View File
@@ -47,6 +47,7 @@ var rotation_mode := "sequential"
var allocated_mode := false
var allocated_playlist := ""
var allocated_roster_size := 0
var allocated_arena_path := ""
var matches_completed := 0
var _countdown_started_ms := -1
@@ -165,7 +166,7 @@ func _poll_match_start(now: int) -> void:
func _start_match() -> void:
var arena_path := ArenaRegistry.path_for_match(matches_completed, rotation_mode)
var arena_path := allocated_arena_path if allocated_mode and not allocated_arena_path.is_empty() else ArenaRegistry.path_for_match(matches_completed, rotation_mode)
# The match scene picks its own arena at random by default. Handing it one
# explicitly is what makes rotation a rotation rather than a coincidence.
NetworkedMatch.server_arena_override = arena_path
+2
View File
@@ -100,6 +100,8 @@ func test_out_of_range_values_are_rejected_with_their_own_message() -> void:
assert_true(not _parse(["--match-length=0"]).is_valid(), "a zero-length match is rejected")
assert_true(not _parse(["--log-level=chatty"]).is_valid(), "an undefined log level is rejected")
assert_true(not _parse(["--arena-rotation=spiral"]).is_valid(), "an undefined rotation mode is rejected")
assert_true(not _parse(["--arena-path=res://scenes/arena_01_elevated.tscn"]).is_valid(), "an elevated arena cannot be selected for allocated ranked play")
assert_true(_parse(["--arena-path=res://scenes/arena_01.tscn"]).is_valid(), "a ranked-eligible arena path is accepted")
assert_true(not _parse(["--smoke-force-goal-after=-2"]).is_valid(), "only -1 disables the deterministic smoke goal")
# Control: the same flags at legal values all pass together.
var ok = _parse(["--port=7000", "--max-clients=6", "--match-length=90", "--log-level=warn", "--arena-rotation=random"])
+1
View File
@@ -72,6 +72,7 @@ spec:
- --server-id=allocation-placeholder
- --playlist-version=casual
- --playlist=casual
- --arena-path=
- --client-build=build-1
- --assignment-expiry-unix=1
- --server-image-digest=sha256:0000000000000000000000000000000000000000000000000000000000000000
+4 -3
View File
@@ -237,9 +237,10 @@ 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 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.
until the trained-policy restriction is lifted. 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.
- 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
+3 -1
View File
@@ -1446,7 +1446,9 @@ 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.
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.
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 childs `--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.
+3
View File
@@ -242,6 +242,9 @@ func (c Client) Allocate(ctx context.Context, request domain.AllocationRequest,
"cosmic-clash.io/protocol": strconv.Itoa(request.Protocol),
"cosmic-clash.io/transport": request.Transport,
}
if request.ArenaPath != "" {
body.Spec.Metadata.Annotations["cosmic-clash.io/arena-path"] = request.ArenaPath
}
if playlist := labels["cosmic-clash.io/playlist"]; playlist == string(domain.Casual) || playlist == string(domain.Ranked) {
body.Spec.Metadata.Annotations["cosmic-clash.io/playlist"] = playlist
}
+6 -1
View File
@@ -38,11 +38,16 @@ func TestAllocateBuildsStrictGameServerAllocationAndEndpoint(t *testing.T) {
t.Fatalf("annotation %s = %q, want %q", key, body.Spec.Metadata.Annotations[key], value)
}
}
if body.Spec.Metadata.Annotations["cosmic-clash.io/arena-path"] != "res://scenes/arena_01.tscn" {
t.Fatalf("arena annotation = %q", body.Spec.Metadata.Annotations["cosmic-clash.io/arena-path"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":{"state":"Allocated","gameServerName":"gs-a","address":"2001:db8::1","ports":[{"name":"default","port":7777}]}}`))
}))
defer server.Close()
got, err := (Client{BaseURL: server.URL, Namespace: "games", HTTP: server.Client()}).Allocate(context.Background(), request(), map[string]string{"cosmic-clash/region": "EU", "cosmic-clash/build": "build-1"}, time.Unix(1000, 0))
allocation := request()
allocation.ArenaPath = "res://scenes/arena_01.tscn"
got, err := (Client{BaseURL: server.URL, Namespace: "games", HTTP: server.Client()}).Allocate(context.Background(), allocation, map[string]string{"cosmic-clash/region": "EU", "cosmic-clash/build": "build-1"}, time.Unix(1000, 0))
if err != nil {
t.Fatal(err)
}
+2 -1
View File
@@ -31,6 +31,7 @@ type AllocationRequest struct {
Region string
Build string
Protocol int
ArenaPath string
Transport string
}
@@ -142,5 +143,5 @@ func validateAllocationRequest(request AllocationRequest) error {
}
func allocationDigest(request AllocationRequest) [32]byte {
return sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s", request.AllocationID, request.MatchID, request.Region, request.Build, request.Protocol, request.Transport)))
return sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s\x00%s", request.AllocationID, request.MatchID, request.Region, request.Build, request.Protocol, request.Transport, request.ArenaPath)))
}
+3
View File
@@ -74,6 +74,9 @@ func PrepareProposal(id string, playlist Playlist, formation MatchFormation, ran
}
proposal.Region = formation.Selection.Region
proposal.Protocol = formation.Selection.Players[0].ProtocolVersion
if playlist == Ranked {
proposal.ArenaPath = arena.Path
}
for _, player := range formation.Selection.Players {
if player.ProtocolVersion != proposal.Protocol {
return PreparedProposal{}, fmt.Errorf("formed match has mixed protocols")
+1
View File
@@ -43,6 +43,7 @@ type Proposal struct {
Playlist Playlist
Region string
Protocol int
ArenaPath string
Participants []ProposalParticipant
State State
Revision uint64
+5 -4
View File
@@ -11,7 +11,8 @@ type RankedParticipant struct {
}
type RankedArena struct {
ID string
ID string
Path string
}
// rankedArenas is the server-owned eligibility registry for launch ranked
@@ -19,9 +20,9 @@ type RankedArena struct {
// 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"},
"arena_01": {ID: "arena_01", Path: "res://scenes/arena_01.tscn"},
"arena_02": {ID: "arena_02", Path: "res://scenes/arena_02.tscn"},
"arena_03": {ID: "arena_03", Path: "res://scenes/arena_03.tscn"},
}
// DefaultRankedArena supplies a safe server-owned eligibility decision while
@@ -0,0 +1,8 @@
-- Persist the matcher-selected arena through acceptance and allocation. NULL
-- remains valid for legacy/casual rows while ranked proposals always write a
-- server-owned floor-goal path.
ALTER TABLE proposals
ADD COLUMN match_arena_path TEXT;
ALTER TABLE matches
ADD COLUMN arena_path TEXT;
@@ -0,0 +1,4 @@
ALTER TABLE matches
DROP COLUMN IF EXISTS arena_path;
ALTER TABLE proposals
DROP COLUMN IF EXISTS match_arena_path;
+7 -3
View File
@@ -30,7 +30,7 @@ UPDATE matches m
SET allocation_id = 'allocation-' || candidate.match_id, allocation_claimed_at = $2
FROM candidate
WHERE m.match_id = candidate.match_id
RETURNING m.match_id, m.playlist, m.region, m.protocol_version, m.allocation_id`
RETURNING m.match_id, m.playlist, m.region, m.protocol_version, m.arena_path, m.allocation_id`
const AllocatingMatchBuildSQL = `SELECT client_build
FROM queue_tickets q
@@ -202,8 +202,9 @@ func ClaimAllocatingMatch(ctx context.Context, db *sql.DB, transport string, now
err := RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
var matchID, playlist, region string
var protocol int
var arenaPath sql.NullString
var claimedID string
err := tx.QueryRowContext(ctx, ClaimAllocatingMatchSQL, now.Add(-AllocationClaimLease), now).Scan(&matchID, &playlist, &region, &protocol, &claimedID)
err := tx.QueryRowContext(ctx, ClaimAllocatingMatchSQL, now.Add(-AllocationClaimLease), now).Scan(&matchID, &playlist, &region, &protocol, &arenaPath, &claimedID)
if err == sql.ErrNoRows {
return nil
}
@@ -233,7 +234,10 @@ func ClaimAllocatingMatch(ctx context.Context, db *sql.DB, transport string, now
if build == "" {
return fmt.Errorf("allocating match has no participants")
}
item.Request = domain.AllocationRequest{AllocationID: claimedID, MatchID: matchID, Playlist: domain.Playlist(playlist), Region: region, Build: build, Protocol: protocol, Transport: transport}
if domain.Playlist(playlist) == domain.Ranked && !arenaPath.Valid {
return fmt.Errorf("ranked allocating match has no arena")
}
item.Request = domain.AllocationRequest{AllocationID: claimedID, MatchID: matchID, Playlist: domain.Playlist(playlist), Region: region, Build: build, Protocol: protocol, ArenaPath: arenaPath.String, Transport: transport}
found = true
return nil
})
+1 -1
View File
@@ -144,5 +144,5 @@ func validAllocationInput(db *sql.DB, request domain.AllocationRequest, now time
}
func allocationRequestDigest(request domain.AllocationRequest) [32]byte {
return sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s", request.AllocationID, request.MatchID, request.Region, request.Build, request.Protocol, request.Transport)))
return sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%s\x00%s", request.AllocationID, request.MatchID, request.Region, request.Build, request.Protocol, request.Transport, request.ArenaPath)))
}
+13 -8
View File
@@ -19,6 +19,7 @@ type AcceptedMatchPlan struct {
ProposalID string
Region string
Protocol int
ArenaPath string
Players []MatchPlayer
}
@@ -40,11 +41,11 @@ ORDER BY player_id
FOR UPDATE`
const AcceptedMatchInsertSQL = `INSERT INTO matches
(match_id, playlist, state, region, protocol_version)
VALUES ($1, $2, 'ALLOCATING', $3, $4)
(match_id, playlist, state, region, protocol_version, arena_path)
VALUES ($1, $2, 'ALLOCATING', $3, $4, NULLIF($5, ''))
ON CONFLICT (match_id) DO NOTHING`
const AcceptedMatchSelectSQL = `SELECT playlist, state, region, protocol_version, server_id
const AcceptedMatchSelectSQL = `SELECT playlist, state, region, protocol_version, arena_path, server_id
FROM matches
WHERE match_id = $1
FOR UPDATE`
@@ -63,7 +64,7 @@ const AcceptedMatchParticipantInsertSQL = `INSERT INTO match_participants
(match_id, player_id, ticket_id, slot, team)
VALUES ($1, $2, $3, $4, $5)`
const StoredProposalMatchPlanSQL = `SELECT match_region, match_protocol
const StoredProposalMatchPlanSQL = `SELECT match_region, match_protocol, match_arena_path
FROM proposals
WHERE proposal_id = $1 AND state = 'ACCEPTED'`
@@ -80,7 +81,7 @@ func PromoteStoredAcceptedProposal(ctx context.Context, db *sql.DB, proposalID s
return fmt.Errorf("invalid stored proposal promotion arguments")
}
plan := AcceptedMatchPlan{MatchID: "match-" + proposalID, ProposalID: proposalID}
if err := db.QueryRowContext(ctx, StoredProposalMatchPlanSQL, proposalID).Scan(&plan.Region, &plan.Protocol); err != nil {
if err := db.QueryRowContext(ctx, StoredProposalMatchPlanSQL, proposalID).Scan(&plan.Region, &plan.Protocol, &plan.ArenaPath); err != nil {
return err
}
rows, err := db.QueryContext(ctx, StoredProposalMatchPlayersSQL, proposalID)
@@ -119,11 +120,14 @@ func CreateMatchFromAcceptedProposal(ctx context.Context, db *sql.DB, plan Accep
if !validAcceptedPlaylistCount(domain.Playlist(playlist), len(plan.Players)) {
return fmt.Errorf("accepted proposal playlist does not match player count")
}
if domain.Playlist(playlist) == domain.Ranked && plan.ArenaPath == "" {
return fmt.Errorf("ranked accepted match plan has no arena")
}
participants, err := acceptedProposalParticipants(ctx, tx, plan)
if err != nil {
return err
}
inserted, err := tx.ExecContext(ctx, AcceptedMatchInsertSQL, plan.MatchID, playlist, plan.Region, plan.Protocol)
inserted, err := tx.ExecContext(ctx, AcceptedMatchInsertSQL, plan.MatchID, playlist, plan.Region, plan.Protocol, plan.ArenaPath)
if err != nil {
return err
}
@@ -216,11 +220,12 @@ func acceptedProposalParticipants(ctx context.Context, tx *sql.Tx, plan Accepted
func verifyAcceptedMatchReplay(ctx context.Context, tx *sql.Tx, plan AcceptedMatchPlan, playlist domain.Playlist, tickets map[string]string) error {
var existingPlaylist, state, region string
var protocol int
var arenaPath sql.NullString
var serverID sql.NullString
if err := tx.QueryRowContext(ctx, AcceptedMatchSelectSQL, plan.MatchID).Scan(&existingPlaylist, &state, &region, &protocol, &serverID); err != nil {
if err := tx.QueryRowContext(ctx, AcceptedMatchSelectSQL, plan.MatchID).Scan(&existingPlaylist, &state, &region, &protocol, &arenaPath, &serverID); err != nil {
return err
}
if existingPlaylist != string(playlist) || state != string(domain.Allocating) || region != plan.Region || protocol != plan.Protocol || serverID.Valid {
if existingPlaylist != string(playlist) || state != string(domain.Allocating) || region != plan.Region || protocol != plan.Protocol || arenaPath.String != plan.ArenaPath || arenaPath.Valid != (plan.ArenaPath != "") || serverID.Valid {
return domain.ErrConflict
}
rows, err := tx.QueryContext(ctx, AcceptedMatchParticipantsSQL, plan.MatchID)
+13
View File
@@ -59,6 +59,19 @@ func TestAcceptedMatchPromotionHonoursPlaylistSizeInvariant(t *testing.T) {
}
}
func TestRankedAcceptedMatchPlanRequiresArenaAfterPlaylistResolution(t *testing.T) {
plan := AcceptedMatchPlan{MatchID: "match-1", ProposalID: "proposal-1", Region: "EU", Protocol: 1, Players: []MatchPlayer{{PlayerID: "player-a", Team: 0, Slot: 0}, {PlayerID: "player-b", Team: 1, Slot: 3}}}
if !validAcceptedMatchPlan(plan) {
t.Fatal("test plan should reach the database playlist guard")
}
// CreateMatchFromAcceptedProposal owns the playlist lookup, so a nil DB is
// the only no-database check available here; the integration suite exercises
// the resolved ranked branch against PostgreSQL.
if err := CreateMatchFromAcceptedProposal(nil, nil, plan, time.Now()); err == nil {
t.Fatal("nil database accepted")
}
}
func TestMatchPlayersFromTeamsUsesDeterministicTeamSlots(t *testing.T) {
teams := domain.Teams{
Team0: []domain.Candidate{{PlayerID: "bravo"}, {PlayerID: "alpha"}},
+6 -3
View File
@@ -11,8 +11,8 @@ import (
)
const ProposalInsertSQL = `INSERT INTO proposals
(proposal_id, playlist, state, expires_at, revision, match_region, match_protocol)
VALUES ($1, $2, 'OPEN', $3, 0, NULLIF($4, ''), NULLIF($5, 0))`
(proposal_id, playlist, state, expires_at, revision, match_region, match_protocol, match_arena_path)
VALUES ($1, $2, 'OPEN', $3, 0, NULLIF($4, ''), NULLIF($5, 0), NULLIF($6, ''))`
const ProposalOutboxInsertSQL = `INSERT INTO outbox
(event_id, aggregate_type, aggregate_id, revision, event_type, payload)
@@ -29,7 +29,7 @@ func CreateProposal(ctx context.Context, db *sql.DB, proposal domain.Proposal, t
return fmt.Errorf("invalid proposal match plan")
}
return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, ProposalInsertSQL, proposal.ProposalID, proposal.Playlist, proposal.ExpiresAt, proposal.Region, proposal.Protocol); err != nil {
if _, err := tx.ExecContext(ctx, ProposalInsertSQL, proposal.ProposalID, proposal.Playlist, proposal.ExpiresAt, proposal.Region, proposal.Protocol, proposal.ArenaPath); err != nil {
return err
}
players := make([]string, 0, len(proposal.Participants))
@@ -75,6 +75,9 @@ func validProposalMatchPlan(proposal domain.Proposal) bool {
if (proposal.Region != "EU" && proposal.Region != "NA") || proposal.Protocol < 1 {
return false
}
if proposal.Playlist == domain.Ranked && proposal.ArenaPath == "" {
return false
}
seenSlots := make(map[int]struct{}, len(proposal.Participants))
teams := [2]int{}
for _, participant := range proposal.Participants {
+6 -5
View File
@@ -343,11 +343,12 @@ func withAllocatedCompatibility(command []string, gameServer GameServer) ([]stri
return command, nil
}
for annotation, flag := range map[string]string{
"cosmic-clash.io/playlist": "playlist",
"cosmic-clash.io/region": "region",
"cosmic-clash.io/build": "client-build",
"cosmic-clash.io/protocol": "protocol-version",
"cosmic-clash.io/transport": "transport",
"cosmic-clash.io/arena-path": "arena-path",
"cosmic-clash.io/playlist": "playlist",
"cosmic-clash.io/region": "region",
"cosmic-clash.io/build": "client-build",
"cosmic-clash.io/protocol": "protocol-version",
"cosmic-clash.io/transport": "transport",
} {
value := annotations[annotation]
if value == "" {
+3 -2
View File
@@ -35,17 +35,18 @@ func TestWithAllocatedConfigOverridesAuthoritativeChildFlags(t *testing.T) {
}
func TestWithAllocatedCompatibilityOverridesStaleFlagsAndRejectsUnsafeValues(t *testing.T) {
command := []string{"game-server", "--region=EU", "--client-build=stale", "--protocol-version=1", "--transport=enet", "--custom=keep"}
command := []string{"game-server", "--region=EU", "--client-build=stale", "--protocol-version=1", "--transport=enet", "--arena-path=res://stale.tscn", "--custom=keep"}
gameServer := GameServer{}
gameServer.ObjectMeta.Annotations = map[string]string{
"cosmic-clash.io/region": "NA", "cosmic-clash.io/build": "build-live",
"cosmic-clash.io/protocol": "12", "cosmic-clash.io/transport": "steam_sdr",
"cosmic-clash.io/arena-path": "res://scenes/arena_01.tscn",
}
got, err := withAllocatedCompatibility(command, gameServer)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"--region=NA", "--client-build=build-live", "--protocol-version=12", "--transport=steam_sdr", "--custom=keep"} {
for _, want := range []string{"--region=NA", "--client-build=build-live", "--protocol-version=12", "--transport=steam_sdr", "--arena-path=res://scenes/arena_01.tscn", "--custom=keep"} {
found := false
for _, arg := range got {
if arg == want {