From 885b5672ee6b53cebd5416c45a080b2416f6f428 Mon Sep 17 00:00:00 2001 From: Sam Shen Date: Thu, 3 Oct 2024 16:53:07 -0700 Subject: [PATCH] Plays are now case-insensitive (#50) * lexer has a PA and Command mode * gamefile writes conf, cr markers * ui reformats gamefile on save * playbyplay notes location --- .github/workflows/main.yml | 4 +- go.mod | 10 ++-- go.sum | 17 ++++--- pkg/game/gamemachine.go | 4 +- pkg/game/modifiers.go | 35 ++++++++++++- pkg/game/play.go | 3 +- pkg/game/playtype_string.go | 59 +++++++++++----------- pkg/gamefile/file.go | 92 ++++++++++++++++++++++++----------- pkg/gamefile/lexer.go | 37 +++++++++----- pkg/gamefile/lexer_test.go | 58 ++++++++++++---------- pkg/gamefile/parser.go | 5 +- pkg/gamefile/parser_test.go | 9 +++- pkg/gamefile/testdata/test.gm | 2 + pkg/gamefile/yaml.go | 6 +-- pkg/gamefile/yaml_test.go | 6 +-- pkg/playbyplay/playbyplay.go | 53 +++++++++++++++++--- pkg/stats/batting.go | 2 + pkg/stats/pitching.go | 2 + pkg/ui/ui.go | 31 +++++++----- 19 files changed, 296 insertions(+), 139 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a9f0509..404b282 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.19' + go-version: '1.22' - name: Caching go modules uses: actions/cache@v2 with: @@ -39,7 +39,7 @@ jobs: 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.47.2 + run: wget -O - -q https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s v1.58.1 - name: Build and Unit Test run: ./hack/build.sh diff --git a/go.mod b/go.mod index 56dc9c9..c540eb4 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,20 @@ module github.com/slshen/sb -go 1.18 +go 1.22.0 + +toolchain go1.22.3 require ( - github.com/alecthomas/participle/v2 v2.1.0 + github.com/alecthomas/participle/v2 v2.1.1 github.com/gdamore/tcell/v2 v2.7.1 github.com/hashicorp/go-multierror v1.1.1 github.com/kr/text v0.2.0 github.com/mitchellh/go-wordwrap v1.0.1 github.com/mitchellh/mapstructure v1.5.0 github.com/rivo/tview v0.0.0-20240921122403-a64fc48d7654 - github.com/spf13/cobra v1.7.0 + github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 8c98b97..1641bb7 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,10 @@ github.com/alecthomas/assert/v2 v2.3.0 h1:mAsH2wmvjsuvyBvAmCtm7zFsBlb8mIHx5ySLVdDZXL0= -github.com/alecthomas/participle/v2 v2.1.0 h1:z7dElHRrOEEq45F2TG5cbQihMtNTv8vwldytDj7Wrz4= -github.com/alecthomas/participle/v2 v2.1.0/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= +github.com/alecthomas/assert/v2 v2.3.0/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= +github.com/alecthomas/participle/v2 v2.1.1 h1:hrjKESvSqGHzRb4yW1ciisFJ4p3MGYih6icjJvbsmV8= +github.com/alecthomas/participle/v2 v2.1.1/go.mod h1:Y1+hAs8DHPmc3YUFzqllV+eSQ9ljPTk0ZkPMtEdAx2c= github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -16,6 +18,7 @@ github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -45,12 +48,12 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/pkg/game/gamemachine.go b/pkg/game/gamemachine.go index d55cb40..06d4ebc 100644 --- a/pkg/game/gamemachine.go +++ b/pkg/game/gamemachine.go @@ -79,7 +79,7 @@ func (m *gameMachine) handleActualPlay(play *gamefile.ActualPlay, lastState *Sta state.Pitches = state.LastState.Pitches + Pitches(play.PitchSequence) state.Batter = state.LastState.Batter } else { - state.Batter = m.battingTeam.parsePlayerID(play.Batter.String()) + state.Batter = m.battingTeam.parsePlayerID(play.Batter) state.Pitches = Pitches(play.PitchSequence) } if state.Batter == "" { @@ -390,7 +390,7 @@ func (m *gameMachine) handlePlayCode(play gamefile.Play, state *State) error { state.Complete = true case pp.playIs("DGR"): state.Play = Play{ - Type: Double, + Type: GroundRuleDouble, } m.impliedAdvance(play, state, "B-2") state.Complete = true diff --git a/pkg/game/modifiers.go b/pkg/game/modifiers.go index a783c8c..c72439e 100644 --- a/pkg/game/modifiers.go +++ b/pkg/game/modifiers.go @@ -1,6 +1,10 @@ package game -import "strings" +import ( + "regexp" + "strconv" + "strings" +) type Modifiers []string @@ -23,6 +27,12 @@ const ( ) type Trajectory string +type Location struct { + Fielder int + Length string +} + +var locationRe = regexp.MustCompile(`[A-Z]+([1-9])(S|D)?`) func (mods Modifiers) Trajectory() Trajectory { for _, m := range mods { @@ -53,6 +63,29 @@ func (mods Modifiers) Trajectory() Trajectory { return "" } +func (mods Modifiers) Location() *Location { + for _, m := range mods { + if m[0] != 'E' { + rm := locationRe.FindStringSubmatch(m) + if rm != nil { + // F8S = short center, P6D deep shortstop + fielder, _ := strconv.Atoi(rm[1]) + var length string + if rm[2] == "D" { + length = "deep" + } else if rm[2] == "S" { + length = "short" + } + return &Location{ + Fielder: fielder, + Length: length, + } + } + } + } + return nil +} + func (mods Modifiers) Contains(codes ...string) bool { for _, m := range mods { for _, code := range codes { diff --git a/pkg/game/play.go b/pkg/game/play.go index e2784c8..f9afb6c 100644 --- a/pkg/game/play.go +++ b/pkg/game/play.go @@ -5,6 +5,7 @@ type PlayType byte const ( Single PlayType = iota Double + GroundRuleDouble Triple HomeRun CaughtStealing @@ -63,7 +64,7 @@ func (p *Play) Is(ts ...PlayType) bool { } func (p *Play) IsHit() bool { - return p.Type == Single || p.Type == Double || p.Type == Triple || p.Type == HomeRun + return p.Type == Single || p.Type == Double || p.Type == Triple || p.Type == HomeRun || p.Type == GroundRuleDouble } func (p *Play) IsStrikeOut() bool { diff --git a/pkg/game/playtype_string.go b/pkg/game/playtype_string.go index 17c00de..49d675c 100644 --- a/pkg/game/playtype_string.go +++ b/pkg/game/playtype_string.go @@ -10,38 +10,39 @@ func _() { var x [1]struct{} _ = x[Single-0] _ = x[Double-1] - _ = x[Triple-2] - _ = x[HomeRun-3] - _ = x[CaughtStealing-4] - _ = x[HitByPitch-5] - _ = x[Walk-6] - _ = x[WalkWildPitch-7] - _ = x[WalkPassedBall-8] - _ = x[WalkPickedOff-9] - _ = x[StolenBase-10] - _ = x[PickedOff-11] - _ = x[CatcherInterference-12] - _ = x[ReachedOnError-13] - _ = x[FieldersChoice-14] - _ = x[WildPitch-15] - _ = x[PassedBall-16] - _ = x[GroundOut-17] - _ = x[FlyOut-18] - _ = x[DoublePlay-19] - _ = x[TriplePlay-20] - _ = x[StrikeOut-21] - _ = x[StrikeOutPassedBall-22] - _ = x[StrikeOutWildPitch-23] - _ = x[StrikeOutPickedOff-24] - _ = x[StrikeOutStolenBase-25] - _ = x[StrikeOutCaughtStealing-26] - _ = x[FoulFlyError-27] - _ = x[NoPlay-28] + _ = x[GroundRuleDouble-2] + _ = x[Triple-3] + _ = x[HomeRun-4] + _ = x[CaughtStealing-5] + _ = x[HitByPitch-6] + _ = x[Walk-7] + _ = x[WalkWildPitch-8] + _ = x[WalkPassedBall-9] + _ = x[WalkPickedOff-10] + _ = x[StolenBase-11] + _ = x[PickedOff-12] + _ = x[CatcherInterference-13] + _ = x[ReachedOnError-14] + _ = x[FieldersChoice-15] + _ = x[WildPitch-16] + _ = x[PassedBall-17] + _ = x[GroundOut-18] + _ = x[FlyOut-19] + _ = x[DoublePlay-20] + _ = x[TriplePlay-21] + _ = x[StrikeOut-22] + _ = x[StrikeOutPassedBall-23] + _ = x[StrikeOutWildPitch-24] + _ = x[StrikeOutPickedOff-25] + _ = x[StrikeOutStolenBase-26] + _ = x[StrikeOutCaughtStealing-27] + _ = x[FoulFlyError-28] + _ = x[NoPlay-29] } -const _PlayType_name = "SingleDoubleTripleHomeRunCaughtStealingHitByPitchWalkWalkWildPitchWalkPassedBallWalkPickedOffStolenBasePickedOffCatcherInterferenceReachedOnErrorFieldersChoiceWildPitchPassedBallGroundOutFlyOutDoublePlayTriplePlayStrikeOutStrikeOutPassedBallStrikeOutWildPitchStrikeOutPickedOffStrikeOutStolenBaseStrikeOutCaughtStealingFoulFlyErrorNoPlay" +const _PlayType_name = "SingleDoubleGroundRuleDoubleTripleHomeRunCaughtStealingHitByPitchWalkWalkWildPitchWalkPassedBallWalkPickedOffStolenBasePickedOffCatcherInterferenceReachedOnErrorFieldersChoiceWildPitchPassedBallGroundOutFlyOutDoublePlayTriplePlayStrikeOutStrikeOutPassedBallStrikeOutWildPitchStrikeOutPickedOffStrikeOutStolenBaseStrikeOutCaughtStealingFoulFlyErrorNoPlay" -var _PlayType_index = [...]uint16{0, 6, 12, 18, 25, 39, 49, 53, 66, 80, 93, 103, 112, 131, 145, 159, 168, 178, 187, 193, 203, 213, 222, 241, 259, 277, 296, 319, 331, 337} +var _PlayType_index = [...]uint16{0, 6, 12, 28, 34, 41, 55, 65, 69, 82, 96, 109, 119, 128, 147, 161, 175, 184, 194, 203, 209, 219, 229, 238, 257, 275, 293, 312, 335, 347, 353} func (i PlayType) String() string { if i >= PlayType(len(_PlayType_index)-1) { diff --git a/pkg/gamefile/file.go b/pkg/gamefile/file.go index d278d4b..9bd82a7 100644 --- a/pkg/gamefile/file.go +++ b/pkg/gamefile/file.go @@ -33,30 +33,30 @@ type TeamEvents struct { type Property struct { Pos Position - Key string `parser:"@Ident"` - Value string `parser:"@Text (NL+|EOF)"` + Key string `parser:"@Key"` + Value string `parser:"@Value (NL+|EOF)"` } type Event struct { Pos Position - Play *ActualPlay `parser:"@@"` - Afters []*After `parser:" @@*"` - Comment string `parser:" @Text? (NL|EOF)"` - Alternative *Alternative `parser:"| 'alt' @@ (NL|EOF)"` - Pitcher string `parser:"| ('pitcher'|'pitching') @Code (NL|EOF)"` - RAdjRunner Numbers `parser:"| 'radj' @Numbers"` - RAdjBase string `parser:" @Code (NL|EOF)"` - Score string `parser:"| 'score' @Code (NL|EOF)"` - Final string `parser:"| 'final' @Code (NL|EOF)"` - HSubEnter Numbers `parser:"| 'hsub' @Numbers"` - HSubFor Numbers `parser:" 'for' @Code (NL|EOF)"` // TODO why @Code - VSubEnter Numbers `parser:"| 'vsub' @Numbers"` - VSubFor Numbers `parser:" 'for' @Code (NL|EOF)"` + Alternative *Alternative `parser:"'alt' @@ (NL|EOF)"` + Pitcher string `parser:"| ('pitcher'|'pitching') @Token (NL|EOF)"` + RAdjRunner Numbers `parser:"| 'radj' @Token"` + RAdjBase string `parser:" @Token (NL|EOF)"` + Score string `parser:"| 'score' @Token (NL|EOF)"` + Final string `parser:"| 'final' @Token (NL|EOF)"` + HSubEnter string `parser:"| 'hsub' @Token"` + HSubFor string `parser:" 'for' @Token (NL|EOF)"` + VSubEnter string `parser:"| 'vsub' @Token"` + VSubFor string `parser:" 'for' @Token (NL|EOF)"` + Play *ActualPlay `parser:"| @@"` + Afters []*After `parser:" @@*"` + Comment string `parser:" @Comment? (NL|EOF)"` Empty bool `parser:"| @NL"` } type After struct { - CourtesyRunner *string `parser:"'cr' @Code"` + CourtesyRunner *string `parser:"'cr' @Token"` Conference *bool `parser:"| @'conf'"` } @@ -68,21 +68,21 @@ type Play interface { type ActualPlay struct { Pos Position - PlateAppearance Numbers `parser:"((@Numbers"` - Batter Numbers `parser:" @Numbers)"` - ContinuedPlateAppearance bool `parser:" | @Dots)"` - PitchSequence string `parser:" @Code"` - Code string `parser:" @Code"` - Advances []string `parser:" @Code*"` + ContinuedPlateAppearance bool `parser:"((@'...')"` + PlateAppearance Numbers `parser:" | (@PA"` + Batter string `parser:" @Token))"` + PitchSequence string `parser:" @Token"` + Code string `parser:" @Token"` + Advances []string `parser:" @Advance*"` } var _ Play = (*ActualPlay)(nil) type Alternative struct { Pos Position - Code string `parser:"@Code"` - Advances []string `parser:"@Code*"` - Comment string `parser:" @Text?"` + Code string `parser:"@Token"` + Advances []string `parser:"@Advance*"` + Comment string `parser:" @Comment?"` } var _ Play = (*Alternative)(nil) @@ -105,6 +105,27 @@ func (f *File) Parse(r io.Reader) error { return nil } +func (p *ActualPlay) normalize() { + if p == nil { + return + } + for i, adv := range p.Advances { + p.Advances[i] = strings.ToUpper(adv) + } + p.Code = strings.ToUpper(p.Code) + p.PitchSequence = strings.ToUpper(p.PitchSequence) +} + +func (a *Alternative) normalize() { + if a == nil { + return + } + for i, adv := range a.Advances { + a.Advances[i] = strings.ToUpper(adv) + } + a.Code = strings.ToUpper(a.Code) +} + func (f *File) Validate() error { f.Properties = make(map[string]string) f.PropertyPos = make(map[string]Position) @@ -113,6 +134,11 @@ func (f *File) Validate() error { f.PropertyPos[prop.Key] = prop.Pos } for _, te := range f.TeamEvents { + for _, event := range te.Events { + // make codes upper code + event.Play.normalize() + event.Alternative.normalize() + } switch te.HomeOrVisitor { case "homeplays": if f.HomeEvents != nil { @@ -186,16 +212,16 @@ func (f *File) writeEvents(w io.Writer, name string, events []*Event) { } else { pa += 1 } - fmt.Fprintf(w, "%d %s ", pa, play.Batter.String()) + fmt.Fprintf(w, "%d %s ", pa, play.Batter) } else { fmt.Fprintf(w, " ... ") } fmt.Fprintf(w, "%s ", play.PitchSequence) - f.writeCodeAdvancesComment(w, play.Code, play.Advances, event.Comment) + f.writeCodeAdvancesComment(w, play.Code, play.Advances, event.Afters, event.Comment) case event.Alternative != nil: alt := event.Alternative fmt.Fprintf(w, " alt") - f.writeCodeAdvancesComment(w, alt.Code, alt.Advances, alt.Comment) + f.writeCodeAdvancesComment(w, alt.Code, alt.Advances, nil, alt.Comment) case event.Pitcher != "": fmt.Fprintf(w, "pitching %s\n", event.Pitcher) case event.RAdjBase != "": @@ -209,11 +235,19 @@ func (f *File) writeEvents(w io.Writer, name string, events []*Event) { fmt.Fprintln(w) } -func (f *File) writeCodeAdvancesComment(w io.Writer, code string, advances []string, comment string) { +func (f *File) writeCodeAdvancesComment(w io.Writer, code string, advances []string, afters []*After, comment string) { fmt.Fprintf(w, "%s", code) for _, adv := range advances { fmt.Fprintf(w, " %s", adv) } + for _, aft := range afters { + if aft.Conference != nil { + fmt.Fprint(w, " conf") + } + if aft.CourtesyRunner != nil { + fmt.Fprintf(w, " cr %s", *aft.CourtesyRunner) + } + } if comment != "" { fmt.Fprintf(w, " : %s", comment) } diff --git a/pkg/gamefile/lexer.go b/pkg/gamefile/lexer.go index ce30b15..f70cb3b 100644 --- a/pkg/gamefile/lexer.go +++ b/pkg/gamefile/lexer.go @@ -5,27 +5,42 @@ import "github.com/alecthomas/participle/v2/lexer" var gameFileDef = lexer.MustStateful( lexer.Rules{ "Root": { - rule("Ident", `[A-Za-z][-_A-Za-z0-9]*`, nil), + rule("Key", `[A-Za-z][-_A-Za-z0-9]*`, nil), + rule("valueStart", `:[ \t]*`, lexer.Push("PropertyValue")), rule("whitespace", `[ \t]+`, nil), - rule("textStart", `:[ \t]*`, lexer.Push("Text")), - rule("dashes", `---[\n\r]`, lexer.Push("Plays")), + rule("dashes", `---[\n\r]*`, lexer.Push("Events")), rule("NL", `[\n\r]`, nil), rule("comment", `//.*[\n\r]`, nil), }, - "Plays": { - rule("Numbers", `[0-9]+[ \t]`, nil), - rule("Keyword", `[a-z][-a-z0-9]*`, nil), - rule("Dots", `\.\.\.`, nil), - rule("Code", `[.0-9A-Z][^ \n\t]*`, nil), + "PropertyValue": { + rule("Value", `[^\n\r]+`, nil), + rule("NL", `[\n\r]`, lexer.Pop()), + }, + "Events": { + rule("PA", `[1-9][0-9]*|alt|\.\.\.`, lexer.Push("PA")), + rule("Keyword", `[^ \t\n\r]+`, lexer.Push("Command")), rule("NL", `[\n\r]`, nil), rule("whitespace", `[ \t]+`, nil), - rule("textStart", `:[ \t]*`, lexer.Push("Text")), rule("comment", `//.*[\n\r]`, nil), }, - "Text": { - rule("Text", "[^\n\r]+", nil), + "PA": { + rule("Advance", `[Bb123][-Xx][123H]([^ \t\n\r]*)`, nil), + rule("colon", `(:|--)[ \t]*`, lexer.Push("PAComment")), rule("NL", `[\n\r]`, lexer.Pop()), + rule("Token", `[^ \t\n\r]+`, nil), + rule("whitespace", `[ \t]+`, nil), + rule("comment", `//.*[\n\r]`, nil), + }, + "Command": { + rule("Token", `[^ \t\n\r]+`, nil), + rule("NL", `[\n\r]`, lexer.Pop()), + rule("whitespace", `[ \t]+`, nil), + rule("comment", `//.*[\n\r]`, nil), + }, + "PAComment": { + rule("Comment", "[^\n\r]+", nil), rule("comment", `//.*[\n\r]`, nil), + lexer.Return(), }, }, ) diff --git a/pkg/gamefile/lexer_test.go b/pkg/gamefile/lexer_test.go index 80187db..07aa37f 100644 --- a/pkg/gamefile/lexer_test.go +++ b/pkg/gamefile/lexer_test.go @@ -1,7 +1,6 @@ package gamefile import ( - "fmt" "strings" "testing" @@ -19,7 +18,8 @@ no-nl: foo --- plays us 1-2 1 00 S6/G6/B B-1 1-3(E3/TH) 2X3(635) : bunt single -...`)) +... 2XH(82) +2 1 bbbb w`)) assert.NoError(err) if !assert.NotNil(lex) { return @@ -28,47 +28,55 @@ plays us 1-2 if !assert.NoError(err) { return } + symbolsByType := map[lexer.TokenType]string{} for n, i := range gameFileDef.Symbols() { - fmt.Println(n, i) + symbolsByType[i] = n } for i, expTok := range []struct{ name, value string }{ - {"Ident", "team"}, - {"Text", "pride-2022"}, + {"Key", "team"}, + {"Value", "pride-2022"}, {"NL", ""}, - {"Ident", "date"}, - {"Text", "5/30/22"}, + {"Key", "date"}, + {"Value", "5/30/22"}, {"NL", ""}, - {"Ident", "comment"}, - {"Text", "Game started late"}, + {"Key", "comment"}, + {"Value", "Game started late"}, {"NL", ""}, - {"Ident", "empty"}, + {"Key", "empty"}, {"NL", ""}, - {"Ident", "no-nl"}, - {"Text", "foo"}, + {"Key", "no-nl"}, + {"Value", "foo"}, {"NL", ""}, {"Keyword", "plays"}, - {"Keyword", "us"}, - {"Code", "1-2"}, + {"Token", "us"}, + {"Token", "1-2"}, {"NL", ""}, - {"Numbers", "1 "}, - {"Numbers", "00 "}, - {"Code", "S6/G6/B"}, - {"Code", "B-1"}, - {"Code", "1-3(E3/TH)"}, - {"Code", "2X3(635)"}, - {"Text", "bunt single"}, + {"PA", "1"}, + {"Token", "00"}, + {"Token", "S6/G6/B"}, + {"Advance", "B-1"}, + {"Advance", "1-3(E3/TH)"}, + {"Advance", "2X3(635)"}, + {"Comment", "bunt single"}, {"NL", ""}, - {"Dots", "..."}, + {"PA", "..."}, + {"Advance", "2XH(82)"}, + {"NL", ""}, + {"PA", "2"}, + {"Token", "1"}, + {"Token", "bbbb"}, + {"Token", "w"}, {"EOF", ""}, } { tok := toks[i] if expTok.name != "" { tokt := gameFileDef.Symbols()[expTok.name] - assert.Less(tokt, 0, "%s not token at %v", tok.Value, tok.Pos) - assert.Equal(tokt, tok.Type, "token not %s at %v", expTok.name, tok.Pos) + actualToken := symbolsByType[tok.Type] + assert.Less(tokt, 0, "%s not token at %v for token type %s", tok.Value, tok.Pos, expTok.name) + assert.Equal(tokt, tok.Type, "token type is %s (%s) not %s at %v", actualToken, tok.Value, expTok.name, tok.Pos) } if expTok.value != "" { - assert.Equal(expTok.value, tok.Value) + assert.Equal(expTok.value, tok.Value, "token value not expected at %v", tok.Pos) } } } diff --git a/pkg/gamefile/parser.go b/pkg/gamefile/parser.go index 5183aee..a8d1932 100644 --- a/pkg/gamefile/parser.go +++ b/pkg/gamefile/parser.go @@ -6,7 +6,10 @@ import ( "github.com/alecthomas/participle/v2" ) -var Parser = participle.MustBuild[File](participle.Lexer(gameFileDef)) +var Parser = participle.MustBuild[File]( + participle.Lexer(gameFileDef), + participle.UseLookahead(10), +) func ParseString(path string, text string) (*File, error) { file, err := Parser.ParseString(path, text) diff --git a/pkg/gamefile/parser_test.go b/pkg/gamefile/parser_test.go index 3c128cb..cb3b614 100644 --- a/pkg/gamefile/parser_test.go +++ b/pkg/gamefile/parser_test.go @@ -24,7 +24,7 @@ func TestParser(t *testing.T) { if assert.NotNil(play) { assert.Equal(1, play.PlateAppearance.Int()) assert.NoError(err) - assert.Equal("7", play.Batter.String()) + assert.Equal("7", play.Batter) assert.Equal("CSFS", play.PitchSequence) assert.Equal("K", play.Code) } @@ -34,8 +34,13 @@ func TestParser(t *testing.T) { assert.Equal("routine ground ball", event.Alternative.Comment) } event = events[8] - assert.True(*event.Afters[0].Conference) + if assert.Len(event.Afters, 1) && assert.NotNil(event.Afters[0].Conference) { + assert.True(*event.Afters[0].Conference) + } event = events[3] assert.Equal("9", *event.Afters[0].CourtesyRunner) + event = events[20] + assert.Equal("3", event.VSubEnter) + assert.Equal("2", event.VSubFor) } } diff --git a/pkg/gamefile/testdata/test.gm b/pkg/gamefile/testdata/test.gm index 8f1d764..5a6e613 100644 --- a/pkg/gamefile/testdata/test.gm +++ b/pkg/gamefile/testdata/test.gm @@ -28,8 +28,10 @@ score 0 12 18 BBBB W B-1 13 11 X E7/F7 B-2 1-H 14 2 BSBFX 4/P4 +vsub 3 for 2 score 1 +pitcher 3 15 25 X 8/F8 16 10 X DGR/P3P B-2 17 13 X S9/L9 B-2 2-H diff --git a/pkg/gamefile/yaml.go b/pkg/gamefile/yaml.go index df266a8..261673b 100644 --- a/pkg/gamefile/yaml.go +++ b/pkg/gamefile/yaml.go @@ -153,15 +153,15 @@ func (p *YAMLParser) isPAComplete(code string) bool { return true } -func (p *YAMLParser) parseBatter(s string) Numbers { +func (p *YAMLParser) parseBatter(s string) string { // the yaml format allows letters at the start of a batter // but the gamefile format only allows digits, so remove the // letters m := regexp.MustCompile(`[a-z]*([0-9]+)`).FindStringSubmatch(s) if m != nil { - return Numbers(m[1]) + return m[1] } - return Numbers("000") + return "000" } func (p *YAMLParser) getPart(parts []string, i int) string { diff --git a/pkg/gamefile/yaml_test.go b/pkg/gamefile/yaml_test.go index 332882d..5f0b916 100644 --- a/pkg/gamefile/yaml_test.go +++ b/pkg/gamefile/yaml_test.go @@ -18,7 +18,7 @@ func TestParseYAML(t *testing.T) { assert.Equal("2", events[0].Pitcher) play := events[1].Play if assert.NotNil(play) { - assert.Equal(Numbers("17"), play.Batter) + assert.Equal("17", play.Batter) assert.Equal("BBBB", play.PitchSequence) assert.Equal("W", play.Code) if assert.Len(play.Advances, 1) { @@ -27,13 +27,13 @@ func TestParseYAML(t *testing.T) { } play = events[2].Play if assert.NotNil(play) { - assert.Equal("6", play.Batter.String()) + assert.Equal("6", play.Batter) assert.Equal("C", play.PitchSequence) assert.Equal("SB2", play.Code) } play = events[36].Play if assert.NotNil(play) { - assert.Equal("00", play.Batter.String()) + assert.Equal("00", play.Batter) assert.Equal("advance on throw", events[36].Comment, play) } } diff --git a/pkg/playbyplay/playbyplay.go b/pkg/playbyplay/playbyplay.go index 56e847c..691344b 100644 --- a/pkg/playbyplay/playbyplay.go +++ b/pkg/playbyplay/playbyplay.go @@ -99,8 +99,8 @@ func (gen *Generator) Generate(w io.Writer) error { } else { gen.score.home += len(state.ScoringRunners) } - fmt.Fprintf(line, ". %d %s, %d %s", gen.score.visitor, gen.Game.Visitor.Name, - gen.score.home, gen.Game.Home.Name) + fmt.Fprintf(line, ". %s %d, %s %d", gen.Game.Visitor.Name, gen.score.visitor, + gen.Game.Home.Name, gen.score.home) } } if state.Comment != "" { @@ -167,7 +167,31 @@ func positionName(fielder int) string { case 9: return "right fielder" } - return fmt.Sprintf("unknwn fielder %d", fielder) + return fmt.Sprintf("unknown fielder %d", fielder) +} + +func locationName(fielder int) string { + switch fielder { + case 1: + return "the circle" + case 2: + return "home plate" + case 3: + return "first base" + case 4: + return "second base" + case 5: + return "third base" + case 6: + return "5-6 hole" + case 7: + return "left field" + case 8: + return "center field" + case 9: + return "right field" + } + return fmt.Sprintf("unknown location %d", fielder) } func hitTrajectory(state *game.State, hit string, fielders []int) string { @@ -178,8 +202,23 @@ func hitTrajectory(state *game.State, hit string, fielders []int) string { fmt.Fprintf(s, " on a %s", trajectory) } if len(fielders) > 0 { - for _, fielder := range fielders { - fmt.Fprintf(s, " to %s", positionName(fielder)) + loc := state.Modifiers.Location() + for i, fielder := range fielders { + var adj string + if i == 0 && loc != nil { + adj = loc.Length + } + fmt.Fprintf(s, " to %s%s", adj, positionName(fielder)) + } + } else { + // H and DGR don't have fielders + loc := state.Modifiers.Location() + if loc != nil { + var length string + if loc.Length != "" { + length = loc.Length + " " + } + fmt.Fprintf(s, " to %s%s", length, locationName(loc.Fielder)) } } return s.String() @@ -213,6 +252,8 @@ func batterPlayDescription(state *game.State) string { return hitTrajectory(state, "singles", play.Fielders) case game.Double: return hitTrajectory(state, "doubles", play.Fielders) + case game.GroundRuleDouble: + return hitTrajectory(state, "hits a ground rule double", play.Fielders) case game.Triple: return hitTrajectory(state, "triples", play.Fielders) case game.Walk: @@ -286,7 +327,7 @@ func runningPlayDescription(team *game.Team, state, lastState *game.State) strin } return strings.Join(sb, ", ") case state.Play.Type == game.CaughtStealing || state.Play.Type == game.StrikeOutCaughtStealing: - return fmt.Sprintf("%s is caught stealing %s", team.GetPlayer(state.Runners[0]).NameOrNumber(), play.CaughtStealingBase) + return fmt.Sprintf("%s is caught stealing %s", team.GetPlayer(state.CaughtStealingRunner).NameOrNumber(), play.CaughtStealingBase) case state.Play.Type == game.WildPitch: return "On a wild pitch" case state.Play.Type == game.PassedBall: diff --git a/pkg/stats/batting.go b/pkg/stats/batting.go index 5fb70ec..654ebd4 100644 --- a/pkg/stats/batting.go +++ b/pkg/stats/batting.go @@ -66,6 +66,8 @@ func (b *Batting) Record(state *game.State) (teamLOB int) { case game.Single: b.Singles++ case game.Double: + fallthrough + case game.GroundRuleDouble: b.Doubles++ case game.Triple: b.Triples++ diff --git a/pkg/stats/pitching.go b/pkg/stats/pitching.go index 267f2e5..f57106b 100644 --- a/pkg/stats/pitching.go +++ b/pkg/stats/pitching.go @@ -80,6 +80,8 @@ func (p *Pitching) Record(state *game.State) { case game.HitByPitch: p.HP++ case game.Double: + fallthrough + case game.GroundRuleDouble: p.Doubles++ case game.Triple: p.Triples++ diff --git a/pkg/ui/ui.go b/pkg/ui/ui.go index 6859daa..8ef0a03 100644 --- a/pkg/ui/ui.go +++ b/pkg/ui/ui.go @@ -194,9 +194,6 @@ func (ui *UI) parseGame(gamePath string) { ui.status.SetText(path.Base(ui.path)) ui.box.SetText("") ui.messages.SetText("") - ui.properties.SetText("", false) - ui.homePlays.SetText("", false) - ui.visitorPlays.SetText("", false) var r io.Reader f, err := os.Open(ui.path) if err != nil { @@ -204,6 +201,9 @@ func (ui *UI) parseGame(gamePath string) { r = strings.NewReader(fmt.Sprintf("date: %s\ngame: 1\n---\n", time.Now().Format(gamefile.GameDateFormat))) } else { ui.messages.SetText(fmt.Sprintf("cannot open %s: %s", ui.path, err)) + ui.properties.SetText("", false) + ui.homePlays.SetText("", false) + ui.visitorPlays.SetText("", false) return } } else { @@ -222,7 +222,7 @@ func (ui *UI) parseGame(gamePath string) { case state == "props": if line == "---" { state = "plays" - ui.properties.SetText(buf.String(), false) + ui.properties.Replace(0, ui.properties.GetTextLength(), buf.String()) buf.Reset() } else { fmt.Fprintln(&buf, line) @@ -235,7 +235,7 @@ func (ui *UI) parseGame(gamePath string) { targetPlays = ui.visitorPlays case targetPlays != nil: if (line == "homeplays" || line == "visitorplays") && line != state { - targetPlays.SetText(buf.String(), false) + targetPlays.Replace(0, targetPlays.GetTextLength(), buf.String()) buf.Reset() if state == "homeplays" { targetPlays = ui.visitorPlays @@ -250,7 +250,7 @@ func (ui *UI) parseGame(gamePath string) { } } if targetPlays != nil { - targetPlays.SetText(buf.String(), false) + targetPlays.Replace(0, targetPlays.GetTextLength(), buf.String()) } // fake a key press so update cycle runs ui.lastKey = time.Now() @@ -339,10 +339,9 @@ func (ui *UI) inputHandler(event *tcell.EventKey) *tcell.EventKey { } } if focusInc != 0 { - f := ui.app.GetFocus() j := 0 for i := range ui.focusOrder { - if ui.focusOrder[i] == f { + if ui.focusOrder[i].HasFocus() { j = i + focusInc if j < 0 { j = len(ui.focusOrder) - 1 @@ -392,23 +391,29 @@ func (ui *UI) save() { canonName = "" } } + var s strings.Builder + gf.Write(&s) + text = s.String() } doSave := func() { originalPath := ui.path if canonName != "" { ui.path = path.Join(path.Dir(ui.path), canonName) } - f, err := os.Create(ui.path) + f, err := os.CreateTemp(path.Dir(ui.path), fmt.Sprintf("%s*", ui.path)) if err != nil { msg := fmt.Sprintf("cannot save %s [yellow:red]%s", ui.path, err.Error()) ui.messages.SetText(msg) } else { _, _ = f.WriteString(text) f.Close() - ui.modified = false - ui.status.SetText(path.Base(ui.path)) - if canonName != "" { - _ = os.Remove(originalPath) + 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 { + ui.parseGame(ui.path) + if canonName != "" { + _ = os.Remove(originalPath) + } } } }