Skip to content

Commit 8b7a91d

Browse files
committed
rewrite to make more pluggable
1 parent 494b677 commit 8b7a91d

24 files changed

Lines changed: 4094 additions & 784 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
coverage.*
2-
elo.*
2+
output/

bench_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package multielo
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func setupLeagueForBenchmark(playerCount int) (*League, []*MatchResult) {
8+
cfg := DefaultConfig()
9+
cfg.MaxMatches = 1 << 30 // effectively unbounded for benchmark runs
10+
l := NewLeagueWithConfig(cfg)
11+
12+
players := make([]*Player, playerCount)
13+
for i := 0; i < playerCount; i++ {
14+
name := string(rune('A'+i%26)) + string(rune('a'+(i/26)))
15+
_ = l.AddPlayer(name)
16+
p, _ := l.GetPlayer(name)
17+
players[i] = p
18+
}
19+
20+
results := make([]*MatchResult, playerCount)
21+
for i := 0; i < playerCount; i++ {
22+
results[i] = &MatchResult{Position: i + 1, Player: players[i]}
23+
}
24+
return l, results
25+
}
26+
27+
// Benchmark: Small match (8 players)
28+
func BenchmarkAddMatchSmall(b *testing.B) {
29+
l, results := setupLeagueForBenchmark(8)
30+
b.ReportAllocs()
31+
b.ResetTimer()
32+
for i := 0; i < b.N; i++ {
33+
if err := l.AddMatch(results); err != nil {
34+
b.Fatalf("add match failed: %v", err)
35+
}
36+
if i%1000 == 0 {
37+
l.ResetMatches()
38+
}
39+
}
40+
}
41+
42+
// Benchmark: Medium match (20 players)
43+
func BenchmarkAddMatchMedium(b *testing.B) {
44+
l, results := setupLeagueForBenchmark(20)
45+
b.ReportAllocs()
46+
b.ResetTimer()
47+
for i := 0; i < b.N; i++ {
48+
if err := l.AddMatch(results); err != nil {
49+
b.Fatalf("add match failed: %v", err)
50+
}
51+
if i%1000 == 0 {
52+
l.ResetMatches()
53+
}
54+
}
55+
}
56+
57+
// Benchmark: Large match (50 players)
58+
func BenchmarkAddMatchLarge(b *testing.B) {
59+
l, results := setupLeagueForBenchmark(50)
60+
b.ReportAllocs()
61+
b.ResetTimer()
62+
for i := 0; i < b.N; i++ {
63+
if err := l.AddMatch(results); err != nil {
64+
b.Fatalf("add match failed: %v", err)
65+
}
66+
if i%100 == 0 {
67+
l.ResetMatches()
68+
}
69+
}
70+
}
71+
72+
// Benchmark: GetPlayer lookups (should be O(1))
73+
func BenchmarkGetPlayer(b *testing.B) {
74+
l, _ := setupLeagueForBenchmark(100)
75+
testName := "Aa" // Known player from setup
76+
77+
b.ReportAllocs()
78+
b.ResetTimer()
79+
for i := 0; i < b.N; i++ {
80+
_, err := l.GetPlayer(testName)
81+
if err != nil {
82+
b.Fatalf("get player failed: %v", err)
83+
}
84+
}
85+
}
86+
87+
// Benchmark: GetLeaderboard with leaderboard caching
88+
func BenchmarkGetLeaderboard(b *testing.B) {
89+
l, results := setupLeagueForBenchmark(30)
90+
91+
// Add some matches to populate the league
92+
for i := 0; i < 10; i++ {
93+
_ = l.AddMatch(results)
94+
}
95+
96+
b.ReportAllocs()
97+
b.ResetTimer()
98+
for i := 0; i < b.N; i++ {
99+
_ = l.GetLeaderboard()
100+
}
101+
}
102+
103+
// Benchmark: GetPlayerStats
104+
func BenchmarkGetPlayerStats(b *testing.B) {
105+
l, results := setupLeagueForBenchmark(20)
106+
107+
// Add some matches to build up stats
108+
for i := 0; i < 50; i++ {
109+
_ = l.AddMatch(results)
110+
}
111+
112+
player, _ := l.GetPlayer("Aa")
113+
114+
b.ReportAllocs()
115+
b.ResetTimer()
116+
for i := 0; i < b.N; i++ {
117+
_ = player.GetStats()
118+
}
119+
}
120+
121+
// Benchmark: League initialization with many players
122+
func BenchmarkLeagueInitialization(b *testing.B) {
123+
const playerCount = 100
124+
b.ReportAllocs()
125+
b.ResetTimer()
126+
127+
for i := 0; i < b.N; i++ {
128+
cfg := DefaultConfig()
129+
cfg.MaxMatches = 1 << 30
130+
l := NewLeagueWithConfig(cfg)
131+
132+
for j := 0; j < playerCount; j++ {
133+
name := string(rune('A'+(j%26))) + string(rune('a'+(j/26)))
134+
_ = l.AddPlayer(name)
135+
}
136+
}
137+
}
138+
139+
// Benchmark: Service layer AddPlayer
140+
func BenchmarkServiceAddPlayer(b *testing.B) {
141+
b.ReportAllocs()
142+
b.ResetTimer()
143+
for i := 0; i < b.N; i++ {
144+
cfg := DefaultConfig()
145+
cfg.MaxMatches = 1 << 30
146+
cfg.MaxPlayers = 10 // Just need a few players per iteration
147+
l := NewLeagueWithConfig(cfg)
148+
service := NewLeagueService(l)
149+
150+
for j := 0; j < 10; j++ {
151+
name := "Player" + string(rune('A'+(j%26)))
152+
if err := service.AddPlayer(name); err != nil {
153+
b.Fatalf("add player failed: %v", err)
154+
}
155+
}
156+
}
157+
}
158+
159+
// Benchmark: Match filtering
160+
func BenchmarkMatchFiltering(b *testing.B) {
161+
l, results := setupLeagueForBenchmark(10)
162+
163+
// Add many matches
164+
for i := 0; i < 100; i++ {
165+
_ = l.AddMatch(results)
166+
}
167+
168+
filter := MatchFilter{
169+
Limit: 10,
170+
Offset: 0,
171+
}
172+
173+
b.ReportAllocs()
174+
b.ResetTimer()
175+
for i := 0; i < b.N; i++ {
176+
_ = l.GetMatchesFiltered(filter)
177+
}
178+
}

