From 4450a85599a149a16910df34dac1afb055e537ba Mon Sep 17 00:00:00 2001 From: Debajit Ghosh Date: Tue, 14 Jul 2026 01:41:41 -0700 Subject: [PATCH] Update custom game code generation from Medieval M-Ayhem testing --- cmd/generate/codegen_go.go | 36 ++ cmd/generate/codegen_go_tests.go | 21 + cmd/generate/codegen_web.go | 112 ++++ cmd/generate/codegen_web_tests.go | 24 + cmd/generate/main.go | 490 ++++++++++++++++++ cmd/generate/main_test.go | 406 +++++++++++++++ cmd/generate/render.go | 71 +++ cmd/generate/schema.go | 79 +++ cmd/generate/template_funcs.go | 55 ++ cmd/generate/templates_go/constants.go.tmpl | 17 + ...qualification_rankings_custom_test.go.tmpl | 82 +++ .../templates_go/ranking_fields.go.tmpl | 69 +++ .../templates_go/ranking_fields_test.go.tmpl | 40 ++ .../reports_rankings_custom.go.tmpl | 97 ++++ .../reports_rankings_custom_test.go.tmpl | 61 +++ cmd/generate/templates_go/score.go.tmpl | 169 ++++++ .../templates_go/score_summary.go.tmpl | 141 +++++ .../templates_go/score_summary_test.go.tmpl | 99 ++++ cmd/generate/templates_go/score_test.go.tmpl | 64 +++ .../templates_go/template_test.go.tmpl | 107 ++++ cmd/generate/validate_logic.go | 308 +++++++++++ cmd/generate/viewmodel.go | 362 +++++++++++++ game/custom_game.yaml | 121 +++++ game/examples/high_seas_havoc.yaml | 104 ++++ 24 files changed, 3135 insertions(+) create mode 100644 cmd/generate/codegen_go.go create mode 100644 cmd/generate/codegen_go_tests.go create mode 100644 cmd/generate/codegen_web.go create mode 100644 cmd/generate/codegen_web_tests.go create mode 100644 cmd/generate/main.go create mode 100644 cmd/generate/main_test.go create mode 100644 cmd/generate/render.go create mode 100644 cmd/generate/schema.go create mode 100644 cmd/generate/template_funcs.go create mode 100644 cmd/generate/templates_go/constants.go.tmpl create mode 100644 cmd/generate/templates_go/qualification_rankings_custom_test.go.tmpl create mode 100644 cmd/generate/templates_go/ranking_fields.go.tmpl create mode 100644 cmd/generate/templates_go/ranking_fields_test.go.tmpl create mode 100644 cmd/generate/templates_go/reports_rankings_custom.go.tmpl create mode 100644 cmd/generate/templates_go/reports_rankings_custom_test.go.tmpl create mode 100644 cmd/generate/templates_go/score.go.tmpl create mode 100644 cmd/generate/templates_go/score_summary.go.tmpl create mode 100644 cmd/generate/templates_go/score_summary_test.go.tmpl create mode 100644 cmd/generate/templates_go/score_test.go.tmpl create mode 100644 cmd/generate/templates_go/template_test.go.tmpl create mode 100644 cmd/generate/validate_logic.go create mode 100644 cmd/generate/viewmodel.go create mode 100644 game/custom_game.yaml create mode 100644 game/examples/high_seas_havoc.yaml diff --git a/cmd/generate/codegen_go.go b/cmd/generate/codegen_go.go new file mode 100644 index 00000000..972aa974 --- /dev/null +++ b/cmd/generate/codegen_go.go @@ -0,0 +1,36 @@ +// Code generators for the server-side Go: game/generated_{constants,score,score_summary, +// ranking_fields}.go. Each emits the gofmt'd output of a templates_go/*.go.tmpl executed against +// the view model (see viewmodel.go). The matching test generators live in codegen_go_tests.go; +// the web UI generators in codegen_web.go. + +package main + +import "path/filepath" + +// phaseFieldPrefix maps a phase to its Go field-name prefix. It's the single source for that mapping, +// used by the `phasePrefix` template helper (template_funcs.go) and by the CountFields joins in +// buildTemplateData (viewmodel.go). +var phaseFieldPrefix = map[string]string{"auto": "Auto", "teleop": "Teleop", "endgame": "Endgame"} + +// generateConstants emits game/generated_constants.go — game-wide metadata, mode flags, foul points. +func generateConstants(yamlData *GameYAML, destDir string) error { + return renderGoTemplate("constants.go.tmpl", filepath.Join(destDir, "generated_constants.go"), buildTemplateData(yamlData)) +} + +// generateScore emits game/generated_score.go — the Score struct, Equals, the Phase enum, and the +// Adjust/Set/Cycle scoring methods + dispatchers. +func generateScore(yamlData *GameYAML, destDir string) error { + return renderGoTemplate("score.go.tmpl", filepath.Join(destDir, "generated_score.go"), buildTemplateData(yamlData)) +} + +// generateScoreSummary emits game/generated_score_summary.go — ScoreSummary, per-phase point +// accumulation, and the DetermineMatchStatus tiebreak cascade. +func generateScoreSummary(yamlData *GameYAML, destDir string) error { + return renderGoTemplate("score_summary.go.tmpl", filepath.Join(destDir, "generated_score_summary.go"), buildTemplateData(yamlData)) +} + +// generateRankingFields emits game/generated_ranking_fields.go — RankingFields, AddScoreSummary, +// and the Less tiebreaker cascade. +func generateRankingFields(yamlData *GameYAML, destDir string) error { + return renderGoTemplate("ranking_fields.go.tmpl", filepath.Join(destDir, "generated_ranking_fields.go"), buildTemplateData(yamlData)) +} diff --git a/cmd/generate/codegen_go_tests.go b/cmd/generate/codegen_go_tests.go new file mode 100644 index 00000000..1a18d634 --- /dev/null +++ b/cmd/generate/codegen_go_tests.go @@ -0,0 +1,21 @@ +// Generators for the tests of the server-side Go (codegen_go.go) — one generated test file per +// generated source file. Each emits the gofmt'd output of a templates_go/*_test.go.tmpl. + +package main + +import "path/filepath" + +// generateScoreTest emits game/generated_score_test.go — Score mutators + Equals. +func generateScoreTest(yamlData *GameYAML, destDir string) error { + return renderGoTemplate("score_test.go.tmpl", filepath.Join(destDir, "generated_score_test.go"), buildTemplateData(yamlData)) +} + +// generateScoreSummaryTest emits game/generated_score_summary_test.go — point math + tiebreaks. +func generateScoreSummaryTest(yamlData *GameYAML, destDir string) error { + return renderGoTemplate("score_summary_test.go.tmpl", filepath.Join(destDir, "generated_score_summary_test.go"), buildTemplateData(yamlData)) +} + +// generateRankingFieldsTest emits game/generated_ranking_fields_test.go — the ranking-Less cascade. +func generateRankingFieldsTest(yamlData *GameYAML, destDir string) error { + return renderGoTemplate("ranking_fields_test.go.tmpl", filepath.Join(destDir, "generated_ranking_fields_test.go"), buildTemplateData(yamlData)) +} diff --git a/cmd/generate/codegen_web.go b/cmd/generate/codegen_web.go new file mode 100644 index 00000000..7e1cfd9d --- /dev/null +++ b/cmd/generate/codegen_web.go @@ -0,0 +1,112 @@ +// Code generators for the web UI surfaces — scoring panel, referee panel, and audience display +// (HTML + JS) — produced by executing the committed templates/static custom_*.tmpl sources. The +// test generator for these surfaces lives in codegen_web_tests.go; the server Go in codegen_go.go. + +package main + +import "path/filepath" + +// ScoringBucket is one rolled-up scoring group — a ScoreSummary point field and an audience-display +// entry. ID/DisplayName are the resolved, presentable label; CountIDs are the scoring counts +// merged into this bucket. +type ScoringBucket struct { + ID string + DisplayName string + CountIDs []string +} + +// buildScoringGroups resolves every scoring count into the bucket it rolls up into, merging counts +// that land in the same bucket. A count with a scoring_group joins that group's bucket; a count with +// no scoring_group stands alone as its own bucket under its own id. (game_piece is piece identity, +// not a rollup — to group counts, give them a shared scoring_group.) +// +// The lookup key is tagged by source ("sg:"/"ct:") so a scoring_group and an ungrouped count that +// happen to share the same id string aren't merged into one bucket by accident. +func buildScoringGroups(yamlData *GameYAML) []ScoringBucket { + var buckets []ScoringBucket + seen := make(map[string]int) // lookup key -> index in buckets, so repeats merge into one bucket + + for _, sc := range yamlData.ScoringCounts { + var lookupKey, groupID, displayName string + if sc.ScoringGroup != "" { // grouped: roll up with the other counts in this scoring_group + lookupKey = "sg:" + sc.ScoringGroup + groupID = sc.ScoringGroup + displayName = sc.ScoringGroup + for _, group := range yamlData.ScoringGroups { + if group.ID == sc.ScoringGroup { + displayName = group.DisplayName // prefer the group's label over its raw id + } + } + } else { // ungrouped: the count is its own bucket + lookupKey = "ct:" + sc.ID + groupID = sc.ID + displayName = sc.DisplayName + } + + if idx, ok := seen[lookupKey]; ok { + buckets[idx].CountIDs = append(buckets[idx].CountIDs, sc.ID) + } else { + seen[lookupKey] = len(buckets) + buckets = append(buckets, ScoringBucket{ID: groupID, DisplayName: displayName, CountIDs: []string{sc.ID}}) + } + } + + return buckets +} + +var phaseSectionTitle = map[string]string{"auto": "Auto", "teleop": "Teleop", "endgame": "Endgame"} + +// generateReportsRankings emits web/generated_reports_rankings_custom.go — the CSV/PDF qualification +// rankings report handlers, whose middle columns track the configured ranking_tiebreakers so the +// report never references a RankingFields column the current custom_game.yaml doesn't generate. +func generateReportsRankings(yamlData *GameYAML, webDir string) error { + return renderGoTemplate("reports_rankings_custom.go.tmpl", filepath.Join(webDir, "generated_reports_rankings_custom.go"), buildTemplateData(yamlData)) +} + +func generateScoringPanelTemplate(yamlData *GameYAML, templatesDir string) error { + return renderWebTemplate( + filepath.Join(templatesDir, "custom_scoring_panel.html.tmpl"), + filepath.Join(templatesDir, "generated_scoring_panel.html"), + buildTemplateData(yamlData), + ) +} + +func generateScoringPanelJS(yamlData *GameYAML, staticJsDir string) error { + return renderWebTemplate( + filepath.Join(staticJsDir, "custom_scoring_panel.js.tmpl"), + filepath.Join(staticJsDir, "generated_scoring_panel.js"), + buildTemplateData(yamlData), + ) +} + +func generateAudienceDisplayTemplate(yamlData *GameYAML, templatesDir string) error { + return renderWebTemplate( + filepath.Join(templatesDir, "custom_audience_display.html.tmpl"), + filepath.Join(templatesDir, "generated_audience_display.html"), + buildTemplateData(yamlData), + ) +} + +func generateAudienceDisplayJS(yamlData *GameYAML, staticJsDir string) error { + return renderWebTemplate( + filepath.Join(staticJsDir, "custom_audience_display.js.tmpl"), + filepath.Join(staticJsDir, "generated_audience_display.js"), + buildTemplateData(yamlData), + ) +} + +func generateRefereePanelTemplate(yamlData *GameYAML, templatesDir string) error { + return renderWebTemplate( + filepath.Join(templatesDir, "custom_referee_panel.html.tmpl"), + filepath.Join(templatesDir, "generated_referee_panel.html"), + buildTemplateData(yamlData), + ) +} + +func generateRefereePanelJS(yamlData *GameYAML, staticJsDir string) error { + return renderWebTemplate( + filepath.Join(staticJsDir, "custom_referee_panel.js.tmpl"), + filepath.Join(staticJsDir, "generated_referee_panel.js"), + buildTemplateData(yamlData), + ) +} diff --git a/cmd/generate/codegen_web_tests.go b/cmd/generate/codegen_web_tests.go new file mode 100644 index 00000000..3a96ed1d --- /dev/null +++ b/cmd/generate/codegen_web_tests.go @@ -0,0 +1,24 @@ +// Generator for the test of the web UI surfaces (codegen_web.go): emits +// cmd/generate/generated_template_test.go, which asserts the rendered templates parse and contain +// the expected per-element markup. + +package main + +import "path/filepath" + +func generateTemplateTest(yamlData *GameYAML, destDir string) error { + return renderGoTemplate("template_test.go.tmpl", filepath.Join(destDir, "generated_template_test.go"), buildTemplateData(yamlData)) +} + +// generateReportsRankingsTest emits web/generated_reports_rankings_custom_test.go — the CSV/PDF +// rankings report tests, whose fixtures and expected CSV are built from the configured tiebreakers. +func generateReportsRankingsTest(yamlData *GameYAML, webDir string) error { + return renderGoTemplate("reports_rankings_custom_test.go.tmpl", filepath.Join(webDir, "generated_reports_rankings_custom_test.go"), buildTemplateData(yamlData)) +} + +// generateQualificationRankingsTest emits tournament/generated_qualification_rankings_custom_test.go +// — the end-to-end CalculateRankings test, which grants points via the first scoring count the +// current custom_game.yaml declares rather than a hard-coded field name. +func generateQualificationRankingsTest(yamlData *GameYAML, tournamentDir string) error { + return renderGoTemplate("qualification_rankings_custom_test.go.tmpl", filepath.Join(tournamentDir, "generated_qualification_rankings_custom_test.go"), buildTemplateData(yamlData)) +} diff --git a/cmd/generate/main.go b/cmd/generate/main.go new file mode 100644 index 00000000..0deef21c --- /dev/null +++ b/cmd/generate/main.go @@ -0,0 +1,490 @@ +// Run via `go generate ./...` from the repo root, which also picks up the unrelated stringer +// directives in plc/plc.go and model/match.go. The directive below runs with this directory +// (cmd/generate/) as its working directory, hence the ../../ prefix on the default custom_game.yaml path. +// +//go:generate go run . -f ../../game/custom_game.yaml -out ../.. +package main + +import ( + "flag" + "fmt" + "gopkg.in/yaml.v3" + "os" + "path/filepath" + "regexp" + "strings" +) + +var goIdentRegexp = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +var validElementPhases = map[string]bool{"auto": true, "teleop": true, "endgame": true} +var validStatusPhases = map[string]bool{"auto": true, "endgame": true} + +// cleanPatterns lists every generated-file glob, matching the patterns in .gitignore. +var cleanPatterns = []string{ + "game/generated_*.go", + "web/generated_*.go", + "tournament/generated_*.go", + "templates/generated_*.html", + "static/js/generated_*.js", + "cmd/generate/generated_*_test.go", +} + +// runClean removes every file matching cleanPatterns, run from the repo root. +func runClean() error { + removed := 0 + for _, pattern := range cleanPatterns { + matches, err := filepath.Glob(pattern) + if err != nil { + return err + } + for _, match := range matches { + if err := os.Remove(match); err != nil { + return err + } + fmt.Println("Removed", match) + removed++ + } + } + if removed == 0 { + fmt.Println("Nothing to clean.") + } + return nil +} + +func main() { + if len(os.Args) > 1 && os.Args[1] == "clean" { + if err := runClean(); err != nil { + fmt.Fprintf(os.Stderr, "Error cleaning generated files: %v\n", err) + os.Exit(1) + } + return + } + + yamlPath := flag.String("f", "game/custom_game.yaml", "path to the game definition YAML to read") + // outRoot decouples the generated-file destinations from the input YAML's location: output always + // lands in the standard repo layout (out/game, out/templates, out/static/js, out/cmd/generate), + // so a config under game/examples/ generates into the same place game/custom_game.yaml would. + // Default "." works when run from the repo root; the go:generate directive passes "../.." since it + // runs in cmd/generate/. + outRoot := flag.String("out", ".", "repo root the generated files are written under") + flag.Parse() + + data, err := os.ReadFile(*yamlPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading custom_game.yaml: %v\n", err) + os.Exit(1) + } + + var yamlData GameYAML + if err := yaml.Unmarshal(data, &yamlData); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing custom_game.yaml: %v\n", err) + os.Exit(1) + } + + // Validation + validationErrors := validateGameYAML(&yamlData) + + // Only check the hand-written scoring logic once the config itself is valid — otherwise the + // generated field set it's checked against may be malformed, producing misleading errors. + if len(validationErrors) == 0 { + logicPath := filepath.Join(*outRoot, "game", "custom_scoring_logic.go") + validationErrors = append(validationErrors, validateCustomScoringLogic(&yamlData, logicPath)...) + } + + if len(validationErrors) > 0 { + fmt.Fprintln(os.Stderr, "Validation errors in custom_game.yaml:") + for _, errStr := range validationErrors { + fmt.Fprintf(os.Stderr, " - %s\n", errStr) + } + os.Exit(1) + } + + // Codegen target dirs — always the standard repo layout under outRoot, independent of where the + // input YAML lives. + gameDir := filepath.Join(*outRoot, "game") + + if err := generateConstants(&yamlData, gameDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating constants: %v\n", err) + os.Exit(1) + } + + if err := generateScore(&yamlData, gameDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating Score struct: %v\n", err) + os.Exit(1) + } + + if err := generateScoreSummary(&yamlData, gameDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating ScoreSummary: %v\n", err) + os.Exit(1) + } + + if err := generateRankingFields(&yamlData, gameDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating RankingFields: %v\n", err) + os.Exit(1) + } + + if err := generateScoreTest(&yamlData, gameDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating score test: %v\n", err) + os.Exit(1) + } + + if err := generateScoreSummaryTest(&yamlData, gameDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating score summary test: %v\n", err) + os.Exit(1) + } + + if err := generateRankingFieldsTest(&yamlData, gameDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating ranking fields test: %v\n", err) + os.Exit(1) + } + + webDir := filepath.Join(*outRoot, "web") + tournamentDir := filepath.Join(*outRoot, "tournament") + + if err := generateReportsRankings(&yamlData, webDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating rankings report handler: %v\n", err) + os.Exit(1) + } + + if err := generateReportsRankingsTest(&yamlData, webDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating rankings report test: %v\n", err) + os.Exit(1) + } + + if err := generateQualificationRankingsTest(&yamlData, tournamentDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating qualification rankings test: %v\n", err) + os.Exit(1) + } + + templatesDir := filepath.Join(*outRoot, "templates") + staticJsDir := filepath.Join(*outRoot, "static/js") + cmdGenerateDir := filepath.Join(*outRoot, "cmd/generate") + + if err := generateScoringPanelTemplate(&yamlData, templatesDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating scoring panel template: %v\n", err) + os.Exit(1) + } + + if err := generateScoringPanelJS(&yamlData, staticJsDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating scoring panel JS: %v\n", err) + os.Exit(1) + } + + if err := generateAudienceDisplayTemplate(&yamlData, templatesDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating audience display template: %v\n", err) + os.Exit(1) + } + + if err := generateAudienceDisplayJS(&yamlData, staticJsDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating audience display JS: %v\n", err) + os.Exit(1) + } + + if err := generateRefereePanelTemplate(&yamlData, templatesDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating referee panel template: %v\n", err) + os.Exit(1) + } + + if err := generateRefereePanelJS(&yamlData, staticJsDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating referee panel JS: %v\n", err) + os.Exit(1) + } + + if err := generateTemplateTest(&yamlData, cmdGenerateDir); err != nil { + fmt.Fprintf(os.Stderr, "Error generating template test: %v\n", err) + os.Exit(1) + } + + fmt.Println("Code generation complete successfully.") +} + +func toCamelCase(s string) string { + parts := strings.Split(s, "_") + for i, p := range parts { + if len(p) > 0 { + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + } + return strings.Join(parts, "") +} + +func validateGameYAML(yamlData *GameYAML) []string { + var validationErrors []string + + if yamlData.Game.Name == "" { + validationErrors = append(validationErrors, "game.name is required") + } + if yamlData.Fouls.MinorFoulPoints <= 0 { + validationErrors = append(validationErrors, "fouls.minor_foul_points must be > 0") + } + if yamlData.Fouls.MajorFoulPoints <= 0 { + validationErrors = append(validationErrors, "fouls.major_foul_points must be > 0") + } + + seenIDs := make(map[string]bool) + checkDup := func(id string, context string) { + if id == "" { + return + } + if seenIDs[id] { + validationErrors = append(validationErrors, fmt.Sprintf("duplicate id: '%s' in %s", id, context)) + } + seenIDs[id] = true + } + + // Game pieces + gamePieces := make(map[string]bool) + for i, gp := range yamlData.GamePieces { + if gp.ID == "" { + validationErrors = append(validationErrors, fmt.Sprintf("game_pieces[%d]: id is required", i)) + } else if !goIdentRegexp.MatchString(gp.ID) { + validationErrors = append(validationErrors, fmt.Sprintf("game_pieces[%d]: id '%s' must be a valid Go identifier (letters, digits, underscores; cannot start with a digit)", i, gp.ID)) + } else { + checkDup(gp.ID, "game_pieces") + gamePieces[gp.ID] = true + } + } + + // Scoring groups + scoringGroups := make(map[string]bool) + for i, dg := range yamlData.ScoringGroups { + if dg.ID == "" { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_groups[%d]: id is required", i)) + } else if !goIdentRegexp.MatchString(dg.ID) { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_groups[%d]: id '%s' must be a valid Go identifier (letters, digits, underscores; cannot start with a digit)", i, dg.ID)) + } else { + checkDup(dg.ID, "scoring_groups") + scoringGroups[dg.ID] = true + } + } + + // scoring_counts. countCamel guards against two distinct ids that CamelCase to the same Go + // identifier (e.g. "deck2" and "deck_2" -> "Deck2"), which would emit a duplicate Score field, + // AdjustCount method, and PointsVal const. + countCamel := make(map[string]string) + for i, sc := range yamlData.ScoringCounts { + if sc.ID == "" { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d]: id is required", i)) + continue + } + if !goIdentRegexp.MatchString(sc.ID) { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d]: id '%s' must be a valid Go identifier (letters, digits, underscores; cannot start with a digit)", i, sc.ID)) + continue + } + checkDup(sc.ID, "scoring_counts") + if camel := toCamelCase(sc.ID); countCamel[camel] != "" && countCamel[camel] != sc.ID { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d].%s: id CamelCases to '%s', colliding with scoring count '%s' (both would generate the same Score field/method)", i, sc.ID, camel, countCamel[camel])) + } else { + countCamel[camel] = sc.ID + } + + if sc.GamePiece == "" { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d].%s: game_piece is required", i, sc.ID)) + } else if !gamePieces[sc.GamePiece] { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d].%s: unknown game_piece '%s'", i, sc.ID, sc.GamePiece)) + } + if sc.ScoringGroup != "" && !scoringGroups[sc.ScoringGroup] { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d].%s: unknown scoring_group '%s'", i, sc.ID, sc.ScoringGroup)) + } + + if len(sc.Phases) == 0 { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d].%s: at least one phase is required", i, sc.ID)) + continue + } + seenPhases := make(map[string]bool) + for j, ep := range sc.Phases { + if !validElementPhases[ep.Phase] { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d].%s.phases[%d]: unknown phase '%s'", i, sc.ID, j, ep.Phase)) + continue + } + if seenPhases[ep.Phase] { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d].%s: duplicate phase '%s'", i, sc.ID, ep.Phase)) + } + seenPhases[ep.Phase] = true + if ep.Points <= 0 { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d].%s.phases[%d]: points must be > 0", i, sc.ID, j)) + } + } + if seenPhases["teleop"] && seenPhases["endgame"] { + validationErrors = append(validationErrors, fmt.Sprintf("scoring_counts[%d].%s: cannot be scored in both teleop and endgame (teleop play continues through endgame)", i, sc.ID)) + } + } + + // statuses. statusCamel guards CamelCase collisions among status ids (which would produce a + // duplicate Statuses field, SetStatus method, and enum type), analogous to counts. + statusCamel := make(map[string]string) + for i, status := range yamlData.Statuses { + if status.ID == "" { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d]: id is required", i)) + continue + } + if !goIdentRegexp.MatchString(status.ID) { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d]: id '%s' must be a valid Go identifier (letters, digits, underscores; cannot start with a digit)", i, status.ID)) + continue + } + checkDup(status.ID, "statuses") + if camel := toCamelCase(status.ID); statusCamel[camel] != "" && statusCamel[camel] != status.ID { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d].%s: id CamelCases to '%s', colliding with status '%s' (both would generate the same Score field/method)", i, status.ID, camel, statusCamel[camel])) + } else { + statusCamel[camel] = status.ID + } + + if len(status.Phases) != 1 { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d].%s: exactly one phase is required (got %d)", i, status.ID, len(status.Phases))) + } else if !validStatusPhases[status.Phases[0].Phase] { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d].%s.phases[0]: unknown phase '%s' (only auto and endgame are supported for statuses)", i, status.ID, status.Phases[0].Phase)) + } + + if len(status.Values) > 0 { + if len(status.Values) < 2 { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d].%s: enum status requires at least 2 values", i, status.ID)) + } + // The generated [3]Status array zero-values to the first declared value, so every + // un-scored robot holds it. If it scored points, they'd be awarded silently (and the + // generated tests, which assume a zero Score contributes 0, would fail). Require the + // baseline value to be worth 0. + if len(status.Values) > 0 && status.Values[0].Points != 0 { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d].%s: the first enum value '%s' must have points: 0 (it is the default state of an un-scored robot)", i, status.ID, status.Values[0].ID)) + } + statusVals := make(map[string]bool) + valCamel := make(map[string]string) + for j, val := range status.Values { + if val.ID == "" { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d].%s values[%d]: id is required", i, status.ID, j)) + } else { + if statusVals[val.ID] { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d].%s: duplicate value id '%s'", i, status.ID, val.ID)) + } + statusVals[val.ID] = true + if camel := toCamelCase(val.ID); valCamel[camel] != "" && valCamel[camel] != val.ID { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d].%s: value id '%s' CamelCases to '%s', colliding with value '%s'", i, status.ID, val.ID, camel, valCamel[camel])) + } else { + valCamel[camel] = val.ID + } + } + } + } else if len(status.Phases) == 1 { + if status.Phases[0].Points <= 0 { + validationErrors = append(validationErrors, fmt.Sprintf("statuses[%d].%s: phases[0].points must be > 0 for bool status", i, status.ID)) + } + } + } + + // RPs + for i, rp := range yamlData.RPs { + if rp.ID == "" { + validationErrors = append(validationErrors, fmt.Sprintf("ranking_points[%d]: id is required", i)) + } else if !goIdentRegexp.MatchString(rp.ID) { + validationErrors = append(validationErrors, fmt.Sprintf("ranking_points[%d]: id '%s' must be a valid Go identifier (letters, digits, underscores; cannot start with a digit)", i, rp.ID)) + } else { + checkDup(rp.ID, "ranking_points") + } + + if rp.LogicFunc == "" || !goIdentRegexp.MatchString(rp.LogicFunc) { + validationErrors = append(validationErrors, fmt.Sprintf("ranking_points[%d].logic_func: '%s' is not a valid Go identifier", i, rp.LogicFunc)) + } + } + + buckets := buildScoringGroups(yamlData) + + // Reject ids whose generated point field (CamelCase(id)+"Points") collides with a built-in field + // or with another generated field. This catches e.g. a scoring_group/status id of "auto_points" + // (-> AutoPoints) or "match" (-> MatchPoints), and two ids that CamelCase to the same name (e.g. + // "auto_points" and "Auto_points"), either of which would emit uncompilable Go. "RankingPoints" + // is reserved too: it isn't a ScoreSummary built-in, but a tiebreaker on such an id would emit a + // RankingFields field colliding with the built-in RankingPoints. + summaryFields := map[string]string{ + "AutoPoints": "a built-in field", "TeleopPoints": "a built-in field", + "EndgamePoints": "a built-in field", "MatchPoints": "a built-in field", + "FoulPoints": "a built-in field", "BonusRankingPoints": "a built-in field", + "RankingPoints": "the built-in RankingFields field", + } + checkSummaryField := func(id, context string) { + field := toCamelCase(id) + "Points" + if existing, ok := summaryFields[field]; ok { + validationErrors = append(validationErrors, fmt.Sprintf("%s '%s': generated point field %s collides with %s", context, id, field, existing)) + return + } + summaryFields[field] = context + " '" + id + "'" + } + for _, bucket := range buckets { + checkSummaryField(bucket.ID, "scoring group") // bucket id = scoring_group id, or ungrouped count's own id + } + for _, status := range yamlData.Statuses { + checkSummaryField(status.ID, "status") + } + + // Reject collisions among the generated *PointsVal package consts. These live in one package + // scope, and a count scored in a phase, a bool status, and an enum value each emit one — so a + // count "foo" in auto (FooAutoPointsVal) and a bool status "foo_auto" (FooAutoPointsVal) would + // clash even though neither the raw ids nor the CamelCase checks above catch it. + pointsValConsts := make(map[string]string) + checkPointsVal := func(name, source string) { + if existing, ok := pointsValConsts[name]; ok { + validationErrors = append(validationErrors, fmt.Sprintf("%s: generated const %s collides with %s", source, name, existing)) + return + } + pointsValConsts[name] = source + } + for _, sc := range yamlData.ScoringCounts { + for _, ep := range sc.Phases { + if validElementPhases[ep.Phase] { + checkPointsVal(toCamelCase(sc.ID)+phaseFieldPrefix[ep.Phase]+"PointsVal", "scoring count '"+sc.ID+"'") + } + } + } + for _, status := range yamlData.Statuses { + if len(status.Values) == 0 { + checkPointsVal(toCamelCase(status.ID)+"PointsVal", "status '"+status.ID+"'") + } else { + for _, val := range status.Values { + checkPointsVal(toCamelCase(status.ID)+toCamelCase(val.ID)+"PointsVal", "status '"+status.ID+"' value '"+val.ID+"'") + } + } + } + + // Build the set of valid tiebreaker metrics: the built-in phase/total points, plus every + // ScoreSummary point field — one per scoring-group bucket (a scoring_group id, or an ungrouped + // element's own id) and one per status. A raw element that is grouped is not valid on its own; + // tiebreak on its group instead. + validElements := map[string]bool{ + "auto_points": true, + "teleop_points": true, + "endgame_points": true, + "total_points": true, + } + for _, bucket := range buckets { + validElements[bucket.ID] = true + } + for _, status := range yamlData.Statuses { + validElements[status.ID] = true + } + + // ranking_tiebreakers — each metric must be known and must not repeat: two entries resolving to + // the same RankingFields field would emit a duplicate struct field (and a duplicate composite- + // literal key in the generated tests), which fails to compile. + seenRankingTiebreaker := make(map[string]bool) + for i, tb := range yamlData.RankingTiebreakers { + if !validElements[tb.Metric] { + validationErrors = append(validationErrors, fmt.Sprintf("ranking_tiebreakers[%d]: unknown metric '%s'", i, tb.Metric)) + } else if seenRankingTiebreaker[tb.Metric] { + validationErrors = append(validationErrors, fmt.Sprintf("ranking_tiebreakers[%d]: duplicate metric '%s'", i, tb.Metric)) + } + seenRankingTiebreaker[tb.Metric] = true + } + + // playoff_tiebreakers — likewise reject repeats: a duplicate metric emits a duplicate + // DetermineMatchStatus tiebreak test function, which fails the generated test build. + seenPlayoffTiebreaker := make(map[string]bool) + for i, tb := range yamlData.PlayoffTiebreakers { + if !validElements[tb.Metric] { + validationErrors = append(validationErrors, fmt.Sprintf("playoff_tiebreakers[%d]: unknown metric '%s'", i, tb.Metric)) + } else if seenPlayoffTiebreaker[tb.Metric] { + validationErrors = append(validationErrors, fmt.Sprintf("playoff_tiebreakers[%d]: duplicate metric '%s'", i, tb.Metric)) + } + seenPlayoffTiebreaker[tb.Metric] = true + } + + return validationErrors +} diff --git a/cmd/generate/main_test.go b/cmd/generate/main_test.go new file mode 100644 index 00000000..1af1bc77 --- /dev/null +++ b/cmd/generate/main_test.go @@ -0,0 +1,406 @@ +package main + +import ( + "github.com/stretchr/testify/assert" + "go/ast" + "go/parser" + "go/token" + "gopkg.in/yaml.v3" + "os" + "path/filepath" + "testing" +) + +func TestValidateTemplates(t *testing.T) { + paths := []string{ + "../../game/custom_game.yaml", + "../../game/examples/high_seas_havoc.yaml", + } + + for _, p := range paths { + t.Run(p, func(t *testing.T) { + data, err := os.ReadFile(p) + assert.Nil(t, err) + + var yamlData GameYAML + err = yaml.Unmarshal(data, &yamlData) + assert.Nil(t, err) + + validationErrors := validateGameYAML(&yamlData) + assert.Empty(t, validationErrors) + }) + } +} + +// testGameYAML is a small, self-contained, valid config the validation tests mutate. Using it +// instead of the shipped game/custom_game.yaml keeps these unit tests independent of whichever game +// is configured — a user who swaps in their own yaml doesn't break the generator's own tests. +func testGameYAML() *GameYAML { + return &GameYAML{ + Game: GameInfo{Name: "Test Game"}, + Fouls: FoulConfig{MinorFoulPoints: 5, MajorFoulPoints: 15}, + GamePieces: []GamePiece{{ID: "cube", DisplayName: "Cube"}}, + ScoringGroups: []ScoringGroup{{ID: "rack", DisplayName: "Rack"}}, + ScoringCounts: []ScoringCount{ + {ID: "rack_low", DisplayName: "Rack Low", GamePiece: "cube", ScoringGroup: "rack", + Phases: []PhasePoints{{Phase: "auto", Points: 3}, {Phase: "teleop", Points: 2}}}, + {ID: "rack_high", DisplayName: "Rack High", GamePiece: "cube", ScoringGroup: "rack", + Phases: []PhasePoints{{Phase: "teleop", Points: 5}}}, + }, + Statuses: []Status{ + {ID: "park", DisplayName: "Park", Phases: []PhasePoints{{Phase: "endgame", Points: 2}}}, + {ID: "climb", DisplayName: "Climb", Phases: []PhasePoints{{Phase: "endgame"}}, Values: []StatusValue{ + {ID: "none", DisplayName: "None", Points: 0}, {ID: "high", DisplayName: "High", Points: 5}}}, + }, + RPs: []RankingPoint{{ID: "auto_rp", DisplayName: "Auto RP", LogicFunc: "ComputeAutoRp"}}, + RankingTiebreakers: []Tiebreaker{{Metric: "total_points"}, {Metric: "auto_points"}}, + PlayoffTiebreakers: []Tiebreaker{{Metric: "auto_points"}, {Metric: "total_points"}}, + } +} + +func TestValidationErrors(t *testing.T) { + tests := []struct { + name string + modify func(*GameYAML) + expectedError string + }{ + { + name: "missing game name", + modify: func(y *GameYAML) { + y.Game.Name = "" + }, + expectedError: "game.name is required", + }, + { + name: "invalid minor foul points", + modify: func(y *GameYAML) { + y.Fouls.MinorFoulPoints = 0 + }, + expectedError: "fouls.minor_foul_points must be > 0", + }, + { + name: "invalid major foul points", + modify: func(y *GameYAML) { + y.Fouls.MajorFoulPoints = -1 + }, + expectedError: "fouls.major_foul_points must be > 0", + }, + { + name: "missing scoring count id", + modify: func(y *GameYAML) { + y.ScoringCounts[0].ID = "" + }, + expectedError: "scoring_counts[0]: id is required", + }, + { + name: "bad scoring count phase", + modify: func(y *GameYAML) { + y.ScoringCounts[0].Phases[0].Phase = "invalid_phase" + }, + expectedError: "unknown phase 'invalid_phase'", + }, + { + name: "scoring count with no phases", + modify: func(y *GameYAML) { + y.ScoringCounts[0].Phases = nil + }, + expectedError: "at least one phase is required", + }, + { + name: "scoring count with duplicate phase", + modify: func(y *GameYAML) { + y.ScoringCounts[0].Phases = []PhasePoints{ + {Phase: "auto", Points: 5}, + {Phase: "auto", Points: 3}, + } + }, + expectedError: "duplicate phase 'auto'", + }, + { + name: "scoring count phase with non-positive points", + modify: func(y *GameYAML) { + y.ScoringCounts[0].Phases = []PhasePoints{{Phase: "auto", Points: 0}} + }, + expectedError: "points must be > 0", + }, + { + name: "scoring count in both teleop and endgame rejected", + modify: func(y *GameYAML) { + y.ScoringCounts[0].Phases = []PhasePoints{{Phase: "teleop", Points: 2}, {Phase: "endgame", Points: 3}} + }, + expectedError: "cannot be scored in both teleop and endgame", + }, + { + name: "unknown scoring_group reference", + modify: func(y *GameYAML) { + y.ScoringCounts[0].ScoringGroup = "nonexistent" + }, + expectedError: "unknown scoring_group 'nonexistent'", + }, + { + name: "missing game_piece rejected", + modify: func(y *GameYAML) { + y.ScoringCounts[0].GamePiece = "" + }, + expectedError: "game_piece is required", + }, + { + name: "enum status with too few values", + modify: func(y *GameYAML) { + y.Statuses = []Status{ + { + ID: "bad_status", + Phases: []PhasePoints{{Phase: "auto"}}, + Values: []StatusValue{ + {ID: "one", DisplayName: "One"}, + }, + }, + } + }, + expectedError: "enum status requires at least 2 values", + }, + { + name: "status with teleop phase rejected", + modify: func(y *GameYAML) { + y.Statuses[0].Phases = []PhasePoints{{Phase: "teleop", Points: 3}} + }, + expectedError: "only auto and endgame are supported for statuses", + }, + { + name: "status with more than one phase rejected", + modify: func(y *GameYAML) { + y.Statuses[0].Phases = []PhasePoints{{Phase: "auto", Points: 3}, {Phase: "endgame", Points: 3}} + }, + expectedError: "exactly one phase is required", + }, + { + name: "unknown tiebreaker metric", + modify: func(y *GameYAML) { + y.RankingTiebreakers = append(y.RankingTiebreakers, Tiebreaker{Metric: "nonexistent"}) + }, + expectedError: "unknown metric 'nonexistent'", + }, + { + name: "duplicate id across sections", + modify: func(y *GameYAML) { + // A scoring count reusing the status id "park". + y.ScoringCounts = append(y.ScoringCounts, ScoringCount{ID: "park", GamePiece: y.GamePieces[0].ID, Phases: []PhasePoints{{Phase: "auto", Points: 5}}}) + }, + expectedError: "duplicate id: 'park'", + }, + { + name: "id collides with a built-in summary field", + modify: func(y *GameYAML) { + // "match" -> MatchPoints, which already exists as a built-in ScoreSummary field. + y.Statuses[0].ID = "match" + }, + expectedError: "collides with a built-in field", + }, + { + name: "two ids generate the same summary field", + modify: func(y *GameYAML) { + // "Rack" CamelCases to the same field as scoring_group "rack" (-> RackPoints), yet is a + // distinct raw id, so the dup-id check misses it. + y.Statuses = append(y.Statuses, Status{ID: "Rack", Phases: []PhasePoints{{Phase: "auto", Points: 3}}}) + }, + expectedError: "collides with scoring group 'rack'", + }, + { + name: "duplicate ranking tiebreaker metric", + modify: func(y *GameYAML) { + // The default already lists total_points; a second entry is a duplicate that would + // emit a duplicate RankingFields struct field. + y.RankingTiebreakers = append(y.RankingTiebreakers, Tiebreaker{Metric: "total_points"}) + }, + expectedError: "duplicate metric 'total_points'", + }, + { + name: "duplicate playoff tiebreaker metric", + modify: func(y *GameYAML) { + y.PlayoffTiebreakers = append(y.PlayoffTiebreakers, Tiebreaker{Metric: "total_points"}) + }, + expectedError: "playoff_tiebreakers", + }, + { + name: "scoring count ids that CamelCase to the same identifier", + modify: func(y *GameYAML) { + // "rackLow" -> "RackLow", same as the fixture's "rack_low". + y.ScoringCounts = append(y.ScoringCounts, ScoringCount{ID: "rackLow", Phases: []PhasePoints{{Phase: "auto", Points: 1}}}) + }, + expectedError: "colliding with scoring count", + }, + { + name: "status ids that CamelCase to the same identifier", + modify: func(y *GameYAML) { + y.Statuses = append(y.Statuses, Status{ID: "Park", Phases: []PhasePoints{{Phase: "endgame", Points: 2}}}) + }, + expectedError: "colliding with status", + }, + { + name: "enum status first value scores points", + modify: func(y *GameYAML) { + y.Statuses = append(y.Statuses, Status{ID: "gizmo", Phases: []PhasePoints{{Phase: "endgame", Points: 1}}, Values: []StatusValue{ + {ID: "low", Points: 2}, {ID: "high", Points: 5}, + }}) + }, + expectedError: "first enum value 'low' must have points: 0", + }, + { + name: "id resolves to the built-in RankingPoints field", + modify: func(y *GameYAML) { + y.Statuses = append(y.Statuses, Status{ID: "ranking", Phases: []PhasePoints{{Phase: "endgame", Points: 1}}}) + }, + expectedError: "RankingPoints collides with the built-in RankingFields field", + }, + { + name: "PointsVal consts collide across a count and a status", + modify: func(y *GameYAML) { + // count "foo" in auto -> FooAutoPointsVal; bool status "foo_auto" -> FooAutoPointsVal. + y.ScoringCounts = append(y.ScoringCounts, ScoringCount{ID: "foo", GamePiece: y.GamePieces[0].ID, Phases: []PhasePoints{{Phase: "auto", Points: 1}}}) + y.Statuses = append(y.Statuses, Status{ID: "foo_auto", Phases: []PhasePoints{{Phase: "endgame", Points: 2}}}) + }, + expectedError: "generated const FooAutoPointsVal collides", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Start from a fresh copy of the self-contained fixture (a new one per case, so appends + // in one case don't leak into another). + yamlData := *testGameYAML() + + tt.modify(&yamlData) + + validationErrors := validateGameYAML(&yamlData) + assert.NotEmpty(t, validationErrors) + + found := false + for _, errStr := range validationErrors { + if assert.Contains(t, errStr, tt.expectedError) { + found = true + break + } + } + assert.True(t, found, "Expected error containing: %q, got: %v", tt.expectedError, validationErrors) + }) + } +} + +func TestValidateCustomScoringLogic(t *testing.T) { + // Self-contained fixture: one ranking_point (ComputeAutoRp), a Score exposing AutoRackLowCount / + // TeleopRackHighCount / ParkStatuses / ClimbStatuses, and the usual summary point fields. + base := testGameYAML() + + writeLogic := func(t *testing.T, content string) string { + path := filepath.Join(t.TempDir(), "custom_scoring_logic.go") + assert.Nil(t, os.WriteFile(path, []byte(content), 0644)) + return path + } + + // A logic file defining the fixture's one logic func with the given body. + logicWith := func(body string) string { + return "package game\n" + + "func ComputeAutoRp(score, opponentScore Score, summary ScoreSummary) bool {\n" + body + "\n}\n" + } + + t.Run("valid logic matching the config", func(t *testing.T) { + path := writeLogic(t, logicWith( + "\tparked := 0\n\tfor _, p := range score.ParkStatuses {\n\t\tif p {\n\t\t\tparked++\n\t\t}\n\t}\n"+ + "\treturn parked >= 2 && score.AutoRackLowCount > 0 && summary.AutoPoints >= 9")) + assert.Empty(t, validateCustomScoringLogic(base, path)) + }) + + t.Run("no false positives on method calls and non-param selectors", func(t *testing.T) { + path := writeLogic(t, logicWith( + "\tfor _, foul := range score.Fouls {\n\t\t_ = foul.PointValue()\n\t}\n\treturn summary.AutoPoints >= 9")) + assert.Empty(t, validateCustomScoringLogic(base, path)) + }) + + t.Run("obsolete Score field with suggestion", func(t *testing.T) { + // A near-miss typo of a real field should be flagged and suggest the real field. + path := writeLogic(t, logicWith("\treturn score.AutoRackLowKount > 2")) + errs := validateCustomScoringLogic(base, path) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0], "AutoRackLowKount") + assert.Contains(t, errs[0], "did you mean 'AutoRackLowCount'") + }) + + t.Run("unknown ScoreSummary field", func(t *testing.T) { + path := writeLogic(t, logicWith("\treturn summary.BogusPoints > 0")) + errs := validateCustomScoringLogic(base, path) + assert.Len(t, errs, 1) + assert.Contains(t, errs[0], "summary.BogusPoints") + assert.Contains(t, errs[0], "is not a field generated") + }) + + t.Run("missing logic func emits a stub with field/helper reference", func(t *testing.T) { + content := "package game\nfunc SomethingElse(score, opponentScore Score, summary ScoreSummary) bool { return false }\n" + errs := validateCustomScoringLogic(base, writeLogic(t, content)) + assert.Len(t, errs, 1) + // The copy-pasteable stub for the missing func. + assert.Contains(t, errs[0], "func ComputeAutoRp(score, opponentScore Score, summary ScoreSummary) bool") + // The data reference: a count field, a status helper (bool + enum with values), a summary total. + assert.Contains(t, errs[0], "AutoRackLowCount") + assert.Contains(t, errs[0], "score.AnyParkStatus()") + assert.Contains(t, errs[0], "Any"+"ClimbStatus(atLeast ClimbStatus)") + assert.Contains(t, errs[0], "ClimbNone") + assert.Contains(t, errs[0], "RackPoints") + assert.Contains(t, errs[0], "HasRankingPointFoul") + }) + + t.Run("missing file with no ranking points is fine", func(t *testing.T) { + noRPs := *base + noRPs.RPs = nil + assert.Empty(t, validateCustomScoringLogic(&noRPs, filepath.Join(t.TempDir(), "does_not_exist.go"))) + }) +} + +// TestGeneratedFieldSetsMatchTemplates guards the hand-maintained base-field lists in +// generatedFieldSets (validate_logic.go) against drifting from what score.go.tmpl / +// score_summary.go.tmpl actually emit. If a base field is added to a template but not to +// generatedFieldSets, the validator would falsely reject a valid custom_scoring_logic.go and halt +// generation; this catches that. Runs against the generated structs for the default config; skipped +// on a fresh checkout where `go generate` hasn't produced them yet. +func TestGeneratedFieldSetsMatchTemplates(t *testing.T) { + data, err := os.ReadFile("../../game/custom_game.yaml") + assert.Nil(t, err) + var y GameYAML + assert.Nil(t, yaml.Unmarshal(data, &y)) + scoreFields, summaryFields := generatedFieldSets(&y) + + check := func(genPath, structName string, allowed map[string]bool) { + src, err := os.ReadFile(genPath) + if err != nil { + t.Skipf("%s not present; run `go generate ./...` first (%v)", genPath, err) + } + for _, field := range structFieldNames(t, src, structName) { + assert.Truef(t, allowed[field], + "generated %s.%s is missing from generatedFieldSets in validate_logic.go — the validator will falsely reject it", + structName, field) + } + } + check("../../game/generated_score.go", "Score", scoreFields) + check("../../game/generated_score_summary.go", "ScoreSummary", summaryFields) +} + +// structFieldNames returns the declared field names of the named struct in Go source src. +func structFieldNames(t *testing.T, src []byte, structName string) []string { + file, err := parser.ParseFile(token.NewFileSet(), "", src, 0) + assert.Nil(t, err) + var names []string + ast.Inspect(file, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok || ts.Name.Name != structName { + return true + } + if st, ok := ts.Type.(*ast.StructType); ok { + for _, f := range st.Fields.List { + for _, nm := range f.Names { + names = append(names, nm.Name) + } + } + } + return false + }) + return names +} diff --git a/cmd/generate/render.go b/cmd/generate/render.go new file mode 100644 index 00000000..e08ae50e --- /dev/null +++ b/cmd/generate/render.go @@ -0,0 +1,71 @@ +// Generate-time template rendering. The committed templates/custom_*.html.tmpl and +// static/js/custom_*.js.tmpl source files are the hand-editable UI defaults; this executes them +// against the view model (see viewmodel.go) to produce the gitignored generated_* files the FMS +// server serves. +// +// Delimiters are [[ ]], not {{ }}. The generated output is itself parsed by the FMS server's own +// html/template engine, so the {{ }} directives in the source (e.g. {{.Position.Title}}, +// {{range $i := seq 3}}, {{template "foulButton"}}) must pass through verbatim — using [[ ]] here +// means the generator treats those {{ }} as literal text and only acts on its own [[ ]] directives. + +package main + +import ( + "bytes" + "embed" + "fmt" + "go/format" + "os" + "path/filepath" + "text/template" +) + +// goTemplates holds the internal Go-emitting templates. Unlike the UI templates/custom_*.tmpl +// (committed, hand-editable game-author surface), these are generator implementation detail — +// embedded, not a customization point — used purely to make the *shape* of the generated Go +// readable in one place instead of buried in strings.Builder calls. +// +//go:embed templates_go/*.tmpl +var goTemplates embed.FS + +// renderWebTemplate parses srcPath (a [[ ]]-delimited generate-time template), executes it against +// data, and writes the result to dstPath. +func renderWebTemplate(srcPath, dstPath string, data any) error { + src, err := os.ReadFile(srcPath) + if err != nil { + return err + } + tmpl, err := template.New(filepath.Base(srcPath)).Delims("[[", "]]").Funcs(genTemplateFuncs).Parse(string(src)) + if err != nil { + return err + } + f, err := os.Create(dstPath) + if err != nil { + return err + } + defer f.Close() + return tmpl.Execute(f, data) +} + +// renderGoTemplate executes the embedded Go template named name against data, runs the result +// through go/format (so the template itself needn't be perfectly indented — gofmt normalizes it), +// and writes it to dstPath. On a format error it returns the unformatted source for debugging. +func renderGoTemplate(name, dstPath string, data any) error { + src, err := goTemplates.ReadFile("templates_go/" + name) + if err != nil { + return err + } + tmpl, err := template.New(name).Delims("[[", "]]").Funcs(genTemplateFuncs).Parse(string(src)) + if err != nil { + return err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return err + } + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return fmt.Errorf("gofmt of generated %s failed: %w\n--- generated source ---\n%s", name, err, buf.String()) + } + return os.WriteFile(dstPath, formatted, 0644) +} diff --git a/cmd/generate/schema.go b/cmd/generate/schema.go new file mode 100644 index 00000000..8f03602c --- /dev/null +++ b/cmd/generate/schema.go @@ -0,0 +1,79 @@ +package main + +type GameYAML struct { + Game GameInfo `yaml:"game"` + Fouls FoulConfig `yaml:"fouls"` + GamePieces []GamePiece `yaml:"game_pieces"` + ScoringGroups []ScoringGroup `yaml:"scoring_groups"` + ScoringCounts []ScoringCount `yaml:"scoring_counts"` + Statuses []Status `yaml:"statuses"` + RPs []RankingPoint `yaml:"ranking_points"` + RankingTiebreakers []Tiebreaker `yaml:"ranking_tiebreakers"` + PlayoffTiebreakers []Tiebreaker `yaml:"playoff_tiebreakers"` +} + +type GameInfo struct { + Name string `yaml:"name"` +} + +type FoulConfig struct { + MinorFoulPoints int `yaml:"minor_foul_points"` + MajorFoulPoints int `yaml:"major_foul_points"` +} + +type GamePiece struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` +} + +// ScoringGroup is a named rollup of scoring counts. Its member counts' points are summed into one +// ScoreSummary field (summary.Points) and shown together on the audience display, and the +// group is the unit tiebreakers reference. Separate from GamePiece, which tracks real piece +// identity, not how scoring rolls up. +type ScoringGroup struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` +} + +type ScoringCount struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + GamePiece string `yaml:"game_piece"` // required; names a game_pieces id (piece identity, not a rollup) + ScoringGroup string `yaml:"scoring_group"` // optional; names a scoring_groups id (the rollup bucket) + Phases []PhasePoints `yaml:"phases"` +} + +// PhasePoints declares that a scoring count (or status) is scored during the given phase, worth +// Points each time. A scoring count with multiple PhasePoints entries generates one Count field per +// phase (e.g. AutoFooCount and TeleopFooCount), each accumulating independently. +type PhasePoints struct { + Phase string `yaml:"phase"` // "auto" | "teleop" | "endgame" + Points int `yaml:"points"` +} + +// Status declares a per-robot status flag. Phases must have exactly one entry, phase "auto" or +// "endgame" (teleop not supported — see CUSTOM_GAMES.md for why). Phases[0].Points is the bool- +// status point value (sugar for an implicit {false: 0, true: Points} values list); it's unused +// when Values is set, since each StatusValue then carries its own per-state points instead. +type Status struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + Phases []PhasePoints `yaml:"phases"` + Values []StatusValue `yaml:"values"` +} + +type StatusValue struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + Points int `yaml:"points"` +} + +type RankingPoint struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + LogicFunc string `yaml:"logic_func"` +} + +type Tiebreaker struct { + Metric string `yaml:"metric"` +} diff --git a/cmd/generate/template_funcs.go b/cmd/generate/template_funcs.go new file mode 100644 index 00000000..68f49ea3 --- /dev/null +++ b/cmd/generate/template_funcs.go @@ -0,0 +1,55 @@ +// Helpers callable from the generate-time templates ([[ ]] funcs). They render structured +// view-model data (plain []string) into JS array literals, so the view model never has to carry +// pre-formatted JavaScript and the templates stay self-documenting. Emitting the leading "[" from +// a function (rather than as literal template text) also sidesteps the [[ ]]-delimiter collision a +// literal "[" immediately before a "[[" action would otherwise cause. + +package main + +import ( + "fmt" + "strings" + "text/template" +) + +var genTemplateFuncs = template.FuncMap{ + "jsStrings": jsStrings, + "jsScoreArray": jsScoreArray, + "add": func(a, b int) int { return a + b }, + // first returns the first n elements — used to build each ranking-Less tier's prior fields. + "first": func(xs []string, n int) []string { return xs[:n] }, + // camel is the one naming primitive: an id -> its Go/JS CamelCase identity, e.g. + // "structure1_level1" -> "Structure1Level1". The generated field/method/const names are this + // plus a literal suffix in the template ("Count", "Statuses", "Points", "PointsVal", "Status"), + // so the naming convention lives at the call site instead of as precomputed view-model fields. + "camel": toCamelCase, + // phasePrefix is a phase's field-name prefix, e.g. "auto" -> "Auto" (so "Auto"+camel(id)+"Count" + // is the Score field, "Phase"+prefix is the Phase enum constant, prefix+"Points" the phase total). + "phasePrefix": func(phase string) string { return phaseFieldPrefix[phase] }, + // displayNames projects an enum status's values to their display names, for jsStrings. + "displayNames": func(vs []ValueView) []string { + names := make([]string, len(vs)) + for i, v := range vs { + names[i] = v.DisplayName + } + return names + }, +} + +// jsStrings renders display names as a JS array of quoted string literals: ["None", "Full"]. +func jsStrings(xs []string) string { + quoted := make([]string, len(xs)) + for i, x := range xs { + quoted[i] = fmt.Sprintf("%q", x) + } + return "[" + strings.Join(quoted, ", ") + "]" +} + +// jsScoreArray renders Score field names as a JS array of accessors: [score.AutoHullCount, score.AutoDeckCount]. +func jsScoreArray(fields []string) string { + exprs := make([]string, len(fields)) + for i, f := range fields { + exprs[i] = "score." + f + } + return "[" + strings.Join(exprs, ", ") + "]" +} diff --git a/cmd/generate/templates_go/constants.go.tmpl b/cmd/generate/templates_go/constants.go.tmpl new file mode 100644 index 00000000..da9c1495 --- /dev/null +++ b/cmd/generate/templates_go/constants.go.tmpl @@ -0,0 +1,17 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +//go:build custom + +package game + +const CustomGameMode = true +const CustomGameName = [[printf "%q" .GameName]] + +// Custom games do not use Hub shift timing; suppresses shift_change sound cues. +const UseShifts = false + +// Foul points (custom game may differ from standard FRC). +// These are referenced by foul.go (untagged) so must live here, not in generated_score_summary.go. +const MinorFoulPoints = [[.MinorFoulPoints]] +const MajorFoulPoints = [[.MajorFoulPoints]] diff --git a/cmd/generate/templates_go/qualification_rankings_custom_test.go.tmpl b/cmd/generate/templates_go/qualification_rankings_custom_test.go.tmpl new file mode 100644 index 00000000..d6e3ffdd --- /dev/null +++ b/cmd/generate/templates_go/qualification_rankings_custom_test.go.tmpl @@ -0,0 +1,82 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +//go:build custom + +package tournament + +import ( + "fmt" + "github.com/Team254/cheesy-arena/game" + "github.com/Team254/cheesy-arena/model" + "github.com/stretchr/testify/assert" + "math/rand" + "testing" + "time" +) + +func TestCalculateRankingsCustom(t *testing.T) { + randomizer := rand.New(rand.NewSource(1)) + game.RankingRandomFloat64 = randomizer.Float64 + database := setupTestDb(t) + + // Create 6 teams and 1 match. + for i := 1; i <= 6; i++ { + assert.Nil(t, database.CreateTeam(&model.Team{Id: i, Nickname: fmt.Sprintf("Team %d", i)})) + } + + match := &model.Match{ + Type: model.Qualification, + TypeOrder: 1, + Time: time.Unix(0, 0), + Red1: 1, + Red2: 3, + Red3: 5, + Blue1: 2, + Blue2: 4, + Blue3: 6, + Status: game.RedWonMatch, + } + assert.Nil(t, database.CreateMatch(match)) + + matchResult := &model.MatchResult{ + MatchId: match.Id, + PlayNumber: 1, + RedScore: &game.Score{PlayoffDq: false}, + BlueScore: &game.Score{PlayoffDq: false}, + } +[[if .RankingTestScore.Field]] // Give Red a scoring lead so they win, using the first scoring element the current + // custom_game.yaml declares — derived from the config so no field name is hard-coded. + matchResult.RedScore.[[.RankingTestScore.Field]] = [[.RankingTestScore.Value]] + database.CreateMatchResult(matchResult) + + updatedRankings, err := CalculateRankings(database, false) + assert.Nil(t, err) + assert.Len(t, updatedRankings, 6) + + // Red won, so the red teams (1, 3, 5) rank first with at least the 3 RP awarded for a win. Bonus + // RP come from the hand-written logic in custom_scoring_logic.go, so we assert the win-RP floor + // rather than an exact total that a logic change would invalidate. + for i := 0; i < 3; i++ { + assert.Contains(t, []int{1, 3, 5}, updatedRankings[i].TeamId) + assert.Equal(t, i+1, updatedRankings[i].Rank) + assert.GreaterOrEqual(t, updatedRankings[i].RankingPoints, 3) + } + + // The blue teams (2, 4, 6) lost and rank last. + for i := 3; i < 6; i++ { + assert.Contains(t, []int{2, 4, 6}, updatedRankings[i].TeamId) + assert.Equal(t, i+1, updatedRankings[i].Rank) + } + + // Red strictly outranks Blue: the lowest red team still has more RP than the top blue team. This + // catches a regression that leaks ranking points to the losing alliance. + assert.Greater(t, updatedRankings[2].RankingPoints, updatedRankings[3].RankingPoints) +[[else]] // This game declares nothing to score, so no alliance can take a lead; just assert that the + // ranking calculation runs and produces one row per team. + database.CreateMatchResult(matchResult) + + updatedRankings, err := CalculateRankings(database, false) + assert.Nil(t, err) + assert.Len(t, updatedRankings, 6) +[[end]]} diff --git a/cmd/generate/templates_go/ranking_fields.go.tmpl b/cmd/generate/templates_go/ranking_fields.go.tmpl new file mode 100644 index 00000000..1b87c0f1 --- /dev/null +++ b/cmd/generate/templates_go/ranking_fields.go.tmpl @@ -0,0 +1,69 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +//go:build custom + +package game + +import "math/rand" + +type RankingFields struct { + RankingPoints int +[[range $field := .RankingTiebreakerFields]] [[$field]] int +[[end]] Random float64 + Wins int + Losses int + Ties int + Disqualifications int + Played int +} + +var RankingRandomFloat64 = rand.Float64 + +func (fields *RankingFields) AddScoreSummary(own, opponent *ScoreSummary, disqualified bool) { + fields.Played++ + fields.Random = RankingRandomFloat64() + if disqualified { + fields.Disqualifications++ + return + } + if own.Score > opponent.Score { + fields.RankingPoints += 3 + fields.Wins++ + } else if own.Score == opponent.Score { + fields.RankingPoints += 1 + fields.Ties++ + } else { + fields.Losses++ + } + fields.RankingPoints += own.BonusRankingPoints +[[range $field := .RankingTiebreakerFields]] fields.[[$field]] += own.[[$field]] +[[end]]} + +type Ranking struct { + TeamId int "db:\"id,manual\"" + Rank int + PreviousRank int + RankingFields +} + +type Rankings []Ranking + +func (rankings Rankings) Len() int { + return len(rankings) +} + +func (rankings Rankings) Swap(i, j int) { + rankings[i], rankings[j] = rankings[j], rankings[i] +} + +func (rankings Rankings) Less(i, j int) bool { + a, b := rankings[i], rankings[j] + if a.RankingPoints*b.Played != b.RankingPoints*a.Played { + return a.RankingPoints*b.Played > b.RankingPoints*a.Played + } +[[range $field := .RankingTiebreakerFields]] if a.[[$field]]*b.Played != b.[[$field]]*a.Played { + return a.[[$field]]*b.Played > b.[[$field]]*a.Played + } +[[end]] return a.Random > b.Random +} diff --git a/cmd/generate/templates_go/ranking_fields_test.go.tmpl b/cmd/generate/templates_go/ranking_fields_test.go.tmpl new file mode 100644 index 00000000..fc3d4351 --- /dev/null +++ b/cmd/generate/templates_go/ranking_fields_test.go.tmpl @@ -0,0 +1,40 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +//go:build custom + +package game + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGeneratedRankingLess_Tier0_RankingPoints(t *testing.T) { + a := Ranking{TeamId: 1, RankingFields: RankingFields{RankingPoints: 10, Played: 3}} + b := Ranking{TeamId: 2, RankingFields: RankingFields{RankingPoints: 5, Played: 3}} + rankings := Rankings{a, b} + assert.True(t, rankings.Less(0, 1)) + assert.False(t, rankings.Less(1, 0)) +} + +[[range $i, $field := .RankingTiebreakerFields]]func TestGeneratedRankingLess_Tier[[add $i 1]]_[[$field]](t *testing.T) { + // Prior tiers equal, so the comparison falls through to [[$field]]. + a := Ranking{TeamId: 1, RankingFields: RankingFields{ + RankingPoints: 9, +[[range first $.RankingTiebreakerFields $i]] [[.]]: 100, +[[end]] [[$field]]: 50, + Played: 3, + }} + b := Ranking{TeamId: 2, RankingFields: RankingFields{ + RankingPoints: 9, +[[range first $.RankingTiebreakerFields $i]] [[.]]: 100, +[[end]] [[$field]]: 30, + Played: 3, + }} + rankings := Rankings{a, b} + assert.True(t, rankings.Less(0, 1)) + assert.False(t, rankings.Less(1, 0)) +} + +[[end]] diff --git a/cmd/generate/templates_go/reports_rankings_custom.go.tmpl b/cmd/generate/templates_go/reports_rankings_custom.go.tmpl new file mode 100644 index 00000000..0b925b8d --- /dev/null +++ b/cmd/generate/templates_go/reports_rankings_custom.go.tmpl @@ -0,0 +1,97 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +//go:build custom + +package web + +import ( + "bytes" + "fmt" + "net/http" + "strconv" +) + +// The ranking columns between RankingPoints and the win/loss record are the configured +// ranking_tiebreakers, in order — so the report always matches the RankingFields the current +// custom_game.yaml generates. + +// Generates a CSV-formatted report of the qualification rankings. +func (web *Web) rankingsCsvReportHandler(w http.ResponseWriter, r *http.Request) { + rankings, err := web.arena.Database.GetAllRankings() + if err != nil { + handleWebErr(w, err) + return + } + + w.Header().Set("Content-Type", "text/plain") + var buf bytes.Buffer + buf.WriteString("Rank,TeamId,RankingPoints,[[range .RankingTiebreakers]][[.Field]],[[end]]Wins,Losses,Ties,Disqualifications,Played\n") + for _, ranking := range rankings { + buf.WriteString(fmt.Sprintf("%d,%d,%d,[[range .RankingTiebreakers]]%d,[[end]]%d,%d,%d,%d,%d\n", + ranking.Rank, + ranking.TeamId, + ranking.RankingPoints, + [[range .RankingTiebreakers]]ranking.[[.Field]], + [[end]]ranking.Wins, + ranking.Losses, + ranking.Ties, + ranking.Disqualifications, + ranking.Played, + )) + } + + cleaned := bytes.ReplaceAll(buf.Bytes(), []byte("\r"), []byte("")) + if _, err := w.Write(cleaned); err != nil { + handleWebErr(w, err) + return + } +} + +// Generates a PDF-formatted report of the qualification rankings. +func (web *Web) rankingsPdfReportHandler(w http.ResponseWriter, r *http.Request) { + rankings, err := web.arena.Database.GetAllRankings() + if err != nil { + handleWebErr(w, err) + return + } + + rowHeight := 6.5 + + pdf := newReportPdf() + pdf.AddPage() + + // Render table header row. + pdf.SetFont("Arial", "B", 10) + pdf.SetFillColor(220, 220, 220) + pdf.CellFormat(195, rowHeight, "Team Standings - "+web.arena.EventSettings.Name, "", 1, "C", false, 0, "") + pdf.CellFormat(15, rowHeight, "Rank", "1", 0, "C", true, 0, "") + pdf.CellFormat(25, rowHeight, "Team", "1", 0, "C", true, 0, "") + pdf.CellFormat(25, rowHeight, "RP", "1", 0, "C", true, 0, "") + [[range .RankingTiebreakers]]pdf.CellFormat(30, rowHeight, "[[.Label]]", "1", 0, "C", true, 0, "") + [[end]]pdf.CellFormat(30, rowHeight, "W-L-T", "1", 0, "C", true, 0, "") + pdf.CellFormat(20, rowHeight, "DQ", "1", 0, "C", true, 0, "") + pdf.CellFormat(20, rowHeight, "Played", "1", 1, "C", true, 0, "") + + for _, ranking := range rankings { + pdf.SetFont("Arial", "B", 10) + pdf.CellFormat(15, rowHeight, strconv.Itoa(ranking.Rank), "1", 0, "C", false, 0, "") + pdf.SetFont("Arial", "", 10) + pdf.CellFormat(25, rowHeight, strconv.Itoa(ranking.TeamId), "1", 0, "C", false, 0, "") + pdf.CellFormat(25, rowHeight, strconv.Itoa(ranking.RankingPoints), "1", 0, "C", false, 0, "") + [[range .RankingTiebreakers]]pdf.CellFormat(30, rowHeight, strconv.Itoa(ranking.[[.Field]]), "1", 0, "C", false, 0, "") + [[end]]record := fmt.Sprintf("%d-%d-%d", ranking.Wins, ranking.Losses, ranking.Ties) + pdf.CellFormat(30, rowHeight, record, "1", 0, "C", false, 0, "") + pdf.CellFormat(20, rowHeight, strconv.Itoa(ranking.Disqualifications), "1", 0, "C", false, 0, "") + pdf.CellFormat(20, rowHeight, strconv.Itoa(ranking.Played), "1", 1, "C", false, 0, "") + } + + addTimeGeneratedFooter(pdf) + + w.Header().Set("Content-Type", "application/pdf") + err = pdf.Output(w) + if err != nil { + handleWebErr(w, err) + return + } +} diff --git a/cmd/generate/templates_go/reports_rankings_custom_test.go.tmpl b/cmd/generate/templates_go/reports_rankings_custom_test.go.tmpl new file mode 100644 index 00000000..ae591181 --- /dev/null +++ b/cmd/generate/templates_go/reports_rankings_custom_test.go.tmpl @@ -0,0 +1,61 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +//go:build custom + +package web + +import ( + "github.com/Team254/cheesy-arena/game" + "github.com/stretchr/testify/assert" + "testing" +) + +// The ranking-tiebreaker columns are built from the current custom_game.yaml, so the fixtures and +// the expected CSV below stay in lockstep with the RankingFields the reports handler emits. + +func TestRankingsCsvReport(t *testing.T) { + web := setupTestWeb(t) + + ranking1 := &game.Ranking{TeamId: 254, Rank: 1, RankingFields: game.RankingFields{ + RankingPoints: 20, + [[range .RankingTiebreakerFields]][[.]]: 10, + [[end]]Wins: 3, + Losses: 2, + Ties: 1, + Disqualifications: 0, + Played: 10, + }} + ranking2 := &game.Ranking{TeamId: 1114, Rank: 2, RankingFields: game.RankingFields{ + RankingPoints: 18, + [[range .RankingTiebreakerFields]][[.]]: 5, + [[end]]Wins: 1, + Losses: 3, + Ties: 2, + Disqualifications: 0, + Played: 10, + }} + web.arena.Database.CreateRanking(ranking1) + web.arena.Database.CreateRanking(ranking2) + + recorder := web.getHttpResponse("/reports/csv/rankings") + assert.Equal(t, 200, recorder.Code) + assert.Equal(t, "text/plain", recorder.Header()["Content-Type"][0]) + expectedBody := "Rank,TeamId,RankingPoints,[[range .RankingTiebreakerFields]][[.]],[[end]]Wins,Losses,Ties,Disqualifications,Played\n" + + "1,254,20,[[range .RankingTiebreakerFields]]10,[[end]]3,2,1,0,10\n" + + "2,1114,18,[[range .RankingTiebreakerFields]]5,[[end]]1,3,2,0,10\n" + assert.Equal(t, expectedBody, recorder.Body.String()) +} + +func TestRankingsPdfReport(t *testing.T) { + web := setupTestWeb(t) + + ranking1 := &game.Ranking{TeamId: 254, Rank: 1, RankingFields: game.RankingFields{RankingPoints: 20, Played: 10}} + ranking2 := &game.Ranking{TeamId: 1114, Rank: 2, RankingFields: game.RankingFields{RankingPoints: 18, Played: 10}} + web.arena.Database.CreateRanking(ranking1) + web.arena.Database.CreateRanking(ranking2) + + recorder := web.getHttpResponse("/reports/pdf/rankings") + assert.Equal(t, 200, recorder.Code) + assert.Equal(t, "application/pdf", recorder.Header()["Content-Type"][0]) +} diff --git a/cmd/generate/templates_go/score.go.tmpl b/cmd/generate/templates_go/score.go.tmpl new file mode 100644 index 00000000..dab9c2ca --- /dev/null +++ b/cmd/generate/templates_go/score.go.tmpl @@ -0,0 +1,169 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +//go:build custom + +package game + +[[range .Statuses]][[if not .IsBool]][[$st := .]]type [[camel $st.ID]]Status int + +const ( +[[range $i, $v := $st.Values]] [[camel $st.ID]][[camel $v.ID]][[if not $i]] [[camel $st.ID]]Status = iota[[end]] +[[end]]) + +[[end]][[end]]// Score holds the instantaneous scoring state for one alliance. +type Score struct { +[[range .ScoringCounts]][[$el := .]][[range .Phases]] [[phasePrefix .Phase]][[camel $el.ID]]Count int +[[end]][[end]] +[[range .Statuses]] [[camel .ID]]Statuses [3][[if .IsBool]]bool[[else]][[camel .ID]]Status[[end]] +[[end]] + // Base (non-generated) Score fields. If you add, remove, or rename one, update the base-field + // list in generatedFieldSets (cmd/generate/validate_logic.go) to match — TestGeneratedFieldSetsMatchTemplates guards this. + Fouls []Foul + PlayoffDq bool + + // Hub is always included because arena.go and team_sign.go access CurrentScore.Hub + // directly without build tags. hub.go is untagged so Hub is available in both builds. + // In the custom build, Hub is never populated (UpdateState is never called) — all + // Hub methods return 0/false. + Hub Hub +} + +func (score *Score) Equals(other *Score) bool { + if [[range .ScoringCounts]][[$el := .]][[range .Phases]]score.[[phasePrefix .Phase]][[camel $el.ID]]Count != other.[[phasePrefix .Phase]][[camel $el.ID]]Count || + [[end]][[end]][[range .Statuses]]score.[[camel .ID]]Statuses != other.[[camel .ID]]Statuses || + [[end]]score.PlayoffDq != other.PlayoffDq || + len(score.Fouls) != len(other.Fouls) { + return false + } + for i, foul := range score.Fouls { + if foul != other.Fouls[i] { + return false + } + } + return true +} + +type Phase int + +const ( + PhaseAuto Phase = iota + PhaseTeleop + PhaseEndgame +) + +[[range .ScoringCounts]][[$el := .]]func (s *Score) Adjust[[camel $el.ID]]Count(phase Phase, delta int) bool { + switch phase { +[[range .Phases]] case Phase[[phasePrefix .Phase]]: + newVal := s.[[phasePrefix .Phase]][[camel $el.ID]]Count + delta + if newVal < 0 { + newVal = 0 + } + if newVal == s.[[phasePrefix .Phase]][[camel $el.ID]]Count { + return false + } + s.[[phasePrefix .Phase]][[camel $el.ID]]Count = newVal + return true +[[end]] } + return false +} + +[[end]]func (s *Score) AdjustCount(id string, phase Phase, delta int) bool { + switch id { +[[range .ScoringCounts]] case "[[.ID]]": + return s.Adjust[[camel .ID]]Count(phase, delta) +[[end]] } + return false +} + +[[range .Statuses]]func (s *Score) Set[[camel .ID]]Status(robotIndex int, value [[if .IsBool]]bool[[else]][[camel .ID]]Status[[end]]) bool { + if robotIndex < 0 || robotIndex >= 3 { + return false + } + if s.[[camel .ID]]Statuses[robotIndex] == value { + return false + } + s.[[camel .ID]]Statuses[robotIndex] = value + return true +} + +[[end]]func (s *Score) SetBoolStatus(id string, robotIndex int, value bool) bool { + switch id { +[[range .Statuses]][[if .IsBool]] case "[[.ID]]": + return s.Set[[camel .ID]]Status(robotIndex, value) +[[end]][[end]] } + return false +} + +func (s *Score) SetEnumStatus(id string, robotIndex int, valueId string) bool { + switch id { +[[range .Statuses]][[if not .IsBool]][[$st := .]] case "[[$st.ID]]": + switch valueId { +[[range $st.Values]] case "[[.ID]]": + return s.Set[[camel $st.ID]]Status(robotIndex, [[camel $st.ID]][[camel .ID]]) +[[end]] } +[[end]][[end]] } + return false +} + +[[range .Statuses]][[if not .IsBool]]func (s *Score) Cycle[[camel .ID]]Status(robotIndex int) bool { + if robotIndex < 0 || robotIndex >= 3 { + return false + } + next := s.[[camel .ID]]Statuses[robotIndex] + 1 + if int(next) >= [[len .Values]] { + next = 0 + } + return s.Set[[camel .ID]]Status(robotIndex, next) +} + +[[end]][[end]]func (s *Score) CycleEnumStatus(id string, robotIndex int) bool { + switch id { +[[range .Statuses]][[if not .IsBool]] case "[[.ID]]": + return s.Cycle[[camel .ID]]Status(robotIndex) +[[end]][[end]] } + return false +} + +// Per-status convenience helpers for scoring logic — Any reports whether any of the 3 robots +// qualifies, Count how many do. For enum statuses, "qualifies" means the robot's status is at least +// atLeast, compared by declared value order (lowest first). +[[range .Statuses]][[$st := .]][[if $st.IsBool]]func (s *Score) Any[[camel $st.ID]]Status() bool { + for _, v := range s.[[camel $st.ID]]Statuses { + if v { + return true + } + } + return false +} + +func (s *Score) Count[[camel $st.ID]]Status() int { + n := 0 + for _, v := range s.[[camel $st.ID]]Statuses { + if v { + n++ + } + } + return n +} + +[[else]]func (s *Score) Any[[camel $st.ID]]Status(atLeast [[camel $st.ID]]Status) bool { + for _, v := range s.[[camel $st.ID]]Statuses { + if v >= atLeast { + return true + } + } + return false +} + +func (s *Score) Count[[camel $st.ID]]Status(atLeast [[camel $st.ID]]Status) int { + n := 0 + for _, v := range s.[[camel $st.ID]]Statuses { + if v >= atLeast { + n++ + } + } + return n +} + +[[end]][[end]] diff --git a/cmd/generate/templates_go/score_summary.go.tmpl b/cmd/generate/templates_go/score_summary.go.tmpl new file mode 100644 index 00000000..ab9847ac --- /dev/null +++ b/cmd/generate/templates_go/score_summary.go.tmpl @@ -0,0 +1,141 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +//go:build custom + +package game + +// Per-element point constants (only used in Summarize() below) +const ( +[[range $el := .ScoringCounts]][[range .Phases]] [[camel $el.ID]][[phasePrefix .Phase]]PointsVal = [[.Points]] +[[end]][[end]][[range $st := .Statuses]][[if $st.IsBool]] [[camel $st.ID]]PointsVal = [[$st.Points]] +[[else]][[range $st.Values]] [[camel $st.ID]][[camel .ID]]PointsVal = [[.Points]] +[[end]][[end]][[end]]) + +type ScoreSummary struct { +[[range .ScoringGroups]] [[camel .ID]]Points int +[[end]][[range .Statuses]] [[camel .ID]]Points int +[[end]] + // Base (non-generated) ScoreSummary fields. If you add, remove, or rename one, update the base- + // field list in generatedFieldSets (cmd/generate/validate_logic.go) — TestGeneratedFieldSetsMatchTemplates guards this. + AutoPoints int + TeleopPoints int + EndgamePoints int + MatchPoints int + + FoulPoints int + + Score int + + PlayoffDq bool + NumOpponentMajorFouls int + +[[range .RankingPoints]] [[camel .ID]]RankingPoint bool +[[end]] BonusRankingPoints int +} + +func (score *Score) Summarize(opponentScore *Score) *ScoreSummary { + summary := new(ScoreSummary) + summary.PlayoffDq = score.PlayoffDq + if score.PlayoffDq { + return summary + } + + var autoPoints int + var teleopPoints int + var endgamePoints int + +[[range .ScoringCounts]][[$el := .]][[range .Phases]] [[.Phase]][[camel $el.ID]] := score.[[phasePrefix .Phase]][[camel $el.ID]]Count * [[camel $el.ID]][[phasePrefix .Phase]]PointsVal + [[.Phase]]Points += [[.Phase]][[camel $el.ID]] + summary.[[camel $el.Group]]Points += [[.Phase]][[camel $el.ID]] +[[end]][[end]] +[[range .Statuses]][[$st := .]][[if .IsBool]] for _, v := range score.[[camel $st.ID]]Statuses { + if v { + summary.[[camel $st.ID]]Points += [[camel $st.ID]]PointsVal + [[$st.Phase]]Points += [[camel $st.ID]]PointsVal + } + } +[[else]] for _, v := range score.[[camel $st.ID]]Statuses { + switch v { +[[range $st.Values]] case [[camel $st.ID]][[camel .ID]]: + summary.[[camel $st.ID]]Points += [[camel $st.ID]][[camel .ID]]PointsVal + [[$st.Phase]]Points += [[camel $st.ID]][[camel .ID]]PointsVal +[[end]] } + } +[[end]][[end]] + summary.AutoPoints = autoPoints + summary.TeleopPoints = teleopPoints + summary.EndgamePoints = endgamePoints + summary.MatchPoints = autoPoints + teleopPoints + endgamePoints + + for _, foul := range opponentScore.Fouls { + summary.FoulPoints += foul.PointValue() + if foul.IsMajor { + summary.NumOpponentMajorFouls++ + } + } + summary.Score = summary.MatchPoints + summary.FoulPoints + +[[range .RankingPoints]] summary.[[camel .ID]]RankingPoint = [[.LogicFunc]](*score, *opponentScore, *summary) + if summary.[[camel .ID]]RankingPoint { + summary.BonusRankingPoints++ + } +[[end]] + return summary +} + +type MatchStatus int + +const ( + MatchScheduled MatchStatus = iota + MatchHidden + RedWonMatch + BlueWonMatch + TieMatch +) + +func (status MatchStatus) Get(redWins, blueWins, tie string) string { + if status == RedWonMatch { + return redWins + } else if status == BlueWonMatch { + return blueWins + } + return tie +} + +func comparePoints(red, blue int) MatchStatus { + if red > blue { + return RedWonMatch + } else if blue > red { + return BlueWonMatch + } + return TieMatch +} + +func DetermineMatchStatus( + red, blue *ScoreSummary, + applyPlayoffTiebreakers bool, +) (MatchStatus, string) { + if red.PlayoffDq != blue.PlayoffDq { + if red.PlayoffDq { + return BlueWonMatch, "" + } + return RedWonMatch, "" + } + + if status := comparePoints(red.Score, blue.Score); status != TieMatch { + return status, "" + } + + if applyPlayoffTiebreakers { + if status := comparePoints(red.NumOpponentMajorFouls, blue.NumOpponentMajorFouls); status != TieMatch { + return status, "TIEBREAK: MAJOR FOULS" + } +[[range .PlayoffTiebreakers]] if status := comparePoints(red.[[.Field]], blue.[[.Field]]); status != TieMatch { + return status, [[printf "%q" .Label]] + } +[[end]] return TieMatch, "TRUE TIE" + } + + return TieMatch, "" +} diff --git a/cmd/generate/templates_go/score_summary_test.go.tmpl b/cmd/generate/templates_go/score_summary_test.go.tmpl new file mode 100644 index 00000000..de6d2f41 --- /dev/null +++ b/cmd/generate/templates_go/score_summary_test.go.tmpl @@ -0,0 +1,99 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +//go:build custom + +package game + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGeneratedSummarize_ZeroScore(t *testing.T) { + score := &Score{} + opponent := &Score{} + summary := score.Summarize(opponent) + assert.Equal(t, 0, summary.AutoPoints) + assert.Equal(t, 0, summary.TeleopPoints) + assert.Equal(t, 0, summary.EndgamePoints) + assert.Equal(t, 0, summary.MatchPoints) + assert.Equal(t, 0, summary.FoulPoints) + assert.Equal(t, 0, summary.Score) +[[range .ScoringGroups]] assert.Equal(t, 0, summary.[[camel .ID]]Points) +[[end]][[range .Statuses]] assert.Equal(t, 0, summary.[[camel .ID]]Points) +[[end]]} + +[[range .ScoringCounts]][[$el := .]]func TestGeneratedSummarize_[[camel $el.ID]](t *testing.T) { +[[range .Phases]] // [[.Phase]] phase — only this count is scored, so its group total equals its own points. + { + score := &Score{[[phasePrefix .Phase]][[camel $el.ID]]Count: 3} + summary := score.Summarize(&Score{}) + assert.Equal(t, 3*[[camel $el.ID]][[phasePrefix .Phase]]PointsVal, summary.[[camel $el.Group]]Points) + assert.Equal(t, 3*[[camel $el.ID]][[phasePrefix .Phase]]PointsVal, summary.[[phasePrefix .Phase]]Points) + } +[[end]]} + +[[end]][[range .ScoringGroups]][[$g := .]][[if gt (len .CountFields) 1]]func TestGeneratedSummarize_[[camel $g.ID]]Rollup(t *testing.T) { + // Score every member of the group at once. Because only this group is scored, the phase totals + // (accumulated independently of the group field) must sum to the group total. This guards the + // group accumulation against a += -> = regression, which the single-count tests above can't + // catch (each scores one member, so an overwrite still leaves the right value). + s := &Score{[[range $i, $f := .CountFields]][[if $i]], [[end]][[$f]]: 1[[end]]} + summary := s.Summarize(&Score{}) + assert.Greater(t, summary.[[camel $g.ID]]Points, 0) + assert.Equal(t, summary.AutoPoints+summary.TeleopPoints+summary.EndgamePoints, summary.[[camel $g.ID]]Points) +} + +[[end]][[end]][[range .Statuses]][[$st := .]]func TestGeneratedSummarize_[[camel $st.ID]](t *testing.T) { +[[if $st.IsBool]] // one robot + { + score := &Score{[[camel $st.ID]]Statuses: [3]bool{true, false, false}} + summary := score.Summarize(&Score{}) + assert.Equal(t, [[camel $st.ID]]PointsVal, summary.[[camel $st.ID]]Points) + assert.Equal(t, [[camel $st.ID]]PointsVal, summary.[[phasePrefix $st.Phase]]Points) + } + // three robots + { + score := &Score{[[camel $st.ID]]Statuses: [3]bool{true, true, true}} + summary := score.Summarize(&Score{}) + assert.Equal(t, 3*[[camel $st.ID]]PointsVal, summary.[[camel $st.ID]]Points) + assert.Equal(t, 3*[[camel $st.ID]]PointsVal, summary.[[phasePrefix $st.Phase]]Points) + } +[[else]][[range $i, $v := $st.Values]][[if $i]] { + score := &Score{[[camel $st.ID]]Statuses: [3][[camel $st.ID]]Status{[[camel $st.ID]][[camel $v.ID]], [[camel $st.ID]][[camel (index $st.Values 0).ID]], [[camel $st.ID]][[camel (index $st.Values 0).ID]]}} + summary := score.Summarize(&Score{}) + assert.Equal(t, [[camel $st.ID]][[camel $v.ID]]PointsVal, summary.[[camel $st.ID]]Points) + assert.Equal(t, [[camel $st.ID]][[camel $v.ID]]PointsVal, summary.[[phasePrefix $st.Phase]]Points) + } +[[end]][[end]][[end]]} + +[[end]]func TestGeneratedSummarize_FoulPoints(t *testing.T) { + score := &Score{} + opponent := &Score{Fouls: []Foul{ + {IsMajor: false}, + {IsMajor: true}, + }} + summary := score.Summarize(opponent) + assert.Equal(t, MinorFoulPoints+MajorFoulPoints, summary.FoulPoints) + assert.Equal(t, 1, summary.NumOpponentMajorFouls) + assert.Equal(t, MinorFoulPoints+MajorFoulPoints, summary.Score) +} + +func TestGeneratedDetermineMatchStatus_MajorFoulsTiebreak(t *testing.T) { + red := &ScoreSummary{Score: 10, NumOpponentMajorFouls: 2} + blue := &ScoreSummary{Score: 10, NumOpponentMajorFouls: 0} + status, label := DetermineMatchStatus(red, blue, true) + assert.Equal(t, RedWonMatch, status) + assert.Equal(t, "TIEBREAK: MAJOR FOULS", label) +} + +[[range .PlayoffTiebreakers]]func TestGeneratedDetermineMatchStatus_[[.Field]]Tiebreak(t *testing.T) { + red := &ScoreSummary{Score: 10, [[.Field]]: 8} + blue := &ScoreSummary{Score: 10, [[.Field]]: 4} + status, label := DetermineMatchStatus(red, blue, true) + assert.Equal(t, RedWonMatch, status) + assert.Equal(t, "[[.Label]]", label) +} + +[[end]] diff --git a/cmd/generate/templates_go/score_test.go.tmpl b/cmd/generate/templates_go/score_test.go.tmpl new file mode 100644 index 00000000..1e0298a4 --- /dev/null +++ b/cmd/generate/templates_go/score_test.go.tmpl @@ -0,0 +1,64 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +//go:build custom + +package game + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGeneratedScoreEquals(t *testing.T) { + a := &Score{} + b := &Score{} + assert.True(t, a.Equals(b)) + b.PlayoffDq = true + assert.False(t, a.Equals(b)) + b.PlayoffDq = false + b.Fouls = []Foul{{}} + assert.False(t, a.Equals(b)) +} + +[[range .ScoringCounts]][[$el := .]]func TestGeneratedAdjust[[camel $el.ID]]Count(t *testing.T) { +[[range .Phases]] { + s := &Score{} + assert.True(t, s.Adjust[[camel $el.ID]]Count(Phase[[phasePrefix .Phase]], 3)) + assert.Equal(t, 3, s.[[phasePrefix .Phase]][[camel $el.ID]]Count) + // negative delta clamps at zero + assert.True(t, s.Adjust[[camel $el.ID]]Count(Phase[[phasePrefix .Phase]], -10)) + assert.Equal(t, 0, s.[[phasePrefix .Phase]][[camel $el.ID]]Count) + } +[[end]]} + +[[end]][[range .Statuses]][[$st := .]]func TestGeneratedSet[[camel $st.ID]]Status(t *testing.T) { + s := &Score{} +[[if $st.IsBool]] assert.True(t, s.Set[[camel $st.ID]]Status(0, true)) + assert.True(t, s.[[camel $st.ID]]Statuses[0]) + assert.False(t, s.Set[[camel $st.ID]]Status(0, true)) // unchanged + assert.False(t, s.Set[[camel $st.ID]]Status(3, true)) // out of range +[[else]][[range $v := $st.Values]] s.Set[[camel $st.ID]]Status(0, [[camel $st.ID]][[camel $v.ID]]) + assert.Equal(t, [[camel $st.ID]][[camel $v.ID]], s.[[camel $st.ID]]Statuses[0]) +[[end]] // cycle wraps from the last value back to the first + s.[[camel $st.ID]]Statuses[1] = [[camel $st.ID]][[camel (index $st.Values (add (len $st.Values) -1)).ID]] + s.Cycle[[camel $st.ID]]Status(1) + assert.Equal(t, [[camel $st.ID]][[camel (index $st.Values 0).ID]], s.[[camel $st.ID]]Statuses[1]) +[[end]]} + +[[end]][[range .Statuses]][[$st := .]]func TestGeneratedStatusHelpers_[[camel $st.ID]](t *testing.T) { + s := &Score{} +[[if $st.IsBool]] assert.False(t, s.Any[[camel $st.ID]]Status()) + assert.Equal(t, 0, s.Count[[camel $st.ID]]Status()) + s.[[camel $st.ID]]Statuses[0] = true + assert.True(t, s.Any[[camel $st.ID]]Status()) + assert.Equal(t, 1, s.Count[[camel $st.ID]]Status()) +[[else]] // One robot at the highest value; the rest stay at the baseline (first) value. + s.[[camel $st.ID]]Statuses[0] = [[camel $st.ID]][[camel (index $st.Values (add (len $st.Values) -1)).ID]] + assert.True(t, s.Any[[camel $st.ID]]Status([[camel $st.ID]][[camel (index $st.Values 1).ID]])) + assert.Equal(t, 1, s.Count[[camel $st.ID]]Status([[camel $st.ID]][[camel (index $st.Values 1).ID]])) + // atLeast the baseline value counts every robot. + assert.Equal(t, 3, s.Count[[camel $st.ID]]Status([[camel $st.ID]][[camel (index $st.Values 0).ID]])) +[[end]]} + +[[end]] diff --git a/cmd/generate/templates_go/template_test.go.tmpl b/cmd/generate/templates_go/template_test.go.tmpl new file mode 100644 index 00000000..7632e013 --- /dev/null +++ b/cmd/generate/templates_go/template_test.go.tmpl @@ -0,0 +1,107 @@ +// Code generated by cmd/generate from game/custom_game.yaml. DO NOT EDIT. +// Regenerate: go generate ./... + +package main + +import ( + "html/template" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGeneratedTemplates_ParseAndContent(t *testing.T) { + templatesDir := "../../templates" + panelPath := filepath.Join(templatesDir, "generated_scoring_panel.html") + audiencePath := filepath.Join(templatesDir, "generated_audience_display.html") + + { + content, err := os.ReadFile(panelPath) + assert.NoError(t, err) + tmpl := template.New("scoring") + tmpl.Funcs(template.FuncMap{ + "dict": func(values ...any) (map[string]any, error) { return nil, nil }, + "seq": func(n int) []int { return []int{0, 1, 2} }, + "add": func(a, b int) int { return a + b }, + }) + _, err = tmpl.Parse(string(content)) + assert.NoError(t, err) + +[[range .ScoringCounts]][[$el := .]][[range .Phases]] assert.Contains(t, string(content), "onclick=\"adjustCount('[[$el.ID]]', '[[.Phase]]', -1);\"") + assert.Contains(t, string(content), "id=\"[[$el.ID]]-[[.Phase]]-count\"") +[[end]][[end]][[range .Statuses]][[if eq .Phase "auto"]][[if .IsBool]] assert.Contains(t, string(content), "onclick=\"toggleBoolStatus('[[.ID]]', {{add $i -1}});\"") +[[else]] assert.Contains(t, string(content), "onclick=\"cycleEnumStatus('[[.ID]]', {{add $i -1}});\"") +[[end]][[end]][[end]] } + + { + content, err := os.ReadFile(audiencePath) + assert.NoError(t, err) + tmpl := template.New("audience") + tmpl.Funcs(template.FuncMap{ + "seq": func(n int) []int { return []int{0, 1, 2, 3} }, + "add": func(a, b int) int { return a + b }, + }) + _, err = tmpl.Parse(string(content)) + assert.NoError(t, err) + +[[range .ScoringGroups]] assert.Contains(t, string(content), "id=\"leftFinal[[camel .ID]]Points\"") + assert.Contains(t, string(content), "id=\"rightFinal[[camel .ID]]Points\"") +[[end]][[range .Statuses]] assert.Contains(t, string(content), "id=\"leftFinal[[camel .ID]]Points\"") +[[end]][[range .RankingPoints]] assert.Contains(t, string(content), "id=\"leftFinal[[camel .ID]]RankingPoint\"") +[[end]] } + + { + refereePath := filepath.Join(templatesDir, "generated_referee_panel.html") + content, err := os.ReadFile(refereePath) + assert.NoError(t, err) + tmpl := template.New("referee") + tmpl.Funcs(template.FuncMap{ + "dict": func(values ...any) (map[string]any, error) { return nil, nil }, + "seq": func(n int) []int { return []int{0, 1, 2} }, + }) + _, err = tmpl.Parse(string(content)) + assert.NoError(t, err) + + assert.Contains(t, string(content), "generated_referee_panel.js") +[[range .Phases]][[if .Counts]] assert.Contains(t, string(content), "phase-[[.Name]]") +[[end]][[end]][[range .Statuses]] assert.Contains(t, string(content), "team-1-[[.ID]]") +[[end]] } +} + +func TestGeneratedJS_Content(t *testing.T) { + jsDir := "../../static/js" + panelJsPath := filepath.Join(jsDir, "generated_scoring_panel.js") + audienceJsPath := filepath.Join(jsDir, "generated_audience_display.js") + + { + content, err := os.ReadFile(panelJsPath) + assert.NoError(t, err) + assert.Contains(t, string(content), "adjustCount = function") + assert.Contains(t, string(content), "toggleBoolStatus = function") + assert.Contains(t, string(content), "setEnumStatus = function") + assert.Contains(t, string(content), "cycleEnumStatus = function") +[[range .ScoringCounts]][[$el := .]][[range .Phases]] assert.Contains(t, string(content), "score.[[phasePrefix .Phase]][[camel $el.ID]]Count") +[[end]][[end]] } + + { + content, err := os.ReadFile(audienceJsPath) + assert.NoError(t, err) + assert.Contains(t, string(content), "handleRealtimeScoreGenerated = function") + assert.Contains(t, string(content), "handleScorePostedGenerated = function") +[[range .ScoringGroups]] assert.Contains(t, string(content), "left[[camel .ID]]Count") + assert.Contains(t, string(content), "leftFinal[[camel .ID]]Points") +[[end]] } + + { + refereeJsPath := filepath.Join(jsDir, "generated_referee_panel.js") + content, err := os.ReadFile(refereeJsPath) + assert.NoError(t, err) + assert.Contains(t, string(content), "updateScoreSummaryGenerated = function") +[[range .Phases]][[if .Counts]] assert.Contains(t, string(content), "phase-[[.Name]]") + assert.Contains(t, string(content), "score.[[.Title]]") +[[end]][[end]][[range .Statuses]] assert.Contains(t, string(content), "team-1-[[.ID]]") + assert.Contains(t, string(content), "score.[[camel .ID]]Statuses") +[[end]] } +} diff --git a/cmd/generate/validate_logic.go b/cmd/generate/validate_logic.go new file mode 100644 index 00000000..e7c186fe --- /dev/null +++ b/cmd/generate/validate_logic.go @@ -0,0 +1,308 @@ +// Generate-time validation of the one hand-written, generated-adjacent file: game/custom_scoring_logic.go. +// +// The custom ranking-point functions there reference fields on the generated Score and ScoreSummary +// structs by name. When a scoring element is renamed or removed in custom_game.yaml, those references +// go stale and `go build -tags custom` fails with a bare "Score has no field or method X" — a message +// pitched in generated Go field names, not the yaml ids the author actually edited. +// +// This runs at `go generate` time, where we already know the exact field set the config produces, and +// turns that into a connected error: it names the offending field, points at the line, and suggests +// the closest current field. It is deliberately a best-effort message layer, not a type checker: +// - It only tracks field accesses on parameters whose type is spelled Score/ScoreSummary in the +// function signature (the documented logic_func shape). Accesses through a local alias or a helper +// function are not followed. +// - Anything it doesn't catch still fails the subsequent `go build`, just with the terse message. +// So a miss degrades to today's behavior; it never lets a genuinely broken reference through. + +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "strings" +) + +// validateCustomScoringLogic checks game/custom_scoring_logic.go against the field set the current +// config generates. It reports: (1) any ranking_points logic_func with no matching top-level function, +// and (2) any Score/ScoreSummary field access that isn't a generated field, with a "did you mean" +// suggestion. It returns human-readable error strings (empty if clean). +func validateCustomScoringLogic(yamlData *GameYAML, logicPath string) []string { + // No ranking points means no logic_func references to satisfy; a missing file is then fine. + src, err := os.ReadFile(logicPath) + if err != nil { + if os.IsNotExist(err) { + if len(yamlData.RPs) == 0 { + return nil + } + return []string{fmt.Sprintf("%s: file not found, but ranking_points declare logic funcs that must be defined there", logicPath)} + } + return []string{fmt.Sprintf("%s: %v", logicPath, err)} + } + + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, logicPath, src, parser.AllErrors) + if err != nil { + return []string{fmt.Sprintf("%s: could not parse: %v", logicPath, err)} + } + + scoreFields, summaryFields := generatedFieldSets(yamlData) + + var errs []string + + // (1) Every declared logic_func must exist as a top-level function. + funcs := map[string]bool{} + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv == nil { + funcs[fn.Name.Name] = true + } + } + var missing []RankingPoint + for _, rp := range yamlData.RPs { + if rp.LogicFunc != "" && !funcs[rp.LogicFunc] { + missing = append(missing, rp) + } + } + if len(missing) > 0 { + errs = append(errs, missingLogicFuncMessage(yamlData, logicPath, missing)) + } + + // (2) Field accesses on Score/ScoreSummary parameters must be generated fields. + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + // Map each parameter of type Score/ScoreSummary to the field set it must satisfy. + paramFields := map[string]map[string]bool{} + if fn.Type.Params != nil { + for _, field := range fn.Type.Params.List { + switch typeIdentName(field.Type) { + case "Score": + for _, name := range field.Names { + paramFields[name.Name] = scoreFields + } + case "ScoreSummary": + for _, name := range field.Names { + paramFields[name.Name] = summaryFields + } + } + } + } + if len(paramFields) == 0 { + continue + } + + // One pre-order walk. A selector in call position (x.Method()) is a method call, not field + // access; we record those as we descend so the field check can skip them — ast.Inspect visits + // a CallExpr before its own .Fun selector child, so the marker is always set in time. + methodCalls := map[*ast.SelectorExpr]bool{} + ast.Inspect(fn.Body, func(n ast.Node) bool { + if call, ok := n.(*ast.CallExpr); ok { + if sel, ok := call.Fun.(*ast.SelectorExpr); ok { + methodCalls[sel] = true + } + return true + } + sel, ok := n.(*ast.SelectorExpr) + if !ok || methodCalls[sel] { + return true + } + base, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + fields, tracked := paramFields[base.Name] + if !tracked || fields[sel.Sel.Name] { + return true + } + pos := fset.Position(sel.Sel.Pos()) + msg := fmt.Sprintf("%s:%d:%d: '%s.%s' is not a field generated from custom_game.yaml", + logicPath, pos.Line, pos.Column, base.Name, sel.Sel.Name) + if suggestion := closestField(sel.Sel.Name, fields); suggestion != "" { + msg += fmt.Sprintf(" — did you mean '%s'?", suggestion) + } + msg += " (a scoring element was likely renamed or removed; update this reference to match)" + errs = append(errs, msg) + return true + }) + } + + return errs +} + +// missingLogicFuncMessage builds a copy-pasteable stub for each undefined logic_func, followed by a +// one-shot reference of the data available to scoring logic for the current config — so an author +// adding a ranking point sees the exact function shape and field/helper names without hunting through +// the generated Go. +func missingLogicFuncMessage(y *GameYAML, logicPath string, missing []RankingPoint) string { + var b strings.Builder + if len(missing) == 1 { + fmt.Fprintf(&b, "%s: ranking_points '%s' needs logic_func '%s'. Add it and implement it:\n", + logicPath, missing[0].ID, missing[0].LogicFunc) + } else { + fmt.Fprintf(&b, "%s: %d ranking-point logic functions are missing. Add them and implement:\n", logicPath, len(missing)) + } + for _, rp := range missing { + fmt.Fprintf(&b, "\nfunc %s(score, opponentScore Score, summary ScoreSummary) bool {\n\t// TODO: implement (ranking_points '%s')\n\treturn false\n}\n", + rp.LogicFunc, rp.ID) + } + b.WriteString(dataReference(y)) + return b.String() +} + +// dataReference renders the fields available to scoring logic — grouped by kind and aligned one +// element per line — so an author can scan the id -> generated-name mapping instead of parsing one +// long comma-separated list. +func dataReference(y *GameYAML) string { + // Column width to left-align the id/label across every section. + width := len("scoring groups") + for _, sc := range y.ScoringCounts { + if len(sc.ID) > width { + width = len(sc.ID) + } + } + for _, st := range y.Statuses { + if len(st.ID) > width { + width = len(st.ID) + } + } + row := func(label, value string) string { return fmt.Sprintf(" %-*s %s\n", width, label, value) } + + var b strings.Builder + b.WriteString("\nData available to the logic (generated from the current custom_game.yaml):\n") + + if len(y.ScoringCounts) > 0 { + b.WriteString("\n score / opponentScore — raw per-element counts:\n") + for _, sc := range y.ScoringCounts { + fields := make([]string, len(sc.Phases)) + for i, ep := range sc.Phases { + fields[i] = phaseFieldPrefix[ep.Phase] + toCamelCase(sc.ID) + "Count" + } + b.WriteString(row(sc.ID, strings.Join(fields, ", "))) + } + } + + if len(y.Statuses) > 0 { + b.WriteString("\n score / opponentScore — per-robot status helpers:\n") + for _, st := range y.Statuses { + name := toCamelCase(st.ID) + var sig string + if len(st.Values) == 0 { + sig = fmt.Sprintf("score.Any%sStatus() / score.Count%sStatus()", name, name) + } else { + vals := make([]string, len(st.Values)) + for i, v := range st.Values { + vals[i] = name + toCamelCase(v.ID) + } + sig = fmt.Sprintf("score.Any%sStatus(atLeast %sStatus) / score.Count%sStatus(...) [%s]", + name, name, name, strings.Join(vals, ", ")) + } + b.WriteString(row(st.ID, sig)) + } + } + + b.WriteString("\n summary — computed point totals:\n") + b.WriteString(row("phases/match", "AutoPoints, TeleopPoints, EndgamePoints, MatchPoints, FoulPoints, Score")) + var groups []string + for _, bucket := range buildScoringGroups(y) { + groups = append(groups, toCamelCase(bucket.ID)+"Points") + } + if len(groups) > 0 { + b.WriteString(row("scoring groups", strings.Join(groups, ", "))) + } + var statusPts []string + for _, st := range y.Statuses { + statusPts = append(statusPts, toCamelCase(st.ID)+"Points") + } + if len(statusPts) > 0 { + b.WriteString(row("statuses", strings.Join(statusPts, ", "))) + } + + b.WriteString("\n opponent fouls (bonus RP):\n") + b.WriteString(" opponentScore.HasRankingPointFoul(ruleNumbers ...string)\n") + return b.String() +} + +// generatedFieldSets returns the exact field names the score.go.tmpl and score_summary.go.tmpl +// templates emit for this config — kept in sync with those templates by construction. +func generatedFieldSets(yamlData *GameYAML) (scoreFields, summaryFields map[string]bool) { + scoreFields = map[string]bool{ + // Hand-written base fields on the generated Score struct. + "Fouls": true, "PlayoffDq": true, "Hub": true, + } + for _, sc := range yamlData.ScoringCounts { + for _, ep := range sc.Phases { + scoreFields[phaseFieldPrefix[ep.Phase]+toCamelCase(sc.ID)+"Count"] = true + } + } + for _, st := range yamlData.Statuses { + scoreFields[toCamelCase(st.ID)+"Statuses"] = true + } + + summaryFields = map[string]bool{ + "AutoPoints": true, "TeleopPoints": true, "EndgamePoints": true, "MatchPoints": true, + "FoulPoints": true, "Score": true, "PlayoffDq": true, "NumOpponentMajorFouls": true, + "BonusRankingPoints": true, + } + for _, bucket := range buildScoringGroups(yamlData) { + summaryFields[toCamelCase(bucket.ID)+"Points"] = true + } + for _, st := range yamlData.Statuses { + summaryFields[toCamelCase(st.ID)+"Points"] = true + } + for _, rp := range yamlData.RPs { + summaryFields[toCamelCase(rp.ID)+"RankingPoint"] = true + } + return scoreFields, summaryFields +} + +// typeIdentName returns the base type name of a parameter type expression, unwrapping a pointer and a +// package qualifier so "Score", "*Score", and "game.Score" all yield "Score". Returns "" otherwise. +func typeIdentName(expr ast.Expr) string { + switch t := expr.(type) { + case *ast.StarExpr: + return typeIdentName(t.X) + case *ast.SelectorExpr: + return t.Sel.Name + case *ast.Ident: + return t.Name + } + return "" +} + +// closestField returns the valid field name closest to target by edit distance, if one is close +// enough to be a plausible typo/rename (within a third of the target's length), else "". +func closestField(target string, valid map[string]bool) string { + best := "" + bestDist := len(target)/3 + 1 // threshold: at most ~1/3 of the name may differ + for field := range valid { + if d := levenshtein(target, field); d < bestDist { + bestDist = d + best = field + } + } + return best +} + +func levenshtein(a, b string) int { + prev := make([]int, len(b)+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= len(a); i++ { + cur := make([]int, len(b)+1) + cur[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + cur[j] = min(cur[j-1]+1, prev[j]+1, prev[j-1]+cost) + } + prev = cur + } + return prev[len(b)] +} diff --git a/cmd/generate/viewmodel.go b/cmd/generate/viewmodel.go new file mode 100644 index 00000000..0e7c17f5 --- /dev/null +++ b/cmd/generate/viewmodel.go @@ -0,0 +1,362 @@ +// View model for the template-based UI/code generation. buildTemplateData turns a validated +// GameYAML into TemplateData — the stable contract the .tmpl files consume. The model carries the +// irreducible facts (ids, display names, phases, points, group membership) plus a few cross-cutting +// resolutions that are NOT pure single-item transforms (scoring-group rollups, resolved tiebreaker +// fields). Pure name-derivations (CamelCase, field/const names, phase prefixes) are intentionally +// NOT stored here: the templates compose them from the `camel`/`phasePrefix` helpers (see +// template_funcs.go) plus a literal suffix, so the naming convention lives in one place and a new +// template rarely needs a new field. +// +// The model never carries runtime values: the actual numbers arrive over the websocket at match +// time, and the generated JS reads them off the live payload by name. Because the same toCamelCase +// produces both a Go struct field and the string a template emits, the two cannot drift. + +package main + +import ( + "fmt" + "strings" +) + +// CountView is one scoring count scored in one phase. The generated Score field is +// phasePrefix(Phase)+camel(ID)+"Count", composed in the templates. +type CountView struct { + ID string + DisplayName string + Phase string // "auto" | "teleop" | "endgame" + Points int +} + +// ScoringCountView is one scoring count with all of its phases. Group is the resolved rollup-bucket +// id (its scoring_group, else its own id); camel(Group)+"Points" is the ScoreSummary field its +// points feed. +type ScoringCountView struct { + ID string + DisplayName string + Group string + Phases []CountView +} + +// ValueView is one named state of an enum status. (A bool status carries no values; its single +// point value is StatusView.Points.) +type ValueView struct { + ID string + DisplayName string + Points int +} + +// StatusView is one per-robot status. IsBool drives both the storage ([3]bool vs [3]Status) +// and the UI (toggle vs cycle). For a bool, Points is the point value and Values is empty; for an +// enum, Values holds the states (each with its own points) and Points is unused. +type StatusView struct { + ID string + DisplayName string + Phase string + IsBool bool + Points int + Values []ValueView +} + +// PhaseView groups the counts and statuses scored in one phase (phase-major layout, for the panels). +// Only non-empty phases are included. CountFields is this phase's Score count-field names, in +// declaration order — a precomputed join the referee panel renders as a JS accessor array. +type PhaseView struct { + Name string // "auto" + Title string // "Auto" + Counts []CountView + Statuses []StatusView + CountFields []string +} + +// GroupView is one scoring-group rollup bucket: a ScoreSummary point field (camel(ID)+"Points") and +// an audience-display entry. CountFields are the member counts' Score fields, phase-expanded — a +// precomputed join the live audience counter sums. +type GroupView struct { + ID string + DisplayName string + CountFields []string +} + +// RPView is one ranking-point bonus. The generated ScoreSummary bool is camel(ID)+"RankingPoint"; +// LogicFunc is the hand-written func in custom_scoring_logic.go that computes it. +type RPView struct { + ID string + DisplayName string + LogicFunc string +} + +// TiebreakerView is one playoff-tiebreaker comparison: the resolved ScoreSummary field to compare +// and the human label shown when it breaks the tie (e.g. Field "AutoPoints", Label "AUTO POINTS"). +type TiebreakerView struct { + Field string + Label string +} + +// ScoreStmt describes how a test can give an alliance a scoring lead in a generated Score: assign +// Value to score.Field. Field is a generated Score field (a scoring count, or a status array) and +// Value is the Go literal to assign it. Both empty when the config declares nothing to score. +type ScoreStmt struct { + Field string + Value string +} + +// TemplateData is the complete, stable contract exposed to the .tmpl files. +type TemplateData struct { + GameName string + MinorFoulPoints int + MajorFoulPoints int + Phases []PhaseView // phase-major: UI sections laid out top-to-bottom + ScoringCounts []ScoringCountView // declaration order: count accessors, summary accumulation + ScoringGroups []GroupView // rollup buckets: summary point fields + audience entries + Statuses []StatusView + RankingPoints []RPView + // RankingTiebreakerFields are the resolved RankingFields/ScoreSummary field names for each + // ranking_tiebreakers metric, in order, e.g. ["MatchPoints", "AutoPoints"]. + RankingTiebreakerFields []string + // RankingTiebreakers are the same tiebreakers with a human column Label alongside the Field, for + // surfaces that show a readable header (the PDF rankings report) rather than the raw Go field name. + RankingTiebreakers []TiebreakerView + PlayoffTiebreakers []TiebreakerView // DetermineMatchStatus tiebreak cascade + // RankingTestScore is how the generated qualification-rankings test gives an alliance a lead — + // derived from the config so the test works for count-based, bool-status, and enum-status games + // alike (and is empty for a game that declares nothing to score). + RankingTestScore ScoreStmt +} + +// metricFieldName maps a tiebreaker metric to its Go field name on RankingFields/ScoreSummary. +// Built-in point metrics have fixed names; any other metric (a scoring-group bucket id, or a status +// id) becomes "{Camel}Points". This is a resolution (a lookup), not a pure transform, so it stays +// here rather than in a template helper. +func metricFieldName(metric string) string { + switch metric { + case "auto_points": + return "AutoPoints" + case "teleop_points": + return "TeleopPoints" + case "endgame_points": + return "EndgamePoints" + case "total_points": + return "MatchPoints" + default: + return toCamelCase(metric) + "Points" + } +} + +// playoffTiebreakerLabel returns the human label for a playoff-tiebreaker metric: fixed strings for +// the built-in point metrics, else the uppercased display name of the referenced group/status. +func playoffTiebreakerLabel(metric string, y *GameYAML) string { + switch metric { + case "auto_points": + return "AUTO POINTS" + case "teleop_points": + return "TELEOP POINTS" + case "endgame_points": + return "ENDGAME POINTS" + case "total_points": + return "TOTAL POINTS" + default: + // A non-built-in metric is a scoring-group id (or, for an ungrouped count, its own id), + // or a status id. Prefer the group/count/status display name for the label. + name := metric + for _, g := range y.ScoringGroups { + if g.ID == metric { + name = g.DisplayName + } + } + for _, sc := range y.ScoringCounts { + if sc.ID == metric { + name = sc.DisplayName + } + } + for _, s := range y.Statuses { + if s.ID == metric { + name = s.DisplayName + } + } + return strings.ToUpper(name) + } +} + +// rankingTiebreakerLabel returns a short, readable column header for a ranking-tiebreaker metric +// (e.g. "Match Pts", "Auto Pts"), used for the PDF rankings report. Built-in point metrics have +// fixed labels; any other metric is a scoring-group/count/status id, labeled by its display name. +func rankingTiebreakerLabel(metric string, y *GameYAML) string { + switch metric { + case "auto_points": + return "Auto Pts" + case "teleop_points": + return "Teleop Pts" + case "endgame_points": + return "Endgame Pts" + case "total_points": + return "Match Pts" + default: + name := metric + for _, g := range y.ScoringGroups { + if g.ID == metric { + name = g.DisplayName + } + } + for _, sc := range y.ScoringCounts { + if sc.ID == metric { + name = sc.DisplayName + } + } + for _, s := range y.Statuses { + if s.ID == metric { + name = s.DisplayName + } + } + return name + } +} + +// buildRankingTestScore derives a single scoring move that gives an alliance a lead, so the generated +// qualification-rankings test never has to name a hard-coded field. It prefers the first scoring +// count; failing that (a status-only game) the first status — a bool set true on all robots, or an +// enum set to its highest-scoring value. Empty when the config declares nothing to score. +func buildRankingTestScore(y *GameYAML) ScoreStmt { + if len(y.ScoringCounts) > 0 { + sc := y.ScoringCounts[0] + ph := sc.Phases[0] + return ScoreStmt{Field: phaseFieldPrefix[ph.Phase] + toCamelCase(sc.ID) + "Count", Value: "10"} + } + if len(y.Statuses) > 0 { + st := y.Statuses[0] + field := toCamelCase(st.ID) + "Statuses" + if len(st.Values) == 0 { // bool status + return ScoreStmt{Field: field, Value: "[3]bool{true, true, true}"} + } + best := st.Values[0] // enum status: pick the highest-scoring state + for _, v := range st.Values[1:] { + if v.Points > best.Points { + best = v + } + } + enum := "game." + toCamelCase(st.ID) + toCamelCase(best.ID) + return ScoreStmt{Field: field, Value: fmt.Sprintf("[3]game.%sStatus{%s, %s, %s}", toCamelCase(st.ID), enum, enum, enum)} + } + return ScoreStmt{} +} + +// phaseOrder is the canonical phase ordering used everywhere a UI is laid out top-to-bottom. +var phaseOrder = []string{"auto", "teleop", "endgame"} + +// buildStatusView resolves a schema Status into its view form. IsBool is presence-based: a bool +// status omits the values list (len 0); an enum lists its states. +func buildStatusView(status Status) StatusView { + sv := StatusView{ + ID: status.ID, + DisplayName: status.DisplayName, + Phase: status.Phases[0].Phase, + IsBool: len(status.Values) == 0, + } + if sv.IsBool { + sv.Points = status.Phases[0].Points + } else { + for _, v := range status.Values { + sv.Values = append(sv.Values, ValueView{ID: v.ID, DisplayName: v.DisplayName, Points: v.Points}) + } + } + return sv +} + +// buildTemplateData turns a validated GameYAML into the view model the templates consume. +func buildTemplateData(yamlData *GameYAML) TemplateData { + td := TemplateData{ + GameName: yamlData.Game.Name, + MinorFoulPoints: yamlData.Fouls.MinorFoulPoints, + MajorFoulPoints: yamlData.Fouls.MajorFoulPoints, + } + + // Per-status views, in declaration order. + statusViews := make([]StatusView, len(yamlData.Statuses)) + for i, status := range yamlData.Statuses { + statusViews[i] = buildStatusView(status) + } + td.Statuses = statusViews + + // Resolve every scoring count to its rollup bucket once (scoring_group, else itself), so the + // count-major and group views agree on where each count's points land. + buckets := buildScoringGroups(yamlData) + countGroup := make(map[string]string) // scoring-count id -> its bucket id + for _, bucket := range buckets { + for _, countID := range bucket.CountIDs { + countGroup[countID] = bucket.ID + } + } + + // Phase-major views, in canonical order, each pre-filtered to its counts and statuses. Empty + // phases dropped so a template can range without an emptiness check. + for _, phase := range phaseOrder { + pv := PhaseView{Name: phase, Title: phaseSectionTitle[phase]} + for _, sc := range yamlData.ScoringCounts { + for _, ep := range sc.Phases { + if ep.Phase == phase { + pv.Counts = append(pv.Counts, CountView{ID: sc.ID, DisplayName: sc.DisplayName, Phase: phase, Points: ep.Points}) + pv.CountFields = append(pv.CountFields, phaseFieldPrefix[phase]+toCamelCase(sc.ID)+"Count") + } + } + } + for _, sv := range statusViews { + if sv.Phase == phase { + pv.Statuses = append(pv.Statuses, sv) + } + } + if len(pv.Counts) > 0 || len(pv.Statuses) > 0 { + td.Phases = append(td.Phases, pv) + } + } + + // Scoring-count-major views: each count with its phases, in declaration order, and its bucket. + for _, sc := range yamlData.ScoringCounts { + scv := ScoringCountView{ID: sc.ID, DisplayName: sc.DisplayName, Group: countGroup[sc.ID]} + for _, ep := range sc.Phases { + scv.Phases = append(scv.Phases, CountView{ID: sc.ID, DisplayName: sc.DisplayName, Phase: ep.Phase, Points: ep.Points}) + } + td.ScoringCounts = append(td.ScoringCounts, scv) + } + + // Scoring-group rollup buckets — one ScoreSummary point field and audience entry each. CountFields + // joins each member count to its per-phase Score fields for the live audience counter. + for _, bucket := range buckets { + gv := GroupView{ID: bucket.ID, DisplayName: bucket.DisplayName} + for _, countID := range bucket.CountIDs { + for i := range yamlData.ScoringCounts { + if yamlData.ScoringCounts[i].ID == countID { + for _, ep := range yamlData.ScoringCounts[i].Phases { + gv.CountFields = append(gv.CountFields, phaseFieldPrefix[ep.Phase]+toCamelCase(countID)+"Count") + } + } + } + } + td.ScoringGroups = append(td.ScoringGroups, gv) + } + + // Ranking tiebreaker field names (resolved), in order, plus a labeled form for readable headers. + for _, tb := range yamlData.RankingTiebreakers { + td.RankingTiebreakerFields = append(td.RankingTiebreakerFields, metricFieldName(tb.Metric)) + td.RankingTiebreakers = append(td.RankingTiebreakers, TiebreakerView{ + Field: metricFieldName(tb.Metric), + Label: rankingTiebreakerLabel(tb.Metric, yamlData), + }) + } + + // How the qualification-rankings test grants a scoring lead (count/status-aware). + td.RankingTestScore = buildRankingTestScore(yamlData) + + // Playoff tiebreaker cascade. + for _, tb := range yamlData.PlayoffTiebreakers { + td.PlayoffTiebreakers = append(td.PlayoffTiebreakers, TiebreakerView{ + Field: metricFieldName(tb.Metric), + Label: "TIEBREAK: " + playoffTiebreakerLabel(tb.Metric, yamlData), + }) + } + + // Ranking points. + for _, rp := range yamlData.RPs { + td.RankingPoints = append(td.RankingPoints, RPView{ID: rp.ID, DisplayName: rp.DisplayName, LogicFunc: rp.LogicFunc}) + } + + return td +} diff --git a/game/custom_game.yaml b/game/custom_game.yaml new file mode 100644 index 00000000..818d45a8 --- /dev/null +++ b/game/custom_game.yaml @@ -0,0 +1,121 @@ +game: + name: "My Custom Game" + +fouls: + minor_foul_points: 5 + major_foul_points: 15 + +# game_pieces: Declare the actual physical game pieces. Required on each scoring_counts entry (a +# count is always "a robot scoring a piece"). Piece identity only — it is not a rollup; to group +# counts together, give them a shared scoring_group (below). +game_pieces: + - id: game_piece_1 + display_name: "Game Piece 1" + - id: game_piece_2 + display_name: "Game Piece 2" + +# scoring_groups: Declare rollup buckets. A scoring_counts entry tagged with scoring_group has its +# live count and points summed into this bucket — both the ScoreSummary point field +# (summary.Points) and the audience display. An entry with no scoring_group is its own +# bucket under its own id (so a lone element needs no wrapper group). Tiebreakers reference buckets. +scoring_groups: + - id: structure1 + display_name: "Struct 1" + - id: structure2 + display_name: "Struct 2" + +# scoring_counts: Auto/teleop/endgame counter elements. Each entry lists the phases it can be +# scored in, each with its own points value (a piece can be worth different points in different +# phases or on different structures). +scoring_counts: + - id: structure1_level1 + display_name: "S1L1" + game_piece: game_piece_1 + scoring_group: structure1 + phases: + - phase: auto + points: 3 + - phase: teleop + points: 1 + + - id: structure1_level2 + display_name: "S1L2" + game_piece: game_piece_1 + scoring_group: structure1 + phases: + - phase: auto + points: 5 + - phase: teleop + points: 3 + + - id: structure2_level1 + display_name: "S2" + game_piece: game_piece_2 + scoring_group: structure2 + phases: + - phase: auto + points: 4 + - phase: teleop + points: 2 + +# statuses: Per-robot statuses (3 robots per alliance). Phases takes exactly one entry, same +# {phase, points} shape as scoring_counts (auto or endgame only — teleop is not supported for +# statuses). +# No 'values' field: generates [3]bool; phases[0].points is the bool-status point value (sugar +# for an implicit two-value enum: false = 0 points, true = points). +# With 'values' as a list: generates a typed enum ([3]{ID}Status) with per-state points instead +# — phases[0].points is unused in that case. +statuses: + - id: leave + display_name: "Leave" + phases: + - phase: auto + points: 3 + + - id: muster + display_name: "Muster" + phases: + - phase: auto + values: + - id: none + display_name: "None" + points: 0 + - id: partial + display_name: "Partial" + points: 3 + - id: full + display_name: "Full" + points: 6 + + - id: park + display_name: "Park" + phases: + - phase: endgame + points: 2 + +# ranking_points: Custom RP bonus conditions. +# logic_func must be implemented in game/custom_scoring_logic.go. +ranking_points: + - id: auton_rp + display_name: "Auto Bonus" + logic_func: "ComputeAutonRP" + + - id: scoring_rp + display_name: "Score Bonus" + logic_func: "ComputeScoringRP" + + - id: endgame_rp + display_name: "End Bonus" + logic_func: "ComputeEndgameRP" + +# ranking_tiebreakers: Drives RankingFields struct fields and Less() sort. +# Applies after RankingPoints/Played. +ranking_tiebreakers: + - metric: total_points + - metric: auto_points + +# playoff_tiebreakers: Drives DetermineMatchStatus() cascade for tied playoff matches. +# Opponent major fouls are always first (implicit — don't list here). +playoff_tiebreakers: + - metric: auto_points + - metric: total_points diff --git a/game/examples/high_seas_havoc.yaml b/game/examples/high_seas_havoc.yaml new file mode 100644 index 00000000..eca5dc41 --- /dev/null +++ b/game/examples/high_seas_havoc.yaml @@ -0,0 +1,104 @@ +game: + name: "High Seas Havoc" + +fouls: + minor_foul_points: 5 + major_foul_points: 10 + +game_pieces: + - id: cannonball + display_name: "Cannonball" + +# scoring_groups: matches the real audience display from ~/Code/mayhem-fms-2025 (the FMS +# implementation for this game), which combines Hull+Deck into one "Ship" total and reports +# Kraken Lair separately as "Lair" — a structure-level rollup, distinct from game_piece (there's +# only one piece, the cannonball, scored on three structures). +scoring_groups: + - id: ship + display_name: "Ship" + - id: lair + display_name: "Lair" + +scoring_counts: + - id: hull + display_name: "Hull" + game_piece: cannonball + scoring_group: ship + phases: + - phase: auto + points: 4 + - phase: teleop + points: 2 + + - id: deck + display_name: "Deck" + game_piece: cannonball + scoring_group: ship + phases: + - phase: auto + points: 10 + - phase: teleop + points: 5 + + - id: kraken_lair + display_name: "Kraken Lair" + game_piece: cannonball + scoring_group: lair + phases: + - phase: endgame + points: 10 + +statuses: + - id: leave + display_name: "Leave" + phases: + - phase: auto + points: 4 + + - id: muster + display_name: "Muster" + phases: + - phase: auto + values: + - id: none + display_name: "None" + points: 0 + - id: partial + display_name: "Partial" + points: 3 + - id: full + display_name: "Full" + points: 6 + + - id: park + display_name: "Park" + phases: + - phase: endgame + points: 3 + +ranking_points: + - id: auton_rp + display_name: "Auton Bonus" + logic_func: "ComputeAutonRP" + + - id: scoring_rp + display_name: "Scoring Bonus" + logic_func: "ComputeScoringRP" + + - id: endgame_rp + display_name: "Endgame Bonus" + logic_func: "ComputeEndgameRP" + +# Tiebreakers reference scoring-group totals (ship, lair), not raw elements. mayhem's real game +# tiebreaks on Deck specifically (a member of Ship); mirroring that exactly would need group nesting +# (Deck both its own group and part of Ship), which isn't supported yet — so this tiebreaks on the +# Ship total instead. +ranking_tiebreakers: + - metric: total_points + - metric: auto_points + - metric: lair + +playoff_tiebreakers: + - metric: lair + - metric: ship + - metric: auto_points