mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 02:32:04 +00:00
feat: add durable result policy core
This commit is contained in:
+1
-1
@@ -1202,7 +1202,7 @@ the local/CI/community transport, not a silent production fallback.
|
|||||||
| 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional and keeps casual ratings outside the API | `RankedIsProvisional` covers the 0–9/10 boundary; authoritative tier derivation and UI remain |
|
| 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional and keeps casual ratings outside the API | `RankedIsProvisional` covers the 0–9/10 boundary; authoritative tier derivation and UI remain |
|
||||||
| 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season rollover compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history and is idempotent by season ID | `ApplySeasonRollover` covers compression, floor/cap and duplicate replay; PostgreSQL transaction locking and 12-week scheduler remain |
|
| 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season rollover compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history and is idempotent by season ID | `ApplySeasonRollover` covers compression, floor/cap and duplicate replay; PostgreSQL transaction locking and 12-week scheduler remain |
|
||||||
| 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 | `server/domain/reconnect.go` covers repeated backend-independent reclaim, binding rejection, old-generation fencing, grace boundary and deterministic cooldown audit ordering; signed authorisations, persistent lease fencing, join transport and full match/result integration remain |
|
| 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 | `server/domain/reconnect.go` covers repeated backend-independent reclaim, binding rejection, old-generation fencing, grace boundary and deterministic cooldown audit ordering; signed authorisations, persistent lease fencing, join transport and full match/result integration remain |
|
||||||
| 8.25 `[D:8.10,8.24]` | Separate result delivery delay from match-integrity failure; signed Agones-annotation spool, retries, 5 m alert/30 m review, suppression only for lost/corrupt authority or measured unfair regional incident | API outage preserves rating/result; clients cannot request exemption; node/pod/integrity faults take the documented suppression/refund path |
|
| 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, and exposes 5 m alert/30 m review delivery thresholds | `server/domain/result.go` and adversarial fixtures cover binding, duplicate/conflict, commit and delivery-health invariants; signed credential verification, Agones annotation spool/reconciliation, PostgreSQL atomic rating/outbox transaction and integrity-classification adapters remain |
|
||||||
|
|
||||||
#### 8D — Agones, allocation and regional scaling
|
#### 8D — Agones, allocation and regional scaling
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResultDeliveryAlertAfter = 5 * time.Minute
|
||||||
|
ResultDeliveryReviewAfter = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type IntegrityState string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IntegrityCertified IntegrityState = "CERTIFIED"
|
||||||
|
IntegritySuppressed IntegrityState = "SUPPRESSED"
|
||||||
|
IntegrityReview IntegrityState = "REVIEW"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrResultBinding = fmt.Errorf("result workload binding rejected")
|
||||||
|
ErrResultConflict = fmt.Errorf("conflicting result")
|
||||||
|
ErrResultInvalid = fmt.Errorf("invalid match result")
|
||||||
|
ErrReceiptMissing = fmt.Errorf("result receipt not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkloadBinding is the identity extracted and validated by the secure
|
||||||
|
// credential adapter. The domain compares every binding dimension recorded by
|
||||||
|
// allocation; a shared service-account class is not sufficient on its own.
|
||||||
|
type WorkloadBinding struct {
|
||||||
|
Issuer string
|
||||||
|
Audience string
|
||||||
|
Namespace string
|
||||||
|
ServiceAcct string
|
||||||
|
PodUID string
|
||||||
|
GameServerUID string
|
||||||
|
MatchID string
|
||||||
|
ServerID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MatchResult struct {
|
||||||
|
MatchID string
|
||||||
|
ServerID string
|
||||||
|
ResultNonce string
|
||||||
|
Team0Score int
|
||||||
|
Team1Score int
|
||||||
|
IntegrityState IntegrityState
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultReceipt struct {
|
||||||
|
ResultID string
|
||||||
|
MatchID string
|
||||||
|
ResultNonce string
|
||||||
|
PayloadDigest [32]byte
|
||||||
|
IntegrityState IntegrityState
|
||||||
|
ReceivedAt time.Time
|
||||||
|
CommittedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResultStore struct {
|
||||||
|
expected WorkloadBinding
|
||||||
|
receipts map[string]ResultReceipt
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResultStore(expected WorkloadBinding) (*ResultStore, error) {
|
||||||
|
if err := validateBinding(expected); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ResultStore{expected: expected, receipts: make(map[string]ResultReceipt)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit is the durable-transaction boundary in miniature. Production code
|
||||||
|
// must persist the receipt, match transition, participant penalties/ratings,
|
||||||
|
// and outbox event atomically around this same decision.
|
||||||
|
func (s *ResultStore) Submit(resultID string, result MatchResult, binding WorkloadBinding, now time.Time) (ResultReceipt, bool, error) {
|
||||||
|
if resultID == "" || !sameBinding(s.expected, binding) {
|
||||||
|
return ResultReceipt{}, false, ErrResultBinding
|
||||||
|
}
|
||||||
|
if err := validateResult(s.expected, result); err != nil {
|
||||||
|
return ResultReceipt{}, false, err
|
||||||
|
}
|
||||||
|
digest := resultDigest(result)
|
||||||
|
if prior, ok := s.receipts[result.MatchID]; ok {
|
||||||
|
if prior.ResultID == resultID && prior.PayloadDigest == digest {
|
||||||
|
return prior, false, nil
|
||||||
|
}
|
||||||
|
return prior, false, ErrResultConflict
|
||||||
|
}
|
||||||
|
receipt := ResultReceipt{ResultID: resultID, MatchID: result.MatchID, ResultNonce: result.ResultNonce, PayloadDigest: digest, IntegrityState: result.IntegrityState, ReceivedAt: now}
|
||||||
|
s.receipts[result.MatchID] = receipt
|
||||||
|
return receipt, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ResultStore) Commit(resultID, matchID string, now time.Time) (ResultReceipt, error) {
|
||||||
|
receipt, ok := s.receipts[matchID]
|
||||||
|
if !ok || receipt.ResultID != resultID {
|
||||||
|
return ResultReceipt{}, ErrReceiptMissing
|
||||||
|
}
|
||||||
|
if receipt.CommittedAt.IsZero() {
|
||||||
|
receipt.CommittedAt = now
|
||||||
|
s.receipts[matchID] = receipt
|
||||||
|
}
|
||||||
|
return receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeliveryHealth string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeliveryHealthy DeliveryHealth = "HEALTHY"
|
||||||
|
DeliveryAlert DeliveryHealth = "ALERT"
|
||||||
|
DeliveryReview DeliveryHealth = "REVIEW"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DeliveryStatus(receipt ResultReceipt, now time.Time) DeliveryHealth {
|
||||||
|
if !receipt.CommittedAt.IsZero() {
|
||||||
|
return DeliveryHealthy
|
||||||
|
}
|
||||||
|
age := now.Sub(receipt.ReceivedAt)
|
||||||
|
if age >= ResultDeliveryReviewAfter {
|
||||||
|
return DeliveryReview
|
||||||
|
}
|
||||||
|
if age >= ResultDeliveryAlertAfter {
|
||||||
|
return DeliveryAlert
|
||||||
|
}
|
||||||
|
return DeliveryHealthy
|
||||||
|
}
|
||||||
|
|
||||||
|
func RatingEligible(receipt ResultReceipt) bool {
|
||||||
|
return receipt.IntegrityState == IntegrityCertified
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBinding(binding WorkloadBinding) error {
|
||||||
|
if binding.Issuer == "" || binding.Audience == "" || binding.Namespace == "" || binding.ServiceAcct == "" || binding.PodUID == "" || binding.GameServerUID == "" || binding.MatchID == "" || binding.ServerID == "" {
|
||||||
|
return ErrResultBinding
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameBinding(a, b WorkloadBinding) bool { return a == b }
|
||||||
|
|
||||||
|
func validateResult(expected WorkloadBinding, result MatchResult) error {
|
||||||
|
if result.MatchID != expected.MatchID || result.ServerID != expected.ServerID || len(result.ResultNonce) < 16 || len(result.ResultNonce) > 128 || result.Team0Score < 0 || result.Team1Score < 0 {
|
||||||
|
return ErrResultInvalid
|
||||||
|
}
|
||||||
|
switch result.IntegrityState {
|
||||||
|
case IntegrityCertified, IntegritySuppressed, IntegrityReview:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return ErrResultInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultDigest(result MatchResult) [32]byte {
|
||||||
|
canonical := result.MatchID + "\x00" + result.ServerID + "\x00" + result.ResultNonce + "\x00" + strconv.Itoa(result.Team0Score) + "\x00" + strconv.Itoa(result.Team1Score) + "\x00" + string(result.IntegrityState)
|
||||||
|
return sha256.Sum256([]byte(canonical))
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testBinding() WorkloadBinding {
|
||||||
|
return WorkloadBinding{Issuer: "https://issuer", Audience: "cosmic-result", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", MatchID: "match-1", ServerID: "server-1"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResult() MatchResult {
|
||||||
|
return MatchResult{MatchID: "match-1", ServerID: "server-1", ResultNonce: "nonce-1234567890", Team0Score: 3, Team1Score: 2, IntegrityState: IntegrityCertified}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultStoreBindsWorkloadAndMakesIdenticalDuplicateInert(t *testing.T) {
|
||||||
|
now := time.Unix(1000, 0)
|
||||||
|
binding := testBinding()
|
||||||
|
store, err := NewResultStore(binding)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, created, err := store.Submit("result-1", testResult(), binding, now)
|
||||||
|
if err != nil || !created || !RatingEligible(first) {
|
||||||
|
t.Fatalf("first result = %+v created=%v err=%v", first, created, err)
|
||||||
|
}
|
||||||
|
replay, created, err := store.Submit("result-1", testResult(), binding, now.Add(time.Minute))
|
||||||
|
if err != nil || created || replay.ReceivedAt != now {
|
||||||
|
t.Fatalf("duplicate result = %+v created=%v err=%v", replay, created, err)
|
||||||
|
}
|
||||||
|
wrong := binding
|
||||||
|
wrong.PodUID = "pod-2"
|
||||||
|
if _, _, err := store.Submit("result-2", testResult(), wrong, now); !errors.Is(err, ErrResultBinding) {
|
||||||
|
t.Fatalf("wrong pod accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictingResultIsInertAndIntegritySuppressesRating(t *testing.T) {
|
||||||
|
now := time.Unix(1000, 0)
|
||||||
|
binding := testBinding()
|
||||||
|
store, _ := NewResultStore(binding)
|
||||||
|
if _, _, err := store.Submit("result-1", testResult(), binding, now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
conflict := testResult()
|
||||||
|
conflict.Team0Score = 99
|
||||||
|
prior, _, err := store.Submit("result-2", conflict, binding, now)
|
||||||
|
if !errors.Is(err, ErrResultConflict) || prior.ResultID != "result-1" || prior.CommittedAt != (time.Time{}) {
|
||||||
|
t.Fatalf("conflict mutated receipt: %+v err=%v", prior, err)
|
||||||
|
}
|
||||||
|
suppressed := testResult()
|
||||||
|
suppressed.MatchID = "match-2"
|
||||||
|
suppressed.IntegrityState = IntegritySuppressed
|
||||||
|
secondBinding := binding
|
||||||
|
secondBinding.MatchID = "match-2"
|
||||||
|
secondStore, _ := NewResultStore(secondBinding)
|
||||||
|
got, _, err := secondStore.Submit("result-2", suppressed, secondBinding, now)
|
||||||
|
if err != nil || RatingEligible(got) {
|
||||||
|
t.Fatalf("suppressed result eligibility = %+v err=%v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultDeliveryHealthSeparatesOutageFromIntegrity(t *testing.T) {
|
||||||
|
now := time.Unix(1000, 0)
|
||||||
|
binding := testBinding()
|
||||||
|
store, _ := NewResultStore(binding)
|
||||||
|
receipt, _, err := store.Submit("result-1", testResult(), binding, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if DeliveryStatus(receipt, now.Add(5*time.Minute-time.Nanosecond)) != DeliveryHealthy || DeliveryStatus(receipt, now.Add(ResultDeliveryAlertAfter)) != DeliveryAlert || DeliveryStatus(receipt, now.Add(ResultDeliveryReviewAfter)) != DeliveryReview {
|
||||||
|
t.Fatal("pending delivery thresholds are wrong")
|
||||||
|
}
|
||||||
|
committed, err := store.Commit("result-1", "match-1", now.Add(31*time.Minute))
|
||||||
|
if err != nil || DeliveryStatus(committed, now.Add(2*time.Hour)) != DeliveryHealthy {
|
||||||
|
t.Fatalf("committed delivery status = %+v err=%v", committed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user