domain/elo.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package domain
2+
3+
import "math"
4+
5+
type ELOCalculator interface {
6+
Calculate(results []*MatchResult, cfg LeagueConfig) ([]MatchDiff, error)
7+
}
8+
9+
type DefaultELOCalculator struct{}
10+
11+
func (DefaultELOCalculator) Calculate(results []*MatchResult, cfg LeagueConfig) ([]MatchDiff, error) {
12+
n := len(results)
13+
if n < 2 {
14+
return nil, ErrInvalidMatch
15+
}
16+
17+
kValue := roundK(cfg.KFactor, n)
18+
changes := make([]MatchDiff, n)
19+
20+
for i, result := range results {
21+
changes[i] = MatchDiff{Player: result.Player, Diff: 0}
22+
}
23+
24+
for i, result := range results {
25+
currentELO := result.Player.ELO()
26+
currentPosition := result.Position
27+
28+
for j, opponentResult := range results {
29+
if i == j {
30+
continue
31+
}
32+
33+
opponentELO := opponentResult.Player.ELO()
34+
opponentPosition := opponentResult.Position
35+
36+
var score float64
37+
if currentPosition < opponentPosition {
38+
score = 1.0
39+
} else if currentPosition == opponentPosition {
40+
score = 0.5
41+
} else {
42+
score = 0.0
43+
}
44+
45+
expectedScore := expectedScore(currentELO, opponentELO)
46+
eloChange := kValue * (score - expectedScore)
47+
changes[i].Diff += int(eloChange)
48+
}
49+
}
50+
51+
return changes, nil
52+
}
53+
54+
func CalculateELOChanges(results []*MatchResult, cfg LeagueConfig) ([]MatchDiff, error) {
55+
return DefaultELOCalculator{}.Calculate(results, cfg)
56+
}
57+
58+
func roundK(k int, n int) float64 {
59+
return math.Round(float64(k) / float64(n-1))
60+
}
61+
62+
func expectedScore(aELO, bELO int) float64 {
63+
return 1.0 / (1.0 + math.Pow(10, float64(bELO-aELO)/400))
64+
}

