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.
Files changed (121) hide show
  1. package/AGENTS.md +63 -0
  2. package/CONTRIBUTORS.md +75 -0
  3. package/LICENSE +21 -0
  4. package/Makefile +131 -0
  5. package/README.md +130 -0
  6. package/bin/cricinfo.js +44 -0
  7. package/cmd/cricinfo/main.go +15 -0
  8. package/go.mod +10 -0
  9. package/go.sum +10 -0
  10. package/internal/app/app.go +11 -0
  11. package/internal/app/app_test.go +122 -0
  12. package/internal/buildinfo/buildinfo.go +16 -0
  13. package/internal/cli/analysis.go +262 -0
  14. package/internal/cli/analysis_test.go +175 -0
  15. package/internal/cli/competitions.go +154 -0
  16. package/internal/cli/competitions_test.go +165 -0
  17. package/internal/cli/leagues.go +297 -0
  18. package/internal/cli/leagues_test.go +194 -0
  19. package/internal/cli/matches.go +403 -0
  20. package/internal/cli/matches_test.go +413 -0
  21. package/internal/cli/players.go +263 -0
  22. package/internal/cli/players_test.go +384 -0
  23. package/internal/cli/root.go +141 -0
  24. package/internal/cli/search.go +119 -0
  25. package/internal/cli/teams.go +214 -0
  26. package/internal/cli/teams_test.go +192 -0
  27. package/internal/cricinfo/analysis.go +1401 -0
  28. package/internal/cricinfo/analysis_phase15_test.go +267 -0
  29. package/internal/cricinfo/client.go +471 -0
  30. package/internal/cricinfo/client_test.go +280 -0
  31. package/internal/cricinfo/cmd/fixture-refresh/main.go +145 -0
  32. package/internal/cricinfo/competitions.go +405 -0
  33. package/internal/cricinfo/competitions_phase13_test.go +234 -0
  34. package/internal/cricinfo/coverage_ledger.go +122 -0
  35. package/internal/cricinfo/coverage_ledger_test.go +253 -0
  36. package/internal/cricinfo/decode.go +115 -0
  37. package/internal/cricinfo/decode_test.go +100 -0
  38. package/internal/cricinfo/entity_index.go +618 -0
  39. package/internal/cricinfo/entity_index_test.go +175 -0
  40. package/internal/cricinfo/fixture_matrix.go +243 -0
  41. package/internal/cricinfo/fixture_matrix_test.go +49 -0
  42. package/internal/cricinfo/fixtures_test.go +264 -0
  43. package/internal/cricinfo/historical_hydration.go +1641 -0
  44. package/internal/cricinfo/historical_phase14_test.go +542 -0
  45. package/internal/cricinfo/leagues.go +1210 -0
  46. package/internal/cricinfo/leagues_phase12_test.go +324 -0
  47. package/internal/cricinfo/live_leagues_test.go +169 -0
  48. package/internal/cricinfo/live_matches_test.go +203 -0
  49. package/internal/cricinfo/live_matrix_test.go +118 -0
  50. package/internal/cricinfo/live_players_test.go +122 -0
  51. package/internal/cricinfo/live_search_test.go +86 -0
  52. package/internal/cricinfo/live_smoke_test.go +213 -0
  53. package/internal/cricinfo/live_teams_test.go +104 -0
  54. package/internal/cricinfo/matches.go +1508 -0
  55. package/internal/cricinfo/matches_phase7_test.go +207 -0
  56. package/internal/cricinfo/matches_phase9_test.go +253 -0
  57. package/internal/cricinfo/normalize_entities.go +1727 -0
  58. package/internal/cricinfo/normalize_leagues.go +346 -0
  59. package/internal/cricinfo/players.go +1332 -0
  60. package/internal/cricinfo/players_phase10_test.go +174 -0
  61. package/internal/cricinfo/players_phase11_test.go +373 -0
  62. package/internal/cricinfo/render_contract.go +1088 -0
  63. package/internal/cricinfo/render_phase4_test.go +633 -0
  64. package/internal/cricinfo/renderer.go +1689 -0
  65. package/internal/cricinfo/resolver.go +813 -0
  66. package/internal/cricinfo/resolver_test.go +244 -0
  67. package/internal/cricinfo/teams.go +603 -0
  68. package/internal/cricinfo/teams_phase8_test.go +231 -0
  69. package/internal/cricinfo/testdata/fixtures/README.md +43 -0
  70. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/broadcasts.json +11 -0
  71. package/internal/cricinfo/testdata/fixtures/aux-competition-metadata/officials.json +150 -0
  72. package/internal/cricinfo/testdata/fixtures/details-plays/detail-110.json +157 -0
  73. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52545007.json +145 -0
  74. package/internal/cricinfo/testdata/fixtures/details-plays/detail-52559021.json +143 -0
  75. package/internal/cricinfo/testdata/fixtures/details-plays/plays.json +15 -0
  76. package/internal/cricinfo/testdata/fixtures/endpoint-matrix.tsv +19 -0
  77. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow-1.json +12 -0
  78. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/fow.json +42 -0
  79. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/innings-1-2.json +38 -0
  80. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnership-1.json +31 -0
  81. package/internal/cricinfo/testdata/fixtures/innings-fow-partnerships/partnerships.json +42 -0
  82. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-offdays.json +20 -0
  83. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar-ondays.json +21 -0
  84. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/calendar.json +14 -0
  85. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-2025.json +13 -0
  86. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-group-1.json +13 -0
  87. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-groups.json +11 -0
  88. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-type-1.json +13 -0
  89. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/season-types.json +11 -0
  90. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/seasons.json +30 -0
  91. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-item-1.json +72 -0
  92. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings-root.json +3 -0
  93. package/internal/cricinfo/testdata/fixtures/leagues-seasons-standings/standings.json +15 -0
  94. package/internal/cricinfo/testdata/fixtures/matches-competitions/competition.json +460 -0
  95. package/internal/cricinfo/testdata/fixtures/matches-competitions/event-1529474.json +86 -0
  96. package/internal/cricinfo/testdata/fixtures/matches-competitions/matchcards-1527966.json +368 -0
  97. package/internal/cricinfo/testdata/fixtures/matches-competitions/situation-1529474.json +10 -0
  98. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257-statistics.json +126 -0
  99. package/internal/cricinfo/testdata/fixtures/players/athlete-1361257.json +113 -0
  100. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-1-statistics-0.json +208 -0
  101. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores-1-2-statistics-0.json +252 -0
  102. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-linescores.json +74 -0
  103. package/internal/cricinfo/testdata/fixtures/players/roster-1361257-statistics-0.json +1008 -0
  104. package/internal/cricinfo/testdata/fixtures/root-discovery/events.json +72 -0
  105. package/internal/cricinfo/testdata/fixtures/root-discovery/root.json +28 -0
  106. package/internal/cricinfo/testdata/fixtures/team-competitor/competitor-789643.json +40 -0
  107. package/internal/cricinfo/testdata/fixtures/team-competitor/leaders-789643.json +353 -0
  108. package/internal/cricinfo/testdata/fixtures/team-competitor/records-789643.json +91 -0
  109. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772-object.json +231 -0
  110. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-1147772.json +235 -0
  111. package/internal/cricinfo/testdata/fixtures/team-competitor/roster-789643.json +322 -0
  112. package/internal/cricinfo/testdata/fixtures/team-competitor/scores-789643.json +19 -0
  113. package/internal/cricinfo/testdata/fixtures/team-competitor/statistics-789643.json +629 -0
  114. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643-athletes.json +7 -0
  115. package/internal/cricinfo/testdata/fixtures/team-competitor/team-789643.json +67 -0
  116. package/internal/cricinfo/testdata/golden/match-empty.golden +1 -0
  117. package/internal/cricinfo/testdata/golden/match-list.golden +2 -0
  118. package/internal/cricinfo/testdata/golden/match-partial.golden +3 -0
  119. package/internal/cricinfo/types.go +54 -0
  120. package/package.json +51 -0
  121. 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
+ }