diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index b510a84c..1e27eb57 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -438,8 +438,9 @@ func _reserve_join_authorisation(token: String, peer_id: int) -> int: var now := Time.get_unix_time_from_system() var history: Dictionary = _join_history.get(token, {}) var lost_at := float(history.get("lost_at", 0.0)) - if lost_at > 0.0 and now - lost_at > RECONNECT_GRACE_SECONDS: - return -1 + if lost_at > 0.0: + if now < lost_at or now - lost_at > RECONNECT_GRACE_SECONDS: + return -1 var generation := int(history.get("generation", 0)) + 1 _join_history[token] = {"generation": generation, "lost_at": 0.0} _active_join_peers[token] = peer_id diff --git a/Game/tests/cases/test_match_net.gd b/Game/tests/cases/test_match_net.gd index eacfe323..527f374a 100644 --- a/Game/tests/cases/test_match_net.gd +++ b/Game/tests/cases/test_match_net.gd @@ -131,6 +131,8 @@ func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> v match_net._remove_player(43) match_net._join_history[token]["lost_at"] = Time.get_unix_time_from_system() - MatchNet.RECONNECT_GRACE_SECONDS - 1.0 assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "reclaim after the grace window is fenced") + match_net._join_history[token]["lost_at"] = Time.get_unix_time_from_system() + 60.0 + assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "clock-reversed reclaim is fenced") var malformed_context := {"match_id": 123, "server_id": "server-1", "protocol": "1", "protocol_version": 1} assert_true(not match_net.configure_join_authorisations([token], malformed_context), "numeric context identity is rejected") malformed_context = {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1.5} diff --git a/multiplayer-next.md b/multiplayer-next.md index 785e2734..2560350d 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1212,7 +1212,7 @@ production fallback. | 8.21 `[D:8.5,8.20]` | **IN PROGRESS.** Pure Go rating core implements canonical Glicko-2, daily inactivity, ranked 1/3 and casual 1/N human-opponent weights, deterministic opponent ordering, and authoritative draw/overtime/abandon scoring; certified result completion now applies per-player updates inside the durable transaction with lexical row locks and revision increments | `server/domain/rating.go`, `server/store/result_sql.go` and tests cover canonical/inactivity/weight/invalid-input, draw/OT/abandon, ordered participant snapshots, lock/value re-read and rating update SQL; live PostgreSQL rating and seasons execution now covered (§8.23), and concurrent result transaction cases are covered: `TestPostgreSQLConcurrentIdenticalResultSubmissionAppliesRatingsExactlyOnce` races 5 identical submissions and confirms one rating application, while `TestPostgreSQLConcurrentConflictingResultSubmissionsKeepOneReceipt` races different payloads and confirms exactly one winner, one conflict, one receipt and one completion event; live maintenance/DB execution remains | | 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional, derives tiers only through validated backend-owned rating bands, and keeps casual ratings outside the API; authenticated HTTP now returns the authoritative ranked view. **"Authoritative ranked view" was durable-adapter-shaped but had no durable adapter**: `rankedProfile`/`profile` only ever read an in-memory map, so every real `GET /v1/profile/ranked` 404'd regardless of a player's actual rating. `RankedProfileProvider` (interface) + `store.PostgresRankedProfiles` close it, preferred over the map when set so existing tests/literals are unaffected; `LastSeasonID`/`SeasonHistory` deliberately left unset (no season pointer on `ratings`, needs its own query/semantics) | `server/domain/rating.go`, `tier_test.go` and `server/api/service.go` cover provisional override, exact band boundaries, malformed policy rejection, session authentication and ranked-only response fields; `server/store/ranked_profile_sql.go`, verified against real PostgreSQL via curl (a fresh identity correctly 404s through the real adapter) and via §8.40's integration test (`control_plane_smoke.gd` now asserts this exact 404 round-trips before queueing); persisted tier policy, client UI and reconnect transport remain | | 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection; migration and Go store now persist a per-player/per-season marker and rating update atomically; `cmd/maintenance` runs bounded due-season batches with signal-bound shutdown | `server/domain/rating.go`, `season_test.go`, `server/migrations/0001_initial.sql` and `server/store/maintenance_sql.go` cover compression, floor/cap, duplicate replay, window boundary, completed-season idempotence, bounded enumeration, row locking and conflict-safe rollover markers; opt-in PostgreSQL execution now covers the durable rating update, marker creation and duplicate replay without a second compression — re-run live for the first time as part of the wider integration-suite verification below, after fixing a test setup gap (a missing `seasons` row tripped the `ranked_season_rollovers` foreign key before the rollover logic itself ran); live maintenance/DB execution remains | -| 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder; canonical signed authorisation issuance/verification now gates admission | `server/domain/reconnect.go`, `join_auth.go` and adversarial fixtures cover repeated backend-independent reclaim, all signed claim binding, tampering, failed verification, old-generation fencing, grace boundary and deterministic cooldown audit ordering; persistent lease fencing, join transport and full match/result integration remain | +| 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Ranked connection policy binds match/server/player/Steam identity/team/global slot/protocol/expiry, permits a 60-second same-token reclaim with monotonically increasing server-owned generations, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder. Admission now rejects an already-connected duplicate, zero-time operations, disconnect-before-admit, duplicate disconnect attempts that could extend grace, and clock-reversed disconnect/reclaim; Godot applies the same reversed-clock fence to its allowlisted signed-token reservation | Go/Godot adversarial fixtures cover signature tampering, every claim binding, active duplicate admission, repeated valid reclaim, old-generation fencing, exact grace boundary, expiry, zero/reversed clocks, and deterministic cooldown ordering. Persistent cross-process lease fencing and full match/result integration remain | | 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, classifies roster/simulation/result/fairness evidence, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds; Go store SQL now executes receipt → match lock → certified rating updates → completion → receipt acknowledgment → outbox atomically, and exposes bounded ordered outbox reads plus publish acknowledgements for replayable fan-out; `OutboxDispatcher` now delivers in order and acknowledges only after successful fan-out; the API validates workload-bound server result submissions and the PostgreSQL adapter repeats domain validation before invoking this durable boundary; production now also runs a filtered `match_completed` dispatcher that turns each committed result into targeted `COMPLETED` state events for every durable participant, without acknowledging proposal rows | `server/domain/result.go`, `workload.go`, `server/workload/jwt.go`, `server/api/service.go`, `server/api/outbox.go`, `server/store/result_sql.go` and `outbox.go` plus adversarial fixtures cover credential binding, duplicate/conflict, annotation forgery, server/match mismatch, invalid direct-adapter payloads, delivery-outage-versus-integrity classification, commit ordering, idempotent SQL reconciliation, ordered rating locks, certified-update gating, unpublished-event replay/ack boundaries, event-type isolation and delivery-before-ack failure ordering; opt-in PostgreSQL execution now covers result-pending completion, durable receipt/outbox publication, ack removal, identical replay and conflicting replay rejection, and real concurrent identical/conflicting submissions; `scripts/run_result_fanout_integration.sh` now drives real PostgreSQL → API WebSocket delivery for an authenticated participant; production credential verification, Agones annotation persistence/reconciliation and integrity evidence adapters remain | #### 8D — Agones, allocation and regional scaling diff --git a/server/domain/reconnect.go b/server/domain/reconnect.go index d91f3403..8a6b1857 100644 --- a/server/domain/reconnect.go +++ b/server/domain/reconnect.go @@ -91,6 +91,9 @@ func (r *RankedConnections) validate(auth JoinAuthorisation, now time.Time) erro // slot with the next server-owned generation. A newer generation fences every // older connection, even if the backend is temporarily unavailable. func (r *RankedConnections) Admit(auth JoinAuthorisation, now time.Time) (uint64, error) { + if now.IsZero() { + return 0, ErrJoinAuthorisation + } if err := r.validate(auth, now); err != nil { return 0, err } @@ -107,7 +110,13 @@ func (r *RankedConnections) Admit(auth JoinAuthorisation, now time.Time) (uint64 if player.Abandoned { return 0, ErrReconnectExpired } + if !player.ConnectedAt.IsZero() && player.LostAt.IsZero() { + return 0, ErrConnectionFenced + } if !player.LostAt.IsZero() { + if now.Before(player.LostAt) { + return 0, ErrJoinAuthorisation + } if now.Sub(player.LostAt) > RankedReconnectGrace { return 0, ErrReconnectExpired } @@ -120,6 +129,9 @@ func (r *RankedConnections) Admit(auth JoinAuthorisation, now time.Time) (uint64 } func (r *RankedConnections) Disconnect(playerID string, generation uint64, now time.Time) error { + if now.IsZero() { + return ErrJoinAuthorisation + } player, ok := r.players[playerID] if !ok { return ErrJoinAuthorisation @@ -130,6 +142,9 @@ func (r *RankedConnections) Disconnect(playerID string, generation uint64, now t if player.Abandoned { return ErrReconnectExpired } + if player.ConnectedAt.IsZero() || !player.LostAt.IsZero() || now.Before(player.ConnectedAt) { + return ErrConnectionFenced + } player.LostAt = now r.players[playerID] = player return nil diff --git a/server/domain/reconnect_test.go b/server/domain/reconnect_test.go index 1409eeb7..f6a3a716 100644 --- a/server/domain/reconnect_test.go +++ b/server/domain/reconnect_test.go @@ -26,19 +26,19 @@ func TestRankedReconnectReclaimsWithinGraceAndFencesOldGeneration(t *testing.T) if gen, err := r.Admit(auth, now); err != nil || gen != 1 { t.Fatalf("initial admit = %d, %v", gen, err) } - if err := r.Disconnect("a", 1, now); err != nil { + if err := r.Disconnect("a", 1, now.Add(time.Second)); err != nil { t.Fatal(err) } - if gen, err := r.Admit(auth, now.Add(RankedReconnectGrace)); err != nil || gen != 2 { + if gen, err := r.Admit(auth, now.Add(time.Second+RankedReconnectGrace)); err != nil || gen != 2 { t.Fatalf("boundary reclaim = %d, %v", gen, err) } - if err := r.Disconnect("a", 1, now.Add(31*time.Second)); !errors.Is(err, ErrConnectionFenced) { + if err := r.Disconnect("a", 1, now.Add(62*time.Second)); !errors.Is(err, ErrConnectionFenced) { t.Fatalf("old connection was not fenced: %v", err) } - if err := r.Disconnect("a", 2, now.Add(31*time.Second)); err != nil { + if err := r.Disconnect("a", 2, now.Add(62*time.Second)); err != nil { t.Fatal(err) } - if gen, err := r.Admit(auth, now.Add(32*time.Second)); err != nil || gen != 3 { + if gen, err := r.Admit(auth, now.Add(63*time.Second)); err != nil || gen != 3 { t.Fatalf("repeated reclaim with existing authorisation = %d, %v", gen, err) } } @@ -59,6 +59,9 @@ func TestRankedReconnectRejectsWrongBindingAndExpiredGrace(t *testing.T) { if _, err := r.Admit(wrongIdentity, now); !errors.Is(err, ErrJoinAuthorisation) { t.Fatalf("wrong SteamID accepted: %v", err) } + if _, err := r.Admit(testRoster(now)[0], now); err != nil { + t.Fatal(err) + } if err := r.Disconnect("a", 1, now); err != nil { t.Fatal(err) } @@ -67,6 +70,36 @@ func TestRankedReconnectRejectsWrongBindingAndExpiredGrace(t *testing.T) { } } +func TestRankedReconnectRejectsDuplicateAndTimeReversedLifecycle(t *testing.T) { + now := time.Unix(1000, 0) + r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now)) + if err != nil { + t.Fatal(err) + } + auth := testRoster(now)[0] + if err := r.Disconnect("a", 1, now); !errors.Is(err, ErrConnectionFenced) { + t.Fatalf("disconnect before admission error = %v", err) + } + if _, err := r.Admit(auth, time.Time{}); !errors.Is(err, ErrJoinAuthorisation) { + t.Fatalf("zero-time admission error = %v", err) + } + if _, err := r.Admit(auth, now); err != nil { + t.Fatal(err) + } + if _, err := r.Admit(auth, now.Add(time.Second)); !errors.Is(err, ErrConnectionFenced) { + t.Fatalf("duplicate active admission error = %v", err) + } + if err := r.Disconnect("a", 1, now.Add(2*time.Second)); err != nil { + t.Fatal(err) + } + if err := r.Disconnect("a", 1, now.Add(30*time.Second)); !errors.Is(err, ErrConnectionFenced) { + t.Fatalf("duplicate disconnect error = %v", err) + } + if _, err := r.Admit(auth, now.Add(time.Second)); !errors.Is(err, ErrJoinAuthorisation) { + t.Fatalf("time-reversed reclaim error = %v", err) + } +} + func TestRankedRosterRejectsDuplicateSlots(t *testing.T) { now := time.Unix(1000, 0) roster := testRoster(now) @@ -91,6 +124,9 @@ func TestRankedAbandonCooldownUsesRollingSevenDayLadder(t *testing.T) { if err != nil { t.Fatal(err) } + if _, err := r.Admit(testRoster(now)[0], now); err != nil { + t.Fatal(err) + } if err := r.Disconnect("a", 1, now); err != nil { t.Fatal(err) }