diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..fe49577 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,20 @@ +--- +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: monthly + groups: + prod: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + groups: + prod: + patterns: + - "*" \ No newline at end of file diff --git a/README.md b/README.md index 6ea6638..aa7f9d4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,57 @@ # rush -simple game + +Rush is a five-player lane battle game that blends elements from risk and battle arena games. +Built in Go, the game emphasises deep, meaningful strategic decisions through [coach personas](internal/ui/rules.md), tactical playbooks, and dynamic lane-based combat. + +## Quick Demo + +![demo](./docs/rush-demo.gif) + +## Prerequisites + +- [Go](https://go.dev/doc/install) (latest stable version) +- [Task](https://taskfile.dev/installation/) (task runner) +- [golangci-lint](https://golangci-lint.run/usage/install/) (for linting) +- [sqlc](https://sqlc.dev/usage/install/) (for database code generation) + +## Getting Started + +### Installation + +1. Clone the repository: + ```bash + git clone + cd rush + ``` + +2. Tidy dependencies: + ```bash + task format + ``` + +### Running the Application + +To run the game: +```bash +task dev +``` + +### Development + +We use `Taskfile.yml` to manage project tasks. To see all available commands, run: +```bash +task --list +``` + +Key tasks include: +- `task test`: Run unit tests. +- `task lint`: Run linters. +- `task format`: Format code and tidy modules. +- `task db-reset`: Reset the database to a clean state. +- `task cover`: Generate and show test coverage. + +## Project Structure + +- `cmd/`: Application entrypoint. +- `internal/`: Core application logic. +- `docs/`: Documentation. diff --git a/cmd/rush/main.go b/cmd/rush/main.go index a0515fe..f2140f3 100644 --- a/cmd/rush/main.go +++ b/cmd/rush/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "log/slog" "os" @@ -11,6 +12,7 @@ import ( "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/tournaments" "github.com/code-gorilla-au/rush/internal/ui" ) @@ -42,6 +44,14 @@ func main() { playbooksSvc := playbooks.NewPlaybooksService(queries) teamsSvc := teams.NewTeamsService(queries, playbooksSvc) gameSvc := games.NewService(queries) + _ = tournaments.NewService(tournaments.ServiceDependencies{ + GamesSvc: gameSvc, + TeamsSvc: teamsSvc, + Store: queries, + TxnFunc: func(txDB *sql.Tx) tournaments.Store { + return database.New(txDB) + }, + }) go func() { hasAICoaches, tErr := teamsSvc.HasAICoaches(ctx) diff --git a/docs/rush-demo.gif b/docs/rush-demo.gif new file mode 100644 index 0000000..75f415a Binary files /dev/null and b/docs/rush-demo.gif differ diff --git a/internal/database/db.go b/internal/database/db.go index 36936fd..bb2f382 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -2,6 +2,7 @@ package database import ( "database/sql" + "fmt" _ "modernc.org/sqlite" ) @@ -15,3 +16,21 @@ func NewSqLiteProvider(dbPath string) (*sql.DB, error) { return db, nil } + +func WithTxnCtx(db *sql.DB, fn func(tx *sql.Tx) error) error { + tx, err := db.Begin() + if err != nil { + return err + } + + err = fn(tx) + if err != nil { + if rErr := tx.Rollback(); rErr != nil { + return fmt.Errorf("tx rollback failed: %w", rErr) + } + + return fmt.Errorf("txn rolled back due to error: %w", err) + } + + return tx.Commit() +} diff --git a/internal/database/game.sql.gen.go b/internal/database/game.sql.gen.go index 9e247c3..ebe898a 100644 --- a/internal/database/game.sql.gen.go +++ b/internal/database/game.sql.gen.go @@ -11,11 +11,34 @@ import ( "encoding/json" ) +const allocateGameToStage = `-- name: AllocateGameToStage :one +insert into stage_games (stage_id, game_id) +values (?, ?) +returning id, stage_id, game_id, created_at, updated_at +` + +type AllocateGameToStageParams struct { + StageID sql.NullInt64 + GameID sql.NullInt64 +} + +func (q *Queries) AllocateGameToStage(ctx context.Context, arg AllocateGameToStageParams) (StageGame, error) { + row := q.db.QueryRowContext(ctx, allocateGameToStage, arg.StageID, arg.GameID) + var i StageGame + err := row.Scan( + &i.ID, + &i.StageID, + &i.GameID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const createGame = `-- name: CreateGame :one insert into games (name, team_a, team_b, - tournament_id, results_log, status, rounds, @@ -24,18 +47,16 @@ values (?, ?, ?, ?, - ?, 'pending', ?, ?) -returning id, name, tournament_id, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at +returning id, name, 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 @@ -46,7 +67,6 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e arg.Name, arg.TeamA, arg.TeamB, - arg.TournamentID, arg.ResultsLog, arg.Rounds, arg.CurrentRound, @@ -55,7 +75,6 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e err := row.Scan( &i.ID, &i.Name, - &i.TournamentID, &i.TeamA, &i.TeamB, &i.Winner, @@ -69,8 +88,60 @@ func (q *Queries) CreateGame(ctx context.Context, arg CreateGameParams) (Game, e return i, err } +const createStage = `-- name: CreateStage :one +insert into stages (name, tournament_id, status) +values (?, ?, ?) +returning id, tournament_id, name, status, created_at, updated_at +` + +type CreateStageParams struct { + Name string + TournamentID sql.NullInt64 + Status string +} + +func (q *Queries) CreateStage(ctx context.Context, arg CreateStageParams) (Stage, error) { + row := q.db.QueryRowContext(ctx, createStage, arg.Name, arg.TournamentID, arg.Status) + var i Stage + err := row.Scan( + &i.ID, + &i.TournamentID, + &i.Name, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createTournament = `-- name: CreateTournament :one +insert into tournaments (name, + number_of_teams) +values (?, + ?) +returning id, name, number_of_teams, created_at, updated_at +` + +type CreateTournamentParams struct { + Name string + NumberOfTeams int64 +} + +func (q *Queries) CreateTournament(ctx context.Context, arg CreateTournamentParams) (Tournament, error) { + row := q.db.QueryRowContext(ctx, createTournament, arg.Name, arg.NumberOfTeams) + var i Tournament + err := row.Scan( + &i.ID, + &i.Name, + &i.NumberOfTeams, + &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 +select id, name, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at from games where id = ? ` @@ -81,7 +152,6 @@ func (q *Queries) GetGameByID(ctx context.Context, id int64) (Game, error) { err := row.Scan( &i.ID, &i.Name, - &i.TournamentID, &i.TeamA, &i.TeamB, &i.Winner, @@ -96,7 +166,7 @@ func (q *Queries) GetGameByID(ctx context.Context, id int64) (Game, error) { } const listCompletedGamesByTeam = `-- name: ListCompletedGamesByTeam :many -select id, name, tournament_id, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at +select id, name, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at from games where status = 'complete' and (team_a = ? or team_b = ?) @@ -120,7 +190,6 @@ func (q *Queries) ListCompletedGamesByTeam(ctx context.Context, arg ListComplete if err := rows.Scan( &i.ID, &i.Name, - &i.TournamentID, &i.TeamA, &i.TeamB, &i.Winner, @@ -144,6 +213,17 @@ func (q *Queries) ListCompletedGamesByTeam(ctx context.Context, arg ListComplete return items, nil } +const startGame = `-- name: StartGame :exec +update games +set status = 'running' +where id = ? +` + +func (q *Queries) StartGame(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, startGame, id) + return err +} + const updateGame = `-- name: UpdateGame :one update games set name = ?, @@ -153,10 +233,9 @@ set name = ?, status = ?, results_log = ?, rounds = ?, - current_round = ?, - tournament_id = ? + current_round = ? where id = ? -returning id, name, tournament_id, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at +returning id, name, team_a, team_b, winner, status, rounds, current_round, results_log, created_at, updated_at ` type UpdateGameParams struct { @@ -168,7 +247,6 @@ type UpdateGameParams struct { ResultsLog json.RawMessage Rounds json.RawMessage CurrentRound int64 - TournamentID sql.NullInt64 ID int64 } @@ -182,14 +260,12 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, e 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, @@ -202,3 +278,31 @@ func (q *Queries) UpdateGame(ctx context.Context, arg UpdateGameParams) (Game, e ) return i, err } + +const updateStage = `-- name: UpdateStage :one +update stages +set name = ?, + status = ? +where id = ? +returning id, tournament_id, name, status, created_at, updated_at +` + +type UpdateStageParams struct { + Name string + Status string + ID int64 +} + +func (q *Queries) UpdateStage(ctx context.Context, arg UpdateStageParams) (Stage, error) { + row := q.db.QueryRowContext(ctx, updateStage, arg.Name, arg.Status, arg.ID) + var i Stage + err := row.Scan( + &i.ID, + &i.TournamentID, + &i.Name, + &i.Status, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go index 946587c..255fa6e 100644 --- a/internal/database/models.gen.go +++ b/internal/database/models.gen.go @@ -22,7 +22,6 @@ type Coach struct { type Game struct { ID int64 Name string - TournamentID sql.NullInt64 TeamA sql.NullInt64 TeamB sql.NullInt64 Winner sql.NullInt64 @@ -52,6 +51,23 @@ type Player struct { UpdatedAt sql.NullTime } +type Stage struct { + ID int64 + TournamentID sql.NullInt64 + Name string + Status string + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + +type StageGame struct { + ID int64 + StageID sql.NullInt64 + GameID sql.NullInt64 + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + type Team struct { ID int64 Name string @@ -62,8 +78,9 @@ type Team struct { } type Tournament struct { - ID int64 - Name string - CreatedAt sql.NullTime - UpdatedAt sql.NullTime + ID int64 + Name string + NumberOfTeams int64 + CreatedAt sql.NullTime + UpdatedAt sql.NullTime } diff --git a/internal/database/queries/game.sql b/internal/database/queries/game.sql index b64fb14..fd61796 100644 --- a/internal/database/queries/game.sql +++ b/internal/database/queries/game.sql @@ -2,7 +2,6 @@ insert into games (name, team_a, team_b, - tournament_id, results_log, status, rounds, @@ -11,7 +10,6 @@ values (?, ?, ?, ?, - ?, 'pending', ?, ?) @@ -22,6 +20,11 @@ select * from games where id = ?; +-- name: StartGame :exec +update games +set status = 'running' +where id = ?; + -- name: UpdateGame :one update games set name = ?, @@ -31,8 +34,7 @@ set name = ?, status = ?, results_log = ?, rounds = ?, - current_round = ?, - tournament_id = ? + current_round = ? where id = ? returning *; @@ -41,4 +43,28 @@ select * from games where status = 'complete' and (team_a = ? or team_b = ?) -order by updated_at desc; \ No newline at end of file +order by updated_at desc; + +-- name: CreateTournament :one +insert into tournaments (name, + number_of_teams) +values (?, + ?) +returning *; + +-- name: CreateStage :one +insert into stages (name, tournament_id, status) +values (?, ?, ?) +returning *; + +-- name: AllocateGameToStage :one +insert into stage_games (stage_id, game_id) +values (?, ?) +returning *; + +-- name: UpdateStage :one +update stages +set name = ?, + status = ? +where id = ? +returning *; \ No newline at end of file diff --git a/internal/database/schema/game.sql b/internal/database/schema/game.sql index 4a23c88..5d9f62d 100644 --- a/internal/database/schema/game.sql +++ b/internal/database/schema/game.sql @@ -1,23 +1,44 @@ +create table if not exists tournaments +( + id integer primary key autoincrement, + name text not null, + number_of_teams integer not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + + +create table if not exists stages +( + id integer primary key autoincrement, + tournament_id integer references tournaments (id), + name text not null, + status text not null, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); -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 stage_games +( + id integer primary key autoincrement, + stage_id integer references stages (id), + game_id integer references games (id), + 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 +create table if not exists games +( + id integer primary key autoincrement, + name text not null, + 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/games/dice_roll.go b/internal/games/decision_engine.go similarity index 78% rename from internal/games/dice_roll.go rename to internal/games/decision_engine.go index 736e387..f558ac0 100644 --- a/internal/games/dice_roll.go +++ b/internal/games/decision_engine.go @@ -12,30 +12,8 @@ func DiceRoll() int { return rand.IntN(6) + 1 } -type TeamDecisionInput struct { - triggeredAugment augments.Name - passivesAugments []augments.Effect - player int64 - roll int -} - -type DecisionInput struct { - lastRound *DuelResult - teamA TeamDecisionInput - teamB TeamDecisionInput -} - -type DecisionEngineFunc func(input DecisionInput) DecisionInput - -type Engine struct { - beforeRoll []DecisionEngineFunc - afterRoll []DecisionEngineFunc - afterAugments []DecisionEngineFunc - rollFn RollFn -} - -func NewDecisionEngine() *Engine { - return &Engine{ +func NewDecisionEngine() *DecisionEngine { + return &DecisionEngine{ beforeRoll: []DecisionEngineFunc{ RulePocketSand, }, @@ -58,7 +36,7 @@ func NewDecisionEngine() *Engine { } } -func (e *Engine) Run(input DecisionInput) DuelResult { +func (e *DecisionEngine) Run(input DecisionInput) DuelResult { for _, ruleFn := range e.beforeRoll { input = ruleFn(input) } diff --git a/internal/games/dice_roll_rules.go b/internal/games/decision_engine_rules.go similarity index 100% rename from internal/games/dice_roll_rules.go rename to internal/games/decision_engine_rules.go diff --git a/internal/games/dice_roll_rules_test.go b/internal/games/decision_engine_rules_test.go similarity index 100% rename from internal/games/dice_roll_rules_test.go rename to internal/games/decision_engine_rules_test.go diff --git a/internal/games/dice_roll_test.go b/internal/games/decision_engine_test.go similarity index 98% rename from internal/games/dice_roll_test.go rename to internal/games/decision_engine_test.go index b91e03c..7f73dd4 100644 --- a/internal/games/dice_roll_test.go +++ b/internal/games/decision_engine_test.go @@ -16,7 +16,7 @@ func TestEngineRun(t *testing.T) { afterCalled := false afterAugmentsCalled := false - engine := &Engine{ + engine := &DecisionEngine{ beforeRoll: []DecisionEngineFunc{ func(input DecisionInput) DecisionInput { beforeCalled = true @@ -54,7 +54,7 @@ func TestEngineRun(t *testing.T) { Test("should trigger twist of fate effect when rule matches", func(t *testing.T) { secondRolls := newSequentialRollFn([]int{6}) - engine := &Engine{ + engine := &DecisionEngine{ beforeRoll: []DecisionEngineFunc{}, afterRoll: []DecisionEngineFunc{ func(input DecisionInput) DecisionInput { @@ -82,7 +82,7 @@ func TestEngineRun(t *testing.T) { Test("should not trigger twist of fate effect when rule does not match", func(t *testing.T) { secondRolls := newSequentialRollFn([]int{6}) - engine := &Engine{ + engine := &DecisionEngine{ beforeRoll: []DecisionEngineFunc{}, afterRoll: []DecisionEngineFunc{ func(input DecisionInput) DecisionInput { @@ -108,7 +108,7 @@ func TestEngineRun(t *testing.T) { odize.AssertEqual(t, 3, result.RollDelta) }). Test("should return draw when both final rolls are equal", func(t *testing.T) { - engine := &Engine{ + engine := &DecisionEngine{ beforeRoll: []DecisionEngineFunc{}, afterRoll: []DecisionEngineFunc{}, afterAugments: []DecisionEngineFunc{}, diff --git a/internal/games/game.go b/internal/games/game.go index 9147739..00118dc 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -139,7 +139,6 @@ func fromGameModel(m database.Game) (Game, error) { return Game{ id: m.ID, name: m.Name, - tournamentID: &m.TournamentID.Int64, teamA: m.TeamA.Int64, teamB: m.TeamB.Int64, winner: &m.Winner.Int64, @@ -153,13 +152,6 @@ func fromGameModel(m database.Game) (Game, error) { } 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 { @@ -180,9 +172,8 @@ func toGameModel(g Game) (database.Game, error) { } return database.Game{ - ID: g.id, - Name: g.name, - TournamentID: resolvedTournamentID, + ID: g.id, + Name: g.name, TeamA: sql.NullInt64{ Int64: g.teamA, Valid: true, diff --git a/internal/games/interfaces.go b/internal/games/interfaces.go index 72f59c0..e1f736a 100644 --- a/internal/games/interfaces.go +++ b/internal/games/interfaces.go @@ -11,6 +11,7 @@ type Store interface { GetGameByID(ctx context.Context, id int64) (database.Game, error) ListCompletedGamesByTeam(ctx context.Context, arg database.ListCompletedGamesByTeamParams) ([]database.Game, error) UpdateGame(ctx context.Context, arg database.UpdateGameParams) (database.Game, error) + StartGame(ctx context.Context, id int64) error } type RollStrategy interface { diff --git a/internal/games/rounds.go b/internal/games/rounds.go index eaff6ee..8d5d682 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -22,7 +22,7 @@ func (r *Round) FillSquad(a LanesConfig, b LanesConfig) { func (r *Round) ResolveLanes(rollFn RollStrategy) RoundResult { var result []RoundResult - for lane := 0; lane < len(r.TeamA.Lanes); lane++ { + for lane := range len(r.TeamA.Lanes) { laneResult := r.ResolveLane(lane, rollFn) result = append(result, laneResult) } @@ -178,7 +178,7 @@ func (s *TeamFormation) FillLanes(f LanesConfig) { func (s *TeamFormation) LaneFill(lane int, players int, teamPlayers []int64) []int64 { remainder := teamPlayers - for i := 0; i < players; i++ { + for range players { if len(remainder) == 0 { break } diff --git a/internal/games/service.go b/internal/games/service.go index 8662677..d075251 100644 --- a/internal/games/service.go +++ b/internal/games/service.go @@ -16,19 +16,11 @@ func NewService(store Store) *Service { } type NewGameParams struct { - TeamA TeamConfig - TeamB TeamConfig - TournamentID *int64 + TeamA TeamConfig + TeamB TeamConfig } 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 { @@ -45,7 +37,6 @@ func (s *Service) NewGame(ctx context.Context, params NewGameParams) (Game, erro Int64: params.TeamB.TeamID, Valid: true, }, - TournamentID: resolvedTournamentID, ResultsLog: []byte(`[]`), Rounds: roundsJsonData, CurrentRound: 0, @@ -72,7 +63,6 @@ func (s *Service) UpdateGame(ctx context.Context, game Game) (Game, error) { ResultsLog: model.ResultsLog, Rounds: model.Rounds, CurrentRound: model.CurrentRound, - TournamentID: model.TournamentID, ID: model.ID, }) if err != nil { @@ -91,6 +81,10 @@ func (s *Service) GetGame(ctx context.Context, id int64) (Game, error) { return fromGameModel(model) } +func (s *Service) StartGame(ctx context.Context, id int64) error { + return s.Store.StartGame(ctx, id) +} + func (s *Service) CompleteGame(ctx context.Context, game Game) (Game, error) { game.status = StatusComplete diff --git a/internal/games/types.go b/internal/games/types.go index 89477e9..1ee3929 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -31,7 +31,6 @@ const ( type Game struct { id int64 name string - tournamentID *int64 teamA int64 teamB int64 winner *int64 @@ -113,3 +112,25 @@ var ( ErrNoRounds = errors.New("no rounds left") ErrGameNotComplete = errors.New("game not complete") ) + +type TeamDecisionInput struct { + triggeredAugment augments.Name + passivesAugments []augments.Effect + player int64 + roll int +} + +type DecisionInput struct { + lastRound *DuelResult + teamA TeamDecisionInput + teamB TeamDecisionInput +} + +type DecisionEngineFunc func(input DecisionInput) DecisionInput + +type DecisionEngine struct { + beforeRoll []DecisionEngineFunc + afterRoll []DecisionEngineFunc + afterAugments []DecisionEngineFunc + rollFn RollFn +} diff --git a/internal/teams/ai_teams.go b/internal/teams/ai_teams.go index c730a82..dce0bbe 100644 --- a/internal/teams/ai_teams.go +++ b/internal/teams/ai_teams.go @@ -248,7 +248,7 @@ func generateAITeams() ([]AIGenerationParams, error) { nameGen := NewTeamNameGenerator() teamNames := nameGen.GenerateUnique(totalTeams) - for i := 0; i < totalTeams; i++ { + for i := range totalTeams { tmpTeam := AIGenerationParams{} if err := faker.FakeData(&tmpTeam); err != nil { diff --git a/internal/teams/service.go b/internal/teams/service.go index 332eee7..50ea454 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -181,7 +181,7 @@ type createPlayerParams struct { func (s *Service) createPlayers(ctx context.Context, teamID int64) ([]database.Player, error) { modelPlayers := make([]database.Player, 5) - for i := 0; i < 5; i++ { + for i := range 5 { playerName := createPlayerParams{} if err := faker.FakeData(&playerName); err != nil { @@ -233,3 +233,12 @@ func (s *Service) SetDefaultCoach(ctx context.Context, id int64) error { func (s *Service) ClearDefaultCoach(ctx context.Context) error { return s.store.ClearDefaultCoach(ctx) } + +func GetPlayerIDs(team Team) []int64 { + var playerIDs []int64 + for _, player := range team.Players { + playerIDs = append(playerIDs, player.ID) + } + + return playerIDs +} diff --git a/internal/tournaments/interfaces.go b/internal/tournaments/interfaces.go new file mode 100644 index 0000000..7bf46d4 --- /dev/null +++ b/internal/tournaments/interfaces.go @@ -0,0 +1,21 @@ +package tournaments + +import ( + "context" + + "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/teams" +) + +type Store interface { + AllocateGameToStage(ctx context.Context, arg database.AllocateGameToStageParams) (database.StageGame, error) + CreateStage(ctx context.Context, arg database.CreateStageParams) (database.Stage, error) + CreateTournament(ctx context.Context, arg database.CreateTournamentParams) (database.Tournament, error) + UpdateStage(ctx context.Context, arg database.UpdateStageParams) (database.Stage, error) +} + +type TeamsService interface { + ListAITeams(ctx context.Context) ([]teams.AITeam, error) + GetCoachByID(ctx context.Context, coachID int64) (teams.Coach, error) + GetTeamAndPlaybooksByCoachID(ctx context.Context, coachID int64) (teams.TeamWithPlaybooks, error) +} diff --git a/internal/tournaments/tournaments.go b/internal/tournaments/tournaments.go new file mode 100644 index 0000000..88adb89 --- /dev/null +++ b/internal/tournaments/tournaments.go @@ -0,0 +1,205 @@ +package tournaments + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/code-gorilla-au/rush/internal/database" + "github.com/code-gorilla-au/rush/internal/games" + "github.com/code-gorilla-au/rush/internal/teams" +) + +type ServiceDependencies struct { + GamesSvc *games.Service + TeamsSvc TeamsService + Store Store + DB *sql.DB + TxnFunc func(db *sql.Tx) Store +} + +func NewService(deps ServiceDependencies) *Service { + return &Service{ + gamesSvc: deps.GamesSvc, + teamsSvc: deps.TeamsSvc, + store: deps.Store, + DB: deps.DB, + txnFunc: deps.TxnFunc, + } +} + +type CreateTournamentParams struct { + Name string + NumberOfTeams NumberOfTeams + CoachID int64 +} + +func (s *Service) CreateTournament(ctx context.Context, params CreateTournamentParams) error { + t, err := s.insertNewTournament(ctx, params.Name, params.NumberOfTeams) + if err != nil { + return fmt.Errorf("inserting new tournament: %w", err) + } + + gameConfigs, err := s.generateGames(ctx, params.CoachID, int64(params.NumberOfTeams)) + if err != nil { + return fmt.Errorf("generating games for tournament: %w", err) + } + + groupStage := t.Stages[0] + + for _, gameConfig := range gameConfigs { + g, gErr := s.gamesSvc.NewGame(ctx, gameConfig) + + if gErr != nil { + return fmt.Errorf("creating new game: %w", gErr) + } + + if _, err = s.store.AllocateGameToStage(ctx, database.AllocateGameToStageParams{ + StageID: sql.NullInt64{ + Int64: groupStage.ID, + Valid: true, + }, + GameID: sql.NullInt64{ + Int64: g.ID(), + Valid: true, + }, + }); err != nil { + return fmt.Errorf("allocating game to stage: %w", err) + } + } + + return nil +} + +func (s *Service) insertNewTournament(ctx context.Context, name string, numberOfTeams NumberOfTeams) (Tournament, error) { + var newTournament database.Tournament + var stage database.Stage + + err := database.WithTxnCtx(s.DB, func(tx *sql.Tx) error { + txDb := s.txnFunc(tx) + + var err error + + newTournament, err = txDb.CreateTournament(ctx, database.CreateTournamentParams{ + Name: name, + NumberOfTeams: int64(numberOfTeams), + }) + if err != nil { + return err + } + + stage, err = txDb.CreateStage(ctx, database.CreateStageParams{ + Name: "Group stage", + TournamentID: sql.NullInt64{ + Int64: newTournament.ID, + Valid: true, + }, + Status: string(games.StatusPending), + }) + if err != nil { + return err + } + + return nil + }) + if err != nil { + return Tournament{}, fmt.Errorf("failed to create tournament: %w", err) + } + + return toTournament(newTournament, []database.Stage{stage}), nil + +} + +func (s *Service) generateGames(ctx context.Context, coachId int64, numberOfTeams int64) ([]games.NewGameParams, error) { + var tournamentGames []games.NewGameParams + + var totalTeams []teams.AITeam + h, err := s.getHumanTeam(ctx, coachId) + if err != nil { + return tournamentGames, fmt.Errorf("failed to get human team: %w", err) + } + + totalTeams = append(totalTeams, h) + + aiTeams, err := s.getNonHumanTeams(ctx, numberOfTeams-1) + if err != nil { + return tournamentGames, fmt.Errorf("failed to get non-human teams: %w", err) + } + + totalTeams = append(totalTeams, aiTeams...) + + tournamentGames = generateGameParamsFromTeams(totalTeams, tournamentGames) + + return tournamentGames, nil +} + +func (s *Service) getNonHumanTeams(ctx context.Context, aiTeams int64) ([]teams.AITeam, error) { + var tournamentList []teams.AITeam + var err error + + tournamentList, err = s.teamsSvc.ListAITeams(ctx) + if err != nil { + return tournamentList, fmt.Errorf("failed to list teams: %w", err) + } + + if int64(len(tournamentList)) < aiTeams { + return tournamentList, errors.New("not enough teams") + } + + return tournamentList[0:aiTeams], nil + +} + +func (s *Service) getHumanTeam(ctx context.Context, coachID int64) (teams.AITeam, error) { + coach, err := s.teamsSvc.GetCoachByID(ctx, coachID) + if err != nil { + return teams.AITeam{}, fmt.Errorf("failed to get coach: %w", err) + } + + team, err := s.teamsSvc.GetTeamAndPlaybooksByCoachID(ctx, coachID) + if err != nil { + return teams.AITeam{}, fmt.Errorf("failed to get team: %w", err) + } + + if len(team.Playbooks) == 0 { + return teams.AITeam{}, errors.New("no playbooks found for team") + } + + return teams.AITeam{ + Coach: coach, + Team: team.Team, + Playbook: team.Playbooks[0], + }, nil +} + +func generateGameParamsFromTeams(totalTeams []teams.AITeam, tournamentGames []games.NewGameParams) []games.NewGameParams { + + for i := 0; i < len(totalTeams); i++ { + + for j := i + 1; j < len(totalTeams); j++ { + first := totalTeams[i] + second := totalTeams[j] + + tournamentGames = append(tournamentGames, games.NewGameParams{ + TeamA: games.TeamConfig{ + TeamID: first.Team.ID, + TeamName: first.Team.Name, + Players: teams.GetPlayerIDs(first.Team), + Augments: first.Coach.AvailableAugments(), + Formations: first.Playbook.Formations, + }, + TeamB: games.TeamConfig{ + TeamID: second.Team.ID, + TeamName: second.Team.Name, + Players: teams.GetPlayerIDs(second.Team), + Augments: second.Coach.AvailableAugments(), + Formations: second.Playbook.Formations, + }, + }) + } + + } + + return tournamentGames +} diff --git a/internal/tournaments/tournaments_test.go b/internal/tournaments/tournaments_test.go new file mode 100644 index 0000000..a2e3bd3 --- /dev/null +++ b/internal/tournaments/tournaments_test.go @@ -0,0 +1,257 @@ +package tournaments + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "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" + _ "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(context.Background()) + odize.AssertNoError(t, err) + return db +} + +func newTestTournamentService(db *sql.DB) *Service { + queries := database.New(db) + teamsSvc := teams.NewTeamsService(queries, playbooks.NewPlaybooksService(queries)) + gamesSvc := games.NewService(queries) + return NewService(ServiceDependencies{ + GamesSvc: gamesSvc, + TeamsSvc: teamsSvc, + Store: queries, + DB: db, + TxnFunc: func(tx *sql.Tx) Store { return database.New(tx) }, + }) +} + +func createTestCoach(ctx context.Context, t *testing.T, db *sql.DB, name string, isHuman bool) database.Coach { + queries := database.New(db) + coach, err := queries.CreateCoach(ctx, database.CreateCoachParams{ + Name: name, + IsHuman: sql.NullBool{Bool: isHuman, Valid: true}, + }) + odize.AssertNoError(t, err) + return coach +} + +func createTestTeam(ctx context.Context, t *testing.T, db *sql.DB, name string, coachID int64) database.Team { + queries := database.New(db) + team, err := queries.CreateTeam(ctx, database.CreateTeamParams{ + Name: name, + CoachID: sql.NullInt64{Int64: coachID, Valid: true}, + }) + odize.AssertNoError(t, err) + return team +} + +func createTestPlaybook(ctx context.Context, t *testing.T, db *sql.DB, name string, teamID int64) playbooks.Playbook { + queries := database.New(db) + playbooksSvc := playbooks.NewPlaybooksService(queries) + pb, err := playbooksSvc.CreatePlaybook(ctx, playbooks.PlaybookParams{ + Name: name, + TeamID: teamID, + Formations: playbooks.Formations(), + }) + odize.AssertNoError(t, err) + return pb +} + +func TestGenerateGameParamsFromTeams(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group.Test("generates correct number of unique games and ensures no duplicate pairs for 4 teams", func(t *testing.T) { + n := 4 + var totalTeams []teams.AITeam + for i := range n { + totalTeams = append(totalTeams, teams.AITeam{ + Team: teams.Team{ID: int64(i + 1), Name: fmt.Sprintf("Team%d", i+1)}, + Coach: teams.Coach{}, + }) + } + + tournamentGames := generateGameParamsFromTeams(totalTeams, nil) + + expectedGames := (n * (n - 1)) / 2 + odize.AssertEqual(t, expectedGames, len(tournamentGames)) + + seenPairs := make(map[string]bool) + for _, game := range tournamentGames { + pairKey := fmt.Sprintf("%d-%d", game.TeamA.TeamID, game.TeamB.TeamID) + reverseKey := fmt.Sprintf("%d-%d", game.TeamB.TeamID, game.TeamA.TeamID) + + odize.AssertFalse(t, seenPairs[pairKey]) + odize.AssertFalse(t, seenPairs[reverseKey]) + + seenPairs[pairKey] = true + } + }). + Test("handles 0 teams", func(t *testing.T) { + tournamentGames := generateGameParamsFromTeams([]teams.AITeam{}, nil) + odize.AssertEqual(t, 0, len(tournamentGames)) + }). + Test("handles 1 team", func(t *testing.T) { + totalTeams := []teams.AITeam{ + {Team: teams.Team{ID: 1, Name: "Team1"}, Coach: teams.Coach{}}, + } + tournamentGames := generateGameParamsFromTeams(totalTeams, nil) + odize.AssertEqual(t, 0, len(tournamentGames)) + }). + Test("handles 2 teams", func(t *testing.T) { + totalTeams := []teams.AITeam{ + {Team: teams.Team{ID: 1, Name: "Team1"}, Coach: teams.Coach{}, Playbook: playbooks.Playbook{Formations: []playbooks.Formation{{Name: "F1"}}}}, + {Team: teams.Team{ID: 2, Name: "Team2"}, Coach: teams.Coach{}, Playbook: playbooks.Playbook{Formations: []playbooks.Formation{{Name: "F2"}}}}, + } + tournamentGames := generateGameParamsFromTeams(totalTeams, nil) + odize.AssertEqual(t, 1, len(tournamentGames)) + odize.AssertEqual(t, []playbooks.Formation{{Name: "F1"}}, tournamentGames[0].TeamA.Formations) + odize.AssertEqual(t, []playbooks.Formation{{Name: "F2"}}, tournamentGames[0].TeamB.Formations) + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestService_CreateTournament_EdgeCases(t *testing.T) { + group := odize.NewGroup(t, nil) + + var db *sql.DB + var s *Service + + group.BeforeEach(func() { + db = setupTestDB(t) + s = newTestTournamentService(db) + }) + + group.AfterEach(func() { + if db != nil { + _ = db.Close() + } + }) + + err := group. + Test("returns error when not enough AI teams", func(t *testing.T) { + ctx := context.Background() + + // Create a coach + coach := createTestCoach(ctx, t, db, "Coach", true) + + // Create a team for the coach + createTestTeam(ctx, t, db, "Team", coach.ID) + + // Not enough AI teams (trying to request 2 teams, but only 1 AI team exists) + err := s.CreateTournament(ctx, CreateTournamentParams{ + Name: "Tournament", + NumberOfTeams: NumberOfTeams(2), + CoachID: coach.ID, + }) + odize.AssertTrue(t, err != nil) + }). + Test("returns error when coach does not exist", func(t *testing.T) { + err := s.CreateTournament(context.Background(), CreateTournamentParams{ + Name: "Tournament", + NumberOfTeams: NumberOfTeams(2), + CoachID: 999, + }) + odize.AssertTrue(t, err != nil) + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestService_CreateTournament_Success(t *testing.T) { + group := odize.NewGroup(t, nil) + + var db *sql.DB + var s *Service + + group.BeforeEach(func() { + db = setupTestDB(t) + s = newTestTournamentService(db) + }) + + group.AfterEach(func() { + if db != nil { + _ = db.Close() + } + }) + + err := group. + Test("successfully creates a tournament", func(t *testing.T) { + ctx := context.Background() + + // Create a coach + coach := createTestCoach(ctx, t, db, "Coach", true) + + // Create a team for the coach + team := createTestTeam(ctx, t, db, "HumanTeam", coach.ID) + + // Create a playbook for the team + createTestPlaybook(ctx, t, db, "PB", team.ID) + + // Create AI teams + for i := 0; i < 2; i++ { + aiCoach := createTestCoach(ctx, t, db, fmt.Sprintf("AICoach%d", i), false) + aiTeam := createTestTeam(ctx, t, db, fmt.Sprintf("Team%d", i), aiCoach.ID) + + createTestPlaybook(ctx, t, db, "PB", aiTeam.ID) + } + + // Create Tournament + err := s.CreateTournament(ctx, CreateTournamentParams{ + Name: "TestTournament", + NumberOfTeams: NumberOfTeams(3), + CoachID: coach.ID, + }) + odize.AssertNoError(t, err) + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestService_GetHumanTeam_NoPlaybooks(t *testing.T) { + group := odize.NewGroup(t, nil) + + var db *sql.DB + var s *Service + + group.BeforeEach(func() { + db = setupTestDB(t) + s = newTestTournamentService(db) + }) + + group.AfterEach(func() { + if db != nil { + _ = db.Close() + } + }) + + err := group.Test("returns error when no playbooks found", func(t *testing.T) { + ctx := context.Background() + // Create a coach + coach := createTestCoach(ctx, t, db, "Coach", true) + + // Create a team for the coach + createTestTeam(ctx, t, db, "Team", coach.ID) + + // getHumanTeam should return error + _, err := s.getHumanTeam(ctx, coach.ID) + odize.AssertTrue(t, err != nil) + odize.AssertEqual(t, "no playbooks found for team", err.Error()) + }).Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/tournaments/transforms.go b/internal/tournaments/transforms.go new file mode 100644 index 0000000..31beca3 --- /dev/null +++ b/internal/tournaments/transforms.go @@ -0,0 +1,27 @@ +package tournaments + +import "github.com/code-gorilla-au/rush/internal/database" + +func toTournament(tournament database.Tournament, stages []database.Stage) Tournament { + return Tournament{ + ID: tournament.ID, + Name: tournament.Name, + Number: NumberOfTeams(tournament.NumberOfTeams), + Stages: toStages(stages), + } +} + +func toStages(stages []database.Stage) []Stage { + var stagesTransformed []Stage + for _, stage := range stages { + stagesTransformed = append(stagesTransformed, toStage(stage)) + } + return stagesTransformed +} + +func toStage(stage database.Stage) Stage { + return Stage{ + ID: stage.ID, + Name: stage.Name, + } +} diff --git a/internal/tournaments/types.go b/internal/tournaments/types.go new file mode 100644 index 0000000..397077d --- /dev/null +++ b/internal/tournaments/types.go @@ -0,0 +1,44 @@ +package tournaments + +import ( + "database/sql" + + "github.com/code-gorilla-au/rush/internal/games" +) + +type NumberOfTeams int64 + +const ( + Four NumberOfTeams = 4 + Eight NumberOfTeams = 8 +) + +type Tournament struct { + ID int64 `json:"id"` + Name string `json:"name"` + Number NumberOfTeams `json:"number_of_teams"` + Stages []Stage `json:"stages"` +} + +type StageStatus string + +const ( + StageStatusActive StageStatus = "active" + StageStatusPending StageStatus = "pending" + StageStatusComplete StageStatus = "complete" +) + +type Stage struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status StageStatus `json:"status"` + Games []games.Game `json:"games"` +} + +type Service struct { + teamsSvc TeamsService + gamesSvc *games.Service + store Store + DB *sql.DB + txnFunc func(db *sql.Tx) Store +} diff --git a/internal/ui/page_rules.go b/internal/ui/page_rules.go index 31fe55d..c75da13 100644 --- a/internal/ui/page_rules.go +++ b/internal/ui/page_rules.go @@ -2,7 +2,6 @@ package ui import ( _ "embed" - "fmt" "github.com/code-gorilla-au/rush/internal/ui/styles" "github.com/code-gorilla-au/rush/internal/ui/uistate" @@ -79,16 +78,6 @@ func (m *ModelRules) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *ModelRules) headerView() string { - title := m.theme.Title.Render("Rules") - return title -} - -func (m *ModelRules) footerView() string { - info := m.theme.Muted.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) - return info -} - func (m *ModelRules) View() tea.View { if !m.ready { return tea.NewView("Initializing...") diff --git a/internal/ui/uibattle/page_battle_confirm.go b/internal/ui/uibattle/page_battle_confirm.go index e6f2bf1..b7cc1a2 100644 --- a/internal/ui/uibattle/page_battle_confirm.go +++ b/internal/ui/uibattle/page_battle_confirm.go @@ -108,6 +108,10 @@ func (m *PageBattleConfirmModel) createGame() tea.Msg { return err } + if err = m.gameSvc.StartGame(m.globalState.Context(), game.ID()); err != nil { + return fmt.Errorf("could not start game: %w", err) + } + return uistate.MsgSwitchPage{ NewPage: uistate.PageGame, GameID: game.ID(),