diff --git a/.golangci.yaml b/.golangci.yaml index b438533..55bb2f8 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -30,7 +30,7 @@ linters: rules: # Exclude some linters from running on tests files. - - path: _test\.go + - path: _test.go linters: - gocyclo - cyclop @@ -38,11 +38,20 @@ linters: - gosec - forbidigo - exhaustruct + - unused settings: depguard: rules: main: list-mode: lax + ireturn: + allow: + - charm.land/bubbletea/v2.Model + - charm.land/bubbletea/v2.Msg + - charm.land/bubbletea/v2.Cmd + - error + - generic + - stdlib diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 159050b..bed27a0 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -1,6 +1,7 @@ ## Global rules - You must always ask before creating mocks +- DO NOT USE VITEST FOR GOLANG ## SQLite queries diff --git a/docs/features.md b/docs/features.md index 809c1c2..6b172b7 100644 --- a/docs/features.md +++ b/docs/features.md @@ -2,7 +2,12 @@ List of features intended for the project. -- [ ] Single battle +- [X] Single battle +- [X] Battle confirmation upgrade +- [X] Better player names +- [ ] Better coach creation journey +- [ ] Events emitted for lane battles +- [ ] Settings to clear game data - [ ] Tournament mode -- [ ] Record battles -- [ ] Team statistics \ No newline at end of file +- [X] Record battles +- [X] Team statistics \ No newline at end of file diff --git a/docs/proposal.md b/docs/proposal.md new file mode 100644 index 0000000..07df7ca --- /dev/null +++ b/docs/proposal.md @@ -0,0 +1,131 @@ +### Proposal: Token-Only Tactical Playtest (Rules-Ready Draft) + +This revision removes `Attack/Defense` and `Rush Lane` mechanics for now. +The playtest focus is strictly on high-impact token decisions and coach identity through token access. + +--- + +### 1. Playtest Scope and Core Rules + +#### Scope +* This phase tests only token-driven decision making. +* No `ATK/DEF` stats. +* No `Rush Lane` declarations. + +#### Duel Flow (Token Windows) +1. **Pre-roll Window**: Coaches may declare one Pre-roll token. +2. **Roll**: Both players roll simultaneously. +3. **Reveal**: Results are revealed. +4. **Reaction Window**: Eligible Reaction tokens may be declared. +5. **Resolution Window**: Eligible Resolution tokens may be declared. +6. **Lane Update**: Apply a final outcome for the duel. + +#### Duel Definition and Token Scope +* A **duel** is one complete lane interaction between the two opposing players, from Pre-roll Window through Lane Update. +* A lane may contain multiple duels across a match (for example, if a lane remains unresolved and is contested again later). +* Unless a token explicitly says otherwise, a token affects **only the current duel** in that lane. +* Token effects apply to all rolls that occur inside that duel (for example, replacement re-rolls), but never carry into future duels or other lanes. + +#### Global Token Constraints +* Each coach equips exactly **3 tokens** per match. +* Each equipped token is **single-use**. +* Max **1 token per coach per duel**. +* Both coaches may use a token in the same duel. +* Tokens do not stack for the same coach in a single duel. + +--- + +### 2. Token Library (Expanded for User Testing) + +#### Twist of Fate (Advantage) +* **Timing**: Pre-roll Window. +* **Effect**: Roll `2d6` and keep the highest. +* **Intent**: Front-load pressure in must-win lanes. + +#### Second Chance (Reactive Re-roll) +* **Timing**: Reaction Window, only if your first roll did not win. +* **Effect**: Re-roll your own die once; second result replaces the first. +* **Intent**: Stabilize critical moments after a miss or tie. + +#### Power Play (Flat Boost) +* **Timing**: Pre-roll Window. +* **Effect**: Gain `+1` to your roll total this duel. +* **Intent**: Reliable low-variance push. + +#### Brace (Damage Control) +* **Timing**: Pre-roll Window. +* **Effect**: Opponent gets `-1` to their roll total this duel. +* **Intent**: Defensive denial and tempo slowdown. + +#### Precision Strike (Near-Miss Conversion) +* **Timing**: Reaction Window, only if you lost by exactly `1`. +* **Effect**: Add `+1` to your revealed total. +* **Intent**: Skill-expression token for close reads. + +#### Jamming Signal (Pre-roll Counter) +* **Timing**: Pre-roll Window. +* **Effect**: Cancel the opponent's declared Pre-roll token. +* **Intent**: Anti-pattern counterplay. +* **Constraint**: Cannot cancel Reaction or Resolution tokens. + +#### Last Stand (Resolution Save) +* **Timing**: Resolution Window, only if you would lose the duel. +* **Effect**: Prevent your elimination this duel; lane remains unresolved. +* **Intent**: Comeback insurance for high-value lanes. + +#### Momentum Surge (Win Streak Convert) +* **Timing**: Pre-roll Window, only if you won your previous duel. +* **Effect**: Gain `+2` this duel. +* **Intent**: Snowball option with explicit condition gate. + +#### Ice in Veins (Tie Breaker) +* **Timing**: Resolution Window, only on a tie. +* **Effect**: Convert tie into a win for your side. +* **Intent**: Tie-state control and clutch finish potential. + +#### Smoke Screen (Information Denial) +* **Timing**: Pre-roll Window. +* **Effect**: Your token declaration remains hidden until after reveal. +* **Intent**: Mind-game tool to punish reactive opponents. + +--- + +### 3. Coach Personas (Token Access Limits) + +At match start, the player selects one coach persona. +Each persona restricts which tokens can be equipped for that match. + +#### Coach Loadout Rule +* Equip exactly **3 single-use tokens** from your persona's allowed list. +* No duplicate token names in the same loadout. + +#### Persona A: Vanguard Coach (Aggressive) +* **Allowed Tokens**: `Twist of Fate`, `Power Play`, `Momentum Surge`, `Precision Strike`, `Ice in Veins` +* **Playstyle**: Tempo-first and lane conversion pressure. +* **Restriction Theme**: No hard defensive save tokens. + +#### Persona B: Bastion Coach (Defensive) +* **Allowed Tokens**: `Second Chance`, `Brace`, `Last Stand`, `Jamming Signal`, `Ice in Veins` +* **Playstyle**: Attrition, denial, and mistake recovery. +* **Restriction Theme**: No high-spike momentum token. + +#### Persona C: Trickster Coach (Control) +* **Allowed Tokens**: `Jamming Signal`, `Smoke Screen`, `Precision Strike`, `Second Chance`, `Power Play` +* **Playstyle**: Information warfare and timing traps. +* **Restriction Theme**: No direct resolution-save token. + +#### Persona D: Wildcard Coach (Flexible) +* **Allowed Tokens**: Any token except `Momentum Surge` and `Last Stand`. +* **Playstyle**: Broad adaptability with reduced extreme effects. +* **Restriction Theme**: No strongest snowball/safety endpoints. + +--- + +### 4. Playtest Objectives + +Track these outcomes to evaluate whether token mechanics are fun and understandable: +* **Token usage rate** by token and by persona. +* **Round swing rate** (how often a token changes the duel winner). +* **Persona pick rate** and win rate spread. +* **Perceived fairness** (post-match user rating). +* **Clarity score** (players can explain what each used token did). diff --git a/docs/rules.md b/docs/rules.md index 1b0fd05..7e5848d 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -1,13 +1,103 @@ -# Rules +# Rules Game rules. -## Game +## Background -- 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 +Rush is a five-player lane battle game. Rush takes some elements from risk and battle arena games. + +## Match setup + +- A match is ten rounds. +- At the battle selection screen, a human coach picks an opponent. +- Human coaches select a coach persona (Vanguard, Bastion, Trickster, or Wildcard). +- Human coaches must select one of their created playbooks before the match starts. +- A playbook defines how the coach assigns players to lanes and how those assignments are prioritised each round. +- Coaches equip exactly three single-use tokens from their persona's allowed list. + +## Round flow + +Each round is resolved in this order: + +1. **Reset players** + - All players return for the new round, even if they were eliminated in the previous round. + +2. **Augmentation and Lane assignment** + - Before each round, coaches have an option of providing an augmentation from their equipped list. + - A coach can use a maximum of 3 augmentations per game. + - Coaches determine where their players go across the three lanes. + - Assignments are made according to the selected playbook. + +3. **Lane duels** + - Players in the same lane duel and roll `1d6`. Highest score wins. + - If dice rolls are equal, immediately re-roll. + - The losing player is eliminated for the round. + - Lane duels continue until no opposing players remain in that lane. + +4. **Round completion and scoring** + - The round only ends when **all lanes** have completed their duels. + - If the total number of remaining players on each team is equal, the round is a draw. + - The team with the higher number of remaining players gets `1` point for the round. + +## Win condition + +- After ten rounds, the coach with the most points wins. +- If both teams have the same number of points, the game is a draw. + +## Strategic depth mechanics + +To keep the game simple but add meaningful decisions, playbooks and coaches should include the following strategic layers: + +### 1) Lane priority planning + +- Each playbook should define a lane priority profile (for example: balanced, left-heavy, right-heavy, center-control). +- Coaches can shift commitments between rounds, but every shift creates tradeoffs in the other lanes. + +### 2) Tempo vs. control choices + +- Coaches should choose between: + - **Tempo play**: stack one lane to secure fast eliminations. + - **Control play**: spread players to contest all lanes and reduce variance. +- This creates different risk profiles across rounds instead of repeating one optimal pattern. + +### 3) Tactical Tokens + +Coaches use tokens to influence the outcome of the match. +- Each token is **single-use** per match. +- Max **3 augmentations per game**. +- An augmentation is selected **before each round**. + +#### Token Library + +| Token | Timing | Effect | +| :--- | :--- | :--- | +| **Twist of Fate** | Pre-roll | Roll 2d6 and keep the highest. | +| **Overpower** | Pre-roll | Gain +1 to your roll total this duel. | +| **Hamstring** | Pre-roll | Opponent gets -1 to their roll total this duel. | +| **Jamming Signal** | Pre-roll | Cancel the opponent's declared Pre-roll token. | +| **Momentum Surge** | Pre-roll | If you won your previous duel, gain +2 this duel. | +| **Second Chance** | Reaction | Re-roll your own die once; second result replaces the first. | +| **Precision Strike** | Reaction | Add +1 to your revealed total (usually if losing by 1). | +| **Last Stand** | Resolution | Prevent your elimination; lane remains unresolved for this duel. | +| **Ice in Veins** | Resolution | Convert a tie into a win for your side. | + +### 4) Coach Personas + +At match start, the player selects one coach persona. Each persona restricts which tokens can be equipped for that match. + +- **Vanguard Coach (Aggressive)**: Focuses on tempo and lane conversion. + - *Allowed*: Twist of Fate, Overpower, Momentum Surge, Precision Strike, Ice in Veins. +- **Bastion Coach (Defensive)**: Focuses on attrition, denial, and mistake recovery. + - *Allowed*: Second Chance, Hamstring, Last Stand, Jamming Signal, Ice in Veins. +- **Trickster Coach (Control)**: Focuses on information and timing traps. + - *Allowed*: Jamming Signal, Precision Strike, Second Chance, Overpower. +- **Wildcard Coach (Flexible)**: Broad adaptability with reduced extreme effects. + - *Allowed*: Twist of Fate, Second Chance, Overpower, Hamstring, Precision Strike, Jamming Signal, Ice in Veins. + +### 5) Lane outcome value beyond score + +- Keep round scoring at `1` point, but track lane-level performance for tie context and future tournament systems: + - lanes won, + - survivor differential, + - clutch re-roll usage. +- These metrics improve strategy feedback without changing the core win condition. \ No newline at end of file diff --git a/internal/augments/augments.go b/internal/augments/augments.go new file mode 100644 index 0000000..da40a24 --- /dev/null +++ b/internal/augments/augments.go @@ -0,0 +1,210 @@ +package augments + +import ( + "maps" + "slices" +) + +var ( + _offenseRepo = map[Name]Effect{ + TwistOfFate: { + Name: TwistOfFate, + Category: CategoryOffense, + Type: TypeActive, + Trigger: TriggerConditionAfterRoll, + Effect: "Roll 2d6 and keep the highest.", + Intent: "Front-load pressure in must-win lanes.", + Action: ActionAddDie, + Target: TargetSelf, + Amount: 1, + }, + Overpower: { + Name: Overpower, + Category: CategoryOffense, + Type: TypeActive, + Trigger: TriggerConditionBeforeRoll, + Effect: "Gain +1 to your roll total this duel.", + Intent: "Reliable low-variance push.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 1, + }, + PrecisionStrike: { + Name: PrecisionStrike, + Category: CategoryOffense, + Type: TypeActive, + Effect: "Add +1 to your revealed total.", + Intent: "Skill-expression token for close reads.", + Action: ActionIncrease, + Target: TargetSelf, + }, + MomentumSurge: { + Name: MomentumSurge, + Category: CategoryOffense, + Type: TypePassive, + Trigger: TriggerConditionAfterRoll, + Effect: "If last duel was a win, gain +2 this duel.", + Intent: "Snowball option with explicit condition gate.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 2, + }, + } + + _defenseRepo = map[Name]Effect{ + Brace: { + Name: Brace, + Category: CategoryDefense, + Type: TypeActive, + Trigger: TriggerConditionAfterRoll, + Effect: "Losing roll by 1, convert result to a tie.", + Intent: "Stabilize critical moments after a close loss", + Action: ActionResultTie, + Target: TargetBoth, + Amount: 0, + }, + Fortify: { + Name: Fortify, + Category: CategoryDefense, + Type: TypePassive, + Trigger: TriggerConditionAfterRoll, + Effect: "If last duel was a tie, gain +1 to roll", + Intent: "Turn the tides on opponent momentum", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 1, + }, + SecondChance: { + Name: SecondChance, + Category: CategoryDefense, + Type: TypePassive, + Trigger: TriggerConditionAfterRoll, + Effect: "Re-roll your own die once; second result replaces the first.", + Intent: "Stabilize critical moments after a miss or tie.", + Action: ActionReRoll, + Target: TargetSelf, + Amount: 1, + }, + LastStand: { + Name: LastStand, + Category: CategoryDefense, + Type: TypeActive, + Trigger: TriggerConditionAfterRoll, + Effect: "If last duel was a loss, and only 1 player remains in the lane, +2 to roll", + Intent: "Slow down snowball effects.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 2, + }, + } + + _sabotageRepo = map[Name]Effect{ + Hamstring: { + Name: Hamstring, + Category: CategorySabotage, + Type: TypeActive, + Trigger: TriggerConditionAfterRoll, + Effect: "Opponent gets -1 to their roll total this duel.", + Intent: "Defensive denial and tempo slowdown.", + Action: ActionDecrease, + Target: TargetOpponent, + Amount: 1, + }, + PocketSand: { + Name: PocketSand, + Category: CategorySabotage, + Type: TypeActive, + Trigger: TriggerConditionBeforeRoll, + Effect: "Cancel the opponent's declared Pre-roll augment.", + Intent: "Anti-pattern counterplay.", + Action: ActionCancel, + Target: TargetOpponent, + Amount: 0, + }, + PoisonEdge: { + Name: PoisonEdge, + Category: CategorySabotage, + Type: TypeActive, + Trigger: TriggerConditionAfterRoll, + Effect: "If last duel was a loss, and only 1 player remains in the lane, +2 to roll", + Intent: "Slow down snowball effects.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 2, + }, + IceInVeins: { + Name: IceInVeins, + Category: CategorySabotage, + Type: TypePassive, + Trigger: TriggerConditionAfterAugments, + Effect: "Convert tie into a win for your side.", + Intent: "Tie-state control and clutch finish potential.", + Action: ActionIncrease, + Target: TargetSelf, + Amount: 1, + }, + } + + _noop = map[Name]Effect{ + NoAugment: { + Name: NoAugment, + Category: CategoryNoOp, + Type: TypePassive, + Trigger: TriggerConditionBeforeRoll, + Effect: "No augment.", + Intent: "Standard attack", + Action: ActionNoOp, + Target: TargetSelf, + Amount: 0, + }, + } + + _repositories = []map[Name]Effect{ + _offenseRepo, + _defenseRepo, + _sabotageRepo, + _noop, + } +) + +func Get(name Name) (Effect, bool) { + for _, repository := range _repositories { + item, ok := repository[name] + if ok { + return item, true + } + } + + return Effect{}, false +} + +func GetByCategory(category Category) ([]Effect, bool) { + var repo map[Name]Effect + + switch category { + case CategoryOffense: + repo = _offenseRepo + case CategoryDefense: + repo = _defenseRepo + case CategorySabotage: + repo = _sabotageRepo + default: + return []Effect{}, false + } + + return slices.Collect(maps.Values(repo)), true +} + +func NamesFromCategory(category Category) []Name { + repo, ok := GetByCategory(category) + if !ok { + return []Name{} + } + + var result []Name + for _, effect := range repo { + result = append(result, effect.Name) + } + + return result +} diff --git a/internal/augments/augments_test.go b/internal/augments/augments_test.go new file mode 100644 index 0000000..a04c1a9 --- /dev/null +++ b/internal/augments/augments_test.go @@ -0,0 +1,151 @@ +package augments + +import ( + "slices" + "testing" + + "github.com/code-gorilla-au/odize" +) + +func TestRepository(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should contain all tokens from proposal", func(t *testing.T) { + expectedTokens := []Name{ + NoAugment, + TwistOfFate, + SecondChance, + Overpower, + Hamstring, + PrecisionStrike, + PocketSand, + LastStand, + MomentumSurge, + IceInVeins, + Brace, + Fortify, + PoisonEdge, + } + + for _, name := range expectedTokens { + _, ok := Get(name) + odize.AssertTrue(t, ok) + } + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestGet(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should return effect and true for valid token", func(t *testing.T) { + effect, ok := Get(TwistOfFate) + odize.AssertTrue(t, ok) + odize.AssertEqual(t, TwistOfFate, effect.Name) + odize.AssertEqual(t, ActionAddDie, effect.Action) + odize.AssertEqual(t, 1, effect.Amount) + }). + Test("should return empty effect and false for non-existent token", func(t *testing.T) { + effect, ok := Get(Name("NonExistent")) + odize.AssertFalse(t, ok) + odize.AssertEqual(t, Effect{}, effect) + }). + Test("should return empty effect and false for empty name", func(t *testing.T) { + effect, ok := Get(Name("")) + odize.AssertFalse(t, ok) + odize.AssertEqual(t, Effect{}, effect) + }). + Test("should return false for case-insensitive mismatch", func(t *testing.T) { + _, ok := Get(Name("twist of fate")) + odize.AssertFalse(t, ok) + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestGetByCategory(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should return offense effects for offense category", func(t *testing.T) { + effects, ok := GetByCategory(CategoryOffense) + odize.AssertTrue(t, ok) + odize.AssertEqual(t, 4, len(effects)) + + for _, effect := range effects { + odize.AssertEqual(t, CategoryOffense, effect.Category) + } + }). + Test("should return defense effects for defense category", func(t *testing.T) { + effects, ok := GetByCategory(CategoryDefense) + odize.AssertTrue(t, ok) + odize.AssertEqual(t, 4, len(effects)) + + for _, effect := range effects { + odize.AssertEqual(t, CategoryDefense, effect.Category) + } + }). + Test("should return sabotage effects for sabotage category", func(t *testing.T) { + effects, ok := GetByCategory(CategorySabotage) + odize.AssertTrue(t, ok) + odize.AssertEqual(t, 4, len(effects)) + + for _, effect := range effects { + odize.AssertEqual(t, CategorySabotage, effect.Category) + } + }). + Test("should return empty effects and false for unknown category", func(t *testing.T) { + effects, ok := GetByCategory(Category("unknown")) + odize.AssertFalse(t, ok) + odize.AssertEqual(t, 0, len(effects)) + }). + Run() + + odize.AssertNoError(t, err) +} + +func TestNamesFromCategory(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should return offense names", func(t *testing.T) { + names := NamesFromCategory(CategoryOffense) + odize.AssertEqual(t, 4, len(names)) + odize.AssertTrue(t, slices.Contains(names, TwistOfFate)) + odize.AssertTrue(t, slices.Contains(names, Overpower)) + odize.AssertTrue(t, slices.Contains(names, PrecisionStrike)) + odize.AssertTrue(t, slices.Contains(names, MomentumSurge)) + }). + Test("should return defense names", func(t *testing.T) { + names := NamesFromCategory(CategoryDefense) + odize.AssertEqual(t, 4, len(names)) + odize.AssertTrue(t, slices.Contains(names, Brace)) + odize.AssertTrue(t, slices.Contains(names, Fortify)) + odize.AssertTrue(t, slices.Contains(names, SecondChance)) + odize.AssertTrue(t, slices.Contains(names, LastStand)) + }). + Test("should return sabotage names", func(t *testing.T) { + names := NamesFromCategory(CategorySabotage) + odize.AssertEqual(t, 4, len(names)) + odize.AssertTrue(t, slices.Contains(names, Hamstring)) + odize.AssertTrue(t, slices.Contains(names, PocketSand)) + odize.AssertTrue(t, slices.Contains(names, PoisonEdge)) + odize.AssertTrue(t, slices.Contains(names, IceInVeins)) + }). + Test("should return empty slice for NoOp category", func(t *testing.T) { + names := NamesFromCategory(CategoryNoOp) + odize.AssertEqual(t, 0, len(names)) + }). + Test("should return empty slice for unknown category", func(t *testing.T) { + names := NamesFromCategory(Category("unknown")) + odize.AssertEqual(t, 0, len(names)) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/augments/types.go b/internal/augments/types.go new file mode 100644 index 0000000..0bff246 --- /dev/null +++ b/internal/augments/types.go @@ -0,0 +1,75 @@ +package augments + +type Name string + +const ( + NoAugment Name = "No Augment" + TwistOfFate Name = "Twist of Fate" + SecondChance Name = "Second Chance" + Overpower Name = "Overpower" + Hamstring Name = "Hamstring" + PrecisionStrike Name = "Precision Strike" + PocketSand Name = "Pocket Sand" + LastStand Name = "Last Stand" + MomentumSurge Name = "Momentum Surge" + IceInVeins Name = "Ice in Veins" + Brace Name = "Brace" + Fortify Name = "Fortify" + PoisonEdge Name = "Critical Hit" +) + +type Action string + +const ( + ActionIncrease Action = "increase" + ActionDecrease Action = "decrease" + ActionAddDie Action = "add_die" + ActionReRoll Action = "re_roll" + ActionCancel Action = "cancel" + ActionResultTie Action = "result_tie" + ActionNoOp Action = "no_op" +) + +type Target string + +const ( + TargetSelf Target = "self" + TargetOpponent Target = "opponent" + TargetBoth Target = "both" +) + +type Category string + +const ( + CategoryOffense Category = "offense" + CategoryDefense Category = "defense" + CategorySabotage Category = "sabotage" + CategoryNoOp Category = "no_op" +) + +type Type string + +const ( + TypeActive Type = "active" + TypePassive Type = "passive" +) + +type TriggerCondition string + +const ( + TriggerConditionBeforeRoll TriggerCondition = "before_roll" + TriggerConditionAfterRoll TriggerCondition = "after_roll" + TriggerConditionAfterAugments TriggerCondition = "after_augments" +) + +type Effect struct { + Name Name `json:"name,omitempty"` + Category Category `json:"category,omitempty"` + Type Type `json:"type,omitempty"` + Trigger TriggerCondition `json:"trigger,omitempty"` + Effect string `json:"effect,omitempty"` + Intent string `json:"intent,omitempty"` + Action Action `json:"action,omitempty"` + Target Target `json:"target,omitempty"` + Amount int `json:"amount,omitempty"` +} diff --git a/internal/database/game.sql.gen.go b/internal/database/game.sql.gen.go index f5a6cb9..9e247c3 100644 --- a/internal/database/game.sql.gen.go +++ b/internal/database/game.sql.gen.go @@ -95,6 +95,55 @@ func (q *Queries) GetGameByID(ctx context.Context, id int64) (Game, error) { return i, err } +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 +from games +where status = 'complete' + and (team_a = ? or team_b = ?) +order by updated_at desc +` + +type ListCompletedGamesByTeamParams struct { + TeamA sql.NullInt64 + TeamB sql.NullInt64 +} + +func (q *Queries) ListCompletedGamesByTeam(ctx context.Context, arg ListCompletedGamesByTeamParams) ([]Game, error) { + rows, err := q.db.QueryContext(ctx, listCompletedGamesByTeam, arg.TeamA, arg.TeamB) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Game + for rows.Next() { + var i Game + if err := rows.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, + ); 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 updateGame = `-- name: UpdateGame :one update games set name = ?, diff --git a/internal/database/migrator.go b/internal/database/migrator.go index 40df3d5..aaa1c63 100644 --- a/internal/database/migrator.go +++ b/internal/database/migrator.go @@ -26,7 +26,7 @@ type Migrator struct { } // NewMigrator creates a new Migrator instance that implements the Service interface. -func NewMigrator(db *sql.DB, fsys fs.FS) Service { +func NewMigrator(db *sql.DB, fsys fs.FS) *Migrator { return &Migrator{ db: db, fs: fsys, diff --git a/internal/database/migrator_test.go b/internal/database/migrator_test.go index 2ec77d4..5e31f95 100644 --- a/internal/database/migrator_test.go +++ b/internal/database/migrator_test.go @@ -12,7 +12,7 @@ func TestMigrator_Migrate(t *testing.T) { if err != nil { t.Fatalf("failed to open database: %v", err) } - defer db.Close() + defer func() { _ = db.Close() }() migrator := NewMigrator(db, SchemaFS) @@ -25,7 +25,7 @@ func TestMigrator_Migrate(t *testing.T) { if err != nil { t.Fatalf("failed to query tables: %v", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() tables := make(map[string]bool) for rows.Next() { diff --git a/internal/database/models.gen.go b/internal/database/models.gen.go index 9a605c0..946587c 100644 --- a/internal/database/models.gen.go +++ b/internal/database/models.gen.go @@ -12,6 +12,7 @@ import ( type Coach struct { ID int64 Name string + Persona string IsDefault sql.NullBool IsHuman sql.NullBool CreatedAt sql.NullTime diff --git a/internal/database/queries/game.sql b/internal/database/queries/game.sql index 746866b..b64fb14 100644 --- a/internal/database/queries/game.sql +++ b/internal/database/queries/game.sql @@ -34,4 +34,11 @@ set name = ?, current_round = ?, tournament_id = ? where id = ? -returning *; \ No newline at end of file +returning *; + +-- name: ListCompletedGamesByTeam :many +select * +from games +where status = 'complete' + and (team_a = ? or team_b = ?) +order by updated_at desc; \ No newline at end of file diff --git a/internal/database/queries/team.sql b/internal/database/queries/team.sql index 324c6f7..df0d120 100644 --- a/internal/database/queries/team.sql +++ b/internal/database/queries/team.sql @@ -17,7 +17,7 @@ SELECT * FROM coaches; SELECT * FROM coaches WHERE is_human = false; -- name: CreateCoach :one -INSERT INTO coaches (name, is_human, is_default) VALUES (?, ?, ?) RETURNING *; +INSERT INTO coaches (name, persona, is_human, is_default) VALUES (?, ?, ?, ?) RETURNING *; -- name: DeleteCoach :exec DELETE FROM coaches WHERE id = ?; diff --git a/internal/database/schema/team.sql b/internal/database/schema/team.sql index bb97104..b5a35ab 100644 --- a/internal/database/schema/team.sql +++ b/internal/database/schema/team.sql @@ -1,6 +1,7 @@ create table if not exists coaches ( id integer primary key autoincrement, name varchar(255) not null, + persona varchar(255) not null default 'Wildcard Coach', is_default BOOLEAN DEFAULT 0, is_human BOOLEAN DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/internal/database/team.sql.gen.go b/internal/database/team.sql.gen.go index 1740dea..6e0d165 100644 --- a/internal/database/team.sql.gen.go +++ b/internal/database/team.sql.gen.go @@ -29,21 +29,28 @@ func (q *Queries) ClearDefaultTeam(ctx context.Context) error { } 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 +INSERT INTO coaches (name, persona, is_human, is_default) VALUES (?, ?, ?, ?) RETURNING id, name, persona, is_default, is_human, created_at, updated_at ` type CreateCoachParams struct { Name string + Persona 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) + row := q.db.QueryRowContext(ctx, createCoach, + arg.Name, + arg.Persona, + arg.IsHuman, + arg.IsDefault, + ) var i Coach err := row.Scan( &i.ID, &i.Name, + &i.Persona, &i.IsDefault, &i.IsHuman, &i.CreatedAt, @@ -126,7 +133,7 @@ func (q *Queries) DeleteTeam(ctx context.Context, id int64) error { } const getAICoaches = `-- name: GetAICoaches :many -SELECT id, name, is_default, is_human, created_at, updated_at FROM coaches WHERE is_human = false +SELECT id, name, persona, is_default, is_human, created_at, updated_at FROM coaches WHERE is_human = false ` func (q *Queries) GetAICoaches(ctx context.Context) ([]Coach, error) { @@ -141,6 +148,7 @@ func (q *Queries) GetAICoaches(ctx context.Context) ([]Coach, error) { if err := rows.Scan( &i.ID, &i.Name, + &i.Persona, &i.IsDefault, &i.IsHuman, &i.CreatedAt, @@ -160,7 +168,7 @@ func (q *Queries) GetAICoaches(ctx context.Context) ([]Coach, error) { } const getCoach = `-- name: GetCoach :one -SELECT id, name, is_default, is_human, created_at, updated_at FROM coaches WHERE id = ? +SELECT id, name, persona, is_default, is_human, created_at, updated_at FROM coaches WHERE id = ? ` func (q *Queries) GetCoach(ctx context.Context, id int64) (Coach, error) { @@ -169,6 +177,7 @@ func (q *Queries) GetCoach(ctx context.Context, id int64) (Coach, error) { err := row.Scan( &i.ID, &i.Name, + &i.Persona, &i.IsDefault, &i.IsHuman, &i.CreatedAt, @@ -178,7 +187,7 @@ func (q *Queries) GetCoach(ctx context.Context, id int64) (Coach, error) { } const getCoaches = `-- name: GetCoaches :many -SELECT id, name, is_default, is_human, created_at, updated_at FROM coaches +SELECT id, name, persona, is_default, is_human, created_at, updated_at FROM coaches ` func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { @@ -193,6 +202,7 @@ func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { if err := rows.Scan( &i.ID, &i.Name, + &i.Persona, &i.IsDefault, &i.IsHuman, &i.CreatedAt, @@ -212,7 +222,7 @@ func (q *Queries) GetCoaches(ctx context.Context) ([]Coach, error) { } const getDefaultCoach = `-- name: GetDefaultCoach :one -SELECT id, name, is_default, is_human, created_at, updated_at FROM coaches WHERE is_default = true LIMIT 1 +SELECT id, name, persona, 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) { @@ -221,6 +231,7 @@ func (q *Queries) GetDefaultCoach(ctx context.Context) (Coach, error) { err := row.Scan( &i.ID, &i.Name, + &i.Persona, &i.IsDefault, &i.IsHuman, &i.CreatedAt, diff --git a/internal/games/dice_roll.go b/internal/games/dice_roll.go new file mode 100644 index 0000000..4980961 --- /dev/null +++ b/internal/games/dice_roll.go @@ -0,0 +1,114 @@ +package games + +import ( + "math/rand/v2" + + "github.com/code-gorilla-au/rush/internal/augments" +) + +func DiceRoll() int { + //nolint:gosec + return rand.IntN(6) + 1 +} + +type TeamDecisionInput struct { + activeAugment augments.Effect + passivesAugments []augments.Effect + player int64 + roll int +} + +type DecisionInput struct { + lastRound *DuelResult + teamA TeamDecisionInput + teamB TeamDecisionInput +} + +type DecisionEngineFunc func(input DecisionInput) DecisionInput + +func RuleTwistOfFate(input DecisionInput) DecisionInput { + return ruleTwistOfFate(input, DiceRoll) +} + +func ruleTwistOfFate(input DecisionInput, roll RollFn) DecisionInput { + if input.teamB.activeAugment.Name == augments.TwistOfFate { + secondRoll := roll() + if input.teamB.roll < secondRoll { + input.teamB.roll = secondRoll + } + } + + if input.teamA.activeAugment.Name == augments.TwistOfFate { + secondRoll := roll() + if input.teamA.roll < secondRoll { + input.teamA.roll = secondRoll + } + } + + return input +} + +type Engine struct { + beforeRoll []DecisionEngineFunc + afterRoll []DecisionEngineFunc + afterAugments []DecisionEngineFunc + rollFn RollFn +} + +func NewDecisionEngine() *Engine { + return &Engine{ + beforeRoll: []DecisionEngineFunc{}, + afterRoll: []DecisionEngineFunc{ + RuleTwistOfFate, + }, + afterAugments: []DecisionEngineFunc{}, + rollFn: DiceRoll, + } +} + +func (e *Engine) Run(input DecisionInput) DuelResult { + for _, ruleFn := range e.beforeRoll { + input = ruleFn(input) + } + + input.teamB.roll = e.rollFn() + input.teamA.roll = e.rollFn() + + for _, ruleFn := range e.afterRoll { + input = ruleFn(input) + } + + for _, ruleFn := range e.afterAugments { + input = ruleFn(input) + } + + return makeDecision(input) +} + +func makeDecision(input DecisionInput) DuelResult { + if input.teamA.roll == input.teamB.roll { + return DuelResult{ + Player: 0, + Outcome: Draw, + Roll: 0, + RollDelta: 0, + } + } + + if input.teamA.roll > input.teamB.roll { + return DuelResult{ + Player: input.teamA.player, + Outcome: TeamA, + Roll: input.teamA.roll, + RollDelta: input.teamA.roll - input.teamB.roll, + } + } + + return DuelResult{ + Player: input.teamB.player, + Outcome: TeamB, + Roll: input.teamB.roll, + RollDelta: input.teamB.roll - input.teamA.roll, + } + +} diff --git a/internal/games/dice_roll_test.go b/internal/games/dice_roll_test.go new file mode 100644 index 0000000..5469702 --- /dev/null +++ b/internal/games/dice_roll_test.go @@ -0,0 +1,262 @@ +package games + +import ( + "testing" + + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/augments" +) + +func TestRuleTwistOfFate(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should not modify input struct (immutability)", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 1, + }, + teamB: TeamDecisionInput{ + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 1, + }, + } + + inputCopy := input + _ = RuleTwistOfFate(input) + + odize.AssertEqual(t, inputCopy, input) + }) + + group.Test("should increase roll for Team A when TwistOfFate provides a higher roll", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 2, + }, + teamB: TeamDecisionInput{ + roll: 3, + }, + } + + mockRoll := func() int { return 5 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 5, res.teamA.roll) + odize.AssertEqual(t, 3, res.teamB.roll) // Team B unchanged + }) + + group.Test("should not change roll for Team A when TwistOfFate provides a lower roll", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 4, + }, + } + + mockRoll := func() int { return 2 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 4, res.teamA.roll) + }) + + group.Test("should increase roll for Team B when TwistOfFate provides a higher roll", func(t *testing.T) { + input := DecisionInput{ + teamB: TeamDecisionInput{ + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 2, + }, + teamA: TeamDecisionInput{ + roll: 3, + }, + } + + mockRoll := func() int { return 5 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 5, res.teamB.roll) + odize.AssertEqual(t, 3, res.teamA.roll) // Team A unchanged + }) + + group.Test("should not change rolls when TwistOfFate augment is not present", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + activeAugment: augments.Effect{Name: "Some Other Effect"}, + roll: 3, + }, + teamB: TeamDecisionInput{ + roll: 4, + }, + } + + mockRoll := func() int { return 6 } + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 3, res.teamA.roll) + odize.AssertEqual(t, 4, res.teamB.roll) + }) + + group.Test("should handle both teams having TwistOfFate", func(t *testing.T) { + input := DecisionInput{ + teamA: TeamDecisionInput{ + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 2, + }, + teamB: TeamDecisionInput{ + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + roll: 3, + }, + } + + rolls := []int{5, 6} // Team B gets 5, Team A gets 6 + idx := 0 + mockRoll := func() int { + val := rolls[idx] + idx++ + return val + } + + res := ruleTwistOfFate(input, mockRoll) + + odize.AssertEqual(t, 6, res.teamA.roll) + odize.AssertEqual(t, 5, res.teamB.roll) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} + +func TestEngineRun(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("should trigger configured rules in run pipeline", func(t *testing.T) { + beforeCalled := false + afterCalled := false + afterAugmentsCalled := false + + engine := &Engine{ + beforeRoll: []DecisionEngineFunc{ + func(input DecisionInput) DecisionInput { + beforeCalled = true + input.teamA.player = 11 + return input + }, + }, + afterRoll: []DecisionEngineFunc{ + func(input DecisionInput) DecisionInput { + afterCalled = true + input.teamA.roll++ + return input + }, + }, + afterAugments: []DecisionEngineFunc{ + func(input DecisionInput) DecisionInput { + afterAugmentsCalled = true + input.teamA.roll++ + return input + }, + }, + rollFn: newSequentialRollFn([]int{3, 3}), + } + + result := engine.Run(DecisionInput{}) + + odize.AssertTrue(t, beforeCalled) + odize.AssertTrue(t, afterCalled) + odize.AssertTrue(t, afterAugmentsCalled) + odize.AssertEqual(t, TeamA, result.Outcome) + odize.AssertEqual(t, int64(11), result.Player) + odize.AssertEqual(t, 5, result.Roll) + odize.AssertEqual(t, 2, result.RollDelta) + }). + Test("should trigger twist of fate effect when rule matches", func(t *testing.T) { + secondRolls := newSequentialRollFn([]int{6}) + + engine := &Engine{ + beforeRoll: []DecisionEngineFunc{}, + afterRoll: []DecisionEngineFunc{ + func(input DecisionInput) DecisionInput { + return ruleTwistOfFate(input, secondRolls) + }, + }, + afterAugments: []DecisionEngineFunc{}, + rollFn: newSequentialRollFn([]int{5, 2}), + } + + result := engine.Run(DecisionInput{ + teamA: TeamDecisionInput{ + player: 101, + activeAugment: augments.Effect{Name: augments.TwistOfFate}, + }, + teamB: TeamDecisionInput{player: 202}, + }) + + odize.AssertEqual(t, TeamA, result.Outcome) + odize.AssertEqual(t, int64(101), result.Player) + odize.AssertEqual(t, 6, result.Roll) + odize.AssertEqual(t, 1, result.RollDelta) + }). + Test("should not trigger twist of fate effect when rule does not match", func(t *testing.T) { + secondRolls := newSequentialRollFn([]int{6}) + + engine := &Engine{ + beforeRoll: []DecisionEngineFunc{}, + afterRoll: []DecisionEngineFunc{ + func(input DecisionInput) DecisionInput { + return ruleTwistOfFate(input, secondRolls) + }, + }, + afterAugments: []DecisionEngineFunc{}, + rollFn: newSequentialRollFn([]int{5, 2}), + } + + result := engine.Run(DecisionInput{ + teamA: TeamDecisionInput{ + player: 101, + activeAugment: augments.Effect{Name: augments.Brace}, + }, + teamB: TeamDecisionInput{player: 202}, + }) + + odize.AssertEqual(t, TeamB, result.Outcome) + odize.AssertEqual(t, int64(202), result.Player) + odize.AssertEqual(t, 5, result.Roll) + odize.AssertEqual(t, 3, result.RollDelta) + }). + Test("should return draw when both final rolls are equal", func(t *testing.T) { + engine := &Engine{ + beforeRoll: []DecisionEngineFunc{}, + afterRoll: []DecisionEngineFunc{}, + afterAugments: []DecisionEngineFunc{}, + rollFn: newSequentialRollFn([]int{4, 4}), + } + + result := engine.Run(DecisionInput{ + teamA: TeamDecisionInput{player: 101}, + teamB: TeamDecisionInput{player: 202}, + }) + + odize.AssertEqual(t, Draw, result.Outcome) + odize.AssertEqual(t, int64(0), result.Player) + odize.AssertEqual(t, 0, result.Roll) + odize.AssertEqual(t, 0, result.RollDelta) + }). + Run() + + odize.AssertNoError(t, err) +} + +func newSequentialRollFn(rolls []int) RollFn { + idx := 0 + + return func() int { + if idx >= len(rolls) { + return rolls[len(rolls)-1] + } + + value := rolls[idx] + idx++ + return value + } +} diff --git a/internal/games/game.go b/internal/games/game.go index 79a2934..a7b5181 100644 --- a/internal/games/game.go +++ b/internal/games/game.go @@ -16,16 +16,18 @@ func generateRounds(teamA TeamConfig, teamB TeamConfig) [10]Round { r.FillSquad( LanesConfig{ - TeamID: teamA.TeamID, - Lane1: teamA.Formations[i].Lane1, - Lane2: teamA.Formations[i].Lane2, - Lane3: teamA.Formations[i].Lane3, + TeamID: teamA.TeamID, + Players: teamA.Players, + 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, + TeamID: teamB.TeamID, + Players: teamB.Players, + Lane1: teamB.Formations[i].Lane1, + Lane2: teamB.Formations[i].Lane2, + Lane3: teamB.Formations[i].Lane3, }, ) @@ -35,9 +37,9 @@ func generateRounds(teamA TeamConfig, teamB TeamConfig) [10]Round { return rounds } -func (g *Game) ResolveRound(roll RollFn) (Result, error) { +func (g *Game) ResolveRound(roll RollStrategy) (RoundResult, error) { if g.currentRound < 0 || g.currentRound >= int64(len(g.rounds)) { - return Result{}, ErrNoRounds + return RoundResult{}, ErrNoRounds } round := &g.rounds[int(g.currentRound)] @@ -51,15 +53,17 @@ func (g *Game) ResolveRound(roll RollFn) (Result, error) { func (g *Game) CalculateWinner() (int64, error) { if g.status != StatusComplete { - return 0, ErrGameNotComplete + return 0, fmt.Errorf("%d: %w", g.id, ErrGameNotComplete) } + teamA := 0 teamB := 0 for _, result := range g.results { - if result.Outcome == ResultTeamA { + switch result.Outcome { + case TeamA: teamA++ - } else if result.Outcome == ResultTeamB { + case TeamB: teamB++ } } @@ -96,6 +100,28 @@ func (g *Game) Name() string { return g.name } +func (g *Game) TeamAScore() int { + teamA := filterResultsByTeam(TeamA, g.results) + return len(teamA) +} + +func (g *Game) TeamBScore() int { + teamB := filterResultsByTeam(TeamB, g.results) + return len(teamB) +} + +func filterResultsByTeam(team Outcome, results []RoundResult) []RoundResult { + var filteredResults []RoundResult + + for _, result := range results { + if result.Outcome == team { + filteredResults = append(filteredResults, result) + } + } + + return filteredResults +} + func fromGameModel(m database.Game) (Game, error) { var rounds [10]Round @@ -103,7 +129,7 @@ func fromGameModel(m database.Game) (Game, error) { return Game{}, fmt.Errorf("failed to unmarshal game model: %w", err) } - var results []Result + var results []RoundResult if err := json.Unmarshal(m.ResultsLog, &results); err != nil { return Game{}, fmt.Errorf("failed to unmarshal results log: %w", err) } diff --git a/internal/games/game_test.go b/internal/games/game_test.go index 87e42a0..beef8bf 100644 --- a/internal/games/game_test.go +++ b/internal/games/game_test.go @@ -12,8 +12,8 @@ func TestGame_ToGameModel(t *testing.T) { group := odize.NewGroup(t, nil) group.Test("should marshal rounds correctly", func(t *testing.T) { - teamA := TeamConfig{TeamID: 1, TeamName: "A", Formations: make([]playbooks.Formation, 10)} - teamB := TeamConfig{TeamID: 2, TeamName: "B", Formations: make([]playbooks.Formation, 10)} + teamA := TeamConfig{TeamID: 1, TeamName: "A", Players: []int64{101, 102, 103}, Formations: make([]playbooks.Formation, 10)} + teamB := TeamConfig{TeamID: 2, TeamName: "B", Players: []int64{201, 202, 203}, Formations: make([]playbooks.Formation, 10)} game := Game{ rounds: generateRounds(teamA, teamB), } @@ -36,8 +36,8 @@ func TestGame_ResolveRound(t *testing.T) { group := odize.NewGroup(t, nil) group.Test("should resolve the first round (index 0)", func(t *testing.T) { - teamA := TeamConfig{TeamID: 1, TeamName: "A", Formations: make([]playbooks.Formation, 10)} - teamB := TeamConfig{TeamID: 2, TeamName: "B", Formations: make([]playbooks.Formation, 10)} + teamA := TeamConfig{TeamID: 1, TeamName: "A", Players: []int64{101, 102, 103}, Formations: make([]playbooks.Formation, 10)} + teamB := TeamConfig{TeamID: 2, TeamName: "B", Players: []int64{201, 202, 203}, Formations: make([]playbooks.Formation, 10)} // Fill some players in round 0 teamA.Formations[0] = playbooks.Formation{Lane1: 1, Lane2: 1, Lane3: 1} @@ -48,17 +48,17 @@ func TestGame_ResolveRound(t *testing.T) { rounds: generateRounds(teamA, teamB), } - rolls := []int{6, 1} + rolls := []int{1, 6} idx := 0 rollFn := func() int { val := rolls[idx%2] idx++ return val } - res, err := game.ResolveRound(rollFn) + res, err := game.ResolveRound(&TestEngine{RollFn: rollFn}) odize.AssertNoError(t, err) - odize.AssertTrue(t, res.Outcome == ResultTeamA || res.Outcome == ResultTeamB) + odize.AssertTrue(t, res.Outcome == TeamA || res.Outcome == TeamB) odize.AssertEqual(t, int64(1), game.currentRound) // Check if players were removed from round 0 diff --git a/internal/games/interfaces.go b/internal/games/interfaces.go index 13d4887..72f59c0 100644 --- a/internal/games/interfaces.go +++ b/internal/games/interfaces.go @@ -9,5 +9,10 @@ import ( type Store interface { CreateGame(ctx context.Context, arg database.CreateGameParams) (database.Game, error) 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) } + +type RollStrategy interface { + Run(input DecisionInput) DuelResult +} diff --git a/internal/games/rounds.go b/internal/games/rounds.go index 4e4a096..9d4cc79 100644 --- a/internal/games/rounds.go +++ b/internal/games/rounds.go @@ -1,11 +1,16 @@ package games -import "errors" +import ( + "errors" + + "github.com/code-gorilla-au/rush/internal/augments" +) func NewRound() Round { return Round{ - TeamA: TeamFormation{Lanes: [3][]int{}}, - TeamB: TeamFormation{Lanes: [3][]int{}}, + TeamA: TeamFormation{Lanes: [3][]int64{}}, + TeamB: TeamFormation{Lanes: [3][]int64{}}, + DuelResults: []DuelResult{}, } } @@ -14,8 +19,8 @@ func (r *Round) FillSquad(a LanesConfig, b LanesConfig) { r.TeamB.FillLanes(b) } -func (r *Round) ResolveLanes(rollFn RollFn) Result { - var result []Result +func (r *Round) ResolveLanes(rollFn RollStrategy) RoundResult { + var result []RoundResult for lane := 0; lane < len(r.TeamA.Lanes); lane++ { laneResult := r.ResolveLane(lane, rollFn) @@ -26,7 +31,7 @@ func (r *Round) ResolveLanes(rollFn RollFn) Result { } -func (r *Round) calculateWinner(result []Result) Result { +func (r *Round) calculateWinner(result []RoundResult) RoundResult { teamAPlayers := 0 teamBPlayers := 0 @@ -34,51 +39,73 @@ func (r *Round) calculateWinner(result []Result) Result { for _, laneResult := range result { switch laneResult.Outcome { - case ResultTeamA: + case TeamA: teamAPlayers += laneResult.RemainingPlayers - case ResultTeamB: + case TeamB: teamBPlayers += laneResult.RemainingPlayers } } if teamAPlayers > teamBPlayers { - return Result{ - Outcome: ResultTeamA, + return RoundResult{ + Outcome: TeamA, RemainingPlayers: teamAPlayers, } } if teamAPlayers < teamBPlayers { - return Result{ - Outcome: ResultTeamB, + return RoundResult{ + Outcome: TeamB, RemainingPlayers: teamBPlayers, } } - return Result{ - Outcome: ResultDraw, + return RoundResult{ + Outcome: Draw, RemainingPlayers: 0, } } -func (r *Round) ResolveLane(lane int, rollFn RollFn) Result { +func (r *Round) ResolveLane(lane int, rollFn RollStrategy) RoundResult { for r.TeamA.LaneHasPlayers(lane) && r.TeamB.LaneHasPlayers(lane) { - aRoll := rollFn() - bRoll := rollFn() + playerA := r.TeamA.LanePeak(lane) + playerB := r.TeamB.LanePeak(lane) + + var lastRound *DuelResult + if len(r.DuelResults) > 0 { + lastRound = new(r.DuelResults[len(r.DuelResults)-1]) + } + + rollInput := DecisionInput{ + lastRound: lastRound, + teamA: TeamDecisionInput{ + activeAugment: augments.Effect{}, + passivesAugments: []augments.Effect{}, + player: playerA, + roll: 0, + }, + teamB: TeamDecisionInput{ + activeAugment: augments.Effect{}, + passivesAugments: []augments.Effect{}, + player: playerB, + roll: 0, + }, + } + + re := rollFn.Run(rollInput) - for aRoll == bRoll { - bRoll = rollFn() - aRoll = rollFn() + for re.Outcome == Draw { + re = rollFn.Run(rollInput) } - if aRoll > bRoll { + r.DuelResults = append(r.DuelResults, re) + if re.Outcome == TeamA { _, err := r.TeamB.LanePop(lane) if errors.Is(err, ErrNoPlayer) { break } - - } else if bRoll > aRoll { + } else { _, err := r.TeamA.LanePop(lane) if errors.Is(err, ErrNoPlayer) { break @@ -88,14 +115,14 @@ func (r *Round) ResolveLane(lane int, rollFn RollFn) Result { } if r.TeamA.LaneHasPlayers(lane) { - return Result{ - Outcome: ResultTeamA, + return RoundResult{ + Outcome: TeamA, RemainingPlayers: r.TeamA.LaneCount(lane), } } - return Result{ - Outcome: ResultTeamB, + return RoundResult{ + Outcome: TeamB, RemainingPlayers: r.TeamB.LaneCount(lane), } @@ -109,27 +136,40 @@ func (s *TeamFormation) LaneHasPlayers(lane int) bool { return len(s.Lanes[lane]) > 0 } -func (s *TeamFormation) LanePop(lane int) (int, error) { +func (s *TeamFormation) LanePeak(lane int) int64 { + return s.Lanes[lane][len(s.Lanes[lane])-1] +} + +func (s *TeamFormation) LanePop(lane int) (int64, error) { tmpLane := s.Lanes[lane] if len(tmpLane) == 0 { return 0, ErrNoPlayer } + item := tmpLane[len(tmpLane)-1] s.Lanes[lane] = tmpLane[:len(tmpLane)-1] - return 1, nil + return item, 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) + remainder := s.LaneFill(0, f.Lane1, f.Players) + remainder = s.LaneFill(1, f.Lane2, remainder) + s.LaneFill(2, f.Lane3, remainder) } -func (s *TeamFormation) LaneFill(lane int, players int) { +func (s *TeamFormation) LaneFill(lane int, players int, teamPlayers []int64) []int64 { + remainder := teamPlayers for i := 0; i < players; i++ { - s.Lanes[lane] = append(s.Lanes[lane], i) + if len(remainder) == 0 { + break + } + player := remainder[0] + remainder = remainder[1:] + s.Lanes[lane] = append(s.Lanes[lane], player) } + + return remainder } diff --git a/internal/games/rounds_test.go b/internal/games/rounds_test.go index 05aad6b..78eb175 100644 --- a/internal/games/rounds_test.go +++ b/internal/games/rounds_test.go @@ -6,6 +6,53 @@ import ( "github.com/code-gorilla-au/odize" ) +func fillLaneWithIDs(t *testing.T, team *TeamFormation, lane int, playerIDs []int64) { + remainder := team.LaneFill(lane, len(playerIDs), playerIDs) + odize.AssertEqual(t, 0, len(remainder)) + odize.AssertEqual(t, len(playerIDs), len(team.Lanes[lane])) + if len(playerIDs) > 0 { + odize.AssertEqual(t, playerIDs, team.Lanes[lane]) + } +} + +func TestFillSquad(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("FillSquad should populate lanes with player IDs in order", func(t *testing.T) { + r := NewRound() + + r.FillSquad( + LanesConfig{ + TeamID: 10, + Players: []int64{101, 102, 103, 104, 105, 106}, + Lane1: 2, + Lane2: 1, + Lane3: 3, + }, + LanesConfig{ + TeamID: 20, + Players: []int64{201, 202, 203, 204, 205, 206}, + Lane1: 1, + Lane2: 3, + Lane3: 2, + }, + ) + + odize.AssertEqual(t, int64(10), r.TeamA.TeamID) + odize.AssertEqual(t, []int64{101, 102}, r.TeamA.Lanes[0]) + odize.AssertEqual(t, []int64{103}, r.TeamA.Lanes[1]) + odize.AssertEqual(t, []int64{104, 105, 106}, r.TeamA.Lanes[2]) + + odize.AssertEqual(t, int64(20), r.TeamB.TeamID) + odize.AssertEqual(t, []int64{201}, r.TeamB.Lanes[0]) + odize.AssertEqual(t, []int64{202, 203, 204}, r.TeamB.Lanes[1]) + odize.AssertEqual(t, []int64{205, 206}, r.TeamB.Lanes[2]) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} + func TestResolveLane(t *testing.T) { group := odize.NewGroup(t, nil) @@ -15,11 +62,11 @@ func TestResolveLane(t *testing.T) { TeamB: TeamFormation{}, } lane := 0 - r.TeamA.LaneFill(lane, 2) - r.TeamB.LaneFill(lane, 1) + fillLaneWithIDs(t, &r.TeamA, lane, []int64{11, 12}) + fillLaneWithIDs(t, &r.TeamB, lane, []int64{21}) // Team A wins if aRoll > bRoll - rolls := []int{6, 1} // aRoll=6, bRoll=1 -> Team B pops + rolls := []int{1, 6} // bRoll=1, aRoll=6 -> Team B pops idx := 0 rollFn := func() int { val := rolls[idx] @@ -27,9 +74,7 @@ func TestResolveLane(t *testing.T) { return val } - res := r.ResolveLane(lane, rollFn) - - odize.AssertEqual(t, ResultTeamA, res.Outcome) + res := r.ResolveLane(lane, &TestEngine{RollFn: rollFn}) odize.AssertEqual(t, 2, res.RemainingPlayers) odize.AssertEqual(t, 0, r.TeamB.LaneCount(lane)) }) @@ -40,11 +85,11 @@ func TestResolveLane(t *testing.T) { TeamB: TeamFormation{}, } lane := 0 - r.TeamA.LaneFill(lane, 1) - r.TeamB.LaneFill(lane, 2) + fillLaneWithIDs(t, &r.TeamA, lane, []int64{31}) + fillLaneWithIDs(t, &r.TeamB, lane, []int64{41, 42}) // Team B wins if bRoll > aRoll - rolls := []int{1, 6} // aRoll=1, bRoll=6 -> Team A pops + rolls := []int{6, 1} // bRoll=6, aRoll=1 -> Team A pops idx := 0 rollFn := func() int { val := rolls[idx] @@ -52,9 +97,7 @@ func TestResolveLane(t *testing.T) { return val } - res := r.ResolveLane(lane, rollFn) - - odize.AssertEqual(t, ResultTeamB, res.Outcome) + res := r.ResolveLane(lane, &TestEngine{RollFn: rollFn}) odize.AssertEqual(t, 2, res.RemainingPlayers) odize.AssertEqual(t, 0, r.TeamA.LaneCount(lane)) }) @@ -65,12 +108,12 @@ func TestResolveLane(t *testing.T) { TeamB: TeamFormation{}, } lane := 0 - r.TeamA.LaneFill(lane, 0) - r.TeamB.LaneFill(lane, 3) + fillLaneWithIDs(t, &r.TeamA, lane, []int64{}) + fillLaneWithIDs(t, &r.TeamB, lane, []int64{51, 52, 53}) - res := r.ResolveLane(lane, func() int { return 1 }) + res := r.ResolveLane(lane, &TestEngine{RollFn: func() int { return 1 }}) - odize.AssertEqual(t, ResultTeamB, res.Outcome) + odize.AssertEqual(t, TeamB, res.Outcome) odize.AssertEqual(t, 3, res.RemainingPlayers) }) @@ -80,12 +123,12 @@ func TestResolveLane(t *testing.T) { TeamB: TeamFormation{}, } lane := 0 - r.TeamA.LaneFill(lane, 3) - r.TeamB.LaneFill(lane, 0) + fillLaneWithIDs(t, &r.TeamA, lane, []int64{61, 62, 63}) + fillLaneWithIDs(t, &r.TeamB, lane, []int64{}) - res := r.ResolveLane(lane, func() int { return 1 }) + res := r.ResolveLane(lane, &TestEngine{RollFn: func() int { return 1 }}) - odize.AssertEqual(t, ResultTeamA, res.Outcome) + odize.AssertEqual(t, TeamA, res.Outcome) odize.AssertEqual(t, 3, res.RemainingPlayers) }) @@ -102,21 +145,21 @@ func TestResolveLanes(t *testing.T) { TeamB: TeamFormation{}, } // Lane 0: Team A wins (2 remaining) - r.TeamA.LaneFill(0, 2) - r.TeamB.LaneFill(0, 1) + fillLaneWithIDs(t, &r.TeamA, 0, []int64{101, 102}) + fillLaneWithIDs(t, &r.TeamB, 0, []int64{201}) // Lane 1: Team B wins (1 remaining) - r.TeamA.LaneFill(1, 1) - r.TeamB.LaneFill(1, 2) + fillLaneWithIDs(t, &r.TeamA, 1, []int64{103}) + fillLaneWithIDs(t, &r.TeamB, 1, []int64{202, 203}) // Lane 2: Team A wins (3 remaining) - r.TeamA.LaneFill(2, 3) - r.TeamB.LaneFill(2, 0) + fillLaneWithIDs(t, &r.TeamA, 2, []int64{104, 105, 106}) + fillLaneWithIDs(t, &r.TeamB, 2, []int64{}) // 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} + rolls := []int{1, 6, 6, 1} idx := 0 rollFn := func() int { val := rolls[idx] @@ -124,12 +167,12 @@ func TestResolveLanes(t *testing.T) { return val } - res := r.ResolveLanes(rollFn) + res := r.ResolveLanes(&TestEngine{RollFn: 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.AssertEqual(t, ResultTeamA, res.Outcome) + odize.AssertEqual(t, TeamA, res.Outcome) odize.AssertEqual(t, 5, res.RemainingPlayers) }) @@ -139,19 +182,19 @@ func TestResolveLanes(t *testing.T) { TeamB: TeamFormation{}, } // Lane 0: Team B wins (3 remaining) - r.TeamA.LaneFill(0, 0) - r.TeamB.LaneFill(0, 3) + fillLaneWithIDs(t, &r.TeamA, 0, []int64{}) + fillLaneWithIDs(t, &r.TeamB, 0, []int64{301, 302, 303}) // Lane 1: Team B wins (2 remaining) - r.TeamA.LaneFill(1, 1) - r.TeamB.LaneFill(1, 2) + fillLaneWithIDs(t, &r.TeamA, 1, []int64{304}) + fillLaneWithIDs(t, &r.TeamB, 1, []int64{401, 402}) // Lane 2: Team A wins (1 remaining) - r.TeamA.LaneFill(2, 1) - r.TeamB.LaneFill(2, 0) + fillLaneWithIDs(t, &r.TeamA, 2, []int64{305}) + fillLaneWithIDs(t, &r.TeamB, 2, []int64{}) // Rolls for Lane 1: A(1), B(6) -> A loses 1 - rolls := []int{1, 6} + rolls := []int{6, 1} idx := 0 rollFn := func() int { val := rolls[idx] @@ -159,12 +202,12 @@ func TestResolveLanes(t *testing.T) { return val } - res := r.ResolveLanes(rollFn) + res := r.ResolveLanes(&TestEngine{RollFn: 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.AssertEqual(t, ResultTeamB, res.Outcome) + odize.AssertEqual(t, TeamB, res.Outcome) odize.AssertEqual(t, 5, res.RemainingPlayers) }) @@ -174,23 +217,23 @@ func TestResolveLanes(t *testing.T) { TeamB: TeamFormation{}, } // Lane 0: Team A wins (1 remaining) - r.TeamA.LaneFill(0, 1) - r.TeamB.LaneFill(0, 0) + fillLaneWithIDs(t, &r.TeamA, 0, []int64{501}) + fillLaneWithIDs(t, &r.TeamB, 0, []int64{}) // Lane 1: Team B wins (1 remaining) - r.TeamA.LaneFill(1, 0) - r.TeamB.LaneFill(1, 1) + fillLaneWithIDs(t, &r.TeamA, 1, []int64{}) + fillLaneWithIDs(t, &r.TeamB, 1, []int64{601}) // Lane 2: Both empty - r.TeamA.LaneFill(2, 0) - r.TeamB.LaneFill(2, 0) + fillLaneWithIDs(t, &r.TeamA, 2, []int64{}) + fillLaneWithIDs(t, &r.TeamB, 2, []int64{}) - res := r.ResolveLanes(func() int { return 1 }) + res := r.ResolveLanes(&TestEngine{RollFn: func() int { return 1 }}) // Total A (1) == Total B (1) // Current logic: if teamAPlayers > teamBPlayers { A wins } else if teamBPlayers > teamAPlayers { B wins } else { Draw } // So it should be Draw. - odize.AssertEqual(t, ResultDraw, res.Outcome) + odize.AssertEqual(t, Draw, res.Outcome) odize.AssertEqual(t, 0, res.RemainingPlayers) }) diff --git a/internal/games/service.go b/internal/games/service.go index 1e01a6c..8662677 100644 --- a/internal/games/service.go +++ b/internal/games/service.go @@ -108,3 +108,61 @@ func (s *Service) CompleteGame(ctx context.Context, game Game) (Game, error) { return updatedGame, nil } + +func (s *Service) GetTeamStatistics(ctx context.Context, teamID int64) (TeamStatistics, error) { + teamIDArg := sql.NullInt64{Int64: teamID, Valid: true} + + models, err := s.Store.ListCompletedGamesByTeam(ctx, database.ListCompletedGamesByTeamParams{ + TeamA: teamIDArg, + TeamB: teamIDArg, + }) + if err != nil { + return TeamStatistics{}, fmt.Errorf("listing completed games: %w", err) + } + + stats := TeamStatistics{} + for _, model := range models { + if err = s.processGameForStats(&stats, model, teamID); err != nil { + return TeamStatistics{}, err + } + } + + if stats.GamesPlayed == 0 { + return stats, nil + } + + stats.WinRate = (float64(stats.Wins) / float64(stats.GamesPlayed)) * 100 + stats.RoundDifferential = stats.RoundsWon - stats.RoundsLost + stats.AverageRoundsWon = float64(stats.RoundsWon) / float64(stats.GamesPlayed) + stats.AverageRoundsLost = float64(stats.RoundsLost) / float64(stats.GamesPlayed) + + return stats, nil +} + +func (s *Service) processGameForStats(stats *TeamStatistics, model database.Game, teamID int64) error { + stats.GamesPlayed++ + + switch { + case !model.Winner.Valid || model.Winner.Int64 == 0: + stats.Draws++ + case model.Winner.Int64 == teamID: + stats.Wins++ + default: + stats.Losses++ + } + + var results []RoundResult + if err := json.Unmarshal(model.ResultsLog, &results); err != nil { + return fmt.Errorf("parsing game %d results: %w", model.ID, err) + } + + teamRoundsWon := len(filterResultsByTeam(TeamA, results)) + teamRoundsLost := len(filterResultsByTeam(TeamB, results)) + if model.TeamB.Valid && model.TeamB.Int64 == teamID { + teamRoundsWon, teamRoundsLost = teamRoundsLost, teamRoundsWon + } + + stats.RoundsWon += teamRoundsWon + stats.RoundsLost += teamRoundsLost + return nil +} diff --git a/internal/games/service_test.go b/internal/games/service_test.go new file mode 100644 index 0000000..585e5f7 --- /dev/null +++ b/internal/games/service_test.go @@ -0,0 +1,129 @@ +package games + +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/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" + _ "modernc.org/sqlite" +) + +func TestService_GetTeamStatistics(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should return zero values when team has no completed games", func(t *testing.T) { + teamSvc, gameSvc := setupStatsTestServices(t) + ctx := t.Context() + + coach, err := teamSvc.CreateCoach(ctx, teams.CreateCoachParams{Name: "Coach Zero", IsHuman: true}) + odize.AssertNoError(t, err) + + team, err := teamSvc.CreateTeam(ctx, "No Games Team", coach.ID, true) + odize.AssertNoError(t, err) + + stats, err := gameSvc.GetTeamStatistics(ctx, team.ID) + odize.AssertNoError(t, err) + + odize.AssertEqual(t, 0, stats.GamesPlayed) + odize.AssertEqual(t, 0, stats.Wins) + odize.AssertEqual(t, 0, stats.Draws) + odize.AssertEqual(t, 0, stats.Losses) + odize.AssertEqual(t, 0, stats.RoundsWon) + odize.AssertEqual(t, 0, stats.RoundsLost) + odize.AssertEqual(t, 0, stats.RoundDifferential) + }) + + group.Test("should calculate mixed win draw loss and round metrics", func(t *testing.T) { + teamSvc, gameSvc := setupStatsTestServices(t) + ctx := t.Context() + + targetCoach, err := teamSvc.CreateCoach(ctx, teams.CreateCoachParams{Name: "Target Coach", IsHuman: true}) + odize.AssertNoError(t, err) + + opponentCoach, err := teamSvc.CreateCoach(ctx, teams.CreateCoachParams{Name: "Opponent Coach", IsHuman: true}) + odize.AssertNoError(t, err) + + targetTeam, err := teamSvc.CreateTeam(ctx, "Target Team", targetCoach.ID, true) + odize.AssertNoError(t, err) + + opponentTeam, err := teamSvc.CreateTeam(ctx, "Opponent Team", opponentCoach.ID, false) + odize.AssertNoError(t, err) + + targetCfg := TeamConfig{TeamID: targetTeam.ID, TeamName: targetTeam.Name, Formations: make([]playbooks.Formation, 10)} + opponentCfg := TeamConfig{TeamID: opponentTeam.ID, TeamName: opponentTeam.Name, Formations: make([]playbooks.Formation, 10)} + + persistCompletedGame(t, gameSvc, ctx, targetCfg, opponentCfg, targetTeam.ID, []RoundResult{ + {Outcome: TeamA}, + {Outcome: TeamA}, + {Outcome: TeamB}, + }) + + persistCompletedGame(t, gameSvc, ctx, opponentCfg, targetCfg, opponentTeam.ID, []RoundResult{ + {Outcome: TeamA}, + {Outcome: TeamB}, + {Outcome: TeamA}, + }) + + persistCompletedGame(t, gameSvc, ctx, targetCfg, opponentCfg, 0, []RoundResult{ + {Outcome: TeamA}, + {Outcome: TeamB}, + }) + + stats, err := gameSvc.GetTeamStatistics(ctx, targetTeam.ID) + odize.AssertNoError(t, err) + + odize.AssertEqual(t, 3, stats.GamesPlayed) + odize.AssertEqual(t, 1, stats.Wins) + odize.AssertEqual(t, 1, stats.Draws) + odize.AssertEqual(t, 1, stats.Losses) + odize.AssertEqual(t, 4, stats.RoundsWon) + odize.AssertEqual(t, 4, stats.RoundsLost) + odize.AssertEqual(t, 0, stats.RoundDifferential) + odize.AssertEqual(t, "33.3", fmt.Sprintf("%.1f", stats.WinRate)) + odize.AssertEqual(t, "1.33", fmt.Sprintf("%.2f", stats.AverageRoundsWon)) + odize.AssertEqual(t, "1.33", fmt.Sprintf("%.2f", stats.AverageRoundsLost)) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} + +func setupStatsTestServices(t *testing.T) (*teams.Service, *Service) { + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to open test database: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + }) + + migrator := database.NewMigrator(db, database.SchemaFS) + if err := migrator.Migrate(t.Context()); err != nil { + t.Fatalf("failed to migrate test database: %v", err) + } + + queries := database.New(db) + playbookSvc := playbooks.NewPlaybooksService(queries) + teamSvc := teams.NewTeamsService(queries, playbookSvc) + gameSvc := NewService(queries) + + return teamSvc, gameSvc +} + +func persistCompletedGame(t *testing.T, gameSvc *Service, ctx context.Context, teamA TeamConfig, teamB TeamConfig, winner int64, results []RoundResult) { + game, err := gameSvc.NewGame(ctx, NewGameParams{TeamA: teamA, TeamB: teamB}) + odize.AssertNoError(t, err) + + game.status = StatusComplete + game.currentRound = int64(len(game.rounds)) + game.results = results + game.winner = &winner + + _, err = gameSvc.UpdateGame(ctx, game) + odize.AssertNoError(t, err) +} diff --git a/internal/games/test_engine.go b/internal/games/test_engine.go new file mode 100644 index 0000000..b188575 --- /dev/null +++ b/internal/games/test_engine.go @@ -0,0 +1,11 @@ +package games + +type TestEngine struct { + RollFn RollFn +} + +func (e *TestEngine) Run(input DecisionInput) DuelResult { + input.teamB.roll = e.RollFn() + input.teamA.roll = e.RollFn() + return makeDecision(input) +} diff --git a/internal/games/types.go b/internal/games/types.go index e859014..8675d19 100644 --- a/internal/games/types.go +++ b/internal/games/types.go @@ -19,12 +19,12 @@ const ( StatusComplete GameStatus = "complete" ) -type ResultOutcome string +type Outcome string const ( - ResultDraw ResultOutcome = "draw" - ResultTeamA ResultOutcome = "team_a" - ResultTeamB ResultOutcome = "team_b" + Draw Outcome = "draw" + TeamA Outcome = "team_a" + TeamB Outcome = "team_b" ) type Game struct { @@ -37,37 +37,60 @@ type Game struct { status GameStatus rounds [10]Round currentRound int64 - results []Result + results []RoundResult createdAt time.Time updatedAt time.Time } type Round struct { - TeamA TeamFormation - TeamB TeamFormation + TeamA TeamFormation `json:"team_a"` + TeamB TeamFormation `json:"team_b"` + DuelResults []DuelResult `json:"duel_results"` +} + +type DuelResult struct { + Player int64 `json:"player"` + Outcome Outcome `json:"outcome"` + Roll int `json:"roll"` + RollDelta int `json:"roll_delta"` +} + +type TeamStatistics struct { + GamesPlayed int `json:"games_played,omitempty"` + Wins int `json:"wins,omitempty"` + Draws int `json:"draws,omitempty"` + Losses int `json:"losses,omitempty"` + WinRate float64 `json:"win_rate,omitempty"` + RoundsWon int `json:"rounds_won,omitempty"` + RoundsLost int `json:"rounds_lost,omitempty"` + RoundDifferential int `json:"round_differential,omitempty"` + AverageRoundsWon float64 `json:"average_rounds_won,omitempty"` + AverageRoundsLost float64 `json:"average_rounds_lost,omitempty"` } type TeamConfig struct { - TeamID int64 - TeamName string - Formations []playbooks.Formation + TeamID int64 `json:"team_id"` + TeamName string `json:"team_name"` + Players []int64 `json:"players"` + Formations []playbooks.Formation `json:"formations"` } type TeamFormation struct { - TeamID int64 - Lanes [3][]int + TeamID int64 `json:"team_id,omitempty"` + Lanes [3][]int64 `json:"lanes,omitempty"` } type LanesConfig struct { - TeamID int64 - Lane1 int - Lane2 int - Lane3 int + TeamID int64 `json:"team_id"` + Players []int64 `json:"players"` + Lane1 int `json:"lane_1"` + Lane2 int `json:"lane_2"` + Lane3 int `json:"lane_3"` } -type Result struct { - Outcome ResultOutcome - RemainingPlayers int +type RoundResult struct { + Outcome Outcome `json:"outcome"` + RemainingPlayers int `json:"remaining_players"` } type RollFn func() int diff --git a/internal/playbooks/service_test.go b/internal/playbooks/service_test.go index 9c73ad2..23a04aa 100644 --- a/internal/playbooks/service_test.go +++ b/internal/playbooks/service_test.go @@ -35,7 +35,7 @@ func TestService(t *testing.T) { group.AfterEach(func() { if db != nil { - db.Close() + _ = db.Close() } }) diff --git a/internal/playbooks/transforms.go b/internal/playbooks/transforms.go index 0d45b26..e758e4c 100644 --- a/internal/playbooks/transforms.go +++ b/internal/playbooks/transforms.go @@ -7,9 +7,13 @@ import ( "github.com/code-gorilla-au/rush/internal/database" ) -func fromFormationJSON(j interface{}) ([]Formation, error) { +func fromFormationJSON(j any) ([]Formation, error) { var f []Formation - if err := json.Unmarshal(j.([]byte), &f); err != nil { + b, ok := j.([]byte) + if !ok { + return []Formation{}, fmt.Errorf("expected []byte for formations, got %T", j) + } + if err := json.Unmarshal(b, &f); err != nil { return []Formation{}, fmt.Errorf("failed to unmarshal formation: %w", err) } diff --git a/internal/teams/ai_teams.go b/internal/teams/ai_teams.go index 85e1723..c730a82 100644 --- a/internal/teams/ai_teams.go +++ b/internal/teams/ai_teams.go @@ -11,18 +11,21 @@ import ( const totalTeams = 12 type AIGenerationParams struct { - CoachName string `faker:"name"` - TeamName string `faker:"username"` - Persona string - Formations []playbooks.Formation + CoachName string `faker:"name"` + TeamName string + Persona string + CoachPersona CoachPersona + Formations []playbooks.Formation } var personas = []struct { Name string + CoachPersona CoachPersona FormationNames []string }{ { - Name: "The Wall - A defensive specialist that focuses on protecting all lanes equally.", + Name: "The Wall - A defensive specialist that focuses on protecting all lanes equally.", + CoachPersona: CoachPersonaBastion, FormationNames: []string{ "balanced-right", "balanced-left", "split-balanced", "strong-centre", "strong-right", "strong-left", @@ -30,7 +33,8 @@ var personas = []struct { }, }, { - Name: "Blitzkrieg - Aggressive tactics focusing on overwhelming the opponent through the center.", + Name: "Blitzkrieg - Aggressive tactics focusing on overwhelming the opponent through the center.", + CoachPersona: CoachPersonaVanguard, FormationNames: []string{ "strong-centre", "single-lane-centre", "overload-centre-left", "overload-centre-right", "balanced-left", "balanced-right", @@ -38,7 +42,8 @@ var personas = []struct { }, }, { - Name: "Flank Specialist - Prefers to attack from the edges, leaving the middle open.", + Name: "Flank Specialist - Prefers to attack from the edges, leaving the middle open.", + CoachPersona: CoachPersonaTrickster, FormationNames: []string{ "split-right", "split-left", "overload-right", "overload-left", "split-balanced", "balanced-right", @@ -46,7 +51,8 @@ var personas = []struct { }, }, { - Name: "Right Side Powerhouse - Concentrates most of the strength on the right flank.", + Name: "Right Side Powerhouse - Concentrates most of the strength on the right flank.", + CoachPersona: CoachPersonaVanguard, FormationNames: []string{ "strong-right", "overload-right", "single-lane-right", "balanced-right", "overload-centre-right", "split-right", @@ -54,7 +60,8 @@ var personas = []struct { }, }, { - Name: "Left Side Powerhouse - Concentrates most of the strength on the left flank.", + Name: "Left Side Powerhouse - Concentrates most of the strength on the left flank.", + CoachPersona: CoachPersonaVanguard, FormationNames: []string{ "strong-left", "overload-left", "single-lane-left", "balanced-left", "overload-centre-left", "split-left", @@ -62,7 +69,8 @@ var personas = []struct { }, }, { - Name: "The Juggernaut - Heavy focus on one single lane to break through.", + Name: "The Juggernaut - Heavy focus on one single lane to break through.", + CoachPersona: CoachPersonaVanguard, FormationNames: []string{ "single-lane-left", "single-lane-centre", "single-lane-right", "overload-left", "overload-right", "strong-centre", @@ -70,7 +78,8 @@ var personas = []struct { }, }, { - Name: "Balanced Strategist - Adapts to the game with a mix of balanced formations.", + Name: "Balanced Strategist - Adapts to the game with a mix of balanced formations.", + CoachPersona: CoachPersonaWildcard, FormationNames: []string{ "balanced-right", "balanced-left", "split-balanced", "strong-centre", "strong-right", "strong-left", @@ -78,7 +87,8 @@ var personas = []struct { }, }, { - Name: "Chaos Theory - Uses unpredictable and highly skewed formations.", + Name: "Chaos Theory - Uses unpredictable and highly skewed formations.", + CoachPersona: CoachPersonaTrickster, FormationNames: []string{ "overload-centre-left", "overload-centre-right", "split-right", "split-left", "overload-left", "overload-right", @@ -86,7 +96,8 @@ var personas = []struct { }, }, { - Name: "The Shield - Focuses on heavy central defense and slight flank pressure.", + Name: "The Shield - Focuses on heavy central defense and slight flank pressure.", + CoachPersona: CoachPersonaBastion, FormationNames: []string{ "strong-centre", "overload-centre-left", "overload-centre-right", "balanced-right", "balanced-left", "split-balanced", @@ -94,7 +105,8 @@ var personas = []struct { }, }, { - Name: "Centrist - Always stays in the middle, daring the opponent to go around.", + Name: "Centrist - Always stays in the middle, daring the opponent to go around.", + CoachPersona: CoachPersonaWildcard, FormationNames: []string{ "strong-centre", "single-lane-centre", "balanced-right", "balanced-left", "overload-centre-left", "overload-centre-right", @@ -102,7 +114,8 @@ var personas = []struct { }, }, { - Name: "Dual Flanker - Strong presence on both left and right, ignoring the center.", + Name: "Dual Flanker - Strong presence on both left and right, ignoring the center.", + CoachPersona: CoachPersonaTrickster, FormationNames: []string{ "split-balanced", "split-right", "split-left", "overload-right", "overload-left", "balanced-right", @@ -110,7 +123,8 @@ var personas = []struct { }, }, { - Name: "Overload Master - Specializes in shifting maximum weight to one side or the other.", + Name: "Overload Master - Specializes in shifting maximum weight to one side or the other.", + CoachPersona: CoachPersonaWildcard, FormationNames: []string{ "overload-right", "overload-left", "overload-centre-left", "overload-centre-right", "single-lane-left", "single-lane-right", @@ -198,6 +212,7 @@ func (s *Service) GenerateAITeams(ctx context.Context) error { func (s *Service) generateTeam(ctx context.Context, team AIGenerationParams) error { coach, err := s.CreateCoach(ctx, CreateCoachParams{ Name: team.CoachName, + Persona: team.CoachPersona, IsHuman: false, IsDefault: false, }) @@ -212,7 +227,7 @@ func (s *Service) generateTeam(ctx context.Context, team AIGenerationParams) err if _, err = s.playbookSvc.CreatePlaybook(ctx, playbooks.PlaybookParams{ TeamID: aiTeam.ID, - Name: fmt.Sprintf("%s Playbook", aiTeam.Name), + Name: aiTeam.Name + " Playbook", Description: team.Persona, Formations: team.Formations, }); err != nil { @@ -230,6 +245,9 @@ func generateAITeams() ([]AIGenerationParams, error) { formationMap[f.Name] = f } + nameGen := NewTeamNameGenerator() + teamNames := nameGen.GenerateUnique(totalTeams) + for i := 0; i < totalTeams; i++ { tmpTeam := AIGenerationParams{} @@ -237,8 +255,11 @@ func generateAITeams() ([]AIGenerationParams, error) { return nil, fmt.Errorf("generating team: %w", err) } + tmpTeam.TeamName = teamNames[i] + persona := personas[i%len(personas)] tmpTeam.Persona = persona.Name + tmpTeam.CoachPersona = persona.CoachPersona teamFormations := make([]playbooks.Formation, 0, len(persona.FormationNames)) for _, name := range persona.FormationNames { diff --git a/internal/teams/ai_teams_test.go b/internal/teams/ai_teams_test.go index 691008c..e49e618 100644 --- a/internal/teams/ai_teams_test.go +++ b/internal/teams/ai_teams_test.go @@ -81,11 +81,14 @@ func TestGenerateAITeams(t *testing.T) { for i, team := range _teams { odize.AssertTrue(t, team.Persona != "") + odize.AssertTrue(t, team.CoachPersona != "") 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) + expectedCoachPersona := personas[i%len(personas)].CoachPersona + odize.AssertTrue(t, team.CoachPersona == expectedCoachPersona) } }). Run() @@ -115,7 +118,8 @@ func TestTeamsService_GenerateTeams(t *testing.T) { mStore.createCoachFunc = func(ctx context.Context, arg database.CreateCoachParams) (database.Coach, error) { coachCount++ - return database.Coach{ID: int64(coachCount), Name: arg.Name}, nil + odize.AssertTrue(t, arg.Persona != "") + return database.Coach{ID: int64(coachCount), Name: arg.Name, Persona: arg.Persona}, nil } mStore.createTeamFunc = func(ctx context.Context, arg database.CreateTeamParams) (database.Team, error) { teamCount++ diff --git a/internal/teams/name_generator.go b/internal/teams/name_generator.go new file mode 100644 index 0000000..06a38cb --- /dev/null +++ b/internal/teams/name_generator.go @@ -0,0 +1,110 @@ +package teams + +import ( + "math/rand/v2" + "strings" +) + +type TeamNameGenerator struct { + rng *rand.Rand +} + +func NewTeamNameGenerator() *TeamNameGenerator { + return &TeamNameGenerator{ + //nolint:gosec + rng: rand.New(rand.NewPCG(42, 42)), + } +} + +var ( + adjectives = []string{ + "Rusty", "Brutal", "Grim", "Blessed", "Cursed", "Unstable", "Feral", "Mouldy", "Turbo", "Heretical", + "Recursive", "Screaming", "Sneaky", "Explosive", "Unwashed", "Violent", "Questionable", "Bloodied", "Rabid", "Malfunctioning", + "Ancient", "Putrid", "Vengeful", "Drunken", "Radioactive", "Glitchy", "Homicidal", "Desperate", "Forgotten", "Toxic", + } + + teamNouns = []string{ + "Goblins", "Ogres", "Ratlings", "Bonechewers", "Skullpunters", "Maulers", "Bruisers", "Bashers", "Fumblers", "Blitzers", + "Misfits", "Snacklords", "Doomrunners", "Mudstompers", "Groinbiters", "Helmetlickers", "Tunnelgrinders", "Chainpullers", "Binfires", "Scraplords", + "Bonecrushers", "Fleshweavers", "Geargrinders", "Muckrakers", "Voidwalkers", "Sludgehounds", "Ironbellies", "Gutterrunners", "Stormbringers", "Hellraisers", + } + + places = []string{ + "Krumpgate", "Middenheap", "Ironbog", "Gutterspire", "Blackditch", "Rustvale", "Muckford", "New Grotsburg", "Ashbarrow", "Boltmarsh", + "Grubchester", "Voidcrate", "Slagwell", "Dockerford", "Meatminster", + } + + doomThings = []string{ + "The Final Whistle", "The Sacred Boot", "The Broken Helmet", "The Emperor's Lunchbox", "The Rusted Cog", + "The Spiked Ball", "The Great Bin", "The Unpaid Invoice", "The Ninth Referee", "The Eternal Scrum", + "The Leaking Barrel", "The Golden Tooth", "The Cursed Whistle", "The Dented Cup", "The Last Rations", + } + + dockerAdjectives = []string{ + "sleepy", "angry", "clever", "feral", "hungry", "nervous", "chaotic", "brave", "grumpy", "wobbly", "sneaky", "loud", + } + + dockerNouns = []string{ + "badger", "squid", "goblin", "troll", "weasel", "servo", "bucket", "daemon", "hamster", "squiggle", "penguin", "slug", + } + + professions = []string{ + "Cook", "Smith", "Baker", "Butcher", "Slayer", "Reaper", "Whacker", "Crusher", "Believer", "Denier", + } + + namePatterns = []func(*TeamNameGenerator) string{ + func(g *TeamNameGenerator) string { + return "The " + g.pick(adjectives) + " " + g.pick(teamNouns) + }, + func(g *TeamNameGenerator) string { + return g.pick(places) + " " + g.pick(teamNouns) + }, + func(g *TeamNameGenerator) string { + return g.pick(adjectives) + " " + g.pick(teamNouns) + }, + func(g *TeamNameGenerator) string { + noun := g.pick(teamNouns) + if singular, ok := strings.CutSuffix(noun, "s"); ok { + noun = singular + } + + return noun + " of " + g.pick(doomThings) + }, + func(g *TeamNameGenerator) string { + return g.pick(dockerAdjectives) + "-" + g.pick(dockerNouns) + }, + func(g *TeamNameGenerator) string { + return g.pick(professions) + " " + g.pick(teamNouns) + }, + } +) + +func (g *TeamNameGenerator) Generate() string { + pattern := namePatterns[g.rng.IntN(len(namePatterns))] + return pattern(g) +} + +func (g *TeamNameGenerator) pick(list []string) string { + return list[g.rng.IntN(len(list))] +} + +func (g *TeamNameGenerator) GenerateUnique(count int) []string { + if count <= 0 { + return []string{} + } + + seen := make(map[string]struct{}, count) + names := make([]string, 0, count) + + for len(names) < count { + name := g.Generate() + if _, exists := seen[name]; exists { + continue + } + + seen[name] = struct{}{} + names = append(names, name) + } + + return names +} diff --git a/internal/teams/name_generator_test.go b/internal/teams/name_generator_test.go new file mode 100644 index 0000000..0fe2299 --- /dev/null +++ b/internal/teams/name_generator_test.go @@ -0,0 +1,42 @@ +package teams + +import ( + "testing" + + "github.com/code-gorilla-au/odize" +) + +func TestTeamNameGenerator(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("GenerateUnique should produce unique names", func(t *testing.T) { + g := NewTeamNameGenerator() + count := 50 + names := g.GenerateUnique(count) + + odize.AssertEqual(t, count, len(names)) + + seen := make(map[string]bool) + for _, name := range names { + if seen[name] { + t.Errorf("Duplicate name found: %s", name) + } + seen[name] = true + odize.AssertTrue(t, len(name) > 0) + } + }) + + group.Test("Generate should produce names in expected formats", func(t *testing.T) { + g := NewTeamNameGenerator() + // Generate many names to hit most templates + for i := 0; i < 100; i++ { + name := g.Generate() + odize.AssertTrue(t, len(name) > 0) + // Basic sanity check that it's not empty or just whitespace + odize.AssertTrue(t, len(name) > 2) + } + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/teams/service.go b/internal/teams/service.go index 7208127..767bad2 100644 --- a/internal/teams/service.go +++ b/internal/teams/service.go @@ -1,12 +1,14 @@ package teams import ( + "cmp" "context" "database/sql" "errors" "fmt" "github.com/code-gorilla-au/rush/internal/database" + "github.com/go-faker/faker/v4" ) type Service struct { @@ -23,13 +25,15 @@ func NewTeamsService(store Store, playbookSvc PlaybookCreatGetter) *Service { type CreateCoachParams struct { Name string + Persona CoachPersona 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, + Name: params.Name, + Persona: string(cmp.Or(params.Persona, CoachPersonaWildcard)), IsHuman: sql.NullBool{ Bool: params.IsHuman, Valid: true, @@ -134,23 +138,6 @@ func (s *Service) GetCoachByID(ctx context.Context, id int64) (Coach, error) { return fromCoachModel(model), nil } -func (s *Service) getTeamPlayers(ctx context.Context, teamID int64) ([]Player, error) { - models, err := s.store.GetTeamMembers(ctx, sql.NullInt64{ - Int64: teamID, - Valid: true, - }) - if err != nil { - return nil, fmt.Errorf("getting team players: %w", err) - } - - players := make([]Player, len(models)) - for i, model := range models { - players[i] = fromPlayerModel(model) - } - - return players, 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, @@ -175,17 +162,28 @@ func (s *Service) CreateTeam(ctx context.Context, name string, coachID int64, is return fromTeamModel(model, playersModel), nil } +type createPlayerParams struct { + Name string `faker:"name"` +} + func (s *Service) createPlayers(ctx context.Context, teamID int64) ([]database.Player, error) { modelPlayers := make([]database.Player, 5) for i := 0; i < 5; i++ { + playerName := createPlayerParams{} + + if err := faker.FakeData(&playerName); err != nil { + return modelPlayers, fmt.Errorf("creating player: %w", err) + } + model, err := s.store.CreatePlayer(ctx, database.CreatePlayerParams{ - Name: "Player " + fmt.Sprint(i+1), + Name: playerName.Name, TeamID: sql.NullInt64{ Int64: teamID, Valid: true, }, }) + if err != nil { return modelPlayers, fmt.Errorf("creating player: %w", err) } diff --git a/internal/teams/service_test.go b/internal/teams/service_test.go index 70362df..6dd79b3 100644 --- a/internal/teams/service_test.go +++ b/internal/teams/service_test.go @@ -40,7 +40,7 @@ func TestService(t *testing.T) { group.AfterEach(func() { if db != nil { - db.Close() + _ = db.Close() } }) @@ -51,14 +51,24 @@ func TestService(t *testing.T) { coach, err := s.CreateCoach(ctx, CreateCoachParams{ Name: name, + Persona: CoachPersonaBastion, IsHuman: false, }) odize.AssertNoError(t, err) odize.AssertTrue(t, coach.ID > 0) odize.AssertEqual(t, name, coach.Name) + odize.AssertEqual(t, CoachPersonaBastion, coach.Persona) odize.AssertFalse(t, coach.CreatedAt.IsZero()) odize.AssertFalse(t, coach.UpdatedAt.IsZero()) }). + Test("CreateCoach should default persona to wildcard when not provided", func(t *testing.T) { + coach, err := s.CreateCoach(t.Context(), CreateCoachParams{ + Name: "Coach Without Persona", + IsHuman: true, + }) + odize.AssertNoError(t, err) + odize.AssertEqual(t, CoachPersonaWildcard, coach.Persona) + }). Test("SetDefaultCoach should set the default coach", func(t *testing.T) { ctx := t.Context() name := "Coach Carter" @@ -222,7 +232,7 @@ func TestService(t *testing.T) { 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) + odize.AssertTrue(t, len(team.Players[0].Name) > 0) }). Run() diff --git a/internal/teams/tokens.go b/internal/teams/tokens.go new file mode 100644 index 0000000..4f94f11 --- /dev/null +++ b/internal/teams/tokens.go @@ -0,0 +1,25 @@ +package teams + +import ( + "github.com/code-gorilla-au/rush/internal/augments" +) + +var _coachPersonaTokens = map[CoachPersona]augments.Category{ + CoachPersonaVanguard: augments.CategoryOffense, + CoachPersonaBastion: augments.CategoryDefense, + CoachPersonaTrickster: augments.CategorySabotage, + CoachPersonaWildcard: augments.CategoryOffense, +} + +func (p CoachPersona) Augments() []augments.Name { + aug, ok := _coachPersonaTokens[p] + if !ok { + return []augments.Name{} + } + + return augments.NamesFromCategory(aug) +} + +func (c *Coach) AvailableAugments() []augments.Name { + return c.Persona.Augments() +} diff --git a/internal/teams/tokens_test.go b/internal/teams/tokens_test.go new file mode 100644 index 0000000..52f7999 --- /dev/null +++ b/internal/teams/tokens_test.go @@ -0,0 +1,31 @@ +package teams + +import ( + "slices" + "testing" + + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/augments" +) + +func TestCoachPersona_AvailableTokens(t *testing.T) { + group := odize.NewGroup(t, nil) + + err := group. + Test("returns expected tokens for known persona", func(t *testing.T) { + tokens := CoachPersonaBastion.Augments() + + odize.AssertTrue(t, slices.Contains(tokens, augments.SecondChance)) + odize.AssertTrue(t, slices.Contains(tokens, augments.LastStand)) + odize.AssertTrue(t, slices.Contains(tokens, augments.Fortify)) + odize.AssertTrue(t, slices.Contains(tokens, augments.Brace)) + + }). + Test("returns empty slice for unknown persona", func(t *testing.T) { + tokens := CoachPersona("Unknown Coach").Augments() + odize.AssertTrue(t, len(tokens) == 0) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/teams/transforms.go b/internal/teams/transforms.go index aa1aa86..f9d549d 100644 --- a/internal/teams/transforms.go +++ b/internal/teams/transforms.go @@ -8,6 +8,7 @@ func fromCoachModel(m database.Coach) Coach { return Coach{ ID: m.ID, Name: m.Name, + Persona: CoachPersona(m.Persona), IsDefault: m.IsDefault.Bool, IsHuman: m.IsHuman.Bool, CreatedAt: m.CreatedAt.Time, diff --git a/internal/teams/types.go b/internal/teams/types.go index 818e3ed..e24a195 100644 --- a/internal/teams/types.go +++ b/internal/teams/types.go @@ -22,12 +22,13 @@ type Team struct { } 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"` + ID int64 `json:"id,omitzero"` + Name string `json:"name,omitzero"` + Persona CoachPersona `json:"persona,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 { @@ -42,3 +43,12 @@ var ( ErrCoachNotFound = errors.New("coach not found") ErrTeamNotFound = errors.New("team not found") ) + +type CoachPersona string + +const ( + CoachPersonaVanguard CoachPersona = "Vanguard" + CoachPersonaBastion CoachPersona = "Bastion" + CoachPersonaTrickster CoachPersona = "Trickster" + CoachPersonaWildcard CoachPersona = "Wildcard" +) diff --git a/internal/ui/app.go b/internal/ui/app.go index 97c47e7..ab8d496 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -7,67 +7,29 @@ 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/ui/iugame" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uibattle" + "github.com/code-gorilla-au/rush/internal/ui/uilocker" + "github.com/code-gorilla-au/rush/internal/ui/uistate" ) -type MsgStateUpdated struct { - Coach *teams.Coach - Team *teams.Team -} - -type MsgSwitchPage struct { - NewPage Page - Playbook *playbooks.Playbook - GameID int64 -} - -type Page int - -const ( - PageTitle Page = iota + 1 - PageCreateCoach - PageLockerRoom - PageLockerPlayers - PageLockerPlaybooksList - PageLockerPlaybooksCreate - PageLockerPlaybooksEdit - PageNewTournament - PageNewBattleSelection - PageTitleSettings - PageGame - PageGameComplete -) - -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 - pageGame tea.Model - pageGameComplete tea.Model - globalState *GlobalState - teamsSvc *teams.Service - playbookSvc *playbooks.Service - gameSvc *games.Service + ctx context.Context + width int + height int + theme styles.IceTheme + currentPage uistate.Page + pageTitle tea.Model + pageCreateCoach tea.Model + pageLocker tea.Model + pageNewTournament tea.Model + pageNewBattle tea.Model + pageGame tea.Model + globalState *uistate.GlobalState + teamsSvc *teams.Service + playbookSvc *playbooks.Service + gameSvc *games.Service } type Dependencies struct { @@ -78,28 +40,23 @@ type Dependencies struct { // New returns a new UI model. func New(deps Dependencies) *RootModel { - state := &GlobalState{} + state := &uistate.GlobalState{} + theme := styles.NewIceTheme() 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, deps.PlaybookSvc, deps.GameSvc), - pageTitleSettings: NewModelTitleSettings(state), - pageGame: NewModelGame(state, deps.GameSvc), - pageGameComplete: NewPageGameComplete(state, deps.TeamsSvc, deps.GameSvc), - globalState: state, - teamsSvc: deps.TeamsSvc, - playbookSvc: deps.PlaybookSvc, - gameSvc: deps.GameSvc, + ctx: context.Background(), + theme: theme, + currentPage: uistate.PageTitle, + pageTitle: NewModelTitle(state, theme), + pageCreateCoach: NewModelCreateCoach(state, deps.TeamsSvc, theme), + pageLocker: uilocker.NewLockerModel(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), + pageNewTournament: NewModelNewTournament(state, theme), + pageNewBattle: uibattle.NewBattleModel(state, deps.TeamsSvc, deps.PlaybookSvc, deps.GameSvc, theme), + pageGame: iugame.NewGameModel(state, deps.TeamsSvc, deps.GameSvc, theme), + globalState: state, + teamsSvc: deps.TeamsSvc, + playbookSvc: deps.PlaybookSvc, + gameSvc: deps.GameSvc, } } @@ -107,109 +64,87 @@ func (m *RootModel) Init() tea.Cmd { return func() tea.Msg { coach, err := m.teamsSvc.GetDefaultCoach(m.ctx) if err != nil { - return MsgStateUpdated{Coach: nil} + return uistate.MsgStateUpdated{Coach: nil} } team, err := m.teamsSvc.GetTeamByCoachID(m.ctx, coach.ID) if err != nil { - return MsgStateUpdated{Coach: nil} + return uistate.MsgStateUpdated{Coach: &coach, Team: nil} } - return MsgStateUpdated{Coach: &coach, Team: &team} + return uistate.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 uistate.MsgStateUpdated: + return m.handleStateUpdated(msg) case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": return m, tea.Quit } - case MsgSwitchPage: + case uistate.MsgSwitchPage: m.currentPage = msg.NewPage - var cmd tea.Cmd - switch m.currentPage { - case PageNewBattleSelection: - cmd = m.pageNewBattleSelection.Init() - case PageGame: - if page, ok := m.pageGame.(*PageGameModel); ok { - page.SetGameID(msg.GameID) - } - cmd = m.pageGame.Init() - case PageGameComplete: - if page, ok := m.pageGameComplete.(*PageGameCompleteModel); ok { - page.SetGameID(msg.GameID) - } - cmd = m.pageGameComplete.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) + return m.handleWindowSize(msg) + } + + return m.updateCurrentPage(msg) +} + +func (m *RootModel) handleStateUpdated(msg uistate.MsgStateUpdated) (tea.Model, tea.Cmd) { + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + var cmds []tea.Cmd + var cmd tea.Cmd + m.pageTitle, cmd = m.pageTitle.Update(msg) + cmds = append(cmds, cmd) + if m.currentPage == uistate.PageCreateCoach { 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) - m.pageGame, cmd = m.pageGame.Update(msg) - cmds = append(cmds, cmd) - m.pageGameComplete, cmd = m.pageGameComplete.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) } + return m, tea.Batch(cmds...) +} +func (m *RootModel) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + m.width = msg.Width + m.height = msg.Height + var cmds []tea.Cmd + 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.pageLocker, cmd = m.pageLocker.Update(msg) + cmds = append(cmds, cmd) + m.pageNewTournament, cmd = m.pageNewTournament.Update(msg) + cmds = append(cmds, cmd) + m.pageNewBattle, cmd = m.pageNewBattle.Update(msg) + cmds = append(cmds, cmd) + m.pageGame, cmd = m.pageGame.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m *RootModel) updateCurrentPage(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch m.currentPage { - case PageTitle: + case uistate.PageTitle: m.pageTitle, cmd = m.pageTitle.Update(msg) - case PageCreateCoach: + case uistate.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: + case uistate.PageLockerRoom: + m.pageLocker, cmd = m.pageLocker.Update(msg) + case uistate.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) - case PageGame: + case uistate.PageNewBattle: + m.pageNewBattle, cmd = m.pageNewBattle.Update(msg) + case uistate.PageGame: m.pageGame, cmd = m.pageGame.Update(msg) - case PageGameComplete: - m.pageGameComplete, cmd = m.pageGameComplete.Update(msg) } - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) + return m, cmd } func (m *RootModel) View() tea.View { @@ -218,30 +153,18 @@ func (m *RootModel) View() tea.View { } switch m.currentPage { - case PageTitle: + case uistate.PageTitle: return m.pageTitle.View() - case PageCreateCoach: + case uistate.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: + case uistate.PageLockerRoom: + return m.pageLocker.View() + case uistate.PageNewTournament: return m.pageNewTournament.View() - case PageNewBattleSelection: - return m.pageNewBattleSelection.View() - case PageTitleSettings: - return m.pageTitleSettings.View() - case PageGame: + case uistate.PageNewBattle: + return m.pageNewBattle.View() + case uistate.PageGame: return m.pageGame.View() - case PageGameComplete: - return m.pageGameComplete.View() } return tea.NewView("unknown page") diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 57adced..c7e2654 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -1,41 +1,22 @@ 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" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" + "github.com/code-gorilla-au/rush/internal/ui/uitest" ) -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() + theme := styles.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) @@ -52,7 +33,7 @@ func TestNew(t *testing.T) { err := group. Test("New should initialize model with IceTheme", func(t *testing.T) { - s, ps, gs := setupServices(t) + s, ps, gs := uitest.SetupServices(t) m := New(Dependencies{ TeamsSvc: s, PlaybookSvc: ps, @@ -61,7 +42,7 @@ func TestNew(t *testing.T) { odize.AssertTrue(t, m.theme.Logo.GetForeground() != nil) }). Test("Init should return a command", func(t *testing.T) { - s, ps, gs := setupServices(t) + s, ps, gs := uitest.SetupServices(t) m := New(Dependencies{ TeamsSvc: s, PlaybookSvc: ps, @@ -71,7 +52,7 @@ func TestNew(t *testing.T) { odize.AssertTrue(t, cmd != nil) }). Test("Update should handle Quit keys", func(t *testing.T) { - s, ps, gs := setupServices(t) + s, ps, gs := uitest.SetupServices(t) m := New(Dependencies{ TeamsSvc: s, PlaybookSvc: ps, @@ -84,17 +65,38 @@ func TestNew(t *testing.T) { odize.AssertTrue(t, cmd != nil) }). Test("Update should handle WindowSizeMsg", func(t *testing.T) { - s, ps, gs := setupServices(t) + s, ps, gs := uitest.SetupServices(t) m := New(Dependencies{ TeamsSvc: s, PlaybookSvc: ps, GameSvc: gs, }) newModel, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 50}) - updatedModel := newModel.(*RootModel) + updatedModel, ok := newModel.(*RootModel) + odize.AssertTrue(t, ok) odize.AssertTrue(t, updatedModel.width == 100) odize.AssertTrue(t, updatedModel.height == 50) }). + Test("Update should route from create coach to locker room when state is updated", func(t *testing.T) { + s, ps, gs := uitest.SetupServices(t) + m := New(Dependencies{ + TeamsSvc: s, + PlaybookSvc: ps, + GameSvc: gs, + }) + + _, _ = m.Update(uistate.MsgSwitchPage{NewPage: uistate.PageCreateCoach}) + + coach := teams.Coach{ID: 1} + team := teams.Team{ID: 1} + + _, cmd := m.Update(uistate.MsgStateUpdated{Coach: &coach, Team: &team}) + odize.AssertTrue(t, cmd != nil) + + routedMsg, ok := cmd().(uistate.MsgSwitchPage) + odize.AssertTrue(t, ok) + odize.AssertEqual(t, uistate.PageLockerRoom, routedMsg.NewPage) + }). Run() odize.AssertNoError(t, err) diff --git a/internal/ui/components/ai_team_list.go b/internal/ui/components/ai_team_list.go index 54f0655..24e965e 100644 --- a/internal/ui/components/ai_team_list.go +++ b/internal/ui/components/ai_team_list.go @@ -1,102 +1,54 @@ package components import ( - "charm.land/bubbles/v2/list" 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/styles" ) -type AITeamItem struct { - team teams.AITeam -} - -func (i AITeamItem) Title() string { return i.team.Team.Name } -func (i AITeamItem) Description() string { return i.team.Coach.Name } -func (i AITeamItem) FilterValue() string { return i.team.Team.Name } - type AITeamList struct { - list list.Model - active bool + List[teams.AITeam] } -func NewAITeamList(items []teams.AITeam) AITeamList { - listItems := make([]list.Item, len(items)) - for i, item := range items { - listItems[i] = AITeamItem{team: 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 = "AI Teams" - l.SetShowStatusBar(false) - l.SetFilteringEnabled(true) - l.Styles.Title = lipgloss.NewStyle().MarginLeft(2).Foreground(lipgloss.Color("#A5F2F3")).Bold(true) - +func NewAITeamList(items []teams.AITeam, theme styles.IceTheme) AITeamList { return AITeamList{ - list: l, - active: true, + List: NewList(ListConfig[teams.AITeam]{ + Items: items, + ItemMapper: func(item teams.AITeam) ListItem[teams.AITeam] { + return ListItem[teams.AITeam]{ + Data: item, + TitleVal: item.Team.Name, + DescVal: item.Coach.Name, + FilterVal: item.Team.Name, + } + }, + EnableFiltering: true, + DisableAutoResize: true, + }, theme), } } func (l *AITeamList) Update(msg tea.Msg) (AITeamList, tea.Cmd) { var cmd tea.Cmd - l.list, cmd = l.list.Update(msg) + l.List, cmd = l.List.Update(msg) return *l, cmd } -func (l *AITeamList) View() string { - activeStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#A5F2F3")). - Padding(1) - - inactiveStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#333333")). - Padding(1). - Foreground(lipgloss.Color("#666666")) - - if l.active { - return activeStyle.Render(l.list.View()) - } - return inactiveStyle.Render(l.list.View()) +func (l *AITeamList) View(theme styles.IceTheme) string { + return l.List.View() } -func (l *AITeamList) SetActive(active bool) { - l.active = active -} - -func (l *AITeamList) SelectedAITeam() *teams.AITeam { - if item, ok := l.list.SelectedItem().(AITeamItem); ok { - return &item.team +func (l *AITeamList) SelectedItem() *teams.AITeam { + if item, ok := l.List.SelectedItem(); ok { + return &item } return nil } -func (l *AITeamList) SetSize(width, height int) { - l.list.SetSize(width, height) -} - func (l *AITeamList) SetItems(items []teams.AITeam) tea.Cmd { - listItems := make([]list.Item, len(items)) - for i, item := range items { - listItems[i] = AITeamItem{team: item} - } - return l.list.SetItems(listItems) + return l.List.SetItems(items) } func (l *AITeamList) SetTitle(title string) { - l.list.Title = title -} - -func (l *AITeamList) Len() int { - return len(l.list.Items()) -} - -func (l *AITeamList) IsFiltering() bool { - return l.list.FilterState() == list.Filtering + l.Model.Title = title } diff --git a/internal/ui/components/coach_avatar.go b/internal/ui/components/coach_avatar.go index 5820584..bdd79ea 100644 --- a/internal/ui/components/coach_avatar.go +++ b/internal/ui/components/coach_avatar.go @@ -1,10 +1,9 @@ package components import ( - "fmt" - "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) // CoachAvatar component displays team name and coach name. @@ -22,13 +21,13 @@ func NewCoachAvatar(coach *teams.Coach, team *teams.Team) CoachAvatar { } // View renders the CoachAvatar component. -func (c CoachAvatar) View(teamStyle lipgloss.Style, coachStyle lipgloss.Style) string { +func (c CoachAvatar) View(theme styles.IceTheme) 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)) + teamName := theme.CoachTeam.Render(c.Team.Name) + coachName := theme.CoachName.Render("Coach: " + 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 index 7d6af4e..35500d0 100644 --- a/internal/ui/components/coach_avatar_test.go +++ b/internal/ui/components/coach_avatar_test.go @@ -4,9 +4,9 @@ import ( "strings" "testing" - "charm.land/lipgloss/v2" "github.com/code-gorilla-au/odize" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) func TestCoachAvatar(t *testing.T) { @@ -14,8 +14,7 @@ func TestCoachAvatar(t *testing.T) { 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")) + theme := styles.NewIceTheme() err := group. Test("NewCoachAvatar should initialize with given coach and team", func(t *testing.T) { @@ -25,17 +24,17 @@ func TestCoachAvatar(t *testing.T) { }). 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) + rendered := avatar.View(theme) 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)) + odize.AssertEqual(t, "", avatarNoCoach.View(theme)) avatarNoTeam := NewCoachAvatar(coach, nil) - odize.AssertEqual(t, "", avatarNoTeam.View(teamStyle, coachStyle)) + odize.AssertEqual(t, "", avatarNoTeam.View(theme)) }). Run() diff --git a/internal/ui/components/coach_complete.go b/internal/ui/components/coach_complete.go index a7024d9..833a412 100644 --- a/internal/ui/components/coach_complete.go +++ b/internal/ui/components/coach_complete.go @@ -1,11 +1,11 @@ package components import ( - "fmt" "strings" "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) // CoachWinnerHuman component displays the human coach winner details. @@ -23,17 +23,15 @@ func NewCoachWinnerHuman(team *teams.Team, coach *teams.Coach) CoachWinnerHuman } // View renders the CoachWinnerHuman component. -func (c CoachWinnerHuman) View(coachStyle lipgloss.Style) string { +func (c CoachWinnerHuman) View(theme styles.IceTheme) string { if c.Team == nil || c.Coach == nil { return "" } - winnerHeader := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Bold(true). - Render(fmt.Sprintf("Winner: %s", c.Team.Name)) + winnerHeader := theme.SecondaryHeader. + Render("Winner: " + c.Team.Name) - coachInfo := coachStyle.Render(fmt.Sprintf("%s (Human Coach)", c.Coach.Name)) + coachInfo := theme.CoachName.Render(c.Coach.Name + " (Human Coach)") players := make([]string, len(c.Team.Players)) for i, p := range c.Team.Players { @@ -66,17 +64,15 @@ func NewCoachWinnerAI(team *teams.Team, coach *teams.Coach) CoachWinnerAI { } // View renders the CoachWinnerAI component. -func (c CoachWinnerAI) View(coachStyle lipgloss.Style) string { +func (c CoachWinnerAI) View(theme styles.IceTheme) string { if c.Team == nil || c.Coach == nil { return "" } - winnerHeader := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Bold(true). - Render(fmt.Sprintf("Winner: %s", c.Team.Name)) + winnerHeader := theme.SecondaryHeader. + Render("Winner: " + c.Team.Name) - coachInfo := coachStyle.Render(fmt.Sprintf("%s (AI Coach)", c.Coach.Name)) + coachInfo := theme.CoachName.Render(c.Coach.Name + " (AI Coach)") players := make([]string, len(c.Team.Players)) for i, p := range c.Team.Players { diff --git a/internal/ui/components/coach_complete_test.go b/internal/ui/components/coach_complete_test.go index 4e7d19e..0ac744b 100644 --- a/internal/ui/components/coach_complete_test.go +++ b/internal/ui/components/coach_complete_test.go @@ -4,9 +4,9 @@ import ( "strings" "testing" - "charm.land/lipgloss/v2" "github.com/code-gorilla-au/odize" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) func TestCoachWinnerComponents(t *testing.T) { @@ -30,12 +30,12 @@ func TestCoachWinnerComponents(t *testing.T) { IsHuman: false, } - coachStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) + theme := styles.NewIceTheme() err := group. Test("CoachWinnerHuman renders correctly", func(t *testing.T) { c := NewCoachWinnerHuman(team, coachHuman) - view := c.View(coachStyle) + view := c.View(theme) odize.AssertTrue(t, contains(view, "Winner: Test Team")) odize.AssertTrue(t, contains(view, "Human Coach (Human Coach)")) @@ -45,7 +45,7 @@ func TestCoachWinnerComponents(t *testing.T) { }). Test("CoachWinnerAI renders correctly", func(t *testing.T) { c := NewCoachWinnerAI(team, coachAI) - view := c.View(coachStyle) + view := c.View(theme) odize.AssertTrue(t, contains(view, "Winner: Test Team")) odize.AssertTrue(t, contains(view, "AI Coach (AI Coach)")) diff --git a/internal/ui/components/footer.go b/internal/ui/components/footer.go index d760113..d3b9fe1 100644 --- a/internal/ui/components/footer.go +++ b/internal/ui/components/footer.go @@ -2,9 +2,8 @@ package components import ( "charm.land/bubbles/v2/help" - "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) // Footer is a reusable component for displaying help information. @@ -30,33 +29,6 @@ func (f *Footer) Update(msg tea.Msg) { } // 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"), - ), - } +func (f *Footer) View(theme styles.IceTheme) string { + return theme.Footer.Render(f.Help.View(f.KeyMap)) } diff --git a/internal/ui/components/footer_test.go b/internal/ui/components/footer_test.go index fbef367..bafa61e 100644 --- a/internal/ui/components/footer_test.go +++ b/internal/ui/components/footer_test.go @@ -5,8 +5,9 @@ import ( "testing" tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" ) func TestFooter(t *testing.T) { @@ -14,21 +15,21 @@ func TestFooter(t *testing.T) { err := group. Test("NewFooter should initialize with given KeyMap", func(t *testing.T) { - keys := NewCommonKeys() + keys := uistate.NewKeyMap() footer := NewFooter(keys) odize.AssertEqual(t, keys, footer.KeyMap) }). Test("Update should set width from WindowSizeMsg", func(t *testing.T) { - footer := NewFooter(NewCommonKeys()) + footer := NewFooter(uistate.NewKeyMap()) 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")) + footer := NewFooter(uistate.NewKeyMap()) + theme := styles.NewIceTheme() - rendered := footer.View(style) + rendered := footer.View(theme) odize.AssertTrue(t, len(rendered) > 0) odize.AssertTrue(t, strings.Contains(rendered, "q")) @@ -39,23 +40,23 @@ func TestFooter(t *testing.T) { odize.AssertNoError(t, err) } -func TestCommonKeys(t *testing.T) { +func TestKeyMap(t *testing.T) { group := odize.NewGroup(t, nil) err := group. - Test("NewCommonKeys should have Quit binding", func(t *testing.T) { - keys := NewCommonKeys() + Test("NewKeyMap should have Quit binding", func(t *testing.T) { + keys := uistate.NewKeyMap() 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() + keys := uistate.NewKeyMap() 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() + keys := uistate.NewKeyMap() fullHelp := keys.FullHelp() odize.AssertEqual(t, 1, len(fullHelp)) odize.AssertEqual(t, 1, len(fullHelp[0])) diff --git a/internal/ui/components/formation_list.go b/internal/ui/components/formation_list.go index 6c91942..827fb58 100644 --- a/internal/ui/components/formation_list.go +++ b/internal/ui/components/formation_list.go @@ -3,31 +3,14 @@ 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" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) -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 + List[playbooks.Formation] + showDescription bool } type FormationListConfig struct { @@ -37,90 +20,53 @@ type FormationListConfig struct { 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 NewFormationList(config FormationListConfig, theme styles.IceTheme) FormationList { + l := FormationList{ + List: NewList(ListConfig[playbooks.Formation]{ + Title: config.Title, + Items: config.Items, + EnableFiltering: config.EnableFiltering, + DisableAutoResize: true, + ItemMapper: func(f playbooks.Formation) ListItem[playbooks.Formation] { + desc := "" + if config.ShowDescription { + desc = f.Description + } + return ListItem[playbooks.Formation]{ + Data: f, + TitleVal: fmt.Sprintf("%s (%d-%d-%d)", f.Name, f.Lane1, f.Lane2, f.Lane3), + DescVal: desc, + FilterVal: f.Name, + } + }, + }, theme), + showDescription: config.ShowDescription, } + l.Model.Title = "" + return 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) + 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) View(theme styles.IceTheme) string { + return l.List.View() } func (l *FormationList) SelectedItem() playbooks.Formation { - if item, ok := l.list.SelectedItem().(FormationItem); ok { - return item.formation + if item, ok := l.List.SelectedItem(); ok { + return item } 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) + return l.List.SetItems(formations) } 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 + return l.Model.Index() } diff --git a/internal/ui/components/game.go b/internal/ui/components/game.go index 74e5925..34f6f7b 100644 --- a/internal/ui/components/game.go +++ b/internal/ui/components/game.go @@ -2,23 +2,23 @@ package components import ( "fmt" - "math/rand/v2" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/games" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) // Game component handles the UI for a single round of a game. type Game struct { - game *games.Game - teamAName string - teamBName string - resolved bool - result games.Result - rollFn games.RollFn - roundComp Round + game *games.Game + teamAName string + teamBName string + resolved bool + result games.RoundResult + rollEngine games.RollStrategy + roundComp Round } // MsgResolveRound is sent when the round should be resolved. @@ -28,11 +28,9 @@ type MsgResolveRound struct{} type MsgNextRound struct{} // NewGame creates a new Game component. -func NewGame(game *games.Game, teamAName, teamBName string, rollFn games.RollFn) Game { - if rollFn == nil { - rollFn = func() int { - return rand.IntN(10) + 1 // 1-10 - } +func NewGame(game *games.Game, teamAName, teamBName string, rollEngine games.RollStrategy) Game { + if rollEngine == nil { + rollEngine = games.NewDecisionEngine() } currentRoundIdx := game.CurrentRound() @@ -43,11 +41,11 @@ func NewGame(game *games.Game, teamAName, teamBName string, rollFn games.RollFn) } return Game{ - game: game, - teamAName: teamAName, - teamBName: teamBName, - rollFn: rollFn, - roundComp: NewRound(currentRound, teamAName, teamBName), + game: game, + teamAName: teamAName, + teamBName: teamBName, + rollEngine: rollEngine, + roundComp: NewRound(currentRound, teamAName, teamBName), } } @@ -81,7 +79,7 @@ func (g *Game) handleRound() { return } - res, err := g.game.ResolveRound(g.rollFn) + res, err := g.game.ResolveRound(g.rollEngine) if err == nil { g.result = res g.resolved = true @@ -92,51 +90,44 @@ func (g *Game) handleRound() { } // View renders the Game component. -func (g *Game) View() string { - roundView := g.roundComp.View() +func (g *Game) View(theme styles.IceTheme) string { + roundView := g.roundComp.View(theme) roundNum := g.game.CurrentRound() if !g.resolved { roundNum++ } - headerStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#A5F2F3")). - Bold(true). - MarginBottom(1) - - roundInfo := headerStyle.Render(fmt.Sprintf("ROUND %d", roundNum)) + roundInfo := theme.Header.Render(fmt.Sprintf("ROUND %d", roundNum)) + scoreInfo := theme.SecondaryHeader.Render( + fmt.Sprintf("%s %d - %d %s", g.teamAName, g.game.TeamAScore(), g.game.TeamBScore(), g.teamBName), + ) var footer string if g.resolved { winner := g.teamAName - if g.result.Outcome == games.ResultTeamB { + switch g.result.Outcome { + case games.TeamB: winner = g.teamBName - } else if g.result.Outcome == games.ResultDraw { + case games.Draw: winner = "Draw" } - winnerStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFD700")). - Bold(true). - MarginTop(1) - - winnerInfo := winnerStyle.Render(fmt.Sprintf("WINNER: %s! (%d players remaining)", winner, g.result.RemainingPlayers)) - prompt := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888888")). + winnerInfo := theme.Winner.Render(fmt.Sprintf("WINNER: %s! (%d players remaining)", winner, g.result.RemainingPlayers)) + prompt := theme.Muted. MarginTop(1). Render("Press Enter for next round...") footer = lipgloss.JoinVertical(lipgloss.Center, winnerInfo, prompt) } else { - footer = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#888888")). + footer = theme.Muted. MarginTop(1). - Render("Resolving...") + Render("Dual in progress...") } content := lipgloss.JoinVertical(lipgloss.Center, roundInfo, + scoreInfo, roundView, footer, ) diff --git a/internal/ui/components/game_test.go b/internal/ui/components/game_test.go index e06e0d8..31c754c 100644 --- a/internal/ui/components/game_test.go +++ b/internal/ui/components/game_test.go @@ -10,6 +10,7 @@ import ( "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/ui/styles" ) type mockStore struct { @@ -32,15 +33,15 @@ func (m *mockStore) CreateGame(ctx context.Context, arg database.CreateGameParam func TestGameComponent(t *testing.T) { group := odize.NewGroup(t, nil) - teamA := games.TeamConfig{TeamID: 1, TeamName: "Team A", Formations: make([]playbooks.Formation, 10)} - teamB := games.TeamConfig{TeamID: 2, TeamName: "Team B", Formations: make([]playbooks.Formation, 10)} + teamA := games.TeamConfig{TeamID: 1, TeamName: "Team A", Players: []int64{101, 102, 103}, Formations: make([]playbooks.Formation, 10)} + teamB := games.TeamConfig{TeamID: 2, TeamName: "Team B", Players: []int64{201, 202, 203}, Formations: make([]playbooks.Formation, 10)} for i := 0; i < 10; i++ { teamA.Formations[i] = playbooks.Formation{Lane1: 1, Lane2: 1, Lane3: 1} teamB.Formations[i] = playbooks.Formation{Lane1: 1, Lane2: 1, Lane3: 1} } - rolls := []int{6, 1} + rolls := []int{1, 6} idx := 0 rollFn := func() int { val := rolls[idx%2] @@ -49,6 +50,7 @@ func TestGameComponent(t *testing.T) { } group.Test("should resolve round after tick", func(t *testing.T) { + theme := styles.NewIceTheme() svc := games.NewService(&mockStore{}) game, err := svc.NewGame(context.Background(), games.NewGameParams{ TeamA: teamA, @@ -56,13 +58,14 @@ func TestGameComponent(t *testing.T) { }) odize.AssertNoError(t, err) - gComp := NewGame(&game, "Team A", "Team B", rollFn) + gComp := NewGame(&game, "Team A", "Team B", &games.TestEngine{RollFn: rollFn}) // Initial state odize.AssertFalse(t, gComp.resolved) - view := gComp.View() + view := gComp.View(theme) odize.AssertTrue(t, strings.Contains(view, "ROUND 1")) - odize.AssertTrue(t, strings.Contains(view, "Resolving...")) + odize.AssertTrue(t, strings.Contains(view, "Team A 0 - 0 Team B")) + odize.AssertTrue(t, strings.Contains(view, "Dual in progress...")) // Handle MsgResolveRound cmd := gComp.Update(MsgResolveRound{}) @@ -70,7 +73,8 @@ func TestGameComponent(t *testing.T) { odize.AssertTrue(t, gComp.resolved) // Resolved state - view = gComp.View() + view = gComp.View(theme) + odize.AssertTrue(t, strings.Contains(view, "Team A 1 - 0 Team B")) odize.AssertTrue(t, strings.Contains(view, "WINNER: Team A")) odize.AssertTrue(t, strings.Contains(view, "Press Enter for next round...")) diff --git a/internal/ui/components/list.go b/internal/ui/components/list.go new file mode 100644 index 0000000..6e5675a --- /dev/null +++ b/internal/ui/components/list.go @@ -0,0 +1,174 @@ +package components + +import ( + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/ui/styles" +) + +type List[T any] struct { + Model list.Model + Active bool + AutoResize bool + ItemMapper func(T) ListItem[T] + width int + height int + topPadding int + bottomPadding int + leftPadding int + rightPadding int +} + +type ListItem[T any] struct { + Data T + TitleVal string + DescVal string + FilterVal string +} + +func (i ListItem[T]) Title() string { return i.TitleVal } +func (i ListItem[T]) Description() string { return i.DescVal } +func (i ListItem[T]) FilterValue() string { return i.FilterVal } + +type ListConfig[T any] struct { + Title string + Items []T + ItemMapper func(T) ListItem[T] + EnableFiltering bool + DisableAutoResize bool + TopPadding int + BottomPadding int + LeftPadding int + RightPadding int +} + +func NewList[T any](config ListConfig[T], theme styles.IceTheme) List[T] { + items := make([]list.Item, len(config.Items)) + for i, item := range config.Items { + items[i] = config.ItemMapper(item) + } + + delegate := list.NewDefaultDelegate() + delegate.Styles.NormalTitle = theme.Base.PaddingLeft(3) + delegate.Styles.NormalDesc = theme.Muted.PaddingLeft(3) + delegate.Styles.SelectedTitle = theme.SelectedTitle + delegate.Styles.SelectedDesc = theme.SelectedDesc + delegate.Styles.DimmedTitle = theme.Muted.PaddingLeft(3) + delegate.Styles.DimmedDesc = theme.Muted.PaddingLeft(3) + + l := list.New(items, delegate, 0, 0) + l.Title = config.Title + l.SetShowTitle(config.Title != "") + l.SetShowStatusBar(false) + l.SetFilteringEnabled(config.EnableFiltering) + l.SetShowHelp(false) + l.Styles.Title = theme.Title + + return List[T]{ + Model: l, + Active: true, + AutoResize: !config.DisableAutoResize, + ItemMapper: config.ItemMapper, + topPadding: config.TopPadding, + bottomPadding: config.BottomPadding, + leftPadding: config.LeftPadding, + rightPadding: config.RightPadding, + } +} + +func (l *List[T]) Update(msg tea.Msg) (List[T], tea.Cmd) { + if !l.Active { + return *l, nil + } + var cmd tea.Cmd + + l.Model, cmd = l.Model.Update(msg) + + if wm, ok := msg.(tea.WindowSizeMsg); ok { + if l.AutoResize { + l.SetSize(wm.Width, wm.Height) + } else { + l.SetSize(l.width, l.height) + } + } + + return *l, cmd +} + +func (l *List[T]) View() string { + style := lipgloss.NewStyle(). + Padding(l.topPadding, l.rightPadding, l.bottomPadding, l.leftPadding) + + if l.width > 0 { + style = style.Width(l.width) + } + if l.height > 0 { + style = style.Height(l.height) + } + + return style.Render(l.Model.View()) +} + +func (l *List[T]) SetSize(width, height int) { + l.width = width + l.height = height + l.Model.SetSize( + max(0, width-l.leftPadding-l.rightPadding), + max(0, height-l.topPadding-l.bottomPadding), + ) +} + +func (l *List[T]) SetPadding(top, right, bottom, left int) { + l.topPadding = top + l.rightPadding = right + l.bottomPadding = bottom + l.leftPadding = left + l.Model.SetSize( + max(0, l.width-l.leftPadding-l.rightPadding), + max(0, l.height-l.topPadding-l.bottomPadding), + ) +} + +func (l *List[T]) SetItems(items []T) tea.Cmd { + listItems := make([]list.Item, len(items)) + for i, item := range items { + listItems[i] = l.ItemMapper(item) + } + return l.Model.SetItems(listItems) +} + +func (l *List[T]) SelectedItem() (T, bool) { + item := l.Model.SelectedItem() + if item == nil { + var zero T + return zero, false + } + if li, ok := item.(ListItem[T]); ok { + return li.Data, true + } + var zero T + return zero, false +} + +func (l *List[T]) Len() int { + return len(l.Model.Items()) +} + +func (l *List[T]) IsFiltering() bool { + return l.Model.FilterState() == list.Filtering +} + +func (l *List[T]) Reset() { + l.Model.Select(0) + l.Model.FilterInput.Reset() +} + +func (l *List[T]) SetActive(active bool) { + l.Active = active +} + +func (l *List[T]) SetTitle(title string) { + l.Model.Title = title + l.Model.SetShowTitle(title != "") +} diff --git a/internal/ui/components/list_test.go b/internal/ui/components/list_test.go new file mode 100644 index 0000000..8c2e489 --- /dev/null +++ b/internal/ui/components/list_test.go @@ -0,0 +1,162 @@ +package components + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/ui/styles" +) + +func TestList(t *testing.T) { + group := odize.NewGroup(t, nil) + + theme := styles.NewIceTheme() + + err := group. + Test("NewList initializes with items", func(t *testing.T) { + items := []string{"Item 1", "Item 2"} + config := ListConfig[string]{ + Title: "Test List", + Items: items, + ItemMapper: func(s string) ListItem[string] { + return ListItem[string]{ + Data: s, + TitleVal: s, + } + }, + } + l := NewList(config, theme) + + odize.AssertEqual(t, 2, l.Len()) + selected, ok := l.SelectedItem() + odize.AssertTrue(t, ok) + odize.AssertEqual(t, "Item 1", selected) + }). + Test("SetSize updates dimensions with no default padding", func(t *testing.T) { + items := []string{"Item 1"} + config := ListConfig[string]{ + Items: items, + ItemMapper: func(s string) ListItem[string] { + return ListItem[string]{Data: s, TitleVal: s} + }, + } + l := NewList(config, theme) + + l.SetSize(100, 50) + // Defaults are now 0 + odize.AssertEqual(t, 100, l.Model.Width()) + odize.AssertEqual(t, 50, l.Model.Height()) + }). + Test("Update handles tea.WindowSizeMsg when AutoResize is enabled", func(t *testing.T) { + items := []string{"Item 1"} + config := ListConfig[string]{ + Items: items, + ItemMapper: func(s string) ListItem[string] { + return ListItem[string]{Data: s, TitleVal: s} + }, + DisableAutoResize: false, + } + l := NewList(config, theme) + + newModel, _ := l.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) + + odize.AssertEqual(t, 80, newModel.Model.Width()) + odize.AssertEqual(t, 40, newModel.Model.Height()) + }). + Test("Update ignores tea.WindowSizeMsg when AutoResize is disabled", func(t *testing.T) { + items := []string{"Item 1"} + config := ListConfig[string]{ + Items: items, + ItemMapper: func(s string) ListItem[string] { + return ListItem[string]{Data: s, TitleVal: s} + }, + DisableAutoResize: true, + } + l := NewList(config, theme) + + newModel, _ := l.Update(tea.WindowSizeMsg{Width: 80, Height: 40}) + + odize.AssertEqual(t, 0, newModel.Model.Width()) + odize.AssertEqual(t, 0, newModel.Model.Height()) + }). + Test("SetPadding and SetSize account for granular padding", func(t *testing.T) { + items := []string{"Item 1"} + config := ListConfig[string]{ + Items: items, + ItemMapper: func(s string) ListItem[string] { + return ListItem[string]{Data: s, TitleVal: s} + }, + LeftPadding: 5, + RightPadding: 5, + TopPadding: 3, + BottomPadding: 3, + } + l := NewList(config, theme) + + l.SetSize(100, 50) + odize.AssertEqual(t, 90, l.Model.Width()) + odize.AssertEqual(t, 44, l.Model.Height()) + + l.SetPadding(5, 5, 5, 5) // top, right, bottom, left + odize.AssertEqual(t, 90, l.Model.Width()) + odize.AssertEqual(t, 40, l.Model.Height()) + }). + Test("View applies padding", func(t *testing.T) { + items := []string{"Item 1"} + config := ListConfig[string]{ + Items: items, + ItemMapper: func(s string) ListItem[string] { + return ListItem[string]{Data: s, TitleVal: s} + }, + LeftPadding: 10, + TopPadding: 6, + } + l := NewList(config, theme) + l.SetSize(100, 50) + + view := l.View() + // The view should have 3 lines of top padding (6/2) + // We can check this by counting newlines at the beginning. + // However, lipgloss might render it differently. + + // Just check that it's not empty and has some length. + odize.AssertTrue(t, len(view) > 0) + }). + Test("View fills the available width", func(t *testing.T) { + items := []string{"Item 1"} + config := ListConfig[string]{ + Items: items, + ItemMapper: func(s string) ListItem[string] { + return ListItem[string]{Data: s, TitleVal: s} + }, + LeftPadding: 0, + RightPadding: 0, + } + theme := styles.NewIceTheme() + l := NewList(config, theme) + l.SetSize(50, 10) + + view := l.View() + lines := strings.Split(view, "\n") + // Find a line that should have content (bubbles list might have some empty lines at start/end depending on padding) + // But since we enforced Width(50) on the style (well, we want to), every line should be 50 chars. + + // For now, let's just see what it currently does. + maxWidth := 0 + for _, line := range lines { + w := lipgloss.Width(line) + if w > maxWidth { + maxWidth = w + } + } + // If it's not enforcing width, maxWidth will likely be the width of "Item 1" + some padding + // which is much less than 50. + odize.AssertEqual(t, 50, maxWidth) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/components/locker_room_list.go b/internal/ui/components/locker_room_list.go index 6b8c8ba..cf15c63 100644 --- a/internal/ui/components/locker_room_list.go +++ b/internal/ui/components/locker_room_list.go @@ -2,71 +2,62 @@ package components import ( tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) type LockerRoomItem int const ( ItemPlayers LockerRoomItem = iota + ItemTeamStatistics ItemPlaybooks - ItemSettings ) func (i LockerRoomItem) String() string { switch i { case ItemPlayers: return "Players" + case ItemTeamStatistics: + return "Team Statistics" case ItemPlaybooks: return "Playbooks" - case ItemSettings: - return "Settings" } return "" } type LockerRoomList struct { - cursor int - items []LockerRoomItem + List[LockerRoomItem] } -func NewLockerRoomList() LockerRoomList { +func NewLockerRoomList(theme styles.IceTheme) LockerRoomList { return LockerRoomList{ - items: []LockerRoomItem{ItemPlayers, ItemPlaybooks, ItemSettings}, + List: NewList(ListConfig[LockerRoomItem]{ + Items: []LockerRoomItem{ItemPlayers, ItemTeamStatistics, ItemPlaybooks}, + ItemMapper: func(i LockerRoomItem) ListItem[LockerRoomItem] { + return ListItem[LockerRoomItem]{ + Data: i, + TitleVal: i.String(), + } + }, + EnableFiltering: false, + DisableAutoResize: true, + }, theme), } } -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) Update(msg tea.Msg) (LockerRoomList, tea.Cmd) { + var cmd tea.Cmd + l.List, cmd = l.List.Update(msg) + return *l, cmd } -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) View(theme styles.IceTheme) string { + return l.List.View() } func (l *LockerRoomList) SelectedItem() LockerRoomItem { - return l.items[l.cursor] + if item, ok := l.List.SelectedItem(); ok { + return item + } + return -1 } diff --git a/internal/ui/components/locker_room_list_test.go b/internal/ui/components/locker_room_list_test.go index 10a2abf..6b21b86 100644 --- a/internal/ui/components/locker_room_list_test.go +++ b/internal/ui/components/locker_room_list_test.go @@ -5,46 +5,50 @@ import ( tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) func TestLockerRoomList(t *testing.T) { group := odize.NewGroup(t, nil) + theme := styles.NewIceTheme() 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]) + l := NewLockerRoomList(theme) + odize.AssertEqual(t, 3, len(l.Model.Items())) + odize.AssertEqual(t, ItemPlayers, l.SelectedItem()) }) group.Test("Update should move cursor down", func(t *testing.T) { - l := NewLockerRoomList() - odize.AssertEqual(t, 0, l.cursor) + l := NewLockerRoomList(theme) + odize.AssertEqual(t, 0, l.Model.Index()) + + l.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, 1, l.Model.Index()) + odize.AssertEqual(t, ItemTeamStatistics, l.SelectedItem()) l.Update(tea.KeyPressMsg{Text: "down"}) - odize.AssertEqual(t, 1, l.cursor) + odize.AssertEqual(t, 2, l.Model.Index()) odize.AssertEqual(t, ItemPlaybooks, l.SelectedItem()) }) group.Test("Update should move cursor up", func(t *testing.T) { - l := NewLockerRoomList() - l.cursor = 1 + l := NewLockerRoomList(theme) + l.Model.Select(1) l.Update(tea.KeyPressMsg{Text: "up"}) - odize.AssertEqual(t, 0, l.cursor) + odize.AssertEqual(t, 0, l.Model.Index()) odize.AssertEqual(t, ItemPlayers, l.SelectedItem()) }) group.Test("Update should not move cursor out of bounds", func(t *testing.T) { - l := NewLockerRoomList() + l := NewLockerRoomList(theme) l.Update(tea.KeyPressMsg{Text: "up"}) - odize.AssertEqual(t, 0, l.cursor) + odize.AssertEqual(t, 0, l.Model.Index()) - l.cursor = 2 + l.Model.Select(2) l.Update(tea.KeyPressMsg{Text: "down"}) - odize.AssertEqual(t, 2, l.cursor) + odize.AssertEqual(t, 2, l.Model.Index()) }) err := group.Run() diff --git a/internal/ui/components/playbook_list.go b/internal/ui/components/playbook_list.go index 75006c6..ebdd7b5 100644 --- a/internal/ui/components/playbook_list.go +++ b/internal/ui/components/playbook_list.go @@ -1,102 +1,54 @@ 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" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) -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 - active bool + List[playbooks.Playbook] } -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) - +func NewPlaybookList(items []playbooks.Playbook, theme styles.IceTheme) PlaybookList { return PlaybookList{ - list: l, - active: true, + List: NewList(ListConfig[playbooks.Playbook]{ + Items: items, + ItemMapper: func(item playbooks.Playbook) ListItem[playbooks.Playbook] { + return ListItem[playbooks.Playbook]{ + Data: item, + TitleVal: item.Name, + DescVal: item.Description, + FilterVal: item.Name, + } + }, + EnableFiltering: true, + DisableAutoResize: true, + }, theme), } } func (l *PlaybookList) Update(msg tea.Msg) (PlaybookList, tea.Cmd) { var cmd tea.Cmd - l.list, cmd = l.list.Update(msg) + l.List, cmd = l.List.Update(msg) return *l, cmd } -func (l *PlaybookList) View() string { - activeStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#A5F2F3")). - Padding(1) - - inactiveStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#333333")). - Padding(1). - Foreground(lipgloss.Color("#666666")) - - if l.active { - return activeStyle.Render(l.list.View()) - } - return inactiveStyle.Render(l.list.View()) -} - -func (l *PlaybookList) SetActive(active bool) { - l.active = active +func (l *PlaybookList) View(theme styles.IceTheme) string { + return l.List.View() } func (l *PlaybookList) SelectedItem() *playbooks.Playbook { - if item, ok := l.list.SelectedItem().(PlaybookItem); ok { - return &item.playbook + if item, ok := l.List.SelectedItem(); ok { + return &item } 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) + return l.List.SetItems(items) } func (l *PlaybookList) SetTitle(title string) { - l.list.Title = title -} - -func (l *PlaybookList) Len() int { - return len(l.list.Items()) -} - -func (l *PlaybookList) IsFiltering() bool { - return l.list.FilterState() == list.Filtering + l.Model.Title = title } diff --git a/internal/ui/components/player_list.go b/internal/ui/components/player_list.go index 4a64449..3c84eb4 100644 --- a/internal/ui/components/player_list.go +++ b/internal/ui/components/player_list.go @@ -3,8 +3,8 @@ 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" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) type PlayerItem struct { @@ -41,32 +41,37 @@ func (l *PlayerList) Update(msg tea.Msg) tea.Cmd { } 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 + return l.handleEditing(item, msg) + } + + return l.handleNavigation(item, msg) +} + +func (l *PlayerList) handleEditing(item *PlayerItem, msg tea.Msg) tea.Cmd { + if msg, ok := msg.(tea.KeyMsg); ok { + 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 } + var cmd tea.Cmd + item.Input, cmd = item.Input.Update(msg) + return cmd +} - switch msg := msg.(type) { - case tea.KeyMsg: +func (l *PlayerList) handleNavigation(item *PlayerItem, msg tea.Msg) tea.Cmd { + if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { case "up", "k": if l.cursor > 0 { @@ -84,7 +89,7 @@ func (l *PlayerList) Update(msg tea.Msg) tea.Cmd { return nil } -func (l *PlayerList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { +func (l *PlayerList) View(theme styles.IceTheme) string { var s string for i, item := range l.Items { var content string @@ -95,9 +100,9 @@ func (l *PlayerList) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style } if i == l.cursor { - s += selectedStyle.Render("> " + content) + s += theme.ListSelected.Render("> " + content) } else { - s += itemStyle.Render(" " + content) + s += theme.Base.Render(" " + content) } if i < len(l.Items)-1 { s += "\n" diff --git a/internal/ui/components/round.go b/internal/ui/components/round.go index 5873ed5..2673845 100644 --- a/internal/ui/components/round.go +++ b/internal/ui/components/round.go @@ -6,6 +6,7 @@ import ( "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/games" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) type Round struct { @@ -22,50 +23,32 @@ func NewRound(round games.Round, teamAName, teamBName string) Round { } } -func (r Round) View() string { - teamAStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#A5F2F3")).Bold(true).Width(10).Align(lipgloss.Right) - teamBStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Width(10).Align(lipgloss.Left) - playerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#87CEEB")) - separatorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")) - laneLabelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).PaddingLeft(2) +func (r Round) View(theme styles.IceTheme) string { + const laneWidth = 16 renderPlayers := func(count int, align lipgloss.Position) string { - dots := strings.TrimSpace(strings.Repeat("● ", count)) - content := playerStyle.Render(dots) - return lipgloss.NewStyle().Width(10).Align(align).Render(content) + dots := strings.TrimSpace(strings.Repeat("⬤ ", count)) + content := theme.Player.Render(dots) + return lipgloss.NewStyle().Width(laneWidth).Align(align).Render(content) } - header := lipgloss.JoinHorizontal(lipgloss.Top, - teamAStyle.Render(r.teamAName), - separatorStyle.Render(" | "), - teamBStyle.Render(r.teamBName), - ) - - divider := separatorStyle.Render(strings.Repeat("-", 10) + "-|-" + strings.Repeat("-", 10)) - renderLane := func(laneNum int) string { aPlayers := renderPlayers(len(r.round.TeamA.Lanes[laneNum-1]), lipgloss.Right) bPlayers := renderPlayers(len(r.round.TeamB.Lanes[laneNum-1]), lipgloss.Left) return lipgloss.JoinHorizontal(lipgloss.Top, aPlayers, - separatorStyle.Render(" | "), + theme.Separator.Render(" ┃ "), bPlayers, - laneLabelStyle.Render(fmt.Sprintf("Lane %d", laneNum)), + theme.Label.Render(fmt.Sprintf("Lane %d", laneNum)), ) } view := lipgloss.JoinVertical( lipgloss.Left, - header, - divider, renderLane(1), renderLane(2), renderLane(3), ) - return lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#87CEEB")). - Padding(1). - Render(view) + return theme.RoundBorder.Render(view) } diff --git a/internal/ui/components/round_test.go b/internal/ui/components/round_test.go index 68eb741..28255de 100644 --- a/internal/ui/components/round_test.go +++ b/internal/ui/components/round_test.go @@ -6,6 +6,7 @@ import ( "github.com/code-gorilla-au/odize" "github.com/code-gorilla-au/rush/internal/games" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) func TestRound(t *testing.T) { @@ -14,14 +15,14 @@ func TestRound(t *testing.T) { round := games.Round{ TeamA: games.TeamFormation{ - Lanes: [3][]int{ + Lanes: [3][]int64{ {1, 2}, {3}, {4, 5, 6}, }, }, TeamB: games.TeamFormation{ - Lanes: [3][]int{ + Lanes: [3][]int64{ {7}, {8, 9}, {}, @@ -35,18 +36,19 @@ func TestRound(t *testing.T) { odize.AssertEqual(t, "Team A", rComp.teamAName) odize.AssertEqual(t, "Team B", rComp.teamBName) }). - Test("View should render team names and players in side-by-side formation", func(t *testing.T) { + Test("View should render players in side-by-side formation", func(t *testing.T) { rComp := NewRound(round, "Team A", "Team B") - rendered := rComp.View() + theme := styles.NewIceTheme() + rendered := rComp.View(theme) - odize.AssertTrue(t, strings.Contains(rendered, "Team A")) - odize.AssertTrue(t, strings.Contains(rendered, "Team B")) - odize.AssertTrue(t, strings.Contains(rendered, "|")) + odize.AssertFalse(t, strings.Contains(rendered, "Team A")) + odize.AssertFalse(t, strings.Contains(rendered, "Team B")) + odize.AssertTrue(t, strings.Contains(rendered, "┃")) odize.AssertTrue(t, strings.Contains(rendered, "Lane 1")) odize.AssertTrue(t, strings.Contains(rendered, "Lane 2")) odize.AssertTrue(t, strings.Contains(rendered, "Lane 3")) // Check for player icons (dots) - odize.AssertTrue(t, strings.Contains(rendered, "●")) + odize.AssertTrue(t, strings.Contains(rendered, "⬤")) }). Run() diff --git a/internal/ui/components/team_tile.go b/internal/ui/components/team_tile.go new file mode 100644 index 0000000..b805421 --- /dev/null +++ b/internal/ui/components/team_tile.go @@ -0,0 +1,80 @@ +package components + +import ( + "slices" + "strings" + + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/ui/styles" +) + +type TeamTile struct { + TeamName string + CoachName string + PlaybookName string + PlayerNames []string +} + +func NewTeamTile(teamName, coachName, playbookName string, playerNames []string) TeamTile { + return TeamTile{ + TeamName: teamName, + CoachName: coachName, + PlaybookName: playbookName, + PlayerNames: slices.Clone(playerNames), + } +} + +func (t TeamTile) View(theme styles.IceTheme, width int) string { + tileBody := lipgloss.JoinVertical(lipgloss.Left, + theme.SecondaryHeader.Render(valueOrDefault(t.TeamName, "Unknown Team")), + renderField(theme, "Coach", t.CoachName), + renderField(theme, "Playbook", t.PlaybookName), + "", + theme.Muted.Render("Players"), + renderPlayers(theme, t.PlayerNames), + ) + + tileStyle := theme.RoundBorder + tileStyle.Align(lipgloss.Left) + + if width > 0 { + tileStyle = tileStyle.Width(width) + } + + return tileStyle.Render(tileBody) +} + +func renderField(theme styles.IceTheme, label, value string) string { + return lipgloss.JoinHorizontal( + lipgloss.Top, + theme.Muted.Render(label+": "), + theme.Base.Render(valueOrDefault(value, "Unknown")), + ) +} + +func renderPlayers(theme styles.IceTheme, playerNames []string) string { + players := make([]string, 0, len(playerNames)) + for _, playerName := range playerNames { + name := strings.TrimSpace(playerName) + if name == "" { + continue + } + + players = append(players, theme.Player.Render("• "+name)) + } + + if len(players) == 0 { + return theme.Muted.Render("No players") + } + + return lipgloss.JoinVertical(lipgloss.Left, players...) +} + +func valueOrDefault(value, fallback string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fallback + } + + return trimmed +} diff --git a/internal/ui/components/team_tile_test.go b/internal/ui/components/team_tile_test.go new file mode 100644 index 0000000..d643ccb --- /dev/null +++ b/internal/ui/components/team_tile_test.go @@ -0,0 +1,41 @@ +package components + +import ( + "strings" + "testing" + + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/ui/styles" +) + +func TestTeamTile(t *testing.T) { + t.Parallel() + group := odize.NewGroup(t, nil) + + err := group. + Test("View should render team, coach, playbook, and players", func(t *testing.T) { + tile := NewTeamTile("Frost Wolves", "Coach Ice", "North Formation", []string{"Alice", "Bob"}) + + rendered := tile.View(styles.NewIceTheme(), 40) + + odize.AssertTrue(t, strings.Contains(rendered, "Frost Wolves")) + odize.AssertTrue(t, strings.Contains(rendered, "Coach Ice")) + odize.AssertTrue(t, strings.Contains(rendered, "North Formation")) + odize.AssertTrue(t, strings.Contains(rendered, "Alice")) + odize.AssertTrue(t, strings.Contains(rendered, "Bob")) + }). + Test("View should show fallback values when data is empty", func(t *testing.T) { + tile := NewTeamTile("", "", "", []string{"", " "}) + + rendered := tile.View(styles.NewIceTheme(), 30) + + odize.AssertTrue(t, strings.Contains(rendered, "Unknown Team")) + odize.AssertTrue(t, strings.Contains(rendered, "Coach")) + odize.AssertTrue(t, strings.Contains(rendered, "Playbook")) + odize.AssertTrue(t, strings.Count(rendered, "Unknown") >= 2) + odize.AssertTrue(t, strings.Contains(rendered, "No players")) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/components/title_menu.go b/internal/ui/components/title_menu.go index a51d1a1..7db6ee8 100644 --- a/internal/ui/components/title_menu.go +++ b/internal/ui/components/title_menu.go @@ -1,8 +1,7 @@ package components import ( - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/ui/styles" ) type TitleItem int @@ -12,7 +11,6 @@ const ( TitleItemLockerRoom TitleItemNewTournament TitleItemNewBattleSelection - TitleItemSettings ) func (i TitleItem) String() string { @@ -25,8 +23,6 @@ func (i TitleItem) String() string { return "New Tournament" case TitleItemNewBattleSelection: return "New Battle" - case TitleItemSettings: - return "Settings" } return "" } @@ -41,36 +37,32 @@ func NewTitleMenu(hasCoach bool) TitleMenu { if !hasCoach { items = []TitleItem{TitleItemCreateCoach} } else { - items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattleSelection, TitleItemSettings} + items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattleSelection} } 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) MoveUp() { + if m.cursor > 0 { + m.cursor-- + } +} + +func (m *TitleMenu) MoveDown() { + if m.cursor < len(m.items)-1 { + m.cursor++ } } -func (m *TitleMenu) View(itemStyle lipgloss.Style, selectedStyle lipgloss.Style) string { +func (m *TitleMenu) View(theme styles.IceTheme) string { var s string for i, item := range m.items { if i == m.cursor { - s += selectedStyle.Render("> " + item.String()) + s += theme.ListSelected.Render("> " + item.String()) } else { - s += itemStyle.Render(" " + item.String()) + s += theme.Base.Render(" " + item.String()) } if i < len(m.items)-1 { s += "\n" @@ -91,7 +83,7 @@ func (m *TitleMenu) SetHasCoach(hasCoach bool) { if !hasCoach { items = []TitleItem{TitleItemCreateCoach} } else { - items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattleSelection, TitleItemSettings} + items = []TitleItem{TitleItemLockerRoom, TitleItemNewTournament, TitleItemNewBattleSelection} } m.items = items if m.cursor >= len(m.items) { diff --git a/internal/ui/page_game.go b/internal/ui/iugame/page_game.go similarity index 80% rename from internal/ui/page_game.go rename to internal/ui/iugame/page_game.go index d279f06..7cc7726 100644 --- a/internal/ui/page_game.go +++ b/internal/ui/iugame/page_game.go @@ -1,4 +1,4 @@ -package ui +package iugame import ( "strings" @@ -7,22 +7,24 @@ import ( "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" ) type PageGameModel struct { width int height int - theme IceTheme - globalState *GlobalState + theme styles.IceTheme + globalState *uistate.GlobalState gameSvc *games.Service gameID int64 game *games.Game gameComp components.Game } -func NewModelGame(state *GlobalState, gameSvc *games.Service) *PageGameModel { +func NewModelGame(state *uistate.GlobalState, gameSvc *games.Service, theme styles.IceTheme) *PageGameModel { return &PageGameModel{ - theme: NewIceTheme(), + theme: theme, globalState: state, gameSvc: gameSvc, } @@ -41,6 +43,7 @@ func (m *PageGameModel) SetGameID(id int64) { } func (m *PageGameModel) Init() tea.Cmd { + m.game = nil return func() tea.Msg { game, err := m.gameSvc.GetGame(m.globalState.Context(), m.gameID) if err != nil { @@ -54,7 +57,7 @@ func (m *PageGameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { - case MsgStateUpdated: + case uistate.MsgStateUpdated: m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team case tea.WindowSizeMsg: @@ -64,7 +67,7 @@ func (m *PageGameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.game = new(games.Game) *m.game = msg.Game teamA, teamB := getTeamNames(m.game.Name()) - m.gameComp = components.NewGame(m.game, teamA, teamB, nil) + m.gameComp = components.NewGame(m.game, teamA, teamB, games.NewDecisionEngine()) cmds = append(cmds, m.gameComp.Init()) case components.MsgResolveRound: cmds = append(cmds, m.gameComp.Update(msg)) @@ -78,7 +81,7 @@ func (m *PageGameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case components.MsgNextRound: if !m.game.IsGameComplete() { teamA, teamB := getTeamNames(m.game.Name()) - m.gameComp = components.NewGame(m.game, teamA, teamB, nil) + m.gameComp = components.NewGame(m.game, teamA, teamB, games.NewDecisionEngine()) cmds = append(cmds, m.gameComp.Init()) } else { cmds = append(cmds, func() tea.Msg { @@ -86,8 +89,8 @@ func (m *PageGameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if err != nil { return MsgGameError{Err: err} } - return MsgSwitchPage{ - NewPage: PageGameComplete, + return MsgSwitchGamePage{ + NewPage: SubPageGameComplete, GameID: m.game.ID(), } }) @@ -117,7 +120,7 @@ func (m *PageGameModel) View() tea.View { if m.game == nil { mainContent = "Loading Game..." } else { - mainContent = m.gameComp.View() + mainContent = m.gameComp.View(m.theme) } centeredContent := lipgloss.Place( diff --git a/internal/ui/page_game_complete.go b/internal/ui/iugame/page_game_complete.go similarity index 80% rename from internal/ui/page_game_complete.go rename to internal/ui/iugame/page_game_complete.go index 3aa2e2b..dfc91be 100644 --- a/internal/ui/page_game_complete.go +++ b/internal/ui/iugame/page_game_complete.go @@ -1,4 +1,4 @@ -package ui +package iugame import ( "fmt" @@ -8,13 +8,15 @@ import ( "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/teams" "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" ) type PageGameCompleteModel struct { width int height int - theme IceTheme - globalState *GlobalState + theme styles.IceTheme + globalState *uistate.GlobalState teamsSvc *teams.Service gameSvc *games.Service gameID int64 @@ -24,9 +26,9 @@ type PageGameCompleteModel struct { err error } -func NewPageGameComplete(state *GlobalState, teamsSvc *teams.Service, gameSvc *games.Service) *PageGameCompleteModel { +func NewPageGameComplete(state *uistate.GlobalState, teamsSvc *teams.Service, gameSvc *games.Service, theme styles.IceTheme) *PageGameCompleteModel { return &PageGameCompleteModel{ - theme: NewIceTheme(), + theme: theme, globalState: state, teamsSvc: teamsSvc, gameSvc: gameSvc, @@ -89,7 +91,7 @@ func (m *PageGameCompleteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "enter": return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageTitle} + return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} } } } @@ -113,7 +115,7 @@ func (m *PageGameCompleteModel) View() tea.View { if m.winnerTeam == nil && !m.isDraw { content = "Loading Results..." } else { - content = m.renderMainContent(content) + content = m.renderMainContent() } centered := lipgloss.Place( @@ -130,34 +132,29 @@ func (m *PageGameCompleteModel) View() tea.View { return view } -func (m *PageGameCompleteModel) renderMainContent(content string) string { - winMsg := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#A5F2F3")). - Bold(true). +func (m *PageGameCompleteModel) renderMainContent() string { + winMsg := m.theme.Logo. Padding(1, 0). Render("🏆 GAME COMPLETE 🏆") var mainContent string if m.isDraw { - mainContent = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFFFF")). - Bold(true). + mainContent = m.theme.SecondaryHeader. Render("It's a DRAW!") } else { if m.winnerCoach.IsHuman { - mainContent = components.NewCoachWinnerHuman(m.winnerTeam, m.winnerCoach).View(m.theme.CoachName) + mainContent = components.NewCoachWinnerHuman(m.winnerTeam, m.winnerCoach).View(m.theme) } else { - mainContent = components.NewCoachWinnerAI(m.winnerTeam, m.winnerCoach).View(m.theme.CoachName) + mainContent = components.NewCoachWinnerAI(m.winnerTeam, m.winnerCoach).View(m.theme) } } footer := m.theme.Footer.Render("\nPress Enter to continue") - content = lipgloss.JoinVertical( + return lipgloss.JoinVertical( lipgloss.Center, winMsg, mainContent, footer, ) - return content } diff --git a/internal/ui/page_game_complete_test.go b/internal/ui/iugame/page_game_complete_test.go similarity index 78% rename from internal/ui/page_game_complete_test.go rename to internal/ui/iugame/page_game_complete_test.go index 43f15e7..ac300fb 100644 --- a/internal/ui/page_game_complete_test.go +++ b/internal/ui/iugame/page_game_complete_test.go @@ -1,7 +1,6 @@ -package ui +package iugame import ( - "database/sql" "testing" tea "charm.land/bubbletea/v2" @@ -10,44 +9,26 @@ 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" - _ "modernc.org/sqlite" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" + "github.com/code-gorilla-au/rush/internal/ui/uitest" ) -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 TestPageGameCompleteModel(t *testing.T) { group := odize.NewGroup(t, nil) - var db *sql.DB var queries *database.Queries var teamsSvc *teams.Service var gameSvc *games.Service - var state *GlobalState + var state *uistate.GlobalState group.BeforeEach(func() { - db = setupTestDB(t) + db := uitest.SetupTestDB(t) + t.Cleanup(func() { _ = db.Close() }) queries = database.New(db) teamsSvc = teams.NewTeamsService(queries, playbooks.NewPlaybooksService(queries)) gameSvc = games.NewService(queries) - state = &GlobalState{} - }) - - group.AfterEach(func() { - if db != nil { - db.Close() - } + state = &uistate.GlobalState{} }) err := group. @@ -88,11 +69,13 @@ func TestPageGameCompleteModel(t *testing.T) { TeamA: games.TeamConfig{ TeamID: team.ID, TeamName: team.Name, + Players: []int64{1, 2, 3}, Formations: formationsA, }, TeamB: games.TeamConfig{ TeamID: teamB.ID, TeamName: teamB.Name, + Players: []int64{4, 5, 6}, Formations: formationsB, }, }) @@ -101,7 +84,7 @@ func TestPageGameCompleteModel(t *testing.T) { // Simulate game completion with Team A as winner rollIdx := 0 rollFn := func() int { - rolls := []int{10, 1} + rolls := []int{1, 10} r := rolls[rollIdx%2] rollIdx++ return r @@ -109,13 +92,14 @@ func TestPageGameCompleteModel(t *testing.T) { // Finish all rounds for i := 0; i < 10; i++ { - game.ResolveRound(rollFn) + _, _ = game.ResolveRound(&games.TestEngine{RollFn: rollFn}) } _, err = gameSvc.CompleteGame(ctx, game) odize.AssertNoError(t, err) - m := NewPageGameComplete(state, teamsSvc, gameSvc) + theme := styles.NewIceTheme() + m := NewPageGameComplete(state, teamsSvc, gameSvc, theme) m.SetGameID(game.ID()) // Execute Init @@ -129,16 +113,17 @@ func TestPageGameCompleteModel(t *testing.T) { odize.AssertFalse(t, winnerMsg.IsDraw) }). Test("should handle enter key and switch to title page", func(t *testing.T) { - m := NewPageGameComplete(state, teamsSvc, gameSvc) + theme := styles.NewIceTheme() + m := NewPageGameComplete(state, teamsSvc, gameSvc, theme) _, cmd := m.Update(tea.KeyPressMsg{ Text: "enter", }) odize.AssertTrue(t, cmd != nil) msg := cmd() - switchMsg, ok := msg.(MsgSwitchPage) + switchMsg, ok := msg.(uistate.MsgSwitchPage) odize.AssertTrue(t, ok) - odize.AssertEqual(t, PageTitle, switchMsg.NewPage) + odize.AssertEqual(t, uistate.PageTitle, switchMsg.NewPage) }). Run() diff --git a/internal/ui/page_game_test.go b/internal/ui/iugame/page_game_test.go similarity index 81% rename from internal/ui/page_game_test.go rename to internal/ui/iugame/page_game_test.go index bdebb6e..baa1a54 100644 --- a/internal/ui/page_game_test.go +++ b/internal/ui/iugame/page_game_test.go @@ -1,4 +1,4 @@ -package ui +package iugame import ( "context" @@ -10,6 +10,8 @@ import ( "github.com/code-gorilla-au/rush/internal/database" "github.com/code-gorilla-au/rush/internal/games" "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" ) type pageGameMockStore struct { @@ -19,7 +21,10 @@ type pageGameMockStore struct { func (m *pageGameMockStore) GetGameByID(ctx context.Context, id int64) (database.Game, error) { rounds := [10]games.Round{} - roundsData, _ := json.Marshal(rounds) + roundsData, err := json.Marshal(rounds) + if err != nil { + return database.Game{}, err + } return database.Game{ ID: id, @@ -46,12 +51,13 @@ func (m *pageGameMockStore) UpdateGame(ctx context.Context, arg database.UpdateG func TestPageGameModel(t *testing.T) { group := odize.NewGroup(t, nil) - state := &GlobalState{} + state := &uistate.GlobalState{} store := &pageGameMockStore{} gameSvc := games.NewService(store) group.Test("should handle MsgGameLoaded and initialize gameComp", func(t *testing.T) { - m := NewModelGame(state, gameSvc) + theme := styles.NewIceTheme() + m := NewModelGame(state, gameSvc, theme) m.SetGameID(1) game, _ := gameSvc.GetGame(context.Background(), 1) @@ -66,7 +72,8 @@ func TestPageGameModel(t *testing.T) { }) group.Test("should persist game on MsgResolveRound", func(t *testing.T) { - m := NewModelGame(state, gameSvc) + theme := styles.NewIceTheme() + m := NewModelGame(state, gameSvc, theme) m.SetGameID(1) game, _ := gameSvc.GetGame(context.Background(), 1) m.Update(MsgGameLoaded{Game: game}) @@ -92,13 +99,15 @@ func TestPageGameModel(t *testing.T) { }) group.Test("should handle MsgNextRound and reset gameComp", func(t *testing.T) { - m := NewModelGame(state, gameSvc) + theme := styles.NewIceTheme() + m := NewModelGame(state, gameSvc, theme) m.SetGameID(1) game, _ := gameSvc.GetGame(context.Background(), 1) m.Update(MsgGameLoaded{Game: game}) // Simulate round resolved (currentRound incremented) - m.game.ResolveRound(func() int { return 1 }) + _, err := m.game.ResolveRound(&games.TestEngine{RollFn: func() int { return 1 }}) + odize.AssertNoError(t, err) _, cmd := m.Update(components.MsgNextRound{}) diff --git a/internal/ui/iugame/root_game.go b/internal/ui/iugame/root_game.go new file mode 100644 index 0000000..a3a39b4 --- /dev/null +++ b/internal/ui/iugame/root_game.go @@ -0,0 +1,114 @@ +package iugame + +import ( + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/games" + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) + +type SubPageGame int + +const ( + SubPageGameRoot SubPageGame = iota + SubPageGameComplete +) + +type MsgSwitchGamePage struct { + NewPage SubPageGame + GameID int64 +} + +// GameModel handles all game related pages. +type GameModel struct { + currentPage SubPageGame + subPageGameRoot tea.Model + subPageGameComplete tea.Model +} + +func NewGameModel(state *uistate.GlobalState, teamsSvc *teams.Service, gameSvc *games.Service, theme styles.IceTheme) *GameModel { + return &GameModel{ + subPageGameRoot: NewModelGame(state, gameSvc, theme), + subPageGameComplete: NewPageGameComplete(state, teamsSvc, gameSvc, theme), + currentPage: SubPageGameRoot, + } +} + +func (m *GameModel) SetGameID(id int64) { + if p, ok := m.subPageGameRoot.(*PageGameModel); ok { + p.SetGameID(id) + } + if p, ok := m.subPageGameComplete.(*PageGameCompleteModel); ok { + p.SetGameID(id) + } +} + +func (m *GameModel) SetPage(page SubPageGame) { + m.currentPage = page +} + +func (m *GameModel) View() tea.View { + switch m.currentPage { + case SubPageGameRoot: + return m.subPageGameRoot.View() + case SubPageGameComplete: + return m.subPageGameComplete.View() + default: + return tea.NewView("unknown game page") + } +} + +func (m *GameModel) Init() tea.Cmd { + switch m.currentPage { + case SubPageGameRoot: + return m.subPageGameRoot.Init() + case SubPageGameComplete: + return m.subPageGameComplete.Init() + default: + return nil + } +} + +func (m *GameModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case uistate.MsgSwitchPage: + if msg.NewPage == uistate.PageGame { + m.currentPage = SubPageGameRoot + m.SetGameID(msg.GameID) + return m, m.Init() + } + case MsgSwitchGamePage: + switch msg.NewPage { + case SubPageGameRoot, SubPageGameComplete: + m.currentPage = msg.NewPage + } + + if msg.GameID != 0 { + m.SetGameID(msg.GameID) + } + + return m, m.Init() + + case tea.WindowSizeMsg: + var cmd tea.Cmd + m.subPageGameRoot, cmd = m.subPageGameRoot.Update(msg) + cmds = append(cmds, cmd) + m.subPageGameComplete, cmd = m.subPageGameComplete.Update(msg) + cmds = append(cmds, cmd) + + } + + var cmd tea.Cmd + switch m.currentPage { + case SubPageGameRoot: + m.subPageGameRoot, cmd = m.subPageGameRoot.Update(msg) + case SubPageGameComplete: + m.subPageGameComplete, cmd = m.subPageGameComplete.Update(msg) + } + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} diff --git a/internal/ui/iugame/root_game_test.go b/internal/ui/iugame/root_game_test.go new file mode 100644 index 0000000..96d02c9 --- /dev/null +++ b/internal/ui/iugame/root_game_test.go @@ -0,0 +1,92 @@ +package iugame + +import ( + "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" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" + "github.com/code-gorilla-au/rush/internal/ui/uitest" +) + +func TestGameModel(t *testing.T) { + group := odize.NewGroup(t, nil) + + var queries *database.Queries + var teamsSvc *teams.Service + var gameSvc *games.Service + var state *uistate.GlobalState + + group.BeforeEach(func() { + db := uitest.SetupTestDB(t) + t.Cleanup(func() { _ = db.Close() }) + queries = database.New(db) + teamsSvc = teams.NewTeamsService(queries, playbooks.NewPlaybooksService(queries)) + gameSvc = games.NewService(queries) + state = &uistate.GlobalState{} + }) + + err := group. + Test("MsgSwitchGamePage should trigger Init of the new page", func(t *testing.T) { + theme := styles.NewIceTheme() + m := NewGameModel(state, teamsSvc, gameSvc, theme) + + // Initial page should be SubPageGameRoot + odize.AssertEqual(t, SubPageGameRoot, m.currentPage) + + // Send MsgSwitchGamePage to switch to SubPageGameComplete + msg := MsgSwitchGamePage{ + NewPage: SubPageGameComplete, + GameID: 123, + } + + _, cmd := m.Update(msg) + + odize.AssertEqual(t, SubPageGameComplete, m.currentPage) + odize.AssertTrue(t, cmd != nil) + + // In bubbletea v2, we can't easily inspect the contents of a Batch without executing it + // or using some internal knowledge. But we can check if it's nil. + // More importantly, we can check if it returns the expected message when executed. + // PageGameCompleteModel.Init() returns a func() tea.Msg. + + // If we execute the command, it should try to load game 123. + // Since game 123 doesn't exist, it should return MsgGameError. + + var resMsg tea.Msg + if cmd != nil { + resMsg = cmd() + } + + // We expect a MsgGameError because game 123 doesn't exist + // But currently, it probably returns nil because Init() was never called + odize.AssertTrue(t, resMsg != nil) + _, ok := resMsg.(MsgGameError) + odize.AssertTrue(t, ok) + }). + Test("uistate.MsgSwitchPage to PageGame should reset to SubPageGameRoot", func(t *testing.T) { + theme := styles.NewIceTheme() + m := NewGameModel(state, teamsSvc, gameSvc, theme) + + // Manually set to SubPageGameComplete + m.currentPage = SubPageGameComplete + + // Send MsgSwitchPage to PageGame + msg := uistate.MsgSwitchPage{ + NewPage: uistate.PageGame, + GameID: 123, + } + + m.Update(msg) + + odize.AssertEqual(t, SubPageGameRoot, m.currentPage) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/page_create_coach.go b/internal/ui/page_create_coach.go index 5c6e9bd..f085bfe 100644 --- a/internal/ui/page_create_coach.go +++ b/internal/ui/page_create_coach.go @@ -7,16 +7,12 @@ import ( "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/teams" "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" ) type createCoachKeyMap struct { - components.CommonKeys - Back key.Binding - Enter key.Binding - Up key.Binding - Down key.Binding - Tab key.Binding - ShiftTab key.Binding + uistate.KeyMap } func (k createCoachKeyMap) ShortHelp() []key.Binding { @@ -33,39 +29,15 @@ func (k createCoachKeyMap) FullHelp() [][]key.Binding { 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"), - ), + KeyMap: uistate.NewKeyMap(), } } type ModelCreateCoach struct { width int height int - theme IceTheme - globalState *GlobalState + theme styles.IceTheme + globalState *uistate.GlobalState teamsSvc *teams.Service coachInput textinput.Model @@ -76,7 +48,7 @@ type ModelCreateCoach struct { footer components.Footer } -func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service) *ModelCreateCoach { +func NewModelCreateCoach(state *uistate.GlobalState, teamsSvc *teams.Service, theme styles.IceTheme) *ModelCreateCoach { c := textinput.New() c.Placeholder = "Coach Name" c.Focus() @@ -86,7 +58,7 @@ func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service) *ModelCrea t := textinput.New() t.Placeholder = "Team Name" t.CharLimit = 156 - c.SetWidth(20) + t.SetWidth(20) keys := newCreateCoachKeyMap() @@ -95,7 +67,7 @@ func NewModelCreateCoach(state *GlobalState, teamsSvc *teams.Service) *ModelCrea teamsSvc: teamsSvc, coachInput: c, teamInput: t, - theme: NewIceTheme(), + theme: theme, keys: keys, footer: components.NewFooter(keys), } @@ -107,6 +79,10 @@ func (m *ModelCreateCoach) Init() tea.Cmd { func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case uistate.MsgSwitchPage: + if msg.NewPage == uistate.PageCreateCoach { + return m, m.Init() + } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height @@ -117,7 +93,10 @@ func (m *ModelCreateCoach) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } - case MsgStateUpdated: + case error: + m.err = msg + + case uistate.MsgStateUpdated: return m.handleStateUpdated(msg) } @@ -155,19 +134,19 @@ func (m *ModelCreateCoach) handleKeyMsg(msg tea.KeyMsg) (tea.Cmd, bool) { case key.Matches(msg, m.keys.Back): return func() tea.Msg { - return MsgSwitchPage{NewPage: PageTitle} + return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} }, true } return nil, false } -func (m *ModelCreateCoach) handleStateUpdated(msg MsgStateUpdated) (tea.Model, tea.Cmd) { +func (m *ModelCreateCoach) handleStateUpdated(msg uistate.MsgStateUpdated) (tea.Model, tea.Cmd) { m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerRoom} + return uistate.MsgSwitchPage{NewPage: uistate.PageLockerRoom} } } @@ -211,7 +190,7 @@ func (m *ModelCreateCoach) submit() tea.Cmd { return err } - return MsgStateUpdated{ + return uistate.MsgStateUpdated{ Coach: &coach, Team: &team, } @@ -222,17 +201,24 @@ func (m *ModelCreateCoach) View() tea.View { view := tea.NewView("") view.AltScreen = true + errorView := "" + if m.err != nil { + errorView = m.theme.Muted.Render(m.err.Error()) + } + form := lipgloss.JoinVertical( lipgloss.Left, m.theme.Logo.Render("RUSH - NEW CAREER"), "", - "Coach Details", + m.theme.SecondaryHeader.Render("Coach Details"), m.coachInput.View(), "", - "Team Details", + m.theme.SecondaryHeader.Render("Team Details"), m.teamInput.View(), "", - m.footer.View(m.theme.Footer), + errorView, + "", + m.footer.View(m.theme), ) centeredContent := lipgloss.Place( diff --git a/internal/ui/page_create_coach_test.go b/internal/ui/page_create_coach_test.go new file mode 100644 index 0000000..70c2f22 --- /dev/null +++ b/internal/ui/page_create_coach_test.go @@ -0,0 +1,69 @@ +package ui + +import ( + "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/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" + "github.com/code-gorilla-au/rush/internal/ui/uitest" +) + +func TestModelCreateCoach(t *testing.T) { + group := odize.NewGroup(t, nil) + + var state *uistate.GlobalState + var theme styles.IceTheme + var teamsSvc *teams.Service + + group.BeforeEach(func() { + state = &uistate.GlobalState{} + theme = styles.NewIceTheme() + + db := uitest.SetupTestDB(t) + t.Cleanup(func() { _ = db.Close() }) + + queries := database.New(db) + playbookSvc := playbooks.NewPlaybooksService(queries) + teamsSvc = teams.NewTeamsService(queries, playbookSvc) + }) + + err := group. + Test("enter should move focus from coach to team input", func(t *testing.T) { + m := NewModelCreateCoach(state, teamsSvc, theme) + + _, _ = m.Update(tea.KeyPressMsg{Text: "enter"}) + + odize.AssertEqual(t, 1, m.focusIndex) + odize.AssertFalse(t, m.coachInput.Focused()) + odize.AssertTrue(t, m.teamInput.Focused()) + }). + Test("submit error from enter should be stored on model", func(t *testing.T) { + db := uitest.SetupTestDB(t) + queries := database.New(db) + playbookSvc := playbooks.NewPlaybooksService(queries) + brokenTeamsSvc := teams.NewTeamsService(queries, playbookSvc) + odize.AssertNoError(t, db.Close()) + + m := NewModelCreateCoach(state, brokenTeamsSvc, theme) + m.coachInput.SetValue("Coach") + m.teamInput.SetValue("Team") + m.focusIndex = 1 + m.updateFocus() + + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + odize.AssertTrue(t, cmd != nil) + + msg := cmd() + _, _ = m.Update(msg) + + odize.AssertTrue(t, m.err != nil) + }). + Run() + + odize.AssertNoError(t, err) +} diff --git a/internal/ui/page_locker_playbooks_edit.go b/internal/ui/page_locker_playbooks_edit.go deleted file mode 100644 index 234b958..0000000 --- a/internal/ui/page_locker_playbooks_edit.go +++ /dev/null @@ -1,279 +0,0 @@ -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_room_test.go b/internal/ui/page_locker_room_test.go deleted file mode 100644 index edd95d5..0000000 --- a/internal/ui/page_locker_room_test.go +++ /dev/null @@ -1,57 +0,0 @@ -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_test.go b/internal/ui/page_new_battle_selection_test.go deleted file mode 100644 index b0189c5..0000000 --- a/internal/ui/page_new_battle_selection_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package ui - -import ( - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/code-gorilla-au/odize" - "github.com/code-gorilla-au/rush/internal/playbooks" - "github.com/code-gorilla-au/rush/internal/teams" -) - -func TestModelNewBattleSelection_Rendering(t *testing.T) { - group := odize.NewGroup(t, nil) - - group.Test("should load data and render selection", func(t *testing.T) { - state := &GlobalState{ - Team: &teams.Team{ID: 1, Name: "My Team"}, - } - - m := NewModelNewBattleSelection(state, nil, nil, nil) - m.width = 100 - m.height = 40 - m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) - - msg := msgDataLoaded{ - playbooks: []playbooks.Playbook{{ID: 1, Name: "Playbook 1"}}, - aiTeams: []teams.AITeam{ - { - Coach: teams.Coach{Name: "Coach A"}, - Team: teams.Team{Name: "Team A"}, - }, - }, - } - m.Update(msg) - - odize.AssertEqual(t, 1, m.playbookList.Len()) - odize.AssertEqual(t, 1, m.aiTeamList.Len()) - - view := m.View() - content := view.Content - - odize.AssertTrue(t, strings.Contains(content, "NEW BATTLE")) - odize.AssertTrue(t, strings.Contains(content, "Playbook 1")) - odize.AssertTrue(t, strings.Contains(content, "Team A")) - }) - - group.Test("should handle state transitions", func(t *testing.T) { - state := &GlobalState{ - Team: &teams.Team{ID: 1, Name: "My Team"}, - } - m := NewModelNewBattleSelection(state, nil, nil, nil) - m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) - m.Update(msgDataLoaded{ - playbooks: []playbooks.Playbook{{ID: 1, Name: "Playbook 1"}}, - aiTeams: []teams.AITeam{ - { - Team: teams.Team{ID: 2, Name: "Team A"}, - }, - }, - }) - - // 1. Initial state: selecting playbook - odize.AssertEqual(t, stateSelectingPlaybook, m.state) - - // 2. Select playbook -> selecting opponent - m.Update(tea.KeyPressMsg{Text: "enter"}) - odize.AssertEqual(t, stateSelectingOpponent, m.state) - odize.AssertTrue(t, m.selectedPlaybook != nil) - - // 3. Select opponent -> confirming - m.Update(tea.KeyPressMsg{Text: "enter"}) - odize.AssertEqual(t, stateConfirming, m.state) - odize.AssertTrue(t, m.selectedAITeam != nil) - - // 4. Back from confirming -> selecting opponent - m.Update(tea.KeyPressMsg{Text: "esc"}) - odize.AssertEqual(t, stateSelectingOpponent, m.state) - - // 5. Back from selecting opponent -> selecting playbook - m.Update(tea.KeyPressMsg{Text: "esc"}) - odize.AssertEqual(t, stateSelectingPlaybook, m.state) - }) - - group.Test("should handle back navigation to title", func(t *testing.T) { - state := &GlobalState{} - m := NewModelNewBattleSelection(state, nil, nil, 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) - } - }) - - err := group.Run() - odize.AssertNoError(t, err) -} diff --git a/internal/ui/page_new_tournament.go b/internal/ui/page_new_tournament.go index 4589a5c..e1c1667 100644 --- a/internal/ui/page_new_tournament.go +++ b/internal/ui/page_new_tournament.go @@ -1,17 +1,22 @@ package ui -import tea "charm.land/bubbletea/v2" +import ( + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) type ModelNewTournament struct { width int height int - theme IceTheme - globalState *GlobalState + theme styles.IceTheme + globalState *uistate.GlobalState } -func NewModelNewTournament(globalState *GlobalState) *ModelNewTournament { +func NewModelNewTournament(globalState *uistate.GlobalState, theme styles.IceTheme) *ModelNewTournament { return &ModelNewTournament{ globalState: globalState, + theme: theme, } } @@ -24,7 +29,7 @@ func (m *ModelNewTournament) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = vMsg.Width m.height = vMsg.Height - case MsgStateUpdated: + case uistate.MsgStateUpdated: m.globalState.Coach = vMsg.Coach m.globalState.Team = vMsg.Team } diff --git a/internal/ui/page_title.go b/internal/ui/page_title.go index ff329be..0d56b3e 100644 --- a/internal/ui/page_title.go +++ b/internal/ui/page_title.go @@ -4,6 +4,8 @@ import ( "strings" "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -11,47 +13,43 @@ import ( ) type titleKeyMap struct { - components.CommonKeys - Enter key.Binding + uistate.KeyMap } func (k titleKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Enter, k.Quit} + return []key.Binding{k.Up, k.Down, k.Enter, k.Quit} } func (k titleKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ - {k.Enter, k.Quit}, + {k.Up, k.Down, k.Enter, k.Quit}, } } func newTitleKeyMap() titleKeyMap { return titleKeyMap{ - CommonKeys: components.NewCommonKeys(), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select"), - ), + KeyMap: uistate.NewKeyMap(), } } type ModelTitle struct { width int height int - theme IceTheme - globalState *GlobalState + theme styles.IceTheme + globalState *uistate.GlobalState keys titleKeyMap footer components.Footer menu components.TitleMenu } -func NewModelTitle(globalState *GlobalState) *ModelTitle { +func NewModelTitle(globalState *uistate.GlobalState, theme styles.IceTheme) *ModelTitle { keys := newTitleKeyMap() return &ModelTitle{ globalState: globalState, keys: keys, footer: components.NewFooter(keys), menu: components.NewTitleMenu(globalState.Coach != nil), + theme: theme, } } @@ -60,50 +58,79 @@ func (m *ModelTitle) Init() tea.Cmd { } 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) + switch msg := msg.(type) { + case uistate.MsgStateUpdated: + m.handleStateUpdated(msg) + case uistate.MsgSwitchPage: + m.handleSwitchPage(msg) 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} - } - } + model, cmd, done := m.handleKey(msg) + if done { + return model, cmd } - m.menu.Update(vMsg) case tea.WindowSizeMsg: - m.width = vMsg.Width - m.height = vMsg.Height - m.footer.Update(vMsg) + m.handleWindowSize(msg) } return m, nil } +func (m *ModelTitle) handleStateUpdated(msg uistate.MsgStateUpdated) { + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + m.menu.SetHasCoach(m.globalState.Coach != nil) +} + +func (m *ModelTitle) handleSwitchPage(msg uistate.MsgSwitchPage) { + if msg.NewPage == uistate.PageTitle { + m.menu.SetHasCoach(m.globalState.Coach != nil) + } +} + +func (m *ModelTitle) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit, true + case key.Matches(msg, m.keys.Up): + m.menu.MoveUp() + case key.Matches(msg, m.keys.Down): + m.menu.MoveDown() + case key.Matches(msg, m.keys.Enter): + return m.handleMenuSelect() + } + return nil, nil, false +} + +func (m *ModelTitle) handleWindowSize(msg tea.WindowSizeMsg) { + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) +} + +func (m *ModelTitle) handleMenuSelect() (tea.Model, tea.Cmd, bool) { + selected := m.menu.SelectedItem() + switch selected { + case components.TitleItemCreateCoach: + return m, func() tea.Msg { + return uistate.MsgSwitchPage{NewPage: uistate.PageCreateCoach} + }, true + case components.TitleItemLockerRoom: + return m, func() tea.Msg { + return uistate.MsgSwitchPage{NewPage: uistate.PageLockerRoom} + }, true + case components.TitleItemNewTournament: + return m, func() tea.Msg { + return uistate.MsgSwitchPage{NewPage: uistate.PageNewTournament} + }, true + case components.TitleItemNewBattleSelection: + return m, func() tea.Msg { + return uistate.MsgSwitchPage{NewPage: uistate.PageNewBattle} + }, true + } + + return nil, nil, false +} + const logo = ` ____ _ _ ____ _ _ | _ \| | | / ___|| | | | @@ -119,7 +146,7 @@ func (m *ModelTitle) View() tea.View { styledLogo := m.theme.Logo.Render(strings.Trim(logo, "\n")) - navigation := m.menu.View(m.theme.Base, m.theme.Hotkey) + navigation := m.menu.View(m.theme) content := lipgloss.JoinVertical( lipgloss.Center, @@ -127,7 +154,7 @@ func (m *ModelTitle) View() tea.View { "", navigation, "", - m.footer.View(m.theme.Footer), + m.footer.View(m.theme), ) centeredContent := lipgloss.Place( diff --git a/internal/ui/page_title_settings.go b/internal/ui/page_title_settings.go deleted file mode 100644 index 9a162a6..0000000 --- a/internal/ui/page_title_settings.go +++ /dev/null @@ -1,37 +0,0 @@ -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 index 4d7a84a..f6c5308 100644 --- a/internal/ui/page_title_test.go +++ b/internal/ui/page_title_test.go @@ -6,6 +6,8 @@ import ( tea "charm.land/bubbletea/v2" "github.com/code-gorilla-au/odize" "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" ) func TestModelTitle(t *testing.T) { @@ -13,7 +15,8 @@ func TestModelTitle(t *testing.T) { 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}) + theme := styles.NewIceTheme() + m := NewModelTitle(&uistate.GlobalState{Coach: nil}, theme) m.width = 100 m.height = 50 @@ -22,14 +25,15 @@ func TestModelTitle(t *testing.T) { msg := cmd() switch switchMsg := msg.(type) { - case MsgSwitchPage: - odize.AssertEqual(t, PageCreateCoach, switchMsg.NewPage) + case uistate.MsgSwitchPage: + odize.AssertEqual(t, uistate.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"}}) + theme := styles.NewIceTheme() + m := NewModelTitle(&uistate.GlobalState{Coach: &teams.Coach{Name: "Coach Carter"}}, theme) m.width = 100 m.height = 50 @@ -38,8 +42,8 @@ func TestModelTitle(t *testing.T) { msg := cmd() switch switchMsg := msg.(type) { - case MsgSwitchPage: - odize.AssertEqual(t, PageLockerRoom, switchMsg.NewPage) + case uistate.MsgSwitchPage: + odize.AssertEqual(t, uistate.PageLockerRoom, switchMsg.NewPage) default: t.Fatalf("expected MsgSwitchPage, got %T", msg) } diff --git a/internal/ui/styles/theme.go b/internal/ui/styles/theme.go new file mode 100644 index 0000000..9b91af8 --- /dev/null +++ b/internal/ui/styles/theme.go @@ -0,0 +1,137 @@ +package styles + +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 + Title lipgloss.Style + Header lipgloss.Style + Winner lipgloss.Style + Muted lipgloss.Style + ActiveBorder lipgloss.Style + InactiveBorder lipgloss.Style + SelectedTitle lipgloss.Style + SelectedDesc lipgloss.Style + TeamA lipgloss.Style + TeamB lipgloss.Style + Player lipgloss.Style + Separator lipgloss.Style + Label lipgloss.Style + Highlight lipgloss.Style + SecondaryHeader lipgloss.Style + RoundBorder 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") + darkGrey := lipgloss.Color("#333333") + grey := lipgloss.Color("#666666") + mutedGrey := lipgloss.Color("#888888") + faintGrey := lipgloss.Color("#555555") + gold := lipgloss.Color("#FFD700") + + return IceTheme{ + Logo: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true), + Footer: lipgloss.NewStyle(). + Foreground(skyBlue). + Italic(true), + Base: lipgloss.NewStyle(). + 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(faintGrey). + Italic(true). + Faint(true), + Title: lipgloss.NewStyle(). + MarginLeft(2). + Foreground(iceBlue). + Bold(true), + Header: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true). + MarginBottom(1), + Winner: lipgloss.NewStyle(). + Foreground(gold). + Bold(true). + MarginTop(1), + Muted: lipgloss.NewStyle(). + Foreground(mutedGrey), + ActiveBorder: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(iceBlue). + Padding(1), + InactiveBorder: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(darkGrey). + Padding(1). + Foreground(grey), + SelectedTitle: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(iceBlue). + PaddingLeft(2), + SelectedDesc: lipgloss.NewStyle(). + Foreground(mutedGrey). + Border(lipgloss.NormalBorder(), false, false, false, true). + BorderForeground(iceBlue). + PaddingLeft(2), + TeamA: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true). + Width(10). + Align(lipgloss.Right), + TeamB: lipgloss.NewStyle(). + Foreground(white). + Bold(true). + Width(10). + Align(lipgloss.Left), + Player: lipgloss.NewStyle(). + Foreground(skyBlue), + Separator: lipgloss.NewStyle(). + Foreground(faintGrey), + Label: lipgloss.NewStyle(). + Foreground(mutedGrey). + PaddingLeft(2), + Highlight: lipgloss.NewStyle(). + Foreground(iceBlue). + Bold(true), + SecondaryHeader: lipgloss.NewStyle(). + Foreground(white). + Bold(true). + MarginBottom(1), + RoundBorder: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(skyBlue). + Padding(1), + } +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go deleted file mode 100644 index 1dfbd67..0000000 --- a/internal/ui/theme.go +++ /dev/null @@ -1,55 +0,0 @@ -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/internal/ui/uibattle/page_battle_confirm.go b/internal/ui/uibattle/page_battle_confirm.go new file mode 100644 index 0000000..2ae0b78 --- /dev/null +++ b/internal/ui/uibattle/page_battle_confirm.go @@ -0,0 +1,243 @@ +package uibattle + +import ( + "errors" + "fmt" + + "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/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type PageBattleConfirmModel struct { + width int + height int + theme styles.IceTheme + globalState *uistate.GlobalState + gameSvc *games.Service + selectedPlaybook *playbooks.Playbook + selectedAITeam *teams.AITeam + keys battleSelectionKeyMap + footer components.Footer + err error +} + +func NewPageBattleConfirm(globalState *uistate.GlobalState, gameSvc *games.Service, theme styles.IceTheme) *PageBattleConfirmModel { + keys := newBattleSelectionKeyMap() + return &PageBattleConfirmModel{ + globalState: globalState, + gameSvc: gameSvc, + theme: theme, + keys: keys, + footer: components.NewFooter(keys), + } +} + +func (m *PageBattleConfirmModel) SetData(playbook *playbooks.Playbook, aiTeam *teams.AITeam) { + m.selectedPlaybook = playbook + m.selectedAITeam = aiTeam +} + +func (m *PageBattleConfirmModel) Init() tea.Cmd { + return nil +} + +func (m *PageBattleConfirmModel) 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 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 MsgSwitchBattlePage{NewPage: SubPageBattleSelection} + } + case key.Matches(msg, m.keys.Select): + return m, m.createGame + } + } + + return m, nil +} + +func (m *PageBattleConfirmModel) createGame() tea.Msg { + if m.globalState == nil || m.globalState.Team == nil || m.selectedPlaybook == nil || m.selectedAITeam == nil { + return errors.New("missing team, playbook, or opponent") + } + + if m.gameSvc == nil { + return errors.New("game service is not configured") + } + + teamAPlayers := getPlayerIDs(m.globalState.Team.Players) + teamBPlayers := getPlayerIDs(m.selectedAITeam.Team.Players) + + params := games.NewGameParams{ + TeamA: games.TeamConfig{ + TeamID: m.globalState.Team.ID, + TeamName: m.globalState.Team.Name, + Players: teamAPlayers, + Formations: m.selectedPlaybook.Formations, + }, + TeamB: games.TeamConfig{ + TeamID: m.selectedAITeam.Team.ID, + TeamName: m.selectedAITeam.Team.Name, + Players: teamBPlayers, + Formations: m.selectedAITeam.Playbook.Formations, + }, + } + + game, err := m.gameSvc.NewGame(m.globalState.Context(), params) + if err != nil { + return err + } + + return uistate.MsgSwitchPage{ + NewPage: uistate.PageGame, + GameID: game.ID(), + } +} + +func (m *PageBattleConfirmModel) View() tea.View { + if m.width == 0 || m.height == 0 { + return tea.NewView("Initializing...") + } + + view := tea.NewView("") + view.AltScreen = true + + var content string + header := "STEP 3: CONFIRMATION" + if m.err != nil { + content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) + header = "ERROR" + } else { + content = m.viewConfirmation() + } + + centeredContent := lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + lipgloss.JoinVertical(lipgloss.Center, + m.theme.Logo.Render("NEW BATTLE"), + m.theme.Muted.Render(header), + "", + content, + "", + m.footer.View(m.theme), + ), + ) + + view.Content = m.theme.Base. + Width(m.width). + Height(m.height). + Render(centeredContent) + + return view +} + +func (m *PageBattleConfirmModel) viewConfirmation() string { + var userTeam *teams.Team + var userCoach *teams.Coach + if m.globalState != nil { + userTeam = m.globalState.Team + userCoach = m.globalState.Coach + } + + opponentTeam, opponentCoach, opponentPlaybook := m.opponentSelection() + + yourTile := newTeamTile(userTeam, userCoach, m.selectedPlaybook) + opponentTile := newTeamTile(opponentTeam, opponentCoach, opponentPlaybook) + tiles := m.viewVSTiles(yourTile, opponentTile) + + return lipgloss.JoinVertical(lipgloss.Center, + m.theme.Logo.Render("CONFIRM BATTLE"), + "", + tiles, + "", + m.theme.ListSelected.Render("Press ENTER to start the game"), + "Press ESC to go back", + ) +} + +func (m *PageBattleConfirmModel) viewVSTiles(yourTile, opponentTile components.TeamTile) string { + vs := m.theme.Highlight.Render("VS") + vsWidth := max(4, lipgloss.Width(vs)+2) + tilesWidth := max(20, m.width-10) + tileWidth := max(20, (tilesWidth-vsWidth)/2) + + leftTileView := yourTile.View(m.theme, tileWidth) + rightTileView := opponentTile.View(m.theme, tileWidth) + vsView := lipgloss.Place( + vsWidth, + max(lipgloss.Height(leftTileView), lipgloss.Height(rightTileView)), + lipgloss.Center, + lipgloss.Center, + vs, + ) + + tiles := lipgloss.JoinHorizontal(lipgloss.Top, leftTileView, vsView, rightTileView) + return tiles +} + +func (m *PageBattleConfirmModel) opponentSelection() (*teams.Team, *teams.Coach, *playbooks.Playbook) { + if m.selectedAITeam == nil { + return nil, nil, nil + } + + return &m.selectedAITeam.Team, &m.selectedAITeam.Coach, &m.selectedAITeam.Playbook +} + +func newTeamTile(team *teams.Team, coach *teams.Coach, playbook *playbooks.Playbook) components.TeamTile { + teamName := "" + coachName := "" + playbookName := "" + var playerNames []string + + if team != nil { + teamName = team.Name + playerNames = getPlayerNames(team.Players) + } + + if coach != nil { + coachName = coach.Name + } + + if playbook != nil { + playbookName = playbook.Name + } + + return components.NewTeamTile(teamName, coachName, playbookName, playerNames) +} + +func getPlayerNames(players []teams.Player) []string { + names := make([]string, len(players)) + for i, player := range players { + names[i] = player.Name + } + + return names +} + +func getPlayerIDs(players []teams.Player) []int64 { + IDs := make([]int64, len(players)) + for i, item := range players { + IDs[i] = item.ID + } + + return IDs +} diff --git a/internal/ui/uibattle/page_battle_confirm_test.go b/internal/ui/uibattle/page_battle_confirm_test.go new file mode 100644 index 0000000..5c2846f --- /dev/null +++ b/internal/ui/uibattle/page_battle_confirm_test.go @@ -0,0 +1,92 @@ +package uibattle + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) + +func TestPageBattleConfirmModel(t *testing.T) { + t.Parallel() + group := odize.NewGroup(t, nil) + + group.Test("should render confirmation details", func(t *testing.T) { + state := &uistate.GlobalState{ + Coach: &teams.Coach{Name: "Coach Me"}, + Team: &teams.Team{ID: 1, Name: "My Team", Players: []teams.Player{ + {Name: "Alice"}, + {Name: "Bob"}, + }}, + } + theme := styles.NewIceTheme() + m := NewPageBattleConfirm(state, nil, theme) + m.width = 100 + m.height = 40 + + m.SetData( + &playbooks.Playbook{Name: "Playbook 1"}, + &teams.AITeam{ + Coach: teams.Coach{Name: "Coach Rival"}, + Playbook: playbooks.Playbook{Name: "Counter Playbook"}, + Team: teams.Team{Name: "Opponent A", Players: []teams.Player{ + {Name: "Eve"}, + {Name: "Mallory"}, + }}, + }, + ) + + view := m.View() + content := view.Content + + odize.AssertTrue(t, strings.Contains(content, "CONFIRM BATTLE")) + odize.AssertTrue(t, strings.Contains(content, "VS")) + odize.AssertTrue(t, strings.Contains(content, "My Team")) + odize.AssertTrue(t, strings.Contains(content, "Coach Me")) + odize.AssertTrue(t, strings.Contains(content, "Playbook 1")) + odize.AssertTrue(t, strings.Contains(content, "Opponent A")) + odize.AssertTrue(t, strings.Contains(content, "Coach Rival")) + odize.AssertTrue(t, strings.Contains(content, "Counter Playbook")) + odize.AssertTrue(t, strings.Contains(content, "Alice")) + odize.AssertTrue(t, strings.Contains(content, "Eve")) + }) + + group.Test("should render fallback values when data is missing", func(t *testing.T) { + state := &uistate.GlobalState{} + theme := styles.NewIceTheme() + m := NewPageBattleConfirm(state, nil, theme) + m.width = 100 + m.height = 40 + + view := m.View() + content := view.Content + + odize.AssertTrue(t, strings.Contains(content, "Unknown Team")) + odize.AssertTrue(t, strings.Contains(content, "No players")) + }) + + group.Test("should handle back navigation", func(t *testing.T) { + state := &uistate.GlobalState{} + theme := styles.NewIceTheme() + m := NewPageBattleConfirm(state, nil, theme) + + _, cmd := m.Update(tea.KeyPressMsg{Text: "esc"}) + + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchBattlePage: + odize.AssertEqual(t, SubPageBattleSelection, v.NewPage) + default: + t.Fatalf("expected MsgSwitchBattlePage, got %T", msg) + } + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/ui/page_new_battle_selection.go b/internal/ui/uibattle/page_battle_selection.go similarity index 53% rename from internal/ui/page_new_battle_selection.go rename to internal/ui/uibattle/page_battle_selection.go index 7146255..d03f737 100644 --- a/internal/ui/page_new_battle_selection.go +++ b/internal/ui/uibattle/page_battle_selection.go @@ -1,27 +1,28 @@ -package ui +package uibattle import ( + "errors" "fmt" "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/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" ) -type msgDataLoaded struct { - playbooks []playbooks.Playbook - aiTeams []teams.AITeam +type MsgBattleSelectionDataLoaded struct { + Playbooks []playbooks.Playbook + AITeams []teams.AITeam } type battleSelectionKeyMap struct { - components.CommonKeys - Back key.Binding - Select key.Binding + uistate.KeyMap } func (k battleSelectionKeyMap) ShortHelp() []key.Binding { @@ -36,15 +37,7 @@ func (k battleSelectionKeyMap) FullHelp() [][]key.Binding { 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"), - ), + KeyMap: uistate.NewKeyMap(), } } @@ -53,14 +46,13 @@ type selectionState int const ( stateSelectingPlaybook selectionState = iota stateSelectingOpponent - stateConfirming ) -type ModelNewBattleSelection struct { +type PageBattleSelectionModel struct { width int height int - theme IceTheme - globalState *GlobalState + theme styles.IceTheme + globalState *uistate.GlobalState teamsSvc *teams.Service playbookSvc *playbooks.Service gameSvc *games.Service @@ -74,31 +66,32 @@ type ModelNewBattleSelection struct { err error } -func NewModelNewBattleSelection(globalState *GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, gameSvc *games.Service) *ModelNewBattleSelection { +func NewModelBattleSelection(globalState *uistate.GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, gameSvc *games.Service, theme styles.IceTheme) *PageBattleSelectionModel { keys := newBattleSelectionKeyMap() - return &ModelNewBattleSelection{ + return &PageBattleSelectionModel{ globalState: globalState, teamsSvc: teamsSvc, playbookSvc: playbookSvc, gameSvc: gameSvc, - theme: NewIceTheme(), + theme: theme, keys: keys, footer: components.NewFooter(keys), - playbookList: components.NewPlaybookList(nil), - aiTeamList: components.NewAITeamList(nil), + playbookList: components.NewPlaybookList(nil, theme), + aiTeamList: components.NewAITeamList(nil, theme), } } -func (m *ModelNewBattleSelection) Init() tea.Cmd { +func (m *PageBattleSelectionModel) Init() tea.Cmd { + m.reset() return m.loadData } -func (m *ModelNewBattleSelection) loadData() tea.Msg { +func (m *PageBattleSelectionModel) loadData() tea.Msg { if m.globalState.Team == nil { - return fmt.Errorf("no team loaded") + return errors.New("no team loaded") } - playbooks, err := m.playbookSvc.GetTeamPlaybooks(m.globalState.Context(), m.globalState.Team.ID) + pb, err := m.playbookSvc.GetTeamPlaybooks(m.globalState.Context(), m.globalState.Team.ID) if err != nil { return err } @@ -108,13 +101,13 @@ func (m *ModelNewBattleSelection) loadData() tea.Msg { return err } - return msgDataLoaded{ - playbooks: playbooks, - aiTeams: aiTeams, + return MsgBattleSelectionDataLoaded{ + Playbooks: pb, + AITeams: aiTeams, } } -func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *PageBattleSelectionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.footer.Update(msg) var cmds []tea.Cmd @@ -122,11 +115,13 @@ func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.playbookList.SetSize(m.width/2-4, m.height-20) - m.aiTeamList.SetSize(m.width/2-4, m.height-20) - case msgDataLoaded: - m.playbookList.SetItems(msg.playbooks) - m.aiTeamList.SetItems(msg.aiTeams) + listWidth := (m.width - 10) / 2 + listHeight := m.height - 20 + m.playbookList.SetSize(listWidth, listHeight) + m.aiTeamList.SetSize(listWidth, listHeight) + case MsgBattleSelectionDataLoaded: + cmds = append(cmds, m.playbookList.SetItems(msg.Playbooks)) + cmds = append(cmds, m.aiTeamList.SetItems(msg.AITeams)) case error: m.err = msg case tea.KeyMsg: @@ -140,17 +135,19 @@ func (m *ModelNewBattleSelection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.aiTeamList.SetActive(m.state == stateSelectingOpponent) var listCmd tea.Cmd - if m.state == stateSelectingPlaybook { + switch m.state { + case stateSelectingPlaybook: m.playbookList, listCmd = m.playbookList.Update(msg) - } else if m.state == stateSelectingOpponent { + case stateSelectingOpponent: m.aiTeamList, listCmd = m.aiTeamList.Update(msg) } + cmds = append(cmds, listCmd) return m, tea.Batch(cmds...) } -func (m *ModelNewBattleSelection) handleKey(msg tea.KeyMsg) (*ModelNewBattleSelection, tea.Cmd) { +func (m *PageBattleSelectionModel) handleKey(msg tea.KeyMsg) (*PageBattleSelectionModel, tea.Cmd) { switch { case key.Matches(msg, m.keys.Quit): return m, tea.Quit @@ -159,12 +156,8 @@ func (m *ModelNewBattleSelection) handleKey(msg tea.KeyMsg) (*ModelNewBattleSele m.state = stateSelectingPlaybook return m, nil } - if m.state == stateConfirming { - m.state = stateSelectingOpponent - return m, nil - } return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageTitle} + return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} } case key.Matches(msg, m.keys.Select): if m.state == stateSelectingPlaybook { @@ -175,49 +168,32 @@ func (m *ModelNewBattleSelection) handleKey(msg tea.KeyMsg) (*ModelNewBattleSele return m, nil } if m.state == stateSelectingOpponent { - m.selectedAITeam = m.aiTeamList.SelectedAITeam() + m.selectedAITeam = m.aiTeamList.SelectedItem() if m.selectedAITeam != nil { - m.state = stateConfirming + return m, func() tea.Msg { + return MsgSwitchBattlePage{ + NewPage: SubPageBattleConfirm, + SelectedPlaybook: m.selectedPlaybook, + SelectedAITeam: m.selectedAITeam, + } + } } return m, nil } - if m.state == stateConfirming { - return m, m.createGame - } } return m, nil } -func (m *ModelNewBattleSelection) createGame() tea.Msg { - if m.selectedPlaybook == nil || m.selectedAITeam == nil { - return fmt.Errorf("missing playbook or opponent") - } - - params := games.NewGameParams{ - TeamA: games.TeamConfig{ - TeamID: m.globalState.Team.ID, - TeamName: m.globalState.Team.Name, - Formations: m.selectedPlaybook.Formations, - }, - TeamB: games.TeamConfig{ - TeamID: m.selectedAITeam.Team.ID, - TeamName: m.selectedAITeam.Team.Name, - Formations: m.selectedAITeam.Playbook.Formations, - }, - } - - game, err := m.gameSvc.NewGame(m.globalState.Context(), params) - if err != nil { - return err - } - - return MsgSwitchPage{ - NewPage: PageGame, - GameID: game.ID(), - } +func (m *PageBattleSelectionModel) reset() { + m.state = stateSelectingPlaybook + m.selectedPlaybook = nil + m.selectedAITeam = nil + m.err = nil + m.playbookList.Reset() + m.aiTeamList.Reset() } -func (m *ModelNewBattleSelection) View() tea.View { +func (m *PageBattleSelectionModel) View() tea.View { if m.width == 0 || m.height == 0 { return tea.NewView("Initializing...") } @@ -230,9 +206,6 @@ func (m *ModelNewBattleSelection) View() tea.View { if m.err != nil { content = m.theme.Logo.Render(fmt.Sprintf("Error: %v", m.err)) header = "ERROR" - } else if m.state == stateConfirming { - content = m.viewConfirmation() - header = "STEP 3: CONFIRMATION" } else { content = m.viewSelection() if m.state == stateSelectingPlaybook { @@ -247,11 +220,11 @@ func (m *ModelNewBattleSelection) View() tea.View { lipgloss.Center, lipgloss.Center, lipgloss.JoinVertical(lipgloss.Center, m.theme.Logo.Render("NEW BATTLE"), - m.theme.Footer.Render(header), + m.theme.Muted.Render(header), "", content, "", - m.footer.View(m.theme.Footer), + m.footer.View(m.theme), ), ) @@ -263,22 +236,27 @@ func (m *ModelNewBattleSelection) View() tea.View { return view } -func (m *ModelNewBattleSelection) viewSelection() string { +func (m *PageBattleSelectionModel) viewSelection() string { + playbookView := m.playbookList.View(m.theme) + aiTeamView := m.aiTeamList.View(m.theme) + + if m.state == stateSelectingPlaybook { + playbookView = m.theme.ActiveBorder.Render(playbookView) + aiTeamView = m.theme.InactiveBorder.Render(aiTeamView) + } else { + playbookView = m.theme.InactiveBorder.Render(playbookView) + aiTeamView = m.theme.ActiveBorder.Render(aiTeamView) + } + return lipgloss.JoinHorizontal(lipgloss.Top, - m.playbookList.View(), + lipgloss.JoinVertical(lipgloss.Left, + m.theme.SecondaryHeader.Render("PLAYBOOKS"), + playbookView, + ), lipgloss.NewStyle().Width(2).Render(""), - m.aiTeamList.View(), - ) -} - -func (m *ModelNewBattleSelection) viewConfirmation() string { - return lipgloss.JoinVertical(lipgloss.Center, - m.theme.Logo.Render("CONFIRM BATTLE"), - "", - fmt.Sprintf("Your Playbook: %s", m.selectedPlaybook.Name), - fmt.Sprintf("Opponent: %s", m.selectedAITeam.Team.Name), - "", - m.theme.ListSelected.Render("Press ENTER to start the game"), - "Press ESC to go back", + lipgloss.JoinVertical(lipgloss.Left, + m.theme.SecondaryHeader.Render("AI TEAMS"), + aiTeamView, + ), ) } diff --git a/internal/ui/uibattle/page_battle_selection_test.go b/internal/ui/uibattle/page_battle_selection_test.go new file mode 100644 index 0000000..19f9cba --- /dev/null +++ b/internal/ui/uibattle/page_battle_selection_test.go @@ -0,0 +1,156 @@ +package uibattle + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/playbooks" + "github.com/code-gorilla-au/rush/internal/teams" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) + +func TestPageBattleSelectionModel_Rendering(t *testing.T) { + group := odize.NewGroup(t, nil) + + group.Test("should load data and render selection", func(t *testing.T) { + state := &uistate.GlobalState{ + Team: &teams.Team{ID: 1, Name: "My Team"}, + } + theme := styles.NewIceTheme() + + m := NewModelBattleSelection(state, nil, nil, nil, theme) + m.width = 100 + m.height = 40 + m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + + msg := MsgBattleSelectionDataLoaded{ + Playbooks: []playbooks.Playbook{{ID: 1, Name: "Playbook 1"}}, + AITeams: []teams.AITeam{ + { + Coach: teams.Coach{Name: "Coach A"}, + Team: teams.Team{Name: "Team A"}, + }, + }, + } + m.Update(msg) + + odize.AssertEqual(t, 1, m.playbookList.Len()) + odize.AssertEqual(t, 1, m.aiTeamList.Len()) + + view := m.View() + content := view.Content + + odize.AssertTrue(t, strings.Contains(content, "NEW BATTLE")) + odize.AssertTrue(t, strings.Contains(content, "Playbook 1")) + odize.AssertTrue(t, strings.Contains(content, "Team A")) + }) + + group.Test("should handle state transitions", func(t *testing.T) { + state := &uistate.GlobalState{ + Team: &teams.Team{ID: 1, Name: "My Team"}, + } + theme := styles.NewIceTheme() + m := NewModelBattleSelection(state, nil, nil, nil, theme) + m.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + m.Update(MsgBattleSelectionDataLoaded{ + Playbooks: []playbooks.Playbook{{ID: 1, Name: "Playbook 1"}}, + AITeams: []teams.AITeam{ + { + Team: teams.Team{ID: 2, Name: "Team A"}, + }, + }, + }) + + // 1. Initial state: selecting playbook + odize.AssertEqual(t, stateSelectingPlaybook, m.state) + + // 2. Select playbook -> selecting opponent + m.Update(tea.KeyPressMsg{Text: "enter"}) + odize.AssertEqual(t, stateSelectingOpponent, m.state) + odize.AssertTrue(t, m.selectedPlaybook != nil) + + // 3. Select opponent -> returns MsgSwitchBattlePage + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchBattlePage: + odize.AssertEqual(t, SubPageBattleConfirm, v.NewPage) + odize.AssertEqual(t, m.selectedPlaybook, v.SelectedPlaybook) + odize.AssertEqual(t, m.selectedAITeam, v.SelectedAITeam) + default: + t.Fatalf("expected MsgSwitchBattlePage, got %T", msg) + } + + // 4. Back from selecting opponent -> selecting playbook + m.Update(tea.KeyPressMsg{Text: "esc"}) + odize.AssertEqual(t, stateSelectingPlaybook, m.state) + }) + + group.Test("should handle back navigation to title", func(t *testing.T) { + state := &uistate.GlobalState{} + theme := styles.NewIceTheme() + m := NewModelBattleSelection(state, nil, nil, nil, theme) + + _, cmd := m.Update(tea.KeyPressMsg{Text: "esc"}) + + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case uistate.MsgSwitchPage: + odize.AssertEqual(t, uistate.PageTitle, v.NewPage) + default: + t.Fatalf("expected MsgSwitchPage, got %T", msg) + } + }) + + group.Test("should reset state on Init", func(t *testing.T) { + state := &uistate.GlobalState{ + Team: &teams.Team{ID: 1, Name: "My Team"}, + } + theme := styles.NewIceTheme() + m := NewModelBattleSelection(state, nil, nil, nil, theme) + + // 1. Set some state + m.state = stateSelectingOpponent + m.selectedPlaybook = &playbooks.Playbook{ID: 1, Name: "Playbook 1"} + m.selectedAITeam = &teams.AITeam{Team: teams.Team{ID: 2, Name: "Team A"}} + + // 2. Call Init + m.Init() + + // 3. Verify it's reset + odize.AssertEqual(t, stateSelectingPlaybook, m.state) + odize.AssertTrue(t, m.selectedPlaybook == nil) + odize.AssertTrue(t, m.selectedAITeam == nil) + }) + + group.Test("should call Init on MsgSwitchPage", func(t *testing.T) { + state := &uistate.GlobalState{ + Team: &teams.Team{ID: 1, Name: "My Team"}, + } + theme := styles.NewIceTheme() + // We test BattleModel here as it now handles MsgSwitchPage + m := NewBattleModel(state, nil, nil, nil, theme) + + // 1. Set some state to verify reset + if p, ok := m.subPageBattleSelection.(*PageBattleSelectionModel); ok { + p.state = stateSelectingOpponent + } + + // 2. Send MsgSwitchPage + _, cmd := m.Update(uistate.MsgSwitchPage{NewPage: uistate.PageNewBattle}) + + // 3. Verify it's reset and returns loadData command + if p, ok := m.subPageBattleSelection.(*PageBattleSelectionModel); ok { + odize.AssertEqual(t, stateSelectingPlaybook, p.state) + } + odize.AssertTrue(t, cmd != nil) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/ui/uibattle/root_battle.go b/internal/ui/uibattle/root_battle.go new file mode 100644 index 0000000..bd4928b --- /dev/null +++ b/internal/ui/uibattle/root_battle.go @@ -0,0 +1,100 @@ +package uibattle + +import ( + 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" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) + +type SubPageBattle int + +const ( + SubPageBattleSelection SubPageBattle = iota + SubPageBattleConfirm +) + +type MsgSwitchBattlePage struct { + NewPage SubPageBattle + SelectedPlaybook *playbooks.Playbook + SelectedAITeam *teams.AITeam +} + +type BattleModel struct { + currentPage SubPageBattle + subPageBattleSelection tea.Model + subPageBattleConfirm tea.Model +} + +func NewBattleModel(state *uistate.GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, gameSvc *games.Service, theme styles.IceTheme) *BattleModel { + return &BattleModel{ + subPageBattleSelection: NewModelBattleSelection(state, teamsSvc, playbookSvc, gameSvc, theme), + subPageBattleConfirm: NewPageBattleConfirm(state, gameSvc, theme), + currentPage: SubPageBattleSelection, + } +} + +func (m *BattleModel) Init() tea.Cmd { + switch m.currentPage { + case SubPageBattleSelection: + return m.subPageBattleSelection.Init() + case SubPageBattleConfirm: + return m.subPageBattleConfirm.Init() + default: + return nil + } +} + +func (m *BattleModel) SetData(playbook *playbooks.Playbook, aiTeam *teams.AITeam) { + if p, ok := m.subPageBattleConfirm.(*PageBattleConfirmModel); ok { + p.SetData(playbook, aiTeam) + } +} + +func (m *BattleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case uistate.MsgSwitchPage: + if msg.NewPage == uistate.PageNewBattle { + m.currentPage = SubPageBattleSelection + return m, m.Init() + } + case MsgSwitchBattlePage: + m.currentPage = msg.NewPage + if msg.SelectedPlaybook != nil && msg.SelectedAITeam != nil { + m.SetData(msg.SelectedPlaybook, msg.SelectedAITeam) + } + return m, m.Init() + case tea.WindowSizeMsg: + var cmd tea.Cmd + m.subPageBattleSelection, cmd = m.subPageBattleSelection.Update(msg) + cmds = append(cmds, cmd) + m.subPageBattleConfirm, cmd = m.subPageBattleConfirm.Update(msg) + cmds = append(cmds, cmd) + } + + var cmd tea.Cmd + switch m.currentPage { + case SubPageBattleSelection: + m.subPageBattleSelection, cmd = m.subPageBattleSelection.Update(msg) + case SubPageBattleConfirm: + m.subPageBattleConfirm, cmd = m.subPageBattleConfirm.Update(msg) + } + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *BattleModel) View() tea.View { + switch m.currentPage { + case SubPageBattleSelection: + return m.subPageBattleSelection.View() + case SubPageBattleConfirm: + return m.subPageBattleConfirm.View() + default: + return tea.NewView("unknown battle page") + } +} diff --git a/internal/ui/page_locker_playbooks_create.go b/internal/ui/uilocker/page_locker_playbooks_create.go similarity index 60% rename from internal/ui/page_locker_playbooks_create.go rename to internal/ui/uilocker/page_locker_playbooks_create.go index aed4a28..e0b0020 100644 --- a/internal/ui/page_locker_playbooks_create.go +++ b/internal/ui/uilocker/page_locker_playbooks_create.go @@ -1,4 +1,4 @@ -package ui +package uilocker import ( "fmt" @@ -8,12 +8,12 @@ import ( "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" ) type lockerPlaybooksCreateKeyMap struct { - components.CommonKeys - Back key.Binding - Enter key.Binding + uistate.KeyMap } func (k lockerPlaybooksCreateKeyMap) ShortHelp() []key.Binding { @@ -28,23 +28,14 @@ func (k lockerPlaybooksCreateKeyMap) FullHelp() [][]key.Binding { 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"), - ), + KeyMap: uistate.NewKeyMap(), } } type ModelLockerPlaybooksCreate struct { width int height int - theme IceTheme - globalState *GlobalState + theme styles.IceTheme playbookSvc *playbooks.Service keys lockerPlaybooksCreateKeyMap footer components.Footer @@ -54,10 +45,9 @@ type ModelLockerPlaybooksCreate struct { err error } -func NewModelLockerPlaybooksCreate(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksCreate { +func NewModelLockerPlaybooksCreate(state *uistate.GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksCreate { return &ModelLockerPlaybooksCreate{ - theme: NewIceTheme(), - globalState: state, + theme: theme, playbookSvc: playbookSvc, keys: newLockerPlaybooksCreateKeyMap(), footer: components.NewFooter(newLockerPlaybooksCreateKeyMap()), @@ -73,47 +63,17 @@ 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 MsgSwitchLockerPage: + m.handleSwitchLockerPage(msg) 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, - }, - } - } - } + model, cmd, done := m.handleKey(msg) + if done { + return model, cmd } case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.footer.Update(msg) + m.handleWindowSize(msg) } var cmd tea.Cmd @@ -123,6 +83,54 @@ func (m *ModelLockerPlaybooksCreate) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *ModelLockerPlaybooksCreate) handleSwitchLockerPage(msg MsgSwitchLockerPage) { + if msg.NewPage == SubPageLockerPlaybooksCreate { + if msg.Playbook != nil { + m.load(msg.Playbook) + } else { + m.reset() + } + } +} + +func (m *ModelLockerPlaybooksCreate) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit, true + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksList} + }, true + case key.Matches(msg, m.keys.Enter): + return m.handleEnter() + } + return nil, nil, false +} + +func (m *ModelLockerPlaybooksCreate) handleEnter() (tea.Model, tea.Cmd, bool) { + name, description := m.playbookForm.Values() + if name != "" { + return m, func() tea.Msg { + return MsgSwitchLockerPage{ + NewPage: SubPageLockerPlaybooksEdit, + Playbook: &playbooks.Playbook{ + ID: m.playbookID, + Name: name, + Description: description, + Formations: m.formations, + }, + } + }, true + } + return nil, nil, false +} + +func (m *ModelLockerPlaybooksCreate) handleWindowSize(msg tea.WindowSizeMsg) { + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) +} + func (m *ModelLockerPlaybooksCreate) reset() { m.playbookForm.Reset() m.formations = nil @@ -161,7 +169,7 @@ func (m *ModelLockerPlaybooksCreate) View() tea.View { "", content, "", - m.footer.View(m.theme.Footer), + m.footer.View(m.theme), ) centeredContent := lipgloss.Place( diff --git a/internal/ui/uilocker/page_locker_playbooks_edit.go b/internal/ui/uilocker/page_locker_playbooks_edit.go new file mode 100644 index 0000000..f922867 --- /dev/null +++ b/internal/ui/uilocker/page_locker_playbooks_edit.go @@ -0,0 +1,324 @@ +package uilocker + +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" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) + +type lockerPlaybooksEditKeyMap struct { + uistate.KeyMap +} + +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{ + KeyMap: uistate.NewKeyMap(), + } +} + +const ( + availableFormationListIndex = iota + selectedFormationListIndex + maxFormationsPerPlaybook = 10 +) + +type ModelLockerPlaybooksEdit struct { + width int + height int + theme styles.IceTheme + globalState *uistate.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 *uistate.GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksEdit { + keys := newLockerPlaybooksEditKeyMap() + model := &ModelLockerPlaybooksEdit{ + theme: theme, + globalState: state, + playbookSvc: playbookSvc, + keys: keys, + footer: components.NewFooter(keys), + formationList: components.NewFormationList(components.FormationListConfig{ + Title: "Available Formations", + Items: playbooks.Formations(), + EnableFiltering: true, + ShowDescription: true, + }, theme), + selectedFormationList: components.NewFormationList(components.FormationListConfig{ + Title: "Selected Formations (Max 10)", + Items: []playbooks.Formation{}, + EnableFiltering: false, + ShowDescription: false, + }, theme), + } + model.setActiveList(availableFormationListIndex) + return model +} + +func (m *ModelLockerPlaybooksEdit) Init() tea.Cmd { + return m.reset() +} + +func (m *ModelLockerPlaybooksEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.footer.Update(msg) + var cmds []tea.Cmd + + switch msg := msg.(type) { + case uistate.MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + case MsgSwitchLockerPage: + if msg.NewPage == SubPageLockerPlaybooksEdit { + if msg.Playbook != nil { + cmds = append(cmds, m.load(msg.Playbook)) + } else { + cmds = append(cmds, m.reset()) + } + } + case error: + m.err = msg + case tea.KeyMsg: + model, cmd, handled := m.handleKey(msg) + if handled { + cmds = append(cmds, cmd) + return model, tea.Batch(cmds...) + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + listWidth := (m.width - 10) / 2 + listHeight := m.height - 20 + m.formationList.SetSize(listWidth, listHeight) + m.selectedFormationList.SetSize(listWidth, listHeight) + } + + 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 m, tea.Batch(cmds...) +} + +func (m *ModelLockerPlaybooksEdit) reset() tea.Cmd { + m.newFormations = nil + m.playbookID = 0 + m.playbookName = "" + m.playbookDescription = "" + m.formationList.Reset() + m.selectedFormationList.Reset() + m.setActiveList(availableFormationListIndex) + m.err = nil + return m.selectedFormationList.SetItems(nil) +} + +func (m *ModelLockerPlaybooksEdit) load(p *playbooks.Playbook) tea.Cmd { + m.newFormations = p.Formations + m.playbookID = p.ID + m.playbookName = p.Name + m.playbookDescription = p.Description + m.formationList.Reset() + m.selectedFormationList.Reset() + m.setActiveList(availableFormationListIndex) + m.err = nil + return m.selectedFormationList.SetItems(m.newFormations) +} + +func (m *ModelLockerPlaybooksEdit) handleKey(msg tea.KeyMsg) (*ModelLockerPlaybooksEdit, tea.Cmd, bool) { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit, true + case key.Matches(msg, m.keys.Back): + return m.handleBack() + case key.Matches(msg, m.keys.Tab): + return m.handleTab() + case key.Matches(msg, m.keys.Enter): + if m.formationList.IsFiltering() { + return m, nil, false + } + return m, m.toggleFormationSelection(), true + case key.Matches(msg, m.keys.Save): + if len(m.newFormations) > 0 { + return m, m.savePlaybook, true + } + } + + return m, nil, false +} + +func (m *ModelLockerPlaybooksEdit) handleBack() (*ModelLockerPlaybooksEdit, tea.Cmd, bool) { + if m.formationList.IsFiltering() { + return m, nil, false + } + return m, func() tea.Msg { + return MsgSwitchLockerPage{ + NewPage: SubPageLockerPlaybooksCreate, + Playbook: &playbooks.Playbook{ + ID: m.playbookID, + Name: m.playbookName, + Description: m.playbookDescription, + Formations: m.newFormations, + }, + } + }, true +} + +func (m *ModelLockerPlaybooksEdit) handleTab() (*ModelLockerPlaybooksEdit, tea.Cmd, bool) { + if m.formationList.IsFiltering() { + return m, nil, false + } + if m.activeList == availableFormationListIndex { + m.setActiveList(selectedFormationListIndex) + } else { + m.setActiveList(availableFormationListIndex) + } + return m, nil, true +} + +func (m *ModelLockerPlaybooksEdit) setActiveList(activeList int) { + m.activeList = activeList + m.formationList.SetActive(activeList == availableFormationListIndex) + m.selectedFormationList.SetActive(activeList == selectedFormationListIndex) +} + +func (m *ModelLockerPlaybooksEdit) toggleFormationSelection() tea.Cmd { + if m.activeList == availableFormationListIndex { + if len(m.newFormations) >= maxFormationsPerPlaybook { + return nil + } + + formation := m.formationList.SelectedItem() + if formation.Name == "" { + return nil + } + + m.newFormations = append(m.newFormations, formation) + return m.selectedFormationList.SetItems(m.newFormations) + } + + if len(m.newFormations) == 0 { + return nil + } + + idx := m.selectedFormationList.SelectedIndex() + if idx < 0 || idx >= len(m.newFormations) { + return nil + } + + m.newFormations = append(m.newFormations[:idx], m.newFormations[idx+1:]...) + return m.selectedFormationList.SetItems(m.newFormations) +} + +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 MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksList} +} + +func (m *ModelLockerPlaybooksEdit) View() tea.View { + if m.width == 0 || m.height == 0 { + return tea.NewView("Initializing...") + } + + 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 { + availableView := m.formationList.View(m.theme) + selectedView := m.selectedFormationList.View(m.theme) + + if m.activeList == availableFormationListIndex { + availableView = m.theme.ActiveBorder.Render(availableView) + selectedView = m.theme.InactiveBorder.Render(selectedView) + } else { + availableView = m.theme.InactiveBorder.Render(availableView) + selectedView = m.theme.ActiveBorder.Render(selectedView) + } + + content = lipgloss.JoinHorizontal( + lipgloss.Top, + lipgloss.JoinVertical(lipgloss.Left, + m.theme.SecondaryHeader.Render("AVAILABLE FORMATIONS"), + availableView, + ), + lipgloss.NewStyle().Width(2).Render(""), + lipgloss.JoinVertical(lipgloss.Left, + m.theme.SecondaryHeader.Render("SELECTED FORMATIONS (MAX 10)"), + selectedView, + ), + ) + content += "\n\n" + m.theme.Muted.Render(fmt.Sprintf("%d/%d formations • Tab: switch • Enter: add/remove • 's': save", len(m.newFormations), maxFormationsPerPlaybook)) + } + + mainContent := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render(title), + "", + content, + "", + m.footer.View(m.theme), + ) + + 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/uilocker/page_locker_playbooks_list.go similarity index 60% rename from internal/ui/page_locker_playbooks_list.go rename to internal/ui/uilocker/page_locker_playbooks_list.go index 8a2056a..6929e65 100644 --- a/internal/ui/page_locker_playbooks_list.go +++ b/internal/ui/uilocker/page_locker_playbooks_list.go @@ -1,4 +1,4 @@ -package ui +package uilocker import ( "fmt" @@ -8,14 +8,12 @@ import ( "charm.land/lipgloss/v2" "github.com/code-gorilla-au/rush/internal/playbooks" "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" ) type lockerPlaybooksListKeyMap struct { - components.CommonKeys - Back key.Binding - Enter key.Binding - New key.Binding - Delete key.Binding + uistate.KeyMap } func (k lockerPlaybooksListKeyMap) ShortHelp() []key.Binding { @@ -30,30 +28,15 @@ func (k lockerPlaybooksListKeyMap) FullHelp() [][]key.Binding { 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")), + KeyMap: uistate.NewKeyMap(), } } type ModelLockerPlaybooksList struct { width int height int - theme IceTheme - globalState *GlobalState + theme styles.IceTheme + globalState *uistate.GlobalState playbookSvc *playbooks.Service keys lockerPlaybooksListKeyMap footer components.Footer @@ -62,9 +45,9 @@ type ModelLockerPlaybooksList struct { err error } -func NewModelLockerPlaybooksList(state *GlobalState, playbookSvc *playbooks.Service) *ModelLockerPlaybooksList { +func NewModelLockerPlaybooksList(state *uistate.GlobalState, playbookSvc *playbooks.Service, theme styles.IceTheme) *ModelLockerPlaybooksList { return &ModelLockerPlaybooksList{ - theme: NewIceTheme(), + theme: theme, globalState: state, playbookSvc: playbookSvc, keys: newLockerPlaybooksListKeyMap(), @@ -95,54 +78,26 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { - case MsgStateUpdated: + case uistate.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 { + m.playbookList = components.NewPlaybookList(msg.Playbooks, m.theme) + m.playbookList.SetSize(m.width/2, m.height-10) + case MsgSwitchLockerPage: + if msg.NewPage == SubPageLockerPlaybooksList { 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 - } + model, cmd, done := m.handleKey(msg) + 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) + m.handleWindowSize(msg) } if m.playbooksLoaded { @@ -154,6 +109,40 @@ func (m *ModelLockerPlaybooksList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *ModelLockerPlaybooksList) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit, true + case key.Matches(msg, m.keys.Back): + if m.playbookList.IsFiltering() { + return nil, nil, false + } + return m, func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerRoom} + }, true + case key.Matches(msg, m.keys.Enter): + return m.handleRouteEditPlaybook() + case key.Matches(msg, m.keys.New): + if !m.playbookList.IsFiltering() { + return m, func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksCreate} + }, true + } + case key.Matches(msg, m.keys.Delete): + return m.handleDeletePlaybook() + } + return nil, nil, false +} + +func (m *ModelLockerPlaybooksList) handleWindowSize(msg 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) +} + func (m *ModelLockerPlaybooksList) handleRouteEditPlaybook() (tea.Model, tea.Cmd, bool) { if m.playbookList.IsFiltering() { return nil, nil, false @@ -162,8 +151,8 @@ func (m *ModelLockerPlaybooksList) handleRouteEditPlaybook() (tea.Model, tea.Cmd selected := m.playbookList.SelectedItem() if selected != nil { return m, func() tea.Msg { - return MsgSwitchPage{ - NewPage: PageLockerPlaybooksCreate, + return MsgSwitchLockerPage{ + NewPage: SubPageLockerPlaybooksCreate, Playbook: selected, } }, true @@ -194,30 +183,19 @@ func (m *ModelLockerPlaybooksList) View() tea.View { 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" - } - } + content = m.renderContent() } mainContent := lipgloss.JoinVertical( lipgloss.Center, - m.theme.Logo.Render(title), + m.theme.Logo.Render("PLAYBOOKS"), "", content, "", - m.footer.View(m.theme.Footer), + m.footer.View(m.theme), ) centeredContent := lipgloss.Place( @@ -233,3 +211,17 @@ func (m *ModelLockerPlaybooksList) View() tea.View { return view } + +func (m *ModelLockerPlaybooksList) renderContent() string { + if !m.playbooksLoaded { + return "Loading..." + } + if m.playbookList.Len() == 0 { + return "No playbooks yet. Press 'n' to create one." + } + content := m.playbookList.View(m.theme) + if !m.playbookList.IsFiltering() { + content += "\n\nPress 'n' to create new playbook" + } + return content +} diff --git a/internal/ui/page_locker_players.go b/internal/ui/uilocker/page_locker_players.go similarity index 60% rename from internal/ui/page_locker_players.go rename to internal/ui/uilocker/page_locker_players.go index 276ced6..71e7421 100644 --- a/internal/ui/page_locker_players.go +++ b/internal/ui/uilocker/page_locker_players.go @@ -1,8 +1,10 @@ -package ui +package uilocker import ( "github.com/code-gorilla-au/rush/internal/teams" "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -10,11 +12,7 @@ import ( ) type lockerPlayersKeyMap struct { - components.CommonKeys - Back key.Binding - Enter key.Binding - Up key.Binding - Down key.Binding + uistate.KeyMap } func (k lockerPlayersKeyMap) ShortHelp() []key.Binding { @@ -30,41 +28,25 @@ func (k lockerPlayersKeyMap) FullHelp() [][]key.Binding { 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"), - ), + KeyMap: uistate.NewKeyMap(), } } type ModelLockerPlayers struct { width int height int - theme IceTheme - globalState *GlobalState + theme styles.IceTheme + globalState *uistate.GlobalState teamsSvc *teams.Service keys lockerPlayersKeyMap footer components.Footer playerList components.PlayerList } -func NewModelLockerPlayers(state *GlobalState, teamsSvc *teams.Service) *ModelLockerPlayers { +func NewModelLockerPlayers(state *uistate.GlobalState, teamsSvc *teams.Service, theme styles.IceTheme) *ModelLockerPlayers { keys := newLockerPlayersKeyMap() return &ModelLockerPlayers{ - theme: NewIceTheme(), + theme: theme, globalState: state, teamsSvc: teamsSvc, keys: keys, @@ -80,29 +62,19 @@ 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 uistate.MsgStateUpdated: + m.handleStateUpdated() + case MsgSwitchLockerPage: + m.handleSwitchLockerPage(msg) 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} - } + model, cmd, done := m.handleKey(msg) + if done { + return model, cmd } case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.footer.Update(msg) + m.handleWindowSize(msg) } cmd := m.playerList.Update(msg) @@ -111,6 +83,36 @@ func (m *ModelLockerPlayers) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } +func (m *ModelLockerPlayers) handleStateUpdated() { + if m.globalState.Team != nil { + m.playerList = components.NewPlayerList(m.globalState.Team.Players) + } +} + +func (m *ModelLockerPlayers) handleSwitchLockerPage(msg MsgSwitchLockerPage) { + if msg.NewPage == SubPageLockerPlayers && m.globalState.Team != nil { + m.playerList = components.NewPlayerList(m.globalState.Team.Players) + } +} + +func (m *ModelLockerPlayers) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd, bool) { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit, true + case key.Matches(msg, m.keys.Back): + return m, func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerRoom} + }, true + } + return nil, nil, false +} + +func (m *ModelLockerPlayers) handleWindowSize(msg tea.WindowSizeMsg) { + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) +} + 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) @@ -137,16 +139,16 @@ func (m *ModelLockerPlayers) View() tea.View { playersView := "No players found" if len(m.playerList.Items) > 0 { - playersView = m.playerList.View(lipgloss.NewStyle(), m.theme.ListSelected) + playersView = m.playerList.View(m.theme) } content := lipgloss.JoinVertical( lipgloss.Center, - m.theme.Logo.Render("PLAYERS"), + m.theme.Logo.Render(m.globalState.Team.Name), "", playersView, "", - m.footer.View(m.theme.Footer), + m.footer.View(m.theme), ) centeredContent := lipgloss.Place( diff --git a/internal/ui/page_locker_room.go b/internal/ui/uilocker/page_locker_room.go similarity index 64% rename from internal/ui/page_locker_room.go rename to internal/ui/uilocker/page_locker_room.go index f13b081..8e075fe 100644 --- a/internal/ui/page_locker_room.go +++ b/internal/ui/uilocker/page_locker_room.go @@ -1,7 +1,9 @@ -package ui +package uilocker import ( "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -9,9 +11,7 @@ import ( ) type lockerRoomKeyMap struct { - components.CommonKeys - Back key.Binding - Select key.Binding + uistate.KeyMap } func (k lockerRoomKeyMap) ShortHelp() []key.Binding { @@ -26,36 +26,28 @@ func (k lockerRoomKeyMap) FullHelp() [][]key.Binding { 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"), - ), + KeyMap: uistate.NewKeyMap(), } } type ModelLockerRoom struct { width int height int - theme IceTheme - globalState *GlobalState + theme styles.IceTheme + globalState *uistate.GlobalState keys lockerRoomKeyMap footer components.Footer list components.LockerRoomList } -func NewModelLockerRoom(globalState *GlobalState) *ModelLockerRoom { +func NewModelLockerRoom(globalState *uistate.GlobalState, theme styles.IceTheme) *ModelLockerRoom { keys := newLockerRoomKeyMap() return &ModelLockerRoom{ globalState: globalState, keys: keys, footer: components.NewFooter(keys), - theme: NewIceTheme(), - list: components.NewLockerRoomList(), + theme: theme, + list: components.NewLockerRoomList(theme), } } @@ -64,10 +56,10 @@ func (m *ModelLockerRoom) Init() tea.Cmd { } func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - m.list.Update(msg) + var cmds []tea.Cmd switch msg := msg.(type) { - case MsgStateUpdated: + case uistate.MsgStateUpdated: m.globalState.Coach = msg.Coach m.globalState.Team = msg.Team case tea.KeyMsg: @@ -76,27 +68,36 @@ func (m *ModelLockerRoom) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case key.Matches(msg, m.keys.Back): return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageTitle} + return uistate.MsgSwitchPage{NewPage: uistate.PageTitle} } case key.Matches(msg, m.keys.Select): switch m.list.SelectedItem() { case components.ItemPlayers: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerPlayers} + return MsgSwitchLockerPage{NewPage: SubPageLockerPlayers} + } + case components.ItemTeamStatistics: + return m, func() tea.Msg { + return MsgSwitchLockerPage{NewPage: SubPageLockerTeamStatistics} } case components.ItemPlaybooks: return m, func() tea.Msg { - return MsgSwitchPage{NewPage: PageLockerPlaybooksList} + return MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksList} } } } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + m.list.SetSize(40, 20) m.footer.Update(msg) } - return m, nil + var listCmd tea.Cmd + m.list, listCmd = m.list.Update(msg) + cmds = append(cmds, listCmd) + + return m, tea.Batch(cmds...) } func (m *ModelLockerRoom) View() tea.View { @@ -105,11 +106,11 @@ func (m *ModelLockerRoom) View() tea.View { content := lipgloss.JoinVertical( lipgloss.Center, - m.theme.Logo.Render("LOCKER ROOM"), + m.theme.Logo.Render("LOCKER ROOM: "+m.globalState.Team.Name), "", - m.list.View(m.theme.Base, m.theme.ListSelected), + m.list.View(m.theme), "", - m.footer.View(m.theme.Footer), + m.footer.View(m.theme), ) centeredContent := lipgloss.Place( diff --git a/internal/ui/uilocker/page_locker_room_test.go b/internal/ui/uilocker/page_locker_room_test.go new file mode 100644 index 0000000..a9f1ae2 --- /dev/null +++ b/internal/ui/uilocker/page_locker_room_test.go @@ -0,0 +1,82 @@ +package uilocker + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) + +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 := &uistate.GlobalState{} + theme := styles.NewIceTheme() + m := NewModelLockerRoom(state, theme) + + // 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 MsgSwitchLockerPage: + odize.AssertEqual(t, SubPageLockerPlayers, 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 := &uistate.GlobalState{} + theme := styles.NewIceTheme() + m := NewModelLockerRoom(state, theme) + + // Select Playbooks (it's the third item) + m.Update(tea.KeyPressMsg{Text: "down"}) + 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 MsgSwitchLockerPage: + odize.AssertEqual(t, SubPageLockerPlaybooksList, v.NewPage) + default: + t.Fatalf("expected MsgSwitchLockerPage, got %T", msg) + } + }) + + group.Test("should route to locker team statistics when team statistics item is selected", func(t *testing.T) { + state := &uistate.GlobalState{} + theme := styles.NewIceTheme() + m := NewModelLockerRoom(state, theme) + + m.Update(tea.KeyPressMsg{Text: "down"}) + odize.AssertEqual(t, components.ItemTeamStatistics, m.list.SelectedItem()) + + _, cmd := m.Update(tea.KeyPressMsg{Text: "enter"}) + + odize.AssertTrue(t, cmd != nil) + msg := cmd() + switch v := msg.(type) { + case MsgSwitchLockerPage: + odize.AssertEqual(t, SubPageLockerTeamStatistics, v.NewPage) + default: + t.Fatalf("expected MsgSwitchLockerPage, got %T", msg) + } + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/ui/uilocker/page_locker_team_statistics.go b/internal/ui/uilocker/page_locker_team_statistics.go new file mode 100644 index 0000000..7fb9c63 --- /dev/null +++ b/internal/ui/uilocker/page_locker_team_statistics.go @@ -0,0 +1,185 @@ +package uilocker + +import ( + "errors" + "fmt" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/code-gorilla-au/rush/internal/games" + "github.com/code-gorilla-au/rush/internal/ui/components" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) + +type lockerTeamStatisticsKeyMap struct { + uistate.KeyMap +} + +func (k lockerTeamStatisticsKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Back, k.Quit} +} + +func (k lockerTeamStatisticsKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Enter, k.Back, k.Quit}, + } +} + +func newLockerTeamStatisticsKeyMap() lockerTeamStatisticsKeyMap { + return lockerTeamStatisticsKeyMap{KeyMap: uistate.NewKeyMap()} +} + +type MsgTeamStatisticsLoaded struct { + Statistics games.TeamStatistics + Err error +} + +type ModelLockerTeamStatistics struct { + width int + height int + theme styles.IceTheme + globalState *uistate.GlobalState + gameSvc *games.Service + keys lockerTeamStatisticsKeyMap + footer components.Footer + stats games.TeamStatistics + loadErr error +} + +func NewModelLockerTeamStatistics(state *uistate.GlobalState, gameSvc *games.Service, theme styles.IceTheme) *ModelLockerTeamStatistics { + keys := newLockerTeamStatisticsKeyMap() + + return &ModelLockerTeamStatistics{ + theme: theme, + globalState: state, + gameSvc: gameSvc, + keys: keys, + footer: components.NewFooter(keys), + } +} + +func (m *ModelLockerTeamStatistics) Init() tea.Cmd { + return nil +} + +func (m *ModelLockerTeamStatistics) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case uistate.MsgStateUpdated: + m.globalState.Coach = msg.Coach + m.globalState.Team = msg.Team + case MsgSwitchLockerPage: + if msg.NewPage == SubPageLockerTeamStatistics { + cmds = append(cmds, m.loadStatisticsCmd()) + } + case MsgTeamStatisticsLoaded: + m.stats = msg.Statistics + m.loadErr = msg.Err + 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 MsgSwitchLockerPage{NewPage: SubPageLockerRoom} + } + case key.Matches(msg, m.keys.Enter): + cmds = append(cmds, m.loadStatisticsCmd()) + } + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.footer.Update(msg) + } + + return m, tea.Batch(cmds...) +} + +func (m *ModelLockerTeamStatistics) loadStatisticsCmd() tea.Cmd { + if m.globalState.Team == nil { + return nil + } + if m.gameSvc == nil { + return func() tea.Msg { + return MsgTeamStatisticsLoaded{Err: errors.New("game service unavailable")} + } + } + + teamID := m.globalState.Team.ID + return func() tea.Msg { + stats, err := m.gameSvc.GetTeamStatistics(m.globalState.Context(), teamID) + return MsgTeamStatisticsLoaded{Statistics: stats, Err: err} + } +} + +func (m *ModelLockerTeamStatistics) View() tea.View { + view := tea.NewView("") + view.AltScreen = true + + teamName := "Unknown Team" + if m.globalState.Team != nil { + teamName = m.globalState.Team.Name + } + + recordTitle := m.theme.Header.Render("Record (W-D-L)") + record := m.theme.Highlight.Render(fmt.Sprintf("%d - %d - %d", m.stats.Wins, m.stats.Draws, m.stats.Losses)) + + statsBody := lipgloss.JoinVertical( + lipgloss.Left, + recordTitle, + record, + "", + m.theme.Label.Render(fmt.Sprintf("Games played: %d", m.stats.GamesPlayed)), + m.theme.Label.Render(fmt.Sprintf("Win rate: %.1f%%", m.stats.WinRate)), + m.theme.Label.Render(fmt.Sprintf("Rounds won: %d", m.stats.RoundsWon)), + m.theme.Label.Render(fmt.Sprintf("Rounds lost: %d", m.stats.RoundsLost)), + m.theme.Label.Render(fmt.Sprintf("Round differential: %+d", m.stats.RoundDifferential)), + m.theme.Label.Render(fmt.Sprintf("Avg rounds won/game: %.2f", m.stats.AverageRoundsWon)), + m.theme.Label.Render(fmt.Sprintf("Avg rounds lost/game: %.2f", m.stats.AverageRoundsLost)), + ) + + if m.stats.GamesPlayed == 0 && m.loadErr == nil { + statsBody = lipgloss.JoinVertical( + lipgloss.Left, + statsBody, + "", + m.theme.Muted.Render("No completed games yet."), + ) + } + + if m.loadErr != nil { + statsBody = lipgloss.JoinVertical( + lipgloss.Left, + statsBody, + "", + m.theme.Muted.Render(fmt.Sprintf("Could not load team statistics: %v", m.loadErr)), + ) + } + + content := lipgloss.JoinVertical( + lipgloss.Center, + m.theme.Logo.Render("TEAM STATISTICS: "+teamName), + "", + m.theme.ActiveBorder.Render(statsBody), + "", + m.footer.View(m.theme), + ) + + 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/uilocker/root_locker.go b/internal/ui/uilocker/root_locker.go new file mode 100644 index 0000000..0a3f0e9 --- /dev/null +++ b/internal/ui/uilocker/root_locker.go @@ -0,0 +1,126 @@ +package uilocker + +import ( + 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" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" +) + +type SubPageLocker int + +const ( + SubPageLockerRoom SubPageLocker = iota + SubPageLockerPlayers + SubPageLockerTeamStatistics + SubPageLockerPlaybooksList + SubPageLockerPlaybooksCreate + SubPageLockerPlaybooksEdit +) + +type MsgSwitchLockerPage struct { + NewPage SubPageLocker + Playbook *playbooks.Playbook + GameID int64 +} + +// LockerModel handles all locker room related pages. +type LockerModel struct { + currentPage SubPageLocker + subPageLockerRoom tea.Model + subPageLockerPlayers tea.Model + subPageLockerTeamStatistics tea.Model + subPageLockerPlaybooksList tea.Model + subPageLockerPlaybooksCreate tea.Model + subPageLockerPlaybooksEdit tea.Model +} + +// NewLockerModel returns a new LockerModel. +func NewLockerModel(state *uistate.GlobalState, teamsSvc *teams.Service, playbookSvc *playbooks.Service, gameSvc *games.Service, theme styles.IceTheme) *LockerModel { + return &LockerModel{ + subPageLockerRoom: NewModelLockerRoom(state, theme), + subPageLockerPlayers: NewModelLockerPlayers(state, teamsSvc, theme), + subPageLockerTeamStatistics: NewModelLockerTeamStatistics(state, gameSvc, theme), + subPageLockerPlaybooksList: NewModelLockerPlaybooksList(state, playbookSvc, theme), + subPageLockerPlaybooksCreate: NewModelLockerPlaybooksCreate(state, playbookSvc, theme), + subPageLockerPlaybooksEdit: NewModelLockerPlaybooksEdit(state, playbookSvc, theme), + } +} + +func (m *LockerModel) Init() tea.Cmd { + return nil +} + +func (m *LockerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case uistate.MsgSwitchPage: + if msg.NewPage == uistate.PageLockerRoom { + m.currentPage = SubPageLockerRoom + return m, m.Init() + } + case MsgSwitchLockerPage: + m.currentPage = msg.NewPage + case tea.WindowSizeMsg: + return m, m.handleWindowSize(msg) + } + + return m.updateCurrentPage(msg) +} + +func (m *LockerModel) handleWindowSize(msg tea.WindowSizeMsg) tea.Cmd { + var cmds []tea.Cmd + var cmd tea.Cmd + m.subPageLockerRoom, cmd = m.subPageLockerRoom.Update(msg) + cmds = append(cmds, cmd) + m.subPageLockerPlayers, cmd = m.subPageLockerPlayers.Update(msg) + cmds = append(cmds, cmd) + m.subPageLockerTeamStatistics, cmd = m.subPageLockerTeamStatistics.Update(msg) + cmds = append(cmds, cmd) + m.subPageLockerPlaybooksList, cmd = m.subPageLockerPlaybooksList.Update(msg) + cmds = append(cmds, cmd) + m.subPageLockerPlaybooksCreate, cmd = m.subPageLockerPlaybooksCreate.Update(msg) + cmds = append(cmds, cmd) + m.subPageLockerPlaybooksEdit, cmd = m.subPageLockerPlaybooksEdit.Update(msg) + cmds = append(cmds, cmd) + return tea.Batch(cmds...) +} + +func (m *LockerModel) updateCurrentPage(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch m.currentPage { + case SubPageLockerRoom: + m.subPageLockerRoom, cmd = m.subPageLockerRoom.Update(msg) + case SubPageLockerPlayers: + m.subPageLockerPlayers, cmd = m.subPageLockerPlayers.Update(msg) + case SubPageLockerTeamStatistics: + m.subPageLockerTeamStatistics, cmd = m.subPageLockerTeamStatistics.Update(msg) + case SubPageLockerPlaybooksList: + m.subPageLockerPlaybooksList, cmd = m.subPageLockerPlaybooksList.Update(msg) + case SubPageLockerPlaybooksCreate: + m.subPageLockerPlaybooksCreate, cmd = m.subPageLockerPlaybooksCreate.Update(msg) + case SubPageLockerPlaybooksEdit: + m.subPageLockerPlaybooksEdit, cmd = m.subPageLockerPlaybooksEdit.Update(msg) + } + return m, cmd +} + +func (m *LockerModel) View() tea.View { + switch m.currentPage { + case SubPageLockerRoom: + return m.subPageLockerRoom.View() + case SubPageLockerPlayers: + return m.subPageLockerPlayers.View() + case SubPageLockerTeamStatistics: + return m.subPageLockerTeamStatistics.View() + case SubPageLockerPlaybooksList: + return m.subPageLockerPlaybooksList.View() + case SubPageLockerPlaybooksCreate: + return m.subPageLockerPlaybooksCreate.View() + case SubPageLockerPlaybooksEdit: + return m.subPageLockerPlaybooksEdit.View() + } + + return tea.NewView("unknown locker page") +} diff --git a/internal/ui/uilocker/root_locker_test.go b/internal/ui/uilocker/root_locker_test.go new file mode 100644 index 0000000..46e1f75 --- /dev/null +++ b/internal/ui/uilocker/root_locker_test.go @@ -0,0 +1,39 @@ +package uilocker + +import ( + "testing" + + "github.com/code-gorilla-au/odize" + "github.com/code-gorilla-au/rush/internal/ui/styles" + "github.com/code-gorilla-au/rush/internal/ui/uistate" + "github.com/code-gorilla-au/rush/internal/ui/uitest" +) + +func TestLockerModel_SwitchPage(t *testing.T) { + group := odize.NewGroup(t, nil) + + state := &uistate.GlobalState{} + theme := styles.NewIceTheme() + ts, ps, gs := uitest.SetupServices(t) + m := NewLockerModel(state, ts, ps, gs, theme) + + group.Test("should update current page on MsgSwitchPage", func(t *testing.T) { + m.Update(MsgSwitchLockerPage{NewPage: SubPageLockerPlayers}) + odize.AssertEqual(t, SubPageLockerPlayers, m.currentPage) + + m.Update(MsgSwitchLockerPage{NewPage: SubPageLockerTeamStatistics}) + odize.AssertEqual(t, SubPageLockerTeamStatistics, m.currentPage) + + m.Update(MsgSwitchLockerPage{NewPage: SubPageLockerPlaybooksList}) + odize.AssertEqual(t, SubPageLockerPlaybooksList, m.currentPage) + }) + + group.Test("should not update current page for non-locker pages", func(t *testing.T) { + m.currentPage = SubPageLockerRoom + m.Update(uistate.MsgSwitchPage{NewPage: uistate.PageTitle}) + odize.AssertEqual(t, SubPageLockerRoom, m.currentPage) + }) + + err := group.Run() + odize.AssertNoError(t, err) +} diff --git a/internal/ui/uistate/keys.go b/internal/ui/uistate/keys.go new file mode 100644 index 0000000..5640f9a --- /dev/null +++ b/internal/ui/uistate/keys.go @@ -0,0 +1,90 @@ +package uistate + +import "charm.land/bubbles/v2/key" + +// KeyMap defines keys that are shared across many pages. +type KeyMap struct { + Quit key.Binding + Back key.Binding + Enter key.Binding + Select key.Binding + Up key.Binding + Down key.Binding + Left key.Binding + Right key.Binding + Tab key.Binding + ShiftTab key.Binding + Save key.Binding + Delete key.Binding + New key.Binding +} + +// ShortHelp returns keybindings to be shown in the mini help view. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Quit} +} + +// FullHelp returns keybindings for the expanded help view. +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Quit}, + } +} + +// NewKeyMap returns a default set of common keys. +func NewKeyMap() KeyMap { + return KeyMap{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + 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"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "left"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "right"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev"), + ), + Save: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "save"), + ), + Delete: key.NewBinding( + key.WithKeys("x", "delete"), + key.WithHelp("x", "delete"), + ), + New: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "new"), + ), + } +} diff --git a/internal/ui/uistate/state.go b/internal/ui/uistate/state.go new file mode 100644 index 0000000..006fa2e --- /dev/null +++ b/internal/ui/uistate/state.go @@ -0,0 +1,39 @@ +package uistate + +import ( + "context" + + "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 + GameID int64 +} + +type Page int + +const ( + PageTitle Page = iota + 1 + PageCreateCoach + PageLockerRoom + PageNewTournament + PageNewBattle + PageGame +) + +type GlobalState struct { + Coach *teams.Coach + Team *teams.Team +} + +func (m *GlobalState) Context() context.Context { + return context.Background() +} diff --git a/internal/ui/uitest/helper.go b/internal/ui/uitest/helper.go new file mode 100644 index 0000000..1a24269 --- /dev/null +++ b/internal/ui/uitest/helper.go @@ -0,0 +1,35 @@ +package uitest + +import ( + "database/sql" + "testing" + + "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:") + 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 SetupServices(t *testing.T) (*teams.Service, *playbooks.Service, *games.Service) { + db := SetupTestDB(t) + queries := database.New(db) + ps := playbooks.NewPlaybooksService(queries) + ts := teams.NewTeamsService(queries, ps) + gs := games.NewService(queries) + return ts, ps, gs +}