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,1332 @@
|
|
|
1
|
+
package cricinfo
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"sort"
|
|
7
|
+
"strconv"
|
|
8
|
+
"strings"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const defaultPlayerNewsLimit = 10
|
|
12
|
+
|
|
13
|
+
// PlayerLookupOptions controls resolver-backed player lookup behavior.
|
|
14
|
+
type PlayerLookupOptions struct {
|
|
15
|
+
LeagueID string
|
|
16
|
+
Limit int
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// PlayerServiceConfig configures player discovery and global player commands.
|
|
20
|
+
type PlayerServiceConfig struct {
|
|
21
|
+
Client *Client
|
|
22
|
+
Resolver *Resolver
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// PlayerService implements domain-level player discovery, profile, news, and statistics commands.
|
|
26
|
+
type PlayerService struct {
|
|
27
|
+
client *Client
|
|
28
|
+
resolver *Resolver
|
|
29
|
+
ownsResolver bool
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// NewPlayerService builds a player service using default client/resolver when omitted.
|
|
33
|
+
func NewPlayerService(cfg PlayerServiceConfig) (*PlayerService, error) {
|
|
34
|
+
client := cfg.Client
|
|
35
|
+
if client == nil {
|
|
36
|
+
var err error
|
|
37
|
+
client, err = NewClient(Config{})
|
|
38
|
+
if err != nil {
|
|
39
|
+
return nil, err
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
resolver := cfg.Resolver
|
|
44
|
+
ownsResolver := false
|
|
45
|
+
if resolver == nil {
|
|
46
|
+
var err error
|
|
47
|
+
resolver, err = NewResolver(ResolverConfig{Client: client})
|
|
48
|
+
if err != nil {
|
|
49
|
+
return nil, err
|
|
50
|
+
}
|
|
51
|
+
ownsResolver = true
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return &PlayerService{
|
|
55
|
+
client: client,
|
|
56
|
+
resolver: resolver,
|
|
57
|
+
ownsResolver: ownsResolver,
|
|
58
|
+
}, nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Close persists resolver cache when owned by this service.
|
|
62
|
+
func (s *PlayerService) Close() error {
|
|
63
|
+
if !s.ownsResolver || s.resolver == nil {
|
|
64
|
+
return nil
|
|
65
|
+
}
|
|
66
|
+
return s.resolver.Close()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Search resolves player entities for discovery.
|
|
70
|
+
func (s *PlayerService) Search(ctx context.Context, query string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
71
|
+
query = strings.TrimSpace(query)
|
|
72
|
+
searchResult, err := s.resolver.Search(ctx, EntityPlayer, query, ResolveOptions{
|
|
73
|
+
Limit: limitOrDefault(opts.Limit, 10),
|
|
74
|
+
LeagueID: strings.TrimSpace(opts.LeagueID),
|
|
75
|
+
})
|
|
76
|
+
if err != nil {
|
|
77
|
+
return NewTransportErrorResult(EntityPlayer, query, err), nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
items := make([]any, 0, len(searchResult.Entities))
|
|
81
|
+
for _, entity := range searchResult.Entities {
|
|
82
|
+
items = append(items, entity.ToRenderable())
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
result := NewListResult(EntityPlayer, items)
|
|
86
|
+
if len(searchResult.Warnings) > 0 {
|
|
87
|
+
result = NewPartialListResult(EntityPlayer, items, searchResult.Warnings...)
|
|
88
|
+
}
|
|
89
|
+
return result, nil
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Profile resolves and returns a normalized global player profile.
|
|
93
|
+
func (s *PlayerService) Profile(ctx context.Context, query string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
94
|
+
lookup, passthrough := s.resolvePlayerLookup(ctx, query, opts, EntityPlayer)
|
|
95
|
+
if passthrough != nil {
|
|
96
|
+
return *passthrough, nil
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
result := NewDataResult(EntityPlayer, lookup.player)
|
|
100
|
+
if len(lookup.warnings) > 0 {
|
|
101
|
+
result = NewPartialResult(EntityPlayer, lookup.player, lookup.warnings...)
|
|
102
|
+
}
|
|
103
|
+
result.RequestedRef = lookup.resolved.RequestedRef
|
|
104
|
+
result.CanonicalRef = lookup.resolved.CanonicalRef
|
|
105
|
+
return result, nil
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// News resolves and returns normalized news articles for a player.
|
|
109
|
+
func (s *PlayerService) News(ctx context.Context, query string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
110
|
+
lookup, passthrough := s.resolvePlayerLookup(ctx, query, opts, EntityNewsArticle)
|
|
111
|
+
if passthrough != nil {
|
|
112
|
+
return *passthrough, nil
|
|
113
|
+
}
|
|
114
|
+
if strings.TrimSpace(lookup.player.NewsRef) == "" {
|
|
115
|
+
return NormalizedResult{
|
|
116
|
+
Kind: EntityNewsArticle,
|
|
117
|
+
Status: ResultStatusEmpty,
|
|
118
|
+
Message: fmt.Sprintf("news route unavailable for player %q", lookup.player.ID),
|
|
119
|
+
}, nil
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
resolved, err := s.client.ResolveRefChain(ctx, lookup.player.NewsRef)
|
|
123
|
+
if err != nil {
|
|
124
|
+
return NewTransportErrorResult(EntityNewsArticle, lookup.player.NewsRef, err), nil
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
page, err := DecodePage[Ref](resolved.Body)
|
|
128
|
+
if err != nil {
|
|
129
|
+
return NormalizedResult{}, fmt.Errorf("decode player news page %q: %w", resolved.CanonicalRef, err)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
limit := limitOrDefault(opts.Limit, defaultPlayerNewsLimit)
|
|
133
|
+
if limit > len(page.Items) {
|
|
134
|
+
limit = len(page.Items)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
items := make([]any, 0, limit)
|
|
138
|
+
warnings := append([]string{}, lookup.warnings...)
|
|
139
|
+
for i := 0; i < limit; i++ {
|
|
140
|
+
itemRef := strings.TrimSpace(page.Items[i].URL)
|
|
141
|
+
if itemRef == "" {
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
itemResolved, itemErr := s.client.ResolveRefChain(ctx, itemRef)
|
|
146
|
+
if itemErr != nil {
|
|
147
|
+
warnings = append(warnings, fmt.Sprintf("news article %s: %v", itemRef, itemErr))
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
article, normalizeErr := NormalizeNewsArticle(itemResolved.Body)
|
|
152
|
+
if normalizeErr != nil {
|
|
153
|
+
warnings = append(warnings, fmt.Sprintf("news article %s: %v", itemResolved.CanonicalRef, normalizeErr))
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
items = append(items, *article)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
result := NewListResult(EntityNewsArticle, items)
|
|
160
|
+
if len(warnings) > 0 {
|
|
161
|
+
result = NewPartialListResult(EntityNewsArticle, items, warnings...)
|
|
162
|
+
}
|
|
163
|
+
result.RequestedRef = resolved.RequestedRef
|
|
164
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
165
|
+
if len(items) == 0 && strings.TrimSpace(result.Message) == "" {
|
|
166
|
+
result.Message = fmt.Sprintf("no news articles found for %q", lookup.player.DisplayName)
|
|
167
|
+
}
|
|
168
|
+
return result, nil
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Stats resolves and returns grouped global player statistics.
|
|
172
|
+
func (s *PlayerService) Stats(ctx context.Context, query string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
173
|
+
return s.statistics(ctx, query, opts)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Career resolves and returns grouped career statistics.
|
|
177
|
+
func (s *PlayerService) Career(ctx context.Context, query string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
178
|
+
return s.statistics(ctx, query, opts)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// MatchStats resolves and returns player-in-match batting/bowling/fielding statistics.
|
|
182
|
+
func (s *PlayerService) MatchStats(ctx context.Context, playerQuery, matchQuery string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
183
|
+
contextData, passthrough := s.resolvePlayerMatchContext(ctx, playerQuery, matchQuery, opts, EntityPlayerMatch)
|
|
184
|
+
if passthrough != nil {
|
|
185
|
+
return *passthrough, nil
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
statsRef := rosterPlayerStatisticsRef(contextData.match, contextData.team, contextData.roster)
|
|
189
|
+
if statsRef == "" {
|
|
190
|
+
return NormalizedResult{
|
|
191
|
+
Kind: EntityPlayerMatch,
|
|
192
|
+
Status: ResultStatusEmpty,
|
|
193
|
+
Message: fmt.Sprintf("match statistics route unavailable for player %q", contextData.playerID),
|
|
194
|
+
}, nil
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
resolved, categories, err := s.fetchStatCategories(ctx, statsRef)
|
|
198
|
+
if err != nil {
|
|
199
|
+
return NewTransportErrorResult(EntityPlayerMatch, statsRef, err), nil
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
batting, bowling, fielding := splitPlayerStatCategories(categories)
|
|
203
|
+
summary := summarizePlayerMatchCategories(categories)
|
|
204
|
+
playerMatch := PlayerMatch{
|
|
205
|
+
PlayerID: contextData.playerID,
|
|
206
|
+
PlayerRef: contextData.roster.PlayerRef,
|
|
207
|
+
PlayerName: contextData.playerName,
|
|
208
|
+
MatchID: contextData.match.ID,
|
|
209
|
+
CompetitionID: nonEmpty(contextData.match.CompetitionID, contextData.match.ID),
|
|
210
|
+
EventID: contextData.match.EventID,
|
|
211
|
+
LeagueID: contextData.match.LeagueID,
|
|
212
|
+
TeamID: contextData.team.ID,
|
|
213
|
+
TeamName: teamDisplayLabel(contextData.team),
|
|
214
|
+
StatisticsRef: resolved.CanonicalRef,
|
|
215
|
+
LinescoresRef: rosterPlayerLinescoresRef(contextData.match, contextData.team, contextData.roster),
|
|
216
|
+
Batting: batting,
|
|
217
|
+
Bowling: bowling,
|
|
218
|
+
Fielding: fielding,
|
|
219
|
+
Summary: summary,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
result := NewDataResult(EntityPlayerMatch, playerMatch)
|
|
223
|
+
warnings := compactWarnings(append(contextData.warnings, contextData.routeWarnings...))
|
|
224
|
+
if len(warnings) > 0 {
|
|
225
|
+
result = NewPartialResult(EntityPlayerMatch, playerMatch, warnings...)
|
|
226
|
+
}
|
|
227
|
+
result.RequestedRef = resolved.RequestedRef
|
|
228
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
229
|
+
return result, nil
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Innings resolves and returns player linescore splits for a selected match.
|
|
233
|
+
func (s *PlayerService) Innings(ctx context.Context, playerQuery, matchQuery string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
234
|
+
contextData, passthrough := s.resolvePlayerMatchContext(ctx, playerQuery, matchQuery, opts, EntityPlayerInnings)
|
|
235
|
+
if passthrough != nil {
|
|
236
|
+
return *passthrough, nil
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
linescoresRef := rosterPlayerLinescoresRef(contextData.match, contextData.team, contextData.roster)
|
|
240
|
+
if linescoresRef == "" {
|
|
241
|
+
return NormalizedResult{
|
|
242
|
+
Kind: EntityPlayerInnings,
|
|
243
|
+
Status: ResultStatusEmpty,
|
|
244
|
+
Message: fmt.Sprintf("player linescores route unavailable for player %q", contextData.playerID),
|
|
245
|
+
}, nil
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
resolved, err := s.client.ResolveRefChain(ctx, linescoresRef)
|
|
249
|
+
if err != nil {
|
|
250
|
+
return NewTransportErrorResult(EntityPlayerInnings, linescoresRef, err), nil
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
payload, err := decodePayloadMap(resolved.Body)
|
|
254
|
+
if err != nil {
|
|
255
|
+
return NormalizedResult{}, fmt.Errorf("decode player linescores %q: %w", resolved.CanonicalRef, err)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
rows := mapSliceField(payload, "items")
|
|
259
|
+
if len(rows) == 0 && len(payload) > 0 {
|
|
260
|
+
rows = append(rows, payload)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
entries := make([]PlayerInnings, 0, len(rows))
|
|
264
|
+
warnings := append([]string{}, contextData.warnings...)
|
|
265
|
+
warnings = append(warnings, contextData.routeWarnings...)
|
|
266
|
+
|
|
267
|
+
for _, row := range rows {
|
|
268
|
+
rowRef := stringField(row, "$ref")
|
|
269
|
+
rowIDs := refIDs(rowRef)
|
|
270
|
+
inningsNumber := intField(row, "value")
|
|
271
|
+
if inningsNumber == 0 {
|
|
272
|
+
inningsNumber = parseInt(rowIDs["inningsId"])
|
|
273
|
+
}
|
|
274
|
+
period := intField(row, "period")
|
|
275
|
+
if period == 0 {
|
|
276
|
+
period = parseInt(rowIDs["periodId"])
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
statisticsRef := nonEmpty(
|
|
280
|
+
stringField(row, "statistics"),
|
|
281
|
+
firstPlayerLinescoreStatisticsRef(row),
|
|
282
|
+
rosterPlayerLinescoreStatisticsRef(contextData.match, contextData.team, contextData.roster, inningsNumber, period),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
inningsEntry := PlayerInnings{
|
|
286
|
+
Ref: rowRef,
|
|
287
|
+
PlayerID: contextData.playerID,
|
|
288
|
+
PlayerName: contextData.playerName,
|
|
289
|
+
MatchID: contextData.match.ID,
|
|
290
|
+
CompetitionID: nonEmpty(contextData.match.CompetitionID, contextData.match.ID),
|
|
291
|
+
EventID: contextData.match.EventID,
|
|
292
|
+
LeagueID: contextData.match.LeagueID,
|
|
293
|
+
TeamID: contextData.team.ID,
|
|
294
|
+
TeamName: teamDisplayLabel(contextData.team),
|
|
295
|
+
InningsNumber: inningsNumber,
|
|
296
|
+
Period: period,
|
|
297
|
+
Order: intField(row, "order"),
|
|
298
|
+
IsBatting: boolField(row, "isBatting"),
|
|
299
|
+
StatisticsRef: statisticsRef,
|
|
300
|
+
Extensions: extensionsFromMap(row,
|
|
301
|
+
"$ref", "period", "value", "displayValue", "isBatting", "order", "mediaId", "statistics", "linescores",
|
|
302
|
+
),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if statisticsRef != "" {
|
|
306
|
+
_, categories, statsErr := s.fetchStatCategories(ctx, statisticsRef)
|
|
307
|
+
if statsErr != nil {
|
|
308
|
+
warnings = append(warnings, fmt.Sprintf("player innings statistics %s: %v", statisticsRef, statsErr))
|
|
309
|
+
} else {
|
|
310
|
+
batting, bowling, fielding := splitPlayerStatCategories(categories)
|
|
311
|
+
inningsEntry.Batting = batting
|
|
312
|
+
inningsEntry.Bowling = bowling
|
|
313
|
+
inningsEntry.Fielding = fielding
|
|
314
|
+
inningsEntry.Summary = summarizePlayerMatchCategories(categories)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
entries = append(entries, inningsEntry)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
sort.Slice(entries, func(i, j int) bool {
|
|
322
|
+
if entries[i].InningsNumber != entries[j].InningsNumber {
|
|
323
|
+
return entries[i].InningsNumber < entries[j].InningsNumber
|
|
324
|
+
}
|
|
325
|
+
if entries[i].Period != entries[j].Period {
|
|
326
|
+
return entries[i].Period < entries[j].Period
|
|
327
|
+
}
|
|
328
|
+
return entries[i].Order < entries[j].Order
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
items := make([]any, 0, len(entries))
|
|
332
|
+
for _, entry := range entries {
|
|
333
|
+
items = append(items, entry)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
result := NewListResult(EntityPlayerInnings, items)
|
|
337
|
+
if compact := compactWarnings(warnings); len(compact) > 0 {
|
|
338
|
+
result = NewPartialListResult(EntityPlayerInnings, items, compact...)
|
|
339
|
+
}
|
|
340
|
+
result.RequestedRef = resolved.RequestedRef
|
|
341
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
342
|
+
if len(items) == 0 && strings.TrimSpace(result.Message) == "" {
|
|
343
|
+
result.Message = fmt.Sprintf("no innings splits found for player %q in match %q", contextData.playerName, contextData.match.ID)
|
|
344
|
+
}
|
|
345
|
+
return result, nil
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Dismissals resolves dismissal-focused wicket views for a player in one match.
|
|
349
|
+
func (s *PlayerService) Dismissals(ctx context.Context, playerQuery, matchQuery string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
350
|
+
contextData, passthrough := s.resolvePlayerMatchContext(ctx, playerQuery, matchQuery, opts, EntityPlayerDismissal)
|
|
351
|
+
if passthrough != nil {
|
|
352
|
+
return *passthrough, nil
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
resolved, deliveries, deliveryWarnings, err := s.fetchPlayerDeliveries(ctx, contextData, true)
|
|
356
|
+
if err != nil {
|
|
357
|
+
return NewTransportErrorResult(EntityPlayerDismissal, matchSubresourceRef(contextData.match, "details", "details"), err), nil
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
wicketByRef, wicketByID, wicketWarnings := s.collectMatchWicketMetadata(ctx, contextData.match)
|
|
361
|
+
warnings := append([]string{}, contextData.warnings...)
|
|
362
|
+
warnings = append(warnings, contextData.routeWarnings...)
|
|
363
|
+
warnings = append(warnings, deliveryWarnings...)
|
|
364
|
+
warnings = append(warnings, wicketWarnings...)
|
|
365
|
+
|
|
366
|
+
items := make([]any, 0, len(deliveries))
|
|
367
|
+
for _, delivery := range deliveries {
|
|
368
|
+
if !isDismissalDelivery(delivery) {
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
wicketMeta, ok := wicketByRef[strings.TrimSpace(delivery.Ref)]
|
|
373
|
+
if !ok {
|
|
374
|
+
detailID := strings.TrimSpace(refIDs(delivery.Ref)["detailId"])
|
|
375
|
+
if detailID != "" {
|
|
376
|
+
wicketMeta, ok = wicketByID[detailID]
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
playerDismissal := PlayerDismissal{
|
|
381
|
+
PlayerID: contextData.playerID,
|
|
382
|
+
PlayerName: contextData.playerName,
|
|
383
|
+
MatchID: contextData.match.ID,
|
|
384
|
+
CompetitionID: nonEmpty(contextData.match.CompetitionID, contextData.match.ID),
|
|
385
|
+
EventID: contextData.match.EventID,
|
|
386
|
+
LeagueID: contextData.match.LeagueID,
|
|
387
|
+
TeamID: nonEmpty(wicketMeta.team.ID, contextData.team.ID),
|
|
388
|
+
TeamName: nonEmpty(teamDisplayLabel(wicketMeta.team), teamDisplayLabel(contextData.team), "Unknown Team"),
|
|
389
|
+
InningsNumber: wicketMeta.innings.InningsNumber,
|
|
390
|
+
Period: wicketMeta.innings.Period,
|
|
391
|
+
WicketNumber: wicketMeta.wicket.Number,
|
|
392
|
+
FOW: wicketMeta.wicket.FOW,
|
|
393
|
+
Over: nonEmpty(wicketMeta.wicket.Over, fmt.Sprintf("%.1f", wicketMeta.wicket.WicketOver)),
|
|
394
|
+
DetailRef: nonEmpty(wicketMeta.wicket.DetailRef, delivery.Ref),
|
|
395
|
+
DetailShortText: nonEmpty(wicketMeta.wicket.DetailShortText, delivery.ShortText),
|
|
396
|
+
DetailText: nonEmpty(wicketMeta.wicket.DetailText, delivery.Text),
|
|
397
|
+
DismissalName: nonEmpty(delivery.DismissalName, delivery.DismissalType, wicketMeta.wicket.FOWType),
|
|
398
|
+
DismissalCard: nonEmpty(wicketMeta.wicket.DismissalCard, delivery.DismissalCard),
|
|
399
|
+
DismissalType: delivery.DismissalType,
|
|
400
|
+
DismissalText: delivery.DismissalText,
|
|
401
|
+
BallsFaced: firstNonZero(wicketMeta.wicket.BallsFaced, wicketMeta.wicket.RunsScored),
|
|
402
|
+
StrikeRate: wicketMeta.wicket.StrikeRate,
|
|
403
|
+
BatsmanPlayerID: delivery.BatsmanPlayerID,
|
|
404
|
+
BowlerPlayerID: delivery.BowlerPlayerID,
|
|
405
|
+
FielderPlayerID: delivery.FielderPlayerID,
|
|
406
|
+
}
|
|
407
|
+
items = append(items, playerDismissal)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
result := NewListResult(EntityPlayerDismissal, items)
|
|
411
|
+
if compact := compactWarnings(warnings); len(compact) > 0 {
|
|
412
|
+
result = NewPartialListResult(EntityPlayerDismissal, items, compact...)
|
|
413
|
+
}
|
|
414
|
+
result.RequestedRef = resolved.RequestedRef
|
|
415
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
416
|
+
if len(items) == 0 && strings.TrimSpace(result.Message) == "" {
|
|
417
|
+
result.Message = fmt.Sprintf("no dismissal events found for player %q in match %q", contextData.playerName, contextData.match.ID)
|
|
418
|
+
}
|
|
419
|
+
return result, nil
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Deliveries resolves delivery events for a player in one match, preserving coordinates and dismissal metadata.
|
|
423
|
+
func (s *PlayerService) Deliveries(ctx context.Context, playerQuery, matchQuery string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
424
|
+
contextData, passthrough := s.resolvePlayerMatchContext(ctx, playerQuery, matchQuery, opts, EntityPlayerDelivery)
|
|
425
|
+
if passthrough != nil {
|
|
426
|
+
return *passthrough, nil
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
resolved, deliveries, deliveryWarnings, err := s.fetchPlayerDeliveries(ctx, contextData, false)
|
|
430
|
+
if err != nil {
|
|
431
|
+
return NewTransportErrorResult(EntityPlayerDelivery, matchSubresourceRef(contextData.match, "details", "details"), err), nil
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
items := make([]any, 0, len(deliveries))
|
|
435
|
+
for _, delivery := range deliveries {
|
|
436
|
+
items = append(items, delivery)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
warnings := append([]string{}, contextData.warnings...)
|
|
440
|
+
warnings = append(warnings, contextData.routeWarnings...)
|
|
441
|
+
warnings = append(warnings, deliveryWarnings...)
|
|
442
|
+
result := NewListResult(EntityPlayerDelivery, items)
|
|
443
|
+
if compact := compactWarnings(warnings); len(compact) > 0 {
|
|
444
|
+
result = NewPartialListResult(EntityPlayerDelivery, items, compact...)
|
|
445
|
+
}
|
|
446
|
+
result.RequestedRef = resolved.RequestedRef
|
|
447
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
448
|
+
if len(items) == 0 && strings.TrimSpace(result.Message) == "" {
|
|
449
|
+
result.Message = fmt.Sprintf("no delivery events found for player %q in match %q", contextData.playerName, contextData.match.ID)
|
|
450
|
+
}
|
|
451
|
+
return result, nil
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Bowling resolves only the bowling-focused player-in-match categories.
|
|
455
|
+
func (s *PlayerService) Bowling(ctx context.Context, playerQuery, matchQuery string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
456
|
+
return s.playerMatchSplitView(ctx, playerQuery, matchQuery, opts, "bowling")
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Batting resolves only the batting-focused player-in-match categories.
|
|
460
|
+
func (s *PlayerService) Batting(ctx context.Context, playerQuery, matchQuery string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
461
|
+
return s.playerMatchSplitView(ctx, playerQuery, matchQuery, opts, "batting")
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
func (s *PlayerService) playerMatchSplitView(
|
|
465
|
+
ctx context.Context,
|
|
466
|
+
playerQuery, matchQuery string,
|
|
467
|
+
opts PlayerLookupOptions,
|
|
468
|
+
view string,
|
|
469
|
+
) (NormalizedResult, error) {
|
|
470
|
+
result, err := s.MatchStats(ctx, playerQuery, matchQuery, opts)
|
|
471
|
+
if err != nil {
|
|
472
|
+
return result, err
|
|
473
|
+
}
|
|
474
|
+
if result.Status == ResultStatusError || result.Data == nil {
|
|
475
|
+
return result, nil
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
playerMatch, ok := result.Data.(PlayerMatch)
|
|
479
|
+
if !ok {
|
|
480
|
+
return result, nil
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
switch strings.ToLower(strings.TrimSpace(view)) {
|
|
484
|
+
case "batting":
|
|
485
|
+
playerMatch.Bowling = nil
|
|
486
|
+
playerMatch.Fielding = nil
|
|
487
|
+
playerMatch.Summary = summarizePlayerMatchCategories(playerMatch.Batting)
|
|
488
|
+
case "bowling":
|
|
489
|
+
playerMatch.Batting = nil
|
|
490
|
+
playerMatch.Fielding = nil
|
|
491
|
+
playerMatch.Summary = summarizePlayerMatchCategories(playerMatch.Bowling)
|
|
492
|
+
default:
|
|
493
|
+
// no-op
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
result.Data = playerMatch
|
|
497
|
+
return result, nil
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
type playerMatchContext struct {
|
|
501
|
+
playerID string
|
|
502
|
+
playerName string
|
|
503
|
+
playerEntity IndexedEntity
|
|
504
|
+
match Match
|
|
505
|
+
team Team
|
|
506
|
+
roster TeamRosterEntry
|
|
507
|
+
warnings []string
|
|
508
|
+
routeWarnings []string
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
type wicketMetadata struct {
|
|
512
|
+
team Team
|
|
513
|
+
innings Innings
|
|
514
|
+
wicket InningsWicket
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
type playerLookup struct {
|
|
518
|
+
entity IndexedEntity
|
|
519
|
+
player Player
|
|
520
|
+
resolved *ResolvedDocument
|
|
521
|
+
warnings []string
|
|
522
|
+
statsRef string
|
|
523
|
+
statsKind EntityKind
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
func (s *PlayerService) statistics(ctx context.Context, query string, opts PlayerLookupOptions) (NormalizedResult, error) {
|
|
527
|
+
lookup, passthrough := s.resolvePlayerLookup(ctx, query, opts, EntityPlayerStats)
|
|
528
|
+
if passthrough != nil {
|
|
529
|
+
return *passthrough, nil
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
statsRef := nonEmpty(lookup.statsRef, "/athletes/"+strings.TrimSpace(lookup.player.ID)+"/statistics")
|
|
533
|
+
resolved, err := s.client.ResolveRefChain(ctx, statsRef)
|
|
534
|
+
if err != nil {
|
|
535
|
+
return NewTransportErrorResult(EntityPlayerStats, statsRef, err), nil
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
playerStats, err := NormalizePlayerStatistics(resolved.Body)
|
|
539
|
+
if err != nil {
|
|
540
|
+
return NormalizedResult{}, fmt.Errorf("normalize player statistics %q: %w", resolved.CanonicalRef, err)
|
|
541
|
+
}
|
|
542
|
+
if strings.TrimSpace(lookup.player.ID) != "" {
|
|
543
|
+
playerStats.PlayerID = strings.TrimSpace(lookup.player.ID)
|
|
544
|
+
}
|
|
545
|
+
if strings.TrimSpace(lookup.player.Ref) != "" {
|
|
546
|
+
playerStats.PlayerRef = strings.TrimSpace(lookup.player.Ref)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
result := NewDataResult(EntityPlayerStats, *playerStats)
|
|
550
|
+
if len(lookup.warnings) > 0 {
|
|
551
|
+
result = NewPartialResult(EntityPlayerStats, *playerStats, lookup.warnings...)
|
|
552
|
+
}
|
|
553
|
+
result.RequestedRef = resolved.RequestedRef
|
|
554
|
+
result.CanonicalRef = resolved.CanonicalRef
|
|
555
|
+
return result, nil
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
func (s *PlayerService) resolvePlayerLookup(ctx context.Context, query string, opts PlayerLookupOptions, kind EntityKind) (*playerLookup, *NormalizedResult) {
|
|
559
|
+
query = strings.TrimSpace(query)
|
|
560
|
+
if query == "" {
|
|
561
|
+
result := NormalizedResult{Kind: kind, Status: ResultStatusEmpty, Message: "player query is required"}
|
|
562
|
+
return nil, &result
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
searchResult, err := s.resolver.Search(ctx, EntityPlayer, query, ResolveOptions{
|
|
566
|
+
Limit: 5,
|
|
567
|
+
LeagueID: strings.TrimSpace(opts.LeagueID),
|
|
568
|
+
})
|
|
569
|
+
if err != nil {
|
|
570
|
+
result := NewTransportErrorResult(kind, query, err)
|
|
571
|
+
return nil, &result
|
|
572
|
+
}
|
|
573
|
+
if len(searchResult.Entities) == 0 {
|
|
574
|
+
result := NormalizedResult{Kind: kind, Status: ResultStatusEmpty, Message: fmt.Sprintf("no players found for %q", query)}
|
|
575
|
+
return nil, &result
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
entity := searchResult.Entities[0]
|
|
579
|
+
ref := nonEmpty(strings.TrimSpace(entity.Ref), "/athletes/"+strings.TrimSpace(entity.ID))
|
|
580
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
581
|
+
if err != nil {
|
|
582
|
+
result := NewTransportErrorResult(kind, ref, err)
|
|
583
|
+
return nil, &result
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
player, err := NormalizePlayer(resolved.Body)
|
|
587
|
+
if err != nil {
|
|
588
|
+
return nil, &NormalizedResult{
|
|
589
|
+
Kind: kind,
|
|
590
|
+
Status: ResultStatusError,
|
|
591
|
+
Message: fmt.Sprintf("normalize player profile %q: %v", resolved.CanonicalRef, err),
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
s.enrichPlayerProfile(ctx, player)
|
|
595
|
+
|
|
596
|
+
return &playerLookup{
|
|
597
|
+
entity: entity,
|
|
598
|
+
player: *player,
|
|
599
|
+
resolved: resolved,
|
|
600
|
+
warnings: searchResult.Warnings,
|
|
601
|
+
statsRef: "/athletes/" + strings.TrimSpace(player.ID) + "/statistics",
|
|
602
|
+
statsKind: kind,
|
|
603
|
+
}, nil
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
func (s *PlayerService) enrichPlayerProfile(ctx context.Context, player *Player) {
|
|
607
|
+
if s == nil || s.resolver == nil || player == nil {
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
if player.Team != nil {
|
|
611
|
+
enriched := s.enrichPlayerAffiliation(ctx, *player.Team)
|
|
612
|
+
player.Team = &enriched
|
|
613
|
+
}
|
|
614
|
+
for i := range player.MajorTeams {
|
|
615
|
+
player.MajorTeams[i] = s.enrichPlayerAffiliation(ctx, player.MajorTeams[i])
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
func (s *PlayerService) enrichPlayerAffiliation(ctx context.Context, affiliation PlayerAffiliation) PlayerAffiliation {
|
|
620
|
+
teamID := strings.TrimSpace(affiliation.ID)
|
|
621
|
+
if teamID == "" {
|
|
622
|
+
teamID = strings.TrimSpace(refIDs(affiliation.Ref)["teamId"])
|
|
623
|
+
}
|
|
624
|
+
if teamID == "" {
|
|
625
|
+
return affiliation
|
|
626
|
+
}
|
|
627
|
+
affiliation.ID = teamID
|
|
628
|
+
if strings.TrimSpace(affiliation.Name) != "" {
|
|
629
|
+
return affiliation
|
|
630
|
+
}
|
|
631
|
+
if s.resolver != nil {
|
|
632
|
+
_ = s.resolver.seedTeamByID(ctx, teamID, "", "")
|
|
633
|
+
if indexed, ok := s.resolver.index.FindByID(EntityTeam, teamID); ok {
|
|
634
|
+
affiliation.Name = nonEmpty(indexed.Name, indexed.ShortName)
|
|
635
|
+
if strings.TrimSpace(affiliation.Ref) == "" {
|
|
636
|
+
affiliation.Ref = indexed.Ref
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return affiliation
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
func (s *PlayerService) resolvePlayerMatchContext(
|
|
644
|
+
ctx context.Context,
|
|
645
|
+
playerQuery, matchQuery string,
|
|
646
|
+
opts PlayerLookupOptions,
|
|
647
|
+
kind EntityKind,
|
|
648
|
+
) (*playerMatchContext, *NormalizedResult) {
|
|
649
|
+
playerQuery = strings.TrimSpace(playerQuery)
|
|
650
|
+
matchQuery = strings.TrimSpace(matchQuery)
|
|
651
|
+
if playerQuery == "" {
|
|
652
|
+
result := NormalizedResult{Kind: kind, Status: ResultStatusEmpty, Message: "player query is required"}
|
|
653
|
+
return nil, &result
|
|
654
|
+
}
|
|
655
|
+
if matchQuery == "" {
|
|
656
|
+
result := NormalizedResult{Kind: kind, Status: ResultStatusEmpty, Message: "--match is required"}
|
|
657
|
+
return nil, &result
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
match, warnings, passthrough := s.resolveMatchForPlayer(ctx, matchQuery, opts.LeagueID, kind)
|
|
661
|
+
if passthrough != nil {
|
|
662
|
+
return nil, passthrough
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
searchResult, err := s.resolver.Search(ctx, EntityPlayer, playerQuery, ResolveOptions{
|
|
666
|
+
Limit: 10,
|
|
667
|
+
LeagueID: nonEmpty(strings.TrimSpace(opts.LeagueID), match.LeagueID),
|
|
668
|
+
MatchID: strings.TrimSpace(match.ID),
|
|
669
|
+
})
|
|
670
|
+
if err != nil {
|
|
671
|
+
result := NewTransportErrorResult(kind, playerQuery, err)
|
|
672
|
+
return nil, &result
|
|
673
|
+
}
|
|
674
|
+
warnings = append(warnings, searchResult.Warnings...)
|
|
675
|
+
|
|
676
|
+
candidateIDs := make([]string, 0, len(searchResult.Entities)+1)
|
|
677
|
+
candidateNames := make([]string, 0, len(searchResult.Entities)+1)
|
|
678
|
+
for _, entity := range searchResult.Entities {
|
|
679
|
+
if strings.TrimSpace(entity.ID) != "" {
|
|
680
|
+
candidateIDs = append(candidateIDs, strings.TrimSpace(entity.ID))
|
|
681
|
+
}
|
|
682
|
+
name := nonEmpty(entity.Name, entity.ShortName)
|
|
683
|
+
if name != "" {
|
|
684
|
+
candidateNames = append(candidateNames, strings.TrimSpace(name))
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if isNumeric(playerQuery) {
|
|
688
|
+
candidateIDs = append(candidateIDs, strings.TrimSpace(playerQuery))
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
team, roster, routeWarnings, found := s.findPlayerRosterEntry(ctx, *match, playerQuery, candidateIDs, candidateNames)
|
|
692
|
+
warnings = append(warnings, routeWarnings...)
|
|
693
|
+
if !found {
|
|
694
|
+
result := NormalizedResult{
|
|
695
|
+
Kind: kind,
|
|
696
|
+
Status: ResultStatusEmpty,
|
|
697
|
+
Message: fmt.Sprintf("player %q not found in match %q roster", playerQuery, match.ID),
|
|
698
|
+
}
|
|
699
|
+
return nil, &result
|
|
700
|
+
}
|
|
701
|
+
team = s.enrichTeamIdentityFromIndex(team)
|
|
702
|
+
|
|
703
|
+
playerID := strings.TrimSpace(roster.PlayerID)
|
|
704
|
+
if playerID == "" {
|
|
705
|
+
playerID = firstNonEmptyString(candidateIDs...)
|
|
706
|
+
}
|
|
707
|
+
if playerID == "" {
|
|
708
|
+
result := NormalizedResult{
|
|
709
|
+
Kind: kind,
|
|
710
|
+
Status: ResultStatusEmpty,
|
|
711
|
+
Message: fmt.Sprintf("unable to resolve player id for %q in match %q", playerQuery, match.ID),
|
|
712
|
+
}
|
|
713
|
+
return nil, &result
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
roster = s.enrichRosterEntryFromIndex(roster)
|
|
717
|
+
playerName := nonEmpty(roster.DisplayName, firstNonEmptyString(candidateNames...), strings.TrimSpace(playerQuery), "Unknown Player")
|
|
718
|
+
playerEntity := IndexedEntity{Kind: EntityPlayer, ID: playerID, Name: playerName}
|
|
719
|
+
if len(searchResult.Entities) > 0 {
|
|
720
|
+
playerEntity = searchResult.Entities[0]
|
|
721
|
+
if strings.TrimSpace(playerEntity.ID) == "" {
|
|
722
|
+
playerEntity.ID = playerID
|
|
723
|
+
}
|
|
724
|
+
if strings.TrimSpace(playerEntity.Name) == "" {
|
|
725
|
+
playerEntity.Name = playerName
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
return &playerMatchContext{
|
|
730
|
+
playerID: playerID,
|
|
731
|
+
playerName: playerName,
|
|
732
|
+
playerEntity: playerEntity,
|
|
733
|
+
match: *match,
|
|
734
|
+
team: team,
|
|
735
|
+
roster: roster,
|
|
736
|
+
warnings: compactWarnings(warnings),
|
|
737
|
+
routeWarnings: compactWarnings(routeWarnings),
|
|
738
|
+
}, nil
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
func (s *PlayerService) resolveMatchForPlayer(
|
|
742
|
+
ctx context.Context,
|
|
743
|
+
matchQuery, leagueID string,
|
|
744
|
+
kind EntityKind,
|
|
745
|
+
) (*Match, []string, *NormalizedResult) {
|
|
746
|
+
searchResult, err := s.resolver.Search(ctx, EntityMatch, strings.TrimSpace(matchQuery), ResolveOptions{
|
|
747
|
+
Limit: 5,
|
|
748
|
+
LeagueID: strings.TrimSpace(leagueID),
|
|
749
|
+
})
|
|
750
|
+
if err != nil {
|
|
751
|
+
result := NewTransportErrorResult(kind, matchQuery, err)
|
|
752
|
+
return nil, nil, &result
|
|
753
|
+
}
|
|
754
|
+
if len(searchResult.Entities) == 0 {
|
|
755
|
+
result := NormalizedResult{
|
|
756
|
+
Kind: kind,
|
|
757
|
+
Status: ResultStatusEmpty,
|
|
758
|
+
Message: fmt.Sprintf("no matches found for %q", matchQuery),
|
|
759
|
+
}
|
|
760
|
+
return nil, nil, &result
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
ref := buildMatchRef(searchResult.Entities[0])
|
|
764
|
+
if ref == "" {
|
|
765
|
+
result := NormalizedResult{
|
|
766
|
+
Kind: kind,
|
|
767
|
+
Status: ResultStatusEmpty,
|
|
768
|
+
Message: fmt.Sprintf("unable to resolve match ref for %q", matchQuery),
|
|
769
|
+
}
|
|
770
|
+
return nil, nil, &result
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
774
|
+
if err != nil {
|
|
775
|
+
result := NewTransportErrorResult(kind, ref, err)
|
|
776
|
+
return nil, nil, &result
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
match, err := NormalizeMatch(resolved.Body)
|
|
780
|
+
if err != nil {
|
|
781
|
+
result := NormalizedResult{
|
|
782
|
+
Kind: kind,
|
|
783
|
+
Status: ResultStatusError,
|
|
784
|
+
Message: fmt.Sprintf("normalize competition match %q: %v", resolved.CanonicalRef, err),
|
|
785
|
+
}
|
|
786
|
+
return nil, nil, &result
|
|
787
|
+
}
|
|
788
|
+
statusCache := map[string]matchStatusSnapshot{}
|
|
789
|
+
teamCache := map[string]teamIdentity{}
|
|
790
|
+
scoreCache := map[string]string{}
|
|
791
|
+
helper := &MatchService{client: s.client, resolver: s.resolver}
|
|
792
|
+
warnings := append([]string{}, searchResult.Warnings...)
|
|
793
|
+
warnings = append(warnings, helper.hydrateMatch(ctx, match, statusCache, teamCache, scoreCache)...)
|
|
794
|
+
return match, compactWarnings(warnings), nil
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
func (s *PlayerService) findPlayerRosterEntry(
|
|
798
|
+
ctx context.Context,
|
|
799
|
+
match Match,
|
|
800
|
+
playerQuery string,
|
|
801
|
+
candidateIDs []string,
|
|
802
|
+
candidateNames []string,
|
|
803
|
+
) (Team, TeamRosterEntry, []string, bool) {
|
|
804
|
+
normalizedQuery := normalizeAlias(playerQuery)
|
|
805
|
+
queryTokens := strings.Fields(normalizedQuery)
|
|
806
|
+
useCandidateIDs := isNumeric(strings.TrimSpace(playerQuery)) || isKnownRefQuery(strings.TrimSpace(playerQuery))
|
|
807
|
+
idSet := map[string]struct{}{}
|
|
808
|
+
for _, id := range candidateIDs {
|
|
809
|
+
id = strings.TrimSpace(id)
|
|
810
|
+
if id == "" {
|
|
811
|
+
continue
|
|
812
|
+
}
|
|
813
|
+
idSet[id] = struct{}{}
|
|
814
|
+
}
|
|
815
|
+
warnings := make([]string, 0)
|
|
816
|
+
bestScore := 0
|
|
817
|
+
var bestTeam Team
|
|
818
|
+
var bestEntry TeamRosterEntry
|
|
819
|
+
for _, team := range match.Teams {
|
|
820
|
+
rosterRef := nonEmpty(strings.TrimSpace(team.RosterRef), competitorSubresourceRef(match, team.ID, "roster"))
|
|
821
|
+
if rosterRef == "" {
|
|
822
|
+
continue
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
resolved, err := s.client.ResolveRefChain(ctx, rosterRef)
|
|
826
|
+
if err != nil {
|
|
827
|
+
warnings = append(warnings, fmt.Sprintf("roster %s: %v", rosterRef, err))
|
|
828
|
+
continue
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
entries, err := NormalizeTeamRosterEntries(resolved.Body, team, TeamScopeMatch, match.ID)
|
|
832
|
+
if err != nil {
|
|
833
|
+
warnings = append(warnings, fmt.Sprintf("roster %s: %v", resolved.CanonicalRef, err))
|
|
834
|
+
continue
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
for _, entry := range entries {
|
|
838
|
+
entry = s.enrichRosterEntryFromIndex(entry)
|
|
839
|
+
playerID := strings.TrimSpace(entry.PlayerID)
|
|
840
|
+
|
|
841
|
+
score := 0
|
|
842
|
+
if useCandidateIDs {
|
|
843
|
+
if _, ok := idSet[playerID]; ok && playerID != "" {
|
|
844
|
+
score = 5000
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
if !useCandidateIDs && normalizedQuery != "" {
|
|
848
|
+
if normalizedID := normalizeAlias(playerID); normalizedID != "" && normalizedID == normalizedQuery {
|
|
849
|
+
score = 5000
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
aliases := compactWarnings([]string{
|
|
854
|
+
entry.DisplayName,
|
|
855
|
+
playerID,
|
|
856
|
+
refIDs(entry.PlayerRef)["athleteId"],
|
|
857
|
+
})
|
|
858
|
+
for _, alias := range aliases {
|
|
859
|
+
normalizedAlias := normalizeAlias(alias)
|
|
860
|
+
if normalizedAlias == "" || normalizedQuery == "" {
|
|
861
|
+
continue
|
|
862
|
+
}
|
|
863
|
+
score = maxInt(score, aliasMatchScore(normalizedAlias, normalizedQuery, queryTokens))
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if score > bestScore {
|
|
867
|
+
bestScore = score
|
|
868
|
+
bestTeam = team
|
|
869
|
+
bestEntry = entry
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if bestScore >= 300 {
|
|
875
|
+
return bestTeam, bestEntry, compactWarnings(warnings), true
|
|
876
|
+
}
|
|
877
|
+
return Team{}, TeamRosterEntry{}, compactWarnings(warnings), false
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
func (s *PlayerService) enrichRosterEntryFromIndex(entry TeamRosterEntry) TeamRosterEntry {
|
|
881
|
+
if s == nil || s.resolver == nil || s.resolver.index == nil {
|
|
882
|
+
return entry
|
|
883
|
+
}
|
|
884
|
+
playerID := strings.TrimSpace(entry.PlayerID)
|
|
885
|
+
if playerID == "" {
|
|
886
|
+
return entry
|
|
887
|
+
}
|
|
888
|
+
player, ok := s.resolver.index.FindByID(EntityPlayer, playerID)
|
|
889
|
+
if !ok {
|
|
890
|
+
return entry
|
|
891
|
+
}
|
|
892
|
+
if strings.TrimSpace(entry.DisplayName) == "" {
|
|
893
|
+
entry.DisplayName = nonEmpty(player.Name, player.ShortName)
|
|
894
|
+
}
|
|
895
|
+
if strings.TrimSpace(entry.PlayerRef) == "" {
|
|
896
|
+
entry.PlayerRef = strings.TrimSpace(player.Ref)
|
|
897
|
+
}
|
|
898
|
+
return entry
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
func (s *PlayerService) enrichTeamIdentityFromIndex(team Team) Team {
|
|
902
|
+
if s == nil || s.resolver == nil || s.resolver.index == nil {
|
|
903
|
+
return team
|
|
904
|
+
}
|
|
905
|
+
teamID := strings.TrimSpace(team.ID)
|
|
906
|
+
if teamID == "" {
|
|
907
|
+
teamID = strings.TrimSpace(refIDs(team.Ref)["teamId"])
|
|
908
|
+
}
|
|
909
|
+
if teamID == "" {
|
|
910
|
+
teamID = strings.TrimSpace(refIDs(team.Ref)["competitorId"])
|
|
911
|
+
}
|
|
912
|
+
if teamID == "" {
|
|
913
|
+
return team
|
|
914
|
+
}
|
|
915
|
+
indexed, ok := s.resolver.index.FindByID(EntityTeam, teamID)
|
|
916
|
+
if !ok {
|
|
917
|
+
return team
|
|
918
|
+
}
|
|
919
|
+
if strings.TrimSpace(team.Name) == "" {
|
|
920
|
+
team.Name = strings.TrimSpace(indexed.Name)
|
|
921
|
+
}
|
|
922
|
+
if strings.TrimSpace(team.ShortName) == "" {
|
|
923
|
+
team.ShortName = strings.TrimSpace(indexed.ShortName)
|
|
924
|
+
}
|
|
925
|
+
if strings.TrimSpace(team.Ref) == "" {
|
|
926
|
+
team.Ref = strings.TrimSpace(indexed.Ref)
|
|
927
|
+
}
|
|
928
|
+
return team
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
func rosterPlayerStatisticsRef(match Match, team Team, entry TeamRosterEntry) string {
|
|
932
|
+
if ref := strings.TrimSpace(entry.StatisticsRef); ref != "" {
|
|
933
|
+
return ref
|
|
934
|
+
}
|
|
935
|
+
if base := competitorSubresourceRef(match, team.ID, ""); base != "" && strings.TrimSpace(entry.PlayerID) != "" {
|
|
936
|
+
return strings.TrimRight(base, "/") + "/roster/" + strings.TrimSpace(entry.PlayerID) + "/statistics/0"
|
|
937
|
+
}
|
|
938
|
+
return ""
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
func rosterPlayerLinescoresRef(match Match, team Team, entry TeamRosterEntry) string {
|
|
942
|
+
if ref := strings.TrimSpace(entry.LinescoresRef); ref != "" {
|
|
943
|
+
return ref
|
|
944
|
+
}
|
|
945
|
+
if base := competitorSubresourceRef(match, team.ID, ""); base != "" && strings.TrimSpace(entry.PlayerID) != "" {
|
|
946
|
+
return strings.TrimRight(base, "/") + "/roster/" + strings.TrimSpace(entry.PlayerID) + "/linescores"
|
|
947
|
+
}
|
|
948
|
+
return ""
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
func rosterPlayerLinescoreStatisticsRef(match Match, team Team, entry TeamRosterEntry, innings, period int) string {
|
|
952
|
+
if innings <= 0 || period <= 0 {
|
|
953
|
+
return ""
|
|
954
|
+
}
|
|
955
|
+
base := competitorSubresourceRef(match, team.ID, "")
|
|
956
|
+
if base == "" || strings.TrimSpace(entry.PlayerID) == "" {
|
|
957
|
+
return ""
|
|
958
|
+
}
|
|
959
|
+
return fmt.Sprintf("%s/roster/%s/linescores/%d/%d/statistics/0", strings.TrimRight(base, "/"), strings.TrimSpace(entry.PlayerID), innings, period)
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
func firstPlayerLinescoreStatisticsRef(row map[string]any) string {
|
|
963
|
+
linescores := mapSliceField(row, "linescores")
|
|
964
|
+
if len(linescores) == 0 {
|
|
965
|
+
return ""
|
|
966
|
+
}
|
|
967
|
+
return stringField(linescores[0], "statistics")
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
func (s *PlayerService) fetchStatCategories(ctx context.Context, ref string) (*ResolvedDocument, []StatCategory, error) {
|
|
971
|
+
resolved, err := s.client.ResolveRefChain(ctx, ref)
|
|
972
|
+
if err != nil {
|
|
973
|
+
return nil, nil, err
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
categories, err := NormalizeStatCategories(resolved.Body)
|
|
977
|
+
if err != nil {
|
|
978
|
+
return nil, nil, fmt.Errorf("normalize stat categories %q: %w", resolved.CanonicalRef, err)
|
|
979
|
+
}
|
|
980
|
+
return resolved, categories, nil
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
func splitPlayerStatCategories(categories []StatCategory) ([]StatCategory, []StatCategory, []StatCategory) {
|
|
984
|
+
batting := make([]StatCategory, 0)
|
|
985
|
+
bowling := make([]StatCategory, 0)
|
|
986
|
+
fielding := make([]StatCategory, 0)
|
|
987
|
+
|
|
988
|
+
for _, category := range categories {
|
|
989
|
+
role := playerStatCategoryRole(category)
|
|
990
|
+
switch role {
|
|
991
|
+
case "batting":
|
|
992
|
+
batting = append(batting, category)
|
|
993
|
+
case "bowling":
|
|
994
|
+
bowling = append(bowling, category)
|
|
995
|
+
default:
|
|
996
|
+
fielding = append(fielding, category)
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
return batting, bowling, fielding
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
func playerStatCategoryRole(category StatCategory) string {
|
|
1004
|
+
battingScore := 0
|
|
1005
|
+
bowlingScore := 0
|
|
1006
|
+
fieldingScore := 0
|
|
1007
|
+
for _, stat := range category.Stats {
|
|
1008
|
+
name := normalizeStatName(stat.Name)
|
|
1009
|
+
switch name {
|
|
1010
|
+
case "ballsfaced", "batted", "battingid", "battingposition", "ducks", "fiftyplus", "fours", "highscore", "hundreds", "minutes", "notouts", "outs", "retireddescription", "runs", "sixes", "strikerate", "dismissalname", "dismissalcard":
|
|
1011
|
+
battingScore++
|
|
1012
|
+
case "balls", "bowled", "bowlingid", "bowlingposition", "bpo", "conceded", "dots", "economyrate", "fivewickets", "fourpluswickets", "foursconceded", "illegaloverlimit", "maidens", "noballs", "overs", "sixesconceded", "tenwickets", "wickets", "wides":
|
|
1013
|
+
bowlingScore++
|
|
1014
|
+
case "dismissals", "fielded", "caught", "caughtfielder", "caughtkeeper", "stumped", "runout":
|
|
1015
|
+
fieldingScore++
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
switch {
|
|
1020
|
+
case battingScore >= bowlingScore && battingScore >= fieldingScore && battingScore > 0:
|
|
1021
|
+
return "batting"
|
|
1022
|
+
case bowlingScore >= battingScore && bowlingScore >= fieldingScore && bowlingScore > 0:
|
|
1023
|
+
return "bowling"
|
|
1024
|
+
case fieldingScore > 0:
|
|
1025
|
+
return "fielding"
|
|
1026
|
+
default:
|
|
1027
|
+
return "fielding"
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
func summarizePlayerMatchCategories(categories []StatCategory) PlayerMatchSummary {
|
|
1032
|
+
summary := PlayerMatchSummary{}
|
|
1033
|
+
ballsBowled := 0
|
|
1034
|
+
concededRuns := 0
|
|
1035
|
+
strikeRateCount := 0
|
|
1036
|
+
economyRateCount := 0
|
|
1037
|
+
totalRuns := 0
|
|
1038
|
+
|
|
1039
|
+
for _, category := range categories {
|
|
1040
|
+
for _, stat := range category.Stats {
|
|
1041
|
+
name := normalizeStatName(stat.Name)
|
|
1042
|
+
intValue := statAsInt(stat)
|
|
1043
|
+
floatValue := statAsFloat(stat)
|
|
1044
|
+
stringValue := firstNonEmptyString(strings.TrimSpace(stat.DisplayValue), statAsString(stat))
|
|
1045
|
+
|
|
1046
|
+
switch name {
|
|
1047
|
+
case "dismissalname":
|
|
1048
|
+
if summary.DismissalName == "" {
|
|
1049
|
+
summary.DismissalName = stringValue
|
|
1050
|
+
}
|
|
1051
|
+
case "dismissalcard":
|
|
1052
|
+
if summary.DismissalCard == "" {
|
|
1053
|
+
summary.DismissalCard = stringValue
|
|
1054
|
+
}
|
|
1055
|
+
case "ballsfaced":
|
|
1056
|
+
summary.BallsFaced += intValue
|
|
1057
|
+
case "strikerate":
|
|
1058
|
+
if floatValue > 0 {
|
|
1059
|
+
summary.StrikeRate += floatValue
|
|
1060
|
+
strikeRateCount++
|
|
1061
|
+
}
|
|
1062
|
+
case "dots":
|
|
1063
|
+
summary.Dots += intValue
|
|
1064
|
+
case "economyrate":
|
|
1065
|
+
if floatValue > 0 {
|
|
1066
|
+
summary.EconomyRate += floatValue
|
|
1067
|
+
economyRateCount++
|
|
1068
|
+
}
|
|
1069
|
+
case "maidens":
|
|
1070
|
+
summary.Maidens += intValue
|
|
1071
|
+
case "foursconceded":
|
|
1072
|
+
summary.FoursConceded += intValue
|
|
1073
|
+
case "sixesconceded":
|
|
1074
|
+
summary.SixesConceded += intValue
|
|
1075
|
+
case "wides":
|
|
1076
|
+
summary.Wides += intValue
|
|
1077
|
+
case "noballs":
|
|
1078
|
+
summary.Noballs += intValue
|
|
1079
|
+
case "bowlerplayerid":
|
|
1080
|
+
if summary.BowlerPlayerID == "" {
|
|
1081
|
+
summary.BowlerPlayerID = stringValue
|
|
1082
|
+
}
|
|
1083
|
+
case "fielderplayerid":
|
|
1084
|
+
if summary.FielderPlayerID == "" {
|
|
1085
|
+
summary.FielderPlayerID = stringValue
|
|
1086
|
+
}
|
|
1087
|
+
case "runs":
|
|
1088
|
+
totalRuns += intValue
|
|
1089
|
+
case "balls":
|
|
1090
|
+
ballsBowled += intValue
|
|
1091
|
+
case "conceded":
|
|
1092
|
+
concededRuns += intValue
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if summary.BallsFaced > 0 && totalRuns > 0 {
|
|
1098
|
+
summary.StrikeRate = (float64(totalRuns) * 100) / float64(summary.BallsFaced)
|
|
1099
|
+
} else if strikeRateCount > 0 {
|
|
1100
|
+
summary.StrikeRate = summary.StrikeRate / float64(strikeRateCount)
|
|
1101
|
+
}
|
|
1102
|
+
if ballsBowled > 0 && concededRuns > 0 {
|
|
1103
|
+
overs := float64(ballsBowled) / 6.0
|
|
1104
|
+
if overs > 0 {
|
|
1105
|
+
summary.EconomyRate = float64(concededRuns) / overs
|
|
1106
|
+
}
|
|
1107
|
+
} else if economyRateCount > 0 {
|
|
1108
|
+
summary.EconomyRate = summary.EconomyRate / float64(economyRateCount)
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return summary
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
func normalizeStatName(name string) string {
|
|
1115
|
+
replacer := strings.NewReplacer(" ", "", "-", "", "_", "")
|
|
1116
|
+
return strings.ToLower(replacer.Replace(strings.TrimSpace(name)))
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
func statAsString(stat StatValue) string {
|
|
1120
|
+
switch typed := stat.Value.(type) {
|
|
1121
|
+
case string:
|
|
1122
|
+
return strings.TrimSpace(typed)
|
|
1123
|
+
case float64:
|
|
1124
|
+
if typed == float64(int64(typed)) {
|
|
1125
|
+
return fmt.Sprintf("%d", int64(typed))
|
|
1126
|
+
}
|
|
1127
|
+
return fmt.Sprintf("%g", typed)
|
|
1128
|
+
case int:
|
|
1129
|
+
return fmt.Sprintf("%d", typed)
|
|
1130
|
+
case int64:
|
|
1131
|
+
return fmt.Sprintf("%d", typed)
|
|
1132
|
+
case bool:
|
|
1133
|
+
if typed {
|
|
1134
|
+
return "true"
|
|
1135
|
+
}
|
|
1136
|
+
return "false"
|
|
1137
|
+
default:
|
|
1138
|
+
return strings.TrimSpace(fmt.Sprintf("%v", stat.Value))
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
func statAsInt(stat StatValue) int {
|
|
1143
|
+
raw := firstNonEmptyString(statAsString(stat), strings.TrimSpace(stat.DisplayValue))
|
|
1144
|
+
value, err := strconvAtoi(raw)
|
|
1145
|
+
if err == nil {
|
|
1146
|
+
return value
|
|
1147
|
+
}
|
|
1148
|
+
floatValue, floatErr := strconvParseFloat(raw)
|
|
1149
|
+
if floatErr == nil {
|
|
1150
|
+
return int(floatValue)
|
|
1151
|
+
}
|
|
1152
|
+
return 0
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
func statAsFloat(stat StatValue) float64 {
|
|
1156
|
+
raw := firstNonEmptyString(statAsString(stat), strings.TrimSpace(stat.DisplayValue))
|
|
1157
|
+
value, err := strconvParseFloat(raw)
|
|
1158
|
+
if err == nil {
|
|
1159
|
+
return value
|
|
1160
|
+
}
|
|
1161
|
+
return 0
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
func (s *PlayerService) fetchPlayerDeliveries(
|
|
1165
|
+
ctx context.Context,
|
|
1166
|
+
contextData *playerMatchContext,
|
|
1167
|
+
dismissalsOnly bool,
|
|
1168
|
+
) (*ResolvedDocument, []DeliveryEvent, []string, error) {
|
|
1169
|
+
detailsRef := nonEmpty(strings.TrimSpace(contextData.match.DetailsRef), matchSubresourceRef(contextData.match, "details", "details"))
|
|
1170
|
+
if detailsRef == "" {
|
|
1171
|
+
return nil, nil, nil, fmt.Errorf("details route unavailable for match %q", contextData.match.ID)
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
resolved, err := s.client.ResolveRefChain(ctx, detailsRef)
|
|
1175
|
+
if err != nil {
|
|
1176
|
+
return nil, nil, nil, err
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
page, err := DecodePage[Ref](resolved.Body)
|
|
1180
|
+
if err != nil {
|
|
1181
|
+
return nil, nil, nil, fmt.Errorf("decode details page %q: %w", resolved.CanonicalRef, err)
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
warnings := make([]string, 0)
|
|
1185
|
+
pageItems := append([]Ref(nil), page.Items...)
|
|
1186
|
+
if page.PageCount > 1 {
|
|
1187
|
+
helper := &MatchService{client: s.client, resolver: s.resolver}
|
|
1188
|
+
extraItems, pageWarnings, pageErr := helper.resolvePageRefs(ctx, resolved)
|
|
1189
|
+
if pageErr != nil {
|
|
1190
|
+
warnings = append(warnings, pageErr.Error())
|
|
1191
|
+
} else {
|
|
1192
|
+
pageItems = extraItems
|
|
1193
|
+
warnings = append(warnings, pageWarnings...)
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
helper := &MatchService{client: s.client, resolver: s.resolver}
|
|
1198
|
+
loaded, loadWarnings := helper.loadDeliveryEvents(ctx, pageItems)
|
|
1199
|
+
warnings = append(warnings, loadWarnings...)
|
|
1200
|
+
|
|
1201
|
+
deliveries := make([]DeliveryEvent, 0, len(loaded))
|
|
1202
|
+
for _, delivery := range loaded {
|
|
1203
|
+
roles := playerInvolvement(delivery, contextData.playerID)
|
|
1204
|
+
if len(roles) == 0 {
|
|
1205
|
+
continue
|
|
1206
|
+
}
|
|
1207
|
+
if dismissalsOnly && !isDismissalDelivery(delivery) {
|
|
1208
|
+
continue
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
delivery.MatchID = nonEmpty(delivery.MatchID, contextData.match.ID)
|
|
1212
|
+
delivery.CompetitionID = nonEmpty(delivery.CompetitionID, contextData.match.CompetitionID, contextData.match.ID)
|
|
1213
|
+
delivery.EventID = nonEmpty(delivery.EventID, contextData.match.EventID)
|
|
1214
|
+
delivery.LeagueID = nonEmpty(delivery.LeagueID, contextData.match.LeagueID)
|
|
1215
|
+
delivery.TeamID = nonEmpty(delivery.TeamID, contextData.team.ID)
|
|
1216
|
+
delivery.Involvement = roles
|
|
1217
|
+
deliveries = append(deliveries, delivery)
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return resolved, deliveries, compactWarnings(warnings), nil
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
func (s *PlayerService) collectMatchWicketMetadata(ctx context.Context, match Match) (map[string]wicketMetadata, map[string]wicketMetadata, []string) {
|
|
1224
|
+
helper := &MatchService{client: s.client, resolver: s.resolver}
|
|
1225
|
+
byRef := map[string]wicketMetadata{}
|
|
1226
|
+
byDetailID := map[string]wicketMetadata{}
|
|
1227
|
+
warnings := make([]string, 0)
|
|
1228
|
+
|
|
1229
|
+
for _, team := range match.Teams {
|
|
1230
|
+
inningsList, _, inningsWarnings := helper.fetchTeamInnings(ctx, match, team)
|
|
1231
|
+
warnings = append(warnings, inningsWarnings...)
|
|
1232
|
+
|
|
1233
|
+
for i := range inningsList {
|
|
1234
|
+
warnings = append(warnings, helper.hydrateInningsTimelines(ctx, &inningsList[i])...)
|
|
1235
|
+
for _, wicket := range inningsList[i].WicketTimeline {
|
|
1236
|
+
if strings.TrimSpace(wicket.DetailRef) == "" {
|
|
1237
|
+
continue
|
|
1238
|
+
}
|
|
1239
|
+
meta := wicketMetadata{
|
|
1240
|
+
team: team,
|
|
1241
|
+
innings: inningsList[i],
|
|
1242
|
+
wicket: wicket,
|
|
1243
|
+
}
|
|
1244
|
+
byRef[strings.TrimSpace(wicket.DetailRef)] = meta
|
|
1245
|
+
if detailID := strings.TrimSpace(refIDs(wicket.DetailRef)["detailId"]); detailID != "" {
|
|
1246
|
+
byDetailID[detailID] = meta
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
return byRef, byDetailID, compactWarnings(warnings)
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
func playerInvolvement(delivery DeliveryEvent, playerID string) []string {
|
|
1256
|
+
playerID = strings.TrimSpace(playerID)
|
|
1257
|
+
if playerID == "" {
|
|
1258
|
+
return nil
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
roles := make([]string, 0, 3)
|
|
1262
|
+
if strings.TrimSpace(delivery.BatsmanPlayerID) == playerID {
|
|
1263
|
+
roles = append(roles, "batting")
|
|
1264
|
+
}
|
|
1265
|
+
if strings.TrimSpace(delivery.BowlerPlayerID) == playerID {
|
|
1266
|
+
roles = append(roles, "bowling")
|
|
1267
|
+
}
|
|
1268
|
+
if strings.TrimSpace(delivery.FielderPlayerID) == playerID {
|
|
1269
|
+
roles = append(roles, "fielding")
|
|
1270
|
+
}
|
|
1271
|
+
for _, involved := range delivery.AthletePlayerIDs {
|
|
1272
|
+
if strings.TrimSpace(involved) == playerID && !containsString(roles, "involved") {
|
|
1273
|
+
roles = append(roles, "involved")
|
|
1274
|
+
break
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
if len(roles) == 0 {
|
|
1278
|
+
return nil
|
|
1279
|
+
}
|
|
1280
|
+
return roles
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
func isDismissalDelivery(delivery DeliveryEvent) bool {
|
|
1284
|
+
return boolField(delivery.Dismissal, "dismissal")
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
func containsString(values []string, needle string) bool {
|
|
1288
|
+
for _, value := range values {
|
|
1289
|
+
if strings.TrimSpace(value) == strings.TrimSpace(needle) {
|
|
1290
|
+
return true
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
return false
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
func teamDisplayLabel(team Team) string {
|
|
1297
|
+
return nonEmpty(strings.TrimSpace(team.ShortName), strings.TrimSpace(team.Name), "Unknown Team")
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
func firstNonZero(values ...int) int {
|
|
1301
|
+
for _, value := range values {
|
|
1302
|
+
if value != 0 {
|
|
1303
|
+
return value
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return 0
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
func firstNonEmptyString(values ...string) string {
|
|
1310
|
+
for _, value := range values {
|
|
1311
|
+
value = strings.TrimSpace(value)
|
|
1312
|
+
if value != "" {
|
|
1313
|
+
return value
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
return ""
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
func strconvAtoi(raw string) (int, error) {
|
|
1320
|
+
return strconv.Atoi(strings.TrimSpace(raw))
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
func strconvParseFloat(raw string) (float64, error) {
|
|
1324
|
+
return strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
func limitOrDefault(value, fallback int) int {
|
|
1328
|
+
if value > 0 {
|
|
1329
|
+
return value
|
|
1330
|
+
}
|
|
1331
|
+
return fallback
|
|
1332
|
+
}
|