mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): persist ranked arena allocations
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -43,6 +43,7 @@ type Proposal struct {
|
||||
Playlist Playlist
|
||||
Region string
|
||||
Protocol int
|
||||
ArenaPath string
|
||||
Participants []ProposalParticipant
|
||||
State State
|
||||
Revision uint64
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user