diff --git a/.covignore b/.covignore new file mode 100644 index 0000000..87c6170 --- /dev/null +++ b/.covignore @@ -0,0 +1 @@ +.gen.go \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..191ba7d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @frag223 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb37759 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: app ci + +on: + pull_request: + branches: [ main, master ] + push: + branches: [ main, master ] + +jobs: + ci: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install sqlc + uses: sqlc-dev/setup-sqlc@v4 + with: + sqlc-version: '1.27.0' + + - name: Run generation + run: task generate + + - name: Run format checker + run: task format-check + +# - name: golangci-lint +# uses: golangci/golangci-lint-action@v9 +# with: +# version: v2.12 + + - name: Run sqlc vet + run: sqlc vet + + - name: Run tests + run: task cover diff --git a/.gitignore b/.gitignore index aaadf73..9620ac9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ go.work.sum # env file .env +.env.local +.idea +.db +.task # Editor/IDE # .idea/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..b438533 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,48 @@ +version: "2" + +linters: + enable: + - cyclop + - nestif + - bodyclose + - iface + - gosec + - errcheck + - errchkjson + - errname + - errorlint + - exptostd + - fatcontext + - forbidigo + - forcetypeassert + - govet + - importas + - ireturn + - perfsprint + - recvcheck + - sloglint + - staticcheck + - unparam + - unused + - wastedassign + exclusions: + generated: lax + + rules: + # Exclude some linters from running on tests files. + - path: _test\.go + linters: + - gocyclo + - cyclop + - dupl + - gosec + - forbidigo + - exhaustruct + + settings: + depguard: + rules: + main: + list-mode: lax + + diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 100644 index 0000000..159050b --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1,229 @@ + +## Global rules +- You must always ask before creating mocks + +## SQLite queries + +- When writing sqlite queries use the tool [sqlc](https://sqlc.dev/) +- When writing SQL queries ensure you annotate your queries +- Following are examples of correct annotations +- after you finished writing the queries, use the command `task gen` to generate the boilerplate go code + +```sql +-- name: GetAuthor :one +SELECT * FROM authors +WHERE id = ? LIMIT 1; + +-- name: ListAuthors :many +SELECT * FROM authors +ORDER BY name; + +-- name: CreateAuthor :one +INSERT INTO authors ( + name, bio +) VALUES ( + ?, ? +) +RETURNING *; + +-- name: UpdateAuthor :exec +UPDATE authors +set name = ?, +bio = ? +WHERE id = ?; + +-- name: DeleteAuthor :exec +DELETE FROM authors +WHERE id = ?; +``` + +## Frontend + +## Golang + +You are an expert in Go, microservices architecture, and clean backend development practices. Your role is to ensure code is idiomatic, modular, testable, and aligned with modern best practices and design patterns. + +review `taskfile.yaml` for list of commands available in repo. + +### UI +- UI is https://github.com/charmbracelet/bubbletea +- Use components: https://github.com/charmbracelet/bubbles +- Use styling: https://github.com/charmbracelet/lipgloss + +### General Responsibilities: +- Do not add useless comments +- Guide the development of idiomatic, maintainable, and high-performance Go code. +- Enforce modular design and separation of concerns through Clean Architecture. +- Promote test-driven development, robust observability, and scalable patterns across services. + +### Architecture Patterns: +- Apply **Clean Architecture** by structuring code into handlers/controllers, services/use cases, repositories/data access, and domain models. +- Use **domain-driven design** principles where applicable. +- Prioritise **interface-driven development** with explicit dependency injection. +- Prefer **composition over inheritance**; favour small, purpose-specific interfaces. +- Ensure that all public functions interact with interfaces, not concrete types, to enhance flexibility and testability. + +### Project Structure Guidelines: +- Use a consistent project layout: + - cmd/: application entrypoint + - internal/: core application logic (not exposed externally) +- Group code by feature when it improves clarity and cohesion. +- Keep logic decoupled from framework-specific code. + +### Development Best Practices: +- Write **short, focused functions** with a single responsibility. +- Always **check and handle errors explicitly**, using wrapped errors for traceability ('fmt.Errorf("context: %w", err)'). +- Avoid **global state**; use constructor functions to inject dependencies. +- Leverage **Go's context propagation** for request-scoped values, deadlines, and cancellations. +- Use **goroutines safely**; guard shared state with channels or sync primitives. +- **Defer closing resources** and handle them carefully to avoid leaks. + +### Security and Resilience: +- Apply **input validation and sanitisation** rigorously, especially on inputs from external sources. +- Use secure defaults for **JWT, cookies**, and configuration settings. +- Isolate sensitive operations with clear **permission boundaries**. +- Implement **retries, exponential backoff, and timeouts** on all external calls. +- Use **circuit breakers and rate limiting** for service protection. +- Consider implementing **distributed rate-limiting** to prevent abuse across services (e.g., using Redis). + +### Testing: +- Write **unit tests** using use [odize](https://github.com/code-gorilla-au/odize) as the test framework and parallel execution. +- Do not mock out the database, we're using sqlite and embedded db for tests. +- Think about edge cases, within reason. +- **Mock external interfaces** cleanly using generated ([Moq](https://github.com/matryer/moq)) or handwritten mocks. +- Separate **fast unit tests** from slower integration and E2E tests. +- Ensure **test coverage** for every exported function, with behavioural checks. +- Test command with coverage is: `task cover`. + + +#### Example odize framework +```golang +func TestQueries(t *testing.T) { + group := odize.NewGroup(t, nil) + + owner := "acme" + repo := "super-repo" + topic := "tooling" + + err := group. + Test("queryReposByTopic builds expected search query", func(t *testing.T) { + q := queryReposByTopic(owner, topic) + + odize.AssertTrue(t, containsAll(q, []string{ + "search(", + "type: REPOSITORY", + "first: 100", + "pageInfo", + "hasNextPage", + "endCursor", + "repositoryCount", + "edges", + "... on Repository", + "name", + "url", + "owner", + "login", + })) + + odize.AssertTrue(t, containsAll(q, []string{ + "owner:" + owner, + "topic:" + topic, + })) + }). + Test("queryGetRepoDetails builds expected repository details query", func(t *testing.T) { + q := queryGetRepoDetails(owner, repo) + + odize.AssertTrue(t, containsAll(q, []string{ + "repository(owner: \"" + owner + "\"", + ", name: \"" + repo + "\")", + })) + + odize.AssertTrue(t, containsAll(q, []string{ + "name", + "url", + "owner", + "login", + })) + + odize.AssertTrue(t, containsAll(q, []string{ + "pullRequests", + "orderBy: {field: CREATED_AT, direction: ASC}", + "totalCount", + "nodes", + "id", + "state", + "title", + "createdAt", + "mergedAt", + "permalink", + "author", + "login", + })) + + odize.AssertTrue(t, containsAll(q, []string{ + "vulnerabilityAlerts", + "pageInfo", + "hasNextPage", + "endCursor", + "nodes", + "securityVulnerability", + "package", + "name", + "advisory", + "severity", + "firstPatchedVersion", + "identifier", + "updatedAt", + })) + }). + Run() + + odize.AssertNoError(t, err) +} + + +func TestService_GetAllOrganisations(t *testing.T) { + group := odize.NewGroup(t, nil) + + var s *Service + + ctx := context.Background() + + group.BeforeEach(func() { + s = NewService(ctx, _testDB, _testTxnDB) + }) + + err := group. + Test("should return all existing organisations", func(t *testing.T) { + initialOrgs, err := s.GetAllOrganisations() + odize.AssertNoError(t, err) + initialCount := len(initialOrgs) + odize.AssertTrue(t, initialCount > 0) + }). + Run() + odize.AssertNoError(t, err) +} +``` + +### Documentation and Standards: +- Document public functions and packages with **GoDoc-style comments**. +- Provide concise **READMEs** for services and libraries. +- Maintain a 'CONTRIBUTING.md' and 'ARCHITECTURE.md' to guide team practices. +- Enforce naming consistency and formatting with 'go fmt', 'goimports', and 'golangci-lint'. + +### Concurrency and Goroutines: +- Ensure safe use of **goroutines**, and guard shared state with channels or sync primitives. +- Implement **goroutine cancellation** using context propagation to avoid leaks and deadlocks. + +### Tooling and Dependencies: +- Rely on **stable, minimal third-party libraries**; prefer the standard library where possible. +- Use **Go modules** for dependency management and reproducibility. +- Version-lock dependencies for deterministic builds. +- Integrate **linting, testing, and security checks** in CI pipelines. + +### Key Conventions: +1. Prioritise **readability, simplicity, and maintainability**. +2. Design for **change**: isolate business logic and minimise framework lock-in. +3. Emphasise clear **boundaries** and **dependency inversion**. +4. Ensure all behaviour is **observable, testable, and documented**. +5. **Automate workflows** for testing, building, and deployment. \ No newline at end of file diff --git a/.junie/memory/errors.md b/.junie/memory/errors.md new file mode 100644 index 0000000..e69de29 diff --git a/.junie/memory/feedback.md b/.junie/memory/feedback.md new file mode 100644 index 0000000..e69de29 diff --git a/.junie/memory/language.json b/.junie/memory/language.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.junie/memory/language.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/.junie/memory/memory.version b/.junie/memory/memory.version new file mode 100644 index 0000000..f398a20 --- /dev/null +++ b/.junie/memory/memory.version @@ -0,0 +1 @@ +3.0 \ No newline at end of file diff --git a/.junie/memory/tasks.md b/.junie/memory/tasks.md new file mode 100644 index 0000000..e69de29 diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..2f76f2c --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,97 @@ +version: '3' + +vars: + GOLANG_WORKSPACES: ./... + GOLANG_COVERAGE: coverage.out + +tasks: + dev: + desc: Run dev + cmds: + - go run ./cmd/rush/... + + lint: + desc: Run golangci-lint + cmds: + - golangci-lint run ./... + - sqlc vet + - task: format-check + + format-check: + desc: Check if Go code is formatted + cmds: + - cmd: test -z "$(gofmt -l .)" + silent: true + status: + - test -z "$(gofmt -l .)" + + format: + desc: Format Go code and tidy modules + cmds: + - go fmt ./... + - go mod tidy + + sqlc: + desc: Generate Go code from SQL queries using sqlc + sources: + - sqlc.yaml + - "**/*.sql" + generates: + - "**/*.gen.go" + cmds: + - sqlc generate + + generate: + desc: Run all code generation + cmds: + - task: sqlc + + ci: + desc: Run ci tasks + cmds: + - task: generate + - task: format + - task: lint + - task: cover + + + test: + desc: Run go tests. + deps: [generate] + cmds: + - go test -cover -coverprofile={{ .GOLANG_COVERAGE }} -failfast {{ .GOLANG_WORKSPACES }} + + watch: + desc: Run go tests in watch mode. + cmds: + - gow test -cover -coverprofile={{ .GOLANG_COVERAGE }} -failfast {{ .GOLANG_WORKSPACES }} + + gen-coverage: + desc: Generate go coverage. + deps: [test] + cmds: + - grep -v -E -f ${PWD}/.covignore {{ .GOLANG_COVERAGE }} > coverage.filtered.out + - mv coverage.filtered.out {{ .GOLANG_COVERAGE }} + + cover: + desc: Run go coverage. + deps: [gen-coverage] + cmds: + - go tool cover -func={{ .GOLANG_COVERAGE }} | grep total + + cover-html: + desc: Run go coverage html. + deps: [gen-coverage] + cmds: + - go tool cover -html={{ .GOLANG_COVERAGE }} + + db-clean: + desc: Run db clean + cmds: + - rm -rf ./.db + - mkdir ./.db + db-reset: + desc: Run db clean and migrate + deps: [db-clean] + cmds: + - go run ./cmd/local/... \ No newline at end of file diff --git a/cmd/local/config.go b/cmd/local/config.go new file mode 100644 index 0000000..350d0eb --- /dev/null +++ b/cmd/local/config.go @@ -0,0 +1,13 @@ +package main + +import "github.com/code-gorilla-au/env" + +type Config struct { + DatabaseUrl string +} + +func NewConfig() *Config { + return &Config{ + DatabaseUrl: env.GetAsString("DATABASE_URL"), + } +} diff --git a/cmd/local/main.go b/cmd/local/main.go new file mode 100644 index 0000000..fd258dc --- /dev/null +++ b/cmd/local/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "log/slog" + "os" + + "github.com/code-gorilla-au/env" + "github.com/code-gorilla-au/rush/internal/database" +) + +func main() { + ctx := context.Background() + env.LoadEnvFile(".env.local") + config := NewConfig() + + slog.Info("Starting local migration") + + db, err := database.NewSqLiteProvider(config.DatabaseUrl) + if err != nil { + slog.Error("Failed to create database provider", "error", err) + os.Exit(1) + } + + defer func() { + slog.Info("Closing local database") + dbErr := db.Close() + if dbErr != nil { + slog.Error("Failed to close database", "error", err) + } + }() + + migrator := database.NewMigrator(db, database.SchemaFS) + if mErr := migrator.Migrate(ctx); mErr != nil { + slog.Error("Failed to migrate database", "error", mErr) + os.Exit(1) + } + slog.Info("Migrate complete") +} diff --git a/cmd/rush/config.go b/cmd/rush/config.go new file mode 100644 index 0000000..350d0eb --- /dev/null +++ b/cmd/rush/config.go @@ -0,0 +1,13 @@ +package main + +import "github.com/code-gorilla-au/env" + +type Config struct { + DatabaseUrl string +} + +func NewConfig() *Config { + return &Config{ + DatabaseUrl: env.GetAsString("DATABASE_URL"), + } +} diff --git a/cmd/rush/main.go b/cmd/rush/main.go new file mode 100644 index 0000000..a0515fe --- /dev/null +++ b/cmd/rush/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "context" + "log/slog" + "os" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/env" + "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/games" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui" +) + +func main() { + ctx := context.Background() + env.LoadEnvFile(".env.local") + config := NewConfig() + + db, err := database.NewSqLiteProvider(config.DatabaseUrl) + if err != nil { + slog.Error("Failed to create database provider", "error", err) + os.Exit(1) + } + + defer func() { + dbErr := db.Close() + if dbErr != nil { + slog.Error("Failed to close database", "error", err) + } + }() + + migrator := database.NewMigrator(db, database.SchemaFS) + if mErr := migrator.Migrate(ctx); mErr != nil { + slog.Error("Failed to migrate database", "error", mErr) + os.Exit(1) + } + + queries := database.New(db) + playbooksSvc := playbooks.NewPlaybooksService(queries) + teamsSvc := teams.NewTeamsService(queries, playbooksSvc) + gameSvc := games.NewService(queries) + + go func() { + hasAICoaches, tErr := teamsSvc.HasAICoaches(ctx) + if tErr != nil { + slog.Error("Failed to check for AI coaches", "error", tErr) + return + } + + if hasAICoaches { + return + } + + if tErr = teamsSvc.GenerateAITeams(ctx); tErr != nil { + slog.Error("Failed to generate teams", "error", tErr) + } + }() + + p := tea.NewProgram(ui.New(ui.Dependencies{ + TeamsSvc: teamsSvc, + PlaybookSvc: playbooksSvc, + GameSvc: gameSvc, + })) + if _, err = p.Run(); err != nil { + slog.Error("Failed to run program", "error", err) + os.Exit(1) + } +} diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..809c1c2 --- /dev/null +++ b/docs/features.md @@ -0,0 +1,8 @@ +# Features + +List of features intended for the project. + +- [ ] Single battle +- [ ] Tournament mode +- [ ] Record battles +- [ ] Team statistics \ No newline at end of file diff --git a/docs/formations.md b/docs/formations.md new file mode 100644 index 0000000..226b59e --- /dev/null +++ b/docs/formations.md @@ -0,0 +1,217 @@ +# Formations + +Formations for a team of 5. + +## Combinations + +### Balanced + +#### Balanced Right + +name: balanced-right +description: each lane has at least one player, with centre and right having two players in each lane. + +```markdown +|---|---|---| +| x | x | x | +| | x | x | +| | | | + +``` + +#### Balanced Left + +name: balanced-left +description: each lane has at least one player, with centre and left having two players in each lane. + +```markdown +|---|---|---| +| x | x | x | +| x | x | | +| | | | + +``` + +### Strong + +#### Strong Right + +name: strong-right +Description: A strong formation with more players on the right side. + +```markdown +|---|---|---| +| x | x | x | +| | | x | +| | | x | + +``` + +#### Strong Left + +name: strong-left +Description: A strong formation with more players on the left side. + +```markdown +|---|---|---| +| x | x | x | +| x | | | +| x | | | + +``` + +#### Strong centre + +name: strong-centre +Description: Concentrated strength in the middle lane. + +```markdown +|---|---|---| +| x | x | x | +| | x | | +| | x | | + +``` + +### Split + +#### Split Balanced + +name: split-balanced +Description: Players are split to stack team members on the outside lanes. + +```markdown +|---|---|---| +| x | x | x | +| x | | x | +| | | | + +``` + +#### Split right + +name: split-right +Description: Players are stacked on the outside lanes, leaving the centre open, with a strong presence on the right flank. + +```markdown +|---|---|---| +| x | | x | +| x | | x | +| | | x | + +``` + +#### Split left + +name: split-left +Description: Players are stacked on the outside lanes, leaving the centre open, with a strong presence on the left flank. + +```markdown +|---|---|---| +| x | | x | +| x | | x | +| x | | | + +``` + + +### Overload + +#### Overload Right + +name: overload-right +Description: Maximum pressure on the right flank, leaving the left open. + +```markdown +|---|---|---| +| | x | x | +| | x | x | +| | | x | + +``` + +#### Overload Left + +name: overload-left +Description: Maximum pressure on the left flank, leaving the right open. + +```markdown +|---|---|---| +| x | x | | +| x | x | | +| x | | | + +``` + +#### Overload Center Left + +name: overload-centre-left +Description: Heavy presence in the centre and left, leaving the right open. + +```markdown +|---|---|---| +| x | x | | +| x | x | | +| | x | | + +``` + +#### Overload Center Right + +name: overload-centre-right +Description: Heavy presence in the centre and right, leaving the left open. + +```markdown +|---|---|---| +| | x | x | +| | x | x | +| | x | | + +``` + +### Single Lane + +#### Single Lane Left + +name: single-lane-left +Description: All players are concentrated in the left lane. + +```markdown +|---|---|---| +| x | | | +| x | | | +| x | | | +| x | | | +| x | | | + +``` + +#### Single Lane Center + +name: single-lane-centre +Description: All players are concentrated in the center lane. + +```markdown +|---|---|---| +| | x | | +| | x | | +| | x | | +| | x | | +| | x | | + +``` + +#### Single Lane Right + +name: single-lane-right +Description: All players are concentrated in the right lane. + +```markdown +|---|---|---| +| | | x | +| | | x | +| | | x | +| | | x | +| | | x | + +``` diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..1b0fd05 --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,13 @@ +# Rules + +Game rules. + +## Game + +- The game is ten rounds. +- Coaches chosen at random to go first; at round 5, coaches swap over. +- Players in the same lane dual and roll a 1d6 die. Highest score wins. +- Loosing player is eliminated. Lane duals continue until no opposing players remain in the lane. +- Round is considered when there are no opposing players remaining in all three lanes. +- Players remaining in all three lanes score 1 point for the round. +- After ten rounds, the coach with the most points wins. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bb7095e --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module github.com/code-gorilla-au/rush + +go 1.26.3 + +require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.4 + github.com/code-gorilla-au/env v1.1.1 + github.com/code-gorilla-au/odize v1.3.5 + github.com/go-faker/faker/v4 v4.8.0 + modernc.org/sqlite v1.52.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..43f692f --- /dev/null +++ b/go.sum @@ -0,0 +1,105 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk= +github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/code-gorilla-au/env v1.1.1 h1:4rkSwCnyymKh+KGAOPx3fEg9v2ZV5i9r92bSf7xvnCE= +github.com/code-gorilla-au/env v1.1.1/go.mod h1:KE4Ymfz5MhMi7SX3ZKH4iMFAHsDCvwOV8WTzgpwzzE4= +github.com/code-gorilla-au/odize v1.3.5 h1:Bjb0c1NXRkbEppsCs2PSN4DHWy3yWIggTXdroibWF54= +github.com/code-gorilla-au/odize v1.3.5/go.mod h1:+PtShsIEca9bAfxltU00OVD75aR5NvtkpOW/HGHdi9w= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-faker/faker/v4 v4.8.0 h1:QAKmb4TyAMxIgf3a8fXubQXwFFJviBGL2/IDa34N6JQ= +github.com/go-faker/faker/v4 v4.8.0/go.mod h1:u1dIRP5neLB6kTzgyVjdBOV5R1uP7BdxkcWk7tiKQXk= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo= +modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/database/db.gen.go b/internal/database/db.gen.go new file mode 100644 index 0000000..85d4b8c --- /dev/null +++ b/internal/database/db.gen.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package database + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/database/db.go b/internal/database/db.go new file mode 100644 index 0000000..36936fd --- /dev/null +++ b/internal/database/db.go @@ -0,0 +1,17 @@ +package database + +import ( + "database/sql" + + _ "modernc.org/sqlite" +) + +func NewSqLiteProvider(dbPath string) (*sql.DB, error) { + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, err + } + + return db, nil +} diff --git a/internal/database/game.sql.gen.go b/internal/database/game.sql.gen.go new file mode 100644 index 0000000..f5a6cb9 --- /dev/null +++ b/internal/database/game.sql.gen.go @@ -0,0 +1,155 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: game.sql + +package database + +import ( + "context" + "database/sql" + "encoding/json" +) + +const createGame = `-- name: CreateGame :one +insert into games (name, + team_a, + team_b, + tournament_id, + results_log, + status, + rounds, + current_round) +values (?, + ?, + ?, + ?, + ?, + 'pending', + ?, + ?) +returning id, name, tournament_id, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at +` + +type CreateGameParams struct { + Name string + TeamA sql.NullInt64 + TeamB sql.NullInt64 + TournamentID sql.NullInt64 + ResultsLog json.RawMessage + Rounds json.RawMessage + CurrentRound int64 +} + +func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, error) { + row := q.db.QueryRowContext(ctx, createGame, + arg.Name, + arg.TeamA, + arg.TeamB, + arg.TournamentID, + arg.ResultsLog, + arg.Rounds, + arg.CurrentRound, + ) + var i Game + err := row.Scan( + &i.ID, + &i.Name, + &i.TournamentID, + &i.TeamA, + &i.TeamB, + &i.Winner, + &i.Status, + &i.Rounds, + &i.CurrentRound, + &i.ResultsLog, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getGameByID = `-- name: GetGameByID :one +select id, name, tournament_id, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at +from games +where id = ? +` + +func (q *Queries) GetGameByID(ctx context.Context, id int64) (Game, error) { + row := q.db.QueryRowContext(ctx, getGameByID, id) + var i Game + err := row.Scan( + &i.ID, + &i.Name, + &i.TournamentID, + &i.TeamA, + &i.TeamB, + &i.Winner, + &i.Status, + &i.Rounds, + &i.CurrentRound, + &i.ResultsLog, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateGame = `-- name: UpdateGame :one +update games +set name = ?, + team_a = ?, + team_b = ?, + winner = ?, + status = ?, + results_log = ?, + rounds = ?, + current_round = ?, + tournament_id = ? +where id = ? +returning id, name, tournament_id, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at +` + +type UpdateGameParams struct { + Name string + TeamA sql.NullInt64 + TeamB sql.NullInt64 + Winner sql.NullInt64 + Status string + ResultsLog json.RawMessage + Rounds json.RawMessage + CurrentRound int64 + TournamentID sql.NullInt64 + ID int64 +} + +func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, error) { + row := q.db.QueryRowContext(ctx, updateGame, + arg.Name, + arg.TeamA, + arg.TeamB, + arg.Winner, + arg.Status, + arg.ResultsLog, + arg.Rounds, + arg.CurrentRound, + arg.TournamentID, + arg.ID, + ) + var i Game + err := row.Scan( + &i.ID, + &i.Name, + &i.TournamentID, + &i.TeamA, + &i.TeamB, + &i.Winner, + &i.Status, + &i.Rounds, + &i.CurrentRound, + &i.ResultsLog, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/database/migrator.go b/internal/database/migrator.go new file mode 100644 index 0000000..40df3d5 --- /dev/null +++ b/internal/database/migrator.go @@ -0,0 +1,64 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "io/fs" + "slices" + "strings" +) + +import "embed" + +//go:embed schema/*.sql +var SchemaFS embed.FS + +// Service defines the interface for database migrations. +type Service interface { + Migrate(ctx context.Context) error +} + +// Migrator handles database schema migrations. +type Migrator struct { + db *sql.DB + fs fs.FS +} + +// NewMigrator creates a new Migrator instance that implements the Service interface. +func NewMigrator(db *sql.DB, fsys fs.FS) Service { + return &Migrator{ + db: db, + fs: fsys, + } +} + +// Migrate executes all SQL files found in the schema directory. +func (m *Migrator) Migrate(ctx context.Context) error { + entries, err := fs.ReadDir(m.fs, "schema") + if err != nil { + return fmt.Errorf("reading schema directory: %w", err) + } + + var sqlFiles []string + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { + sqlFiles = append(sqlFiles, entry.Name()) + } + } + + slices.Sort(sqlFiles) + + for _, fileName := range sqlFiles { + content, err := fs.ReadFile(m.fs, "schema/"+fileName) + if err != nil { + return fmt.Errorf("reading schema file %s: %w", fileName, err) + } + + if _, err := m.db.ExecContext(ctx, string(content)); err != nil { + return fmt.Errorf("executing schema file %s: %w", fileName, err) + } + } + + return nil +} diff --git a/internal/database/migrator_test.go b/internal/database/migrator_test.go new file mode 100644 index 0000000..2ec77d4 --- /dev/null +++ b/internal/database/migrator_test.go @@ -0,0 +1,45 @@ +package database + +import ( + "database/sql" + "testing" + + _ "modernc.org/sqlite" +) + +func TestMigrator_Migrate(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + migrator := NewMigrator(db, SchemaFS) + + if err := migrator.Migrate(t.Context()); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + + // Verify that tables were created + rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table'") + if err != nil { + t.Fatalf("failed to query tables: %v", err) + } + defer rows.Close() + + tables := make(map[string]bool) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + t.Fatalf("failed to scan table name: %v", err) + } + tables[name] = true + } + + expectedTables := []string{"coaches", "teams", "players"} + for _, table := range expectedTables { + if !tables[table] { + t.Errorf("expected table %s not found", table) + } + } +} diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go new file mode 100644 index 0000000..9a605c0 --- /dev/null +++ b/internal/database/models.gen.go @@ -0,0 +1,68 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package database + +import ( + "database/sql" + "encoding/json" +) + +type Coach struct { + ID int64 + Name string + IsDefault sql.NullBool + IsHuman sql.NullBool + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + +type Game struct { + ID int64 + Name string + TournamentID sql.NullInt64 + TeamA sql.NullInt64 + TeamB sql.NullInt64 + Winner sql.NullInt64 + Status string + Rounds json.RawMessage + CurrentRound int64 + ResultsLog json.RawMessage + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + +type Playbook struct { + ID int64 + Name string + TeamID sql.NullInt64 + Description sql.NullString + Formations interface{} + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + +type Player struct { + ID int64 + Name string + TeamID sql.NullInt64 + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + +type Team struct { + ID int64 + Name string + IsDefault sql.NullBool + CoachID sql.NullInt64 + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + +type Tournament struct { + ID int64 + Name string + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} diff --git a/internal/database/playbook.sql.gen.go b/internal/database/playbook.sql.gen.go new file mode 100644 index 0000000..d1f45ae --- /dev/null +++ b/internal/database/playbook.sql.gen.go @@ -0,0 +1,130 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: playbook.sql + +package database + +import ( + "context" + "database/sql" +) + +const createPlaybook = `-- name: CreatePlaybook :one +insert into playbooks (name, + description, + formations, + team_id) +VALUES (?, + ?, + ?, + ?) +RETURNING id, name, team_id, description, formations, created_at, updated_at +` + +type CreatePlaybookParams struct { + Name string + Description sql.NullString + Formations interface{} + TeamID sql.NullInt64 +} + +func (q *Queries) CreatePlaybook(ctx context.Context, arg CreatePlaybookParams) (Playbook, error) { + row := q.db.QueryRowContext(ctx, createPlaybook, + arg.Name, + arg.Description, + arg.Formations, + arg.TeamID, + ) + var i Playbook + err := row.Scan( + &i.ID, + &i.Name, + &i.TeamID, + &i.Description, + &i.Formations, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deletePlaybook = `-- name: DeletePlaybook :exec +delete from playbooks where id = ? +` + +func (q *Queries) DeletePlaybook(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deletePlaybook, id) + return err +} + +const getPlaybooksByTeam = `-- name: GetPlaybooksByTeam :many +select id, name, team_id, description, formations, created_at, updated_at from playbooks where team_id = ? +` + +func (q *Queries) GetPlaybooksByTeam(ctx context.Context, teamID sql.NullInt64) ([]Playbook, error) { + rows, err := q.db.QueryContext(ctx, getPlaybooksByTeam, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Playbook + for rows.Next() { + var i Playbook + if err := rows.Scan( + &i.ID, + &i.Name, + &i.TeamID, + &i.Description, + &i.Formations, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updatePlaybook = `-- name: UpdatePlaybook :one +update playbooks +set name = ?, + description = ?, + formations = ? +where id = ? +returning id, name, team_id, description, formations, created_at, updated_at +` + +type UpdatePlaybookParams struct { + Name string + Description sql.NullString + Formations interface{} + ID int64 +} + +func (q *Queries) UpdatePlaybook(ctx context.Context, arg UpdatePlaybookParams) (Playbook, error) { + row := q.db.QueryRowContext(ctx, updatePlaybook, + arg.Name, + arg.Description, + arg.Formations, + arg.ID, + ) + var i Playbook + err := row.Scan( + &i.ID, + &i.Name, + &i.TeamID, + &i.Description, + &i.Formations, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/database/queries/game.sql b/internal/database/queries/game.sql new file mode 100644 index 0000000..746866b --- /dev/null +++ b/internal/database/queries/game.sql @@ -0,0 +1,37 @@ +-- name: CreateGame :one +insert into games (name, + team_a, + team_b, + tournament_id, + results_log, + status, + rounds, + current_round) +values (?, + ?, + ?, + ?, + ?, + 'pending', + ?, + ?) +returning *; + +-- name: GetGameByID :one +select * +from games +where id = ?; + +-- name: UpdateGame :one +update games +set name = ?, + team_a = ?, + team_b = ?, + winner = ?, + status = ?, + results_log = ?, + rounds = ?, + current_round = ?, + tournament_id = ? +where id = ? +returning *; \ No newline at end of file diff --git a/internal/database/queries/playbook.sql b/internal/database/queries/playbook.sql new file mode 100644 index 0000000..ca63db1 --- /dev/null +++ b/internal/database/queries/playbook.sql @@ -0,0 +1,24 @@ +-- name: CreatePlaybook :one +insert into playbooks (name, + description, + formations, + team_id) +VALUES (?, + ?, + ?, + ?) +RETURNING *; + +-- name: UpdatePlaybook :one +update playbooks +set name = ?, + description = ?, + formations = ? +where id = ? +returning *; + +-- name: DeletePlaybook :exec +delete from playbooks where id = ?; + +-- name: GetPlaybooksByTeam :many +select * from playbooks where team_id = ?; diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql new file mode 100644 index 0000000..bda4394 --- /dev/null +++ b/internal/database/queries/team.sql @@ -0,0 +1,56 @@ +-- name: GetCoach :one +SELECT * FROM teams WHERE id = ?; + +-- name: GetDefaultCoach :one +SELECT * FROM coaches WHERE is_default = true LIMIT 1; + +-- name: SetDefaultCoach :exec +UPDATE coaches SET is_default = true WHERE id = ?; + +-- name: ClearDefaultCoach :exec +UPDATE coaches SET is_default = false WHERE is_default = true; + +-- name: GetCoaches :many +SELECT * FROM coaches; + +-- name: GetAICoaches :many +SELECT * FROM coaches WHERE is_human = false; + +-- name: CreateCoach :one +INSERT INTO coaches (name, is_human, is_default) VALUES (?, ?, ?) RETURNING *; + +-- name: DeleteCoach :exec +DELETE FROM coaches WHERE id = ?; + +-- name: GetTeams :many +SELECT * FROM teams; + +-- name: GetTeam :one +SELECT * FROM teams WHERE id = ?; + +-- name: GetTeamByCoachID :one +SELECT * FROM teams WHERE coach_id = ? LIMIT 1; + +-- name: CreateTeam :one +INSERT INTO teams (name, is_default, coach_id) VALUES (?, ?, ?) RETURNING *; + +-- name: SetDefaultTeam :exec +UPDATE teams SET is_default = true WHERE id = ?; + +-- name: ClearDefaultTeam :exec +UPDATE teams SET is_default = false WHERE is_default = true; + +-- name: DeleteTeam :exec +DELETE FROM teams WHERE id = ?; + +-- name: GetTeamMembers :many +SELECT * FROM players WHERE team_id = ?; + +-- name: CreatePlayer :one +INSERT INTO players (name, team_id) VALUES (?,?) RETURNING *; + +-- name: UpdatePlayer :exec +UPDATE players SET name = ? WHERE id = ?; + +-- name: DeletePlayer :exec +DELETE FROM players WHERE id = ?; \ No newline at end of file diff --git a/internal/database/schema/game.sql b/internal/database/schema/game.sql new file mode 100644 index 0000000..4a23c88 --- /dev/null +++ b/internal/database/schema/game.sql @@ -0,0 +1,23 @@ + +create table if not exists tournaments ( + id integer primary key autoincrement, + name text not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +create table if not exists games ( + id integer primary key autoincrement, + name text not null, + tournament_id integer references tournaments(id), + team_a integer references teams(id), + team_b integer references teams(id), + winner integer references teams(id), + status varchar(255) not null, + rounds text not null, + current_round integer not null default 0, + results_log text not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + diff --git a/internal/database/schema/playbook.sql b/internal/database/schema/playbook.sql new file mode 100644 index 0000000..42a031f --- /dev/null +++ b/internal/database/schema/playbook.sql @@ -0,0 +1,9 @@ +create table if not exists playbooks ( + id integer primary key autoincrement, + name varchar(255) not null, + team_id integer references teams(id), + description text, + formations string not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/internal/database/schema/team.sql b/internal/database/schema/team.sql new file mode 100644 index 0000000..bb97104 --- /dev/null +++ b/internal/database/schema/team.sql @@ -0,0 +1,25 @@ +create table if not exists coaches ( + id integer primary key autoincrement, + name varchar(255) not null, + is_default BOOLEAN DEFAULT 0, + is_human BOOLEAN DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +create table if not exists teams ( + id integer primary key autoincrement, + name varchar(255) not null, + is_default BOOLEAN DEFAULT 0, + coach_id integer references coaches(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +create table if not exists players ( + id integer primary key autoincrement, + name varchar(255) not null, + team_id integer references teams(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go new file mode 100644 index 0000000..d9f28d3 --- /dev/null +++ b/internal/database/team.sql.gen.go @@ -0,0 +1,365 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: team.sql + +package database + +import ( + "context" + "database/sql" +) + +const clearDefaultCoach = `-- name: ClearDefaultCoach :exec +UPDATE coaches SET is_default = false WHERE is_default = true +` + +func (q *Queries) ClearDefaultCoach(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, clearDefaultCoach) + return err +} + +const clearDefaultTeam = `-- name: ClearDefaultTeam :exec +UPDATE teams SET is_default = false WHERE is_default = true +` + +func (q *Queries) ClearDefaultTeam(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, clearDefaultTeam) + return err +} + +const createCoach = `-- name: CreateCoach :one +INSERT INTO coaches (name, is_human, is_default) VALUES (?, ?, ?) RETURNING id, name, is_default, is_human, created_at, updated_at +` + +type CreateCoachParams struct { + Name string + IsHuman sql.NullBool + IsDefault sql.NullBool +} + +func (q *Queries) CreateCoach(ctx context.Context, arg CreateCoachParams) (Coach, error) { + row := q.db.QueryRowContext(ctx, createCoach, arg.Name, arg.IsHuman, arg.IsDefault) + var i Coach + err := row.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.IsHuman, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createPlayer = `-- name: CreatePlayer :one +INSERT INTO players (name, team_id) VALUES (?,?) RETURNING id, name, team_id, created_at, updated_at +` + +type CreatePlayerParams struct { + Name string + TeamID sql.NullInt64 +} + +func (q *Queries) CreatePlayer(ctx context.Context, arg CreatePlayerParams) (Player, error) { + row := q.db.QueryRowContext(ctx, createPlayer, arg.Name, arg.TeamID) + var i Player + err := row.Scan( + &i.ID, + &i.Name, + &i.TeamID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createTeam = `-- name: CreateTeam :one +INSERT INTO teams (name, is_default, coach_id) VALUES (?, ?, ?) RETURNING id, name, is_default, coach_id, created_at, updated_at +` + +type CreateTeamParams struct { + Name string + IsDefault sql.NullBool + CoachID sql.NullInt64 +} + +func (q *Queries) CreateTeam(ctx context.Context, arg CreateTeamParams) (Team, error) { + row := q.db.QueryRowContext(ctx, createTeam, arg.Name, arg.IsDefault, arg.CoachID) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.CoachID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteCoach = `-- name: DeleteCoach :exec +DELETE FROM coaches WHERE id = ? +` + +func (q *Queries) DeleteCoach(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteCoach, id) + return err +} + +const deletePlayer = `-- name: DeletePlayer :exec +DELETE FROM players WHERE id = ? +` + +func (q *Queries) DeletePlayer(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deletePlayer, id) + return err +} + +const deleteTeam = `-- name: DeleteTeam :exec +DELETE FROM teams WHERE id = ? +` + +func (q *Queries) DeleteTeam(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteTeam, id) + return err +} + +const getAICoaches = `-- name: GetAICoaches :many +SELECT id, name, is_default, is_human, created_at, updated_at FROM coaches WHERE is_human = false +` + +func (q *Queries) GetAICoaches(ctx context.Context) ([]Coach, error) { + rows, err := q.db.QueryContext(ctx, getAICoaches) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Coach + for rows.Next() { + var i Coach + if err := rows.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.IsHuman, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getCoach = `-- name: GetCoach :one +SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams WHERE id = ? +` + +func (q *Queries) GetCoach(ctx context.Context, id int64) (Team, error) { + row := q.db.QueryRowContext(ctx, getCoach, id) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.CoachID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getCoaches = `-- name: GetCoaches :many +SELECT id, name, is_default, is_human, created_at, updated_at FROM coaches +` + +func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { + rows, err := q.db.QueryContext(ctx, getCoaches) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Coach + for rows.Next() { + var i Coach + if err := rows.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.IsHuman, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getDefaultCoach = `-- name: GetDefaultCoach :one +SELECT id, name, is_default, is_human, created_at, updated_at FROM coaches WHERE is_default = true LIMIT 1 +` + +func (q *Queries) GetDefaultCoach(ctx context.Context) (Coach, error) { + row := q.db.QueryRowContext(ctx, getDefaultCoach) + var i Coach + err := row.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.IsHuman, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getTeam = `-- name: GetTeam :one +SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams WHERE id = ? +` + +func (q *Queries) GetTeam(ctx context.Context, id int64) (Team, error) { + row := q.db.QueryRowContext(ctx, getTeam, id) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.CoachID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getTeamByCoachID = `-- name: GetTeamByCoachID :one +SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams WHERE coach_id = ? LIMIT 1 +` + +func (q *Queries) GetTeamByCoachID(ctx context.Context, coachID sql.NullInt64) (Team, error) { + row := q.db.QueryRowContext(ctx, getTeamByCoachID, coachID) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.CoachID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getTeamMembers = `-- name: GetTeamMembers :many +SELECT id, name, team_id, created_at, updated_at FROM players WHERE team_id = ? +` + +func (q *Queries) GetTeamMembers(ctx context.Context, teamID sql.NullInt64) ([]Player, error) { + rows, err := q.db.QueryContext(ctx, getTeamMembers, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Player + for rows.Next() { + var i Player + if err := rows.Scan( + &i.ID, + &i.Name, + &i.TeamID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getTeams = `-- name: GetTeams :many +SELECT id, name, is_default, coach_id, created_at, updated_at FROM teams +` + +func (q *Queries) GetTeams(ctx context.Context) ([]Team, error) { + rows, err := q.db.QueryContext(ctx, getTeams) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Team + for rows.Next() { + var i Team + if err := rows.Scan( + &i.ID, + &i.Name, + &i.IsDefault, + &i.CoachID, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const setDefaultCoach = `-- name: SetDefaultCoach :exec +UPDATE coaches SET is_default = true WHERE id = ? +` + +func (q *Queries) SetDefaultCoach(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, setDefaultCoach, id) + return err +} + +const setDefaultTeam = `-- name: SetDefaultTeam :exec +UPDATE teams SET is_default = true WHERE id = ? +` + +func (q *Queries) SetDefaultTeam(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, setDefaultTeam, id) + return err +} + +const updatePlayer = `-- name: UpdatePlayer :exec +UPDATE players SET name = ? WHERE id = ? +` + +type UpdatePlayerParams struct { + Name string + ID int64 +} + +func (q *Queries) UpdatePlayer(ctx context.Context, arg UpdatePlayerParams) error { + _, err := q.db.ExecContext(ctx, updatePlayer, arg.Name, arg.ID) + return err +} diff --git a/internal/games/game.go b/internal/games/game.go new file mode 100644 index 0000000..2ff84cf --- /dev/null +++ b/internal/games/game.go @@ -0,0 +1,138 @@ +package games + +import ( + "database/sql" + "encoding/json" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" +) + +func generateRounds(teamA TeamConfig, teamB TeamConfig) [10]Round { + rounds := [10]Round{} + + for i := range rounds { + r := NewRound() + + r.FillSquad( + LanesConfig{ + TeamID: teamA.TeamID, + Lane1: teamA.Formations[i].Lane1, + Lane2: teamA.Formations[i].Lane2, + Lane3: teamA.Formations[i].Lane3, + }, + LanesConfig{ + TeamID: teamB.TeamID, + Lane1: teamB.Formations[i].Lane1, + Lane2: teamB.Formations[i].Lane2, + Lane3: teamB.Formations[i].Lane3, + }, + ) + + rounds[i] = r + } + + return rounds +} + +func (g *Game) ResolveRound(roll RollFn) (Result, error) { + if g.currentRound <= 0 || g.currentRound >= int64(len(g.rounds)) { + return Result{}, ErrNoRounds + } + + round := g.rounds[int(g.currentRound)] + result := round.ResolveLanes(roll) + + g.results = append(g.results, result) + g.currentRound++ + + return result, nil +} + +func (g *Game) IsGameComplete() bool { + return g.currentRound >= int64(len(g.rounds)) +} + +func fromGameModel(m database.Game) (Game, error) { + + var rounds [10]Round + if err := json.Unmarshal(m.Rounds, &rounds); err != nil { + return Game{}, fmt.Errorf("failed to unmarshal game model: %w", err) + } + + var results []Result + if err := json.Unmarshal(m.ResultsLog, &results); err != nil { + return Game{}, fmt.Errorf("failed to unmarshal results log: %w", err) + } + + return Game{ + id: m.ID, + name: m.Name, + tournamentID: &m.TournamentID.Int64, + teamA: m.TeamA.Int64, + teamB: m.TeamB.Int64, + winner: &m.Winner.Int64, + status: GameStatus(m.Status), + rounds: rounds, + currentRound: m.CurrentRound, + results: results, + createdAt: m.CreatedAt.Time, + updatedAt: m.UpdatedAt.Time, + }, nil +} + +func toGameModel(g Game) (database.Game, error) { + resolvedTournamentID := sql.NullInt64{} + if g.tournamentID != nil { + resolvedTournamentID = sql.NullInt64{ + Int64: *g.tournamentID, + Valid: true, + } + } + + resolvedWinner := sql.NullInt64{} + if g.winner != nil { + resolvedWinner = sql.NullInt64{ + Int64: *g.winner, + Valid: true, + } + } + + roundData, err := json.Marshal(g.results) + if err != nil { + return database.Game{}, fmt.Errorf("failed to marshal game model: %w", err) + } + + resultsData, err := json.Marshal(g.results) + if err != nil { + return database.Game{}, fmt.Errorf("failed to marshal results model: %w", err) + } + + return database.Game{ + ID: g.id, + Name: g.name, + TournamentID: resolvedTournamentID, + TeamA: sql.NullInt64{ + Int64: g.teamA, + Valid: true, + }, + TeamB: sql.NullInt64{ + Int64: g.teamB, + Valid: true, + }, + Winner: resolvedWinner, + Status: string(g.status), + Rounds: roundData, + CurrentRound: g.currentRound, + ResultsLog: resultsData, + CreatedAt: sql.NullTime{ + Time: g.createdAt, + Valid: true, + }, + UpdatedAt: sql.NullTime{ + Time: g.updatedAt, + Valid: true, + }, + }, nil + +} diff --git a/internal/games/interfaces.go b/internal/games/interfaces.go new file mode 100644 index 0000000..13d4887 --- /dev/null +++ b/internal/games/interfaces.go @@ -0,0 +1,13 @@ +package games + +import ( + "context" + + "github.com/code-gorilla-au/rush/internal/database" +) + +type Store interface { + CreateGame(ctx context.Context, arg database.CreateGameParams) (database.Game, error) + GetGameByID(ctx context.Context, id int64) (database.Game, error) + UpdateGame(ctx context.Context, arg database.UpdateGameParams) (database.Game, error) +} diff --git a/internal/games/rounds.go b/internal/games/rounds.go new file mode 100644 index 0000000..3845b09 --- /dev/null +++ b/internal/games/rounds.go @@ -0,0 +1,124 @@ +package games + +import "errors" + +func NewRound() Round { + return Round{ + TeamA: TeamFormation{Lanes: [3][]int{}}, + TeamB: TeamFormation{Lanes: [3][]int{}}, + } +} + +func (r *Round) FillSquad(a LanesConfig, b LanesConfig) { + r.TeamA.FillLanes(a) + r.TeamB.FillLanes(b) +} + +func (r *Round) ResolveLanes(rollFn RollFn) Result { + var result []Result + + for lane := 0; lane < len(r.TeamA.Lanes); lane++ { + laneResult := r.ResolveLane(lane, rollFn) + result = append(result, laneResult) + } + + teamA := 0 + teamAPlayers := 0 + + teamB := 0 + teamBPlayers := 0 + + for _, laneResult := range result { + if laneResult.TeamA { + teamA++ + teamAPlayers += laneResult.RemainingPlayers + } else { + teamB++ + teamBPlayers += laneResult.RemainingPlayers + } + } + + if teamAPlayers > teamBPlayers { + return Result{ + TeamA: true, + TeamB: false, + RemainingPlayers: teamAPlayers, + } + } + + return Result{ + TeamA: false, + TeamB: true, + RemainingPlayers: teamBPlayers, + } + +} + +func (r *Round) ResolveLane(lane int, rollFn RollFn) Result { + for r.TeamA.LaneHasPlayers(lane) && r.TeamB.LaneHasPlayers(lane) { + aRoll := rollFn() + bRoll := rollFn() + + if aRoll > bRoll { + _, err := r.TeamB.LanePop(lane) + if errors.Is(err, ErrNoPlayer) { + break + } + + } else if bRoll > aRoll { + _, err := r.TeamA.LanePop(lane) + if errors.Is(err, ErrNoPlayer) { + break + } + } + + } + + if r.TeamA.LaneHasPlayers(lane) { + return Result{ + TeamA: true, + TeamB: false, + RemainingPlayers: r.TeamA.LaneCount(lane), + } + } + + return Result{ + TeamA: false, + TeamB: true, + RemainingPlayers: r.TeamB.LaneCount(lane), + } + +} + +func (s *TeamFormation) LaneCount(lane int) int { + return len(s.Lanes[lane]) +} + +func (s *TeamFormation) LaneHasPlayers(lane int) bool { + return len(s.Lanes[lane]) > 0 +} + +func (s *TeamFormation) LanePop(lane int) (int, error) { + tmpLane := s.Lanes[lane] + if len(tmpLane) == 0 { + return 0, ErrNoPlayer + } + + s.Lanes[lane] = tmpLane[:len(tmpLane)-1] + + return 1, nil +} + +func (s *TeamFormation) FillLanes(f LanesConfig) { + s.TeamID = f.TeamID + + s.LaneFill(0, f.Lane1) + s.LaneFill(1, f.Lane2) + s.LaneFill(2, f.Lane3) +} + +func (s *TeamFormation) LaneFill(lane int, players int) { + for i := 0; i < players; i++ { + s.Lanes[lane] = append(s.Lanes[lane], i) + } +} diff --git a/internal/games/rounds_test.go b/internal/games/rounds_test.go new file mode 100644 index 0000000..de8d8a6 --- /dev/null +++ b/internal/games/rounds_test.go @@ -0,0 +1,233 @@ +package games + +import ( + "testing" + + "github.com/code-gorilla-au/odize" +) + +func TestResolveLane(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("Team A should win when Team B runs out of players", func(t *testing.T) { + r := &Round{ + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, + } + lane := 0 + r.TeamA.LaneFill(lane, 2) + r.TeamB.LaneFill(lane, 1) + + // Team A wins if aRoll > bRoll + rolls := []int{6, 1} // aRoll=6, bRoll=1 -> Team B pops + idx := 0 + rollFn := func() int { + val := rolls[idx] + idx++ + return val + } + + res := r.ResolveLane(lane, rollFn) + + odize.AssertTrue(t, res.TeamA) + odize.AssertFalse(t, res.TeamB) + odize.AssertEqual(t, 2, res.RemainingPlayers) + odize.AssertEqual(t, 0, r.TeamB.LaneCount(lane)) + }) + + group.Test("Team B should win when Team A runs out of players", func(t *testing.T) { + r := &Round{ + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, + } + lane := 0 + r.TeamA.LaneFill(lane, 1) + r.TeamB.LaneFill(lane, 2) + + // Team B wins if bRoll > aRoll + rolls := []int{1, 6} // aRoll=1, bRoll=6 -> Team A pops + idx := 0 + rollFn := func() int { + val := rolls[idx] + idx++ + return val + } + + res := r.ResolveLane(lane, rollFn) + + odize.AssertFalse(t, res.TeamA) + odize.AssertTrue(t, res.TeamB) + odize.AssertEqual(t, 2, res.RemainingPlayers) + odize.AssertEqual(t, 0, r.TeamA.LaneCount(lane)) + }) + + group.Test("Draw should not result in any player being eliminated", func(t *testing.T) { + r := &Round{ + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, + } + lane := 0 + r.TeamA.LaneFill(lane, 1) + r.TeamB.LaneFill(lane, 1) + + // Draw + rolls := []int{3, 3, 6, 1} // aRoll=3, bRoll=3 -> nothing; then aRoll=6, bRoll=1 -> Team B pops + idx := 0 + rollFn := func() int { + val := rolls[idx] + idx++ + return val + } + + res := r.ResolveLane(lane, rollFn) + + odize.AssertTrue(t, res.TeamA) + odize.AssertFalse(t, res.TeamB) + odize.AssertEqual(t, 1, res.RemainingPlayers) + odize.AssertEqual(t, 0, r.TeamB.LaneCount(lane)) + odize.AssertEqual(t, 1, r.TeamA.LaneCount(lane)) + }) + + group.Test("Team A starts with 0 players should lose immediately", func(t *testing.T) { + r := &Round{ + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, + } + lane := 0 + r.TeamA.LaneFill(lane, 0) + r.TeamB.LaneFill(lane, 3) + + res := r.ResolveLane(lane, func() int { return 1 }) + + odize.AssertFalse(t, res.TeamA) + odize.AssertTrue(t, res.TeamB) + odize.AssertEqual(t, 3, res.RemainingPlayers) + }) + + group.Test("Team B starts with 0 players should lose immediately", func(t *testing.T) { + r := &Round{ + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, + } + lane := 0 + r.TeamA.LaneFill(lane, 3) + r.TeamB.LaneFill(lane, 0) + + res := r.ResolveLane(lane, func() int { return 1 }) + + odize.AssertTrue(t, res.TeamA) + odize.AssertFalse(t, res.TeamB) + odize.AssertEqual(t, 3, res.RemainingPlayers) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} + +func TestResolveLanes(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("Team A should win the round if they have more remaining players across all lanes", func(t *testing.T) { + r := &Round{ + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, + } + // Lane 0: Team A wins (2 remaining) + r.TeamA.LaneFill(0, 2) + r.TeamB.LaneFill(0, 1) + + // Lane 1: Team B wins (1 remaining) + r.TeamA.LaneFill(1, 1) + r.TeamB.LaneFill(1, 2) + + // Lane 2: Team A wins (3 remaining) + r.TeamA.LaneFill(2, 3) + r.TeamB.LaneFill(2, 0) + + // Rolls for Lane 0: A(6), B(1) -> B loses 1 + // Rolls for Lane 1: A(1), B(6) -> A loses 1 + // Lane 2: No rolls needed as B has 0 players + rolls := []int{6, 1, 1, 6} + idx := 0 + rollFn := func() int { + val := rolls[idx] + idx++ + return val + } + + res := r.ResolveLanes(rollFn) + + // Team A players: Lane 0 (2), Lane 1 (0), Lane 2 (3) = 5 + // Team B players: Lane 0 (0), Lane 1 (2), Lane 2 (0) = 2 + // Total A (5) > Total B (2) -> Team A wins + odize.AssertTrue(t, res.TeamA) + odize.AssertFalse(t, res.TeamB) + odize.AssertEqual(t, 5, res.RemainingPlayers) + }) + + group.Test("Team B should win the round if they have more remaining players across all lanes", func(t *testing.T) { + r := &Round{ + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, + } + // Lane 0: Team B wins (3 remaining) + r.TeamA.LaneFill(0, 0) + r.TeamB.LaneFill(0, 3) + + // Lane 1: Team B wins (2 remaining) + r.TeamA.LaneFill(1, 1) + r.TeamB.LaneFill(1, 2) + + // Lane 2: Team A wins (1 remaining) + r.TeamA.LaneFill(2, 1) + r.TeamB.LaneFill(2, 0) + + // Rolls for Lane 1: A(1), B(6) -> A loses 1 + rolls := []int{1, 6} + idx := 0 + rollFn := func() int { + val := rolls[idx] + idx++ + return val + } + + res := r.ResolveLanes(rollFn) + + // Team A players: Lane 0 (0), Lane 1 (0), Lane 2 (1) = 1 + // Team B players: Lane 0 (3), Lane 1 (2), Lane 2 (0) = 5 + // Total B (5) > Total A (1) -> Team B wins + odize.AssertFalse(t, res.TeamA) + odize.AssertTrue(t, res.TeamB) + odize.AssertEqual(t, 5, res.RemainingPlayers) + }) + + group.Test("A tie in total remaining players should default to Team B win (or current logic)", func(t *testing.T) { + r := &Round{ + TeamA: TeamFormation{}, + TeamB: TeamFormation{}, + } + // Lane 0: Team A wins (1 remaining) + r.TeamA.LaneFill(0, 1) + r.TeamB.LaneFill(0, 0) + + // Lane 1: Team B wins (1 remaining) + r.TeamA.LaneFill(1, 0) + r.TeamB.LaneFill(1, 1) + + // Lane 2: Both empty + r.TeamA.LaneFill(2, 0) + r.TeamB.LaneFill(2, 0) + + res := r.ResolveLanes(func() int { return 1 }) + + // Total A (1) == Total B (1) + // Current logic: if teamAPlayers > teamBPlayers { A wins } else { B wins } + // So it should be Team B win. + odize.AssertFalse(t, res.TeamA) + odize.AssertTrue(t, res.TeamB) + odize.AssertEqual(t, 1, res.RemainingPlayers) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/games/service.go b/internal/games/service.go new file mode 100644 index 0000000..f523b32 --- /dev/null +++ b/internal/games/service.go @@ -0,0 +1,94 @@ +package games + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" +) + +func NewService(store Store) *Service { + return &Service{ + Store: store, + } +} + +type NewGameParams struct { + TeamA TeamConfig + TeamB TeamConfig + TournamentID *int64 +} + +func (s *Service) NewGame(ctx context.Context, params NewGameParams) (Game, error) { + resolvedTournamentID := sql.NullInt64{} + if params.TournamentID != nil { + resolvedTournamentID = sql.NullInt64{ + Int64: *params.TournamentID, + Valid: true, + } + } + + roundsJsonData, rErr := json.Marshal(generateRounds(params.TeamA, params.TeamB)) + if rErr != nil { + return Game{}, fmt.Errorf("failed to marshal rounds json data: %w", rErr) + } + + model, err := s.Store.CreateGame(ctx, database.CreateGameParams{ + Name: fmt.Sprintf("%s VS %s", params.TeamA.TeamName, params.TeamB.TeamName), + TeamA: sql.NullInt64{ + Int64: params.TeamA.TeamID, + Valid: true, + }, + TeamB: sql.NullInt64{ + Int64: params.TeamB.TeamID, + Valid: true, + }, + TournamentID: resolvedTournamentID, + ResultsLog: nil, + Rounds: roundsJsonData, + CurrentRound: 0, + }) + if err != nil { + return Game{}, fmt.Errorf("creating game: %w", err) + } + + return fromGameModel(model) +} + +func (s *Service) UpdateGame(ctx context.Context, game Game) (Game, error) { + model, err := toGameModel(game) + if err != nil { + return Game{}, fmt.Errorf("failed to convert game to model: %w", err) + } + + updated, err := s.Store.UpdateGame(ctx, database.UpdateGameParams{ + Name: model.Name, + TeamA: model.TeamA, + TeamB: model.TeamB, + Winner: model.Winner, + Status: model.Status, + ResultsLog: model.ResultsLog, + Rounds: model.Rounds, + CurrentRound: model.CurrentRound, + TournamentID: model.TournamentID, + ID: model.ID, + }) + if err != nil { + return Game{}, fmt.Errorf("updating game: %w", err) + } + + return fromGameModel(updated) +} + +func (s *Service) CompleteGame(ctx context.Context, game Game) (Game, error) { + game.status = StatusComplete + + updatedGame, err := s.UpdateGame(ctx, game) + if err != nil { + return Game{}, fmt.Errorf("completing game: %w", err) + } + + return updatedGame, nil +} diff --git a/internal/games/types.go b/internal/games/types.go new file mode 100644 index 0000000..58853ff --- /dev/null +++ b/internal/games/types.go @@ -0,0 +1,71 @@ +package games + +import ( + "errors" + "time" + + "github.com/code-gorilla-au/rush/internal/playbooks" +) + +type Service struct { + Store Store +} + +type GameStatus string + +const ( + StatusPending GameStatus = "pending" + StatusRunning GameStatus = "running" + StatusComplete GameStatus = "complete" +) + +type Game struct { + id int64 + name string + tournamentID *int64 + teamA int64 + teamB int64 + winner *int64 + status GameStatus + rounds [10]Round + currentRound int64 + results []Result + createdAt time.Time + updatedAt time.Time +} + +type Round struct { + TeamA TeamFormation + TeamB TeamFormation +} + +type TeamConfig struct { + TeamID int64 + TeamName string + Formations []playbooks.Formation +} + +type TeamFormation struct { + TeamID int64 + Lanes [3][]int +} + +type LanesConfig struct { + TeamID int64 + Lane1 int + Lane2 int + Lane3 int +} + +type Result struct { + TeamA bool + TeamB bool + RemainingPlayers int +} + +type RollFn func() int + +var ( + ErrNoPlayer = errors.New("no player left in lane") + ErrNoRounds = errors.New("no rounds left") +) diff --git a/internal/playbooks/formations.go b/internal/playbooks/formations.go new file mode 100644 index 0000000..1274f13 --- /dev/null +++ b/internal/playbooks/formations.go @@ -0,0 +1,116 @@ +package playbooks + +import "slices" + +var formations = []Formation{ + { + Name: "balanced-right", + Description: "each lane has at least one player, with centre and right having two players in each lane.", + Lane1: 1, + Lane2: 2, + Lane3: 2, + }, + { + Name: "balanced-left", + Description: "each lane has at least one player, with centre and left having two players in each lane.", + Lane1: 2, + Lane2: 2, + Lane3: 1, + }, + { + Name: "strong-right", + Description: "A strong formation with more players on the right side.", + Lane1: 1, + Lane2: 1, + Lane3: 3, + }, + { + Name: "strong-left", + Description: "A strong formation with more players on the left side.", + Lane1: 3, + Lane2: 1, + Lane3: 1, + }, + { + Name: "strong-centre", + Description: "Concentrated strength in the middle lane.", + Lane1: 1, + Lane2: 3, + Lane3: 1, + }, + { + Name: "split-balanced", + Description: "Players are split to stack team members on the outside lanes.", + Lane1: 2, + Lane2: 1, + Lane3: 2, + }, + { + Name: "split-right", + Description: "Players are stacked on the outside lanes, leaving the centre open, with a strong presence on the right flank.", + Lane1: 2, + Lane2: 0, + Lane3: 3, + }, + { + Name: "split-left", + Description: "Players are stacked on the outside lanes, leaving the centre open, with a strong presence on the left flank.", + Lane1: 3, + Lane2: 0, + Lane3: 2, + }, + { + Name: "overload-right", + Description: "Maximum pressure on the right flank, leaving the left open.", + Lane1: 0, + Lane2: 2, + Lane3: 3, + }, + { + Name: "overload-left", + Description: "Maximum pressure on the left flank, leaving the right open.", + Lane1: 3, + Lane2: 2, + Lane3: 0, + }, + { + Name: "overload-centre-left", + Description: "Heavy presence in the centre and left, leaving the right open.", + Lane1: 2, + Lane2: 3, + Lane3: 0, + }, + { + Name: "overload-centre-right", + Description: "Heavy presence in the centre and right, leaving the left open.", + Lane1: 0, + Lane2: 3, + Lane3: 2, + }, + { + Name: "single-lane-left", + Description: "All players are concentrated in the left lane.", + Lane1: 5, + Lane2: 0, + Lane3: 0, + }, + { + Name: "single-lane-centre", + Description: "All players are concentrated in the center lane.", + Lane1: 0, + Lane2: 5, + Lane3: 0, + }, + { + Name: "single-lane-right", + Description: "All players are concentrated in the right lane.", + Lane1: 0, + Lane2: 0, + Lane3: 5, + }, +} + +func Formations() []Formation { + cloned := slices.Clone(formations) + return cloned +} diff --git a/internal/playbooks/interfaces.go b/internal/playbooks/interfaces.go new file mode 100644 index 0000000..2c80399 --- /dev/null +++ b/internal/playbooks/interfaces.go @@ -0,0 +1,15 @@ +package playbooks + +import ( + "context" + "database/sql" + + "github.com/code-gorilla-au/rush/internal/database" +) + +type Store interface { + CreatePlaybook(ctx context.Context, arg database.CreatePlaybookParams) (database.Playbook, error) + DeletePlaybook(ctx context.Context, id int64) error + GetPlaybooksByTeam(ctx context.Context, teamID sql.NullInt64) ([]database.Playbook, error) + UpdatePlaybook(ctx context.Context, arg database.UpdatePlaybookParams) (database.Playbook, error) +} diff --git a/internal/playbooks/service.go b/internal/playbooks/service.go new file mode 100644 index 0000000..45ff9b1 --- /dev/null +++ b/internal/playbooks/service.go @@ -0,0 +1,89 @@ +package playbooks + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" +) + +type Service struct { + store Store +} + +func NewPlaybooksService(store Store) *Service { + return &Service{store: store} +} + +type PlaybookParams struct { + TeamID int64 + Name string + Description string + Formations []Formation +} + +func (s *Service) CreatePlaybook(ctx context.Context, params PlaybookParams) (Playbook, error) { + + data, err := json.Marshal(params.Formations) + if err != nil { + return Playbook{}, fmt.Errorf("failed to marshal formations: %w", err) + } + + model, err := s.store.CreatePlaybook(ctx, database.CreatePlaybookParams{ + Name: params.Name, + Description: sql.NullString{ + String: params.Description, + Valid: true, + }, + Formations: data, + TeamID: sql.NullInt64{ + Int64: params.TeamID, + Valid: true, + }, + }) + if err != nil { + return Playbook{}, err + } + + return fromPlaybookModel(model) +} + +func (s *Service) GetTeamPlaybooks(ctx context.Context, teamID int64) ([]Playbook, error) { + models, err := s.store.GetPlaybooksByTeam(ctx, sql.NullInt64{ + Valid: true, + Int64: teamID, + }) + if err != nil { + return nil, err + } + + return fromPlaybookModels(models) +} + +func (s *Service) DeletePlaybook(ctx context.Context, id int64) error { + return s.store.DeletePlaybook(ctx, id) +} + +func (s *Service) UpdatePlaybook(ctx context.Context, id int64, params PlaybookParams) (Playbook, error) { + data, err := json.Marshal(params.Formations) + if err != nil { + return Playbook{}, fmt.Errorf("failed to marshal formations: %w", err) + } + + model, err := s.store.UpdatePlaybook(ctx, database.UpdatePlaybookParams{ + ID: id, + Name: params.Name, + Description: sql.NullString{ + String: params.Description, + Valid: true, + }, + Formations: data, + }) + if err != nil { + return Playbook{}, err + } + + return fromPlaybookModel(model) +} diff --git a/internal/playbooks/service_test.go b/internal/playbooks/service_test.go new file mode 100644 index 0000000..9c73ad2 --- /dev/null +++ b/internal/playbooks/service_test.go @@ -0,0 +1,149 @@ +package playbooks + +import ( + "database/sql" + "testing" + + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/database" + _ "modernc.org/sqlite" +) + +func setupTestDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite", ":memory:") + odize.AssertNoError(t, err) + + migrator := database.NewMigrator(db, database.SchemaFS) + err = migrator.Migrate(t.Context()) + odize.AssertNoError(t, err) + + return db +} + +func TestService(t *testing.T) { + group := odize.NewGroup(t, nil) + + var db *sql.DB + var queries *database.Queries + var s *Service + + group.BeforeEach(func() { + db = setupTestDB(t) + queries = database.New(db) + s = NewPlaybooksService(queries) + }) + + group.AfterEach(func() { + if db != nil { + db.Close() + } + }) + + err := group. + Test("CreatePlaybook should create a playbook and return it", func(t *testing.T) { + ctx := t.Context() + params := PlaybookParams{ + TeamID: 1, + Name: "Test Playbook", + Description: "Test Description", + Formations: []Formation{ + { + Name: "Formation 1", + Lane1: 1, + Lane2: 2, + Lane3: 3, + }, + }, + } + + pb, err := s.CreatePlaybook(ctx, params) + odize.AssertNoError(t, err) + odize.AssertTrue(t, pb.ID > 0) + odize.AssertEqual(t, params.Name, pb.Name) + odize.AssertEqual(t, params.Description, pb.Description) + odize.AssertEqual(t, 1, len(pb.Formations)) + odize.AssertEqual(t, "Formation 1", pb.Formations[0].Name) + }). + Test("GetTeamPlaybooks should return playbooks for a team", func(t *testing.T) { + ctx := t.Context() + + // Create a team first (using queries directly to avoid dependency on teams package) + res, err := db.ExecContext(ctx, "INSERT INTO teams (name, is_default) VALUES (?, ?)", "Team 1", true) + odize.AssertNoError(t, err) + teamID, err := res.LastInsertId() + odize.AssertNoError(t, err) + + // Create a playbook for this team + params := PlaybookParams{ + TeamID: teamID, + Name: "Team Playbook", + Formations: []Formation{{Name: "F1"}}, + } + + _, err = s.CreatePlaybook(ctx, params) + odize.AssertNoError(t, err) + + playbooks, err := s.GetTeamPlaybooks(ctx, teamID) + odize.AssertNoError(t, err) + odize.AssertEqual(t, 1, len(playbooks)) + odize.AssertEqual(t, "Team Playbook", playbooks[0].Name) + }). + Test("DeletePlaybook should delete a playbook", func(t *testing.T) { + ctx := t.Context() + params := PlaybookParams{ + TeamID: 1, + Name: "To Be Deleted", + Formations: []Formation{{Name: "F1"}}, + } + + pb, err := s.CreatePlaybook(ctx, params) + odize.AssertNoError(t, err) + + err = s.DeletePlaybook(ctx, pb.ID) + odize.AssertNoError(t, err) + + // Verify it is deleted + playbooks, err := s.GetTeamPlaybooks(ctx, 1) + odize.AssertNoError(t, err) + + found := false + for _, p := range playbooks { + if p.ID == pb.ID { + found = true + break + } + } + odize.AssertFalse(t, found) + }). + Test("UpdatePlaybookFormations should update formations", func(t *testing.T) { + ctx := t.Context() + params := PlaybookParams{ + TeamID: 1, + Name: "To Be Updated", + Formations: []Formation{{Name: "F1"}}, + } + + pb, err := s.CreatePlaybook(ctx, params) + odize.AssertNoError(t, err) + + newFormations := []Formation{ + { + Name: "New Formation", + Lane1: 5, + }, + } + + updatedPb, err := s.UpdatePlaybook(ctx, pb.ID, PlaybookParams{ + Name: pb.Name, + Formations: newFormations, + }) + odize.AssertNoError(t, err) + odize.AssertEqual(t, pb.ID, updatedPb.ID) + odize.AssertEqual(t, 1, len(updatedPb.Formations)) + odize.AssertEqual(t, "New Formation", updatedPb.Formations[0].Name) + odize.AssertEqual(t, 5, updatedPb.Formations[0].Lane1) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/playbooks/transforms.go b/internal/playbooks/transforms.go new file mode 100644 index 0000000..0d45b26 --- /dev/null +++ b/internal/playbooks/transforms.go @@ -0,0 +1,45 @@ +package playbooks + +import ( + "encoding/json" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" +) + +func fromFormationJSON(j interface{}) ([]Formation, error) { + var f []Formation + if err := json.Unmarshal(j.([]byte), &f); err != nil { + return []Formation{}, fmt.Errorf("failed to unmarshal formation: %w", err) + } + + return f, nil +} + +func fromPlaybookModel(model database.Playbook) (Playbook, error) { + formations, err := fromFormationJSON(model.Formations) + if err != nil { + return Playbook{}, fmt.Errorf("failed to unmarshal formations: %w", err) + } + + return Playbook{ + ID: model.ID, + Name: model.Name, + Description: model.Description.String, + Formations: formations, + }, nil +} + +func fromPlaybookModels(models []database.Playbook) ([]Playbook, error) { + playbooks := make([]Playbook, len(models)) + + for i, model := range models { + playbook, err := fromPlaybookModel(model) + if err != nil { + return nil, err + } + playbooks[i] = playbook + } + + return playbooks, nil +} diff --git a/internal/playbooks/types.go b/internal/playbooks/types.go new file mode 100644 index 0000000..87fb20d --- /dev/null +++ b/internal/playbooks/types.go @@ -0,0 +1,21 @@ +package playbooks + +import "time" + +type Playbook struct { + ID int64 + Name string + TeamID int64 + Description string + Formations []Formation + CreatedAt time.Time + UpdatedAt time.Time +} + +type Formation struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Lane1 int `json:"lane_1,omitempty"` + Lane2 int `json:"lane_2,omitempty"` + Lane3 int `json:"lane_3,omitempty"` +} diff --git a/internal/teams/ai_teams.go b/internal/teams/ai_teams.go new file mode 100644 index 0000000..85e1723 --- /dev/null +++ b/internal/teams/ai_teams.go @@ -0,0 +1,256 @@ +package teams + +import ( + "context" + "fmt" + + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/go-faker/faker/v4" +) + +const totalTeams = 12 + +type AIGenerationParams struct { + CoachName string `faker:"name"` + TeamName string `faker:"username"` + Persona string + Formations []playbooks.Formation +} + +var personas = []struct { + Name string + FormationNames []string +}{ + { + Name: "The Wall - A defensive specialist that focuses on protecting all lanes equally.", + FormationNames: []string{ + "balanced-right", "balanced-left", "split-balanced", + "strong-centre", "strong-right", "strong-left", + "overload-centre-left", "overload-centre-right", "balanced-right", "balanced-left", + }, + }, + { + Name: "Blitzkrieg - Aggressive tactics focusing on overwhelming the opponent through the center.", + FormationNames: []string{ + "strong-centre", "single-lane-centre", "overload-centre-left", + "overload-centre-right", "balanced-left", "balanced-right", + "strong-centre", "single-lane-centre", "strong-centre", "split-balanced", + }, + }, + { + Name: "Flank Specialist - Prefers to attack from the edges, leaving the middle open.", + FormationNames: []string{ + "split-right", "split-left", "overload-right", + "overload-left", "split-balanced", "balanced-right", + "balanced-left", "split-right", "split-left", "overload-right", + }, + }, + { + Name: "Right Side Powerhouse - Concentrates most of the strength on the right flank.", + FormationNames: []string{ + "strong-right", "overload-right", "single-lane-right", + "balanced-right", "overload-centre-right", "split-right", + "strong-right", "overload-right", "single-lane-right", "balanced-right", + }, + }, + { + Name: "Left Side Powerhouse - Concentrates most of the strength on the left flank.", + FormationNames: []string{ + "strong-left", "overload-left", "single-lane-left", + "balanced-left", "overload-centre-left", "split-left", + "strong-left", "overload-left", "single-lane-left", "balanced-left", + }, + }, + { + Name: "The Juggernaut - Heavy focus on one single lane to break through.", + FormationNames: []string{ + "single-lane-left", "single-lane-centre", "single-lane-right", + "overload-left", "overload-right", "strong-centre", + "single-lane-left", "single-lane-centre", "single-lane-right", "split-balanced", + }, + }, + { + Name: "Balanced Strategist - Adapts to the game with a mix of balanced formations.", + FormationNames: []string{ + "balanced-right", "balanced-left", "split-balanced", + "strong-centre", "strong-right", "strong-left", + "split-right", "split-left", "balanced-right", "balanced-left", + }, + }, + { + Name: "Chaos Theory - Uses unpredictable and highly skewed formations.", + FormationNames: []string{ + "overload-centre-left", "overload-centre-right", "split-right", + "split-left", "overload-left", "overload-right", + "single-lane-left", "single-lane-right", "overload-centre-left", "overload-centre-right", + }, + }, + { + Name: "The Shield - Focuses on heavy central defense and slight flank pressure.", + FormationNames: []string{ + "strong-centre", "overload-centre-left", "overload-centre-right", + "balanced-right", "balanced-left", "split-balanced", + "strong-centre", "overload-centre-left", "overload-centre-right", "strong-centre", + }, + }, + { + Name: "Centrist - Always stays in the middle, daring the opponent to go around.", + FormationNames: []string{ + "strong-centre", "single-lane-centre", "balanced-right", + "balanced-left", "overload-centre-left", "overload-centre-right", + "strong-centre", "single-lane-centre", "strong-centre", "single-lane-centre", + }, + }, + { + Name: "Dual Flanker - Strong presence on both left and right, ignoring the center.", + FormationNames: []string{ + "split-balanced", "split-right", "split-left", + "overload-right", "overload-left", "balanced-right", + "balanced-left", "split-balanced", "split-right", "split-left", + }, + }, + { + Name: "Overload Master - Specializes in shifting maximum weight to one side or the other.", + FormationNames: []string{ + "overload-right", "overload-left", "overload-centre-left", + "overload-centre-right", "single-lane-left", "single-lane-right", + "overload-right", "overload-left", "overload-centre-left", "overload-centre-right", + }, + }, +} + +type AITeam struct { + Coach Coach + Team Team + Playbook playbooks.Playbook +} + +func (s *Service) HasAICoaches(ctx context.Context) (bool, error) { + aiCoaches, err := s.store.GetAICoaches(ctx) + if err != nil { + return false, fmt.Errorf("listing coaches: %w", err) + } + + return len(aiCoaches) > 0, nil +} + +func (s *Service) ListAITeams(ctx context.Context) ([]AITeam, error) { + aiCoaches, err := s.store.GetAICoaches(ctx) + if err != nil { + return nil, fmt.Errorf("listing coaches: %w", err) + } + + aiTeams := make([]AITeam, len(aiCoaches)) + for i, coach := range aiCoaches { + aiTeam, tErr := s.GetAITeam(ctx, fromCoachModel(coach)) + if tErr != nil { + return nil, fmt.Errorf("getting team: %w", tErr) + } + + aiTeams[i] = aiTeam + } + + return aiTeams, nil +} + +func (s *Service) GetAITeam(ctx context.Context, coach Coach) (AITeam, error) { + aiTeam, tErr := s.GetTeamByCoachID(ctx, coach.ID) + if tErr != nil { + return AITeam{}, fmt.Errorf("GetAITeam: %w", tErr) + } + + aiPlaybook, pErr := s.playbookSvc.GetTeamPlaybooks(ctx, aiTeam.ID) + if pErr != nil { + return AITeam{}, fmt.Errorf("GetAITeam playbooks: %w", pErr) + } + + return AITeam{ + Coach: coach, + Team: aiTeam, + Playbook: aiPlaybook[0], + }, nil +} + +func (s *Service) ListAICoaches(ctx context.Context) ([]Coach, error) { + models, err := s.store.GetAICoaches(ctx) + if err != nil { + return nil, fmt.Errorf("listing ai coaches: %w", err) + } + + return fromCoachesModel(models), nil +} + +func (s *Service) GenerateAITeams(ctx context.Context) error { + aiTeams, err := generateAITeams() + if err != nil { + return fmt.Errorf("generating AI teams: %w", err) + } + + for _, team := range aiTeams { + if err = s.generateTeam(ctx, team); err != nil { + return fmt.Errorf("generating team: %w", err) + } + } + + return nil +} + +func (s *Service) generateTeam(ctx context.Context, team AIGenerationParams) error { + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: team.CoachName, + IsHuman: false, + IsDefault: false, + }) + if err != nil { + return fmt.Errorf("creating AI coach: %w", err) + } + + aiTeam, err := s.CreateTeam(ctx, team.TeamName, coach.ID, true) + if err != nil { + return fmt.Errorf("creating team: %w", err) + } + + if _, err = s.playbookSvc.CreatePlaybook(ctx, playbooks.PlaybookParams{ + TeamID: aiTeam.ID, + Name: fmt.Sprintf("%s Playbook", aiTeam.Name), + Description: team.Persona, + Formations: team.Formations, + }); err != nil { + return fmt.Errorf("creating AI playbook: %w", err) + } + + return nil +} + +func generateAITeams() ([]AIGenerationParams, error) { + aiTeams := make([]AIGenerationParams, totalTeams) + allFormations := playbooks.Formations() + formationMap := make(map[string]playbooks.Formation) + for _, f := range allFormations { + formationMap[f.Name] = f + } + + for i := 0; i < totalTeams; i++ { + tmpTeam := AIGenerationParams{} + + if err := faker.FakeData(&tmpTeam); err != nil { + return nil, fmt.Errorf("generating team: %w", err) + } + + persona := personas[i%len(personas)] + tmpTeam.Persona = persona.Name + + teamFormations := make([]playbooks.Formation, 0, len(persona.FormationNames)) + for _, name := range persona.FormationNames { + if f, ok := formationMap[name]; ok { + teamFormations = append(teamFormations, f) + } + } + + tmpTeam.Formations = teamFormations + + aiTeams[i] = tmpTeam + } + + return aiTeams, nil +} diff --git a/internal/teams/ai_teams_test.go b/internal/teams/ai_teams_test.go new file mode 100644 index 0000000..691008c --- /dev/null +++ b/internal/teams/ai_teams_test.go @@ -0,0 +1,185 @@ +package teams + +import ( + "context" + "errors" + "testing" + + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/playbooks" +) + +type mockTeamCreator struct { + createCoachFunc func(ctx context.Context, params CreateCoachParams) (Coach, error) + listAICoachesFunc func(ctx context.Context) ([]Coach, error) + getTeamByCoachIDFunc func(ctx context.Context, id int64) (Team, error) + createTeamFunc func(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) +} + +func (m *mockTeamCreator) CreateCoach(ctx context.Context, params CreateCoachParams) (Coach, error) { + return m.createCoachFunc(ctx, params) +} + +func (m *mockTeamCreator) ListAICoaches(ctx context.Context) ([]Coach, error) { + return m.listAICoachesFunc(ctx) +} + +func (m *mockTeamCreator) GetTeamByCoachID(ctx context.Context, id int64) (Team, error) { + return m.getTeamByCoachIDFunc(ctx, id) +} + +func (m *mockTeamCreator) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { + return m.createTeamFunc(ctx, name, coachID, isDefault) +} + +type mockStore struct { + Store + createCoachFunc func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) + createTeamFunc func(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) + createPlayerFunc func(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) + getAICoachesFunc func(ctx context.Context) ([]database.Coach, error) +} + +func (m *mockStore) CreateCoach(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { + return m.createCoachFunc(ctx, arg) +} + +func (m *mockStore) CreateTeam(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) { + return m.createTeamFunc(ctx, arg) +} + +func (m *mockStore) CreatePlayer(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) { + return m.createPlayerFunc(ctx, arg) +} + +func (m *mockStore) GetAICoaches(ctx context.Context) ([]database.Coach, error) { + return m.getAICoachesFunc(ctx) +} + +type mockPlaybookCreator struct { + createPlaybookFunc func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) + getTeamPlaybooksFunc func(ctx context.Context, teamID int64) ([]playbooks.Playbook, error) +} + +func (m *mockPlaybookCreator) CreatePlaybook(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { + return m.createPlaybookFunc(ctx, params) +} + +func (m *mockPlaybookCreator) GetTeamPlaybooks(ctx context.Context, teamID int64) ([]playbooks.Playbook, error) { + return m.getTeamPlaybooksFunc(ctx, teamID) +} + +func TestGenerateAITeams(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should generate exactly 12 teams with personas and formations", func(t *testing.T) { + _teams, err := generateAITeams() + odize.AssertNoError(t, err) + odize.AssertTrue(t, len(_teams) == 12) + + for i, team := range _teams { + odize.AssertTrue(t, team.Persona != "") + odize.AssertTrue(t, len(team.Formations) == 10) + + // Verify personas are assigned in order + expectedPersona := personas[i%len(personas)].Name + odize.AssertTrue(t, team.Persona == expectedPersona) + } + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestTeamsService_GenerateTeams(t *testing.T) { + group := odize.NewGroup(t, nil) + + var mStore *mockStore + var mockPlaybooks *mockPlaybookCreator + var svc *Service + + group.BeforeEach(func() { + mStore = &mockStore{} + mockPlaybooks = &mockPlaybookCreator{} + svc = &Service{ + store: mStore, + playbookSvc: mockPlaybooks, + } + }) + + err := group. + Test("should successfully generate all teams", func(t *testing.T) { + var coachCount, teamCount, playbookCount, playerCount int + + mStore.createCoachFunc = func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { + coachCount++ + return database.Coach{ID: int64(coachCount), Name: arg.Name}, nil + } + mStore.createTeamFunc = func(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) { + teamCount++ + return database.Team{ID: int64(teamCount), Name: arg.Name}, nil + } + mStore.createPlayerFunc = func(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) { + playerCount++ + return database.Player{ID: int64(playerCount), Name: arg.Name}, nil + } + mockPlaybooks.createPlaybookFunc = func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { + playbookCount++ + return playbooks.Playbook{ID: int64(playbookCount), Name: params.Name}, nil + } + + err := svc.GenerateAITeams(t.Context()) + odize.AssertNoError(t, err) + odize.AssertEqual(t, totalTeams, coachCount) + odize.AssertEqual(t, totalTeams, teamCount) + odize.AssertEqual(t, totalTeams, playbookCount) + odize.AssertEqual(t, totalTeams*5, playerCount) + }). + Test("should return error when CreateCoach fails", func(t *testing.T) { + expectedErr := errors.New("coach error") + mStore.createCoachFunc = func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { + return database.Coach{}, expectedErr + } + + err := svc.GenerateAITeams(t.Context()) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, expectedErr)) + }). + Test("should return error when CreateTeam fails", func(t *testing.T) { + expectedErr := errors.New("team error") + mStore.createCoachFunc = func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { + return database.Coach{ID: 1}, nil + } + mStore.createTeamFunc = func(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) { + return database.Team{}, expectedErr + } + + err := svc.GenerateAITeams(t.Context()) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, expectedErr)) + }). + Test("should return error when CreatePlaybook fails", func(t *testing.T) { + expectedErr := errors.New("playbook error") + mStore.createCoachFunc = func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { + return database.Coach{ID: 1}, nil + } + mStore.createTeamFunc = func(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) { + return database.Team{ID: 1, Name: arg.Name}, nil + } + mStore.createPlayerFunc = func(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) { + return database.Player{}, nil + } + mockPlaybooks.createPlaybookFunc = func(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) { + return playbooks.Playbook{}, expectedErr + } + + err := svc.GenerateAITeams(t.Context()) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, expectedErr)) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/teams/interfaces.go b/internal/teams/interfaces.go new file mode 100644 index 0000000..fd83d30 --- /dev/null +++ b/internal/teams/interfaces.go @@ -0,0 +1,46 @@ +package teams + +import ( + "context" + "database/sql" + + "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/playbooks" +) + +type Store interface { + PlayerStore + TeamStore + CoachStore +} + +type CoachStore interface { + GetDefaultCoach(ctx context.Context) (database.Coach, error) + ClearDefaultCoach(ctx context.Context) error + CreateCoach(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) + GetCoaches(ctx context.Context) ([]database.Coach, error) + GetAICoaches(ctx context.Context) ([]database.Coach, error) + SetDefaultCoach(ctx context.Context, id int64) error + SetDefaultTeam(ctx context.Context, id int64) error +} + +type PlayerStore interface { + CreatePlayer(ctx context.Context, arg database.CreatePlayerParams) (database.Player, error) + GetTeamMembers(ctx context.Context, teamID sql.NullInt64) ([]database.Player, error) + UpdatePlayer(ctx context.Context, arg database.UpdatePlayerParams) error +} + +type TeamStore interface { + CreateTeam(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) + GetTeamByCoachID(ctx context.Context, coachID sql.NullInt64) (database.Team, error) + DeleteTeam(ctx context.Context, id int64) error + SetDefaultTeam(ctx context.Context, id int64) error + ClearDefaultTeam(ctx context.Context) error +} + +var _ Store = (*database.Queries)(nil) + +type PlaybookCreator interface { + CreatePlaybook(ctx context.Context, params playbooks.PlaybookParams) (playbooks.Playbook, error) + GetTeamPlaybooks(ctx context.Context, teamID int64) ([]playbooks.Playbook, error) +} diff --git a/internal/teams/service.go b/internal/teams/service.go new file mode 100644 index 0000000..365bc4e --- /dev/null +++ b/internal/teams/service.go @@ -0,0 +1,174 @@ +package teams + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" +) + +type Service struct { + store Store + playbookSvc PlaybookCreator +} + +func NewTeamsService(store Store, playbookSvc PlaybookCreator) *Service { + return &Service{ + store: store, + playbookSvc: playbookSvc, + } +} + +type CreateCoachParams struct { + Name string + IsHuman bool + IsDefault bool +} + +func (s *Service) CreateCoach(ctx context.Context, params CreateCoachParams) (Coach, error) { + model, err := s.store.CreateCoach(ctx, database.CreateCoachParams{ + Name: params.Name, + IsHuman: sql.NullBool{ + Bool: params.IsHuman, + Valid: true, + }, + IsDefault: sql.NullBool{ + Bool: params.IsDefault, + Valid: true, + }, + }) + if err != nil { + return Coach{}, fmt.Errorf("creating coach: %w", err) + } + + return fromCoachModel(model), nil +} + +func (s *Service) GetDefaultCoach(ctx context.Context) (Coach, error) { + model, err := s.store.GetDefaultCoach(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Coach{}, ErrCoachNotFound + } + + return Coach{}, fmt.Errorf("getting default coach: %w", err) + } + + return fromCoachModel(model), nil +} + +func (s *Service) GetTeamAndPlaybooksByCoachID(ctx context.Context, id int64) (TeamWithPlaybooks, error) { + team, err := s.GetTeamByCoachID(ctx, id) + if err != nil { + return TeamWithPlaybooks{}, err + } + + playbooks, err := s.playbookSvc.GetTeamPlaybooks(ctx, team.ID) + if err != nil { + return TeamWithPlaybooks{}, err + } + + return TeamWithPlaybooks{ + Playbooks: playbooks, + Team: team, + }, nil +} + +func (s *Service) GetTeamByCoachID(ctx context.Context, id int64) (Team, error) { + model, err := s.store.GetTeamByCoachID(ctx, sql.NullInt64{ + Valid: true, + Int64: id, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Team{}, ErrTeamNotFound + } + + return Team{}, fmt.Errorf("GetTeamByCoachID: %w", err) + } + + pModel, err := s.store.GetTeamMembers(ctx, sql.NullInt64{ + Int64: model.ID, + Valid: true, + }) + if err != nil { + return Team{}, fmt.Errorf("GetTeamByCoachID team members: %w", err) + } + + return fromTeamModel(model, pModel), nil +} + +func (s *Service) CreateTeam(ctx context.Context, name string, coachID int64, isDefault bool) (Team, error) { + model, err := s.store.CreateTeam(ctx, database.CreateTeamParams{ + Name: name, + IsDefault: sql.NullBool{ + Bool: isDefault, + Valid: true, + }, + CoachID: sql.NullInt64{ + Int64: coachID, + Valid: true, + }, + }) + if err != nil { + return Team{}, fmt.Errorf("creating team %s: %w", name, err) + } + + playersModel, err := s.createPlayers(ctx, model.ID) + if err != nil { + return Team{}, fmt.Errorf("creating players: %w", err) + } + + return fromTeamModel(model, playersModel), nil +} + +func (s *Service) createPlayers(ctx context.Context, teamID int64) ([]database.Player, error) { + modelPlayers := make([]database.Player, 5) + + for i := 0; i < 5; i++ { + model, err := s.store.CreatePlayer(ctx, database.CreatePlayerParams{ + Name: "Player " + fmt.Sprint(i+1), + TeamID: sql.NullInt64{ + Int64: teamID, + Valid: true, + }, + }) + if err != nil { + return modelPlayers, fmt.Errorf("creating player: %w", err) + } + + modelPlayers[i] = model + } + + return modelPlayers, nil +} + +func (s *Service) SetDefaultTeam(ctx context.Context, id int64) error { + return s.store.SetDefaultTeam(ctx, id) +} + +func (s *Service) UpdatePlayer(ctx context.Context, id int64, name string) error { + err := s.store.UpdatePlayer(ctx, database.UpdatePlayerParams{ + ID: id, + Name: name, + }) + if err != nil { + return fmt.Errorf("updating player: %w", err) + } + + return nil +} + +func (s *Service) ClearDefaultTeam(ctx context.Context) error { + return s.store.ClearDefaultTeam(ctx) +} + +func (s *Service) SetDefaultCoach(ctx context.Context, id int64) error { + return s.store.SetDefaultCoach(ctx, id) +} + +func (s *Service) ClearDefaultCoach(ctx context.Context) error { + return s.store.ClearDefaultCoach(ctx) +} diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go new file mode 100644 index 0000000..70362df --- /dev/null +++ b/internal/teams/service_test.go @@ -0,0 +1,230 @@ +package teams + +import ( + "database/sql" + "errors" + "testing" + + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/playbooks" + _ "modernc.org/sqlite" +) + +func setupTestDB(t *testing.T) *sql.DB { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + + migrator := database.NewMigrator(db, database.SchemaFS) + if err := migrator.Migrate(t.Context()); err != nil { + t.Fatalf("failed to migrate database: %v", err) + } + + return db +} + +func TestService(t *testing.T) { + group := odize.NewGroup(t, nil) + + var db *sql.DB + var queries *database.Queries + var s *Service + + group.BeforeEach(func() { + db = setupTestDB(t) + queries = database.New(db) + s = NewTeamsService(queries, playbooks.NewPlaybooksService(queries)) + }) + + group.AfterEach(func() { + if db != nil { + db.Close() + } + }) + + err := group. + Test("CreateCoach should create a coach and return it", func(t *testing.T) { + ctx := t.Context() + name := "Coach Carter" + + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: name, + IsHuman: false, + }) + odize.AssertNoError(t, err) + odize.AssertTrue(t, coach.ID > 0) + odize.AssertEqual(t, name, coach.Name) + odize.AssertFalse(t, coach.CreatedAt.IsZero()) + odize.AssertFalse(t, coach.UpdatedAt.IsZero()) + }). + Test("SetDefaultCoach should set the default coach", func(t *testing.T) { + ctx := t.Context() + name := "Coach Carter" + + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: name, + IsHuman: false, + }) + odize.AssertNoError(t, err) + + err = s.SetDefaultCoach(ctx, coach.ID) + odize.AssertNoError(t, err) + + // Verify it's set + queries := database.New(db) + model, err := queries.GetDefaultCoach(ctx) + odize.AssertNoError(t, err) + odize.AssertEqual(t, coach.ID, model.ID) + odize.AssertTrue(t, model.IsDefault.Bool) + }). + Test("ClearDefaultCoach should clear the default coach", func(t *testing.T) { + ctx := t.Context() + name := "Coach Carter" + + _, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: name, + IsDefault: true, + }) + odize.AssertNoError(t, err) + + err = s.ClearDefaultCoach(ctx) + odize.AssertNoError(t, err) + + // Verify it's cleared + queries := database.New(db) + _, err = queries.GetDefaultCoach(ctx) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, sql.ErrNoRows)) + }). + Test("SetDefaultTeam should set the default team", func(t *testing.T) { + ctx := t.Context() + queries := database.New(db) + _, err := queries.CreateTeam(ctx, database.CreateTeamParams{ + Name: "The Bulls", + }) + odize.AssertNoError(t, err) + + var teamID int64 + err = db.QueryRowContext(ctx, "SELECT id FROM teams WHERE name = ?", "The Bulls").Scan(&teamID) + odize.AssertNoError(t, err) + + err = s.SetDefaultTeam(ctx, teamID) + odize.AssertNoError(t, err) + + // Verify it's set + var isDefault bool + err = db.QueryRowContext(ctx, "SELECT is_default FROM teams WHERE id = ?", teamID).Scan(&isDefault) + odize.AssertNoError(t, err) + odize.AssertTrue(t, isDefault) + }). + Test("ClearDefaultTeam should clear the default team", func(t *testing.T) { + _, err := queries.CreateTeam(t.Context(), database.CreateTeamParams{ + Name: "The Bulls", + IsDefault: sql.NullBool{Bool: true, Valid: true}, + }) + odize.AssertNoError(t, err) + + var teamID int64 + err = db.QueryRowContext(t.Context(), "SELECT id FROM teams WHERE name = ?", "The Bulls").Scan(&teamID) + odize.AssertNoError(t, err) + + err = s.ClearDefaultTeam(t.Context()) + odize.AssertNoError(t, err) + + // Verify it's cleared + var isDefault bool + err = db.QueryRowContext(t.Context(), "SELECT is_default FROM teams WHERE id = ?", teamID).Scan(&isDefault) + odize.AssertNoError(t, err) + odize.AssertFalse(t, isDefault) + }). + Test("GetDefaultCoach should return the default coach", func(t *testing.T) { + ctx := t.Context() + name := "Coach Carter" + _, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: name, + IsDefault: true, + }) + odize.AssertNoError(t, err) + + coach, err := s.GetDefaultCoach(ctx) + odize.AssertNoError(t, err) + odize.AssertEqual(t, name, coach.Name) + }). + Test("GetDefaultCoach should return error if no default coach", func(t *testing.T) { + _, err := s.GetDefaultCoach(t.Context()) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, ErrCoachNotFound)) + }). + Test("UpdatePlayer should update player name", func(t *testing.T) { + ctx := t.Context() + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: "Coach", + IsDefault: true, + }) + odize.AssertNoError(t, err) + + team, err := s.CreateTeam(ctx, "Team", coach.ID, true) + odize.AssertNoError(t, err) + odize.AssertTrue(t, len(team.Players) > 0) + + player := team.Players[0] + newName := "Updated Name" + + err = s.UpdatePlayer(ctx, player.ID, newName) + odize.AssertNoError(t, err) + + // Verify + members, err := queries.GetTeamMembers(ctx, sql.NullInt64{Int64: team.ID, Valid: true}) + odize.AssertNoError(t, err) + + found := false + for _, m := range members { + if m.ID == player.ID { + odize.AssertEqual(t, newName, m.Name) + found = true + break + } + } + odize.AssertTrue(t, found) + }). + Test("GetTeamByCoachID should return team and players", func(t *testing.T) { + ctx := t.Context() + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: "Coach", + IsDefault: true, + }) + odize.AssertNoError(t, err) + + _, err = s.CreateTeam(ctx, "The Bulls", coach.ID, true) + odize.AssertNoError(t, err) + + team, err := s.GetTeamByCoachID(ctx, coach.ID) + odize.AssertNoError(t, err) + odize.AssertEqual(t, "The Bulls", team.Name) + odize.AssertEqual(t, 5, len(team.Players)) + }). + Test("GetTeamByCoachID should return error if team not found", func(t *testing.T) { + _, err := s.GetTeamByCoachID(t.Context(), 999) + odize.AssertError(t, err) + odize.AssertTrue(t, errors.Is(err, ErrTeamNotFound)) + }). + Test("CreateTeam should create a team with default players", func(t *testing.T) { + ctx := t.Context() + coach, err := s.CreateCoach(ctx, CreateCoachParams{ + Name: "Coach", + IsHuman: false, + }) + odize.AssertNoError(t, err) + + team, err := s.CreateTeam(ctx, "Lakers", coach.ID, true) + odize.AssertNoError(t, err) + odize.AssertEqual(t, "Lakers", team.Name) + odize.AssertEqual(t, 5, len(team.Players)) + odize.AssertEqual(t, "Player 1", team.Players[0].Name) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/teams/transforms.go b/internal/teams/transforms.go new file mode 100644 index 0000000..aa1aa86 --- /dev/null +++ b/internal/teams/transforms.go @@ -0,0 +1,53 @@ +package teams + +import ( + "github.com/code-gorilla-au/rush/internal/database" +) + +func fromCoachModel(m database.Coach) Coach { + return Coach{ + ID: m.ID, + Name: m.Name, + IsDefault: m.IsDefault.Bool, + IsHuman: m.IsHuman.Bool, + CreatedAt: m.CreatedAt.Time, + UpdatedAt: m.UpdatedAt.Time, + } +} + +func fromCoachesModel(m []database.Coach) []Coach { + coaches := make([]Coach, len(m)) + + for i, coach := range m { + coaches[i] = fromCoachModel(coach) + } + + return coaches +} + +func fromTeamModel(m database.Team, p []database.Player) Team { + players := make([]Player, len(p)) + + for i, player := range p { + players[i] = fromPlayerModel(player) + } + + return Team{ + ID: m.ID, + Name: m.Name, + CoachID: int(m.CoachID.Int64), + Players: players, + CreatedAt: m.CreatedAt.Time, + UpdatedAt: m.UpdatedAt.Time, + } +} + +func fromPlayerModel(m database.Player) Player { + return Player{ + ID: m.ID, + Name: m.Name, + TeamID: int(m.TeamID.Int64), + CreatedAt: m.CreatedAt.Time, + UpdatedAt: m.UpdatedAt.Time, + } +} diff --git a/internal/teams/types.go b/internal/teams/types.go new file mode 100644 index 0000000..818e3ed --- /dev/null +++ b/internal/teams/types.go @@ -0,0 +1,44 @@ +package teams + +import ( + "errors" + "time" + + "github.com/code-gorilla-au/rush/internal/playbooks" +) + +type TeamWithPlaybooks struct { + Playbooks []playbooks.Playbook `json:"playbooks,omitzero"` + Team Team `json:"team,omitzero"` +} + +type Team struct { + ID int64 `json:"id,omitzero"` + Name string `json:"name,omitzero"` + CoachID int `json:"coach_id,omitzero"` + Players []Player `json:"players,omitzero"` + CreatedAt time.Time `json:"created_at,omitzero"` + UpdatedAt time.Time `json:"updated_at,omitzero"` +} + +type Coach struct { + ID int64 `json:"id,omitzero"` + Name string `json:"name,omitzero"` + IsDefault bool `json:"is_default,omitzero"` + IsHuman bool `json:"is_human,omitzero"` + CreatedAt time.Time `json:"created_at,omitzero"` + UpdatedAt time.Time `json:"updated_at,omitzero"` +} + +type Player struct { + ID int64 `json:"id,omitzero"` + Name string `json:"name,omitzero"` + TeamID int `json:"team_id,omitzero"` + CreatedAt time.Time `json:"created_at,omitzero"` + UpdatedAt time.Time `json:"updated_at,omitzero"` +} + +var ( + ErrCoachNotFound = errors.New("coach not found") + ErrTeamNotFound = errors.New("team not found") +) diff --git a/internal/ui/app.go b/internal/ui/app.go new file mode 100644 index 0000000..223288d --- /dev/null +++ b/internal/ui/app.go @@ -0,0 +1,219 @@ +package ui + +import ( + "context" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/games" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" +) + +type MsgStateUpdated struct { + Coach *teams.Coach + Team *teams.Team +} + +type MsgSwitchPage struct { + NewPage Page + Playbook *playbooks.Playbook +} + +type Page int + +const ( + PageTitle Page = iota + 1 + PageCreateCoach + PageLockerRoom + PageLockerPlayers + PageLockerPlaybooksList + PageLockerPlaybooksCreate + PageLockerPlaybooksEdit + PageNewTournament + PageNewBattleSelection + PageTitleSettings +) + +type GlobalState struct { + Coach *teams.Coach + Team *teams.Team +} + +func (m *GlobalState) Context() context.Context { + return context.Background() +} + +type RootModel struct { + ctx context.Context + width int + height int + theme IceTheme + currentPage Page + pageTitle tea.Model + pageCreateCoach tea.Model + pageLockerRoom tea.Model + pageLockerPlayers tea.Model + pageLockerPlaybooksList tea.Model + pageLockerPlaybooksCreate tea.Model + pageLockerPlaybooksEdit tea.Model + pageNewTournament tea.Model + pageNewBattleSelection tea.Model + pageTitleSettings tea.Model + globalState *GlobalState + teamsSvc *teams.Service + playbookSvc *playbooks.Service + gameSvc *games.Service +} + +type Dependencies struct { + TeamsSvc *teams.Service + PlaybookSvc *playbooks.Service + GameSvc *games.Service +} + +// New returns a new UI model. +func New(deps Dependencies) *RootModel { + state := &GlobalState{} + + return &RootModel{ + ctx: context.Background(), + theme: NewIceTheme(), + currentPage: PageTitle, + pageTitle: NewModelTitle(state), + pageCreateCoach: NewModelCreateCoach(state, deps.TeamsSvc), + pageLockerRoom: NewModelLockerRoom(state), + pageLockerPlayers: NewModelLockerPlayers(state, deps.TeamsSvc), + pageLockerPlaybooksList: NewModelLockerPlaybooksList(state, deps.PlaybookSvc), + pageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, deps.PlaybookSvc), + pageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, deps.PlaybookSvc), + pageNewTournament: NewModelNewTournament(state), + pageNewBattleSelection: NewModelNewBattleSelection(state, deps.TeamsSvc), + pageTitleSettings: NewModelTitleSettings(state), + globalState: state, + teamsSvc: deps.TeamsSvc, + playbookSvc: deps.PlaybookSvc, + gameSvc: deps.GameSvc, + } +} + +func (m *RootModel) Init() tea.Cmd { + return func() tea.Msg { + coach, err := m.teamsSvc.GetDefaultCoach(m.ctx) + if err != nil { + return MsgStateUpdated{Coach: nil} + } + + team, err := m.teamsSvc.GetTeamByCoachID(m.ctx, coach.ID) + if err != nil { + return MsgStateUpdated{Coach: nil} + } + + return MsgStateUpdated{Coach: &coach, Team: &team} + } +} + +func (m *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + return m, tea.Quit + } + case MsgSwitchPage: + m.currentPage = msg.NewPage + var cmd tea.Cmd + switch m.currentPage { + case PageNewBattleSelection: + cmd = m.pageNewBattleSelection.Init() + } + cmds = append(cmds, cmd) + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + var cmd tea.Cmd + m.pageTitle, cmd = m.pageTitle.Update(msg) + cmds = append(cmds, cmd) + m.pageCreateCoach, cmd = m.pageCreateCoach.Update(msg) + cmds = append(cmds, cmd) + m.pageLockerRoom, cmd = m.pageLockerRoom.Update(msg) + cmds = append(cmds, cmd) + m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) + cmds = append(cmds, cmd) + m.pageLockerPlaybooksList, cmd = m.pageLockerPlaybooksList.Update(msg) + cmds = append(cmds, cmd) + m.pageLockerPlaybooksCreate, cmd = m.pageLockerPlaybooksCreate.Update(msg) + cmds = append(cmds, cmd) + m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) + cmds = append(cmds, cmd) + m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) + cmds = append(cmds, cmd) + m.pageNewBattleSelection, cmd = m.pageNewBattleSelection.Update(msg) + cmds = append(cmds, cmd) + m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + } + + var cmd tea.Cmd + switch m.currentPage { + case PageTitle: + m.pageTitle, cmd = m.pageTitle.Update(msg) + case PageCreateCoach: + m.pageCreateCoach, cmd = m.pageCreateCoach.Update(msg) + case PageLockerRoom: + m.pageLockerRoom, cmd = m.pageLockerRoom.Update(msg) + case PageLockerPlayers: + m.pageLockerPlayers, cmd = m.pageLockerPlayers.Update(msg) + case PageLockerPlaybooksList: + m.pageLockerPlaybooksList, cmd = m.pageLockerPlaybooksList.Update(msg) + case PageLockerPlaybooksCreate: + m.pageLockerPlaybooksCreate, cmd = m.pageLockerPlaybooksCreate.Update(msg) + case PageLockerPlaybooksEdit: + m.pageLockerPlaybooksEdit, cmd = m.pageLockerPlaybooksEdit.Update(msg) + case PageNewTournament: + m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) + case PageNewBattleSelection: + m.pageNewBattleSelection, cmd = m.pageNewBattleSelection.Update(msg) + case PageTitleSettings: + m.pageTitleSettings, cmd = m.pageTitleSettings.Update(msg) + } + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *RootModel) View() tea.View { + if m.width == 0 || m.height == 0 { + return tea.NewView("Initializing...") + } + + switch m.currentPage { + case PageTitle: + return m.pageTitle.View() + case PageCreateCoach: + return m.pageCreateCoach.View() + case PageLockerRoom: + return m.pageLockerRoom.View() + case PageLockerPlayers: + return m.pageLockerPlayers.View() + case PageLockerPlaybooksList: + return m.pageLockerPlaybooksList.View() + case PageLockerPlaybooksCreate: + return m.pageLockerPlaybooksCreate.View() + case PageLockerPlaybooksEdit: + return m.pageLockerPlaybooksEdit.View() + case PageNewTournament: + return m.pageNewTournament.View() + case PageNewBattleSelection: + return m.pageNewBattleSelection.View() + case PageTitleSettings: + return m.pageTitleSettings.View() + } + + return tea.NewView("unknown page") +} diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go new file mode 100644 index 0000000..57adced --- /dev/null +++ b/internal/ui/app_test.go @@ -0,0 +1,101 @@ +package ui + +import ( + "database/sql" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/games" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" +) + +func setupServices(t *testing.T) (*teams.Service, *playbooks.Service, *games.Service) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + + migrator := database.NewMigrator(db, database.SchemaFS) + if err := migrator.Migrate(t.Context()); err != nil { + t.Fatalf("failed to migrate database: %v", err) + } + + queries := database.New(db) + ps := playbooks.NewPlaybooksService(queries) + ts := teams.NewTeamsService(queries, ps) + gs := games.NewService(queries) + return ts, ps, gs +} + +func TestTheme(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("NewIceTheme should return a theme with correct colors", func(t *testing.T) { + theme := NewIceTheme() + // We can't easily check the color values from the Style object in Lipgloss v2 + // without deep inspection, but we can check if they are not empty. + odize.AssertTrue(t, theme.Logo.GetForeground() != nil) + odize.AssertTrue(t, theme.Footer.GetForeground() != nil) + odize.AssertTrue(t, theme.Base.GetBackground() != nil) + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestNew(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("New should initialize model with IceTheme", func(t *testing.T) { + s, ps, gs := setupServices(t) + m := New(Dependencies{ + TeamsSvc: s, + PlaybookSvc: ps, + GameSvc: gs, + }) + odize.AssertTrue(t, m.theme.Logo.GetForeground() != nil) + }). + Test("Init should return a command", func(t *testing.T) { + s, ps, gs := setupServices(t) + m := New(Dependencies{ + TeamsSvc: s, + PlaybookSvc: ps, + GameSvc: gs, + }) + cmd := m.Init() + odize.AssertTrue(t, cmd != nil) + }). + Test("Update should handle Quit keys", func(t *testing.T) { + s, ps, gs := setupServices(t) + m := New(Dependencies{ + TeamsSvc: s, + PlaybookSvc: ps, + GameSvc: gs, + }) + _, cmd := m.Update(tea.KeyPressMsg{Text: "q"}) + odize.AssertTrue(t, cmd != nil) + + _, cmd = m.Update(tea.KeyPressMsg{Text: "ctrl+c"}) + odize.AssertTrue(t, cmd != nil) + }). + Test("Update should handle WindowSizeMsg", func(t *testing.T) { + s, ps, gs := setupServices(t) + m := New(Dependencies{ + TeamsSvc: s, + PlaybookSvc: ps, + GameSvc: gs, + }) + newModel, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) + updatedModel := newModel.(*RootModel) + odize.AssertTrue(t, updatedModel.width == 100) + odize.AssertTrue(t, updatedModel.height == 50) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/components/coach_avatar.go b/internal/ui/components/coach_avatar.go new file mode 100644 index 0000000..5820584 --- /dev/null +++ b/internal/ui/components/coach_avatar.go @@ -0,0 +1,34 @@ +package components + +import ( + "fmt" + + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/teams" +) + +// CoachAvatar component displays team name and coach name. +type CoachAvatar struct { + Coach *teams.Coach + Team *teams.Team +} + +// NewCoachAvatar creates a new CoachAvatar component. +func NewCoachAvatar(coach *teams.Coach, team *teams.Team) CoachAvatar { + return CoachAvatar{ + Coach: coach, + Team: team, + } +} + +// View renders the CoachAvatar component. +func (c CoachAvatar) View(teamStyle lipgloss.Style, coachStyle lipgloss.Style) string { + if c.Coach == nil || c.Team == nil { + return "" + } + + teamName := teamStyle.Render(c.Team.Name) + coachName := coachStyle.Render(fmt.Sprintf("Coach: %s", c.Coach.Name)) + + return lipgloss.JoinVertical(lipgloss.Left, teamName, coachName) +} diff --git a/internal/ui/components/coach_avatar_test.go b/internal/ui/components/coach_avatar_test.go new file mode 100644 index 0000000..7d6af4e --- /dev/null +++ b/internal/ui/components/coach_avatar_test.go @@ -0,0 +1,43 @@ +package components + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/teams" +) + +func TestCoachAvatar(t *testing.T) { + group := odize.NewGroup(t, nil) + + coach := &teams.Coach{Name: "Ted Lasso"} + team := &teams.Team{Name: "AFC Richmond"} + teamStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#0000FF")) + coachStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + + err := group. + Test("NewCoachAvatar should initialize with given coach and team", func(t *testing.T) { + avatar := NewCoachAvatar(coach, team) + odize.AssertEqual(t, coach, avatar.Coach) + odize.AssertEqual(t, team, avatar.Team) + }). + Test("View should render team and coach names when both are present", func(t *testing.T) { + avatar := NewCoachAvatar(coach, team) + rendered := avatar.View(teamStyle, coachStyle) + + odize.AssertTrue(t, strings.Contains(rendered, "AFC Richmond")) + odize.AssertTrue(t, strings.Contains(rendered, "Coach: Ted Lasso")) + }). + Test("View should return empty string if coach or team is nil", func(t *testing.T) { + avatarNoCoach := NewCoachAvatar(nil, team) + odize.AssertEqual(t, "", avatarNoCoach.View(teamStyle, coachStyle)) + + avatarNoTeam := NewCoachAvatar(coach, nil) + odize.AssertEqual(t, "", avatarNoTeam.View(teamStyle, coachStyle)) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/components/footer.go b/internal/ui/components/footer.go new file mode 100644 index 0000000..d760113 --- /dev/null +++ b/internal/ui/components/footer.go @@ -0,0 +1,62 @@ +package components + +import ( + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// Footer is a reusable component for displaying help information. +type Footer struct { + Help help.Model + KeyMap help.KeyMap + Width int +} + +// NewFooter creates a new Footer component. +func NewFooter(keyMap help.KeyMap) Footer { + return Footer{ + Help: help.New(), + KeyMap: keyMap, + } +} + +// Update updates the footer component. +func (f *Footer) Update(msg tea.Msg) { + if msg, ok := msg.(tea.WindowSizeMsg); ok { + f.Width = msg.Width + } +} + +// View renders the footer component. +func (f Footer) View(style lipgloss.Style) string { + return style.Render(f.Help.View(f.KeyMap)) +} + +// CommonKeys defines keys that are shared across many pages. +type CommonKeys struct { + Quit key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. +func (k CommonKeys) ShortHelp() []key.Binding { + return []key.Binding{k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. +func (k CommonKeys) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Quit}, + } +} + +// NewCommonKeys returns a default set of common keys. +func NewCommonKeys() CommonKeys { + return CommonKeys{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + } +} diff --git a/internal/ui/components/footer_test.go b/internal/ui/components/footer_test.go new file mode 100644 index 0000000..fbef367 --- /dev/null +++ b/internal/ui/components/footer_test.go @@ -0,0 +1,67 @@ +package components + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/odize" +) + +func TestFooter(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("NewFooter should initialize with given KeyMap", func(t *testing.T) { + keys := NewCommonKeys() + footer := NewFooter(keys) + odize.AssertEqual(t, keys, footer.KeyMap) + }). + Test("Update should set width from WindowSizeMsg", func(t *testing.T) { + footer := NewFooter(NewCommonKeys()) + msg := tea.WindowSizeMsg{Width: 100, Height: 50} + footer.Update(msg) + odize.AssertEqual(t, 100, footer.Width) + }). + Test("View should render help text", func(t *testing.T) { + footer := NewFooter(NewCommonKeys()) + style := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")) + + rendered := footer.View(style) + + odize.AssertTrue(t, len(rendered) > 0) + odize.AssertTrue(t, strings.Contains(rendered, "q")) + odize.AssertTrue(t, strings.Contains(rendered, "quit")) + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestCommonKeys(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("NewCommonKeys should have Quit binding", func(t *testing.T) { + keys := NewCommonKeys() + odize.AssertTrue(t, keys.Quit.Enabled()) + odize.AssertEqual(t, "q", keys.Quit.Keys()[0]) + }). + Test("ShortHelp should contain Quit", func(t *testing.T) { + keys := NewCommonKeys() + shortHelp := keys.ShortHelp() + odize.AssertEqual(t, 1, len(shortHelp)) + odize.AssertEqual(t, keys.Quit, shortHelp[0]) + }). + Test("FullHelp should contain Quit in the first row", func(t *testing.T) { + keys := NewCommonKeys() + fullHelp := keys.FullHelp() + odize.AssertEqual(t, 1, len(fullHelp)) + odize.AssertEqual(t, 1, len(fullHelp[0])) + odize.AssertEqual(t, keys.Quit, fullHelp[0][0]) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/components/formation_list.go b/internal/ui/components/formation_list.go new file mode 100644 index 0000000..6c91942 --- /dev/null +++ b/internal/ui/components/formation_list.go @@ -0,0 +1,126 @@ +package components + +import ( + "fmt" + + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" +) + +type FormationItem struct { + formation playbooks.Formation + showDescription bool +} + +func (i FormationItem) Title() string { + return fmt.Sprintf("%s (%d-%d-%d)", i.formation.Name, i.formation.Lane1, i.formation.Lane2, i.formation.Lane3) +} +func (i FormationItem) Description() string { + if i.showDescription { + return i.formation.Description + } + return "" +} +func (i FormationItem) FilterValue() string { return i.formation.Name } + +type FormationList struct { + list list.Model + active bool +} + +type FormationListConfig struct { + Title string + Items []playbooks.Formation + EnableFiltering bool + ShowDescription bool +} + +func NewFormationList(config FormationListConfig) FormationList { + items := make([]list.Item, len(config.Items)) + for i, f := range config.Items { + items[i] = FormationItem{ + formation: f, + showDescription: config.ShowDescription, + } + } + + delegate := list.NewDefaultDelegate() + // Match PlaybookList styling + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("#A5F2F3")).BorderForeground(lipgloss.Color("#A5F2F3")) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("#87CEEB")).BorderForeground(lipgloss.Color("#A5F2F3")) + + l := list.New(items, delegate, 0, 0) + l.Title = config.Title + l.SetShowStatusBar(false) + l.SetFilteringEnabled(config.EnableFiltering) + l.Styles.Title = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#A5F2F3")).Bold(true) + + return FormationList{ + list: l, + } +} + +func (l *FormationList) Update(msg tea.Msg) (FormationList, tea.Cmd) { + if !l.active { + return *l, nil + } + var cmd tea.Cmd + l.list, cmd = l.list.Update(msg) + return *l, cmd +} + +func (l *FormationList) View() string { + style := lipgloss.NewStyle().Padding(1).Border(lipgloss.RoundedBorder()) + if l.active { + style = style.BorderForeground(lipgloss.Color("#A5F2F3")) + } + return style.Render(l.list.View()) +} + +func (l *FormationList) SelectedItem() playbooks.Formation { + if item, ok := l.list.SelectedItem().(FormationItem); ok { + return item.formation + } + return playbooks.Formation{} +} + +func (l *FormationList) SetSize(width, height int) { + l.list.SetSize(width, height) +} + +func (l *FormationList) SetActive(active bool) { + l.active = active +} + +func (l *FormationList) SetItems(formations []playbooks.Formation) tea.Cmd { + items := make([]list.Item, len(formations)) + + showDescription := false + if len(l.list.Items()) > 0 { + if first, ok := l.list.Items()[0].(FormationItem); ok { + showDescription = first.showDescription + } + } + + for i, f := range formations { + items[i] = FormationItem{ + formation: f, + showDescription: showDescription, + } + } + return l.list.SetItems(items) +} + +func (l *FormationList) SelectedIndex() int { + return l.list.Index() +} + +func (l *FormationList) Len() int { + return len(l.list.Items()) +} + +func (l *FormationList) IsFiltering() bool { + return l.list.FilterState() == list.Filtering +} diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go new file mode 100644 index 0000000..6b8c8ba --- /dev/null +++ b/internal/ui/components/locker_room_list.go @@ -0,0 +1,72 @@ +package components + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type LockerRoomItem int + +const ( + ItemPlayers LockerRoomItem = iota + ItemPlaybooks + ItemSettings +) + +func (i LockerRoomItem) String() string { + switch i { + case ItemPlayers: + return "Players" + case ItemPlaybooks: + return "Playbooks" + case ItemSettings: + return "Settings" + } + return "" +} + +type LockerRoomList struct { + cursor int + items []LockerRoomItem +} + +func NewLockerRoomList() LockerRoomList { + return LockerRoomList{ + items: []LockerRoomItem{ItemPlayers, ItemPlaybooks, ItemSettings}, + } +} + +func (l *LockerRoomList) Update(msg tea.Msg) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if l.cursor > 0 { + l.cursor-- + } + case "down", "j": + if l.cursor < len(l.items)-1 { + l.cursor++ + } + } + } +} + +func (l *LockerRoomList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { + var s string + for i, item := range l.items { + if i == l.cursor { + s += selectedStyle.Render("> " + item.String()) + } else { + s += itemStyle.Render(" " + item.String()) + } + if i < len(l.items)-1 { + s += "\n" + } + } + return s +} + +func (l *LockerRoomList) SelectedItem() LockerRoomItem { + return l.items[l.cursor] +} diff --git a/internal/ui/components/locker_room_list_test.go b/internal/ui/components/locker_room_list_test.go new file mode 100644 index 0000000..10a2abf --- /dev/null +++ b/internal/ui/components/locker_room_list_test.go @@ -0,0 +1,52 @@ +package components + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" +) + +func TestLockerRoomList(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("NewLockerRoomList should have 3 items", func(t *testing.T) { + l := NewLockerRoomList() + odize.AssertEqual(t, 3, len(l.items)) + odize.AssertEqual(t, ItemPlayers, l.items[0]) + odize.AssertEqual(t, ItemPlaybooks, l.items[1]) + odize.AssertEqual(t, ItemSettings, l.items[2]) + }) + + group.Test("Update should move cursor down", func(t *testing.T) { + l := NewLockerRoomList() + odize.AssertEqual(t, 0, l.cursor) + + l.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, 1, l.cursor) + odize.AssertEqual(t, ItemPlaybooks, l.SelectedItem()) + }) + + group.Test("Update should move cursor up", func(t *testing.T) { + l := NewLockerRoomList() + l.cursor = 1 + + l.Update(tea.KeyPressMsg{Text: "up"}) + odize.AssertEqual(t, 0, l.cursor) + odize.AssertEqual(t, ItemPlayers, l.SelectedItem()) + }) + + group.Test("Update should not move cursor out of bounds", func(t *testing.T) { + l := NewLockerRoomList() + + l.Update(tea.KeyPressMsg{Text: "up"}) + odize.AssertEqual(t, 0, l.cursor) + + l.cursor = 2 + l.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, 2, l.cursor) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/ui/components/playbook_form.go b/internal/ui/components/playbook_form.go new file mode 100644 index 0000000..34a80d5 --- /dev/null +++ b/internal/ui/components/playbook_form.go @@ -0,0 +1,98 @@ +package components + +import ( + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type PlaybookForm struct { + NameInput textinput.Model + DescriptionInput textinput.Model + focusIndex int +} + +func NewPlaybookForm() PlaybookForm { + name := textinput.New() + name.Placeholder = "Playbook Name" + name.Focus() + + description := textinput.New() + description.Placeholder = "Description" + + return PlaybookForm{ + NameInput: name, + DescriptionInput: description, + focusIndex: 0, + } +} + +func (f *PlaybookForm) Update(msg tea.Msg) (PlaybookForm, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "tab", "shift+tab", "up", "down": + s := msg.String() + if s == "up" || s == "shift+tab" { + f.focusIndex-- + } else { + f.focusIndex++ + } + + if f.focusIndex > 1 { + f.focusIndex = 0 + } else if f.focusIndex < 0 { + f.focusIndex = 1 + } + + var cmd tea.Cmd + if f.focusIndex == 0 { + cmd = f.NameInput.Focus() + f.DescriptionInput.Blur() + } else { + f.NameInput.Blur() + cmd = f.DescriptionInput.Focus() + } + cmds = append(cmds, cmd) + } + } + + var cmd tea.Cmd + f.NameInput, cmd = f.NameInput.Update(msg) + cmds = append(cmds, cmd) + + f.DescriptionInput, cmd = f.DescriptionInput.Update(msg) + cmds = append(cmds, cmd) + + return *f, tea.Batch(cmds...) +} + +func (f *PlaybookForm) View() string { + return lipgloss.JoinVertical( + lipgloss.Left, + "Name:", + f.NameInput.View(), + "", + "Description:", + f.DescriptionInput.View(), + ) +} + +func (f *PlaybookForm) SetValues(name, description string) { + f.NameInput.SetValue(name) + f.DescriptionInput.SetValue(description) +} + +func (f *PlaybookForm) Values() (string, string) { + return f.NameInput.Value(), f.DescriptionInput.Value() +} + +func (f *PlaybookForm) Reset() { + f.NameInput.Reset() + f.DescriptionInput.Reset() + f.focusIndex = 0 + f.NameInput.Focus() + f.DescriptionInput.Blur() +} diff --git a/internal/ui/components/playbook_list.go b/internal/ui/components/playbook_list.go new file mode 100644 index 0000000..5036f2e --- /dev/null +++ b/internal/ui/components/playbook_list.go @@ -0,0 +1,78 @@ +package components + +import ( + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" +) + +type PlaybookItem struct { + playbook playbooks.Playbook +} + +func (i PlaybookItem) Title() string { return i.playbook.Name } +func (i PlaybookItem) Description() string { return i.playbook.Description } +func (i PlaybookItem) FilterValue() string { return i.playbook.Name } + +type PlaybookList struct { + list list.Model +} + +func NewPlaybookList(items []playbooks.Playbook) PlaybookList { + listItems := make([]list.Item, len(items)) + for i, item := range items { + listItems[i] = PlaybookItem{playbook: item} + } + + delegate := list.NewDefaultDelegate() + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(lipgloss.Color("#A5F2F3")).BorderForeground(lipgloss.Color("#A5F2F3")) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(lipgloss.Color("#87CEEB")).BorderForeground(lipgloss.Color("#A5F2F3")) + + l := list.New(listItems, delegate, 0, 0) + l.Title = "Playbooks" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.Styles.Title = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#A5F2F3")).Bold(true) + + return PlaybookList{ + list: l, + } +} + +func (l *PlaybookList) Update(msg tea.Msg) (PlaybookList, tea.Cmd) { + var cmd tea.Cmd + l.list, cmd = l.list.Update(msg) + return *l, cmd +} + +func (l *PlaybookList) View() string { + return l.list.View() +} + +func (l *PlaybookList) SelectedItem() *playbooks.Playbook { + if item, ok := l.list.SelectedItem().(PlaybookItem); ok { + return &item.playbook + } + return nil +} + +func (l *PlaybookList) SetSize(width, height int) { + l.list.SetSize(width, height) +} + +func (l *PlaybookList) SetItems(items []playbooks.Playbook) tea.Cmd { + listItems := make([]list.Item, len(items)) + for i, item := range items { + listItems[i] = PlaybookItem{playbook: item} + } + return l.list.SetItems(listItems) +} + +func (l *PlaybookList) Len() int { + return len(l.list.Items()) +} + +func (l *PlaybookList) IsFiltering() bool { + return l.list.FilterState() == list.Filtering +} diff --git a/internal/ui/components/player_list.go b/internal/ui/components/player_list.go new file mode 100644 index 0000000..4a64449 --- /dev/null +++ b/internal/ui/components/player_list.go @@ -0,0 +1,111 @@ +package components + +import ( + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/teams" +) + +type PlayerItem struct { + Player teams.Player + Input textinput.Model + IsEditing bool +} + +type PlayerList struct { + cursor int + Items []PlayerItem +} + +func NewPlayerList(players []teams.Player) PlayerList { + items := make([]PlayerItem, len(players)) + for i, p := range players { + ti := textinput.New() + ti.SetValue(p.Name) + ti.CharLimit = 50 + ti.SetWidth(20) + items[i] = PlayerItem{ + Player: p, + Input: ti, + } + } + return PlayerList{ + Items: items, + } +} + +func (l *PlayerList) Update(msg tea.Msg) tea.Cmd { + if len(l.Items) == 0 { + return nil + } + + item := &l.Items[l.cursor] + + if item.IsEditing { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + item.IsEditing = false + item.Input.Blur() + item.Player.Name = item.Input.Value() + return func() tea.Msg { + return MsgPlayerUpdated{Player: item.Player} + } + case "esc": + item.IsEditing = false + item.Input.Blur() + item.Input.SetValue(item.Player.Name) + return nil + } + } + var cmd tea.Cmd + item.Input, cmd = item.Input.Update(msg) + return cmd + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if l.cursor > 0 { + l.cursor-- + } + case "down", "j": + if l.cursor < len(l.Items)-1 { + l.cursor++ + } + case "enter": + item.IsEditing = true + return item.Input.Focus() + } + } + return nil +} + +func (l *PlayerList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { + var s string + for i, item := range l.Items { + var content string + if item.IsEditing { + content = item.Input.View() + } else { + content = item.Player.Name + } + + if i == l.cursor { + s += selectedStyle.Render("> " + content) + } else { + s += itemStyle.Render(" " + content) + } + if i < len(l.Items)-1 { + s += "\n" + } + } + return s +} + +type MsgPlayerUpdated struct { + Player teams.Player +} diff --git a/internal/ui/components/title_menu.go b/internal/ui/components/title_menu.go new file mode 100644 index 0000000..a51d1a1 --- /dev/null +++ b/internal/ui/components/title_menu.go @@ -0,0 +1,100 @@ +package components + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type TitleItem int + +const ( + TitleItemCreateCoach TitleItem = iota + TitleItemLockerRoom + TitleItemNewTournament + TitleItemNewBattleSelection + TitleItemSettings +) + +func (i TitleItem) String() string { + switch i { + case TitleItemCreateCoach: + return "Create Coach" + case TitleItemLockerRoom: + return "Locker Room" + case TitleItemNewTournament: + return "New Tournament" + case TitleItemNewBattleSelection: + return "New Battle" + case TitleItemSettings: + return "Settings" + } + return "" +} + +type TitleMenu struct { + cursor int + items []TitleItem +} + +func NewTitleMenu(hasCoach bool) TitleMenu { + var items []TitleItem + if !hasCoach { + items = []TitleItem{TitleItemCreateCoach} + } else { + items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattleSelection, TitleItemSettings} + } + return TitleMenu{ + items: items, + } +} + +func (m *TitleMenu) Update(msg tea.Msg) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.items)-1 { + m.cursor++ + } + } + } +} + +func (m *TitleMenu) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { + var s string + for i, item := range m.items { + if i == m.cursor { + s += selectedStyle.Render("> " + item.String()) + } else { + s += itemStyle.Render(" " + item.String()) + } + if i < len(m.items)-1 { + s += "\n" + } + } + return s +} + +func (m *TitleMenu) SelectedItem() TitleItem { + if m.cursor < 0 || m.cursor >= len(m.items) { + return -1 + } + return m.items[m.cursor] +} + +func (m *TitleMenu) SetHasCoach(hasCoach bool) { + var items []TitleItem + if !hasCoach { + items = []TitleItem{TitleItemCreateCoach} + } else { + items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattleSelection, TitleItemSettings} + } + m.items = items + if m.cursor >= len(m.items) { + m.cursor = 0 + } +} diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go new file mode 100644 index 0000000..5c6e9bd --- /dev/null +++ b/internal/ui/page_create_coach.go @@ -0,0 +1,250 @@ +package ui + +import ( + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/components" +) + +type createCoachKeyMap struct { + components.CommonKeys + Back key.Binding + Enter key.Binding + Up key.Binding + Down key.Binding + Tab key.Binding + ShiftTab key.Binding +} + +func (k createCoachKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Back, k.Quit} +} + +func (k createCoachKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.Back}, + {k.Up, k.Down, k.Tab, k.ShiftTab}, + {k.Quit}, + } +} + +func newCreateCoachKeyMap() createCoachKeyMap { + return createCoachKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "continue"), + ), + Up: key.NewBinding( + key.WithKeys("up"), + key.WithHelp("↑", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down"), + key.WithHelp("↓", "down"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev"), + ), + } +} + +type ModelCreateCoach struct { + width int + height int + theme IceTheme + globalState *GlobalState + teamsSvc *teams.Service + + coachInput textinput.Model + teamInput textinput.Model + focusIndex int + err error + keys createCoachKeyMap + footer components.Footer +} + +func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service) *ModelCreateCoach { + c := textinput.New() + c.Placeholder = "Coach Name" + c.Focus() + c.CharLimit = 156 + c.SetWidth(20) + + t := textinput.New() + t.Placeholder = "Team Name" + t.CharLimit = 156 + c.SetWidth(20) + + keys := newCreateCoachKeyMap() + + return &ModelCreateCoach{ + globalState: state, + teamsSvc: teamsSvc, + coachInput: c, + teamInput: t, + theme: NewIceTheme(), + keys: keys, + footer: components.NewFooter(keys), + } +} + +func (m *ModelCreateCoach) Init() tea.Cmd { + return func() tea.Msg { return textinput.Blink() } +} + +func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) + + case tea.KeyMsg: + if cmd, done := m.handleKeyMsg(msg); done { + return m, cmd + } + + case MsgStateUpdated: + return m.handleStateUpdated(msg) + } + + return m.updateInputs(msg) +} + +func (m *ModelCreateCoach) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool) { + switch { + case key.Matches(msg, m.keys.Quit): + return tea.Quit, true + + case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.ShiftTab): + m.focusIndex-- + if m.focusIndex < 0 { + m.focusIndex = 1 + } + m.updateFocus() + return nil, false + + case key.Matches(msg, m.keys.Down), key.Matches(msg, m.keys.Tab): + m.focusIndex++ + if m.focusIndex > 1 { + m.focusIndex = 0 + } + m.updateFocus() + return nil, false + + case key.Matches(msg, m.keys.Enter): + if m.focusIndex == 1 { + return m.submit(), true + } + m.focusIndex++ + m.updateFocus() + return nil, false + + case key.Matches(msg, m.keys.Back): + return func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} + }, true + } + + return nil, false +} + +func (m *ModelCreateCoach) handleStateUpdated(msg MsgStateUpdated) (tea.Model, tea.Cmd) { + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerRoom} + } +} + +func (m *ModelCreateCoach) updateInputs(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + var cmd tea.Cmd + + m.coachInput, cmd = m.coachInput.Update(msg) + cmds = append(cmds, cmd) + + m.teamInput, cmd = m.teamInput.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *ModelCreateCoach) updateFocus() { + if m.focusIndex == 0 { + m.coachInput.Focus() + m.teamInput.Blur() + } else { + m.coachInput.Blur() + m.teamInput.Focus() + } +} + +func (m *ModelCreateCoach) submit() tea.Cmd { + return func() tea.Msg { + ctx := m.globalState.Context() + coach, err := m.teamsSvc.CreateCoach(ctx, teams.CreateCoachParams{ + Name: m.coachInput.Value(), + IsHuman: true, + IsDefault: true, + }) + if err != nil { + return err + } + + team, err := m.teamsSvc.CreateTeam(ctx, m.teamInput.Value(), coach.ID, true) + if err != nil { + return err + } + + return MsgStateUpdated{ + Coach: &coach, + Team: &team, + } + } +} + +func (m *ModelCreateCoach) View() tea.View { + view := tea.NewView("") + view.AltScreen = true + + form := lipgloss.JoinVertical( + lipgloss.Left, + m.theme.Logo.Render("RUSH - NEW CAREER"), + "", + "Coach Details", + m.coachInput.View(), + "", + "Team Details", + m.teamInput.View(), + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + form, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} diff --git a/internal/ui/page_locker_playbooks_create.go b/internal/ui/page_locker_playbooks_create.go new file mode 100644 index 0000000..aed4a28 --- /dev/null +++ b/internal/ui/page_locker_playbooks_create.go @@ -0,0 +1,179 @@ +package ui + +import ( + "fmt" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/ui/components" +) + +type lockerPlaybooksCreateKeyMap struct { + components.CommonKeys + Back key.Binding + Enter key.Binding +} + +func (k lockerPlaybooksCreateKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Back, k.Quit} +} + +func (k lockerPlaybooksCreateKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.Back, k.Quit}, + } +} + +func newLockerPlaybooksCreateKeyMap() lockerPlaybooksCreateKeyMap { + return lockerPlaybooksCreateKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + } +} + +type ModelLockerPlaybooksCreate struct { + width int + height int + theme IceTheme + globalState *GlobalState + playbookSvc *playbooks.Service + keys lockerPlaybooksCreateKeyMap + footer components.Footer + playbookID int64 + playbookForm components.PlaybookForm + formations []playbooks.Formation + err error +} + +func NewModelLockerPlaybooksCreate(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksCreate { + return &ModelLockerPlaybooksCreate{ + theme: NewIceTheme(), + globalState: state, + playbookSvc: playbookSvc, + keys: newLockerPlaybooksCreateKeyMap(), + footer: components.NewFooter(newLockerPlaybooksCreateKeyMap()), + playbookForm: components.NewPlaybookForm(), + } +} + +func (m *ModelLockerPlaybooksCreate) Init() tea.Cmd { + return nil +} + +func (m *ModelLockerPlaybooksCreate) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + case MsgSwitchPage: + if msg.NewPage == PageLockerPlaybooksCreate { + if msg.Playbook != nil { + m.load(msg.Playbook) + } else { + m.reset() + } + } + case error: + m.err = msg + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerPlaybooksList} + } + case key.Matches(msg, m.keys.Enter): + name, description := m.playbookForm.Values() + if name != "" { + return m, func() tea.Msg { + return MsgSwitchPage{ + NewPage: PageLockerPlaybooksEdit, + Playbook: &playbooks.Playbook{ + ID: m.playbookID, + Name: name, + Description: description, + Formations: m.formations, + }, + } + } + } + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) + } + + var cmd tea.Cmd + m.playbookForm, cmd = m.playbookForm.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *ModelLockerPlaybooksCreate) reset() { + m.playbookForm.Reset() + m.formations = nil + m.playbookID = 0 + m.err = nil +} + +func (m *ModelLockerPlaybooksCreate) load(p *playbooks.Playbook) { + m.playbookForm.SetValues(p.Name, p.Description) + m.formations = p.Formations + m.playbookID = p.ID + m.err = nil +} + +func (m *ModelLockerPlaybooksCreate) View() tea.View { + view := tea.NewView("") + view.AltScreen = true + + var content string + title := "PLAYBOOK NAME" + + if m.err != nil { + content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) + } else { + if m.playbookID != 0 { + title = "EDIT PLAYBOOK" + } else { + title = "CREATE PLAYBOOK" + } + content = "Enter playbook details:\n\n" + m.playbookForm.View() + } + + mainContent := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render(title), + "", + content, + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + mainContent, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} diff --git a/internal/ui/page_locker_playbooks_edit.go b/internal/ui/page_locker_playbooks_edit.go new file mode 100644 index 0000000..234b958 --- /dev/null +++ b/internal/ui/page_locker_playbooks_edit.go @@ -0,0 +1,279 @@ +package ui + +import ( + "fmt" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/ui/components" +) + +type lockerPlaybooksEditKeyMap struct { + components.CommonKeys + Back key.Binding + Enter key.Binding + Select key.Binding +} + +func (k lockerPlaybooksEditKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Back, k.Quit} +} + +func (k lockerPlaybooksEditKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.Select, k.Back, k.Quit}, + } +} + +func newLockerPlaybooksEditKeyMap() lockerPlaybooksEditKeyMap { + return lockerPlaybooksEditKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "confirm"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + } +} + +type ModelLockerPlaybooksEdit struct { + width int + height int + theme IceTheme + globalState *GlobalState + playbookSvc *playbooks.Service + keys lockerPlaybooksEditKeyMap + footer components.Footer + formationList components.FormationList + selectedFormationList components.FormationList + activeList int // 0 for formationList, 1 for selectedFormationList + playbookID int64 + playbookName string + playbookDescription string + newFormations []playbooks.Formation + err error +} + +func NewModelLockerPlaybooksEdit(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksEdit { + return &ModelLockerPlaybooksEdit{ + theme: NewIceTheme(), + globalState: state, + playbookSvc: playbookSvc, + keys: newLockerPlaybooksEditKeyMap(), + footer: components.NewFooter(newLockerPlaybooksEditKeyMap()), + formationList: components.NewFormationList(components.FormationListConfig{ + Title: "Available Formations", + Items: playbooks.Formations(), + EnableFiltering: true, + ShowDescription: true, + }), + selectedFormationList: components.NewFormationList(components.FormationListConfig{ + Title: "Selected Formations (Max 10)", + Items: []playbooks.Formation{}, + EnableFiltering: false, + ShowDescription: false, + }), + } +} + +func (m *ModelLockerPlaybooksEdit) Init() tea.Cmd { + return nil +} + +func (m *ModelLockerPlaybooksEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + case MsgSwitchPage: + if msg.NewPage == PageLockerPlaybooksEdit { + if msg.Playbook != nil { + m.load(msg.Playbook) + } else { + m.reset() + } + } + case error: + m.err = msg + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + if m.formationList.IsFiltering() { + break + } + return m, func() tea.Msg { + return MsgSwitchPage{ + NewPage: PageLockerPlaybooksCreate, + Playbook: &playbooks.Playbook{ + ID: m.playbookID, + Name: m.playbookName, + Description: m.playbookDescription, + Formations: m.newFormations, + }, + } + } + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.formationList.SetSize(msg.Width/2-4, msg.Height-15) + m.selectedFormationList.SetSize(msg.Width/2-4, msg.Height-15) + m.footer.Update(msg) + } + + cmds = append(cmds, m.updateAddFormations(msg)) + + return m, tea.Batch(cmds...) +} + +func (m *ModelLockerPlaybooksEdit) reset() { + m.newFormations = nil + m.playbookID = 0 + m.playbookName = "" + m.playbookDescription = "" + m.selectedFormationList.SetItems(nil) + m.err = nil +} + +func (m *ModelLockerPlaybooksEdit) load(p *playbooks.Playbook) { + m.newFormations = p.Formations + m.playbookID = p.ID + m.playbookName = p.Name + m.playbookDescription = p.Description + m.selectedFormationList.SetItems(m.newFormations) + m.err = nil +} + +func (m *ModelLockerPlaybooksEdit) updateAddFormations(msg tea.Msg) tea.Cmd { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + if m.formationList.IsFiltering() { + break + } + switch msg.String() { + case "tab": + m.activeList = (m.activeList + 1) % 2 + m.formationList.SetActive(m.activeList == 0) + m.selectedFormationList.SetActive(m.activeList == 1) + return nil + case "enter": + if m.activeList == 0 { + if len(m.newFormations) < 10 { + f := m.formationList.SelectedItem() + if f.Name != "" { + m.newFormations = append(m.newFormations, f) + cmds = append(cmds, m.selectedFormationList.SetItems(m.newFormations)) + } + } + } else { + if len(m.newFormations) > 0 { + idx := m.selectedFormationList.SelectedIndex() + if idx >= 0 && idx < len(m.newFormations) { + m.newFormations = append(m.newFormations[:idx], m.newFormations[idx+1:]...) + cmds = append(cmds, m.selectedFormationList.SetItems(m.newFormations)) + } + } + } + return tea.Batch(cmds...) + case "s": // Save + if len(m.newFormations) > 0 { + return m.savePlaybook + } + } + } + + m.formationList.SetActive(m.activeList == 0) + m.selectedFormationList.SetActive(m.activeList == 1) + + var cmd tea.Cmd + m.formationList, cmd = m.formationList.Update(msg) + cmds = append(cmds, cmd) + + m.selectedFormationList, cmd = m.selectedFormationList.Update(msg) + cmds = append(cmds, cmd) + + return tea.Batch(cmds...) +} + +func (m *ModelLockerPlaybooksEdit) savePlaybook() tea.Msg { + var err error + if m.playbookID != 0 { + _, err = m.playbookSvc.UpdatePlaybook(m.globalState.Context(), m.playbookID, playbooks.PlaybookParams{ + TeamID: m.globalState.Team.ID, + Name: m.playbookName, + Description: m.playbookDescription, + Formations: m.newFormations, + }) + } else { + _, err = m.playbookSvc.CreatePlaybook(m.globalState.Context(), playbooks.PlaybookParams{ + TeamID: m.globalState.Team.ID, + Name: m.playbookName, + Description: m.playbookDescription, + Formations: m.newFormations, + }) + } + if err != nil { + return err + } + return MsgSwitchPage{NewPage: PageLockerPlaybooksList} +} + +func (m *ModelLockerPlaybooksEdit) View() tea.View { + view := tea.NewView("") + view.AltScreen = true + + var content string + title := "ALLOCATE FORMATIONS" + + if m.err != nil { + content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) + } else { + m.formationList.SetSize(m.width/2-4, m.height-15) + m.selectedFormationList.SetSize(m.width/2-4, m.height-15) + + content = lipgloss.JoinHorizontal( + lipgloss.Top, + m.formationList.View(), + lipgloss.NewStyle().Width(2).Render(""), + m.selectedFormationList.View(), + ) + content += "\n\n" + m.theme.Footer.Render(fmt.Sprintf("%d/10 formations • Tab: switch • Enter: add/remove • 's': save", len(m.newFormations))) + } + + mainContent := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render(title), + "", + content, + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + mainContent, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} diff --git a/internal/ui/page_locker_playbooks_list.go b/internal/ui/page_locker_playbooks_list.go new file mode 100644 index 0000000..8a2056a --- /dev/null +++ b/internal/ui/page_locker_playbooks_list.go @@ -0,0 +1,235 @@ +package ui + +import ( + "fmt" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/ui/components" +) + +type lockerPlaybooksListKeyMap struct { + components.CommonKeys + Back key.Binding + Enter key.Binding + New key.Binding + Delete key.Binding +} + +func (k lockerPlaybooksListKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.New, k.Back, k.Quit} +} + +func (k lockerPlaybooksListKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.New, k.Delete, k.Back, k.Quit}, + } +} + +func newLockerPlaybooksListKeyMap() lockerPlaybooksListKeyMap { + return lockerPlaybooksListKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + New: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "new playbook"), + ), + Delete: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "delete")), + } +} + +type ModelLockerPlaybooksList struct { + width int + height int + theme IceTheme + globalState *GlobalState + playbookSvc *playbooks.Service + keys lockerPlaybooksListKeyMap + footer components.Footer + playbookList components.PlaybookList + playbooksLoaded bool + err error +} + +func NewModelLockerPlaybooksList(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksList { + return &ModelLockerPlaybooksList{ + theme: NewIceTheme(), + globalState: state, + playbookSvc: playbookSvc, + keys: newLockerPlaybooksListKeyMap(), + footer: components.NewFooter(newLockerPlaybooksListKeyMap()), + } +} + +func (m *ModelLockerPlaybooksList) Init() tea.Cmd { + return m.loadPlaybooks +} + +func (m *ModelLockerPlaybooksList) loadPlaybooks() tea.Msg { + if m.globalState.Team == nil { + return nil + } + items, err := m.playbookSvc.GetTeamPlaybooks(m.globalState.Context(), m.globalState.Team.ID) + if err != nil { + return err + } + return MsgPlaybooksLoaded{Playbooks: items} +} + +type MsgPlaybooksLoaded struct { + Playbooks []playbooks.Playbook +} + +func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + case MsgPlaybooksLoaded: + m.playbooksLoaded = true + m.playbookList = components.NewPlaybookList(msg.Playbooks) + m.playbookList.SetSize(m.width, m.height-10) + case MsgSwitchPage: + if msg.NewPage == PageLockerPlaybooksList { + cmds = append(cmds, m.loadPlaybooks) + } + case error: + m.err = msg + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + if m.playbookList.IsFiltering() { + break + } + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerRoom} + } + case key.Matches(msg, m.keys.Enter): + model, cmd, done := m.handleRouteEditPlaybook() + if done { + return model, cmd + } + case key.Matches(msg, m.keys.New): + if !m.playbookList.IsFiltering() { + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerPlaybooksCreate} + } + } + case key.Matches(msg, m.keys.Delete): + model, cmd, done := m.handleDeletePlaybook() + if done { + return model, cmd + } + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if m.playbooksLoaded { + m.playbookList.SetSize(msg.Width, msg.Height-10) + } + m.footer.Update(msg) + } + + if m.playbooksLoaded { + var cmd tea.Cmd + m.playbookList, cmd = m.playbookList.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *ModelLockerPlaybooksList) handleRouteEditPlaybook() (tea.Model, tea.Cmd, bool) { + if m.playbookList.IsFiltering() { + return nil, nil, false + } + + selected := m.playbookList.SelectedItem() + if selected != nil { + return m, func() tea.Msg { + return MsgSwitchPage{ + NewPage: PageLockerPlaybooksCreate, + Playbook: selected, + } + }, true + } + + return nil, nil, false +} + +func (m *ModelLockerPlaybooksList) handleDeletePlaybook() (tea.Model, tea.Cmd, bool) { + if m.playbookList.IsFiltering() { + return nil, nil, false + } + + selected := m.playbookList.SelectedItem() + if selected == nil { + return nil, nil, false + } + + if err := m.playbookSvc.DeletePlaybook(m.globalState.Context(), selected.ID); err != nil { + m.err = err + } + + return m, m.loadPlaybooks, true +} + +func (m *ModelLockerPlaybooksList) View() tea.View { + view := tea.NewView("") + view.AltScreen = true + + var content string + title := "PLAYBOOKS" + + if m.err != nil { + content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) + } else if !m.playbooksLoaded { + content = "Loading..." + } else { + if m.playbookList.Len() == 0 { + content = "No playbooks yet. Press 'n' to create one." + } else { + content = m.playbookList.View() + if !m.playbookList.IsFiltering() { + content += "\n\nPress 'n' to create new playbook" + } + } + } + + mainContent := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render(title), + "", + content, + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + mainContent, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} diff --git a/internal/ui/page_locker_players.go b/internal/ui/page_locker_players.go new file mode 100644 index 0000000..276ced6 --- /dev/null +++ b/internal/ui/page_locker_players.go @@ -0,0 +1,164 @@ +package ui + +import ( + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/components" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type lockerPlayersKeyMap struct { + components.CommonKeys + Back key.Binding + Enter key.Binding + Up key.Binding + Down key.Binding +} + +func (k lockerPlayersKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Back, k.Quit} +} + +func (k lockerPlayersKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.Back, k.Quit}, + {k.Up, k.Down}, + } +} + +func newLockerPlayersKeyMap() lockerPlayersKeyMap { + return lockerPlayersKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back to locker room"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "edit/save"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + } +} + +type ModelLockerPlayers struct { + width int + height int + theme IceTheme + globalState *GlobalState + teamsSvc *teams.Service + keys lockerPlayersKeyMap + footer components.Footer + playerList components.PlayerList +} + +func NewModelLockerPlayers(state *GlobalState, teamsSvc *teams.Service) *ModelLockerPlayers { + keys := newLockerPlayersKeyMap() + return &ModelLockerPlayers{ + theme: NewIceTheme(), + globalState: state, + teamsSvc: teamsSvc, + keys: keys, + footer: components.NewFooter(keys), + } +} + +func (m *ModelLockerPlayers) Init() tea.Cmd { + return nil +} + +func (m *ModelLockerPlayers) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case MsgStateUpdated: + if m.globalState.Team != nil { + m.playerList = components.NewPlayerList(m.globalState.Team.Players) + } + case MsgSwitchPage: + if msg.NewPage == PageLockerPlayers && m.globalState.Team != nil { + m.playerList = components.NewPlayerList(m.globalState.Team.Players) + } + case components.MsgPlayerUpdated: + cmds = append(cmds, m.handlePlayerUpdated(msg)) + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerRoom} + } + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) + } + + cmd := m.playerList.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *ModelLockerPlayers) handlePlayerUpdated(msg components.MsgPlayerUpdated) tea.Cmd { + return func() tea.Msg { + err := m.teamsSvc.UpdatePlayer(m.globalState.Context(), msg.Player.ID, msg.Player.Name) + if err != nil { + return err // Should probably handle this better + } + + // Update global state + if m.globalState.Team != nil { + for i, p := range m.globalState.Team.Players { + if p.ID == msg.Player.ID { + m.globalState.Team.Players[i] = msg.Player + break + } + } + } + return nil + } +} + +func (m *ModelLockerPlayers) View() tea.View { + view := tea.NewView("") + view.AltScreen = true + + playersView := "No players found" + if len(m.playerList.Items) > 0 { + playersView = m.playerList.View(lipgloss.NewStyle(), m.theme.ListSelected) + } + + content := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render("PLAYERS"), + "", + playersView, + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + content, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} diff --git a/internal/ui/page_locker_room.go b/internal/ui/page_locker_room.go new file mode 100644 index 0000000..f13b081 --- /dev/null +++ b/internal/ui/page_locker_room.go @@ -0,0 +1,127 @@ +package ui + +import ( + "github.com/code-gorilla-au/rush/internal/ui/components" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type lockerRoomKeyMap struct { + components.CommonKeys + Back key.Binding + Select key.Binding +} + +func (k lockerRoomKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Select, k.Back, k.Quit} +} + +func (k lockerRoomKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Select, k.Back, k.Quit}, + } +} + +func newLockerRoomKeyMap() lockerRoomKeyMap { + return lockerRoomKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back to title"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + } +} + +type ModelLockerRoom struct { + width int + height int + theme IceTheme + globalState *GlobalState + keys lockerRoomKeyMap + footer components.Footer + list components.LockerRoomList +} + +func NewModelLockerRoom(globalState *GlobalState) *ModelLockerRoom { + keys := newLockerRoomKeyMap() + return &ModelLockerRoom{ + globalState: globalState, + keys: keys, + footer: components.NewFooter(keys), + theme: NewIceTheme(), + list: components.NewLockerRoomList(), + } +} + +func (m *ModelLockerRoom) Init() tea.Cmd { + return nil +} + +func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.list.Update(msg) + + switch msg := msg.(type) { + case MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} + } + case key.Matches(msg, m.keys.Select): + switch m.list.SelectedItem() { + case components.ItemPlayers: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerPlayers} + } + case components.ItemPlaybooks: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerPlaybooksList} + } + } + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) + } + + return m, nil +} + +func (m *ModelLockerRoom) View() tea.View { + view := tea.NewView("") + view.AltScreen = true + + content := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render("LOCKER ROOM"), + "", + m.list.View(m.theme.Base, m.theme.ListSelected), + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + content, + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} diff --git a/internal/ui/page_locker_room_test.go b/internal/ui/page_locker_room_test.go new file mode 100644 index 0000000..edd95d5 --- /dev/null +++ b/internal/ui/page_locker_room_test.go @@ -0,0 +1,57 @@ +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/ui/components" +) + +func TestModelLockerRoom_Selection(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should route to locker players when players item is selected", func(t *testing.T) { + state := &GlobalState{} + m := NewModelLockerRoom(state) + + // Ensure ItemPlayers is selected (it is by default) + odize.AssertEqual(t, components.ItemPlayers, m.list.SelectedItem()) + + // Simulate Enter key press + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageLockerPlayers, v.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }) + + group.Test("should route to locker playbooks when playbooks item is selected", func(t *testing.T) { + state := &GlobalState{} + m := NewModelLockerRoom(state) + + // Select Playbooks (it's the second item) + m.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, components.ItemPlaybooks, m.list.SelectedItem()) + + // Simulate Enter key press + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageLockerPlaybooksList, v.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/page_new_battle_selection.go new file mode 100644 index 0000000..1ba8d03 --- /dev/null +++ b/internal/ui/page_new_battle_selection.go @@ -0,0 +1,201 @@ +package ui + +import ( + "fmt" + + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/components" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type msgAICoachesLoaded struct { + aiTeams []AITeamItem +} + +type battleSelectionKeyMap struct { + components.CommonKeys + Back key.Binding + Select key.Binding +} + +func (k battleSelectionKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Select, k.Back, k.Quit} +} + +func (k battleSelectionKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Select, k.Back, k.Quit}, + } +} + +func newBattleSelectionKeyMap() battleSelectionKeyMap { + return battleSelectionKeyMap{ + CommonKeys: components.NewCommonKeys(), + Back: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "back"), + ), + Select: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + } +} + +type AITeamItem struct { + coach teams.Coach + team teams.Team +} + +type ModelNewBattleSelection struct { + width int + height int + theme IceTheme + globalState *GlobalState + teamsSvc *teams.Service + aiCoaches []AITeamItem + selectedCoachIdx int + keys battleSelectionKeyMap + footer components.Footer + err error +} + +func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service) *ModelNewBattleSelection { + keys := newBattleSelectionKeyMap() + return &ModelNewBattleSelection{ + globalState: globalState, + teamsSvc: teamsSvc, + theme: NewIceTheme(), + keys: keys, + footer: components.NewFooter(keys), + } +} + +func (m *ModelNewBattleSelection) Init() tea.Cmd { + return m.loadAICoaches +} + +func (m *ModelNewBattleSelection) loadAICoaches() tea.Msg { + aiTeams, err := m.teamsSvc.ListAITeams(m.globalState.Context()) + if err != nil { + return err + } + + items := make([]AITeamItem, 0, len(aiTeams)) + for _, aiTeam := range aiTeams { + items = append(items, AITeamItem{ + coach: aiTeam.Coach, + team: aiTeam.Team, + }) + } + + return msgAICoachesLoaded{aiTeams: items} +} + +func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.footer.Update(msg) + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case msgAICoachesLoaded: + m.aiCoaches = msg.aiTeams + if len(m.aiCoaches) > 0 { + m.selectedCoachIdx = 0 + } + case error: + m.err = msg + case tea.KeyMsg: + return m.handleKey(msg) + } + + return m, nil +} + +func (m *ModelNewBattleSelection) handleKey(msg tea.KeyMsg) (*ModelNewBattleSelection, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} + } + case key.Matches(msg, m.keys.Select): + if len(m.aiCoaches) > 0 { + // TODO: Start battle with selected coach + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitle} + } + } + case msg.String() == "up", msg.String() == "k": + if m.selectedCoachIdx > 0 { + m.selectedCoachIdx-- + } + case msg.String() == "down", msg.String() == "j": + if m.selectedCoachIdx < len(m.aiCoaches)-1 { + m.selectedCoachIdx++ + } + } + return m, nil +} + +func (m *ModelNewBattleSelection) View() tea.View { + if m.width == 0 || m.height == 0 { + return tea.NewView("Initializing...") + } + + view := tea.NewView("") + view.AltScreen = true + + var content string + if m.err != nil { + content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) + } else { + content = m.viewCoaches() + } + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + lipgloss.JoinVertical(lipgloss.Center, + m.theme.Logo.Render("NEW BATTLE"), + "", + content, + "", + m.footer.View(m.theme.Footer), + ), + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} + +func (m *ModelNewBattleSelection) viewCoaches() string { + if len(m.aiCoaches) == 0 { + return "No AI coaches available" + } + + var s string + s += m.theme.Logo.Render("Select your opponent:") + "\n\n" + + for i, item := range m.aiCoaches { + avatar := components.NewCoachAvatar(&item.coach, &item.team) + avatarView := avatar.View(m.theme.CoachTeam, m.theme.CoachName) + if i == m.selectedCoachIdx { + s += m.theme.ListSelected.Render("> " + avatarView) + } else { + s += " " + avatarView + } + s += "\n\n" + } + + return s +} diff --git a/internal/ui/page_new_battle_selection_test.go b/internal/ui/page_new_battle_selection_test.go new file mode 100644 index 0000000..2f6e0b0 --- /dev/null +++ b/internal/ui/page_new_battle_selection_test.go @@ -0,0 +1,120 @@ +package ui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/teams" +) + +func TestModelNewBattleSelection_Rendering(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should load AI coaches and render them", func(t *testing.T) { + state := &GlobalState{} + + m := NewModelNewBattleSelection(state, nil) + m.width = 100 + m.height = 40 + + aiTeams := []AITeamItem{ + { + coach: teams.Coach{Name: "Coach A"}, + team: teams.Team{Name: "Team A"}, + }, + } + + msg := msgAICoachesLoaded{aiTeams: aiTeams} + m.Update(msg) + + odize.AssertEqual(t, 1, len(m.aiCoaches)) + + view := m.View() + content := view.Content + + odize.AssertTrue(t, strings.Contains(content, "Select your opponent")) + odize.AssertTrue(t, !strings.Contains(content, "No AI coaches available")) + }) + + group.Test("should handle select coach and return to title", func(t *testing.T) { + state := &GlobalState{} + m := NewModelNewBattleSelection(state, nil) + m.width = 100 + m.height = 40 + m.aiCoaches = []AITeamItem{ + { + coach: teams.Coach{ID: 1, Name: "Coach A"}, + team: teams.Team{ID: 1, Name: "Team A"}, + }, + } + m.selectedCoachIdx = 0 + + // Simulate Enter key + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageTitle, v.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }) + + group.Test("should handle back navigation from coach selection", func(t *testing.T) { + state := &GlobalState{} + m := NewModelNewBattleSelection(state, nil) + + _, cmd := m.Update(tea.KeyPressMsg{Text: "esc"}) + + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageTitle, v.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }) + + group.Test("should handle navigation between coaches", func(t *testing.T) { + state := &GlobalState{} + m := NewModelNewBattleSelection(state, nil) + m.aiCoaches = []AITeamItem{ + {coach: teams.Coach{Name: "Coach A"}}, + {coach: teams.Coach{Name: "Coach B"}}, + {coach: teams.Coach{Name: "Coach C"}}, + } + m.selectedCoachIdx = 0 + + // Move down + m.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, 1, m.selectedCoachIdx) + + // Move down again + m.Update(tea.KeyPressMsg{Text: "j"}) + odize.AssertEqual(t, 2, m.selectedCoachIdx) + + // Move down at the end (should stay) + m.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, 2, m.selectedCoachIdx) + + // Move up + m.Update(tea.KeyPressMsg{Text: "up"}) + odize.AssertEqual(t, 1, m.selectedCoachIdx) + + // Move up again + m.Update(tea.KeyPressMsg{Text: "k"}) + odize.AssertEqual(t, 0, m.selectedCoachIdx) + + // Move up at the beginning (should stay) + m.Update(tea.KeyPressMsg{Text: "up"}) + odize.AssertEqual(t, 0, m.selectedCoachIdx) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/ui/page_new_tournament.go b/internal/ui/page_new_tournament.go new file mode 100644 index 0000000..4589a5c --- /dev/null +++ b/internal/ui/page_new_tournament.go @@ -0,0 +1,36 @@ +package ui + +import tea "charm.land/bubbletea/v2" + +type ModelNewTournament struct { + width int + height int + theme IceTheme + globalState *GlobalState +} + +func NewModelNewTournament(globalState *GlobalState) *ModelNewTournament { + return &ModelNewTournament{ + globalState: globalState, + } +} + +func (m *ModelNewTournament) Init() tea.Cmd { + return nil +} + +func (m *ModelNewTournament) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch vMsg := msg.(type) { + case tea.WindowSizeMsg: + m.width = vMsg.Width + m.height = vMsg.Height + case MsgStateUpdated: + m.globalState.Coach = vMsg.Coach + m.globalState.Team = vMsg.Team + } + return m, nil +} + +func (m *ModelNewTournament) View() tea.View { + return tea.NewView("new tournament") +} diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go new file mode 100644 index 0000000..ff329be --- /dev/null +++ b/internal/ui/page_title.go @@ -0,0 +1,147 @@ +package ui + +import ( + "strings" + + "github.com/code-gorilla-au/rush/internal/ui/components" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type titleKeyMap struct { + components.CommonKeys + Enter key.Binding +} + +func (k titleKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Quit} +} + +func (k titleKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.Quit}, + } +} + +func newTitleKeyMap() titleKeyMap { + return titleKeyMap{ + CommonKeys: components.NewCommonKeys(), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ), + } +} + +type ModelTitle struct { + width int + height int + theme IceTheme + globalState *GlobalState + keys titleKeyMap + footer components.Footer + menu components.TitleMenu +} + +func NewModelTitle(globalState *GlobalState) *ModelTitle { + keys := newTitleKeyMap() + return &ModelTitle{ + globalState: globalState, + keys: keys, + footer: components.NewFooter(keys), + menu: components.NewTitleMenu(globalState.Coach != nil), + } +} + +func (m *ModelTitle) Init() tea.Cmd { + return nil +} + +func (m *ModelTitle) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch vMsg := msg.(type) { + case MsgStateUpdated: + m.globalState.Coach = vMsg.Coach + m.globalState.Team = vMsg.Team + m.menu.SetHasCoach(m.globalState.Coach != nil) + case tea.KeyMsg: + switch { + case key.Matches(vMsg, m.keys.Quit): + return m, tea.Quit + case key.Matches(vMsg, m.keys.Enter): + selected := m.menu.SelectedItem() + switch selected { + case components.TitleItemCreateCoach: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageCreateCoach} + } + case components.TitleItemLockerRoom: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageLockerRoom} + } + case components.TitleItemNewTournament: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageNewTournament} + } + case components.TitleItemNewBattleSelection: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageNewBattleSelection} + } + case components.TitleItemSettings: + return m, func() tea.Msg { + return MsgSwitchPage{NewPage: PageTitleSettings} + } + } + } + m.menu.Update(vMsg) + case tea.WindowSizeMsg: + m.width = vMsg.Width + m.height = vMsg.Height + m.footer.Update(vMsg) + } + + return m, nil +} + +const logo = ` + ____ _ _ ____ _ _ + | _ \| | | / ___|| | | | + | |_) | | | \___ \| |_| | + | _ <| |_| |___) | _ | + |_| \_\\___/|____/|_| |_| +` + +func (m *ModelTitle) View() tea.View { + if m.width == 0 || m.height == 0 { + return tea.NewView("Initializing...") + } + + styledLogo := m.theme.Logo.Render(strings.Trim(logo, "\n")) + + navigation := m.menu.View(m.theme.Base, m.theme.Hotkey) + + content := lipgloss.JoinVertical( + lipgloss.Center, + styledLogo, + "", + navigation, + "", + m.footer.View(m.theme.Footer), + ) + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + content, + ) + + finalView := m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + view := tea.NewView(finalView) + view.AltScreen = true + return view +} diff --git a/internal/ui/page_title_settings.go b/internal/ui/page_title_settings.go new file mode 100644 index 0000000..9a162a6 --- /dev/null +++ b/internal/ui/page_title_settings.go @@ -0,0 +1,37 @@ +package ui + +import tea "charm.land/bubbletea/v2" + +type ModelTitleSettings struct { + width int + height int + theme IceTheme + globalState *GlobalState +} + +func NewModelTitleSettings(globalState *GlobalState) *ModelTitleSettings { + return &ModelTitleSettings{ + globalState: globalState, + } +} + +func (m *ModelTitleSettings) Init() tea.Cmd { + return nil +} + +func (m *ModelTitleSettings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch vMsg := msg.(type) { + case tea.WindowSizeMsg: + m.width = vMsg.Width + m.height = vMsg.Height + case MsgStateUpdated: + m.globalState.Coach = vMsg.Coach + m.globalState.Team = vMsg.Team + } + + return m, nil +} + +func (m *ModelTitleSettings) View() tea.View { + return tea.NewView("Settings") +} diff --git a/internal/ui/page_title_test.go b/internal/ui/page_title_test.go new file mode 100644 index 0000000..4d7a84a --- /dev/null +++ b/internal/ui/page_title_test.go @@ -0,0 +1,50 @@ +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/teams" +) + +func TestModelTitle(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should route to create coach when coach is nil and enter is pressed", func(t *testing.T) { + m := NewModelTitle(&GlobalState{Coach: nil}) + m.width = 100 + m.height = 50 + + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + odize.AssertTrue(t, cmd != nil) + + msg := cmd() + switch switchMsg := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageCreateCoach, switchMsg.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }). + Test("should route to locker room when coach is not nil and enter is pressed", func(t *testing.T) { + m := NewModelTitle(&GlobalState{Coach: &teams.Coach{Name: "Coach Carter"}}) + m.width = 100 + m.height = 50 + + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + odize.AssertTrue(t, cmd != nil) + + msg := cmd() + switch switchMsg := msg.(type) { + case MsgSwitchPage: + odize.AssertEqual(t, PageLockerRoom, switchMsg.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..1dfbd67 --- /dev/null +++ b/internal/ui/theme.go @@ -0,0 +1,55 @@ +package ui + +import "charm.land/lipgloss/v2" + +// IceTheme defines the "ice" color palette and styles. +type IceTheme struct { + Logo lipgloss.Style + Footer lipgloss.Style + Base lipgloss.Style + Button lipgloss.Style + Hotkey lipgloss.Style + ListSelected lipgloss.Style + CoachTeam lipgloss.Style + CoachName lipgloss.Style +} + +// NewIceTheme returns a new IceTheme with the "ice" palette. +func NewIceTheme() IceTheme { + // Colors + iceBlue := lipgloss.Color("#A5F2F3") + skyBlue := lipgloss.Color("#87CEEB") + white := lipgloss.Color("#FFFFFF") + black := lipgloss.Color("#000000") + + return IceTheme{ + Logo: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true), + Footer: lipgloss.NewStyle(). + Foreground(skyBlue). + Italic(true), + Base: lipgloss.NewStyle(). + Background(black). + Foreground(white), + Button: lipgloss.NewStyle(). + Foreground(black). + Background(iceBlue). + Padding(0, 3). + MarginTop(1). + Bold(true), + Hotkey: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true), + ListSelected: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true), + CoachTeam: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true), + CoachName: lipgloss.NewStyle(). + Foreground(lipgloss.Color("#555555")). + Italic(true). + Faint(true), + } +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..c54d8dc --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,18 @@ +version: "2" +sql: + - schema: "internal/database/schema" + queries: "internal/database/queries" + engine: "sqlite" + gen: + go: + package: "database" + out: "internal/database" + output_db_file_name: "db.gen.go" + output_models_file_name: "models.gen.go" + output_querier_file_name: "querier.gen.go" + output_files_suffix: ".gen" + overrides: + - column: "games.rounds" + go_type: "encoding/json.RawMessage" + - column: "games.results_log" + go_type: "encoding/json.RawMessage"