diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index 9d67499f..b7f54759 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -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) diff --git a/Game/scripts/server_config.gd b/Game/scripts/server_config.gd index 53a56f08..dec25f83 100644 --- a/Game/scripts/server_config.gd +++ b/Game/scripts/server_config.gd @@ -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: diff --git a/Game/scripts/server_match_loop.gd b/Game/scripts/server_match_loop.gd index f43881fb..55ac39b4 100644 --- a/Game/scripts/server_match_loop.gd +++ b/Game/scripts/server_match_loop.gd @@ -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 diff --git a/Game/tests/cases/test_server_config.gd b/Game/tests/cases/test_server_config.gd index 2421ed61..11487ce9 100644 --- a/Game/tests/cases/test_server_config.gd +++ b/Game/tests/cases/test_server_config.gd @@ -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"]) diff --git a/deploy/k8s/base/fleet.yaml b/deploy/k8s/base/fleet.yaml index 5bac3629..9f17f6ae 100644 --- a/deploy/k8s/base/fleet.yaml +++ b/deploy/k8s/base/fleet.yaml @@ -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 diff --git a/docs/MATCHMAKING.md b/docs/MATCHMAKING.md index c74955dd..97573679 100644 --- a/docs/MATCHMAKING.md +++ b/docs/MATCHMAKING.md @@ -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 diff --git a/multiplayer-next.md b/multiplayer-next.md index 39e8e5f4..22119c5d 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -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 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. +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 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/agones/allocation.go b/server/agones/allocation.go index 8f24db35..7857a79d 100644 --- a/server/agones/allocation.go +++ b/server/agones/allocation.go @@ -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 } diff --git a/server/agones/allocation_test.go b/server/agones/allocation_test.go index 7dc3e6ee..021eac89 100644 --- a/server/agones/allocation_test.go +++ b/server/agones/allocation_test.go @@ -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) } diff --git a/server/domain/allocator.go b/server/domain/allocator.go index 6c64d1bb..27eee59b 100644 --- a/server/domain/allocator.go +++ b/server/domain/allocator.go @@ -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))) } diff --git a/server/domain/formation.go b/server/domain/formation.go index 1148704d..a18d1319 100644 --- a/server/domain/formation.go +++ b/server/domain/formation.go @@ -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") diff --git a/server/domain/proposal.go b/server/domain/proposal.go index 00691eee..0718f223 100644 --- a/server/domain/proposal.go +++ b/server/domain/proposal.go @@ -43,6 +43,7 @@ type Proposal struct { Playlist Playlist Region string Protocol int + ArenaPath string Participants []ProposalParticipant State State Revision uint64 diff --git a/server/domain/ranked.go b/server/domain/ranked.go index d491524a..45fbd1e7 100644 --- a/server/domain/ranked.go +++ b/server/domain/ranked.go @@ -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 diff --git a/server/migrations/0008_match_arena_paths.sql b/server/migrations/0008_match_arena_paths.sql new file mode 100644 index 00000000..5eb168ca --- /dev/null +++ b/server/migrations/0008_match_arena_paths.sql @@ -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; diff --git a/server/migrations/down/0008_match_arena_paths.sql b/server/migrations/down/0008_match_arena_paths.sql new file mode 100644 index 00000000..beed70bd --- /dev/null +++ b/server/migrations/down/0008_match_arena_paths.sql @@ -0,0 +1,4 @@ +ALTER TABLE matches + DROP COLUMN IF EXISTS arena_path; +ALTER TABLE proposals + DROP COLUMN IF EXISTS match_arena_path; diff --git a/server/store/allocation_match_sql.go b/server/store/allocation_match_sql.go index 937eb199..c2ba3fd9 100644 --- a/server/store/allocation_match_sql.go +++ b/server/store/allocation_match_sql.go @@ -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, ®ion, &protocol, &claimedID) + err := tx.QueryRowContext(ctx, ClaimAllocatingMatchSQL, now.Add(-AllocationClaimLease), now).Scan(&matchID, &playlist, ®ion, &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 }) diff --git a/server/store/allocator_sql.go b/server/store/allocator_sql.go index c439a7bb..67422ba8 100644 --- a/server/store/allocator_sql.go +++ b/server/store/allocator_sql.go @@ -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))) } diff --git a/server/store/match_sql.go b/server/store/match_sql.go index a110a16b..f9c65d6a 100644 --- a/server/store/match_sql.go +++ b/server/store/match_sql.go @@ -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, ®ion, &protocol, &serverID); err != nil { + if err := tx.QueryRowContext(ctx, AcceptedMatchSelectSQL, plan.MatchID).Scan(&existingPlaylist, &state, ®ion, &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) diff --git a/server/store/match_sql_test.go b/server/store/match_sql_test.go index d673ee93..e6fbed0e 100644 --- a/server/store/match_sql_test.go +++ b/server/store/match_sql_test.go @@ -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"}}, diff --git a/server/store/proposal_sql.go b/server/store/proposal_sql.go index 48ce2f8a..b5f0422b 100644 --- a/server/store/proposal_sql.go +++ b/server/store/proposal_sql.go @@ -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 { diff --git a/server/supervisor/supervisor.go b/server/supervisor/supervisor.go index fd87b381..e61465b9 100644 --- a/server/supervisor/supervisor.go +++ b/server/supervisor/supervisor.go @@ -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 == "" { diff --git a/server/supervisor/supervisor_test.go b/server/supervisor/supervisor_test.go index fa849ad2..32572dd1 100644 --- a/server/supervisor/supervisor_test.go +++ b/server/supervisor/supervisor_test.go @@ -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 {