mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 16:33:43 +00:00
feat(multiplayer): persist ranked arena allocations
This commit is contained in:
@@ -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