domain/errors.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package domain
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
)
7+
8+
type ErrorType string
9+
10+
const (
11+
ErrorTypeValidation ErrorType = "VALIDATION"
12+
ErrorTypeNotFound ErrorType = "NOT_FOUND"
13+
ErrorTypePlayerNotFound ErrorType = "PLAYER_NOT_FOUND"
14+
ErrorTypePlayerExists ErrorType = "PLAYER_EXISTS"
15+
ErrorTypeConflict ErrorType = "CONFLICT"
16+
ErrorTypeInternal ErrorType = "INTERNAL"
17+
)
18+
19+
type ELOError struct {
20+
Type ErrorType
21+
Message string
22+
Player string
23+
Cause error
24+
Context map[string]interface{}
25+
}
26+
27+
func (e ELOError) Error() string {
28+
if e.Player != "" {
29+
return fmt.Sprintf("[%s] %s (player: %s)", e.Type, e.Message, e.Player)
30+
}
31+
return fmt.Sprintf("[%s] %s", e.Type, e.Message)
32+
}
33+
34+
func (e ELOError) Unwrap() error {
35+
return e.Cause
36+
}
37+
38+
var (
39+
ErrInvalidPlayerName = ELOError{Type: ErrorTypeValidation, Message: "player name cannot be empty or whitespace only"}
40+
ErrPlayerAlreadyExists = ELOError{Type: ErrorTypePlayerExists, Message: "player already exists"}
41+
ErrPlayerNotFound = ELOError{Type: ErrorTypePlayerNotFound, Message: "player not found"}
42+
ErrELOOutOfBounds = ELOError{Type: ErrorTypeValidation, Message: "ELO value out of allowed range"}
43+
ErrInvalidPosition = ELOError{Type: ErrorTypeValidation, Message: "match position must be positive"}
44+
ErrInvalidMatch = ELOError{Type: ErrorTypeValidation, Message: "invalid match data"}
45+
ErrLeagueFull = ELOError{Type: ErrorTypeConflict, Message: "league has reached maximum player capacity"}
46+
ErrNoPlayers = ELOError{Type: ErrorTypeValidation, Message: "no players in league"}
47+
)
48+
49+
var (
50+
ErrMatchNotFound = errors.New("match not found")
51+
ErrInvalidELOChange = errors.New("invalid elo change")
52+
ErrInvalidPlayer = errors.New("invalid player")
53+
ErrInvalidPlayerStats = errors.New("invalid player stats")
54+
ErrInvalidLeague = errors.New("invalid league")
55+
)

domain/graph.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package domain
2+
3+
type GraphRenderer interface {
4+
Render(data GraphData, filenamePrefix string) (string, error)
5+
}
6+
7+
type GraphData struct {
8+
Config LeagueConfig
9+
Players []GraphPlayer
10+
Matches []GraphMatch
11+
}
12+
13+
type GraphPlayer struct {
14+
Name string
15+
ELO int
16+
ELOHistory []int
17+
}
18+
19+
type GraphMatch struct {
20+
Results []GraphMatchResult
21+
}
22+
23+
type GraphMatchResult struct {
24+
Position int
25+
Name string
26+
}
27+
28+
var graphRenderer GraphRenderer
29+
30+
func SetGraphRenderer(r GraphRenderer) {
31+
graphRenderer = r
32+
}
33+
34+
func (l *League) GenerateGraph() (string, error) {
35+
return l.GenerateGraphWithFilename("elo")
36+
}
37+
38+
func (l *League) GenerateGraphWithFilename(filenamePrefix string) (string, error) {
39+
l.mu.RLock()
40+
if l.playersRepo.Count() == 0 {
41+
l.mu.RUnlock()
42+
return "", ErrNoPlayers
43+
}
44+
if l.matchesRepo.Count() == 0 {
45+
l.mu.RUnlock()
46+
return "", ELOError{Type: ErrorTypeValidation, Message: "no matches to plot"}
47+
}
48+
49+
data := GraphData{Config: l.config}
50+
51+
players := l.playersRepo.List()
52+
data.Players = make([]GraphPlayer, 0, len(players))
53+
for _, p := range players {
54+
data.Players = append(data.Players, GraphPlayer{
55+
Name: p.Name(),
56+
ELO: p.ELO(),
57+
ELOHistory: l.playerELOHistory[p.Name()],
58+
})
59+
}
60+
61+
matches := l.matchesRepo.List()
62+
data.Matches = make([]GraphMatch, 0, len(matches))
63+
for _, m := range matches {
64+
gm := GraphMatch{Results: make([]GraphMatchResult, 0, len(m.Results))}
65+
for _, r := range m.Results {
66+
gm.Results = append(gm.Results, GraphMatchResult{Position: r.Position, Name: r.Player.Name()})
67+
}
68+
data.Matches = append(data.Matches, gm)
69+
}
70+
l.mu.RUnlock()
71+
72+
if graphRenderer == nil {
73+
return "", ELOError{Type: ErrorTypeInternal, Message: "graph renderer not configured"}
74+
}
75+
return graphRenderer.Render(data, filenamePrefix)
76+
}

0 commit comments

Comments
 (0)