cricinfo-cli-go 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +63 -0
- package/CONTRIBUTORS.md +75 -0
- package/LICENSE +21 -0
- package/Makefile +131 -0
- package/README.md +130 -0
- package/bin/cricinfo.js +44 -0
- package/cmd/cricinfo/main.go +15 -0
- package/go.mod +10 -0
- package/go.sum +10 -0
- package/internal/app/app.go +11 -0
- package/internal/app/app_test.go +122 -0
- package/internal/buildinfo/buildinfo.go +16 -0
- package/internal/cli/analysis.go +262 -0
- package/internal/cli/analysis_test.go +175 -0
- package/internal/cli/competitions.go +154 -0
- package/internal/cli/competitions_test.go +165 -0
- package/internal/cli/leagues.go +297 -0
- package/internal/cli/leagues_test.go +194 -0
- package/internal/cli/matches.go +403 -0
- package/internal/cli/matches_test.go +413 -0
- package/internal/cli/players.go +263 -0
- package/internal/cli/players_test.go +384 -0
- package/internal/cli/root.go +141 -0
- package/internal/cli/search.go +119 -0
- package/internal/cli/teams.go +214 -0
- package/internal/cli/teams_test.go +192 -0
- package/internal/cricinfo/analysis.go +1401 -0
- package/internal/cricinfo/analysis_phase15_test.go +267 -0
- package/internal/cricinfo/client.go +471 -0
- package/internal/cricinfo/client_test.go +280 -0
- package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
- package/internal/cricinfo/competitions.go +405 -0
- package/internal/cricinfo/competitions_phase13_test.go +234 -0
- package/internal/cricinfo/coverage_ledger.go +122 -0
- package/internal/cricinfo/coverage_ledger_test.go +253 -0
- package/internal/cricinfo/decode.go +115 -0
- package/internal/cricinfo/decode_test.go +100 -0
- package/internal/cricinfo/entity_index.go +618 -0
- package/internal/cricinfo/entity_index_test.go +175 -0
- package/internal/cricinfo/fixture_matrix.go +243 -0
- package/internal/cricinfo/fixture_matrix_test.go +49 -0
- package/internal/cricinfo/fixtures_test.go +264 -0
- package/internal/cricinfo/historical_hydration.go +1641 -0
- package/internal/cricinfo/historical_phase14_test.go +542 -0
- package/internal/cricinfo/leagues.go +1210 -0
- package/internal/cricinfo/leagues_phase12_test.go +324 -0
- package/internal/cricinfo/live_leagues_test.go +169 -0
- package/internal/cricinfo/live_matches_test.go +203 -0
- package/internal/cricinfo/live_matrix_test.go +118 -0
- package/internal/cricinfo/live_players_test.go +122 -0
- package/internal/cricinfo/live_search_test.go +86 -0
- package/internal/cricinfo/live_smoke_test.go +213 -0
- package/internal/cricinfo/live_teams_test.go +104 -0
- package/internal/cricinfo/matches.go +1508 -0
- package/internal/cricinfo/matches_phase7_test.go +207 -0
- package/internal/cricinfo/matches_phase9_test.go +253 -0
- package/internal/cricinfo/normalize_entities.go +1727 -0
- package/internal/cricinfo/normalize_leagues.go +346 -0
- package/internal/cricinfo/players.go +1332 -0
- package/internal/cricinfo/players_phase10_test.go +174 -0
- package/internal/cricinfo/players_phase11_test.go +373 -0
- package/internal/cricinfo/render_contract.go +1088 -0
- package/internal/cricinfo/render_phase4_test.go +633 -0
- package/internal/cricinfo/renderer.go +1689 -0
- package/internal/cricinfo/resolver.go +813 -0
- package/internal/cricinfo/resolver_test.go +244 -0
- package/internal/cricinfo/teams.go +603 -0
- package/internal/cricinfo/teams_phase8_test.go +231 -0
- package/internal/cricinfo/testdata/fixtures/README.md +43 -0
- package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
- package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
- package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
- package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
- package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
- package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
- package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
- package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
- package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
- package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
- package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
- package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
- package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
- package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
- package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
- package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
- package/internal/cricinfo/types.go +54 -0
- package/package.json +51 -0
- package/scripts/postinstall.js +153 -0
|
@@ -0,0 +1,1401 @@
|
|
|
1
|
+
package cricinfo
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"sort"
|
|
8
|
+
"strconv"
|
|
9
|
+
"strings"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
const (
|
|
13
|
+
analysisScopeMatch = "match"
|
|
14
|
+
analysisScopeSeason = "season"
|
|
15
|
+
analysisScopeSeasons = "seasons"
|
|
16
|
+
|
|
17
|
+
analysisMetricEconomy = "economy"
|
|
18
|
+
analysisMetricDots = "dots"
|
|
19
|
+
analysisMetricSixesConceded = "sixes-conceded"
|
|
20
|
+
analysisMetricFours = "fours"
|
|
21
|
+
analysisMetricSixes = "sixes"
|
|
22
|
+
analysisMetricStrikeRate = "strike-rate"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
var (
|
|
26
|
+
analysisGroupDismissAllowed = map[string]struct{}{
|
|
27
|
+
"player": {},
|
|
28
|
+
"team": {},
|
|
29
|
+
"league": {},
|
|
30
|
+
"season": {},
|
|
31
|
+
"dismissal-type": {},
|
|
32
|
+
"innings": {},
|
|
33
|
+
}
|
|
34
|
+
analysisGroupBowlingAllowed = map[string]struct{}{
|
|
35
|
+
"player": {},
|
|
36
|
+
"team": {},
|
|
37
|
+
"league": {},
|
|
38
|
+
"season": {},
|
|
39
|
+
}
|
|
40
|
+
analysisGroupBattingAllowed = map[string]struct{}{
|
|
41
|
+
"player": {},
|
|
42
|
+
"team": {},
|
|
43
|
+
"league": {},
|
|
44
|
+
"season": {},
|
|
45
|
+
}
|
|
46
|
+
analysisGroupPartnershipAllowed = map[string]struct{}{
|
|
47
|
+
"team": {},
|
|
48
|
+
"league": {},
|
|
49
|
+
"season": {},
|
|
50
|
+
"innings": {},
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// AnalysisServiceConfig configures analysis command behavior.
|
|
55
|
+
type AnalysisServiceConfig struct {
|
|
56
|
+
Client *Client
|
|
57
|
+
Resolver *Resolver
|
|
58
|
+
Hydration *HistoricalHydrationService
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// AnalysisDismissalOptions configures dismissal analysis execution.
|
|
62
|
+
type AnalysisDismissalOptions struct {
|
|
63
|
+
LeagueQuery string
|
|
64
|
+
Seasons string
|
|
65
|
+
TypeQuery string
|
|
66
|
+
GroupQuery string
|
|
67
|
+
DateFrom string
|
|
68
|
+
DateTo string
|
|
69
|
+
MatchLimit int
|
|
70
|
+
GroupBy string
|
|
71
|
+
TeamQuery string
|
|
72
|
+
PlayerQuery string
|
|
73
|
+
DismissalType string
|
|
74
|
+
Innings int
|
|
75
|
+
Period int
|
|
76
|
+
Top int
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// AnalysisMetricOptions configures bowling/batting/partnership analysis execution.
|
|
80
|
+
type AnalysisMetricOptions struct {
|
|
81
|
+
Metric string
|
|
82
|
+
Scope string
|
|
83
|
+
LeagueQuery string
|
|
84
|
+
TypeQuery string
|
|
85
|
+
GroupQuery string
|
|
86
|
+
DateFrom string
|
|
87
|
+
DateTo string
|
|
88
|
+
MatchLimit int
|
|
89
|
+
GroupBy string
|
|
90
|
+
TeamQuery string
|
|
91
|
+
PlayerQuery string
|
|
92
|
+
DismissalType string
|
|
93
|
+
Innings int
|
|
94
|
+
Period int
|
|
95
|
+
Top int
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// AnalysisService derives ranked cricket analysis over normalized hydrated data.
|
|
99
|
+
type AnalysisService struct {
|
|
100
|
+
client *Client
|
|
101
|
+
resolver *Resolver
|
|
102
|
+
hydration *HistoricalHydrationService
|
|
103
|
+
ownsResolver bool
|
|
104
|
+
ownsHydration bool
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// NewAnalysisService builds an analysis service using default client/resolver/hydration when omitted.
|
|
108
|
+
func NewAnalysisService(cfg AnalysisServiceConfig) (*AnalysisService, error) {
|
|
109
|
+
client := cfg.Client
|
|
110
|
+
if client == nil {
|
|
111
|
+
var err error
|
|
112
|
+
client, err = NewClient(Config{})
|
|
113
|
+
if err != nil {
|
|
114
|
+
return nil, err
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
resolver := cfg.Resolver
|
|
119
|
+
ownsResolver := false
|
|
120
|
+
if resolver == nil {
|
|
121
|
+
var err error
|
|
122
|
+
resolver, err = NewResolver(ResolverConfig{Client: client})
|
|
123
|
+
if err != nil {
|
|
124
|
+
return nil, err
|
|
125
|
+
}
|
|
126
|
+
ownsResolver = true
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
hydration := cfg.Hydration
|
|
130
|
+
ownsHydration := false
|
|
131
|
+
if hydration == nil {
|
|
132
|
+
var err error
|
|
133
|
+
hydration, err = NewHistoricalHydrationService(HistoricalHydrationServiceConfig{
|
|
134
|
+
Client: client,
|
|
135
|
+
Resolver: resolver,
|
|
136
|
+
})
|
|
137
|
+
if err != nil {
|
|
138
|
+
if ownsResolver {
|
|
139
|
+
_ = resolver.Close()
|
|
140
|
+
}
|
|
141
|
+
return nil, err
|
|
142
|
+
}
|
|
143
|
+
ownsHydration = true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return &AnalysisService{
|
|
147
|
+
client: client,
|
|
148
|
+
resolver: resolver,
|
|
149
|
+
hydration: hydration,
|
|
150
|
+
ownsResolver: ownsResolver,
|
|
151
|
+
ownsHydration: ownsHydration,
|
|
152
|
+
}, nil
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Close persists resolver state when owned by this service.
|
|
156
|
+
func (s *AnalysisService) Close() error {
|
|
157
|
+
var errs []error
|
|
158
|
+
if s.ownsHydration && s.hydration != nil {
|
|
159
|
+
if err := s.hydration.Close(); err != nil {
|
|
160
|
+
errs = append(errs, err)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if s.ownsResolver && s.resolver != nil {
|
|
164
|
+
if err := s.resolver.Close(); err != nil {
|
|
165
|
+
errs = append(errs, err)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if len(errs) == 0 {
|
|
169
|
+
return nil
|
|
170
|
+
}
|
|
171
|
+
return errors.Join(errs...)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Dismissals ranks dismissal patterns across league+season scope.
|
|
175
|
+
func (s *AnalysisService) Dismissals(ctx context.Context, opts AnalysisDismissalOptions) (NormalizedResult, error) {
|
|
176
|
+
leagueQuery := strings.TrimSpace(opts.LeagueQuery)
|
|
177
|
+
if leagueQuery == "" {
|
|
178
|
+
return NormalizedResult{
|
|
179
|
+
Kind: EntityAnalysisDismiss,
|
|
180
|
+
Status: ResultStatusEmpty,
|
|
181
|
+
Message: "--league is required",
|
|
182
|
+
}, nil
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
seasons, err := parseSeasonRange(opts.Seasons)
|
|
186
|
+
if err != nil {
|
|
187
|
+
return NormalizedResult{
|
|
188
|
+
Kind: EntityAnalysisDismiss,
|
|
189
|
+
Status: ResultStatusEmpty,
|
|
190
|
+
Message: err.Error(),
|
|
191
|
+
}, nil
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
groupBy, err := parseGroupBy(opts.GroupBy, []string{"dismissal-type"}, analysisGroupDismissAllowed)
|
|
195
|
+
if err != nil {
|
|
196
|
+
return NormalizedResult{Kind: EntityAnalysisDismiss, Status: ResultStatusEmpty, Message: err.Error()}, nil
|
|
197
|
+
}
|
|
198
|
+
filters := analysisFiltersFromDismissal(opts)
|
|
199
|
+
top := limitOrDefault(opts.Top, 20)
|
|
200
|
+
|
|
201
|
+
agg := map[string]*analysisAggregate{}
|
|
202
|
+
warnings := make([]string, 0)
|
|
203
|
+
combinedMatchIDs := make([]string, 0)
|
|
204
|
+
combinedMetrics := HydrationMetrics{}
|
|
205
|
+
leagueName := ""
|
|
206
|
+
|
|
207
|
+
for _, seasonQuery := range seasons {
|
|
208
|
+
session, scopeSummary, beginWarnings, passthrough := s.beginSeasonScope(ctx, analysisSeasonScopeRequest{
|
|
209
|
+
LeagueQuery: leagueQuery,
|
|
210
|
+
SeasonQuery: seasonQuery,
|
|
211
|
+
TypeQuery: opts.TypeQuery,
|
|
212
|
+
GroupQuery: opts.GroupQuery,
|
|
213
|
+
DateFrom: opts.DateFrom,
|
|
214
|
+
DateTo: opts.DateTo,
|
|
215
|
+
MatchLimit: opts.MatchLimit,
|
|
216
|
+
}, EntityAnalysisDismiss)
|
|
217
|
+
if passthrough != nil {
|
|
218
|
+
return *passthrough, nil
|
|
219
|
+
}
|
|
220
|
+
warnings = append(warnings, beginWarnings...)
|
|
221
|
+
|
|
222
|
+
scope := scopeSummary
|
|
223
|
+
if strings.TrimSpace(leagueName) == "" {
|
|
224
|
+
leagueName = strings.TrimSpace(scope.League.Name)
|
|
225
|
+
}
|
|
226
|
+
combinedMatchIDs = append(combinedMatchIDs, scope.MatchIDs...)
|
|
227
|
+
metrics := session.Metrics()
|
|
228
|
+
combinedMetrics.ResolveCacheHits += metrics.ResolveCacheHits
|
|
229
|
+
combinedMetrics.ResolveCacheMisses += metrics.ResolveCacheMisses
|
|
230
|
+
combinedMetrics.DomainCacheHits += metrics.DomainCacheHits
|
|
231
|
+
combinedMetrics.DomainCacheMisses += metrics.DomainCacheMisses
|
|
232
|
+
|
|
233
|
+
seasonID := seasonIdentifier(scope, seasonQuery)
|
|
234
|
+
matches := session.ScopedMatches()
|
|
235
|
+
for _, match := range matches {
|
|
236
|
+
playerByID, playerWarnings := s.playerNameMapForMatch(ctx, session, match.ID)
|
|
237
|
+
warnings = append(warnings, playerWarnings...)
|
|
238
|
+
|
|
239
|
+
innings, inningsWarnings, hydrateErr := session.HydrateInnings(ctx, match.ID)
|
|
240
|
+
if hydrateErr != nil {
|
|
241
|
+
if statusErr := analysisTransportResult(EntityAnalysisDismiss, match.ID, hydrateErr); statusErr != nil {
|
|
242
|
+
return *statusErr, nil
|
|
243
|
+
}
|
|
244
|
+
warnings = append(warnings, fmt.Sprintf("match %s innings: %v", match.ID, hydrateErr))
|
|
245
|
+
continue
|
|
246
|
+
}
|
|
247
|
+
warnings = append(warnings, inningsWarnings...)
|
|
248
|
+
|
|
249
|
+
for _, inn := range innings {
|
|
250
|
+
for _, wicket := range inn.WicketTimeline {
|
|
251
|
+
dismissalType := firstNonEmptyString(wicket.FOWType, wicket.DismissalCard)
|
|
252
|
+
if dismissalType == "" {
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
playerID := strings.TrimSpace(refIDs(wicket.AthleteRef)["athleteId"])
|
|
256
|
+
row := analysisSourceRow{
|
|
257
|
+
MatchID: strings.TrimSpace(match.ID),
|
|
258
|
+
LeagueID: strings.TrimSpace(match.LeagueID),
|
|
259
|
+
SeasonID: seasonID,
|
|
260
|
+
TeamID: strings.TrimSpace(inn.TeamID),
|
|
261
|
+
TeamName: strings.TrimSpace(inn.TeamName),
|
|
262
|
+
PlayerID: playerID,
|
|
263
|
+
PlayerName: strings.TrimSpace(playerByID[playerID]),
|
|
264
|
+
DismissalType: dismissalType,
|
|
265
|
+
InningsNumber: inn.InningsNumber,
|
|
266
|
+
Period: inn.Period,
|
|
267
|
+
CountValue: 1,
|
|
268
|
+
}
|
|
269
|
+
if row.PlayerName == "" {
|
|
270
|
+
row.PlayerName = row.PlayerID
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if !filters.matches(row) {
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
key, dims := buildAnalysisGroup(row, groupBy)
|
|
277
|
+
entry := agg[key]
|
|
278
|
+
if entry == nil {
|
|
279
|
+
entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
|
|
280
|
+
agg[key] = entry
|
|
281
|
+
}
|
|
282
|
+
entry.count += row.CountValue
|
|
283
|
+
entry.matchIDs[row.MatchID] = struct{}{}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
rows := make([]AnalysisRow, 0, len(agg))
|
|
290
|
+
for key, entry := range agg {
|
|
291
|
+
row := entry.row
|
|
292
|
+
row.Key = key
|
|
293
|
+
row.Metric = "dismissals"
|
|
294
|
+
row.Value = float64(entry.count)
|
|
295
|
+
row.Count = entry.count
|
|
296
|
+
row.Matches = len(entry.matchIDs)
|
|
297
|
+
rows = append(rows, row)
|
|
298
|
+
}
|
|
299
|
+
rows = rankAnalysisRows(rows, false)
|
|
300
|
+
rows = trimAnalysisRows(rows, top)
|
|
301
|
+
|
|
302
|
+
view := AnalysisView{
|
|
303
|
+
Command: "dismissals",
|
|
304
|
+
Metric: "dismissals",
|
|
305
|
+
Scope: AnalysisScope{
|
|
306
|
+
Mode: analysisScopeSeasons,
|
|
307
|
+
LeagueID: leagueQuery,
|
|
308
|
+
LeagueName: leagueName,
|
|
309
|
+
Seasons: seasons,
|
|
310
|
+
MatchIDs: dedupeStrings(combinedMatchIDs),
|
|
311
|
+
MatchCount: len(dedupeStrings(combinedMatchIDs)),
|
|
312
|
+
DateFrom: strings.TrimSpace(opts.DateFrom),
|
|
313
|
+
DateTo: strings.TrimSpace(opts.DateTo),
|
|
314
|
+
TypeQuery: strings.TrimSpace(opts.TypeQuery),
|
|
315
|
+
GroupQuery: strings.TrimSpace(opts.GroupQuery),
|
|
316
|
+
HydrationMetric: combinedMetrics,
|
|
317
|
+
},
|
|
318
|
+
GroupBy: groupBy,
|
|
319
|
+
Filters: AnalysisFilters{
|
|
320
|
+
TeamQuery: strings.TrimSpace(opts.TeamQuery),
|
|
321
|
+
PlayerQuery: strings.TrimSpace(opts.PlayerQuery),
|
|
322
|
+
DismissalType: strings.TrimSpace(opts.DismissalType),
|
|
323
|
+
Innings: opts.Innings,
|
|
324
|
+
Period: opts.Period,
|
|
325
|
+
},
|
|
326
|
+
Rows: rows,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return analysisResult(EntityAnalysisDismiss, view, warnings), nil
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Bowling ranks bowling metrics over match or season scope.
|
|
333
|
+
func (s *AnalysisService) Bowling(ctx context.Context, opts AnalysisMetricOptions) (NormalizedResult, error) {
|
|
334
|
+
metric, err := normalizeBowlingMetric(opts.Metric)
|
|
335
|
+
if err != nil {
|
|
336
|
+
return NormalizedResult{Kind: EntityAnalysisBowl, Status: ResultStatusEmpty, Message: err.Error()}, nil
|
|
337
|
+
}
|
|
338
|
+
groupBy, err := parseGroupBy(opts.GroupBy, []string{"player"}, analysisGroupBowlingAllowed)
|
|
339
|
+
if err != nil {
|
|
340
|
+
return NormalizedResult{Kind: EntityAnalysisBowl, Status: ResultStatusEmpty, Message: err.Error()}, nil
|
|
341
|
+
}
|
|
342
|
+
filters := analysisFiltersFromMetric(opts)
|
|
343
|
+
top := limitOrDefault(opts.Top, 20)
|
|
344
|
+
|
|
345
|
+
run, passthrough := s.resolveMetricScope(ctx, opts, EntityAnalysisBowl)
|
|
346
|
+
if passthrough != nil {
|
|
347
|
+
return *passthrough, nil
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
agg := map[string]*analysisAggregate{}
|
|
351
|
+
warnings := append([]string{}, run.warnings...)
|
|
352
|
+
for _, match := range run.session.ScopedMatches() {
|
|
353
|
+
seasonID := seasonForMatch(match, run.seasonHint)
|
|
354
|
+
players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
|
|
355
|
+
if hydrateErr != nil {
|
|
356
|
+
if statusErr := analysisTransportResult(EntityAnalysisBowl, match.ID, hydrateErr); statusErr != nil {
|
|
357
|
+
return *statusErr, nil
|
|
358
|
+
}
|
|
359
|
+
warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
|
|
360
|
+
continue
|
|
361
|
+
}
|
|
362
|
+
warnings = append(warnings, playerWarnings...)
|
|
363
|
+
|
|
364
|
+
for _, player := range players {
|
|
365
|
+
totals := extractBowlingTotals(player)
|
|
366
|
+
playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
|
|
367
|
+
teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
|
|
368
|
+
row := analysisSourceRow{
|
|
369
|
+
MatchID: strings.TrimSpace(player.MatchID),
|
|
370
|
+
LeagueID: strings.TrimSpace(player.LeagueID),
|
|
371
|
+
SeasonID: seasonID,
|
|
372
|
+
TeamID: strings.TrimSpace(player.TeamID),
|
|
373
|
+
TeamName: strings.TrimSpace(teamName),
|
|
374
|
+
PlayerID: strings.TrimSpace(player.PlayerID),
|
|
375
|
+
PlayerName: strings.TrimSpace(playerName),
|
|
376
|
+
CountValue: 1,
|
|
377
|
+
Dots: totals.dots,
|
|
378
|
+
SixesConceded: totals.sixesConceded,
|
|
379
|
+
Balls: totals.balls,
|
|
380
|
+
RunsConceded: totals.conceded,
|
|
381
|
+
EconomySample: totals.economy,
|
|
382
|
+
}
|
|
383
|
+
if !filters.matches(row) {
|
|
384
|
+
continue
|
|
385
|
+
}
|
|
386
|
+
key, dims := buildAnalysisGroup(row, groupBy)
|
|
387
|
+
entry := agg[key]
|
|
388
|
+
if entry == nil {
|
|
389
|
+
entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
|
|
390
|
+
agg[key] = entry
|
|
391
|
+
}
|
|
392
|
+
entry.matchIDs[row.MatchID] = struct{}{}
|
|
393
|
+
entry.dots += row.Dots
|
|
394
|
+
entry.sixesConceded += row.SixesConceded
|
|
395
|
+
entry.balls += row.Balls
|
|
396
|
+
entry.runsConceded += row.RunsConceded
|
|
397
|
+
if row.EconomySample > 0 {
|
|
398
|
+
entry.economyTotal += row.EconomySample
|
|
399
|
+
entry.economyCount++
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
rows := make([]AnalysisRow, 0, len(agg))
|
|
405
|
+
for key, entry := range agg {
|
|
406
|
+
if entry == nil {
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
if !hasBowlingActivity(entry) {
|
|
410
|
+
continue
|
|
411
|
+
}
|
|
412
|
+
row := entry.row
|
|
413
|
+
row.Key = key
|
|
414
|
+
row.Metric = metric
|
|
415
|
+
row.Matches = len(entry.matchIDs)
|
|
416
|
+
|
|
417
|
+
switch metric {
|
|
418
|
+
case analysisMetricEconomy:
|
|
419
|
+
row.Value = economyFromAggregate(entry)
|
|
420
|
+
row.Extras = map[string]any{
|
|
421
|
+
"runsConceded": entry.runsConceded,
|
|
422
|
+
"balls": entry.balls,
|
|
423
|
+
"dots": entry.dots,
|
|
424
|
+
"sixesConceded": entry.sixesConceded,
|
|
425
|
+
}
|
|
426
|
+
case analysisMetricDots:
|
|
427
|
+
row.Value = float64(entry.dots)
|
|
428
|
+
row.Count = entry.dots
|
|
429
|
+
case analysisMetricSixesConceded:
|
|
430
|
+
row.Value = float64(entry.sixesConceded)
|
|
431
|
+
row.Count = entry.sixesConceded
|
|
432
|
+
}
|
|
433
|
+
rows = append(rows, row)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
rows = rankAnalysisRows(rows, metric == analysisMetricEconomy)
|
|
437
|
+
rows = trimAnalysisRows(rows, top)
|
|
438
|
+
|
|
439
|
+
view := AnalysisView{
|
|
440
|
+
Command: "bowling",
|
|
441
|
+
Metric: metric,
|
|
442
|
+
Scope: buildSingleScope(run, opts),
|
|
443
|
+
GroupBy: groupBy,
|
|
444
|
+
Filters: AnalysisFilters{TeamQuery: strings.TrimSpace(opts.TeamQuery), PlayerQuery: strings.TrimSpace(opts.PlayerQuery)},
|
|
445
|
+
Rows: rows,
|
|
446
|
+
}
|
|
447
|
+
return analysisResult(EntityAnalysisBowl, view, warnings), nil
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Batting ranks batting metrics over match or season scope.
|
|
451
|
+
func (s *AnalysisService) Batting(ctx context.Context, opts AnalysisMetricOptions) (NormalizedResult, error) {
|
|
452
|
+
metric, err := normalizeBattingMetric(opts.Metric)
|
|
453
|
+
if err != nil {
|
|
454
|
+
return NormalizedResult{Kind: EntityAnalysisBat, Status: ResultStatusEmpty, Message: err.Error()}, nil
|
|
455
|
+
}
|
|
456
|
+
groupBy, err := parseGroupBy(opts.GroupBy, []string{"player"}, analysisGroupBattingAllowed)
|
|
457
|
+
if err != nil {
|
|
458
|
+
return NormalizedResult{Kind: EntityAnalysisBat, Status: ResultStatusEmpty, Message: err.Error()}, nil
|
|
459
|
+
}
|
|
460
|
+
filters := analysisFiltersFromMetric(opts)
|
|
461
|
+
top := limitOrDefault(opts.Top, 20)
|
|
462
|
+
|
|
463
|
+
run, passthrough := s.resolveMetricScope(ctx, opts, EntityAnalysisBat)
|
|
464
|
+
if passthrough != nil {
|
|
465
|
+
return *passthrough, nil
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
agg := map[string]*analysisAggregate{}
|
|
469
|
+
warnings := append([]string{}, run.warnings...)
|
|
470
|
+
for _, match := range run.session.ScopedMatches() {
|
|
471
|
+
seasonID := seasonForMatch(match, run.seasonHint)
|
|
472
|
+
players, playerWarnings, hydrateErr := run.session.HydratePlayerMatchSummaries(ctx, match.ID)
|
|
473
|
+
if hydrateErr != nil {
|
|
474
|
+
if statusErr := analysisTransportResult(EntityAnalysisBat, match.ID, hydrateErr); statusErr != nil {
|
|
475
|
+
return *statusErr, nil
|
|
476
|
+
}
|
|
477
|
+
warnings = append(warnings, fmt.Sprintf("match %s player summary: %v", match.ID, hydrateErr))
|
|
478
|
+
continue
|
|
479
|
+
}
|
|
480
|
+
warnings = append(warnings, playerWarnings...)
|
|
481
|
+
|
|
482
|
+
for _, player := range players {
|
|
483
|
+
totals := extractBattingTotals(player)
|
|
484
|
+
playerName := analysisDisplayPlayerName(s.resolver, player.PlayerID, player.PlayerName)
|
|
485
|
+
teamName := analysisDisplayTeamName(s.resolver, player.TeamID, player.TeamName)
|
|
486
|
+
row := analysisSourceRow{
|
|
487
|
+
MatchID: strings.TrimSpace(player.MatchID),
|
|
488
|
+
LeagueID: strings.TrimSpace(player.LeagueID),
|
|
489
|
+
SeasonID: seasonID,
|
|
490
|
+
TeamID: strings.TrimSpace(player.TeamID),
|
|
491
|
+
TeamName: strings.TrimSpace(teamName),
|
|
492
|
+
PlayerID: strings.TrimSpace(player.PlayerID),
|
|
493
|
+
PlayerName: strings.TrimSpace(playerName),
|
|
494
|
+
CountValue: 1,
|
|
495
|
+
Fours: totals.fours,
|
|
496
|
+
BattingSixes: totals.sixes,
|
|
497
|
+
RunsScored: totals.runs,
|
|
498
|
+
BallsFaced: totals.balls,
|
|
499
|
+
StrikeSample: totals.strikeRate,
|
|
500
|
+
}
|
|
501
|
+
if !filters.matches(row) {
|
|
502
|
+
continue
|
|
503
|
+
}
|
|
504
|
+
key, dims := buildAnalysisGroup(row, groupBy)
|
|
505
|
+
entry := agg[key]
|
|
506
|
+
if entry == nil {
|
|
507
|
+
entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
|
|
508
|
+
agg[key] = entry
|
|
509
|
+
}
|
|
510
|
+
entry.matchIDs[row.MatchID] = struct{}{}
|
|
511
|
+
entry.fours += row.Fours
|
|
512
|
+
entry.battingSixes += row.BattingSixes
|
|
513
|
+
entry.runsScored += row.RunsScored
|
|
514
|
+
entry.ballsFaced += row.BallsFaced
|
|
515
|
+
if row.StrikeSample > 0 {
|
|
516
|
+
entry.strikeRateTotal += row.StrikeSample
|
|
517
|
+
entry.strikeRateCount++
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
rows := make([]AnalysisRow, 0, len(agg))
|
|
523
|
+
for key, entry := range agg {
|
|
524
|
+
row := entry.row
|
|
525
|
+
row.Key = key
|
|
526
|
+
row.Metric = metric
|
|
527
|
+
row.Matches = len(entry.matchIDs)
|
|
528
|
+
|
|
529
|
+
switch metric {
|
|
530
|
+
case analysisMetricFours:
|
|
531
|
+
row.Value = float64(entry.fours)
|
|
532
|
+
row.Count = entry.fours
|
|
533
|
+
case analysisMetricSixes:
|
|
534
|
+
row.Value = float64(entry.battingSixes)
|
|
535
|
+
row.Count = entry.battingSixes
|
|
536
|
+
case analysisMetricStrikeRate:
|
|
537
|
+
row.Value = strikeRateFromAggregate(entry)
|
|
538
|
+
}
|
|
539
|
+
row.Extras = map[string]any{
|
|
540
|
+
"runs": entry.runsScored,
|
|
541
|
+
"ballsFaced": entry.ballsFaced,
|
|
542
|
+
"fours": entry.fours,
|
|
543
|
+
"sixes": entry.battingSixes,
|
|
544
|
+
}
|
|
545
|
+
rows = append(rows, row)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
rows = rankAnalysisRows(rows, false)
|
|
549
|
+
rows = trimAnalysisRows(rows, top)
|
|
550
|
+
|
|
551
|
+
view := AnalysisView{
|
|
552
|
+
Command: "batting",
|
|
553
|
+
Metric: metric,
|
|
554
|
+
Scope: buildSingleScope(run, opts),
|
|
555
|
+
GroupBy: groupBy,
|
|
556
|
+
Filters: AnalysisFilters{TeamQuery: strings.TrimSpace(opts.TeamQuery), PlayerQuery: strings.TrimSpace(opts.PlayerQuery)},
|
|
557
|
+
Rows: rows,
|
|
558
|
+
}
|
|
559
|
+
return analysisResult(EntityAnalysisBat, view, warnings), nil
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Partnerships ranks partnerships over match or season scope.
|
|
563
|
+
func (s *AnalysisService) Partnerships(ctx context.Context, opts AnalysisMetricOptions) (NormalizedResult, error) {
|
|
564
|
+
groupBy, err := parseGroupBy(opts.GroupBy, []string{"innings"}, analysisGroupPartnershipAllowed)
|
|
565
|
+
if err != nil {
|
|
566
|
+
return NormalizedResult{Kind: EntityAnalysisPart, Status: ResultStatusEmpty, Message: err.Error()}, nil
|
|
567
|
+
}
|
|
568
|
+
filters := analysisFiltersFromMetric(opts)
|
|
569
|
+
top := limitOrDefault(opts.Top, 20)
|
|
570
|
+
|
|
571
|
+
run, passthrough := s.resolveMetricScope(ctx, opts, EntityAnalysisPart)
|
|
572
|
+
if passthrough != nil {
|
|
573
|
+
return *passthrough, nil
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
agg := map[string]*analysisAggregate{}
|
|
577
|
+
warnings := append([]string{}, run.warnings...)
|
|
578
|
+
for _, match := range run.session.ScopedMatches() {
|
|
579
|
+
seasonID := seasonForMatch(match, run.seasonHint)
|
|
580
|
+
partnerships, partnershipWarnings, hydrateErr := run.session.HydratePartnershipSummaries(ctx, match.ID)
|
|
581
|
+
if hydrateErr != nil {
|
|
582
|
+
if statusErr := analysisTransportResult(EntityAnalysisPart, match.ID, hydrateErr); statusErr != nil {
|
|
583
|
+
return *statusErr, nil
|
|
584
|
+
}
|
|
585
|
+
warnings = append(warnings, fmt.Sprintf("match %s partnerships: %v", match.ID, hydrateErr))
|
|
586
|
+
continue
|
|
587
|
+
}
|
|
588
|
+
warnings = append(warnings, partnershipWarnings...)
|
|
589
|
+
|
|
590
|
+
for _, partnership := range partnerships {
|
|
591
|
+
inningsNumber := parseInt(partnership.InningsID)
|
|
592
|
+
period := parseInt(partnership.Period)
|
|
593
|
+
row := analysisSourceRow{
|
|
594
|
+
MatchID: strings.TrimSpace(partnership.MatchID),
|
|
595
|
+
LeagueID: strings.TrimSpace(match.LeagueID),
|
|
596
|
+
SeasonID: seasonID,
|
|
597
|
+
TeamID: strings.TrimSpace(partnership.TeamID),
|
|
598
|
+
TeamName: strings.TrimSpace(partnership.TeamName),
|
|
599
|
+
InningsNumber: inningsNumber,
|
|
600
|
+
Period: period,
|
|
601
|
+
RunsScored: partnership.Runs,
|
|
602
|
+
CountValue: 1,
|
|
603
|
+
}
|
|
604
|
+
if !filters.matchesPartnership(row) {
|
|
605
|
+
continue
|
|
606
|
+
}
|
|
607
|
+
key, dims := buildAnalysisGroup(row, groupBy)
|
|
608
|
+
entry := agg[key]
|
|
609
|
+
if entry == nil {
|
|
610
|
+
entry = &analysisAggregate{row: dims, matchIDs: map[string]struct{}{}}
|
|
611
|
+
agg[key] = entry
|
|
612
|
+
}
|
|
613
|
+
entry.matchIDs[row.MatchID] = struct{}{}
|
|
614
|
+
entry.runsScored += row.RunsScored
|
|
615
|
+
entry.count += row.CountValue
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
rows := make([]AnalysisRow, 0, len(agg))
|
|
620
|
+
for key, entry := range agg {
|
|
621
|
+
row := entry.row
|
|
622
|
+
row.Key = key
|
|
623
|
+
row.Metric = "partnership-runs"
|
|
624
|
+
row.Value = float64(entry.runsScored)
|
|
625
|
+
row.Count = entry.count
|
|
626
|
+
row.Matches = len(entry.matchIDs)
|
|
627
|
+
rows = append(rows, row)
|
|
628
|
+
}
|
|
629
|
+
rows = rankAnalysisRows(rows, false)
|
|
630
|
+
rows = trimAnalysisRows(rows, top)
|
|
631
|
+
|
|
632
|
+
view := AnalysisView{
|
|
633
|
+
Command: "partnerships",
|
|
634
|
+
Metric: "partnership-runs",
|
|
635
|
+
Scope: buildSingleScope(run, opts),
|
|
636
|
+
GroupBy: groupBy,
|
|
637
|
+
Filters: AnalysisFilters{TeamQuery: strings.TrimSpace(opts.TeamQuery), Innings: opts.Innings},
|
|
638
|
+
Rows: rows,
|
|
639
|
+
}
|
|
640
|
+
return analysisResult(EntityAnalysisPart, view, warnings), nil
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
type analysisSeasonScopeRequest struct {
|
|
644
|
+
LeagueQuery string
|
|
645
|
+
SeasonQuery string
|
|
646
|
+
TypeQuery string
|
|
647
|
+
GroupQuery string
|
|
648
|
+
DateFrom string
|
|
649
|
+
DateTo string
|
|
650
|
+
MatchLimit int
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
type analysisScopeRun struct {
|
|
654
|
+
session *HistoricalScopeSession
|
|
655
|
+
scope HistoricalScopeSummary
|
|
656
|
+
mode string
|
|
657
|
+
seasonHint string
|
|
658
|
+
warnings []string
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
func (s *AnalysisService) beginSeasonScope(
|
|
662
|
+
ctx context.Context,
|
|
663
|
+
req analysisSeasonScopeRequest,
|
|
664
|
+
kind EntityKind,
|
|
665
|
+
) (*HistoricalScopeSession, HistoricalScopeSummary, []string, *NormalizedResult) {
|
|
666
|
+
session, err := s.hydration.BeginScope(ctx, HistoricalScopeOptions{
|
|
667
|
+
LeagueQuery: strings.TrimSpace(req.LeagueQuery),
|
|
668
|
+
SeasonQuery: strings.TrimSpace(req.SeasonQuery),
|
|
669
|
+
TypeQuery: strings.TrimSpace(req.TypeQuery),
|
|
670
|
+
GroupQuery: strings.TrimSpace(req.GroupQuery),
|
|
671
|
+
DateFrom: strings.TrimSpace(req.DateFrom),
|
|
672
|
+
DateTo: strings.TrimSpace(req.DateTo),
|
|
673
|
+
MatchLimit: req.MatchLimit,
|
|
674
|
+
})
|
|
675
|
+
if err != nil {
|
|
676
|
+
if transport := analysisTransportResult(kind, req.LeagueQuery, err); transport != nil {
|
|
677
|
+
return nil, HistoricalScopeSummary{}, nil, transport
|
|
678
|
+
}
|
|
679
|
+
result := NormalizedResult{Kind: kind, Status: ResultStatusError, Message: err.Error()}
|
|
680
|
+
return nil, HistoricalScopeSummary{}, nil, &result
|
|
681
|
+
}
|
|
682
|
+
scope := session.Scope()
|
|
683
|
+
return session, scope, scope.Warnings, nil
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
func (s *AnalysisService) resolveMetricScope(ctx context.Context, opts AnalysisMetricOptions, kind EntityKind) (*analysisScopeRun, *NormalizedResult) {
|
|
687
|
+
scopeMode, scopeQuery, err := parseMetricScope(opts.Scope)
|
|
688
|
+
if err != nil {
|
|
689
|
+
result := NormalizedResult{Kind: kind, Status: ResultStatusEmpty, Message: err.Error()}
|
|
690
|
+
return nil, &result
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
switch scopeMode {
|
|
694
|
+
case analysisScopeSeason:
|
|
695
|
+
if strings.TrimSpace(opts.LeagueQuery) == "" {
|
|
696
|
+
result := NormalizedResult{Kind: kind, Status: ResultStatusEmpty, Message: "--league is required for season scope"}
|
|
697
|
+
return nil, &result
|
|
698
|
+
}
|
|
699
|
+
session, scope, warnings, passthrough := s.beginSeasonScope(ctx, analysisSeasonScopeRequest{
|
|
700
|
+
LeagueQuery: opts.LeagueQuery,
|
|
701
|
+
SeasonQuery: scopeQuery,
|
|
702
|
+
TypeQuery: opts.TypeQuery,
|
|
703
|
+
GroupQuery: opts.GroupQuery,
|
|
704
|
+
DateFrom: opts.DateFrom,
|
|
705
|
+
DateTo: opts.DateTo,
|
|
706
|
+
MatchLimit: opts.MatchLimit,
|
|
707
|
+
}, kind)
|
|
708
|
+
if passthrough != nil {
|
|
709
|
+
return nil, passthrough
|
|
710
|
+
}
|
|
711
|
+
return &analysisScopeRun{session: session, scope: scope, mode: analysisScopeSeason, seasonHint: seasonIdentifier(scope, scopeQuery), warnings: warnings}, nil
|
|
712
|
+
case analysisScopeMatch:
|
|
713
|
+
match, warnings, err := s.resolveMatchByQuery(ctx, scopeQuery, opts.LeagueQuery)
|
|
714
|
+
if err != nil {
|
|
715
|
+
if transport := analysisTransportResult(kind, scopeQuery, err); transport != nil {
|
|
716
|
+
return nil, transport
|
|
717
|
+
}
|
|
718
|
+
result := NormalizedResult{Kind: kind, Status: ResultStatusError, Message: err.Error()}
|
|
719
|
+
return nil, &result
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
session := newHistoricalScopeSession(s.client, s.resolver, HistoricalScopeOptions{
|
|
723
|
+
LeagueQuery: strings.TrimSpace(nonEmpty(match.LeagueID, opts.LeagueQuery)),
|
|
724
|
+
})
|
|
725
|
+
session.matches = []Match{*match}
|
|
726
|
+
session.league = League{ID: strings.TrimSpace(nonEmpty(match.LeagueID, opts.LeagueQuery))}
|
|
727
|
+
session.warnings = compactWarnings(warnings)
|
|
728
|
+
scope := session.Scope()
|
|
729
|
+
return &analysisScopeRun{session: session, scope: scope, mode: analysisScopeMatch, seasonHint: seasonForMatch(*match, ""), warnings: warnings}, nil
|
|
730
|
+
default:
|
|
731
|
+
result := NormalizedResult{Kind: kind, Status: ResultStatusEmpty, Message: "unsupported scope"}
|
|
732
|
+
return nil, &result
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
func (s *AnalysisService) resolveMatchByQuery(ctx context.Context, query, leagueHint string) (*Match, []string, error) {
|
|
737
|
+
query = strings.TrimSpace(query)
|
|
738
|
+
if query == "" {
|
|
739
|
+
return nil, nil, fmt.Errorf("match scope query is required")
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
searchResult, err := s.resolver.Search(ctx, EntityMatch, query, ResolveOptions{
|
|
743
|
+
Limit: 5,
|
|
744
|
+
LeagueID: strings.TrimSpace(leagueHint),
|
|
745
|
+
})
|
|
746
|
+
if err != nil {
|
|
747
|
+
return nil, nil, err
|
|
748
|
+
}
|
|
749
|
+
if len(searchResult.Entities) == 0 {
|
|
750
|
+
return nil, searchResult.Warnings, fmt.Errorf("no matches found for %q", query)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
entity := searchResult.Entities[0]
|
|
754
|
+
ref := buildMatchRef(entity)
|
|
755
|
+
if strings.TrimSpace(ref) == "" {
|
|
756
|
+
return nil, searchResult.Warnings, fmt.Errorf("unable to resolve match ref for %q", query)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
760
|
+
if err != nil {
|
|
761
|
+
return nil, searchResult.Warnings, err
|
|
762
|
+
}
|
|
763
|
+
match, err := NormalizeMatch(resolved.Body)
|
|
764
|
+
if err != nil {
|
|
765
|
+
return nil, searchResult.Warnings, fmt.Errorf("normalize match %q: %w", resolved.CanonicalRef, err)
|
|
766
|
+
}
|
|
767
|
+
return match, searchResult.Warnings, nil
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
func (s *AnalysisService) playerNameMapForMatch(ctx context.Context, session *HistoricalScopeSession, matchID string) (map[string]string, []string) {
|
|
771
|
+
players, warnings, err := session.HydratePlayerMatchSummaries(ctx, matchID)
|
|
772
|
+
if err != nil {
|
|
773
|
+
return map[string]string{}, []string{fmt.Sprintf("match %s player names: %v", matchID, err)}
|
|
774
|
+
}
|
|
775
|
+
out := map[string]string{}
|
|
776
|
+
for _, player := range players {
|
|
777
|
+
id := strings.TrimSpace(player.PlayerID)
|
|
778
|
+
if id == "" {
|
|
779
|
+
continue
|
|
780
|
+
}
|
|
781
|
+
if _, ok := out[id]; ok {
|
|
782
|
+
continue
|
|
783
|
+
}
|
|
784
|
+
out[id] = strings.TrimSpace(player.PlayerName)
|
|
785
|
+
}
|
|
786
|
+
return out, warnings
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
func analysisResult(kind EntityKind, view AnalysisView, warnings []string) NormalizedResult {
|
|
790
|
+
if len(view.Rows) == 0 {
|
|
791
|
+
result := NormalizedResult{
|
|
792
|
+
Kind: kind,
|
|
793
|
+
Status: ResultStatusEmpty,
|
|
794
|
+
Message: "no analysis rows found for selected scope",
|
|
795
|
+
Data: view,
|
|
796
|
+
}
|
|
797
|
+
if compact := compactWarnings(warnings); len(compact) > 0 {
|
|
798
|
+
result.Status = ResultStatusPartial
|
|
799
|
+
result.Warnings = compact
|
|
800
|
+
}
|
|
801
|
+
return result
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if compact := compactWarnings(warnings); len(compact) > 0 {
|
|
805
|
+
return NewPartialResult(kind, view, compact...)
|
|
806
|
+
}
|
|
807
|
+
return NewDataResult(kind, view)
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
func buildSingleScope(run *analysisScopeRun, opts AnalysisMetricOptions) AnalysisScope {
|
|
811
|
+
scope := run.scope
|
|
812
|
+
seasons := []string{}
|
|
813
|
+
if run.mode == analysisScopeSeason {
|
|
814
|
+
seasons = append(seasons, seasonIdentifier(scope, run.seasonHint))
|
|
815
|
+
} else if run.seasonHint != "" {
|
|
816
|
+
seasons = append(seasons, strings.TrimSpace(run.seasonHint))
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return AnalysisScope{
|
|
820
|
+
Mode: run.mode,
|
|
821
|
+
RequestedLeagueID: strings.TrimSpace(opts.LeagueQuery),
|
|
822
|
+
LeagueID: strings.TrimSpace(scope.League.ID),
|
|
823
|
+
LeagueName: strings.TrimSpace(scope.League.Name),
|
|
824
|
+
Seasons: compactWarnings(seasons),
|
|
825
|
+
MatchIDs: scope.MatchIDs,
|
|
826
|
+
MatchCount: len(scope.MatchIDs),
|
|
827
|
+
DateFrom: strings.TrimSpace(nonEmpty(scope.DateFrom, opts.DateFrom)),
|
|
828
|
+
DateTo: strings.TrimSpace(nonEmpty(scope.DateTo, opts.DateTo)),
|
|
829
|
+
TypeQuery: strings.TrimSpace(opts.TypeQuery),
|
|
830
|
+
GroupQuery: strings.TrimSpace(opts.GroupQuery),
|
|
831
|
+
HydrationMetric: run.session.Metrics(),
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
func analysisTransportResult(kind EntityKind, requestedRef string, err error) *NormalizedResult {
|
|
836
|
+
var statusErr *HTTPStatusError
|
|
837
|
+
if errors.As(err, &statusErr) {
|
|
838
|
+
result := NewTransportErrorResult(kind, requestedRef, err)
|
|
839
|
+
return &result
|
|
840
|
+
}
|
|
841
|
+
return nil
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
func normalizeBowlingMetric(raw string) (string, error) {
|
|
845
|
+
metric := strings.ToLower(strings.TrimSpace(raw))
|
|
846
|
+
metric = strings.ReplaceAll(metric, "_", "-")
|
|
847
|
+
metric = strings.ReplaceAll(metric, " ", "-")
|
|
848
|
+
switch metric {
|
|
849
|
+
case analysisMetricEconomy, analysisMetricDots, analysisMetricSixesConceded:
|
|
850
|
+
return metric, nil
|
|
851
|
+
default:
|
|
852
|
+
return "", fmt.Errorf("--metric must be one of: economy, dots, sixes-conceded")
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
func normalizeBattingMetric(raw string) (string, error) {
|
|
857
|
+
metric := strings.ToLower(strings.TrimSpace(raw))
|
|
858
|
+
metric = strings.ReplaceAll(metric, "_", "-")
|
|
859
|
+
metric = strings.ReplaceAll(metric, " ", "-")
|
|
860
|
+
switch metric {
|
|
861
|
+
case analysisMetricFours, analysisMetricSixes, analysisMetricStrikeRate:
|
|
862
|
+
return metric, nil
|
|
863
|
+
default:
|
|
864
|
+
return "", fmt.Errorf("--metric must be one of: fours, sixes, strike-rate")
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
func parseMetricScope(raw string) (string, string, error) {
|
|
869
|
+
raw = strings.TrimSpace(raw)
|
|
870
|
+
if raw == "" {
|
|
871
|
+
return "", "", fmt.Errorf("--scope is required (match:<match> or season:<season>)")
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
parts := strings.SplitN(raw, ":", 2)
|
|
875
|
+
if len(parts) != 2 {
|
|
876
|
+
return "", "", fmt.Errorf("--scope must use explicit mode: match:<match> or season:<season>")
|
|
877
|
+
}
|
|
878
|
+
mode := strings.ToLower(strings.TrimSpace(parts[0]))
|
|
879
|
+
query := strings.TrimSpace(parts[1])
|
|
880
|
+
if query == "" {
|
|
881
|
+
return "", "", fmt.Errorf("scope query is required")
|
|
882
|
+
}
|
|
883
|
+
switch mode {
|
|
884
|
+
case analysisScopeMatch, analysisScopeSeason:
|
|
885
|
+
return mode, query, nil
|
|
886
|
+
default:
|
|
887
|
+
return "", "", fmt.Errorf("unsupported --scope mode %q (expected match or season)", mode)
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
func parseSeasonRange(raw string) ([]string, error) {
|
|
892
|
+
raw = strings.TrimSpace(raw)
|
|
893
|
+
if raw == "" {
|
|
894
|
+
return nil, fmt.Errorf("--seasons is required (example: 2023-2025)")
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
items := strings.Split(raw, ",")
|
|
898
|
+
out := make([]string, 0)
|
|
899
|
+
for _, item := range items {
|
|
900
|
+
item = strings.TrimSpace(item)
|
|
901
|
+
if item == "" {
|
|
902
|
+
continue
|
|
903
|
+
}
|
|
904
|
+
if strings.Contains(item, "-") {
|
|
905
|
+
parts := strings.SplitN(item, "-", 2)
|
|
906
|
+
left := parseYear(parts[0])
|
|
907
|
+
right := parseYear(parts[1])
|
|
908
|
+
if left == 0 || right == 0 {
|
|
909
|
+
return nil, fmt.Errorf("invalid season range %q", item)
|
|
910
|
+
}
|
|
911
|
+
if left > right {
|
|
912
|
+
left, right = right, left
|
|
913
|
+
}
|
|
914
|
+
for year := left; year <= right; year++ {
|
|
915
|
+
out = append(out, fmt.Sprintf("%d", year))
|
|
916
|
+
}
|
|
917
|
+
continue
|
|
918
|
+
}
|
|
919
|
+
if parseYear(item) == 0 {
|
|
920
|
+
return nil, fmt.Errorf("invalid season %q", item)
|
|
921
|
+
}
|
|
922
|
+
out = append(out, item)
|
|
923
|
+
}
|
|
924
|
+
if len(out) == 0 {
|
|
925
|
+
return nil, fmt.Errorf("--seasons did not resolve any seasons")
|
|
926
|
+
}
|
|
927
|
+
return dedupeStrings(out), nil
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
func parseGroupBy(raw string, defaults []string, allowed map[string]struct{}) ([]string, error) {
|
|
931
|
+
raw = strings.TrimSpace(raw)
|
|
932
|
+
if raw == "" {
|
|
933
|
+
return append([]string{}, defaults...), nil
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
parts := strings.Split(raw, ",")
|
|
937
|
+
out := make([]string, 0, len(parts))
|
|
938
|
+
seen := map[string]struct{}{}
|
|
939
|
+
for _, part := range parts {
|
|
940
|
+
normalized := normalizeGroupField(part)
|
|
941
|
+
if normalized == "" {
|
|
942
|
+
continue
|
|
943
|
+
}
|
|
944
|
+
if _, ok := allowed[normalized]; !ok {
|
|
945
|
+
return nil, fmt.Errorf("unsupported --group-by field %q", strings.TrimSpace(part))
|
|
946
|
+
}
|
|
947
|
+
if _, ok := seen[normalized]; ok {
|
|
948
|
+
continue
|
|
949
|
+
}
|
|
950
|
+
seen[normalized] = struct{}{}
|
|
951
|
+
out = append(out, normalized)
|
|
952
|
+
}
|
|
953
|
+
if len(out) == 0 {
|
|
954
|
+
return append([]string{}, defaults...), nil
|
|
955
|
+
}
|
|
956
|
+
return out, nil
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
func normalizeGroupField(raw string) string {
|
|
960
|
+
field := strings.ToLower(strings.TrimSpace(raw))
|
|
961
|
+
field = strings.ReplaceAll(field, "_", "-")
|
|
962
|
+
field = strings.ReplaceAll(field, " ", "-")
|
|
963
|
+
switch field {
|
|
964
|
+
case "dismissal", "dismissals", "dismissaltype", "dismissal-type":
|
|
965
|
+
return "dismissal-type"
|
|
966
|
+
case "inning", "innings", "innings-period":
|
|
967
|
+
return "innings"
|
|
968
|
+
default:
|
|
969
|
+
return field
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
type analysisSourceRow struct {
|
|
974
|
+
MatchID string
|
|
975
|
+
LeagueID string
|
|
976
|
+
SeasonID string
|
|
977
|
+
TeamID string
|
|
978
|
+
TeamName string
|
|
979
|
+
PlayerID string
|
|
980
|
+
PlayerName string
|
|
981
|
+
DismissalType string
|
|
982
|
+
InningsNumber int
|
|
983
|
+
Period int
|
|
984
|
+
CountValue int
|
|
985
|
+
|
|
986
|
+
Dots int
|
|
987
|
+
SixesConceded int
|
|
988
|
+
Balls int
|
|
989
|
+
RunsConceded int
|
|
990
|
+
EconomySample float64
|
|
991
|
+
|
|
992
|
+
Fours int
|
|
993
|
+
BattingSixes int
|
|
994
|
+
RunsScored int
|
|
995
|
+
BallsFaced int
|
|
996
|
+
StrikeSample float64
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
type analysisAggregate struct {
|
|
1000
|
+
row AnalysisRow
|
|
1001
|
+
matchIDs map[string]struct{}
|
|
1002
|
+
count int
|
|
1003
|
+
|
|
1004
|
+
dots int
|
|
1005
|
+
sixesConceded int
|
|
1006
|
+
balls int
|
|
1007
|
+
runsConceded int
|
|
1008
|
+
economyTotal float64
|
|
1009
|
+
economyCount int
|
|
1010
|
+
|
|
1011
|
+
fours int
|
|
1012
|
+
battingSixes int
|
|
1013
|
+
runsScored int
|
|
1014
|
+
ballsFaced int
|
|
1015
|
+
strikeRateTotal float64
|
|
1016
|
+
strikeRateCount int
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
func buildAnalysisGroup(row analysisSourceRow, groupBy []string) (string, AnalysisRow) {
|
|
1020
|
+
parts := make([]string, 0, len(groupBy))
|
|
1021
|
+
dims := AnalysisRow{}
|
|
1022
|
+
|
|
1023
|
+
for _, field := range groupBy {
|
|
1024
|
+
switch field {
|
|
1025
|
+
case "player":
|
|
1026
|
+
label := firstNonEmptyString(row.PlayerName, row.PlayerID)
|
|
1027
|
+
if label == "" {
|
|
1028
|
+
label = "unknown-player"
|
|
1029
|
+
}
|
|
1030
|
+
parts = append(parts, "player="+label)
|
|
1031
|
+
dims.PlayerID = row.PlayerID
|
|
1032
|
+
dims.PlayerName = row.PlayerName
|
|
1033
|
+
case "team":
|
|
1034
|
+
label := firstNonEmptyString(row.TeamName, row.TeamID)
|
|
1035
|
+
if label == "" {
|
|
1036
|
+
label = "unknown-team"
|
|
1037
|
+
}
|
|
1038
|
+
parts = append(parts, "team="+label)
|
|
1039
|
+
dims.TeamID = row.TeamID
|
|
1040
|
+
dims.TeamName = row.TeamName
|
|
1041
|
+
case "league":
|
|
1042
|
+
label := firstNonEmptyString(row.LeagueID)
|
|
1043
|
+
if label == "" {
|
|
1044
|
+
label = "unknown-league"
|
|
1045
|
+
}
|
|
1046
|
+
parts = append(parts, "league="+label)
|
|
1047
|
+
dims.LeagueID = row.LeagueID
|
|
1048
|
+
case "season":
|
|
1049
|
+
label := firstNonEmptyString(row.SeasonID)
|
|
1050
|
+
if label == "" {
|
|
1051
|
+
label = "unknown-season"
|
|
1052
|
+
}
|
|
1053
|
+
parts = append(parts, "season="+label)
|
|
1054
|
+
dims.SeasonID = row.SeasonID
|
|
1055
|
+
case "dismissal-type":
|
|
1056
|
+
label := firstNonEmptyString(row.DismissalType)
|
|
1057
|
+
if label == "" {
|
|
1058
|
+
label = "unknown-dismissal"
|
|
1059
|
+
}
|
|
1060
|
+
parts = append(parts, "dismissal="+label)
|
|
1061
|
+
dims.DismissalType = row.DismissalType
|
|
1062
|
+
case "innings":
|
|
1063
|
+
label := fmt.Sprintf("%d/%d", row.InningsNumber, row.Period)
|
|
1064
|
+
if row.InningsNumber <= 0 {
|
|
1065
|
+
label = "unknown-innings"
|
|
1066
|
+
}
|
|
1067
|
+
parts = append(parts, "innings="+label)
|
|
1068
|
+
dims.InningsNumber = row.InningsNumber
|
|
1069
|
+
dims.Period = row.Period
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
if len(parts) == 0 {
|
|
1074
|
+
return "all", dims
|
|
1075
|
+
}
|
|
1076
|
+
return strings.Join(parts, " | "), dims
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
func rankAnalysisRows(rows []AnalysisRow, asc bool) []AnalysisRow {
|
|
1080
|
+
sort.Slice(rows, func(i, j int) bool {
|
|
1081
|
+
if rows[i].Value != rows[j].Value {
|
|
1082
|
+
if asc {
|
|
1083
|
+
return rows[i].Value < rows[j].Value
|
|
1084
|
+
}
|
|
1085
|
+
return rows[i].Value > rows[j].Value
|
|
1086
|
+
}
|
|
1087
|
+
if rows[i].Count != rows[j].Count {
|
|
1088
|
+
return rows[i].Count > rows[j].Count
|
|
1089
|
+
}
|
|
1090
|
+
return rows[i].Key < rows[j].Key
|
|
1091
|
+
})
|
|
1092
|
+
for i := range rows {
|
|
1093
|
+
rows[i].Rank = i + 1
|
|
1094
|
+
}
|
|
1095
|
+
return rows
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
func trimAnalysisRows(rows []AnalysisRow, top int) []AnalysisRow {
|
|
1099
|
+
if top <= 0 || top >= len(rows) {
|
|
1100
|
+
return rows
|
|
1101
|
+
}
|
|
1102
|
+
return append([]AnalysisRow(nil), rows[:top]...)
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
func economyFromAggregate(agg *analysisAggregate) float64 {
|
|
1106
|
+
if agg == nil {
|
|
1107
|
+
return 0
|
|
1108
|
+
}
|
|
1109
|
+
if agg.balls > 0 {
|
|
1110
|
+
overs := float64(agg.balls) / 6.0
|
|
1111
|
+
if overs > 0 {
|
|
1112
|
+
return float64(agg.runsConceded) / overs
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if agg.economyCount > 0 {
|
|
1116
|
+
return agg.economyTotal / float64(agg.economyCount)
|
|
1117
|
+
}
|
|
1118
|
+
return 0
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
func hasBowlingActivity(agg *analysisAggregate) bool {
|
|
1122
|
+
if agg == nil {
|
|
1123
|
+
return false
|
|
1124
|
+
}
|
|
1125
|
+
return agg.balls > 0 || agg.runsConceded > 0 || agg.dots > 0 || agg.sixesConceded > 0 || agg.economyCount > 0
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
func strikeRateFromAggregate(agg *analysisAggregate) float64 {
|
|
1129
|
+
if agg == nil {
|
|
1130
|
+
return 0
|
|
1131
|
+
}
|
|
1132
|
+
if agg.ballsFaced > 0 {
|
|
1133
|
+
return (float64(agg.runsScored) * 100.0) / float64(agg.ballsFaced)
|
|
1134
|
+
}
|
|
1135
|
+
if agg.strikeRateCount > 0 {
|
|
1136
|
+
return agg.strikeRateTotal / float64(agg.strikeRateCount)
|
|
1137
|
+
}
|
|
1138
|
+
return 0
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
type bowlingTotals struct {
|
|
1142
|
+
dots int
|
|
1143
|
+
sixesConceded int
|
|
1144
|
+
balls int
|
|
1145
|
+
conceded int
|
|
1146
|
+
economy float64
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
func extractBowlingTotals(player PlayerMatch) bowlingTotals {
|
|
1150
|
+
totals := bowlingTotals{
|
|
1151
|
+
dots: player.Summary.Dots,
|
|
1152
|
+
sixesConceded: player.Summary.SixesConceded,
|
|
1153
|
+
economy: player.Summary.EconomyRate,
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
for _, category := range player.Bowling {
|
|
1157
|
+
for _, stat := range category.Stats {
|
|
1158
|
+
switch normalizeStatName(stat.Name) {
|
|
1159
|
+
case "dots":
|
|
1160
|
+
totals.dots += statAsInt(stat)
|
|
1161
|
+
case "sixesconceded":
|
|
1162
|
+
totals.sixesConceded += statAsInt(stat)
|
|
1163
|
+
case "balls":
|
|
1164
|
+
totals.balls += statAsInt(stat)
|
|
1165
|
+
case "conceded":
|
|
1166
|
+
totals.conceded += statAsInt(stat)
|
|
1167
|
+
case "economyrate":
|
|
1168
|
+
if value := statAsFloat(stat); value > 0 {
|
|
1169
|
+
totals.economy = value
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// summary already includes merged dots/sixes values in most payloads; avoid double counting.
|
|
1176
|
+
if totals.dots > 0 && player.Summary.Dots > 0 {
|
|
1177
|
+
totals.dots = analysisMaxInt(totals.dots, player.Summary.Dots)
|
|
1178
|
+
}
|
|
1179
|
+
if totals.sixesConceded > 0 && player.Summary.SixesConceded > 0 {
|
|
1180
|
+
totals.sixesConceded = analysisMaxInt(totals.sixesConceded, player.Summary.SixesConceded)
|
|
1181
|
+
}
|
|
1182
|
+
return totals
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
type battingTotals struct {
|
|
1186
|
+
fours int
|
|
1187
|
+
sixes int
|
|
1188
|
+
runs int
|
|
1189
|
+
balls int
|
|
1190
|
+
strikeRate float64
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
func extractBattingTotals(player PlayerMatch) battingTotals {
|
|
1194
|
+
totals := battingTotals{strikeRate: player.Summary.StrikeRate, balls: player.Summary.BallsFaced}
|
|
1195
|
+
for _, category := range player.Batting {
|
|
1196
|
+
for _, stat := range category.Stats {
|
|
1197
|
+
switch normalizeStatName(stat.Name) {
|
|
1198
|
+
case "fours":
|
|
1199
|
+
totals.fours += statAsInt(stat)
|
|
1200
|
+
case "sixes":
|
|
1201
|
+
totals.sixes += statAsInt(stat)
|
|
1202
|
+
case "runs":
|
|
1203
|
+
totals.runs += statAsInt(stat)
|
|
1204
|
+
case "ballsfaced":
|
|
1205
|
+
totals.balls += statAsInt(stat)
|
|
1206
|
+
case "strikerate":
|
|
1207
|
+
if value := statAsFloat(stat); value > 0 {
|
|
1208
|
+
totals.strikeRate = value
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
return totals
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
func seasonIdentifier(scope HistoricalScopeSummary, fallback string) string {
|
|
1217
|
+
if scope.Season != nil {
|
|
1218
|
+
if strings.TrimSpace(scope.Season.ID) != "" {
|
|
1219
|
+
return strings.TrimSpace(scope.Season.ID)
|
|
1220
|
+
}
|
|
1221
|
+
if scope.Season.Year > 0 {
|
|
1222
|
+
return fmt.Sprintf("%d", scope.Season.Year)
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
fallback = strings.TrimSpace(fallback)
|
|
1226
|
+
if fallback != "" {
|
|
1227
|
+
return fallback
|
|
1228
|
+
}
|
|
1229
|
+
return ""
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
func seasonForMatch(match Match, fallback string) string {
|
|
1233
|
+
if date := strings.TrimSpace(match.Date); date != "" {
|
|
1234
|
+
if parsed, ok := parseMatchTime(match); ok {
|
|
1235
|
+
return fmt.Sprintf("%d", parsed.UTC().Year())
|
|
1236
|
+
}
|
|
1237
|
+
if len(date) >= 4 {
|
|
1238
|
+
if _, err := strconv.Atoi(date[:4]); err == nil {
|
|
1239
|
+
return date[:4]
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return strings.TrimSpace(fallback)
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
func dedupeStrings(values []string) []string {
|
|
1247
|
+
seen := map[string]struct{}{}
|
|
1248
|
+
out := make([]string, 0, len(values))
|
|
1249
|
+
for _, value := range values {
|
|
1250
|
+
value = strings.TrimSpace(value)
|
|
1251
|
+
if value == "" {
|
|
1252
|
+
continue
|
|
1253
|
+
}
|
|
1254
|
+
if _, ok := seen[value]; ok {
|
|
1255
|
+
continue
|
|
1256
|
+
}
|
|
1257
|
+
seen[value] = struct{}{}
|
|
1258
|
+
out = append(out, value)
|
|
1259
|
+
}
|
|
1260
|
+
return out
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
func analysisMaxInt(a, b int) int {
|
|
1264
|
+
if a > b {
|
|
1265
|
+
return a
|
|
1266
|
+
}
|
|
1267
|
+
return b
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
func analysisDisplayPlayerName(resolver *Resolver, playerID, fallback string) string {
|
|
1271
|
+
name := strings.TrimSpace(fallback)
|
|
1272
|
+
if name != "" {
|
|
1273
|
+
return name
|
|
1274
|
+
}
|
|
1275
|
+
if resolver != nil && resolver.index != nil {
|
|
1276
|
+
if indexed, ok := resolver.index.FindByID(EntityPlayer, strings.TrimSpace(playerID)); ok {
|
|
1277
|
+
name = nonEmpty(indexed.Name, indexed.ShortName)
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return strings.TrimSpace(name)
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
func analysisDisplayTeamName(resolver *Resolver, teamID, fallback string) string {
|
|
1284
|
+
name := strings.TrimSpace(fallback)
|
|
1285
|
+
if name != "" {
|
|
1286
|
+
return name
|
|
1287
|
+
}
|
|
1288
|
+
if resolver != nil && resolver.index != nil {
|
|
1289
|
+
if indexed, ok := resolver.index.FindByID(EntityTeam, strings.TrimSpace(teamID)); ok {
|
|
1290
|
+
name = nonEmpty(indexed.ShortName, indexed.Name)
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return strings.TrimSpace(name)
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
type analysisFilterSpec struct {
|
|
1297
|
+
teamQuery string
|
|
1298
|
+
playerQuery string
|
|
1299
|
+
dismissalType string
|
|
1300
|
+
innings int
|
|
1301
|
+
period int
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
func analysisFiltersFromDismissal(opts AnalysisDismissalOptions) analysisFilterSpec {
|
|
1305
|
+
return analysisFilterSpec{
|
|
1306
|
+
teamQuery: strings.TrimSpace(opts.TeamQuery),
|
|
1307
|
+
playerQuery: strings.TrimSpace(opts.PlayerQuery),
|
|
1308
|
+
dismissalType: strings.TrimSpace(opts.DismissalType),
|
|
1309
|
+
innings: opts.Innings,
|
|
1310
|
+
period: opts.Period,
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
func analysisFiltersFromMetric(opts AnalysisMetricOptions) analysisFilterSpec {
|
|
1315
|
+
return analysisFilterSpec{
|
|
1316
|
+
teamQuery: strings.TrimSpace(opts.TeamQuery),
|
|
1317
|
+
playerQuery: strings.TrimSpace(opts.PlayerQuery),
|
|
1318
|
+
dismissalType: strings.TrimSpace(opts.DismissalType),
|
|
1319
|
+
innings: opts.Innings,
|
|
1320
|
+
period: opts.Period,
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
func (f analysisFilterSpec) matches(row analysisSourceRow) bool {
|
|
1325
|
+
if !f.matchesTeam(row) {
|
|
1326
|
+
return false
|
|
1327
|
+
}
|
|
1328
|
+
if !f.matchesPlayer(row) {
|
|
1329
|
+
return false
|
|
1330
|
+
}
|
|
1331
|
+
if !f.matchesDismissal(row) {
|
|
1332
|
+
return false
|
|
1333
|
+
}
|
|
1334
|
+
if !f.matchesInnings(row) {
|
|
1335
|
+
return false
|
|
1336
|
+
}
|
|
1337
|
+
if !f.matchesPeriod(row) {
|
|
1338
|
+
return false
|
|
1339
|
+
}
|
|
1340
|
+
return true
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
func (f analysisFilterSpec) matchesPartnership(row analysisSourceRow) bool {
|
|
1344
|
+
if !f.matchesTeam(row) {
|
|
1345
|
+
return false
|
|
1346
|
+
}
|
|
1347
|
+
if !f.matchesInnings(row) {
|
|
1348
|
+
return false
|
|
1349
|
+
}
|
|
1350
|
+
return true
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
func (f analysisFilterSpec) matchesTeam(row analysisSourceRow) bool {
|
|
1354
|
+
query := normalizeAlias(f.teamQuery)
|
|
1355
|
+
if query == "" {
|
|
1356
|
+
return true
|
|
1357
|
+
}
|
|
1358
|
+
candidates := []string{normalizeAlias(row.TeamID), normalizeAlias(row.TeamName)}
|
|
1359
|
+
for _, candidate := range candidates {
|
|
1360
|
+
if candidate != "" && candidate == query {
|
|
1361
|
+
return true
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return false
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
func (f analysisFilterSpec) matchesPlayer(row analysisSourceRow) bool {
|
|
1368
|
+
query := normalizeAlias(f.playerQuery)
|
|
1369
|
+
if query == "" {
|
|
1370
|
+
return true
|
|
1371
|
+
}
|
|
1372
|
+
candidates := []string{normalizeAlias(row.PlayerID), normalizeAlias(row.PlayerName)}
|
|
1373
|
+
for _, candidate := range candidates {
|
|
1374
|
+
if candidate != "" && candidate == query {
|
|
1375
|
+
return true
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
return false
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
func (f analysisFilterSpec) matchesDismissal(row analysisSourceRow) bool {
|
|
1382
|
+
query := normalizeAlias(f.dismissalType)
|
|
1383
|
+
if query == "" {
|
|
1384
|
+
return true
|
|
1385
|
+
}
|
|
1386
|
+
return normalizeAlias(row.DismissalType) == query
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
func (f analysisFilterSpec) matchesInnings(row analysisSourceRow) bool {
|
|
1390
|
+
if f.innings <= 0 {
|
|
1391
|
+
return true
|
|
1392
|
+
}
|
|
1393
|
+
return row.InningsNumber == f.innings
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
func (f analysisFilterSpec) matchesPeriod(row analysisSourceRow) bool {
|
|
1397
|
+
if f.period <= 0 {
|
|
1398
|
+
return true
|
|
1399
|
+
}
|
|
1400
|
+
return row.Period == f.period
|
|
1401
|
+
}
|