mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-14 17:52:04 +00:00
feat: add matchmaking domain state core
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
// Package domain contains database-independent matchmaking invariants.
|
||||
// Adapters may persist these records in PostgreSQL, but must not redefine
|
||||
// transition, revision, or idempotency behavior.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ResourceKind string
|
||||
|
||||
const (
|
||||
QueueTicket ResourceKind = "queue_ticket"
|
||||
Proposal ResourceKind = "proposal"
|
||||
Match ResourceKind = "match"
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
Queued State = "QUEUED"
|
||||
Proposed State = "PROPOSED"
|
||||
Accepted State = "ACCEPTED"
|
||||
Allocating State = "ALLOCATING"
|
||||
ProcessReady State = "PROCESS_READY"
|
||||
AssignmentReady State = "ASSIGNMENT_READY"
|
||||
Assigned State = "ASSIGNED"
|
||||
Connecting State = "CONNECTING"
|
||||
Live State = "LIVE"
|
||||
ResultPending State = "RESULT_PENDING"
|
||||
Completed State = "COMPLETED"
|
||||
Cancelled State = "CANCELLED"
|
||||
Expired State = "EXPIRED"
|
||||
Failed State = "FAILED"
|
||||
Open State = "OPEN"
|
||||
Declined State = "DECLINED"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConflict = errors.New("mutation conflict")
|
||||
ErrStaleRevision = errors.New("stale revision")
|
||||
ErrIllegalTransition = errors.New("illegal state transition")
|
||||
)
|
||||
|
||||
type Record struct {
|
||||
Kind ResourceKind
|
||||
ID string
|
||||
State State
|
||||
Revision uint64
|
||||
idempotent map[string]appliedMutation
|
||||
}
|
||||
|
||||
type appliedMutation struct {
|
||||
payloadDigest [32]byte
|
||||
result Result
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Kind ResourceKind
|
||||
ID string
|
||||
State State
|
||||
Revision uint64
|
||||
}
|
||||
|
||||
func NewRecord(kind ResourceKind, id string, state State) *Record {
|
||||
return &Record{Kind: kind, ID: id, State: state, idempotent: make(map[string]appliedMutation)}
|
||||
}
|
||||
|
||||
// Apply performs all validation before changing the record. Replaying an
|
||||
// identical idempotency key returns the original result without advancing the
|
||||
// revision. Reusing a key with a different payload, or presenting a stale
|
||||
// revision, is inert and returns an error.
|
||||
func (r *Record) Apply(idempotencyKey string, payload []byte, expectedRevision uint64, target State) (Result, error) {
|
||||
if idempotencyKey == "" {
|
||||
return Result{}, fmt.Errorf("%w: empty idempotency key", ErrConflict)
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
if prior, ok := r.idempotent[idempotencyKey]; ok {
|
||||
if !bytes.Equal(prior.payloadDigest[:], digest[:]) {
|
||||
return Result{}, fmt.Errorf("%w: idempotency key reused with different payload", ErrConflict)
|
||||
}
|
||||
return prior.result, nil
|
||||
}
|
||||
if expectedRevision != r.Revision {
|
||||
return Result{}, fmt.Errorf("%w: expected %d, current %d", ErrStaleRevision, expectedRevision, r.Revision)
|
||||
}
|
||||
if !legalTransition(r.Kind, r.State, target) {
|
||||
return Result{}, fmt.Errorf("%w: %s %s -> %s", ErrIllegalTransition, r.Kind, r.State, target)
|
||||
}
|
||||
|
||||
r.State = target
|
||||
r.Revision++
|
||||
result := Result{Kind: r.Kind, ID: r.ID, State: r.State, Revision: r.Revision}
|
||||
r.idempotent[idempotencyKey] = appliedMutation{payloadDigest: digest, result: result}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func legalTransition(kind ResourceKind, from, to State) bool {
|
||||
var targets []State
|
||||
switch kind {
|
||||
case QueueTicket:
|
||||
targets = queueTransitions[from]
|
||||
case Proposal:
|
||||
targets = proposalTransitions[from]
|
||||
case Match:
|
||||
targets = matchTransitions[from]
|
||||
default:
|
||||
return false
|
||||
}
|
||||
for _, target := range targets {
|
||||
if target == to {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var queueTransitions = map[State][]State{
|
||||
Queued: {Proposed, Cancelled, Expired},
|
||||
Proposed: {Queued, Accepted, Cancelled, Expired},
|
||||
Accepted: {Queued, Allocating, Cancelled, Failed},
|
||||
Allocating: {ProcessReady, Failed, Cancelled},
|
||||
ProcessReady: {AssignmentReady, Failed, Cancelled},
|
||||
AssignmentReady: {Assigned, Failed, Cancelled},
|
||||
Assigned: {Connecting, Failed, Cancelled},
|
||||
Connecting: {Live, Failed, Expired},
|
||||
Live: {ResultPending, Failed},
|
||||
ResultPending: {Completed, Failed},
|
||||
Completed: {}, Cancelled: {}, Expired: {}, Failed: {},
|
||||
}
|
||||
|
||||
var proposalTransitions = map[State][]State{
|
||||
Open: {Accepted, Declined, Expired, Cancelled},
|
||||
Accepted: {}, Declined: {}, Expired: {}, Cancelled: {},
|
||||
}
|
||||
|
||||
var matchTransitions = map[State][]State{
|
||||
Allocating: {ProcessReady, Failed, Cancelled},
|
||||
ProcessReady: {AssignmentReady, Failed, Cancelled},
|
||||
AssignmentReady: {Assigned, Failed, Cancelled},
|
||||
Assigned: {Connecting, Failed, Cancelled},
|
||||
Connecting: {Live, Failed, Cancelled},
|
||||
Live: {ResultPending, Failed},
|
||||
ResultPending: {Completed, Failed},
|
||||
Completed: {}, Cancelled: {}, Failed: {},
|
||||
}
|
||||
Reference in New Issue
Block a user