From eb2ec2e850cef7a03c2a100cbdee360dd6a536b0 Mon Sep 17 00:00:00 2001 From: Sam Shen Date: Mon, 9 Jun 2025 14:53:29 -0700 Subject: [PATCH] Track defensive errors and misplays * update tooling to latest --- .github/workflows/main.yml | 10 +-- .golangci.yaml | 14 +++++ TODO.md | 4 +- cmd/alt.go | 4 ++ go.mod | 4 +- hack/build.sh | 7 --- pkg/boxscore/box.go | 48 +++++++++++--- pkg/boxscore/box.tmpl | 3 +- pkg/boxscore/lineup.go | 64 ++++++++++++++++--- pkg/boxscore/util.go | 23 ------- pkg/boxscore/util_test.go | 5 -- pkg/dataframe/data.go | 8 +-- pkg/dataframe/pkg/datapackage.go | 4 +- pkg/dataframe/select.go | 1 + pkg/game/fieldingerror.go | 5 ++ pkg/game/game.go | 15 ++--- pkg/game/gamemachine.go | 14 +++++ pkg/game/state.go | 11 ++-- pkg/game/team.go | 25 ++++++-- pkg/stats/alt_data.go | 104 ++++++++++++++++++++++++++++--- pkg/stats/fielding.go | 19 +++++- pkg/stats/gamestats.go | 4 ++ pkg/stats/player.go | 2 + pkg/stats/teamstats.go | 13 ++-- pkg/text/short.go | 48 ++++++++++++++ pkg/text/short_test.go | 39 ++++++++++++ pkg/ui/ui.go | 2 +- 27 files changed, 400 insertions(+), 100 deletions(-) create mode 100644 .golangci.yaml create mode 100644 pkg/text/short.go create mode 100644 pkg/text/short_test.go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 12e1635..1640943 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,7 +30,7 @@ jobs: - name: Setting up go uses: actions/setup-go@v2 with: - go-version: '1.22' + go-version: '1.24' - name: Caching go modules uses: actions/cache@v4 with: @@ -38,8 +38,10 @@ jobs: key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go- - - name: Installing golangci-lint - run: wget -O - -q https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s v1.58.1 + - name: lint + uses: golangci/golangci-lint-action@v8 + with: + version: v2.1 - name: Build and Unit Test run: ./hack/build.sh - + \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..0d05084 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,14 @@ +version: "2" +linters: + default: none + enable: + - gosec + - misspell + - gocritic + - whitespace + - goprintffuncname + settings: + gosec: + excludes: + - G304 + diff --git a/TODO.md b/TODO.md index 1708876..0e0d89a 100644 --- a/TODO.md +++ b/TODO.md @@ -6,10 +6,12 @@ * Verify batting order -* Record defensive positions +~~* Record defensive positions~~ * Count hard hit balls (foul or in-play) * Implement "final" and "err" specials. Remove final score. * Upload game logs. Add per-game RE24. + +* Record DP, FLEX and pinch runners somehow \ No newline at end of file diff --git a/cmd/alt.go b/cmd/alt.go index 0477aa1..d70f9d3 100644 --- a/cmd/alt.go +++ b/cmd/alt.go @@ -36,6 +36,10 @@ func altCommand() *cobra.Command { alt.Name = fmt.Sprintf("%s game %s %s at %s Alt Plays", g.Date, g.Number, g.Visitor.Name, g.Home.Name) alt.RemoveColumn("Game") fmt.Println(alt) + pp := gs.GetPerPlayerAltData() + if pp.RowCount() > 0 { + fmt.Println(pp) + } } return nil }, diff --git a/go.mod b/go.mod index 32633ed..b9aa509 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/slshen/paperscore -go 1.22.0 - -toolchain go1.22.3 +go 1.24.0 require ( github.com/alecthomas/participle/v2 v2.1.1 diff --git a/hack/build.sh b/hack/build.sh index 5138f5b..6a014d2 100755 --- a/hack/build.sh +++ b/hack/build.sh @@ -4,10 +4,3 @@ set -euo pipefail set -x go test -cover ./... - -linter=golangci-lint -if [ -x ./bin/golangci-lint ]; then - linter=./bin/golangci-lint -fi -$linter run -E stylecheck -E gosec -E goimports -E misspell -E gocritic \ - -E whitespace -E goprintffuncname \ No newline at end of file diff --git a/pkg/boxscore/box.go b/pkg/boxscore/box.go index 277b812..2df3eeb 100644 --- a/pkg/boxscore/box.go +++ b/pkg/boxscore/box.go @@ -48,8 +48,8 @@ func NewBoxScore(g *game.Game, re stats.RunExpectancy) (*BoxScore, error) { boxscore := &BoxScore{ Game: g, Stats: gs, - HomeLineup: &Lineup{gs.GetStats(g.Home)}, - VisitorLineup: &Lineup{gs.GetStats(g.Visitor)}, + HomeLineup: newLineup(gs.GetStats(g.Home)), + VisitorLineup: newLineup(gs.GetStats(g.Visitor)), } if err := boxscore.run(); err != nil { return nil, err @@ -94,11 +94,9 @@ func (box *BoxScore) InningScoreTable() *dataframe.Data { tab := &dataframe.Data{ Columns: []*dataframe.Column{ { - Name: fmt.Sprintf("%s #%s", box.Game.Date, box.Game.Number), - Format: "%-20s", Values: []string{ - firstWord(box.Game.Visitor.Name, 20), - firstWord(box.Game.Home.Name, 20), + box.Game.Visitor.ShortName, + box.Game.Home.ShortName, }, }, }, @@ -139,13 +137,47 @@ func (box *BoxScore) InningScoreTable() *dataframe.Data { func (box *BoxScore) AltPlays() *dataframe.Data { dat := box.Stats.GetAltData() dat = dat.Select( - dataframe.Col("In"), + dataframe.DeriveStrings("Inn", func(idx *dataframe.Index, i int) string { + inn := idx.GetInt(i, "I") + half := idx.GetString(i, "H") + o := idx.GetInt(i, "O") + return fmt.Sprintf("%c%d.%d", half[0], inn, o) + }).WithFormat("%4s"), dataframe.Rename("Reality", "Play").WithFormat("%-30s"), - dataframe.Col("RCost"), dataframe.Col("Comment")) + dataframe.Col("RCost"), + dataframe.Col("Comment"), + dataframe.DeriveStrings("Players", func(idx *dataframe.Index, i int) string { + credit := idx.GetString(i, "Credit") + if credit == "" { + return "" + } + s := &strings.Builder{} + for p := range strings.FieldsSeq(credit) { + player := box.Game.GetPlayer(game.PlayerID(p)) + if s.Len() > 0 { + s.WriteString(", ") + } + s.WriteString(player.GetShortName()) + } + return s.String() + }), + ) dat.Name = "ALT" return dat } +func (box *BoxScore) AltPlaysPerPlayer() *dataframe.Data { + dat := box.Stats.GetPerPlayerAltData() + dat.Name = "ALT CREDIT" + idx := dat.GetIndex() + idx.GetColumn("Player").Format = "%-20s" + dat.RApply(func(row int) { + player := box.Game.GetPlayer(game.PlayerID(idx.GetString(row, "Player"))) + idx.GetColumn("Player").GetStrings()[row] = player.GetShortName() + }) + return dat +} + func (box *BoxScore) ScoringPlays() (string, error) { gen := playbyplay.Generator{ Game: box.Game, diff --git a/pkg/boxscore/box.tmpl b/pkg/boxscore/box.tmpl index 1575ebc..2d70486 100644 --- a/pkg/boxscore/box.tmpl +++ b/pkg/boxscore/box.tmpl @@ -1,11 +1,12 @@ {{.Game.Visitor.Name}} at {{.Game.Home.Name}} {{.Game.Date}} game {{.Game.Number}} {{.InningScoreTable}} -{{paste .VisitorLineup.BattingTable.String .HomeLineup.BattingTable.String 1 44}} +{{paste .VisitorLineup.PlayerTable.String .HomeLineup.PlayerTable.String 1 44}} {{paste (execute "batting.tmpl" .VisitorLineup) (execute "batting.tmpl" .HomeLineup) 1 44}} {{- paste .VisitorLineup.PitchingTable.String .HomeLineup.PitchingTable.String 1 -44}} {{paste (execute "pitching.tmpl" .VisitorLineup) (execute "pitching.tmpl" .HomeLineup) 1 44}} {{.AltPlays}} +{{.AltPlaysPerPlayer}} {{if (not (or .IncludePlays .IncludeScoringPlays))}} {{- range .Comments}}{{.Half}} {{ordinal .Inning}}, {{.Outs}} Outs - {{.Text}} {{end}}{{end}} diff --git a/pkg/boxscore/lineup.go b/pkg/boxscore/lineup.go index 52b4da3..031f1d8 100644 --- a/pkg/boxscore/lineup.go +++ b/pkg/boxscore/lineup.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/slshen/paperscore/pkg/dataframe" + "github.com/slshen/paperscore/pkg/game" "github.com/slshen/paperscore/pkg/stats" "github.com/slshen/paperscore/pkg/text" ) @@ -13,8 +14,42 @@ type Lineup struct { *stats.TeamStats } -func (lineup *Lineup) BattingTable() *dataframe.Data { - dat := lineup.GetBattingData().Select( +func newLineup(ts *stats.TeamStats) *Lineup { + return &Lineup{ + TeamStats: ts, + } +} + +func (lineup *Lineup) haveDefensivePositions() bool { + for _, positions := range lineup.PositionsByPlayer { + for _, p := range positions { + if p != 1 { + return true + } + } + } + return false +} + +func (lineup *Lineup) PlayerTable() *dataframe.Data { + batting := lineup.GetBattingData() + selection := []dataframe.Selection{} + // if we have any defensive lineup data available other than pitchers, include "F" column + if lineup.haveDefensivePositions() { + selection = append(selection, + dataframe.DeriveStrings("F", func(idx *dataframe.Index, i int) string { + positions := lineup.PositionsByPlayer[game.PlayerID(idx.GetString(i, "PlayerID"))] + s := &strings.Builder{} + for _, pos := range positions { + if s.Len() > 0 { + s.WriteRune(' ') + } + s.WriteString(game.FielderNames[pos-1]) + } + return s.String() + }).WithFormat("%-5s")) + } + selection = append(selection, dataframe.Rename("Name", "#").WithFormat("%-14s"), dataframe.Col("AB"), dataframe.Rename("Hits", "H"), @@ -22,20 +57,14 @@ func (lineup *Lineup) BattingTable() *dataframe.Data { dataframe.Rename("StrikeOuts", "K"), dataframe.Rename("Walks", "BB"), ) + dat := batting.Select(selection...) idx := dat.GetIndex() names := idx.GetColumn("#") dat.RApply(func(row int) { // Shorten "Babe Ruth" to "B Ruth" name := names.GetString(row) if strings.ContainsRune(name, ' ') { - parts := strings.Split(name, " ") - for i, part := range parts[0 : len(parts)-1] { - if len(part) > 2 { - // unless it's a very short name - parts[i] = part[0:1] - } - } - names.GetStrings()[row] = strings.Join(parts, " ") + names.GetStrings()[row] = text.NameShorten(name) } }) idx.GetColumn("AB").Summary = dataframe.Sum @@ -68,6 +97,21 @@ func (lineup *Lineup) ErrorsList() string { fmt.Fprintf(s, " E%d:%d", f.Position, f.Errors) } } + if len(lineup.ErrorsByPlayer) > 0 { + fmt.Fprintln(s) + s.WriteString("Errors - ") + comma := false + for playerID, n := range lineup.ErrorsByPlayer { + if comma { + s.WriteString(", ") + } + comma = true + s.WriteString(text.NameShorten(lineup.Team.Players[playerID].NameOrNumber())) + if n > 1 { + fmt.Fprintf(s, "(%d)", n) + } + } + } return s.String() } diff --git a/pkg/boxscore/util.go b/pkg/boxscore/util.go index 6acf629..a55f5aa 100644 --- a/pkg/boxscore/util.go +++ b/pkg/boxscore/util.go @@ -9,29 +9,6 @@ import ( "github.com/slshen/paperscore/pkg/text" ) -func firstWord(s string, w int) string { - out := &strings.Builder{} - for s != "" { - space := strings.IndexRune(s, ' ') - if space > 0 { - if out.Len()+space < w { - if out.Len() > 0 { - out.WriteRune(' ') - } - out.WriteString(s[0:space]) - s = s[space+1:] - continue - } - } - if out.Len()+len(s) < w { - out.WriteString(s) - } - break - } - - return out.String() -} - func paste(c1, c2 string, sepWidth, leftLen int) string { switch { case leftLen > 0: diff --git a/pkg/boxscore/util_test.go b/pkg/boxscore/util_test.go index 7b890a4..0f030b8 100644 --- a/pkg/boxscore/util_test.go +++ b/pkg/boxscore/util_test.go @@ -14,8 +14,3 @@ world`, "xxxxxx", 4, 0) world `, s) } - -func TestFirstWord(t *testing.T) { - assert := assert.New(t) - assert.Equal("Athletics Mercado", firstWord("Athletics Mercado Walling", 20)) -} diff --git a/pkg/dataframe/data.go b/pkg/dataframe/data.go index 0761bb3..db78fd9 100644 --- a/pkg/dataframe/data.go +++ b/pkg/dataframe/data.go @@ -201,7 +201,7 @@ func (dat *Data) RowCount() int { func (dat *Data) RSort(less func(r1 int, r2 int) bool) *Data { rc := dat.RowCount() rowNumbers := make([]int, rc) - for i := 0; i < rc; i++ { + for i := range rc { rowNumbers[i] = i } sort.Slice(rowNumbers, func(i, j int) bool { @@ -224,19 +224,19 @@ func (dat *Data) RSort(less func(r1 int, r2 int) bool) *Data { switch scol.GetType() { case Int: values := make([]int, scol.Len()) - for row := 0; row < scol.Len(); row++ { + for row := range scol.Len() { values[row] = scol.GetInt(rowNumbers[row]) } rcol.Values = values case Float: values := make([]float64, scol.Len()) - for row := 0; row < scol.Len(); row++ { + for row := range scol.Len() { values[row] = scol.GetFloat(rowNumbers[row]) } rcol.Values = values case String: values := make([]string, scol.Len()) - for row := 0; row < scol.Len(); row++ { + for row := range scol.Len() { values[row] = scol.GetString(rowNumbers[row]) } rcol.Values = values diff --git a/pkg/dataframe/pkg/datapackage.go b/pkg/dataframe/pkg/datapackage.go index 1760baa..c67c5d6 100644 --- a/pkg/dataframe/pkg/datapackage.go +++ b/pkg/dataframe/pkg/datapackage.go @@ -113,7 +113,7 @@ func (dp *DataPackage) Write(dir string) error { } func (dp *DataPackage) writeJSON(path string, val interface{}) error { - if err := os.MkdirAll(filepath.Dir(path), 0777); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { return err } f, err := os.Create(path) @@ -129,7 +129,7 @@ func (dp *DataPackage) writeJSON(path string, val interface{}) error { func (dp *DataPackage) writeContent(dir string, r Resource) error { path := filepath.Join(dir, r.GetPath()) - if err := os.MkdirAll(filepath.Dir(path), 0777); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { return err } f, err := os.Create(filepath.Join(dir, r.GetPath())) diff --git a/pkg/dataframe/select.go b/pkg/dataframe/select.go index e06be6c..ba7b061 100644 --- a/pkg/dataframe/select.go +++ b/pkg/dataframe/select.go @@ -34,6 +34,7 @@ func (dat *Data) Add(sels ...Selection) { } } +// Adds an existing column to the new frame func Col(name string) Selection { return func(i *Index) *Column { return i.GetColumn(name) diff --git a/pkg/game/fieldingerror.go b/pkg/game/fieldingerror.go index bd0ce4d..dd82a30 100644 --- a/pkg/game/fieldingerror.go +++ b/pkg/game/fieldingerror.go @@ -12,6 +12,11 @@ type FieldingError struct { Modifiers } +var FielderNames = []string{ + "P", "C", "1B", "2B", "3B", "SS", + "LF", "CF", "RF", +} + var NoError = FieldingError{} func parseFieldingError(play gamefile.Play, s string) (FieldingError, error) { diff --git a/pkg/game/game.go b/pkg/game/game.go index d26e03c..9ebc643 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -187,6 +187,14 @@ func NewGame(gf *gamefile.File) (*Game, error) { return g, errs } +func (g *Game) GetPlayer(playerID PlayerID) *Player { + p := g.Home.Players[playerID] + if p == nil { + p = g.Visitor.Players[playerID] + } + return p +} + func (g *Game) GetStates() []*State { return g.states } @@ -319,10 +327,3 @@ func (g *Game) GetTournament() string { } return "Other" } - -func (g *Game) GetSeason(us string) string { - if g.Season != "" { - return g.Season - } - return "" -} diff --git a/pkg/game/gamemachine.go b/pkg/game/gamemachine.go index 8dc89a5..60049ce 100644 --- a/pkg/game/gamemachine.go +++ b/pkg/game/gamemachine.go @@ -70,6 +70,16 @@ func (m *gameMachine) handleAlternative(alt *gamefile.Alternative, lastState *St state.Batter = lastState.Batter state.Pitches = lastState.Pitches state.AlternativeFor = lastState + for _, p := range alt.Credit { + player := m.battingTeam.Players[m.battingTeam.parsePlayerID(p)] + if player == nil { + player = m.fieldingTeam.Players[m.fieldingTeam.parsePlayerID(p)] + } + if player == nil { + return nil, NewError("no player %s for alt credit on either team", alt.Pos, p) + } + state.AlternativeCredits = append(state.AlternativeCredits, player) + } err := m.handlePlay(alt, state) return state, err } @@ -193,6 +203,7 @@ func (m *gameMachine) parseAdvances(play gamefile.Play, state *State) error { func (m *gameMachine) handleSpecialEvent(event *gamefile.Event, state *State) (*State, error) { if event.Pitcher != "" { m.pitcher = m.fieldingTeam.parsePlayerID(event.Pitcher) + state.Defense[0] = m.pitcher } if event.PlayerName != nil { playerID := m.battingTeam.parsePlayerID(event.PlayerName.Player) @@ -203,6 +214,9 @@ func (m *gameMachine) handleSpecialEvent(event *gamefile.Event, state *State) (* state = state.Copy() for _, pp := range event.Defense { state.Defense[pp.PositionNumber()-1] = m.fieldingTeam.parsePlayerID(pp.Player) + if pp.PositionNumber() == 1 { + m.pitcher = state.Defense[0] + } } return state, nil } diff --git a/pkg/game/state.go b/pkg/game/state.go index 3ff9266..604ba20 100644 --- a/pkg/game/state.go +++ b/pkg/game/state.go @@ -29,11 +29,12 @@ type State struct { Score int Pitcher PlayerID PlateAppearance - Defense [9]PlayerID `yaml:",flow,omitempty"` - Runners [3]PlayerID `yaml:",omitempty,flow"` - Comment string `yaml:",omitempty"` - LastState *State `yaml:"-"` - AlternativeFor *State `yaml:"-"` + Defense [9]PlayerID `yaml:",flow,omitempty"` + Runners [3]PlayerID `yaml:",omitempty,flow"` + Comment string `yaml:",omitempty"` + LastState *State `yaml:"-"` + AlternativeFor *State `yaml:"-"` + AlternativeCredits []*Player } type PlateAppearance struct { diff --git a/pkg/game/team.go b/pkg/game/team.go index 9767771..054ad52 100644 --- a/pkg/game/team.go +++ b/pkg/game/team.go @@ -3,28 +3,32 @@ package game import ( "errors" "fmt" + "log" "os" "path/filepath" "regexp" "strings" "unicode" + "github.com/slshen/paperscore/pkg/text" "gopkg.in/yaml.v3" ) type TeamID string type Team struct { - ID TeamID - Name string `yaml:"name"` - Us bool `yaml:"us"` - Players map[PlayerID]*Player + ID TeamID + Name string `yaml:"name"` + ShortName string `yaml:"short_name"` + Us bool `yaml:"us"` + Players map[PlayerID]*Player playerIDs map[string]PlayerID } type Player struct { PlayerID `yaml:"-"` + Team *Team `yaml:"-"` Name string Number string Inactive bool @@ -48,7 +52,8 @@ func GetTeam(dir, name, id string) (*Team, error) { for i := 0; i < 3; i++ { err := team.readFile(dir, id) if err == nil { - return team, nil + log.Default().Printf("Loaded team %s from %s", id, dir) + goto done } if errors.Is(err, os.ErrNotExist) { dir = filepath.Clean(filepath.Join(dir, "..")) @@ -58,6 +63,10 @@ func GetTeam(dir, name, id string) (*Team, error) { } return team, fmt.Errorf("cannot find team file for %s", id) } +done: + if team.ShortName == "" { + team.ShortName = text.Initialize(team.Name) + } return team, nil } @@ -71,6 +80,7 @@ func (team *Team) readFile(dir, id string) error { return err } for playerID, player := range team.Players { + player.Team = team player.PlayerID = playerID if player.Number == "" { player.Number = team.getDefaultPlayerNumber(playerID) @@ -88,6 +98,7 @@ func (team *Team) GetPlayer(id PlayerID) *Player { } player := &Player{ PlayerID: id, + Team: team, Name: string(id), Number: team.getDefaultPlayerNumber(id), } @@ -123,6 +134,10 @@ func (team *Team) getDefaultPlayerNumber(player PlayerID) string { return fmt.Sprintf("00%d", len(team.Players)+1) } +func (player *Player) GetShortName() string { + return text.NameShorten(player.NameOrNumber()) +} + func (player *Player) NameOrNumber() string { if player.Name != "" { return player.Name diff --git a/pkg/stats/alt_data.go b/pkg/stats/alt_data.go index 28d39e0..6ff5604 100644 --- a/pkg/stats/alt_data.go +++ b/pkg/stats/alt_data.go @@ -1,7 +1,9 @@ package stats import ( - "fmt" + "maps" + "slices" + "strings" "github.com/slshen/paperscore/pkg/dataframe" "github.com/slshen/paperscore/pkg/game" @@ -9,15 +11,16 @@ import ( type AltData struct { re RunExpectancy - game, inn, bat, o, rnr, play, alt, - cost, comment *dataframe.Column + game, half, inn, bat, o, rnr, play, alt, + cost, comment, credit *dataframe.Column } func NewAltData(re RunExpectancy) *AltData { alt := &AltData{ re: re, game: dataframe.NewColumn("Game", "%10s", dataframe.EmptyStrings), - inn: dataframe.NewColumn("In", "%4s", dataframe.EmptyStrings), + half: dataframe.NewColumn("H", "%3s", dataframe.EmptyStrings), + inn: dataframe.NewColumn("I", "%1d", dataframe.EmptyInts), bat: dataframe.NewColumn("Bat", "%4s", dataframe.EmptyStrings), o: dataframe.NewColumn("O", "%1d", dataframe.EmptyInts), rnr: dataframe.NewColumn("Rnr", "%3s", dataframe.EmptyStrings), @@ -25,6 +28,7 @@ func NewAltData(re RunExpectancy) *AltData { alt: dataframe.NewColumn("Alternate", "%30s", dataframe.EmptyStrings), cost: dataframe.NewColumn("RCost", "%6.2f", dataframe.EmptyFloats), comment: dataframe.NewColumn("Comment", "%-20s", dataframe.EmptyStrings), + credit: dataframe.NewColumn("Credit", "%s", dataframe.EmptyStrings), } alt.cost.Summary = dataframe.Sum return alt @@ -33,22 +37,61 @@ func NewAltData(re RunExpectancy) *AltData { func (alt *AltData) GetData() *dataframe.Data { dat := &dataframe.Data{ Columns: []*dataframe.Column{ - alt.game, alt.inn, alt.bat, alt.o, alt.rnr, alt.play, - alt.alt, alt.cost, alt.comment, + alt.game, alt.inn, alt.half, alt.bat, alt.o, alt.rnr, alt.play, + alt.alt, alt.cost, alt.comment, alt.credit, }, } return dat.RSort(dataframe.Less(dataframe.Descending(dataframe.CompareFloat(alt.cost)))) } +func (alt *AltData) GetPerPlayerData() *dataframe.Data { + dat := &dataframe.Data{ + Columns: []*dataframe.Column{ + dataframe.NewColumn("Player", "%-6s", dataframe.EmptyStrings), + dataframe.NewColumn("Plays", "%-20s", dataframe.EmptyStrings), + dataframe.NewColumn("RCost", "%.2f", dataframe.EmptyFloats), + }, + } + for i, val := range alt.credit.GetStrings() { + if val != "" { + players := strings.Fields(val) + share := alt.cost.GetFloat(i) / float64(len(players)) + for _, player := range players { + dat.Columns[0].AppendString(player) + dat.Columns[1].AppendString(alt.play.GetString(i)) + dat.Columns[2].AppendFloat(share) + } + } + } + res := dat.GroupBy("Player").Aggregate( + dataframe.ASum("Cost", dat.Columns[2]).WithFormat("%4.2f").WithSummary(dataframe.Sum), + dataframe.AFunc("Plays", dataframe.String, func(acol *dataframe.Column, group *dataframe.Group) { + plays := &strings.Builder{} + for _, row := range group.Rows { + play := alt.play.GetString(row) + if plays.Len() > 0 { + plays.WriteString(", ") + } + plays.WriteString(play) + } + acol.AppendString(plays.String()) + }), + ) + res = res.RSort(dataframe.Less(dataframe.Descending(dataframe.CompareFloat(res.Columns[1])))) + res.Arrange("Cost", "Player", "Plays") + return res +} + func (alt *AltData) Record(gameID string, state *game.State) float64 { if alt.re == nil || state.AlternativeFor == nil { return 0 } - halfIndicator := "B" if state.Half == game.Top { - halfIndicator = "T" + alt.half.AppendString("TOP") + } else { + alt.half.AppendString("BOT") } - alt.inn.AppendString(fmt.Sprintf("%s%d.%d", halfIndicator, state.InningNumber, state.Outs-state.OutsOnPlay)) + alt.inn.AppendInt(state.InningNumber) _, _, _, change := GetExpectedRunsChange(alt.re, state) var outs int if state.LastState != nil { @@ -67,5 +110,48 @@ func (alt *AltData) Record(gameID string, state *game.State) float64 { } alt.cost.AppendFloat(price) alt.comment.AppendString(state.Comment) + credit := &strings.Builder{} + for _, p := range getAltCredit(state) { + if credit.Len() > 0 { + credit.WriteRune(' ') + } + credit.WriteString(string(p)) + } + alt.credit.AppendString(credit.String()) return change } + +func getAltCredit(alt *game.State) []game.PlayerID { + credits := map[game.PlayerID]bool{} + for _, p := range alt.AlternativeCredits { + credits[p.PlayerID] = true + } + state := alt.AlternativeFor + if state.FieldingError.IsFieldingError() { + player := state.Defense[state.FieldingError.Fielder-1] + if player != "" { + credits[player] = true + } + } + if state.Play.Is(game.PassedBall) { + catcher := state.Defense[1] + if catcher != "" { + credits[catcher] = true + } + } + if state.Play.Is(game.WildPitch) { + pitcher := state.Defense[0] + if pitcher != "" { + credits[pitcher] = true + } + } + for _, adv := range state.Advances { + if adv.IsFieldingError() { + fielder := state.Defense[adv.FieldingError.Fielder-1] + if fielder != "" { + credits[fielder] = true + } + } + } + return slices.Collect(maps.Keys(credits)) +} diff --git a/pkg/stats/fielding.go b/pkg/stats/fielding.go index 45996f6..15c0c5d 100644 --- a/pkg/stats/fielding.go +++ b/pkg/stats/fielding.go @@ -1,11 +1,15 @@ package stats import ( + "slices" + "github.com/slshen/paperscore/pkg/game" ) type FieldingStats struct { FieldingByPosition []*Fielding + PositionsByPlayer map[game.PlayerID][]int + ErrorsByPlayer map[game.PlayerID]int Errors int } @@ -23,11 +27,24 @@ func newFieldingStats() *FieldingStats { } return &FieldingStats{ FieldingByPosition: fs, + PositionsByPlayer: map[game.PlayerID][]int{}, + ErrorsByPlayer: map[game.PlayerID]int{}, } } -func (stats *FieldingStats) recordError(e game.FieldingError) { +func (stats *FieldingStats) recordFielder(pos int, player game.PlayerID) { + positions := stats.PositionsByPlayer[player] + if !slices.Contains(positions, pos) { + stats.PositionsByPlayer[player] = append(positions, pos) + } +} + +func (stats *FieldingStats) recordError(state *game.State, e game.FieldingError) { f := stats.FieldingByPosition[e.Fielder-1] + player := state.Defense[e.Fielder-1] + if player != "" { + stats.ErrorsByPlayer[player]++ + } f.Errors++ stats.Errors++ } diff --git a/pkg/stats/gamestats.go b/pkg/stats/gamestats.go index 0e99c26..18293b4 100644 --- a/pkg/stats/gamestats.go +++ b/pkg/stats/gamestats.go @@ -98,6 +98,10 @@ func (gs *GameStats) GetAltData() *dataframe.Data { return gs.alt.GetData() } +func (gs *GameStats) GetPerPlayerAltData() *dataframe.Data { + return gs.alt.GetPerPlayerData() +} + func (gs *GameStats) getBattingData(includeInactiveBatters bool) *dataframe.Data { var dat *dataframe.Data for _, stats := range gs.TeamStats { diff --git a/pkg/stats/player.go b/pkg/stats/player.go index 7f2c849..ede7415 100644 --- a/pkg/stats/player.go +++ b/pkg/stats/player.go @@ -3,6 +3,7 @@ package stats import "github.com/slshen/paperscore/pkg/game" type PlayerData struct { + PlayerID string Name string Team string Number string @@ -13,6 +14,7 @@ type PlayerData struct { func NewPlayerData(team string, player *game.Player) PlayerData { return PlayerData{ + PlayerID: string(player.PlayerID), Name: player.NameOrNumber(), Team: team, Number: player.Number, diff --git a/pkg/stats/teamstats.go b/pkg/stats/teamstats.go index 6b91434..4f064f5 100644 --- a/pkg/stats/teamstats.go +++ b/pkg/stats/teamstats.go @@ -179,11 +179,16 @@ func (stats *TeamStats) RecordFielding(g *game.Game, state *game.State) { pitching := stats.GetPitching(state.Pitcher) pitching.Record(state) pitching.GameAppearances[g.ID] = true + for i, player := range state.Defense { + if player != "" { + stats.recordFielder(i+1, player) + } + } switch state.Play.Type { case game.ReachedOnError: fallthrough case game.CatcherInterference: - stats.recordError(state.Play.FieldingError) + stats.recordError(state, state.Play.FieldingError) case game.WalkPickedOff: fallthrough case game.PickedOff: @@ -194,14 +199,14 @@ func (stats *TeamStats) RecordFielding(g *game.Game, state *game.State) { fallthrough case game.StrikeOutPickedOff: if state.NotOutOnPlay && state.Play.FieldingError.IsFieldingError() { - stats.recordError(state.Play.FieldingError) + stats.recordError(state, state.Play.FieldingError) } case game.FoulFlyError: - stats.recordError(state.Play.FieldingError) + stats.recordError(state, state.Play.FieldingError) } for _, adv := range state.Advances { if adv.IsFieldingError() { - stats.recordError(adv.FieldingError) + stats.recordError(state, adv.FieldingError) } } } diff --git a/pkg/text/short.go b/pkg/text/short.go new file mode 100644 index 0000000..0d8a421 --- /dev/null +++ b/pkg/text/short.go @@ -0,0 +1,48 @@ +package text + +import ( + "strings" +) + +// NameShorten returns a string made from the first letter of each word (for words longer than +// 2 characters) except the last word, which is included in full, unless the last word is a single +// character. +// Example: "John Ronald Reuel Tolkien" -> "JRR Tolkien" +// Example: "Angie W" -> "Angie W" +func NameShorten(s string) string { + words := strings.Fields(s) + if len(words) == 0 { + return "" + } + if len(words) == 1 { + return words[0] + } + if last := words[len(words)-1]; len(last) == 1 { + return s + } + var b strings.Builder + for _, word := range words[:len(words)-1] { + if len(word) <= 2 { + b.WriteString(word) + } else { + b.WriteByte(word[0]) + } + } + b.WriteByte(' ') + b.WriteString(words[len(words)-1]) + return b.String() +} + +func Initialize(s string) string { + var b strings.Builder + for _, word := range strings.Fields(s) { + for i, ch := range word { + if i == 0 || (ch >= 'A' && ch <= 'Z') { + b.WriteRune(ch) + } else { + break + } + } + } + return b.String() +} diff --git a/pkg/text/short_test.go b/pkg/text/short_test.go new file mode 100644 index 0000000..c8f57ea --- /dev/null +++ b/pkg/text/short_test.go @@ -0,0 +1,39 @@ +package text + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNameShorten(t *testing.T) { + tests := []struct { + in, out string + }{ + {"Go Programming Language", "GoP Language"}, + {"Hello World", "H World"}, + {"Single", "Single"}, + {"", ""}, + {"Angie S", "Angie S"}, + {"multiple spaces here", "ms here"}, + {" leading and trailing ", "la trailing"}, + {"OneWord", "OneWord"}, + {"Kh Simmons", "Kh Simmons"}, + } + + for _, tc := range tests { + assert.Equal(t, tc.out, NameShorten(tc.in), "input: %q", tc.in) + } +} + +func TestInitialize(t *testing.T) { + tests := []struct { + in, out string + }{ + {"Oberlin College", "OC"}, + {"NC Wesleyan", "NCW"}, + } + for _, tc := range tests { + assert.Equal(t, tc.out, Initialize(tc.in), "input: %q", tc.in) + } +} diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go index 98d2d40..2fcce5a 100644 --- a/pkg/ui/ui.go +++ b/pkg/ui/ui.go @@ -414,7 +414,7 @@ func (ui *UI) save() { ui.messages.SetText(msg) } else { _, _ = f.WriteString(text) - f.Close() + _ = f.Close() if err := os.Rename(f.Name(), ui.path); err != nil { ui.messages.SetText(fmt.Sprintf("could not save %s [yellow:red]%s", ui.path, err.Error())) } else {